Files
Aura/docs/plans/0006-sink-recording.md
T
Brummel 7b8e8010dd plan: 0006 sink recording
Six tasks: (1) aura-core Ctx::now(); (2) thread the Ctx::new signature
change through aura-std's five test call sites; (3) aura-engine
production shrink (remove observe, run -> (), thread the run-loop Ctx);
(4) migrate the engine test suite to a test-local Recorder fixture +
drained channel (the finish-threading task — the test module is one
compile unit, so it restores the full cargo test); (5) eight new
node-recording proof tests; (6) ledger C8/C22 realization notes + the
ship gate (workspace test, clippy, purity grep).

Recon found the spec undercounts Ctx::new call sites (9, not 3): the
aura-std and aura-engine run-loop sites also break on the signature
change and are threaded in tasks 2-3.

refs #2
2026-06-04 14:28:14 +02:00

53 KiB
Raw Blame History

Sink recording — recording is a node role, not a type — Implementation Plan

Parent spec: docs/specs/0006-sink-recording.md

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

Goal: Replace the engine's single observe: usize recording affordance with recording-by-node, so one run records many streams; recording is an out-of-graph eval side effect to a destination the node holds, with no Sink type.

Architecture: Three production changes — Ctx gains now: Timestamp + now() (aura-core); Harness loses observe (field, bootstrap param + check, per-cycle collection) and run returns () constructing Ctx::new(&inputs, ts) (aura-engine). A test-local Recorder fixture (holding an mpsc::Sender) proves multi-stream recording; existing firing/DAG tests migrate from the dense Vec<Option<row>> to a sparse, timestamped drained channel. Ledger C8/C22 gain cycle-0006 realization notes.

Tech Stack: Rust workspace (aura-core ← aura-std ← aura-engine); std::sync::mpsc for the test read-back; the existing per-field kind check (0005) covers recorder edges.


Files this plan creates or modifies

  • Modify: crates/aura-core/src/ctx.rs:10-19Ctx gains now: Timestamp + now(); Ctx::new takes now; 3 in-crate test call sites updated; new unit test ctx_now_returns_cycle_timestamp.
  • Modify: crates/aura-std/src/sma.rs:51,70,84,87 — 3 Ctx::new test call sites thread a timestamp; test-module import gains Timestamp.
  • Modify: crates/aura-std/src/sub.rs:52,64,68 — 2 Ctx::new test call sites thread a timestamp; test-module import gains Timestamp.
  • Modify: crates/aura-engine/src/harness.rs — production: remove observe (field :91, Debug :105, bootstrap param :119 + check :122-124, construction :204, destructure :224-225, loop bookkeeping :263,:277-279, :293,:295), change run to () (:213), thread Ctx::new(&nb.inputs, ts) (:275), fix doc-comments (:8,:59,:208-212). Tests: add Recorder + TapForward fixtures; migrate the 14 bootstrap/run tests; add 8 new proof tests.
  • Modify: docs/design/INDEX.md:210,495 — C8 + C22 cycle-0006 realization notes.

Decision recorded (orchestrator): the spec lists both a migrated ohlcv_bundles_five_field_record and a new recorder_taps_all_fields_of_a_record; they prove overlapping mechanics. Both are kept and differentiated: the migrated test records two bars (focus: barrier timing — one record per bar, on the fifth cycle of each timestamp), the new test records one bar (focus: a 5-input recorder taps all five fields via five field-wise edges, 0005 — asserts the row has five fields). No spec-named test is dropped.

Out of scope (stays #3): the exhaustive multi-producer × multi-consumer × multi-sink stress matrix. This plan ships the substrate (#2) only.


Task 1: Ctx::now() (aura-core)

Files:

  • Modify: crates/aura-core/src/ctx.rs

  • Step 1: Add the now field, the now() accessor, and update Ctx::new

Replace the struct + impl opening (ctx.rs:8-19):

/// Read-only, zero-copy view of a node's inputs for one `eval`, in schema
/// order, plus the cycle's timestamp (C4). `Copy` because it is just a borrow of
/// the input slice plus a `Copy` timestamp.
#[derive(Clone, Copy)]
pub struct Ctx<'a> {
    inputs: &'a [AnyColumn],
    now: Timestamp,
}

impl<'a> Ctx<'a> {
    /// Wrap the per-input columns (in schema-declared order) and the cycle
    /// timestamp for one `eval`.
    pub fn new(inputs: &'a [AnyColumn], now: Timestamp) -> Self {
        Self { inputs, now }
    }

    /// The current cycle's timestamp (C4). Causal — the present cycle's
    /// timestamp, never the future (C2) — so reading it introduces no look-ahead.
    pub fn now(&self) -> Timestamp {
        self.now
    }

(Timestamp is already imported at ctx.rs:6.)

  • Step 2: Thread the timestamp through the 3 in-crate test call sites

In ctx.rs #[cfg(test)] mod tests, the three Ctx::new(&inputs) calls become Ctx::new(&inputs, Timestamp(0)):

  • ctx.rs:72 (in ctx_hands_financial_indexed_windows):
        let ctx = Ctx::new(&inputs, Timestamp(0));
  • ctx.rs:87 (in ctx_addresses_multiple_inputs):
        let ctx = Ctx::new(&inputs, Timestamp(0));
  • ctx.rs:97 (in ctx_panics_on_kind_mismatch):
        let ctx = Ctx::new(&inputs, Timestamp(0));

The test-module import (ctx.rs:64, use crate::{Scalar, ScalarKind};) gains Timestamp:

    use crate::{Scalar, ScalarKind, Timestamp};
  • Step 3: Add the ctx_now_returns_cycle_timestamp unit test

Append inside #[cfg(test)] mod tests (after ctx_panics_on_kind_mismatch, before the closing } at ctx.rs:100):

    #[test]
    fn ctx_now_returns_cycle_timestamp() {
        let inputs: Vec<AnyColumn> = vec![];
        let ctx = Ctx::new(&inputs, Timestamp(42));
        assert_eq!(ctx.now(), Timestamp(42));
    }
  • Step 4: Verify aura-core compiles and its tests pass

Run: cargo test -p aura-core Expected: PASS — test result: ok. 20 passed; 0 failed (the prior 19 + the new ctx_now_returns_cycle_timestamp). The downstream crates are not compiled by a -p aura-core run, so their still-old Ctx::new calls do not break this gate.


Task 2: thread Ctx::new through aura-std (caller-threading forced by Task 1)

Files:

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

The Ctx::new signature change in Task 1 breaks every aura-std test call site. This task threads all five (compile-driven enumeration: sma.rs:70,84,87; sub.rs:64,68). Mechanical — the node tests do not assert on now(), so a Timestamp(0) placeholder is correct.

