use std::collections::{BTreeMap, VecDeque};
use std::sync::Arc;
use std::time::Instant;
use crate::forecast::{
ArimaModel, ForecastModel, ForecastResult, HoltLinearModel, HoltWintersModel,
LinearRegressionModel, ModelType, SesModel,
};
use chronix_core::FieldValue;
use dashmap::DashMap;
use metrics::counter;
use tracing::{debug, warn};
use crate::error::{AnalyticsError, Result};
#[derive(Debug, Clone)]
pub struct ContinuousForecastConfig {
pub measurement: String,
pub field: Option<String>,
pub model_type: ModelType,
pub update_interval_points: usize,
pub refit_mae_multiplier: f64,
pub enabled: bool,
pub min_fit_points: usize,
pub max_buffer_size: usize,
pub min_mae_samples: usize,
}
impl ContinuousForecastConfig {
#[must_use]
pub fn new(measurement: impl Into<String>, model_type: ModelType) -> Self {
Self {
measurement: measurement.into(),
field: None,
model_type,
update_interval_points: 1000,
refit_mae_multiplier: 2.0,
enabled: true,
min_fit_points: 30,
max_buffer_size: 10_000,
min_mae_samples: 10,
}
}
#[must_use]
pub fn with_field(mut self, field: impl Into<String>) -> Self {
self.field = Some(field.into());
self
}
#[must_use]
pub fn with_update_interval(mut self, points: usize) -> Self {
self.update_interval_points = points;
self
}
#[must_use]
pub fn with_min_fit_points(mut self, n: usize) -> Self {
self.min_fit_points = n;
self
}
#[must_use]
pub fn with_max_buffer_size(mut self, n: usize) -> Self {
self.max_buffer_size = n;
self
}
}
struct ForecastState {
timestamps: VecDeque<i64>,
values: VecDeque<f64>,
model: Box<dyn ForecastModel>,
fitted: bool,
points_since_fit: usize,
baseline_mae: Option<f64>,
last_fit_time: Option<Instant>,
update_interval: usize,
refit_multiplier: f64,
min_mae_samples: usize,
min_fit_points: usize,
max_buffer_size: usize,
last_prediction: Option<f64>,
recent_errors: VecDeque<f64>,
}
impl ForecastState {
fn new(config: &ContinuousForecastConfig) -> Self {
let model: Box<dyn ForecastModel> = create_model(&config.model_type);
Self {
timestamps: VecDeque::with_capacity(config.min_fit_points),
values: VecDeque::with_capacity(config.min_fit_points),
model,
fitted: false,
points_since_fit: 0,
baseline_mae: None,
last_fit_time: None,
update_interval: config.update_interval_points,
refit_multiplier: config.refit_mae_multiplier,
min_mae_samples: config.min_mae_samples,
min_fit_points: config.min_fit_points,
max_buffer_size: config.max_buffer_size,
last_prediction: None,
recent_errors: VecDeque::with_capacity(50),
}
}
}
fn create_model(model_type: &ModelType) -> Box<dyn ForecastModel> {
match model_type {
ModelType::Ses => Box::new(SesModel::new(Some(0.3))),
ModelType::HoltLinear => Box::new(HoltLinearModel::new(Some(0.3), Some(0.1), 1.0)),
ModelType::HoltWinters => Box::new(HoltWintersModel::new(Some(0.3), Some(0.1), Some(0.1), Some(12), false)),
ModelType::LinearRegression => Box::new(LinearRegressionModel::new()),
ModelType::Arima => Box::new(ArimaModel::new(1, 1, 1)),
ModelType::Sarima => {
warn!("SARIMA not yet supported in continuous mode, using ARIMA(1,1,1)");
Box::new(ArimaModel::new(1, 1, 1))
}
ModelType::Custom(name) => {
crate::registry::global_registry()
.create_model_default(name)
.unwrap_or_else(|e| {
warn!(plugin = %name, error = %e, "custom model plugin failed, falling back to SES");
Box::new(SesModel::new(Some(0.3)))
})
}
}
}
type ForecastStateKey = (String, String);
#[derive(Debug, Clone)]
pub enum ForecastUpdate {
Accumulating,
InitialFit,
Incremental,
ReFit,
}
pub struct ContinuousForecastEngine {
configs: DashMap<String, ContinuousForecastConfig>,
series_state: DashMap<ForecastStateKey, Arc<parking_lot::Mutex<ForecastState>>>,
}
impl ContinuousForecastEngine {
#[must_use]
pub fn new() -> Self {
Self {
configs: DashMap::new(),
series_state: DashMap::new(),
}
}
pub fn enable(&self, config: ContinuousForecastConfig) -> Result<()> {
if !config.enabled {
return Err(AnalyticsError::Config("Config is disabled".into()));
}
let measurement = config.measurement.clone();
self.configs.insert(measurement.clone(), config);
debug!(measurement = %measurement, "Continuous forecasting enabled");
Ok(())
}
pub fn disable(&self, measurement: &str) {
self.configs.remove(measurement);
self.series_state.retain(|k, _| k.0 != measurement);
}
#[must_use]
pub fn is_enabled(&self, measurement: &str) -> bool {
self.configs.contains_key(measurement)
}
pub fn process_point(
&self,
measurement: &str,
tags: &BTreeMap<String, String>,
fields: &BTreeMap<String, FieldValue>,
timestamp: i64,
) -> Option<ForecastUpdate> {
let config = self.configs.get(measurement)?;
let value = extract_numeric_value(fields, config.field.as_deref())?;
let series_key = compute_tags_key(tags);
let key = (measurement.to_string(), series_key);
let state_arc = self
.series_state
.entry(key)
.or_insert_with(|| Arc::new(parking_lot::Mutex::new(ForecastState::new(&config))))
.clone();
let mut state = state_arc.lock();
if let Some(predicted) = state.last_prediction.take() {
if value.is_finite() && predicted.is_finite() {
let error = (predicted - value).abs();
state.recent_errors.push_back(error);
if state.recent_errors.len() > 50 {
state.recent_errors.pop_front();
}
}
}
state.timestamps.push_back(timestamp);
state.values.push_back(value);
while state.timestamps.len() > state.max_buffer_size {
state.timestamps.pop_front();
state.values.pop_front();
}
if !state.fitted {
if state.values.len() >= state.min_fit_points {
let ts: Vec<i64> = state.timestamps.iter().copied().collect();
let vals: Vec<f64> = state.values.iter().copied().collect();
match state.model.fit(&ts, &vals) {
Ok(()) => {
state.fitted = true;
state.points_since_fit = 0;
state.last_fit_time = Some(Instant::now());
state.baseline_mae =
compute_mae_from_errors(&state.recent_errors, state.min_mae_samples);
state.last_prediction = state
.model
.predict(1)
.ok()
.and_then(|r| r.values.first().copied());
debug!(measurement = %measurement, "Forecast model initial fit");
counter!("chronix_forecast_fit_total", "measurement" => measurement.to_string()).increment(1);
return Some(ForecastUpdate::InitialFit);
}
Err(e) => {
warn!(measurement = %measurement, error = %e, "Initial model fit failed");
return None;
}
}
}
return Some(ForecastUpdate::Accumulating);
}
state.points_since_fit += 1;
if let Err(e) = state.model.update(timestamp, value) {
warn!(measurement = %measurement, error = %e, "Incremental update failed");
}
state.last_prediction = state
.model
.predict(1)
.ok()
.and_then(|r| r.values.first().copied());
if state.points_since_fit >= state.update_interval {
let current_mae = compute_mae_from_errors(&state.recent_errors, state.min_mae_samples);
if state.baseline_mae.is_none() {
state.baseline_mae = current_mae;
}
let needs_refit = match (current_mae, state.baseline_mae) {
(Some(current), Some(baseline)) if baseline > 0.0 => {
current > baseline * state.refit_multiplier
}
(Some(_), Some(_)) => false, _ => false, };
if needs_refit {
let retain = state.max_buffer_size;
while state.timestamps.len() > retain {
state.timestamps.pop_front();
state.values.pop_front();
}
let ts: Vec<i64> = state.timestamps.iter().copied().collect();
let vals: Vec<f64> = state.values.iter().copied().collect();
match state.model.fit(&ts, &vals) {
Ok(()) => {
state.points_since_fit = 0;
state.last_fit_time = Some(Instant::now());
state.recent_errors.clear();
state.baseline_mae = None; state.last_prediction = state
.model
.predict(1)
.ok()
.and_then(|r| r.values.first().copied());
debug!(measurement = %measurement, "Forecast model re-fit");
counter!("chronix_forecast_refit_total", "measurement" => measurement.to_string()).increment(1);
return Some(ForecastUpdate::ReFit);
}
Err(e) => {
warn!(measurement = %measurement, error = %e, "Re-fit failed");
}
}
}
state.points_since_fit = 0;
return Some(ForecastUpdate::Incremental);
}
Some(ForecastUpdate::Incremental)
}
pub fn predict(
&self,
measurement: &str,
tags: &BTreeMap<String, String>,
horizon: usize,
) -> Result<ForecastResult> {
let series_key = compute_tags_key(tags);
let key = (measurement.to_string(), series_key.clone());
let state_entry = self
.series_state
.get(&key)
.ok_or_else(|| AnalyticsError::NotConfigured(format!("{measurement}:{series_key}")))?;
let state = state_entry.lock();
if !state.fitted {
return Err(AnalyticsError::Internal("Model not yet fitted".into()));
}
state.model.predict(horizon).map_err(AnalyticsError::from)
}
#[must_use]
pub fn config_count(&self) -> usize {
self.configs.len()
}
#[must_use]
pub fn series_count(&self) -> usize {
self.series_state.len()
}
}
impl Default for ContinuousForecastEngine {
fn default() -> Self {
Self::new()
}
}
fn compute_mae_from_errors(errors: &VecDeque<f64>, min_samples: usize) -> Option<f64> {
if errors.len() < min_samples.max(1) {
return None;
}
let sum: f64 = errors.iter().sum();
Some(sum / errors.len() as f64)
}
use crate::util::{compute_tags_key, extract_numeric_value};
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mae_requires_a_minimum_sample_count() {
let mut errors: VecDeque<f64> = VecDeque::new();
for _ in 0..9 {
errors.push_back(1.0);
assert_eq!(
compute_mae_from_errors(&errors, 10),
None,
"MAE reported from only {} samples",
errors.len()
);
}
errors.push_back(1.0);
assert_eq!(compute_mae_from_errors(&errors, 10), Some(1.0));
}
#[test]
fn mae_of_empty_window_is_none() {
let errors: VecDeque<f64> = VecDeque::new();
assert_eq!(compute_mae_from_errors(&errors, 0), None);
assert_eq!(compute_mae_from_errors(&errors, 10), None);
}
#[test]
fn default_config_sets_a_min_mae_sample_count() {
let cfg = ContinuousForecastConfig::new("m", ModelType::Ses);
assert!(
cfg.min_mae_samples >= 2,
"a 1-sample MAE must not be able to drive a re-fit"
);
}
fn make_fields(value: f64) -> BTreeMap<String, FieldValue> {
let mut fields = BTreeMap::new();
fields.insert("value".to_string(), FieldValue::F64(value));
fields
}
fn make_tags(host: &str) -> BTreeMap<String, String> {
let mut tags = BTreeMap::new();
tags.insert("host".to_string(), host.to_string());
tags
}
#[test]
fn enable_disable() {
let engine = ContinuousForecastEngine::new();
let config = ContinuousForecastConfig::new("cpu", ModelType::Ses).with_min_fit_points(5);
engine.enable(config).unwrap();
assert!(engine.is_enabled("cpu"));
engine.disable("cpu");
assert!(!engine.is_enabled("cpu"));
}
#[test]
fn accumulates_before_fitting() {
let engine = ContinuousForecastEngine::new();
let config = ContinuousForecastConfig::new("cpu", ModelType::Ses).with_min_fit_points(10);
engine.enable(config).unwrap();
let tags = make_tags("host1");
for i in 0..9 {
let result = engine.process_point("cpu", &tags, &make_fields(50.0), i * 1000);
assert!(matches!(result, Some(ForecastUpdate::Accumulating)));
}
}
#[test]
fn initial_fit() {
let engine = ContinuousForecastEngine::new();
let config = ContinuousForecastConfig::new("cpu", ModelType::Ses).with_min_fit_points(10);
engine.enable(config).unwrap();
let tags = make_tags("host1");
let mut last_result = None;
for i in 0..10 {
last_result = engine.process_point(
"cpu",
&tags,
&make_fields(50.0 + i as f64),
i * 1_000_000_000,
);
}
assert!(matches!(last_result, Some(ForecastUpdate::InitialFit)));
}
#[test]
fn incremental_update_after_fit() {
let engine = ContinuousForecastEngine::new();
let config = ContinuousForecastConfig::new("cpu", ModelType::Ses)
.with_min_fit_points(5)
.with_update_interval(100);
engine.enable(config).unwrap();
let tags = make_tags("host1");
for i in 0..5 {
engine.process_point(
"cpu",
&tags,
&make_fields(50.0 + i as f64),
i * 1_000_000_000,
);
}
let result = engine.process_point("cpu", &tags, &make_fields(55.0), 5_000_000_000);
assert!(matches!(result, Some(ForecastUpdate::Incremental)));
}
#[test]
fn predict_after_fit() {
let engine = ContinuousForecastEngine::new();
let config = ContinuousForecastConfig::new("cpu", ModelType::Ses).with_min_fit_points(10);
engine.enable(config).unwrap();
let tags = make_tags("host1");
for i in 0..10 {
engine.process_point(
"cpu",
&tags,
&make_fields(50.0 + i as f64),
i * 1_000_000_000,
);
}
let forecast = engine.predict("cpu", &tags, 5).unwrap();
assert_eq!(forecast.values.len(), 5);
assert_eq!(forecast.timestamps.len(), 5);
}
#[test]
fn predict_before_fit_errors() {
let engine = ContinuousForecastEngine::new();
let config = ContinuousForecastConfig::new("cpu", ModelType::Ses).with_min_fit_points(100);
engine.enable(config).unwrap();
let tags = make_tags("host1");
for i in 0..5 {
engine.process_point("cpu", &tags, &make_fields(50.0), i * 1000);
}
assert!(engine.predict("cpu", &tags, 5).is_err());
}
#[test]
fn unconfigured_measurement_ignored() {
let engine = ContinuousForecastEngine::new();
let tags = make_tags("host1");
let result = engine.process_point("cpu", &tags, &make_fields(50.0), 0);
assert!(result.is_none());
}
#[test]
fn disabled_config_rejected() {
let engine = ContinuousForecastEngine::new();
let mut config = ContinuousForecastConfig::new("cpu", ModelType::Ses);
config.enabled = false;
assert!(engine.enable(config).is_err());
}
#[test]
fn multiple_series_independent() {
let engine = ContinuousForecastEngine::new();
let config = ContinuousForecastConfig::new("cpu", ModelType::Ses).with_min_fit_points(5);
engine.enable(config).unwrap();
let tags_a = make_tags("host1");
let tags_b = make_tags("host2");
for i in 0..5 {
engine.process_point(
"cpu",
&tags_a,
&make_fields(50.0 + i as f64),
i * 1_000_000_000,
);
engine.process_point(
"cpu",
&tags_b,
&make_fields(100.0 + i as f64),
i * 1_000_000_000,
);
}
assert_eq!(engine.series_count(), 2);
let fa = engine.predict("cpu", &tags_a, 1).unwrap();
let fb = engine.predict("cpu", &tags_b, 1).unwrap();
assert!(fb.values[0] > fa.values[0]);
}
#[test]
fn compute_mae_window_based() {
let engine = ContinuousForecastEngine::new();
let config = ContinuousForecastConfig::new("cpu", ModelType::Ses).with_min_fit_points(10);
engine.enable(config).unwrap();
let tags = make_tags("test_mae");
for i in 0..10 {
engine.process_point("cpu", &tags, &make_fields(50.0), i * 1_000_000_000);
}
let pred = engine.predict("cpu", &tags, 5);
assert!(pred.is_ok());
let values = &pred.unwrap().values;
for v in values {
assert!((v - 50.0).abs() < 1.0, "predicted {v} far from 50.0");
}
}
}