use std::collections::BTreeSet;
use serde::{Deserialize, Serialize};
use crate::detect::{self, DetectionOutput};
use crate::event::Event;
use crate::matrix::MatrixStrategy;
use crate::model::{Criticality, FailureMode, FailureModeStanding, Issue, Level, Mitigation};
use crate::readiness::ReadinessReport;
use crate::response::{self, ResponseClass};
use crate::scoring::{self, Axis};
use crate::signal::Signal;
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Registry {
pub component_ids: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FailureModeView {
#[serde(flatten)]
pub failure_mode: FailureMode,
pub criticality: Option<Criticality>,
pub residual_criticality: Option<Criticality>,
pub standing: Option<FailureModeStanding>,
pub response_class: Option<ResponseClass>,
pub derived_severity: Option<crate::matrix::StrategyLevel>,
pub derived_probability: Option<crate::matrix::StrategyLevel>,
pub mitigations: Vec<Mitigation>,
}
impl FailureModeView {
pub fn is_scored(&self) -> bool {
self.failure_mode.cause.is_some()
&& self.failure_mode.effect.is_some()
&& self.derived_severity.is_some()
&& self.derived_probability.is_some()
}
}
pub(crate) fn derived_ordinals(
strategy: MatrixStrategy,
fm: &FailureMode,
) -> (Option<u8>, Option<u8>) {
let severity = scoring::derive_ordinal(strategy, Axis::Severity, &fm.severity_observations)
.ok()
.flatten();
let probability =
scoring::derive_ordinal(strategy, Axis::Probability, &fm.probability_observations)
.ok()
.flatten();
(severity, probability)
}
fn strategy_level(strategy: MatrixStrategy, ordinal: u8) -> Option<crate::matrix::StrategyLevel> {
strategy.scale().into_iter().find(|l| l.ordinal == ordinal)
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FmecaState {
pub session_id: String,
pub matrix_strategy: MatrixStrategy,
pub matrix_scale: Vec<crate::matrix::StrategyLevel>,
pub failure_modes: Vec<FailureModeView>,
pub issues: Vec<Issue>,
pub signals: Vec<Signal>,
pub registry: Registry,
pub readiness: ReadinessReport,
pub scoring_catalog: Vec<crate::scoring::ScoreCriterion>,
}
#[derive(Debug, Default)]
pub(crate) struct RawFold {
pub session_id: String,
pub matrix_strategy: MatrixStrategy,
pub failure_modes: Vec<FailureMode>,
pub mitigations: Vec<Mitigation>,
}
pub(crate) fn raw_fold(events: &[Event]) -> RawFold {
let mut fold = RawFold::default();
for event in events {
match event {
Event::SessionOpened {
session_id,
matrix_strategy,
} => {
fold.session_id = session_id.clone();
fold.matrix_strategy = *matrix_strategy;
}
Event::FailureModeAdded { failure_mode } => {
fold.failure_modes.push((**failure_mode).clone());
}
Event::MitigationAdded { mitigation } => {
fold.mitigations.push((**mitigation).clone());
}
Event::Rescored { rescore } => {
if let Some(fm) = fold
.failure_modes
.iter_mut()
.find(|fm| fm.id == rescore.failure_mode_id)
{
fm.severity_observations = rescore.severity_observations.clone();
fm.probability_observations = rescore.probability_observations.clone();
}
}
}
}
fold
}
fn build_registry(fold: &RawFold) -> Registry {
let ids: BTreeSet<String> = fold
.failure_modes
.iter()
.map(|fm| fm.component.id.clone())
.collect();
Registry {
component_ids: ids.into_iter().collect(),
}
}
pub(crate) fn mitigations_for<'a>(fold: &'a RawFold, fm_id: &str) -> Vec<&'a Mitigation> {
fold.mitigations
.iter()
.filter(|m| m.failure_mode_id == fm_id)
.collect()
}
pub(crate) fn raw_criticality(
strategy: MatrixStrategy,
severity: Option<u8>,
probability: Option<u8>,
) -> Option<Criticality> {
match (severity, probability) {
(Some(s), Some(p)) => Some(strategy.criticality(s, p)),
_ => None,
}
}
pub(crate) fn mitigation_residual(
strategy: MatrixStrategy,
mitigation: &Mitigation,
) -> Option<Criticality> {
let sev = scoring::derive_ordinal(
strategy,
Axis::Severity,
&mitigation.residual_severity_observations,
)
.ok()
.flatten()?;
let prob = scoring::derive_ordinal(
strategy,
Axis::Probability,
&mitigation.residual_probability_observations,
)
.ok()
.flatten()?;
Some(strategy.criticality(sev, prob))
}
pub(crate) fn residual_criticality(
strategy: MatrixStrategy,
raw: Option<Criticality>,
mitigations: &[&Mitigation],
) -> Option<Criticality> {
if mitigations.is_empty() {
return raw;
}
mitigations
.iter()
.filter_map(|m| mitigation_residual(strategy, m))
.min_by_key(|c| c.rank())
.or(raw)
}
fn standing_of(
mitigations: &[&Mitigation],
residual: Option<Criticality>,
) -> Option<FailureModeStanding> {
let residual = residual?;
if residual == Level::Low {
return Some(FailureModeStanding::Acceptable);
}
if mitigations.is_empty() {
Some(FailureModeStanding::Unmitigated)
} else {
Some(FailureModeStanding::UnderMitigated)
}
}
pub fn project(events: &[Event]) -> FmecaState {
let fold = raw_fold(events);
let registry = build_registry(&fold);
let strategy = fold.matrix_strategy;
let failure_modes: Vec<FailureModeView> = fold
.failure_modes
.iter()
.map(|fm| {
let mits = mitigations_for(&fold, &fm.id);
let (sev_ordinal, prob_ordinal) = derived_ordinals(strategy, fm);
let raw = raw_criticality(strategy, sev_ordinal, prob_ordinal);
let residual = residual_criticality(strategy, raw, &mits);
let standing = standing_of(&mits, residual);
let response_class = response::response_class(raw, fm.domain, fm.scope);
FailureModeView {
failure_mode: fm.clone(),
criticality: raw,
residual_criticality: residual,
standing,
response_class,
derived_severity: sev_ordinal.and_then(|o| strategy_level(strategy, o)),
derived_probability: prob_ordinal.and_then(|o| strategy_level(strategy, o)),
mitigations: mits.into_iter().cloned().collect(),
}
})
.collect();
let detection: DetectionOutput = detect::detect(&failure_modes);
let readiness = crate::readiness::assess(&failure_modes, &detection.issues);
let signals = crate::signal::compute_signals(&failure_modes, &detection);
FmecaState {
session_id: fold.session_id.clone(),
matrix_strategy: strategy,
matrix_scale: strategy.scale(),
failure_modes,
issues: detection.issues,
signals,
registry,
readiness,
scoring_catalog: scoring::catalog_for(strategy),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::Level::{High, Low, Medium};
use crate::model::{EvidenceRef, MitigationKind};
fn mit(residual_sev: Vec<&str>, residual_prob: Vec<&str>) -> Mitigation {
Mitigation {
id: "m".to_string(),
session_id: "s".to_string(),
failure_mode_id: "fm".to_string(),
kind: MitigationKind::Prevention,
description: "m".to_string(),
residual_severity_observations: residual_sev.into_iter().map(String::from).collect(),
residual_probability_observations: residual_prob
.into_iter()
.map(String::from)
.collect(),
source: EvidenceRef::new("t"),
}
}
#[test]
fn residual_observations_derive_residual_criticality_3x3() {
let q = MatrixStrategy::Qualitative3x3;
assert_eq!(
mitigation_residual(q, &mit(vec!["cosmetic"], vec!["rare_edge_case"])),
Some(Low)
);
assert_eq!(
mitigation_residual(q, &mit(vec!["data_loss"], vec!["happens_in_normal_use"])),
Some(High)
);
assert_eq!(
mitigation_residual(q, &mit(vec!["user_facing_degradation"], vec!["occasional"])),
Some(Medium)
);
}
#[test]
fn residual_observations_max_combine() {
let q = MatrixStrategy::Qualitative3x3;
assert_eq!(
mitigation_residual(
q,
&mit(vec!["cosmetic", "data_loss"], vec!["rare_edge_case"])
),
Some(Medium)
);
}
#[test]
fn residual_empty_axis_yields_none() {
let q = MatrixStrategy::Qualitative3x3;
assert_eq!(
mitigation_residual(q, &mit(vec![], vec!["rare_edge_case"])),
None
);
assert_eq!(mitigation_residual(q, &mit(vec![], vec![])), None);
}
#[test]
fn residual_unknown_id_does_not_panic_yields_none_in_projection() {
let q = MatrixStrategy::Qualitative3x3;
assert_eq!(
mitigation_residual(q, &mit(vec!["not_a_real_id"], vec!["rare_edge_case"])),
None
);
}
#[test]
fn residual_cross_strategy_id_yields_none_in_projection() {
let q = MatrixStrategy::Qualitative3x3;
assert_eq!(
mitigation_residual(
q,
&mit(vec!["loss_of_life_or_mission"], vec!["near_certain"])
),
None
);
}
#[test]
fn residual_nasa_5x5_uses_same_derivation_and_matrix() {
let n = MatrixStrategy::Nasa8004_5x5;
assert_eq!(
mitigation_residual(
n,
&mit(vec!["loss_of_life_or_mission"], vec!["near_certain"])
),
Some(High)
);
assert_eq!(
mitigation_residual(n, &mit(vec!["negligible_impact"], vec!["improbable"])),
Some(Low)
);
assert_eq!(
mitigation_residual(n, &mit(vec!["degraded_capability"], vec!["occasional"])),
Some(Medium)
);
}
#[test]
fn best_residual_wins_across_mitigations() {
let q = MatrixStrategy::Qualitative3x3;
let weak = mit(vec!["data_loss"], vec!["happens_in_normal_use"]); let strong = mit(vec!["cosmetic"], vec!["rare_edge_case"]); let mits = vec![&weak, &strong];
assert_eq!(
residual_criticality(q, Some(High), &mits),
Some(Low),
"best (lowest) residual wins"
);
}
#[test]
fn unscored_residual_mitigation_falls_back_to_raw() {
let q = MatrixStrategy::Qualitative3x3;
let unscored = mit(vec![], vec![]);
let mits = vec![&unscored];
assert_eq!(residual_criticality(q, Some(High), &mits), Some(High));
}
}