1use std::collections::{HashMap, VecDeque};
2
3const DEFAULT_WINDOW_SIZE: usize = 1000;
4const DEFAULT_DEVIATION_THRESHOLD: f64 = 0.10;
5const MIN_OBSERVATIONS: usize = 10;
6
7pub struct SlaTracker {
17 window_size: usize,
18 deviation_threshold: f64,
19 windows: HashMap<String, VecDeque<Option<String>>>,
21 expected: HashMap<(String, String), f64>,
23}
24
25impl SlaTracker {
26 pub fn new() -> Self {
27 SlaTracker {
28 window_size: Self::env_window_size(),
29 deviation_threshold: Self::env_deviation_threshold(),
30 windows: HashMap::new(),
31 expected: HashMap::new(),
32 }
33 }
34
35 pub fn with_config(window_size: usize, deviation_threshold: f64) -> Self {
36 SlaTracker {
37 window_size,
38 deviation_threshold,
39 windows: HashMap::new(),
40 expected: HashMap::new(),
41 }
42 }
43
44 pub fn record(
54 &mut self,
55 node_id: &str,
56 outcome: &str,
57 declared_probability: f64,
58 occurred: bool,
59 ) -> bool {
60 self.expected.insert(
61 (node_id.to_string(), outcome.to_string()),
62 declared_probability,
63 );
64
65 let window = self.windows.entry(node_id.to_string()).or_default();
66 window.push_back(occurred.then(|| outcome.to_string()));
67 if window.len() > self.window_size {
68 window.pop_front();
69 }
70
71 self.is_anomaly(node_id, outcome, declared_probability)
72 }
73
74 fn is_anomaly(&self, node_id: &str, outcome: &str, declared: f64) -> bool {
75 let window = match self.windows.get(node_id) {
76 Some(w) => w,
77 None => return false,
78 };
79 if window.len() < MIN_OBSERVATIONS {
80 return false;
81 }
82 let observed = self.observed_frequency(node_id, outcome);
83 (observed - declared).abs() > self.deviation_threshold
84 }
85
86 pub fn observed_frequency(&self, node_id: &str, outcome: &str) -> f64 {
89 match self.windows.get(node_id) {
90 Some(window) if !window.is_empty() => {
91 let total = window.len() as f64;
92 let hits = window
93 .iter()
94 .filter(|label| label.as_deref() == Some(outcome))
95 .count() as f64;
96 hits / total
97 }
98 _ => 0.0,
99 }
100 }
101
102 pub fn declared_probability(&self, node_id: &str, outcome: &str) -> Option<f64> {
104 self.expected
105 .get(&(node_id.to_string(), outcome.to_string()))
106 .copied()
107 }
108
109 pub fn total_evaluations(&self) -> usize {
110 self.windows.values().map(|w| w.len()).sum()
111 }
112
113 fn env_window_size() -> usize {
114 std::env::var("ETDL_SLA_WINDOW")
115 .ok()
116 .and_then(|v| v.parse().ok())
117 .unwrap_or(DEFAULT_WINDOW_SIZE)
118 }
119
120 fn env_deviation_threshold() -> f64 {
121 std::env::var("ETDL_SLA_THRESHOLD")
122 .ok()
123 .and_then(|v| v.parse().ok())
124 .unwrap_or(DEFAULT_DEVIATION_THRESHOLD)
125 }
126}
127
128impl Default for SlaTracker {
129 fn default() -> Self {
130 Self::new()
131 }
132}
133
134#[cfg(test)]
135mod tests {
136 use super::*;
137
138 #[test]
139 fn test_sla_tracking() {
140 let mut tracker = SlaTracker::new();
141 for i in 0..100 {
143 tracker.record("barrier_1", "SUCCESS", 0.95, i < 95);
144 }
145 let freq = tracker.observed_frequency("barrier_1", "SUCCESS");
146 assert!((freq - 0.95).abs() < 0.01, "got {}", freq);
147 assert_eq!(tracker.total_evaluations(), 100);
148 }
149
150 #[test]
151 fn test_sla_anomaly_detection() {
152 let mut tracker = SlaTracker::with_config(100, 0.10);
153 let mut anomaly_detected = false;
155 for i in 0..80 {
156 let is_anomaly = tracker.record("barrier_1", "SUCCESS", 0.95, i < 48);
157 if is_anomaly {
158 anomaly_detected = true;
159 }
160 }
161 assert!(anomaly_detected);
162 }
163
164 #[test]
165 fn no_anomaly_when_matching_declared() {
166 let mut tracker = SlaTracker::with_config(100, 0.10);
167 let mut anomaly = false;
168 for i in 0..100 {
169 anomaly |= tracker.record("b", "SUCCESS", 0.5, i % 2 == 0);
170 }
171 assert!(!anomaly, "matching declared should not alarm");
172 }
173
174 #[test]
175 fn window_is_bounded() {
176 let mut tracker = SlaTracker::with_config(50, 0.10);
177 for i in 0..200 {
178 tracker.record("b", "SUCCESS", 0.5, i % 2 == 0);
179 }
180 assert_eq!(tracker.total_evaluations(), 50);
181 }
182}