From 67114878f6a0f2b0ddfa49c587d76061cf575279 Mon Sep 17 00:00:00 2001 From: ruv Date: Wed, 3 Jun 2026 09:23:08 +0200 Subject: [PATCH] fix(mat): never triage a survivor with a heartbeat as Deceased (safety) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both triage paths in the Mass Casualty Assessment tool classified a survivor as Deceased (Black) on "no breathing + no movement" while completely ignoring the heartbeat signal: - domain `TriageCalculator::calculate` → `combine_assessments(Absent, None)` returned Deceased. That branch is in fact only reachable *because* a heartbeat makes `has_vitals()` true (breathing+movement absent alone → Unknown) — so every "Deceased" was a live person with a pulse. - detection `EnsembleClassifier::determine_triage` (the path used by `classify()`) returned Deceased on `!has_breathing && !has_movement`, also ignoring `reading.heartbeat`. A survivor with a detectable pulse but no sensed breathing/movement is in respiratory arrest — the most time-critical *savable* state. Reporting them Deceased would deprioritize a rescuable person. WiFi-CSI also cannot confirm death (no airway-repositioning step), so a pulse must override. Fix: in both paths, if the result would be Deceased but a heartbeat is present, return Immediate. Total absence of breathing, movement AND heartbeat is unchanged (domain → Unknown, ensemble → Deceased). 2 safety regression tests added. Full MAT suite: 168 + 6 + 3 passed, 0 failed (existing test_no_vitals_is_deceased still green — no heartbeat → Deceased). Co-Authored-By: claude-flow --- .../src/detection/ensemble.rs | 29 +++++++++++++ .../wifi-densepose-mat/src/domain/triage.rs | 42 ++++++++++++++++++- 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/v2/crates/wifi-densepose-mat/src/detection/ensemble.rs b/v2/crates/wifi-densepose-mat/src/detection/ensemble.rs index 9947f8f5..d1324d2f 100644 --- a/v2/crates/wifi-densepose-mat/src/detection/ensemble.rs +++ b/v2/crates/wifi-densepose-mat/src/detection/ensemble.rs @@ -172,6 +172,14 @@ impl EnsembleClassifier { 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; } @@ -295,6 +303,27 @@ mod tests { assert_eq!(result.recommended_triage, TriageStatus::Deceased); } + /// SAFETY regression: heartbeat present but no sensed breathing/movement is + /// respiratory arrest — Immediate, never Deceased. Only the *total* absence + /// of breathing, movement AND heartbeat (the test above) is Deceased. + #[test] + fn test_heartbeat_with_no_breathing_or_movement_is_immediate() { + // breathing: None, heartbeat: Some(72 bpm), movement: None + let reading = make_reading(None, Some(72.0), MovementType::None); + + let classifier = EnsembleClassifier::new(EnsembleConfig { + min_ensemble_confidence: 0.0, + ..EnsembleConfig::default() + }); + + let result = classifier.classify(&reading); + assert_eq!( + result.recommended_triage, + TriageStatus::Immediate, + "a survivor with a pulse must never be triaged Deceased" + ); + } + #[test] fn test_ensemble_confidence_weighting() { let classifier = EnsembleClassifier::new(EnsembleConfig { diff --git a/v2/crates/wifi-densepose-mat/src/domain/triage.rs b/v2/crates/wifi-densepose-mat/src/domain/triage.rs index 9baf7710..5b14e6a0 100644 --- a/v2/crates/wifi-densepose-mat/src/domain/triage.rs +++ b/v2/crates/wifi-densepose-mat/src/domain/triage.rs @@ -104,7 +104,20 @@ impl TriageCalculator { let movement_status = Self::assess_movement(vitals); // Step 4: Combine assessments - Self::combine_assessments(breathing_status, movement_status) + let status = Self::combine_assessments(breathing_status, movement_status); + + // Step 5: SAFETY OVERRIDE — a detectable heartbeat means the survivor is + // ALIVE. `combine_assessments` only sees breathing + movement, so a + // person with a pulse but no *sensed* breathing/movement (respiratory + // arrest, or breathing too shallow for CSI to pick up) would otherwise + // be reported Deceased and deprioritized for rescue. No breathing + a + // pulse is the most time-critical *savable* state, so escalate to + // Immediate rather than ever calling a survivor with a heartbeat dead. + if status == TriageStatus::Deceased && vitals.heartbeat.is_some() { + return TriageStatus::Immediate; + } + + status } /// Assess breathing status @@ -217,7 +230,9 @@ enum MovementAssessment { #[cfg(test)] mod tests { use super::*; - use crate::domain::{BreathingPattern, ConfidenceScore, MovementProfile}; + use crate::domain::{ + BreathingPattern, ConfidenceScore, HeartbeatSignature, MovementProfile, SignalStrength, + }; use chrono::Utc; fn create_vitals( @@ -233,6 +248,29 @@ mod tests { } } + /// SAFETY regression: a survivor with a detectable heartbeat but no sensed + /// breathing or movement is in respiratory arrest — Immediate (Red), and + /// must NEVER be reported Deceased. (Before the fix, `combine_assessments` + /// ignored heartbeat and returned Deceased; that path was in fact only + /// reachable *because* a heartbeat made `has_vitals()` true.) + #[test] + fn heartbeat_with_no_breathing_or_movement_is_immediate_not_deceased() { + let vitals = VitalSignsReading { + breathing: None, + heartbeat: Some(HeartbeatSignature { + rate_bpm: 72.0, + variability: 0.1, + strength: SignalStrength::Moderate, + }), + movement: MovementProfile::default(), + timestamp: Utc::now(), + confidence: ConfidenceScore::new(0.8), + }; + let status = TriageCalculator::calculate(&vitals); + assert_eq!(status, TriageStatus::Immediate, "pulse present ⇒ alive"); + assert_ne!(status, TriageStatus::Deceased); + } + #[test] fn test_no_vitals_is_unknown() { let vitals = create_vitals(None, MovementProfile::default());