feat(0070): streaming sink reductions — finalize hook + folding sinks (#138)

M3 machinery for BLOCKER #138: sink reductions can now fold online instead of
buffering every per-cycle row to an unbounded channel.

- aura-core: additive `Node::finalize` end-of-stream hook (default no-op) +
  `SeriesFold` online accumulator (last / max_drawdown / sign_flips).
- aura-engine: `Harness::run` calls `finalize` once per node in topo order after
  the source loop; `summarize` refactored to drive `SeriesFold` (byte-identical),
  the now-dead `sign0` removed.
- aura-std: `GatedRecorder` (emits only gated rows + the final row on finalize)
  and `SeriesReducer` (folds one f64 series, emits one summary row on finalize),
  both emitting the `Recorder` channel type — no aura-engine dependency.
- Integration tests: folded reductions are byte-for-byte equal to the
  raw-recorder path; the R-reduction's peak memory is O(trades), independent of
  cycle count.

Ratified two off-plan fixes the implementer made in the tests (both plan defects,
not code defects): the GatedRecorder correctly flushes the final non-gated row on
finalize, so the retained count is K_TRADES + 1; and the heterogeneous PM record
needs kind-correct zero scalars (a blanket f64 zero is rejected by the kind-typed
AnyColumn::push).

Wiring these reducers into the stage1-r sweep (the actual O(cycles)->O(trades)
win) follows next. refs #138
This commit is contained in:
2026-06-25 11:00:02 +02:00
parent bf886a1386
commit cfe7bad0ff
10 changed files with 653 additions and 39 deletions
+14
View File
@@ -280,6 +280,12 @@ pub trait Node {
fn label(&self) -> String {
"node".to_string()
}
/// End-of-stream hook: `Harness::run` calls it once per node, in topological
/// order, after the source loop drains. A folding sink overrides it to flush
/// its accumulated summary; the default is a no-op, so existing nodes are
/// unaffected. Takes `&mut self` like `eval`, so `Node` stays object-safe.
fn finalize(&mut self) {}
}
#[cfg(test)]
@@ -315,6 +321,14 @@ mod tests {
assert_eq!(Bare.label(), "node");
}
#[test]
fn default_finalize_is_a_callable_noop() {
// a node that does not override finalize uses the default no-op; calling
// it compiles and does nothing (additive, non-breaking for existing nodes).
let mut b = Bare;
b.finalize();
}
#[test]
fn primitive_builder_label_is_the_bare_type() {
// param-generic: the label is just the node type, no knob suffix — the