Skip to main content

nucleide_material/
cusum.rs

1//! One-sided upper Page CUSUM change detector over a scalar stream.
2//!
3//! Dependency-free: the in-control mean and variance are tracked online
4//! with Welford's recurrence, and the CUSUM statistic follows Page's
5//! cumulative-sum rule with reference shift and alarm threshold scaled by
6//! the running standard deviation. No simulator concepts (time, agents)
7//! are involved: this is a pure function of the observed sequence.
8//!
9//! Update order per observation `x` (after skipping non-finite inputs,
10//! which leave the state untouched):
11//!
12//! ```text
13//! n += 1
14//! mean += (x - mean) / n            (Welford)
15//! M2   += (x - mean_old) * (x - mean)
16//! std   = sqrt(M2 / (n - 1))        (sample std; 0 for n < 2)
17//! S     = max(0, S + (x - mean) - k * std)
18//! alarm = n > startup && S > h * std
19//! ```
20
21/// One-sided upper Page CUSUM detector with Welford running statistics.
22///
23/// `ref_shift_k` is the reference shift in units of the running standard
24/// deviation (the smallest sustained mean shift worth flagging),
25/// `alarm_h` is the alarm threshold in the same units, and `startup` is
26/// the number of initial observations during which alarms are suppressed
27/// while the running statistics settle.
28#[derive(Debug, Clone, PartialEq)]
29pub struct Cusum {
30    ref_shift_k: f64,
31    alarm_h: f64,
32    startup: usize,
33    count: usize,
34    mean: f64,
35    m2: f64,
36    statistic: f64,
37    alarmed: bool,
38}
39
40impl Cusum {
41    /// Build a detector; fails when `ref_shift_k` is negative or
42    /// non-finite, `alarm_h` is non-positive or non-finite.
43    pub fn new(ref_shift_k: f64, alarm_h: f64, startup: usize) -> crate::Result<Self> {
44        if !ref_shift_k.is_finite() || ref_shift_k < 0.0 {
45            return Err(crate::Error::InvalidCusum(format!(
46                "reference shift k must be finite and >= 0, got {ref_shift_k}"
47            )));
48        }
49        if !alarm_h.is_finite() || alarm_h <= 0.0 {
50            return Err(crate::Error::InvalidCusum(format!(
51                "alarm threshold h must be finite and > 0, got {alarm_h}"
52            )));
53        }
54        Ok(Self {
55            ref_shift_k,
56            alarm_h,
57            startup,
58            count: 0,
59            mean: 0.0,
60            m2: 0.0,
61            statistic: 0.0,
62            alarmed: false,
63        })
64    }
65
66    /// Number of observations consumed (non-finite inputs do not count).
67    pub fn count(&self) -> usize {
68        self.count
69    }
70
71    /// Running mean of the observations seen so far (0 with no data).
72    pub fn mean(&self) -> f64 {
73        self.mean
74    }
75
76    /// Running sample variance (`M2 / (n - 1)`; 0 with fewer than 2 points).
77    pub fn variance(&self) -> f64 {
78        if self.count >= 2 {
79            self.m2 / (self.count as f64 - 1.0)
80        } else {
81            0.0
82        }
83    }
84
85    /// Running sample standard deviation.
86    pub fn std(&self) -> f64 {
87        self.variance().sqrt()
88    }
89
90    /// Current CUSUM statistic `S >= 0`.
91    pub fn statistic(&self) -> f64 {
92        self.statistic
93    }
94
95    /// Whether the detector is currently alarmed.
96    ///
97    /// Live (not latched): re-evaluated on every [`Cusum::update`] as
98    /// `count > startup && statistic > h * std`.
99    pub fn status(&self) -> bool {
100        self.alarmed
101    }
102
103    /// Feed one observation; returns the resulting alarm [`Cusum::status`].
104    ///
105    /// Non-finite inputs are ignored (no state change) so a corrupt sample
106    /// cannot poison the running statistics.
107    pub fn update(&mut self, x: f64) -> bool {
108        if !x.is_finite() {
109            return self.alarmed;
110        }
111        self.count += 1;
112        let n = self.count as f64;
113        let old_mean = self.mean;
114        self.mean += (x - old_mean) / n;
115        self.m2 += (x - old_mean) * (x - self.mean);
116        let std = self.std();
117        let shifted = x - self.mean - self.ref_shift_k * std;
118        self.statistic = (self.statistic + shifted).max(0.0);
119        self.alarmed = self.count > self.startup && self.statistic > self.alarm_h * std;
120        self.alarmed
121    }
122
123    /// Drop all observations; tuning parameters are kept.
124    pub fn reset(&mut self) {
125        self.count = 0;
126        self.mean = 0.0;
127        self.m2 = 0.0;
128        self.statistic = 0.0;
129        self.alarmed = false;
130    }
131}
132
133impl Default for Cusum {
134    /// Canonical safeguards tuning: `k = 0.5`, `h = 4.0`, `startup = 10`.
135    fn default() -> Self {
136        Self {
137            ref_shift_k: 0.5,
138            alarm_h: 4.0,
139            startup: 10,
140            count: 0,
141            mean: 0.0,
142            m2: 0.0,
143            statistic: 0.0,
144            alarmed: false,
145        }
146    }
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152
153    fn close(a: f64, b: f64, tol: f64) {
154        assert!(
155            (a - b).abs() <= tol,
156            "{a} != {b} within absolute tolerance {tol}"
157        );
158    }
159
160    #[test]
161    fn default_tuning_matches_canonical_values() {
162        let cusum = Cusum::default();
163        assert_eq!(cusum.ref_shift_k, 0.5);
164        assert_eq!(cusum.alarm_h, 4.0);
165        assert_eq!(cusum.startup, 10);
166        assert_eq!(cusum.count(), 0);
167        assert_eq!(cusum.statistic(), 0.0);
168        assert!(!cusum.status());
169        // `new` with the same tuning agrees with `default`.
170        assert_eq!(Cusum::new(0.5, 4.0, 10).unwrap(), cusum);
171    }
172
173    #[test]
174    fn invalid_parameters_rejected() {
175        assert!(matches!(
176            Cusum::new(f64::NAN, 4.0, 10),
177            Err(crate::Error::InvalidCusum(_))
178        ));
179        assert!(matches!(
180            Cusum::new(-0.1, 4.0, 10),
181            Err(crate::Error::InvalidCusum(_))
182        ));
183        assert!(matches!(
184            Cusum::new(0.5, 0.0, 10),
185            Err(crate::Error::InvalidCusum(_))
186        ));
187        assert!(matches!(
188            Cusum::new(0.5, f64::INFINITY, 10),
189            Err(crate::Error::InvalidCusum(_))
190        ));
191    }
192
193    #[test]
194    fn welford_tracks_naive_mean_and_sample_variance() {
195        // Hand-picked sequence; oracle moments recomputed naively below.
196        let xs = [2.0, 4.0, 4.0, 4.0, 5.0, 5.0, 7.0, 9.0];
197        let mut cusum = Cusum::new(0.5, 4.0, 100).unwrap();
198        let mut seen: Vec<f64> = Vec::new();
199        for &x in &xs {
200            seen.push(x);
201            cusum.update(x);
202            let n = seen.len() as f64;
203            let mean = seen.iter().sum::<f64>() / n;
204            let var = if seen.len() >= 2 {
205                seen.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / (n - 1.0)
206            } else {
207                0.0
208            };
209            close(cusum.mean(), mean, 1e-12);
210            close(cusum.variance(), var, 1e-12);
211            close(cusum.std(), var.sqrt(), 1e-12);
212            assert_eq!(cusum.count(), seen.len());
213        }
214        // Naive oracle totals for the full sequence: sum 40, mean 5.0,
215        // M2 = 9 + 1 + 1 + 1 + 0 + 0 + 4 + 16 = 32.0.
216        close(cusum.mean(), 5.0, 1e-12);
217        close(cusum.variance(), 32.0 / 7.0, 1e-12);
218    }
219
220    #[test]
221    fn constant_stream_never_alarms() {
222        let mut cusum = Cusum::default();
223        for _ in 0..50 {
224            assert!(!cusum.update(1.0));
225        }
226        assert_eq!(cusum.statistic(), 0.0);
227        assert_eq!(cusum.mean(), 1.0);
228        assert_eq!(cusum.variance(), 0.0);
229        assert!(!cusum.status());
230    }
231
232    #[test]
233    fn step_change_alarms_after_changepoint() {
234        // Ten in-control points at 1.0, then a sustained step to 2.0.
235        let mut cusum = Cusum::default();
236        for _ in 0..10 {
237            assert!(!cusum.update(1.0), "baseline must stay quiet");
238        }
239        assert_eq!(cusum.statistic(), 0.0);
240        // First two post-change points build the statistic without firing.
241        // At n = 11: mean = 12/11, var = 1/11,
242        //   S = 2 - 12/11 - 0.5*sqrt(1/11) = 0.7583352368020273.
243        // At n = 12: mean = 14/12, var = 2/11,
244        //   S = S11 + (2 - 14/12) - 0.5*sqrt(2/11) = 1.39704383409498.
245        assert!(!cusum.update(2.0));
246        close(cusum.statistic(), 0.7583352368020273, 1e-12);
247        assert!(!cusum.update(2.0));
248        close(cusum.statistic(), 1.39704383409498, 1e-12);
249        // The shift persists, so the detector must fire on the very next
250        // point (n = 13, S = 1.947010098498992 > 4*std) and stay alarmed.
251        assert!(cusum.update(2.0));
252        close(cusum.statistic(), 1.947010098498992, 1e-12);
253        assert!(cusum.status());
254        assert_eq!(cusum.count(), 13);
255    }
256
257    #[test]
258    fn reset_clears_state_but_keeps_tuning() {
259        let mut cusum = Cusum::default();
260        for _ in 0..10 {
261            cusum.update(1.0);
262        }
263        for _ in 0..10 {
264            cusum.update(2.0);
265        }
266        assert!(cusum.status());
267        cusum.reset();
268        assert_eq!(cusum.count(), 0);
269        assert_eq!(cusum.mean(), 0.0);
270        assert_eq!(cusum.variance(), 0.0);
271        assert_eq!(cusum.statistic(), 0.0);
272        assert!(!cusum.status());
273        assert_eq!(cusum, Cusum::default());
274    }
275
276    #[test]
277    fn non_finite_inputs_leave_state_untouched() {
278        let mut cusum = Cusum::default();
279        cusum.update(1.0);
280        let snapshot = cusum.clone();
281        assert_eq!(cusum.update(f64::NAN), snapshot.status());
282        assert_eq!(cusum.update(f64::INFINITY), snapshot.status());
283        assert_eq!(cusum, snapshot);
284    }
285}