#[derive(Debug, Clone)]
pub struct StatBaselineStats {
pub mean: f64,
pub std: f64,
pub min: f64,
pub max: f64,
pub sample_count: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum StatRegressionDirection {
Higher,
Lower,
Either,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub enum StatRegressionSeverity {
Mild,
Moderate,
Severe,
Critical,
}
impl StatRegressionSeverity {
pub fn from_relative_change(rel: f64) -> Self {
let abs = rel.abs();
if abs > 0.50 {
Self::Critical
} else if abs > 0.25 {
Self::Severe
} else if abs > 0.10 {
Self::Moderate
} else {
Self::Mild
}
}
}
#[derive(Debug, Clone)]
pub struct StatRegressionEvent {
pub step: u64,
pub value: f64,
pub z_score: f64,
pub relative_change: f64,
pub severity: StatRegressionSeverity,
}
#[derive(Debug, Clone)]
pub struct StatRegressionConfig {
pub z_score_threshold: f64,
pub relative_threshold: f64,
pub min_samples_for_detection: usize,
pub direction: StatRegressionDirection,
}
impl Default for StatRegressionConfig {
fn default() -> Self {
Self {
z_score_threshold: 3.0,
relative_threshold: 0.10,
min_samples_for_detection: 5,
direction: StatRegressionDirection::Either,
}
}
}
pub struct StatRegressionDetector {
pub metric_name: String,
pub baseline: StatBaselineStats,
pub config: StatRegressionConfig,
pub detection_history: Vec<StatRegressionEvent>,
}
impl StatRegressionDetector {
pub fn new(
metric_name: &str,
baseline: StatBaselineStats,
config: StatRegressionConfig,
) -> Self {
Self {
metric_name: metric_name.to_string(),
baseline,
config,
detection_history: Vec::new(),
}
}
pub fn z_score(&self, value: f64) -> f64 {
if self.baseline.std.abs() < f64::EPSILON {
return 0.0;
}
(value - self.baseline.mean) / self.baseline.std
}
pub fn relative_change(&self, value: f64) -> f64 {
if self.baseline.mean.abs() < f64::EPSILON {
return 0.0;
}
(value - self.baseline.mean) / self.baseline.mean
}
pub fn check_point(&mut self, step: u64, value: f64) -> Option<StatRegressionEvent> {
if self.baseline.sample_count < self.config.min_samples_for_detection {
return None;
}
let z = self.z_score(value);
let rel = self.relative_change(value);
let direction_ok = match self.config.direction {
StatRegressionDirection::Higher => rel < -self.config.relative_threshold,
StatRegressionDirection::Lower => rel > self.config.relative_threshold,
StatRegressionDirection::Either => rel.abs() > self.config.relative_threshold,
};
if z.abs() < self.config.z_score_threshold || !direction_ok {
return None;
}
let severity = StatRegressionSeverity::from_relative_change(rel);
let event = StatRegressionEvent { step, value, z_score: z, relative_change: rel, severity };
self.detection_history.push(event.clone());
Some(event)
}
pub fn recent_events(&self, n: usize) -> &[StatRegressionEvent] {
let len = self.detection_history.len();
let start = len.saturating_sub(n);
&self.detection_history[start..]
}
pub fn build_baseline(samples: &[f64]) -> StatBaselineStats {
if samples.is_empty() {
return StatBaselineStats {
mean: 0.0,
std: 0.0,
min: 0.0,
max: 0.0,
sample_count: 0,
};
}
let mut count = 0usize;
let mut mean = 0.0f64;
let mut m2 = 0.0f64;
let mut min = f64::INFINITY;
let mut max = f64::NEG_INFINITY;
for &x in samples {
count += 1;
let delta = x - mean;
mean += delta / count as f64;
let delta2 = x - mean;
m2 += delta * delta2;
if x < min {
min = x;
}
if x > max {
max = x;
}
}
let variance = if count > 1 { m2 / (count - 1) as f64 } else { 0.0 };
let std = variance.sqrt();
StatBaselineStats { mean, std, min, max, sample_count: count }
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ChangeDirection {
Up,
Down,
}
#[derive(Debug, Clone)]
pub struct CusumAlert {
pub direction: ChangeDirection,
pub s_value: f64,
}
pub struct CusumDetector {
pub k: f64,
pub h: f64,
pub s_hi: f64,
pub s_lo: f64,
pub target_mean: f64,
pub target_std: f64,
}
impl CusumDetector {
pub fn new(target_mean: f64, target_std: f64, k: f64, h: f64) -> Self {
Self {
k,
h,
s_hi: 0.0,
s_lo: 0.0,
target_mean,
target_std,
}
}
pub fn update(&mut self, value: f64) -> Option<CusumAlert> {
let z = if self.target_std.abs() > f64::EPSILON {
(value - self.target_mean) / self.target_std
} else {
value - self.target_mean
};
self.s_hi = (self.s_hi + z - self.k).max(0.0);
self.s_lo = (self.s_lo - z - self.k).max(0.0);
if self.s_hi > self.h {
let s_value = self.s_hi;
self.s_hi = 0.0; return Some(CusumAlert { direction: ChangeDirection::Up, s_value });
}
if self.s_lo > self.h {
let s_value = self.s_lo;
self.s_lo = 0.0;
return Some(CusumAlert { direction: ChangeDirection::Down, s_value });
}
None
}
pub fn reset(&mut self) {
self.s_hi = 0.0;
self.s_lo = 0.0;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_build_baseline_mean() {
let samples = [1.0, 2.0, 3.0, 4.0, 5.0];
let b = StatRegressionDetector::build_baseline(&samples);
assert!((b.mean - 3.0).abs() < 1e-9);
assert_eq!(b.sample_count, 5);
assert_eq!(b.min, 1.0);
assert_eq!(b.max, 5.0);
}
#[test]
fn test_build_baseline_std() {
let samples = [1.0f64, 2.0, 3.0, 4.0, 5.0];
let b = StatRegressionDetector::build_baseline(&samples);
assert!((b.mean - 3.0).abs() < 1e-9);
let expected_std = 2.5f64.sqrt();
assert!((b.std - expected_std).abs() < 1e-9, "std={}", b.std);
assert!(b.std > 0.0);
}
#[test]
fn test_build_baseline_empty() {
let b = StatRegressionDetector::build_baseline(&[]);
assert_eq!(b.sample_count, 0);
assert_eq!(b.mean, 0.0);
}
#[test]
fn test_build_baseline_single() {
let b = StatRegressionDetector::build_baseline(&[42.0]);
assert_eq!(b.mean, 42.0);
assert_eq!(b.std, 0.0);
assert_eq!(b.sample_count, 1);
}
#[test]
fn test_z_score_positive() {
let samples: Vec<f64> = (0..10).map(|i| i as f64).collect();
let baseline = StatRegressionDetector::build_baseline(&samples);
let detector = StatRegressionDetector::new("metric", baseline, Default::default());
let z = detector.z_score(20.0); assert!(z > 3.0, "z should be > 3.0, got {z}");
}
#[test]
fn test_z_score_zero_std() {
let baseline = StatBaselineStats { mean: 5.0, std: 0.0, min: 5.0, max: 5.0, sample_count: 5 };
let detector = StatRegressionDetector::new("m", baseline, Default::default());
assert_eq!(detector.z_score(5.0), 0.0);
assert_eq!(detector.z_score(10.0), 0.0);
}
#[test]
fn test_relative_change_positive() {
let baseline = StatBaselineStats { mean: 2.0, std: 0.1, min: 1.9, max: 2.1, sample_count: 10 };
let detector = StatRegressionDetector::new("x", baseline, Default::default());
let rel = detector.relative_change(3.0); assert!((rel - 0.5).abs() < 1e-9, "rel={rel}");
}
#[test]
fn test_check_point_detects_regression() {
let samples: Vec<f64> = (0..20).map(|_| 1.0).collect();
let baseline = StatRegressionDetector::build_baseline(&samples);
let config = StatRegressionConfig {
z_score_threshold: 2.0,
relative_threshold: 0.10,
min_samples_for_detection: 5,
direction: StatRegressionDirection::Either,
};
let samples2: Vec<f64> = (0..20).map(|i| 1.0 + (i as f64) * 0.01).collect();
let baseline2 = StatRegressionDetector::build_baseline(&samples2);
let mut detector = StatRegressionDetector::new("loss", baseline2, config);
let mean = detector.baseline.mean;
let std = detector.baseline.std;
let far_value = mean + 6.0 * std;
let event = detector.check_point(100, far_value);
assert!(event.is_some(), "should detect regression for extreme value");
}
#[test]
fn test_check_point_no_detection_below_threshold() {
let samples: Vec<f64> = (0..30).map(|i| 10.0 + (i as f64) * 0.1).collect();
let baseline = StatRegressionDetector::build_baseline(&samples);
let config = StatRegressionConfig {
z_score_threshold: 3.0,
relative_threshold: 0.50,
min_samples_for_detection: 5,
direction: StatRegressionDirection::Either,
};
let mut detector = StatRegressionDetector::new("acc", baseline, config);
let close_val = detector.baseline.mean * 1.01;
let event = detector.check_point(1, close_val);
assert!(event.is_none(), "should not detect for small deviation");
}
#[test]
fn test_check_point_direction_lower_only() {
let samples: Vec<f64> = (0..20).map(|i| 10.0 + (i as f64) * 0.05).collect();
let baseline = StatRegressionDetector::build_baseline(&samples);
let config = StatRegressionConfig {
z_score_threshold: 1.5,
relative_threshold: 0.05,
min_samples_for_detection: 5,
direction: StatRegressionDirection::Lower, };
let mut detector = StatRegressionDetector::new("loss", baseline, config);
let mean = detector.baseline.mean;
let std = detector.baseline.std.max(0.05);
let below = mean - 5.0 * std;
assert!(detector.check_point(1, below).is_none());
let above = mean + 5.0 * std;
assert!(detector.check_point(2, above).is_some());
}
#[test]
fn test_check_point_insufficient_samples() {
let baseline = StatBaselineStats { mean: 5.0, std: 1.0, min: 4.0, max: 6.0, sample_count: 2 };
let config = StatRegressionConfig {
min_samples_for_detection: 10,
..Default::default()
};
let mut detector = StatRegressionDetector::new("m", baseline, config);
assert!(detector.check_point(0, 100.0).is_none());
}
#[test]
fn test_recent_events() {
let samples: Vec<f64> = (0..30).map(|i| i as f64 * 0.1).collect();
let baseline = StatRegressionDetector::build_baseline(&samples);
let config = StatRegressionConfig {
z_score_threshold: 1.0,
relative_threshold: 0.05,
min_samples_for_detection: 5,
direction: StatRegressionDirection::Either,
};
let mut detector = StatRegressionDetector::new("m", baseline, config);
let mean = detector.baseline.mean;
let std = detector.baseline.std.max(0.01);
for step in 0..5_u64 {
detector.check_point(step, mean + 10.0 * std);
}
let recent = detector.recent_events(3);
assert!(recent.len() <= 3);
}
#[test]
fn test_severity_thresholds() {
assert_eq!(StatRegressionSeverity::from_relative_change(0.05), StatRegressionSeverity::Mild);
assert_eq!(StatRegressionSeverity::from_relative_change(0.15), StatRegressionSeverity::Moderate);
assert_eq!(StatRegressionSeverity::from_relative_change(0.30), StatRegressionSeverity::Severe);
assert_eq!(StatRegressionSeverity::from_relative_change(0.60), StatRegressionSeverity::Critical);
assert_eq!(StatRegressionSeverity::from_relative_change(-0.60), StatRegressionSeverity::Critical);
}
#[test]
fn test_cusum_no_alert_for_in_control() {
let mut cusum = CusumDetector::new(0.0, 1.0, 0.5, 4.0);
for i in 0..50 {
let v = if i % 2 == 0 { 0.1 } else { -0.1 };
assert!(cusum.update(v).is_none(), "should not alert for in-control data");
}
}
#[test]
fn test_cusum_detects_upward_shift() {
let mut cusum = CusumDetector::new(0.0, 1.0, 0.5, 4.0);
let mut alerted = false;
for _ in 0..50 {
if let Some(a) = cusum.update(2.0) {
assert_eq!(a.direction, ChangeDirection::Up);
assert!(a.s_value > 4.0);
alerted = true;
break;
}
}
assert!(alerted, "CUSUM must detect upward shift");
}
#[test]
fn test_cusum_detects_downward_shift() {
let mut cusum = CusumDetector::new(0.0, 1.0, 0.5, 4.0);
let mut alerted = false;
for _ in 0..50 {
if let Some(a) = cusum.update(-2.0) {
assert_eq!(a.direction, ChangeDirection::Down);
alerted = true;
break;
}
}
assert!(alerted, "CUSUM must detect downward shift");
}
#[test]
fn test_cusum_reset() {
let mut cusum = CusumDetector::new(0.0, 1.0, 0.5, 4.0);
cusum.s_hi = 3.9;
cusum.s_lo = 3.9;
cusum.reset();
assert_eq!(cusum.s_hi, 0.0);
assert_eq!(cusum.s_lo, 0.0);
}
#[test]
fn test_cusum_alert_resets_accumulator() {
let mut cusum = CusumDetector::new(0.0, 1.0, 0.5, 4.0);
cusum.s_hi = 4.4;
let alert = cusum.update(0.2);
assert!(alert.is_some());
assert_eq!(cusum.s_hi, 0.0);
}
#[test]
fn test_cusum_zero_std_uses_raw_deviation() {
let mut cusum = CusumDetector::new(5.0, 0.0, 0.5, 4.0);
let mut alerted = false;
for _ in 0..20 {
if cusum.update(7.0).is_some() {
alerted = true;
break;
}
}
assert!(alerted, "CUSUM with zero std should still detect large deviation");
}
}