  • Step 1: Update the three Ctx::new call sites in sma.rs

Add Timestamp to the test-module import (sma.rs:51, use aura_core::AnyColumn;):

    use aura_core::{AnyColumn, Timestamp};

sma.rs:70 (in sma_warms_up_then_tracks_the_window_mean):

            let got = sma.eval(Ctx::new(&inputs, Timestamp(0)));

sma.rs:84 (in sma_length_one_is_identity):

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

sma.rs:87 (same test):

        assert_eq!(sma.eval(Ctx::new(&inputs, Timestamp(0))), Some([Scalar::F64(9.0)].as_slice()));
  • Step 2: Update the two Ctx::new call sites in sub.rs

Add Timestamp to the test-module import (sub.rs:52, use aura_core::AnyColumn;):

    use aura_core::{AnyColumn, Timestamp};

sub.rs:64 (in sub_is_difference_once_both_inputs_present):

        assert_eq!(sub.eval(Ctx::new(&inputs, Timestamp(0))), None);

sub.rs:68 (same test):

        assert_eq!(sub.eval(Ctx::new(&inputs, Timestamp(0))), Some([Scalar::F64(6.0)].as_slice()));
  • Step 3: Verify aura-std compiles and its tests pass

Run: cargo test -p aura-std Expected: PASS — test result: ok. 3 passed; 0 failed. aura-engine (downstream) is not compiled by this gate, so its still-old API does not break it.


Task 3: shrink the engine surface — remove observe, run -> () (aura-engine production)

Files:

  • Modify: crates/aura-engine/src/harness.rs (production code only; the #[cfg(test)] mod tests is migrated in Task 4)

This task changes three production surfaces (observe removal, run return type, the run-loop Ctx::new) and the doc-comments that reference them. The #[cfg(test)] test module is left broken on purpose — it still calls the old 4-arg bootstrap and binds run's return — and is restored in Task 4. The gate here is therefore a production-only build (cargo build, which does not compile #[cfg(test)] modules), per the planner's compile-gate-ordering rule.

  • Step 1: Fix the module doc and the BadIndex doc

harness.rs:8 — the module doc currently reads (exact substring to replace):

//! (the cycle-0002 shape), so `Ctx` is unchanged. A node's `eval` returns a

Replace that one line with:

//! (the cycle-0002 shape), so `Ctx` borrows them read-only and additionally
//! carries the cycle timestamp (`ctx.now()`, C4). A node's `eval` returns a

harness.rs:59 — drop the observe clause from the BadIndex doc:

    /// A node or slot index in an edge or target is out of range.
    BadIndex,
  • Step 2: Remove the observe field and its Debug line

harness.rs:86-92 — the struct loses observe:

/// A bootstrapped, frozen root graph instance plus its deterministic run loop.
pub struct Harness {
    nodes: Vec<NodeBox>,
    topo: Vec<usize>,
    out_edges: Vec<Vec<Edge>>,
    sources: Vec<SourceSpec>,
}

harness.rs:100-106 — the Debug impl loses the observe field line:

        f.debug_struct("Harness")
            .field("nodes", &self.nodes.len())
            .field("topo", &self.topo)
            .field("out_edges", &self.out_edges)
            .field("sources", &self.sources)
            .finish()
  • Step 3: Drop the observe parameter, its bounds check, and its construction

harness.rs:115-124bootstrap loses its fourth parameter and the observe >= n check:

    pub fn bootstrap(
        nodes: Vec<Box<dyn Node>>,
        sources: Vec<SourceSpec>,
        edges: Vec<Edge>,
    ) -> Result<Harness, BootstrapError> {
        let n = nodes.len();

        let schemas: Vec<_> = nodes.iter().map(|nd| nd.schema()).collect();

(The let n = nodes.len(); line stays — n is still used to size out_edges and indeg. Only the if observe >= n { ... } block is removed.)

harness.rs:199-205 — the construction loses observe:

        Ok(Harness {
            nodes: boxes,
            topo,
            out_edges,
            sources,
        })
  • Step 4: Change run to return (), drop the observe bookkeeping, thread ts into Ctx

Replace the entire run function (harness.rs:208-296, from the /// Drive the sources doc-comment through the closing } of run) with the block below. Changes vs. the original: return type (); doc rewritten; observe dropped from the destructure and the let observe = *observe; line gone; let mut out gone; let mut observed gone; Ctx::new(&nb.inputs, ts); the if nidx == observe branch gone; out.push(observed) and the trailing out return gone.

    /// Drive the sources, k-way-merged in timestamp order (ties by source index,
    /// C4). One stream per source, each ascending in timestamp (C3 ingestion
    /// precondition). Recording is a node-side concern: a recording node pushes
    /// its record to a destination it holds (out of graph) inside `eval`; the
    /// engine only routes in-graph edges and is oblivious to the side effect.
    /// Allocates nothing per cycle beyond the reused scratch buffer.
    pub fn run(&mut self, streams: Vec<Vec<(Timestamp, Scalar)>>) {
        assert_eq!(
            streams.len(),
            self.sources.len(),
            "run: one stream per source required (got {} streams for {} sources)",
            streams.len(),
            self.sources.len()
        );

        // disjoint field borrows so the topo walk can read topo/out_edges/sources
        // while mutating nodes
        let Harness { nodes, topo, out_edges, sources } = self;

        let mut cursor: Vec<usize> = vec![0; streams.len()];
        let mut cycle_id: u64 = 0;
        let mut scratch: Vec<Scalar> = Vec::new();

        loop {
            // pick the live source head with the smallest (timestamp, source index)
            let mut pick: Option<usize> = None;
            for (s, stream) in streams.iter().enumerate() {
                if cursor[s] < stream.len() {
                    match pick {
                        None => pick = Some(s),
                        Some(p) => {
                            if stream[cursor[s]].0 < streams[p][cursor[p]].0 {
                                pick = Some(s);
                            }
                        }
                    }
                }
            }
            let s = match pick {
                Some(s) => s,
                None => break, // all streams exhausted
            };
            let (ts, value) = streams[s][cursor[s]];
            cursor[s] += 1;
            cycle_id += 1;

            // forward the source value into its target slots, stamping freshness
            for t in sources[s].targets.iter() {
                let nb = &mut nodes[t.node];
                nb.inputs[t.slot].push(value).expect("source kind checked at wiring");
                nb.slots[t.slot] = SlotState { fresh_at: cycle_id, last_ts: ts };
            }

            // evaluate in topological order; gate by firing; forward Some outputs
            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, ts))
                };
                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 };
                    }
                }
            }
        }
    }

