fmeca 0.2.0

Deterministic, offline kernel for structured FMECA (Failure Modes, Effects & Criticality Analysis): an event-sourced failure-mode ledger where criticality is a fixed qualitative S×P matrix, residual risk and standing are computed, the prevent→detect→fail-fast mitigation discipline is enforced, and a readiness gate reports whether every High/Medium residual has been reduced to Low.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
//! The [`FmecaState`] fold: replay the append-only log into a live
//! snapshot with **computed** criticality, residual risk, and standing.
//!
//! Nothing computed is ever stored — criticality, residual, and standing are
//! pure functions of the events. The fold happens in passes:
//!  1. Collect failure modes (applying re-scores), grouped mitigations, and the
//!     registry of component ids.
//!  2. Compute each failure mode's raw criticality, best residual criticality,
//!     and discrete [`FailureModeStanding`].
//!  3. Run the detectors (`crate::detect`) to produce issues + signals, then
//!     the readiness gate ([`crate::readiness`]).
//!
//! Residual rule: the **best (lowest-criticality)** residual among a
//! failure mode's mitigations; with no mitigation the residual is the raw S/P
//! criticality.
//!
//! Abandoned mitigations (spec §6.3) are left out of residual and standing,
//! and so out of every detector that asks "is this failure mode mitigated?".
//! The verified residual and the verified gate use `Verified` mitigations only.
//!
//! Standing rules:
//!  - `acceptable`     — residual criticality is `Low`.
//!  - `under_mitigated`— has ≥1 mitigation but residual criticality is High/Medium.
//!  - `unmitigated`    — has no mitigation and raw criticality is High/Medium.

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;

/// The session registry: the component ids seen so far, so callers
/// reuse stable identity instead of re-declaring it.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Registry {
    pub component_ids: Vec<String>,
}

/// A mitigation with its computed resolution status. Derefs to the
/// [`Mitigation`] it wraps, so `view.kind` / `view.description` read through.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MitigationView {
    #[serde(flatten)]
    pub mitigation: Mitigation,
    /// The last `MitigationStatusChanged` status, or `Planned`.
    #[serde(default)]
    pub status: MitigationStatus,
    /// Evidence accumulated across every status change, in order.
    #[serde(default)]
    pub evidence: Vec<ResolutionEvidence>,
    /// Every status change, in append order.
    #[serde(default)]
    pub history: Vec<MitigationStatusChange>,
}

impl std::ops::Deref for MitigationView {
    type Target = Mitigation;

    fn deref(&self) -> &Mitigation {
        &self.mitigation
    }
}

impl MitigationView {
    /// True unless the mitigation was abandoned.
    pub fn is_active(&self) -> bool {
        self.status != MitigationStatus::Abandoned
    }
}

/// A failure mode paired with its computed criticality, residual, standing, and
/// the mitigations applied to it.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FailureModeView {
    #[serde(flatten)]
    pub failure_mode: FailureMode,
    /// Raw criticality from the unmitigated S/P, or `None` if not yet scored.
    pub criticality: Option<Criticality>,
    /// Best (lowest) residual criticality across mitigations, or the raw
    /// criticality when no mitigation exists. `None` if not yet scored *and*
    /// unmitigated.
    pub residual_criticality: Option<Criticality>,
    /// Computed standing. `None` until the failure mode is scored.
    pub standing: Option<FailureModeStanding>,
    /// Deterministically-derived remediation magnitude.
    /// `None` until the failure mode is scored.
    pub response_class: Option<ResponseClass>,
    /// Code-derived unmitigated severity, as a level on the SESSION'S strategy
    /// scale (note: from the `severity_observations`, never supplied
    /// by the model). `None` if no severity observation was given. Under the
    /// default 3×3 strategy this is one of low/medium/high; under NASA 5×5 it is
    /// one of the 5 levels.
    pub derived_severity: Option<crate::matrix::StrategyLevel>,
    /// Code-derived unmitigated probability on the session's strategy scale
    ///. `None` if no probability observation was given.
    pub derived_probability: Option<crate::matrix::StrategyLevel>,
    /// Mitigations applied to this failure mode, in append order, with their
    /// resolution status.
    pub mitigations: Vec<MitigationView>,
    /// Best residual among `Verified` mitigations, else the raw criticality.
    #[serde(default)]
    pub verified_residual_criticality: Option<Criticality>,
    /// The last `FailureModeDispositioned` disposition, or `Open`.
    #[serde(default)]
    pub disposition: Disposition,
    /// Every disposition change, in append order.
    #[serde(default)]
    pub disposition_history: Vec<DispositionChange>,
}

