1use serde::{Deserialize, Serialize};
4use std::collections::VecDeque;
5
6use crate::metrics::MetricSample;
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct Forecast {
11 pub predicted_value: f32,
13 pub slope: f32,
15 pub ewma: f32,
17 pub confidence: f32,
20 pub horizon: usize,
22}
23
24pub struct ForecastEngine {
31 ewma_alpha: f32,
33}
34
35impl ForecastEngine {
36 #[must_use]
38 pub const fn new() -> Self {
39 Self { ewma_alpha: 0.3 }
40 }
41
42 #[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 #[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 let values = clamp_outliers(&raw_values);
66
67 let (slope, intercept) = linear_regression(&values);
69 let predicted_linear = slope.mul_add((n + horizon - 1) as f32, intercept);
70
71 let ewma = compute_ewma(&values, self.ewma_alpha);
73
74 let r_squared = compute_r_squared(&values, slope, intercept);
76
77 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
96fn 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
119fn compute_r_squared(values: &[f32], slope: f32, intercept: f32) -> f32 {
122 let n = values.len();
123 if n < 3 {
124 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; let mut ss_tot = 0.0_f32; 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 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
148fn 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
161fn clamp_outliers(values: &[f32]) -> Vec<f32> {
168 if values.len() < 4 {
169 return values.to_vec();
170 }
171
172 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 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 let scaled_mad = 1.4826 * mad;
194 if scaled_mad < f32::EPSILON {
195 let has_non_zero_dev = abs_devs.iter().any(|&d| d > f32::EPSILON);
199 if !has_non_zero_dev {
200 return values.to_vec();
202 }
203 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 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); }
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); }
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 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 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 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 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 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 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 assert!(
456 clamped[4] < 10.0,
457 "outlier should be clamped: got {}",
458 clamped[4]
459 );
460 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}