Skip to main content

chronix_analytics/anomaly/
zscore.rs

1//! Z-Score based anomaly detector.
2
3use crate::compute::{simd_mean, simd_variance};
4
5use crate::anomaly::error::AnomalyError;
6use crate::anomaly::traits::{
7    AnomalyDetector, AnomalyScore, DetectorType, scale_floor, validate_lengths,
8};
9
10/// Flags points whose absolute Z-Score exceeds a configurable threshold.
11#[derive(serde::Serialize, serde::Deserialize)]
12pub struct ZScoreDetector {
13    threshold: f64,
14    mean: f64,
15    std_dev: f64,
16    fitted: bool,
17}
18
19impl ZScoreDetector {
20    /// Creates a new detector.  `threshold` defaults to 3.0σ.
21    pub fn new(threshold: Option<f64>) -> Self {
22        Self {
23            threshold: threshold.unwrap_or(3.0),
24            mean: 0.0,
25            std_dev: 0.0,
26            fitted: false,
27        }
28    }
29
30    /// Absolute z-score against the fitted baseline.
31    ///
32    /// σ is floored *relative to the fitted mean* (see [`scale_floor`]), so a
33    /// zero-variance baseline scores a value 1e-13 away from the mean at
34    /// ~1e-4 σ rather than at `inf`. The returned score is always finite for
35    /// a finite input, which is what keeps `inf` out of the details string
36    /// and out of the alert payloads built from it.
37    #[inline]
38    fn score_value(&self, value: f64) -> f64 {
39        ((value - self.mean) / scale_floor(self.std_dev, self.mean)).abs()
40    }
41
42    /// Normalize a raw z-score into `[0, 1]` using a sigmoid mapping.
43    #[inline]
44    fn normalize(raw: f64, threshold: f64) -> f64 {
45        // Maps `threshold` → ~0.5, well-above → ~1.0
46        let x = (raw - threshold) * 2.0;
47        1.0 / (1.0 + (-x).exp())
48    }
49}
50
51impl AnomalyDetector for ZScoreDetector {
52    #[tracing::instrument(skip_all, level = "debug")]
53    fn fit(&mut self, _timestamps: &[i64], values: &[f64]) -> Result<(), AnomalyError> {
54        let _start = std::time::Instant::now();
55        if values.len() < 3 {
56            return Err(AnomalyError::InsufficientData {
57                min: 3,
58                got: values.len(),
59            });
60        }
61        self.mean = simd_mean(values);
62        self.std_dev = simd_variance(values, self.mean).sqrt();
63        self.fitted = true;
64        metrics::histogram!("chronix_anomaly_fit_duration_seconds", "method" => "zscore")
65            .record(_start.elapsed().as_secs_f64());
66        Ok(())
67    }
68
69    #[tracing::instrument(skip_all, level = "debug")]
70    fn detect(
71        &mut self,
72        timestamps: &[i64],
73        values: &[f64],
74    ) -> Result<Vec<AnomalyScore>, AnomalyError> {
75        let _start = std::time::Instant::now();
76        validate_lengths(timestamps, values)?;
77        if !self.fitted {
78            return Err(AnomalyError::NotFitted);
79        }
80        let mut scores = Vec::with_capacity(values.len());
81        for (i, &v) in values.iter().enumerate() {
82            let raw = self.score_value(v);
83            let is_anomaly = raw > self.threshold;
84            scores.push(AnomalyScore {
85                timestamp: timestamps[i],
86                value: v,
87                score: Self::normalize(raw, self.threshold),
88                is_anomaly,
89                method: DetectorType::ZScore,
90                threshold: self.threshold,
91                details: format!("z={raw:.4}"),
92            });
93        }
94        let anomaly_count = scores.iter().filter(|s| s.is_anomaly).count();
95        metrics::counter!("chronix_anomaly_detected_total", "method" => "zscore")
96            .increment(anomaly_count as u64);
97        metrics::histogram!("chronix_anomaly_detect_duration_seconds", "method" => "zscore")
98            .record(_start.elapsed().as_secs_f64());
99        Ok(scores)
100    }
101
102    fn detect_point(&mut self, timestamp: i64, value: f64) -> Result<AnomalyScore, AnomalyError> {
103        if !self.fitted {
104            return Err(AnomalyError::NotFitted);
105        }
106        let raw = self.score_value(value);
107        Ok(AnomalyScore {
108            timestamp,
109            value,
110            score: Self::normalize(raw, self.threshold),
111            is_anomaly: raw > self.threshold,
112            method: DetectorType::ZScore,
113            threshold: self.threshold,
114            details: format!("z={raw:.4}"),
115        })
116    }
117
118    fn detector_type(&self) -> DetectorType {
119        DetectorType::ZScore
120    }
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126
127    fn make_normal(n: usize, outlier_idx: &[usize], outlier_val: f64) -> (Vec<i64>, Vec<f64>) {
128        let ts: Vec<i64> = (0..n as i64).map(|i| i * 1_000_000_000).collect();
129        let mut vals: Vec<f64> = (0..n)
130            .map(|i| {
131                // Deterministic pseudo-normal via simple hash
132
133                ((i as f64 * 0.1).sin() * 5.0) + 50.0
134            })
135            .collect();
136        for &idx in outlier_idx {
137            if idx < n {
138                vals[idx] = outlier_val;
139            }
140        }
141        (ts, vals)
142    }
143
144    #[test]
145    fn outliers_flagged() {
146        let (ts, vals) = make_normal(200, &[50, 150], 200.0);
147        let mut det = ZScoreDetector::new(Some(3.0));
148        det.fit(&ts, &vals).unwrap();
149        let scores = det.detect(&ts, &vals).unwrap();
150        assert!(scores[50].is_anomaly);
151        assert!(scores[150].is_anomaly);
152        // Normal points should mostly be non-anomalous
153        let normals: usize = scores
154            .iter()
155            .enumerate()
156            .filter(|(i, _)| *i != 50 && *i != 150)
157            .filter(|(_, s)| !s.is_anomaly)
158            .count();
159        assert!(normals > 180);
160    }
161
162    #[test]
163    fn threshold_sensitivity() {
164        let (ts, vals) = make_normal(200, &[100], 100.0);
165        let mut loose = ZScoreDetector::new(Some(5.0));
166        let mut tight = ZScoreDetector::new(Some(1.5));
167        loose.fit(&ts, &vals).unwrap();
168        tight.fit(&ts, &vals).unwrap();
169        let s_loose = loose.detect(&ts, &vals).unwrap();
170        let s_tight = tight.detect(&ts, &vals).unwrap();
171        let count_loose: usize = s_loose.iter().filter(|s| s.is_anomaly).count();
172        let count_tight: usize = s_tight.iter().filter(|s| s.is_anomaly).count();
173        assert!(count_tight >= count_loose);
174    }
175
176    #[test]
177    fn detect_point_streaming() {
178        let (ts, vals) = make_normal(100, &[], 0.0);
179        let mut det = ZScoreDetector::new(None);
180        det.fit(&ts, &vals).unwrap();
181        // Normal point
182        let s = det.detect_point(999, 50.0).unwrap();
183        assert!(!s.is_anomaly);
184        // Outlier
185        let s = det.detect_point(1000, 500.0).unwrap();
186        assert!(s.is_anomaly);
187    }
188
189    #[test]
190    fn insufficient_data() {
191        let mut det = ZScoreDetector::new(None);
192        assert!(det.fit(&[0, 1], &[1.0, 2.0]).is_err());
193    }
194
195    #[test]
196    fn detect_mismatched_lengths_returns_error() {
197        let (ts, vals) = make_normal(100, &[], 0.0);
198        let mut det = ZScoreDetector::new(None);
199        det.fit(&ts, &vals).unwrap();
200        // timestamps shorter than values
201        let result = det.detect(&ts[..50], &vals);
202        assert!(result.is_err());
203        let err = result.unwrap_err().to_string();
204        assert!(err.contains("timestamps length"), "error: {err}");
205        // timestamps longer than values
206        let result = det.detect(&ts, &vals[..50]);
207        assert!(result.is_err());
208    }
209}