Files
Aura/docs/plans/0005-node-output-record.md
T
Brummel f95fe0f4e4 plan: 0005 node output record
Four tasks, ordered by compile-gate discipline (each leaves a coherent gate
satisfiable):
1. aura-core contract (FieldSpec, NodeSchema.output -> Vec<FieldSpec>, Node::eval
   -> Option<&[Scalar]>) + aura-std Sma/Sub degenerate migration, threaded in one
   task. The signature change breaks aura-engine, so the gate is SCOPED to
   `-p aura-core -p aura-std` (engine is knowingly broken until task 3).
2. aura-engine production: Edge.from_field, NodeBox.out_len, per-field bootstrap
   kind/range check, reused-scratch field-indexed run forward, run ->
   Vec<Option<Vec<Scalar>>>. Breaks the in-module tests, so the gate is a PARTIAL
   `cargo build -p aura-engine --lib` + clippy --lib only.
3. aura-engine tests: migrate the 3 firing fixtures to the slice return; add the
   Ohlcv 5-field bundler + TwoField mixed-kind fixture; the field-binding proof
   (high-low, close-open), K>1 output, determinism, and must-fail (from_field OOB
   -> BadIndex, per-field kind mismatch -> KindMismatch); adapt all prior
   0003/0004 tests to from_field + the Vec return. Full workspace gate + purity
   grep.
4. design ledger: C8 revision (one output port carrying a record) + C7 sharpening
   (composite-bundle is the node-output model). Docs-only; glossary record-reality
   is audit-time, not this plan.

Orchestrator decisions baked in: the spec's illustrative `schemas_out_len[nidx]`
is committed as a `NodeBox.out_len` field (set at bootstrap) read into a run-loop
local; the firing fixtures hold a `[Scalar; 1]` buffer constructed at each call
site; Sub drops `#[derive(Default)]` (Scalar has no Default) for a manual Default
that keeps clippy's new_without_default quiet; the engine lib.rs doc is left
untouched (no single-scalar phrase there -- only harness.rs doc + Edge doc move).

plan-recon confirmed every spec-named path exists and the compile-driven site set
(6 output: literals, 6 fn eval, 6 Edge literals, 11 .run callers); from_field /
FieldSpec / Ohlcv are net-new. No content-pin/golden-file twin-miss risk.

refs #1
refs walking-skeleton

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 15:46:56 +02:00

39 KiB
Raw Blame History

Node output is a record — Implementation Plan

Parent spec: docs/specs/0005-node-output-record.md

For agentic workers: REQUIRED SUB-SKILL: use the implement skill to run this plan. Steps use - [ ] checkboxes for tracking.

Goal: Generalize a node's output from one scalar to a record of K ≥ 1 base-scalar columns (scalar = degenerate K = 1), bound field-wise via Edge::from_field, proven with a neutral OHLCV bundler.

Architecture: aura-core changes the contract (NodeSchema.output: Vec<FieldSpec>, Node::eval -> Option<&[Scalar]>); aura-std migrates Sma/Sub to the 1-field degenerate case; aura-engine adds Edge::from_field, a per-field bootstrap kind/range check, and a reused-scratch field-indexed forward in the run loop (run -> Vec<Option<Vec<Scalar>>>). The design ledger records the C8 revision and C7 sharpening.

Tech Stack: Rust workspace (aura-core, aura-std, aura-engine); the Node/NodeSchema/Edge/Harness types; cargo build/test/clippy.


Files this plan creates or modifies:

  • Modify: crates/aura-core/src/node.rs:1-47 — add FieldSpec; NodeSchema.outputVec<FieldSpec>; Node::evalOption<&[Scalar]>; module/struct/trait docs.
  • Modify: crates/aura-core/src/lib.rs:21,42 — re-export FieldSpec; doc wording.
  • Modify: crates/aura-std/src/sma.rs:6-83out buffer; 1-field schema; slice return; in-crate tests.
  • Modify: crates/aura-std/src/sub.rs:6-62out buffer; 1-field schema; slice return; manual Default; in-crate test.
  • Modify: crates/aura-engine/src/harness.rs:21-329Edge.from_field; NodeBox.out_len; per-field bootstrap check; run loop scratch + return type; module/Edge docs.
  • Modify: crates/aura-engine/src/harness.rs:331-669 (Test) — migrate 3 fixtures to slice return; add Ohlcv/TwoField; field-binding + must-fail tests; adapt all Edge literals + expected vectors.
  • Modify: docs/design/INDEX.md:168-197 — C8 revision + C7 sharpening.

Task 1: aura-core contract + aura-std degenerate migration

Files:

  • Modify: crates/aura-core/src/node.rs
  • Modify: crates/aura-core/src/lib.rs
  • Modify: crates/aura-std/src/sma.rs
  • Modify: crates/aura-std/src/sub.rs

Compile-gate note: this task's gate is scoped to aura-core + aura-std. Changing Node::eval/NodeSchema.output knowingly breaks aura-engine (its production reads from.output and its fixtures impl the old eval signature); aura-engine is repaired in Tasks 23. A workspace-wide gate here is unsatisfiable by design — do not run --workspace in this task.

  • Step 1: Add FieldSpec and change NodeSchema/Node in node.rs

