//! `RollingMin` — minimum over the last `length` values of one f64 input. //! //! The mirror of `RollingMax`: an **ascending** monotonic deque (front = window min). //! Each cycle pops from the back every element >= the new sample, pushes the new one, //! and evicts the front once it leaves the window. See `rolling_max.rs` for the shared //! rationale (O(1) amortized, window in node state, warm-up skip-emit, C7). use aura_core::{ Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder, ScalarKind, }; use std::collections::VecDeque; /// Minimum over the last `length` values of one f64 input, maintained by an ascending /// monotonic deque (front = current window min). `None` during warm-up. pub struct RollingMin { length: usize, deque: VecDeque<(f64, u64)>, // ascending; front = window min seen: u64, count: usize, out: [Cell; 1], } impl RollingMin { /// Build a rolling min of window `length` (must be >= 1). pub fn new(length: usize) -> Self { assert!(length >= 1, "RollingMin 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 `RollingMin::new`. pub fn builder() -> PrimitiveBuilder { PrimitiveBuilder::new( "RollingMin", 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 }], }, |p| Box::new(RollingMin::new(p[0].i64() as usize)), ) } } impl Node for RollingMin { 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; } let x = w[0]; let idx = self.seen; self.seen += 1; // ascending invariant: a back element >= x can never again be the min — drop it. while let Some(&(v, _)) = self.deque.back() { if v >= x { self.deque.pop_back(); } else { break; } } self.deque.push_back((x, 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; } self.out[0] = Cell::from_f64(self.deque.front().expect("deque non-empty after push").0); Some(&self.out) } fn label(&self) -> String { format!("RollingMin({})", self.length) } } #[cfg(test)] mod tests { use super::*; use aura_core::{AnyColumn, Scalar, Timestamp}; fn drive(node: &mut RollingMin, 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_min_warms_up_then_tracks_the_window_min() { // min of [9,7,8], [7,8,5], [8,5,6]; silent for the first two. let mut node = RollingMin::new(3); let got = drive(&mut node, &[9.0, 7.0, 8.0, 5.0, 6.0]); assert_eq!(got, vec![None, None, Some(7.0), Some(5.0), Some(5.0)]); } #[test] fn rolling_min_evicts_the_expiring_window_minimum() { // length 2; the early small value must leave the window: [1,9]->1, [9,8]->8, [8,7]->7. let mut node = RollingMin::new(2); let got = drive(&mut node, &[1.0, 9.0, 8.0, 7.0]); assert_eq!(got, vec![None, Some(1.0), Some(8.0), Some(7.0)]); } #[test] fn rolling_min_length_one_is_identity() { let mut node = RollingMin::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_min() { // the monotonic deque must equal a naive O(N) per-window min at every warmed // cycle, across a long deterministic series (mirrors RollingMax's cross-check). // u64 + wrapping arithmetic matches rolling_max.rs and avoids i32 overflow panics. let length = 37; let mut node = RollingMin::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::INFINITY, f64::min); 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!(RollingMin::new(20).lookbacks(), vec![1]); } #[test] fn length_must_be_at_least_one() { let r = std::panic::catch_unwind(|| RollingMin::new(0)); assert!(r.is_err(), "RollingMin::new(0) must panic"); } #[test] fn label_carries_the_window() { assert_eq!(RollingMin::new(20).label(), "RollingMin(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 // RollingMax's builder_declares_length_param_and_value_output). let schema = RollingMin::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 RollingMax's // builder_input_slot_is_named_series). assert_eq!(RollingMin::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 // RollingMax's builder_bind_removes_length_from_param_space). let bound = RollingMin::builder().named("channel").bind("length", Scalar::i64(20)); assert!(bound.schema().params.is_empty()); assert_eq!(RollingMin::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 RollingMin(20) — // the `p[0].i64() as usize` build closure is exercised end-to-end (mirrors // RollingMax's builder_bound_node_builds_with_injected_length). let node = RollingMin::builder().bind("length", Scalar::i64(20)).build(&[]); assert_eq!(node.label(), "RollingMin(20)"); } }