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());