//! `RollingMax` — maximum over the last `length` values of one f64 input. //! //! Sliding-window maximum maintained by a **descending monotonic deque** (the //! textbook O(1)-amortized sliding-window-max): each cycle pops from the back every //! element <= the new sample (they can never again be the window max), pushes the new //! one, and evicts the front once its stream index leaves the window. The front is //! always the current window max. A per-cycle O(N) re-scan would be too slow at the //! large `N` (channel lengths in the thousands of bars) the breakout screen sweeps. //! //! Like `Sma`/`Delay`, the window lives in node state, so `eval` reads only the newest //! sample and `lookbacks()` is `1`. Warm-up is **skip-emit**: `None` until `length` //! samples have passed (the window is not yet full). The deque is pre-sized to //! `length` so the hot path is allocation-free (C7). Per the "operator is topology" //! convention (see `gt.rs`), `RollingMax` and `RollingMin` are two node types, not one //! node with a max/min param. use aura_core::{ Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder, ScalarKind, }; use std::collections::VecDeque; /// Maximum over the last `length` values of one f64 input, maintained by a descending /// monotonic deque (front = current window max). `None` during warm-up. pub struct RollingMax { length: usize, // descending monotonic deque of (value, stream_index); front is the window max. // node-owned, bounded by `length` (C7), pre-sized so push_back does not allocate. deque: VecDeque<(f64, u64)>, seen: u64, // stream position of the next sample (drives front eviction) count: usize, // samples seen so far, capped at `length` — the warm-up gate out: [Cell; 1], } impl RollingMax { /// Build a rolling max of window `length` (must be >= 1; mirror `Sma::new`). pub fn new(length: usize) -> Self { assert!(length >= 1, "RollingMax length must be >= 1"); Self { length, deque: VecDeque::with_capacity(length), seen: 0, count: 0, out: [Cell::from_f64(0.0)], } } /// The param-generic recipe for a blueprint primitive: declares `length` and builds /// through `RollingMax::new`. pub fn builder() -> PrimitiveBuilder { PrimitiveBuilder::new( "RollingMax", NodeSchema { inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "series".into() }], output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }], params: vec![ParamSpec { name: "length".into(), kind: ScalarKind::I64 }], doc: "rolling maximum over a fixed lookback window", }, |p| Box::new(RollingMax::new(p[0].i64() as usize)), ) } } impl Node for RollingMax { // The window lives in node state, so only the newest sample is read each cycle. fn lookbacks(&self) -> Vec { vec![1] } fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Cell]> { let w = ctx.f64_in(0); if w.is_empty() { return None; // no sample yet } let x = w[0]; // index 0 = newest (financial indexing) let idx = self.seen; self.seen += 1; // descending invariant: a back element <= x can never again be the max — drop it. while let Some(&(v, _)) = self.deque.back() { if v <= x { self.deque.pop_back(); } else { break; } } self.deque.push_back((x, idx)); // evict the front once it leaves the window [idx-length+1, idx] (no subtraction: // front index `i` is out when `i + length <= idx`). while let Some(&(_, i)) = self.deque.front() { if i + self.length as u64 <= idx { self.deque.pop_front(); } else { break; } } if self.count < self.length { self.count += 1; } if self.count < self.length { return None; // not yet warmed up } self.out[0] = Cell::from_f64(self.deque.front().expect("deque non-empty after push").0); Some(&self.out) } fn label(&self) -> String { format!("RollingMax({})", self.length) } } #[cfg(test)] mod tests { use super::*; use aura_core::{AnyColumn, Scalar, Timestamp}; fn drive(node: &mut RollingMax, feed: &[f64]) -> Vec> { let mut inputs = vec![AnyColumn::with_capacity(ScalarKind::F64, 1)]; let mut out = Vec::new(); for &v in feed { inputs[0].push(Scalar::f64(v)).unwrap(); out.push(node.eval(Ctx::new(&inputs, Timestamp(0))).map(|r| r[0].f64())); } out } #[test] fn rolling_max_warms_up_then_tracks_the_window_max() { // max of [1,3,2], [3,2,5], [2,5,4] once warmed up; silent for the first two. let mut node = RollingMax::new(3); let got = drive(&mut node, &[1.0, 3.0, 2.0, 5.0, 4.0]); assert_eq!(got, vec![None, None, Some(3.0), Some(5.0), Some(5.0)]); } #[test] fn rolling_max_evicts_the_expiring_window_maximum() { // the front-eviction path: the early big value must leave the window. length 2. // windows: [9,1]->9, [1,2]->2, [2,3]->3 (the 9 has expired, not stuck as max). let mut node = RollingMax::new(2); let got = drive(&mut node, &[9.0, 1.0, 2.0, 3.0]); assert_eq!(got, vec![None, Some(9.0), Some(2.0), Some(3.0)]); } #[test] fn rolling_max_length_one_is_identity() { let mut node = RollingMax::new(1); assert_eq!(drive(&mut node, &[7.0, 9.0, 4.0]), vec![Some(7.0), Some(9.0), Some(4.0)]); } #[test] fn deque_matches_naive_window_max() { // the monotonic deque must equal a naive O(N) per-window max at every warmed // cycle, across a long deterministic series (mirrors Sma's re-sum cross-check). let length = 37; let mut node = RollingMax::new(length); let series: Vec = (0..3_000u64) .map(|i| (i.wrapping_mul(1103515245).wrapping_add(12345) % 1000) as f64 * 0.5 - 250.0) .collect(); let mut inputs = vec![AnyColumn::with_capacity(ScalarKind::F64, 1)]; for (t, &x) in series.iter().enumerate() { inputs[0].push(Scalar::f64(x)).unwrap(); let got = node.eval(Ctx::new(&inputs, Timestamp(0))).map(|r| r[0].f64()); if t + 1 >= length { let want = series[t + 1 - length..=t].iter().cloned().fold(f64::NEG_INFINITY, f64::max); assert_eq!(got, Some(want), "at t={t}"); } else { assert_eq!(got, None, "silent until warmed up at t={t}"); } } } #[test] fn lookback_is_one_window_lives_in_node_state() { assert_eq!(RollingMax::new(20).lookbacks(), vec![1]); } #[test] fn length_must_be_at_least_one() { let r = std::panic::catch_unwind(|| RollingMax::new(0)); assert!(r.is_err(), "RollingMax::new(0) must panic"); } #[test] fn label_carries_the_window() { assert_eq!(RollingMax::new(20).label(), "RollingMax(20)"); } #[test] fn builder_declares_length_param_and_value_output() { // the blueprint seam later tasks bootstrap through: the param-generic recipe // declares a single I64 `length` knob and an F64 `value` output (mirrors Sma's // nodes_declare_expected_params). let schema = RollingMax::builder().schema().clone(); assert_eq!(schema.params, vec![ParamSpec { name: "length".into(), kind: ScalarKind::I64 }]); assert_eq!(schema.output.len(), 1); assert_eq!(schema.output[0].name, "value"); assert_eq!(schema.output[0].kind, ScalarKind::F64); } #[test] fn builder_input_slot_is_named_series() { // the named f64 input the breakout graph wires close into (mirrors Sma's // input_slot_is_named_series). assert_eq!(RollingMax::builder().schema().inputs[0].name, "series"); } #[test] fn builder_bind_removes_length_from_param_space() { // a bound length reports an empty param surface; the open form keeps it (mirrors // Sma's bind_removes_slot_from_param_space). let bound = RollingMax::builder().named("channel").bind("length", Scalar::i64(20)); assert!(bound.schema().params.is_empty()); assert_eq!(RollingMax::builder().named("channel").params().len(), 1); } #[test] fn builder_bound_node_builds_with_injected_length() { // built from an empty open slice, the bound builder yields a RollingMax(20) — // the `p[0].i64() as usize` build closure is exercised end-to-end (mirrors Sma's // bound_node_builds_with_injected_value). let node = RollingMax::builder().bind("length", Scalar::i64(20)).build(&[]); assert_eq!(node.label(), "RollingMax(20)"); } }