From e77060fdd45e68d1d6ac5981f0658b25ca6dbeb6 Mon Sep 17 00:00:00 2001 From: Brummel Date: Wed, 3 Jun 2026 12:12:18 +0200 Subject: [PATCH] plan: cycle 0002 node contract and ctx MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five tasks, each leaving the workspace green: 1. AnyColumn read-side as_f64/as_i64/as_bool/as_ts (mirror of as_*_mut). 2. Ctx<'a> + typed financial-indexed window accessors + tests. 3. Node trait + NodeSchema + InputSpec; lib.rs wiring + roadmap doc update. 4. Sma worked node in aura-std + hand-driven tests. 5. Workspace gate: build / test (20) / clippy -D warnings / purity grep. Ordered so each compilation unit builds after its task (accessors → Ctx → Node → Sma). Verbatim Rust + exact cargo commands per the planner Iron Law. --- docs/plans/0002-node-contract-and-ctx.md | 545 +++++++++++++++++++++++ 1 file changed, 545 insertions(+) create mode 100644 docs/plans/0002-node-contract-and-ctx.md diff --git a/docs/plans/0002-node-contract-and-ctx.md b/docs/plans/0002-node-contract-and-ctx.md new file mode 100644 index 0000000..23e94c6 --- /dev/null +++ b/docs/plans/0002-node-contract-and-ctx.md @@ -0,0 +1,545 @@ +# Node Contract and Ctx — Implementation Plan + +> **Parent spec:** `docs/specs/0002-node-contract-and-ctx.md` +> +> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` skill to run +> this plan. Steps use `- [ ]` checkboxes for tracking. + +**Goal:** Add the `Node` contract (`schema`/`eval`), the `Ctx` read-side, the +read-side `AnyColumn` accessors, and one worked `Sma` node — each task leaving the +workspace green. + +**Architecture:** Two new modules in `aura-core` (`ctx`, `node`) plus read-side +accessors on `AnyColumn`; one worked node (`Sma`) in `aura-std`. Tasks are ordered +so every compilation unit builds after each task: read-side accessors first (self +-contained), then `Ctx` (needs the accessors), then `Node` (needs `Ctx`), then +`Sma` (needs both), then the workspace gate. + +**Tech Stack:** `aura-core` (no deps), `aura-std` (depends on `aura-core`), Rust +2024, `cargo build/test/clippy --workspace`. + +--- + +**Files this plan creates or modifies:** + +- Modify: `crates/aura-core/src/any.rs` — add read-side `as_f64/as_i64/as_bool/as_ts` inside `impl AnyColumn` (before the closing brace at `:123`), + a read-accessor test in the existing `mod tests`. +- Create: `crates/aura-core/src/ctx.rs` — `Ctx<'a>` borrow-wrapper + typed window accessors + tests. +- Create: `crates/aura-core/src/node.rs` — `Node` trait, `NodeSchema`, `InputSpec`. +- Modify: `crates/aura-core/src/lib.rs` — `mod`/`pub use` for `ctx` then `node`; roadmap doc-comment update. +- Create: `crates/aura-std/src/sma.rs` — `Sma` worked node + hand-driven tests. +- Modify: `crates/aura-std/src/lib.rs:15-17` — replace the "no API yet" paragraph with `mod sma; pub use sma::Sma;`. + +--- + +### Task 1: `AnyColumn` read-side accessors + +**Files:** +- Modify: `crates/aura-core/src/any.rs` + +- [ ] **Step 1: Add the four read-side accessors** + +In `crates/aura-core/src/any.rs`, inside the `impl AnyColumn` block, immediately +after the `as_ts_mut` method (ends at `:122`) and before the block's closing brace +(`:123`), insert: + +```rust + + /// Read-side concrete-column accessor (the symmetric partner of + /// `as_*_mut`); `Some` only for the matching kind. `Ctx` uses these to hand + /// a typed window from a type-erased edge. + pub fn as_i64(&self) -> Option<&Column> { + match self { + AnyColumn::I64(c) => Some(c), + _ => None, + } + } + + pub fn as_f64(&self) -> Option<&Column> { + match self { + AnyColumn::F64(c) => Some(c), + _ => None, + } + } + + pub fn as_bool(&self) -> Option<&Column> { + match self { + AnyColumn::Bool(c) => Some(c), + _ => None, + } + } + + pub fn as_ts(&self) -> Option<&Column> { + match self { + AnyColumn::Ts(c) => Some(c), + _ => None, + } + } +``` + +(`Column` and `Timestamp` are already imported at `any.rs:5` and `:7`.) + +- [ ] **Step 2: Add a read-accessor test** + +In `crates/aura-core/src/any.rs`, inside the existing `#[cfg(test)] mod tests` +(which already has `use super::*;` at `:127`), before its closing brace, add: + +```rust + + #[test] + fn read_accessor_matches_only_its_kind() { + let f = AnyColumn::with_capacity(ScalarKind::F64, 2); + assert!(f.as_f64().is_some()); + assert!(f.as_i64().is_none()); + assert!(f.as_bool().is_none()); + assert!(f.as_ts().is_none()); + + let i = AnyColumn::with_capacity(ScalarKind::I64, 2); + assert!(i.as_i64().is_some()); + assert!(i.as_f64().is_none()); + } +``` + +- [ ] **Step 3: Verify the crate builds and the new test passes** + +Run: `cargo test -p aura-core read_accessor_matches_only_its_kind` +Expected: PASS (`test result: ok. 1 passed`). + +- [ ] **Step 4: Verify nothing else regressed** + +Run: `cargo test -p aura-core` +Expected: PASS — 15 tests (the prior 14 + the new one). + +--- + +### Task 2: `Ctx` — the read-side evaluation context + +**Files:** +- Create: `crates/aura-core/src/ctx.rs` +- Modify: `crates/aura-core/src/lib.rs` + +- [ ] **Step 1: Create `crates/aura-core/src/ctx.rs`** + +```rust +//! The evaluation context (C8): the read-side window access a node sees in +//! `eval`. The engine sizes and types each input from the node's `schema` at +//! wiring, so the typed accessors below treat a kind mismatch as an engine bug +//! (panic), not a user-facing error. + +use crate::{AnyColumn, Timestamp, Window}; + +/// Read-only, zero-copy view of a node's inputs for one `eval`, in schema +/// order. `Copy` because it is just a borrow of the input slice. +#[derive(Clone, Copy)] +pub struct Ctx<'a> { + inputs: &'a [AnyColumn], +} + +impl<'a> Ctx<'a> { + /// Wrap the per-input columns (in schema-declared order) for one `eval`. + pub fn new(inputs: &'a [AnyColumn]) -> Self { + Self { inputs } + } + + /// Zero-copy `f64` window into input `i` (index 0 = newest). Panics if input + /// `i` is not an `f64` edge — a wiring bug, never reachable from a correctly + /// wired graph. + pub fn f64_in(&self, i: usize) -> Window<'a, f64> { + let inputs: &'a [AnyColumn] = self.inputs; + inputs[i] + .as_f64() + .expect("input kind mismatch (checked at wiring) — engine bug") + .window() + } + + /// Zero-copy `i64` window into input `i` (index 0 = newest). See `f64_in`. + pub fn i64_in(&self, i: usize) -> Window<'a, i64> { + let inputs: &'a [AnyColumn] = self.inputs; + inputs[i] + .as_i64() + .expect("input kind mismatch (checked at wiring) — engine bug") + .window() + } + + /// Zero-copy `bool` window into input `i` (index 0 = newest). See `f64_in`. + pub fn bool_in(&self, i: usize) -> Window<'a, bool> { + let inputs: &'a [AnyColumn] = self.inputs; + inputs[i] + .as_bool() + .expect("input kind mismatch (checked at wiring) — engine bug") + .window() + } + + /// Zero-copy `timestamp` window into input `i` (index 0 = newest). See + /// `f64_in`. + pub fn ts_in(&self, i: usize) -> Window<'a, Timestamp> { + let inputs: &'a [AnyColumn] = self.inputs; + inputs[i] + .as_ts() + .expect("input kind mismatch (checked at wiring) — engine bug") + .window() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{Scalar, ScalarKind}; + + #[test] + fn ctx_hands_financial_indexed_windows() { + let mut inputs = vec![AnyColumn::with_capacity(ScalarKind::F64, 4)]; + for v in [10.0_f64, 20.0, 30.0] { + inputs[0].push(Scalar::F64(v)).unwrap(); + } + let ctx = Ctx::new(&inputs); + let w = ctx.f64_in(0); + assert_eq!(w.len(), 3); + assert_eq!(w[0], 30.0); // newest + assert_eq!(w[2], 10.0); // oldest + } + + #[test] + fn ctx_addresses_multiple_inputs() { + let mut inputs = vec![ + AnyColumn::with_capacity(ScalarKind::F64, 2), + AnyColumn::with_capacity(ScalarKind::I64, 2), + ]; + inputs[0].push(Scalar::F64(1.5)).unwrap(); + inputs[1].push(Scalar::I64(42)).unwrap(); + let ctx = Ctx::new(&inputs); + assert_eq!(ctx.f64_in(0)[0], 1.5); + assert_eq!(ctx.i64_in(1)[0], 42); + } + + #[test] + #[should_panic(expected = "engine bug")] + fn ctx_panics_on_kind_mismatch() { + let mut inputs = vec![AnyColumn::with_capacity(ScalarKind::I64, 2)]; + inputs[0].push(Scalar::I64(7)).unwrap(); + let ctx = Ctx::new(&inputs); + let _ = ctx.f64_in(0); // wrong kind → panic + } +} +``` + +- [ ] **Step 2: Wire `ctx` into `lib.rs`** + +In `crates/aura-core/src/lib.rs`, add `mod ctx;` to the module block. Replace: + +```rust +mod any; +mod column; +mod error; +mod scalar; +``` + +with: + +```rust +mod any; +mod column; +mod ctx; +mod error; +mod scalar; +``` + +Then add the re-export. Replace: + +```rust +pub use any::AnyColumn; +pub use column::{Column, Window}; +pub use error::KindMismatch; +pub use scalar::{Scalar, ScalarKind, Timestamp}; +``` + +with: + +```rust +pub use any::AnyColumn; +pub use column::{Column, Window}; +pub use ctx::Ctx; +pub use error::KindMismatch; +pub use scalar::{Scalar, ScalarKind, Timestamp}; +``` + +- [ ] **Step 3: Verify the new context tests pass** + +Run: `cargo test -p aura-core ctx_` +Expected: PASS — 3 tests (`ctx_hands_financial_indexed_windows`, +`ctx_addresses_multiple_inputs`, `ctx_panics_on_kind_mismatch`). + +- [ ] **Step 4: Verify the crate still builds clean** + +Run: `cargo test -p aura-core` +Expected: PASS — 18 tests (15 from Task 1 + 3 new). + +--- + +### Task 3: The `Node` trait + +**Files:** +- Create: `crates/aura-core/src/node.rs` +- Modify: `crates/aura-core/src/lib.rs` + +- [ ] **Step 1: Create `crates/aura-core/src/node.rs`** + +```rust +//! The node contract (C8): the interface every node implements. A node declares +//! its inputs and output kind via `schema`, and computes one cycle's output via +//! `eval`. Firing policy (C6) and tunable params (C12/C19) are deliberately not +//! part of the schema yet — see spec 0002's "Out of scope". + +use crate::{Ctx, Scalar, ScalarKind}; + +/// One declared input of a node: its scalar kind and the lookback depth the +/// engine must pre-size for it (must be >= 1). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct InputSpec { + pub kind: ScalarKind, + pub lookback: usize, +} + +/// A node's declared interface: its inputs (in order) and its single output +/// kind. Built once at wiring, never on the hot path — the `Vec` is fine here. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct NodeSchema { + pub inputs: Vec, + pub output: ScalarKind, +} + +/// The universal composable dataflow unit (C8): at most one output, a producer +/// or transformer. `schema` declares the interface; `eval` computes one cycle's +/// output (`None` = filter / not-yet-warmed-up). `&mut self` because a node may +/// keep its own derived state. +pub trait Node { + fn schema(&self) -> NodeSchema; + fn eval(&mut self, ctx: Ctx<'_>) -> Option; +} +``` + +- [ ] **Step 2: Wire `node` into `lib.rs`** + +In `crates/aura-core/src/lib.rs`, add `mod node;` to the module block. Replace: + +```rust +mod any; +mod column; +mod ctx; +mod error; +mod scalar; +``` + +with: + +```rust +mod any; +mod column; +mod ctx; +mod error; +mod node; +mod scalar; +``` + +Then add the re-export. Replace: + +```rust +pub use any::AnyColumn; +pub use column::{Column, Window}; +pub use ctx::Ctx; +pub use error::KindMismatch; +pub use scalar::{Scalar, ScalarKind, Timestamp}; +``` + +with: + +```rust +pub use any::AnyColumn; +pub use column::{Column, Window}; +pub use ctx::Ctx; +pub use error::KindMismatch; +pub use node::{InputSpec, Node, NodeSchema}; +pub use scalar::{Scalar, ScalarKind, Timestamp}; +``` + +- [ ] **Step 3: Update the roadmap doc-comment** + +In `crates/aura-core/src/lib.rs`, replace the "Still to come" paragraph: + +```rust +//! Still to come (subsequent cycles): the `Node` trait and its `schema`/`eval`, +//! the evaluation context `Ctx`, the firing policies (A: fire-on-any-fresh + +//! hold; B: all-fresh barrier), and the deterministic sim loop. +``` + +with: + +```rust +//! Delivered in cycle 0002 — the node contract: +//! +//! - [`Node`] — the `schema`/`eval` contract every node implements (C8), with +//! [`NodeSchema`] / [`InputSpec`] declaring inputs (kind + lookback) and the +//! single output kind; +//! - [`Ctx`] — the per-`eval` read-side: zero-copy, financial-indexed [`Window`] +//! access into each input (closing the cycle-0001 read-side gap on +//! [`AnyColumn`]). +//! +//! Still to come (subsequent cycles): the firing policies (A: fire-on-any-fresh +//! + hold; B: all-fresh barrier), the deterministic sim loop, sources, and +//! ingestion. +``` + +- [ ] **Step 4: Verify `aura-core` builds with the trait present** + +Run: `cargo build -p aura-core` +Expected: `Finished` — 0 errors, 0 warnings. + +- [ ] **Step 5: Verify the full core suite still passes** + +Run: `cargo test -p aura-core` +Expected: PASS — 18 tests (unchanged; `node.rs` adds types, no new core test — +the node is exercised from `aura-std` in Task 4). + +--- + +### Task 4: `Sma` — the worked producer node + +**Files:** +- Create: `crates/aura-std/src/sma.rs` +- Modify: `crates/aura-std/src/lib.rs` + +- [ ] **Step 1: Create `crates/aura-std/src/sma.rs`** + +```rust +//! `Sma` — simple moving average over the last `length` values of one f64 +//! input. The walking skeleton's first worked node: it proves the `aura-core` +//! `Node` contract is authorable from a downstream crate and evaluable with no +//! engine present (the test drives it by hand, as the sim loop later will). + +use aura_core::{Ctx, InputSpec, Node, NodeSchema, Scalar, ScalarKind}; + +/// Simple moving average over the last `length` values of one f64 input. +pub struct Sma { + length: usize, +} + +impl Sma { + /// Build an SMA of window `length` (must be >= 1). + pub fn new(length: usize) -> Self { + assert!(length >= 1, "SMA length must be >= 1"); + Self { length } + } +} + +impl Node for Sma { + fn schema(&self) -> NodeSchema { + NodeSchema { + inputs: vec![InputSpec { kind: ScalarKind::F64, lookback: self.length }], + output: ScalarKind::F64, + } + } + + fn eval(&mut self, ctx: Ctx<'_>) -> Option { + let w = ctx.f64_in(0); + if w.len() < self.length { + return None; // not yet warmed up + } + let mut sum = 0.0; + for k in 0..self.length { + sum += w[k]; // index 0 = newest (financial indexing) + } + Some(Scalar::F64(sum / self.length as f64)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use aura_core::AnyColumn; + + #[test] + fn sma_warms_up_then_tracks_the_window_mean() { + let mut sma = Sma::new(3); + let schema = sma.schema(); + + // size the input column from the schema, as the engine will at wiring + let mut inputs = vec![AnyColumn::with_capacity( + schema.inputs[0].kind, + schema.inputs[0].lookback, + )]; + + let feed = [1.0_f64, 2.0, 3.0, 4.0, 5.0]; + // means of [1,2,3], [2,3,4], [3,4,5] once warmed up + let expect = [None, None, Some(2.0), Some(3.0), Some(4.0)]; + + for (v, want) in feed.iter().zip(expect) { + inputs[0].push(Scalar::F64(*v)).unwrap(); + assert_eq!(sma.eval(Ctx::new(&inputs)), want.map(Scalar::F64)); + } + } + + #[test] + fn sma_length_one_is_identity() { + let mut sma = Sma::new(1); + let mut inputs = vec![AnyColumn::with_capacity(ScalarKind::F64, 1)]; + + inputs[0].push(Scalar::F64(7.0)).unwrap(); + assert_eq!(sma.eval(Ctx::new(&inputs)), Some(Scalar::F64(7.0))); + + inputs[0].push(Scalar::F64(9.0)).unwrap(); + assert_eq!(sma.eval(Ctx::new(&inputs)), Some(Scalar::F64(9.0))); + } +} +``` + +- [ ] **Step 2: Wire `sma` into `aura-std/src/lib.rs`** + +In `crates/aura-std/src/lib.rs`, replace the final doc paragraph (`:15-17`): + +```rust +//! Types are intentionally absent until the first spec needs them (the +//! walking-skeleton milestone). This crate documents intent; it does not yet +//! define API. +``` + +with: + +```rust +//! The first block lands with the walking skeleton: [`Sma`], the simple moving +//! average — a worked producer node proving the `aura-core` `Node` contract. + +mod sma; +pub use sma::Sma; +``` + +- [ ] **Step 3: Verify the SMA tests pass** + +Run: `cargo test -p aura-std` +Expected: PASS — 2 tests (`sma_warms_up_then_tracks_the_window_mean`, +`sma_length_one_is_identity`). + +--- + +### Task 5: Workspace gate + +**Files:** none (verification only). + +- [ ] **Step 1: Full workspace build** + +Run: `cargo build --workspace` +Expected: `Finished` — 0 errors, 0 warnings. + +- [ ] **Step 2: Full workspace test** + +Run: `cargo test --workspace` +Expected: PASS — 20 tests total (18 in `aura-core`, 2 in `aura-std`), 0 failed. + +- [ ] **Step 3: Clippy, warnings-as-errors** + +Run: `cargo clippy --workspace --all-targets -- -D warnings` +Expected: `Finished` — no warnings. + +- [ ] **Step 4: Surface-purity grep** + +Run: `grep -rnE 'RefCell|Rc<|dyn Any' crates/*/src` +Expected: no matches (exit code 1, no output) — the hot path stays free of +type-erased payloads, reference counting, and interior mutability.