Skip to main content

wm_selfmodel/
alert.rs

1//! Alert engine — threshold rules checked against forecasts.
2
3use serde::{Deserialize, Serialize};
4
5use crate::forecast::Forecast;
6use crate::metrics::MetricKind;
7
8/// Alert severity level.
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
10#[serde(rename_all = "snake_case")]
11pub enum AlertLevel {
12    /// Informational — metric approaching threshold.
13    Info,
14    /// Warning — metric will likely cross threshold soon.
15    Warning,
16    /// Critical — metric predicted to cross danger threshold.
17    Critical,
18}
19
20impl AlertLevel {
21    /// Human-readable name.
22    #[must_use]
23    pub const fn as_str(self) -> &'static str {
24        match self {
25            Self::Info => "info",
26            Self::Warning => "warning",
27            Self::Critical => "critical",
28        }
29    }
30}
31
32/// Comparison operator for threshold rules.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(rename_all = "snake_case")]
35pub enum Comparison {
36    /// Predicted value > threshold.
37    GreaterThan,
38    /// Predicted value < threshold.
39    LessThan,
40}
41
42/// An alert rule — when a metric's forecast crosses a threshold, fire an alert.
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct AlertRule {
45    /// Which metric to watch.
46    pub metric: MetricKind,
47    /// Threshold value.
48    pub threshold: f32,
49    /// Comparison direction.
50    pub comparison: Comparison,
51    /// How many samples ahead to forecast.
52    pub horizon: usize,
53    /// Alert level when triggered.
54    pub level: AlertLevel,
55}
56
57/// An active alert — a rule that has been triggered.
58#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct Alert {
60    /// Which metric triggered the alert.
61    pub metric: MetricKind,
62    /// Alert level.
63    pub level: AlertLevel,
64    /// The forecasted value that triggered the alert.
65    pub predicted_value: f32,
66    /// The threshold that was crossed.
67    pub threshold: f32,
68    /// Human-readable message.
69    pub message: String,
70    /// Forecast confidence (0.0–1.0).
71    pub confidence: f32,
72}
73
74/// Alert engine — holds rules and evaluates forecasts against them.
75pub struct AlertEngine {
76    rules: Vec<AlertRule>,
77}
78
79impl AlertEngine {
80    /// Create an alert engine with default rules for all metrics.
81    #[must_use]
82    pub fn with_default_rules() -> Self {
83        let mut rules = Vec::new();
84
85        for kind in MetricKind::all() {
86            // Warning rule
87            rules.push(AlertRule {
88                metric: *kind,
89                threshold: kind.default_warning(),
90                comparison: if kind.higher_is_better() {
91                    Comparison::LessThan
92                } else {
93                    Comparison::GreaterThan
94                },
95                horizon: 5,
96                level: AlertLevel::Warning,
97            });
98
99            // Critical rule
100            rules.push(AlertRule {
101                metric: *kind,
102                threshold: kind.default_critical(),
103                comparison: if kind.higher_is_better() {
104                    Comparison::LessThan
105                } else {
106                    Comparison::GreaterThan
107                },
108                horizon: 5,
109                level: AlertLevel::Critical,
110            });
111        }
112
113        Self { rules }
114    }
115
116    /// Create an empty alert engine (no rules).
117    #[must_use]
118    pub const fn new() -> Self {
119        Self { rules: Vec::new() }
120    }
121
122    /// Add a custom alert rule.
123    pub fn add_rule(&mut self, rule: AlertRule) {
124        self.rules.push(rule);
125    }
126
127    /// Get all rules.
128    #[must_use]
129    pub fn rules(&self) -> &[AlertRule] {
130        &self.rules
131    }
132
133    /// Evaluate a forecast against a rule. Returns an alert if triggered.
134    /// Static version that doesn't require &self (for use after releasing locks).
135    #[must_use]
136    pub fn evaluate_rule(rule: &AlertRule, forecast: &Forecast) -> Option<Alert> {
137        Self::new().evaluate(rule, forecast)
138    }
139
140    /// Evaluate a forecast against a rule. Returns an alert if triggered.
141    #[must_use]
142    pub fn evaluate(&self, rule: &AlertRule, forecast: &Forecast) -> Option<Alert> {
143        let triggered = match rule.comparison {
144            Comparison::GreaterThan => forecast.predicted_value > rule.threshold,
145            Comparison::LessThan => forecast.predicted_value < rule.threshold,
146        };
147
148        if !triggered {
149            return None;
150        }
151
152        let direction = match rule.comparison {
153            Comparison::GreaterThan => "exceed",
154            Comparison::LessThan => "drops below",
155        };
156
157        let message = format!(
158            "{} predicted to {} {:.3} within {} samples (threshold: {:.3}, confidence: {:.2})",
159            rule.metric.as_str(),
160            direction,
161            forecast.predicted_value,
162            rule.horizon,
163            rule.threshold,
164            forecast.confidence,
165        );
166
167        Some(Alert {
168            metric: rule.metric,
169            level: rule.level,
170            predicted_value: forecast.predicted_value,
171            threshold: rule.threshold,
172            message,
173            confidence: forecast.confidence,
174        })
175    }
176}
177
178impl Default for AlertEngine {
179    fn default() -> Self {
180        Self::with_default_rules()
181    }
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187
188    fn make_forecast(value: f32, confidence: f32) -> Forecast {
189        Forecast {
190            predicted_value: value,
191            slope: 0.1,
192            ewma: value - 0.05,
193            confidence,
194            horizon: 5,
195        }
196    }
197
198    #[test]
199    fn alert_level_as_str() {
200        assert_eq!(AlertLevel::Info.as_str(), "info");
201        assert_eq!(AlertLevel::Warning.as_str(), "warning");
202        assert_eq!(AlertLevel::Critical.as_str(), "critical");
203    }
204
205    #[test]
206    fn alert_engine_default_has_rules() {
207        let engine = AlertEngine::with_default_rules();
208        // 13 metrics * 2 rules (warning + critical) = 26
209        assert_eq!(engine.rules().len(), 26);
210    }
211
212    #[test]
213    fn alert_engine_empty() {
214        let engine = AlertEngine::new();
215        assert_eq!(engine.rules().len(), 0);
216    }
217
218    #[test]
219    fn alert_engine_add_rule() {
220        let mut engine = AlertEngine::new();
221        engine.add_rule(AlertRule {
222            metric: MetricKind::CpuLoad,
223            threshold: 0.8,
224            comparison: Comparison::GreaterThan,
225            horizon: 3,
226            level: AlertLevel::Warning,
227        });
228        assert_eq!(engine.rules().len(), 1);
229    }
230
231    #[test]
232    fn alert_evaluate_greater_than_triggered() {
233        let engine = AlertEngine::new();
234        let rule = AlertRule {
235            metric: MetricKind::CpuLoad,
236            threshold: 0.7,
237            comparison: Comparison::GreaterThan,
238            horizon: 5,
239            level: AlertLevel::Warning,
240        };
241        let forecast = make_forecast(0.85, 0.9);
242        let alert = engine.evaluate(&rule, &forecast);
243        assert!(alert.is_some());
244        let alert = alert.unwrap();
245        assert_eq!(alert.metric, MetricKind::CpuLoad);
246        assert_eq!(alert.level, AlertLevel::Warning);
247        assert!(alert.message.contains("cpu_load"));
248    }
249
250    #[test]
251    fn alert_evaluate_greater_than_not_triggered() {
252        let engine = AlertEngine::new();
253        let rule = AlertRule {
254            metric: MetricKind::CpuLoad,
255            threshold: 0.9,
256            comparison: Comparison::GreaterThan,
257            horizon: 5,
258            level: AlertLevel::Critical,
259        };
260        let forecast = make_forecast(0.5, 0.9);
261        let alert = engine.evaluate(&rule, &forecast);
262        assert!(alert.is_none());
263    }
264
265    #[test]
266    fn alert_evaluate_less_than_triggered() {
267        let engine = AlertEngine::new();
268        let rule = AlertRule {
269            metric: MetricKind::Coherence,
270            threshold: 0.3,
271            comparison: Comparison::LessThan,
272            horizon: 5,
273            level: AlertLevel::Warning,
274        };
275        let forecast = make_forecast(0.15, 0.8);
276        let alert = engine.evaluate(&rule, &forecast);
277        assert!(alert.is_some());
278        let alert = alert.unwrap();
279        assert_eq!(alert.metric, MetricKind::Coherence);
280        assert!(alert.message.contains("coherence"));
281        assert!(alert.message.contains("drops below"));
282    }
283
284    #[test]
285    fn alert_evaluate_less_than_not_triggered() {
286        let engine = AlertEngine::new();
287        let rule = AlertRule {
288            metric: MetricKind::Coherence,
289            threshold: 0.3,
290            comparison: Comparison::LessThan,
291            horizon: 5,
292            level: AlertLevel::Warning,
293        };
294        let forecast = make_forecast(0.8, 0.9);
295        let alert = engine.evaluate(&rule, &forecast);
296        assert!(alert.is_none());
297    }
298
299    #[test]
300    fn alert_message_contains_direction() {
301        let engine = AlertEngine::new();
302        let rule = AlertRule {
303            metric: MetricKind::CpuLoad,
304            threshold: 0.7,
305            comparison: Comparison::GreaterThan,
306            horizon: 5,
307            level: AlertLevel::Critical,
308        };
309        let forecast = make_forecast(0.95, 0.9);
310        let alert = engine.evaluate(&rule, &forecast).unwrap();
311        assert!(alert.message.contains("exceed"));
312    }
313
314    #[test]
315    fn alert_serialization() {
316        let alert = Alert {
317            metric: MetricKind::CpuLoad,
318            level: AlertLevel::Critical,
319            predicted_value: 0.95,
320            threshold: 0.9,
321            message: "CPU load critical".to_string(),
322            confidence: 0.9,
323        };
324        let json = serde_json::to_string(&alert).unwrap();
325        let back: Alert = serde_json::from_str(&json).unwrap();
326        assert_eq!(back.metric, MetricKind::CpuLoad);
327        assert_eq!(back.level, AlertLevel::Critical);
328        assert!((back.predicted_value - 0.95).abs() < 0.001);
329    }
330
331    #[test]
332    fn alert_rule_serialization() {
333        let rule = AlertRule {
334            metric: MetricKind::ErrorRate,
335            threshold: 0.1,
336            comparison: Comparison::GreaterThan,
337            horizon: 3,
338            level: AlertLevel::Warning,
339        };
340        let json = serde_json::to_string(&rule).unwrap();
341        let back: AlertRule = serde_json::from_str(&json).unwrap();
342        assert_eq!(back.metric, MetricKind::ErrorRate);
343        assert_eq!(back.comparison, Comparison::GreaterThan);
344    }
345
346    #[test]
347    fn default_rules_cover_all_metrics() {
348        let engine = AlertEngine::with_default_rules();
349        for kind in MetricKind::all() {
350            let has_rule = engine.rules().iter().any(|r| r.metric == *kind);
351            assert!(has_rule, "No rule for {kind:?}");
352        }
353    }
354
355    #[test]
356    fn default_rules_coherence_uses_less_than() {
357        let engine = AlertEngine::with_default_rules();
358        let coherence_rules: Vec<_> = engine
359            .rules()
360            .iter()
361            .filter(|r| r.metric == MetricKind::Coherence)
362            .collect();
363        assert_eq!(coherence_rules.len(), 2);
364        assert!(
365            coherence_rules
366                .iter()
367                .all(|r| r.comparison == Comparison::LessThan)
368        );
369    }
370
371    #[test]
372    fn default_rules_cpu_load_uses_greater_than() {
373        let engine = AlertEngine::with_default_rules();
374        let cpu_rules: Vec<_> = engine
375            .rules()
376            .iter()
377            .filter(|r| r.metric == MetricKind::CpuLoad)
378            .collect();
379        assert_eq!(cpu_rules.len(), 2);
380        assert!(
381            cpu_rules
382                .iter()
383                .all(|r| r.comparison == Comparison::GreaterThan)
384        );
385    }
386}