Files
Aura/docs/specs/0002-node-contract-and-ctx.md
T
Brummel 4f72701e3b spec: cycle 0002 node contract and ctx
The second walking-skeleton cycle, on top of the 0001 streaming substrate:
the Node contract (C8) and its evaluation context.

Scope (one iteration):
- `Node` trait — `schema() -> NodeSchema` (inputs: kind + lookback; output
  kind) + `eval(&mut self, Ctx) -> Option<Scalar>` (None = filter / not warmed).
- `Ctx` — a Copy borrow-wrapper handing eval zero-copy, financial-indexed
  Windows per input (`ctx.f64_in(i)[k]`, index 0 = newest).
- Read-side `AnyColumn::as_f64/as_i64/as_bool/as_ts` — mirrors the existing
  write-side `as_*_mut` and closes the cycle-0001 audit gap.
- `Sma` in aura-std — the worked producer node (the skeleton's first block),
  driven by a hand-written test that mimics the future sim loop.

Deliberate deferrals (recorded as decisions, not gaps): the sim loop (C4),
freshness gating (C5), firing policies (C6, so InputSpec carries no firing
field yet), schema-level tunable params (C12/C19), and sinks / no-output
nodes (C8 consumer side). The hand-driven test stands in for the loop.

Grounding-check PASS: all load-bearing assumptions about the 0001 substrate
(Column/Window/AnyColumn/Scalar, financial indexing) ratified by green tests.

refs walking-skeleton
2026-06-03 12:05:38 +02:00

12 KiB

The Node Contract and Ctx — Design Spec

Date: 2026-06-03 Status: Draft — awaiting user spec review Authors: orchestrator + Claude

Goal

Deliver the node contract (C8) and its evaluation context on top of the cycle-0001 streaming substrate: the Node trait (schema + eval), a Ctx that hands a node read-only, zero-copy windows into its inputs, and one worked producer node (a simple moving average) that proves a node is authorable and evaluable. The node is exercised by a hand-driven test that mimics, by hand, exactly what the sim loop will later do — push a fresh input value, call eval, collect the output — so the contract is fully proven this cycle without an engine.

This is the first slice of the walking-skeleton milestone after the substrate. It deliberately stops short of the sim loop (C4), freshness gating (C5), firing policies (C6), sources (C11), and ingestion (C3): those build on a proven node contract in later cycles. This cycle nails the single highest-leverage interface — the one every future node forever implements — and closes the read-side gap the cycle-0001 audit noted (AnyColumn had write-side as_*_mut accessors but no symmetric read-side window).

Architecture

Three additions to aura-core, plus one worked node in aura-std:

  1. Node trait (aura-core/src/node.rs). schema(&self) -> NodeSchema declares the node's inputs (scalar kind + required lookback depth) and its single output kind; eval(&mut self, ctx: Ctx<'_>) -> Option<Scalar> computes this cycle's output. &mut self because a node may keep its own derived state (C8); Option because None = filter / not-yet-warmed-up.

  2. Ctx (aura-core/src/ctx.rs). A thin, Copy borrow-wrapper over the node's input columns, in schema-declared order. It exposes one typed accessor per scalar kind — f64_in(i), i64_in(i), bool_in(i), ts_in(i) — each returning a zero-copy Window (cycle 0001) into input i, with financial indexing (index 0 = newest). The kind was checked at wiring; a kind mismatch here is an engine bug and panics with a clear message.

  3. Read-side accessors on AnyColumn (aura-core/src/any.rs). as_f64, as_i64, as_bool, as_ts return Option<&Column<T>> — the symmetric read-side of the existing as_*_mut, and the mechanism Ctx uses to obtain a typed window from a type-erased edge. This closes the cycle-0001 audit gap.

  4. Sma (aura-std/src/sma.rs). The worked example: a producer node with one f64 input and one f64 output, computing the arithmetic mean of the last length values, emitting None until warmed up. It lives in aura-std (not in aura-core's tests) on purpose — it is the walking skeleton's first real block (C16 names SMA an aura-std block), and authoring it in a downstream crate proves aura-core's Node/Ctx are usable across the crate boundary exactly as a real project-side node would use them.

Concrete code shapes

The worked node — what a node author writes (the headline)

This is the empirical evidence for the feature-acceptance criterion: the actual Rust a node author produces against aura-core. If this is natural to write and fully testable, the contract is right.

// aura-std/src/sma.rs
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 {
    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))
    }
}

The hand-driven test — proving it runs without an engine (the proof)

The test builds the input column the way the engine eventually will (sized from schema), then drives the node by hand — push, eval, assert — which is exactly the loop the sim engine will generalize in a later cycle.

// aura-std/src/sma.rs (tests)
#[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];
    let expect = [None, None, Some(2.0), Some(3.0), Some(4.0)]; // means of [1,2,3],[2,3,4],[3,4,5]

    for (v, want) in feed.iter().zip(expect) {
        inputs[0].push(Scalar::F64(*v)).unwrap();
        let got = sma.eval(Ctx::new(&inputs));
        assert_eq!(got, want.map(Scalar::F64));
    }
}

Implementation shapes (supporting, secondary)

