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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user