In crates/aura-core/src/node.rs, replace the NodeSchema struct (currently lines 32-38) and the Node trait (currently lines 40-47) with the block below. This adds FieldSpec immediately before NodeSchema, changes the output field type, the eval return type, and the surrounding doc comments:

/// One declared output column of a node's record: its name (metadata for sinks /
/// the playground, C18) and its scalar kind. The position of a `FieldSpec` in
/// `NodeSchema.output` is what an `Edge` binds (`Edge::from_field`); the name is
/// not load-bearing for wiring.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct FieldSpec {
    pub name: &'static str,
    pub kind: ScalarKind,
}

/// A node's declared interface: its inputs (in order) and its output record — an
/// ordered list of named base columns; length 1 is a scalar (the degenerate
/// case). Built once at wiring, never on the hot path — the `Vec`s are fine here.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct NodeSchema {
    pub inputs: Vec<InputSpec>,
    pub output: Vec<FieldSpec>,
}

/// The universal composable dataflow unit (C8): one output port carrying a record
/// of 1..K base columns, a producer or transformer. `schema` declares the
/// interface; `eval` computes one cycle's row, returning a borrowed slice into a
/// buffer the node owns (`None` = filter / not-yet-warmed-up). `&mut self` because
/// a node may keep its own derived state (and its output buffer).
pub trait Node {
    fn schema(&self) -> NodeSchema;
    fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Scalar]>;
}
  • Step 2: Update the node.rs module doc

In crates/aura-core/src/node.rs, replace the module-doc line (currently line 3):

//! and its output kind via `schema`, and computes one cycle's output via `eval`.

with:

//! and its output record (1..K base columns, C7) via `schema`, and computes one
//! cycle's row via `eval`.
  • Step 3: Re-export FieldSpec and fix the lib doc in aura-core/src/lib.rs

In crates/aura-core/src/lib.rs, replace the node re-export (currently line 42):

pub use node::{Firing, InputSpec, Node, NodeSchema};

with:

pub use node::{FieldSpec, Firing, InputSpec, Node, NodeSchema};

Then replace the doc line (currently line 21) that reads:

//!   [`NodeSchema`] / [`InputSpec`] declaring inputs (kind + lookback) and the
//!   single output kind;

with:

//!   [`NodeSchema`] / [`InputSpec`] declaring inputs (kind + lookback) and the
//!   output record ([`FieldSpec`] columns; length 1 = scalar);
  • Step 4: Migrate Sma (aura-std/src/sma.rs)

In crates/aura-std/src/sma.rs, replace the import (line 6):

use aura_core::{Ctx, Firing, InputSpec, Node, NodeSchema, Scalar, ScalarKind};

with:

use aura_core::{Ctx, FieldSpec, Firing, InputSpec, Node, NodeSchema, Scalar, ScalarKind};

Replace the struct (lines 9-11) and ctor (lines 13-19):

/// Simple moving average over the last `length` values of one f64 input.
pub struct Sma {
    length: usize,
    out: [Scalar; 1],
}

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, out: [Scalar::F64(0.0)] }
    }
}

Replace the impl Node for Sma body (lines 21-44) with:

impl Node for Sma {
    fn schema(&self) -> NodeSchema {
        NodeSchema {
            inputs: vec![InputSpec {
                kind: ScalarKind::F64,
                lookback: self.length,
                firing: Firing::Any,
            }],
            output: vec![FieldSpec { name: "value", kind: 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)
        }
        self.out[0] = Scalar::F64(sum / self.length as f64);
        Some(&self.out)
    }
}
  • Step 5: Adapt the Sma in-crate tests

In crates/aura-std/src/sma.rs, replace the body of sma_warms_up_then_tracks_the_window_mean (the loop currently at lines 66-69) with:

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

Replace the two assertions in sma_length_one_is_identity (currently lines 77-81):

        inputs[0].push(Scalar::F64(7.0)).unwrap();
        assert_eq!(sma.eval(Ctx::new(&inputs)), Some([Scalar::F64(7.0)].as_slice()));

        inputs[0].push(Scalar::F64(9.0)).unwrap();
        assert_eq!(sma.eval(Ctx::new(&inputs)), Some([Scalar::F64(9.0)].as_slice()));
  • Step 6: Migrate Sub (aura-std/src/sub.rs)

In crates/aura-std/src/sub.rs, replace the import (line 6):

use aura_core::{Ctx, Firing, InputSpec, Node, NodeSchema, Scalar, ScalarKind};

with:

use aura_core::{Ctx, FieldSpec, Firing, InputSpec, Node, NodeSchema, Scalar, ScalarKind};

Replace the struct + ctor (lines 10-18) with (the #[derive(Default)] is dropped because Scalar has no Default; a manual Default keeps clippy's new_without_default quiet):

/// Two-input f64 difference: input 0 minus input 1. Emits `None` until both
/// inputs have a value.
pub struct Sub {
    out: [Scalar; 1],
}

impl Sub {
    /// Build a `Sub` node.
    pub fn new() -> Self {
        Self { out: [Scalar::F64(0.0)] }
    }
}

impl Default for Sub {
    fn default() -> Self {
        Self::new()
    }
}

Replace the impl Node for Sub body (lines 20-39) with:

impl Node for Sub {
    fn schema(&self) -> NodeSchema {
        NodeSchema {
            inputs: vec![
                InputSpec { kind: ScalarKind::F64, lookback: 1, firing: Firing::Any },
                InputSpec { kind: ScalarKind::F64, lookback: 1, firing: Firing::Any },
            ],
            output: vec![FieldSpec { name: "value", kind: ScalarKind::F64 }],
        }
    }

    fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Scalar]> {
        let a = ctx.f64_in(0);
        let b = ctx.f64_in(1);
        if a.is_empty() || b.is_empty() {
            return None;
        }
        self.out[0] = Scalar::F64(a[0] - b[0]);
        Some(&self.out)
    }
}
  • Step 7: Adapt the Sub in-crate test

In crates/aura-std/src/sub.rs, replace the two assertions in sub_is_difference_once_both_inputs_present (currently lines 55-60):

