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

1375 lines
53 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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-19``Ctx` 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`):
```rust
/// 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`):
```rust
let ctx = Ctx::new(&inputs, Timestamp(0));
```
- `ctx.rs:87` (in `ctx_addresses_multiple_inputs`):
```rust
let ctx = Ctx::new(&inputs, Timestamp(0));
```
- `ctx.rs:97` (in `ctx_panics_on_kind_mismatch`):
```rust
let ctx = Ctx::new(&inputs, Timestamp(0));
```
The test-module import (`ctx.rs:64`, `use crate::{Scalar, ScalarKind};`) gains
`Timestamp`:
```rust
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`):
```rust
#[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;`):
```rust
use aura_core::{AnyColumn, Timestamp};
```
`sma.rs:70` (in `sma_warms_up_then_tracks_the_window_mean`):
```rust
let got = sma.eval(Ctx::new(&inputs, Timestamp(0)));
```
`sma.rs:84` (in `sma_length_one_is_identity`):
```rust
assert_eq!(sma.eval(Ctx::new(&inputs, Timestamp(0))), Some([Scalar::F64(7.0)].as_slice()));
```
`sma.rs:87` (same test):
```rust
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;`):
```rust
use aura_core::{AnyColumn, Timestamp};
```
`sub.rs:64` (in `sub_is_difference_once_both_inputs_present`):
```rust
assert_eq!(sub.eval(Ctx::new(&inputs, Timestamp(0))), None);
```
`sub.rs:68` (same test):
```rust
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):
```rust
//! (the cycle-0002 shape), so `Ctx` is unchanged. A node's `eval` returns a
```
Replace that one line with:
```rust
//! (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:
```rust
/// 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`:
```rust
/// 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:
```rust
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-124``bootstrap` loses its fourth parameter and the
`observe >= n` check:
```rust
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`:
```rust
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.
```rust
/// 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`):
```rust
/// 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`:
```rust
#[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`:
```rust
#[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`:
```rust
#[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`:
```rust
#[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`:
```rust
#[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`:
```rust
#[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`:
```rust
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`:
```rust
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`:
```rust
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`:
```rust
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`:
```rust
#[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`:
```rust
#[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`:
```rust
#[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`:
```rust
#[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]`):
```rust
/// 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 `}`):
```rust
#[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:
```rust
#[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:
```rust
#[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:
```rust
#[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:
```rust
#[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:
```rust
#[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:
```rust
#[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:
```markdown
**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:
```markdown
**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.