diff --git a/crates/aura-engine/src/harness.rs b/crates/aura-engine/src/harness.rs index 98a60eb..e2b5814 100644 --- a/crates/aura-engine/src/harness.rs +++ b/crates/aura-engine/src/harness.rs @@ -547,6 +547,49 @@ mod tests { assert_eq!(h2.run(vec![s0, s1]), out); // deterministic } + #[test] + fn within_source_diamond_rejoin_barrier_fires() { + // One source fans out through two unequal-latency producers (SMA(2), + // SMA(4)) that rejoin at a Barrier(0) node. Every push in a cycle carries + // that cycle's timestamp, so once both SMAs are warm and emit in the same + // cycle, both barrier inputs share the timestamp and the barrier fires — + // the within-source diamond rejoin the timestamp token handles (C6), + // distinct from the multi-source barrier above. + let build = || { + Harness::bootstrap( + vec![Box::new(Sma::new(2)), Box::new(Sma::new(4)), Box::new(BarrierSum)], + 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 }, Edge { from: 1, to: 2, slot: 1 }], + 2, + ) + .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 mut h = build(); + let out = h.run(vec![prices.clone()]); + // 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![ + None, + None, + None, + Some(Scalar::F64(28.0)), + Some(Scalar::F64(32.0)), + Some(Scalar::F64(36.0)), + ] + ); + + // determinism (C1): a second identical run is bit-identical + let mut h2 = build(); + assert_eq!(h2.run(vec![prices]), out); + } + #[test] fn mixed_a_and_b_or_combine_on_one_node() { // in0,in1 = barrier group 0 (sources 0,1); in2 = as-of (source 2). diff --git a/docs/design/INDEX.md b/docs/design/INDEX.md index 214ffdb..b0775e2 100644 --- a/docs/design/INDEX.md +++ b/docs/design/INDEX.md @@ -154,6 +154,16 @@ A single node may mix an A input and a B group. **Forbids.** A single global firing mode; forcing per-node-only granularity. **Why.** Both are genuinely needed; RustAst implemented only B. Per-input-group granularity is required by the OHLC-plus-bias case where one node needs both. +**Realization (cycle 0004).** Firing is tagged per input — `Firing::{Any, +Barrier(u8)}` on `InputSpec` — and inputs sharing a `Barrier` id form a group; a +mode-A input is its own trivial group, so "per input group" and the per-input tag +coincide. The barrier's synchronization token is the cycle **timestamp**, not the +`cycle_id`: under C4 four same-timestamp sources are four distinct cycles, so +RustAst's `cycle_id`-equality barrier could never fire across them. A group fires +when every member's last push carries the current cycle's timestamp, guarded by +"≥1 member fresh this cycle" (so a group completed earlier does not re-fire). This +fires both the multi-source bar and the within-source diamond rejoin (every push +in a cycle carries that cycle's timestamp). ### C7 — Four scalar base types, streamed as SoA **Guarantee.** Only `i64`, `f64`, `bool`, `timestamp` (newtype over i64, diff --git a/docs/glossary.md b/docs/glossary.md index 5b491ca..3718a85 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -133,7 +133,7 @@ The aura-native, per-project store of one record per run — a manifest + metric ### run-count **Avoid:** total_count -The per-series counter used to detect whether an input is fresh this cycle; it drives freshness-gated recompute. ("total_count" is RustAst's name for the same counter.) +The per-series push counter on every `Column` — bumped on each push, never moved by a read; the node-visible freshness primitive. The engine's per-cycle firing gate (C5/C6) reads a per-wiring-slot cycle epoch (`fresh_at == cycle_id`, the freshness epoch C4's cycle_id materializes as) rather than this counter, which remains the column-level count a node may read for its own logic. ("total_count" is RustAst's name for the same counter.) ### scalar base types **Avoid:** —