        // only input 0 present -> None
        inputs[0].push(Scalar::F64(10.0)).unwrap();
        assert_eq!(sub.eval(Ctx::new(&inputs)), None);

        // both present -> a - b
        inputs[1].push(Scalar::F64(4.0)).unwrap();
        assert_eq!(sub.eval(Ctx::new(&inputs)), Some([Scalar::F64(6.0)].as_slice()));
  • Step 8: Scoped gate — aura-core + aura-std build/test/clippy

Run: cargo build -p aura-core -p aura-std --all-targets Expected: Finished — 0 errors. (aura-engine is NOT built here; it is knowingly broken until Task 3.)

Run: cargo test -p aura-core -p aura-std Expected: PASS — aura-core 19 passed, aura-std 3 passed (sma 2 + sub 1), 0 failed.

Run: cargo clippy -p aura-core -p aura-std --all-targets -- -D warnings Expected: Finished — 0 warnings.


Task 2: aura-engine production — Edge.from_field, per-field bootstrap, field-indexed forward

Files:

  • Modify: crates/aura-engine/src/harness.rs (production only; the #[cfg(test)] module is Task 3)

Compile-gate note: this task changes the bootstrap/run signatures and the Edge struct, which knowingly breaks the in-module #[cfg(test)] fixtures and tests. Its gate is therefore a partial --lib build that excludes test code; the tests are rewritten and the full gate runs in Task 3. Do not run cargo test or --all-targets in this task.

  • Step 1: Add from_field to Edge and update its doc

In crates/aura-engine/src/harness.rs, replace the Edge doc + struct (lines 23-29):

/// Forwards one field (`from_field`) of a producer's output record into a
/// consumer's input slot. Consuming a whole record is N such edges, one per field
/// (there is no "bind whole record" mechanism).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Edge {
    pub from: usize,
    pub to: usize,
    pub slot: usize,
    pub from_field: usize,
}
  • Step 2: Add out_len to NodeBox

In crates/aura-engine/src/harness.rs, replace the NodeBox struct (lines 72-77):

struct NodeBox {
    node: Box<dyn Node>,
    inputs: Vec<AnyColumn>,
    firing: Vec<Firing>,
    slots: Vec<SlotState>,
    out_len: usize,
}
  • Step 3: Set out_len at bootstrap

In crates/aura-engine/src/harness.rs, replace the per-node box construction loop (lines 123-137) with (it lifts the output width from the schema alongside firing and slots):

        let mut boxes: Vec<NodeBox> = Vec::with_capacity(n);
        for (nd, schema) in nodes.into_iter().zip(schemas.iter()) {
            let inputs: Vec<AnyColumn> = schema
                .inputs
                .iter()
                .map(|spec| AnyColumn::with_capacity(spec.kind, spec.lookback))
                .collect();
            let firing: Vec<Firing> = schema.inputs.iter().map(|spec| spec.firing).collect();
            let slots: Vec<SlotState> = schema
                .inputs
                .iter()
                .map(|_| SlotState { fresh_at: 0, last_ts: Timestamp(i64::MIN) })
                .collect();
            let out_len = schema.output.len();
            boxes.push(NodeBox { node: nd, inputs, firing, slots, out_len });
        }
  • Step 4: Per-field edge kind/range check at bootstrap

In crates/aura-engine/src/harness.rs, replace the edge-check loop (lines 153-166). Also update the leading comment so it reads "field kind" rather than "output kind":

        // edges: indices in range, producer output field kind == consumer slot kind
        let mut out_edges: Vec<Vec<Edge>> = vec![Vec::new(); n];
        for &e in &edges {
            let from = schemas.get(e.from).ok_or(BootstrapError::BadIndex)?;
            let to = schemas.get(e.to).ok_or(BootstrapError::BadIndex)?;
            let field = from.output.get(e.from_field).ok_or(BootstrapError::BadIndex)?;
            let slot = to.inputs.get(e.slot).ok_or(BootstrapError::BadIndex)?;
            if field.kind != slot.kind {
                return Err(BootstrapError::KindMismatch {
                    producer: field.kind,
                    consumer: slot.kind,
                });
            }
            out_edges[e.from].push(e);
        }
  • Step 5: Change the run signature and declare the scratch row

In crates/aura-engine/src/harness.rs, replace the run signature (line 205):

