use alloc::format;
use crate::domain::AnomalyScore;
use crate::error::{RcfError, RcfResult};
use crate::thresholded::AnomalyGrade;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[non_exhaustive]
pub enum Severity {
Normal,
Low,
Medium,
High,
Critical,
}
impl Severity {
#[must_use]
pub fn label(&self) -> &'static str {
match self {
Severity::Normal => "normal",
Severity::Low => "low",
Severity::Medium => "medium",
Severity::High => "high",
Severity::Critical => "critical",
}
}
}
impl core::fmt::Display for Severity {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.write_str(self.label())
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct SeverityBands {
pub low: f64,
pub medium: f64,
pub high: f64,
pub critical: f64,
}
impl Default for SeverityBands {
fn default() -> Self {
Self {
low: 2.0,
medium: 3.0,
high: 4.0,
critical: 5.0,
}
}
}
impl SeverityBands {
pub fn new(low: f64, medium: f64, high: f64, critical: f64) -> RcfResult<Self> {
let bands = Self {
low,
medium,
high,
critical,
};
bands.validate()?;
Ok(bands)
}
pub fn validate(&self) -> RcfResult<()> {
for (name, value) in [
("low", self.low),
("medium", self.medium),
("high", self.high),
("critical", self.critical),
] {
if !value.is_finite() {
return Err(RcfError::InvalidConfig(
format!("SeverityBands::{name} must be finite, got {value}").into(),
));
}
}
if self.low < 0.0 {
return Err(RcfError::InvalidConfig(
format!("SeverityBands::low must be >= 0, got {}", self.low).into(),
));
}
if !(self.low < self.medium && self.medium < self.high && self.high < self.critical) {
return Err(RcfError::InvalidConfig(format!(
"SeverityBands must be strictly ascending: low={} medium={} high={} critical={}",
self.low, self.medium, self.high, self.critical
).into()));
}
Ok(())
}
#[must_use]
pub fn classify(&self, score: f64) -> Severity {
if !score.is_finite() || score < self.low {
return Severity::Normal;
}
if score < self.medium {
return Severity::Low;
}
if score < self.high {
return Severity::Medium;
}
if score < self.critical {
return Severity::High;
}
Severity::Critical
}
}
impl AnomalyScore {
#[must_use]
pub fn severity(&self, bands: &SeverityBands) -> Severity {
bands.classify(f64::from(*self))
}
}
impl AnomalyGrade {
#[must_use]
pub fn severity(&self, bands: &SeverityBands) -> Severity {
self.score().severity(bands)
}
}
#[cfg(test)]
#[allow(clippy::float_cmp)] mod tests {
use super::*;
#[test]
fn default_matches_ebpfsentinel_ml_detection() {
let b = SeverityBands::default();
assert_eq!(b.low, 2.0);
assert_eq!(b.medium, 3.0);
assert_eq!(b.high, 4.0);
assert_eq!(b.critical, 5.0);
}
#[test]
fn classify_routes_every_band() {
let b = SeverityBands::default();
assert_eq!(b.classify(0.0), Severity::Normal);
assert_eq!(b.classify(1.99), Severity::Normal);
assert_eq!(b.classify(2.0), Severity::Low);
assert_eq!(b.classify(2.99), Severity::Low);
assert_eq!(b.classify(3.0), Severity::Medium);
assert_eq!(b.classify(3.99), Severity::Medium);
assert_eq!(b.classify(4.0), Severity::High);
assert_eq!(b.classify(4.99), Severity::High);
assert_eq!(b.classify(5.0), Severity::Critical);
assert_eq!(b.classify(1_000.0), Severity::Critical);
}
#[test]
fn classify_handles_non_finite() {
let b = SeverityBands::default();
assert_eq!(b.classify(f64::NAN), Severity::Normal);
assert_eq!(b.classify(f64::NEG_INFINITY), Severity::Normal);
assert_eq!(b.classify(f64::INFINITY), Severity::Normal);
}
#[test]
fn new_rejects_non_ascending() {
assert!(SeverityBands::new(3.0, 2.0, 4.0, 5.0).is_err());
assert!(SeverityBands::new(2.0, 2.0, 4.0, 5.0).is_err());
assert!(SeverityBands::new(2.0, 3.0, 4.0, 4.0).is_err());
}
#[test]
fn new_rejects_negative_low() {
assert!(SeverityBands::new(-0.1, 1.0, 2.0, 3.0).is_err());
}
#[test]
fn new_rejects_non_finite() {
assert!(SeverityBands::new(f64::NAN, 1.0, 2.0, 3.0).is_err());
assert!(SeverityBands::new(2.0, 3.0, f64::INFINITY, 5.0).is_err());
}
#[test]
fn severity_ordering_is_monotonic() {
assert!(Severity::Normal < Severity::Low);
assert!(Severity::Low < Severity::Medium);
assert!(Severity::Medium < Severity::High);
assert!(Severity::High < Severity::Critical);
}
#[test]
fn severity_labels_match_soc_vocab() {
assert_eq!(Severity::Normal.label(), "normal");
assert_eq!(Severity::Low.label(), "low");
assert_eq!(Severity::Medium.label(), "medium");
assert_eq!(Severity::High.label(), "high");
assert_eq!(Severity::Critical.label(), "critical");
assert_eq!(format!("{}", Severity::High), "high");
}
#[test]
fn anomaly_score_severity_routes_correctly() {
let b = SeverityBands::default();
let s = AnomalyScore::new(3.5).unwrap();
assert_eq!(s.severity(&b), Severity::Medium);
}
#[test]
fn anomaly_grade_severity_uses_raw_score() {
let b = SeverityBands::default();
let grade =
AnomalyGrade::new(AnomalyScore::new(6.0).unwrap(), 4.5, 1.0, true, true).unwrap();
assert_eq!(grade.severity(&b), Severity::Critical);
}
#[test]
fn custom_bands_work() {
let b = SeverityBands::new(0.5, 1.0, 2.0, 3.0).unwrap();
assert_eq!(b.classify(0.4), Severity::Normal);
assert_eq!(b.classify(0.5), Severity::Low);
assert_eq!(b.classify(1.5), Severity::Medium);
assert_eq!(b.classify(2.5), Severity::High);
assert_eq!(b.classify(10.0), Severity::Critical);
}
}