Skip to main content

wm_selfmodel/
forecast.rs

1//! Forecasting — linear extrapolation + EWMA prediction.
2
3use serde::{Deserialize, Serialize};
4use std::collections::VecDeque;
5
6use crate::metrics::MetricSample;
7
8/// A forecast prediction for a metric.
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct Forecast {
11    /// Predicted value `horizon` samples into the future.
12    pub predicted_value: f32,
13    /// Rate of change per sample (slope from linear regression).
14    pub slope: f32,
15    /// EWMA of recent values (smoothing out noise).
16    pub ewma: f32,
17    /// Confidence in the prediction (0.0–1.0).
18    /// Based on variance of residuals — lower variance = higher confidence.
19    pub confidence: f32,
20    /// How many samples ahead the prediction is.
21    pub horizon: usize,
22}
23
24/// Forecast engine — produces predictions from historical data.
25///
26/// Uses a hybrid approach:
27/// 1. Linear extrapolation from the slope of recent samples
28/// 2. EWMA smoothing to reduce noise sensitivity
29/// 3. Confidence from residual variance (how well linear fit matches data)
30pub struct ForecastEngine {
31    /// EWMA smoothing factor (0.0–1.0). Higher = more weight on recent.
32    ewma_alpha: f32,
33}
34
35impl ForecastEngine {
36    /// Create a new forecast engine with default alpha (0.3).
37    #[must_use]
38    pub const fn new() -> Self {
39        Self { ewma_alpha: 0.3 }
40    }
41
42    /// Create with a custom EWMA alpha.
43    #[must_use]
44    pub const fn with_alpha(alpha: f32) -> Self {
45        Self {
46            ewma_alpha: alpha.clamp(0.0, 1.0),
47        }
48    }
49
50    /// Forecast from a history of samples.
51    ///
52    /// Combines linear extrapolation (for trend) with EWMA (for noise resistance).
53    /// Confidence is derived from how well the linear model fits the data
54    /// (R²-like measure from residual variance).
55    ///
56    /// Outlier values are clamped to ±3 standard deviations from the mean
57    /// before fitting, preventing a single extreme spike from dominating
58    /// the forecast.
59    #[must_use]
60    pub fn forecast(&self, history: &VecDeque<MetricSample>, horizon: usize) -> Forecast {
61        let n = history.len();
62        let raw_values: Vec<f32> = history.iter().map(|s| s.value).collect();
63
64        // Clamp outliers to ±3σ from mean before fitting
65        let values = clamp_outliers(&raw_values);
66
67        // Compute linear regression (least squares)
68        let (slope, intercept) = linear_regression(&values);
69        let predicted_linear = slope.mul_add((n + horizon - 1) as f32, intercept);
70
71        // Compute EWMA
72        let ewma = compute_ewma(&values, self.ewma_alpha);
73
74        // Compute confidence from R² (coefficient of determination)
75        let r_squared = compute_r_squared(&values, slope, intercept);
76
77        // Blend linear prediction with EWMA (weight by confidence)
78        let blended = r_squared.mul_add(predicted_linear, (1.0 - r_squared) * ewma);
79
80        Forecast {
81            predicted_value: blended,
82            slope,
83            ewma,
84            confidence: r_squared,
85            horizon,
86        }
87    }
88}
89
90impl Default for ForecastEngine {
91    fn default() -> Self {
92        Self::new()
93    }
94}
95
96/// Compute linear regression (slope, intercept) from a series of values.
97/// x = index (0, 1, 2, ...), y = value.
98fn linear_regression(values: &[f32]) -> (f32, f32) {
99    let n = values.len() as f32;
100    if n < 2.0 {
101        return (0.0, values.first().copied().unwrap_or(0.0));
102    }
103
104    let sum_x: f32 = (0..values.len()).map(|i| i as f32).sum();
105    let sum_y: f32 = values.iter().copied().sum();
106    let sum_xy: f32 = values.iter().enumerate().map(|(i, &v)| i as f32 * v).sum();
107    let sum_x_sq: f32 = (0..values.len()).map(|i| (i as f32).powi(2)).sum();
108
109    let denominator = n.mul_add(sum_x_sq, -(sum_x * sum_x));
110    if denominator.abs() < f32::EPSILON {
111        return (0.0, sum_y / n);
112    }
113
114    let slope = n.mul_add(sum_xy, -(sum_x * sum_y)) / denominator;
115    let intercept = slope.mul_add(-sum_x, sum_y) / n;
116    (slope, intercept)
117}
118
119/// Compute R² (coefficient of determination) — how well the linear model fits.
120/// Returns 0.0–1.0. Higher = better fit = more confidence in extrapolation.
121fn compute_r_squared(values: &[f32], slope: f32, intercept: f32) -> f32 {
122    let n = values.len();
123    if n < 3 {
124        // Not enough points to assess fit
125        return 0.5;
126    }
127
128    let mean_y: f32 = values.iter().copied().sum::<f32>() / n as f32;
129
130    let mut ss_res = 0.0_f32; // Residual sum of squares
131    let mut ss_tot = 0.0_f32; // Total sum of squares
132
133    for (i, &y) in values.iter().enumerate() {
134        let predicted = slope.mul_add(i as f32, intercept);
135        ss_res += (y - predicted).powi(2);
136        ss_tot += (y - mean_y).powi(2);
137    }
138
139    if ss_tot < f32::EPSILON {
140        // All values are the same — perfect fit, but no trend
141        return 0.9;
142    }
143
144    let r_squared = 1.0 - ss_res / ss_tot;
145    r_squared.clamp(0.0, 1.0)
146}
147
148/// Compute EWMA (exponentially weighted moving average).
149fn compute_ewma(values: &[f32], alpha: f32) -> f32 {
150    if values.is_empty() {
151        return 0.0;
152    }
153    let alpha = alpha.clamp(0.0, 1.0);
154    let mut ewma = values[0];
155    for &v in &values[1..] {
156        ewma = alpha.mul_add(v, (1.0 - alpha) * ewma);
157    }
158    ewma
159}
160
161/// Clamp outlier values using median and MAD (median absolute deviation).
162///
163/// Uses the median (robust to outliers) and MAD scaled by 1.4826 (to make
164/// it comparable to std dev) with a 3.5 threshold. This prevents a single
165/// extreme spike (e.g., f32::MAX, sensor glitch) from dominating the linear
166/// regression and EWMA computations.
167fn clamp_outliers(values: &[f32]) -> Vec<f32> {
168    if values.len() < 4 {
169        return values.to_vec();
170    }
171
172    // Compute median
173    let mut sorted: Vec<f32> = values.to_vec();
174    sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
175    let mid = sorted.len() / 2;
176    let median = if sorted.len() % 2 == 0 {
177        f32::midpoint(sorted[mid - 1], sorted[mid])
178    } else {
179        sorted[mid]
180    };
181
182    // Compute MAD (median absolute deviation)
183    let abs_devs: Vec<f32> = values.iter().map(|&v| (v - median).abs()).collect();
184    let mut sorted_devs = abs_devs.clone();
185    sorted_devs.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
186    let mad = if sorted_devs.len() % 2 == 0 {
187        f32::midpoint(sorted_devs[mid - 1], sorted_devs[mid])
188    } else {
189        sorted_devs[mid]
190    };
191
192    // Scale MAD to be comparable to std dev: σ ≈ 1.4826 * MAD
193    let scaled_mad = 1.4826 * mad;
194    if scaled_mad < f32::EPSILON {
195        // MAD is 0 — more than half the values are identical (at the median).
196        // Use the median magnitude as a fallback scale, since any deviation
197        // from the majority value is suspicious.
198        let has_non_zero_dev = abs_devs.iter().any(|&d| d > f32::EPSILON);
199        if !has_non_zero_dev {
200            // All values are identical — no outliers
201            return values.to_vec();
202        }
203        // Use median magnitude as scale (100% of |median|, min 0.01)
204        let fallback_scale = median.abs().max(0.01);
205        let threshold = 3.0 * fallback_scale;
206        let lower = median - threshold;
207        let upper = median + threshold;
208        return values.iter().map(|&v| v.clamp(lower, upper)).collect();
209    }
210
211    // Clamp at ±3.5 scaled MAD from median (3.5 is a common robust threshold)
212    let threshold = 3.5 * scaled_mad;
213    let lower = median - threshold;
214    let upper = median + threshold;
215
216    values.iter().map(|&v| v.clamp(lower, upper)).collect()
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222    use chrono::Utc;
223
224    fn make_history(values: &[f32]) -> VecDeque<MetricSample> {
225        values
226            .iter()
227            .map(|&v| MetricSample {
228                kind: crate::metrics::MetricKind::CpuLoad,
229                value: v,
230                timestamp: Utc::now(),
231            })
232            .collect()
233    }
234
235    #[test]
236    fn forecast_linear_trend() {
237        let history = make_history(&[0.1, 0.2, 0.3, 0.4, 0.5]);
238        let engine = ForecastEngine::new();
239        let f = engine.forecast(&history, 3);
240        assert!(f.predicted_value > 0.5);
241        assert!(f.slope > 0.0);
242        assert!((f.slope - 0.1).abs() < 0.01);
243        assert!(f.confidence > 0.9); // Perfect linear fit
244    }
245
246    #[test]
247    fn forecast_decreasing_trend() {
248        let history = make_history(&[0.5, 0.4, 0.3, 0.2, 0.1]);
249        let engine = ForecastEngine::new();
250        let f = engine.forecast(&history, 2);
251        assert!(f.predicted_value < 0.1);
252        assert!(f.slope < 0.0);
253    }
254
255    #[test]
256    fn forecast_noisy_data_lower_confidence() {
257        let history = make_history(&[0.3, 0.7, 0.2, 0.6, 0.3]);
258        let engine = ForecastEngine::new();
259        let f = engine.forecast(&history, 3);
260        assert!(f.confidence < 0.8); // Noisy data = lower confidence
261    }
262
263    #[test]
264    fn forecast_constant_data() {
265        let history = make_history(&[0.5, 0.5, 0.5, 0.5]);
266        let engine = ForecastEngine::new();
267        let f = engine.forecast(&history, 5);
268        assert!((f.predicted_value - 0.5).abs() < 0.1);
269        assert!((f.slope - 0.0).abs() < 0.01);
270        assert!(f.confidence > 0.8);
271    }
272
273    #[test]
274    fn forecast_two_points() {
275        let history = make_history(&[0.3, 0.5]);
276        let engine = ForecastEngine::new();
277        let f = engine.forecast(&history, 2);
278        assert!(f.predicted_value > 0.5);
279        assert!(f.slope > 0.0);
280    }
281
282    #[test]
283    fn forecast_ewma_blends_with_linear() {
284        let history = make_history(&[0.1, 0.9, 0.1, 0.9, 0.1]);
285        let engine = ForecastEngine::new();
286        let f = engine.forecast(&history, 3);
287        // With oscillating data, prediction should be somewhere in the middle
288        assert!(f.predicted_value > 0.0 && f.predicted_value < 1.0);
289    }
290
291    #[test]
292    fn forecast_horizon_affects_prediction() {
293        let history = make_history(&[0.1, 0.2, 0.3, 0.4, 0.5]);
294        let engine = ForecastEngine::new();
295        let f1 = engine.forecast(&history, 1);
296        let f10 = engine.forecast(&history, 10);
297        assert!(f10.predicted_value > f1.predicted_value);
298    }
299
300    #[test]
301    fn forecast_engine_default() {
302        let engine = ForecastEngine::default();
303        let history = make_history(&[0.1, 0.2, 0.3]);
304        let f = engine.forecast(&history, 1);
305        assert!(f.predicted_value > 0.2);
306    }
307
308    #[test]
309    fn forecast_engine_custom_alpha() {
310        let engine = ForecastEngine::with_alpha(0.8);
311        let history = make_history(&[0.1, 0.5, 0.9]);
312        let f = engine.forecast(&history, 1);
313        // High alpha = more weight on recent = prediction closer to 0.9
314        assert!(f.ewma > 0.5);
315    }
316
317    #[test]
318    fn forecast_serialization() {
319        let f = Forecast {
320            predicted_value: 0.5,
321            slope: 0.1,
322            ewma: 0.4,
323            confidence: 0.9,
324            horizon: 5,
325        };
326        let json = serde_json::to_string(&f).unwrap();
327        let back: Forecast = serde_json::from_str(&json).unwrap();
328        assert!((back.predicted_value - 0.5).abs() < 0.001);
329        assert_eq!(back.horizon, 5);
330    }
331
332    #[test]
333    fn linear_regression_perfect_fit() {
334        let values = [1.0, 2.0, 3.0, 4.0, 5.0];
335        let (slope, intercept) = linear_regression(&values);
336        assert!((slope - 1.0).abs() < 0.001);
337        assert!((intercept - 1.0).abs() < 0.001);
338    }
339
340    #[test]
341    fn linear_regression_flat() {
342        let values = [0.5, 0.5, 0.5, 0.5];
343        let (slope, intercept) = linear_regression(&values);
344        assert!((slope - 0.0).abs() < 0.001);
345        assert!((intercept - 0.5).abs() < 0.001);
346    }
347
348    #[test]
349    fn r_squared_perfect_linear() {
350        let values = [1.0, 2.0, 3.0, 4.0, 5.0];
351        let r2 = compute_r_squared(&values, 1.0, 1.0);
352        assert!((r2 - 1.0).abs() < 0.001);
353    }
354
355    #[test]
356    fn r_squared_noisy() {
357        let values = [0.3, 0.7, 0.2, 0.6, 0.3];
358        let (slope, intercept) = linear_regression(&values);
359        let r2 = compute_r_squared(&values, slope, intercept);
360        assert!(r2 < 0.5);
361    }
362
363    #[test]
364    fn r_squared_constant() {
365        let values = [0.5, 0.5, 0.5, 0.5];
366        let r2 = compute_r_squared(&values, 0.0, 0.5);
367        assert!(r2 > 0.8);
368    }
369
370    #[test]
371    fn ewma_computation() {
372        let values = [0.1, 0.2, 0.3, 0.4, 0.5];
373        let ewma = compute_ewma(&values, 0.3);
374        assert!(ewma > 0.1 && ewma < 0.5);
375    }
376
377    #[test]
378    fn ewma_empty() {
379        let ewma = compute_ewma(&[], 0.3);
380        assert_eq!(ewma, 0.0);
381    }
382
383    #[test]
384    fn ewma_single_value() {
385        let ewma = compute_ewma(&[0.42], 0.3);
386        assert!((ewma - 0.42).abs() < 0.001);
387    }
388
389    #[test]
390    fn forecast_outlier_does_not_dominate() {
391        // Stable history with one extreme spike
392        let history = make_history(&[0.3, 0.3, 0.3, 0.3, 100.0, 0.3, 0.3, 0.3, 0.3]);
393        let engine = ForecastEngine::new();
394        let f = engine.forecast(&history, 3);
395        // Without clamping, the 100.0 spike would dominate the forecast.
396        // With clamping, the prediction should stay reasonable.
397        assert!(
398            f.predicted_value < 10.0,
399            "outlier should not dominate forecast: got {}",
400            f.predicted_value
401        );
402        assert!(
403            f.slope.abs() < 5.0,
404            "slope should not be dominated by outlier: got {}",
405            f.slope
406        );
407    }
408
409    #[test]
410    fn forecast_extreme_outlier_clamped() {
411        // f32::MAX in history should not produce NaN or infinite forecast
412        let history = make_history(&[0.3, 0.3, 0.3, 0.3, f32::MAX, 0.3, 0.3, 0.3, 0.3]);
413        let engine = ForecastEngine::new();
414        let f = engine.forecast(&history, 3);
415        assert!(!f.predicted_value.is_nan(), "forecast should not be NaN");
416        assert!(
417            !f.predicted_value.is_infinite(),
418            "forecast should not be infinite"
419        );
420        assert!(
421            f.predicted_value < 10.0,
422            "extreme outlier should be clamped: got {}",
423            f.predicted_value
424        );
425    }
426
427    #[test]
428    fn forecast_negative_outlier_clamped() {
429        // Extreme negative outlier
430        let history = make_history(&[0.5, 0.5, 0.5, 0.5, -1000.0, 0.5, 0.5, 0.5, 0.5]);
431        let engine = ForecastEngine::new();
432        let f = engine.forecast(&history, 3);
433        assert!(!f.predicted_value.is_nan(), "forecast should not be NaN");
434        assert!(
435            f.predicted_value > -10.0,
436            "negative outlier should be clamped: got {}",
437            f.predicted_value
438        );
439    }
440
441    #[test]
442    fn clamp_outliers_preserves_normal_values() {
443        let values = vec![0.1, 0.2, 0.3, 0.4, 0.5];
444        let clamped = clamp_outliers(&values);
445        for (orig, clamped_val) in values.iter().zip(clamped.iter()) {
446            assert!((orig - clamped_val).abs() < 0.001);
447        }
448    }
449
450    #[test]
451    fn clamp_outliers_clamps_extreme() {
452        let values = vec![0.3_f32, 0.3, 0.3, 0.3, 100.0, 0.3, 0.3, 0.3, 0.3];
453        let clamped = clamp_outliers(&values);
454        // The 100.0 should be clamped down significantly
455        assert!(
456            clamped[4] < 10.0,
457            "outlier should be clamped: got {}",
458            clamped[4]
459        );
460        // Normal values should be unchanged
461        for i in [0, 1, 2, 3, 5, 6, 7, 8] {
462            assert!((clamped[i] - 0.3).abs() < 0.001);
463        }
464    }
465
466    #[test]
467    fn clamp_outliers_short_input_unchanged() {
468        let values = vec![0.1, 0.2, 0.3];
469        let clamped = clamp_outliers(&values);
470        assert_eq!(clamped, values);
471    }
472}