use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
pub const DEFAULT_ZSCORE_SENSITIVITY: f64 = 3.0;
pub const DEFAULT_IQR_SENSITIVITY: f64 = 1.5;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum AnomalyMethod {
#[default]
Zscore,
Iqr,
}
impl AnomalyMethod {
pub fn default_sensitivity(self) -> f64 {
match self {
AnomalyMethod::Zscore => DEFAULT_ZSCORE_SENSITIVITY,
AnomalyMethod::Iqr => DEFAULT_IQR_SENSITIVITY,
}
}
}
pub fn detect(method: AnomalyMethod, baseline: &[f64], x: f64, sensitivity: f64) -> Option<String> {
match method {
AnomalyMethod::Zscore => zscore_anomaly(baseline, x, sensitivity),
AnomalyMethod::Iqr => iqr_anomaly(baseline, x, sensitivity),
}
}
const CONSTANT_EPS: f64 = 1e-9;
pub fn zscore_anomaly(baseline: &[f64], x: f64, sensitivity: f64) -> Option<String> {
if baseline.is_empty() {
return None;
}
let n = baseline.len() as f64;
let mean = baseline.iter().sum::<f64>() / n;
let var = baseline
.iter()
.map(|&v| {
let d = v - mean;
d * d
})
.sum::<f64>()
/ n;
let std = var.sqrt();
if std <= CONSTANT_EPS * mean.abs().max(1.0) {
if (x - mean).abs() > CONSTANT_EPS * mean.abs().max(1.0) {
return Some(format!("deviates from a constant baseline of {mean}"));
}
return None;
}
let z = (x - mean).abs() / std;
if z > sensitivity {
return Some(format!(
"|z| {z:.2} exceeds {sensitivity} (baseline mean {mean:.4}, std {std:.4}, n {})",
baseline.len()
));
}
None
}
pub fn iqr_anomaly(baseline: &[f64], x: f64, sensitivity: f64) -> Option<String> {
if baseline.is_empty() {
return None;
}
let mut sorted: Vec<f64> = baseline.iter().copied().filter(|v| v.is_finite()).collect();
if sorted.is_empty() {
return None;
}
sorted.sort_by(f64::total_cmp);
let q1 = quantile(&sorted, 0.25);
let q3 = quantile(&sorted, 0.75);
let iqr = q3 - q1;
let lower = q1 - sensitivity * iqr;
let upper = q3 + sensitivity * iqr;
if x < lower || x > upper {
return Some(format!(
"outside [{lower:.4}, {upper:.4}] (q1 {q1:.4}, q3 {q3:.4}, fence {sensitivity}×IQR, n {})",
baseline.len()
));
}
None
}
pub fn quantile(sorted: &[f64], q: f64) -> f64 {
let n = sorted.len();
if n == 0 {
return f64::NAN;
}
if n == 1 {
return sorted[0];
}
let pos = q.clamp(0.0, 1.0) * (n - 1) as f64;
let lo = pos.floor() as usize;
let hi = pos.ceil() as usize;
let frac = pos - lo as f64;
sorted[lo] + (sorted[hi] - sorted[lo]) * frac
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn zscore_flags_far_point_and_accepts_near_one() {
let base = [100.0, 102.0, 98.0, 101.0, 99.0];
assert!(zscore_anomaly(&base, 100.5, 3.0).is_none());
let detail = zscore_anomaly(&base, 150.0, 3.0).expect("far point flagged");
assert!(detail.contains("|z|"), "{detail}");
}
#[test]
fn zscore_constant_baseline_flags_any_change() {
let base = [5.0, 5.0, 5.0];
assert!(zscore_anomaly(&base, 5.0, 3.0).is_none());
assert!(
zscore_anomaly(&base, 5.1, 3.0)
.unwrap()
.contains("constant baseline")
);
}
#[test]
fn near_constant_baseline_is_treated_as_constant() {
let base = [8.0 / 3.0, 2.6666666666666665, 2.666666666666667, 8.0 / 3.0];
assert!(zscore_anomaly(&base, 2.666666666666667, 3.0).is_none());
let detail = zscore_anomaly(&base, 3.5, 3.0).unwrap();
assert!(detail.contains("constant baseline"), "{detail}");
}
#[test]
fn iqr_fences_flag_outliers() {
let base = [10.0, 11.0, 12.0, 13.0, 14.0, 15.0];
assert!(iqr_anomaly(&base, 12.5, 1.5).is_none());
assert!(iqr_anomaly(&base, 40.0, 1.5).unwrap().contains("outside"));
assert!(iqr_anomaly(&base, -20.0, 1.5).is_some());
}
#[test]
fn empty_and_non_finite_baselines_never_flag() {
assert!(zscore_anomaly(&[], 1.0, 3.0).is_none());
assert!(iqr_anomaly(&[], 1.0, 1.5).is_none());
assert!(iqr_anomaly(&[f64::NAN], 1.0, 1.5).is_none());
}
#[test]
fn quantile_interpolates_type7() {
let s = [1.0, 2.0, 3.0, 4.0];
assert_eq!(quantile(&s, 0.0), 1.0);
assert_eq!(quantile(&s, 1.0), 4.0);
assert_eq!(quantile(&s, 0.5), 2.5);
assert_eq!(quantile(&[7.0], 0.3), 7.0);
assert!(quantile(&[], 0.5).is_nan());
}
#[test]
fn detect_dispatches_on_method_and_defaults() {
let base = [1.0, 1.0, 1.0, 1.0];
assert!(detect(AnomalyMethod::Zscore, &base, 2.0, 3.0).is_some());
assert!(detect(AnomalyMethod::Iqr, &base, 2.0, 1.5).is_some());
assert_eq!(AnomalyMethod::Zscore.default_sensitivity(), 3.0);
assert_eq!(AnomalyMethod::Iqr.default_sensitivity(), 1.5);
assert_eq!(AnomalyMethod::default(), AnomalyMethod::Zscore);
assert_eq!(
serde_json::to_value(AnomalyMethod::Iqr).unwrap(),
serde_json::json!("iqr")
);
}
}