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 && 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 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 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 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 state.last_prediction = state
307 .model
308 .predict(1)
309 .ok()
310 .and_then(|r| r.values.first().copied());
311
312 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 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, _ => false, };
328
329 if needs_refit {
330 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; 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 state.points_since_fit = 0;
364 return Some(ForecastUpdate::Incremental);
365 }
366
367 Some(ForecastUpdate::Incremental)
368 }
369
370 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 #[must_use]
395 pub fn config_count(&self) -> usize {
396 self.configs.len()
397 }
398
399 #[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
412fn 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 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 #[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 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 assert!(fb.values[0] > fa.values[0]);
629 }
630
631 #[test]
632 fn compute_mae_window_based() {
633 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 for i in 0..10 {
641 engine.process_point("cpu", &tags, &make_fields(50.0), i * 1_000_000_000);
642 }
643
644 let pred = engine.predict("cpu", &tags, 5);
646 assert!(pred.is_ok());
647 let values = &pred.unwrap().values;
648 for v in values {
650 assert!((v - 50.0).abs() < 1.0, "predicted {v} far from 50.0");
651 }
652 }
653}