Files
Aura/docs/plans/0002-node-contract-and-ctx.md
T
Brummel e77060fdd4 plan: cycle 0002 node contract and ctx
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.
2026-06-03 12:12:18 +02:00

16 KiB

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.rsCtx<'a> borrow-wrapper + typed window accessors + tests.
  • Create: crates/aura-core/src/node.rsNode trait, NodeSchema, InputSpec.
  • Modify: crates/aura-core/src/lib.rsmod/pub use for ctx then node; roadmap doc-comment update.
  • Create: crates/aura-std/src/sma.rsSma 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:


    /// 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<i64>> {
        match self {
            AnyColumn::I64(c) => Some(c),
            _ => None,
        }
    }

    pub fn as_f64(&self) -> Option<&Column<f64>> {
        match self {
            AnyColumn::F64(c) => Some(c),
            _ => None,
        }
    }

    pub fn as_bool(&self) -> Option<&Column<bool>> {
        match self {
            AnyColumn::Bool(c) => Some(c),
            _ => None,
        }
    }

    pub fn as_ts(&self) -> Option<&Column<Timestamp>> {
        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:


    #[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

//! 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:

mod any;
mod column;
mod error;
mod scalar;

with:

mod any;
mod column;
mod ctx;
mod error;
mod scalar;

Then add the re-export. Replace:

pub use any::AnyColumn;
pub use column::{Column, Window};
pub use error::KindMismatch;
pub use scalar::{Scalar, ScalarKind, Timestamp};

with:

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

//! 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<InputSpec>,
    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<Scalar>;
}
  • Step 2: Wire node into lib.rs

In crates/aura-core/src/lib.rs, add mod node; to the module block. Replace:

mod any;
mod column;
mod ctx;
mod error;
mod scalar;

with:

mod any;
mod column;
mod ctx;
mod error;
mod node;
mod scalar;

Then add the re-export. Replace:

pub use any::AnyColumn;
pub use column::{Column, Window};
pub use ctx::Ctx;
pub use error::KindMismatch;
pub use scalar::{Scalar, ScalarKind, Timestamp};

with:

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:

//! 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:

//! 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

//! `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<Scalar> {
        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):

//! 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:

//! 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.