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
+35 -5
View File
@@ -1,16 +1,32 @@
//! The node contract (C8): the interface every node implements. A node declares
//! its inputs and output kind via `schema`, and computes one cycle's output via
//! `eval`. Firing policy (C6) and tunable params (C12/C19) are deliberately not
//! part of the schema yet — see spec 0002's "Out of scope".
//! its inputs (each with its scalar kind, lookback depth, and firing policy, C6)
//! and its output kind via `schema`, and computes one cycle's output via `eval`.
//! Tunable params (C12/C19) are deliberately not part of the schema yet — see
//! spec 0002's "Out of scope".
use crate::{Ctx, Scalar, ScalarKind};
/// One declared input of a node: its scalar kind and the lookback depth the
/// engine must pre-size for it (must be >= 1).
/// The firing policy of one input (C6): how the engine decides whether a node
/// re-evaluates this cycle. A node fires when *any* of its input groups fires
/// (OR over groups).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Firing {
/// Mode A — fire-on-any-fresh + hold (as-of join): fires the node whenever
/// this input is fresh this cycle; a stale input contributes its held value.
Any,
/// Mode B — all-fresh barrier (synchronizing join): this input is a member of
/// barrier group `N`; the group fires the node only when every member shares
/// the current cycle's timestamp.
Barrier(u8),
}
/// One declared input of a node: its scalar kind, the lookback depth the engine
/// must pre-size for it (must be >= 1), and its firing policy (C6).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct InputSpec {
pub kind: ScalarKind,
pub lookback: usize,
pub firing: Firing,
}
/// A node's declared interface: its inputs (in order) and its single output
@@ -29,3 +45,17 @@ pub trait Node {
fn schema(&self) -> NodeSchema;
fn eval(&mut self, ctx: Ctx<'_>) -> Option<Scalar>;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn input_spec_carries_firing() {
let a = InputSpec { kind: ScalarKind::F64, lookback: 1, firing: Firing::Any };
let b = InputSpec { kind: ScalarKind::F64, lookback: 2, firing: Firing::Barrier(0) };
assert_eq!(a.firing, Firing::Any);
assert_eq!(b.firing, Firing::Barrier(0));
assert_ne!(a.firing, b.firing);
}
}