Skip to main content

faucet_core/
anomaly.rs

1//! Learned-baseline anomaly detection shared by the CLI's SLA volume check
2//! (#202) and column profiling (#708): a new observation is flagged against a
3//! rolling baseline of earlier observations by z-score or Tukey IQR fences.
4//! Pure math over `f64` — no I/O, no config loading.
5
6use schemars::JsonSchema;
7use serde::{Deserialize, Serialize};
8
9/// Default z-score threshold.
10pub const DEFAULT_ZSCORE_SENSITIVITY: f64 = 3.0;
11/// Default Tukey-fence IQR multiplier.
12pub const DEFAULT_IQR_SENSITIVITY: f64 = 1.5;
13
14/// How an observation is flagged as anomalous against the rolling baseline.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
16#[serde(rename_all = "snake_case")]
17pub enum AnomalyMethod {
18    /// Flag when |x − mean| / std exceeds the sensitivity.
19    #[default]
20    Zscore,
21    /// Flag when x falls outside the Tukey fences [Q1 − k·IQR, Q3 + k·IQR]
22    /// with k = the sensitivity.
23    Iqr,
24}
25
26impl AnomalyMethod {
27    /// The method's conventional default sensitivity.
28    pub fn default_sensitivity(self) -> f64 {
29        match self {
30            AnomalyMethod::Zscore => DEFAULT_ZSCORE_SENSITIVITY,
31            AnomalyMethod::Iqr => DEFAULT_IQR_SENSITIVITY,
32        }
33    }
34}
35
36/// Run `method` over `baseline`; `Some(detail)` when `x` is anomalous.
37/// Callers guarantee a non-empty baseline (at least 2 points for a
38/// meaningful spread).
39pub fn detect(method: AnomalyMethod, baseline: &[f64], x: f64, sensitivity: f64) -> Option<String> {
40    match method {
41        AnomalyMethod::Zscore => zscore_anomaly(baseline, x, sensitivity),
42        AnomalyMethod::Iqr => iqr_anomaly(baseline, x, sensitivity),
43    }
44}
45
46/// Spread below this fraction of the baseline's magnitude counts as zero —
47/// a baseline of equal values that are not bit-identical (`2.6667` repeated
48/// as the mean of several runs) otherwise yields a std of ~1e-16 and an
49/// astronomical z-score.
50const CONSTANT_EPS: f64 = 1e-9;
51
52/// Population z-score test. A constant baseline (std 0) flags any deviation
53/// as a regime change.
54pub fn zscore_anomaly(baseline: &[f64], x: f64, sensitivity: f64) -> Option<String> {
55    if baseline.is_empty() {
56        return None;
57    }
58    let n = baseline.len() as f64;
59    let mean = baseline.iter().sum::<f64>() / n;
60    let var = baseline
61        .iter()
62        .map(|&v| {
63            let d = v - mean;
64            d * d
65        })
66        .sum::<f64>()
67        / n;
68    let std = var.sqrt();
69    if std <= CONSTANT_EPS * mean.abs().max(1.0) {
70        if (x - mean).abs() > CONSTANT_EPS * mean.abs().max(1.0) {
71            return Some(format!("deviates from a constant baseline of {mean}"));
72        }
73        return None;
74    }
75    let z = (x - mean).abs() / std;
76    if z > sensitivity {
77        return Some(format!(
78            "|z| {z:.2} exceeds {sensitivity} (baseline mean {mean:.4}, std {std:.4}, n {})",
79            baseline.len()
80        ));
81    }
82    None
83}
84
85/// Tukey fences test: anomalous outside [Q1 − k·IQR, Q3 + k·IQR].
86pub fn iqr_anomaly(baseline: &[f64], x: f64, sensitivity: f64) -> Option<String> {
87    if baseline.is_empty() {
88        return None;
89    }
90    let mut sorted: Vec<f64> = baseline.iter().copied().filter(|v| v.is_finite()).collect();
91    if sorted.is_empty() {
92        return None;
93    }
94    sorted.sort_by(f64::total_cmp);
95    let q1 = quantile(&sorted, 0.25);
96    let q3 = quantile(&sorted, 0.75);
97    let iqr = q3 - q1;
98    let lower = q1 - sensitivity * iqr;
99    let upper = q3 + sensitivity * iqr;
100    if x < lower || x > upper {
101        return Some(format!(
102            "outside [{lower:.4}, {upper:.4}] (q1 {q1:.4}, q3 {q3:.4}, fence {sensitivity}×IQR, n {})",
103            baseline.len()
104        ));
105    }
106    None
107}
108
109/// Linear-interpolation quantile (R type-7) over an ascending, non-empty slice.
110pub fn quantile(sorted: &[f64], q: f64) -> f64 {
111    let n = sorted.len();
112    if n == 0 {
113        return f64::NAN;
114    }
115    if n == 1 {
116        return sorted[0];
117    }
118    let pos = q.clamp(0.0, 1.0) * (n - 1) as f64;
119    let lo = pos.floor() as usize;
120    let hi = pos.ceil() as usize;
121    let frac = pos - lo as f64;
122    sorted[lo] + (sorted[hi] - sorted[lo]) * frac
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128
129    #[test]
130    fn zscore_flags_far_point_and_accepts_near_one() {
131        let base = [100.0, 102.0, 98.0, 101.0, 99.0];
132        assert!(zscore_anomaly(&base, 100.5, 3.0).is_none());
133        let detail = zscore_anomaly(&base, 150.0, 3.0).expect("far point flagged");
134        assert!(detail.contains("|z|"), "{detail}");
135    }
136
137    #[test]
138    fn zscore_constant_baseline_flags_any_change() {
139        let base = [5.0, 5.0, 5.0];
140        assert!(zscore_anomaly(&base, 5.0, 3.0).is_none());
141        assert!(
142            zscore_anomaly(&base, 5.1, 3.0)
143                .unwrap()
144                .contains("constant baseline")
145        );
146    }
147
148    #[test]
149    fn near_constant_baseline_is_treated_as_constant() {
150        // Four runs whose string length averaged to the same value: their
151        // floating-point means differ in the last bit, so a naive std is ~1e-16.
152        let base = [8.0 / 3.0, 2.6666666666666665, 2.666666666666667, 8.0 / 3.0];
153        assert!(zscore_anomaly(&base, 2.666666666666667, 3.0).is_none());
154        let detail = zscore_anomaly(&base, 3.5, 3.0).unwrap();
155        assert!(detail.contains("constant baseline"), "{detail}");
156    }
157
158    #[test]
159    fn iqr_fences_flag_outliers() {
160        let base = [10.0, 11.0, 12.0, 13.0, 14.0, 15.0];
161        assert!(iqr_anomaly(&base, 12.5, 1.5).is_none());
162        assert!(iqr_anomaly(&base, 40.0, 1.5).unwrap().contains("outside"));
163        assert!(iqr_anomaly(&base, -20.0, 1.5).is_some());
164    }
165
166    #[test]
167    fn empty_and_non_finite_baselines_never_flag() {
168        assert!(zscore_anomaly(&[], 1.0, 3.0).is_none());
169        assert!(iqr_anomaly(&[], 1.0, 1.5).is_none());
170        assert!(iqr_anomaly(&[f64::NAN], 1.0, 1.5).is_none());
171    }
172
173    #[test]
174    fn quantile_interpolates_type7() {
175        let s = [1.0, 2.0, 3.0, 4.0];
176        assert_eq!(quantile(&s, 0.0), 1.0);
177        assert_eq!(quantile(&s, 1.0), 4.0);
178        assert_eq!(quantile(&s, 0.5), 2.5);
179        assert_eq!(quantile(&[7.0], 0.3), 7.0);
180        assert!(quantile(&[], 0.5).is_nan());
181    }
182
183    #[test]
184    fn detect_dispatches_on_method_and_defaults() {
185        let base = [1.0, 1.0, 1.0, 1.0];
186        assert!(detect(AnomalyMethod::Zscore, &base, 2.0, 3.0).is_some());
187        assert!(detect(AnomalyMethod::Iqr, &base, 2.0, 1.5).is_some());
188        assert_eq!(AnomalyMethod::Zscore.default_sensitivity(), 3.0);
189        assert_eq!(AnomalyMethod::Iqr.default_sensitivity(), 1.5);
190        assert_eq!(AnomalyMethod::default(), AnomalyMethod::Zscore);
191        assert_eq!(
192            serde_json::to_value(AnomalyMethod::Iqr).unwrap(),
193            serde_json::json!("iqr")
194        );
195    }
196}