use serde::{Deserialize, Serialize};
use crate::model::{Criticality, Level};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum MatrixStrategy {
#[default]
Qualitative3x3,
Nasa8004_5x5,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub struct StrategyLevel {
pub ordinal: u8,
pub label: String,
}
impl StrategyLevel {
fn new(ordinal: u8, label: &str) -> Self {
Self {
ordinal,
label: label.to_string(),
}
}
}
impl MatrixStrategy {
pub fn id(self) -> &'static str {
match self {
MatrixStrategy::Qualitative3x3 => "qualitative3x3",
MatrixStrategy::Nasa8004_5x5 => "nasa8004_5x5",
}
}
pub fn level_count(self) -> u8 {
match self {
MatrixStrategy::Qualitative3x3 => 3,
MatrixStrategy::Nasa8004_5x5 => 5,
}
}
pub fn scale(self) -> Vec<StrategyLevel> {
match self {
MatrixStrategy::Qualitative3x3 => vec![
StrategyLevel::new(1, "low"),
StrategyLevel::new(2, "medium"),
StrategyLevel::new(3, "high"),
],
MatrixStrategy::Nasa8004_5x5 => vec![
StrategyLevel::new(1, "negligible"),
StrategyLevel::new(2, "marginal"),
StrategyLevel::new(3, "moderate"),
StrategyLevel::new(4, "critical"),
StrategyLevel::new(5, "catastrophic"),
],
}
}
pub fn is_valid_ordinal(self, ordinal: u8) -> bool {
ordinal >= 1 && ordinal <= self.level_count()
}
pub fn criticality(self, severity_ordinal: u8, probability_ordinal: u8) -> Criticality {
let s = severity_ordinal.clamp(1, self.level_count());
let p = probability_ordinal.clamp(1, self.level_count());
match self {
MatrixStrategy::Qualitative3x3 => {
crate::criticality::criticality(ordinal_to_level(s), ordinal_to_level(p))
}
MatrixStrategy::Nasa8004_5x5 => nasa_5x5_criticality(s, p),
}
}
}
fn ordinal_to_level(ordinal: u8) -> Level {
match ordinal {
1 => Level::Low,
2 => Level::Medium,
_ => Level::High,
}
}
fn nasa_5x5_criticality(s: u8, p: u8) -> Criticality {
let lo = s.min(p);
let hi = s.max(p);
let high = s + p >= 7;
let low = (lo == 1 && hi <= 3) || (s == 2 && p == 2);
if high {
Level::High
} else if low {
Level::Low
} else {
Level::Medium
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::Level::{High, Low, Medium};
#[test]
fn default_strategy_is_qualitative_3x3() {
assert_eq!(MatrixStrategy::default(), MatrixStrategy::Qualitative3x3);
}
#[test]
fn strategy_ids_are_stable_and_match_serde() {
assert_eq!(MatrixStrategy::Qualitative3x3.id(), "qualitative3x3");
assert_eq!(MatrixStrategy::Nasa8004_5x5.id(), "nasa8004_5x5");
let v = serde_json::to_value(MatrixStrategy::Nasa8004_5x5).unwrap();
assert_eq!(v, serde_json::json!("nasa8004_5x5"));
let back: MatrixStrategy = serde_json::from_value(v).unwrap();
assert_eq!(back, MatrixStrategy::Nasa8004_5x5);
}
#[test]
fn scales_have_the_right_arity_and_ascending_ordinals() {
let q = MatrixStrategy::Qualitative3x3.scale();
assert_eq!(q.len(), 3);
assert_eq!(MatrixStrategy::Qualitative3x3.level_count(), 3);
let n = MatrixStrategy::Nasa8004_5x5.scale();
assert_eq!(n.len(), 5);
assert_eq!(MatrixStrategy::Nasa8004_5x5.level_count(), 5);
for scale in [q, n] {
for (i, lvl) in scale.iter().enumerate() {
assert_eq!(
lvl.ordinal as usize,
i + 1,
"ordinals are 1-based ascending"
);
}
}
}
#[test]
fn qualitative_3x3_collapses_via_historic_table() {
for s in 1..=3u8 {
for p in 1..=3u8 {
let got = MatrixStrategy::Qualitative3x3.criticality(s, p);
let want =
crate::criticality::criticality(ordinal_to_level(s), ordinal_to_level(p));
assert_eq!(got, want, "3x3 ({s},{p}) must match historic table");
}
}
}
const NASA_TABLE: [[Criticality; 5]; 5] = [
[Low, Low, Low, Medium, Medium],
[Low, Low, Medium, Medium, High],
[Low, Medium, Medium, High, High],
[Medium, Medium, High, High, High],
[Medium, High, High, High, High],
];
#[test]
fn nasa_5x5_matches_golden_table_for_all_25_cells() {
let strat = MatrixStrategy::Nasa8004_5x5;
let mut seen = 0usize;
for c in 1..=5u8 {
for l in 1..=5u8 {
let want = NASA_TABLE[(c - 1) as usize][(l - 1) as usize];
let got = strat.criticality(c, l);
assert_eq!(got, want, "NASA 5x5 cell (C={c}, L={l}) must be {want:?}");
seen += 1;
}
}
assert_eq!(seen, 25, "all 25 NASA cells must be covered");
}
#[test]
fn nasa_5x5_corners_are_intuitive() {
let n = MatrixStrategy::Nasa8004_5x5;
assert_eq!(n.criticality(1, 1), Low, "least/least is Low");
assert_eq!(n.criticality(5, 5), High, "worst/worst is High");
assert_eq!(n.criticality(3, 3), Medium, "centre is Medium");
}
#[test]
fn out_of_range_ordinal_is_clamped_not_panicked() {
assert_eq!(MatrixStrategy::Nasa8004_5x5.criticality(0, 0), Low); assert_eq!(MatrixStrategy::Nasa8004_5x5.criticality(99, 99), High); assert_eq!(MatrixStrategy::Qualitative3x3.criticality(0, 99), Medium); }
#[test]
fn ordinal_validity_tracks_scale_arity() {
assert!(MatrixStrategy::Qualitative3x3.is_valid_ordinal(3));
assert!(!MatrixStrategy::Qualitative3x3.is_valid_ordinal(4));
assert!(MatrixStrategy::Nasa8004_5x5.is_valid_ordinal(5));
assert!(!MatrixStrategy::Nasa8004_5x5.is_valid_ordinal(6));
assert!(!MatrixStrategy::Nasa8004_5x5.is_valid_ordinal(0));
}
}