use serde::{Deserialize, Serialize};
use crate::error::{FmecaError, Result};
use crate::matrix::MatrixStrategy;
use crate::model::Level;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Axis {
Severity,
Probability,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ScoreCriterion {
pub id: String,
pub axis: Axis,
pub level_ordinal: u8,
pub level: String,
pub description: String,
}
impl ScoreCriterion {
fn new(id: &str, axis: Axis, level_ordinal: u8, level: &str, description: &str) -> Self {
Self {
id: id.to_string(),
axis,
level_ordinal,
level: level.to_string(),
description: description.to_string(),
}
}
}
pub fn catalog() -> Vec<ScoreCriterion> {
catalog_3x3()
}
pub fn catalog_for(strategy: MatrixStrategy) -> Vec<ScoreCriterion> {
match strategy {
MatrixStrategy::Qualitative3x3 => catalog_3x3(),
MatrixStrategy::Nasa8004_5x5 => catalog_nasa_5x5(),
}
}
fn catalog_3x3() -> Vec<ScoreCriterion> {
use Axis::{Probability, Severity};
vec![
ScoreCriterion::new(
"data_loss",
Severity,
3,
"high",
"Permanent loss or corruption of user/system data.",
),
ScoreCriterion::new(
"security_breach",
Severity,
3,
"high",
"Confidentiality, integrity, or access-control compromise.",
),
ScoreCriterion::new(
"service_outage",
Severity,
3,
"high",
"The component or a dependent becomes unavailable.",
),
ScoreCriterion::new(
"user_facing_degradation",
Severity,
2,
"medium",
"Users see degraded behaviour but can still complete their task.",
),
ScoreCriterion::new(
"recoverable_error",
Severity,
2,
"medium",
"An error that the system or user can recover from with effort.",
),
ScoreCriterion::new(
"cosmetic",
Severity,
1,
"low",
"Cosmetic/non-functional issue with no impact on the task.",
),
ScoreCriterion::new(
"happens_in_normal_use",
Probability,
3,
"high",
"Triggered by ordinary, expected usage.",
),
ScoreCriterion::new(
"known_recurring",
Probability,
3,
"high",
"Already observed to recur in practice.",
),
ScoreCriterion::new(
"occasional",
Probability,
2,
"medium",
"Happens intermittently under common-but-not-constant conditions.",
),
ScoreCriterion::new(
"load_or_concurrency_dependent",
Probability,
2,
"medium",
"Surfaces under load, contention, or specific timing.",
),
ScoreCriterion::new(
"rare_edge_case",
Probability,
1,
"low",
"Only under an uncommon, narrow edge case.",
),
ScoreCriterion::new(
"requires_misuse",
Probability,
1,
"low",
"Only via deliberate misuse or an unsupported configuration.",
),
]
}
fn catalog_nasa_5x5() -> Vec<ScoreCriterion> {
use Axis::{Probability, Severity};
vec![
ScoreCriterion::new(
"loss_of_life_or_mission",
Severity,
5,
"catastrophic",
"Loss of life, total mission loss, or unrecoverable system destruction.",
),
ScoreCriterion::new(
"permanent_data_or_asset_loss",
Severity,
5,
"catastrophic",
"Permanent, unrecoverable loss of critical data or a major asset.",
),
ScoreCriterion::new(
"major_system_damage",
Severity,
4,
"critical",
"Severe but recoverable damage; major capability lost for an extended period.",
),
ScoreCriterion::new(
"extended_outage",
Severity,
4,
"critical",
"Prolonged outage of a primary capability requiring significant recovery.",
),
ScoreCriterion::new(
"degraded_capability",
Severity,
3,
"moderate",
"Partial loss of capability; mission/task continues with reduced margin.",
),
ScoreCriterion::new(
"recoverable_disruption",
Severity,
3,
"moderate",
"Disruption that is recoverable with planned effort.",
),
ScoreCriterion::new(
"minor_impact",
Severity,
2,
"marginal",
"Minor impact; workaround available, little effect on the objective.",
),
ScoreCriterion::new(
"negligible_impact",
Severity,
1,
"negligible",
"Negligible effect; no meaningful impact on the objective.",
),
ScoreCriterion::new(
"near_certain",
Probability,
5,
"near_certain",
"Expected to occur (near-certain) under normal conditions.",
),
ScoreCriterion::new(
"frequent",
Probability,
5,
"near_certain",
"Occurs frequently / repeatedly in normal operation.",
),
ScoreCriterion::new(
"probable",
Probability,
4,
"critical",
"Will probably occur several times over the life of the system.",
),
ScoreCriterion::new(
"occasional",
Probability,
3,
"moderate",
"Likely to occur sometime in the life of the system.",
),
ScoreCriterion::new(
"remote",
Probability,
2,
"marginal",
"Unlikely but possible to occur in the life of the system.",
),
ScoreCriterion::new(
"improbable",
Probability,
1,
"negligible",
"So unlikely it can be assumed occurrence may not be experienced.",
),
]
}
fn find_criterion(strategy: MatrixStrategy, axis: Axis, id: &str) -> Option<ScoreCriterion> {
catalog_for(strategy)
.into_iter()
.find(|c| c.axis == axis && c.id == id)
}
pub fn derive_ordinal(
strategy: MatrixStrategy,
axis: Axis,
observed_ids: &[String],
) -> Result<Option<u8>> {
let mut best: Option<u8> = None;
for id in observed_ids {
let criterion = find_criterion(strategy, axis, id).ok_or_else(|| {
FmecaError::InvalidObservation(format!(
"unknown {} observation id '{id}' for strategy '{}'",
axis_label(axis),
strategy.id()
))
})?;
best = Some(match best {
Some(prev) if prev >= criterion.level_ordinal => prev,
_ => criterion.level_ordinal,
});
}
Ok(best)
}
pub fn derive_level(axis: Axis, observed_ids: &[String]) -> Result<Option<Level>> {
let ordinal = derive_ordinal(MatrixStrategy::Qualitative3x3, axis, observed_ids)?;
Ok(ordinal.map(ordinal_to_level_3x3))
}
fn ordinal_to_level_3x3(ordinal: u8) -> Level {
match ordinal {
1 => Level::Low,
2 => Level::Medium,
_ => Level::High,
}
}
fn axis_label(axis: Axis) -> &'static str {
match axis {
Axis::Severity => "severity",
Axis::Probability => "probability",
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::Level::{High, Low, Medium};
#[test]
fn empty_observations_yield_none() {
assert_eq!(derive_level(Axis::Severity, &[]).unwrap(), None);
assert_eq!(derive_level(Axis::Probability, &[]).unwrap(), None);
}
#[test]
fn single_known_observation_maps_to_its_level() {
assert_eq!(
derive_level(Axis::Severity, &["data_loss".into()]).unwrap(),
Some(High)
);
assert_eq!(
derive_level(Axis::Severity, &["user_facing_degradation".into()]).unwrap(),
Some(Medium)
);
assert_eq!(
derive_level(Axis::Severity, &["cosmetic".into()]).unwrap(),
Some(Low)
);
assert_eq!(
derive_level(Axis::Probability, &["happens_in_normal_use".into()]).unwrap(),
Some(High)
);
assert_eq!(
derive_level(Axis::Probability, &["rare_edge_case".into()]).unwrap(),
Some(Low)
);
}
#[test]
fn multiple_observations_combine_with_max() {
assert_eq!(
derive_level(Axis::Severity, &["cosmetic".into(), "data_loss".into()]).unwrap(),
Some(High)
);
assert_eq!(
derive_level(
Axis::Probability,
&["rare_edge_case".into(), "occasional".into()]
)
.unwrap(),
Some(Medium)
);
assert_eq!(
derive_level(Axis::Severity, &["data_loss".into(), "cosmetic".into()]).unwrap(),
Some(High)
);
}
#[test]
fn unknown_observation_is_error() {
let err = derive_level(Axis::Severity, &["not_a_real_id".into()]).unwrap_err();
assert!(matches!(err, FmecaError::InvalidObservation(_)));
assert!(err.to_string().starts_with("INVALID_OBSERVATION:"));
}
#[test]
fn cross_axis_id_does_not_match() {
let err = derive_level(Axis::Severity, &["happens_in_normal_use".into()]).unwrap_err();
assert!(matches!(err, FmecaError::InvalidObservation(_)));
}
#[test]
fn catalog_ids_are_unique_per_axis() {
for strat in [MatrixStrategy::Qualitative3x3, MatrixStrategy::Nasa8004_5x5] {
let mut seen = std::collections::BTreeSet::new();
for c in catalog_for(strat) {
assert!(
seen.insert((c.axis, c.id.clone())),
"duplicate catalog id {:?} on {:?} in {:?}",
c.id,
c.axis,
strat
);
}
}
}
#[test]
fn catalog_levels_are_within_strategy_scale() {
for strat in [MatrixStrategy::Qualitative3x3, MatrixStrategy::Nasa8004_5x5] {
for c in catalog_for(strat) {
assert!(
strat.is_valid_ordinal(c.level_ordinal),
"{:?} criterion {:?} ordinal {} out of scale",
strat,
c.id,
c.level_ordinal
);
}
}
}
#[test]
fn nasa_observations_derive_to_5_level_ordinals() {
let n = MatrixStrategy::Nasa8004_5x5;
assert_eq!(
derive_ordinal(n, Axis::Severity, &["loss_of_life_or_mission".into()]).unwrap(),
Some(5)
);
assert_eq!(
derive_ordinal(n, Axis::Severity, &["degraded_capability".into()]).unwrap(),
Some(3)
);
assert_eq!(
derive_ordinal(n, Axis::Severity, &["negligible_impact".into()]).unwrap(),
Some(1)
);
assert_eq!(
derive_ordinal(n, Axis::Probability, &["near_certain".into()]).unwrap(),
Some(5)
);
assert_eq!(
derive_ordinal(n, Axis::Probability, &["improbable".into()]).unwrap(),
Some(1)
);
}
#[test]
fn nasa_max_combine_and_empty_and_unknown() {
let n = MatrixStrategy::Nasa8004_5x5;
assert_eq!(
derive_ordinal(
n,
Axis::Severity,
&["minor_impact".into(), "major_system_damage".into()]
)
.unwrap(),
Some(4)
);
assert_eq!(derive_ordinal(n, Axis::Severity, &[]).unwrap(), None);
let err = derive_ordinal(n, Axis::Severity, &["data_loss".into()]).unwrap_err();
assert!(matches!(err, FmecaError::InvalidObservation(_)));
}
#[test]
fn cross_strategy_ids_are_isolated() {
let n = MatrixStrategy::Nasa8004_5x5;
let q = MatrixStrategy::Qualitative3x3;
assert!(derive_ordinal(n, Axis::Severity, &["data_loss".into()]).is_err());
assert!(derive_ordinal(q, Axis::Severity, &["loss_of_life_or_mission".into()]).is_err());
let err = derive_level(Axis::Severity, &["loss_of_life_or_mission".into()]).unwrap_err();
assert!(matches!(err, FmecaError::InvalidObservation(_)));
}
}