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}