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, Disposition, DispositionChange, FailureMode, FailureModeStanding, Issue, Level,
Mitigation, MitigationStatus, MitigationStatusChange, ResolutionEvidence, SessionMeta,
};
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 MitigationView {
#[serde(flatten)]
pub mitigation: Mitigation,
#[serde(default)]
pub status: MitigationStatus,
#[serde(default)]
pub evidence: Vec<ResolutionEvidence>,
#[serde(default)]
pub history: Vec<MitigationStatusChange>,
}
impl std::ops::Deref for MitigationView {
type Target = Mitigation;
fn deref(&self) -> &Mitigation {
&self.mitigation
}
}
impl MitigationView {
pub fn is_active(&self) -> bool {
self.status != MitigationStatus::Abandoned
}
}
#[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<MitigationView>,
#[serde(default)]
pub verified_residual_criticality: Option<Criticality>,
#[serde(default)]
pub disposition: Disposition,
#[serde(default)]
pub disposition_history: Vec<DispositionChange>,
}
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 fn active_mitigations(&self) -> impl Iterator<Item = &MitigationView> {
self.mitigations.iter().filter(|m| m.is_active())
}
pub fn is_mitigated(&self) -> bool {
self.active_mitigations().next().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,
#[serde(default)]
pub meta: SessionMeta,
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 meta: SessionMeta,
pub status_changes: Vec<MitigationStatusChange>,
pub disposition_changes: Vec<DispositionChange>,
}
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,
meta,
} => {
fold.session_id = session_id.clone();
fold.matrix_strategy = *matrix_strategy;
fold.meta = meta.clone();
}
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();
}
}
Event::MitigationStatusChanged { change } => {
fold.status_changes.push((**change).clone());
}
Event::FailureModeDispositioned { change } => {
fold.disposition_changes.push((**change).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()
}
fn mitigation_view(fold: &RawFold, mitigation: &Mitigation) -> MitigationView {
let history: Vec<MitigationStatusChange> = fold
.status_changes
.iter()
.filter(|c| c.mitigation_id == mitigation.id)
.cloned()
.collect();
MitigationView {
mitigation: mitigation.clone(),
status: history.last().map(|c| c.status).unwrap_or_default(),
evidence: history.iter().flat_map(|c| c.evidence.clone()).collect(),
history,
}
}
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)
}
}
fn mitigations_where(
views: &[MitigationView],
keep: impl Fn(&MitigationView) -> bool,
) -> Vec<&Mitigation> {
views
.iter()
.filter(|m| keep(m))
.map(|m| &m.mitigation)
.collect()
}
fn verified_view(fmv: &FailureModeView) -> FailureModeView {
let mut view = fmv.clone();
view.mitigations
.retain(|m| m.status == MitigationStatus::Verified);
let verified = mitigations_where(&view.mitigations, |_| true);
view.residual_criticality = fmv.verified_residual_criticality;
view.standing = standing_of(&verified, view.residual_criticality);
view
}
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 views: Vec<MitigationView> = mitigations_for(&fold, &fm.id)
.into_iter()
.map(|m| mitigation_view(&fold, m))
.collect();
let active = mitigations_where(&views, MitigationView::is_active);
let verified = mitigations_where(&views, |m| m.status == MitigationStatus::Verified);
let (sev_ordinal, prob_ordinal) = derived_ordinals(strategy, fm);
let raw = raw_criticality(strategy, sev_ordinal, prob_ordinal);
let residual = residual_criticality(strategy, raw, &active);
let verified_residual = residual_criticality(strategy, raw, &verified);
let standing = standing_of(&active, residual);
let response_class = response::response_class(raw, fm.domain, fm.scope);
let disposition_history: Vec<DispositionChange> = fold
.disposition_changes
.iter()
.filter(|c| c.failure_mode_id == fm.id)
.cloned()
.collect();
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: views,
verified_residual_criticality: verified_residual,
disposition: disposition_history
.last()
.map(|c| c.disposition)
.unwrap_or_default(),
disposition_history,
}
})
.collect();
let detection: DetectionOutput = detect::detect(&failure_modes);
let verified_views: Vec<FailureModeView> = failure_modes.iter().map(verified_view).collect();
let verified_detection = detect::detect(&verified_views);
let readiness = crate::readiness::assess(
&failure_modes,
&detection.issues,
&verified_views,
&verified_detection.issues,
);
let signals = crate::signal::compute_signals(&failure_modes, &detection);
FmecaState {
session_id: fold.session_id.clone(),
meta: fold.meta.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));
}
}