Skip to main content

anomstream_core/
severity.rs

1//! Ordinal severity bands derived from a raw anomaly score.
2//!
3//! A thin convenience layer over [`crate::AnomalyScore`] /
4//! [`crate::AnomalyGrade`] that maps a score into one of five
5//! ordinal bands (`Normal`, `Low`, `Medium`, `High`, `Critical`).
6//! Defaults match the eBPFsentinel Enterprise ml-detection
7//! thresholds (`2.0 / 3.0 / 4.0 / 5.0`), so downstream alert
8//! routing can share the same vocabulary the agent already uses.
9//!
10//! Lib stays policy-free — the bands are caller-supplied. The
11//! `Default` provides a sensible starting point for RCF scores
12//! derived from the crate's Guha-2016-style scoring convention.
13
14use alloc::format;
15
16use crate::domain::AnomalyScore;
17use crate::error::{RcfError, RcfResult};
18use crate::thresholded::AnomalyGrade;
19
20/// Ordinal severity label.
21///
22/// Ordered by increasing urgency; `PartialOrd` / `Ord` make it
23/// trivial to compare (`sev >= Severity::High`) for alert routing
24/// policies.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
26#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
27#[non_exhaustive]
28pub enum Severity {
29    /// Score below the `low` threshold — no alert.
30    Normal,
31    /// Score in `[low, medium)`.
32    Low,
33    /// Score in `[medium, high)`.
34    Medium,
35    /// Score in `[high, critical)`.
36    High,
37    /// Score at or above `critical`.
38    Critical,
39}
40
41impl Severity {
42    /// Caller-facing label, matching SOC dashboard conventions.
43    #[must_use]
44    pub fn label(&self) -> &'static str {
45        match self {
46            Severity::Normal => "normal",
47            Severity::Low => "low",
48            Severity::Medium => "medium",
49            Severity::High => "high",
50            Severity::Critical => "critical",
51        }
52    }
53}
54
55impl core::fmt::Display for Severity {
56    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
57        f.write_str(self.label())
58    }
59}
60
61/// Ascending thresholds defining the four transition points
62/// between the five severity bands.
63///
64/// Ordering invariant enforced at construction:
65/// `0 ≤ low < medium < high < critical`.
66#[derive(Debug, Clone, Copy, PartialEq)]
67#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
68pub struct SeverityBands {
69    /// `Normal → Low` transition.
70    pub low: f64,
71    /// `Low → Medium` transition.
72    pub medium: f64,
73    /// `Medium → High` transition.
74    pub high: f64,
75    /// `High → Critical` transition.
76    pub critical: f64,
77}
78
79impl Default for SeverityBands {
80    fn default() -> Self {
81        // eBPFsentinel Enterprise ml-detection defaults.
82        Self {
83            low: 2.0,
84            medium: 3.0,
85            high: 4.0,
86            critical: 5.0,
87        }
88    }
89}
90
91impl SeverityBands {
92    /// Build a validated set of bands.
93    ///
94    /// # Errors
95    ///
96    /// Returns [`RcfError::InvalidConfig`] when any threshold is
97    /// non-finite, when `low < 0`, or when the four thresholds are
98    /// not strictly ascending.
99    pub fn new(low: f64, medium: f64, high: f64, critical: f64) -> RcfResult<Self> {
100        let bands = Self {
101            low,
102            medium,
103            high,
104            critical,
105        };
106        bands.validate()?;
107        Ok(bands)
108    }
109
110    /// Validate the ordering invariant.
111    ///
112    /// # Errors
113    ///
114    /// Same as [`Self::new`].
115    pub fn validate(&self) -> RcfResult<()> {
116        for (name, value) in [
117            ("low", self.low),
118            ("medium", self.medium),
119            ("high", self.high),
120            ("critical", self.critical),
121        ] {
122            if !value.is_finite() {
123                return Err(RcfError::InvalidConfig(
124                    format!("SeverityBands::{name} must be finite, got {value}").into(),
125                ));
126            }
127        }
128        if self.low < 0.0 {
129            return Err(RcfError::InvalidConfig(
130                format!("SeverityBands::low must be >= 0, got {}", self.low).into(),
131            ));
132        }
133        if !(self.low < self.medium && self.medium < self.high && self.high < self.critical) {
134            return Err(RcfError::InvalidConfig(format!(
135                "SeverityBands must be strictly ascending: low={} medium={} high={} critical={}",
136                self.low, self.medium, self.high, self.critical
137            ).into()));
138        }
139        Ok(())
140    }
141
142    /// Classify a raw score into its severity band.
143    #[must_use]
144    pub fn classify(&self, score: f64) -> Severity {
145        if !score.is_finite() || score < self.low {
146            return Severity::Normal;
147        }
148        if score < self.medium {
149            return Severity::Low;
150        }
151        if score < self.high {
152            return Severity::Medium;
153        }
154        if score < self.critical {
155            return Severity::High;
156        }
157        Severity::Critical
158    }
159}
160
161impl AnomalyScore {
162    /// Classify this raw score into a [`Severity`] band using the
163    /// supplied thresholds.
164    #[must_use]
165    pub fn severity(&self, bands: &SeverityBands) -> Severity {
166        bands.classify(f64::from(*self))
167    }
168}
169
170impl AnomalyGrade {
171    /// Classify the graded verdict into a [`Severity`] band. Uses
172    /// the raw score, **not** the bounded `grade ∈ [0, 1]` —
173    /// severity bands live in raw-score space so a caller switching
174    /// between `ThresholdedForest` and the bare
175    /// [`crate::RandomCutForest`] gets consistent labels.
176    #[must_use]
177    pub fn severity(&self, bands: &SeverityBands) -> Severity {
178        self.score().severity(bands)
179    }
180}
181
182#[cfg(test)]
183#[allow(clippy::float_cmp)] // Tests assert exact band boundaries.
184mod tests {
185    use super::*;
186
187    #[test]
188    fn default_matches_ebpfsentinel_ml_detection() {
189        let b = SeverityBands::default();
190        assert_eq!(b.low, 2.0);
191        assert_eq!(b.medium, 3.0);
192        assert_eq!(b.high, 4.0);
193        assert_eq!(b.critical, 5.0);
194    }
195
196    #[test]
197    fn classify_routes_every_band() {
198        let b = SeverityBands::default();
199        assert_eq!(b.classify(0.0), Severity::Normal);
200        assert_eq!(b.classify(1.99), Severity::Normal);
201        assert_eq!(b.classify(2.0), Severity::Low);
202        assert_eq!(b.classify(2.99), Severity::Low);
203        assert_eq!(b.classify(3.0), Severity::Medium);
204        assert_eq!(b.classify(3.99), Severity::Medium);
205        assert_eq!(b.classify(4.0), Severity::High);
206        assert_eq!(b.classify(4.99), Severity::High);
207        assert_eq!(b.classify(5.0), Severity::Critical);
208        assert_eq!(b.classify(1_000.0), Severity::Critical);
209    }
210
211    #[test]
212    fn classify_handles_non_finite() {
213        let b = SeverityBands::default();
214        // Every non-finite input maps to `Normal` — safer default
215        // than forcing a Critical on NaN poisoning upstream.
216        assert_eq!(b.classify(f64::NAN), Severity::Normal);
217        assert_eq!(b.classify(f64::NEG_INFINITY), Severity::Normal);
218        assert_eq!(b.classify(f64::INFINITY), Severity::Normal);
219    }
220
221    #[test]
222    fn new_rejects_non_ascending() {
223        assert!(SeverityBands::new(3.0, 2.0, 4.0, 5.0).is_err());
224        assert!(SeverityBands::new(2.0, 2.0, 4.0, 5.0).is_err());
225        assert!(SeverityBands::new(2.0, 3.0, 4.0, 4.0).is_err());
226    }
227
228    #[test]
229    fn new_rejects_negative_low() {
230        assert!(SeverityBands::new(-0.1, 1.0, 2.0, 3.0).is_err());
231    }
232
233    #[test]
234    fn new_rejects_non_finite() {
235        assert!(SeverityBands::new(f64::NAN, 1.0, 2.0, 3.0).is_err());
236        assert!(SeverityBands::new(2.0, 3.0, f64::INFINITY, 5.0).is_err());
237    }
238
239    #[test]
240    fn severity_ordering_is_monotonic() {
241        assert!(Severity::Normal < Severity::Low);
242        assert!(Severity::Low < Severity::Medium);
243        assert!(Severity::Medium < Severity::High);
244        assert!(Severity::High < Severity::Critical);
245    }
246
247    #[test]
248    fn severity_labels_match_soc_vocab() {
249        assert_eq!(Severity::Normal.label(), "normal");
250        assert_eq!(Severity::Low.label(), "low");
251        assert_eq!(Severity::Medium.label(), "medium");
252        assert_eq!(Severity::High.label(), "high");
253        assert_eq!(Severity::Critical.label(), "critical");
254        assert_eq!(format!("{}", Severity::High), "high");
255    }
256
257    #[test]
258    fn anomaly_score_severity_routes_correctly() {
259        let b = SeverityBands::default();
260        let s = AnomalyScore::new(3.5).unwrap();
261        assert_eq!(s.severity(&b), Severity::Medium);
262    }
263
264    #[test]
265    fn anomaly_grade_severity_uses_raw_score() {
266        let b = SeverityBands::default();
267        let grade =
268            AnomalyGrade::new(AnomalyScore::new(6.0).unwrap(), 4.5, 1.0, true, true).unwrap();
269        assert_eq!(grade.severity(&b), Severity::Critical);
270    }
271
272    #[test]
273    fn custom_bands_work() {
274        let b = SeverityBands::new(0.5, 1.0, 2.0, 3.0).unwrap();
275        assert_eq!(b.classify(0.4), Severity::Normal);
276        assert_eq!(b.classify(0.5), Severity::Low);
277        assert_eq!(b.classify(1.5), Severity::Medium);
278        assert_eq!(b.classify(2.5), Severity::High);
279        assert_eq!(b.classify(10.0), Severity::Critical);
280    }
281}