spec: cycle 0004 firing policies and merge

Fourth walking-skeleton cycle: the reactive-semantics layer — both firing
policies (C6), the k-way ingestion merge (C3), the monotonic cycle_id clock
(C4), and freshness-gated recompute / sample-and-hold (C5). This is the first
point where the engine departs from RustAst rather than sharpening it.

Both rails ship together (user directive: "must sit right from the start"):
- A = fire-on-any-fresh + hold (as-of join);
- B = all-fresh barrier (synchronizing join), accumulating across cycles.
Per-input-group, mixable on one node. Encoded as Firing::{Any, Barrier(u8)} on
InputSpec (where C8 places firing); default Any keeps Sma/Sub unchanged.

Key design resolution (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).

Two read clocks, both load-bearing (resolves the 0003 audit's "0004 owns
cycle_id" with no write-only field): cycle_id is the freshness epoch (C5 reads
it via per-slot fresh_at == cycle_id); timestamp is the barrier token (C6 reads
it via last_ts == T). Sample-and-hold falls out of the existing push model — a
non-firing node pushes nothing, so consumers see the held value via window[0].

Scope (user-chosen): reactive semantics only — in-memory (timestamp, value)
sources; the merge is the permanent ingestion core, the Source trait (C11) +
data-server IO are a later orthogonal cycle. bootstrap/run/InputSpec signatures
change (the 0003 tests re-express on the multi-source API).

Headline test pair (identical sources, firing-only difference): A emits
[None,None,120,130,140,240] (holds); B emits [None,None,120,None,None,240]
(waits for timestamp coincidence). Plus determinism (C1), mixed A+B, the 0003
fan-out/join compat baseline, and the bootstrap rejections.

Grounding-check PASS: 10 load-bearing substrate assumptions ratified by green
tests; the spec's "before" shapes match disk exactly.

