From cdad0322969ad496fa63e8c7d5cf4c19de40967f Mon Sep 17 00:00:00 2001 From: Dragan Spiridonov Date: Tue, 15 Sep 2026 14:09:33 +0200 Subject: [PATCH] fix(calibration): only the grid-bound radio may write the single-link baseline A room binding may name several radios, and every one of them was admitted into the field model. That model is single-link: one baseline, one set of amplitude offsets. Averaging two radios' offsets into it flattens the eigenstructure and leaves only a scalar energy threshold behind. Measured on three ESP32-C6 nodes: 4-node binding baseline_eigenvalue_count 0, residual threshold 18.90, empty room reported an occupant in 1162/1162 frames 1-node binding baseline_eigenvalue_count 5, residual threshold 3.65, empty room absent 604/604, occupied present 616/616 `person_count_at` already refused to score a non-bound radio, calling it "deterministic false occupancy from hardware-specific amplitude offsets" -- but only when exactly one node was bound. With more it fell through to the shared mixed history, so the guard never fired in the case it described. Restrict the baseline write, not the admission. Every bound radio is still admitted and recorded as a contributor: `calibration_source_nodes_missing` refuses to finalize until each one proves it is live on the frozen grid, so gating them out of the feed path entirely deadlocks the capture -- measured on hardware as 43,120 frames over 51 minutes stuck in `collecting` with missing_source_node_ids=[12,13,14]. Only `maybe_feed_calibration` is restricted to the grid-bound radio, and scoring follows that same radio whatever the size of the room's node set. This is the narrowing `bootstrap_baseline::store` already applies when it persists `vec![binding.source_node_id]` as the frozen model source. Co-Authored-By: Ruflo & AQE Claude-Session: https://claude.ai/code/session_01Qh7eir6C2U5CSwD7GR43Zv --- .../wifi-densepose-sensing-server/src/main.rs | 104 +++++++++++++++++- 1 file changed, 98 insertions(+), 6 deletions(-) diff --git a/v2/crates/wifi-densepose-sensing-server/src/main.rs b/v2/crates/wifi-densepose-sensing-server/src/main.rs index f37211ca8..04563cf13 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/main.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/main.rs @@ -2113,8 +2113,10 @@ impl AppStateInner { amplitudes: &[f64], observed_at: std::time::Instant, ) -> bool { - // The field model remains single-link, but every radio in the room - // binding may contribute bounded observations on the frozen grid. + // Every bound radio is admitted this far: `calibration_source_nodes_missing` + // requires each one to prove it is present and delivering CSI on the + // frozen grid before a capture may finalize. Only writing the baseline + // is restricted, below. let binding_matches = self .calibration_grid_binding .is_some_and(|binding| binding.grid == grid); @@ -2157,12 +2159,33 @@ impl AppStateInner { } CalibrationSequenceOrder::First | CalibrationSequenceOrder::Forward => {} } + // The field model is single-link: one baseline, one set of amplitude + // offsets. Averaging a second radio's offsets into it flattens the + // eigenstructure and leaves only a scalar energy threshold behind. + // Measured on three ESP32-C6 nodes: a four-node binding finalized with + // baseline_eigenvalue_count 0 and reported an occupant in an empty + // room, where the same room on the bound radio alone finalized with 5 + // and reported absent. + // + // So only the grid-bound radio writes the baseline. The others still + // count as contributors -- they are present on the frozen grid, which + // is exactly what the finalize precondition asks -- they simply do not + // author the model. This mirrors the narrowing `bootstrap_baseline::store` + // already applies when it persists `vec![binding.source_node_id]` as the + // frozen model source. + let writes_baseline = self + .calibration_grid_binding + .is_some_and(|binding| binding.source_node_id == node_id); let accepted = self.field_model.as_mut().is_some_and(|field| { if matches!( field.status(), CalibrationStatus::Uncalibrated | CalibrationStatus::Collecting ) { - field_bridge::maybe_feed_calibration(field, amplitudes) + if writes_baseline { + field_bridge::maybe_feed_calibration(field, amplitudes) + } else { + true + } } else { field.check_freshness( (chrono::Utc::now().timestamp_millis().max(0) as u64) @@ -2261,9 +2284,13 @@ impl AppStateInner { /// the active calibration (or a restored bootstrap image) bound exactly /// one. `None` means no single node owns the baseline. fn bound_source_node_id(&self) -> Option { - (self.calibration_source_node_ids.len() == 1) - .then(|| self.calibration_source_node_ids.iter().next().copied()) - .flatten() + // The grid binding names the one radio that fed the single-link + // baseline, whatever the size of the room's node set. Scoring any + // other radio against that baseline is the documented false-occupancy + // case, so prefer the bound source and never fall back to the shared + // mixed-radio history while a binding exists. + self.calibration_grid_binding + .map(|binding| binding.source_node_id) .or_else(|| { self.bootstrap_baseline.as_ref().and_then(|metadata| { (metadata.source_node_ids.len() == 1) @@ -7231,6 +7258,71 @@ mod bootstrap_vital_publication_tests { assert_eq!(status["missing_source_node_ids"], serde_json::json!([])); } + /// A room may bind several radios for identity, but the single-link field + /// model has one baseline and one set of amplitude offsets. Only the node + /// the grid was bound to may write it; the others stay in the receipt and + /// keep their tracking. Measured consequence of the old behaviour: a + /// four-node binding finalized with baseline_eigenvalue_count 0 and + /// reported an occupant in an empty room. + /// A room may bind several radios for identity, but the single-link field + /// model has one baseline and one set of amplitude offsets, so only the + /// grid-bound radio may author it. The others must still register as + /// contributors: `calibration_source_nodes_missing` refuses to finalize a + /// capture until every bound node has proven it is live on the frozen + /// grid. Gating them out of the feed path entirely deadlocks the capture + /// -- measured on hardware: 43,120 frames over 51 minutes stuck in + /// `collecting` with missing_source_node_ids=[12,13,14]. + #[test] + fn multi_node_binding_contributes_but_only_the_bound_node_writes_the_baseline() { + let mut state = AppStateInner::minimal(); + state.field_model = Some( + FieldModel::new(field_bridge::single_link_config()).expect("field model"), + ); + bind_test_calibration(&mut state); + for node_id in [7_u8, 11, 13] { + state.calibration_source_node_ids.insert(node_id); + state.node_states.entry(node_id).or_insert_with(NodeState::new); + } + assert_eq!( + state.calibration_grid_binding.expect("binding").source_node_id, + 5 + ); + + // Every bound radio is admitted on the frozen grid. + for node_id in [5_u8, 7, 11, 13] { + assert!( + feed_test_frame(&mut state, node_id, 1, &[0.25; 64]), + "node {node_id} must register as a contributor" + ); + } + + // ...so the finalize precondition is satisfiable: nothing is missing. + let missing: Vec = state + .calibration_source_node_ids + .difference(&state.calibration_observed_source_node_ids) + .copied() + .collect(); + assert!( + missing.is_empty(), + "a multi-node binding must still be able to finalize, missing={missing:?}" + ); + + // But only the bound radio authored the baseline. + assert_eq!( + state + .field_model + .as_ref() + .expect("field model") + .calibration_frame_count(), + 1, + "exactly one radio may write the single-link baseline" + ); + + // And scoring follows that same radio. + assert_eq!(state.bound_source_node_id(), Some(5)); + } + + #[test] fn non_bound_grid_never_enters_field_model_history() { let mut state = AppStateInner::minimal();