Skip to main content

trustformers_debug/regression/
statistical.rs

1//! Statistical regression detection algorithms.
2//!
3//! Provides:
4//! - [`StatRegressionDetector`] — z-score + relative-change detector with
5//!   Welford online mean/variance estimation.
6//! - [`CusumDetector`] — CUSUM sequential change-point detection algorithm.
7//!
8//! These supplement the performance-baseline detector in `detector.rs` with
9//! algorithms operating directly on a streaming sequence of scalar metric
10//! values rather than on full profiling reports.
11
12// ─────────────────────────────────────────────────────────────────────────────
13// BaselineStats
14// ─────────────────────────────────────────────────────────────────────────────
15
16/// Statistical summary of a reference sample set.
17///
18/// Built via [`StatRegressionDetector::build_baseline`] using Welford's
19/// online algorithm for numerically stable mean and variance.
20///
21/// # Example
22///
23/// ```
24/// use trustformers_debug::regression::statistical::StatRegressionDetector;
25///
26/// let samples = [1.0, 2.0, 3.0, 4.0, 5.0];
27/// let b = StatRegressionDetector::build_baseline(&samples);
28/// assert!((b.mean - 3.0).abs() < 1e-9);
29/// assert_eq!(b.sample_count, 5);
30/// ```
31#[derive(Debug, Clone)]
32pub struct StatBaselineStats {
33    /// Arithmetic mean of the baseline samples.
34    pub mean: f64,
35    /// Sample standard deviation (Bessel-corrected).
36    pub std: f64,
37    /// Minimum value in the baseline sample set.
38    pub min: f64,
39    /// Maximum value in the baseline sample set.
40    pub max: f64,
41    /// Number of samples used to compute these statistics.
42    pub sample_count: usize,
43}
44
45// ─────────────────────────────────────────────────────────────────────────────
46// RegressionDirection / RegressionSeverity / RegressionEvent
47// ─────────────────────────────────────────────────────────────────────────────
48
49/// Indicates the direction that is considered "bad" for a metric.
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub enum StatRegressionDirection {
52    /// Metric should increase over time (e.g. accuracy, F1).
53    Higher,
54    /// Metric should decrease over time (e.g. loss, latency).
55    Lower,
56    /// Flag any significant deviation in either direction.
57    Either,
58}
59
60/// Severity class for a detected regression event.
61#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
62pub enum StatRegressionSeverity {
63    /// |z| or |rel_change| is slightly above threshold.
64    Mild,
65    /// Clearly detectable regression (|rel_change| > 10 %).
66    Moderate,
67    /// Strong regression (|rel_change| > 25 %).
68    Severe,
69    /// Extreme regression (|rel_change| > 50 %).
70    Critical,
71}
72
73impl StatRegressionSeverity {
74    /// Determines severity from the absolute relative change (expressed as a
75    /// fraction, not a percentage).
76    pub fn from_relative_change(rel: f64) -> Self {
77        let abs = rel.abs();
78        if abs > 0.50 {
79            Self::Critical
80        } else if abs > 0.25 {
81            Self::Severe
82        } else if abs > 0.10 {
83            Self::Moderate
84        } else {
85            Self::Mild
86        }
87    }
88}
89
90/// A single detected regression event.
91#[derive(Debug, Clone)]
92pub struct StatRegressionEvent {
93    /// Training step at which the anomaly was observed.
94    pub step: u64,
95    /// The observed metric value.
96    pub value: f64,
97    /// Signed z-score: `(value − mean) / std`.
98    pub z_score: f64,
99    /// Signed relative change: `(value − mean) / mean`.
100    pub relative_change: f64,
101    /// Severity class.
102    pub severity: StatRegressionSeverity,
103}
104
105// ─────────────────────────────────────────────────────────────────────────────
106// StatRegressionConfig
107// ─────────────────────────────────────────────────────────────────────────────
108
109/// Configuration for [`StatRegressionDetector`].
110#[derive(Debug, Clone)]
111pub struct StatRegressionConfig {
112    /// Minimum |z-score| to flag a regression (default: 3.0).
113    pub z_score_threshold: f64,
114    /// Minimum |relative change| to flag a regression (default: 0.10 = 10 %).
115    pub relative_threshold: f64,
116    /// Minimum number of baseline samples before detection is enabled.
117    pub min_samples_for_detection: usize,
118    /// Which deviation direction triggers an alert.
119    pub direction: StatRegressionDirection,
120}
121
122impl Default for StatRegressionConfig {
123    fn default() -> Self {
124        Self {
125            z_score_threshold: 3.0,
126            relative_threshold: 0.10,
127            min_samples_for_detection: 5,
128            direction: StatRegressionDirection::Either,
129        }
130    }
131}
132
133// ─────────────────────────────────────────────────────────────────────────────
134// StatRegressionDetector
135// ─────────────────────────────────────────────────────────────────────────────
136
137/// Statistical regression detector for a single scalar training metric.
138///
139/// Uses a fixed [`StatBaselineStats`] as the reference distribution and emits
140/// [`StatRegressionEvent`]s whenever a new observation deviates significantly
141/// from that baseline.
142///
143/// # Example
144///
145/// ```
146/// use trustformers_debug::regression::statistical::{
147///     StatBaselineStats, StatRegressionConfig, StatRegressionDetector, StatRegressionDirection,
148/// };
149///
150/// let samples = [1.0f64, 1.1, 0.9, 1.05, 0.95];
151/// let baseline = StatRegressionDetector::build_baseline(&samples);
152///
153/// let config = StatRegressionConfig {
154///     z_score_threshold: 2.0,
155///     relative_threshold: 0.15,
156///     min_samples_for_detection: 2,
157///     direction: StatRegressionDirection::Either,
158/// };
159///
160/// let mut detector = StatRegressionDetector::new("loss", baseline, config);
161/// // Far-out value should trigger a regression
162/// let event = detector.check_point(10, 5.0);
163/// assert!(event.is_some());
164/// ```
165pub struct StatRegressionDetector {
166    pub metric_name: String,
167    pub baseline: StatBaselineStats,
168    pub config: StatRegressionConfig,
169    pub detection_history: Vec<StatRegressionEvent>,
170}
171
172impl StatRegressionDetector {
173    /// Creates a new detector for the named metric.
174    pub fn new(
175        metric_name: &str,
176        baseline: StatBaselineStats,
177        config: StatRegressionConfig,
178    ) -> Self {
179        Self {
180            metric_name: metric_name.to_string(),
181            baseline,
182            config,
183            detection_history: Vec::new(),
184        }
185    }
186
187    /// Computes the z-score of a new observation relative to the baseline.
188    pub fn z_score(&self, value: f64) -> f64 {
189        if self.baseline.std.abs() < f64::EPSILON {
190            return 0.0;
191        }
192        (value - self.baseline.mean) / self.baseline.std
193    }
194
195    /// Computes the relative change `(value − mean) / mean`.
196    pub fn relative_change(&self, value: f64) -> f64 {
197        if self.baseline.mean.abs() < f64::EPSILON {
198            return 0.0;
199        }
200        (value - self.baseline.mean) / self.baseline.mean
201    }
202
203    /// Evaluates a new data point and returns a [`StatRegressionEvent`] if a
204    /// regression is detected, or `None` otherwise.
205    ///
206    /// A regression is detected when:
207    /// 1. `|z_score| > config.z_score_threshold` **and**
208    /// 2. `|relative_change| > config.relative_threshold` **and**
209    /// 3. The direction constraint is satisfied.
210    ///
211    /// Detection only fires once `baseline.sample_count >= min_samples_for_detection`.
212    pub fn check_point(&mut self, step: u64, value: f64) -> Option<StatRegressionEvent> {
213        if self.baseline.sample_count < self.config.min_samples_for_detection {
214            return None;
215        }
216
217        let z = self.z_score(value);
218        let rel = self.relative_change(value);
219
220        // Check direction constraint.
221        let direction_ok = match self.config.direction {
222            StatRegressionDirection::Higher => rel < -self.config.relative_threshold,
223            StatRegressionDirection::Lower => rel > self.config.relative_threshold,
224            StatRegressionDirection::Either => rel.abs() > self.config.relative_threshold,
225        };
226
227        if z.abs() < self.config.z_score_threshold || !direction_ok {
228            return None;
229        }
230
231        let severity = StatRegressionSeverity::from_relative_change(rel);
232        let event = StatRegressionEvent {
233            step,
234            value,
235            z_score: z,
236            relative_change: rel,
237            severity,
238        };
239        self.detection_history.push(event.clone());
240        Some(event)
241    }
242
243    /// Returns the last `n` events in detection history (oldest first).
244    pub fn recent_events(&self, n: usize) -> &[StatRegressionEvent] {
245        let len = self.detection_history.len();
246        let start = len.saturating_sub(n);
247        &self.detection_history[start..]
248    }
249
250    /// Builds a [`StatBaselineStats`] from a slice of samples using Welford's
251    /// one-pass algorithm for numerically stable variance computation.
252    ///
253    /// Returns a baseline with `sample_count = 0` and all zeros when `samples`
254    /// is empty.
255    pub fn build_baseline(samples: &[f64]) -> StatBaselineStats {
256        if samples.is_empty() {
257            return StatBaselineStats {
258                mean: 0.0,
259                std: 0.0,
260                min: 0.0,
261                max: 0.0,
262                sample_count: 0,
263            };
264        }
265
266        // Welford's online algorithm
267        let mut count = 0usize;
268        let mut mean = 0.0f64;
269        let mut m2 = 0.0f64;
270        let mut min = f64::INFINITY;
271        let mut max = f64::NEG_INFINITY;
272
273        for &x in samples {
274            count += 1;
275            let delta = x - mean;
276            mean += delta / count as f64;
277            let delta2 = x - mean;
278            m2 += delta * delta2;
279            if x < min {
280                min = x;
281            }
282            if x > max {
283                max = x;
284            }
285        }
286
287        let variance = if count > 1 { m2 / (count - 1) as f64 } else { 0.0 };
288        let std = variance.sqrt();
289
290        StatBaselineStats {
291            mean,
292            std,
293            min,
294            max,
295            sample_count: count,
296        }
297    }
298}
299
300// ─────────────────────────────────────────────────────────────────────────────
301// CUSUM
302// ─────────────────────────────────────────────────────────────────────────────
303
304/// The direction of a CUSUM change-point alert.
305#[derive(Debug, Clone, PartialEq, Eq)]
306pub enum ChangeDirection {
307    /// The process mean appears to have shifted upward.
308    Up,
309    /// The process mean appears to have shifted downward.
310    Down,
311}
312
313/// Alert emitted by [`CusumDetector`] when a change point is detected.
314#[derive(Debug, Clone)]
315pub struct CusumAlert {
316    /// Direction of the detected mean shift.
317    pub direction: ChangeDirection,
318    /// The cumulative-sum value that crossed the decision threshold.
319    pub s_value: f64,
320}
321
322/// CUSUM (Cumulative Sum) sequential change-point detector.
323///
324/// Detects a sustained shift in the mean of a metric stream.  The algorithm
325/// maintains two accumulators:
326///
327/// - `S_hi` detects **upward** mean shifts (metric increasing).
328/// - `S_lo` detects **downward** mean shifts (metric decreasing).
329///
330/// # Parameters
331///
332/// - `k` — allowable slack, typically `0.5 * σ * shift_size_in_sigmas`.
333/// - `h` — decision threshold (number of standard deviations of the
334///   cumulative sum before an alarm is raised).  A common choice is `4.0 – 5.0`.
335///
336/// # Example
337///
338/// ```
339/// use trustformers_debug::regression::statistical::{CusumDetector, ChangeDirection};
340///
341/// let mut cusum = CusumDetector::new(0.0, 1.0, 0.5, 4.0);
342/// // feed values well above the target mean — should eventually alert
343/// let mut alerted = false;
344/// for _ in 0..20 {
345///     if let Some(a) = cusum.update(3.0) {
346///         assert_eq!(a.direction, ChangeDirection::Up);
347///         alerted = true;
348///         break;
349///     }
350/// }
351/// assert!(alerted, "CUSUM should have detected upward shift");
352/// ```
353pub struct CusumDetector {
354    /// Allowable slack (reference value).
355    pub k: f64,
356    /// Decision threshold — alarm when `S_hi > h` or `S_lo > h`.
357    pub h: f64,
358    /// Upper cumulative sum.
359    pub s_hi: f64,
360    /// Lower cumulative sum.
361    pub s_lo: f64,
362    /// Target (in-control) process mean.
363    pub target_mean: f64,
364    /// Target (in-control) process standard deviation.
365    pub target_std: f64,
366}
367
368impl CusumDetector {
369    /// Creates a new CUSUM detector.
370    ///
371    /// # Arguments
372    ///
373    /// - `target_mean` — in-control mean.
374    /// - `target_std` — in-control standard deviation (used to normalise inputs).
375    /// - `k` — slack parameter (0.5 is a common default for detecting 1σ shifts).
376    /// - `h` — threshold (4.0–5.0 gives low false-alarm rates for Gaussian inputs).
377    pub fn new(target_mean: f64, target_std: f64, k: f64, h: f64) -> Self {
378        Self {
379            k,
380            h,
381            s_hi: 0.0,
382            s_lo: 0.0,
383            target_mean,
384            target_std,
385        }
386    }
387
388    /// Incorporates a new observation and returns an alert if a change point is
389    /// detected.
390    ///
391    /// The observation is first standardised as `z = (value − target_mean) / target_std`
392    /// (unless `target_std == 0`, in which case the raw deviation is used).
393    ///
394    /// After firing, the triggering accumulator is reset to zero so detection
395    /// can resume.  Callers that wish to track sustained changes should call
396    /// [`reset`](Self::reset) manually instead.
397    pub fn update(&mut self, value: f64) -> Option<CusumAlert> {
398        let z = if self.target_std.abs() > f64::EPSILON {
399            (value - self.target_mean) / self.target_std
400        } else {
401            value - self.target_mean
402        };
403
404        self.s_hi = (self.s_hi + z - self.k).max(0.0);
405        self.s_lo = (self.s_lo - z - self.k).max(0.0);
406
407        if self.s_hi > self.h {
408            let s_value = self.s_hi;
409            self.s_hi = 0.0; // reset after alarm
410            return Some(CusumAlert {
411                direction: ChangeDirection::Up,
412                s_value,
413            });
414        }
415        if self.s_lo > self.h {
416            let s_value = self.s_lo;
417            self.s_lo = 0.0;
418            return Some(CusumAlert {
419                direction: ChangeDirection::Down,
420                s_value,
421            });
422        }
423        None
424    }
425
426    /// Resets both cumulative sums to zero without changing parameters.
427    pub fn reset(&mut self) {
428        self.s_hi = 0.0;
429        self.s_lo = 0.0;
430    }
431}
432
433// ─────────────────────────────────────────────────────────────────────────────
434// Tests
435// ─────────────────────────────────────────────────────────────────────────────
436
437#[cfg(test)]
438mod tests {
439    use super::*;
440
441    // ── StatRegressionDetector::build_baseline ────────────────────────────────
442
443    #[test]
444    fn test_build_baseline_mean() {
445        let samples = [1.0, 2.0, 3.0, 4.0, 5.0];
446        let b = StatRegressionDetector::build_baseline(&samples);
447        assert!((b.mean - 3.0).abs() < 1e-9);
448        assert_eq!(b.sample_count, 5);
449        assert_eq!(b.min, 1.0);
450        assert_eq!(b.max, 5.0);
451    }
452
453    #[test]
454    fn test_build_baseline_std() {
455        // For [1, 2, 3, 4, 5] mean=3, sample std = sqrt(2.5) ≈ 1.5811
456        let samples = [1.0f64, 2.0, 3.0, 4.0, 5.0];
457        let b = StatRegressionDetector::build_baseline(&samples);
458        assert!((b.mean - 3.0).abs() < 1e-9);
459        // Bessel-corrected std for [1,2,3,4,5]: variance = (4+1+0+1+4)/4 = 2.5, std = sqrt(2.5)
460        let expected_std = 2.5f64.sqrt();
461        assert!((b.std - expected_std).abs() < 1e-9, "std={}", b.std);
462        assert!(b.std > 0.0);
463    }
464
465    #[test]
466    fn test_build_baseline_empty() {
467        let b = StatRegressionDetector::build_baseline(&[]);
468        assert_eq!(b.sample_count, 0);
469        assert_eq!(b.mean, 0.0);
470    }
471
472    #[test]
473    fn test_build_baseline_single() {
474        let b = StatRegressionDetector::build_baseline(&[42.0]);
475        assert_eq!(b.mean, 42.0);
476        assert_eq!(b.std, 0.0);
477        assert_eq!(b.sample_count, 1);
478    }
479
480    // ── z_score and relative_change ──────────────────────────────────────────
481
482    #[test]
483    fn test_z_score_positive() {
484        let samples: Vec<f64> = (0..10).map(|i| i as f64).collect();
485        let baseline = StatRegressionDetector::build_baseline(&samples);
486        let detector = StatRegressionDetector::new("metric", baseline, Default::default());
487        let z = detector.z_score(20.0); // far above mean (4.5)
488        assert!(z > 3.0, "z should be > 3.0, got {z}");
489    }
490
491    #[test]
492    fn test_z_score_zero_std() {
493        let baseline = StatBaselineStats {
494            mean: 5.0,
495            std: 0.0,
496            min: 5.0,
497            max: 5.0,
498            sample_count: 5,
499        };
500        let detector = StatRegressionDetector::new("m", baseline, Default::default());
501        assert_eq!(detector.z_score(5.0), 0.0);
502        assert_eq!(detector.z_score(10.0), 0.0);
503    }
504
505    #[test]
506    fn test_relative_change_positive() {
507        let baseline = StatBaselineStats {
508            mean: 2.0,
509            std: 0.1,
510            min: 1.9,
511            max: 2.1,
512            sample_count: 10,
513        };
514        let detector = StatRegressionDetector::new("x", baseline, Default::default());
515        let rel = detector.relative_change(3.0); // 50% increase
516        assert!((rel - 0.5).abs() < 1e-9, "rel={rel}");
517    }
518
519    // ── check_point regression detection ─────────────────────────────────────
520
521    #[test]
522    fn test_check_point_detects_regression() {
523        let config = StatRegressionConfig {
524            z_score_threshold: 2.0,
525            relative_threshold: 0.10,
526            min_samples_for_detection: 5,
527            direction: StatRegressionDirection::Either,
528        };
529        // Constant samples would give std == 0 (z_score always 0, nothing
530        // could ever look like a regression), so use realistic varying data.
531        let samples: Vec<f64> = (0..20).map(|i| 1.0 + (i as f64) * 0.01).collect();
532        let baseline = StatRegressionDetector::build_baseline(&samples);
533        let mut detector = StatRegressionDetector::new("loss", baseline, config);
534        // Inject a value 5 std-devs away from the mean
535        let mean = detector.baseline.mean;
536        let std = detector.baseline.std;
537        let far_value = mean + 6.0 * std;
538        let event = detector.check_point(100, far_value);
539        assert!(
540            event.is_some(),
541            "should detect regression for extreme value"
542        );
543    }
544
545    #[test]
546    fn test_check_point_no_detection_below_threshold() {
547        let samples: Vec<f64> = (0..30).map(|i| 10.0 + (i as f64) * 0.1).collect();
548        let baseline = StatRegressionDetector::build_baseline(&samples);
549        let config = StatRegressionConfig {
550            z_score_threshold: 3.0,
551            relative_threshold: 0.50,
552            min_samples_for_detection: 5,
553            direction: StatRegressionDirection::Either,
554        };
555        let mut detector = StatRegressionDetector::new("acc", baseline, config);
556        // A value only 1% away from mean should not trigger
557        let close_val = detector.baseline.mean * 1.01;
558        let event = detector.check_point(1, close_val);
559        assert!(event.is_none(), "should not detect for small deviation");
560    }
561
562    #[test]
563    fn test_check_point_direction_lower_only() {
564        let samples: Vec<f64> = (0..20).map(|i| 10.0 + (i as f64) * 0.05).collect();
565        let baseline = StatRegressionDetector::build_baseline(&samples);
566        let config = StatRegressionConfig {
567            z_score_threshold: 1.5,
568            relative_threshold: 0.05,
569            min_samples_for_detection: 5,
570            direction: StatRegressionDirection::Lower, // higher is worse
571        };
572        let mut detector = StatRegressionDetector::new("loss", baseline, config);
573        let mean = detector.baseline.mean;
574        let std = detector.baseline.std.max(0.05);
575        // Value far BELOW mean (improvement) — should NOT trigger for Lower direction
576        let below = mean - 5.0 * std;
577        assert!(detector.check_point(1, below).is_none());
578        // Value far ABOVE mean (regression for loss) — should trigger
579        let above = mean + 5.0 * std;
580        assert!(detector.check_point(2, above).is_some());
581    }
582
583    #[test]
584    fn test_check_point_insufficient_samples() {
585        let baseline = StatBaselineStats {
586            mean: 5.0,
587            std: 1.0,
588            min: 4.0,
589            max: 6.0,
590            sample_count: 2,
591        };
592        let config = StatRegressionConfig {
593            min_samples_for_detection: 10,
594            ..Default::default()
595        };
596        let mut detector = StatRegressionDetector::new("m", baseline, config);
597        assert!(detector.check_point(0, 100.0).is_none());
598    }
599
600    #[test]
601    fn test_recent_events() {
602        let samples: Vec<f64> = (0..30).map(|i| i as f64 * 0.1).collect();
603        let baseline = StatRegressionDetector::build_baseline(&samples);
604        let config = StatRegressionConfig {
605            z_score_threshold: 1.0,
606            relative_threshold: 0.05,
607            min_samples_for_detection: 5,
608            direction: StatRegressionDirection::Either,
609        };
610        let mut detector = StatRegressionDetector::new("m", baseline, config);
611        let mean = detector.baseline.mean;
612        let std = detector.baseline.std.max(0.01);
613        for step in 0..5_u64 {
614            detector.check_point(step, mean + 10.0 * std);
615        }
616        let recent = detector.recent_events(3);
617        assert!(recent.len() <= 3);
618    }
619
620    // ── StatRegressionSeverity ────────────────────────────────────────────────
621
622    #[test]
623    fn test_severity_thresholds() {
624        assert_eq!(
625            StatRegressionSeverity::from_relative_change(0.05),
626            StatRegressionSeverity::Mild
627        );
628        assert_eq!(
629            StatRegressionSeverity::from_relative_change(0.15),
630            StatRegressionSeverity::Moderate
631        );
632        assert_eq!(
633            StatRegressionSeverity::from_relative_change(0.30),
634            StatRegressionSeverity::Severe
635        );
636        assert_eq!(
637            StatRegressionSeverity::from_relative_change(0.60),
638            StatRegressionSeverity::Critical
639        );
640        // negative (improvement) uses abs
641        assert_eq!(
642            StatRegressionSeverity::from_relative_change(-0.60),
643            StatRegressionSeverity::Critical
644        );
645    }
646
647    // ── CusumDetector ─────────────────────────────────────────────────────────
648
649    #[test]
650    fn test_cusum_no_alert_for_in_control() {
651        let mut cusum = CusumDetector::new(0.0, 1.0, 0.5, 4.0);
652        // Feed values close to target mean — should not alert
653        for i in 0..50 {
654            let v = if i % 2 == 0 { 0.1 } else { -0.1 };
655            assert!(
656                cusum.update(v).is_none(),
657                "should not alert for in-control data"
658            );
659        }
660    }
661
662    #[test]
663    fn test_cusum_detects_upward_shift() {
664        let mut cusum = CusumDetector::new(0.0, 1.0, 0.5, 4.0);
665        let mut alerted = false;
666        for _ in 0..50 {
667            if let Some(a) = cusum.update(2.0) {
668                assert_eq!(a.direction, ChangeDirection::Up);
669                assert!(a.s_value > 4.0);
670                alerted = true;
671                break;
672            }
673        }
674        assert!(alerted, "CUSUM must detect upward shift");
675    }
676
677    #[test]
678    fn test_cusum_detects_downward_shift() {
679        let mut cusum = CusumDetector::new(0.0, 1.0, 0.5, 4.0);
680        let mut alerted = false;
681        for _ in 0..50 {
682            if let Some(a) = cusum.update(-2.0) {
683                assert_eq!(a.direction, ChangeDirection::Down);
684                alerted = true;
685                break;
686            }
687        }
688        assert!(alerted, "CUSUM must detect downward shift");
689    }
690
691    #[test]
692    fn test_cusum_reset() {
693        let mut cusum = CusumDetector::new(0.0, 1.0, 0.5, 4.0);
694        cusum.s_hi = 3.9;
695        cusum.s_lo = 3.9;
696        cusum.reset();
697        assert_eq!(cusum.s_hi, 0.0);
698        assert_eq!(cusum.s_lo, 0.0);
699    }
700
701    #[test]
702    fn test_cusum_alert_resets_accumulator() {
703        let mut cusum = CusumDetector::new(0.0, 1.0, 0.5, 4.0);
704        // Force s_hi to be just below threshold, then push it over.
705        cusum.s_hi = 4.4;
706        let alert = cusum.update(0.2);
707        assert!(alert.is_some());
708        // After the alert, s_hi should have been reset.
709        assert_eq!(cusum.s_hi, 0.0);
710    }
711
712    #[test]
713    fn test_cusum_zero_std_uses_raw_deviation() {
714        let mut cusum = CusumDetector::new(5.0, 0.0, 0.5, 4.0);
715        let mut alerted = false;
716        for _ in 0..20 {
717            if cusum.update(7.0).is_some() {
718                alerted = true;
719                break;
720            }
721        }
722        assert!(
723            alerted,
724            "CUSUM with zero std should still detect large deviation"
725        );
726    }
727}