1#[derive(Debug, Clone)]
32pub struct StatBaselineStats {
33 pub mean: f64,
35 pub std: f64,
37 pub min: f64,
39 pub max: f64,
41 pub sample_count: usize,
43}
44
45#[derive(Debug, Clone, PartialEq, Eq)]
51pub enum StatRegressionDirection {
52 Higher,
54 Lower,
56 Either,
58}
59
60#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
62pub enum StatRegressionSeverity {
63 Mild,
65 Moderate,
67 Severe,
69 Critical,
71}
72
73impl StatRegressionSeverity {
74 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#[derive(Debug, Clone)]
92pub struct StatRegressionEvent {
93 pub step: u64,
95 pub value: f64,
97 pub z_score: f64,
99 pub relative_change: f64,
101 pub severity: StatRegressionSeverity,
103}
104
105#[derive(Debug, Clone)]
111pub struct StatRegressionConfig {
112 pub z_score_threshold: f64,
114 pub relative_threshold: f64,
116 pub min_samples_for_detection: usize,
118 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
133pub 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 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 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 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 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 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 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 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 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#[derive(Debug, Clone, PartialEq, Eq)]
306pub enum ChangeDirection {
307 Up,
309 Down,
311}
312
313#[derive(Debug, Clone)]
315pub struct CusumAlert {
316 pub direction: ChangeDirection,
318 pub s_value: f64,
320}
321
322pub struct CusumDetector {
354 pub k: f64,
356 pub h: f64,
358 pub s_hi: f64,
360 pub s_lo: f64,
362 pub target_mean: f64,
364 pub target_std: f64,
366}
367
368impl CusumDetector {
369 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 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; 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 pub fn reset(&mut self) {
428 self.s_hi = 0.0;
429 self.s_lo = 0.0;
430 }
431}
432
433#[cfg(test)]
438mod tests {
439 use super::*;
440
441 #[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 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 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 #[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); 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); assert!((rel - 0.5).abs() < 1e-9, "rel={rel}");
517 }
518
519 #[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 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 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 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, };
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 let below = mean - 5.0 * std;
577 assert!(detector.check_point(1, below).is_none());
578 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 #[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 assert_eq!(
642 StatRegressionSeverity::from_relative_change(-0.60),
643 StatRegressionSeverity::Critical
644 );
645 }
646
647 #[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 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 cusum.s_hi = 4.4;
706 let alert = cusum.update(0.2);
707 assert!(alert.is_some());
708 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}