    pub fn run(&mut self, streams: Vec<Vec<(Timestamp, Scalar)>>) -> Vec<Option<Vec<Scalar>>> {

Then, immediately after the let mut out = Vec::new(); line (line 221), add one reused scratch buffer (allocated once; its capacity stabilizes after the first fires, so the inter-node forward path allocates nothing per cycle):

        let mut scratch: Vec<Scalar> = Vec::new();
  • Step 6: Field-indexed forward in the run loop

In crates/aura-engine/src/harness.rs, replace the per-node evaluate/forward block (lines 254-278) — from let mut observed = None; through out.push(observed); — with the block below. result is Option<&[Scalar]> (which is Copy, since &[Scalar] is a shared reference); the observed row is cloned out, the producer row is copied into scratch (ending the borrow), and each out-edge forwards its single field:

            let mut observed: Option<Vec<Scalar>> = None;
            for &nidx in topo.iter() {
                let out_len = nodes[nidx].out_len;
                let fired = {
                    let nb = &nodes[nidx];
                    fires(&nb.firing, &nb.slots, cycle_id, ts)
                };
                if !fired {
                    continue; // hold: no eval, no push
                }
                let result: Option<&[Scalar]> = {
                    let nb = &mut nodes[nidx];
                    nb.node.eval(Ctx::new(&nb.inputs))
                };
                if nidx == observe {
                    observed = result.map(|row| row.to_vec());
                }
                if let Some(row) = result {
                    debug_assert_eq!(row.len(), out_len, "node returned a row of the wrong width");
                    scratch.clear();
                    scratch.extend_from_slice(row);
                    for e in out_edges[nidx].iter() {
                        let nb = &mut nodes[e.to];
                        nb.inputs[e.slot]
                            .push(scratch[e.from_field])
                            .expect("edge kind checked at wiring");
                        nb.slots[e.slot] = SlotState { fresh_at: cycle_id, last_ts: ts };
                    }
                }
            }
            out.push(observed);
  • Step 7: Update the harness module doc for the record-forward framing

In crates/aura-engine/src/harness.rs, replace the module-doc sentence about the node owning its input columns (line 8, currently //! the cycle-0002 shape), so \Ctx` is unchanged.) with the two lines below (the Ctx`-unchanged claim stays true; the addition states the output-record forward):

//! the cycle-0002 shape), so `Ctx` is unchanged. A node's `eval` returns a
//! borrowed record (`Option<&[Scalar]>`); each out-edge forwards one field of it
//! (`Edge::from_field`) into a consumer slot, so the K fields are co-fresh (C6).
  • Step 8: Partial gate — aura-engine lib build/clippy (tests excluded)

Run: cargo build -p aura-engine --lib Expected: Finished — 0 errors. (The #[cfg(test)] module is NOT compiled by --lib; it is rewritten in Task 3.)

Run: cargo clippy -p aura-engine --lib -- -D warnings Expected: Finished — 0 warnings.


Task 3: aura-engine tests — fixtures migration, OHLCV proof, must-fail cases

Files:

  • Modify: crates/aura-engine/src/harness.rs (the #[cfg(test)] mod tests block, lines 331-669)

  • Step 1: Add FieldSpec to the test-module imports

In crates/aura-engine/src/harness.rs, replace the test-module import (line 337):

    use aura_core::{FieldSpec, InputSpec, NodeSchema};
  • Step 2: Migrate AsOfSum to the slice return

In crates/aura-engine/src/harness.rs, replace the AsOfSum fixture (lines 350-369) with (it gains a 1-field output buffer; behaviour identical):

    struct AsOfSum {
        out: [Scalar; 1],
    }
    impl Node for AsOfSum {
        fn schema(&self) -> NodeSchema {
            NodeSchema {
                inputs: vec![
                    InputSpec { kind: ScalarKind::F64, lookback: 1, firing: Firing::Any },
                    InputSpec { kind: ScalarKind::F64, lookback: 1, firing: Firing::Any },
                ],
                output: vec![FieldSpec { name: "value", kind: ScalarKind::F64 }],
            }
        }
        fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Scalar]> {
            let a = ctx.f64_in(0);
            let b = ctx.f64_in(1);
            if a.is_empty() || b.is_empty() {
                return None;
            }
            self.out[0] = Scalar::F64(a[0] + b[0]);
            Some(&self.out)
        }
    }
  • Step 3: Migrate BarrierSum to the slice return

In crates/aura-engine/src/harness.rs, replace the BarrierSum fixture (lines 373-387):

    struct BarrierSum {
        out: [Scalar; 1],
    }
    impl Node for BarrierSum {
        fn schema(&self) -> NodeSchema {
            NodeSchema {
                inputs: vec![
                    InputSpec { kind: ScalarKind::F64, lookback: 1, firing: Firing::Barrier(0) },
                    InputSpec { kind: ScalarKind::F64, lookback: 1, firing: Firing::Barrier(0) },
                ],
                output: vec![FieldSpec { name: "value", kind: ScalarKind::F64 }],
            }
        }
        fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Scalar]> {
            self.out[0] = Scalar::F64(ctx.f64_in(0)[0] + ctx.f64_in(1)[0]);
            Some(&self.out)
        }
    }
  • Step 4: Migrate MixedSum to the slice return

In crates/aura-engine/src/harness.rs, replace the MixedSum fixture (lines 392-413):

    struct MixedSum {
        out: [Scalar; 1],
    }
    impl Node for MixedSum {
        fn schema(&self) -> NodeSchema {
            NodeSchema {
                inputs: vec![
                    InputSpec { kind: ScalarKind::F64, lookback: 1, firing: Firing::Barrier(0) },
                    InputSpec { kind: ScalarKind::F64, lookback: 1, firing: Firing::Barrier(0) },
                    InputSpec { kind: ScalarKind::F64, lookback: 1, firing: Firing::Any },
                ],
                output: vec![FieldSpec { name: "value", kind: ScalarKind::F64 }],
            }
        }
        fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Scalar]> {
            let a = ctx.f64_in(0);
            let b = ctx.f64_in(1);
            let c = ctx.f64_in(2);
            if a.is_empty() || b.is_empty() || c.is_empty() {
                return None;
            }
            self.out[0] = Scalar::F64(a[0] + b[0] + c[0]);
            Some(&self.out)
        }
    }
  • Step 5: Add the Ohlcv and TwoField fixtures

In crates/aura-engine/src/harness.rs, immediately after the MixedSum fixture (after the block replaced in Step 4), insert these two new fixtures:

    /// A neutral multi-field producer: five f64 inputs bundled into one 5-field
    /// record. No trading-domain logic — it proves the K > 1 output mechanism in
    /// isolation. The five inputs are a Barrier(0) group, so the node emits one
    /// complete bar only when all five share the cycle's timestamp.
    struct Ohlcv {
        out: [Scalar; 5],
    }
    impl Node for Ohlcv {
        fn schema(&self) -> NodeSchema {
            NodeSchema {
                inputs: vec![
                    InputSpec { kind: ScalarKind::F64, lookback: 1, firing: Firing::Barrier(0) },
                    InputSpec { kind: ScalarKind::F64, lookback: 1, firing: Firing::Barrier(0) },
                    InputSpec { kind: ScalarKind::F64, lookback: 1, firing: Firing::Barrier(0) },
                    InputSpec { kind: ScalarKind::F64, lookback: 1, firing: Firing::Barrier(0) },
                    InputSpec { kind: ScalarKind::F64, lookback: 1, firing: Firing::Barrier(0) },
                ],
                output: vec![
                    FieldSpec { name: "open", kind: ScalarKind::F64 },
                    FieldSpec { name: "high", kind: ScalarKind::F64 },
                    FieldSpec { name: "low", kind: ScalarKind::F64 },
                    FieldSpec { name: "close", kind: ScalarKind::F64 },
                    FieldSpec { name: "volume", kind: ScalarKind::F64 },
                ],
            }
        }
        fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Scalar]> {
            for i in 0..5 {
                let w = ctx.f64_in(i);
                if w.is_empty() {
                    return None; // not yet warmed
                }
                self.out[i] = Scalar::F64(w[0]);
            }
            Some(&self.out) // one 5-field record, all fields co-fresh
        }
    }

