1use 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#[derive(Debug, Clone)]
23pub struct ContinuousForecastConfig {
24 pub measurement: String,
26 pub field: Option<String>,
28 pub model_type: ModelType,
30 pub update_interval_points: usize,
32 pub refit_mae_multiplier: f64,
34 pub enabled: bool,
36 pub min_fit_points: usize,
38 pub max_buffer_size: usize,
40 pub min_mae_samples: usize,
50}
51
52impl ContinuousForecastConfig {
53 #[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 #[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 #[must_use]
78 pub fn with_update_interval(mut self, points: usize) -> Self {
79 self.update_interval_points = points;
80 self
81 }
82
83 #[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 #[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
98struct 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 min_mae_samples: usize,
111 min_fit_points: usize,
112 max_buffer_size: usize,
113 last_prediction: Option<f64>,
116 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 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
165type ForecastStateKey = (String, String);
167
168#[derive(Debug, Clone)]
170pub enum ForecastUpdate {
171 Accumulating,
173 InitialFit,
175 Incremental,
177 ReFit,
179}
180
181pub struct ContinuousForecastEngine {
183 configs: DashMap<String, ContinuousForecastConfig>,
184 series_state: DashMap<ForecastStateKey, Arc<parking_lot::Mutex<ForecastState>>>,
188}
189
190impl ContinuousForecastEngine {
191 #[must_use]
193 pub fn new() -> Self {
194 Self {
195 configs: DashMap::new(),
196 series_state: DashMap::new(),
197 }
198 }
199
200 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 pub fn disable(&self, measurement: &str) {
213 self.configs.remove(measurement);
214 self.series_state.retain(|k, _| k.0 != measurement);
215 }
216
217 #[must_use]
219 pub fn is_enabled(&self, measurement: &str) -> bool {
220 self.configs.contains_key(measurement)
221 }
222
223 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 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 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 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 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 state.last_prediction = state
306 .model
307 .predict(1)
308 .ok()
309 .and_then(|r| r.values.first().copied());
310
311 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 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, _ => false, };
327
328 if needs_refit {
329 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; 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 state.points_since_fit = 0;
363 return Some(ForecastUpdate::Incremental);
364 }
365
366 Some(ForecastUpdate::Incremental)
367 }
368
369 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 #[must_use]
394 pub fn config_count(&self) -> usize {
395 self.configs.len()
396 }
397
398 #[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
411fn 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 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 #[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 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 assert!(fb.values[0] > fa.values[0]);
628 }
629
630 #[test]
631 fn compute_mae_window_based() {
632 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 for i in 0..10 {
640 engine.process_point("cpu", &tags, &make_fields(50.0), i * 1_000_000_000);
641 }
642
643 let pred = engine.predict("cpu", &tags, 5);
645 assert!(pred.is_ok());
646 let values = &pred.unwrap().values;
647 for v in values {
649 assert!((v - 50.0).abs() < 1.0, "predicted {v} far from 50.0");
650 }
651 }
652}