refs walking-skeleton

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-03 14:00:06 +02:00
parent 02fe89176f
commit 4cbb7795ef
@@ -0,0 +1,351 @@
# Firing Policies + Ingestion Merge — Design Spec
**Date:** 2026-06-03
**Status:** Draft — awaiting user spec review
**Authors:** orchestrator + Claude
## Goal
Make the reactive semantics of the engine *sit right from the start*: a node
re-evaluates only when its inputs warrant it, driven by **two firing policies**
that must both ship and both be fully tested:
- **A — fire-on-any-fresh + hold** (as-of join): the node fires whenever any of
its A-inputs is fresh this cycle; stale inputs contribute their last held value
(C5 sample-and-hold).
- **B — all-fresh barrier** (synchronizing join): the node fires only when every
member of a barrier group shares the current cycle's timestamp — e.g. O/H/L/C
assembled from four separate sources, complete only when all four have ticked
at the same bar time.
Firing is only *observable* when inputs tick at heterogeneous rates, so this
cycle also delivers the enabling substrate: a **k-way merge of multiple sources**
into one chronological cycle stream (C3), the **monotonic cycle_id** clock (C4),
and **freshness-gated recompute** (C5). Per the chosen scope, sources are
in-memory `(timestamp, value)` streams declared by the test/caller; the merge is
the permanent ingestion core, while the `Source` trait + data-server IO are a
later, orthogonal cycle.
This is the first place the engine departs from RustAst rather than sharpening
it: RustAst had one clock per source and a `cycle_id`-equality barrier that only
synchronizes a single source's fan-out; it never merged heterogeneous sources and
never implemented mode A (see the design notes below).
## Architecture
Three contracts compose into one mechanism that turns out to need **two** read
clocks, not one:
- **`cycle_id` is the freshness epoch (C4 → C5).** The merge advances a monotonic
`cycle_id` per emitted record. Each push into an input slot stamps that slot
with the current `cycle_id`. A slot is *fresh this cycle* iff its stamp equals
the current `cycle_id`. This is the genuine first reader of C4's `cycle_id`
it is not a write-only field.
- **`timestamp` is the barrier token (C4 → C6).** Each push also stamps the slot
with the cycle's data timestamp `T`. A barrier group is complete iff all its
members carry `last_ts == T`. This is RustAst's `last_cycle_per_input.all(==)`
barrier with the synchronization token changed from `cycle_id` to `timestamp`
the change forced by C4's tie rule (see design notes).
- **Push *is* freshness propagation.** A node that fires pushes its output into
consumers' input columns, stamping them fresh; a node that does not fire pushes
nothing, so consumers keep seeing the held value via `window[0]`. Sample-and-
hold (C5) falls out of the existing push model with no new storage — only
engine-side per-slot bookkeeping (`fresh_at`, `last_ts`).
The graph wiring (flat node array, index edge table, Kahn topo order), the
node-owns-its-input-columns shape (cycle 0002), and `Ctx` are all unchanged. What
changes: `bootstrap` takes many sources instead of one; `run` takes many streams
and merges them; each node carries per-input firing + freshness state; the eval
step is gated by the firing predicate instead of running unconditionally.
## Concrete code shapes
### User-facing: the two rails, authored as nodes
The two firing modes are exercised by two fixture nodes that are **identical
except for the firing annotation in their schema** — a 2-input f64 sum. This is
the empirical evidence for the feature-acceptance criterion: the only thing a
node author writes to choose a rail is `Firing::Any` vs `Firing::Barrier(g)`.
```rust
// Mode A — as-of join: fires whenever either input is fresh; holds the other.
struct AsOfSum;
impl Node for AsOfSum {
fn schema(&self) -> NodeSchema {
NodeSchema {
inputs: vec![
InputSpec { kind: ScalarKind::F64, lookback: 1, firing: Firing::Any },
InputSpec { kind: ScalarKind::F64, lookback: 1, firing: Firing::Any },
],
output: ScalarKind::F64,
}
}
fn eval(&mut self, ctx: Ctx<'_>) -> Option<Scalar> {
let a = ctx.f64_in(0);
let b = ctx.f64_in(1);
if a.is_empty() || b.is_empty() { return None; } // warm-up
Some(Scalar::F64(a[0] + b[0]))
}
}
// Mode B — barrier: fires only when both inputs share the cycle timestamp T.
struct BarrierSum;
impl Node for BarrierSum {
fn schema(&self) -> NodeSchema {
NodeSchema {
inputs: vec![
InputSpec { kind: ScalarKind::F64, lookback: 1, firing: Firing::Barrier(0) },
InputSpec { kind: ScalarKind::F64, lookback: 1, firing: Firing::Barrier(0) },
],
output: ScalarKind::F64,
}
}
fn eval(&mut self, ctx: Ctx<'_>) -> Option<Scalar> {
Some(Scalar::F64(ctx.f64_in(0)[0] + ctx.f64_in(1)[0]))
}
}
```
### User-facing: wiring two heterogeneous sources and running
```rust
// source 0 ticks at t=1,2,3,4; source 1 ticks at t=2,4 (slower).
let s0 = vec![(Timestamp(1), Scalar::F64(10.0)), (Timestamp(2), Scalar::F64(20.0)),
(Timestamp(3), Scalar::F64(30.0)), (Timestamp(4), Scalar::F64(40.0))];
let s1 = vec![(Timestamp(2), Scalar::F64(100.0)), (Timestamp(4), Scalar::F64(200.0))];
let mut h = Harness::bootstrap(
vec![Box::new(AsOfSum)],
vec![
SourceSpec { kind: ScalarKind::F64, targets: vec![Target { node: 0, slot: 0 }] },
SourceSpec { kind: ScalarKind::F64, targets: vec![Target { node: 0, slot: 1 }] },
],
vec![], // no producer->producer edges
0, // observe node 0
).expect("valid");
let out = h.run(vec![s0, s1]);
// Mode A: [None, None, Some(120.0), Some(130.0), Some(140.0), Some(240.0)]
// ^ holds s1=100 across t=3 and t=4-on-s0; emits on every tick.
// Mode B (same wiring, BarrierSum instead of AsOfSum):
// [None, None, Some(120.0), None, None, Some(240.0)]
// ^ emits ONLY at t=2 and t=4 where both inputs share the timestamp; holds (no
// emit) at t=1, t=3, and the t=4 s0-cycle until s1 completes the bar.
```
The merged cycle order (one record = one cycle, ties by source index, C4) is:
`(t1,s0) (t2,s0) (t2,s1) (t3,s0) (t4,s0) (t4,s1)` — six cycles. The two emitted
vectors above differ exactly at the held cycles, which *is* the difference
between the two rails.
### Implementation shape (secondary)
**aura-core — `Firing` enum + `InputSpec.firing` (before → after):**
```rust
// before
pub struct InputSpec { pub kind: ScalarKind, pub lookback: usize }
// after
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Firing {
Any, // mode A: fires the node whenever this input is fresh this cycle
Barrier(u8), // mode B: member of barrier group N; the group fires when all
// its members share the current cycle's timestamp
}
pub struct InputSpec { pub kind: ScalarKind, pub lookback: usize, pub firing: Firing }
```
`Sma` (1 input) and `Sub` (2 inputs) in aura-std gain `firing: Firing::Any` on
each declared input — under a single source ticking every cycle, an `Any` input
is fresh every cycle, so their behavior is unchanged (backward compatible).
**aura-engine — `SourceSpec`, per-slot state, signature changes (before → after):**
```rust
// new: a declared source — its scalar kind and the input slots it feeds.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SourceSpec { pub kind: ScalarKind, pub targets: Vec<Target> }
// per-input engine bookkeeping (not visible to nodes).
struct SlotState { fresh_at: u64, last_ts: Timestamp } // init { 0, Timestamp(i64::MIN) }
// NodeBox gains the lifted firing vector + the per-input slot state.
struct NodeBox {
node: Box<dyn Node>,
inputs: Vec<AnyColumn>,
firing: Vec<Firing>, // lifted from schema at bootstrap
slots: Vec<SlotState>,
}
// bootstrap: many sources instead of (source_targets, source_kind).
// before: bootstrap(nodes, source_targets: Vec<Target>, edges, source_kind: ScalarKind, observe)
// after:
pub fn bootstrap(
nodes: Vec<Box<dyn Node>>,
sources: Vec<SourceSpec>,
edges: Vec<Edge>,
observe: usize,
) -> Result<Harness, BootstrapError>
// run: many streams instead of one record iterator.
// before: run(&mut self, records: impl Iterator<Item = (Timestamp, Scalar)>)
// after:
pub fn run(&mut self, streams: Vec<Vec<(Timestamp, Scalar)>>) -> Vec<Option<Scalar>>
```
The `Harness` struct's `source_targets: Vec<Target>` field becomes
`sources: Vec<SourceSpec>`; no other field is added (`cycle_id` is a loop local,
slot state lives in `NodeBox`).
**The firing predicate (shape):**
```rust
fn fires(firing: &[Firing], slots: &[SlotState], cycle_id: u64, ts: Timestamp) -> bool {
// mode A: any A-input fresh this cycle
for (i, f) in firing.iter().enumerate() {
if matches!(f, Firing::Any) && slots[i].fresh_at == cycle_id { return true; }
}
// mode B: for each distinct barrier group, fire iff >=1 member fresh this
// cycle AND all members carry last_ts == ts. The ">=1 fresh" clause is the
// once-per-T guard: a group already completed in a prior cycle has no fresh
// member now, so it does not re-fire.
for g in distinct_barrier_groups(firing) {
let members = barrier_members(firing, g);
let any_fresh = members.iter().any(|&i| slots[i].fresh_at == cycle_id);
let all_at_ts = members.iter().all(|&i| slots[i].last_ts == ts);
if any_fresh && all_at_ts { return true; }
}
false
}
```
**The run loop (shape):** maintain a cursor per source; per cycle, pick the live
source head with the smallest `(timestamp, source_index)` (C4 k-way merge);
increment `cycle_id`; push the value into that source's target slots (stamping
`fresh_at = cycle_id`, `last_ts = ts`); walk nodes in topo order, and for each
node that `fires(...)`, `eval` it, capture the observed node's result, and
forward each `Some` output into consumers' slots (stamping them likewise). A node
that does not fire is skipped (held). Allocation-free on the per-cycle path
(cursors + counters only).
## Components
- **`Firing` (aura-core)** — the per-input policy enum; part of `InputSpec`, so
it is declared in `schema()` exactly where C8 places it ("declares each input's
… firing group").
- **`SourceSpec` (aura-engine)** — a declared source: its kind + the input slots
it feeds. The multi-source generalization of cycle 0003's `(source_targets,
source_kind)` pair.
- **`SlotState` (aura-engine, private)** — per-input `{ fresh_at, last_ts }`; the
two read clocks (freshness epoch + barrier token).
- **`Harness::bootstrap` / `run` (aura-engine)** — extended to many sources +
the merge + the firing gate. Topo order, edge kind-checks, and cycle rejection
are unchanged from 0003.
- **`AsOfSum` / `BarrierSum` (test fixtures in aura-engine tests)** — the two
rails as authored nodes; not library nodes (no speculative aura-std surface
until a real strategy needs a reusable join).
## Data flow
1. The caller hands `run` one `(Timestamp, Scalar)` stream per source, each
ascending in time (guaranteed by C3 ingestion; the test supplies sorted vecs).
2. The k-way merge emits one record per cycle in global `(timestamp, source
index)` order, advancing `cycle_id` monotonically (C3/C4).
3. The record's value is pushed into its source's target slots; those slots are
stamped `fresh_at = cycle_id`, `last_ts = ts`.
4. Nodes evaluate in topo order. Each node's firing predicate reads its slots'
`fresh_at` (freshness, C5) and `last_ts` (barrier, C6). A firing node evals
and forwards `Some` outputs into consumers (stamping them fresh); a non-firing
node holds.
5. The observed node's emitted value (`Some` when it fired and produced output,
`None` when it held or filtered) is collected per cycle and returned.
## Error handling
- **Bootstrap (`BootstrapError`, unchanged variants):**
- `KindMismatch { producer, consumer }` — a source target's slot kind ≠ the
source's `kind`, or an edge's producer output ≠ consumer slot kind. Checked
per source and per edge, as in 0003.
- `BadIndex` — a source target, edge endpoint, or `observe` index out of range.
- `Cycle` — the wiring contains a directed cycle (Kahn leftover).
- **`run` precondition:** `streams.len()` must equal `sources.len()`, and each
stream must be ascending by timestamp. A mismatch in length is a caller bug; the
shape (panic with a clear message vs. a typed error) is a planner decision, but
it is *not* a silent no-op. Stream monotonicity is a documented precondition of
the C3 ingestion boundary, not re-validated in the hot path.
- **Cold barrier member:** a slot never pushed has `last_ts = Timestamp(i64::MIN)`,
which equals no real timestamp, so a barrier with an un-ticked member never
completes — correct warm-up behavior.
- **Zero-input node:** never has a fresh input, so never fires; harmless (no such
node exists today). Noted, not special-cased.
## Testing strategy
All on both rails, all deterministic (C1):
1. **Mode A — as-of hold** (`aura-engine`): the worked `AsOfSum` example above;
assert the emitted vector `[None, None, 120, 130, 140, 240]`, proving the held
s1 value (100) is reused across t=3 and the t=4 s0-cycle.
2. **Mode B — barrier wait** (`aura-engine`): same wiring with `BarrierSum`;
assert `[None, None, 120, None, None, 240]`, proving the node emits only when
both inputs share the timestamp and holds (no emit) otherwise.
3. **Determinism** (C1): a second identical `run` of each rail is bit-identical to
the first.
4. **Mixed A+B on one node:** a 3-input fixture `[Barrier(0), Barrier(0), Any]`
fed by three sources; assert it fires both when the barrier pair completes
(held A) and when the A-input ticks (held barrier pair) — the OR-combine.
5. **Backward-compat fan-out/join** (single source, all `Any`): the 0003
`source -> {Sma(2), Sma(4)} -> Sub` DAG re-expressed on the new API still emits
`[None, None, None, 2, 2, 2]`, proving the firing gate is transparent when one
source ticks every cycle.
6. **Bootstrap rejections** (adapted to `SourceSpec`): kind-mismatch (i64 source
into an f64 slot), bad index (observe out of range), and a directed cycle —
the 0003 reject tests re-expressed.
7. **aura-core:** `InputSpec`/schema carry `firing`; `Sma`/`Sub` declare
`Firing::Any`; existing aura-core/aura-std tests stay green.
## Acceptance criteria
Per aura's feature-acceptance criterion (the audience reaches for it; it improves
correctness; it reintroduces no failure class the core constraints forbid):
- **Reached-for:** authoring a strategy that combines a fast price with a held
daily bias (mode A) or assembles a bar from separate O/H/L/C feeds (mode B) is
exactly C6's named motivation; the only authoring act is the `Firing`
annotation, shown above.
- **Correctness / no look-ahead (C2):** freshness reads only past pushes; the
barrier waits for completeness rather than peeking ahead; held values are
explicit last values, never future ones.
- **Determinism (C1):** the merge tie-break (source order) and the firing
predicate (pure function of `fresh_at`/`last_ts`, which evolve deterministically)
make every run reproducible — asserted in test 3.
- **No new failure class:** no interior concurrency, no per-cycle allocation, no
merge inside the graph (C3 — the single merge is at ingestion), no `dyn Any` /
`Rc` / `RefCell` on the surface.
- Gates green: `cargo build/test/clippy --workspace -- -D warnings`; surface-
purity grep clean.
## Design notes — why aura diverges from RustAst here
RustAst (`src/ast/rtl/streams/`) is the only prior implementation, and studying it
fixed two decisions:
- **Barrier token: `timestamp`, not `cycle_id`.** RustAst's `PipeStream` barrier
is `last_cycle_per_input.iter().all(|&c| c == cycle_id)` — all inputs reported
the same `cycle_id`. That synchronizes one source's fan-out/rejoin (every path
carries the originating tick's `cycle_id`), but it **cannot** synchronize
independent sources: under C4, four sources at the same bar timestamp become
four *different* `cycle_id`s (ties broken by source order), so `all(== cycle_id)`
would never fire for C6's own O/H/L/C example. Substituting `timestamp` for
`cycle_id` is the minimal faithful generalization: it fires the diamond rejoin
(paths share the cycle timestamp) *and* the multi-source bar (separate cycles,
shared timestamp).
- **Mode A is new.** RustAst implemented only the barrier (C6: "RustAst
implemented only B"); fire-on-any-fresh + hold has no prior art and is built
here from the freshness primitive.
- **The merge is new.** RustAst gives each source its own `RootStream` clock
(`data_streams.rs`) with no k-way merge; aura's single merged timeline (C3) with
one shared `cycle_id`/`timestamp` sequence is precisely what makes a cross-source
barrier meaningful. RustAst's `total_count` push counter is aura's already-built
`Column::run_count` (glossary: run-count, Avoid total_count).