mirror of
https://github.com/ruvnet/RuView
synced 2026-06-09 10:13:17 +00:00
fix(mat): never triage a survivor with a heartbeat as Deceased (safety) (#926)
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).
This commit is contained in:
@@ -172,6 +172,14 @@ impl EnsembleClassifier {
|
|||||||
let has_movement = reading.movement.movement_type != MovementType::None;
|
let has_movement = reading.movement.movement_type != MovementType::None;
|
||||||
|
|
||||||
if !has_breathing && !has_movement {
|
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;
|
return TriageStatus::Deceased;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,6 +303,27 @@ mod tests {
|
|||||||
assert_eq!(result.recommended_triage, TriageStatus::Deceased);
|
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]
|
#[test]
|
||||||
fn test_ensemble_confidence_weighting() {
|
fn test_ensemble_confidence_weighting() {
|
||||||
let classifier = EnsembleClassifier::new(EnsembleConfig {
|
let classifier = EnsembleClassifier::new(EnsembleConfig {
|
||||||
|
|||||||
@@ -104,7 +104,20 @@ impl TriageCalculator {
|
|||||||
let movement_status = Self::assess_movement(vitals);
|
let movement_status = Self::assess_movement(vitals);
|
||||||
|
|
||||||
// Step 4: Combine assessments
|
// 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
|
/// Assess breathing status
|
||||||
@@ -217,7 +230,9 @@ enum MovementAssessment {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::domain::{BreathingPattern, ConfidenceScore, MovementProfile};
|
use crate::domain::{
|
||||||
|
BreathingPattern, ConfidenceScore, HeartbeatSignature, MovementProfile, SignalStrength,
|
||||||
|
};
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
|
|
||||||
fn create_vitals(
|
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]
|
#[test]
|
||||||
fn test_no_vitals_is_unknown() {
|
fn test_no_vitals_is_unknown() {
|
||||||
let vitals = create_vitals(None, MovementProfile::default());
|
let vitals = create_vitals(None, MovementProfile::default());
|
||||||
|
|||||||
Reference in New Issue
Block a user