From b8ba324a41b1f4585f879016c9b3ebf95beedb3b Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 21 Jul 2026 22:51:10 +0200 Subject: [PATCH] =?UTF-8?q?feat(aura-core,=20aura-engine):=20Node::initial?= =?UTF-8?q?ize=20=E2=80=94=20the=20start-of-stream=20mirror=20of=20finaliz?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the second lifecycle hook the #283 design revision pairs with finalize: Node::initialize (default no-op) runs once per node, in topological order, before the first source value — inside the deterministic sequence, no within-sim concurrency (C1). Consumers acquire run resources there (the record consumer opens its streaming writer); the hook is infallible by signature, so an acquiring node stores its error, degrades to inert, and surfaces the failure once, terminally, at finalize. Every existing node keeps the default. Pinned by the mirror test run_initializes_every_node_once_before_the_stream (init0,init1 strictly before any eval, exactly once), the shape of the existing finalize test. RED was observed by temporarily reverting the flush (test fails with evals-before-inits), then re-applied. Ledger note for cycle close: C8's lifecycle section gains initialize as finalize's mirror. refs #283 --- crates/aura-core/src/node.rs | 9 ++++++ crates/aura-engine/src/harness.rs | 54 +++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+) diff --git a/crates/aura-core/src/node.rs b/crates/aura-core/src/node.rs index 520712e..b04f0a8 100644 --- a/crates/aura-core/src/node.rs +++ b/crates/aura-core/src/node.rs @@ -379,6 +379,15 @@ pub trait Node { "node".to_string() } + /// Start-of-stream hook: `Harness::run` calls it once per node, in + /// topological order, before the first source value — the mirror of + /// [`Node::finalize`]. A consumer overrides it to acquire run resources + /// (e.g. the file a recording sink streams into). Infallible by + /// signature: an implementation that can fail stores its error, degrades + /// to inert, and surfaces the failure once, terminally, at `finalize`. + /// The default is a no-op, so existing nodes are unaffected. + fn initialize(&mut self) {} + /// End-of-stream hook: `Harness::run` calls it once per node, in topological /// order, after the source loop drains. A folding sink overrides it to flush /// its accumulated summary; the default is a no-op, so existing nodes are diff --git a/crates/aura-engine/src/harness.rs b/crates/aura-engine/src/harness.rs index 5785ca4..74cdc88 100644 --- a/crates/aura-engine/src/harness.rs +++ b/crates/aura-engine/src/harness.rs @@ -519,6 +519,14 @@ impl Harness { let mut cycle_id: u64 = 0; let mut scratch: Vec = Vec::new(); + // start-of-stream: initialize every node once, in topological order, + // before the first source value (the mirror of the end-of-stream + // finalize flush below). Runs inside the deterministic sequence — no + // within-sim concurrency (C1). + for &nidx in topo.iter() { + nodes[nidx].node.initialize(); + } + loop { // pick the live source head with the smallest (timestamp, source index). // strictly-`<` replace + source-order scan preserves C4 tie-breaking. @@ -2809,6 +2817,52 @@ mod tests { assert_eq!(fired, vec![0, 1]); } + struct InitProbe { + id: usize, + tx: mpsc::Sender, + evaled: bool, + } + impl Node for InitProbe { + fn lookbacks(&self) -> Vec { + vec![1] + } + fn initialize(&mut self) { + let _ = self.tx.send(format!("init{}", self.id)); + } + fn eval(&mut self, _ctx: Ctx<'_>) -> Option<&[Cell]> { + if !self.evaled { + self.evaled = true; + let _ = self.tx.send(format!("eval{}", self.id)); + } + None + } + } + + #[test] + fn run_initializes_every_node_once_before_the_stream() { + let (tx, rx) = mpsc::channel(); + // two sink probes, both fed by the single source; no inter-node edges. + let mut h = boot( + vec![ + Box::new(InitProbe { id: 0, tx: tx.clone(), evaled: false }), + Box::new(InitProbe { id: 1, tx, evaled: false }), + ], + vec![ + recorder_sig(&[ScalarKind::F64], Firing::Any), + recorder_sig(&[ScalarKind::F64], Firing::Any), + ], + vec![SourceSpec::raw(ScalarKind::F64, vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }])], + vec![], + ) + .expect("valid"); + // nothing initialized before the run. + assert!(rx.try_recv().is_err()); + h.run(vec![Box::new(VecSource::new(f64_stream(&[(1, 1.0), (2, 2.0)])))]); + // both inits fire, in topo order, strictly before any eval. + let fired: Vec = rx.try_iter().collect(); + assert_eq!(fired, vec!["init0", "init1", "eval0", "eval1"]); + } + #[test] fn bind_tap_attaches_a_caller_built_sink_and_records_the_tapped_series() { // A tap on a producer; bind_tap attaches a caller-built Recorder; the run