Files
Brummel cfe7bad0ff 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
2026-06-25 11:00:02 +02:00

98 lines
2.7 KiB
Rust

//! `SeriesFold` — the online accumulator behind `summarize`'s three pip
//! statistics (`last`, `max_drawdown`, `sign_flips`). One arithmetic source of
//! truth, foldable a value at a time, so a slice driver (`summarize`) and a
//! per-`eval` streaming sink (`SeriesReducer`) compute byte-identical results.
/// Three-way sign: `-1.0` / `0.0` / `+1.0`. A zero maps to `0.0` so flat is
/// distinct from long/short in the flip count (unlike `f64::signum`).
fn sign0(v: f64) -> f64 {
if v > 0.0 {
1.0
} else if v < 0.0 {
-1.0
} else {
0.0
}
}
/// Online fold of a single `f64` series into the pip summary statistics. The
/// operations and their order mirror `summarize`'s loops exactly, so a
/// slice-driven and a per-value-driven fold agree bit-for-bit (IEEE-754
/// non-associativity respected).
#[derive(Clone, Debug)]
pub struct SeriesFold {
/// the last value folded (`total_pips` for an equity series); `0.0` if none.
pub last: f64,
peak: f64,
/// running `max(peak - value)`, always `>= 0.0`.
pub max_drawdown: f64,
prev_sign: Option<f64>,
/// count of adjacent normalized-sign changes.
pub sign_flips: u64,
}
impl Default for SeriesFold {
fn default() -> Self {
Self { last: 0.0, peak: f64::NEG_INFINITY, max_drawdown: 0.0, prev_sign: None, sign_flips: 0 }
}
}
impl SeriesFold {
pub fn new() -> Self {
Self::default()
}
/// Fold one value into the running statistics.
pub fn fold(&mut self, v: f64) {
self.last = v;
if v > self.peak {
self.peak = v;
}
let dd = self.peak - v;
if dd > self.max_drawdown {
self.max_drawdown = dd;
}
let s = sign0(v);
if let Some(p) = self.prev_sign
&& s != p
{
self.sign_flips += 1;
}
self.prev_sign = Some(s);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn series_fold_empty_is_zero() {
let f = SeriesFold::new();
assert_eq!(f.last, 0.0);
assert_eq!(f.max_drawdown, 0.0);
assert_eq!(f.sign_flips, 0);
}
#[test]
fn series_fold_tracks_last_and_drawdown() {
let mut f = SeriesFold::new();
for v in [10.0, 12.0, 8.0, 9.0] {
f.fold(v);
}
assert_eq!(f.last, 9.0);
assert_eq!(f.max_drawdown, 4.0); // peak 12 - trough 8
assert_eq!(f.sign_flips, 0); // all positive
}
#[test]
fn series_fold_counts_sign_flips_zero_distinct() {
let mut f = SeriesFold::new();
for v in [1.0, -1.0, 0.0, 2.0] {
f.fold(v);
}
// signs + - 0 + -> flips at -, 0, + = 3
assert_eq!(f.sign_flips, 3);
}
}