1use super::spec::{AnomalyMethod, SlaSpec, VolumeAnomalySpec};
6use super::state::SlaState;
7use std::fmt;
8
9#[derive(Debug, Clone, PartialEq)]
11pub enum SlaViolation {
12 Staleness { since_secs: u64, max_secs: u64 },
14 MinRows { rows: u64, min: u64 },
16 Volume { rows: u64, detail: String },
18}
19
20impl SlaViolation {
21 pub fn kind(&self) -> &'static str {
24 match self {
25 SlaViolation::Staleness { .. } => "staleness",
26 SlaViolation::MinRows { .. } => "min_rows",
27 SlaViolation::Volume { .. } => "volume",
28 }
29 }
30}
31
32impl fmt::Display for SlaViolation {
33 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
34 match self {
35 SlaViolation::Staleness {
36 since_secs,
37 max_secs,
38 } => write!(
39 f,
40 "pipeline is stale: last success {since_secs}s ago exceeds max_staleness_secs {max_secs}"
41 ),
42 SlaViolation::MinRows { rows, min } => write!(
43 f,
44 "run wrote {rows} record(s), below min_rows_per_run {min}"
45 ),
46 SlaViolation::Volume { rows, detail } => {
47 write!(f, "run volume {rows} is anomalous: {detail}")
48 }
49 }
50 }
51}
52
53pub fn evaluate_success(spec: &SlaSpec, prior: &SlaState, rows: u64) -> Vec<SlaViolation> {
57 let mut out = Vec::new();
58 if let Some(min) = spec.min_rows_per_run
59 && rows < min
60 {
61 out.push(SlaViolation::MinRows { rows, min });
62 }
63 if let Some(va) = &spec.volume_anomaly
64 && prior.volumes.len() >= va.min_history as usize
65 && let Some(detail) = detect_anomaly(&prior.volumes, rows, va)
66 {
67 out.push(SlaViolation::Volume { rows, detail });
68 }
69 out
70}
71
72pub fn evaluate_failure(spec: &SlaSpec, prior: &SlaState, now_unix: i64) -> Vec<SlaViolation> {
75 match (spec.max_staleness_secs, prior.last_success_unix) {
76 (Some(max_secs), Some(last)) => {
77 let since_secs = now_unix.saturating_sub(last).max(0) as u64;
78 if since_secs > max_secs {
79 vec![SlaViolation::Staleness {
80 since_secs,
81 max_secs,
82 }]
83 } else {
84 Vec::new()
85 }
86 }
87 _ => Vec::new(),
88 }
89}
90
91pub fn detect_anomaly(baseline: &[u64], rows: u64, va: &VolumeAnomalySpec) -> Option<String> {
94 let sensitivity = va.effective_sensitivity();
95 match va.method {
96 AnomalyMethod::Zscore => zscore_anomaly(baseline, rows, sensitivity),
97 AnomalyMethod::Iqr => iqr_anomaly(baseline, rows, sensitivity),
98 }
99}
100
101fn zscore_anomaly(baseline: &[u64], rows: u64, sensitivity: f64) -> Option<String> {
102 let n = baseline.len() as f64;
103 let mean = baseline.iter().map(|&v| v as f64).sum::<f64>() / n;
104 let var = baseline
105 .iter()
106 .map(|&v| {
107 let d = v as f64 - mean;
108 d * d
109 })
110 .sum::<f64>()
111 / n;
112 let std = var.sqrt();
113 let x = rows as f64;
114 if std == 0.0 {
115 if x != mean {
117 return Some(format!(
118 "deviates from a constant baseline of {mean:.0} records/run"
119 ));
120 }
121 return None;
122 }
123 let z = (x - mean).abs() / std;
124 if z > sensitivity {
125 return Some(format!(
126 "|z| {z:.2} exceeds {sensitivity} (baseline mean {mean:.1}, std {std:.1}, n {})",
127 baseline.len()
128 ));
129 }
130 None
131}
132
133fn iqr_anomaly(baseline: &[u64], rows: u64, sensitivity: f64) -> Option<String> {
134 let mut sorted = baseline.to_vec();
135 sorted.sort_unstable();
136 let q1 = quantile(&sorted, 0.25);
137 let q3 = quantile(&sorted, 0.75);
138 let iqr = q3 - q1;
139 let lower = q1 - sensitivity * iqr;
140 let upper = q3 + sensitivity * iqr;
141 let x = rows as f64;
142 if x < lower || x > upper {
143 return Some(format!(
144 "outside [{lower:.1}, {upper:.1}] (q1 {q1:.1}, q3 {q3:.1}, fence {sensitivity}×IQR, n {})",
145 baseline.len()
146 ));
147 }
148 None
149}
150
151fn quantile(sorted: &[u64], q: f64) -> f64 {
154 let n = sorted.len();
155 if n == 1 {
156 return sorted[0] as f64;
157 }
158 let pos = q * (n - 1) as f64;
159 let lo = pos.floor() as usize;
160 let hi = pos.ceil() as usize;
161 let frac = pos - lo as f64;
162 sorted[lo] as f64 + (sorted[hi] as f64 - sorted[lo] as f64) * frac
163}
164
165#[cfg(test)]
166mod tests {
167 use super::super::spec::{DEFAULT_MIN_HISTORY, DEFAULT_WINDOW};
168 use super::*;
169
170 fn spec(
171 staleness: Option<u64>,
172 min_rows: Option<u64>,
173 va: Option<VolumeAnomalySpec>,
174 ) -> SlaSpec {
175 SlaSpec {
176 max_staleness_secs: staleness,
177 min_rows_per_run: min_rows,
178 volume_anomaly: va,
179 }
180 }
181
182 fn va(method: AnomalyMethod, sensitivity: Option<f64>) -> VolumeAnomalySpec {
183 VolumeAnomalySpec {
184 method,
185 sensitivity,
186 min_history: DEFAULT_MIN_HISTORY,
187 window: DEFAULT_WINDOW,
188 }
189 }
190
191 fn state_with(volumes: &[u64], last_success: Option<i64>) -> SlaState {
192 SlaState {
193 last_success_unix: last_success,
194 volumes: volumes.to_vec(),
195 }
196 }
197
198 #[test]
199 fn min_rows_fires_below_floor_only() {
200 let s = spec(None, Some(10), None);
201 let prior = SlaState::default();
202 let v = evaluate_success(&s, &prior, 3);
203 assert_eq!(v.len(), 1);
204 assert_eq!(v[0].kind(), "min_rows");
205 assert!(v[0].to_string().contains("below min_rows_per_run 10"));
206 assert!(evaluate_success(&s, &prior, 10).is_empty());
207 }
208
209 #[test]
210 fn volume_anomaly_waits_for_min_history() {
211 let s = spec(None, None, Some(va(AnomalyMethod::Zscore, None)));
212 let prior = state_with(&[100, 100, 100, 100], None);
214 assert!(evaluate_success(&s, &prior, 0).is_empty());
215 let prior = state_with(&[100, 100, 100, 100, 100], None);
217 let v = evaluate_success(&s, &prior, 0);
218 assert_eq!(v.len(), 1);
219 assert_eq!(v[0].kind(), "volume");
220 }
221
222 #[test]
223 fn zscore_flags_injected_drop_and_passes_normal() {
224 let baseline = [100, 105, 95, 102, 98, 101, 99, 103];
225 let cfg = va(AnomalyMethod::Zscore, None);
226 assert!(detect_anomaly(&baseline, 0, &cfg).is_some(), "drop to zero");
227 assert!(detect_anomaly(&baseline, 500, &cfg).is_some(), "spike");
228 assert!(detect_anomaly(&baseline, 101, &cfg).is_none(), "normal");
229 }
230
231 #[test]
232 fn zscore_constant_baseline_flags_any_deviation() {
233 let baseline = [50, 50, 50, 50, 50];
234 let cfg = va(AnomalyMethod::Zscore, None);
235 let detail = detect_anomaly(&baseline, 49, &cfg).expect("deviation from constant");
236 assert!(detail.contains("constant baseline"), "{detail}");
237 assert!(detect_anomaly(&baseline, 50, &cfg).is_none());
238 }
239
240 #[test]
241 fn zscore_sensitivity_widens_the_pass_band() {
242 let baseline = [100, 110, 90, 105, 95];
243 assert!(detect_anomaly(&baseline, 120, &va(AnomalyMethod::Zscore, Some(1.0))).is_some());
245 assert!(detect_anomaly(&baseline, 120, &va(AnomalyMethod::Zscore, None)).is_none());
246 }
247
248 #[test]
249 fn iqr_flags_outliers_outside_fences() {
250 let baseline = [100, 102, 98, 101, 99, 103, 97, 100];
251 let cfg = va(AnomalyMethod::Iqr, None);
252 assert!(detect_anomaly(&baseline, 0, &cfg).is_some(), "drop");
253 assert!(detect_anomaly(&baseline, 1000, &cfg).is_some(), "spike");
254 assert!(detect_anomaly(&baseline, 100, &cfg).is_none(), "median");
255 }
256
257 #[test]
258 fn iqr_zero_spread_flags_any_outside_value() {
259 let baseline = [70, 70, 70, 70, 70];
260 let cfg = va(AnomalyMethod::Iqr, None);
261 assert!(detect_anomaly(&baseline, 71, &cfg).is_some());
262 assert!(detect_anomaly(&baseline, 70, &cfg).is_none());
263 }
264
265 #[test]
266 fn quantile_interpolates() {
267 let sorted = [10, 20, 30, 40];
268 assert_eq!(quantile(&sorted, 0.0), 10.0);
269 assert_eq!(quantile(&sorted, 1.0), 40.0);
270 assert_eq!(quantile(&sorted, 0.5), 25.0);
271 assert_eq!(quantile(&sorted, 0.25), 17.5);
272 assert_eq!(quantile(&[42], 0.75), 42.0);
273 }
274
275 #[test]
276 fn staleness_fires_only_past_threshold_with_history() {
277 let s = spec(Some(3600), None, None);
278 let prior = state_with(&[], Some(10_000));
280 assert!(evaluate_failure(&s, &prior, 10_000 + 3600).is_empty());
281 let v = evaluate_failure(&s, &prior, 10_000 + 3601);
283 assert_eq!(v.len(), 1);
284 assert_eq!(v[0].kind(), "staleness");
285 assert!(v[0].to_string().contains("3601s ago"));
286 assert!(evaluate_failure(&s, &SlaState::default(), 999_999).is_empty());
288 let s = spec(None, Some(1), None);
290 assert!(evaluate_failure(&s, &prior, 999_999).is_empty());
291 }
292
293 #[test]
294 fn staleness_tolerates_clock_skew() {
295 let s = spec(Some(60), None, None);
297 let prior = state_with(&[], Some(2_000));
298 assert!(evaluate_failure(&s, &prior, 1_000).is_empty());
299 }
300
301 #[test]
302 fn success_checks_combine() {
303 let s = spec(Some(3600), Some(50), Some(va(AnomalyMethod::Zscore, None)));
304 let prior = state_with(&[100, 101, 99, 100, 100], Some(0));
305 let v = evaluate_success(&s, &prior, 10);
306 let kinds: Vec<_> = v.iter().map(|x| x.kind()).collect();
307 assert_eq!(kinds, vec!["min_rows", "volume"]);
308 }
309}