New — aura-core/src/node.rs:

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 (>= 1). Firing policy (C6) and tunable params
/// (C12/C19) are deliberately not declared yet — see "Out of scope".
#[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, so 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).
pub trait Node {
    fn schema(&self) -> NodeSchema;
    fn eval(&mut self, ctx: Ctx<'_>) -> Option<Scalar>;
}

New — aura-core/src/ctx.rs:

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> {
    pub fn new(inputs: &'a [AnyColumn]) -> Self {
        Self { inputs }
    }

    pub fn f64_in(&self, i: usize) -> Window<'a, f64> {
        self.inputs[i]
            .as_f64()
            .expect("input kind mismatch (checked at wiring) — engine bug")
            .window()
    }
    // i64_in / bool_in / ts_in are identical against as_i64 / as_bool / as_ts.
}

Modify — aura-core/src/any.rs (add the read-side, mirroring as_*_mut):

impl AnyColumn {
    pub fn as_f64(&self) -> Option<&Column<f64>> {
        match self {
            AnyColumn::F64(c) => Some(c),
            _ => None,
        }
    }
    // as_i64 / as_bool / as_ts mirror this against their arms.
}

Modify — aura-core/src/lib.rs: mod ctx; mod node; + re-export Ctx, Node, NodeSchema, InputSpec; update the roadmap doc comment.

Modify — aura-std/src/lib.rs: mod sma; pub use sma::Sma; and drop the placeholder doc-only body.

Components

Component Crate Responsibility
Node trait aura-core the schema + eval contract every node implements
NodeSchema, InputSpec aura-core a node's declared inputs (kind + lookback) and output kind
Ctx aura-core per-eval typed, zero-copy window access into inputs
AnyColumn::as_* aura-core read-side type-erased→typed column access (closes 0001 gap)
Sma aura-std the worked producer node; the walking skeleton's first block

Data flow

For one eval (mimicking one future sim cycle):

  1. Caller (the test now; the engine later) holds one AnyColumn per input, sized from the node's schema at wiring.
  2. A fresh value is pushed onto an input column (newest at index 0).
  3. The caller wraps the input slice in a Ctx and calls node.eval(ctx).
  4. eval reads its inputs through ctx.f64_in(i) → a zero-copy Window, indexes newest-first, and returns Some(Scalar) or None.

No allocation occurs inside eval (the Window borrows; the SMA sum is a stack scalar). The Vec in NodeSchema is wiring-time only, never on the hot path.

Error handling

  • Kind mismatch in Ctx accessorsctx.f64_in(i) on a non-f64 input panics with an explicit "engine bug" message. This is not a user-facing error: the engine sizes and types inputs from schema at wiring, so a mismatch can only mean the wiring layer is broken. Panicking (vs. returning Option) keeps node-author code clean (w[k], not w?[k]) and surfaces wiring bugs loudly.
  • Out-of-range input index i — slice indexing panics; same rationale (the engine addresses only declared inputs).
  • Not warmed upeval returns None (a normal value, not an error): the window is shorter than the required lookback. The SMA shows the idiom.
  • AnyColumn::as_* on a wrong kind — returns None (the low-level, non-panicking primitive; Ctx is the layer that turns the wiring-guaranteed case into a panic).

Testing strategy

aura-core (substrate-level, in the new modules):

  • any.rs — read-side accessors: as_f64/as_i64/as_bool/as_ts each return Some(&Column<T>) for the matching kind and None for every other kind.
  • ctx.rsf64_in returns a window with financial indexing (newest at 0) over a multi-input slice (addressing input 1, not just 0); a typed accessor on a mismatched input panics (#[should_panic]).

aura-std (node-level, the worked example):

  • SMA warm-up: None while the window is shorter than length.
  • SMA value: tracks the moving mean exactly over [1..5] with length = 3.
  • SMA degenerate length = 1: output equals the newest input each cycle.

All four workspace gates stay green: cargo build/test/clippy --workspace and the surface-purity grep (no dyn Any / Rc< / RefCell / per-event heap alloc).

Acceptance criteria

  1. cargo build --workspace, cargo test --workspace, cargo clippy --workspace --all-targets -- -D warnings all green.
  2. The worked Sma node compiles and its hand-driven test passes: None through warm-up, then the exact window mean — proving a node is authorable in a downstream crate and evaluable with no engine present.
  3. Ctx hands eval zero-copy, financial-indexed windows per input; no allocation inside eval.
  4. AnyColumn read-side accessors return the typed column for the matching kind and None otherwise (cycle-0001 audit gap closed).
  5. Surface purity preserved: the new code adds no dyn Any, Rc, RefCell, or per-event heap allocation on the eval path.

Out of scope (deliberate deferrals, recorded so they are decisions not gaps)

  • Sim loop / cycle clock (C4) and freshness-gated recompute (C5) — the hand-driven test stands in for the loop this cycle; the engine generalizes it next.
  • Firing policies (C6)InputSpec declares kind + lookback only; the firing group is added when the sim loop that consumes it lands (C6 is meaningless without the loop, so declaring it now would be a field nothing reads).
  • Tunable params in schema (C12/C19) — the param-space is a bootstrap-cycle concern; Sma's length is a plain constructor arg for now.
  • Sinks / no-output nodes (C8 consumer side)NodeSchema.output is a single ScalarKind; the no-output sink refinement arrives with the broker/sink cycle.
  • Sources / ingestion (C3/C11) and composites (C9) — later cycles.