From 78821f165790cf7401fbf9a8536e78b339e79146 Mon Sep 17 00:00:00 2001 From: ruv Date: Thu, 11 Jun 2026 21:19:08 -0400 Subject: [PATCH] =?UTF-8?q?fix(mat):=20unify=20divergent=20triage=20engine?= =?UTF-8?q?s=20to=20single=20canonical=20source=20(ADR-158=20=C2=A71)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ensemble gate (EnsembleClassifier::determine_triage) and the survivor record (Survivor::new -> TriageCalculator::calculate) used two different START-protocol approximations with different rate bands and movement handling. The pipeline gated on the ensemble triage then discarded it and recomputed via TriageCalculator, so a survivor could be admitted as one priority and recorded as another (e.g. 28 bpm + Tremor: gate said Delayed, record said Immediate). In a mass-casualty tool that divergence is a life-safety defect. determine_triage now delegates to TriageCalculator (the single source of truth), retaining only the ensemble confidence gate (low confidence -> Unknown, except Immediate which is never suppressed). Updated unit + integration tests to the canonical expectations and added a divergent-boundary regression asserting gate triage == survivor-record triage. Co-Authored-By: claude-flow --- .../src/detection/ensemble.rs | 191 +++++++++++------- .../tests/integration_adr001.rs | 44 +++- 2 files changed, 159 insertions(+), 76 deletions(-) diff --git a/v2/crates/wifi-densepose-mat/src/detection/ensemble.rs b/v2/crates/wifi-densepose-mat/src/detection/ensemble.rs index d1324d2f..3e61c138 100644 --- a/v2/crates/wifi-densepose-mat/src/detection/ensemble.rs +++ b/v2/crates/wifi-densepose-mat/src/detection/ensemble.rs @@ -9,7 +9,9 @@ //! The classifier produces a single confidence score and a recommended //! triage status based on the combined signals. -use crate::domain::{BreathingType, MovementType, TriageStatus, VitalSignsReading}; +use crate::domain::{ + triage::TriageCalculator, MovementType, TriageStatus, VitalSignsReading, +}; /// Configuration for the ensemble classifier #[derive(Debug, Clone)] @@ -133,79 +135,40 @@ impl EnsembleClassifier { } } - /// Determine triage status based on vital signs analysis. + /// Determine triage status for a reading. /// - /// Uses START triage protocol logic: - /// - Immediate (Red): Breathing abnormal (agonal, apnea, too fast/slow) - /// - Delayed (Yellow): Breathing present, limited movement - /// - Minor (Green): Normal breathing + active movement - /// - Deceased (Black): No vitals detected at all - /// - Unknown: Insufficient data to classify + /// CANONICAL TRIAGE: this delegates to [`TriageCalculator::calculate`], the + /// single source of truth used by both the ensemble gate (here) and the + /// `Survivor` record (`Survivor::new` / `update_vitals`). Previously this + /// method implemented a *second*, divergent START-protocol approximation + /// (different rate bands, different movement handling). The pipeline gated + /// on the ensemble's triage then discarded it and recomputed via + /// `TriageCalculator` in `Survivor::new`, so a survivor could be gated as + /// one priority and recorded as another (e.g. 28 bpm + Tremor: old ensemble + /// said Delayed, the survivor record said Immediate). In a mass-casualty + /// tool that divergence is a life-safety defect. The two are now unified. /// - /// Critical patterns (Agonal, Apnea, extreme rates) are always classified - /// as Immediate regardless of confidence level, because in disaster response - /// a false negative (missing a survivor in distress) is far more costly - /// than a false positive. + /// The only ensemble-specific behaviour retained is the confidence gate: + /// when the combined ensemble confidence is below the configured minimum, + /// the reading is reported [`TriageStatus::Unknown`] (insufficient signal to + /// classify) UNLESS the canonical calculator flags it [`TriageStatus::Immediate`]. + /// Distress is never suppressed by low confidence — a false negative + /// (missing a survivor in distress) is far more costly than a false positive. fn determine_triage(&self, reading: &VitalSignsReading, confidence: f64) -> TriageStatus { - // CRITICAL PATTERNS: always classify regardless of confidence. - // In disaster response, any sign of distress must be escalated. - if let Some(ref breathing) = reading.breathing { - match breathing.pattern_type { - BreathingType::Agonal | BreathingType::Apnea => { - return TriageStatus::Immediate; - } - _ => {} - } + let canonical = TriageCalculator::calculate(reading); - let rate = breathing.rate_bpm; - if !(10.0..=30.0).contains(&rate) { - return TriageStatus::Immediate; - } + // Distress (Immediate) is always surfaced regardless of confidence. + if canonical == TriageStatus::Immediate { + return TriageStatus::Immediate; } - // Below confidence threshold: not enough signal to classify further + // Below the ensemble confidence threshold: not enough signal to trust a + // non-distress classification. Report Unknown rather than guessing. if confidence < self.config.min_ensemble_confidence { return TriageStatus::Unknown; } - let has_breathing = reading.breathing.is_some(); - let has_movement = reading.movement.movement_type != MovementType::None; - - if !has_breathing && !has_movement { - // SAFETY: a detectable heartbeat means the survivor is ALIVE. No - // sensed breathing/movement *with* a pulse is respiratory arrest — - // the most time-critical savable state (Immediate), never Deceased. - // Only the total absence of breathing, movement AND heartbeat is - // reported Deceased. - if reading.heartbeat.is_some() { - return TriageStatus::Immediate; - } - return TriageStatus::Deceased; - } - - if !has_breathing && has_movement { - return TriageStatus::Immediate; - } - - // Has breathing above threshold - assess triage level - if let Some(ref breathing) = reading.breathing { - let rate = breathing.rate_bpm; - - if !(12.0..=24.0).contains(&rate) { - if has_movement { - return TriageStatus::Delayed; - } - return TriageStatus::Immediate; - } - - // Normal breathing rate - if has_movement { - return TriageStatus::Minor; - } - return TriageStatus::Delayed; - } - - TriageStatus::Unknown + canonical } /// Get configuration @@ -218,7 +181,8 @@ impl EnsembleClassifier { mod tests { use super::*; use crate::domain::{ - BreathingPattern, ConfidenceScore, HeartbeatSignature, MovementProfile, SignalStrength, + BreathingPattern, BreathingType, ConfidenceScore, HeartbeatSignature, MovementProfile, + SignalStrength, }; fn make_reading( @@ -251,7 +215,12 @@ mod tests { } #[test] - fn test_normal_breathing_with_movement_is_minor() { + fn test_normal_breathing_with_periodic_movement_is_canonical() { + // UNIFICATION: Periodic movement maps to MinimalMovement in the canonical + // calculator (it is likely breathing-correlated, not purposeful), so + // Normal breathing + Periodic → Delayed. The old ensemble engine treated + // ANY non-None movement as "active" and returned Minor — diverging from + // the survivor record. Gate and survivor must now agree. let classifier = EnsembleClassifier::new(EnsembleConfig::default()); let reading = make_reading( Some((16.0, BreathingType::Normal)), @@ -261,8 +230,29 @@ mod tests { let result = classifier.classify(&reading); assert!(result.confidence > 0.0); - assert_eq!(result.recommended_triage, TriageStatus::Minor); assert!(result.breathing_detected); + let survivor = crate::domain::triage::TriageCalculator::calculate(&reading); + assert_eq!(result.recommended_triage, survivor); + assert_eq!(result.recommended_triage, TriageStatus::Delayed); + } + + #[test] + fn test_normal_breathing_purposeful_movement_is_minor() { + // Gross + voluntary = Responsive (following commands / walking wounded). + // make_reading sets is_voluntary=true for any non-None movement, so Gross + // here is voluntary → Responsive → Minor. Confirms the canonical "walking + // wounded" path still resolves to Minor and gate==survivor. + let classifier = EnsembleClassifier::new(EnsembleConfig::default()); + let reading = make_reading( + Some((16.0, BreathingType::Normal)), + None, + MovementType::Gross, + ); + + let result = classifier.classify(&reading); + let survivor = crate::domain::triage::TriageCalculator::calculate(&reading); + assert_eq!(result.recommended_triage, survivor); + assert_eq!(result.recommended_triage, TriageStatus::Minor); } #[test] @@ -275,8 +265,16 @@ mod tests { } #[test] - fn test_normal_breathing_no_movement_is_delayed() { - let classifier = EnsembleClassifier::new(EnsembleConfig::default()); + fn test_normal_breathing_no_movement_is_immediate_canonical() { + // UNIFICATION: Normal breathing but ZERO detectable movement means the + // survivor is unresponsive (not following commands) — START classifies + // breathing-but-unresponsive as Immediate. The old ensemble engine + // returned Delayed here, diverging from the survivor record. Gate and + // survivor must agree. + let classifier = EnsembleClassifier::new(EnsembleConfig { + min_ensemble_confidence: 0.0, + ..EnsembleConfig::default() + }); let reading = make_reading( Some((16.0, BreathingType::Normal)), None, @@ -284,11 +282,19 @@ mod tests { ); let result = classifier.classify(&reading); - assert_eq!(result.recommended_triage, TriageStatus::Delayed); + let survivor = crate::domain::triage::TriageCalculator::calculate(&reading); + assert_eq!(result.recommended_triage, survivor); + assert_eq!(result.recommended_triage, TriageStatus::Immediate); } #[test] - fn test_no_vitals_is_deceased() { + fn test_no_vitals_is_unknown_canonical() { + // UNIFICATION: with the canonical TriageCalculator now driving the gate, + // a reading with NO sensed vitals at all is Unknown (a remote sensor that + // sees nothing cannot confirm death — it may be a signal/occlusion issue), + // matching what `Survivor::new` records. The old ensemble engine returned + // Deceased here, diverging from the survivor record; that is the bug this + // task fixes. let mv = MovementProfile::default(); let mut reading = VitalSignsReading::new(None, None, mv); reading.confidence = ConfidenceScore::new(0.5); @@ -300,7 +306,48 @@ mod tests { let classifier = EnsembleClassifier::new(config); let result = classifier.classify(&reading); - assert_eq!(result.recommended_triage, TriageStatus::Deceased); + assert_eq!(result.recommended_triage, TriageStatus::Unknown); + // And it must agree with the canonical calculator directly. + assert_eq!( + result.recommended_triage, + crate::domain::triage::TriageCalculator::calculate(&reading) + ); + } + + /// CRITICAL unification regression (fails on the old divergent engines). + /// + /// A 28 bpm Normal-rate breather with only an involuntary Tremor is a + /// classic divergent boundary case: + /// - OLD ensemble engine: 28 ∈ [10,30] and ∈ [12,24] is false, but it had + /// movement → Delayed. + /// - OLD `TriageCalculator` (used by `Survivor::new`): 28 ∈ [10,30] = Normal + /// breathing, Tremor → InvoluntaryOnly (not following commands) → Immediate. + /// The gate would have admitted it as Delayed while the survivor record said + /// Immediate. After unification BOTH must return the SAME triage. + #[test] + fn test_divergent_boundary_28bpm_tremor_gate_equals_survivor() { + let reading = make_reading( + Some((28.0, BreathingType::Normal)), + None, + MovementType::Tremor, + ); + + let classifier = EnsembleClassifier::new(EnsembleConfig { + min_ensemble_confidence: 0.0, + ..EnsembleConfig::default() + }); + + // Gate triage (ensemble) and survivor-record triage (Survivor::new path, + // i.e. TriageCalculator::calculate) must be identical. + let gate = classifier.classify(&reading).recommended_triage; + let survivor = crate::domain::triage::TriageCalculator::calculate(&reading); + + assert_eq!( + gate, survivor, + "gate triage {gate:?} must equal survivor-record triage {survivor:?}" + ); + // And the canonical answer for this distress case is Immediate. + assert_eq!(gate, TriageStatus::Immediate); } /// SAFETY regression: heartbeat present but no sensed breathing/movement is diff --git a/v2/crates/wifi-densepose-mat/tests/integration_adr001.rs b/v2/crates/wifi-densepose-mat/tests/integration_adr001.rs index 675a5d76..e4628b85 100644 --- a/v2/crates/wifi-densepose-mat/tests/integration_adr001.rs +++ b/v2/crates/wifi-densepose-mat/tests/integration_adr001.rs @@ -71,7 +71,10 @@ fn test_ensemble_classifier_triage_logic() { let classifier = EnsembleClassifier::new(EnsembleConfig::default()); - // Normal breathing + movement = Minor (Green) + // UNIFICATION (canonical TriageCalculator): Periodic movement is treated as + // MinimalMovement (likely breathing-correlated, not purposeful), so Normal + // breathing + Periodic → Delayed — and the ensemble gate now agrees with the + // survivor record. Purposeful (Gross + voluntary) movement is what yields Minor. let normal_breathing = VitalSignsReading::new( Some(BreathingPattern { rate_bpm: 16.0, @@ -88,8 +91,34 @@ fn test_ensemble_classifier_triage_logic() { }, ); let result = classifier.classify(&normal_breathing); - assert_eq!(result.recommended_triage, TriageStatus::Minor); + assert_eq!(result.recommended_triage, TriageStatus::Delayed); assert!(result.breathing_detected); + // Gate triage must equal the survivor-record triage (single source of truth). + assert_eq!( + result.recommended_triage, + wifi_densepose_mat::domain::triage::TriageCalculator::calculate(&normal_breathing), + ); + + // Gross + voluntary movement = Responsive (walking wounded) = Minor. + let purposeful = VitalSignsReading::new( + Some(BreathingPattern { + rate_bpm: 16.0, + pattern_type: BreathingType::Normal, + amplitude: 0.5, + regularity: 0.9, + }), + None, + MovementProfile { + movement_type: MovementType::Gross, + intensity: 0.7, + frequency: 0.3, + is_voluntary: true, + }, + ); + assert_eq!( + classifier.classify(&purposeful).recommended_triage, + TriageStatus::Minor, + ); // Agonal breathing = Immediate (Red) let agonal = VitalSignsReading::new( @@ -105,7 +134,10 @@ fn test_ensemble_classifier_triage_logic() { let result = classifier.classify(&agonal); assert_eq!(result.recommended_triage, TriageStatus::Immediate); - // Normal breathing, no movement = Delayed (Yellow) + // UNIFICATION (canonical): Normal breathing with a pulse but NO detectable + // movement = unresponsive (not following commands) = Immediate per START. + // The old divergent ensemble returned Delayed here; the survivor record + // (TriageCalculator) said Immediate. They now agree on Immediate. let stable = VitalSignsReading::new( Some(BreathingPattern { rate_bpm: 14.0, @@ -121,8 +153,12 @@ fn test_ensemble_classifier_triage_logic() { MovementProfile::default(), ); let result = classifier.classify(&stable); - assert_eq!(result.recommended_triage, TriageStatus::Delayed); + assert_eq!(result.recommended_triage, TriageStatus::Immediate); assert!(result.heartbeat_detected); + assert_eq!( + result.recommended_triage, + wifi_densepose_mat::domain::triage::TriageCalculator::calculate(&stable), + ); } #[test]