1use std::collections::VecDeque;
6
7use metrics::gauge;
8use parking_lot::RwLock;
9use serde::{Deserialize, Serialize};
10use tracing::debug;
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct AccuracyMetrics {
15 pub model_type: String,
17 pub measurement: String,
19 pub version: String,
21 pub mape: f64,
23 pub rmse: f64,
25 pub mae: f64,
27 pub mase: Option<f64>,
32 pub n_points: usize,
34 pub evaluated_at: i64,
36}
37
38impl AccuracyMetrics {
39 pub fn emit_prometheus(&self) {
41 gauge!(
42 "chronix_forecast_mape",
43 "measurement" => self.measurement.clone(),
44 "model_type" => self.model_type.clone()
45 )
46 .set(self.mape);
47 gauge!(
48 "chronix_forecast_rmse",
49 "measurement" => self.measurement.clone(),
50 "model_type" => self.model_type.clone()
51 )
52 .set(self.rmse);
53 gauge!(
54 "chronix_forecast_mae",
55 "measurement" => self.measurement.clone(),
56 "model_type" => self.model_type.clone()
57 )
58 .set(self.mae);
59 if let Some(mase_val) = self.mase {
60 gauge!(
61 "chronix_forecast_mase",
62 "measurement" => self.measurement.clone(),
63 "model_type" => self.model_type.clone()
64 )
65 .set(mase_val);
66 }
67 }
68}
69
70pub fn compute_accuracy(
77 forecasted: &[f64],
78 actual: &[f64],
79 model_type: &str,
80 measurement: &str,
81 version: &str,
82) -> std::result::Result<AccuracyMetrics, crate::error::AnalyticsError> {
83 if forecasted.len() != actual.len() {
84 return Err(crate::error::AnalyticsError::Config(format!(
85 "forecasted length ({}) != actual length ({})",
86 forecasted.len(),
87 actual.len(),
88 )));
89 }
90
91 let n = forecasted.len();
92 if n == 0 {
93 return Ok(AccuracyMetrics {
94 model_type: model_type.to_string(),
95 measurement: measurement.to_string(),
96 version: version.to_string(),
97 mape: 0.0,
98 rmse: 0.0,
99 mae: 0.0,
100 mase: None,
101 n_points: 0,
102 evaluated_at: now_nanos(),
103 });
104 }
105
106 let mut sum_abs_error = 0.0;
107 let mut sum_sq_error = 0.0;
108 let mut sum_abs_pct_error = 0.0;
109 let mut pct_count = 0usize;
110
111 for (f, a) in forecasted.iter().zip(actual.iter()) {
116 let error = f - a;
117 sum_abs_error += error.abs();
118 sum_sq_error += error * error;
119
120 if a.abs() > f64::EPSILON {
121 sum_abs_pct_error += (error / a).abs();
122 pct_count += 1;
123 }
124 }
125
126 let mae = sum_abs_error / n as f64;
127 let rmse = (sum_sq_error / n as f64).sqrt();
128 let mape = if pct_count > 0 {
133 100.0 * sum_abs_pct_error / pct_count as f64
134 } else {
135 f64::NAN
136 };
137
138 Ok(AccuracyMetrics {
139 model_type: model_type.to_string(),
140 measurement: measurement.to_string(),
141 version: version.to_string(),
142 mape,
143 rmse,
144 mae,
145 mase: None,
146 n_points: n,
147 evaluated_at: now_nanos(),
148 })
149}
150
151pub fn compute_accuracy_with_mase(
166 forecasted: &[f64],
167 actual: &[f64],
168 training_actual: &[f64],
169 model_type: &str,
170 measurement: &str,
171 version: &str,
172) -> std::result::Result<AccuracyMetrics, crate::error::AnalyticsError> {
173 let mut metrics = compute_accuracy(forecasted, actual, model_type, measurement, version)?;
174 let mase_val = mase(actual, forecasted, training_actual);
175 metrics.mase = if mase_val.is_finite() {
176 Some(mase_val)
177 } else {
178 None
179 };
180 Ok(metrics)
181}
182
183pub fn mase(actual: &[f64], forecasted: &[f64], training: &[f64]) -> f64 {
188 if training.len() < 2 || actual.len() != forecasted.len() || actual.is_empty() {
189 return f64::NAN;
190 }
191 let naive_mae: f64 = training
193 .windows(2)
194 .map(|w| (w[1] - w[0]).abs())
195 .sum::<f64>()
196 / (training.len() - 1) as f64;
197 if naive_mae < f64::EPSILON {
198 return f64::NAN;
199 }
200 let forecast_mae: f64 = actual
201 .iter()
202 .zip(forecasted.iter())
203 .map(|(a, f)| (a - f).abs())
204 .sum::<f64>()
205 / actual.len() as f64;
206 forecast_mae / naive_mae
207}
208
209pub struct ForecastAccuracyTracker {
211 metrics: RwLock<VecDeque<AccuracyMetrics>>,
212 max_staleness_nanos: i64,
214 max_entries: usize,
216}
217
218impl ForecastAccuracyTracker {
219 #[must_use]
221 pub fn new() -> Self {
222 Self {
223 metrics: RwLock::new(VecDeque::new()),
224 max_staleness_nanos: 24 * 3600 * 1_000_000_000,
226 max_entries: 10_000,
227 }
228 }
229
230 #[must_use]
232 pub fn with_max_staleness_nanos(mut self, nanos: i64) -> Self {
233 self.max_staleness_nanos = nanos;
234 self
235 }
236
237 #[must_use]
239 pub fn with_max_entries(mut self, max: usize) -> Self {
240 self.max_entries = max;
241 self
242 }
243
244 pub fn record(&self, metrics: AccuracyMetrics) {
246 metrics.emit_prometheus();
247 debug!(
248 measurement = %metrics.measurement,
249 model_type = %metrics.model_type,
250 mape = metrics.mape,
251 rmse = metrics.rmse,
252 mae = metrics.mae,
253 "Forecast accuracy recorded"
254 );
255 let mut store = self.metrics.write();
256 store.push_back(metrics);
257 while store.len() > self.max_entries {
258 store.pop_front();
259 }
260 }
261
262 pub fn evaluate(
269 &self,
270 forecasted: &[f64],
271 actual: &[f64],
272 model_type: &str,
273 measurement: &str,
274 version: &str,
275 ) -> std::result::Result<AccuracyMetrics, crate::error::AnalyticsError> {
276 let m = compute_accuracy(forecasted, actual, model_type, measurement, version)?;
277 self.record(m.clone());
278 Ok(m)
279 }
280
281 #[must_use]
283 pub fn all(&self) -> Vec<AccuracyMetrics> {
284 self.metrics.read().iter().cloned().collect()
285 }
286
287 #[must_use]
289 pub fn for_measurement(&self, measurement: &str) -> Vec<AccuracyMetrics> {
290 self.metrics
291 .read()
292 .iter()
293 .filter(|m| m.measurement == measurement)
294 .cloned()
295 .collect()
296 }
297
298 #[must_use]
300 pub fn latest(&self, measurement: &str) -> Option<AccuracyMetrics> {
301 self.metrics
302 .read()
303 .iter()
304 .filter(|m| m.measurement == measurement)
305 .max_by_key(|m| m.evaluated_at)
306 .cloned()
307 }
308
309 #[must_use]
311 pub fn stale_measurements(&self, known_measurements: &[&str]) -> Vec<String> {
312 let now = now_nanos();
313 let store = self.metrics.read();
314
315 known_measurements
316 .iter()
317 .filter(|m| {
318 let latest = store
319 .iter()
320 .filter(|acc| acc.measurement == **m)
321 .max_by_key(|acc| acc.evaluated_at);
322 match latest {
323 Some(acc) => (now - acc.evaluated_at) > self.max_staleness_nanos,
324 None => true,
325 }
326 })
327 .map(std::string::ToString::to_string)
328 .collect()
329 }
330
331 #[must_use]
333 pub fn count(&self) -> usize {
334 self.metrics.read().len()
335 }
336}
337
338impl Default for ForecastAccuracyTracker {
339 fn default() -> Self {
340 Self::new()
341 }
342}
343
344fn now_nanos() -> i64 {
345 i64::try_from(
346 std::time::SystemTime::now()
347 .duration_since(std::time::UNIX_EPOCH)
348 .unwrap_or_default()
349 .as_nanos(),
350 )
351 .unwrap_or(i64::MAX)
352}
353
354#[cfg(test)]
355mod tests {
356 use super::*;
357
358 #[test]
359 fn compute_accuracy_perfect() {
360 let actual = vec![1.0, 2.0, 3.0, 4.0, 5.0];
361 let forecasted = actual.clone();
362 let m = compute_accuracy(&forecasted, &actual, "ses", "cpu", "v1").unwrap();
363 assert!(m.mape.abs() < f64::EPSILON);
364 assert!(m.rmse.abs() < f64::EPSILON);
365 assert!(m.mae.abs() < f64::EPSILON);
366 assert_eq!(m.n_points, 5);
367 }
368
369 #[test]
370 fn compute_accuracy_with_error() {
371 let actual = vec![10.0, 20.0, 30.0];
372 let forecasted = vec![12.0, 18.0, 33.0];
373 let m = compute_accuracy(&forecasted, &actual, "holt", "energy", "v1").unwrap();
374
375 assert!((m.mae - 7.0 / 3.0).abs() < 1e-10);
377 assert!((m.mape - 100.0 * 0.4 / 3.0).abs() < 1e-10);
379 assert!((m.rmse - (17.0_f64 / 3.0).sqrt()).abs() < 1e-10);
381 }
382
383 #[test]
384 fn compute_accuracy_empty() {
385 let m = compute_accuracy(&[], &[], "ses", "cpu", "v1").unwrap();
386 assert_eq!(m.n_points, 0);
387 assert!(m.mape.abs() < f64::EPSILON);
388 }
389
390 #[test]
391 fn compute_accuracy_zero_actual() {
392 let actual = vec![0.0, 10.0];
394 let forecasted = vec![1.0, 11.0];
395 let m = compute_accuracy(&forecasted, &actual, "ses", "cpu", "v1").unwrap();
396 assert!((m.mape - 10.0).abs() < 1e-10);
398 assert!((m.mae - 1.0).abs() < f64::EPSILON);
400 }
401
402 #[test]
403 fn tracker_record_and_query() {
404 let tracker = ForecastAccuracyTracker::new();
405 let m1 = compute_accuracy(&[10.0], &[10.5], "ses", "cpu", "v1").unwrap();
406 let m2 = compute_accuracy(&[20.0], &[21.0], "holt", "energy", "v1").unwrap();
407 tracker.record(m1);
408 tracker.record(m2);
409
410 assert_eq!(tracker.count(), 2);
411 let cpu = tracker.for_measurement("cpu");
412 assert_eq!(cpu.len(), 1);
413 assert_eq!(cpu[0].model_type, "ses");
414 }
415
416 #[test]
417 fn tracker_latest() {
418 let tracker = ForecastAccuracyTracker::new();
419
420 let mut m1 = compute_accuracy(&[10.0], &[10.5], "ses", "cpu", "v1").unwrap();
421 m1.evaluated_at = 100;
422 let mut m2 = compute_accuracy(&[10.0], &[10.3], "ses", "cpu", "v2").unwrap();
423 m2.evaluated_at = 200;
424
425 tracker.record(m1);
426 tracker.record(m2);
427
428 let latest = tracker.latest("cpu").unwrap();
429 assert_eq!(latest.version, "v2");
430 assert_eq!(latest.evaluated_at, 200);
431 }
432
433 #[test]
434 fn tracker_evaluate() {
435 let tracker = ForecastAccuracyTracker::new();
436 let m = tracker
437 .evaluate(&[10.0, 20.0], &[11.0, 19.0], "ses", "cpu", "v1")
438 .unwrap();
439 assert_eq!(m.n_points, 2);
440 assert_eq!(tracker.count(), 1);
441 }
442
443 #[test]
444 fn stale_measurements() {
445 let tracker = ForecastAccuracyTracker::new().with_max_staleness_nanos(1_000_000); tracker
449 .evaluate(&[10.0], &[10.5], "ses", "cpu", "v1")
450 .unwrap();
451
452 let stale = tracker.stale_measurements(&["cpu", "memory"]);
454 assert!(stale.contains(&"memory".to_string()));
455 }
457
458 #[test]
459 fn accuracy_serde_roundtrip() {
460 let m = compute_accuracy(&[1.0, 2.0], &[1.1, 2.2], "ses", "cpu", "v1").unwrap();
461 let json = serde_json::to_string(&m).unwrap();
462 let de: AccuracyMetrics = serde_json::from_str(&json).unwrap();
463 assert_eq!(de.model_type, "ses");
464 assert_eq!(de.measurement, "cpu");
465 assert!((de.mae - m.mae).abs() < f64::EPSILON);
466 }
467
468 #[test]
469 fn bounded_accuracy_fifo_eviction() {
470 let tracker = ForecastAccuracyTracker::new().with_max_entries(3);
471
472 for i in 0..6 {
473 let mut m = compute_accuracy(&[1.0], &[1.1], "ses", &format!("m{i}"), "v1").unwrap();
474 m.evaluated_at = i as i64;
475 tracker.record(m);
476 }
477
478 assert_eq!(tracker.count(), 3);
479 let all = tracker.all();
480 let measurements: Vec<_> = all.iter().map(|m| m.measurement.as_str()).collect();
482 assert_eq!(measurements, vec!["m3", "m4", "m5"]);
483 }
484
485 #[test]
486 fn bounded_accuracy_latest_uses_retained() {
487 let tracker = ForecastAccuracyTracker::new().with_max_entries(2);
488
489 for i in 0..3 {
491 let mut m = compute_accuracy(&[1.0], &[1.1 + i as f64], "ses", "cpu", &format!("v{i}"))
492 .unwrap();
493 m.evaluated_at = i as i64;
494 tracker.record(m);
495 }
496
497 assert_eq!(tracker.count(), 2);
498 let latest = tracker.latest("cpu").unwrap();
499 assert_eq!(latest.version, "v2");
500 }
501}