    /// A producer whose output record mixes kinds: field 0 is f64, field 1 is i64.
    /// Used only to prove the bootstrap kind check is per-field (field 0 would bind
    /// into an f64 slot; field 1 would not). Its `eval` never runs in these tests —
    /// bootstrap rejects the wiring first.
    struct TwoField {
        out: [Scalar; 2],
    }
    impl Node for TwoField {
        fn schema(&self) -> NodeSchema {
            NodeSchema {
                inputs: vec![InputSpec { kind: ScalarKind::F64, lookback: 1, firing: Firing::Any }],
                output: vec![
                    FieldSpec { name: "f", kind: ScalarKind::F64 },
                    FieldSpec { name: "i", kind: ScalarKind::I64 },
                ],
            }
        }
        fn eval(&mut self, _ctx: Ctx<'_>) -> Option<&[Scalar]> {
            self.out[0] = Scalar::F64(0.0);
            self.out[1] = Scalar::I64(0);
            Some(&self.out)
        }
    }
  • Step 6: Adapt chain_source_sma_runs

In crates/aura-engine/src/harness.rs, replace the expected vector of chain_source_sma_runs (the assert_eq! currently at lines 426-435) with the record-wrapped form:

        assert_eq!(
            out,
            vec![
                None,
                None,
                Some(vec![Scalar::F64(2.0)]),
                Some(vec![Scalar::F64(3.0)]),
                Some(vec![Scalar::F64(4.0)]),
            ]
        );
  • Step 7: Adapt fan_out_join_dag_runs_deterministically

In crates/aura-engine/src/harness.rs, replace the two Edge literals (line 449) with from_field-bearing forms:

                vec![Edge { from: 0, to: 2, slot: 0, from_field: 0 }, Edge { from: 1, to: 2, slot: 1, from_field: 0 }],

Replace the expected vector (currently lines 459-469) with:

