chronix_analytics/lifecycle/
accuracy.rs1use std::collections::{HashMap, VecDeque};
7use std::sync::atomic::{AtomicUsize, Ordering};
8
9use parking_lot::RwLock;
10
11use crate::lifecycle::registry::AccuracyMetrics;
12
13#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
15pub struct AccuracyTrackerConfig {
16 pub window_size: usize,
18 pub refit_mape_threshold: f64,
20 pub refit_window_count: usize,
23 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#[derive(Debug, Clone)]
40struct Observation {
41 actual: f64,
42 predicted: f64,
43}
44
45#[derive(Debug)]
47struct ModelState {
48 observations: VecDeque<Observation>,
49 config: AccuracyTrackerConfig,
50 consecutive_bad_windows: AtomicUsize,
51 created_at: i64,
53 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 fn needs_refit(&self) -> bool {
106 let current_count = self.observations.len();
107 let prev = self.last_refit_check_count.load(Ordering::Relaxed);
108 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
132type ModelKey = (String, String);
134
135#[derive(Debug)]
139pub struct AccuracyTracker {
140 config: AccuracyTrackerConfig,
141 models: RwLock<HashMap<ModelKey, ModelState>>,
142}
143
144impl AccuracyTracker {
145 pub fn new(config: AccuracyTrackerConfig) -> Self {
147 Self {
148 config,
149 models: RwLock::new(HashMap::new()),
150 }
151 }
152
153 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 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 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 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 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 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 pub fn tracked_model_count(&self) -> usize {
223 self.models.read().len()
224 }
225}
226
227#[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 for i in 0..50 {
268 let actual = 100.0 + i as f64;
269 let pred = actual * 1.20; tracker.add_observation("cpu", "ses_model", actual, pred);
271 }
272
273 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; 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 assert!(!tracker.is_stale("cpu", "ses_model", 2000)); assert!(tracker.is_stale("cpu", "ses_model", 5000)); }
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}