Skip to main content

etdl_core/
sla.rs

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
7/// Tracks observed outcome frequencies per node against the declared
8/// probabilities, and reports SLA anomalies when the divergence exceeds a
9/// threshold (ETDL ยง9.3).
10///
11/// The observed frequency of an outcome is the fraction of the node's
12/// evaluations in the rolling window that produced that outcome. This gives a
13/// meaningful comparison against the declared probability: if a branch declares
14/// `SUCCESS = 0.95` but only 60% of evaluations actually succeed, the deviation
15/// exceeds the threshold and an anomaly is reported.
16pub struct SlaTracker {
17    window_size: usize,
18    deviation_threshold: f64,
19    /// node id -> (rolling outcome labels, bounded to window_size)
20    windows: HashMap<String, VecDeque<Option<String>>>,
21    /// (node id, outcome) -> declared probability
22    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    /// Record one evaluation of `node_id`.
45    ///
46    /// `occurred` is true when `outcome` was actually observed (the branch was
47    /// taken / the failure happened); false otherwise. The declared probability
48    /// for the outcome is remembered (the most recent value wins).
49    ///
50    /// Returns `true` when the observed frequency for `outcome` deviates from
51    /// the declared probability by more than the threshold (with enough
52    /// observations to be meaningful).
53    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    /// The fraction of the node's rolling window evaluations that produced
87    /// `outcome`. Returns 0.0 when there are no observations yet.
88    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    /// The most recently declared probability for `(node_id, outcome)`, if any.
103    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        // 95 of 100 evaluations succeed -> observed ~0.95 matches declared 0.95.
142        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        // Declared 0.95 but only 60% succeed -> anomaly.
154        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}