fix(mat): unify divergent triage engines to single canonical source (ADR-158 §1)
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 <ruv@ruv.net>
This commit is contained in:
parent
67dd539e68
commit
78821f1657
|
|
@ -9,7 +9,9 @@
|
||||||
//! The classifier produces a single confidence score and a recommended
|
//! The classifier produces a single confidence score and a recommended
|
||||||
//! triage status based on the combined signals.
|
//! 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
|
/// Configuration for the ensemble classifier
|
||||||
#[derive(Debug, Clone)]
|
#[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:
|
/// CANONICAL TRIAGE: this delegates to [`TriageCalculator::calculate`], the
|
||||||
/// - Immediate (Red): Breathing abnormal (agonal, apnea, too fast/slow)
|
/// single source of truth used by both the ensemble gate (here) and the
|
||||||
/// - Delayed (Yellow): Breathing present, limited movement
|
/// `Survivor` record (`Survivor::new` / `update_vitals`). Previously this
|
||||||
/// - Minor (Green): Normal breathing + active movement
|
/// method implemented a *second*, divergent START-protocol approximation
|
||||||
/// - Deceased (Black): No vitals detected at all
|
/// (different rate bands, different movement handling). The pipeline gated
|
||||||
/// - Unknown: Insufficient data to classify
|
/// 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
|
/// The only ensemble-specific behaviour retained is the confidence gate:
|
||||||
/// as Immediate regardless of confidence level, because in disaster response
|
/// when the combined ensemble confidence is below the configured minimum,
|
||||||
/// a false negative (missing a survivor in distress) is far more costly
|
/// the reading is reported [`TriageStatus::Unknown`] (insufficient signal to
|
||||||
/// than a false positive.
|
/// 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 {
|
fn determine_triage(&self, reading: &VitalSignsReading, confidence: f64) -> TriageStatus {
|
||||||
// CRITICAL PATTERNS: always classify regardless of confidence.
|
let canonical = TriageCalculator::calculate(reading);
|
||||||
// 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 rate = breathing.rate_bpm;
|
// Distress (Immediate) is always surfaced regardless of confidence.
|
||||||
if !(10.0..=30.0).contains(&rate) {
|
if canonical == TriageStatus::Immediate {
|
||||||
return 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 {
|
if confidence < self.config.min_ensemble_confidence {
|
||||||
return TriageStatus::Unknown;
|
return TriageStatus::Unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
let has_breathing = reading.breathing.is_some();
|
canonical
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get configuration
|
/// Get configuration
|
||||||
|
|
@ -218,7 +181,8 @@ impl EnsembleClassifier {
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::domain::{
|
use crate::domain::{
|
||||||
BreathingPattern, ConfidenceScore, HeartbeatSignature, MovementProfile, SignalStrength,
|
BreathingPattern, BreathingType, ConfidenceScore, HeartbeatSignature, MovementProfile,
|
||||||
|
SignalStrength,
|
||||||
};
|
};
|
||||||
|
|
||||||
fn make_reading(
|
fn make_reading(
|
||||||
|
|
@ -251,7 +215,12 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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 classifier = EnsembleClassifier::new(EnsembleConfig::default());
|
||||||
let reading = make_reading(
|
let reading = make_reading(
|
||||||
Some((16.0, BreathingType::Normal)),
|
Some((16.0, BreathingType::Normal)),
|
||||||
|
|
@ -261,8 +230,29 @@ mod tests {
|
||||||
|
|
||||||
let result = classifier.classify(&reading);
|
let result = classifier.classify(&reading);
|
||||||
assert!(result.confidence > 0.0);
|
assert!(result.confidence > 0.0);
|
||||||
assert_eq!(result.recommended_triage, TriageStatus::Minor);
|
|
||||||
assert!(result.breathing_detected);
|
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]
|
#[test]
|
||||||
|
|
@ -275,8 +265,16 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_normal_breathing_no_movement_is_delayed() {
|
fn test_normal_breathing_no_movement_is_immediate_canonical() {
|
||||||
let classifier = EnsembleClassifier::new(EnsembleConfig::default());
|
// 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(
|
let reading = make_reading(
|
||||||
Some((16.0, BreathingType::Normal)),
|
Some((16.0, BreathingType::Normal)),
|
||||||
None,
|
None,
|
||||||
|
|
@ -284,11 +282,19 @@ mod tests {
|
||||||
);
|
);
|
||||||
|
|
||||||
let result = classifier.classify(&reading);
|
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]
|
#[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 mv = MovementProfile::default();
|
||||||
let mut reading = VitalSignsReading::new(None, None, mv);
|
let mut reading = VitalSignsReading::new(None, None, mv);
|
||||||
reading.confidence = ConfidenceScore::new(0.5);
|
reading.confidence = ConfidenceScore::new(0.5);
|
||||||
|
|
@ -300,7 +306,48 @@ mod tests {
|
||||||
let classifier = EnsembleClassifier::new(config);
|
let classifier = EnsembleClassifier::new(config);
|
||||||
|
|
||||||
let result = classifier.classify(&reading);
|
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
|
/// SAFETY regression: heartbeat present but no sensed breathing/movement is
|
||||||
|
|
|
||||||
|
|
@ -71,7 +71,10 @@ fn test_ensemble_classifier_triage_logic() {
|
||||||
|
|
||||||
let classifier = EnsembleClassifier::new(EnsembleConfig::default());
|
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(
|
let normal_breathing = VitalSignsReading::new(
|
||||||
Some(BreathingPattern {
|
Some(BreathingPattern {
|
||||||
rate_bpm: 16.0,
|
rate_bpm: 16.0,
|
||||||
|
|
@ -88,8 +91,34 @@ fn test_ensemble_classifier_triage_logic() {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
let result = classifier.classify(&normal_breathing);
|
let result = classifier.classify(&normal_breathing);
|
||||||
assert_eq!(result.recommended_triage, TriageStatus::Minor);
|
assert_eq!(result.recommended_triage, TriageStatus::Delayed);
|
||||||
assert!(result.breathing_detected);
|
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)
|
// Agonal breathing = Immediate (Red)
|
||||||
let agonal = VitalSignsReading::new(
|
let agonal = VitalSignsReading::new(
|
||||||
|
|
@ -105,7 +134,10 @@ fn test_ensemble_classifier_triage_logic() {
|
||||||
let result = classifier.classify(&agonal);
|
let result = classifier.classify(&agonal);
|
||||||
assert_eq!(result.recommended_triage, TriageStatus::Immediate);
|
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(
|
let stable = VitalSignsReading::new(
|
||||||
Some(BreathingPattern {
|
Some(BreathingPattern {
|
||||||
rate_bpm: 14.0,
|
rate_bpm: 14.0,
|
||||||
|
|
@ -121,8 +153,12 @@ fn test_ensemble_classifier_triage_logic() {
|
||||||
MovementProfile::default(),
|
MovementProfile::default(),
|
||||||
);
|
);
|
||||||
let result = classifier.classify(&stable);
|
let result = classifier.classify(&stable);
|
||||||
assert_eq!(result.recommended_triage, TriageStatus::Delayed);
|
assert_eq!(result.recommended_triage, TriageStatus::Immediate);
|
||||||
assert!(result.heartbeat_detected);
|
assert!(result.heartbeat_detected);
|
||||||
|
assert_eq!(
|
||||||
|
result.recommended_triage,
|
||||||
|
wifi_densepose_mat::domain::triage::TriageCalculator::calculate(&stable),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue