Skip to main content

chronix_analytics/
accuracy.rs

1//! Forecast accuracy tracking — compares past forecasts with actual values.
2//!
3//! Computes MAPE, RMSE, and MAE for each model and writes accuracy metrics.
4
5use std::collections::VecDeque;
6
7use metrics::gauge;
8use parking_lot::RwLock;
9use serde::{Deserialize, Serialize};
10use tracing::debug;
11
12/// Accuracy metrics for a forecast model.
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct AccuracyMetrics {
15    /// Model type name.
16    pub model_type: String,
17    /// Measurement name.
18    pub measurement: String,
19    /// Model version or identifier.
20    pub version: String,
21    /// Mean Absolute Percentage Error.
22    pub mape: f64,
23    /// Root Mean Square Error.
24    pub rmse: f64,
25    /// Mean Absolute Error.
26    pub mae: f64,
27    /// Mean Absolute Scaled Error (MASE) — scale-independent metric
28    /// recommended by Hyndman & Koehler (2006). Uses in-sample naive
29    /// forecast errors as the scaling denominator. `None` when no
30    /// training data is provided or when the naive MAE is zero.
31    pub mase: Option<f64>,
32    /// Number of data points evaluated.
33    pub n_points: usize,
34    /// Evaluation timestamp (nanos since epoch).
35    pub evaluated_at: i64,
36}
37
38impl AccuracyMetrics {
39    /// Emit Prometheus metrics.
40    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
70/// Compute accuracy metrics from forecasted vs actual values.
71///
72/// # Errors
73///
74/// Returns `AnalyticsError::Config` if `forecasted` and `actual` have
75/// different lengths.
76pub 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    // MAPE is undefined when actual == 0 (division by zero).
112    // We skip such points, matching the standard "filtered MAPE"
113    // definition.  If ALL actual values are zero, MAPE returns 0.0
114    // rather than NaN — callers should check `n_points` for validity.
115    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    // Return NaN (not 0.0) when ALL actuals are zero,
129    // making MAPE correctly distinguishable from "perfect forecast".
130    // Callers (AccuracyTracker, ABTestEvaluator) already guard on
131    // `is_nan()` before comparisons.
132    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
151/// Compute accuracy metrics including MASE using in-sample training data.
152///
153/// MASE (Mean Absolute Scaled Error) is a scale-independent metric that
154/// compares the forecast MAE against a naive random-walk baseline computed
155/// from the `training_actual` series. A MASE < 1 means the forecast is
156/// better than the naive baseline; MASE > 1 means it is worse.
157///
158/// Reference: Hyndman & Koehler (2006), "Another look at measures of
159/// forecast accuracy", International Journal of Forecasting.
160///
161/// # Errors
162///
163/// Returns `AnalyticsError::Config` if `forecasted` and `actual` have
164/// different lengths, or if `training_actual` has fewer than 2 elements.
165pub 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
183/// Compute the Mean Absolute Scaled Error (MASE).
184///
185/// Returns `NaN` when `training` has fewer than 2 elements or the naive
186/// in-sample MAE is zero (constant training series).
187pub 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    // Naive in-sample MAE: mean of |y_t - y_{t-1}| for t=1..n
192    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
209/// Forecast accuracy tracker — stores and queries accuracy metrics.
210pub struct ForecastAccuracyTracker {
211    metrics: RwLock<VecDeque<AccuracyMetrics>>,
212    /// Maximum staleness before warning (nanos).
213    max_staleness_nanos: i64,
214    /// Maximum stored metrics (FIFO eviction). Default: 10,000.
215    max_entries: usize,
216}
217
218impl ForecastAccuracyTracker {
219    /// Create a new tracker with default staleness (24h).
220    #[must_use]
221    pub fn new() -> Self {
222        Self {
223            metrics: RwLock::new(VecDeque::new()),
224            // 24 hours in nanos
225            max_staleness_nanos: 24 * 3600 * 1_000_000_000,
226            max_entries: 10_000,
227        }
228    }
229
230    /// Set the maximum staleness.
231    #[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    /// Set maximum stored metrics (FIFO eviction when exceeded).
238    #[must_use]
239    pub fn with_max_entries(mut self, max: usize) -> Self {
240        self.max_entries = max;
241        self
242    }
243
244    /// Record an accuracy evaluation.
245    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    /// Evaluate accuracy from forecasted vs actual values and record.
263    ///
264    /// # Errors
265    ///
266    /// Returns `AnalyticsError::Config` if `forecasted` and `actual` have
267    /// different lengths.
268    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    /// Get all accuracy metrics.
282    #[must_use]
283    pub fn all(&self) -> Vec<AccuracyMetrics> {
284        self.metrics.read().iter().cloned().collect()
285    }
286
287    /// Get accuracy metrics for a measurement.
288    #[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    /// Get the latest accuracy metric for a measurement.
299    #[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    /// Check for stale forecasts — returns measurements with no recent evaluation.
310    #[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    /// Count of recorded evaluations.
332    #[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        // MAE = (2 + 2 + 3) / 3 = 2.333...
376        assert!((m.mae - 7.0 / 3.0).abs() < 1e-10);
377        // MAPE = 100 * ((2/10) + (2/20) + (3/30)) / 3 = 100 * (0.2 + 0.1 + 0.1) / 3 = 100 * 0.4/3
378        assert!((m.mape - 100.0 * 0.4 / 3.0).abs() < 1e-10);
379        // RMSE = sqrt((4 + 4 + 9) / 3) = sqrt(17/3)
380        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        // When actual is 0, MAPE skips that point
393        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        // MAPE only uses the non-zero actual: 100 * |1/10| / 1 = 10%
397        assert!((m.mape - 10.0).abs() < 1e-10);
398        // MAE = (1 + 1) / 2 = 1
399        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); // 1ms
446
447        // cpu has a recent evaluation
448        tracker
449            .evaluate(&[10.0], &[10.5], "ses", "cpu", "v1")
450            .unwrap();
451
452        // memory has no evaluation at all
453        let stale = tracker.stale_measurements(&["cpu", "memory"]);
454        assert!(stale.contains(&"memory".to_string()));
455        // cpu may or may not be stale depending on timing
456    }
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        // Only m3, m4, m5 should remain
481        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        // Record 3 for "cpu" → only the last 2 are retained
490        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}