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