feat(engine): firing policies A/B + the k-way ingestion merge

Cycle 0004: the reactive-semantics layer. The engine now merges multiple
timestamped sources into one chronological cycle stream (C3) and gates each
node's re-evaluation by a per-input firing policy (C5/C6) — both rails shipping
together, as the user required ("must sit right from the start").

- `Firing` (aura-core) — a per-input enum on `InputSpec`, where C8 places firing:
  `Any` (mode A, fire-on-any-fresh + hold / as-of join) and `Barrier(u8)` (mode B,
  all-fresh barrier / synchronizing join). Default-free; Sma/Sub declare
  `Firing::Any`, so under a single source ticking every cycle their behavior is
  unchanged (backward compatible — the 0003 fan-out/join DAG still emits
  [None,None,None,2,2,2]).
- `SourceSpec` + the k-way merge (aura-engine) — `run` takes one
  `(Timestamp, Scalar)` stream per source and merges them by (timestamp, source
  index) (C4 ties by declaration order). One record = one cycle. This is the
  permanent ingestion core; the Source trait + data-server IO (C3/C11) are a
  later orthogonal cycle (the user scoped this cycle to the reactive semantics).
- Two read clocks, both load-bearing (resolves the 0003 audit's "0004 owns
  cycle_id" with no write-only field): each push stamps the input slot with
  `fresh_at` (the cycle_id — freshness epoch, C5: fresh iff fresh_at == cycle_id)
  and `last_ts` (the data timestamp — barrier token, C6: a barrier group is
  complete iff all members carry last_ts == T). The `fires()` predicate ORs the
  groups; the ">=1 member fresh this cycle" clause is the once-per-timestamp
  guard. Sample-and-hold falls out of the push model: a non-firing node pushes
  nothing, so consumers keep seeing the held value via window[0].

Key design call, studied against RustAst (src/ast/rtl/streams/): the barrier
token is the data *timestamp*, not cycle_id. RustAst's
last_cycle_per_input.all(== cycle_id) synchronizes one source's fan-out but
cannot synchronize independent sources — under C4 four same-timestamp sources
are four different cycle_ids, so the cycle_id barrier never fires for C6's own
OHLC example. Substituting timestamp is the minimal faithful generalization
(fires both the diamond rejoin and the multi-source bar). Mode A and the k-way
merge are both new (RustAst had neither); RustAst's total_count push counter is
aura's already-built Column::run_count.

Both rails proven on identical sources, firing-only difference:
- mode A (AsOfSum): [None,None,120,130,140,240] — holds the slow input;
- mode B (BarrierSum): [None,None,120,None,None,240] — waits for timestamp
  coincidence;
- mixed A+B (MixedSum): the OR-combine fires both when the barrier pair completes
  (holding the as-of input) and when the as-of input ticks (holding the pair).
Plus determinism (C1, bit-identical re-run of each rail) and the three bootstrap
rejections re-expressed on SourceSpec.

Gates green (re-run by the orchestrator, not just the agent): cargo build/test
(30: aura-core 19 + aura-std 3 + aura-engine 8) / clippy --workspace
--all-targets -D warnings clean; surface-purity grep (no dyn-Any / Rc / RefCell)
clean — the harness doc phrases RustAst's model without the literal tokens to
avoid self-match.

refs walking-skeleton

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-03 14:21:42 +02:00
parent 10a40127b6
commit f37b2a35d3
6 changed files with 447 additions and 112 deletions
+6 -2
View File
@@ -3,7 +3,7 @@
//! `Node` contract is authorable from a downstream crate and evaluable with no
//! engine present (the test drives it by hand, as the sim loop later will).
use aura_core::{Ctx, InputSpec, Node, NodeSchema, Scalar, ScalarKind};
use aura_core::{Ctx, Firing, InputSpec, Node, NodeSchema, Scalar, ScalarKind};
/// Simple moving average over the last `length` values of one f64 input.
pub struct Sma {
@@ -21,7 +21,11 @@ impl Sma {
impl Node for Sma {
fn schema(&self) -> NodeSchema {
NodeSchema {
inputs: vec![InputSpec { kind: ScalarKind::F64, lookback: self.length }],
inputs: vec![InputSpec {
kind: ScalarKind::F64,
lookback: self.length,
firing: Firing::Any,
}],
output: ScalarKind::F64,
}
}
+3 -3
View File
@@ -3,7 +3,7 @@
//! real fan-out + join to run (two SMAs joining into one node), exercising
//! multi-input `Ctx` access inside a running graph.
use aura_core::{Ctx, InputSpec, Node, NodeSchema, Scalar, ScalarKind};
use aura_core::{Ctx, Firing, InputSpec, Node, NodeSchema, Scalar, ScalarKind};
/// Two-input f64 difference: input 0 minus input 1. Emits `None` until both
/// inputs have a value.
@@ -21,8 +21,8 @@ impl Node for Sub {
fn schema(&self) -> NodeSchema {
NodeSchema {
inputs: vec![
InputSpec { kind: ScalarKind::F64, lookback: 1 },
InputSpec { kind: ScalarKind::F64, lookback: 1 },
InputSpec { kind: ScalarKind::F64, lookback: 1, firing: Firing::Any },
InputSpec { kind: ScalarKind::F64, lookback: 1, firing: Firing::Any },
],
output: ScalarKind::F64,
}