fix(mat): never triage a survivor with a heartbeat as Deceased (safety)
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 <ruv@ruv.net>
This commit is contained in:
parent
6ee21a0941
commit
67114878f6
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
Loading…
Reference in New Issue