        assert_eq!(
            out,
            vec![
                None,
                None,
                None,
                Some(vec![Scalar::F64(2.0)]),
                Some(vec![Scalar::F64(2.0)]),
                Some(vec![Scalar::F64(2.0)]),
            ]
        );
  • Step 8: Adapt the three fixture-construction sites and the mode-A/mode-B/mixed expected vectors

In crates/aura-engine/src/harness.rs:

Replace vec![Box::new(AsOfSum)] (line 482) with:

                vec![Box::new(AsOfSum { out: [Scalar::F64(0.0)] })],

Replace the mode_a_as_of_fires_on_any_fresh_and_holds expected vector (lines 498-507):

        assert_eq!(
            out,
            vec![
                None,
                None,
                Some(vec![Scalar::F64(120.0)]),
                Some(vec![Scalar::F64(130.0)]),
                Some(vec![Scalar::F64(140.0)]),
                Some(vec![Scalar::F64(240.0)]),
            ]
        );

Replace vec![Box::new(BarrierSum)] in mode_b_barrier_fires_only_on_timestamp_coincidence (line 519) with:

                vec![Box::new(BarrierSum { out: [Scalar::F64(0.0)] })],

Replace the mode_b_barrier_fires_only_on_timestamp_coincidence expected vector (lines 535-544):

        assert_eq!(
            out,
            vec![
                None,
                None,
                Some(vec![Scalar::F64(120.0)]),
                None,
                None,
                Some(vec![Scalar::F64(240.0)]),
            ]
        );

Replace vec![Box::new(MixedSum)] (line 597) with:

            vec![Box::new(MixedSum { out: [Scalar::F64(0.0)] })],

Replace the mixed_a_and_b_or_combine_on_one_node expected vector (lines 616-625):

        assert_eq!(
            out,
            vec![
                None,
                None,
                Some(vec![Scalar::F64(221.0)]),
                Some(vec![Scalar::F64(223.0)]),
                None,
            ]
        );
  • Step 9: Adapt within_source_diamond_rejoin_barrier_fires

In crates/aura-engine/src/harness.rs, replace the BarrierSum construction in the node list (line 560, vec![Box::new(Sma::new(2)), Box::new(Sma::new(4)), Box::new(BarrierSum)]):

                vec![Box::new(Sma::new(2)), Box::new(Sma::new(4)), Box::new(BarrierSum { out: [Scalar::F64(0.0)] })],

Replace the two Edge literals (line 565):

                vec![Edge { from: 0, to: 2, slot: 0, from_field: 0 }, Edge { from: 1, to: 2, slot: 1, from_field: 0 }],

Replace the expected vector (lines 577-586):

        assert_eq!(
            out,
            vec![
                None,
                None,
                None,
                Some(vec![Scalar::F64(28.0)]),
                Some(vec![Scalar::F64(32.0)]),
                Some(vec![Scalar::F64(36.0)]),
            ]
        );
  • Step 10: Adapt bootstrap_rejects_a_cycle

In crates/aura-engine/src/harness.rs, replace the two Edge literals in bootstrap_rejects_a_cycle (line 634):

            vec![Edge { from: 0, to: 1, slot: 0, from_field: 0 }, Edge { from: 1, to: 0, slot: 0, from_field: 0 }],

(bootstrap_rejects_a_kind_mismatch and bootstrap_rejects_a_bad_index use a source kind mismatch and a bad observe index respectively — no Edge literal, no run output — so they need no change.)

  • Step 11: Add the K > 1 output test

In crates/aura-engine/src/harness.rs, at the end of the mod tests block (after bootstrap_rejects_a_bad_index, before the closing } of the module), add:

    /// Build five timestamp-aligned f64 sources feeding Ohlcv's five barrier slots.
    fn ohlcv_streams() -> Vec<Vec<(Timestamp, Scalar)>> {
        vec![
            f64_stream(&[(1, 10.0), (2, 20.0)]), // open
            f64_stream(&[(1, 15.0), (2, 25.0)]), // high
            f64_stream(&[(1, 8.0), (2, 19.0)]),  // low
            f64_stream(&[(1, 12.0), (2, 22.0)]), // close
            f64_stream(&[(1, 100.0), (2, 200.0)]), // volume
        ]
    }

    fn ohlcv_sources() -> Vec<SourceSpec> {
        (0..5)
            .map(|slot| SourceSpec {
                kind: ScalarKind::F64,
                targets: vec![Target { node: 0, slot }],
            })
            .collect()
    }

    #[test]
    fn ohlcv_bundles_five_field_record() {
        // node 0 = Ohlcv; five sources feed O/H/L/C/V; observe node 0. The barrier
        // fires once all five share the timestamp, so each bar appears on the fifth
        // cycle of its timestamp (the four partial cycles hold -> None).
        let mut h = Harness::bootstrap(
            vec![Box::new(Ohlcv { out: [Scalar::F64(0.0); 5] })],
            ohlcv_sources(),
            vec![],
            0,
        )
        .expect("valid");
        let out = h.run(ohlcv_streams());
        assert_eq!(
            out,
            vec![
                None,
                None,
                None,
                None,
                Some(vec![
                    Scalar::F64(10.0),
                    Scalar::F64(15.0),
                    Scalar::F64(8.0),
                    Scalar::F64(12.0),
                    Scalar::F64(100.0),
                ]),
                None,
                None,
                None,
                None,
                Some(vec![
                    Scalar::F64(20.0),
                    Scalar::F64(25.0),
                    Scalar::F64(19.0),
                    Scalar::F64(22.0),
                    Scalar::F64(200.0),
                ]),
            ]
        );
    }
  • Step 12: Add the field-wise binding proof (high low) + determinism

