use serde::{Deserialize, Serialize};
use crate::forecast::Forecast;
use crate::metrics::MetricKind;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AlertLevel {
Info,
Warning,
Critical,
}
impl AlertLevel {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Info => "info",
Self::Warning => "warning",
Self::Critical => "critical",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Comparison {
GreaterThan,
LessThan,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AlertRule {
pub metric: MetricKind,
pub threshold: f32,
pub comparison: Comparison,
pub horizon: usize,
pub level: AlertLevel,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Alert {
pub metric: MetricKind,
pub level: AlertLevel,
pub predicted_value: f32,
pub threshold: f32,
pub message: String,
pub confidence: f32,
}
pub struct AlertEngine {
rules: Vec<AlertRule>,
}
impl AlertEngine {
#[must_use]
pub fn with_default_rules() -> Self {
let mut rules = Vec::new();
for kind in MetricKind::all() {
rules.push(AlertRule {
metric: *kind,
threshold: kind.default_warning(),
comparison: if kind.higher_is_better() {
Comparison::LessThan
} else {
Comparison::GreaterThan
},
horizon: 5,
level: AlertLevel::Warning,
});
rules.push(AlertRule {
metric: *kind,
threshold: kind.default_critical(),
comparison: if kind.higher_is_better() {
Comparison::LessThan
} else {
Comparison::GreaterThan
},
horizon: 5,
level: AlertLevel::Critical,
});
}
Self { rules }
}
#[must_use]
pub const fn new() -> Self {
Self { rules: Vec::new() }
}
pub fn add_rule(&mut self, rule: AlertRule) {
self.rules.push(rule);
}
#[must_use]
pub fn rules(&self) -> &[AlertRule] {
&self.rules
}
#[must_use]
pub fn evaluate_rule(rule: &AlertRule, forecast: &Forecast) -> Option<Alert> {
Self::new().evaluate(rule, forecast)
}
#[must_use]
pub fn evaluate(&self, rule: &AlertRule, forecast: &Forecast) -> Option<Alert> {
let triggered = match rule.comparison {
Comparison::GreaterThan => forecast.predicted_value > rule.threshold,
Comparison::LessThan => forecast.predicted_value < rule.threshold,
};
if !triggered {
return None;
}
let direction = match rule.comparison {
Comparison::GreaterThan => "exceed",
Comparison::LessThan => "drops below",
};
let message = format!(
"{} predicted to {} {:.3} within {} samples (threshold: {:.3}, confidence: {:.2})",
rule.metric.as_str(),
direction,
forecast.predicted_value,
rule.horizon,
rule.threshold,
forecast.confidence,
);
Some(Alert {
metric: rule.metric,
level: rule.level,
predicted_value: forecast.predicted_value,
threshold: rule.threshold,
message,
confidence: forecast.confidence,
})
}
}
impl Default for AlertEngine {
fn default() -> Self {
Self::with_default_rules()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_forecast(value: f32, confidence: f32) -> Forecast {
Forecast {
predicted_value: value,
slope: 0.1,
ewma: value - 0.05,
confidence,
horizon: 5,
}
}
#[test]
fn alert_level_as_str() {
assert_eq!(AlertLevel::Info.as_str(), "info");
assert_eq!(AlertLevel::Warning.as_str(), "warning");
assert_eq!(AlertLevel::Critical.as_str(), "critical");
}
#[test]
fn alert_engine_default_has_rules() {
let engine = AlertEngine::with_default_rules();
assert_eq!(engine.rules().len(), 26);
}
#[test]
fn alert_engine_empty() {
let engine = AlertEngine::new();
assert_eq!(engine.rules().len(), 0);
}
#[test]
fn alert_engine_add_rule() {
let mut engine = AlertEngine::new();
engine.add_rule(AlertRule {
metric: MetricKind::CpuLoad,
threshold: 0.8,
comparison: Comparison::GreaterThan,
horizon: 3,
level: AlertLevel::Warning,
});
assert_eq!(engine.rules().len(), 1);
}
#[test]
fn alert_evaluate_greater_than_triggered() {
let engine = AlertEngine::new();
let rule = AlertRule {
metric: MetricKind::CpuLoad,
threshold: 0.7,
comparison: Comparison::GreaterThan,
horizon: 5,
level: AlertLevel::Warning,
};
let forecast = make_forecast(0.85, 0.9);
let alert = engine.evaluate(&rule, &forecast);
assert!(alert.is_some());
let alert = alert.unwrap();
assert_eq!(alert.metric, MetricKind::CpuLoad);
assert_eq!(alert.level, AlertLevel::Warning);
assert!(alert.message.contains("cpu_load"));
}
#[test]
fn alert_evaluate_greater_than_not_triggered() {
let engine = AlertEngine::new();
let rule = AlertRule {
metric: MetricKind::CpuLoad,
threshold: 0.9,
comparison: Comparison::GreaterThan,
horizon: 5,
level: AlertLevel::Critical,
};
let forecast = make_forecast(0.5, 0.9);
let alert = engine.evaluate(&rule, &forecast);
assert!(alert.is_none());
}
#[test]
fn alert_evaluate_less_than_triggered() {
let engine = AlertEngine::new();
let rule = AlertRule {
metric: MetricKind::Coherence,
threshold: 0.3,
comparison: Comparison::LessThan,
horizon: 5,
level: AlertLevel::Warning,
};
let forecast = make_forecast(0.15, 0.8);
let alert = engine.evaluate(&rule, &forecast);
assert!(alert.is_some());
let alert = alert.unwrap();
assert_eq!(alert.metric, MetricKind::Coherence);
assert!(alert.message.contains("coherence"));
assert!(alert.message.contains("drops below"));
}
#[test]
fn alert_evaluate_less_than_not_triggered() {
let engine = AlertEngine::new();
let rule = AlertRule {
metric: MetricKind::Coherence,
threshold: 0.3,
comparison: Comparison::LessThan,
horizon: 5,
level: AlertLevel::Warning,
};
let forecast = make_forecast(0.8, 0.9);
let alert = engine.evaluate(&rule, &forecast);
assert!(alert.is_none());
}
#[test]
fn alert_message_contains_direction() {
let engine = AlertEngine::new();
let rule = AlertRule {
metric: MetricKind::CpuLoad,
threshold: 0.7,
comparison: Comparison::GreaterThan,
horizon: 5,
level: AlertLevel::Critical,
};
let forecast = make_forecast(0.95, 0.9);
let alert = engine.evaluate(&rule, &forecast).unwrap();
assert!(alert.message.contains("exceed"));
}
#[test]
fn alert_serialization() {
let alert = Alert {
metric: MetricKind::CpuLoad,
level: AlertLevel::Critical,
predicted_value: 0.95,
threshold: 0.9,
message: "CPU load critical".to_string(),
confidence: 0.9,
};
let json = serde_json::to_string(&alert).unwrap();
let back: Alert = serde_json::from_str(&json).unwrap();
assert_eq!(back.metric, MetricKind::CpuLoad);
assert_eq!(back.level, AlertLevel::Critical);
assert!((back.predicted_value - 0.95).abs() < 0.001);
}
#[test]
fn alert_rule_serialization() {
let rule = AlertRule {
metric: MetricKind::ErrorRate,
threshold: 0.1,
comparison: Comparison::GreaterThan,
horizon: 3,
level: AlertLevel::Warning,
};
let json = serde_json::to_string(&rule).unwrap();
let back: AlertRule = serde_json::from_str(&json).unwrap();
assert_eq!(back.metric, MetricKind::ErrorRate);
assert_eq!(back.comparison, Comparison::GreaterThan);
}
#[test]
fn default_rules_cover_all_metrics() {
let engine = AlertEngine::with_default_rules();
for kind in MetricKind::all() {
let has_rule = engine.rules().iter().any(|r| r.metric == *kind);
assert!(has_rule, "No rule for {kind:?}");
}
}
#[test]
fn default_rules_coherence_uses_less_than() {
let engine = AlertEngine::with_default_rules();
let coherence_rules: Vec<_> = engine
.rules()
.iter()
.filter(|r| r.metric == MetricKind::Coherence)
.collect();
assert_eq!(coherence_rules.len(), 2);
assert!(
coherence_rules
.iter()
.all(|r| r.comparison == Comparison::LessThan)
);
}
#[test]
fn default_rules_cpu_load_uses_greater_than() {
let engine = AlertEngine::with_default_rules();
let cpu_rules: Vec<_> = engine
.rules()
.iter()
.filter(|r| r.metric == MetricKind::CpuLoad)
.collect();
assert_eq!(cpu_rules.len(), 2);
assert!(
cpu_rules
.iter()
.all(|r| r.comparison == Comparison::GreaterThan)
);
}
}