use std::collections::VecDeque;
use metrics::gauge;
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
use tracing::debug;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AccuracyMetrics {
pub model_type: String,
pub measurement: String,
pub version: String,
pub mape: f64,
pub rmse: f64,
pub mae: f64,
pub mase: Option<f64>,
pub n_points: usize,
pub evaluated_at: i64,
}
impl AccuracyMetrics {
pub fn emit_prometheus(&self) {
gauge!(
"chronix_forecast_mape",
"measurement" => self.measurement.clone(),
"model_type" => self.model_type.clone()
)
.set(self.mape);
gauge!(
"chronix_forecast_rmse",
"measurement" => self.measurement.clone(),
"model_type" => self.model_type.clone()
)
.set(self.rmse);
gauge!(
"chronix_forecast_mae",
"measurement" => self.measurement.clone(),
"model_type" => self.model_type.clone()
)
.set(self.mae);
if let Some(mase_val) = self.mase {
gauge!(
"chronix_forecast_mase",
"measurement" => self.measurement.clone(),
"model_type" => self.model_type.clone()
)
.set(mase_val);
}
}
}
pub fn compute_accuracy(
forecasted: &[f64],
actual: &[f64],
model_type: &str,
measurement: &str,
version: &str,
) -> std::result::Result<AccuracyMetrics, crate::error::AnalyticsError> {
if forecasted.len() != actual.len() {
return Err(crate::error::AnalyticsError::Config(format!(
"forecasted length ({}) != actual length ({})",
forecasted.len(),
actual.len(),
)));
}
let n = forecasted.len();
if n == 0 {
return Ok(AccuracyMetrics {
model_type: model_type.to_string(),
measurement: measurement.to_string(),
version: version.to_string(),
mape: 0.0,
rmse: 0.0,
mae: 0.0,
mase: None,
n_points: 0,
evaluated_at: now_nanos(),
});
}
let mut sum_abs_error = 0.0;
let mut sum_sq_error = 0.0;
let mut sum_abs_pct_error = 0.0;
let mut pct_count = 0usize;
for (f, a) in forecasted.iter().zip(actual.iter()) {
let error = f - a;
sum_abs_error += error.abs();
sum_sq_error += error * error;
if a.abs() > f64::EPSILON {
sum_abs_pct_error += (error / a).abs();
pct_count += 1;
}
}
let mae = sum_abs_error / n as f64;
let rmse = (sum_sq_error / n as f64).sqrt();
let mape = if pct_count > 0 {
100.0 * sum_abs_pct_error / pct_count as f64
} else {
f64::NAN
};
Ok(AccuracyMetrics {
model_type: model_type.to_string(),
measurement: measurement.to_string(),
version: version.to_string(),
mape,
rmse,
mae,
mase: None,
n_points: n,
evaluated_at: now_nanos(),
})
}
pub fn compute_accuracy_with_mase(
forecasted: &[f64],
actual: &[f64],
training_actual: &[f64],
model_type: &str,
measurement: &str,
version: &str,
) -> std::result::Result<AccuracyMetrics, crate::error::AnalyticsError> {
let mut metrics = compute_accuracy(forecasted, actual, model_type, measurement, version)?;
let mase_val = mase(actual, forecasted, training_actual);
metrics.mase = if mase_val.is_finite() {
Some(mase_val)
} else {
None
};
Ok(metrics)
}
pub fn mase(actual: &[f64], forecasted: &[f64], training: &[f64]) -> f64 {
if training.len() < 2 || actual.len() != forecasted.len() || actual.is_empty() {
return f64::NAN;
}
let naive_mae: f64 = training
.windows(2)
.map(|w| (w[1] - w[0]).abs())
.sum::<f64>()
/ (training.len() - 1) as f64;
if naive_mae < f64::EPSILON {
return f64::NAN;
}
let forecast_mae: f64 = actual
.iter()
.zip(forecasted.iter())
.map(|(a, f)| (a - f).abs())
.sum::<f64>()
/ actual.len() as f64;
forecast_mae / naive_mae
}
pub struct ForecastAccuracyTracker {
metrics: RwLock<VecDeque<AccuracyMetrics>>,
max_staleness_nanos: i64,
max_entries: usize,
}
impl ForecastAccuracyTracker {
#[must_use]
pub fn new() -> Self {
Self {
metrics: RwLock::new(VecDeque::new()),
max_staleness_nanos: 24 * 3600 * 1_000_000_000,
max_entries: 10_000,
}
}
#[must_use]
pub fn with_max_staleness_nanos(mut self, nanos: i64) -> Self {
self.max_staleness_nanos = nanos;
self
}
#[must_use]
pub fn with_max_entries(mut self, max: usize) -> Self {
self.max_entries = max;
self
}
pub fn record(&self, metrics: AccuracyMetrics) {
metrics.emit_prometheus();
debug!(
measurement = %metrics.measurement,
model_type = %metrics.model_type,
mape = metrics.mape,
rmse = metrics.rmse,
mae = metrics.mae,
"Forecast accuracy recorded"
);
let mut store = self.metrics.write();
store.push_back(metrics);
while store.len() > self.max_entries {
store.pop_front();
}
}
pub fn evaluate(
&self,
forecasted: &[f64],
actual: &[f64],
model_type: &str,
measurement: &str,
version: &str,
) -> std::result::Result<AccuracyMetrics, crate::error::AnalyticsError> {
let m = compute_accuracy(forecasted, actual, model_type, measurement, version)?;
self.record(m.clone());
Ok(m)
}
#[must_use]
pub fn all(&self) -> Vec<AccuracyMetrics> {
self.metrics.read().iter().cloned().collect()
}
#[must_use]
pub fn for_measurement(&self, measurement: &str) -> Vec<AccuracyMetrics> {
self.metrics
.read()
.iter()
.filter(|m| m.measurement == measurement)
.cloned()
.collect()
}
#[must_use]
pub fn latest(&self, measurement: &str) -> Option<AccuracyMetrics> {
self.metrics
.read()
.iter()
.filter(|m| m.measurement == measurement)
.max_by_key(|m| m.evaluated_at)
.cloned()
}
#[must_use]
pub fn stale_measurements(&self, known_measurements: &[&str]) -> Vec<String> {
let now = now_nanos();
let store = self.metrics.read();
known_measurements
.iter()
.filter(|m| {
let latest = store
.iter()
.filter(|acc| acc.measurement == **m)
.max_by_key(|acc| acc.evaluated_at);
match latest {
Some(acc) => (now - acc.evaluated_at) > self.max_staleness_nanos,
None => true,
}
})
.map(std::string::ToString::to_string)
.collect()
}
#[must_use]
pub fn count(&self) -> usize {
self.metrics.read().len()
}
}
impl Default for ForecastAccuracyTracker {
fn default() -> Self {
Self::new()
}
}
fn now_nanos() -> i64 {
i64::try_from(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos(),
)
.unwrap_or(i64::MAX)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn compute_accuracy_perfect() {
let actual = vec![1.0, 2.0, 3.0, 4.0, 5.0];
let forecasted = actual.clone();
let m = compute_accuracy(&forecasted, &actual, "ses", "cpu", "v1").unwrap();
assert!(m.mape.abs() < f64::EPSILON);
assert!(m.rmse.abs() < f64::EPSILON);
assert!(m.mae.abs() < f64::EPSILON);
assert_eq!(m.n_points, 5);
}
#[test]
fn compute_accuracy_with_error() {
let actual = vec![10.0, 20.0, 30.0];
let forecasted = vec![12.0, 18.0, 33.0];
let m = compute_accuracy(&forecasted, &actual, "holt", "energy", "v1").unwrap();
assert!((m.mae - 7.0 / 3.0).abs() < 1e-10);
assert!((m.mape - 100.0 * 0.4 / 3.0).abs() < 1e-10);
assert!((m.rmse - (17.0_f64 / 3.0).sqrt()).abs() < 1e-10);
}
#[test]
fn compute_accuracy_empty() {
let m = compute_accuracy(&[], &[], "ses", "cpu", "v1").unwrap();
assert_eq!(m.n_points, 0);
assert!(m.mape.abs() < f64::EPSILON);
}
#[test]
fn compute_accuracy_zero_actual() {
let actual = vec![0.0, 10.0];
let forecasted = vec![1.0, 11.0];
let m = compute_accuracy(&forecasted, &actual, "ses", "cpu", "v1").unwrap();
assert!((m.mape - 10.0).abs() < 1e-10);
assert!((m.mae - 1.0).abs() < f64::EPSILON);
}
#[test]
fn tracker_record_and_query() {
let tracker = ForecastAccuracyTracker::new();
let m1 = compute_accuracy(&[10.0], &[10.5], "ses", "cpu", "v1").unwrap();
let m2 = compute_accuracy(&[20.0], &[21.0], "holt", "energy", "v1").unwrap();
tracker.record(m1);
tracker.record(m2);
assert_eq!(tracker.count(), 2);
let cpu = tracker.for_measurement("cpu");
assert_eq!(cpu.len(), 1);
assert_eq!(cpu[0].model_type, "ses");
}
#[test]
fn tracker_latest() {
let tracker = ForecastAccuracyTracker::new();
let mut m1 = compute_accuracy(&[10.0], &[10.5], "ses", "cpu", "v1").unwrap();
m1.evaluated_at = 100;
let mut m2 = compute_accuracy(&[10.0], &[10.3], "ses", "cpu", "v2").unwrap();
m2.evaluated_at = 200;
tracker.record(m1);
tracker.record(m2);
let latest = tracker.latest("cpu").unwrap();
assert_eq!(latest.version, "v2");
assert_eq!(latest.evaluated_at, 200);
}
#[test]
fn tracker_evaluate() {
let tracker = ForecastAccuracyTracker::new();
let m = tracker
.evaluate(&[10.0, 20.0], &[11.0, 19.0], "ses", "cpu", "v1")
.unwrap();
assert_eq!(m.n_points, 2);
assert_eq!(tracker.count(), 1);
}
#[test]
fn stale_measurements() {
let tracker = ForecastAccuracyTracker::new().with_max_staleness_nanos(1_000_000);
tracker
.evaluate(&[10.0], &[10.5], "ses", "cpu", "v1")
.unwrap();
let stale = tracker.stale_measurements(&["cpu", "memory"]);
assert!(stale.contains(&"memory".to_string()));
}
#[test]
fn accuracy_serde_roundtrip() {
let m = compute_accuracy(&[1.0, 2.0], &[1.1, 2.2], "ses", "cpu", "v1").unwrap();
let json = serde_json::to_string(&m).unwrap();
let de: AccuracyMetrics = serde_json::from_str(&json).unwrap();
assert_eq!(de.model_type, "ses");
assert_eq!(de.measurement, "cpu");
assert!((de.mae - m.mae).abs() < f64::EPSILON);
}
#[test]
fn bounded_accuracy_fifo_eviction() {
let tracker = ForecastAccuracyTracker::new().with_max_entries(3);
for i in 0..6 {
let mut m = compute_accuracy(&[1.0], &[1.1], "ses", &format!("m{i}"), "v1").unwrap();
m.evaluated_at = i as i64;
tracker.record(m);
}
assert_eq!(tracker.count(), 3);
let all = tracker.all();
let measurements: Vec<_> = all.iter().map(|m| m.measurement.as_str()).collect();
assert_eq!(measurements, vec!["m3", "m4", "m5"]);
}
#[test]
fn bounded_accuracy_latest_uses_retained() {
let tracker = ForecastAccuracyTracker::new().with_max_entries(2);
for i in 0..3 {
let mut m = compute_accuracy(&[1.0], &[1.1 + i as f64], "ses", "cpu", &format!("v{i}"))
.unwrap();
m.evaluated_at = i as i64;
tracker.record(m);
}
assert_eq!(tracker.count(), 2);
let latest = tracker.latest("cpu").unwrap();
assert_eq!(latest.version, "v2");
}
}