Skip to main content

chronix_analytics/lifecycle/
accuracy.rs

1//! Accuracy tracking and model staleness monitoring.
2//!
3//! Tracks sliding-window MAPE, RMSE, MAE for active models, and triggers
4//! re-fit when accuracy degrades below configurable thresholds.
5
6use std::collections::{HashMap, VecDeque};
7use std::sync::atomic::{AtomicUsize, Ordering};
8
9use parking_lot::RwLock;
10
11use crate::lifecycle::registry::AccuracyMetrics;
12
13/// Configuration for the accuracy tracker.
14#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
15pub struct AccuracyTrackerConfig {
16    /// Window size for sliding-window metrics.  Default: 100.
17    pub window_size: usize,
18    /// MAPE threshold triggering re-fit.  Default: 0.10 (10%).
19    pub refit_mape_threshold: f64,
20    /// Number of consecutive windows exceeding threshold before re-fit.
21    /// Default: 5.
22    pub refit_window_count: usize,
23    /// Maximum model age in seconds before staleness warning.  Default: 7 days.
24    pub max_model_age_secs: i64,
25}
26
27impl Default for AccuracyTrackerConfig {
28    fn default() -> Self {
29        Self {
30            window_size: 100,
31            refit_mape_threshold: 0.10,
32            refit_window_count: 5,
33            max_model_age_secs: 7 * 24 * 3600,
34        }
35    }
36}
37
38/// Observation record for accuracy tracking.
39#[derive(Debug, Clone)]
40struct Observation {
41    actual: f64,
42    predicted: f64,
43}
44
45/// Per-model accuracy state.
46#[derive(Debug)]
47struct ModelState {
48    observations: VecDeque<Observation>,
49    config: AccuracyTrackerConfig,
50    consecutive_bad_windows: AtomicUsize,
51    /// Model creation timestamp (epoch seconds).
52    created_at: i64,
53    /// Observation count at last `needs_refit` evaluation.
54    last_refit_check_count: AtomicUsize,
55}
56
57impl ModelState {
58    fn new(config: AccuracyTrackerConfig, created_at: i64) -> Self {
59        Self {
60            observations: VecDeque::new(),
61            config,
62            consecutive_bad_windows: AtomicUsize::new(0),
63            created_at,
64            last_refit_check_count: AtomicUsize::new(0),
65        }
66    }
67
68    fn add_observation(&mut self, actual: f64, predicted: f64) {
69        self.observations
70            .push_back(Observation { actual, predicted });
71        let max_size = self.config.window_size * 10;
72        while self.observations.len() > max_size {
73            self.observations.pop_front();
74        }
75    }
76
77    fn current_metrics(&self) -> Option<AccuracyMetrics> {
78        if self.observations.len() < 2 {
79            return None;
80        }
81        let start = self
82            .observations
83            .len()
84            .saturating_sub(self.config.window_size);
85        let actuals: Vec<f64> = self
86            .observations
87            .iter()
88            .skip(start)
89            .map(|o| o.actual)
90            .collect();
91        let preds: Vec<f64> = self
92            .observations
93            .iter()
94            .skip(start)
95            .map(|o| o.predicted)
96            .collect();
97        Some(AccuracyMetrics::compute(&actuals, &preds))
98    }
99
100    /// Check if the model needs refitting.
101    ///
102    /// Uses atomic counters so this can be called with `&self` (no
103    /// exclusive access required), which lets `AccuracyTracker` use a
104    /// read lock instead of a write lock.
105    fn needs_refit(&self) -> bool {
106        let current_count = self.observations.len();
107        let prev = self.last_refit_check_count.load(Ordering::Relaxed);
108        // Only update consecutive_bad_windows when new data has arrived.
109        if current_count > prev {
110            self.last_refit_check_count
111                .store(current_count, Ordering::Relaxed);
112            if let Some(m) = self.current_metrics() {
113                if !m.mape.is_nan() && m.mape > self.config.refit_mape_threshold {
114                    self.consecutive_bad_windows.fetch_add(1, Ordering::Relaxed);
115                } else {
116                    self.consecutive_bad_windows.store(0, Ordering::Relaxed);
117                }
118            }
119        }
120        self.consecutive_bad_windows.load(Ordering::Relaxed) >= self.config.refit_window_count
121    }
122
123    fn is_stale(&self, now_epoch_secs: i64) -> bool {
124        now_epoch_secs - self.created_at > self.config.max_model_age_secs
125    }
126
127    fn age_secs(&self, now_epoch_secs: i64) -> i64 {
128        now_epoch_secs - self.created_at
129    }
130}
131
132/// Key for a tracked model: `(measurement, model_name)`.
133type ModelKey = (String, String);
134
135/// Accuracy tracker that monitors multiple models.
136///
137/// Thread-safe — `models` wrapped in `RwLock`.
138#[derive(Debug)]
139pub struct AccuracyTracker {
140    config: AccuracyTrackerConfig,
141    models: RwLock<HashMap<ModelKey, ModelState>>,
142}
143
144impl AccuracyTracker {
145    /// Creates a new accuracy tracker with the given configuration.
146    pub fn new(config: AccuracyTrackerConfig) -> Self {
147        Self {
148            config,
149            models: RwLock::new(HashMap::new()),
150        }
151    }
152
153    /// Register a model for tracking.
154    pub fn register_model(&self, measurement: &str, model_name: &str, created_at_epoch_secs: i64) {
155        let key = (measurement.to_string(), model_name.to_string());
156        self.models
157            .write()
158            .entry(key)
159            .or_insert_with(|| ModelState::new(self.config.clone(), created_at_epoch_secs));
160    }
161
162    /// Add an observation (actual vs predicted) for a tracked model.
163    pub fn add_observation(
164        &self,
165        measurement: &str,
166        model_name: &str,
167        actual: f64,
168        predicted: f64,
169    ) {
170        let key = (measurement.to_string(), model_name.to_string());
171        let mut models = self.models.write();
172        if let Some(state) = models.get_mut(&key) {
173            state.add_observation(actual, predicted);
174            if let Some(m) = state.current_metrics() {
175                metrics::gauge!("chronix_model_accuracy_mape").set(m.mape);
176                metrics::gauge!("chronix_model_accuracy_rmse").set(m.rmse);
177            }
178        }
179    }
180
181    /// Get current accuracy metrics for a model.
182    pub fn metrics(&self, measurement: &str, model_name: &str) -> Option<AccuracyMetrics> {
183        let key = (measurement.to_string(), model_name.to_string());
184        self.models.read().get(&key)?.current_metrics()
185    }
186
187    /// Check if a model needs re-fitting based on sustained accuracy degradation.
188    pub fn needs_refit(&self, measurement: &str, model_name: &str) -> bool {
189        let key = (measurement.to_string(), model_name.to_string());
190        self.models
191            .read()
192            .get(&key)
193            .is_some_and(ModelState::needs_refit)
194    }
195
196    /// Check if a model is stale (older than `max_model_age_secs`).
197    pub fn is_stale(&self, measurement: &str, model_name: &str, now_epoch_secs: i64) -> bool {
198        let key = (measurement.to_string(), model_name.to_string());
199        self.models
200            .read()
201            .get(&key)
202            .is_some_and(|s| s.is_stale(now_epoch_secs))
203    }
204
205    /// Get model age in seconds.
206    pub fn model_age_secs(
207        &self,
208        measurement: &str,
209        model_name: &str,
210        now_epoch_secs: i64,
211    ) -> Option<i64> {
212        let key = (measurement.to_string(), model_name.to_string());
213        self.models.read().get(&key).map(|s| {
214            let age = s.age_secs(now_epoch_secs);
215            metrics::gauge!("chronix_model_age_seconds", "measurement" => measurement.to_string())
216                .set(age as f64);
217            age
218        })
219    }
220
221    /// Number of tracked models.
222    pub fn tracked_model_count(&self) -> usize {
223        self.models.read().len()
224    }
225}
226
227// -----------------------------------------------------------------------
228// Tests
229// -----------------------------------------------------------------------
230#[cfg(test)]
231mod tests {
232    use super::*;
233
234    fn make_tracker() -> AccuracyTracker {
235        let config = AccuracyTrackerConfig {
236            window_size: 10,
237            refit_mape_threshold: 0.10,
238            refit_window_count: 3,
239            max_model_age_secs: 3600,
240        };
241        let tracker = AccuracyTracker::new(config);
242        tracker.register_model("cpu", "ses_model", 1000);
243        tracker
244    }
245
246    #[test]
247    fn test_register_and_add_observation() {
248        let tracker = make_tracker();
249        tracker.add_observation("cpu", "ses_model", 100.0, 105.0);
250        tracker.add_observation("cpu", "ses_model", 200.0, 195.0);
251
252        let m = tracker.metrics("cpu", "ses_model").unwrap();
253        assert!(m.mae > 0.0);
254    }
255
256    #[test]
257    fn test_no_metrics_without_observations() {
258        let tracker = make_tracker();
259        assert!(tracker.metrics("cpu", "ses_model").is_none());
260    }
261
262    #[test]
263    fn test_needs_refit_bad_accuracy() {
264        let tracker = make_tracker();
265
266        // Add observations with > 10% MAPE
267        for i in 0..50 {
268            let actual = 100.0 + i as f64;
269            let pred = actual * 1.20; // 20% off
270            tracker.add_observation("cpu", "ses_model", actual, pred);
271        }
272
273        // Check multiple times with new data each round to accumulate
274        // consecutive_bad_windows (each call requires fresh observations).
275        for round in 0..3 {
276            for j in 0..5 {
277                let actual = 200.0 + (round * 5 + j) as f64;
278                let pred = actual * 1.20;
279                tracker.add_observation("cpu", "ses_model", actual, pred);
280            }
281            tracker.needs_refit("cpu", "ses_model");
282        }
283        assert!(tracker.needs_refit("cpu", "ses_model"));
284    }
285
286    #[test]
287    fn test_no_refit_good_accuracy() {
288        let tracker = make_tracker();
289
290        for i in 0..50 {
291            let actual = 100.0 + i as f64;
292            let pred = actual * 1.01; // 1% off
293            tracker.add_observation("cpu", "ses_model", actual, pred);
294        }
295
296        assert!(!tracker.needs_refit("cpu", "ses_model"));
297    }
298
299    #[test]
300    fn test_staleness() {
301        let tracker = make_tracker();
302        // created_at=1000, max_age=3600
303        assert!(!tracker.is_stale("cpu", "ses_model", 2000)); // 1000s < 3600s
304        assert!(tracker.is_stale("cpu", "ses_model", 5000)); // 4000s > 3600s
305    }
306
307    #[test]
308    fn test_model_age() {
309        let tracker = make_tracker();
310        let age = tracker.model_age_secs("cpu", "ses_model", 2000).unwrap();
311        assert_eq!(age, 1000);
312    }
313
314    #[test]
315    fn test_nonexistent_model() {
316        let tracker = make_tracker();
317        assert!(tracker.metrics("memory", "foo").is_none());
318        assert!(!tracker.is_stale("memory", "foo", 0));
319    }
320
321    #[test]
322    fn test_tracked_model_count() {
323        let tracker = make_tracker();
324        assert_eq!(tracker.tracked_model_count(), 1);
325        tracker.register_model("mem", "detector", 2000);
326        assert_eq!(tracker.tracked_model_count(), 2);
327    }
328}