dwara_core/analytics/
insights.rs

1//! ML traffic insights (DW-092): EWMA-based capacity forecasting and
2//! seasonal-baseline anomaly detection over the live sketch window
3//! rotations.
4//!
5//! The engine is hand-rolled (no ML crates — no `hdrhistogram`, no
6//! `tdigest`, no ML framework). It maintains two structures:
7//!
8//! - a **recent ring buffer** of completed sketch windows
9//!   (`BaselineWindow` each), capped at `baseline_windows` entries,
10//!   feeding an EWMA trend over the most recent observations; and
11//! - a **minute-of-day seasonal pattern**: a fixed 1440-entry ring
12//!   (one per minute of the day) of running averages for requests,
13//!   error rate, and latency, updated incrementally as windows rotate.
14//!
15//! Forecasting combines the seasonal average for the NEXT minute with
16//! an EWMA trend adjustment derived from the recent ring. Anomaly
17//! detection compares the CURRENT window's shape to the seasonal
18//! baseline for the same minute-of-day, flagging a window whose
19//! requests, error rate, or latency exceed the baseline by a
20//! configurable factor (default 2.0).
21//!
22//! The engine lives inside [`crate::analytics::EmbeddedAnalytics`] and
23//! is fed by the live-sketch window rotation: when a sketch window
24//! expires, the completed window's aggregates are handed to
25//! [`InsightsEngine::observe`]. Reads (forecast, anomaly) are
26//! admin-path only — never on the request hot path.
27
28use std::collections::VecDeque;
29use std::sync::{Arc, RwLock};
30
31use crate::config::AnalyticsInsights;
32
33/// The EWMA smoothing factor applied to the recent-window trend. A
34/// higher value reacts faster to changes; 0.3 is a moderate
35/// trend-following weight (the same family of weight the adaptive
36/// rate-limiter's EWMA uses, DW-089).
37const EWMA_ALPHA: f64 = 0.3;
38
39/// The anomaly factor: a current value exceeding the seasonal baseline
40/// by this multiple is flagged anomalous. 2.0 = a doubling.
41const ANOMALY_FACTOR: f64 = 2.0;
42
43/// One completed sketch window handed to the insights engine. A plain
44/// DTO — the live-sketch rotation constructs it from its per-route
45/// aggregates (summed across routes for the window-level shape).
46#[derive(Debug, Clone, Copy, Default)]
47pub struct BaselineWindow {
48    /// Wall-clock ms since the Unix epoch (the window's start).
49    pub ts_ms: i64,
50    /// Total requests across all routes in the window.
51    pub requests: u64,
52    /// Total errors (status >= 500) across all routes.
53    pub errors: u64,
54    /// The window's average latency in milliseconds (mean of per-route
55    /// averages, weighted by request count).
56    pub avg_latency_ms: f64,
57}
58
59impl BaselineWindow {
60    /// The error rate as a fraction in [0, 1].
61    pub fn error_rate(&self) -> f64 {
62        if self.requests == 0 {
63            0.0
64        } else {
65            self.errors as f64 / self.requests as f64
66        }
67    }
68}
69
70/// One minute-of-day seasonal entry: running averages for the windows
71/// that landed on this minute of the day. Updated incrementally with
72/// an exponential moving average so a long-running gateway converges
73/// on the daily traffic shape.
74#[derive(Debug, Clone, Copy, Default)]
75struct SeasonalEntry {
76    /// Number of windows observed for this minute-of-day (the data
77    /// volume behind the averages — drives forecast confidence).
78    count: u64,
79    /// Running average request count per window.
80    avg_requests: f64,
81    /// Running average error rate per window.
82    avg_error_rate: f64,
83    /// Running average latency per window.
84    avg_latency_ms: f64,
85}
86
87/// The mutable state behind the insights engine, guarded by a RwLock.
88struct BaselineState {
89    /// Ring buffer of recent completed windows (newest at the back),
90    /// capped at `baseline_windows`.
91    recent: VecDeque<BaselineWindow>,
92    /// The minute-of-day seasonal pattern: 1440 entries (one per
93    /// minute of the day).
94    seasonal: Box<[SeasonalEntry; 1440]>,
95    /// The cap on the recent ring buffer.
96    cap: usize,
97}
98
99/// The forecast result returned by [`InsightsEngine::forecast`].
100#[derive(Debug, Clone, Copy, Default, serde::Serialize)]
101pub struct ForecastResult {
102    /// The wall-clock ms of the next window the forecast targets.
103    pub next_window_ms: i64,
104    /// Predicted request count for the next window.
105    pub predicted_requests: f64,
106    /// Predicted error rate (fraction in [0, 1]) for the next window.
107    pub predicted_error_rate: f64,
108    /// Predicted average latency in milliseconds for the next window.
109    pub predicted_avg_latency_ms: f64,
110    /// Forecast confidence in [0, 1]: higher when more seasonal data
111    /// backs the prediction (0 when no baseline data exists).
112    pub confidence: f64,
113}
114
115/// The anomaly result returned by [`InsightsEngine::detect_anomaly`].
116#[derive(Debug, Clone, Default, serde::Serialize)]
117pub struct AnomalyResult {
118    /// Whether the current window is anomalous (any metric exceeded
119    /// the seasonal baseline by the anomaly factor).
120    pub is_anomalous: bool,
121    /// Anomaly score in [0, 1]: the maximum ratio of current to
122    /// baseline across the checked metrics, normalized.
123    pub score: f64,
124    /// A human-readable reason naming the triggering metric, or None
125    /// when not anomalous.
126    pub reason: Option<String>,
127    /// The seasonal baseline request count for the current
128    /// minute-of-day.
129    pub baseline_requests: f64,
130    /// The current window's request count.
131    pub current_requests: u64,
132    /// The seasonal baseline error rate for the current
133    /// minute-of-day.
134    pub baseline_error_rate: f64,
135    /// The current window's error rate.
136    pub current_error_rate: f64,
137    /// The seasonal baseline average latency for the current
138    /// minute-of-day.
139    pub baseline_latency_ms: f64,
140    /// The current window's average latency.
141    pub current_latency_ms: f64,
142}
143
144/// The ML traffic insights engine (DW-092): in-process, hand-rolled
145/// EWMA forecasting and seasonal-baseline anomaly detection. Held
146/// inside [`crate::analytics::EmbeddedAnalytics`] behind an `Arc` and
147/// fed by the live-sketch window rotation.
148pub struct InsightsEngine {
149    forecast_enabled: bool,
150    anomaly_enabled: bool,
151    baseline_windows: u64,
152    state: RwLock<BaselineState>,
153}
154
155impl InsightsEngine {
156    /// Construct from the insights config. `baseline_windows` is
157    /// clamped to at least 1 (the ring buffer must hold at least one
158    /// window).
159    pub fn new(config: &AnalyticsInsights) -> Arc<Self> {
160        let cap = config.baseline_windows.max(1) as usize;
161        Arc::new(InsightsEngine {
162            forecast_enabled: config.forecast,
163            anomaly_enabled: config.anomaly_baseline,
164            baseline_windows: config.baseline_windows.max(1),
165            state: RwLock::new(BaselineState {
166                recent: VecDeque::with_capacity(cap),
167                seasonal: Box::new([SeasonalEntry::default(); 1440]),
168                cap,
169            }),
170        })
171    }
172
173    /// Whether forecasting is enabled.
174    pub fn forecast_enabled(&self) -> bool {
175        self.forecast_enabled
176    }
177
178    /// Whether anomaly detection is enabled.
179    pub fn anomaly_enabled(&self) -> bool {
180        self.anomaly_enabled
181    }
182
183    /// The configured baseline window cap.
184    pub fn baseline_windows(&self) -> u64 {
185        self.baseline_windows
186    }
187
188    /// Observe one completed sketch window: add it to the recent ring
189    /// buffer (evicting the oldest when full) and update the
190    /// minute-of-day seasonal entry with an exponential moving average.
191    pub fn observe(&self, window: BaselineWindow) {
192        let mut state = self.state.write().unwrap();
193        // Update the recent ring buffer.
194        if state.recent.len() >= state.cap {
195            state.recent.pop_front();
196        }
197        state.recent.push_back(window);
198        // Update the seasonal entry for the window's minute-of-day.
199        let mod_idx = minute_of_day(window.ts_ms);
200        let entry = &mut state.seasonal[mod_idx];
201        let alpha = if entry.count == 0 { 1.0 } else { EWMA_ALPHA };
202        entry.avg_requests = ema(entry.avg_requests, window.requests as f64, alpha);
203        entry.avg_error_rate = ema(entry.avg_error_rate, window.error_rate(), alpha);
204        entry.avg_latency_ms = ema(entry.avg_latency_ms, window.avg_latency_ms, alpha);
205        entry.count = entry.count.saturating_add(1);
206    }
207
208    /// Forecast the next window's shape: the seasonal average for the
209    /// next minute-of-day, trend-adjusted by an EWMA over the recent
210    /// ring. Confidence is the fraction of the seasonal entry's data
211    /// volume relative to a full day of observations (capped at 1.0).
212    pub fn forecast(&self, now_ms: i64) -> ForecastResult {
213        if !self.forecast_enabled {
214            return ForecastResult::default();
215        }
216        let state = self.state.read().unwrap();
217        let next_mod = minute_of_day(now_ms.saturating_add(60_000));
218        let entry = state.seasonal[next_mod];
219        // EWMA trend over the recent ring: the difference between the
220        // most recent window and the seasonal average, applied as a
221        // fractional adjustment (clamped so the trend cannot invert
222        // the seasonal signal).
223        let trend_req = state
224            .recent
225            .back()
226            .map(|w| (w.requests as f64 - entry.avg_requests) * EWMA_ALPHA)
227            .unwrap_or(0.0);
228        let predicted_requests = (entry.avg_requests + trend_req).max(0.0);
229        // Confidence: the seasonal entry's count relative to a full
230        // day (1440 windows = one complete cycle). Saturates at 1.0.
231        let confidence = (entry.count as f64 / 1440.0).min(1.0);
232        ForecastResult {
233            next_window_ms: now_ms.saturating_add(60_000),
234            predicted_requests,
235            predicted_error_rate: entry.avg_error_rate.clamp(0.0, 1.0),
236            predicted_avg_latency_ms: entry.avg_latency_ms.max(0.0),
237            confidence,
238        }
239    }
240
241    /// Detect whether the current window is anomalous relative to the
242    /// seasonal baseline for its minute-of-day. A metric is flagged
243    /// when the current value exceeds the baseline by a factor of 2.0
244    /// (the `ANOMALY_FACTOR` constant). The anomaly score is the
245    /// maximum current/baseline ratio across the checked metrics,
246    /// normalized to [0, 1].
247    pub fn detect_anomaly(&self, current: &BaselineWindow) -> AnomalyResult {
248        if !self.anomaly_enabled {
249            return AnomalyResult::default();
250        }
251        let state = self.state.read().unwrap();
252        let mod_idx = minute_of_day(current.ts_ms);
253        let entry = state.seasonal[mod_idx];
254        // No baseline data yet: nothing is anomalous (the baseline has
255        // not been built).
256        if entry.count == 0 {
257            return AnomalyResult {
258                baseline_requests: 0.0,
259                current_requests: current.requests,
260                baseline_error_rate: 0.0,
261                current_error_rate: current.error_rate(),
262                baseline_latency_ms: 0.0,
263                current_latency_ms: current.avg_latency_ms,
264                ..Default::default()
265            };
266        }
267        let mut score = 0.0f64;
268        let mut reason: Option<String> = None;
269        // Requests: a spike is current > baseline * factor.
270        if entry.avg_requests > 0.0 {
271            let ratio = current.requests as f64 / entry.avg_requests;
272            if ratio >= ANOMALY_FACTOR {
273                score = score.max(ratio / ANOMALY_FACTOR);
274                if reason.is_none() {
275                    reason = Some(format!(
276                        "request spike: {} current vs {:.1} baseline ({:.1}x)",
277                        current.requests, entry.avg_requests, ratio
278                    ));
279                }
280            }
281        }
282        // Error rate: a spike is current > baseline * factor (and the
283        // current rate is non-trivial).
284        let cur_err = current.error_rate();
285        if entry.avg_error_rate > 0.0 {
286            let ratio = cur_err / entry.avg_error_rate;
287            if ratio >= ANOMALY_FACTOR && cur_err > 0.0 {
288                score = score.max(ratio / ANOMALY_FACTOR);
289                if reason.is_none() {
290                    reason = Some(format!(
291                        "error-rate spike: {:.4} current vs {:.4} baseline ({:.1}x)",
292                        cur_err, entry.avg_error_rate, ratio
293                    ));
294                }
295            }
296        }
297        // Latency: a spike is current > baseline * factor.
298        if entry.avg_latency_ms > 0.0 {
299            let ratio = current.avg_latency_ms / entry.avg_latency_ms;
300            if ratio >= ANOMALY_FACTOR {
301                score = score.max(ratio / ANOMALY_FACTOR);
302                if reason.is_none() {
303                    reason = Some(format!(
304                        "latency spike: {:.1}ms current vs {:.1}ms baseline ({:.1}x)",
305                        current.avg_latency_ms, entry.avg_latency_ms, ratio
306                    ));
307                }
308            }
309        }
310        let is_anomalous = reason.is_some();
311        AnomalyResult {
312            is_anomalous,
313            score: score.min(1.0),
314            reason,
315            baseline_requests: entry.avg_requests,
316            current_requests: current.requests,
317            baseline_error_rate: entry.avg_error_rate,
318            current_error_rate: cur_err,
319            baseline_latency_ms: entry.avg_latency_ms,
320            current_latency_ms: current.avg_latency_ms,
321        }
322    }
323}
324
325/// The minute-of-day index (0..1440) for a wall-clock ms timestamp.
326/// Computed from the timestamp's UTC minute-of-day so the seasonal
327/// pattern is timezone-stable.
328fn minute_of_day(ts_ms: i64) -> usize {
329    let ms_per_day: i64 = 86_400_000;
330    let within_day = ts_ms.rem_euclid(ms_per_day);
331    (within_day / 60_000) as usize
332}
333
334/// One exponential moving average step: `prev` updated toward `sample`
335/// by weight `alpha` (0..=1). When `prev` is the first observation
336/// (`alpha == 1.0`), the result is the sample itself.
337fn ema(prev: f64, sample: f64, alpha: f64) -> f64 {
338    prev + alpha * (sample - prev)
339}
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344
345    fn window(ts_ms: i64, requests: u64, errors: u64, latency: f64) -> BaselineWindow {
346        BaselineWindow {
347            ts_ms,
348            requests,
349            errors,
350            avg_latency_ms: latency,
351        }
352    }
353
354    #[test]
355    fn minute_of_day_wraps_at_day_boundary() {
356        // Midnight UTC = minute 0.
357        assert_eq!(minute_of_day(0), 0);
358        // 00:01 UTC = minute 1.
359        assert_eq!(minute_of_day(60_000), 1);
360        // 23:59 UTC = minute 1439.
361        assert_eq!(minute_of_day(23 * 3_600_000 + 59 * 60_000), 1439);
362        // Next day wraps back to 0.
363        assert_eq!(minute_of_day(86_400_000), 0);
364    }
365
366    #[test]
367    fn forecast_returns_zero_when_disabled() {
368        let engine = InsightsEngine::new(&AnalyticsInsights {
369            forecast: false,
370            anomaly_baseline: false,
371            baseline_windows: 10,
372        });
373        let r = engine.forecast(0);
374        assert_eq!(r.predicted_requests, 0.0);
375        assert_eq!(r.confidence, 0.0);
376    }
377
378    #[test]
379    fn forecast_predicts_from_seasonal_baseline() {
380        let engine = InsightsEngine::new(&AnalyticsInsights {
381            forecast: true,
382            anomaly_baseline: false,
383            baseline_windows: 10,
384        });
385        // Feed one window at minute 0.
386        engine.observe(window(0, 100, 5, 50.0));
387        let r = engine.forecast(0);
388        // Next window is minute 1 (now + 60s), which has no data yet:
389        // confidence 0, predictions 0.
390        assert_eq!(r.confidence, 0.0);
391        // Forecast at minute 0 - 60s (so next is minute 0): the
392        // seasonal entry for minute 0 has data.
393        let r2 = engine.forecast(-60_000);
394        assert!(r2.predicted_requests > 0.0);
395        assert!(r2.confidence > 0.0);
396    }
397
398    #[test]
399    fn anomaly_flags_request_spike() {
400        let engine = InsightsEngine::new(&AnalyticsInsights {
401            forecast: false,
402            anomaly_baseline: true,
403            baseline_windows: 10,
404        });
405        // Build a baseline at minute 0: 100 requests.
406        engine.observe(window(0, 100, 0, 50.0));
407        // Current window at the same minute: 300 requests (3x).
408        let r = engine.detect_anomaly(&window(0, 300, 0, 50.0));
409        assert!(r.is_anomalous);
410        assert!(r.score > 0.0);
411        assert!(r.reason.is_some());
412    }
413
414    #[test]
415    fn anomaly_ignores_normal_traffic() {
416        let engine = InsightsEngine::new(&AnalyticsInsights {
417            forecast: false,
418            anomaly_baseline: true,
419            baseline_windows: 10,
420        });
421        engine.observe(window(0, 100, 0, 50.0));
422        // Current window within normal range.
423        let r = engine.detect_anomaly(&window(0, 120, 0, 55.0));
424        assert!(!r.is_anomalous);
425        assert!(r.reason.is_none());
426    }
427
428    #[test]
429    fn anomaly_returns_default_when_disabled() {
430        let engine = InsightsEngine::new(&AnalyticsInsights {
431            forecast: false,
432            anomaly_baseline: false,
433            baseline_windows: 10,
434        });
435        let r = engine.detect_anomaly(&window(0, 999, 999, 999.0));
436        assert!(!r.is_anomalous);
437    }
438
439    #[test]
440    fn anomaly_no_baseline_is_not_anomalous() {
441        let engine = InsightsEngine::new(&AnalyticsInsights {
442            forecast: false,
443            anomaly_baseline: true,
444            baseline_windows: 10,
445        });
446        // No observations yet.
447        let r = engine.detect_anomaly(&window(0, 999, 0, 999.0));
448        assert!(!r.is_anomalous);
449    }
450
451    #[test]
452    fn seasonal_baseline_builds_over_time() {
453        let engine = InsightsEngine::new(&AnalyticsInsights {
454            forecast: true,
455            anomaly_baseline: true,
456            baseline_windows: 100,
457        });
458        // Feed several windows at minute 0 across "days".
459        for day in 0..5 {
460            engine.observe(window(day * 86_400_000, 100, 5, 50.0));
461        }
462        let r = engine.forecast(-60_000);
463        // After 5 observations the confidence is 5/1440.
464        assert!((r.confidence - 5.0 / 1440.0).abs() < 1e-9);
465        assert!(r.predicted_requests > 0.0);
466    }
467
468    #[test]
469    fn recent_ring_evicts_oldest() {
470        let engine = InsightsEngine::new(&AnalyticsInsights {
471            forecast: false,
472            anomaly_baseline: true,
473            baseline_windows: 3,
474        });
475        for i in 0..5 {
476            engine.observe(window(i * 60_000, 100, 0, 50.0));
477        }
478        let state = engine.state.read().unwrap();
479        assert_eq!(state.recent.len(), 3);
480        // The oldest two (i=0, i=1) were evicted; the back is i=4.
481        assert_eq!(state.recent.front().unwrap().ts_ms, 2 * 60_000);
482        assert_eq!(state.recent.back().unwrap().ts_ms, 4 * 60_000);
483    }
484}