mirror of
https://github.com/ruvnet/RuView
synced 2026-08-07 20:01:43 +00:00
78821f1657
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>
396 lines
15 KiB
Rust
396 lines
15 KiB
Rust
//! Ensemble classifier that combines breathing, heartbeat, and movement signals
|
|
//! into a unified survivor detection confidence score.
|
|
//!
|
|
//! The ensemble uses weighted voting across the three detector signals:
|
|
//! - Breathing presence is the strongest indicator of a living survivor
|
|
//! - Heartbeat (when enabled) provides high-confidence confirmation
|
|
//! - Movement type distinguishes active vs trapped survivors
|
|
//!
|
|
//! The classifier produces a single confidence score and a recommended
|
|
//! triage status based on the combined signals.
|
|
|
|
use crate::domain::{
|
|
triage::TriageCalculator, MovementType, TriageStatus, VitalSignsReading,
|
|
};
|
|
|
|
/// Configuration for the ensemble classifier
|
|
#[derive(Debug, Clone)]
|
|
pub struct EnsembleConfig {
|
|
/// Weight for breathing signal (0.0-1.0)
|
|
pub breathing_weight: f64,
|
|
/// Weight for heartbeat signal (0.0-1.0)
|
|
pub heartbeat_weight: f64,
|
|
/// Weight for movement signal (0.0-1.0)
|
|
pub movement_weight: f64,
|
|
/// Minimum combined confidence to report a detection
|
|
pub min_ensemble_confidence: f64,
|
|
}
|
|
|
|
impl Default for EnsembleConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
breathing_weight: 0.50,
|
|
heartbeat_weight: 0.30,
|
|
movement_weight: 0.20,
|
|
min_ensemble_confidence: 0.3,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Result of ensemble classification
|
|
#[derive(Debug, Clone)]
|
|
pub struct EnsembleResult {
|
|
/// Combined confidence score (0.0-1.0)
|
|
pub confidence: f64,
|
|
/// Recommended triage status based on signal analysis
|
|
pub recommended_triage: TriageStatus,
|
|
/// Whether breathing was detected
|
|
pub breathing_detected: bool,
|
|
/// Whether heartbeat was detected
|
|
pub heartbeat_detected: bool,
|
|
/// Whether meaningful movement was detected
|
|
pub movement_detected: bool,
|
|
/// Individual signal confidences
|
|
pub signal_confidences: SignalConfidences,
|
|
}
|
|
|
|
/// Individual confidence scores for each signal type
|
|
#[derive(Debug, Clone)]
|
|
pub struct SignalConfidences {
|
|
/// Breathing detection confidence
|
|
pub breathing: f64,
|
|
/// Heartbeat detection confidence
|
|
pub heartbeat: f64,
|
|
/// Movement detection confidence
|
|
pub movement: f64,
|
|
}
|
|
|
|
/// Ensemble classifier combining breathing, heartbeat, and movement detectors
|
|
pub struct EnsembleClassifier {
|
|
config: EnsembleConfig,
|
|
}
|
|
|
|
impl EnsembleClassifier {
|
|
/// Create a new ensemble classifier
|
|
pub fn new(config: EnsembleConfig) -> Self {
|
|
Self { config }
|
|
}
|
|
|
|
/// Classify a vital signs reading using weighted ensemble voting.
|
|
///
|
|
/// The ensemble combines individual detector outputs with configured weights
|
|
/// to produce a single confidence score and triage recommendation.
|
|
pub fn classify(&self, reading: &VitalSignsReading) -> EnsembleResult {
|
|
// Extract individual signal confidences (using method calls)
|
|
let breathing_conf = reading
|
|
.breathing
|
|
.as_ref()
|
|
.map(|b| b.confidence())
|
|
.unwrap_or(0.0);
|
|
|
|
let heartbeat_conf = reading
|
|
.heartbeat
|
|
.as_ref()
|
|
.map(|h| h.confidence())
|
|
.unwrap_or(0.0);
|
|
|
|
let movement_conf = if reading.movement.movement_type != MovementType::None {
|
|
reading.movement.confidence()
|
|
} else {
|
|
0.0
|
|
};
|
|
|
|
// Weighted ensemble confidence
|
|
let total_weight = self.config.breathing_weight
|
|
+ self.config.heartbeat_weight
|
|
+ self.config.movement_weight;
|
|
|
|
let ensemble_confidence = if total_weight > 0.0 {
|
|
(breathing_conf * self.config.breathing_weight
|
|
+ heartbeat_conf * self.config.heartbeat_weight
|
|
+ movement_conf * self.config.movement_weight)
|
|
/ total_weight
|
|
} else {
|
|
0.0
|
|
};
|
|
|
|
let breathing_detected = reading.breathing.is_some();
|
|
let heartbeat_detected = reading.heartbeat.is_some();
|
|
let movement_detected = reading.movement.movement_type != MovementType::None;
|
|
|
|
// Determine triage status from signal combination
|
|
let recommended_triage = self.determine_triage(reading, ensemble_confidence);
|
|
|
|
EnsembleResult {
|
|
confidence: ensemble_confidence,
|
|
recommended_triage,
|
|
breathing_detected,
|
|
heartbeat_detected,
|
|
movement_detected,
|
|
signal_confidences: SignalConfidences {
|
|
breathing: breathing_conf,
|
|
heartbeat: heartbeat_conf,
|
|
movement: movement_conf,
|
|
},
|
|
}
|
|
}
|
|
|
|
/// Determine triage status for a reading.
|
|
///
|
|
/// 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.
|
|
///
|
|
/// 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 {
|
|
let canonical = TriageCalculator::calculate(reading);
|
|
|
|
// Distress (Immediate) is always surfaced regardless of confidence.
|
|
if canonical == TriageStatus::Immediate {
|
|
return TriageStatus::Immediate;
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
|
|
canonical
|
|
}
|
|
|
|
/// Get configuration
|
|
pub fn config(&self) -> &EnsembleConfig {
|
|
&self.config
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::domain::{
|
|
BreathingPattern, BreathingType, ConfidenceScore, HeartbeatSignature, MovementProfile,
|
|
SignalStrength,
|
|
};
|
|
|
|
fn make_reading(
|
|
breathing: Option<(f32, BreathingType)>,
|
|
heartbeat: Option<f32>,
|
|
movement: MovementType,
|
|
) -> VitalSignsReading {
|
|
let bp = breathing.map(|(rate, pattern_type)| BreathingPattern {
|
|
rate_bpm: rate,
|
|
pattern_type,
|
|
amplitude: 0.9,
|
|
regularity: 0.9,
|
|
});
|
|
|
|
let hb = heartbeat.map(|rate| HeartbeatSignature {
|
|
rate_bpm: rate,
|
|
variability: 0.1,
|
|
strength: SignalStrength::Moderate,
|
|
});
|
|
|
|
let is_moving = movement != MovementType::None;
|
|
let mv = MovementProfile {
|
|
movement_type: movement,
|
|
intensity: if is_moving { 0.5 } else { 0.0 },
|
|
frequency: 0.0,
|
|
is_voluntary: is_moving,
|
|
};
|
|
|
|
VitalSignsReading::new(bp, hb, mv)
|
|
}
|
|
|
|
#[test]
|
|
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)),
|
|
None,
|
|
MovementType::Periodic,
|
|
);
|
|
|
|
let result = classifier.classify(&reading);
|
|
assert!(result.confidence > 0.0);
|
|
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]
|
|
fn test_agonal_breathing_is_immediate() {
|
|
let classifier = EnsembleClassifier::new(EnsembleConfig::default());
|
|
let reading = make_reading(Some((8.0, BreathingType::Agonal)), None, MovementType::None);
|
|
|
|
let result = classifier.classify(&reading);
|
|
assert_eq!(result.recommended_triage, TriageStatus::Immediate);
|
|
}
|
|
|
|
#[test]
|
|
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,
|
|
MovementType::None,
|
|
);
|
|
|
|
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::Immediate);
|
|
}
|
|
|
|
#[test]
|
|
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);
|
|
|
|
let config = EnsembleConfig {
|
|
min_ensemble_confidence: 0.0,
|
|
..EnsembleConfig::default()
|
|
};
|
|
let classifier = EnsembleClassifier::new(config);
|
|
|
|
let result = classifier.classify(&reading);
|
|
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
|
|
/// 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 {
|
|
breathing_weight: 0.6,
|
|
heartbeat_weight: 0.3,
|
|
movement_weight: 0.1,
|
|
min_ensemble_confidence: 0.0,
|
|
});
|
|
|
|
let reading = make_reading(
|
|
Some((16.0, BreathingType::Normal)),
|
|
Some(72.0),
|
|
MovementType::Periodic,
|
|
);
|
|
|
|
let result = classifier.classify(&reading);
|
|
assert!(result.confidence > 0.0);
|
|
assert!(result.breathing_detected);
|
|
assert!(result.heartbeat_detected);
|
|
assert!(result.movement_detected);
|
|
}
|
|
}
|