Skip to main content

mnemo_baseline/
anomaly.rs

1//! z-score + EWMA drift detector (v0.4.1 P0-3).
2
3use serde::{Deserialize, Serialize};
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
6pub enum Severity {
7    Info,
8    Notice,
9    Warning,
10    High,
11    Critical,
12}
13
14impl Severity {
15    pub fn from_z(z: f32) -> Self {
16        let a = z.abs();
17        match a {
18            x if x >= 4.0 => Severity::Critical,
19            x if x >= 3.0 => Severity::High,
20            x if x >= 2.0 => Severity::Warning,
21            x if x >= 1.0 => Severity::Notice,
22            _ => Severity::Info,
23        }
24    }
25}
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
28pub enum BaselineMetric {
29    RecallRate,
30    WriteRate,
31    NamespaceFanout,
32    ToolMix,
33    HmacContinuity,
34    ForgetRate,
35}
36
37impl BaselineMetric {
38    pub fn as_str(&self) -> &'static str {
39        match self {
40            BaselineMetric::RecallRate => "recall_rate_per_min",
41            BaselineMetric::WriteRate => "write_rate_per_min",
42            BaselineMetric::NamespaceFanout => "namespace_fanout",
43            BaselineMetric::ToolMix => "tool_mix_kl_divergence",
44            BaselineMetric::HmacContinuity => "hmac_continuity",
45            BaselineMetric::ForgetRate => "forget_rate_per_min",
46        }
47    }
48}
49
50#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
51pub struct BaselineDelta {
52    pub metric: BaselineMetric,
53    pub z: f32,
54    pub ewma_drift: f32,
55    pub severity: Severity,
56}
57
58impl BaselineDelta {
59    pub fn new(metric: BaselineMetric, z: f32, ewma_drift: f32) -> Self {
60        Self {
61            metric,
62            z,
63            ewma_drift,
64            severity: Severity::from_z(z),
65        }
66    }
67}
68
69/// Compute the z-score of an observation against a rolling
70/// (mean, stddev). Stddev floored at `1e-6` so a steady-state
71/// metric (zero variance) doesn't divide by zero.
72pub fn z_score(x: f32, mean: f32, stddev: f32) -> f32 {
73    let s = stddev.max(1e-6);
74    (x - mean) / s
75}
76
77/// Compute the EWMA drift between the live observation and the
78/// historical mean, given alpha. Larger alpha = more weight on
79/// recent obs.
80pub fn ewma_drift(prev_ewma: f32, x: f32, alpha: f32) -> f32 {
81    let a = alpha.clamp(0.0, 1.0);
82    a * x + (1.0 - a) * prev_ewma
83}
84
85#[cfg(test)]
86mod tests {
87    use super::*;
88
89    #[test]
90    fn severity_thresholds() {
91        assert_eq!(Severity::from_z(0.5), Severity::Info);
92        assert_eq!(Severity::from_z(1.5), Severity::Notice);
93        assert_eq!(Severity::from_z(2.5), Severity::Warning);
94        assert_eq!(Severity::from_z(3.5), Severity::High);
95        assert_eq!(Severity::from_z(5.0), Severity::Critical);
96    }
97
98    #[test]
99    fn z_score_handles_zero_variance() {
100        // Steady-state metric shouldn't NaN.
101        let z = z_score(2.0, 1.0, 0.0);
102        assert!(z.is_finite());
103    }
104
105    #[test]
106    fn ewma_clamps_alpha() {
107        let d = ewma_drift(10.0, 100.0, 5.0);
108        // Alpha clamped to 1.0 → result == observation.
109        assert!((d - 100.0).abs() < 1e-3);
110    }
111
112    #[test]
113    fn burst_flips_severity_to_high() {
114        // 10x recall burst → e.g. observed 50/min, mean 5/min, stddev 2/min.
115        let z = z_score(50.0, 5.0, 2.0);
116        let sev = Severity::from_z(z);
117        // 10x burst with 2.5σ stddev gives z~22 → Critical.
118        assert!(sev == Severity::Critical || sev == Severity::High);
119    }
120
121    #[test]
122    fn metric_strings_are_stable() {
123        for m in [
124            BaselineMetric::RecallRate,
125            BaselineMetric::WriteRate,
126            BaselineMetric::NamespaceFanout,
127            BaselineMetric::ToolMix,
128            BaselineMetric::HmacContinuity,
129            BaselineMetric::ForgetRate,
130        ] {
131            assert!(!m.as_str().is_empty());
132        }
133    }
134}