In crates/aura-engine/src/harness.rs, after the test added in Step 11, add:

    #[test]
    fn edge_binds_single_field_high_minus_low() {
        // nodes [Ohlcv (0), Sub (1)]; Sub binds field 1 (high) and field 2 (low)
        // of the Ohlcv record -> high - low == the bar range. Observing Sub proves
        // from_field routes the right columns (not field 0), and that the two bound
        // fields are co-fresh (Sub's Any inputs both fire in the bar's cycle).
        let build = || {
            Harness::bootstrap(
                vec![
                    Box::new(Ohlcv { out: [Scalar::F64(0.0); 5] }),
                    Box::new(Sub::new()),
                ],
                ohlcv_sources(),
                vec![
                    Edge { from: 0, to: 1, slot: 0, from_field: 1 }, // high
                    Edge { from: 0, to: 1, slot: 1, from_field: 2 }, // low
                ],
                1,
            )
            .expect("valid DAG")
        };
        let mut h = build();
        let out = h.run(ohlcv_streams());
        // bar1: 15 - 8 = 7; bar2: 25 - 19 = 6 (each on the bar's fifth cycle).
        assert_eq!(
            out,
            vec![
                None,
                None,
                None,
                None,
                Some(vec![Scalar::F64(7.0)]),
                None,
                None,
                None,
                None,
                Some(vec![Scalar::F64(6.0)]),
            ]
        );

        // determinism (C1): a second identical run is bit-identical
        let mut h2 = build();
        assert_eq!(h2.run(ohlcv_streams()), out);
    }
  • Step 13: Add the distinct-fields proof (close open)

In crates/aura-engine/src/harness.rs, after the test added in Step 12, add:

    #[test]
    fn distinct_edges_read_distinct_fields() {
        // Same Ohlcv, a different consumer: Sub binds field 3 (close) and field 0
        // (open) -> close - open. Proves two different edges on one record read two
        // different fields (3 and 0, neither of them the high/low pair above).
        let mut h = Harness::bootstrap(
            vec![
                Box::new(Ohlcv { out: [Scalar::F64(0.0); 5] }),
                Box::new(Sub::new()),
            ],
            ohlcv_sources(),
            vec![
                Edge { from: 0, to: 1, slot: 0, from_field: 3 }, // close
                Edge { from: 0, to: 1, slot: 1, from_field: 0 }, // open
            ],
            1,
        )
        .expect("valid DAG");
        let out = h.run(ohlcv_streams());
        // bar1: 12 - 10 = 2; bar2: 22 - 20 = 2.
        assert_eq!(
            out,
            vec![
                None,
                None,
                None,
                None,
                Some(vec![Scalar::F64(2.0)]),
                None,
                None,
                None,
                None,
                Some(vec![Scalar::F64(2.0)]),
            ]
        );
    }
  • Step 14: Add the must-fail tests (from_field OOB; per-field kind mismatch)

In crates/aura-engine/src/harness.rs, after the test added in Step 13, add:

    #[test]
    fn bootstrap_rejects_from_field_out_of_range() {
        // Sma(0) has a 1-field output (index 0 only); an edge reading field 9 is
        // out of range -> BadIndex (caught before any kind check).
        let err = Harness::bootstrap(
            vec![Box::new(Sma::new(1)), Box::new(Sma::new(1))],
            vec![SourceSpec { kind: ScalarKind::F64, targets: vec![Target { node: 0, slot: 0 }] }],
            vec![Edge { from: 0, to: 1, slot: 0, from_field: 9 }],
            0,
        )
        .unwrap_err();
        assert_eq!(err, BootstrapError::BadIndex);
    }

    #[test]
    fn bootstrap_rejects_per_field_kind_mismatch() {
        // TwoField(0) output: field 0 f64, field 1 i64. Binding field 1 (i64) into
        // Sma(1)'s f64 input slot is a per-field kind mismatch -> KindMismatch. (The
        // mismatch is field-specific: from_field 0 would have matched.)
        let err = Harness::bootstrap(
            vec![Box::new(TwoField { out: [Scalar::F64(0.0), Scalar::I64(0)] }), Box::new(Sma::new(1))],
            vec![SourceSpec { kind: ScalarKind::F64, targets: vec![Target { node: 0, slot: 0 }] }],
            vec![Edge { from: 0, to: 1, slot: 0, from_field: 1 }],
            1,
        )
        .unwrap_err();
        assert_eq!(
            err,
            BootstrapError::KindMismatch { producer: ScalarKind::I64, consumer: ScalarKind::F64 }
        );
    }
  • Step 15: Full workspace gate + purity grep

