From ce93244117ce533237b0055048ee13c6540b682b Mon Sep 17 00:00:00 2001 From: Dragan Spiridonov Date: Sun, 11 Oct 2026 14:05:06 +0200 Subject: [PATCH] feat(sensing): integrate decision and holdout diagnostics for Mac qualification --- .../wifi-densepose-sensing-server/README.md | 24 +- .../src/bootstrap_baseline.rs | 286 ++++++++++++++++++ .../src/field_bridge.rs | 61 +++- .../wifi-densepose-sensing-server/src/main.rs | 64 +++- 4 files changed, 427 insertions(+), 8 deletions(-) diff --git a/v2/crates/wifi-densepose-sensing-server/README.md b/v2/crates/wifi-densepose-sensing-server/README.md index 386dabca2..d7e3457d6 100644 --- a/v2/crates/wifi-densepose-sensing-server/README.md +++ b/v2/crates/wifi-densepose-sensing-server/README.md @@ -88,7 +88,29 @@ cargo run -p wifi-densepose-sensing-server -- \ Complete the normal empty-room calibration, then call `POST /api/v1/calibration/bootstrap/promote`. The server measures a separate 12-sample holdout -before storing an aggregate field-model snapshot. On a later start with the same installation ID, +before storing an aggregate field-model snapshot. Promotion requires all of the following: + +- Exactly 12 fresh samples, spaced at least one second apart, with no stale samples. +- At least 10 of the 12 samples classified as empty. +- Zero samples containing numeric vital-sign estimates. + +The 10-of-12 tolerance applies only to the empty classification. A holdout with 12 empty samples +and five samples containing vital-sign estimates still fails; a vital-sign estimate does not +prove occupancy, but it prevents storing the startup baseline under this conservative check. + +Completed holdout evaluations return the existing `validation` counters and additive +`validation_diagnostics.requirements` and `validation_diagnostics.failures` fields. A failed +check retains `success: false`, `error_code`, and `error`; the error describes the observed +counts and required limits. Each failure identifies its `check`, `observed` and `required` +counts, and `comparison`, so clients can explain every failed condition. For example, five +vital-sign samples produce a `vital_sign_sample_count` failure with `observed: 5`, +`required: 0`, and `comparison: "at_most"`. + +The bounded refinement endpoint, `POST /api/v1/calibration/bootstrap/refine`, also requires +12 fresh samples with zero stale or vital-sign samples. Its diagnostics omit the empty-sample +minimum because refinement does not apply that promotion-only check. + +On a later start with the same installation ID, the snapshot is restored with `bootstrap_only` authority. It can reduce startup background false positives, but cannot authorize calibrated evidence or numeric heart and breathing rates. Use `POST /api/v1/calibration/reset` with administrator scope to remove it. diff --git a/v2/crates/wifi-densepose-sensing-server/src/bootstrap_baseline.rs b/v2/crates/wifi-densepose-sensing-server/src/bootstrap_baseline.rs index b6ce978c8..326e2f5fa 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/bootstrap_baseline.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/bootstrap_baseline.rs @@ -90,6 +90,131 @@ pub struct BootstrapValidationResult { pub passed: bool, } +/// Independent ADR-355 requirements. Refinement does not count empty votes. +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct BootstrapValidationRequirements { + pub sample_count: usize, + #[serde(skip_serializing_if = "Option::is_none")] + pub min_empty_sample_count: Option, + pub max_stale_sample_count: usize, + pub max_vital_sign_sample_count: usize, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct BootstrapValidationFailure { + pub check: &'static str, + pub observed: usize, + pub required: usize, + pub comparison: &'static str, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct BootstrapValidationDiagnostics { + pub requirements: BootstrapValidationRequirements, + pub failures: Vec, +} + +impl BootstrapValidationResult { + pub fn diagnostics(&self) -> BootstrapValidationDiagnostics { + validation_diagnostics( + self.sample_count, + Some(self.empty_sample_count), + self.stale_sample_count, + self.vital_sign_sample_count, + ) + } +} + +impl BootstrapValidationDiagnostics { + /// Describe every failed check, without interpreting estimates as people. + pub fn failure_summary(&self) -> String { + self.failures + .iter() + .map(|failure| match failure.check { + "sample_count" => format!( + "{} samples collected (exactly {} required); check the CSI stream", + failure.observed, failure.required, + ), + "empty_sample_count" => format!( + "{} samples reported empty (at least {} required); vacate the room and check for moving objects", + failure.observed, failure.required, + ), + "stale_sample_count" => format!( + "{} stale samples ({} allowed); check the CSI stream", + failure.observed, failure.required, + ), + _ => format!( + "numeric vital-sign estimates in {}/{} samples ({} allowed); vacate the room and check for moving objects", + failure.observed, self.requirements.sample_count, failure.required, + ), + }) + .collect::>() + .join("; ") + } +} + +/// Explain existing gate counters without changing how samples are counted. +/// Promotion supplies all attempted samples; refinement supplies only fresh +/// residuals. Stale samples are a separate check, never added to sample_count. +pub fn validation_diagnostics( + sample_count: usize, + empty_sample_count: Option, + stale_sample_count: usize, + vital_sign_sample_count: usize, +) -> BootstrapValidationDiagnostics { + let requirements = BootstrapValidationRequirements { + sample_count: BOOTSTRAP_VALIDATION_SAMPLES, + min_empty_sample_count: empty_sample_count.map(|_| BOOTSTRAP_VALIDATION_MIN_EMPTY), + max_stale_sample_count: 0, + max_vital_sign_sample_count: 0, + }; + let mut failures = Vec::new(); + let checks = [ + ( + "sample_count", + sample_count, + requirements.sample_count, + "equal", + sample_count != requirements.sample_count, + ), + ( + "empty_sample_count", + empty_sample_count.unwrap_or(0), + BOOTSTRAP_VALIDATION_MIN_EMPTY, + "at_least", + empty_sample_count.is_some_and(|count| count < BOOTSTRAP_VALIDATION_MIN_EMPTY), + ), + ( + "stale_sample_count", + stale_sample_count, + requirements.max_stale_sample_count, + "at_most", + stale_sample_count != 0, + ), + ( + "vital_sign_sample_count", + vital_sign_sample_count, + requirements.max_vital_sign_sample_count, + "at_most", + vital_sign_sample_count != 0, + ), + ]; + for (check, observed, required, comparison, failed) in checks { + if failed { + failures.push(BootstrapValidationFailure { + check, + observed, + required, + comparison, + }); + } + } + BootstrapValidationDiagnostics { + requirements, + failures, + } +} + #[derive(Debug, Error)] pub enum BootstrapBaselineError { #[error("a stable installation_id is required for a local bootstrap baseline")] @@ -505,6 +630,167 @@ mod tests { assert!(!evaluate_validation(&vital).passed); } + fn empty_validation_samples(count: usize) -> Vec { + vec![ + BootstrapValidationSample { + fresh_tick: true, + calibrated_empty: true, + vital_signs_absent: true, + }; + count + ] + } + + #[test] + fn validation_diagnostics_keep_empty_tolerance_separate_from_vitals() { + let mut samples = empty_validation_samples(12); + samples[0].calibrated_empty = false; + samples[1].calibrated_empty = false; + let passing = evaluate_validation(&samples); + assert!(passing.passed); + assert_eq!(passing.empty_sample_count, 10); + assert!(passing.diagnostics().failures.is_empty()); + assert!(passing.diagnostics().failure_summary().is_empty()); + + samples[2].calibrated_empty = false; + let rejected = evaluate_validation(&samples); + assert!(!rejected.passed); + assert_eq!( + rejected.diagnostics().failures, + vec![BootstrapValidationFailure { + check: "empty_sample_count", + observed: 9, + required: 10, + comparison: "at_least", + }] + ); + } + + #[test] + fn validation_diagnostics_explain_numeric_vital_veto_even_when_all_empty() { + for count in [1, 5] { + let mut samples = empty_validation_samples(12); + for sample in samples.iter_mut().take(count) { + sample.vital_signs_absent = false; + } + let result = evaluate_validation(&samples); + assert!(!result.passed); + assert_eq!(result.empty_sample_count, 12); + let diagnostics = result.diagnostics(); + let json = serde_json::to_value(&diagnostics).unwrap(); + assert_eq!( + json["requirements"], + serde_json::json!({ + "sample_count": 12, + "min_empty_sample_count": 10, + "max_stale_sample_count": 0, + "max_vital_sign_sample_count": 0, + }) + ); + assert_eq!( + json["failures"], + serde_json::json!([{ + "check": "vital_sign_sample_count", + "observed": count, + "required": 0, + "comparison": "at_most", + }]) + ); + assert!(diagnostics.failure_summary().contains(&format!( + "numeric vital-sign estimates in {count}/12 samples (0 allowed)" + ))); + } + } + + #[test] + fn validation_diagnostics_reject_stale_incomplete_and_oversized_windows() { + for count in [0, 11, 13] { + let result = evaluate_validation(&empty_validation_samples(count)); + assert!(!result.passed); + let diagnostics = result.diagnostics(); + assert_eq!(diagnostics.failures[0].check, "sample_count"); + assert_eq!(diagnostics.failures[0].observed, count); + assert_eq!(diagnostics.failures[0].required, 12); + assert!(diagnostics + .failure_summary() + .contains("check the CSI stream")); + } + let mut samples = empty_validation_samples(12); + samples[0].fresh_tick = false; + let result = evaluate_validation(&samples); + assert!(!result.passed); + assert_eq!(result.sample_count, 12); + assert_eq!(result.empty_sample_count, 11); + assert_eq!( + result.diagnostics().failures, + vec![BootstrapValidationFailure { + check: "stale_sample_count", + observed: 1, + required: 0, + comparison: "at_most", + }] + ); + } + + #[test] + fn validation_diagnostics_report_all_simultaneous_failures() { + let mut samples = empty_validation_samples(11); + samples[0].fresh_tick = false; + samples[1].calibrated_empty = false; + samples[2].vital_signs_absent = false; + let result = evaluate_validation(&samples); + assert!(!result.passed); + let diagnostics = result.diagnostics(); + assert_eq!( + diagnostics + .failures + .iter() + .map(|failure| failure.check) + .collect::>(), + [ + "sample_count", + "empty_sample_count", + "stale_sample_count", + "vital_sign_sample_count", + ] + ); + let summary = diagnostics.failure_summary(); + for expected in [ + "11 samples collected (exactly 12 required)", + "9 samples reported empty (at least 10 required)", + "1 stale samples (0 allowed)", + "numeric vital-sign estimates in 1/12 samples (0 allowed)", + ] { + assert!(summary.contains(expected), "missing {expected}: {summary}"); + } + } + + #[test] + fn refinement_diagnostics_keep_fresh_count_and_omit_empty_vote_requirement() { + let diagnostics = validation_diagnostics(11, None, 1, 5); + let json = serde_json::to_value(&diagnostics).unwrap(); + assert!(json["requirements"].get("min_empty_sample_count").is_none()); + assert_eq!( + diagnostics + .failures + .iter() + .map(|failure| failure.check) + .collect::>(), + [ + "sample_count", + "stale_sample_count", + "vital_sign_sample_count", + ] + ); + assert_eq!(diagnostics.failures[0].observed, 11); + assert!(diagnostics + .failure_summary() + .contains("5/12 samples (0 allowed)")); + let passing = validation_diagnostics(12, None, 0, 0); + assert!(passing.failures.is_empty()); + assert!(passing.failure_summary().is_empty()); + } + #[test] fn restart_verifies_the_exact_stored_payload_bytes() { let dir = tempfile::tempdir().unwrap(); diff --git a/v2/crates/wifi-densepose-sensing-server/src/field_bridge.rs b/v2/crates/wifi-densepose-sensing-server/src/field_bridge.rs index 2e3f5d5bb..6793cc26f 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/field_bridge.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/field_bridge.rs @@ -24,6 +24,9 @@ pub const CALIBRATION_MODEL_RECEIPT_SCHEMA: &str = pub const CALIBRATED_PRESENCE_EVIDENCE_SCHEMA: &str = "ruview.calibration.calibrated-presence-evidence.v2"; +/// Schema for bounded explanations, independent from calibration authority. +pub const DETECTOR_DIAGNOSTICS_SCHEMA: &str = "ruview.calibration.detector-diagnostics.v1"; + /// Length-only canonicalizer for calibration frames (issue #1170 pattern, /// shared with `multistatic_bridge`). Raw ESP32 amplitudes arrive at the /// hardware's native width (HT20 ≈ 64, HT40 ≈ 128/192); the FieldModel is @@ -90,7 +93,7 @@ fn mean_frame(frames: &[Vec]) -> Option> { Some(mean) } -fn perturbation_occupancy(field: &FieldModel, frames: &[Vec]) -> Option { +fn perturbation_occupancy_with_diagnostics(field: &FieldModel, frames: &[Vec]) -> Option<(usize, OccupancyDiagnostics)> { let adaptive_threshold = field.empty_room_residual_energy_threshold(); let frame = match adaptive_threshold { Some(_) => mean_frame(frames)?, @@ -122,7 +125,19 @@ fn perturbation_occupancy(field: &FieldModel, frames: &[Vec]) -> Option]) -> Option { + perturbation_occupancy_with_diagnostics(field, frames).map(|(count, _)| count) } /// Provenance for a count produced by the calibrated field model. Callers @@ -143,10 +158,21 @@ impl CalibratedOccupancyMethod { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +/// Explanation of the very same scoring operation; it grants no authority. +#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize)] +pub struct OccupancyDiagnostics { + pub residual_energy: Option, + pub decision_threshold: Option, + pub signed_margin: Option, + pub window_frames: usize, + pub fallback_reason: Option<&'static str>, +} + +#[derive(Debug, Clone, Copy, PartialEq)] pub struct CalibratedOccupancy { pub person_count: usize, pub method: CalibratedOccupancyMethod, + pub diagnostics: OccupancyDiagnostics, } /// Create a FieldModelConfig for single-link mode (one ESP32 node = one link). @@ -284,11 +310,15 @@ pub fn calibrated_occupancy( return Some(CalibratedOccupancy { person_count: person_count.min(MAX_SINGLE_LINK_OCCUPANCY), method: CalibratedOccupancyMethod::Eigenvalue, + diagnostics: OccupancyDiagnostics { + residual_energy: None, decision_threshold: None, signed_margin: None, + window_frames: frames.len(), fallback_reason: None, + }, }); } + let (person_count, diagnostics) = perturbation_occupancy_with_diagnostics(field, &frames)?; Some(CalibratedOccupancy { - person_count: perturbation_occupancy(field, &frames)?, - method: CalibratedOccupancyMethod::PerturbationEnergy, + person_count, method: CalibratedOccupancyMethod::PerturbationEnergy, diagnostics, }) } @@ -799,6 +829,27 @@ mod tests { ); } + #[test] + fn diagnostic_margin_explains_the_same_perturbation_decision() { + let field = fresh_test_model(); + let baseline = field.modes().unwrap().baseline[0].clone(); + for offset in [0.0, 0.3, 5.0, 20.0] { + let history: VecDeque> = (0..50) + .map(|_| baseline.iter().map(|value| value + offset).collect()).collect(); + let result = calibrated_occupancy(&field, &history, 1_500_000).unwrap(); + assert_eq!(result.person_count, occupancy_or_fallback(&field, &history, 1_500_000, 0.0, 0)); + if result.method == CalibratedOccupancyMethod::PerturbationEnergy { + let energy = result.diagnostics.residual_energy.unwrap(); + let threshold = result.diagnostics.decision_threshold.unwrap(); + assert_eq!(result.diagnostics.signed_margin, Some(energy - threshold)); + assert_eq!(result.person_count > 0, energy > threshold); + assert_eq!(result.diagnostics.window_frames, 50); + } else { + assert_eq!(result.diagnostics.signed_margin, None); + } + } + } + #[test] fn occupancy_falls_back_after_expiry_without_a_process_restart() { let field = fresh_test_model(); diff --git a/v2/crates/wifi-densepose-sensing-server/src/main.rs b/v2/crates/wifi-densepose-sensing-server/src/main.rs index 63ea44184..d35f8830f 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/main.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/main.rs @@ -474,6 +474,19 @@ struct CalibratedPresenceEvidence { inference_method: String, presence: bool, person_count: usize, + #[serde(skip)] + diagnostics: Option, +} + +/// Bounded decision explanation, separate from the strict authority envelope. +#[derive(Debug, Clone, Serialize)] +struct DetectorDiagnostics { + schema: &'static str, + scored_node_id: u8, + source_age_ms: u64, + source_sequence: u32, + required_window_frames: usize, + runtime: field_bridge::OccupancyDiagnostics, } /// Sensing update broadcast to WebSocket clients @@ -495,6 +508,8 @@ struct SensingUpdate { /// active model receipt. Omitted when no calibrated result is available. #[serde(default, skip_serializing_if = "Option::is_none")] calibrated_presence_evidence: Option, + #[serde(skip_deserializing, skip_serializing_if = "Option::is_none")] + detector_diagnostics: Option, // ── ADR-022 Phase 3: Enhanced multi-BSSID pipeline fields ── /// Enhanced motion estimate from multi-BSSID pipeline. #[serde(skip_serializing_if = "Option::is_none")] @@ -2793,6 +2808,17 @@ impl AppStateInner { inference_method: occupancy.method.wire_name().to_string(), presence: occupancy.person_count > 0, person_count: occupancy.person_count, + diagnostics: self.calibration_grid_binding.and_then(|binding| { + let node = self.node_states.get(&binding.source_node_id)?; + Some(DetectorDiagnostics { + schema: field_bridge::DETECTOR_DIAGNOSTICS_SCHEMA, + scored_node_id: binding.source_node_id, + source_age_ms: node.field_model_latest_seen?.elapsed().as_millis().min(u64::MAX as u128) as u64, + source_sequence: node.field_model_latest_sequence?, + required_window_frames: self.field_model.as_ref()?.modes()?.baseline_runtime_window_size.unwrap_or(50), + runtime: occupancy.diagnostics, + }) + }), }) } @@ -3235,6 +3261,21 @@ mod calibration_expiry_tests { state } + #[test] + fn diagnostics_stay_outside_the_strict_calibration_authority_envelope() { + let state = state_with_runtime_window(); + let evidence = state.calibrated_presence_evidence(5, 77, 1_500).unwrap(); + let diagnostic = evidence.diagnostics.as_ref().expect("bound source diagnostics"); + assert_eq!(diagnostic.scored_node_id, 5); + assert_eq!(diagnostic.runtime.window_frames, 50); + let encoded = serde_json::to_value(&evidence).unwrap(); + assert!(encoded.get("diagnostics").is_none()); + assert_eq!(encoded.as_object().unwrap().len(), 13); + assert_eq!(encoded["person_count"], 0); + assert_eq!(serde_json::to_value(diagnostic).unwrap()["schema"], + field_bridge::DETECTOR_DIAGNOSTICS_SCHEMA); + } + #[test] fn calibrated_empty_blocks_vitals_even_when_room_heuristic_reports_one() { let state = state_with_runtime_window(); @@ -5058,6 +5099,7 @@ async fn wifi_task(state: SharedState, tick_ms: u64) { ), vital_signs: None, calibrated_presence_evidence: None, + detector_diagnostics: None, enhanced_motion, enhanced_breathing, posture: posture_str, @@ -5227,6 +5269,7 @@ async fn windows_wifi_fallback_tick(state: &SharedState, seq: u32) { ), vital_signs: None, calibrated_presence_evidence: None, + detector_diagnostics: None, enhanced_motion: None, enhanced_breathing: None, posture: None, @@ -6117,6 +6160,7 @@ mod latest_update_staleness_tests { classifier: None, activity: None, calibrated_presence_evidence: None, + detector_diagnostics: None, enhanced_motion: None, enhanced_breathing: None, posture: None, @@ -10036,12 +10080,14 @@ async fn calibration_promote_bootstrap( } let validation = bootstrap_baseline::evaluate_validation(&samples); + let validation_diagnostics = validation.diagnostics(); if !validation.passed { return Json(serde_json::json!({ "success": false, "error_code": "bootstrap_validation_failed", - "error": "The held out empty room check did not pass. The model was not stored.", + "error": format!("The held out empty room check did not pass: {}. The model was not stored.", validation_diagnostics.failure_summary()), "validation": validation, + "validation_diagnostics": validation_diagnostics, })); } @@ -10107,6 +10153,7 @@ async fn calibration_promote_bootstrap( "success": true, "message": "Validated startup baseline stored locally.", "validation": validation, + "validation_diagnostics": validation_diagnostics, "holdout_window_size": holdout_window_size, "bootstrap_baseline": stored, "calibrated_evidence_authorized": false, @@ -10236,6 +10283,12 @@ async fn calibration_refine_bootstrap( + Duration::from_millis(BOOTSTRAP_VALIDATION_MIN_SPACING_MS); } + let validation_diagnostics = bootstrap_baseline::validation_diagnostics( + residuals.len(), + None, + stale_sample_count, + vital_sign_sample_count, + ); if residuals.len() != BOOTSTRAP_VALIDATION_SAMPLES || stale_sample_count != 0 || vital_sign_sample_count != 0 @@ -10243,12 +10296,13 @@ async fn calibration_refine_bootstrap( return Json(serde_json::json!({ "success": false, "error_code": "bootstrap_refinement_validation_failed", - "error": "The fresh empty-room refinement gate did not pass. The stored baseline was not changed.", + "error": format!("The fresh empty-room refinement gate did not pass: {}. The stored baseline was not changed.", validation_diagnostics.failure_summary()), "validation": { "sample_count": residuals.len(), "stale_sample_count": stale_sample_count, "vital_sign_sample_count": vital_sign_sample_count, }, + "validation_diagnostics": validation_diagnostics, })); } @@ -10335,6 +10389,7 @@ async fn calibration_refine_bootstrap( Json(serde_json::json!({ "success": true, "message": "The local empty-room startup baseline was refined.", + "validation_diagnostics": validation_diagnostics, "validation": { "sample_count": residuals.len(), "stale_sample_count": stale_sample_count, @@ -11709,6 +11764,7 @@ fn mediatek_room_update( }, vital_signs: None, calibrated_presence_evidence: None, + detector_diagnostics: None, enhanced_motion: None, enhanced_breathing: None, posture: None, @@ -12629,6 +12685,7 @@ async fn udp_receiver_task( classification, signal_field, vital_signs: published_vitals, + detector_diagnostics: calibrated_presence_evidence.as_ref().and_then(|e| e.diagnostics.clone()), calibrated_presence_evidence, enhanced_motion: None, enhanced_breathing: None, @@ -13124,6 +13181,7 @@ async fn udp_receiver_task( &sub_variances, ), vital_signs: published_vitals, + detector_diagnostics: calibrated_presence_evidence.as_ref().and_then(|e| e.diagnostics.clone()), calibrated_presence_evidence, enhanced_motion: None, enhanced_breathing: None, @@ -13364,6 +13422,7 @@ async fn simulated_data_task(state: SharedState, tick_ms: u64) { ), vital_signs: None, calibrated_presence_evidence: None, + detector_diagnostics: None, enhanced_motion: None, enhanced_breathing: None, posture: None, @@ -16068,6 +16127,7 @@ mod observatory_persons_field_position_tests { signal_field, vital_signs: None, calibrated_presence_evidence: None, + detector_diagnostics: None, enhanced_motion: None, enhanced_breathing: None, posture: None,