impl FailureModeView {
    /// True when the failure mode has cause, effect, and a derived
    /// severity+probability — i.e. it can be analyzed (`clarify`).
    /// Severity/probability now come from observations.
    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()
    }

    /// Mitigations that still count toward residual and standing (not
    /// `Abandoned`), in append order.
    pub fn active_mitigations(&self) -> impl Iterator<Item = &MitigationView> {
        self.mitigations.iter().filter(|m| m.is_active())
    }

    /// True when the failure mode has at least one non-abandoned mitigation.
    pub fn is_mitigated(&self) -> bool {
        self.active_mitigations().next().is_some()
    }
}

/// Derive the unmitigated (severity, probability) ordinals from a failure mode's
/// observations under the SESSION'S strategy. Observations are
/// validated at write time against the session's strategy, so an unexpected
/// unknown id here yields `None` rather than panicking — replay must never panic
/// on persisted data.
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)
}

/// Resolve an ordinal to the strategy's [`StrategyLevel`] (ordinal + label) for
/// surfacing in the projection. An out-of-scale ordinal (impossible on validated
/// data) yields `None` rather than panicking.
fn strategy_level(strategy: MatrixStrategy, ordinal: u8) -> Option<crate::matrix::StrategyLevel> {
    strategy.scale().into_iter().find(|l| l.ordinal == ordinal)
}

/// The full computed projection of a session (`FmecaState`).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FmecaState {
    pub session_id: String,
    /// Analysis metadata recorded on `SessionOpened` (default for old logs).
    #[serde(default)]
    pub meta: SessionMeta,
    /// The criticality-matrix strategy selected when this session was opened
    ///. Fixed for the life of the session; recorded in `SessionOpened`.
    pub matrix_strategy: MatrixStrategy,
    /// The active strategy's ordered scale (ordinal + label), so the caller knows
    /// how many levels its observations resolve to.
    pub matrix_scale: Vec<crate::matrix::StrategyLevel>,
    /// Live failure modes with their computed criticality/residual/standing, in
    /// insertion order.
    pub failure_modes: Vec<FailureModeView>,
    pub issues: Vec<Issue>,
    pub signals: Vec<Signal>,
    pub registry: Registry,
    pub readiness: ReadinessReport,
    /// The ACTIVE strategy's fixed scoring-criteria catalog,
    /// surfaced so the caller knows the exact observation vocabulary for
    /// `severity_observations` / `probability_observations` under this session's
    /// strategy.
    pub scoring_catalog: Vec<crate::scoring::ScoreCriterion>,
}

/// Intermediate raw fold of the log, before criticality/standing are computed.
#[derive(Debug, Default)]
pub(crate) struct RawFold {
    pub session_id: String,
    /// The strategy recorded on `SessionOpened`; default 3×3.
    pub matrix_strategy: MatrixStrategy,
    /// Failure modes in insertion order (re-scores applied in place).
    pub failure_modes: Vec<FailureMode>,
    /// Mitigations in append order.
    pub mitigations: Vec<Mitigation>,
    /// Analysis metadata from `SessionOpened`.
    pub meta: SessionMeta,
    /// Mitigation status changes in append order (unknown ids are ignored).
    pub status_changes: Vec<MitigationStatusChange>,
    /// Failure-mode disposition changes in append order (unknown ids are
    /// ignored).
    pub disposition_changes: Vec<DispositionChange>,
}

/// Fold a replayed event stream into a [`RawFold`] (pass 1). A `Rescored` event
/// mutates the targeted failure mode's unmitigated S/P in place.
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)
                {
                    // Note: a rescore replaces the OBSERVATIONS; the
                    // level is re-derived during projection.
                    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
}

/// Build the registry from the raw fold: every component id seen,
/// sorted & deduped for stable output.
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(),
    }
}

/// Mitigations applied to a given failure mode, in append order.
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()
}

/// A mitigation's view: status = last change (or `Planned`), evidence
/// accumulated, full history. Changes for other ids are ignored, so a change
/// naming an unknown mitigation is simply dropped (replay stays total).
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,
    }
}

/// Raw criticality of a failure mode from its code-derived unmitigated S/P
/// ordinals, collapsed through the SESSION'S [`MatrixStrategy`] to the public
/// {Low|Medium|High} bucket. Takes derived ordinals (from
/// observations), not levels supplied by the caller. The richer 5×5 input
/// collapses to L/M/H here, leaving everything downstream unchanged.
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,
    }
}

/// One mitigation's residual criticality. The residual S/P is
/// derived from the mitigation's OBSERVATIONS via the SAME
/// [`scoring::derive_ordinal`] used for the unmitigated axes (MAX-combine within
/// the session's strategy), then collapsed by the SAME
/// [`MatrixStrategy::criticality`]. There is NO second scoring path. `None` when
/// either residual axis is unobserved (the mitigation contributes no residual).
///
/// Observations are validated at write time against the session's strategy, so an
/// unexpected unknown id here yields `None` rather than panicking — replay must
/// never panic on persisted data.
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))
}

