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
+52
View File
@@ -441,6 +441,13 @@ impl Harness {
}
}
}
// end-of-stream: flush every node once, in topological order, so folding
// sinks (C8) emit their accumulated summary. This runs AFTER the
// deterministic event loop, so it adds no within-sim concurrency (C1).
for &nidx in topo.iter() {
nodes[nidx].node.finalize();
}
}
}
@@ -2265,4 +2272,49 @@ mod tests {
};
assert_eq!(build(), build());
}
struct FinalizeProbe {
id: usize,
tx: mpsc::Sender<usize>,
}
impl Node for FinalizeProbe {
fn lookbacks(&self) -> Vec<usize> {
vec![1]
}
fn eval(&mut self, _ctx: Ctx<'_>) -> Option<&[Cell]> {
None
}
fn finalize(&mut self) {
let _ = self.tx.send(self.id);
}
}
#[test]
fn run_finalizes_every_node_once_after_the_stream() {
let (tx, rx) = mpsc::channel();
// two sink probes, both fed by the single source; no inter-node edges.
let mut h = boot(
vec![
Box::new(FinalizeProbe { id: 0, tx: tx.clone() }),
Box::new(FinalizeProbe { id: 1, tx }),
],
vec![
recorder_sig(&[ScalarKind::F64], Firing::Any),
recorder_sig(&[ScalarKind::F64], Firing::Any),
],
vec![SourceSpec {
kind: ScalarKind::F64,
targets: vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }],
}],
vec![],
)
.expect("valid");
// nothing finalized before the run.
assert!(rx.try_recv().is_err());
h.run(vec![Box::new(VecSource::new(f64_stream(&[(1, 1.0), (2, 2.0)])))]);
// each node finalized exactly once, after the stream drained.
let mut fired: Vec<usize> = rx.try_iter().collect();
fired.sort_unstable();
assert_eq!(fired, vec![0, 1]);
}
}
+13 -39
View File
@@ -8,7 +8,7 @@
//! cycle 0029) — the same encoder the run registry uses, so a record's stdout
//! and on-disk shapes coincide.
use aura_core::{Scalar, ScalarKind, Timestamp};
use aura_core::{Scalar, ScalarKind, SeriesFold, Timestamp};
use std::collections::HashMap;
/// Summary metrics reduced from a run's recorded streams — the `-> metrics`
@@ -370,48 +370,22 @@ pub fn summarize(
equity: &[(Timestamp, f64)],
exposure: &[(Timestamp, f64)],
) -> RunMetrics {
// total pips: the last cumulative equity value (0.0 if empty).
let total_pips = equity.last().map(|&(_, v)| v).unwrap_or(0.0);
// max drawdown: the largest running-peak-minus-value, always >= 0.0.
let mut peak = f64::NEG_INFINITY;
let mut max_drawdown = 0.0_f64;
// total_pips + max_drawdown fold over equity; sign_flips folds over exposure.
// SeriesFold is the single arithmetic source of truth (shared with the
// SeriesReducer sink), so this is byte-identical to the prior inline loops.
let mut eqf = SeriesFold::new();
for &(_, v) in equity {
if v > peak {
peak = v;
}
let dd = peak - v;
if dd > max_drawdown {
max_drawdown = dd;
}
eqf.fold(v);
}
// bias sign-flips: adjacent samples whose normalized sign differs.
let mut bias_sign_flips = 0u64;
let mut prev: Option<f64> = None;
let mut exf = SeriesFold::new();
for &(_, v) in exposure {
let s = sign0(v);
if let Some(p) = prev
&& s != p
{
bias_sign_flips += 1;
}
prev = Some(s);
exf.fold(v);
}
RunMetrics { total_pips, max_drawdown, bias_sign_flips, r: None }
}
/// Three-way sign: `-1.0` / `0.0` / `+1.0`. Unlike `f64::signum` (which returns
/// `+1.0` for `+0.0`), a zero exposure maps to `0.0` so flat is distinct from
/// long/short in the sign-flip count.
fn sign0(v: f64) -> f64 {
if v > 0.0 {
1.0
} else if v < 0.0 {
-1.0
} else {
0.0
RunMetrics {
total_pips: eqf.last,
max_drawdown: eqf.max_drawdown,
bias_sign_flips: exf.sign_flips,
r: None,
}
}