- [ ] **Step 5: Verify the production library builds (test module intentionally still broken)**

Run: `cargo build --workspace`
Expected: PASS  `Finished` with no errors. `cargo build` does not compile
`#[cfg(test)]` modules, so the not-yet-migrated `harness.rs` tests do not break
this gate. aura-core and aura-std production are already on the new API (Tasks 1-2);
no production caller of `run`/`bootstrap` exists outside the test module
(verified: aura-cli does not reference them).

---

## Task 4: migrate the engine test suite onto the recording API (aura-engine tests)

**Files:**
- Modify: `crates/aura-engine/src/harness.rs` (`#[cfg(test)] mod tests` only)

This is the "finish-threading" task: the `harness.rs` test module is one
compilation unit, so it compiles only once **all** 14 `bootstrap` call sites drop
their 4th argument and all run-binding sites move to the drained channel. The gate
is therefore the full `cargo test -p aura-engine`. Behaviour is preserved: a
recorded stream is exactly the old `Some(row)` entries, now sparse and tagged with
each firing cycle's timestamp.

- [ ] **Step 1: Add the test-module `mpsc` import and the `Recorder` fixture**

At the top of `#[cfg(test)] mod tests` (`harness.rs:348-353`), add the `mpsc`
import after the existing `use` lines:
```rust
    use std::sync::mpsc;

Add the Recorder fixture alongside the other fixtures (after TwoField, before the first #[test] at harness.rs:501):

    /// A recording node (test-local fixture; stands in for a downstream author's
    /// chart/registry sink). It declares typed input slots and holds an
    /// `mpsc::Sender`; on every fired cycle it reads the newest of each input plus
    /// `ctx.now()`, sends the timestamped record out of the graph, and returns
    /// `None` (pure consumer — C8). Read-back is via the channel, never `Rc`/
    /// `RefCell`, so `aura-engine/src` stays free of the interior-mutability the
    /// purity invariant (C7) forbids.
    struct Recorder {
        kinds: Vec<ScalarKind>,
        firing: Firing,
        tx: mpsc::Sender<(Timestamp, Vec<Scalar>)>,
    }
    impl Recorder {
        fn new(
            kinds: &[ScalarKind],
            firing: Firing,
            tx: mpsc::Sender<(Timestamp, Vec<Scalar>)>,
        ) -> Self {
            Self { kinds: kinds.to_vec(), firing, tx }
        }
    }
    impl Node for Recorder {
        fn schema(&self) -> NodeSchema {
            NodeSchema {
                inputs: self
                    .kinds
                    .iter()
                    .map(|&kind| InputSpec { kind, lookback: 1, firing: self.firing })
                    .collect(),
                output: vec![], // pure sink: no output port
            }
        }
        fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Scalar]> {
            let mut row = Vec::with_capacity(self.kinds.len());
            for (i, &kind) in self.kinds.iter().enumerate() {
                let v = match kind {
                    ScalarKind::I64 => {
                        let w = ctx.i64_in(i);
                        if w.is_empty() {
                            return None; // not yet warmed
                        }
                        Scalar::I64(w[0])
                    }
                    ScalarKind::F64 => {
                        let w = ctx.f64_in(i);
                        if w.is_empty() {
                            return None;
                        }
                        Scalar::F64(w[0])
                    }
                    ScalarKind::Bool => {
                        let w = ctx.bool_in(i);
                        if w.is_empty() {
                            return None;
                        }
                        Scalar::Bool(w[0])
                    }
                    ScalarKind::Timestamp => {
                        let w = ctx.ts_in(i);
                        if w.is_empty() {
                            return None;
                        }
                        Scalar::Ts(w[0])
                    }
                };
                row.push(v);
            }
            let _ = self.tx.send((ctx.now(), row)); // out-of-graph side effect
            None // records, forwards nothing
        }
    }
  • Step 2: Migrate chain_source_sma_runs

Replace harness.rs:501-522:

    #[test]
    fn chain_source_sma_runs() {
        // node 0 = SMA(3); source -> SMA(3).in0; node 1 = Recorder taps node 0.
        let (tx, rx) = mpsc::channel();
        let mut h = Harness::bootstrap(
            vec![
                Box::new(Sma::new(3)),
                Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, tx)),
            ],
            vec![SourceSpec { kind: ScalarKind::F64, targets: vec![Target { node: 0, slot: 0 }] }],
            vec![Edge { from: 0, to: 1, slot: 0, from_field: 0 }],
        )
        .expect("valid");
        h.run(vec![f64_stream(&[(1, 1.0), (2, 2.0), (3, 3.0), (4, 4.0), (5, 5.0)])]);
        let got: Vec<(Timestamp, Vec<Scalar>)> = rx.try_iter().collect();
        // SMA(3) warms at cycle 3; the recorder captures only fired cycles, each
        // tagged with the cycle's timestamp (sparse — no None hold-rows).
        assert_eq!(
            got,
            vec![
                (Timestamp(3), vec![Scalar::F64(2.0)]),
                (Timestamp(4), vec![Scalar::F64(3.0)]),
                (Timestamp(5), vec![Scalar::F64(4.0)]),
            ]
        );
    }
  • Step 3: Migrate fan_out_join_dag_runs_deterministically

Replace harness.rs:524-560:

    #[test]
    fn fan_out_join_dag_runs_deterministically() {
        // 0 = SMA(2), 1 = SMA(4), 2 = Sub; source fans into both SMAs; SMAs join
        // into Sub; node 3 = Recorder taps Sub — the 0003 baseline on the new API.
        let build = |tx| {
            Harness::bootstrap(
                vec![
                    Box::new(Sma::new(2)),
                    Box::new(Sma::new(4)),
                    Box::new(Sub::new()),
                    Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, tx)),
                ],
                vec![SourceSpec {
                    kind: ScalarKind::F64,
                    targets: vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }],
                }],
                vec![
                    Edge { from: 0, to: 2, slot: 0, from_field: 0 },
                    Edge { from: 1, to: 2, slot: 1, from_field: 0 },
                    Edge { from: 2, to: 3, slot: 0, from_field: 0 },
                ],
            )
            .expect("valid DAG")
        };
        let prices = f64_stream(&[(1, 10.0), (2, 12.0), (3, 14.0), (4, 16.0), (5, 18.0), (6, 20.0)]);

        let (tx, rx) = mpsc::channel();
        let mut h = build(tx);
        h.run(vec![prices.clone()]);
        let out: Vec<(Timestamp, Vec<Scalar>)> = rx.try_iter().collect();
        // Sub fires once SMA(4) is warm (cycle 4): 15-13, 17-15, 19-17 -> 2.
        assert_eq!(
            out,
            vec![
                (Timestamp(4), vec![Scalar::F64(2.0)]),
                (Timestamp(5), vec![Scalar::F64(2.0)]),
                (Timestamp(6), vec![Scalar::F64(2.0)]),
            ]
        );

        // determinism (C1): a second identical run drains a bit-identical stream.
        let (tx2, rx2) = mpsc::channel();
        let mut h2 = build(tx2);
        h2.run(vec![prices]);
        let out2: Vec<(Timestamp, Vec<Scalar>)> = rx2.try_iter().collect();
        assert_eq!(out2, out);
    }
  • Step 4: Migrate mode_a_as_of_fires_on_any_fresh_and_holds

Replace harness.rs:562-597:

    #[test]
    fn mode_a_as_of_fires_on_any_fresh_and_holds() {
        // AsOfSum @0; node 1 = Recorder taps it. source 0 ticks t=1..4; source 1
        // ticks t=2,4 (slower); both AsOfSum inputs Any.
        let build = |tx| {
            Harness::bootstrap(
                vec![
                    Box::new(AsOfSum { out: [Scalar::F64(0.0)] }),
                    Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, tx)),
                ],
                vec![
                    SourceSpec { kind: ScalarKind::F64, targets: vec![Target { node: 0, slot: 0 }] },
                    SourceSpec { kind: ScalarKind::F64, targets: vec![Target { node: 0, slot: 1 }] },
                ],
                vec![Edge { from: 0, to: 1, slot: 0, from_field: 0 }],
            )
            .expect("valid")
        };
        let s0 = f64_stream(&[(1, 10.0), (2, 20.0), (3, 30.0), (4, 40.0)]);
        let s1 = f64_stream(&[(2, 100.0), (4, 200.0)]);

        let (tx, rx) = mpsc::channel();
        let mut h = build(tx);
        h.run(vec![s0.clone(), s1.clone()]);
        let out: Vec<(Timestamp, Vec<Scalar>)> = rx.try_iter().collect();
        // holds s1=100 across t=3 and the t=4 s0-cycle; emits on every tick once warm.
        assert_eq!(
            out,
            vec![
                (Timestamp(2), vec![Scalar::F64(120.0)]),
                (Timestamp(3), vec![Scalar::F64(130.0)]),
                (Timestamp(4), vec![Scalar::F64(140.0)]),
                (Timestamp(4), vec![Scalar::F64(240.0)]),
            ]
        );

        let (tx2, rx2) = mpsc::channel();
        let mut h2 = build(tx2);
        h2.run(vec![s0, s1]);
        let out2: Vec<(Timestamp, Vec<Scalar>)> = rx2.try_iter().collect();
        assert_eq!(out2, out); // deterministic
    }
  • Step 5: Migrate mode_b_barrier_fires_only_on_timestamp_coincidence

Replace harness.rs:599-634:

    #[test]
    fn mode_b_barrier_fires_only_on_timestamp_coincidence() {
        // identical wiring to mode A, but both BarrierSum inputs are Barrier(0).
        let build = |tx| {
            Harness::bootstrap(
                vec![
                    Box::new(BarrierSum { out: [Scalar::F64(0.0)] }),
                    Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, tx)),
                ],
                vec![
                    SourceSpec { kind: ScalarKind::F64, targets: vec![Target { node: 0, slot: 0 }] },
                    SourceSpec { kind: ScalarKind::F64, targets: vec![Target { node: 0, slot: 1 }] },
                ],
                vec![Edge { from: 0, to: 1, slot: 0, from_field: 0 }],
            )
            .expect("valid")
        };
        let s0 = f64_stream(&[(1, 10.0), (2, 20.0), (3, 30.0), (4, 40.0)]);
        let s1 = f64_stream(&[(2, 100.0), (4, 200.0)]);

        let (tx, rx) = mpsc::channel();
        let mut h = build(tx);
        h.run(vec![s0.clone(), s1.clone()]);
        let out: Vec<(Timestamp, Vec<Scalar>)> = rx.try_iter().collect();
        // records ONLY at t=2 and t=4 where both inputs share the timestamp.
        assert_eq!(
            out,
            vec![
                (Timestamp(2), vec![Scalar::F64(120.0)]),
                (Timestamp(4), vec![Scalar::F64(240.0)]),
            ]
        );

        let (tx2, rx2) = mpsc::channel();
        let mut h2 = build(tx2);
        h2.run(vec![s0, s1]);
        let out2: Vec<(Timestamp, Vec<Scalar>)> = rx2.try_iter().collect();
        assert_eq!(out2, out); // deterministic
    }
  • Step 6: Migrate within_source_diamond_rejoin_barrier_fires

Replace harness.rs:636-677:

    #[test]
    fn within_source_diamond_rejoin_barrier_fires() {
        // One source fans out through SMA(2), SMA(4) that rejoin at a Barrier(0)
        // node; node 3 = Recorder taps the barrier. Every push in a cycle carries
        // that cycle's timestamp, so once both SMAs warm and emit in the same
        // cycle, both barrier inputs share the timestamp and the barrier fires.
        let build = |tx| {
            Harness::bootstrap(
                vec![
                    Box::new(Sma::new(2)),
                    Box::new(Sma::new(4)),
                    Box::new(BarrierSum { out: [Scalar::F64(0.0)] }),
                    Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, tx)),
                ],
                vec![SourceSpec {
                    kind: ScalarKind::F64,
                    targets: vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }],
                }],
                vec![
                    Edge { from: 0, to: 2, slot: 0, from_field: 0 },
                    Edge { from: 1, to: 2, slot: 1, from_field: 0 },
                    Edge { from: 2, to: 3, slot: 0, from_field: 0 },
                ],
            )
            .expect("valid DAG")
        };
        let prices = f64_stream(&[(1, 10.0), (2, 12.0), (3, 14.0), (4, 16.0), (5, 18.0), (6, 20.0)]);

        let (tx, rx) = mpsc::channel();
        let mut h = build(tx);
        h.run(vec![prices.clone()]);
        let out: Vec<(Timestamp, Vec<Scalar>)> = rx.try_iter().collect();
        // SMA(4) warms at cycle 4; from then both paths emit each cycle at the same
        // timestamp, so the barrier fires: SMA(2)+SMA(4) = 15+13, 17+15, 19+17.
        assert_eq!(
            out,
            vec![
                (Timestamp(4), vec![Scalar::F64(28.0)]),
                (Timestamp(5), vec![Scalar::F64(32.0)]),
                (Timestamp(6), vec![Scalar::F64(36.0)]),
            ]
        );

        let (tx2, rx2) = mpsc::channel();
        let mut h2 = build(tx2);
        h2.run(vec![prices]);
        let out2: Vec<(Timestamp, Vec<Scalar>)> = rx2.try_iter().collect();
        assert_eq!(out2, out);
    }
  • Step 7: Migrate mixed_a_and_b_or_combine_on_one_node

Replace harness.rs:679-712:

    #[test]
    fn mixed_a_and_b_or_combine_on_one_node() {
        // MixedSum @0 (in0,in1 barrier group 0; in2 as-of); node 1 = Recorder taps it.
        let (tx, rx) = mpsc::channel();
        let mut h = Harness::bootstrap(
            vec![
                Box::new(MixedSum { out: [Scalar::F64(0.0)] }),
                Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, tx)),
            ],
            vec![
                SourceSpec { kind: ScalarKind::F64, targets: vec![Target { node: 0, slot: 0 }] },
                SourceSpec { kind: ScalarKind::F64, targets: vec![Target { node: 0, slot: 1 }] },
                SourceSpec { kind: ScalarKind::F64, targets: vec![Target { node: 0, slot: 2 }] },
            ],
            vec![Edge { from: 0, to: 1, slot: 0, from_field: 0 }],
        )
        .expect("valid");
        let s0 = f64_stream(&[(2, 20.0), (5, 50.0)]); // in0 (barrier)
        let s1 = f64_stream(&[(2, 200.0)]);           // in1 (barrier)
        let s2 = f64_stream(&[(1, 1.0), (3, 3.0)]);   // in2 (as-of)

        h.run(vec![s0, s1, s2]);
        let out: Vec<(Timestamp, Vec<Scalar>)> = rx.try_iter().collect();
        // c3: barrier pair completes at t=2, holds c=1 -> 221. c4: as-of input
        // ticks at t=3, holds the pair -> 223. c1 filters; c2,c5 hold (no record).
        assert_eq!(
            out,
            vec![
                (Timestamp(2), vec![Scalar::F64(221.0)]),
                (Timestamp(3), vec![Scalar::F64(223.0)]),
            ]
        );
    }
  • Step 8: Drop the observe argument from the four unchanged bootstrap_rejects_* tests

These tests call bootstrap(...).unwrap_err() and do not run; the only change is removing the trailing 4th argument.

bootstrap_rejects_a_cycle (harness.rs:717-722) — remove the 0, at :721:

        let err = Harness::bootstrap(
            vec![Box::new(Sma::new(1)), Box::new(Sma::new(1))],
            vec![],
            vec![Edge { from: 0, to: 1, slot: 0, from_field: 0 }, Edge { from: 1, to: 0, slot: 0, from_field: 0 }],
        )
        .unwrap_err();

bootstrap_rejects_a_kind_mismatch (harness.rs:730-735) — remove the 0, at :735:

        let err = Harness::bootstrap(
            vec![Box::new(Sma::new(1))],
            vec![SourceSpec { kind: ScalarKind::I64, targets: vec![Target { node: 0, slot: 0 }] }],
            vec![],
        )
        .unwrap_err();

bootstrap_rejects_from_field_out_of_range (harness.rs:904-909) — remove the 0, at :908:

        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 }],
        )
        .unwrap_err();

bootstrap_rejects_per_field_kind_mismatch (harness.rs:919-924) — remove the 1, at :923:

        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 }],
        )
        .unwrap_err();
  • Step 9: Repurpose bootstrap_rejects_a_bad_index (the observe 5 trigger no longer exists)

Replace harness.rs:743-754:

    #[test]
    fn bootstrap_rejects_a_bad_index() {
        // an edge target node (9) that does not exist -> BadIndex. (The old trigger
        // — an out-of-range observe index — is gone with `observe`; BadIndex itself
        // is unchanged, only the path that reaches it.)
        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: 9, slot: 0, from_field: 0 }],
        )
        .unwrap_err();
        assert_eq!(err, BootstrapError::BadIndex);
    }
  • Step 10: Migrate ohlcv_bundles_five_field_record (two bars, all five fields)

Replace harness.rs:776-816:

    #[test]
    fn ohlcv_bundles_five_field_record() {
        // node 0 = Ohlcv; five sources feed O/H/L/C/V; node 1 = a 5-input Recorder
        // taps all five fields via five edges. The barrier fires once all five share
        // the timestamp, so each bar is recorded once, on the fifth cycle of its ts.
        let (tx, rx) = mpsc::channel();
        let mut h = Harness::bootstrap(
            vec![
                Box::new(Ohlcv { out: [Scalar::F64(0.0); 5] }),
                Box::new(Recorder::new(
                    &[ScalarKind::F64, ScalarKind::F64, ScalarKind::F64, ScalarKind::F64, ScalarKind::F64],
                    Firing::Any,
                    tx,
                )),
            ],
            ohlcv_sources(),
            vec![
                Edge { from: 0, to: 1, slot: 0, from_field: 0 }, // open
                Edge { from: 0, to: 1, slot: 1, from_field: 1 }, // high
                Edge { from: 0, to: 1, slot: 2, from_field: 2 }, // low
                Edge { from: 0, to: 1, slot: 3, from_field: 3 }, // close
                Edge { from: 0, to: 1, slot: 4, from_field: 4 }, // volume
            ],
        )
        .expect("valid");
        h.run(ohlcv_streams());
        let out: Vec<(Timestamp, Vec<Scalar>)> = rx.try_iter().collect();
        assert_eq!(
            out,
            vec![
                (Timestamp(1), vec![
                    Scalar::F64(10.0),
                    Scalar::F64(15.0),
                    Scalar::F64(8.0),
                    Scalar::F64(12.0),
                    Scalar::F64(100.0),
                ]),
                (Timestamp(2), vec![
                    Scalar::F64(20.0),
                    Scalar::F64(25.0),
                    Scalar::F64(19.0),
                    Scalar::F64(22.0),
                    Scalar::F64(200.0),
                ]),
            ]
        );
    }
  • Step 11: Migrate edge_binds_single_field_high_minus_low

Replace harness.rs:818-861:

    #[test]
    fn edge_binds_single_field_high_minus_low() {
        // [Ohlcv (0), Sub (1), Recorder (2)]; Sub binds high (field 1) and low
        // (field 2) of the Ohlcv record -> high - low; the Recorder taps Sub.
        // Proves from_field routes the right columns (not field 0) and the two
        // bound fields are co-fresh (Sub's Any inputs both fire in the bar's cycle).
        let build = |tx| {
            Harness::bootstrap(
                vec![
                    Box::new(Ohlcv { out: [Scalar::F64(0.0); 5] }),
                    Box::new(Sub::new()),
                    Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, tx)),
                ],
                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
                    Edge { from: 1, to: 2, slot: 0, from_field: 0 }, // Sub -> Recorder
                ],
            )
            .expect("valid DAG")
        };
        let (tx, rx) = mpsc::channel();
        let mut h = build(tx);
        h.run(ohlcv_streams());
        let out: Vec<(Timestamp, Vec<Scalar>)> = rx.try_iter().collect();
        // bar1: 15 - 8 = 7; bar2: 25 - 19 = 6 (each on the bar's fifth cycle).
        assert_eq!(
            out,
            vec![
                (Timestamp(1), vec![Scalar::F64(7.0)]),
                (Timestamp(2), vec![Scalar::F64(6.0)]),
            ]
        );

        let (tx2, rx2) = mpsc::channel();
        let mut h2 = build(tx2);
        h2.run(ohlcv_streams());
        let out2: Vec<(Timestamp, Vec<Scalar>)> = rx2.try_iter().collect();
        assert_eq!(out2, out);
    }
  • Step 12: Migrate distinct_edges_read_distinct_fields

Replace harness.rs:863-898:

    #[test]
    fn distinct_edges_read_distinct_fields() {
        // Same Ohlcv, a different consumer: Sub binds close (field 3) and open
        // (field 0) -> close - open; the Recorder taps Sub. Proves two edges on one
        // record read two different fields (3 and 0, not the high/low pair above).
        let (tx, rx) = mpsc::channel();
        let mut h = Harness::bootstrap(
            vec![
                Box::new(Ohlcv { out: [Scalar::F64(0.0); 5] }),
                Box::new(Sub::new()),
                Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, tx)),
            ],
            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
                Edge { from: 1, to: 2, slot: 0, from_field: 0 }, // Sub -> Recorder
            ],
        )
        .expect("valid DAG");
        h.run(ohlcv_streams());
        let out: Vec<(Timestamp, Vec<Scalar>)> = rx.try_iter().collect();
        // bar1: 12 - 10 = 2; bar2: 22 - 20 = 2.
        assert_eq!(
            out,
            vec![
                (Timestamp(1), vec![Scalar::F64(2.0)]),
                (Timestamp(2), vec![Scalar::F64(2.0)]),
            ]
        );
    }
  • Step 13: Verify the migrated suite compiles and is green

Run: cargo test -p aura-engine Expected: PASS — test result: ok. 14 passed; 0 failed (the 14 pre-existing tests, now on the recording API; behaviour preserved). This confirms the test module compiles again (every bootstrap/run call site migrated).


Task 5: new proof tests for node-recording (aura-engine tests)

Files:

  • Modify: crates/aura-engine/src/harness.rs (#[cfg(test)] mod tests only)

These are the #2 deliverable. They are additive — they compile against the now-migrated API and the Recorder fixture from Task 4.

  • Step 1: Add the TapForward fixture (producer-and-sink in one node)

Add after the Recorder fixture (before the first #[test]):

    /// A node that records AND forwards: it sends `(now, value)` out of the graph
    /// (sink side effect) and returns its value as a one-field output the engine
    /// forwards downstream (producer). Proves the C8 "both" role.
    struct TapForward {
        out: [Scalar; 1],
        tx: mpsc::Sender<(Timestamp, Vec<Scalar>)>,
    }
    impl Node for TapForward {
        fn schema(&self) -> NodeSchema {
            NodeSchema {
                inputs: vec![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 w = ctx.f64_in(0);
            if w.is_empty() {
                return None;
            }
            let v = w[0];
            let _ = self.tx.send((ctx.now(), vec![Scalar::F64(v)])); // sink side effect
            self.out[0] = Scalar::F64(v);
            Some(&self.out) // producer output: engine forwards it
        }
    }
  • Step 2: Add multi_sink_records_distinct_interior_streams (the headline)

Append at the end of #[cfg(test)] mod tests (before the module's closing }):

    #[test]
    fn multi_sink_records_distinct_interior_streams() {
        // Two recorders tap SMA(2) and SMA(4) in ONE run -> one run records many
        // streams (the #2 headline). Each drained stream is individually correct.
        let (tx_fast, rx_fast) = mpsc::channel();
        let (tx_slow, rx_slow) = mpsc::channel();
        let mut h = Harness::bootstrap(
            vec![
                Box::new(Sma::new(2)),
                Box::new(Sma::new(4)),
                Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, tx_fast)),
                Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, tx_slow)),
            ],
            vec![SourceSpec {
                kind: ScalarKind::F64,
                targets: vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }],
            }],
            vec![
                Edge { from: 0, to: 2, slot: 0, from_field: 0 }, // SMA(2) -> recorder fast
                Edge { from: 1, to: 3, slot: 0, from_field: 0 }, // SMA(4) -> recorder slow
            ],
        )
        .expect("valid DAG");
        h.run(vec![f64_stream(&[(1, 10.0), (2, 12.0), (3, 14.0), (4, 16.0), (5, 18.0)])]);
        let fast: Vec<(Timestamp, Vec<Scalar>)> = rx_fast.try_iter().collect();
        let slow: Vec<(Timestamp, Vec<Scalar>)> = rx_slow.try_iter().collect();
        // SMA(2) warms at cycle 2, SMA(4) at cycle 4 — two different-rate streams.
        assert_eq!(
            fast,
            vec![
                (Timestamp(2), vec![Scalar::F64(11.0)]),
                (Timestamp(3), vec![Scalar::F64(13.0)]),
                (Timestamp(4), vec![Scalar::F64(15.0)]),
                (Timestamp(5), vec![Scalar::F64(17.0)]),
            ]
        );
        assert_eq!(
            slow,
            vec![
                (Timestamp(4), vec![Scalar::F64(13.0)]),
                (Timestamp(5), vec![Scalar::F64(15.0)]),
            ]
        );
    }
  • Step 3: Add recorder_taps_all_fields_of_a_record (one bar, five edges)

Append:

    #[test]
    fn recorder_taps_all_fields_of_a_record() {
        // A 5-input Recorder taps all five OHLCV fields via five field-wise edges
        // (0005: N edges, no whole-record bind); its recorded row is the whole bar.
        let (tx, rx) = mpsc::channel();
        let mut h = Harness::bootstrap(
            vec![
                Box::new(Ohlcv { out: [Scalar::F64(0.0); 5] }),
                Box::new(Recorder::new(
                    &[ScalarKind::F64, ScalarKind::F64, ScalarKind::F64, ScalarKind::F64, ScalarKind::F64],
                    Firing::Any,
                    tx,
                )),
            ],
            ohlcv_sources(),
            vec![
                Edge { from: 0, to: 1, slot: 0, from_field: 0 },
                Edge { from: 0, to: 1, slot: 1, from_field: 1 },
                Edge { from: 0, to: 1, slot: 2, from_field: 2 },
                Edge { from: 0, to: 1, slot: 3, from_field: 3 },
                Edge { from: 0, to: 1, slot: 4, from_field: 4 },
            ],
        )
        .expect("valid");
        h.run(vec![
            f64_stream(&[(1, 10.0)]),
            f64_stream(&[(1, 15.0)]),
            f64_stream(&[(1, 8.0)]),
            f64_stream(&[(1, 12.0)]),
            f64_stream(&[(1, 100.0)]),
        ]);
        let out: Vec<(Timestamp, Vec<Scalar>)> = rx.try_iter().collect();
        assert_eq!(out.len(), 1);
        assert_eq!(out[0].1.len(), 5); // all five fields recorded as one row
        assert_eq!(
            out,
            vec![(Timestamp(1), vec![
                Scalar::F64(10.0),
                Scalar::F64(15.0),
                Scalar::F64(8.0),
                Scalar::F64(12.0),
                Scalar::F64(100.0),
            ])]
        );
    }
  • Step 4: Add recorder_records_mixed_scalar_kinds

Append:

    #[test]
    fn recorder_records_mixed_scalar_kinds() {
        // A recorder with i64 + f64 + bool + timestamp inputs records a four-field
        // mixed-kind row -> recording is not f64-only. Four sources tick once each
        // at t=1,2,3,4; only on cycle 4 are all slots warm, so it records once,
        // holding the earlier-ticked values.
        let (tx, rx) = mpsc::channel();
        let mut h = Harness::bootstrap(
            vec![Box::new(Recorder::new(
                &[ScalarKind::I64, ScalarKind::F64, ScalarKind::Bool, ScalarKind::Timestamp],
                Firing::Any,
                tx,
            ))],
            vec![
                SourceSpec { kind: ScalarKind::I64, targets: vec![Target { node: 0, slot: 0 }] },
                SourceSpec { kind: ScalarKind::F64, targets: vec![Target { node: 0, slot: 1 }] },
                SourceSpec { kind: ScalarKind::Bool, targets: vec![Target { node: 0, slot: 2 }] },
                SourceSpec { kind: ScalarKind::Timestamp, targets: vec![Target { node: 0, slot: 3 }] },
            ],
            vec![],
        )
        .expect("valid");
        h.run(vec![
            vec![(Timestamp(1), Scalar::I64(7))],
            vec![(Timestamp(2), Scalar::F64(1.5))],
            vec![(Timestamp(3), Scalar::Bool(true))],
            vec![(Timestamp(4), Scalar::Ts(Timestamp(99)))],
        ]);
        let out: Vec<(Timestamp, Vec<Scalar>)> = rx.try_iter().collect();
        assert_eq!(
            out,
            vec![(Timestamp(4), vec![
                Scalar::I64(7),
                Scalar::F64(1.5),
                Scalar::Bool(true),
                Scalar::Ts(Timestamp(99)),
            ])]
        );
    }
  • Step 5: Add node_is_producer_and_sink_at_once

Append:

    #[test]
    fn node_is_producer_and_sink_at_once() {
        // TapForward records its input AND forwards it downstream; a second
        // Recorder taps the forwarded output. Both channels see the same stream ->
        // one node is producer and sink at once (C8 "both").
        let (tx_tap, rx_tap) = mpsc::channel();
        let (tx_down, rx_down) = mpsc::channel();
        let mut h = Harness::bootstrap(
            vec![
                Box::new(TapForward { out: [Scalar::F64(0.0)], tx: tx_tap }),
                Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, tx_down)),
            ],
            vec![SourceSpec { kind: ScalarKind::F64, targets: vec![Target { node: 0, slot: 0 }] }],
            vec![Edge { from: 0, to: 1, slot: 0, from_field: 0 }],
        )
        .expect("valid");
        h.run(vec![f64_stream(&[(1, 10.0), (2, 20.0), (3, 30.0)])]);
        let tapped: Vec<(Timestamp, Vec<Scalar>)> = rx_tap.try_iter().collect();
        let downstream: Vec<(Timestamp, Vec<Scalar>)> = rx_down.try_iter().collect();
        let expected = vec![
            (Timestamp(1), vec![Scalar::F64(10.0)]),
            (Timestamp(2), vec![Scalar::F64(20.0)]),
            (Timestamp(3), vec![Scalar::F64(30.0)]),
        ];
        assert_eq!(tapped, expected); // it recorded (sink side effect)
        assert_eq!(downstream, expected); // and forwarded (producer output)
    }
  • Step 6: Add recording_is_deterministic

Append:

    #[test]
    fn recording_is_deterministic() {
        // Two fresh harnesses, two channels, identical input -> bit-identical
        // recorded streams (C1).
        let build = |tx| {
            Harness::bootstrap(
                vec![
                    Box::new(Sma::new(3)),
                    Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, tx)),
                ],
                vec![SourceSpec { kind: ScalarKind::F64, targets: vec![Target { node: 0, slot: 0 }] }],
                vec![Edge { from: 0, to: 1, slot: 0, from_field: 0 }],
            )
            .expect("valid")
        };
        let prices = f64_stream(&[(1, 1.0), (2, 2.0), (3, 3.0), (4, 4.0), (5, 5.0)]);

        let (tx_a, rx_a) = mpsc::channel();
        let mut a = build(tx_a);
        a.run(vec![prices.clone()]);
        let run_a: Vec<(Timestamp, Vec<Scalar>)> = rx_a.try_iter().collect();

        let (tx_b, rx_b) = mpsc::channel();
        let mut b = build(tx_b);
        b.run(vec![prices]);
        let run_b: Vec<(Timestamp, Vec<Scalar>)> = rx_b.try_iter().collect();

        assert_eq!(run_a, run_b);
        assert!(!run_a.is_empty()); // and it actually recorded something
    }
  • Step 7: Add the two recorder firing-mode tests

Append:

    #[test]
    fn recorder_barrier_firing_records_only_on_coincidence() {
        // A 2-input Barrier(0) recorder records only on cycles where both inputs
        // share the timestamp — the recorder's OWN firing policy gates recording.
        let (tx, rx) = mpsc::channel();
        let mut h = Harness::bootstrap(
            vec![Box::new(Recorder::new(
                &[ScalarKind::F64, ScalarKind::F64],
                Firing::Barrier(0),
                tx,
            ))],
            vec![
                SourceSpec { kind: ScalarKind::F64, targets: vec![Target { node: 0, slot: 0 }] },
                SourceSpec { kind: ScalarKind::F64, targets: vec![Target { node: 0, slot: 1 }] },
            ],
            vec![],
        )
        .expect("valid");
        let s0 = f64_stream(&[(1, 10.0), (2, 20.0), (3, 30.0), (4, 40.0)]);
        let s1 = f64_stream(&[(2, 100.0), (4, 200.0)]);
        h.run(vec![s0, s1]);
        let out: Vec<(Timestamp, Vec<Scalar>)> = rx.try_iter().collect();
        // records ONLY at t=2 and t=4 (both inputs coincide); holds otherwise.
        assert_eq!(
            out,
            vec![
                (Timestamp(2), vec![Scalar::F64(20.0), Scalar::F64(100.0)]),
                (Timestamp(4), vec![Scalar::F64(40.0), Scalar::F64(200.0)]),
            ]
        );
    }

    #[test]
    fn recorder_any_firing_records_on_each_fresh() {
        // A 2-input Any recorder records on any-fresh once both are warm (as-of),
        // holding the stale input.
        let (tx, rx) = mpsc::channel();
        let mut h = Harness::bootstrap(
            vec![Box::new(Recorder::new(
                &[ScalarKind::F64, ScalarKind::F64],
                Firing::Any,
                tx,
            ))],
            vec![
                SourceSpec { kind: ScalarKind::F64, targets: vec![Target { node: 0, slot: 0 }] },
                SourceSpec { kind: ScalarKind::F64, targets: vec![Target { node: 0, slot: 1 }] },
            ],
            vec![],
        )
        .expect("valid");
        let s0 = f64_stream(&[(1, 10.0), (2, 20.0), (3, 30.0), (4, 40.0)]);
        let s1 = f64_stream(&[(2, 100.0), (4, 200.0)]);
        h.run(vec![s0, s1]);
        let out: Vec<(Timestamp, Vec<Scalar>)> = rx.try_iter().collect();
        // from t=2 on, records every cycle holding the stale input; two cycles fall
        // on t=4 (the s0 tick then the s1 tick).
        assert_eq!(
            out,
            vec![
                (Timestamp(2), vec![Scalar::F64(20.0), Scalar::F64(100.0)]),
                (Timestamp(3), vec![Scalar::F64(30.0), Scalar::F64(100.0)]),
                (Timestamp(4), vec![Scalar::F64(40.0), Scalar::F64(100.0)]),
                (Timestamp(4), vec![Scalar::F64(40.0), Scalar::F64(200.0)]),
            ]
        );
    }
  • Step 8: Add bootstrap_rejects_kind_mismatched_recorder_edge

Append:

    #[test]
    fn bootstrap_rejects_kind_mismatched_recorder_edge() {
        // TwoField output: field 0 f64, field 1 i64. Binding field 1 (i64) into a
        // Recorder's f64 input slot is a per-field kind mismatch -> KindMismatch
        // (0005's check already covers recorder edges; recording adds no new hole).
        let (tx, _rx) = mpsc::channel();
        let err = Harness::bootstrap(
            vec![
                Box::new(TwoField { out: [Scalar::F64(0.0), Scalar::I64(0)] }),
                Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, tx)),
            ],
            vec![SourceSpec { kind: ScalarKind::F64, targets: vec![Target { node: 0, slot: 0 }] }],
            vec![Edge { from: 0, to: 1, slot: 0, from_field: 1 }],
        )
        .unwrap_err();
        assert_eq!(
            err,
            BootstrapError::KindMismatch { producer: ScalarKind::I64, consumer: ScalarKind::F64 }
        );
    }
  • Step 9: Verify the full engine suite is green

Run: cargo test -p aura-engine Expected: PASS — test result: ok. 22 passed; 0 failed (14 migrated + 8 new).


Task 6: ledger realization notes + final ship gate

Files:

  • Modify: docs/design/INDEX.md

  • Step 1: Append the C8 cycle-0006 realization note

After the existing **Realization (cycle 0005).** paragraph in C8 (ends at INDEX.md:210), insert:

**Realization (cycle 0006).** The pure-consumer (sink) half of this contract is
now realized at the substrate: **recording is a node role, not a type.** A
recording node reads its typed input windows + `ctx.now()` in `eval` and pushes
the record to a destination it holds as a field (a channel, a chart handle) — an
**out-of-graph side effect**. There is no `Sink` type, trait, or engine flag: a
node that only records returns `None` (pure consumer), and a node may record
**and** return an output the engine forwards in the same `eval` (the "both"
case). In-graph routing stays engine-owned data (the edge table); the escape out
of the graph is the node's own side effect — and that boundary is the
determinism / graph-as-data boundary (C1/C7).
  • Step 2: Append the C22 cycle-0006 realization note

After the C22 **Why.** paragraph (ends at INDEX.md:495, before the --- at :497), insert:

**Realization (cycle 0006).** Sinks-as-recording-mechanism is realized at the
substrate level: a recorded trace is exactly what a recording node pushed out of
the graph (no engine recording registry; the constructing World holds each
recording node's destination). The engine's single `observe: usize` affordance is
removed — `Harness::run` returns `()` and recording is a node-side concern, so one
run records *many* streams (one per recording node) instead of exactly one row.
Recorded streams are sparse and timestamped (a record per fired cycle, tagged
`ctx.now()`), matching a trace of timestamped events (C18). No new contract; the
`Harness` API change (observe removed, `run -> ()`) is recorded here.
  • Step 3: Final ship gate — full suite, clippy, purity grep

Run: cargo test --workspace Expected: PASS — 0 failed across all crates (aura-core 20, aura-std 3, aura-engine 22).

Run: cargo clippy --workspace --all-targets -- -D warnings Expected: PASS — Finished with no warnings (compiles every test target too).

Run: git grep -nE 'dyn Any|Rc<|RefCell' crates/aura-engine/src; echo "exit=$status" Expected: no matching lines — the purity grep finds nothing in engine source (the Recorder read-back uses mpsc, not interior mutability). Output is just exit=1 (fish: grep's no-match exit code), with no preceding match lines.

Run: git grep -nE '\bobserve\b' crates/aura-engine/src Expected: no matches that name the removed field — the only acceptable residue is none (the module-doc "observer push" phrase at harness.rs:6 uses "observer", not "observe", and is RustAst-contrast prose left intact). If any observe field reference remains, it is a missed deletion from Task 3.