Skip to main content

chronix_analytics/
continuous_forecast.rs

1//! Continuous forecast engine — re-fits forecast models as new data arrives.
2//!
3//! Tracks per-series point counts and re-fits when enough new data accumulates
4//! or model accuracy degrades.
5
6use std::collections::{BTreeMap, VecDeque};
7use std::sync::Arc;
8use std::time::Instant;
9
10use crate::forecast::{
11    ArimaModel, ForecastModel, ForecastResult, HoltLinearModel, HoltWintersModel,
12    LinearRegressionModel, ModelType, SesModel,
13};
14use chronix_core::FieldValue;
15use dashmap::DashMap;
16use metrics::counter;
17use tracing::{debug, warn};
18
19use crate::error::{AnalyticsError, Result};
20
21/// Configuration for continuous forecasting on a measurement.
22#[derive(Debug, Clone)]
23pub struct ContinuousForecastConfig {
24    /// Measurement to forecast.
25    pub measurement: String,
26    /// Field to forecast (default: first f64 field).
27    pub field: Option<String>,
28    /// Model type to use.
29    pub model_type: ModelType,
30    /// Re-fit after this many new points.
31    pub update_interval_points: usize,
32    /// Maximum residual MAE multiplier before full re-fit.
33    pub refit_mae_multiplier: f64,
34    /// Whether continuous forecasting is enabled.
35    pub enabled: bool,
36    /// Minimum points needed for initial fit.
37    pub min_fit_points: usize,
38    /// Maximum buffer size for training data per series (prevents unbounded growth).
39    pub max_buffer_size: usize,
40    /// Minimum number of 1-step-ahead errors before the rolling MAE is
41    /// treated as meaningful.
42    ///
43    /// A MAE computed from one or two residuals is noise, and it drives an
44    /// expensive decision: `current > baseline × refit_mae_multiplier`
45    /// triggers a **full model re-fit**. With a tiny window a single unlucky
46    /// residual either causes a spurious re-fit — wasted CPU on a constrained
47    /// gateway — or, if it lands in the *baseline*, inflates the threshold so
48    /// that genuine drift never triggers one.
49    pub min_mae_samples: usize,
50}
51
52impl ContinuousForecastConfig {
53    /// Create a new config.
54    #[must_use]
55    pub fn new(measurement: impl Into<String>, model_type: ModelType) -> Self {
56        Self {
57            measurement: measurement.into(),
58            field: None,
59            model_type,
60            update_interval_points: 1000,
61            refit_mae_multiplier: 2.0,
62            enabled: true,
63            min_fit_points: 30,
64            max_buffer_size: 10_000,
65            min_mae_samples: 10,
66        }
67    }
68
69    /// Set the field to monitor.
70    #[must_use]
71    pub fn with_field(mut self, field: impl Into<String>) -> Self {
72        self.field = Some(field.into());
73        self
74    }
75
76    /// Set the update interval.
77    #[must_use]
78    pub fn with_update_interval(mut self, points: usize) -> Self {
79        self.update_interval_points = points;
80        self
81    }
82
83    /// Set minimum fit points.
84    #[must_use]
85    pub fn with_min_fit_points(mut self, n: usize) -> Self {
86        self.min_fit_points = n;
87        self
88    }
89
90    /// Set maximum buffer size per series.
91    #[must_use]
92    pub fn with_max_buffer_size(mut self, n: usize) -> Self {
93        self.max_buffer_size = n;
94        self
95    }
96}
97
98/// Per-series forecast state.
99struct ForecastState {
100    timestamps: VecDeque<i64>,
101    values: VecDeque<f64>,
102    model: Box<dyn ForecastModel>,
103    fitted: bool,
104    points_since_fit: usize,
105    baseline_mae: Option<f64>,
106    last_fit_time: Option<Instant>,
107    update_interval: usize,
108    refit_multiplier: f64,
109    /// See [`ContinuousForecastConfig::min_mae_samples`].
110    min_mae_samples: usize,
111    min_fit_points: usize,
112    max_buffer_size: usize,
113    /// Last 1-step-ahead prediction, used to compute true
114    /// forecast error when the next actual value arrives.
115    last_prediction: Option<f64>,
116    /// Rolling window of recent absolute 1-step-ahead errors.
117    recent_errors: VecDeque<f64>,
118}
119
120impl ForecastState {
121    fn new(config: &ContinuousForecastConfig) -> Self {
122        let model: Box<dyn ForecastModel> = create_model(&config.model_type);
123        Self {
124            timestamps: VecDeque::with_capacity(config.min_fit_points),
125            values: VecDeque::with_capacity(config.min_fit_points),
126            model,
127            fitted: false,
128            points_since_fit: 0,
129            baseline_mae: None,
130            last_fit_time: None,
131            update_interval: config.update_interval_points,
132            refit_multiplier: config.refit_mae_multiplier,
133            min_mae_samples: config.min_mae_samples,
134            min_fit_points: config.min_fit_points,
135            max_buffer_size: config.max_buffer_size,
136            last_prediction: None,
137            recent_errors: VecDeque::with_capacity(50),
138        }
139    }
140}
141
142fn create_model(model_type: &ModelType) -> Box<dyn ForecastModel> {
143    match model_type {
144        ModelType::Ses => Box::new(SesModel::new(Some(0.3))),
145        ModelType::HoltLinear => Box::new(HoltLinearModel::new(Some(0.3), Some(0.1), 1.0)),
146        ModelType::HoltWinters => Box::new(HoltWintersModel::new(Some(0.3), Some(0.1), Some(0.1), Some(12), false)),
147        ModelType::LinearRegression => Box::new(LinearRegressionModel::new()),
148        // Use proper ARIMA(1,1,1) instead of silently falling back to SES.
149        ModelType::Arima => Box::new(ArimaModel::new(1, 1, 1)),
150        ModelType::Sarima => {
151            warn!("SARIMA not yet supported in continuous mode, using ARIMA(1,1,1)");
152            Box::new(ArimaModel::new(1, 1, 1))
153        }
154        ModelType::Custom(name) => {
155            crate::registry::global_registry()
156                .create_model_default(name)
157                .unwrap_or_else(|e| {
158                    warn!(plugin = %name, error = %e, "custom model plugin failed, falling back to SES");
159                    Box::new(SesModel::new(Some(0.3)))
160                })
161        }
162    }
163}
164
165/// Key for per-series state: `(measurement, canonical_tags_key)`.
166type ForecastStateKey = (String, String);
167
168/// Update result from processing a point.
169#[derive(Debug, Clone)]
170pub enum ForecastUpdate {
171    /// Not enough data yet.
172    Accumulating,
173    /// Model was just fitted for the first time.
174    InitialFit,
175    /// Incremental update applied.
176    Incremental,
177    /// Full re-fit triggered (accuracy degraded).
178    ReFit,
179}
180
181/// Continuous forecast engine — re-fits models as new data arrives.
182pub struct ContinuousForecastEngine {
183    configs: DashMap<String, ContinuousForecastConfig>,
184    /// Per-series model state. Uses `Arc<Mutex>` so the DashMap shard
185    /// lock is released before acquiring the per-series Mutex (avoids
186    /// blocking other series in the same shard during expensive fit()).
187    series_state: DashMap<ForecastStateKey, Arc<parking_lot::Mutex<ForecastState>>>,
188}
189
190impl ContinuousForecastEngine {
191    /// Create a new continuous forecast engine.
192    #[must_use]
193    pub fn new() -> Self {
194        Self {
195            configs: DashMap::new(),
196            series_state: DashMap::new(),
197        }
198    }
199
200    /// Enable continuous forecasting for a measurement.
201    pub fn enable(&self, config: ContinuousForecastConfig) -> Result<()> {
202        if !config.enabled {
203            return Err(AnalyticsError::Config("Config is disabled".into()));
204        }
205        let measurement = config.measurement.clone();
206        self.configs.insert(measurement.clone(), config);
207        debug!(measurement = %measurement, "Continuous forecasting enabled");
208        Ok(())
209    }
210
211    /// Disable continuous forecasting for a measurement.
212    pub fn disable(&self, measurement: &str) {
213        self.configs.remove(measurement);
214        self.series_state.retain(|k, _| k.0 != measurement);
215    }
216
217    /// Check if a measurement has continuous forecasting enabled.
218    #[must_use]
219    pub fn is_enabled(&self, measurement: &str) -> bool {
220        self.configs.contains_key(measurement)
221    }
222
223    /// Process a new data point for a series.
224    ///
225    /// Returns the update type if the model was updated.
226    pub fn process_point(
227        &self,
228        measurement: &str,
229        tags: &BTreeMap<String, String>,
230        fields: &BTreeMap<String, FieldValue>,
231        timestamp: i64,
232    ) -> Option<ForecastUpdate> {
233        let config = self.configs.get(measurement)?;
234
235        let value = extract_numeric_value(fields, config.field.as_deref())?;
236        let series_key = compute_tags_key(tags);
237        let key = (measurement.to_string(), series_key);
238
239        let state_arc = self
240            .series_state
241            .entry(key)
242            .or_insert_with(|| Arc::new(parking_lot::Mutex::new(ForecastState::new(&config))))
243            .clone();
244
245        let mut state = state_arc.lock();
246
247        // Record 1-step-ahead forecast error if we had a prediction.
248        if let Some(predicted) = state.last_prediction.take()
249            && value.is_finite()
250            && predicted.is_finite()
251        {
252            let error = (predicted - value).abs();
253            state.recent_errors.push_back(error);
254            if state.recent_errors.len() > 50 {
255                state.recent_errors.pop_front();
256            }
257        }
258
259        state.timestamps.push_back(timestamp);
260        state.values.push_back(value);
261
262        // Evict oldest data if buffer exceeds max (O(1) per eviction)
263        while state.timestamps.len() > state.max_buffer_size {
264            state.timestamps.pop_front();
265            state.values.pop_front();
266        }
267
268        if !state.fitted {
269            if state.values.len() >= state.min_fit_points {
270                let ts: Vec<i64> = state.timestamps.iter().copied().collect();
271                let vals: Vec<f64> = state.values.iter().copied().collect();
272                match state.model.fit(&ts, &vals) {
273                    Ok(()) => {
274                        state.fitted = true;
275                        state.points_since_fit = 0;
276                        state.last_fit_time = Some(Instant::now());
277                        state.baseline_mae =
278                            compute_mae_from_errors(&state.recent_errors, state.min_mae_samples);
279                        // Store 1-step-ahead prediction for next arrival.
280                        state.last_prediction = state
281                            .model
282                            .predict(1)
283                            .ok()
284                            .and_then(|r| r.values.first().copied());
285                        debug!(measurement = %measurement, "Forecast model initial fit");
286                        counter!("chronix_forecast_fit_total", "measurement" => measurement.to_string()).increment(1);
287                        return Some(ForecastUpdate::InitialFit);
288                    }
289                    Err(e) => {
290                        warn!(measurement = %measurement, error = %e, "Initial model fit failed");
291                        return None;
292                    }
293                }
294            }
295            return Some(ForecastUpdate::Accumulating);
296        }
297
298        // Incremental update
299        state.points_since_fit += 1;
300
301        if let Err(e) = state.model.update(timestamp, value) {
302            warn!(measurement = %measurement, error = %e, "Incremental update failed");
303        }
304
305        // Store 1-step-ahead prediction for next error calculation.
306        state.last_prediction = state
307            .model
308            .predict(1)
309            .ok()
310            .and_then(|r| r.values.first().copied());
311
312        // Check if we need a full re-fit
313        if state.points_since_fit >= state.update_interval {
314            let current_mae = compute_mae_from_errors(&state.recent_errors, state.min_mae_samples);
315
316            // Set baseline MAE on first check after fit/refit.
317            if state.baseline_mae.is_none() {
318                state.baseline_mae = current_mae;
319            }
320
321            let needs_refit = match (current_mae, state.baseline_mae) {
322                (Some(current), Some(baseline)) if baseline > 0.0 => {
323                    current > baseline * state.refit_multiplier
324                }
325                (Some(_), Some(_)) => false, // baseline is zero — model is perfect
326                _ => false,                  // not enough data for comparison yet
327            };
328
329            if needs_refit {
330                // Truncate history to most recent max_buffer_size entries
331                // so the re-fitted model trains on recent data, not the entire history.
332                let retain = state.max_buffer_size;
333                while state.timestamps.len() > retain {
334                    state.timestamps.pop_front();
335                    state.values.pop_front();
336                }
337
338                let ts: Vec<i64> = state.timestamps.iter().copied().collect();
339                let vals: Vec<f64> = state.values.iter().copied().collect();
340                match state.model.fit(&ts, &vals) {
341                    Ok(()) => {
342                        state.points_since_fit = 0;
343                        state.last_fit_time = Some(Instant::now());
344                        state.recent_errors.clear();
345                        state.baseline_mae = None; // will be rebuilt from new errors
346                        // Store 1-step-ahead prediction for next error calculation.
347                        state.last_prediction = state
348                            .model
349                            .predict(1)
350                            .ok()
351                            .and_then(|r| r.values.first().copied());
352                        debug!(measurement = %measurement, "Forecast model re-fit");
353                        counter!("chronix_forecast_refit_total", "measurement" => measurement.to_string()).increment(1);
354                        return Some(ForecastUpdate::ReFit);
355                    }
356                    Err(e) => {
357                        warn!(measurement = %measurement, error = %e, "Re-fit failed");
358                    }
359                }
360            }
361
362            // Reset counter even if we didn't re-fit
363            state.points_since_fit = 0;
364            return Some(ForecastUpdate::Incremental);
365        }
366
367        Some(ForecastUpdate::Incremental)
368    }
369
370    /// Get a forecast for a series.
371    pub fn predict(
372        &self,
373        measurement: &str,
374        tags: &BTreeMap<String, String>,
375        horizon: usize,
376    ) -> Result<ForecastResult> {
377        let series_key = compute_tags_key(tags);
378        let key = (measurement.to_string(), series_key.clone());
379
380        let state_entry = self
381            .series_state
382            .get(&key)
383            .ok_or_else(|| AnalyticsError::NotConfigured(format!("{measurement}:{series_key}")))?;
384
385        let state = state_entry.lock();
386        if !state.fitted {
387            return Err(AnalyticsError::Internal("Model not yet fitted".into()));
388        }
389
390        state.model.predict(horizon).map_err(AnalyticsError::from)
391    }
392
393    /// Number of configured measurements.
394    #[must_use]
395    pub fn config_count(&self) -> usize {
396        self.configs.len()
397    }
398
399    /// Number of tracked series.
400    #[must_use]
401    pub fn series_count(&self) -> usize {
402        self.series_state.len()
403    }
404}
405
406impl Default for ContinuousForecastEngine {
407    fn default() -> Self {
408        Self::new()
409    }
410}
411
412/// Compute MAE from the rolling window of 1-step-ahead forecast errors.
413///
414/// Each error was computed as |predicted(t) - actual(t)| where predicted(t) was
415/// the model's 1-step-ahead forecast made *before* observing actual(t). This
416/// avoids the previous bug of comparing multi-step future forecasts against
417/// historical actuals at mismatched time offsets.
418///
419/// Returns `None` until at least `min_samples` residuals have accumulated —
420/// see [`ContinuousForecastConfig::min_mae_samples`]. Both callers treat
421/// `None` as "no comparison possible", which is the safe default: no re-fit is
422/// triggered and no baseline is frozen from noise.
423fn compute_mae_from_errors(errors: &VecDeque<f64>, min_samples: usize) -> Option<f64> {
424    if errors.len() < min_samples.max(1) {
425        return None;
426    }
427    let sum: f64 = errors.iter().sum();
428    Some(sum / errors.len() as f64)
429}
430
431use crate::util::{compute_tags_key, extract_numeric_value};
432
433#[cfg(test)]
434mod tests {
435    use super::*;
436
437    #[test]
438    fn mae_requires_a_minimum_sample_count() {
439        let mut errors: VecDeque<f64> = VecDeque::new();
440        // Below the threshold the MAE is noise and must not be reported.
441        for _ in 0..9 {
442            errors.push_back(1.0);
443            assert_eq!(
444                compute_mae_from_errors(&errors, 10),
445                None,
446                "MAE reported from only {} samples",
447                errors.len()
448            );
449        }
450        errors.push_back(1.0);
451        assert_eq!(compute_mae_from_errors(&errors, 10), Some(1.0));
452    }
453
454    /// `min_samples = 0` must still require at least one residual rather than
455    /// dividing by zero.
456    #[test]
457    fn mae_of_empty_window_is_none() {
458        let errors: VecDeque<f64> = VecDeque::new();
459        assert_eq!(compute_mae_from_errors(&errors, 0), None);
460        assert_eq!(compute_mae_from_errors(&errors, 10), None);
461    }
462
463    #[test]
464    fn default_config_sets_a_min_mae_sample_count() {
465        let cfg = ContinuousForecastConfig::new("m", ModelType::Ses);
466        assert!(
467            cfg.min_mae_samples >= 2,
468            "a 1-sample MAE must not be able to drive a re-fit"
469        );
470    }
471
472    fn make_fields(value: f64) -> BTreeMap<String, FieldValue> {
473        let mut fields = BTreeMap::new();
474        fields.insert("value".to_string(), FieldValue::F64(value));
475        fields
476    }
477
478    fn make_tags(host: &str) -> BTreeMap<String, String> {
479        let mut tags = BTreeMap::new();
480        tags.insert("host".to_string(), host.to_string());
481        tags
482    }
483
484    #[test]
485    fn enable_disable() {
486        let engine = ContinuousForecastEngine::new();
487        let config = ContinuousForecastConfig::new("cpu", ModelType::Ses).with_min_fit_points(5);
488        engine.enable(config).unwrap();
489        assert!(engine.is_enabled("cpu"));
490        engine.disable("cpu");
491        assert!(!engine.is_enabled("cpu"));
492    }
493
494    #[test]
495    fn accumulates_before_fitting() {
496        let engine = ContinuousForecastEngine::new();
497        let config = ContinuousForecastConfig::new("cpu", ModelType::Ses).with_min_fit_points(10);
498        engine.enable(config).unwrap();
499
500        let tags = make_tags("host1");
501        for i in 0..9 {
502            let result = engine.process_point("cpu", &tags, &make_fields(50.0), i * 1000);
503            assert!(matches!(result, Some(ForecastUpdate::Accumulating)));
504        }
505    }
506
507    #[test]
508    fn initial_fit() {
509        let engine = ContinuousForecastEngine::new();
510        let config = ContinuousForecastConfig::new("cpu", ModelType::Ses).with_min_fit_points(10);
511        engine.enable(config).unwrap();
512
513        let tags = make_tags("host1");
514        let mut last_result = None;
515        for i in 0..10 {
516            last_result = engine.process_point(
517                "cpu",
518                &tags,
519                &make_fields(50.0 + i as f64),
520                i * 1_000_000_000,
521            );
522        }
523        assert!(matches!(last_result, Some(ForecastUpdate::InitialFit)));
524    }
525
526    #[test]
527    fn incremental_update_after_fit() {
528        let engine = ContinuousForecastEngine::new();
529        let config = ContinuousForecastConfig::new("cpu", ModelType::Ses)
530            .with_min_fit_points(5)
531            .with_update_interval(100);
532        engine.enable(config).unwrap();
533
534        let tags = make_tags("host1");
535        for i in 0..5 {
536            engine.process_point(
537                "cpu",
538                &tags,
539                &make_fields(50.0 + i as f64),
540                i * 1_000_000_000,
541            );
542        }
543        // After fit, next point should be incremental
544        let result = engine.process_point("cpu", &tags, &make_fields(55.0), 5_000_000_000);
545        assert!(matches!(result, Some(ForecastUpdate::Incremental)));
546    }
547
548    #[test]
549    fn predict_after_fit() {
550        let engine = ContinuousForecastEngine::new();
551        let config = ContinuousForecastConfig::new("cpu", ModelType::Ses).with_min_fit_points(10);
552        engine.enable(config).unwrap();
553
554        let tags = make_tags("host1");
555        for i in 0..10 {
556            engine.process_point(
557                "cpu",
558                &tags,
559                &make_fields(50.0 + i as f64),
560                i * 1_000_000_000,
561            );
562        }
563
564        let forecast = engine.predict("cpu", &tags, 5).unwrap();
565        assert_eq!(forecast.values.len(), 5);
566        assert_eq!(forecast.timestamps.len(), 5);
567    }
568
569    #[test]
570    fn predict_before_fit_errors() {
571        let engine = ContinuousForecastEngine::new();
572        let config = ContinuousForecastConfig::new("cpu", ModelType::Ses).with_min_fit_points(100);
573        engine.enable(config).unwrap();
574
575        let tags = make_tags("host1");
576        for i in 0..5 {
577            engine.process_point("cpu", &tags, &make_fields(50.0), i * 1000);
578        }
579
580        assert!(engine.predict("cpu", &tags, 5).is_err());
581    }
582
583    #[test]
584    fn unconfigured_measurement_ignored() {
585        let engine = ContinuousForecastEngine::new();
586        let tags = make_tags("host1");
587        let result = engine.process_point("cpu", &tags, &make_fields(50.0), 0);
588        assert!(result.is_none());
589    }
590
591    #[test]
592    fn disabled_config_rejected() {
593        let engine = ContinuousForecastEngine::new();
594        let mut config = ContinuousForecastConfig::new("cpu", ModelType::Ses);
595        config.enabled = false;
596        assert!(engine.enable(config).is_err());
597    }
598
599    #[test]
600    fn multiple_series_independent() {
601        let engine = ContinuousForecastEngine::new();
602        let config = ContinuousForecastConfig::new("cpu", ModelType::Ses).with_min_fit_points(5);
603        engine.enable(config).unwrap();
604
605        let tags_a = make_tags("host1");
606        let tags_b = make_tags("host2");
607
608        for i in 0..5 {
609            engine.process_point(
610                "cpu",
611                &tags_a,
612                &make_fields(50.0 + i as f64),
613                i * 1_000_000_000,
614            );
615            engine.process_point(
616                "cpu",
617                &tags_b,
618                &make_fields(100.0 + i as f64),
619                i * 1_000_000_000,
620            );
621        }
622
623        assert_eq!(engine.series_count(), 2);
624
625        let fa = engine.predict("cpu", &tags_a, 1).unwrap();
626        let fb = engine.predict("cpu", &tags_b, 1).unwrap();
627        // Series B should predict higher values
628        assert!(fb.values[0] > fa.values[0]);
629    }
630
631    #[test]
632    fn compute_mae_window_based() {
633        // Verify that compute_mae uses a rolling window, not a single point.
634        let engine = ContinuousForecastEngine::new();
635        let config = ContinuousForecastConfig::new("cpu", ModelType::Ses).with_min_fit_points(10);
636        engine.enable(config).unwrap();
637
638        let tags = make_tags("test_mae");
639        // Feed 10 stable points to trigger initial fit.
640        for i in 0..10 {
641            engine.process_point("cpu", &tags, &make_fields(50.0), i * 1_000_000_000);
642        }
643
644        // After fitting on constant data, MAE should be very small.
645        let pred = engine.predict("cpu", &tags, 5);
646        assert!(pred.is_ok());
647        let values = &pred.unwrap().values;
648        // SES on constant data should predict ~50.0.
649        for v in values {
650            assert!((v - 50.0).abs() < 1.0, "predicted {v} far from 50.0");
651        }
652    }
653}