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:
ruv 2026-06-11 21:19:08 -04:00
parent 67dd539e68
commit 78821f1657
2 changed files with 159 additions and 76 deletions

View File

@ -9,7 +9,9 @@
//! The classifier produces a single confidence score and a recommended
//! 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
#[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:
/// - Immediate (Red): Breathing abnormal (agonal, apnea, too fast/slow)
/// - Delayed (Yellow): Breathing present, limited movement
/// - Minor (Green): Normal breathing + active movement
/// - Deceased (Black): No vitals detected at all
/// - Unknown: Insufficient data to classify
/// CANONICAL TRIAGE: this delegates to [`TriageCalculator::calculate`], the
/// single source of truth used by both the ensemble gate (here) and the
/// `Survivor` record (`Survivor::new` / `update_vitals`). Previously this
/// method implemented a *second*, divergent START-protocol approximation
/// (different rate bands, different movement handling). The pipeline gated
/// 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
/// as Immediate regardless of confidence level, because in disaster response
/// a false negative (missing a survivor in distress) is far more costly
/// than a false positive.
/// The only ensemble-specific behaviour retained is the confidence gate:
/// when the combined ensemble confidence is below the configured minimum,
/// the reading is reported [`TriageStatus::Unknown`] (insufficient signal to
/// 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 {
// CRITICAL PATTERNS: always classify regardless of confidence.
// 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 canonical = TriageCalculator::calculate(reading);
let rate = breathing.rate_bpm;
if !(10.0..=30.0).contains(&rate) {
return TriageStatus::Immediate;
}
// Distress (Immediate) is always surfaced regardless of confidence.
if canonical == 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 {
return TriageStatus::Unknown;
}
let has_breathing = reading.breathing.is_some();
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
canonical
}
/// Get configuration
@ -218,7 +181,8 @@ impl EnsembleClassifier {
mod tests {
use super::*;
use crate::domain::{
BreathingPattern, ConfidenceScore, HeartbeatSignature, MovementProfile, SignalStrength,
BreathingPattern, BreathingType, ConfidenceScore, HeartbeatSignature, MovementProfile,
SignalStrength,
};
fn make_reading(
@ -251,7 +215,12 @@ mod tests {
}
#[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 reading = make_reading(
Some((16.0, BreathingType::Normal)),
@ -261,8 +230,29 @@ mod tests {
let result = classifier.classify(&reading);
assert!(result.confidence > 0.0);
assert_eq!(result.recommended_triage, TriageStatus::Minor);
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]
@ -275,8 +265,16 @@ mod tests {
}
#[test]
fn test_normal_breathing_no_movement_is_delayed() {
let classifier = EnsembleClassifier::new(EnsembleConfig::default());
fn test_normal_breathing_no_movement_is_immediate_canonical() {
// 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(
Some((16.0, BreathingType::Normal)),
None,
@ -284,11 +282,19 @@ mod tests {
);
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]
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 mut reading = VitalSignsReading::new(None, None, mv);
reading.confidence = ConfidenceScore::new(0.5);
@ -300,7 +306,48 @@ mod tests {
let classifier = EnsembleClassifier::new(config);
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

View File

@ -71,7 +71,10 @@ fn test_ensemble_classifier_triage_logic() {
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(
Some(BreathingPattern {
rate_bpm: 16.0,
@ -88,8 +91,34 @@ fn test_ensemble_classifier_triage_logic() {
},
);
let result = classifier.classify(&normal_breathing);
assert_eq!(result.recommended_triage, TriageStatus::Minor);
assert_eq!(result.recommended_triage, TriageStatus::Delayed);
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)
let agonal = VitalSignsReading::new(
@ -105,7 +134,10 @@ fn test_ensemble_classifier_triage_logic() {
let result = classifier.classify(&agonal);
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(
Some(BreathingPattern {
rate_bpm: 14.0,
@ -121,8 +153,12 @@ fn test_ensemble_classifier_triage_logic() {
MovementProfile::default(),
);
let result = classifier.classify(&stable);
assert_eq!(result.recommended_triage, TriageStatus::Delayed);
assert_eq!(result.recommended_triage, TriageStatus::Immediate);
assert!(result.heartbeat_detected);
assert_eq!(
result.recommended_triage,
wifi_densepose_mat::domain::triage::TriageCalculator::calculate(&stable),
);
}
#[test]