diff --git a/docs/adr/ADR-355-local-empty-room-bootstrap-prior.md b/docs/adr/ADR-355-local-empty-room-bootstrap-prior.md index 5eb4ba732..1ec6ee36d 100644 --- a/docs/adr/ADR-355-local-empty-room-bootstrap-prior.md +++ b/docs/adr/ADR-355-local-empty-room-bootstrap-prior.md @@ -25,8 +25,10 @@ prior only after two server controlled stages: 1. Normal calibration reaches both 1,000 accepted frames and 600 seconds. The frame target represents twenty complete fifty frame runtime windows. - Collection binds to the first accepted ESP32 source and rejects frames from - other radios for that single link model. + A caller may select one currently live ESP32 source. The server checks its + freshness before replacing an active bootstrap prior, binds collection to + that source, and rejects frames from other radios. When no source is + selected, the first accepted ESP32 source preserves legacy behavior. 2. Promotion observes 12 new samples spaced by at least one second. Each sample has a four second bounded acquisition timeout. At least 10 must resolve empty, all ticks must be fresh, and none may publish numeric vital @@ -89,6 +91,9 @@ occupied sequences. Empty data is never relabeled as human training. gate as `sensing_update`. When the bootstrap background matches, it emits absent state, zero people, null numeric rates, and an explicit abstention reason instead of forwarding raw edge candidates. +7. `POST /api/v1/calibration/start?source_node_id=N` selects a live source for + deterministic single link capture. A missing or stale selection fails + without disabling the active bootstrap prior. ## Measured installation evidence @@ -111,3 +116,5 @@ and data directory. Status must report `binding_mode=bootstrap_only`, zero collection frames, and false authority for calibrated evidence and numeric vitals. Twelve fresh empty samples must contain no numeric vital signs. Starting a new room calibration must replace the prior and begin at frame zero. +When several radios are live, selecting Node 5 must leave Nodes 3 and 7 out of +the source set for the entire capture. diff --git a/v2/crates/wifi-densepose-sensing-server/src/main.rs b/v2/crates/wifi-densepose-sensing-server/src/main.rs index 0c0628301..16261c1a1 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/main.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/main.rs @@ -6159,6 +6159,14 @@ mod bootstrap_vital_publication_tests { assert!(!calibration_source_accepts(&sources, 3)); } + #[test] + fn explicit_single_link_source_rejects_other_live_nodes() { + let sources = std::collections::BTreeSet::from([5]); + assert!(calibration_source_accepts(&sources, 5)); + assert!(!calibration_source_accepts(&sources, 3)); + assert!(!calibration_source_accepts(&sources, 7)); + } + fn strong_candidates() -> VitalSigns { VitalSigns { breathing_rate_bpm: Some(15.0), @@ -6249,8 +6257,33 @@ mod bootstrap_vital_publication_tests { // ── Field model calibration endpoints (eigenvalue person counting) ────────── -async fn calibration_start(State(state): State) -> Json { +#[derive(Debug, Default, Deserialize)] +struct CalibrationStartQuery { + /// Optional explicit single-link source. When omitted, the first accepted + /// ESP32 packet preserves the legacy binding behavior. + source_node_id: Option, +} + +async fn calibration_start( + State(state): State, + Query(query): Query, +) -> Json { let mut s = state.write().await; + if let Some(source_node_id) = query.source_node_id { + let now = std::time::Instant::now(); + let source_is_live = s.node_states.get(&source_node_id).is_some_and(|node| { + node.last_frame_time + .is_some_and(|seen| now.saturating_duration_since(seen) < ESP32_OFFLINE_TIMEOUT) + }); + if !source_is_live { + return Json(serde_json::json!({ + "success": false, + "error_code": "calibration_source_unavailable", + "error": "The requested calibration source is missing or stale.", + "source_node_id": source_node_id, + })); + } + } if s.bootstrap_baseline_active { s.field_model = None; s.bootstrap_baseline_active = false; @@ -6279,10 +6312,14 @@ async fn calibration_start(State(state): State) -> Json