chie_core/
forecasting.rs

1//! Resource usage forecasting with time series analysis.
2//!
3//! This module provides forecasting capabilities for resource usage prediction
4//! using simple linear regression and moving averages.
5//!
6//! # Features
7//!
8//! - Linear trend forecasting
9//! - Moving average smoothing
10//! - Growth rate calculation
11//! - Time-to-capacity estimation
12//! - Anomaly detection in trends
13//!
14//! # Example
15//!
16//! ```
17//! use chie_core::forecasting::{Forecaster, ForecastMethod};
18//!
19//! let mut forecaster = Forecaster::new(ForecastMethod::LinearRegression);
20//!
21//! // Add historical data points
22//! forecaster.add_sample(100.0);
23//! forecaster.add_sample(150.0);
24//! forecaster.add_sample(200.0);
25//! forecaster.add_sample(250.0);
26//!
27//! // Predict future value
28//! if let Some(forecast) = forecaster.forecast(1) {
29//!     println!("Predicted value in 1 period: {:.2}", forecast);
30//! }
31//!
32//! // Estimate time to reach capacity
33//! if let Some(periods) = forecaster.time_to_capacity(1000.0) {
34//!     println!("Will reach capacity in {} periods", periods);
35//! }
36//! ```
37
38use std::collections::VecDeque;
39
40/// Forecasting methods.
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum ForecastMethod {
43    /// Simple moving average.
44    MovingAverage,
45    /// Linear regression (least squares).
46    LinearRegression,
47    /// Exponential smoothing.
48    ExponentialSmoothing,
49}
50
51/// A forecaster for time series data.
52pub struct Forecaster {
53    /// Forecasting method.
54    method: ForecastMethod,
55    /// Historical samples.
56    samples: VecDeque<f64>,
57    /// Maximum number of samples to retain.
58    max_samples: usize,
59    /// Alpha parameter for exponential smoothing (0-1).
60    smoothing_alpha: f64,
61}
62
63impl Forecaster {
64    /// Create a new forecaster with the specified method.
65    #[must_use]
66    pub fn new(method: ForecastMethod) -> Self {
67        Self {
68            method,
69            samples: VecDeque::new(),
70            max_samples: 100,
71            smoothing_alpha: 0.3,
72        }
73    }
74
75    /// Create a forecaster with custom configuration.
76    #[must_use]
77    pub fn with_config(method: ForecastMethod, max_samples: usize, smoothing_alpha: f64) -> Self {
78        Self {
79            method,
80            samples: VecDeque::with_capacity(max_samples),
81            max_samples,
82            smoothing_alpha: smoothing_alpha.clamp(0.0, 1.0),
83        }
84    }
85
86    /// Add a sample to the historical data.
87    pub fn add_sample(&mut self, value: f64) {
88        self.samples.push_back(value);
89
90        // Trim old samples
91        while self.samples.len() > self.max_samples {
92            self.samples.pop_front();
93        }
94    }
95
96    /// Add multiple samples at once.
97    pub fn add_samples(&mut self, values: &[f64]) {
98        for &value in values {
99            self.add_sample(value);
100        }
101    }
102
103    /// Forecast the value for `periods` ahead.
104    #[must_use]
105    #[inline]
106    pub fn forecast(&self, periods: usize) -> Option<f64> {
107        if self.samples.is_empty() {
108            return None;
109        }
110
111        match self.method {
112            ForecastMethod::MovingAverage => self.forecast_moving_average(),
113            ForecastMethod::LinearRegression => self.forecast_linear_regression(periods),
114            ForecastMethod::ExponentialSmoothing => self.forecast_exponential_smoothing(),
115        }
116    }
117
118    /// Forecast using moving average.
119    #[must_use]
120    fn forecast_moving_average(&self) -> Option<f64> {
121        if self.samples.is_empty() {
122            return None;
123        }
124
125        let sum: f64 = self.samples.iter().sum();
126        Some(sum / self.samples.len() as f64)
127    }
128
129    /// Forecast using linear regression.
130    #[must_use]
131    fn forecast_linear_regression(&self, periods: usize) -> Option<f64> {
132        if self.samples.len() < 2 {
133            return None;
134        }
135
136        let (slope, intercept) = self.calculate_linear_trend();
137        let next_x = self.samples.len() as f64 + periods as f64 - 1.0;
138        Some(slope * next_x + intercept)
139    }
140
141    /// Forecast using exponential smoothing.
142    #[must_use]
143    fn forecast_exponential_smoothing(&self) -> Option<f64> {
144        if self.samples.is_empty() {
145            return None;
146        }
147
148        let mut smoothed = self.samples[0];
149        for &value in self.samples.iter().skip(1) {
150            smoothed = self.smoothing_alpha * value + (1.0 - self.smoothing_alpha) * smoothed;
151        }
152
153        Some(smoothed)
154    }
155
156    /// Calculate linear trend (slope and intercept).
157    #[must_use]
158    fn calculate_linear_trend(&self) -> (f64, f64) {
159        let n = self.samples.len() as f64;
160
161        // Calculate means
162        let mean_x = (n - 1.0) / 2.0;
163        let mean_y: f64 = self.samples.iter().sum::<f64>() / n;
164
165        // Calculate slope
166        let mut numerator = 0.0;
167        let mut denominator = 0.0;
168
169        for (i, &y) in self.samples.iter().enumerate() {
170            let x = i as f64;
171            numerator += (x - mean_x) * (y - mean_y);
172            denominator += (x - mean_x) * (x - mean_x);
173        }
174
175        let slope = if denominator != 0.0 {
176            numerator / denominator
177        } else {
178            0.0
179        };
180
181        let intercept = mean_y - slope * mean_x;
182
183        (slope, intercept)
184    }
185
186    /// Get the current growth rate (slope of linear trend).
187    #[must_use]
188    #[inline]
189    pub fn growth_rate(&self) -> Option<f64> {
190        if self.samples.len() < 2 {
191            return None;
192        }
193
194        let (slope, _) = self.calculate_linear_trend();
195        Some(slope)
196    }
197
198    /// Estimate periods until reaching a capacity threshold.
199    #[must_use]
200    pub fn time_to_capacity(&self, capacity: f64) -> Option<usize> {
201        if self.samples.is_empty() {
202            return None;
203        }
204
205        let current = self.samples.back()?;
206
207        if *current >= capacity {
208            return Some(0);
209        }
210
211        let growth = self.growth_rate()?;
212
213        if growth <= 0.0 {
214            return None; // Not growing
215        }
216
217        let periods = ((capacity - current) / growth).ceil() as usize;
218        Some(periods)
219    }
220
221    /// Get the confidence level of the forecast (0-1).
222    ///
223    /// Based on R-squared for linear regression.
224    #[must_use]
225    pub fn confidence(&self) -> Option<f64> {
226        if self.samples.len() < 2 {
227            return None;
228        }
229
230        match self.method {
231            ForecastMethod::LinearRegression => self.calculate_r_squared(),
232            ForecastMethod::MovingAverage | ForecastMethod::ExponentialSmoothing => {
233                Some(0.5) // Default moderate confidence
234            }
235        }
236    }
237
238    /// Calculate R-squared for linear regression.
239    #[must_use]
240    fn calculate_r_squared(&self) -> Option<f64> {
241        if self.samples.len() < 2 {
242            return None;
243        }
244
245        let (slope, intercept) = self.calculate_linear_trend();
246        let mean_y: f64 = self.samples.iter().sum::<f64>() / self.samples.len() as f64;
247
248        let mut ss_tot = 0.0;
249        let mut ss_res = 0.0;
250
251        for (i, &y) in self.samples.iter().enumerate() {
252            let x = i as f64;
253            let y_pred = slope * x + intercept;
254
255            ss_tot += (y - mean_y) * (y - mean_y);
256            ss_res += (y - y_pred) * (y - y_pred);
257        }
258
259        if ss_tot == 0.0 {
260            return Some(0.0);
261        }
262
263        Some(1.0 - (ss_res / ss_tot))
264    }
265
266    /// Detect if current trend is anomalous compared to forecast.
267    #[must_use]
268    #[inline]
269    pub fn is_anomalous(&self, threshold: f64) -> bool {
270        if self.samples.len() < 3 {
271            return false;
272        }
273
274        let latest = match self.samples.back() {
275            Some(&v) => v,
276            None => return false,
277        };
278
279        // Forecast based on all but the last sample
280        let mut temp_samples = self.samples.clone();
281        temp_samples.pop_back();
282
283        let temp_forecaster = Forecaster {
284            method: self.method,
285            samples: temp_samples,
286            max_samples: self.max_samples,
287            smoothing_alpha: self.smoothing_alpha,
288        };
289
290        let forecast = match temp_forecaster.forecast(1) {
291            Some(f) => f,
292            None => return false,
293        };
294
295        let deviation = (latest - forecast).abs();
296        let avg = temp_forecaster.forecast_moving_average().unwrap_or(latest);
297
298        if avg == 0.0 {
299            return false;
300        }
301
302        let relative_deviation = deviation / avg;
303        relative_deviation > threshold
304    }
305
306    /// Get the number of samples.
307    #[must_use]
308    #[inline]
309    pub fn sample_count(&self) -> usize {
310        self.samples.len()
311    }
312
313    /// Get the latest sample value.
314    #[must_use]
315    #[inline]
316    pub fn latest_value(&self) -> Option<f64> {
317        self.samples.back().copied()
318    }
319
320    /// Clear all samples.
321    pub fn clear(&mut self) {
322        self.samples.clear();
323    }
324}
325
326/// Resource capacity forecast.
327#[derive(Debug, Clone)]
328pub struct CapacityForecast {
329    /// Current usage.
330    pub current_usage: f64,
331    /// Total capacity.
332    pub total_capacity: f64,
333    /// Forecasted usage in N periods.
334    pub forecasted_usage: f64,
335    /// Periods until capacity is reached.
336    pub periods_to_capacity: Option<usize>,
337    /// Growth rate per period.
338    pub growth_rate: f64,
339    /// Forecast confidence (0-1).
340    pub confidence: f64,
341}
342
343impl CapacityForecast {
344    /// Check if capacity will be exceeded soon.
345    #[must_use]
346    #[inline]
347    pub fn is_critical(&self, threshold_periods: usize) -> bool {
348        match self.periods_to_capacity {
349            Some(periods) => periods <= threshold_periods,
350            None => false,
351        }
352    }
353
354    /// Get usage percentage.
355    #[must_use]
356    #[inline]
357    pub fn usage_percent(&self) -> f64 {
358        if self.total_capacity == 0.0 {
359            return 0.0;
360        }
361        (self.current_usage / self.total_capacity) * 100.0
362    }
363}
364
365#[cfg(test)]
366mod tests {
367    use super::*;
368
369    #[test]
370    fn test_moving_average_forecast() {
371        let mut forecaster = Forecaster::new(ForecastMethod::MovingAverage);
372        forecaster.add_samples(&[10.0, 20.0, 30.0, 40.0]);
373
374        let forecast = forecaster.forecast(1);
375        assert_eq!(forecast, Some(25.0));
376    }
377
378    #[test]
379    fn test_linear_regression_forecast() {
380        let mut forecaster = Forecaster::new(ForecastMethod::LinearRegression);
381        forecaster.add_samples(&[10.0, 20.0, 30.0, 40.0]);
382
383        let forecast = forecaster.forecast(1);
384        assert!(forecast.is_some());
385        // Should predict ~50.0 for the next period
386        let value = forecast.unwrap();
387        assert!((value - 50.0).abs() < 1.0);
388    }
389
390    #[test]
391    fn test_growth_rate() {
392        let mut forecaster = Forecaster::new(ForecastMethod::LinearRegression);
393        forecaster.add_samples(&[10.0, 20.0, 30.0, 40.0]);
394
395        let growth = forecaster.growth_rate();
396        assert!(growth.is_some());
397        // Should be ~10.0 per period
398        let rate = growth.unwrap();
399        assert!((rate - 10.0).abs() < 0.1);
400    }
401
402    #[test]
403    fn test_time_to_capacity() {
404        let mut forecaster = Forecaster::new(ForecastMethod::LinearRegression);
405        forecaster.add_samples(&[10.0, 20.0, 30.0, 40.0]);
406
407        let periods = forecaster.time_to_capacity(100.0);
408        assert!(periods.is_some());
409        // Should take ~6 periods to reach 100
410        assert_eq!(periods.unwrap(), 6);
411    }
412
413    #[test]
414    fn test_time_to_capacity_already_exceeded() {
415        let mut forecaster = Forecaster::new(ForecastMethod::LinearRegression);
416        forecaster.add_samples(&[10.0, 20.0, 30.0, 40.0]);
417
418        let periods = forecaster.time_to_capacity(30.0);
419        assert_eq!(periods, Some(0));
420    }
421
422    #[test]
423    fn test_exponential_smoothing() {
424        let mut forecaster = Forecaster::new(ForecastMethod::ExponentialSmoothing);
425        forecaster.add_samples(&[10.0, 20.0, 30.0, 40.0]);
426
427        let forecast = forecaster.forecast(1);
428        assert!(forecast.is_some());
429    }
430
431    #[test]
432    fn test_confidence() {
433        let mut forecaster = Forecaster::new(ForecastMethod::LinearRegression);
434        forecaster.add_samples(&[10.0, 20.0, 30.0, 40.0]);
435
436        let confidence = forecaster.confidence();
437        assert!(confidence.is_some());
438        // Perfect linear trend should have high confidence
439        let conf = confidence.unwrap();
440        assert!(conf > 0.9);
441    }
442
443    #[test]
444    fn test_anomaly_detection() {
445        let mut forecaster = Forecaster::new(ForecastMethod::LinearRegression);
446        forecaster.add_samples(&[10.0, 20.0, 30.0, 40.0]);
447
448        // Not anomalous
449        assert!(!forecaster.is_anomalous(0.5));
450
451        // Add anomalous value
452        forecaster.add_sample(100.0);
453        assert!(forecaster.is_anomalous(0.5));
454    }
455
456    #[test]
457    fn test_sample_management() {
458        let mut forecaster = Forecaster::new(ForecastMethod::MovingAverage);
459        assert_eq!(forecaster.sample_count(), 0);
460        assert!(forecaster.latest_value().is_none());
461
462        forecaster.add_sample(42.0);
463        assert_eq!(forecaster.sample_count(), 1);
464        assert_eq!(forecaster.latest_value(), Some(42.0));
465
466        forecaster.clear();
467        assert_eq!(forecaster.sample_count(), 0);
468    }
469
470    #[test]
471    fn test_capacity_forecast_critical() {
472        let forecast = CapacityForecast {
473            current_usage: 80.0,
474            total_capacity: 100.0,
475            forecasted_usage: 95.0,
476            periods_to_capacity: Some(3),
477            growth_rate: 5.0,
478            confidence: 0.9,
479        };
480
481        assert!(forecast.is_critical(5));
482        assert!(!forecast.is_critical(2));
483        assert_eq!(forecast.usage_percent(), 80.0);
484    }
485}