Run: cargo build --workspace --all-targets Expected: Finished — 0 errors.

Run: cargo test --workspace Expected: PASS — 0 failed. Per-crate: aura-core 19 passed; aura-std 3 passed; aura-engine 14 passed (the 9 prior tests + ohlcv_bundles_five_field_record, edge_binds_single_field_high_minus_low, distinct_edges_read_distinct_fields, bootstrap_rejects_from_field_out_of_range, bootstrap_rejects_per_field_kind_mismatch).

Run: cargo clippy --workspace --all-targets -- -D warnings Expected: Finished — 0 warnings.

Run: grep -rnE 'RefCell|Rc<|dyn Any' crates/*/src Expected: no output (exit code 1) — the record is a bundle of base columns, no fifth type, no heterogeneous payload.


Task 4: Design ledger — C8 revision + C7 sharpening

Files:

  • Modify: docs/design/INDEX.md (C7 at lines 168-179; C8 at lines 181-197)

This is a docs-only task (no compile gate). The glossary composite/node record-reality pass is explicitly an audit-time follow-up, NOT this plan.

  • Step 1: Sharpen C7 — composite-bundle is the node-output model

In docs/design/INDEX.md, in the C7 guarantee, replace the sentence (line 170-171):

sim, as columnar Structure-of-Arrays. Composite streams
(OHLCV) are bundles of base columns. Edges are type-erased to these four kinds;

with:

sim, as columnar Structure-of-Arrays. Composite streams
(OHLCV) are bundles of base columns — this is the **node-output model** too: a
node emits a record of 1..K base columns (C8), each forwarded field-wise to a
consumer slot; the bundle is structural, never a fifth scalar type. Edges are
type-erased to these four kinds;
  • Step 2: Revise C8 — one output port carrying a record

In docs/design/INDEX.md, in the C8 guarantee, replace eval(ctx) -> Option<Scalar> (line 185) with eval(ctx) -> Option<&[Scalar]>. Concretely, replace (lines 184-185):

param-space the optimizer sweeps, C12/C19/C20) + `eval(ctx) -> Option<Scalar>`. The
engine provides read-only, zero-copy windows into each input's SoA ring buffer

with:

param-space the optimizer sweeps, C12/C19/C20) + `eval(ctx) -> Option<&[Scalar]>`. The
engine provides read-only, zero-copy windows into each input's SoA ring buffer
  • Step 3: Revise the C8 output-arity sentence

In docs/design/INDEX.md, replace (lines 189-192):

not-yet-warmed-up. A node is a **producer, a consumer, or both**: a
producer/transformer exposes **at most one** output (one series per node); a
**pure consumer (sink)** — chart, equity, logger — has **no** output. Sources
are pure producers; sinks are pure consumers.

with:

not-yet-warmed-up. A node is a **producer, a consumer, or both**: a
producer/transformer exposes **one output port**, whose payload is a **record of
1..K base-scalar columns** (a scalar is the degenerate K=1 record; an `eval`
returns a borrowed row, one value per column); a **pure consumer (sink)** — chart,
equity, logger — has **no** output. Sources are pure producers; sinks are pure
consumers.
  • Step 4: Revise the C8 Forbids + add the cycle-0005 realization

In docs/design/INDEX.md, replace the C8 Forbids/Why block (lines 193-197):

**Forbids.** A node sizing/growing its input lookback at runtime; more than one
output per node (model as multiple nodes); copy-on-read of input history.
**Why.** Engine-provided windows mean LLM-authored code cannot mis-manage
lookback bookkeeping, and history passes through zero-copy. Fixed, pre-sized
buffers suit deterministic, pre-dimensioned sims (no realloc in the hot loop).

with:

**Forbids.** A node sizing/growing its input lookback at runtime; more than one
output **port** per node; a fifth scalar type or a heterogeneous output payload
(a record is a bundle of base columns, C7); copy-on-read of input history.
**Why.** Engine-provided windows mean LLM-authored code cannot mis-manage
lookback bookkeeping, and history passes through zero-copy. Fixed, pre-sized
buffers suit deterministic, pre-dimensioned sims (no realloc in the hot loop).
**Realization (cycle 0005).** `NodeSchema.output` is a `Vec<FieldSpec>` (named base
columns; length 1 = scalar). Binding is **field-wise only**: `Edge::from_field`
selects one producer column per edge; consuming a whole record is N edges (no
"bind whole record" mechanism). The K fields of one record are **co-fresh by
construction** (one `eval`, one timestamp), so C6 is untouched. `eval` returns
`Option<&[Scalar]>` — a borrowed row into a node-owned buffer — so the forward
path allocates nothing per cycle (C7).
  • Step 5: Sanity-check the ledger edits

Run: grep -n 'Option<&\[Scalar\]>' docs/design/INDEX.md Expected: at least one hit (the C8 guarantee line).

Run: grep -n 'one series per node' docs/design/INDEX.md Expected: no output (exit 1) — the old single-output framing is gone from C8.

Run: grep -n 'Realization (cycle 0005)' docs/design/INDEX.md Expected: one hit — the cycle-0005 realization note is present.