Skip to main content

polydat_nodes/
stability.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! Signal-settling / steady-state detection over a window of samples.
5//!
6//! An objective computed from a live metric is volatile: each read
7//! follows the current metric window, and after a phase completes that
8//! window is empty. A host that needs the level the phase actually
9//! produced, and a signal for when that level has settled, keeps the
10//! recent samples itself and asks `is_stable` about them.
11//!
12//! The node is a pure function of the window it is given. It keeps no
13//! history of its own, so both outputs are memoized like any other
14//! pure step, agree on all four engines and in every fiber, and read
15//! the same whichever of the two a host pulls first (runtime_model.md
16//! §9.1).
17
18/// The settled level and the steady-state signal of a window of
19/// samples.
20///
21/// Signature: `is_stable(samples: vec_f64, margin: f64, min_samples:
22/// u64) -> (stable_value: f64, stable: u64)`
23///
24/// - `stable_value` is the median of `samples`: a central estimate that
25///   one outlier, such as a trailing empty-window sample, barely moves.
26///   An empty window reports `0.0`.
27/// - `stable` is `1` when the window holds at least `min_samples`
28///   samples and their standard deviation is within
29///   `margin · max(|median|, 1)`, a relative band with an absolute
30///   floor so a level near zero still settles; otherwise `0`.
31///
32/// The host owns the window: it appends each new sample and drops the
33/// oldest, and writes the window as an input before it reads. The
34/// cost is linear in the window's length and is paid only when the
35/// window changes, so it scales with how often the host updates the
36/// window, not with how often samples arrive. A host whose samples
37/// arrive much faster than it needs to decide should aggregate them
38/// into the window rather than pass every one.
39#[polydat::polydat_node(category = Math, output_names(stable_value, stable))]
40fn is_stable(
41    samples: &[f64],
42    #[poly_default(0.05f64)] margin: polydat::derive_support::Const<f64>,
43    #[poly_default(8u64)] min_samples: polydat::derive_support::Const<u64>,
44) -> (f64, u64) {
45    let n = samples.len();
46    if n == 0 {
47        return (0.0, 0);
48    }
49    let median = median_of(samples);
50    if n < (*min_samples) as usize {
51        return (median, 0);
52    }
53    let mean = samples.iter().sum::<f64>() / n as f64;
54    let var = samples
55        .iter()
56        .map(|x| {
57            let d = x - mean;
58            d * d
59        })
60        .sum::<f64>()
61        / n as f64;
62    let threshold = (*margin) * median.abs().max(1.0);
63    let stable = if var.sqrt() <= threshold { 1 } else { 0 };
64    (median, stable)
65}
66
67/// The median by selection rather than a full sort: linear in the
68/// window's length. The window is copied, since the input is shared.
69fn median_of(samples: &[f64]) -> f64 {
70    let mut s = samples.to_vec();
71    let n = s.len();
72    let mid = n / 2;
73    let (lower, upper, _) = s.select_nth_unstable_by(mid, f64::total_cmp);
74    let upper = *upper;
75    if n % 2 == 1 {
76        upper
77    } else {
78        let below = lower.iter().copied().fold(f64::NEG_INFINITY, f64::max);
79        0.5 * (below + upper)
80    }
81}
82
83#[cfg(test)]
84mod tests {
85    use super::*;
86    use polydat::ast::{PolydatNode, SliceArc, Value};
87
88    fn window(v: &[f64]) -> Value {
89        Value::VecF64(SliceArc::from_vec(v.to_vec()))
90    }
91
92    fn eval(node: &IsStable, samples: &[f64]) -> (f64, u64) {
93        let mut out = [Value::None, Value::None];
94        node.eval(&[window(samples)], &mut out);
95        (out[0].as_f64(), out[1].as_u64())
96    }
97
98    /// A window that holds a ramp is not steady; once the host's window
99    /// holds only the steady tail, the signal settles at its level.
100    #[test]
101    fn settles_on_a_steady_window_and_reports_the_level() {
102        let node = IsStable::new(0.05, 4);
103        let (_, stable) = eval(&node, &[0.0, 1.0, 3.0, 4.5, 5.0, 5.0, 5.0, 5.0]);
104        assert_eq!(stable, 0, "a window with the ramp in it is not steady");
105        let (level, stable) = eval(&node, &[5.0; 8]);
106        assert_eq!(stable, 1, "a steady window is stable");
107        assert!(
108            (level - 5.0).abs() < 1e-9,
109            "the level is the steady value, got {level}"
110        );
111    }
112
113    /// One trailing outlier moves the median barely and breaks the strict
114    /// steady-state test.
115    #[test]
116    fn a_trailing_outlier_does_not_move_the_level() {
117        let node = IsStable::new(0.05, 4);
118        let (level, stable) = eval(&node, &[5.0, 5.0, 5.0, 5.0, 5.0, 5.0, 5.0, 0.0]);
119        assert!(
120            (level - 5.0).abs() < 1e-9,
121            "the median absorbs one outlier, got {level}"
122        );
123        assert_eq!(stable, 0, "one outlier breaks strict steady state");
124    }
125
126    /// Below `min_samples` the signal is never stable, however clean.
127    #[test]
128    fn reports_unstable_until_min_samples() {
129        let node = IsStable::new(0.05, 4);
130        assert_eq!(eval(&node, &[5.0]).1, 0);
131        assert_eq!(eval(&node, &[5.0; 3]).1, 0);
132        assert_eq!(eval(&node, &[5.0; 4]).1, 1);
133    }
134
135    /// A level near zero still settles, thanks to the absolute floor.
136    #[test]
137    fn a_near_zero_level_still_settles() {
138        let node = IsStable::new(0.05, 4);
139        let (level, stable) = eval(&node, &[0.0; 8]);
140        assert_eq!(stable, 1);
141        assert!(level.abs() < 1e-9);
142    }
143
144    /// An empty window reports no level and no stability, and an even
145    /// window's median is the mean of its two middle values.
146    #[test]
147    fn empty_and_even_windows() {
148        let node = IsStable::new(0.05, 1);
149        assert_eq!(eval(&node, &[]), (0.0, 0));
150        assert!((eval(&node, &[1.0, 4.0, 2.0, 3.0]).0 - 2.5).abs() < 1e-12);
151    }
152
153    /// Both outputs come from the same window whichever is pulled first,
154    /// and pulling them again returns the same values: the node keeps no
155    /// history.
156    #[test]
157    fn both_outputs_read_one_window_in_any_order() {
158        let mut k = polydat::dsl::compile::compile_polydat_kernel(
159            "extern samples: vec_f64\n(stable_value, stable) := is_stable(samples, 0.05, 4)",
160        )
161        .expect("compile");
162        k.set_input("samples", window(&[5.0; 8]))
163            .expect("write the window");
164        assert_eq!(k.pull("stable").as_u64(), 1);
165        assert!((k.pull("stable_value").as_f64() - 5.0).abs() < 1e-9);
166        assert_eq!(k.pull("stable").as_u64(), 1);
167    }
168}