/// Best (lowest-criticality) residual across a failure mode's mitigations
///. With no mitigation, the residual is the raw S/P criticality. Each
/// mitigation's residual is derived from its OBSERVATIONS via
/// [`mitigation_residual`] — the SAME scoring + matrix used for the unmitigated
/// axes. A mitigation whose residual axes are unobserved contributes nothing.
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())
        // Mitigations exist but none yields a derived residual: fall back to the
        // raw criticality so an unscored-residual mitigation never silently
        // improves standing.
        .or(raw)
}

/// Discrete standing of a failure mode. `None` until it is scored.
fn standing_of(
    mitigations: &[&Mitigation],
    residual: Option<Criticality>,
) -> Option<FailureModeStanding> {
    let residual = residual?;
    if residual == Level::Low {
        return Some(FailureModeStanding::Acceptable);
    }
    // Residual is High/Medium.
    if mitigations.is_empty() {
        // Unscored failure modes have residual == None above, so reaching here
        // with no mitigation means it is scored and unmitigated.
        Some(FailureModeStanding::Unmitigated)
    } else {
        Some(FailureModeStanding::UnderMitigated)
    }
}

/// The mitigations of `views` that satisfy `keep`, as plain [`Mitigation`]
/// references for the residual / standing helpers.
fn mitigations_where(
    views: &[MitigationView],
    keep: impl Fn(&MitigationView) -> bool,
) -> Vec<&Mitigation> {
    views
        .iter()
        .filter(|m| keep(m))
        .map(|m| &m.mitigation)
        .collect()
}

/// The view the verified gate sees: every non-verified mitigation removed,
/// residual and standing recomputed from the verified ones.
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
}

/// Build the complete [`FmecaState`] from a replayed event stream. This is the
/// single deterministic entry point used by both `state.get` and `append`.
///
/// Total: never panics, even on a log `validate_log` would reject (changes for
/// unknown ids are ignored).
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};

    /// Build a mitigation carrying explicit residual OBSERVATIONS.
    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"),
        }
    }

    // --- residual derive TABLE (mirrors the FailureMode-axis tests) ----------

    #[test]
    fn residual_observations_derive_residual_criticality_3x3() {
        let q = MatrixStrategy::Qualitative3x3;
        // cosmetic→low sev, rare_edge_case→low prob ⇒ Low residual.
        assert_eq!(
            mitigation_residual(q, &mit(vec!["cosmetic"], vec!["rare_edge_case"])),
            Some(Low)
        );
        // data_loss→high sev, happens_in_normal_use→high prob ⇒ High residual.
        assert_eq!(
            mitigation_residual(q, &mit(vec!["data_loss"], vec!["happens_in_normal_use"])),
            Some(High)
        );
        // user_facing_degradation→medium sev, occasional→medium prob ⇒ Medium.
        assert_eq!(
            mitigation_residual(q, &mit(vec!["user_facing_degradation"], vec!["occasional"])),
            Some(Medium)
        );
    }

    #[test]
    fn residual_observations_max_combine() {
        let q = MatrixStrategy::Qualitative3x3;
        // severity: cosmetic(low) + data_loss(high) → high; probability low ⇒
        // criticality(High, Low) = Medium.
        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;
        // empty severity residual ⇒ no derived residual for this mitigation.
        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() {
        // Replay-safety: an unknown id (rejected at write time) must not panic
        // during the fold; it degrades to None.
        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() {
        // A NASA id under the 3×3 strategy is unknown ⇒ None (write-time
        // validation is what surfaces the INVALID_OBSERVATION error).
        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;
        // (5,5) ⇒ High; (1,1) ⇒ Low; (3,3) ⇒ Medium — same matrix as the
        // unmitigated axes.
        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)
        );
    }

    // --- best-residual selection + unscored-residual fallback ----------------

    #[test]
    fn best_residual_wins_across_mitigations() {
        let q = MatrixStrategy::Qualitative3x3;
        let weak = mit(vec!["data_loss"], vec!["happens_in_normal_use"]); // High
        let strong = mit(vec!["cosmetic"], vec!["rare_edge_case"]); // Low
        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() {
        // A mitigation with an unobserved residual must NOT silently improve
        // standing: residual falls back to the raw criticality.
        let q = MatrixStrategy::Qualitative3x3;
        let unscored = mit(vec![], vec![]);
        let mits = vec![&unscored];
        assert_eq!(residual_criticality(q, Some(High), &mits), Some(High));
    }
}