From c3764a429f77fd9ca62dd13daefe54a4136e7832 Mon Sep 17 00:00:00 2001 From: Dragan Spiridonov Date: Sun, 4 Oct 2026 11:50:13 +0200 Subject: [PATCH] fix(sensing): require calibrated occupancy before publishing vitals --- .../wifi-densepose-sensing-server/src/main.rs | 121 ++++++++++++++++-- 1 file changed, 111 insertions(+), 10 deletions(-) diff --git a/v2/crates/wifi-densepose-sensing-server/src/main.rs b/v2/crates/wifi-densepose-sensing-server/src/main.rs index 180ae6f53..b233710ed 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/main.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/main.rs @@ -2795,6 +2795,82 @@ mod calibration_expiry_tests { assert!(expired.calibrated_presence_evidence(5, 77, u64::MAX / 2).is_none()); } + fn qualified_vital_candidates() -> VitalSigns { + VitalSigns { + breathing_rate_bpm: Some(15.0), + heart_rate_bpm: Some(70.0), + breathing_confidence: 0.9, + heartbeat_confidence: 0.9, + signal_quality: 0.9, + } + } + + fn state_with_runtime_window() -> AppStateInner { + let mut state = state_with_receipt(); + let model = state.field_model.as_mut().unwrap(); + let mut snapshot = model.export_snapshot().unwrap(); + snapshot.modes.baseline_runtime_window_size = Some(50); + *model = FieldModel::from_snapshot(snapshot, 1_500_000).unwrap(); + state + } + + #[test] + fn calibrated_empty_blocks_vitals_even_when_room_heuristic_reports_one() { + let state = state_with_runtime_window(); + assert_eq!(state.person_count_at(1_500), 0); + let candidates = qualified_vital_candidates(); + // This is the old live WebSocket gate: it publishes despite empty RF. + assert!(vitals_for_publication(&candidates, true, 1).is_some()); + assert!(calibrated_vitals_for_publication(&state, &candidates, 1, 1_500).is_none()); + } + + #[test] + fn calibrated_single_occupant_preserves_quality_and_room_gates() { + let mut state = state_with_runtime_window(); + let baseline = state.field_model.as_ref().unwrap().modes().unwrap().baseline[0].clone(); + for offset in [0.1, 0.2, 0.3, 0.5, 0.7, 1.0] { + state.node_states.get_mut(&5).unwrap().field_model_history = + (0..50).map(|_| baseline.iter().map(|value| value + offset).collect()).collect(); + if state.person_count_at(1_500) == 1 { break; } + } + assert_eq!(state.person_count_at(1_500), 1); + let candidates = qualified_vital_candidates(); + assert!(calibrated_vitals_for_publication(&state, &candidates, 1, 1_500).is_some()); + for count in [0, 2, 3] { + assert!(calibrated_vitals_for_publication(&state, &candidates, count, 1_500).is_none()); + } + let weak = VitalSigns { signal_quality: 0.1, ..candidates.clone() }; + assert!(calibrated_vitals_for_publication(&state, &weak, 1, 1_500).is_none()); + state.bootstrap_baseline_active = true; + assert!(calibrated_vitals_for_publication(&state, &candidates, 1, 1_500).is_none()); + state.bootstrap_baseline_active = false; + assert!(calibrated_vitals_for_publication(&state, &candidates, 1, u64::MAX / 2).is_none()); + state.calibration_model_receipt = None; + assert!(calibrated_vitals_for_publication(&state, &candidates, 1, 1_500).is_none()); + } + + #[test] + fn calibrated_vitals_require_a_complete_fresh_unfaulted_source_window() { + let mut state = state_with_runtime_window(); + let candidates = qualified_vital_candidates(); + // Use a positive source window so refusals aren't merely empty-room gating. + let baseline = state.field_model.as_ref().unwrap().modes().unwrap().baseline[0].clone(); + state.node_states.get_mut(&5).unwrap().field_model_history = + (0..50).map(|_| baseline.iter().map(|value| value + 0.3).collect()).collect(); + assert_eq!(state.person_count_at(1_500), 1); + assert!(calibrated_vitals_for_publication(&state, &candidates, 1, 1_500).is_some()); + state.calibration_sequence_fault_node_ids.insert(5); + assert!(calibrated_vitals_for_publication(&state, &candidates, 1, 1_500).is_none()); + state.calibration_sequence_fault_node_ids.clear(); + let node = state.node_states.get_mut(&5).unwrap(); + node.field_model_latest_seen = Some(std::time::Instant::now() - ESP32_OFFLINE_TIMEOUT); + assert!(calibrated_vitals_for_publication(&state, &candidates, 1, 1_500).is_none()); + let node = state.node_states.get_mut(&5).unwrap(); + node.field_model_latest_seen = Some(std::time::Instant::now()); + node.field_model_history.pop_front(); + assert!(calibrated_vitals_for_publication(&state, &candidates, 1, 1_500).is_none()); + } + #[test] fn bootstrap_field_model_remains_negative_only_for_person_count() { let runtime = state_with_model(false); @@ -7274,6 +7350,32 @@ fn vitals_for_publication( }) } +/// The debounced room heuristic can remain positive in an empty room. It +/// cannot authorize numeric rates when the bound field model says absent. +/// Require both the existing room gate and a fresh, complete model window; +/// never use a heuristic fallback as calibrated single-occupant evidence. +fn calibrated_vitals_for_publication( + state: &AppStateInner, + candidates: &VitalSigns, + room_person_count: usize, + observed_at_unix_ms: u64, +) -> Option { + if room_person_count != 1 || !state.calibration_sequence_fault_node_ids.is_empty() { + return None; + } + let binding = state.calibration_grid_binding?; + let window_size = state.field_model.as_ref()?.modes()?.baseline_runtime_window_size?; + if !state.field_model_holdout_ready(binding, window_size, std::time::Instant::now()) { + return None; + } + let evidence = state.calibrated_presence_evidence( + binding.source_node_id, + state.tick, + observed_at_unix_ms, + )?; + vitals_for_publication(candidates, true, evidence.person_count) +} + fn edge_vitals_message_for_publication( raw: &Esp32VitalsPacket, published_vitals: Option<&VitalSigns>, @@ -9173,12 +9275,11 @@ async fn vital_signs_endpoint(State(state): State) -> Json