1use schemars::JsonSchema;
7use serde::{Deserialize, Serialize};
8
9pub const DEFAULT_ZSCORE_SENSITIVITY: f64 = 3.0;
11pub const DEFAULT_IQR_SENSITIVITY: f64 = 1.5;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
16#[serde(rename_all = "snake_case")]
17pub enum AnomalyMethod {
18 #[default]
20 Zscore,
21 Iqr,
24}
25
26impl AnomalyMethod {
27 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
36pub 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
46const CONSTANT_EPS: f64 = 1e-9;
51
52pub 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
85pub 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
109pub 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 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}