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            if value.is_finite() && predicted.is_finite() {
250                let error = (predicted - value).abs();
251                state.recent_errors.push_back(error);
252                if state.recent_errors.len() > 50 {
253                    state.recent_errors.pop_front();
254                }
255            }
256        }
257
258        state.timestamps.push_back(timestamp);
259        state.values.push_back(value);
260
261        // Evict oldest data if buffer exceeds max (O(1) per eviction)
262        while state.timestamps.len() > state.max_buffer_size {
263            state.timestamps.pop_front();
264            state.values.pop_front();
265        }
266
267        if !state.fitted {
268            if state.values.len() >= state.min_fit_points {
269                let ts: Vec<i64> = state.timestamps.iter().copied().collect();
270                let vals: Vec<f64> = state.values.iter().copied().collect();
271                match state.model.fit(&ts, &vals) {
272                    Ok(()) => {
273                        state.fitted = true;
274                        state.points_since_fit = 0;
275                        state.last_fit_time = Some(Instant::now());
276                        state.baseline_mae =
277                            compute_mae_from_errors(&state.recent_errors, state.min_mae_samples);
278                        // Store 1-step-ahead prediction for next arrival.
279                        state.last_prediction = state
280                            .model
281                            .predict(1)
282                            .ok()
283                            .and_then(|r| r.values.first().copied());
284                        debug!(measurement = %measurement, "Forecast model initial fit");
285                        counter!("chronix_forecast_fit_total", "measurement" => measurement.to_string()).increment(1);
286                        return Some(ForecastUpdate::InitialFit);
287                    }
288                    Err(e) => {
289                        warn!(measurement = %measurement, error = %e, "Initial model fit failed");
290                        return None;
291                    }
292                }
293            }
294            return Some(ForecastUpdate::Accumulating);
295        }
296
297        // Incremental update
298        state.points_since_fit += 1;
299
300        if let Err(e) = state.model.update(timestamp, value) {
301            warn!(measurement = %measurement, error = %e, "Incremental update failed");
302        }
303
304        // Store 1-step-ahead prediction for next error calculation.
305        state.last_prediction = state
306            .model
307            .predict(1)
308            .ok()
309            .and_then(|r| r.values.first().copied());
310
311        // Check if we need a full re-fit
312        if state.points_since_fit >= state.update_interval {
313            let current_mae = compute_mae_from_errors(&state.recent_errors, state.min_mae_samples);
314
315            // Set baseline MAE on first check after fit/refit.
316            if state.baseline_mae.is_none() {
317                state.baseline_mae = current_mae;
318            }
319
320            let needs_refit = match (current_mae, state.baseline_mae) {
321                (Some(current), Some(baseline)) if baseline > 0.0 => {
322                    current > baseline * state.refit_multiplier
323                }
324                (Some(_), Some(_)) => false, // baseline is zero — model is perfect
325                _ => false,                  // not enough data for comparison yet
326            };
327
328            if needs_refit {
329                // Truncate history to most recent max_buffer_size entries
330                // so the re-fitted model trains on recent data, not the entire history.
331                let retain = state.max_buffer_size;
332                while state.timestamps.len() > retain {
333                    state.timestamps.pop_front();
334                    state.values.pop_front();
335                }
336
337                let ts: Vec<i64> = state.timestamps.iter().copied().collect();
338                let vals: Vec<f64> = state.values.iter().copied().collect();
339                match state.model.fit(&ts, &vals) {
340                    Ok(()) => {
341                        state.points_since_fit = 0;
342                        state.last_fit_time = Some(Instant::now());
343                        state.recent_errors.clear();
344                        state.baseline_mae = None; // will be rebuilt from new errors
345                                                   // Store 1-step-ahead prediction for next error calculation.
346                        state.last_prediction = state
347                            .model
348                            .predict(1)
349                            .ok()
350                            .and_then(|r| r.values.first().copied());
351                        debug!(measurement = %measurement, "Forecast model re-fit");
352                        counter!("chronix_forecast_refit_total", "measurement" => measurement.to_string()).increment(1);
353                        return Some(ForecastUpdate::ReFit);
354                    }
355                    Err(e) => {
356                        warn!(measurement = %measurement, error = %e, "Re-fit failed");
357                    }
358                }
359            }
360
361            // Reset counter even if we didn't re-fit
362            state.points_since_fit = 0;
363            return Some(ForecastUpdate::Incremental);
364        }
365
366        Some(ForecastUpdate::Incremental)
367    }
368
369    /// Get a forecast for a series.
370    pub fn predict(
371        &self,
372        measurement: &str,
373        tags: &BTreeMap<String, String>,
374        horizon: usize,
375    ) -> Result<ForecastResult> {
376        let series_key = compute_tags_key(tags);
377        let key = (measurement.to_string(), series_key.clone());
378
379        let state_entry = self
380            .series_state
381            .get(&key)
382            .ok_or_else(|| AnalyticsError::NotConfigured(format!("{measurement}:{series_key}")))?;
383
384        let state = state_entry.lock();
385        if !state.fitted {
386            return Err(AnalyticsError::Internal("Model not yet fitted".into()));
387        }
388
389        state.model.predict(horizon).map_err(AnalyticsError::from)
390    }
391
392    /// Number of configured measurements.
393    #[must_use]
394    pub fn config_count(&self) -> usize {
395        self.configs.len()
396    }
397
398    /// Number of tracked series.
399    #[must_use]
400    pub fn series_count(&self) -> usize {
401        self.series_state.len()
402    }
403}
404
405impl Default for ContinuousForecastEngine {
406    fn default() -> Self {
407        Self::new()
408    }
409}
410
411/// Compute MAE from the rolling window of 1-step-ahead forecast errors.
412///
413/// Each error was computed as |predicted(t) - actual(t)| where predicted(t) was
414/// the model's 1-step-ahead forecast made *before* observing actual(t). This
415/// avoids the previous bug of comparing multi-step future forecasts against
416/// historical actuals at mismatched time offsets.
417///
418/// Returns `None` until at least `min_samples` residuals have accumulated —
419/// see [`ContinuousForecastConfig::min_mae_samples`]. Both callers treat
420/// `None` as "no comparison possible", which is the safe default: no re-fit is
421/// triggered and no baseline is frozen from noise.
422fn compute_mae_from_errors(errors: &VecDeque<f64>, min_samples: usize) -> Option<f64> {
423    if errors.len() < min_samples.max(1) {
424        return None;
425    }
426    let sum: f64 = errors.iter().sum();
427    Some(sum / errors.len() as f64)
428}
429
430use crate::util::{compute_tags_key, extract_numeric_value};
431
432#[cfg(test)]
433mod tests {
434    use super::*;
435
436    #[test]
437    fn mae_requires_a_minimum_sample_count() {
438        let mut errors: VecDeque<f64> = VecDeque::new();
439        // Below the threshold the MAE is noise and must not be reported.
440        for _ in 0..9 {
441            errors.push_back(1.0);
442            assert_eq!(
443                compute_mae_from_errors(&errors, 10),
444                None,
445                "MAE reported from only {} samples",
446                errors.len()
447            );
448        }
449        errors.push_back(1.0);
450        assert_eq!(compute_mae_from_errors(&errors, 10), Some(1.0));
451    }
452
453    /// `min_samples = 0` must still require at least one residual rather than
454    /// dividing by zero.
455    #[test]
456    fn mae_of_empty_window_is_none() {
457        let errors: VecDeque<f64> = VecDeque::new();
458        assert_eq!(compute_mae_from_errors(&errors, 0), None);
459        assert_eq!(compute_mae_from_errors(&errors, 10), None);
460    }
461
462    #[test]
463    fn default_config_sets_a_min_mae_sample_count() {
464        let cfg = ContinuousForecastConfig::new("m", ModelType::Ses);
465        assert!(
466            cfg.min_mae_samples >= 2,
467            "a 1-sample MAE must not be able to drive a re-fit"
468        );
469    }
470
471    fn make_fields(value: f64) -> BTreeMap<String, FieldValue> {
472        let mut fields = BTreeMap::new();
473        fields.insert("value".to_string(), FieldValue::F64(value));
474        fields
475    }
476
477    fn make_tags(host: &str) -> BTreeMap<String, String> {
478        let mut tags = BTreeMap::new();
479        tags.insert("host".to_string(), host.to_string());
480        tags
481    }
482
483    #[test]
484    fn enable_disable() {
485        let engine = ContinuousForecastEngine::new();
486        let config = ContinuousForecastConfig::new("cpu", ModelType::Ses).with_min_fit_points(5);
487        engine.enable(config).unwrap();
488        assert!(engine.is_enabled("cpu"));
489        engine.disable("cpu");
490        assert!(!engine.is_enabled("cpu"));
491    }
492
493    #[test]
494    fn accumulates_before_fitting() {
495        let engine = ContinuousForecastEngine::new();
496        let config = ContinuousForecastConfig::new("cpu", ModelType::Ses).with_min_fit_points(10);
497        engine.enable(config).unwrap();
498
499        let tags = make_tags("host1");
500        for i in 0..9 {
501            let result = engine.process_point("cpu", &tags, &make_fields(50.0), i * 1000);
502            assert!(matches!(result, Some(ForecastUpdate::Accumulating)));
503        }
504    }
505
506    #[test]
507    fn initial_fit() {
508        let engine = ContinuousForecastEngine::new();
509        let config = ContinuousForecastConfig::new("cpu", ModelType::Ses).with_min_fit_points(10);
510        engine.enable(config).unwrap();
511
512        let tags = make_tags("host1");
513        let mut last_result = None;
514        for i in 0..10 {
515            last_result = engine.process_point(
516                "cpu",
517                &tags,
518                &make_fields(50.0 + i as f64),
519                i * 1_000_000_000,
520            );
521        }
522        assert!(matches!(last_result, Some(ForecastUpdate::InitialFit)));
523    }
524
525    #[test]
526    fn incremental_update_after_fit() {
527        let engine = ContinuousForecastEngine::new();
528        let config = ContinuousForecastConfig::new("cpu", ModelType::Ses)
529            .with_min_fit_points(5)
530            .with_update_interval(100);
531        engine.enable(config).unwrap();
532
533        let tags = make_tags("host1");
534        for i in 0..5 {
535            engine.process_point(
536                "cpu",
537                &tags,
538                &make_fields(50.0 + i as f64),
539                i * 1_000_000_000,
540            );
541        }
542        // After fit, next point should be incremental
543        let result = engine.process_point("cpu", &tags, &make_fields(55.0), 5_000_000_000);
544        assert!(matches!(result, Some(ForecastUpdate::Incremental)));
545    }
546
547    #[test]
548    fn predict_after_fit() {
549        let engine = ContinuousForecastEngine::new();
550        let config = ContinuousForecastConfig::new("cpu", ModelType::Ses).with_min_fit_points(10);
551        engine.enable(config).unwrap();
552
553        let tags = make_tags("host1");
554        for i in 0..10 {
555            engine.process_point(
556                "cpu",
557                &tags,
558                &make_fields(50.0 + i as f64),
559                i * 1_000_000_000,
560            );
561        }
562
563        let forecast = engine.predict("cpu", &tags, 5).unwrap();
564        assert_eq!(forecast.values.len(), 5);
565        assert_eq!(forecast.timestamps.len(), 5);
566    }
567
568    #[test]
569    fn predict_before_fit_errors() {
570        let engine = ContinuousForecastEngine::new();
571        let config = ContinuousForecastConfig::new("cpu", ModelType::Ses).with_min_fit_points(100);
572        engine.enable(config).unwrap();
573
574        let tags = make_tags("host1");
575        for i in 0..5 {
576            engine.process_point("cpu", &tags, &make_fields(50.0), i * 1000);
577        }
578
579        assert!(engine.predict("cpu", &tags, 5).is_err());
580    }
581
582    #[test]
583    fn unconfigured_measurement_ignored() {
584        let engine = ContinuousForecastEngine::new();
585        let tags = make_tags("host1");
586        let result = engine.process_point("cpu", &tags, &make_fields(50.0), 0);
587        assert!(result.is_none());
588    }
589
590    #[test]
591    fn disabled_config_rejected() {
592        let engine = ContinuousForecastEngine::new();
593        let mut config = ContinuousForecastConfig::new("cpu", ModelType::Ses);
594        config.enabled = false;
595        assert!(engine.enable(config).is_err());
596    }
597
598    #[test]
599    fn multiple_series_independent() {
600        let engine = ContinuousForecastEngine::new();
601        let config = ContinuousForecastConfig::new("cpu", ModelType::Ses).with_min_fit_points(5);
602        engine.enable(config).unwrap();
603
604        let tags_a = make_tags("host1");
605        let tags_b = make_tags("host2");
606
607        for i in 0..5 {
608            engine.process_point(
609                "cpu",
610                &tags_a,
611                &make_fields(50.0 + i as f64),
612                i * 1_000_000_000,
613            );
614            engine.process_point(
615                "cpu",
616                &tags_b,
617                &make_fields(100.0 + i as f64),
618                i * 1_000_000_000,
619            );
620        }
621
622        assert_eq!(engine.series_count(), 2);
623
624        let fa = engine.predict("cpu", &tags_a, 1).unwrap();
625        let fb = engine.predict("cpu", &tags_b, 1).unwrap();
626        // Series B should predict higher values
627        assert!(fb.values[0] > fa.values[0]);
628    }
629
630    #[test]
631    fn compute_mae_window_based() {
632        // Verify that compute_mae uses a rolling window, not a single point.
633        let engine = ContinuousForecastEngine::new();
634        let config = ContinuousForecastConfig::new("cpu", ModelType::Ses).with_min_fit_points(10);
635        engine.enable(config).unwrap();
636
637        let tags = make_tags("test_mae");
638        // Feed 10 stable points to trigger initial fit.
639        for i in 0..10 {
640            engine.process_point("cpu", &tags, &make_fields(50.0), i * 1_000_000_000);
641        }
642
643        // After fitting on constant data, MAE should be very small.
644        let pred = engine.predict("cpu", &tags, 5);
645        assert!(pred.is_ok());
646        let values = &pred.unwrap().values;
647        // SES on constant data should predict ~50.0.
648        for v in values {
649            assert!((v - 50.0).abs() < 1.0, "predicted {v} far from 50.0");
650        }
651    }
652}