1use serde::{Deserialize, Serialize};
4
5use crate::forecast::Forecast;
6use crate::metrics::MetricKind;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
10#[serde(rename_all = "snake_case")]
11pub enum AlertLevel {
12 Info,
14 Warning,
16 Critical,
18}
19
20impl AlertLevel {
21 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(rename_all = "snake_case")]
35pub enum Comparison {
36 GreaterThan,
38 LessThan,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct AlertRule {
45 pub metric: MetricKind,
47 pub threshold: f32,
49 pub comparison: Comparison,
51 pub horizon: usize,
53 pub level: AlertLevel,
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct Alert {
60 pub metric: MetricKind,
62 pub level: AlertLevel,
64 pub predicted_value: f32,
66 pub threshold: f32,
68 pub message: String,
70 pub confidence: f32,
72}
73
74pub struct AlertEngine {
76 rules: Vec<AlertRule>,
77}
78
79impl AlertEngine {
80 #[must_use]
82 pub fn with_default_rules() -> Self {
83 let mut rules = Vec::new();
84
85 for kind in MetricKind::all() {
86 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 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 #[must_use]
118 pub const fn new() -> Self {
119 Self { rules: Vec::new() }
120 }
121
122 pub fn add_rule(&mut self, rule: AlertRule) {
124 self.rules.push(rule);
125 }
126
127 #[must_use]
129 pub fn rules(&self) -> &[AlertRule] {
130 &self.rules
131 }
132
133 #[must_use]
136 pub fn evaluate_rule(rule: &AlertRule, forecast: &Forecast) -> Option<Alert> {
137 Self::new().evaluate(rule, forecast)
138 }
139
140 #[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 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}