#[derive(Debug, Clone, PartialEq)]
pub enum DriftStatus {
NoDrift,
Warning(f64),
Drift(f64),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AnomalySeverity {
Low,
Medium,
High,
}
#[derive(Debug, Clone)]
pub struct SlidingWindowBaseline {
window_size: usize,
values: Vec<f64>,
mean: f64,
m2: f64, count: usize,
}
impl SlidingWindowBaseline {
pub fn new(window_size: usize) -> Self {
Self { window_size, values: Vec::with_capacity(window_size), mean: 0.0, m2: 0.0, count: 0 }
}
pub fn update(&mut self, value: f64) {
contract_pre_update!();
if value.is_nan() || value.is_infinite() {
return;
}
if self.values.len() >= self.window_size {
self.values.remove(0);
}
self.values.push(value);
self.count = self.values.len();
if self.count > 0 {
self.mean = self.values.iter().sum::<f64>() / self.count as f64;
if self.count > 1 {
self.m2 = self.values.iter().map(|v| (v - self.mean).powi(2)).sum::<f64>();
}
}
}
pub fn std(&self) -> f64 {
if self.count < 2 {
return 0.0;
}
(self.m2 / (self.count - 1) as f64).sqrt()
}
pub fn z_score(&self, value: f64) -> f64 {
let std = self.std();
if std == 0.0 {
return 0.0;
}
(value - self.mean) / std
}
pub fn detect_anomaly(&self, value: f64, threshold: f64) -> Option<Anomaly> {
if self.count < 10 {
return None; }
let z = self.z_score(value).abs();
if z < threshold {
return None;
}
let severity = if z >= 5.0 {
AnomalySeverity::High
} else if z >= 4.0 {
AnomalySeverity::Medium
} else {
AnomalySeverity::Low
};
Some(Anomaly {
value,
z_score: z,
severity,
baseline_mean: self.mean,
baseline_std: self.std(),
})
}
pub fn mean(&self) -> f64 {
self.mean
}
pub fn count(&self) -> usize {
self.count
}
}
#[derive(Debug, Clone)]
pub struct Anomaly {
pub value: f64,
pub z_score: f64,
pub severity: AnomalySeverity,
pub baseline_mean: f64,
pub baseline_std: f64,
}
#[derive(Debug)]
pub struct DriftDetector {
baseline: SlidingWindowBaseline,
threshold: f64,
warning_threshold: f64,
}
impl DriftDetector {
pub fn new(window_size: usize) -> Self {
Self {
baseline: SlidingWindowBaseline::new(window_size),
threshold: 0.05, warning_threshold: 0.1, }
}
pub fn with_thresholds(mut self, warning: f64, drift: f64) -> Self {
self.warning_threshold = warning;
self.threshold = drift;
self
}
pub fn check(&mut self, value: f64) -> DriftStatus {
let z = self.baseline.z_score(value).abs();
self.baseline.update(value);
if self.baseline.count() < 10 {
return DriftStatus::NoDrift;
}
let p = z_to_p(z);
if p < self.threshold {
DriftStatus::Drift(p)
} else if p < self.warning_threshold {
DriftStatus::Warning(p)
} else {
DriftStatus::NoDrift
}
}
}
fn z_to_p(z: f64) -> f64 {
let t = 1.0 / (1.0 + 0.2316419 * z.abs());
let d = 0.3989423 * (-z * z / 2.0).exp();
let p =
d * t * (0.3193815 + t * (-0.3565638 + t * (1.781478 + t * (-1.821256 + t * 1.330274))));
2.0 * p }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_sliding_window_new() {
let baseline = SlidingWindowBaseline::new(100);
assert_eq!(baseline.count(), 0);
}
#[test]
fn test_sliding_window_update() {
let mut baseline = SlidingWindowBaseline::new(100);
for i in 0..10 {
baseline.update(f64::from(i));
}
assert_eq!(baseline.count(), 10);
assert!((baseline.mean() - 4.5).abs() < 1e-6);
}
#[test]
fn test_sliding_window_rolls() {
let mut baseline = SlidingWindowBaseline::new(5);
for i in 0..10 {
baseline.update(f64::from(i));
}
assert_eq!(baseline.count(), 5);
assert!((baseline.mean() - 7.0).abs() < 1e-6);
}
#[test]
fn test_z_score() {
let mut baseline = SlidingWindowBaseline::new(100);
for i in 0..100 {
baseline.update(f64::from(i));
}
let z_mean = baseline.z_score(50.0);
assert!(z_mean.abs() < 0.5);
}
#[test]
fn test_detect_anomaly_none() {
let mut baseline = SlidingWindowBaseline::new(100);
for i in 0..100 {
baseline.update(50.0 + f64::from(i % 5));
}
let anomaly = baseline.detect_anomaly(52.0, 3.0);
assert!(anomaly.is_none());
}
#[test]
fn test_detect_anomaly_high() {
let mut baseline = SlidingWindowBaseline::new(100);
for i in 0..100 {
baseline.update(50.0 + f64::from(i % 10));
}
let anomaly = baseline.detect_anomaly(100.0, 3.0);
assert!(anomaly.is_some());
let a = anomaly.expect("operation should succeed");
assert!(a.z_score > 5.0); }
#[test]
fn test_drift_detector_no_drift() {
let mut detector = DriftDetector::new(100);
for _ in 0..50 {
let status = detector.check(50.0);
assert_eq!(status, DriftStatus::NoDrift);
}
}
#[test]
fn test_drift_detector_detects_drift() {
let mut detector = DriftDetector::new(100);
for i in 0..100 {
detector.check(50.0 + f64::from(i % 10));
}
let status = detector.check(200.0);
assert!(
matches!(status, DriftStatus::Drift(_) | DriftStatus::Warning(_)),
"Expected drift or warning, got {status:?}"
);
}
#[test]
fn test_anomaly_severity_low() {
let mut baseline = SlidingWindowBaseline::new(100);
for _ in 0..100 {
baseline.update(50.0);
}
}
#[test]
fn test_update_with_nan() {
let mut baseline = SlidingWindowBaseline::new(100);
baseline.update(1.0);
baseline.update(f64::NAN);
baseline.update(2.0);
assert_eq!(baseline.count(), 2);
}
#[test]
fn test_update_with_infinity() {
let mut baseline = SlidingWindowBaseline::new(100);
baseline.update(1.0);
baseline.update(f64::INFINITY);
baseline.update(f64::NEG_INFINITY);
baseline.update(2.0);
assert_eq!(baseline.count(), 2);
}
#[test]
fn test_std_with_single_value() {
let mut baseline = SlidingWindowBaseline::new(100);
baseline.update(42.0);
assert_eq!(baseline.std(), 0.0);
}
#[test]
fn test_z_score_zero_std() {
let mut baseline = SlidingWindowBaseline::new(100);
baseline.update(5.0);
baseline.update(5.0);
assert_eq!(baseline.z_score(10.0), 0.0);
}
#[test]
fn test_detect_anomaly_not_enough_data() {
let mut baseline = SlidingWindowBaseline::new(100);
for i in 0..5 {
baseline.update(f64::from(i));
}
let anomaly = baseline.detect_anomaly(100.0, 3.0);
assert!(anomaly.is_none());
}
#[test]
fn test_anomaly_severity_medium() {
let mut baseline = SlidingWindowBaseline::new(100);
for i in 0..100 {
baseline.update(50.0 + f64::from(i % 5 - 2));
}
let anomaly = baseline.detect_anomaly(56.0, 3.0);
if let Some(a) = anomaly {
println!("z_score: {}", a.z_score);
}
}
#[test]
fn test_drift_detector_with_thresholds() {
let detector = DriftDetector::new(100).with_thresholds(0.15, 0.08);
assert_eq!(detector.threshold, 0.08);
assert_eq!(detector.warning_threshold, 0.15);
}
#[test]
fn test_drift_status_warning() {
let mut detector = DriftDetector::new(50).with_thresholds(0.3, 0.05);
for i in 0..50 {
detector.check(50.0 + f64::from(i % 10));
}
let _status = detector.check(75.0);
}
#[test]
fn test_z_to_p_approximation() {
let mut detector = DriftDetector::new(100);
for i in 0..100 {
detector.check(50.0 + f64::from(i % 5));
}
let _status = detector.check(60.0);
}
#[test]
fn test_drift_status_eq() {
assert_eq!(DriftStatus::NoDrift, DriftStatus::NoDrift);
assert_ne!(DriftStatus::NoDrift, DriftStatus::Drift(0.01));
assert_ne!(DriftStatus::Warning(0.08), DriftStatus::Drift(0.08));
}
#[test]
fn test_anomaly_clone() {
let anomaly = Anomaly {
value: 100.0,
z_score: 5.0,
severity: AnomalySeverity::High,
baseline_mean: 50.0,
baseline_std: 10.0,
};
let cloned = anomaly.clone();
assert_eq!(anomaly.value, cloned.value);
assert_eq!(anomaly.severity, cloned.severity);
}
#[test]
fn test_sliding_window_baseline_clone() {
let mut baseline = SlidingWindowBaseline::new(50);
baseline.update(1.0);
baseline.update(2.0);
let cloned = baseline.clone();
assert_eq!(baseline.count(), cloned.count());
assert_eq!(baseline.mean(), cloned.mean());
}
}