1use alloc::format;
15
16use crate::domain::AnomalyScore;
17use crate::error::{RcfError, RcfResult};
18use crate::thresholded::AnomalyGrade;
19
20#[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 Normal,
31 Low,
33 Medium,
35 High,
37 Critical,
39}
40
41impl Severity {
42 #[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#[derive(Debug, Clone, Copy, PartialEq)]
67#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
68pub struct SeverityBands {
69 pub low: f64,
71 pub medium: f64,
73 pub high: f64,
75 pub critical: f64,
77}
78
79impl Default for SeverityBands {
80 fn default() -> Self {
81 Self {
83 low: 2.0,
84 medium: 3.0,
85 high: 4.0,
86 critical: 5.0,
87 }
88 }
89}
90
91impl SeverityBands {
92 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 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 #[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 #[must_use]
165 pub fn severity(&self, bands: &SeverityBands) -> Severity {
166 bands.classify(f64::from(*self))
167 }
168}
169
170impl AnomalyGrade {
171 #[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)] mod 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 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}