//! `SeriesReducer` — a folding sink (C8) over one `f64` column. It folds each //! warm value into a `SeriesFold` and, on `finalize`, emits a single summary row //! `[last, max_drawdown, sign_flips]`, so a downstream reduction reads O(1) per //! member instead of the whole per-cycle series. C7-pure: owned fold + channel. use aura_core::{Cell, Ctx, Firing, Node, NodeSchema, PortSpec, PrimitiveBuilder, Scalar, ScalarKind, SeriesFold, Timestamp}; use std::sync::mpsc::Sender; /// A single-`f64`-column folding sink. Emits exactly one row on `finalize`: /// `[Scalar::f64(last), Scalar::f64(max_drawdown), Scalar::i64(sign_flips)]`. pub struct SeriesReducer { fold: SeriesFold, last_ts: Timestamp, tx: Sender<(Timestamp, Vec)>, } impl SeriesReducer { pub fn new(tx: Sender<(Timestamp, Vec)>) -> Self { Self { fold: SeriesFold::new(), last_ts: Timestamp(0), tx } } pub fn builder(firing: Firing, tx: Sender<(Timestamp, Vec)>) -> PrimitiveBuilder { PrimitiveBuilder::new( "SeriesReducer", NodeSchema { inputs: vec![PortSpec { kind: ScalarKind::F64, firing, name: "col[0]".to_string() }], output: vec![], // sink: empty output (C8) params: vec![], }, move |_| Box::new(SeriesReducer::new(tx.clone())), ) } } impl Node for SeriesReducer { fn lookbacks(&self) -> Vec { vec![1] } fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Cell]> { let v = ctx.f64_in(0).get(0)?; // None during warm-up self.fold.fold(v); self.last_ts = ctx.now(); None } fn finalize(&mut self) { let row = vec![ Scalar::f64(self.fold.last), Scalar::f64(self.fold.max_drawdown), Scalar::i64(self.fold.sign_flips as i64), ]; let _ = self.tx.send((self.last_ts, row)); } fn label(&self) -> String { "SeriesReducer".to_string() } } #[cfg(test)] mod tests { use super::*; use aura_core::AnyColumn; use std::sync::mpsc; #[test] fn folds_and_emits_one_summary_row_on_finalize() { let (tx, rx) = mpsc::channel(); let mut r = SeriesReducer::new(tx); let mut inputs = vec![AnyColumn::with_capacity(ScalarKind::F64, 1)]; for (t, v) in [(1_i64, 10.0_f64), (2, 12.0), (3, 8.0)] { inputs[0].push(Scalar::f64(v)).unwrap(); assert_eq!(r.eval(Ctx::new(&inputs, Timestamp(t))), None); } // nothing emitted per-eval. assert!(rx.try_recv().is_err()); r.finalize(); let out: Vec<(Timestamp, Vec)> = rx.try_iter().collect(); assert_eq!(out.len(), 1); assert_eq!(out[0].1[0], Scalar::f64(8.0)); // last assert_eq!(out[0].1[1], Scalar::f64(4.0)); // max_drawdown 12 - 8 assert_eq!(out[0].1[2], Scalar::i64(0)); // sign_flips (all positive) } #[test] fn empty_finalize_emits_zero_summary() { let (tx, rx) = mpsc::channel(); let mut r = SeriesReducer::new(tx); r.finalize(); let out: Vec<(Timestamp, Vec)> = rx.try_iter().collect(); assert_eq!(out, vec![(Timestamp(0), vec![Scalar::f64(0.0), Scalar::f64(0.0), Scalar::i64(0)])]); } }