From 7cdf5714e8141265fb4f4e48dc20938e2e7ce1d3 Mon Sep 17 00:00:00 2001 From: Joe Date: Sun, 30 Aug 2026 20:57:26 -0400 Subject: [PATCH 01/27] feat(firmware): align the CSI rate gate to mesh time HOW the 50 Hz rate is limited decides whether cross-node fusion is possible at all, and the original gate quietly made it impossible. The gate was "at least 20 ms since MY last accept". Each node therefore has an independent phase: node A can accept at t=0,20,40 ms while node B accepts at 7,27,47. Both are perfectly healthy, both hit 50 Hz, and they accept disjoint frames -- so no two nodes ever hold a measurement of the same transmission and there is nothing to pair. MEASURED 2026-08-30 with two boards side by side, logging (tx_mac, rx_seq) before and after the gate: they HEARD the same transmissions 72% of the time but only 25% of ACCEPTED frames were common. The frames were there; the gate was throwing away the pairing. (Also settles an open question from ADR-345: rx_seq is populated -- 4099 distinct values across ~44,000 frames, both nodes reporting the identical range.) Now gates on a bucket of the mesh-aligned epoch, so every node takes the first frame it hears in the same absolute 20 ms window and frames both nodes heard are frames both nodes keep. Mesh sync is good to roughly 490 us against a 20 ms bucket -- about 2.5% of a window -- so boundary disagreements are rare. Falls back to the original elapsed-time gate whenever c6_sync_espnow_is_valid() is false, because an unsynced epoch would otherwise gate on a meaningless number; a node that has not heard a leader yet simply gets the old behaviour. The half-interval floor is deliberate: bucket gating alone could accept at the end of one window and the start of the next, back to back, and this gate exists to prevent an SPI flash cache crash in the WiFi ISR, not for tidiness. Behind CONFIG_CSI_GATE_MESH_ALIGNED (default y) so it can be A/B'd against the old gate; CONFIG_CSI_SEQ_DIAG measures the difference directly. NOT yet deployed to the fleet, and not yet enough on its own: the wire format carries a per-node counter, not rx_seq, so the server still cannot pair frames. Wire v3 has to carry rx_seq before any of this is usable off a serial cable. Co-Authored-By: claude-flow (cherry picked from commit 4b54ef8ecb2a28d173d411a064809dc081c43f52) --- firmware/esp32-csi-node/main/csi_collector.c | 51 +++++++++++++++++++- 1 file changed, 49 insertions(+), 2 deletions(-) diff --git a/firmware/esp32-csi-node/main/csi_collector.c b/firmware/esp32-csi-node/main/csi_collector.c index 6a15443f..52b3a900 100644 --- a/firmware/esp32-csi-node/main/csi_collector.c +++ b/firmware/esp32-csi-node/main/csi_collector.c @@ -114,6 +114,9 @@ static int64_t s_last_send_us = 0; */ #define CSI_MIN_PROCESS_INTERVAL_US (20 * 1000) /* 50 Hz */ static int64_t s_last_process_us = 0; +/* Mesh-epoch window index of the last accepted frame. UINT64_MAX means "none + * yet", which no real bucket can collide with. */ +static uint64_t s_last_gate_bucket = UINT64_MAX; static uint32_t s_early_drop = 0; /* ---- ADR-029: Channel-hop state ---- */ @@ -277,9 +280,53 @@ static void wifi_csi_callback(void *ctx, wifi_csi_info_t *info) (void)ctx; /* Early rate gate: drop excess callbacks to ~50 Hz to prevent - * SPI flash cache crash in WiFi ISR (wDev_ProcessFiq). */ + * SPI flash cache crash in WiFi ISR (wDev_ProcessFiq). + * + * HOW the rate is limited decides whether cross-node fusion is possible. + * + * The original gate is "at least 20 ms since MY last accept". Each node + * therefore has an independent phase: node A can accept at t=0,20,40 ms + * while node B accepts at 7,27,47. Both are perfectly healthy and both hit + * 50 Hz, yet they accept disjoint frames and nothing can be paired. + * + * MEASURED 2026-08-30, two boards side by side: they HEARD the same + * transmissions 72% of the time but only 25% of accepted frames were + * common. The frames were there; the gate was throwing away the pairing. + * + * So gate on a bucket of the MESH-ALIGNED epoch instead: every node takes + * the first frame it hears in the same absolute 20 ms window. Two nodes + * that heard the same frame now both accept it. Mesh sync is good to + * roughly 490 us against a 20 ms bucket -- about 2.5% of a window -- so + * boundary disagreements are rare. + * + * Falls back to the original elapsed-time gate whenever mesh sync is not + * valid (no leader heard yet, or a node that just booted), because an + * unsynced epoch would otherwise gate on a meaningless number. A node + * running unsynced simply gets the old behaviour rather than no gate. + * + * The half-interval floor below keeps the crash protection honest: bucket + * gating alone could accept at the end of one window and the start of the + * next, back to back. The floor bounds that to ~100 Hz for a single pair + * while the average stays at 50 Hz. Do not remove it; the gate exists for + * a crash, not for tidiness. */ int64_t now_us = esp_timer_get_time(); - if ((now_us - s_last_process_us) < CSI_MIN_PROCESS_INTERVAL_US) { + bool take; +#ifdef CONFIG_CSI_GATE_MESH_ALIGNED + if (c6_sync_espnow_is_valid()) { + uint64_t bucket = c6_sync_espnow_get_epoch_us() + / (uint64_t)CSI_MIN_PROCESS_INTERVAL_US; + take = (bucket != s_last_gate_bucket) + && ((now_us - s_last_process_us) >= CSI_MIN_PROCESS_INTERVAL_US / 2); + if (take) { + s_last_gate_bucket = bucket; + } + } else { + take = (now_us - s_last_process_us) >= CSI_MIN_PROCESS_INTERVAL_US; + } +#else + take = (now_us - s_last_process_us) >= CSI_MIN_PROCESS_INTERVAL_US; +#endif + if (!take) { s_early_drop++; return; } From d6347fe12ef65035381ae32da1cb766e1cc4868a Mon Sep 17 00:00:00 2001 From: Joe Date: Fri, 4 Sep 2026 11:53:40 -0400 Subject: [PATCH 02/27] fix(sensing-server): key node positions by node id, not by list position Fixes the indexing mismatch found in review. Positions were inserted keyed by the entry's index in the parsed list and read back by the node's actual node_id: for (idx, p) in positions.iter().enumerate() { node_positions_config.insert(idx as u8, *p); // key = index } ... s.node_positions_config.get(&id) // lookup = node_id On any fleet not numbered 0,1,2,... every lookup misses and falls through to the hardcoded [2.0, 0.0, 1.5] that --node-positions exists to replace. The config reads as applied and does nothing, which is worse than not having wired it up. It passed here only because these nine boards happen to be 0..8. --node-positions now accepts an explicit form alongside the positional one: x,y,z;... positional, identity is the index (unchanged) node_id:x,y,z;... explicit, identity is the stated id Both are accepted and may be mixed, so existing invocations keep working. Coordinates never contain a colon, so the split is unambiguous. An unparseable or out-of-range id is skipped rather than guessed at -- falling back to positional there would reintroduce the ambiguity being removed, and 256 must not wrap to node 0. Explicit ids are the better form on a real fleet: with the positional one, the binding of a position to a board is implied only by argument order, which is exactly the fragility that produced this bug. MultistaticFuser::set_node_positions() is deliberately left alone. It consumes the list positionally, matched to per-frame iteration order rather than to node id -- a real and separate concern, but this change is about the NodeInfo output and quietly altering fusion semantics inside it would be the wrong scope. The fuser receives the same list, in the order given, as before. The map is now built by field_bridge::node_positions_by_id() rather than a loop in main(). That is the point: the defect lived in a loop no test could reach. Nineteen tests cover it, including non-sequential ids (11,12,13), a fleet re-homed to 0,2,4,...,16 with two-digit ids, a fleet that does not start at zero, and an assertion that the list indices are NOT keys -- which is the bug stated directly. The four pre-existing parse tests are untouched and still pass, so the positional path is unchanged. 265 server tests pass. Co-Authored-By: claude-flow --- .../wifi-densepose-sensing-server/src/cli.rs | 7 +- .../src/field_bridge.rs | 263 +++++++++++++++++- .../wifi-densepose-sensing-server/src/main.rs | 22 +- 3 files changed, 274 insertions(+), 18 deletions(-) diff --git a/v2/crates/wifi-densepose-sensing-server/src/cli.rs b/v2/crates/wifi-densepose-sensing-server/src/cli.rs index 7ec447bd..80e4356b 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/cli.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/cli.rs @@ -178,7 +178,12 @@ pub struct Args { #[arg(long, value_name = "TYPE")] pub build_index: Option, - /// Node positions for multistatic fusion (format: "x,y,z;x,y,z;...") + /// Node positions for multistatic fusion. + /// + /// Two forms, mixable: "x,y,z;..." positional, where each entry's identity + /// is its index, and "node_id:x,y,z;..." explicit. Prefer the explicit form + /// unless the fleet is numbered 0,1,2,... — otherwise "the third entry" and + /// "node 3" are different nodes and nothing in the string says which is meant. #[arg(long, env = "SENSING_NODE_POSITIONS")] pub node_positions: Option, 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 5b7f588f..0520f072 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/field_bridge.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/field_bridge.rs @@ -136,33 +136,80 @@ pub fn maybe_feed_calibration(field: &mut FieldModel, frame_history: &VecDeque Vec<[f32; 3]> { +/// `node_id: None` means the entry was given positionally, so its identity is +/// its index in the list — which is only the same thing as a node id on a fleet +/// numbered `0, 1, 2, ...`. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct NodePositionEntry { + pub node_id: Option, + pub position: [f32; 3], +} + +/// Parse node positions, keeping any explicit node ids. +/// +/// Two accepted forms, per entry: +/// +/// * `x,y,z` — positional; identity is the entry's index. +/// * `node_id:x,y,z` — explicit; identity is the stated node id. +/// +/// The explicit form exists because the positional one is only unambiguous on a +/// fleet whose ids happen to run `0, 1, 2, ...`. Anywhere else, "the third +/// entry" and "node 3" are different nodes, and nothing in the string says +/// which was meant. Naming the id removes the guess rather than relocating it. +/// +/// Both forms may be mixed; a positional entry keeps its index as its identity. +/// Malformed entries are skipped with a warning rather than failing the boot — +/// a typo in one triplet should not take the server down. +pub fn parse_node_position_entries(input: &str) -> Vec { if input.is_empty() { return Vec::new(); } input .split(';') .enumerate() - .filter_map(|(idx, triplet)| { + .filter_map(|(idx, raw)| { + let entry = raw.trim(); + if entry.is_empty() { + return None; + } + // An explicit id is everything before the first ':'. Coordinates + // never contain one, so this cannot be ambiguous. + let (node_id, triplet) = match entry.split_once(':') { + Some((id_str, rest)) => match id_str.trim().parse::() { + Ok(id) => (Some(id), rest), + Err(_) => { + tracing::warn!( + "Skipping node position entry {idx}: '{entry}' has \ + an unparseable node id before ':' (expected \ + node_id:x,y,z with node_id in 0..=255)" + ); + return None; + } + }, + None => (None, entry), + }; + let parts: Vec<&str> = triplet.split(',').collect(); if parts.len() != 3 { tracing::warn!( - "Skipping malformed node position entry {idx}: '{triplet}' (expected x,y,z)" + "Skipping malformed node position entry {idx}: '{entry}' (expected x,y,z)" ); return None; } match ( - parts[0].parse::(), - parts[1].parse::(), - parts[2].parse::(), + parts[0].trim().parse::(), + parts[1].trim().parse::(), + parts[2].trim().parse::(), ) { - (Ok(x), Ok(y), Ok(z)) => Some([x, y, z]), + (Ok(x), Ok(y), Ok(z)) => Some(NodePositionEntry { + node_id, + position: [x, y, z], + }), _ => { - tracing::warn!("Skipping unparseable node position entry {idx}: '{triplet}'"); + tracing::warn!("Skipping unparseable node position entry {idx}: '{entry}'"); None } } @@ -170,6 +217,48 @@ pub fn parse_node_positions(input: &str) -> Vec<[f32; 3]> { .collect() } +/// Build the `node_id -> position` map the live `NodeInfo` output reads. +/// +/// Identity is the explicit `node_id:` prefix when the operator gave one, and +/// the entry's list index otherwise. +/// +/// This exists as a function rather than a loop inside `main()` because the bug +/// it fixes was invisible without a test: positions were inserted keyed by list +/// index and read back by `node_id`, so on any fleet not numbered `0, 1, 2, ...` +/// every lookup missed and silently fell through to the hardcoded +/// `[2.0, 0.0, 1.5]` that `--node-positions` exists to replace. The config +/// looked applied and did nothing. +pub fn node_positions_by_id( + entries: &[NodePositionEntry], +) -> std::collections::HashMap { + let mut map = std::collections::HashMap::new(); + for (idx, e) in entries.iter().enumerate() { + let key = e.node_id.unwrap_or(idx as u8); + if let Some(prev) = map.insert(key, e.position) { + tracing::warn!( + "node position for node {key} given twice ({prev:?} then {:?}); the later entry wins", + e.position + ); + } + } + map +} + +/// Parse node positions from a semicolon-delimited string. +/// +/// Format: `"x,y,z;x,y,z;..."` where each coordinate is an `f32`. +/// Malformed entries are skipped with a warning log. +/// +/// Positional view, for consumers that index the list rather than key it by +/// node id — `MultistaticFuser::set_node_positions()` is the one that matters. +/// Use [`parse_node_position_entries`] where identity is needed. +pub fn parse_node_positions(input: &str) -> Vec<[f32; 3]> { + parse_node_position_entries(input) + .into_iter() + .map(|e| e.position) + .collect() +} + #[cfg(test)] mod tests { use super::*; @@ -203,6 +292,158 @@ mod tests { assert_eq!(positions[0], [3.0, 4.0, 5.0]); } + // ---- Explicit node ids (the indexing-mismatch regression) ---- + + /// Positional entries carry no id, so identity falls back to the index. + /// This is the ONLY case the old behaviour handled correctly. + #[test] + fn positional_entries_report_no_explicit_id() { + let e = parse_node_position_entries("0,0,1.5;3,0,1.5"); + assert_eq!(e.len(), 2); + assert!(e.iter().all(|x| x.node_id.is_none())); + assert_eq!(e[1].position, [3.0, 0.0, 1.5]); + } + + /// THE REGRESSION. Positions were stored keyed by list index and read back + /// by node_id, so on any fleet not numbered 0,1,2,... every lookup missed + /// and fell through to the hardcoded default the option exists to replace. + /// Non-sequential ids are what catch that. + #[test] + fn non_sequential_node_ids_are_preserved() { + let e = parse_node_position_entries("11:1,2,3;12:4,5,6;13:7,8,9"); + assert_eq!(e.len(), 3); + assert_eq!(e[0].node_id, Some(11)); + assert_eq!(e[1].node_id, Some(12)); + assert_eq!(e[2].node_id, Some(13)); + assert_eq!(e[2].position, [7.0, 8.0, 9.0]); + // The index would have been 0,1,2 -- none of which is a real node here. + for (idx, entry) in e.iter().enumerate() { + assert_ne!( + entry.node_id, + Some(idx as u8), + "index and node id must not be conflated" + ); + } + } + + /// Two-digit ids, and a fleet re-homed to even numbers -- the shape of the + /// planned hardware check (nine boards moved from 0..8 to 0,2,4,...,16). + #[test] + fn two_digit_and_even_numbered_fleet_ids_parse() { + let spec = "0:0,0,1;2:1,0,1;4:2,0,1;6:3,0,1;8:4,0,1;10:5,0,1;12:6,0,1;14:7,0,1;16:8,0,1"; + let e = parse_node_position_entries(spec); + assert_eq!(e.len(), 9); + let ids: Vec = e.iter().map(|x| x.node_id.unwrap()).collect(); + assert_eq!(ids, vec![0, 2, 4, 6, 8, 10, 12, 14, 16]); + assert_eq!(e[8].position, [8.0, 0.0, 1.0]); + } + + /// A non-zero starting index must work too -- nothing requires node 0. + #[test] + fn a_fleet_that_does_not_start_at_zero_parses() { + let e = parse_node_position_entries("18:1.5,2.5,3.5"); + assert_eq!(e[0].node_id, Some(18)); + assert_eq!(e[0].position, [1.5, 2.5, 3.5]); + } + + /// Mixing is allowed: an unkeyed entry keeps its index. + #[test] + fn mixed_forms_keep_their_own_identity() { + let e = parse_node_position_entries("0,0,1;7:9,9,9"); + assert_eq!(e[0].node_id, None); + assert_eq!(e[1].node_id, Some(7)); + } + + /// An unparseable id is skipped rather than silently treated as positional + /// -- guessing there would reintroduce the exact ambiguity being removed. + #[test] + fn an_unparseable_node_id_is_skipped_not_guessed() { + let e = parse_node_position_entries("abc:1,2,3;5:4,5,6"); + assert_eq!(e.len(), 1, "only the well-formed entry survives"); + assert_eq!(e[0].node_id, Some(5)); + } + + /// 256 is out of range for a u8 node id and must not wrap to 0. + #[test] + fn an_out_of_range_node_id_is_rejected() { + let e = parse_node_position_entries("256:1,2,3"); + assert!(e.is_empty(), "256 must not wrap to node 0"); + } + + /// The positional view stays byte-compatible for the fuser, which indexes + /// the list rather than keying it. + #[test] + fn the_positional_view_is_unchanged_by_explicit_ids() { + let p = parse_node_positions("11:1,2,3;12:4,5,6"); + assert_eq!(p, vec![[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]]); + } + + /// The regression itself, at the map that `NodeInfo` actually reads. + /// + /// The old code inserted keyed by list index and looked up by node_id. On a + /// fleet numbered 11,12,13 that map holds keys 0,1,2, so every lookup misses + /// and the node reports the hardcoded default. This asserts both halves: + /// the real ids resolve, and the indices are absent. + #[test] + fn the_map_is_keyed_by_node_id_not_list_index() { + let entries = parse_node_position_entries("11:1,2,3;12:4,5,6;13:7,8,9"); + let map = node_positions_by_id(&entries); + + assert_eq!(map.get(&11), Some(&[1.0, 2.0, 3.0])); + assert_eq!(map.get(&12), Some(&[4.0, 5.0, 6.0])); + assert_eq!(map.get(&13), Some(&[7.0, 8.0, 9.0])); + + for idx in 0u8..3 { + assert!( + map.get(&idx).is_none(), + "list index {idx} must not be a key -- that is the bug: a lookup by node_id would miss and fall back to the hardcoded position" + ); + } + } + + /// Positional input keeps the old behaviour exactly: index becomes the key. + /// This is what made the bug invisible on a 0..8 fleet. + #[test] + fn positional_input_still_keys_by_index() { + let entries = parse_node_position_entries("0,0,1;3,0,1;6,0,1"); + let map = node_positions_by_id(&entries); + assert_eq!(map.get(&0), Some(&[0.0, 0.0, 1.0])); + assert_eq!(map.get(&1), Some(&[3.0, 0.0, 1.0])); + assert_eq!(map.get(&2), Some(&[6.0, 0.0, 1.0])); + } + + /// A re-homed fleet at 0,2,4,...,16 resolves on the real ids, and the odd + /// indices in between are absent. + #[test] + fn an_even_numbered_fleet_resolves_by_id() { + let spec = "0:0,0,1;2:1,0,1;4:2,0,1;6:3,0,1;8:4,0,1;10:5,0,1;12:6,0,1;14:7,0,1;16:8,0,1"; + let map = node_positions_by_id(&parse_node_position_entries(spec)); + assert_eq!(map.len(), 9); + assert_eq!(map.get(&16), Some(&[8.0, 0.0, 1.0])); + assert_eq!(map.get(&10), Some(&[5.0, 0.0, 1.0])); + // Index-keyed would have produced 0..8; 1,3,5,7 are not real nodes here. + for odd in [1u8, 3, 5, 7] { + assert!(map.get(&odd).is_none(), "node {odd} does not exist in this fleet"); + } + } + + /// A duplicate id keeps the later entry rather than silently holding two. + #[test] + fn a_duplicate_node_id_keeps_the_later_entry() { + let map = node_positions_by_id(&parse_node_position_entries("4:1,1,1;4:2,2,2")); + assert_eq!(map.len(), 1); + assert_eq!(map.get(&4), Some(&[2.0, 2.0, 2.0])); + } + + /// Whitespace around entries and coordinates is tolerated. + #[test] + fn whitespace_is_tolerated() { + let e = parse_node_position_entries(" 3 : 1.0 , 2.0 , 3.0 "); + assert_eq!(e.len(), 1); + assert_eq!(e[0].node_id, Some(3)); + assert_eq!(e[0].position, [1.0, 2.0, 3.0]); + } + /// Regression: a freshly-started (`Uncalibrated`) field model must begin /// collecting once frames arrive. Before the fix, `maybe_feed_calibration` /// only fed while already `Collecting`, but only `feed_calibration` sets diff --git a/v2/crates/wifi-densepose-sensing-server/src/main.rs b/v2/crates/wifi-densepose-sensing-server/src/main.rs index 286cad1d..52ada709 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/main.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/main.rs @@ -8678,15 +8678,25 @@ async fn main() { ..cfg.clone() }); if let Some(ref pos_str) = args.node_positions { - let positions = field_bridge::parse_node_positions(pos_str); - if !positions.is_empty() { + let entries = field_bridge::parse_node_position_entries(pos_str); + if !entries.is_empty() { info!( "Configured {} node positions for multistatic fusion", - positions.len() + entries.len() ); - for (idx, p) in positions.iter().enumerate() { - node_positions_config.insert(idx as u8, *p); - } + // Identity comes from the explicit `node_id:` prefix when + // given, and from the list index otherwise. Built by a + // tested function rather than inline here, because keying + // this map by index while reading it back by node_id is + // precisely the bug being fixed, and a loop inside main() + // is unreachable from any test. + node_positions_config = field_bridge::node_positions_by_id(&entries); + // The fuser indexes this list positionally, matched to + // per-frame iteration order rather than to node id. Left + // as-is deliberately: this change is about the NodeInfo + // output, and quietly altering fusion semantics inside it + // would be the wrong scope. Order given is order passed. + let positions: Vec<[f32; 3]> = entries.iter().map(|e| e.position).collect(); fuser.set_node_positions(positions); } } From 2e9e87c65c23ef901a45e1b03480ae3f8dc6a9c9 Mon Sep 17 00:00:00 2001 From: rUv Date: Fri, 4 Sep 2026 16:31:38 -0400 Subject: [PATCH 03/27] feat(calibration): persist safe empty room bootstrap prior (#1826) --- ...DR-355-local-empty-room-bootstrap-prior.md | 82 +++ docs/adr/README.md | 1 + .../wifi-densepose-sensing-server/README.md | 19 + .../src/bearer_auth.rs | 5 + .../src/bootstrap_baseline.rs | 388 +++++++++++++ .../wifi-densepose-sensing-server/src/lib.rs | 1 + .../wifi-densepose-sensing-server/src/main.rs | 540 ++++++++++++++++-- .../src/ruvsense/field_model.rs | 167 +++++- 8 files changed, 1157 insertions(+), 46 deletions(-) create mode 100644 docs/adr/ADR-355-local-empty-room-bootstrap-prior.md create mode 100644 v2/crates/wifi-densepose-sensing-server/src/bootstrap_baseline.rs diff --git a/docs/adr/ADR-355-local-empty-room-bootstrap-prior.md b/docs/adr/ADR-355-local-empty-room-bootstrap-prior.md new file mode 100644 index 00000000..c14f7827 --- /dev/null +++ b/docs/adr/ADR-355-local-empty-room-bootstrap-prior.md @@ -0,0 +1,82 @@ +# ADR 355: Local empty room bootstrap prior + +Status: Accepted + +Date: 2026-09-04 + +## Context + +Most installations are occupied when RuView first starts. An uncalibrated +signal heuristic can interpret HVAC, clock error, neighboring motion, or a +short covariance window as a person. It can also surface plausible numeric +vitals before a room model establishes occupant attribution. + +One empty home is not representative training data for all homes and contains +no positive human examples. It is useful only as a recent negative prior for +the same installation. It cannot define a universal human signal model. + +The field model was memory only. The server also used a relative `data/` +directory, which is unreliable for embedded application hosts. + +## Decision + +RuView may persist a completed empty room field model as a local bootstrap +prior only after two server controlled stages: + +1. Normal calibration reaches both 12,000 accepted frames and 600 seconds. +2. Promotion observes 12 new one second samples. At least 10 must resolve + empty, all ticks must be fresh, and none may publish numeric vital signs. + +The stored image contains aggregate means, environmental modes, mode energies, +noise statistics, configuration, contributing node IDs, and a server generated +model ID. It excludes raw CSI, waveforms, device addresses, room names, pose, +identity, credentials, and positive human labels. + +The image is bound to a SHA 256 digest of the stable installation ID, carries +an integrity digest, is capped at 1 MiB, and inherits the field model expiry. +Files with an invalid schema, dimensions, values, node set, installation +binding, digest, size, or lifetime fail closed. + +Restoring an image creates a field model in `bootstrap_only` mode. It may +reduce an uncalibrated false person count, but it cannot authorize calibrated +evidence or numeric heart and breathing rates. Explicit calibration replaces +it with fresh statistics. Cancelling an unfinished capture restores a valid +prior. Administrator scoped reset removes it. + +The server accepts `--data-dir` or `RUVIEW_DATA_DIR`, allowing an application +host to provide a protected state directory. + +## Consequences + +Startup can use recent measured background structure instead of only generic +heuristics. No accuracy lift is claimed until occupied and empty held out data +from multiple installations is recorded. + +The largest failure mode is overfitting to a temporarily quiet home and +suppressing a weak occupant. Controls are low authority, 24 hour expiry, +installation binding, vital abstention, explicit recalibration, and reset. + +A human signal model still requires positive, consented, leakage separated +occupied sequences. Empty data is never relabeled as human training. + +## Implementation + +1. `wifi-densepose-signal::ruvsense::field_model` exports and validates + `FieldModelSnapshotV1`. +2. `wifi-densepose-sensing-server::bootstrap_baseline` owns schema, integrity, + storage, lifetime, and the held out gate. +3. `POST /api/v1/calibration/bootstrap/promote` measures the validation window + and stores the image only on pass. +4. `GET /api/v1/calibration/status` reports bootstrap authority and explicitly + denies calibrated evidence and numeric vital authority. +5. Numeric vitals fail closed unless an explicit fresh calibration resolves + exactly one occupant at sufficient signal quality and confidence. + +## Acceptance test + +With a physically empty room, complete calibration on stable nodes, promote +the model, and restart the server with the same installation ID 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. diff --git a/docs/adr/README.md b/docs/adr/README.md index 83a12eba..84b858b2 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -96,6 +96,7 @@ Statuses: **Proposed** (under discussion), **Accepted** (approved and/or impleme | [ADR-349](ADR-349-governed-local-and-fal-forecast-training.md) | Governed local and fal.ai forecast training | Proposed | | [ADR-350](ADR-350-ruvector-predictive-memory-and-ruvllm-boundary.md) | RuVector predictive memory and RuVLLM authority boundary | Proposed | | [ADR-354](ADR-354-forecast-governance-enforcement-in-ruforecast-submodule.md) | fal.ai forecast governance and spend enforcement now real, in the vendored RuForecast submodule | Accepted | +| [ADR-355](ADR-355-local-empty-room-bootstrap-prior.md) | Local empty room bootstrap prior | Accepted | ### Platform and UI diff --git a/v2/crates/wifi-densepose-sensing-server/README.md b/v2/crates/wifi-densepose-sensing-server/README.md index dd8ae39c..2224a6dd 100644 --- a/v2/crates/wifi-densepose-sensing-server/README.md +++ b/v2/crates/wifi-densepose-sensing-server/README.md @@ -43,6 +43,8 @@ per ADR-022 Phase 3. - **WebSocket broadcast** -- Real-time sensing updates pushed to all connected clients at `ws://localhost:8765/ws/sensing`. - **Static file serving** -- Hosts the sensing UI on port 8080 with CORS headers. +- **Private startup baseline** -- Restores a recent, installation-bound empty-room field model + without storing raw CSI or authorizing numeric vital signs (ADR-355). ## Modules @@ -74,6 +76,23 @@ cargo run -p wifi-densepose-sensing-server -- \ --static-dir ./ui ``` +### Empty-room startup baseline + +Provide a stable installation ID and an application-owned private state directory: + +```bash +cargo run -p wifi-densepose-sensing-server -- \ + --installation-id installation-01 \ + --data-dir /path/to/private/application-state +``` + +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, +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. + ### Using as a library ```rust diff --git a/v2/crates/wifi-densepose-sensing-server/src/bearer_auth.rs b/v2/crates/wifi-densepose-sensing-server/src/bearer_auth.rs index 783ed7c1..d8a9cbfa 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/bearer_auth.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/bearer_auth.rs @@ -491,6 +491,10 @@ const READ_SAFE_MUTATIONS: &[&str] = &[ // Capture and calibration: create data, never destroy it. "/api/v1/calibration/start", "/api/v1/calibration/stop", + // Promotion consumes only server measured held out evidence and stores a + // bounded privacy reduced prior. Cancel discards only an unfinished run. + "/api/v1/calibration/bootstrap/promote", + "/api/v1/calibration/cancel", "/api/v1/pose/calibrate", "/api/v1/recording/start", "/api/v1/recording/stop", @@ -1970,6 +1974,7 @@ mod scope_gate_polarity_tests { (Method::DELETE, "/api/v1/models/m1"), (Method::DELETE, "/api/v1/recording/r1"), (Method::POST, "/api/v1/config/ground-truth"), + (Method::POST, "/api/v1/calibration/reset"), ] { assert_eq!( required_scope_for(&m, p), diff --git a/v2/crates/wifi-densepose-sensing-server/src/bootstrap_baseline.rs b/v2/crates/wifi-densepose-sensing-server/src/bootstrap_baseline.rs new file mode 100644 index 00000000..30fb6a11 --- /dev/null +++ b/v2/crates/wifi-densepose-sensing-server/src/bootstrap_baseline.rs @@ -0,0 +1,388 @@ +//! Local, privacy reduced empty room bootstrap model persistence. +//! +//! A bootstrap image is deliberately lower authority than a completed room +//! calibration. It can suppress background perturbations while the server +//! starts, but restoring it never authorizes calibrated evidence or vitals. + +use rand::{rngs::OsRng, RngCore}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::fs::{self, OpenOptions}; +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; +use thiserror::Error; +use wifi_densepose_signal::ruvsense::field_model::{ + FieldModel, FieldModelError, FieldModelSnapshotV1, +}; + +pub const BOOTSTRAP_BASELINE_SCHEMA: &str = "ruview.bootstrap-empty-field-model.v1"; +pub const BOOTSTRAP_BASELINE_AUTHORITY: &str = "bootstrap_only"; +pub const BOOTSTRAP_VALIDATION_SAMPLES: usize = 12; +pub const BOOTSTRAP_VALIDATION_MIN_EMPTY: usize = 10; +const BOOTSTRAP_MAX_FILE_BYTES: u64 = 1_048_576; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] +struct BootstrapPayloadV1 { + schema: String, + authority: String, + installation_binding_sha256: String, + source_node_ids: Vec, + source_model_id: String, + created_at_unix_ms: u64, + expires_at_unix_ms: u64, + field_model: FieldModelSnapshotV1, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] +struct BootstrapImageV1 { + payload: BootstrapPayloadV1, + content_sha256: String, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct BootstrapBaselineMetadata { + pub authority: &'static str, + pub source_node_ids: Vec, + pub source_model_id: String, + pub created_at_unix_ms: u64, + pub expires_at_unix_ms: u64, + pub content_sha256: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct BootstrapValidationSample { + pub fresh_tick: bool, + pub calibrated_empty: bool, + pub vital_signs_absent: bool, +} + +#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] +pub struct BootstrapValidationResult { + pub sample_count: usize, + pub empty_sample_count: usize, + pub stale_sample_count: usize, + pub vital_sign_sample_count: usize, + pub passed: bool, +} + +#[derive(Debug, Error)] +pub enum BootstrapBaselineError { + #[error("a stable installation_id is required for a local bootstrap baseline")] + MissingInstallationId, + #[error("bootstrap baseline path is invalid")] + InvalidPath, + #[error("bootstrap baseline file is too large")] + FileTooLarge, + #[error("bootstrap baseline image is malformed: {0}")] + Malformed(String), + #[error("bootstrap baseline belongs to another installation")] + InstallationMismatch, + #[error("bootstrap baseline has expired")] + Expired, + #[error("bootstrap baseline digest does not verify")] + DigestMismatch, + #[error("field model snapshot is invalid: {0}")] + FieldModel(#[from] FieldModelError), + #[error("bootstrap baseline storage failed: {0}")] + Io(#[from] std::io::Error), + #[error("bootstrap baseline serialization failed: {0}")] + Json(#[from] serde_json::Error), +} + +pub fn path_in(data_dir: &Path) -> PathBuf { + data_dir.join("calibration").join("bootstrap-empty-v1.json") +} + +pub fn installation_binding(installation_id: &str) -> Result { + let installation_id = installation_id.trim(); + if installation_id.is_empty() || installation_id.len() > 128 { + return Err(BootstrapBaselineError::MissingInstallationId); + } + Ok(hex_sha256(installation_id.as_bytes())) +} + +pub fn evaluate_validation(samples: &[BootstrapValidationSample]) -> BootstrapValidationResult { + let empty_sample_count = samples + .iter() + .filter(|sample| sample.fresh_tick && sample.calibrated_empty) + .count(); + let stale_sample_count = samples.iter().filter(|sample| !sample.fresh_tick).count(); + let vital_sign_sample_count = samples + .iter() + .filter(|sample| !sample.vital_signs_absent) + .count(); + BootstrapValidationResult { + sample_count: samples.len(), + empty_sample_count, + stale_sample_count, + vital_sign_sample_count, + passed: samples.len() == BOOTSTRAP_VALIDATION_SAMPLES + && empty_sample_count >= BOOTSTRAP_VALIDATION_MIN_EMPTY + && stale_sample_count == 0 + && vital_sign_sample_count == 0, + } +} + +pub fn store( + path: &Path, + installation_id: &str, + source_node_ids: &[u8], + source_model_id: &str, + created_at_unix_ms: u64, + field_model: &FieldModel, +) -> Result { + let snapshot = field_model.export_snapshot()?; + let calibrated_at_ms = snapshot.modes.calibrated_at_us / 1_000; + let expiry_ms = (snapshot.config.baseline_expiry_s * 1_000.0) as u64; + let payload = BootstrapPayloadV1 { + schema: BOOTSTRAP_BASELINE_SCHEMA.to_string(), + authority: BOOTSTRAP_BASELINE_AUTHORITY.to_string(), + installation_binding_sha256: installation_binding(installation_id)?, + source_node_ids: source_node_ids.to_vec(), + source_model_id: source_model_id.to_string(), + created_at_unix_ms, + expires_at_unix_ms: calibrated_at_ms.saturating_add(expiry_ms), + field_model: snapshot, + }; + validate_payload(&payload, installation_id, created_at_unix_ms)?; + let content_sha256 = payload_digest(&payload)?; + let image = BootstrapImageV1 { + payload, + content_sha256, + }; + let bytes = serde_json::to_vec(&image)?; + if bytes.len() as u64 > BOOTSTRAP_MAX_FILE_BYTES { + return Err(BootstrapBaselineError::FileTooLarge); + } + atomic_write_private(path, &bytes)?; + Ok(metadata(&image)) +} + +pub fn load( + path: &Path, + installation_id: &str, + current_unix_ms: u64, +) -> Result<(FieldModel, BootstrapBaselineMetadata), BootstrapBaselineError> { + let file_metadata = fs::symlink_metadata(path)?; + if file_metadata.file_type().is_symlink() || !file_metadata.is_file() { + return Err(BootstrapBaselineError::InvalidPath); + } + if file_metadata.len() > BOOTSTRAP_MAX_FILE_BYTES { + return Err(BootstrapBaselineError::FileTooLarge); + } + let mut bytes = Vec::with_capacity(file_metadata.len() as usize); + OpenOptions::new() + .read(true) + .open(path)? + .take(BOOTSTRAP_MAX_FILE_BYTES + 1) + .read_to_end(&mut bytes)?; + if bytes.len() as u64 > BOOTSTRAP_MAX_FILE_BYTES { + return Err(BootstrapBaselineError::FileTooLarge); + } + let image: BootstrapImageV1 = serde_json::from_slice(&bytes)?; + validate_payload(&image.payload, installation_id, current_unix_ms)?; + if payload_digest(&image.payload)? != image.content_sha256 { + return Err(BootstrapBaselineError::DigestMismatch); + } + let model = FieldModel::from_snapshot( + image.payload.field_model.clone(), + current_unix_ms.saturating_mul(1_000), + )?; + Ok((model, metadata(&image))) +} + +pub fn remove(path: &Path) -> Result { + match fs::symlink_metadata(path) { + Ok(metadata) => { + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err(BootstrapBaselineError::InvalidPath); + } + fs::remove_file(path)?; + Ok(true) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(error.into()), + } +} + +fn validate_payload( + payload: &BootstrapPayloadV1, + installation_id: &str, + current_unix_ms: u64, +) -> Result<(), BootstrapBaselineError> { + if payload.schema != BOOTSTRAP_BASELINE_SCHEMA + || payload.authority != BOOTSTRAP_BASELINE_AUTHORITY + || payload.source_model_id.is_empty() + || payload.source_model_id.len() > 128 + || payload.source_node_ids.is_empty() + || payload.source_node_ids.len() > 16 + || payload + .source_node_ids + .windows(2) + .any(|pair| pair[0] >= pair[1]) + || payload.created_at_unix_ms == 0 + || payload.expires_at_unix_ms <= payload.created_at_unix_ms + { + return Err(BootstrapBaselineError::Malformed( + "invalid schema, authority, identity, node set, or lifetime".into(), + )); + } + if payload.installation_binding_sha256 != installation_binding(installation_id)? { + return Err(BootstrapBaselineError::InstallationMismatch); + } + if current_unix_ms > payload.expires_at_unix_ms { + return Err(BootstrapBaselineError::Expired); + } + Ok(()) +} + +fn payload_digest(payload: &BootstrapPayloadV1) -> Result { + Ok(hex_sha256(&serde_json::to_vec(payload)?)) +} + +fn metadata(image: &BootstrapImageV1) -> BootstrapBaselineMetadata { + BootstrapBaselineMetadata { + authority: BOOTSTRAP_BASELINE_AUTHORITY, + source_node_ids: image.payload.source_node_ids.clone(), + source_model_id: image.payload.source_model_id.clone(), + created_at_unix_ms: image.payload.created_at_unix_ms, + expires_at_unix_ms: image.payload.expires_at_unix_ms, + content_sha256: image.content_sha256.clone(), + } +} + +fn hex_sha256(bytes: &[u8]) -> String { + let digest = Sha256::digest(bytes); + digest.iter().map(|byte| format!("{byte:02x}")).collect() +} + +fn atomic_write_private(path: &Path, bytes: &[u8]) -> Result<(), BootstrapBaselineError> { + let parent = path.parent().ok_or(BootstrapBaselineError::InvalidPath)?; + fs::create_dir_all(parent)?; + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .ok_or(BootstrapBaselineError::InvalidPath)?; + let mut nonce = [0_u8; 8]; + OsRng.fill_bytes(&mut nonce); + let temporary = parent.join(format!( + ".{file_name}.{:016x}.tmp", + u64::from_le_bytes(nonce) + )); + + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let write_result = (|| -> Result<(), std::io::Error> { + let mut file = options.open(&temporary)?; + file.write_all(bytes)?; + file.sync_all()?; + fs::rename(&temporary, path)?; + Ok(()) + })(); + if write_result.is_err() { + let _ = fs::remove_file(&temporary); + } + write_result.map_err(Into::into) +} + +#[cfg(test)] +mod tests { + use super::*; + use wifi_densepose_signal::ruvsense::field_model::FieldModelConfig; + + fn completed_model(now_us: u64) -> FieldModel { + let mut model = FieldModel::new(FieldModelConfig { + n_links: 1, + n_subcarriers: 4, + n_modes: 2, + min_calibration_frames: 3, + min_calibration_duration_s: 0.0, + baseline_expiry_s: 3_600.0, + }) + .unwrap(); + for index in 0..3 { + model + .feed_calibration(&[vec![1.0 + index as f64, 2.0, 3.0, 4.0]]) + .unwrap(); + } + model.finalize_calibration(now_us, 7).unwrap(); + model + } + + #[test] + fn round_trip_is_installation_bound_and_contains_no_raw_frames() { + let dir = tempfile::tempdir().unwrap(); + let path = path_in(dir.path()); + let now_ms = 1_000_000; + let model = completed_model(now_ms * 1_000); + let stored = store(&path, "home-a", &[5], "model", now_ms, &model).unwrap(); + let text = String::from_utf8(fs::read(&path).unwrap()).unwrap(); + assert!(!text.contains("home-a")); + assert!(!text.contains("raw_csi")); + + let (restored, loaded) = load(&path, "home-a", now_ms + 1).unwrap(); + assert_eq!(stored, loaded); + assert_eq!( + restored.export_snapshot().unwrap(), + model.export_snapshot().unwrap() + ); + assert!(matches!( + load(&path, "home-b", now_ms + 1), + Err(BootstrapBaselineError::InstallationMismatch) + )); + } + + #[test] + fn tamper_and_expiry_fail_closed() { + let dir = tempfile::tempdir().unwrap(); + let path = path_in(dir.path()); + let now_ms = 1_000_000; + let model = completed_model(now_ms * 1_000); + store(&path, "home-a", &[5], "model", now_ms, &model).unwrap(); + + let mut image: serde_json::Value = + serde_json::from_slice(&fs::read(&path).unwrap()).unwrap(); + image["payload"]["source_model_id"] = serde_json::json!("forged"); + fs::write(&path, serde_json::to_vec(&image).unwrap()).unwrap(); + assert!(matches!( + load(&path, "home-a", now_ms + 1), + Err(BootstrapBaselineError::DigestMismatch) + )); + + store(&path, "home-a", &[5], "model", now_ms, &model).unwrap(); + assert!(matches!( + load(&path, "home-a", now_ms + 3_600_001), + Err(BootstrapBaselineError::Expired) + | Err(BootstrapBaselineError::FieldModel( + FieldModelError::BaselineExpired { .. } + )) + )); + } + + #[test] + fn promotion_gate_requires_twelve_fresh_samples_and_zero_vitals() { + let passing = vec![ + BootstrapValidationSample { + fresh_tick: true, + calibrated_empty: true, + vital_signs_absent: true, + }; + BOOTSTRAP_VALIDATION_SAMPLES + ]; + assert!(evaluate_validation(&passing).passed); + + let mut stale = passing.clone(); + stale[0].fresh_tick = false; + assert!(!evaluate_validation(&stale).passed); + let mut vital = passing; + vital[0].vital_signs_absent = false; + assert!(!evaluate_validation(&vital).passed); + } +} diff --git a/v2/crates/wifi-densepose-sensing-server/src/lib.rs b/v2/crates/wifi-densepose-sensing-server/src/lib.rs index 93d00f11..0cf0214f 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/lib.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/lib.rs @@ -9,6 +9,7 @@ //! - Real-time CSI introspection / low-latency tap (`introspection`, ADR-099) pub mod bearer_auth; +pub mod bootstrap_baseline; pub mod browser_session; pub mod ws_ticket; pub mod cli; diff --git a/v2/crates/wifi-densepose-sensing-server/src/main.rs b/v2/crates/wifi-densepose-sensing-server/src/main.rs index cc5cb028..111c91cd 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/main.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/main.rs @@ -39,11 +39,15 @@ use wifi_densepose_sensing_server::{ dataset, embedding, error_response, graph_transformer, rufield_surface, semconv, telemetry, trainer, }; +use wifi_densepose_sensing_server::bootstrap_baseline::{ + self, BootstrapBaselineMetadata, BootstrapValidationSample, BOOTSTRAP_VALIDATION_SAMPLES, +}; // ADR-295 / ADR-297: canonical provenance state + per-node/room inference. use wifi_densepose_sensing_server::inference::{fuse_room, NodeInference, RoomInference}; use wifi_densepose_sensing_server::provenance::SourceState; use ruvector_mincut::{DynamicMinCut, MinCutBuilder}; +use rand::{rngs::OsRng, RngCore}; use std::collections::{BTreeMap, HashMap, VecDeque}; use std::net::SocketAddr; use std::path::PathBuf; @@ -142,6 +146,11 @@ struct Args { #[arg(long, env = "RUVIEW_INSTALLATION_ID")] installation_id: Option, + /// Private server state directory used for runtime configuration and the + /// installation bound empty room bootstrap prior. + #[arg(long, default_value = "data", env = "RUVIEW_DATA_DIR")] + data_dir: PathBuf, + /// Human-readable local service name shown by commissioning clients. #[arg( long, @@ -1585,6 +1594,16 @@ struct AppStateInner { engine_bridge: engine_bridge::EngineBridge, /// SVD-based room field model for eigenvalue person counting (None until calibration). field_model: Option, + /// Stable installation identity used only to bind local persisted state. + installation_id: Option, + /// Metadata for the privacy reduced empty room image, when available. + bootstrap_baseline: Option, + /// A restored prior can affect startup occupancy but cannot authorize vitals. + bootstrap_baseline_active: bool, + /// Server generated identity for the current explicit calibration model. + calibration_model_id: Option, + /// Nodes that actually contributed frames to the current calibration. + calibration_source_node_ids: std::collections::BTreeSet, // ── ADR-044 §5.2: adaptive rolling-p95 normalization ───────────────────── /// Rolling P95 of `FeatureInfo.variance` over the last ~30 s (600 frames @ 20 Hz). pub(crate) p95_variance: RollingP95, @@ -1849,6 +1868,11 @@ impl AppStateInner { None, ), field_model: None, + installation_id: None, + bootstrap_baseline: None, + bootstrap_baseline_active: false, + calibration_model_id: None, + calibration_source_node_ids: std::collections::BTreeSet::new(), p95_variance: RollingP95::new(600, 60), p95_motion_band_power: RollingP95::new(600, 60), p95_spectral_power: RollingP95::new(600, 60), @@ -3364,7 +3388,7 @@ async fn windows_wifi_task(state: SharedState, tick_ms: u64) { feat_variance.min(1.0), &sub_variances, ), - vital_signs: Some(vitals), + vital_signs: None, enhanced_motion, enhanced_breathing, posture: posture_str, @@ -3528,7 +3552,7 @@ async fn windows_wifi_fallback_tick(state: &SharedState, seq: u32) { feat_variance.min(1.0), &sub_variances, ), - vital_signs: Some(vitals), + vital_signs: None, enhanced_motion: None, enhanced_breathing: None, posture: None, @@ -5979,10 +6003,96 @@ async fn adaptive_unload(State(state): State) -> Json Option { + if !explicit_calibration_fresh || person_count != 1 { + return None; + } + let signal_quality = candidates.signal_quality.clamp(0.0, 1.0); + if signal_quality < VITAL_PUBLICATION_MIN_SIGNAL_QUALITY { + return None; + } + let breathing_rate_bpm = (candidates.breathing_confidence + >= VITAL_PUBLICATION_MIN_CONFIDENCE) + .then_some(candidates.breathing_rate_bpm) + .flatten(); + let heart_rate_bpm = + (candidates.heartbeat_confidence >= VITAL_PUBLICATION_MIN_CONFIDENCE) + .then_some(candidates.heart_rate_bpm) + .flatten(); + if breathing_rate_bpm.is_none() && heart_rate_bpm.is_none() { + return None; + } + Some(VitalSigns { + breathing_rate_bpm, + heart_rate_bpm, + breathing_confidence: candidates.breathing_confidence.clamp(0.0, 1.0), + heartbeat_confidence: candidates.heartbeat_confidence.clamp(0.0, 1.0), + signal_quality, + }) +} + +fn opaque_calibration_model_id() -> String { + let mut bytes = [0_u8; 16]; + OsRng.fill_bytes(&mut bytes); + let suffix: String = bytes.iter().map(|byte| format!("{byte:02x}")).collect(); + format!("cal-model-{suffix}") +} + +#[cfg(test)] +mod bootstrap_vital_publication_tests { + use super::*; + + fn strong_candidates() -> VitalSigns { + VitalSigns { + breathing_rate_bpm: Some(15.0), + heart_rate_bpm: Some(72.0), + breathing_confidence: 0.9, + heartbeat_confidence: 0.9, + signal_quality: 0.9, + } + } + + #[test] + fn uncalibrated_empty_and_multi_person_rooms_publish_no_vitals() { + let candidates = strong_candidates(); + assert!(vitals_for_publication(&candidates, false, 1).is_none()); + assert!(vitals_for_publication(&candidates, true, 0).is_none()); + assert!(vitals_for_publication(&candidates, true, 2).is_none()); + } + + #[test] + fn one_explicitly_calibrated_occupant_may_publish_qualified_vitals() { + let published = vitals_for_publication(&strong_candidates(), true, 1).unwrap(); + assert_eq!(published.breathing_rate_bpm, Some(15.0)); + assert_eq!(published.heart_rate_bpm, Some(72.0)); + } + + #[test] + fn weak_vital_candidates_fail_closed() { + let mut candidates = strong_candidates(); + candidates.signal_quality = 0.2; + assert!(vitals_for_publication(&candidates, true, 1).is_none()); + } +} + // ── Field model calibration endpoints (eigenvalue person counting) ────────── async fn calibration_start(State(state): State) -> Json { let mut s = state.write().await; + if s.bootstrap_baseline_active { + s.field_model = None; + s.bootstrap_baseline_active = false; + } // Guard: don't discard an in-progress or fresh calibration if let Some(ref fm) = s.field_model { match fm.status() { @@ -6005,9 +6115,12 @@ async fn calibration_start(State(state): State) -> Json { s.field_model = Some(fm); + s.calibration_model_id = Some(opaque_calibration_model_id()); + s.calibration_source_node_ids.clear(); Json(serde_json::json!({ "success": true, "message": "Calibration started — keep room empty while frames accumulate.", + "model_id": s.calibration_model_id, })) } // ADR-080 #2: FieldModel init error chain stays server-side only. @@ -6017,6 +6130,8 @@ async fn calibration_start(State(state): State) -> Json) -> Json { let mut s = state.write().await; + let model_id = s.calibration_model_id.clone(); + let source_node_ids: Vec = s.calibration_source_node_ids.iter().copied().collect(); if let Some(ref mut fm) = s.field_model { // Guard: finalizing before enough empty-room frames have accumulated // is a client-side sequencing error, not a server fault. Return a @@ -6062,6 +6177,8 @@ async fn calibration_stop(State(state): State) -> Json) -> Json) -> Json { let s = state.read().await; - match s.field_model.as_ref() { - // #1756: expose elapsed wall-clock time and the effective aggregate - // sample rate alongside the frame count, so a client can see the - // shape of the capture instead of frames alone. - Some(fm) => Json(serde_json::json!({ - "active": true, - "status": format!("{:?}", fm.status()), - "frame_count": fm.calibration_frame_count(), - "min_frames": fm.min_calibration_frames(), - "elapsed_s": fm.calibration_elapsed_s(), - "frames_per_second": fm.calibration_frames_per_second(), - "min_duration_s": fm.min_calibration_duration_s(), - })), - None => Json(serde_json::json!({ + let (active, status, frame_count, min_frames, elapsed_s, frames_per_second, min_duration_s) = + s.field_model.as_ref().map_or( + (false, "none".to_string(), 0, 0, 0.0, 0.0, 0.0), + |model| { + ( + true, + format!("{:?}", model.status()).to_lowercase(), + model.calibration_frame_count(), + model.min_calibration_frames(), + model.calibration_elapsed_s(), + model.calibration_frames_per_second(), + model.min_calibration_duration_s(), + ) + }, + ); + Json(serde_json::json!({ + "active": active, + "status": status, + "frame_count": frame_count, + "min_frames": min_frames, + "elapsed_s": elapsed_s, + "frames_per_second": frames_per_second, + "min_duration_s": min_duration_s, + "model_id": s.calibration_model_id, + "source_node_ids": s.calibration_source_node_ids, + "binding_mode": if s.bootstrap_baseline_active { "bootstrap_only" } else if active { "runtime" } else { "none" }, + "bootstrap_baseline": s.bootstrap_baseline.as_ref().map(|metadata| serde_json::json!({ + "stored": true, + "active": s.bootstrap_baseline_active, + "authority": metadata.authority, + "source_node_ids": metadata.source_node_ids, + "source_model_id": metadata.source_model_id, + "created_at_unix_ms": metadata.created_at_unix_ms, + "expires_at_unix_ms": metadata.expires_at_unix_ms, + "content_sha256": metadata.content_sha256, + "calibrated_evidence_authorized": false, + "numeric_vitals_authorized": false, + })).unwrap_or_else(|| serde_json::json!({ + "stored": false, "active": false, - "status": "none", + "authority": "none", + "calibrated_evidence_authorized": false, + "numeric_vitals_authorized": false, })), + })) +} + +/// Validate a completed empty room model against twelve fresh server observed +/// samples, then persist only aggregate statistics. The request cannot supply +/// scores, labels, raw CSI, or a replacement model. +async fn calibration_promote_bootstrap( + State(state): State, +) -> Json { + let expected_model_id = { + let s = state.read().await; + if s.bootstrap_baseline_active + || !s.field_model.as_ref().is_some_and(|model| { + matches!(model.status(), CalibrationStatus::Fresh) + }) + { + return Json(serde_json::json!({ + "success": false, + "error_code": "calibration_not_finalized", + "error": "Finalize an explicit room calibration before validating a startup baseline.", + })); + } + match s.calibration_model_id.clone() { + Some(model_id) => model_id, + None => { + return Json(serde_json::json!({ + "success": false, + "error_code": "calibration_model_identity_missing", + "error": "The active calibration has no server generated model identity.", + })); + } + } + }; + + let mut samples = Vec::with_capacity(BOOTSTRAP_VALIDATION_SAMPLES); + let mut last_tick = None; + for _ in 0..BOOTSTRAP_VALIDATION_SAMPLES { + let interval_deadline = tokio::time::Instant::now() + Duration::from_secs(1); + let mut sample = BootstrapValidationSample { + fresh_tick: false, + calibrated_empty: false, + vital_signs_absent: true, + }; + while tokio::time::Instant::now() < interval_deadline { + let observed = { + let s = state.read().await; + if s.calibration_model_id.as_deref() != Some(expected_model_id.as_str()) + || s.bootstrap_baseline_active + { + None + } else { + s.latest_update.as_ref().and_then(|update| { + (Some(update.tick) != last_tick).then_some(( + update.tick, + s.person_count() == 0, + update.vital_signs.is_none(), + )) + }) + } + }; + if let Some((tick, calibrated_empty, vital_signs_absent)) = observed { + last_tick = Some(tick); + sample = BootstrapValidationSample { + fresh_tick: true, + calibrated_empty, + vital_signs_absent, + }; + break; + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + samples.push(sample); + tokio::time::sleep_until(interval_deadline).await; } + + let validation = bootstrap_baseline::evaluate_validation(&samples); + 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.", + "validation": validation, + })); + } + + let mut s = state.write().await; + if s.calibration_model_id.as_deref() != Some(expected_model_id.as_str()) + || s.bootstrap_baseline_active + { + return Json(serde_json::json!({ + "success": false, + "error_code": "calibration_model_changed", + "error": "Calibration changed during validation. The model was not stored.", + })); + } + let Some(installation_id) = s.installation_id.clone() else { + return Json(serde_json::json!({ + "success": false, + "error_code": "installation_id_required", + "error": "A stable installation identity is required to store a local startup baseline.", + })); + }; + let source_node_ids: Vec = s.calibration_source_node_ids.iter().copied().collect(); + if source_node_ids.is_empty() || source_node_ids.len() > 16 { + return Json(serde_json::json!({ + "success": false, + "error_code": "calibration_source_nodes_missing", + "error": "The active calibration must contain between 1 and 16 contributing ESP32 nodes.", + })); + } + let Some(field_model) = s.field_model.as_ref() else { + return Json(serde_json::json!({ + "success": false, + "error_code": "no_field_model", + "error": "Calibration changed during validation. The model was not stored.", + })); + }; + let path = bootstrap_baseline::path_in(&s.data_dir); + let created_at_unix_ms = chrono::Utc::now().timestamp_millis() as u64; + let stored = match bootstrap_baseline::store( + &path, + &installation_id, + &source_node_ids, + &expected_model_id, + created_at_unix_ms, + field_model, + ) { + Ok(metadata) => metadata, + Err(error) => { + return error_response::internal_error_json("bootstrap baseline store", error) + } + }; + s.bootstrap_baseline = Some(stored.clone()); + info!( + model_id = %stored.source_model_id, + source_node_ids = ?stored.source_node_ids, + content_sha256 = %stored.content_sha256, + "Stored validated privacy reduced startup baseline" + ); + Json(serde_json::json!({ + "success": true, + "message": "Validated startup baseline stored locally.", + "validation": validation, + "bootstrap_baseline": stored, + "calibrated_evidence_authorized": false, + "numeric_vitals_authorized": false, + })) +} + +/// Cancel only an unfinished capture. Completed model deletion remains on the +/// administrator scoped reset route. +async fn calibration_cancel(State(state): State) -> Json { + let mut s = state.write().await; + let cancellable = s.field_model.as_ref().is_some_and(|model| { + matches!( + model.status(), + CalibrationStatus::Uncalibrated | CalibrationStatus::Collecting + ) + }); + if !cancellable { + return Json(serde_json::json!({ + "success": false, + "error_code": "calibration_not_collecting", + "error": "Only an unfinished empty room capture can be cancelled.", + })); + } + s.field_model = None; + s.calibration_model_id = None; + s.calibration_source_node_ids.clear(); + if let Some(installation_id) = s.installation_id.clone() { + let path = bootstrap_baseline::path_in(&s.data_dir); + if let Ok((model, metadata)) = bootstrap_baseline::load( + &path, + &installation_id, + chrono::Utc::now().timestamp_millis() as u64, + ) { + s.field_model = Some(model); + s.bootstrap_baseline = Some(metadata); + s.bootstrap_baseline_active = true; + } + } + Json(serde_json::json!({ + "success": true, + "message": "Unfinished empty room capture cancelled.", + "status": if s.bootstrap_baseline_active { "bootstrap" } else { "none" }, + })) +} + +async fn calibration_reset(State(state): State) -> Json { + let mut s = state.write().await; + s.field_model = None; + s.calibration_model_id = None; + s.calibration_source_node_ids.clear(); + s.bootstrap_baseline_active = false; + let path = bootstrap_baseline::path_in(&s.data_dir); + let bootstrap_removed = match bootstrap_baseline::remove(&path) { + Ok(removed) => { + s.bootstrap_baseline = None; + removed + } + Err(error) => { + warn!(%error, "Could not remove stored bootstrap baseline during reset"); + false + } + }; + Json(serde_json::json!({ + "success": true, + "message": "Calibration model reset.", + "status": "none", + "bootstrap_removed": bootstrap_removed, + })) } /// Compatibility surface used by the bundled dashboard. Activity history is @@ -6119,16 +6473,27 @@ fn chrono_timestamp() -> u64 { async fn vital_signs_endpoint(State(state): State) -> Json { let s = state.read().await; - let vs = &s.latest_vitals; + let person_count = s.person_count(); + let explicit_calibration_fresh = !s.bootstrap_baseline_active + && s.field_model + .as_ref() + .is_some_and(|model| matches!(model.status(), CalibrationStatus::Fresh)); + let published = vitals_for_publication( + &s.latest_vitals, + explicit_calibration_fresh, + person_count, + ); let (br_len, br_cap, hb_len, hb_cap) = s.vital_detector.buffer_status(); Json(serde_json::json!({ "vital_signs": { - "breathing_rate_bpm": vs.breathing_rate_bpm, - "heart_rate_bpm": vs.heart_rate_bpm, - "breathing_confidence": vs.breathing_confidence, - "heartbeat_confidence": vs.heartbeat_confidence, - "signal_quality": vs.signal_quality, + "breathing_rate_bpm": published.as_ref().and_then(|value| value.breathing_rate_bpm), + "heart_rate_bpm": published.as_ref().and_then(|value| value.heart_rate_bpm), + "breathing_confidence": published.as_ref().map(|value| value.breathing_confidence), + "heartbeat_confidence": published.as_ref().map(|value| value.heartbeat_confidence), + "signal_quality": published.as_ref().map(|value| value.signal_quality), }, + "authority": if published.is_some() { "explicit_calibration" } else { "abstained" }, + "abstention_reason": if published.is_some() { serde_json::Value::Null } else { serde_json::json!("fresh explicit calibration with exactly one occupant and qualified evidence required") }, "buffer_status": { "breathing_samples": br_len, "breathing_capacity": br_cap, @@ -6193,6 +6558,18 @@ async fn edge_registry_endpoint( /// GET /api/v1/edge-vitals — latest edge vitals from ESP32 (ADR-039). async fn edge_vitals_endpoint(State(state): State) -> Json { let s = state.read().await; + let explicit_single_occupant = !s.bootstrap_baseline_active + && s.field_model + .as_ref() + .is_some_and(|model| matches!(model.status(), CalibrationStatus::Fresh)) + && s.person_count() == 1; + if !explicit_single_occupant { + return Json(serde_json::json!({ + "status": "abstained", + "edge_vitals": null, + "message": "Fresh explicit calibration with exactly one occupant is required.", + })); + } match &s.edge_vitals { Some(v) => Json(serde_json::json!({ "status": "ok", @@ -6831,8 +7208,15 @@ async fn udp_receiver_task( .get(&node_id) .map(|ns| ns.frame_history.clone()) { - if let Some(ref mut fm) = s.field_model { + let accepted = if let Some(ref mut fm) = s.field_model { + let before = fm.calibration_frame_count(); field_bridge::maybe_feed_calibration(fm, &frame_history); + fm.calibration_frame_count() > before + } else { + false + }; + if accepted { + s.calibration_source_node_ids.insert(node_id); } } @@ -6903,6 +7287,24 @@ async fn udp_receiver_task( (vitals.presence_score as f64).min(1.0), &[], ); + let vital_candidates = VitalSigns { + breathing_rate_bpm: (vitals.breathing_rate_bpm > 0.0) + .then_some(vitals.breathing_rate_bpm), + heart_rate_bpm: (vitals.heartrate_bpm > 0.0) + .then_some(vitals.heartrate_bpm), + breathing_confidence: if vitals.presence { 0.7 } else { 0.0 }, + heartbeat_confidence: if vitals.presence { 0.7 } else { 0.0 }, + signal_quality: vitals.presence_score as f64, + }; + let explicit_calibration_fresh = !s.bootstrap_baseline_active + && s.field_model.as_ref().is_some_and(|model| { + matches!(model.status(), CalibrationStatus::Fresh) + }); + let published_vitals = vitals_for_publication( + &vital_candidates, + explicit_calibration_fresh, + total_persons, + ); let mut update = SensingUpdate { msg_type: "sensing_update".to_string(), @@ -6913,21 +7315,7 @@ async fn udp_receiver_task( features: fused_features.clone(), classification, signal_field, - vital_signs: Some(VitalSigns { - breathing_rate_bpm: if vitals.breathing_rate_bpm > 0.0 { - Some(vitals.breathing_rate_bpm) - } else { - None - }, - heart_rate_bpm: if vitals.heartrate_bpm > 0.0 { - Some(vitals.heartrate_bpm) - } else { - None - }, - breathing_confidence: if vitals.presence { 0.7 } else { 0.0 }, - heartbeat_confidence: if vitals.presence { 0.7 } else { 0.0 }, - signal_quality: vitals.presence_score as f64, - }), + vital_signs: published_vitals, enhanced_motion: None, enhanced_breathing: None, posture: None, @@ -7312,8 +7700,15 @@ async fn udp_receiver_task( .get(&node_id) .map(|ns| ns.frame_history.clone()) { - if let Some(ref mut fm) = s.field_model { + let accepted = if let Some(ref mut fm) = s.field_model { + let before = fm.calibration_frame_count(); field_bridge::maybe_feed_calibration(fm, &frame_history); + fm.calibration_frame_count() > before + } else { + false + }; + if accepted { + s.calibration_source_node_ids.insert(node_id); } } @@ -7369,6 +7764,15 @@ async fn udp_receiver_task( NODE_STALE_AFTER_MS, ); let room_classification = debounce_room_classification(&mut s, &room_inference); + let explicit_calibration_fresh = !s.bootstrap_baseline_active + && s.field_model.as_ref().is_some_and(|model| { + matches!(model.status(), CalibrationStatus::Fresh) + }); + let published_vitals = vitals_for_publication( + &vitals, + explicit_calibration_fresh, + total_persons, + ); let mut update = SensingUpdate { msg_type: "sensing_update".to_string(), @@ -7390,7 +7794,7 @@ async fn udp_receiver_task( fused_features.variance.min(1.0), &sub_variances, ), - vital_signs: Some(vitals), + vital_signs: published_vitals, enhanced_motion: None, enhanced_breathing: None, posture: None, @@ -7645,7 +8049,7 @@ async fn simulated_data_task(state: SharedState, tick_ms: u64) { features.variance.min(1.0), &sub_variances, ), - vital_signs: Some(vitals), + vital_signs: None, enhanced_motion: None, enhanced_breathing: None, posture: None, @@ -8810,12 +9214,49 @@ async fn main() { ); // ADR-044 §5.3: load persisted runtime config from the data directory. - let data_dir = std::path::PathBuf::from("data"); + let data_dir = args.data_dir.clone(); + if let Err(error) = std::fs::create_dir_all(&data_dir) { + warn!(path = %data_dir.display(), %error, "Could not create server data directory"); + } let runtime_config = load_runtime_config(&data_dir); // ADR-271: resolve (or generate + persist) the browser-session signing key // before any request can arrive. Zero-config for a single appliance; the // env var still wins for a multi-instance deployment that must share one. wifi_densepose_sensing_server::browser_session::init_secret(&data_dir); + let (bootstrap_field_model, bootstrap_metadata, bootstrap_baseline_active) = + if !args.calibrate { + args.installation_id.as_deref().map_or( + (None, None, false), + |installation_id| { + let path = bootstrap_baseline::path_in(&data_dir); + match bootstrap_baseline::load( + &path, + installation_id, + chrono::Utc::now().timestamp_millis() as u64, + ) { + Ok((model, metadata)) => { + info!( + source_model_id = %metadata.source_model_id, + source_node_ids = ?metadata.source_node_ids, + "Restored privacy reduced empty room bootstrap prior" + ); + (Some(model), Some(metadata), true) + } + Err(bootstrap_baseline::BootstrapBaselineError::Io(error)) + if error.kind() == std::io::ErrorKind::NotFound => + { + (None, None, false) + } + Err(error) => { + warn!(%error, "Ignored invalid empty room bootstrap prior"); + (None, None, false) + } + } + }, + ) + } else { + (None, None, false) + }; info!( "Loaded runtime config: dedup_factor={:.2}", runtime_config.dedup_factor @@ -9029,8 +9470,13 @@ async fn main() { info!("Field model calibration enabled — room should be empty during startup"); FieldModel::new(field_bridge::single_link_config()).ok() } else { - None + bootstrap_field_model }, + installation_id: args.installation_id.clone(), + bootstrap_baseline: bootstrap_metadata, + bootstrap_baseline_active, + calibration_model_id: args.calibrate.then(opaque_calibration_model_id), + calibration_source_node_ids: std::collections::BTreeSet::new(), // ADR-044 §5.2: rolling-P95 over ~30 s at 20 Hz; warm-up after 60 samples. p95_variance: RollingP95::new(600, 60), p95_motion_band_power: RollingP95::new(600, 60), @@ -9326,7 +9772,13 @@ async fn main() { // Field model calibration (eigenvalue-based person counting) .route("/api/v1/calibration/start", post(calibration_start)) .route("/api/v1/calibration/stop", post(calibration_stop)) + .route( + "/api/v1/calibration/bootstrap/promote", + post(calibration_promote_bootstrap), + ) + .route("/api/v1/calibration/cancel", post(calibration_cancel)) .route("/api/v1/calibration/status", get(calibration_status)) + .route("/api/v1/calibration/reset", post(calibration_reset)) // ADR-044 §5.3: runtime-configurable dedup factor .route( "/api/v1/config/dedup-factor", diff --git a/v2/crates/wifi-densepose-signal/src/ruvsense/field_model.rs b/v2/crates/wifi-densepose-signal/src/ruvsense/field_model.rs index 9ea6a6ef..14c9e65d 100644 --- a/v2/crates/wifi-densepose-signal/src/ruvsense/field_model.rs +++ b/v2/crates/wifi-densepose-signal/src/ruvsense/field_model.rs @@ -23,6 +23,7 @@ use ndarray::Array2; use ndarray_linalg::Eigh; #[cfg(feature = "eigenvalue")] use ndarray_linalg::UPLO; +use serde::{Deserialize, Serialize}; // --------------------------------------------------------------------------- // Calibration window constants @@ -42,6 +43,11 @@ pub const CALIBRATION_DURATION_S: f64 = 600.0; /// [`CALIBRATION_DURATION_S`] ("10 min at 20 Hz"). pub const ASSUMED_CALIBRATION_RATE_HZ: f64 = 20.0; +/// A recent window whose mean remains within this residual energy of the +/// empty room manifold is treated as background before eigenvalue counting. +#[cfg(feature = "eigenvalue")] +const EMPTY_ROOM_RESIDUAL_ENERGY_MAX: f64 = 1.0; + // --------------------------------------------------------------------------- // Error types // --------------------------------------------------------------------------- @@ -261,7 +267,8 @@ impl LinkBaselineStats { // --------------------------------------------------------------------------- /// Configuration for field model calibration and runtime. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] pub struct FieldModelConfig { /// Number of links in the mesh. pub n_links: usize, @@ -304,7 +311,8 @@ impl Default for FieldModelConfig { /// Learned from SVD on the covariance of CSI amplitudes during /// empty-room calibration. The top-K modes capture environmental /// variation (temperature, humidity, time-of-day effects). -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] pub struct FieldNormalMode { /// Per-link baseline mean: `[n_links][n_subcarriers]`. pub baseline: Vec>, @@ -362,6 +370,18 @@ pub enum CalibrationStatus { Expired, } +/// Privacy reduced restart image for a completed field model. +/// +/// This contains only aggregate baseline statistics and environmental modes. +/// It excludes raw CSI frames, device addresses, room names, credentials, and +/// calibration authority. A restored image is only a bootstrap prior. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct FieldModelSnapshotV1 { + pub config: FieldModelConfig, + pub modes: FieldNormalMode, +} + /// The persistent field model for a single room. /// /// Maintains per-link Welford statistics during calibration, then @@ -483,6 +503,96 @@ impl FieldModel { self.modes.as_ref() } + /// Export aggregate model state suitable for local restart persistence. + /// Raw calibration observations and Welford accumulators are not exported. + pub fn export_snapshot(&self) -> Result { + let modes = self.modes.clone().ok_or(FieldModelError::NotCalibrated)?; + Ok(FieldModelSnapshotV1 { + config: self.config.clone(), + modes, + }) + } + + /// Restore a bounded, validated aggregate snapshot. + pub fn from_snapshot( + snapshot: FieldModelSnapshotV1, + current_us: u64, + ) -> Result { + let config = &snapshot.config; + if config.n_links > 16 || config.n_subcarriers > 2_048 { + return Err(FieldModelError::InvalidConfig( + "snapshot dimensions exceed bounded limits".into(), + )); + } + if !config.min_calibration_duration_s.is_finite() + || config.min_calibration_duration_s < 0.0 + || !config.baseline_expiry_s.is_finite() + || config.baseline_expiry_s <= 0.0 + || config.baseline_expiry_s > 604_800.0 + { + return Err(FieldModelError::InvalidConfig( + "snapshot timing values are invalid".into(), + )); + } + + let modes = &snapshot.modes; + let baseline_shape_valid = modes.baseline.len() == config.n_links + && modes + .baseline + .iter() + .all(|link| link.len() == config.n_subcarriers); + let mode_shape_valid = modes.environmental_modes.len() == modes.mode_energies.len() + && modes.environmental_modes.len() <= config.n_modes + && modes + .environmental_modes + .iter() + .all(|mode| mode.len() == config.n_subcarriers); + let values_valid = modes + .baseline + .iter() + .flatten() + .chain(modes.environmental_modes.iter().flatten()) + .all(|value| value.is_finite()) + && modes + .mode_energies + .iter() + .all(|value| value.is_finite() && *value >= 0.0) + && modes.variance_explained.is_finite() + && (0.0..=10.0).contains(&modes.variance_explained) + && modes.baseline_noise_var.is_finite() + && modes.baseline_noise_var >= 0.0 + && modes.baseline_eigenvalue_count <= config.n_subcarriers; + if !baseline_shape_valid || !mode_shape_valid || !values_valid { + return Err(FieldModelError::InvalidConfig( + "snapshot modes are malformed or non finite".into(), + )); + } + if modes.calibrated_at_us == 0 + || modes.calibrated_at_us > current_us.saturating_add(300_000_000) + { + return Err(FieldModelError::InvalidConfig( + "snapshot calibration time is invalid".into(), + )); + } + let elapsed_s = current_us.saturating_sub(modes.calibrated_at_us) as f64 / 1_000_000.0; + if elapsed_s > config.baseline_expiry_s { + return Err(FieldModelError::BaselineExpired { + elapsed_s, + max_s: config.baseline_expiry_s, + }); + } + + let mut model = Self::new(snapshot.config)?; + model.status = if elapsed_s > model.config.baseline_expiry_s * 0.5 { + CalibrationStatus::Stale + } else { + CalibrationStatus::Fresh + }; + model.last_calibration_us = modes.calibrated_at_us; + model.modes = Some(snapshot.modes); + Ok(model) + } + /// Number of calibration frames collected so far. pub fn calibration_frame_count(&self) -> u64 { self.link_stats @@ -883,6 +993,17 @@ impl FieldModel { *m /= count as f64; } + // Rank alone is not person evidence. A continuation window can expose + // more significant eigenvalues while its mean remains on the learned + // empty room manifold. Fail toward empty only inside the same + // conservative residual energy boundary used by the server fallback. + if self + .extract_perturbation(&[mean.clone()]) + .is_ok_and(|perturbation| perturbation.total_energy <= EMPTY_ROOM_RESIDUAL_ENERGY_MAX) + { + return Ok(0); + } + let mut cov = Array2::::zeros((n, n)); for frame in recent_frames { if frame.len() >= n { @@ -1538,6 +1659,48 @@ mod tests { ); } + #[test] + fn snapshot_round_trip_preserves_aggregate_modes_without_raw_frames() { + let config = make_config(1, 4, 10); + let mut model = FieldModel::new(config).unwrap(); + for i in 0..10 { + model + .feed_calibration(&[vec![1.0 + i as f64 * 0.01, 2.0, 3.0, 4.0]]) + .unwrap(); + } + model.finalize_calibration(1_000_000, 0xCA1).unwrap(); + + let snapshot = model.export_snapshot().unwrap(); + let restored = FieldModel::from_snapshot(snapshot.clone(), 2_000_000).unwrap(); + + assert_eq!(restored.status(), CalibrationStatus::Fresh); + assert_eq!(restored.calibration_frame_count(), 0); + assert_eq!(restored.export_snapshot().unwrap(), snapshot); + } + + #[test] + fn snapshot_restore_rejects_expired_and_malformed_images() { + let config = make_config(1, 4, 10); + let mut model = FieldModel::new(config).unwrap(); + for _ in 0..10 { + model.feed_calibration(&[vec![1.0, 2.0, 3.0, 4.0]]).unwrap(); + } + model.finalize_calibration(1_000_000, 0).unwrap(); + let snapshot = model.export_snapshot().unwrap(); + + assert!(matches!( + FieldModel::from_snapshot(snapshot.clone(), 90_000_000_000), + Err(FieldModelError::BaselineExpired { .. }) + )); + + let mut malformed = snapshot; + malformed.modes.baseline[0].pop(); + assert!(matches!( + FieldModel::from_snapshot(malformed, 2_000_000), + Err(FieldModelError::InvalidConfig(_)) + )); + } + #[test] fn test_environmental_projection_removes_drift() { let config = make_config(1, 4, 10); From 2227b42841a230f1df00e5d9099e45b454de648b Mon Sep 17 00:00:00 2001 From: rUv Date: Fri, 4 Sep 2026 23:02:06 -0400 Subject: [PATCH 04/27] feat(calibration): learn empty room startup confidence (#1840) --- ...DR-355-local-empty-room-bootstrap-prior.md | 55 +- .../wifi-densepose-sensing-server/Cargo.toml | 2 +- .../src/bootstrap_baseline.rs | 54 +- .../src/field_bridge.rs | 300 ++++++++- .../wifi-densepose-sensing-server/src/main.rs | 327 ++++++++-- .../src/ruvsense/field_model.rs | 590 ++++++++++++++++-- 6 files changed, 1218 insertions(+), 110 deletions(-) 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 c14f7827..5eb4ba73 100644 --- a/docs/adr/ADR-355-local-empty-room-bootstrap-prior.md +++ b/docs/adr/ADR-355-local-empty-room-bootstrap-prior.md @@ -23,17 +23,26 @@ directory, which is unreliable for embedded application hosts. RuView may persist a completed empty room field model as a local bootstrap prior only after two server controlled stages: -1. Normal calibration reaches both 12,000 accepted frames and 600 seconds. -2. Promotion observes 12 new one second samples. At least 10 must resolve - empty, all ticks must be fresh, and none may publish numeric vital signs. +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. +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 + signs. The stored image contains aggregate means, environmental modes, mode energies, -noise statistics, configuration, contributing node IDs, and a server generated -model ID. It excludes raw CSI, waveforms, device addresses, room names, pose, -identity, credentials, and positive human labels. +noise statistics, sorted scalar residual references, configuration, one source +node ID, and a server generated model ID. It excludes raw CSI, waveforms, +device addresses, room names, pose, identity, credentials, and positive human +labels. Runtime matching compares a fifty frame mean with the learned residual +distribution. Its score is empirical background conformance, not an absence +probability. The image is bound to a SHA 256 digest of the stable installation ID, carries -an integrity digest, is capped at 1 MiB, and inherits the field model expiry. +an integrity digest over the exact stored payload bytes, is capped at 1 MiB, +and inherits the field model expiry. Files with an invalid schema, dimensions, values, node set, installation binding, digest, size, or lifetime fail closed. @@ -43,6 +52,11 @@ evidence or numeric heart and breathing rates. Explicit calibration replaces it with fresh statistics. Cancelling an unfinished capture restores a valid prior. Administrator scoped reset removes it. +The status endpoint reports the learned runtime window, residual threshold, +reference count, current background conformance, and the negative only +authority boundary. A restored model never resumes collection and never +persists raw calibration frames. + The server accepts `--data-dir` or `RUVIEW_DATA_DIR`, allowing an application host to provide a protected state directory. @@ -71,12 +85,29 @@ occupied sequences. Empty data is never relabeled as human training. denies calibrated evidence and numeric vital authority. 5. Numeric vitals fail closed unless an explicit fresh calibration resolves exactly one occupant at sufficient signal quality and confidence. +6. The legacy `edge_vitals` WebSocket message applies the same publication + 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. + +## Measured installation evidence + +One physical empty home run on source Node 5 collected 1,762 accepted frames +over 655 seconds and produced 35 privacy reduced reference windows. Promotion +observed 12 of 12 fresh empty samples, zero stale samples, and zero numeric +vital samples. After process restart, 12 of 12 fresh samples matched the empty +background and contained no numeric vital signs. Background conformance scores +ranged from 50.0 to 64.3 percent with a median of 54.3 percent. + +This is measured negative evidence for one installation. No occupied positive +holdout was recorded, so it does not prove person recall, person counting, or +cross home accuracy improvement. ## Acceptance test With a physically empty room, complete calibration on stable nodes, promote -the model, and restart the server with the same installation ID 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. +the single source model, and restart the server with the same installation ID +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. diff --git a/v2/crates/wifi-densepose-sensing-server/Cargo.toml b/v2/crates/wifi-densepose-sensing-server/Cargo.toml index 3ab1a3c3..eaae6dfc 100644 --- a/v2/crates/wifi-densepose-sensing-server/Cargo.toml +++ b/v2/crates/wifi-densepose-sensing-server/Cargo.toml @@ -30,7 +30,7 @@ mdns-sd = "0.11" # Serialization serde = { workspace = true } -serde_json.workspace = true +serde_json = { workspace = true, features = ["raw_value"] } # Logging tracing.workspace = true 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 30fb6a11..291e7e1c 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/bootstrap_baseline.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/bootstrap_baseline.rs @@ -6,6 +6,7 @@ use rand::{rngs::OsRng, RngCore}; use serde::{Deserialize, Serialize}; +use serde_json::value::RawValue; use sha2::{Digest, Sha256}; use std::fs::{self, OpenOptions}; use std::io::{Read, Write}; @@ -19,6 +20,8 @@ pub const BOOTSTRAP_BASELINE_SCHEMA: &str = "ruview.bootstrap-empty-field-model. pub const BOOTSTRAP_BASELINE_AUTHORITY: &str = "bootstrap_only"; pub const BOOTSTRAP_VALIDATION_SAMPLES: usize = 12; pub const BOOTSTRAP_VALIDATION_MIN_EMPTY: usize = 10; +pub const BOOTSTRAP_VALIDATION_MIN_SPACING_MS: u64 = 1_000; +pub const BOOTSTRAP_VALIDATION_SAMPLE_TIMEOUT_MS: u64 = 4_000; const BOOTSTRAP_MAX_FILE_BYTES: u64 = 1_048_576; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] @@ -41,6 +44,13 @@ struct BootstrapImageV1 { content_sha256: String, } +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct BootstrapImageRawV1 { + payload: Box, + content_sha256: String, +} + #[derive(Debug, Clone, Serialize, PartialEq, Eq)] pub struct BootstrapBaselineMetadata { pub authority: &'static str, @@ -181,11 +191,19 @@ pub fn load( if bytes.len() as u64 > BOOTSTRAP_MAX_FILE_BYTES { return Err(BootstrapBaselineError::FileTooLarge); } - let image: BootstrapImageV1 = serde_json::from_slice(&bytes)?; - validate_payload(&image.payload, installation_id, current_unix_ms)?; - if payload_digest(&image.payload)? != image.content_sha256 { + // Verify the exact payload bytes written by storage before decoding the + // floating point model. Equivalent JSON number spellings may serialize + // differently, so decode and reserialize is not an integrity contract. + let raw_image: BootstrapImageRawV1 = serde_json::from_slice(&bytes)?; + if hex_sha256(raw_image.payload.get().as_bytes()) != raw_image.content_sha256 { return Err(BootstrapBaselineError::DigestMismatch); } + let payload: BootstrapPayloadV1 = serde_json::from_str(raw_image.payload.get())?; + validate_payload(&payload, installation_id, current_unix_ms)?; + let image = BootstrapImageV1 { + payload, + content_sha256: raw_image.content_sha256, + }; let model = FieldModel::from_snapshot( image.payload.field_model.clone(), current_unix_ms.saturating_mul(1_000), @@ -368,6 +386,11 @@ mod tests { #[test] fn promotion_gate_requires_twelve_fresh_samples_and_zero_vitals() { + assert_eq!(BOOTSTRAP_VALIDATION_MIN_SPACING_MS, 1_000); + assert_eq!(BOOTSTRAP_VALIDATION_SAMPLE_TIMEOUT_MS, 4_000); + assert!( + BOOTSTRAP_VALIDATION_SAMPLE_TIMEOUT_MS >= BOOTSTRAP_VALIDATION_MIN_SPACING_MS + ); let passing = vec![ BootstrapValidationSample { fresh_tick: true, @@ -385,4 +408,29 @@ mod tests { vital[0].vital_signs_absent = false; assert!(!evaluate_validation(&vital).passed); } + + #[test] + fn restart_verifies_the_exact_stored_payload_bytes() { + let dir = tempfile::tempdir().unwrap(); + let path = path_in(dir.path()); + let now_ms = 1_000_000; + let model = completed_model(now_ms * 1_000); + store(&path, "home-a", &[5], "model", now_ms, &model).unwrap(); + + let original = fs::read_to_string(&path).unwrap(); + let image: BootstrapImageRawV1 = serde_json::from_str(&original).unwrap(); + let rewritten_payload = image.payload.get().replacen( + "\"min_calibration_duration_s\":0.0", + "\"min_calibration_duration_s\":0.00", + 1, + ); + assert_ne!(rewritten_payload, image.payload.get()); + let digest = hex_sha256(rewritten_payload.as_bytes()); + let rewritten = + format!("{{\"payload\":{rewritten_payload},\"content_sha256\":\"{digest}\"}}"); + fs::write(&path, rewritten).unwrap(); + + let (_, loaded) = load(&path, "home-a", now_ms + 1).unwrap(); + assert_eq!(loaded.content_sha256, digest); + } } 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 5b7f588f..a60c266c 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/field_bridge.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/field_bridge.rs @@ -40,6 +40,103 @@ const ENERGY_THRESH_3: f64 = 25.0; /// reliable — genuine higher counts come from the multistatic fusion path. const MAX_SINGLE_LINK_OCCUPANCY: usize = 3; +/// Low-authority comparison of the current source window with a restored +/// empty-room bootstrap image. This is background conformance only and cannot +/// authorize presence, person count, pose, identity, or vital signs. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct BootstrapBackgroundMatch { + pub matches_empty: bool, + pub score: Option, + pub residual_energy: f64, + pub residual_energy_threshold: f64, + pub window_size: usize, + pub reference_window_count: usize, +} + +fn mean_frame(frames: &[Vec]) -> Option> { + let width = frames.first()?.len(); + if width == 0 { + return None; + } + let mut mean = vec![0.0_f64; width]; + let mut count = 0_usize; + for frame in frames { + if frame.len() != width { + continue; + } + for (sum, value) in mean.iter_mut().zip(frame.iter()) { + *sum += value; + } + count += 1; + } + if count == 0 { + return None; + } + for value in &mut mean { + *value /= count as f64; + } + Some(mean) +} + +fn perturbation_occupancy(field: &FieldModel, frames: &[Vec]) -> Option { + let adaptive_threshold = field.empty_room_residual_energy_threshold(); + let frame = match adaptive_threshold { + Some(_) => mean_frame(frames)?, + None => frames.first()?.clone(), + }; + let perturbation = field.extract_perturbation(&[frame]).ok()?; + let count = match adaptive_threshold { + Some(threshold) => { + let empty_threshold = threshold.max(1.0); + if perturbation.total_energy > empty_threshold * 25.0 { + 3 + } else if perturbation.total_energy > empty_threshold * 12.0 { + 2 + } else if perturbation.total_energy > empty_threshold { + 1 + } else { + 0 + } + } + None => { + if perturbation.total_energy > ENERGY_THRESH_3 { + 3 + } else if perturbation.total_energy > ENERGY_THRESH_2 { + 2 + } else if perturbation.total_energy > 1.0 { + 1 + } else { + 0 + } + } + }; + Some(count) +} + +/// Provenance for a count produced by the calibrated field model. Callers +/// that attach calibration evidence must never substitute the score heuristic +/// when the calibrated path cannot evaluate the observation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CalibratedOccupancyMethod { + Eigenvalue, + PerturbationEnergy, +} + +impl CalibratedOccupancyMethod { + pub const fn wire_name(self) -> &'static str { + match self { + Self::Eigenvalue => "field_model_eigenvalue_v1", + Self::PerturbationEnergy => "field_model_perturbation_energy_v1", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CalibratedOccupancy { + pub person_count: usize, + pub method: CalibratedOccupancyMethod, +} + /// Create a FieldModelConfig for single-link mode (one ESP32 node = one link). /// This avoids the DimensionMismatch error when feeding single-frame observations. pub fn single_link_config() -> FieldModelConfig { @@ -67,7 +164,7 @@ pub fn occupancy_or_fallback( .iter() .rev() .take(OCCUPANCY_WINDOW) - .cloned() + .map(|frame| CALIB_NORMALIZER.resample_to_canonical(frame)) .collect(); if frames.is_empty() { @@ -87,26 +184,86 @@ pub fn occupancy_or_fallback( // Fallback: perturbation energy thresholds. // FieldModel expects [n_links][n_subcarriers] — we use n_links=1. - let observation = vec![frames[0].clone()]; - match field.extract_perturbation(&observation) { - Ok(perturbation) => { - if perturbation.total_energy > ENERGY_THRESH_3 { - 3 - } else if perturbation.total_energy > ENERGY_THRESH_2 { - 2 - } else if perturbation.total_energy > 1.0 { - 1 - } else { - 0 - } - } - Err(_) => score_to_person_count(smoothed_score, prev_count), - } + perturbation_occupancy(field, &frames) + .unwrap_or_else(|| score_to_person_count(smoothed_score, prev_count)) } _ => score_to_person_count(smoothed_score, prev_count), } } +/// Return true only when a restored low-authority bootstrap model has a full +/// runtime window and that model resolves the window as empty. This helper is +/// deliberately one-way: a bootstrap prior may suppress an obvious background +/// false positive, but cannot authorize presence or vital signs. +pub fn bootstrap_empty_prior_applies( + field: &FieldModel, + frame_history: &VecDeque>, + observed_at_us: u64, +) -> bool { + bootstrap_background_match(field, frame_history, observed_at_us) + .is_some_and(|result| result.matches_empty) +} + +/// Score the current canonical runtime window against the learned empty-room +/// residual references. Legacy models without scalar references preserve the +/// binary match but intentionally return no score. +pub fn bootstrap_background_match( + field: &FieldModel, + frame_history: &VecDeque>, + observed_at_us: u64, +) -> Option { + if frame_history.len() < OCCUPANCY_WINDOW + || field.check_freshness(observed_at_us) != CalibrationStatus::Fresh + { + return None; + } + let frames: Vec> = frame_history + .iter() + .rev() + .take(OCCUPANCY_WINDOW) + .map(|frame| CALIB_NORMALIZER.resample_to_canonical(frame)) + .collect(); + let result = field.empty_room_match(&frames)?; + Some(BootstrapBackgroundMatch { + matches_empty: result.matches_empty, + score: result.score, + residual_energy: result.residual_energy, + residual_energy_threshold: result.residual_energy_threshold, + window_size: result.window_size, + reference_window_count: result.reference_window_count, + }) +} + +/// Produce a fail-closed occupancy result from a fresh calibrated model. +pub fn calibrated_occupancy( + field: &FieldModel, + frame_history: &VecDeque>, + observed_at_us: u64, +) -> Option { + if field.check_freshness(observed_at_us) != CalibrationStatus::Fresh { + return None; + } + let frames: Vec> = frame_history + .iter() + .rev() + .take(OCCUPANCY_WINDOW) + .map(|frame| CALIB_NORMALIZER.resample_to_canonical(frame)) + .collect(); + if frames.is_empty() { + return None; + } + if let Ok(person_count) = field.estimate_occupancy(&frames) { + return Some(CalibratedOccupancy { + person_count: person_count.min(MAX_SINGLE_LINK_OCCUPANCY), + method: CalibratedOccupancyMethod::Eigenvalue, + }); + } + Some(CalibratedOccupancy { + person_count: perturbation_occupancy(field, &frames)?, + method: CalibratedOccupancyMethod::PerturbationEnergy, + }) +} + /// Feed the latest frame to the FieldModel during calibration collection. /// /// Acts while the model is `Uncalibrated` or `Collecting`. The first fed frame @@ -174,6 +331,33 @@ pub fn parse_node_positions(input: &str) -> Vec<[f32; 3]> { mod tests { use super::*; + fn fresh_test_model() -> FieldModel { + let config = FieldModelConfig { + n_links: 1, + n_subcarriers: 56, + n_modes: 3, + min_calibration_frames: 10, + min_calibration_duration_s: 0.0, + baseline_expiry_s: 2.0, + }; + let mut field = FieldModel::new(config).expect("field model"); + for index in 0..20 { + let phase = index as f64 * 0.1; + let observation = vec![(0..56) + .map(|subcarrier| { + 1.0 + subcarrier as f64 * 0.01 + (phase + subcarrier as f64 * 0.03).sin() * 0.01 + }) + .collect()]; + field + .feed_calibration(&observation) + .expect("calibration frame"); + } + field + .finalize_calibration(1_000_000, 7) + .expect("finalize calibration"); + field + } + #[test] fn test_parse_node_positions() { let positions = parse_node_positions("0,0,1.5;3,0,1.5;1.5,3,1.5"); @@ -263,4 +447,88 @@ mod tests { "wide frame must accumulate, not be silently dropped" ); } + + #[test] + fn calibrated_occupancy_is_fresh_model_only_and_normalizes_runtime_frames() { + let field = fresh_test_model(); + let mut history = VecDeque::new(); + history.push_back(vec![1.02; 128]); + let result = + calibrated_occupancy(&field, &history, 1_500_000).expect("fresh calibrated inference"); + assert!(result.person_count <= MAX_SINGLE_LINK_OCCUPANCY); + assert!(matches!( + result.method, + CalibratedOccupancyMethod::Eigenvalue | CalibratedOccupancyMethod::PerturbationEnergy + )); + assert_eq!(calibrated_occupancy(&field, &history, 4_000_000), None); + assert_eq!( + calibrated_occupancy(&field, &VecDeque::new(), 1_500_000), + None + ); + } + + #[test] + fn adaptive_runtime_reference_scores_empty_and_rejects_shift() { + let config = FieldModelConfig { + n_links: 1, + n_subcarriers: 56, + n_modes: 3, + min_calibration_frames: 500, + min_calibration_duration_s: 0.0, + baseline_expiry_s: 60.0, + }; + let mut field = FieldModel::new(config).expect("field model"); + let mut calibration = Vec::new(); + for frame_index in 0..600 { + let time = frame_index as f64 * 0.071; + let frame: Vec = (0..56) + .map(|subcarrier| { + let carrier = subcarrier as f64; + 18.0 + carrier * 0.08 + + (time + carrier * 0.13).sin() * 0.8 + + (time * 0.37 + carrier * 0.031).cos() * 0.35 + }) + .collect(); + field.feed_calibration(&[frame.clone()]).unwrap(); + calibration.push(frame); + } + field.finalize_calibration(1_000_000, 7).unwrap(); + + let empty_history: VecDeque> = calibration[550..600].iter().cloned().collect(); + let empty = calibrated_occupancy(&field, &empty_history, 1_500_000) + .expect("adaptive perturbation result"); + assert_eq!(empty.person_count, 0); + let background = bootstrap_background_match(&field, &empty_history, 1_500_000) + .expect("background score"); + assert!(background.matches_empty); + assert_eq!(background.reference_window_count, 12); + assert!(background + .score + .is_some_and(|score| (0.5..=1.0).contains(&score))); + + let short_history: VecDeque> = empty_history.iter().take(49).cloned().collect(); + assert!(!bootstrap_empty_prior_applies( + &field, + &short_history, + 1_500_000 + )); + + let shifted_history: VecDeque> = calibration[550..600] + .iter() + .map(|frame| { + frame + .iter() + .enumerate() + .map(|(index, value)| value + if index % 3 == 0 { 8.0 } else { -5.0 }) + .collect() + }) + .collect(); + let shifted = calibrated_occupancy(&field, &shifted_history, 1_500_000) + .expect("shifted perturbation result"); + assert!(shifted.person_count >= 1); + let shifted_background = bootstrap_background_match(&field, &shifted_history, 1_500_000) + .expect("shifted background result"); + assert!(!shifted_background.matches_empty); + assert_eq!(shifted_background.score, Some(0.0)); + } } diff --git a/v2/crates/wifi-densepose-sensing-server/src/main.rs b/v2/crates/wifi-densepose-sensing-server/src/main.rs index 111c91cd..0c062830 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/main.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/main.rs @@ -40,7 +40,9 @@ use wifi_densepose_sensing_server::{ trainer, }; use wifi_densepose_sensing_server::bootstrap_baseline::{ - self, BootstrapBaselineMetadata, BootstrapValidationSample, BOOTSTRAP_VALIDATION_SAMPLES, + self, BootstrapBaselineMetadata, BootstrapValidationSample, + BOOTSTRAP_VALIDATION_MIN_SPACING_MS, BOOTSTRAP_VALIDATION_SAMPLES, + BOOTSTRAP_VALIDATION_SAMPLE_TIMEOUT_MS, }; // ADR-295 / ADR-297: canonical provenance state + per-node/room inference. use wifi_densepose_sensing_server::inference::{fuse_room, NodeInference, RoomInference}; @@ -1715,12 +1717,27 @@ impl AppStateInner { fn person_count(&self) -> usize { match self.field_model.as_ref() { Some(fm) => { - // Prefer global frame_history (populated by wifi/simulate paths). - // Fall back to freshest per-node history (populated by ESP32 paths). - let history = if !self.frame_history.is_empty() { + // A single-link model may score only the source node it was + // calibrated against. Applying one node's baseline to a + // different radio creates deterministic false occupancy from + // hardware-specific amplitude offsets. + let bound_source_node_id = (self.calibration_source_node_ids.len() == 1) + .then(|| self.calibration_source_node_ids.iter().next().copied()) + .flatten() + .or_else(|| { + self.bootstrap_baseline.as_ref().and_then(|metadata| { + (metadata.source_node_ids.len() == 1) + .then_some(metadata.source_node_ids[0]) + }) + }); + let history = if let Some(node_id) = bound_source_node_id { + self.node_states + .get(&node_id) + .map(|state| &state.frame_history) + .unwrap_or(&self.frame_history) + } else if !self.frame_history.is_empty() { &self.frame_history } else { - // Find the node with the most recent frame self.node_states .values() .filter(|ns| !ns.frame_history.is_empty()) @@ -1739,6 +1756,36 @@ impl AppStateInner { } } + /// A restored startup image has negative-only authority. It may suppress + /// an empty-room false positive after one complete runtime window, but it + /// cannot establish positive presence or publish vital signs. + fn bootstrap_empty_prior_applies(&self, observed_at_unix_ms: u64) -> bool { + self.bootstrap_background_match(observed_at_unix_ms) + .is_some_and(|result| result.matches_empty) + } + + /// Return current background conformance for the exact source node bound + /// into the restored image. This remains diagnostic and negative-only. + fn bootstrap_background_match( + &self, + observed_at_unix_ms: u64, + ) -> Option { + if !self.bootstrap_baseline_active { + return None; + } + let field_model = self.field_model.as_ref()?; + let metadata = self.bootstrap_baseline.as_ref()?; + let [source_node_id] = metadata.source_node_ids.as_slice() else { + return None; + }; + let history = &self.node_states.get(source_node_id)?.frame_history; + field_bridge::bootstrap_background_match( + field_model, + history, + observed_at_unix_ms.saturating_mul(1_000), + ) + } + fn effective_source(&self) -> String { if self.source == "esp32" { if let Some(last) = self.last_esp32_frame { @@ -6041,6 +6088,50 @@ fn vitals_for_publication( }) } +fn edge_vitals_message_for_publication( + raw: &Esp32VitalsPacket, + published_vitals: Option<&VitalSigns>, + bootstrap_empty: bool, + explicit_calibration_fresh: bool, + person_count: usize, +) -> serde_json::Value { + let numeric_vitals_authorized = published_vitals.is_some(); + let abstention_reason = if numeric_vitals_authorized { + None + } else if bootstrap_empty { + Some("bootstrap_empty_background") + } else if !explicit_calibration_fresh { + Some("explicit_calibration_required") + } else if person_count != 1 { + Some("exactly_one_occupant_required") + } else { + Some("vital_quality_gate_failed") + }; + let effective_presence = raw.presence && !bootstrap_empty; + let effective_motion = raw.motion && !bootstrap_empty; + let effective_person_count = if bootstrap_empty { 0 } else { person_count }; + + serde_json::json!({ + "type": "edge_vitals", + "node_id": raw.node_id, + "presence": effective_presence, + "fall_detected": raw.fall_detected && !bootstrap_empty, + "motion": effective_motion, + "breathing_rate_bpm": published_vitals + .and_then(|vitals| vitals.breathing_rate_bpm), + "heartrate_bpm": published_vitals + .and_then(|vitals| vitals.heart_rate_bpm), + "n_persons": effective_person_count, + "person_count_valid": raw.person_count_valid && !bootstrap_empty, + "motion_energy": if bootstrap_empty { 0.0 } else { raw.motion_energy }, + "presence_score": if bootstrap_empty { 0.0 } else { raw.presence_score }, + "rssi": raw.rssi, + "calibrated_evidence_authorized": explicit_calibration_fresh, + "numeric_vitals_authorized": numeric_vitals_authorized, + "abstention_reason": abstention_reason, + }) +} + fn opaque_calibration_model_id() -> String { let mut bytes = [0_u8; 16]; OsRng.fill_bytes(&mut bytes); @@ -6048,10 +6139,26 @@ fn opaque_calibration_model_id() -> String { format!("cal-model-{suffix}") } +fn calibration_source_accepts( + source_node_ids: &std::collections::BTreeSet, + node_id: u8, +) -> bool { + source_node_ids.is_empty() || source_node_ids.contains(&node_id) +} + #[cfg(test)] mod bootstrap_vital_publication_tests { use super::*; + #[test] + fn single_link_calibration_binds_to_first_source() { + let mut sources = std::collections::BTreeSet::new(); + assert!(calibration_source_accepts(&sources, 5)); + sources.insert(5); + assert!(calibration_source_accepts(&sources, 5)); + assert!(!calibration_source_accepts(&sources, 3)); + } + fn strong_candidates() -> VitalSigns { VitalSigns { breathing_rate_bpm: Some(15.0), @@ -6083,6 +6190,61 @@ mod bootstrap_vital_publication_tests { candidates.signal_quality = 0.2; assert!(vitals_for_publication(&candidates, true, 1).is_none()); } + + fn raw_edge_vitals() -> Esp32VitalsPacket { + Esp32VitalsPacket { + node_id: 5, + presence: true, + fall_detected: true, + motion: true, + breathing_rate_bpm: 15.0, + heartrate_bpm: 72.0, + rssi: -42, + n_persons: 1, + person_count_valid: true, + motion_energy: 0.8, + presence_score: 0.9, + timestamp_ms: 42, + } + } + + #[test] + fn bootstrap_empty_background_suppresses_legacy_edge_vitals() { + let message = edge_vitals_message_for_publication( + &raw_edge_vitals(), + None, + true, + false, + 0, + ); + assert_eq!(message["presence"], false); + assert_eq!(message["motion"], false); + assert_eq!(message["fall_detected"], false); + assert_eq!(message["n_persons"], 0); + assert!(message["breathing_rate_bpm"].is_null()); + assert!(message["heartrate_bpm"].is_null()); + assert_eq!(message["numeric_vitals_authorized"], false); + assert_eq!(message["abstention_reason"], "bootstrap_empty_background"); + } + + #[test] + fn explicit_single_occupant_may_publish_legacy_edge_vitals() { + let published = vitals_for_publication(&strong_candidates(), true, 1).unwrap(); + let message = edge_vitals_message_for_publication( + &raw_edge_vitals(), + Some(&published), + false, + true, + 1, + ); + assert_eq!(message["presence"], true); + assert_eq!(message["fall_detected"], true); + assert_eq!(message["n_persons"], 1); + assert_eq!(message["breathing_rate_bpm"], 15.0); + assert_eq!(message["heartrate_bpm"], 72.0); + assert_eq!(message["numeric_vitals_authorized"], true); + assert!(message["abstention_reason"].is_null()); + } } // ── Field model calibration endpoints (eigenvalue person counting) ────────── @@ -6194,6 +6356,17 @@ async fn calibration_stop(State(state): State) -> Json) -> Json { let s = state.read().await; + let observed_at_unix_ms = chrono::Utc::now().timestamp_millis().max(0) as u64; + let bootstrap_background_match = s.bootstrap_background_match(observed_at_unix_ms); + let runtime_reference = s.field_model.as_ref().and_then(|model| { + model.modes().map(|modes| serde_json::json!({ + "window_size": modes.baseline_runtime_window_size, + "significant_eigenvalue_p95": modes.baseline_runtime_eigenvalue_count, + "residual_energy_threshold": modes.empty_room_residual_energy_threshold, + "residual_reference_window_count": modes.empty_room_residual_energy_reference.len(), + "raw_calibration_frames_persisted": false, + })) + }); let (active, status, frame_count, min_frames, elapsed_s, frames_per_second, min_duration_s) = s.field_model.as_ref().map_or( (false, "none".to_string(), 0, 0, 0.0, 0.0, 0.0), @@ -6219,6 +6392,7 @@ async fn calibration_status(State(state): State) -> Json) -> Json = s.calibration_source_node_ids.iter().copied().collect(); - if source_node_ids.is_empty() || source_node_ids.len() > 16 { + if source_node_ids.len() != 1 { return Json(serde_json::json!({ "success": false, "error_code": "calibration_source_nodes_missing", - "error": "The active calibration must contain between 1 and 16 contributing ESP32 nodes.", + "error": "The single-link startup baseline must contain exactly one contributing ESP32 node.", })); } let Some(field_model) = s.field_model.as_ref() else { @@ -6825,6 +7012,7 @@ async fn mesh_endpoint(State(state): State) -> Json) -> Json { let s = state.read().await; let now = std::time::Instant::now(); + let observed_at_unix_ms = chrono::Utc::now().timestamp_millis().max(0) as u64; let nodes: Vec = s .node_states .iter() @@ -6836,13 +7024,24 @@ async fn nodes_endpoint(State(state): State) -> Json 5000; let status = if stale { "stale" } else { "active" }; let rssi = ns.rssi_history.back().copied().unwrap_or(-90.0); + let bootstrap_empty = s.bootstrap_baseline_active + && s.bootstrap_baseline.as_ref().is_some_and(|metadata| { + metadata.source_node_ids.as_slice() == [id] + }) + && s.field_model.as_ref().is_some_and(|field_model| { + field_bridge::bootstrap_empty_prior_applies( + field_model, + &ns.frame_history, + observed_at_unix_ms.saturating_mul(1_000), + ) + }); serde_json::json!({ "node_id": id, "status": status, "last_seen_ms": elapsed_ms, "rssi_dbm": rssi, - "motion_level": &ns.current_motion_level, - "person_count": ns.prev_person_count, + "motion_level": if bootstrap_empty { "absent" } else { &ns.current_motion_level }, + "person_count": if bootstrap_empty { 0 } else { ns.prev_person_count }, "person_count_valid": ns.edge_vitals .as_ref() .map(|vitals| vitals.person_count_valid), @@ -7088,24 +7287,6 @@ async fn udp_receiver_task( vitals.presence ); let mut s = state.write().await; - // Broadcast vitals via WebSocket. - if let Ok(json) = serde_json::to_string(&serde_json::json!({ - "type": "edge_vitals", - "node_id": vitals.node_id, - "presence": vitals.presence, - "fall_detected": vitals.fall_detected, - "motion": vitals.motion, - "breathing_rate_bpm": vitals.breathing_rate_bpm, - "heartrate_bpm": vitals.heartrate_bpm, - "n_persons": vitals.n_persons, - "person_count_valid": vitals.person_count_valid, - "motion_energy": vitals.motion_energy, - "presence_score": vitals.presence_score, - "rssi": vitals.rssi, - })) { - let _ = s.tx.send(json); - } - // Issue #323: Also emit a sensing_update so the UI renders // detections for ESP32 nodes running the edge DSP pipeline // (Tier 2+). Without this, vitals arrive but the UI shows @@ -7153,9 +7334,18 @@ async fn udp_receiver_task( 0.05 }; - // Aggregate person count: gate on presence first (matching WiFi path). + let observed_at_unix_ms = + chrono::Utc::now().timestamp_millis().max(0) as u64; + let bootstrap_empty = + s.bootstrap_empty_prior_applies(observed_at_unix_ms); + + // A startup prior has negative-only authority. Once a full + // runtime window matches the stored background, raw edge + // presence cannot force the count back to one. let now = std::time::Instant::now(); - let total_persons = if vitals.presence { + let total_persons = if bootstrap_empty { + 0 + } else if vitals.presence { let dedup = s.dedup_factor; let (fused, fallback_count) = multistatic_bridge::fuse_or_fallback( &s.multistatic_fuser, @@ -7208,10 +7398,16 @@ async fn udp_receiver_task( .get(&node_id) .map(|ns| ns.frame_history.clone()) { - let accepted = if let Some(ref mut fm) = s.field_model { - let before = fm.calibration_frame_count(); - field_bridge::maybe_feed_calibration(fm, &frame_history); - fm.calibration_frame_count() > before + let source_allowed = + calibration_source_accepts(&s.calibration_source_node_ids, node_id); + let accepted = if source_allowed { + if let Some(ref mut fm) = s.field_model { + let before = fm.calibration_frame_count(); + field_bridge::maybe_feed_calibration(fm, &frame_history); + fm.calibration_frame_count() > before + } else { + false + } } else { false }; @@ -7278,7 +7474,15 @@ async fn udp_receiver_task( // node's own reading no longer overwrites the room's. The // old ad-hoc "boost confidence by node count" is replaced by // `room_inference`'s freshness-weighted multi-node confidence. - let classification = debounce_room_classification(&mut s, &room_inference); + let classification = if bootstrap_empty { + ClassificationInfo { + motion_level: "absent".to_string(), + presence: false, + confidence: 0.0, + } + } else { + debounce_room_classification(&mut s, &room_inference) + }; let signal_field = generate_signal_field( fused_features.mean_rssi, @@ -7306,6 +7510,21 @@ async fn udp_receiver_task( total_persons, ); + // Keep the legacy edge_vitals stream aligned with the + // governed sensing envelope. Raw numeric candidates are + // never published before the explicit calibration and + // single-occupant gates have passed. + let edge_vitals_message = edge_vitals_message_for_publication( + &vitals, + published_vitals.as_ref(), + bootstrap_empty, + explicit_calibration_fresh, + total_persons, + ); + if let Ok(json) = serde_json::to_string(&edge_vitals_message) { + let _ = s.tx.send(json); + } + let mut update = SensingUpdate { msg_type: "sensing_update".to_string(), timestamp: chrono::Utc::now().timestamp_millis() as f64 / 1000.0, @@ -7645,9 +7864,17 @@ async fn udp_receiver_task( 0.05 }; - // Aggregate person count: gate on presence first (matching WiFi path). + let observed_at_unix_ms = + chrono::Utc::now().timestamp_millis().max(0) as u64; + let bootstrap_empty = + s.bootstrap_empty_prior_applies(observed_at_unix_ms); + + // A restored prior can suppress a background-only raw + // classification. It cannot authorize positive presence. let now = std::time::Instant::now(); - let total_persons = if classification.presence { + let total_persons = if bootstrap_empty { + 0 + } else if classification.presence { let dedup = s.dedup_factor; let (fused, fallback_count) = multistatic_bridge::fuse_or_fallback( &s.multistatic_fuser, @@ -7700,10 +7927,16 @@ async fn udp_receiver_task( .get(&node_id) .map(|ns| ns.frame_history.clone()) { - let accepted = if let Some(ref mut fm) = s.field_model { - let before = fm.calibration_frame_count(); - field_bridge::maybe_feed_calibration(fm, &frame_history); - fm.calibration_frame_count() > before + let source_allowed = + calibration_source_accepts(&s.calibration_source_node_ids, node_id); + let accepted = if source_allowed { + if let Some(ref mut fm) = s.field_model { + let before = fm.calibration_frame_count(); + field_bridge::maybe_feed_calibration(fm, &frame_history); + fm.calibration_frame_count() > before + } else { + false + } } else { false }; @@ -7763,7 +7996,15 @@ async fn udp_receiver_task( active_nodes.iter().filter_map(|ni| ni.node_inference.as_ref()), NODE_STALE_AFTER_MS, ); - let room_classification = debounce_room_classification(&mut s, &room_inference); + let room_classification = if bootstrap_empty { + ClassificationInfo { + motion_level: "absent".to_string(), + presence: false, + confidence: 0.0, + } + } else { + debounce_room_classification(&mut s, &room_inference) + }; let explicit_calibration_fresh = !s.bootstrap_baseline_active && s.field_model.as_ref().is_some_and(|model| { matches!(model.status(), CalibrationStatus::Fresh) diff --git a/v2/crates/wifi-densepose-signal/src/ruvsense/field_model.rs b/v2/crates/wifi-densepose-signal/src/ruvsense/field_model.rs index 14c9e65d..56fd0901 100644 --- a/v2/crates/wifi-densepose-signal/src/ruvsense/field_model.rs +++ b/v2/crates/wifi-densepose-signal/src/ruvsense/field_model.rs @@ -7,8 +7,8 @@ //! (the residual). //! //! # Algorithm -//! 1. Collect CSI during empty-room calibration (>=10 min wall-clock; the -//! frame target is this duration at the assumed 20 Hz single-node rate) +//! 1. Collect CSI during empty-room calibration (>=10 min wall-clock and at +//! least twenty complete runtime-sized background windows) //! 2. Compute per-link baseline mean (Welford online accumulator) //! 3. Decompose covariance via SVD to extract environmental modes //! 4. At runtime: observation - baseline, project out top-K modes, keep residual @@ -24,6 +24,7 @@ use ndarray_linalg::Eigh; #[cfg(feature = "eigenvalue")] use ndarray_linalg::UPLO; use serde::{Deserialize, Serialize}; +use std::collections::VecDeque; // --------------------------------------------------------------------------- // Calibration window constants @@ -33,21 +34,30 @@ use serde::{Deserialize, Serialize}; /// /// The Welford statistics exist to absorb slow environmental variation — /// HVAC cycles, thermal drift — so the calibration gate is expressed in -/// wall-clock time. The historical frame target of 12,000 is this duration at -/// the assumed single-node rate. Every accepted node packet advances the same -/// field model, so N nodes reach any frame target N times faster; frames alone -/// therefore cannot gate calibration (#1756). +/// wall-clock time. Every accepted node packet advances the same field model, +/// so N nodes reach any frame target N times faster; frames alone therefore +/// cannot gate calibration (#1756). pub const CALIBRATION_DURATION_S: f64 = 600.0; -/// Assumed per-node CSI rate (Hz) used to relate the frame target to -/// [`CALIBRATION_DURATION_S`] ("10 min at 20 Hz"). -pub const ASSUMED_CALIBRATION_RATE_HZ: f64 = 20.0; - /// A recent window whose mean remains within this residual energy of the /// empty room manifold is treated as background before eigenvalue counting. -#[cfg(feature = "eigenvalue")] const EMPTY_ROOM_RESIDUAL_ENERGY_MAX: f64 = 1.0; +/// Runtime occupancy is evaluated over fifty frames. Keep ten raw windows +/// from the end of calibration for covariance rank learning, plus bounded +/// privacy reduced window means spanning the full capture for residual +/// conformance scoring. +const RUNTIME_OCCUPANCY_WINDOW: usize = 50; +const CALIBRATION_REFERENCE_FRAMES: usize = RUNTIME_OCCUPANCY_WINDOW * 10; +/// Minimum independent runtime sized means required to learn an empirical +/// empty room residual distribution. Wall clock duration remains the primary +/// coverage gate, so slow hardware is not forced to imitate a nominal rate. +pub const MIN_BACKGROUND_REFERENCE_WINDOWS: usize = 20; +pub const MIN_CALIBRATION_FRAMES: usize = + RUNTIME_OCCUPANCY_WINDOW * MIN_BACKGROUND_REFERENCE_WINDOWS; +const CALIBRATION_BACKGROUND_WINDOWS_MAX: usize = 512; +const EMPTY_ROOM_RESIDUAL_MARGIN: f64 = 1.5; + // --------------------------------------------------------------------------- // Error types // --------------------------------------------------------------------------- @@ -276,12 +286,11 @@ pub struct FieldModelConfig { pub n_subcarriers: usize, /// Number of environmental modes to retain (K). Max 5. pub n_modes: usize, - /// Minimum calibration frames before baseline is valid. Derived from the - /// intended wall-clock window at the assumed single-node rate - /// (`CALIBRATION_DURATION_S * ASSUMED_CALIBRATION_RATE_HZ` = 12,000). - /// A frame count alone is not sufficient: N nodes streaming in aggregate - /// reach it in 1/N of the window, so `min_calibration_duration_s` also - /// gates finalization (#1756). + /// Minimum calibration frames before baseline is valid. This is the + /// runtime window size times the minimum number of independent background + /// references. A frame count alone is not sufficient: N nodes streaming + /// in aggregate reach it in 1/N of the window, so + /// `min_calibration_duration_s` also gates finalization (#1756). pub min_calibration_frames: usize, /// Minimum wall-clock duration of the calibration window in seconds. /// Slow environmental variation (HVAC cycles, thermal drift) can only be @@ -298,8 +307,7 @@ impl Default for FieldModelConfig { n_links: 6, n_subcarriers: 56, n_modes: 3, - min_calibration_frames: (CALIBRATION_DURATION_S * ASSUMED_CALIBRATION_RATE_HZ) - as usize, + min_calibration_frames: MIN_CALIBRATION_FRAMES, min_calibration_duration_s: CALIBRATION_DURATION_S, baseline_expiry_s: 86_400.0, } @@ -336,6 +344,37 @@ pub struct FieldNormalMode { /// per-window sample size. Defaults to 0.0 in the diagonal-fallback path. /// Issue #942. pub baseline_noise_var: f64, + /// Empty room significant eigenvalue count learned from runtime sized + /// windows. The percentile is stored instead of raw CSI. `None` identifies + /// snapshots created before runtime window calibration was introduced. + #[serde(default)] + pub baseline_runtime_eigenvalue_count: Option, + /// Number of frames used for the runtime window reference above. + #[serde(default)] + pub baseline_runtime_window_size: Option, + /// Hardware adaptive upper bound for the residual energy of an empty room + /// window mean. Only this aggregate boundary is persisted. + #[serde(default)] + pub empty_room_residual_energy_threshold: Option, + /// Sorted residual energies of privacy reduced runtime window means sampled + /// across the full calibration. Raw CSI is never persisted. These scalar + /// references support an empirical background match score without turning + /// an empty room model into positive person evidence. + #[serde(default)] + pub empty_room_residual_energy_reference: Vec, +} + +/// A bounded comparison between one runtime window and the learned empty room +/// residual distribution. `score` is an empirical conformance score, not a +/// probability that a person is absent. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct EmptyRoomMatch { + pub matches_empty: bool, + pub score: Option, + pub residual_energy: f64, + pub residual_energy_threshold: f64, + pub window_size: usize, + pub reference_window_count: usize, } /// Body perturbation extracted from a CSI observation. @@ -405,6 +444,16 @@ pub struct FieldModel { /// Monotonic timestamp of the first accepted calibration frame of the /// current session; `None` until collection starts (#1756). calibration_started: Option, + /// Bounded, memory only tail used to learn inference sized empty room + /// covariance statistics. It is never included in a snapshot. + calibration_reference_tail: VecDeque>>, + /// Per link sum for the current privacy reduced runtime sized window. + calibration_window_sum: Vec>, + /// Number of accepted observations in `calibration_window_sum`. + calibration_window_count: usize, + /// Runtime sized per link means spanning the full calibration. This is + /// bounded, memory only, and reduced to scalar residuals at finalization. + calibration_window_means: VecDeque>>, } /// Diagonal variance fallback for when full covariance SVD is unavailable. @@ -460,6 +509,133 @@ fn diagonal_fallback( (mode_energies, environmental_modes, baseline_count) } +#[cfg(feature = "eigenvalue")] +fn significant_eigenvalue_count( + frames: &[Vec], + n_subcarriers: usize, + baseline_noise_var: f64, +) -> Option { + if frames.len() < 10 { + return None; + } + + let mut mean = vec![0.0_f64; n_subcarriers]; + let mut count = 0_usize; + for frame in frames { + if frame.len() >= n_subcarriers { + for index in 0..n_subcarriers { + mean[index] += frame[index]; + } + count += 1; + } + } + if count < 2 { + return None; + } + for value in &mut mean { + *value /= count as f64; + } + + let mut covariance = Array2::::zeros((n_subcarriers, n_subcarriers)); + for frame in frames { + if frame.len() >= n_subcarriers { + for row in 0..n_subcarriers { + let centered_row = frame[row] - mean[row]; + for column in row..n_subcarriers { + let value = centered_row * (frame[column] - mean[column]); + covariance[[row, column]] += value; + if row != column { + covariance[[column, row]] += value; + } + } + } + } + } + covariance *= 1.0 / (count as f64 - 1.0); + + let (eigenvalues, _) = covariance.eigh(UPLO::Upper).ok()?; + let mut positive: Vec = eigenvalues + .iter() + .copied() + .filter(|value| *value > 1e-10) + .collect(); + positive.sort_by(|left, right| left.partial_cmp(right).unwrap_or(std::cmp::Ordering::Equal)); + let local_noise_var = if positive.len() >= 4 { + let half = positive.len() / 2; + positive[..half].iter().sum::() / half as f64 + } else { + positive.first().copied()? + }; + let noise_var = if baseline_noise_var > 0.0 { + local_noise_var.max(baseline_noise_var) + } else { + local_noise_var + }; + let ratio = n_subcarriers as f64 / count as f64; + let threshold = noise_var * (1.0 + ratio.sqrt()).powi(2); + Some( + eigenvalues + .iter() + .filter(|&&value| value > threshold) + .count(), + ) +} + +fn residual_energy_for_link( + modes: &FieldNormalMode, + link_index: usize, + observation: &[f64], +) -> Option { + let baseline = modes.baseline.get(link_index)?; + if observation.len() != baseline.len() { + return None; + } + let mut residual: Vec = observation + .iter() + .zip(baseline.iter()) + .map(|(value, reference)| value - reference) + .collect(); + for mode in &modes.environmental_modes { + let projection: f64 = residual + .iter() + .zip(mode.iter()) + .map(|(value, basis)| value * basis) + .sum(); + for (value, basis) in residual.iter_mut().zip(mode.iter()) { + *value -= projection * basis; + } + } + Some( + residual + .iter() + .map(|value| value * value) + .sum::() + .sqrt(), + ) +} + +fn percentile_95(mut values: Vec) -> Option { + values.retain(|value| value.is_finite()); + values.sort_by(|left, right| left.partial_cmp(right).unwrap_or(std::cmp::Ordering::Equal)); + let index = values + .len() + .saturating_mul(95) + .div_ceil(100) + .saturating_sub(1); + values.get(index).copied() +} + +#[cfg(feature = "eigenvalue")] +fn percentile_95_usize(mut values: Vec) -> Option { + values.sort_unstable(); + let index = values + .len() + .saturating_mul(95) + .div_ceil(100) + .saturating_sub(1); + values.get(index).copied() +} + impl FieldModel { /// Create a new field model for the given configuration. pub fn new(config: FieldModelConfig) -> Result { @@ -480,6 +656,7 @@ impl FieldModel { let link_stats = (0..config.n_links) .map(|_| LinkBaselineStats::new(config.n_subcarriers)) .collect(); + let calibration_window_sum = vec![vec![0.0_f64; config.n_subcarriers]; config.n_links]; Ok(Self { config, @@ -490,6 +667,10 @@ impl FieldModel { covariance_sum: None, covariance_count: 0, calibration_started: None, + calibration_reference_tail: VecDeque::with_capacity(CALIBRATION_REFERENCE_FRAMES), + calibration_window_sum, + calibration_window_count: 0, + calibration_window_means: VecDeque::with_capacity(CALIBRATION_BACKGROUND_WINDOWS_MAX), }) } @@ -503,6 +684,14 @@ impl FieldModel { self.modes.as_ref() } + /// Hardware adaptive empty room boundary learned at finalization. Returns + /// `None` for legacy snapshots that predate runtime window calibration. + pub fn empty_room_residual_energy_threshold(&self) -> Option { + self.modes + .as_ref() + .and_then(|modes| modes.empty_room_residual_energy_threshold) + } + /// Export aggregate model state suitable for local restart persistence. /// Raw calibration observations and Welford accumulators are not exported. pub fn export_snapshot(&self) -> Result { @@ -561,7 +750,26 @@ impl FieldModel { && (0.0..=10.0).contains(&modes.variance_explained) && modes.baseline_noise_var.is_finite() && modes.baseline_noise_var >= 0.0 - && modes.baseline_eigenvalue_count <= config.n_subcarriers; + && modes.baseline_eigenvalue_count <= config.n_subcarriers + && modes + .baseline_runtime_eigenvalue_count + .is_none_or(|count| count <= config.n_subcarriers) + && modes + .baseline_runtime_window_size + .is_none_or(|size| (10..=CALIBRATION_REFERENCE_FRAMES).contains(&size)) + && modes + .empty_room_residual_energy_threshold + .is_none_or(|threshold| threshold.is_finite() && threshold >= 0.0) + && modes.empty_room_residual_energy_reference.len() + <= CALIBRATION_BACKGROUND_WINDOWS_MAX + && modes + .empty_room_residual_energy_reference + .iter() + .all(|value| value.is_finite() && *value >= 0.0) + && modes + .empty_room_residual_energy_reference + .windows(2) + .all(|window| window[0] <= window[1]); if !baseline_shape_valid || !mode_shape_valid || !values_valid { return Err(FieldModelError::InvalidConfig( "snapshot modes are malformed or non finite".into(), @@ -678,6 +886,39 @@ impl FieldModel { // Count once per frame (not per link) for correct MP ratio self.covariance_count += 1; + if self.calibration_reference_tail.len() == CALIBRATION_REFERENCE_FRAMES { + self.calibration_reference_tail.pop_front(); + } + self.calibration_reference_tail + .push_back(observations.to_vec()); + + for (link_sum, observation) in self + .calibration_window_sum + .iter_mut() + .zip(observations.iter()) + { + for (sum, value) in link_sum.iter_mut().zip(observation.iter()) { + *sum += value; + } + } + self.calibration_window_count += 1; + if self.calibration_window_count == RUNTIME_OCCUPANCY_WINDOW { + let divisor = self.calibration_window_count as f64; + let means: Vec> = self + .calibration_window_sum + .iter() + .map(|link_sum| link_sum.iter().map(|value| value / divisor).collect()) + .collect(); + if self.calibration_window_means.len() == CALIBRATION_BACKGROUND_WINDOWS_MAX { + self.calibration_window_means.pop_front(); + } + self.calibration_window_means.push_back(means); + for link_sum in &mut self.calibration_window_sum { + link_sum.fill(0.0); + } + self.calibration_window_count = 0; + } + Ok(()) } @@ -812,8 +1053,7 @@ impl FieldModel { } Err(_) => { // Fallback to diagonal approximation on SVD failure - let (e, m, b) = - diagonal_fallback(&self.link_stats, n_sc, n_modes); + let (e, m, b) = diagonal_fallback(&self.link_stats, n_sc, n_modes); (e, m, b, 0.0_f64) } } @@ -865,7 +1105,7 @@ impl FieldModel { 0.0 }; - let field_mode = FieldNormalMode { + let mut field_mode = FieldNormalMode { baseline, environmental_modes, mode_energies, @@ -874,15 +1114,142 @@ impl FieldModel { geometry_hash, baseline_eigenvalue_count: baseline_eig_count, baseline_noise_var, + baseline_runtime_eigenvalue_count: None, + baseline_runtime_window_size: None, + empty_room_residual_energy_threshold: None, + empty_room_residual_energy_reference: Vec::new(), }; + // The full calibration covariance and a fifty frame runtime covariance + // have different Marcenko Pastur aspect ratios. This first version is + // deliberately single link: combining link specific residual scales + // requires a separate multistatic calibration model. + if self.config.n_links == 1 { + let reference_frames: Vec> = self + .calibration_reference_tail + .iter() + .filter_map(|observation| observation.first().cloned()) + .collect(); + let complete_windows: Vec<&[Vec]> = reference_frames + .chunks(RUNTIME_OCCUPANCY_WINDOW) + .filter(|window| window.len() == RUNTIME_OCCUPANCY_WINDOW) + .collect(); + + #[cfg(feature = "eigenvalue")] + { + let counts: Vec = complete_windows + .iter() + .filter_map(|window| { + significant_eigenvalue_count(window, n_sc, baseline_noise_var) + }) + .collect(); + field_mode.baseline_runtime_eigenvalue_count = percentile_95_usize(counts); + } + + let mut residual_energies: Vec = self + .calibration_window_means + .iter() + .filter_map(|means| means.first()) + .filter_map(|mean| residual_energy_for_link(&field_mode, 0, mean)) + .filter(|value| value.is_finite() && *value >= 0.0) + .collect(); + if residual_energies.is_empty() { + residual_energies = complete_windows + .iter() + .filter_map(|window| { + let mut mean = vec![0.0_f64; n_sc]; + for frame in *window { + for (sum, value) in mean.iter_mut().zip(frame.iter()) { + *sum += value; + } + } + for value in &mut mean { + *value /= window.len() as f64; + } + residual_energy_for_link(&field_mode, 0, &mean) + }) + .collect(); + } + residual_energies.sort_by(|left, right| { + left.partial_cmp(right).unwrap_or(std::cmp::Ordering::Equal) + }); + field_mode.empty_room_residual_energy_threshold = + percentile_95(residual_energies.clone()).map(|value| { + (value * EMPTY_ROOM_RESIDUAL_MARGIN).max(EMPTY_ROOM_RESIDUAL_ENERGY_MAX) + }); + field_mode.empty_room_residual_energy_reference = residual_energies; + if field_mode.baseline_runtime_eigenvalue_count.is_some() + || field_mode.empty_room_residual_energy_threshold.is_some() + { + field_mode.baseline_runtime_window_size = Some(RUNTIME_OCCUPANCY_WINDOW); + } + } + self.modes = Some(field_mode); self.status = CalibrationStatus::Fresh; self.last_calibration_us = timestamp_us; + self.calibration_reference_tail.clear(); + self.calibration_window_means.clear(); + for link_sum in &mut self.calibration_window_sum { + link_sum.fill(0.0); + } + self.calibration_window_count = 0; Ok(self.modes.as_ref().unwrap()) } + /// Compare a runtime sized single link window with the learned empty room + /// distribution. The score is empirical conformance while the learned + /// boundary holds. Crossing the boundary returns zero. Legacy snapshots + /// without residual references retain binary suppression but no score. + pub fn empty_room_match(&self, recent_frames: &[Vec]) -> Option { + let modes = self.modes.as_ref()?; + if self.config.n_links != 1 { + return None; + } + let window_size = modes.baseline_runtime_window_size?; + if recent_frames.len() < window_size { + return None; + } + let mut mean = vec![0.0_f64; self.config.n_subcarriers]; + for frame in recent_frames.iter().take(window_size) { + if frame.len() < self.config.n_subcarriers { + return None; + } + for (sum, value) in mean.iter_mut().zip(frame.iter()) { + *sum += value; + } + } + for value in &mut mean { + *value /= window_size as f64; + } + let residual_energy = residual_energy_for_link(modes, 0, &mean)?; + let residual_energy_threshold = modes + .empty_room_residual_energy_threshold? + .max(EMPTY_ROOM_RESIDUAL_ENERGY_MAX); + let matches_empty = residual_energy <= residual_energy_threshold; + let reference_window_count = modes.empty_room_residual_energy_reference.len(); + let score = if !matches_empty { + Some(0.0) + } else if reference_window_count == 0 { + None + } else { + let at_or_below = modes + .empty_room_residual_energy_reference + .partition_point(|value| *value <= residual_energy); + let empirical_quantile = at_or_below as f64 / reference_window_count as f64; + Some((1.0 - empirical_quantile * 0.5).clamp(0.5, 1.0)) + }; + Some(EmptyRoomMatch { + matches_empty, + score, + residual_energy, + residual_energy_threshold, + window_size, + reference_window_count, + }) + } + /// Extract body perturbation from a runtime observation. /// /// Subtracts baseline, projects out environmental modes, returns residual. @@ -993,14 +1360,19 @@ impl FieldModel { *m /= count as f64; } - // Rank alone is not person evidence. A continuation window can expose - // more significant eigenvalues while its mean remains on the learned - // empty room manifold. Fail toward empty only inside the same - // conservative residual energy boundary used by the server fallback. - if self + // Rank alone is not person evidence. A short continuation window can + // expose more significant eigenvalues while its mean remains on the + // learned empty room manifold. Use the hardware adaptive residual + // boundary when available. + let empty_room_residual_energy_max = modes + .empty_room_residual_energy_threshold + .unwrap_or(EMPTY_ROOM_RESIDUAL_ENERGY_MAX) + .max(EMPTY_ROOM_RESIDUAL_ENERGY_MAX); + let mean_residual_energy = self .extract_perturbation(&[mean.clone()]) - .is_ok_and(|perturbation| perturbation.total_energy <= EMPTY_ROOM_RESIDUAL_ENERGY_MAX) - { + .ok() + .map(|perturbation| perturbation.total_energy); + if mean_residual_energy.is_some_and(|energy| energy <= empty_room_residual_energy_max) { return Ok(0); } @@ -1064,7 +1436,23 @@ impl FieldModel { let mp_threshold = noise_var * (1.0 + ratio.sqrt()).powi(2); let significant = eigenvalues.iter().filter(|&&ev| ev > mp_threshold).count(); - let occupancy = significant.saturating_sub(modes.baseline_eigenvalue_count); + let reference_count = if modes.baseline_runtime_window_size == Some(count) { + modes + .baseline_runtime_eigenvalue_count + .unwrap_or(modes.baseline_eigenvalue_count) + } else { + modes.baseline_eigenvalue_count + }; + let rank_occupancy = significant.saturating_sub(reference_count); + // A stable person can shift the window mean without increasing its + // covariance rank. Preserve at least one occupant after the learned + // empty room residual boundary is exceeded. + let occupancy = + if mean_residual_energy.is_some_and(|energy| energy > empty_room_residual_energy_max) { + rank_occupancy.max(1) + } else { + rank_occupancy + }; Ok(occupancy.min(10)) // Cap at 10 persons } @@ -1103,6 +1491,12 @@ impl FieldModel { self.covariance_sum = None; self.covariance_count = 0; self.calibration_started = None; + self.calibration_reference_tail.clear(); + self.calibration_window_count = 0; + for link_sum in &mut self.calibration_window_sum { + link_sum.fill(0.0); + } + self.calibration_window_means.clear(); } } @@ -1623,6 +2017,131 @@ mod tests { assert_eq!(occupancy, 0, "Noise-only frames should yield 0 occupancy"); } + #[cfg(feature = "eigenvalue")] + #[test] + fn runtime_sized_empty_reference_scores_held_out_background() { + let config = FieldModelConfig { + n_links: 1, + n_subcarriers: 56, + n_modes: 3, + min_calibration_frames: 500, + min_calibration_duration_s: 0.0, + baseline_expiry_s: 86_400.0, + }; + let mut model = FieldModel::new(config).unwrap(); + let mut calibration = Vec::new(); + for frame_index in 0..600 { + let time = frame_index as f64 * 0.071; + let frame: Vec = (0..56) + .map(|subcarrier| { + let carrier = subcarrier as f64; + 18.0 + carrier * 0.08 + + (time + carrier * 0.13).sin() * 0.8 + + (time * 0.37 + carrier * 0.031).cos() * 0.35 + }) + .collect(); + model.feed_calibration(&[frame.clone()]).unwrap(); + calibration.push(frame); + } + model.finalize_calibration(1_000_000, 0).unwrap(); + + let modes = model.modes().unwrap(); + assert_eq!( + modes.baseline_runtime_window_size, + Some(RUNTIME_OCCUPANCY_WINDOW) + ); + assert!(modes.baseline_runtime_eigenvalue_count.is_some()); + assert!(modes.empty_room_residual_energy_threshold.is_some()); + assert_eq!(modes.empty_room_residual_energy_reference.len(), 12); + + let held_out_empty = calibration[550..600].to_vec(); + assert_eq!(model.estimate_occupancy(&held_out_empty).unwrap(), 0); + let background = model + .empty_room_match(&held_out_empty) + .expect("background match"); + assert!(background.matches_empty); + assert_eq!(background.reference_window_count, 12); + assert!(background + .score + .is_some_and(|score| (0.5..=1.0).contains(&score))); + } + + #[cfg(feature = "eigenvalue")] + #[test] + fn runtime_reference_rejects_shifted_held_out_window() { + let config = FieldModelConfig { + n_links: 1, + n_subcarriers: 56, + n_modes: 3, + min_calibration_frames: 500, + min_calibration_duration_s: 0.0, + baseline_expiry_s: 86_400.0, + }; + let mut model = FieldModel::new(config).unwrap(); + for frame_index in 0..600 { + let time = frame_index as f64 * 0.071; + let frame: Vec = (0..56) + .map(|subcarrier| { + let carrier = subcarrier as f64; + 18.0 + carrier * 0.08 + + (time + carrier * 0.13).sin() * 0.8 + + (time * 0.37 + carrier * 0.031).cos() * 0.35 + }) + .collect(); + model.feed_calibration(&[frame]).unwrap(); + } + model.finalize_calibration(1_000_000, 0).unwrap(); + + let shifted: Vec> = (0..RUNTIME_OCCUPANCY_WINDOW) + .map(|frame_index| { + let time = (600 + frame_index) as f64 * 0.071; + (0..56) + .map(|subcarrier| { + let carrier = subcarrier as f64; + let body_shift = if subcarrier % 3 == 0 { 8.0 } else { -5.0 }; + 18.0 + carrier * 0.08 + + (time + carrier * 0.13).sin() * 0.8 + + (time * 0.37 + carrier * 0.031).cos() * 0.35 + + body_shift + }) + .collect() + }) + .collect(); + assert!(model.estimate_occupancy(&shifted).unwrap() >= 1); + let background = model.empty_room_match(&shifted).expect("shifted match"); + assert!(!background.matches_empty); + assert_eq!(background.score, Some(0.0)); + } + + #[test] + fn runtime_background_reference_is_single_link_only() { + let config = FieldModelConfig { + n_links: 2, + n_subcarriers: 8, + n_modes: 2, + min_calibration_frames: 100, + min_calibration_duration_s: 0.0, + baseline_expiry_s: 86_400.0, + }; + let mut model = FieldModel::new(config).unwrap(); + for frame_index in 0..100 { + let phase = frame_index as f64 * 0.1; + model + .feed_calibration(&[ + (0..8).map(|index| phase + index as f64).collect(), + (0..8).map(|index| phase - index as f64).collect(), + ]) + .unwrap(); + } + model.finalize_calibration(1_000_000, 0).unwrap(); + + let modes = model.modes().unwrap(); + assert_eq!(modes.baseline_runtime_window_size, None); + assert_eq!(modes.baseline_runtime_eigenvalue_count, None); + assert_eq!(modes.empty_room_residual_energy_threshold, None); + assert!(modes.empty_room_residual_energy_reference.is_empty()); + } + #[test] fn test_baseline_eigenvalue_count_stored() { let config = FieldModelConfig { @@ -1735,14 +2254,15 @@ mod tests { // ----------------------------------------------------------------- #[test] - fn test_default_frame_target_derives_from_duration() { + fn test_default_frame_target_covers_background_reference_windows() { let cfg = FieldModelConfig::default(); assert_eq!(cfg.min_calibration_duration_s, CALIBRATION_DURATION_S); assert_eq!( - cfg.min_calibration_frames, - (CALIBRATION_DURATION_S * ASSUMED_CALIBRATION_RATE_HZ) as usize, - "frame target must be the intended window at the assumed rate" + cfg.min_calibration_frames, MIN_CALIBRATION_FRAMES, + "frame target must cover the minimum independent runtime windows" ); + assert_eq!(MIN_CALIBRATION_FRAMES, 1_000); + assert_eq!(MIN_BACKGROUND_REFERENCE_WINDOWS, 20); } #[test] From c4021f98846cb03759140dcb265b157082b2ef81 Mon Sep 17 00:00:00 2001 From: rUv Date: Sat, 5 Sep 2026 09:42:43 -0400 Subject: [PATCH 05/27] docs: add WiFlow reproduction protocol (#1787) --- docs/research/wiflow-reproduction-protocol.md | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 docs/research/wiflow-reproduction-protocol.md diff --git a/docs/research/wiflow-reproduction-protocol.md b/docs/research/wiflow-reproduction-protocol.md new file mode 100644 index 00000000..5bd80771 --- /dev/null +++ b/docs/research/wiflow-reproduction-protocol.md @@ -0,0 +1,63 @@ +# WiFlow Reproduction Protocol + +Status: Proposed research protocol + +Tracks issue #1786. + +## Objective + +Determine whether a CSI optical flow style representation improves reusable camera free motion intelligence in RuView rather than only improving a proxy flow metric. + +## Required capture metadata + +Every run records hardware family, firmware, antenna count and geometry, channel width, center frequency, packet rate, timestamp source, synchronization method, room identity, participant identity class, label source, calibration, packet loss, missing subcarriers, preprocessing version, model commit, accelerator, software versions, power mode, and random seeds. + +## Baselines + +1. current RuView CSI preprocessing plus strongest task specific sensing head +2. WiFlow style motion field representation +3. simpler temporal derivative or phase and amplitude motion baseline + +All conditions use identical train, validation, and held out environment splits. + +## Metrics + +Primary intermediate metrics: + +* endpoint error or equivalent flow error +* directional consistency +* temporal stability +* uncertainty calibration + +Required downstream metrics: + +* tracking quality +* occupancy or presence quality +* pose or activity metric where available +* spatial state consistency in RuField or WorldGraph + +Systems metrics: + +* p50 and p95 inference latency +* peak memory +* CPU, GPU, or accelerator utilization +* energy per inference where measurable +* bytes per observation and sustained bandwidth + +## Stress tests + +Test unseen rooms, unseen participants, changed antenna geometry, a second RF hardware family, packet loss, burst loss, missing subcarriers, lower packet rate, clock jitter, and partial calibration failure. + +## Promotion gate + +The representation graduates only when the same learned representation improves at least two downstream RuView tasks on held out environments, or reaches equivalent downstream quality with materially lower edge cost. + +A better optical flow proxy score alone is insufficient. + +## Security and privacy + +Visual or LiDAR labels may be used as privileged training evidence but must not become a runtime dependency for camera free deployment claims. Dataset provenance and consent requirements remain explicit. + +## Rollback + +The experiment is isolated behind a representation interface. Existing RuView sensing heads remain the production fallback. From 30404dd9cfd4d0193d5ce54b3786c3611ea89e6b Mon Sep 17 00:00:00 2001 From: ruv Date: Sat, 5 Sep 2026 08:50:44 -0400 Subject: [PATCH 06/27] feat(calibration): select explicit baseline source --- ...DR-355-local-empty-room-bootstrap-prior.md | 11 +++++- .../wifi-densepose-sensing-server/src/main.rs | 39 ++++++++++++++++++- 2 files changed, 47 insertions(+), 3 deletions(-) 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 5eb4ba73..1ec6ee36 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 0c062830..16261c1a 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 Date: Sat, 5 Sep 2026 09:15:07 -0400 Subject: [PATCH 07/27] fix(calibration): learn held out empty residuals --- ...DR-355-local-empty-room-bootstrap-prior.md | 14 ++ .../wifi-densepose-sensing-server/src/main.rs | 233 ++++++++++++++++++ .../src/ruvsense/field_model.rs | 126 ++++++++++ 3 files changed, 373 insertions(+) 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 1ec6ee36..e03ef06d 100644 --- a/docs/adr/ADR-355-local-empty-room-bootstrap-prior.md +++ b/docs/adr/ADR-355-local-empty-room-bootstrap-prior.md @@ -54,6 +54,14 @@ evidence or numeric heart and breathing rates. Explicit calibration replaces it with fresh statistics. Cancelling an unfinished capture restores a valid prior. Administrator scoped reset removes it. +A restored image may receive one operator confirmed held out empty refinement. +The server measures 12 fresh, source bound runtime residuals at one second +spacing and requires zero stale samples and zero numeric vital samples. It +persists only the scalar residuals. The new boundary is the larger of the +existing threshold and 110 percent of the held out 95th percentile, capped at +125 percent of the prior threshold. The one use marker persists in the model, +so repeated requests cannot ratchet the threshold upward. + The status endpoint reports the learned runtime window, residual threshold, reference count, current background conformance, and the negative only authority boundary. A restored model never resumes collection and never @@ -94,6 +102,9 @@ occupied sequences. Empty data is never relabeled as human training. 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. +8. `POST /api/v1/calibration/bootstrap/refine?confirmed_empty=true&source_node_id=N` + performs the bounded one time held out refinement. It requires administrator + authority when transport authentication is enabled. ## Measured installation evidence @@ -118,3 +129,6 @@ 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. +After restart, a confirmed empty holdout may refine the restored boundary once. +The stored snapshot must reject a second refinement and must continue to deny +calibrated evidence and numeric vital authority. diff --git a/v2/crates/wifi-densepose-sensing-server/src/main.rs b/v2/crates/wifi-densepose-sensing-server/src/main.rs index 16261c1a..cbbed146 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/main.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/main.rs @@ -6264,6 +6264,13 @@ struct CalibrationStartQuery { source_node_id: Option, } +#[derive(Debug, Default, Deserialize)] +struct BootstrapRefineQuery { + #[serde(default)] + confirmed_empty: bool, + source_node_id: Option, +} + async fn calibration_start( State(state): State, Query(query): Query, @@ -6401,6 +6408,7 @@ async fn calibration_status(State(state): State) -> Json, + Query(query): Query, +) -> Json { + if !query.confirmed_empty { + return Json(serde_json::json!({ + "success": false, + "error_code": "empty_confirmation_required", + "error": "Explicit empty-room confirmation is required for bootstrap refinement.", + })); + } + + let (expected_model_id, expected_digest, source_node_id) = { + let s = state.read().await; + let Some(metadata) = s.bootstrap_baseline.as_ref().filter(|_| s.bootstrap_baseline_active) + else { + return Json(serde_json::json!({ + "success": false, + "error_code": "bootstrap_not_active", + "error": "A restored startup baseline must be active before refinement.", + })); + }; + let [source_node_id] = metadata.source_node_ids.as_slice() else { + return Json(serde_json::json!({ + "success": false, + "error_code": "bootstrap_source_invalid", + "error": "Bootstrap refinement requires exactly one bound source node.", + })); + }; + if query + .source_node_id + .is_some_and(|requested| requested != *source_node_id) + { + return Json(serde_json::json!({ + "success": false, + "error_code": "bootstrap_source_mismatch", + "error": "The requested source does not match the stored bootstrap source.", + })); + } + ( + metadata.source_model_id.clone(), + metadata.content_sha256.clone(), + *source_node_id, + ) + }; + + let mut residuals = Vec::with_capacity(BOOTSTRAP_VALIDATION_SAMPLES); + let mut stale_sample_count = 0usize; + let mut vital_sign_sample_count = 0usize; + let mut last_sequence = None; + let mut next_sample_at = tokio::time::Instant::now(); + for _ in 0..BOOTSTRAP_VALIDATION_SAMPLES { + tokio::time::sleep_until(next_sample_at).await; + let interval_deadline = tokio::time::Instant::now() + + Duration::from_millis(BOOTSTRAP_VALIDATION_SAMPLE_TIMEOUT_MS); + let mut observed = None; + while tokio::time::Instant::now() < interval_deadline { + observed = { + let s = state.read().await; + let identity_matches = s.bootstrap_baseline_active + && s.bootstrap_baseline.as_ref().is_some_and(|metadata| { + metadata.source_model_id == expected_model_id + && metadata.content_sha256 == expected_digest + && metadata.source_node_ids.as_slice() == [source_node_id] + }); + if !identity_matches { + None + } else { + s.node_states.get(&source_node_id).and_then(|node| { + let sequence = node.latest_csi_sequence?; + let fresh = node.last_frame_time.is_some_and(|seen| { + std::time::Instant::now().saturating_duration_since(seen) + < ESP32_OFFLINE_TIMEOUT + }) && Some(sequence) != last_sequence; + if !fresh { + return None; + } + let model = s.field_model.as_ref()?; + let recent_frames: Vec> = + node.frame_history.iter().cloned().collect(); + let residual = model.empty_room_match(&recent_frames)?.residual_energy; + let vital_signs_absent = s + .latest_update + .as_ref() + .is_some_and(|update| update.vital_signs.is_none()); + Some((sequence, residual, vital_signs_absent)) + }) + } + }; + if observed.is_some() { + break; + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + if let Some((sequence, residual, vital_signs_absent)) = observed { + last_sequence = Some(sequence); + residuals.push(residual); + if !vital_signs_absent { + vital_sign_sample_count += 1; + } + } else { + stale_sample_count += 1; + } + next_sample_at = tokio::time::Instant::now() + + Duration::from_millis(BOOTSTRAP_VALIDATION_MIN_SPACING_MS); + } + + if residuals.len() != BOOTSTRAP_VALIDATION_SAMPLES + || stale_sample_count != 0 + || vital_sign_sample_count != 0 + { + 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.", + "validation": { + "sample_count": residuals.len(), + "stale_sample_count": stale_sample_count, + "vital_sign_sample_count": vital_sign_sample_count, + }, + })); + } + + let mut s = state.write().await; + let identity_matches = s.bootstrap_baseline_active + && s.bootstrap_baseline.as_ref().is_some_and(|metadata| { + metadata.source_model_id == expected_model_id + && metadata.content_sha256 == expected_digest + && metadata.source_node_ids.as_slice() == [source_node_id] + }); + if !identity_matches { + return Json(serde_json::json!({ + "success": false, + "error_code": "bootstrap_changed", + "error": "The bootstrap identity changed during refinement. The stored baseline was not changed.", + })); + } + let candidate_snapshot = match s + .field_model + .as_ref() + .ok_or(wifi_densepose_signal::ruvsense::field_model::FieldModelError::NotCalibrated) + .and_then(|model| model.export_snapshot()) + { + Ok(snapshot) => snapshot, + Err(_) => { + return Json(serde_json::json!({ + "success": false, + "error_code": "bootstrap_refinement_rejected", + "error": "The bootstrap model rejected this bounded refinement.", + })); + } + }; + let now_us = chrono::Utc::now().timestamp_micros() as u64; + let mut candidate = match FieldModel::from_snapshot(candidate_snapshot, now_us) { + Ok(model) => model, + Err(_) => { + return Json(serde_json::json!({ + "success": false, + "error_code": "bootstrap_refinement_rejected", + "error": "The bootstrap model rejected this bounded refinement.", + })); + } + }; + let refinement = match candidate.refine_empty_room_residual_boundary(&residuals) { + Ok(refinement) => refinement, + Err(_) => { + return Json(serde_json::json!({ + "success": false, + "error_code": "bootstrap_refinement_rejected", + "error": "The bootstrap model rejected this bounded refinement.", + })); + } + }; + let Some(installation_id) = s.installation_id.clone() else { + return Json(serde_json::json!({ + "success": false, + "error_code": "installation_id_required", + "error": "A stable installation identity is required to store a local startup baseline.", + })); + }; + let path = bootstrap_baseline::path_in(&s.data_dir); + let created_at_unix_ms = chrono::Utc::now().timestamp_millis() as u64; + let stored = match bootstrap_baseline::store( + &path, + &installation_id, + &[source_node_id], + &expected_model_id, + created_at_unix_ms, + &candidate, + ) { + Ok(metadata) => metadata, + Err(error) => { + return error_response::internal_error_json("bootstrap baseline refinement store", error) + } + }; + s.field_model = Some(candidate); + s.bootstrap_baseline = Some(stored.clone()); + Json(serde_json::json!({ + "success": true, + "message": "The local empty-room startup baseline was refined.", + "validation": { + "sample_count": residuals.len(), + "stale_sample_count": stale_sample_count, + "vital_sign_sample_count": vital_sign_sample_count, + }, + "refinement": { + "threshold_before": refinement.threshold_before, + "threshold_after": refinement.threshold_after, + "held_out_p95": refinement.held_out_p95, + "held_out_sample_count": refinement.held_out_sample_count, + "reference_window_count": refinement.reference_window_count, + }, + "bootstrap_baseline": stored, + "calibrated_evidence_authorized": false, + "numeric_vitals_authorized": false, + })) +} + /// Cancel only an unfinished capture. Completed model deletion remains on the /// administrator scoped reset route. async fn calibration_cancel(State(state): State) -> Json { @@ -10054,6 +10283,10 @@ async fn main() { "/api/v1/calibration/bootstrap/promote", post(calibration_promote_bootstrap), ) + .route( + "/api/v1/calibration/bootstrap/refine", + post(calibration_refine_bootstrap), + ) .route("/api/v1/calibration/cancel", post(calibration_cancel)) .route("/api/v1/calibration/status", get(calibration_status)) .route("/api/v1/calibration/reset", post(calibration_reset)) diff --git a/v2/crates/wifi-densepose-signal/src/ruvsense/field_model.rs b/v2/crates/wifi-densepose-signal/src/ruvsense/field_model.rs index 56fd0901..7bba49ef 100644 --- a/v2/crates/wifi-densepose-signal/src/ruvsense/field_model.rs +++ b/v2/crates/wifi-densepose-signal/src/ruvsense/field_model.rs @@ -57,6 +57,10 @@ pub const MIN_CALIBRATION_FRAMES: usize = RUNTIME_OCCUPANCY_WINDOW * MIN_BACKGROUND_REFERENCE_WINDOWS; const CALIBRATION_BACKGROUND_WINDOWS_MAX: usize = 512; const EMPTY_ROOM_RESIDUAL_MARGIN: f64 = 1.5; +const EMPTY_ROOM_HELD_OUT_MARGIN: f64 = 1.10; +const EMPTY_ROOM_REFINEMENT_MAX_LIFT: f64 = 1.25; +const EMPTY_ROOM_REFINEMENT_MIN_SAMPLES: usize = 10; +const EMPTY_ROOM_REFINEMENT_MAX_SAMPLES: usize = 64; // --------------------------------------------------------------------------- // Error types @@ -362,6 +366,11 @@ pub struct FieldNormalMode { /// an empty room model into positive person evidence. #[serde(default)] pub empty_room_residual_energy_reference: Vec, + /// Number of operator-confirmed held-out empty refinements. Version one + /// permits one bounded refinement so repeated calls cannot ratchet the + /// background boundary upward until a weak occupant is suppressed. + #[serde(default)] + pub empty_room_residual_refinement_count: u8, } /// A bounded comparison between one runtime window and the learned empty room @@ -377,6 +386,16 @@ pub struct EmptyRoomMatch { pub reference_window_count: usize, } +/// Receipt for one bounded, operator-confirmed empty-room refinement. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct EmptyRoomRefinement { + pub threshold_before: f64, + pub threshold_after: f64, + pub held_out_p95: f64, + pub held_out_sample_count: usize, + pub reference_window_count: usize, +} + /// Body perturbation extracted from a CSI observation. /// /// After subtracting the baseline and projecting out environmental @@ -692,6 +711,73 @@ impl FieldModel { .and_then(|modes| modes.empty_room_residual_energy_threshold) } + /// Refine a completed single-link empty-room boundary with scalar residuals + /// measured during a separate operator-confirmed empty holdout. + /// + /// This never ingests or persists raw CSI. The lift is capped at 25 percent + /// and may happen only once per snapshot, preventing repeated calls from + /// silently erasing weak occupied evidence. + pub fn refine_empty_room_residual_boundary( + &mut self, + held_out_residuals: &[f64], + ) -> Result { + if self.config.n_links != 1 || self.status != CalibrationStatus::Fresh { + return Err(FieldModelError::NotCalibrated); + } + if !(EMPTY_ROOM_REFINEMENT_MIN_SAMPLES..=EMPTY_ROOM_REFINEMENT_MAX_SAMPLES) + .contains(&held_out_residuals.len()) + || held_out_residuals + .iter() + .any(|value| !value.is_finite() || *value < 0.0) + { + return Err(FieldModelError::InvalidConfig( + "held-out empty residuals are missing, excessive, or non-finite".into(), + )); + } + let modes = self.modes.as_mut().ok_or(FieldModelError::NotCalibrated)?; + if modes.empty_room_residual_refinement_count != 0 { + return Err(FieldModelError::InvalidConfig( + "the empty-room boundary already has its one permitted held-out refinement" + .into(), + )); + } + if modes.empty_room_residual_energy_reference.len() + held_out_residuals.len() + > CALIBRATION_BACKGROUND_WINDOWS_MAX + { + return Err(FieldModelError::InvalidConfig( + "the empty-room residual reference limit would be exceeded".into(), + )); + } + let threshold_before = modes + .empty_room_residual_energy_threshold + .ok_or(FieldModelError::NotCalibrated)? + .max(EMPTY_ROOM_RESIDUAL_ENERGY_MAX); + let held_out_p95 = percentile_95(held_out_residuals.to_vec()) + .ok_or(FieldModelError::NotCalibrated)?; + let proposed = held_out_p95 * EMPTY_ROOM_HELD_OUT_MARGIN; + let threshold_after = threshold_before + .max(proposed.min(threshold_before * EMPTY_ROOM_REFINEMENT_MAX_LIFT)); + + modes + .empty_room_residual_energy_reference + .extend_from_slice(held_out_residuals); + modes + .empty_room_residual_energy_reference + .sort_by(|left, right| { + left.partial_cmp(right).unwrap_or(std::cmp::Ordering::Equal) + }); + modes.empty_room_residual_energy_threshold = Some(threshold_after); + modes.empty_room_residual_refinement_count = 1; + + Ok(EmptyRoomRefinement { + threshold_before, + threshold_after, + held_out_p95, + held_out_sample_count: held_out_residuals.len(), + reference_window_count: modes.empty_room_residual_energy_reference.len(), + }) + } + /// Export aggregate model state suitable for local restart persistence. /// Raw calibration observations and Welford accumulators are not exported. pub fn export_snapshot(&self) -> Result { @@ -762,6 +848,7 @@ impl FieldModel { .is_none_or(|threshold| threshold.is_finite() && threshold >= 0.0) && modes.empty_room_residual_energy_reference.len() <= CALIBRATION_BACKGROUND_WINDOWS_MAX + && modes.empty_room_residual_refinement_count <= 1 && modes .empty_room_residual_energy_reference .iter() @@ -1118,6 +1205,7 @@ impl FieldModel { baseline_runtime_window_size: None, empty_room_residual_energy_threshold: None, empty_room_residual_energy_reference: Vec::new(), + empty_room_residual_refinement_count: 0, }; // The full calibration covariance and a fifty frame runtime covariance @@ -2066,6 +2154,44 @@ mod tests { .is_some_and(|score| (0.5..=1.0).contains(&score))); } + #[test] + fn held_out_empty_refinement_is_bounded_and_single_use() { + let config = FieldModelConfig { + n_links: 1, + n_subcarriers: 8, + n_modes: 2, + min_calibration_frames: 100, + min_calibration_duration_s: 0.0, + baseline_expiry_s: 86_400.0, + }; + let mut model = FieldModel::new(config).unwrap(); + for frame_index in 0..1_000 { + let phase = frame_index as f64 * 0.071; + let frame = (0..8) + .map(|index| 20.0 + index as f64 * 0.1 + (phase + index as f64).sin()) + .collect(); + model.feed_calibration(&[frame]).unwrap(); + } + model.finalize_calibration(1_000_000, 0).unwrap(); + + let before = model.empty_room_residual_energy_threshold().unwrap(); + let residuals = vec![before * 1.4; 12]; + let receipt = model + .refine_empty_room_residual_boundary(&residuals) + .unwrap(); + assert_eq!(receipt.threshold_before, before); + assert!(receipt.threshold_after > before); + assert!(receipt.threshold_after <= before * EMPTY_ROOM_REFINEMENT_MAX_LIFT); + assert_eq!(receipt.held_out_sample_count, 12); + assert_eq!( + model.modes().unwrap().empty_room_residual_refinement_count, + 1 + ); + assert!(model + .refine_empty_room_residual_boundary(&residuals) + .is_err()); + } + #[cfg(feature = "eigenvalue")] #[test] fn runtime_reference_rejects_shifted_held_out_window() { From a2c662f9f734ad4df2b3a3cd028b719b38dca120 Mon Sep 17 00:00:00 2001 From: ruv Date: Sat, 5 Sep 2026 10:28:19 -0400 Subject: [PATCH 08/27] fix: harden CSI timing and baseline reliability --- ...ul-csi-and-reliability-gated-adaptation.md | 151 +++++++++++++++++ docs/adr/README.md | 1 + firmware/esp32-csi-node/README.md | 9 +- firmware/esp32-csi-node/main/csi_collector.c | 17 +- firmware/esp32-csi-node/main/csi_collector.h | 3 + firmware/esp32-csi-node/test/Makefile | 21 ++- .../esp32-csi-node/test/fuzz_csi_serialize.c | 12 ++ .../esp32-csi-node/test/stubs/esp_stubs.h | 1 + .../esp32-csi-node/test/test_csi_sanitize.c | 56 +++++++ .../wifi-densepose-hardware/src/csi_frame.rs | 7 +- .../src/esp32_parser.rs | 7 +- .../src/adaptive_classifier.rs | 51 ++++++ .../wifi-densepose-sensing-server/src/csi.rs | 155 ++++++++++++++++-- .../src/field_bridge.rs | 10 ++ .../wifi-densepose-sensing-server/src/main.rs | 99 ++++++++--- .../src/vital_signs.rs | 41 +++++ .../src/ruvsense/field_model.rs | 97 ++++++++++- 17 files changed, 690 insertions(+), 48 deletions(-) create mode 100644 docs/adr/ADR-356-rate-truthful-csi-and-reliability-gated-adaptation.md create mode 100644 firmware/esp32-csi-node/test/test_csi_sanitize.c diff --git a/docs/adr/ADR-356-rate-truthful-csi-and-reliability-gated-adaptation.md b/docs/adr/ADR-356-rate-truthful-csi-and-reliability-gated-adaptation.md new file mode 100644 index 00000000..9562ce1a --- /dev/null +++ b/docs/adr/ADR-356-rate-truthful-csi-and-reliability-gated-adaptation.md @@ -0,0 +1,151 @@ +# ADR 356: Rate truthful CSI and reliability gated room adaptation + +Status: Accepted + +Date: 2026-09-05 + +## Context + +RuView mixed three different notions of time. ESP32 firmware requested a packet +rate, the server observed a delivered rate, and several server paths still used +fixed 2 Hz, 10 Hz, or 20 Hz assumptions. One feature named +`dominant_freq_hz` was not a temporal frequency at all. It was the index of the +strongest subcarrier multiplied by 0.05. A second defect compared a newly +inserted frame with itself, forcing that motion delta to zero. + +These errors can move a spectral peak into the breathing or heartbeat band and +produce plausible but false values. They also make models sensitive to WiFi +traffic and processor scheduling rather than physical motion. + +ESP-IDF documents that `wifi_csi_info_t.first_word_invalid` marks the first four +CSI bytes invalid because of a hardware limitation. It also documents that the +CSI receive callback runs in the WiFi task and should hand work to a lower +priority task rather than perform lengthy processing. RuView serialized the +invalid bytes unchanged and fed the original buffer to edge processing. + +Recent work suggests four useful directions with different maturity: + +1. OpenCSI represents each link as a dimensionless residual against its own + quiet period temporal standard deviation and exposes baseline maturity. Its + reported transfer results are limited to binary presence. +2. MORIC derives motion centered delay and Doppler views to reduce dependence + on static environment specific CSI structure. +3. Parameter efficient CSI crowd counting combines self supervised pretraining, + small room adapters, and a stateful counting machine. +4. WiFi JEPA masks complete radio links and predicts their latent embeddings, + using real and ray traced CSI for pose representation learning. + +The papers report promising external results. None is measured RuView evidence, +and none authorizes deployment without leakage free occupied and empty held out +evaluation. + +## Decision + +### Firmware ingestion + +1. When ESP-IDF marks the first CSI word invalid, zero the first four serialized + CSI bytes while retaining the fixed packet shape. +2. Set ADR-018 byte 19 bit 5 to record that sanitation occurred. +3. Feed edge DSP from the sanitized serialized payload, never the original + invalid driver buffer. +4. Keep callback work bounded to sanitation, serialization, and the existing + queue handoff. Do not add transforms to the WiFi task. + +### Server time model + +1. Estimate delivered CSI rate from accepted monotonic frame timestamps after + five samples. Use 20 Hz only during startup. +2. Cap the physical input rate at the firmware contract of 50 Hz so a burst or + timestamp artifact cannot retune downstream filters above their supported + range. +3. Reconfigure the vital detector only after a 20 percent rate change. Clear + temporal and smoothing state whenever its clock changes. Samples calculated + with different clocks must never be blended. +4. Compute temporal dominant frequency from the recent mean amplitude series + with demeaning, a Hann window, and bounded direct Fourier bins from 0.1 Hz to + the smaller of 4 Hz or 45 percent of sample rate. +5. Return zero for short, flat, nonfinite, or spectrally ambiguous windows. +6. Compare the current frame with the prior frame, not itself, for motion delta. +7. Export zero sample rate until the estimate is mature. Never present a startup + fallback as a measured node property. + +### Model compatibility and room reliability + +1. Preserve the historical fifth input feature for version 1 adaptive models at + the model boundary. Version 2 and later models consume the true temporal + frequency. This prevents an existing model from silently receiving a + different input distribution after a server update. +2. A background model is unreliable until it contains at least ten finite, + nonnegative residual references. An unreliable model cannot suppress a + runtime change and exposes no conformance score. +3. Expose background maturity, reliability, and a robust residual Z score based + on the median and median absolute deviation. +4. Keep the persisted empty home prior negative only. It cannot authorize + calibrated presence, person count, pose, or numeric vitals. + +### Model roadmap + +The following work remains proposal only: + +1. Evaluate an OpenCSI style per link quiet residual normalization for binary + presence without replacing absolute magnitude features used for motion. +2. Evaluate delay and Doppler views after phase sanitation for activity. +3. Pretrain a frozen multilink encoder and adapt small room specific modules for + counting. +4. Evaluate link masked self supervision for pose only when at least three + spatially distinct links and synchronized positive pose labels exist. + +Every candidate uses immutable manifests and splits by room, subject, +recording, and time. It is compared with the frozen baseline under identical +seeds. Counting includes confusion matrices and mean absolute error. Pose +includes PCK at 20 centimetres, MPJPE, and the mean pose baseline. A shuffled +label control must fail. + +Promotion requires at least 10 percent held out lift, less than 10 milliseconds +added p95 inference latency, no more than 20 percent peak memory overhead, zero +leakage or secret findings, frozen anchor retention, and no regression in empty +rejection or occupied recall. Promotion remains an explicit maintainer action. + +## Consequences + +The firmware no longer preserves a known invalid prefix as sensor evidence. The +server uses an observed physical clock for spectral features and vital filters. +Existing version 1 adaptive models remain compatible. Sparse background samples +fail open to observation and fail closed for suppression. + +The direct Fourier estimator is intentionally small and bounded to 128 temporal +samples. It is portable and deterministic, but it is not a replacement for a +phase sanitized delay and Doppler pipeline. + +The largest remaining uncertainty is human recall. Empty home evidence can +measure false presence but cannot show whether weak occupied signals are being +suppressed. The fix path is a consented, leakage separated occupied holdout with +multiple spatial links and synchronized reference labels. + +## Research basis + +1. Espressif ESP-IDF WiFi CSI programming guide: + https://docs.espressif.com/projects/esp-idf/en/v5.5.4/esp32/api-guides/wifi.html +2. Espressif ESP CSI reference implementation: + https://github.com/espressif/esp-csi +3. OpenCSI: + https://arxiv.org/abs/2607.26665 +4. MORIC: + https://arxiv.org/abs/2506.12997 +5. Parameter efficient CSI crowd counting: + https://arxiv.org/abs/2601.02203 +6. WiFi JEPA: + https://arxiv.org/abs/2607.11064 + +## Acceptance test + +Run the firmware serializer host tests and the focused hardware, signal, and +sensing server Rust suites. The deterministic serializer test must prove the +first four bytes are zero only when ESP-IDF marks them invalid and bit 5 must +round trip through the Rust parser. The temporal estimator must recover a +1.2 Hz synthetic signal within 0.21 Hz, abstain on short and flat inputs, and +remain below 10 milliseconds p95 on the target Mac. Existing version 1 model +input must retain the legacy proxy. A background model with nine references +must never report a match or suppress a runtime change. Physical firmware is +not qualified until an S3 and C6 boot and stream the sanitized flag without +watchdog, memory, or transport regressions during a five minute burn in. diff --git a/docs/adr/README.md b/docs/adr/README.md index 84b858b2..095c4bcc 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -97,6 +97,7 @@ Statuses: **Proposed** (under discussion), **Accepted** (approved and/or impleme | [ADR-350](ADR-350-ruvector-predictive-memory-and-ruvllm-boundary.md) | RuVector predictive memory and RuVLLM authority boundary | Proposed | | [ADR-354](ADR-354-forecast-governance-enforcement-in-ruforecast-submodule.md) | fal.ai forecast governance and spend enforcement now real, in the vendored RuForecast submodule | Accepted | | [ADR-355](ADR-355-local-empty-room-bootstrap-prior.md) | Local empty room bootstrap prior | Accepted | +| [ADR-356](ADR-356-rate-truthful-csi-and-reliability-gated-adaptation.md) | Rate truthful CSI and reliability gated room adaptation | Accepted | ### Platform and UI diff --git a/firmware/esp32-csi-node/README.md b/firmware/esp32-csi-node/README.md index ebdddc2d..2469814f 100644 --- a/firmware/esp32-csi-node/README.md +++ b/firmware/esp32-csi-node/README.md @@ -257,10 +257,17 @@ Offset Size Field 12 4 Sequence number (LE u32) 16 1 RSSI (i8) 17 1 Noise floor (i8) -18 2 Reserved +18 1 PPDU type (ADR-110; zero when tagging is disabled) +19 1 Flags: bit0=40 MHz, bit2=STBC, bit4=sync valid, + bit5=invalid first CSI word zeroed by firmware 20 N*2 I/Q pairs (n_antennas * n_subcarriers * 2 bytes) ``` +When ESP-IDF marks `first_word_invalid`, the firmware preserves packet geometry, +zeros the first four CSI bytes, and sets byte 19 bit 5. The same sanitized bytes +feed the edge DSP path. This prevents a documented hardware artifact from being +learned as motion while keeping older readers wire compatible. + ### Vitals Packet (32 bytes) ``` diff --git a/firmware/esp32-csi-node/main/csi_collector.c b/firmware/esp32-csi-node/main/csi_collector.c index 6a15443f..dfa6398b 100644 --- a/firmware/esp32-csi-node/main/csi_collector.c +++ b/firmware/esp32-csi-node/main/csi_collector.c @@ -263,8 +263,16 @@ size_t csi_serialize_frame(const wifi_csi_info_t *info, uint8_t *buf, size_t buf buf[19] = 0; #endif - /* I/Q data */ + /* I/Q data. ESP-IDF documents that the first four CSI bytes are invalid + * when first_word_invalid is set. Never let those hardware artifacts feed + * either the host or edge DSP path. Preserve the fixed ADR-018 geometry by + * zeroing rather than removing bytes, and mark the sanitation in bit 5. */ memcpy(&buf[CSI_HEADER_SIZE], info->buf, iq_len); + if (info->first_word_invalid && iq_len > 0) { + size_t invalid_len = iq_len < 4 ? iq_len : 4; + memset(&buf[CSI_HEADER_SIZE], 0, invalid_len); + buf[19] |= CSI_FLAG_FIRST_WORD_SANITIZED; + } return frame_size; } @@ -330,13 +338,16 @@ static void wifi_csi_callback(void *ctx, wifi_csi_info_t *info) * while the on-device Tier 1/2 pipeline receives a uniform, sustainable * stream. Enqueuing every burst frame overloaded the unicore C6 DSP and * turned 30-40 callback pps into an irregular approximately 8 Hz subset. */ - if (info->buf && info->len > 0) { + if (frame_len > CSI_HEADER_SIZE) { if (s_next_edge_enqueue_us == 0) { s_next_edge_enqueue_us = now_us; } if (now_us >= s_next_edge_enqueue_us) { - (void)edge_enqueue_csi((const uint8_t *)info->buf, (uint16_t)info->len, + /* Reuse the sanitized ADR-018 payload. Feeding info->buf here + * would reintroduce first_word_invalid artifacts on device. */ + (void)edge_enqueue_csi(&frame_buf[CSI_HEADER_SIZE], + (uint16_t)(frame_len - CSI_HEADER_SIZE), (int8_t)info->rx_ctrl.rssi, info->rx_ctrl.channel); /* Preserve the configured sample clock instead of resetting it to diff --git a/firmware/esp32-csi-node/main/csi_collector.h b/firmware/esp32-csi-node/main/csi_collector.h index 92f43105..cce99393 100644 --- a/firmware/esp32-csi-node/main/csi_collector.h +++ b/firmware/esp32-csi-node/main/csi_collector.h @@ -17,6 +17,9 @@ /** ADR-018 header size in bytes. */ #define CSI_HEADER_SIZE 20 +/** ADR-018 byte 19: ESP-IDF reported and firmware sanitized invalid CSI prefix. */ +#define CSI_FLAG_FIRST_WORD_SANITIZED (1U << 5) + /** Maximum frame buffer size (header + 4 antennas * 256 subcarriers * 2 bytes). */ #define CSI_MAX_FRAME_SIZE (CSI_HEADER_SIZE + 4 * 256 * 2) diff --git a/firmware/esp32-csi-node/test/Makefile b/firmware/esp32-csi-node/test/Makefile index e2b46699..19c82dbe 100644 --- a/firmware/esp32-csi-node/test/Makefile +++ b/firmware/esp32-csi-node/test/Makefile @@ -45,10 +45,20 @@ FUZZ_JOBS ?= 1 .PHONY: all clean run_serialize run_edge run_nvs run_all test_adr110 run_adr110 \ test_vitals run_vitals test_mmwave_detect run_mmwave_detect host_tests \ - test_thermal run_thermal + test_thermal run_thermal test_csi_sanitize run_csi_sanitize all: fuzz_serialize fuzz_edge fuzz_nvs test_adr110 test_vitals test_mmwave_detect \ - test_thermal + test_thermal test_csi_sanitize + +# --- ESP-IDF invalid first-word sanitation --- +# Links the production serializer. Function sections and dead stripping keep +# unrelated device-only callback code out of this host executable. +test_csi_sanitize: test_csi_sanitize.c $(MAIN_DIR)/csi_collector.c $(STUBS_SRC) + cc -std=c99 -Wall -Wextra -Istubs -I$(MAIN_DIR) -ffunction-sections \ + $^ -Wl,-dead_strip -o $@ -lm + +run_csi_sanitize: test_csi_sanitize + ./test_csi_sanitize # --- ADR-110 encoding unit tests --- # Host-side, no libFuzzer needed — plain C99 deterministic table tests @@ -93,8 +103,8 @@ test_thermal: test_thermal_hysteresis.c $(MAIN_DIR)/thermal.h run_thermal: test_thermal ./test_thermal -host_tests: run_adr110 run_vitals run_mmwave_detect run_thermal - @echo "Host tests passed (ADR-110 + vitals #998/#996 + mmwave detect #1135 + thermal)" +host_tests: run_adr110 run_vitals run_mmwave_detect run_thermal run_csi_sanitize + @echo "Host tests passed (ADR-110 + CSI sanitation + vitals + mmwave + thermal)" # --- Serialize fuzzer --- # Tests csi_serialize_frame() with random wifi_csi_info_t inputs. @@ -130,5 +140,6 @@ run_nvs: fuzz_nvs run_all: run_serialize run_edge run_nvs clean: - rm -f fuzz_serialize fuzz_edge fuzz_nvs test_adr110 test_vitals + rm -f fuzz_serialize fuzz_edge fuzz_nvs test_adr110 test_vitals test_mmwave_detect \ + test_thermal test_csi_sanitize rm -rf corpus_serialize/ corpus_edge/ corpus_nvs/ diff --git a/firmware/esp32-csi-node/test/fuzz_csi_serialize.c b/firmware/esp32-csi-node/test/fuzz_csi_serialize.c index 1eb3af85..4aade042 100644 --- a/firmware/esp32-csi-node/test/fuzz_csi_serialize.c +++ b/firmware/esp32-csi-node/test/fuzz_csi_serialize.c @@ -90,6 +90,7 @@ int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) info.rx_ctrl.sig_mode = legacy_inputs & 0x03; info.rx_ctrl.cwb = (legacy_inputs >> 2) & 0x01; info.rx_ctrl.stbc = (legacy_inputs >> 3) & 0x01; + info.first_word_invalid = (test_case & 0x80) != 0; /* Use remaining fuzz data as I/Q buffer content. */ uint16_t iq_len; @@ -136,6 +137,17 @@ int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) if (result > out_len) { __builtin_trap(); /* Buffer overflow detected. */ } + if (result >= CSI_HEADER_SIZE + iq_len && info.first_word_invalid && iq_len > 0) { + size_t invalid_len = iq_len < 4 ? iq_len : 4; + for (size_t i = 0; i < invalid_len; i++) { + if (out_buf[CSI_HEADER_SIZE + i] != 0) { + __builtin_trap(); /* Invalid hardware prefix escaped sanitation. */ + } + } + if ((out_buf[19] & CSI_FLAG_FIRST_WORD_SANITIZED) == 0) { + __builtin_trap(); /* Sanitized payload must carry provenance. */ + } + } /* --- Test case 1: NULL info pointer --- */ if (test_case & 0x01) { diff --git a/firmware/esp32-csi-node/test/stubs/esp_stubs.h b/firmware/esp32-csi-node/test/stubs/esp_stubs.h index 3d849a89..e2dc08ab 100644 --- a/firmware/esp32-csi-node/test/stubs/esp_stubs.h +++ b/firmware/esp32-csi-node/test/stubs/esp_stubs.h @@ -90,6 +90,7 @@ typedef struct { typedef struct { wifi_pkt_rx_ctrl_t rx_ctrl; uint8_t mac[6]; + bool first_word_invalid; int16_t len; /**< Length of the I/Q buffer in bytes. */ int8_t *buf; /**< Pointer to I/Q data. */ } wifi_csi_info_t; diff --git a/firmware/esp32-csi-node/test/test_csi_sanitize.c b/firmware/esp32-csi-node/test/test_csi_sanitize.c new file mode 100644 index 00000000..f0776a76 --- /dev/null +++ b/firmware/esp32-csi-node/test/test_csi_sanitize.c @@ -0,0 +1,56 @@ +/** Deterministic host test for ESP-IDF first_word_invalid sanitation. */ + +#include "esp_stubs.h" +#include "nvs_config.h" +#include "csi_collector.h" + +#include +#include +#include + +nvs_config_t g_nvs_config; + +static void make_info(wifi_csi_info_t *info, int8_t *iq, uint16_t len, bool invalid) +{ + memset(info, 0, sizeof(*info)); + info->rx_ctrl.channel = 6; + info->rx_ctrl.rssi = -42; + info->rx_ctrl.noise_floor = -91; + info->len = (int16_t)len; + info->buf = iq; + info->first_word_invalid = invalid; +} + +int main(void) +{ + int8_t iq[] = {1, 2, 3, 4, 5, 6, 7, 8}; + uint8_t frame[CSI_MAX_FRAME_SIZE]; + wifi_csi_info_t info; + + make_info(&info, iq, sizeof(iq), true); + size_t len = csi_serialize_frame(&info, frame, sizeof(frame)); + assert(len == CSI_HEADER_SIZE + sizeof(iq)); + assert(frame[CSI_HEADER_SIZE] == 0); + assert(frame[CSI_HEADER_SIZE + 1] == 0); + assert(frame[CSI_HEADER_SIZE + 2] == 0); + assert(frame[CSI_HEADER_SIZE + 3] == 0); + assert(frame[CSI_HEADER_SIZE + 4] == 5); + assert((frame[19] & CSI_FLAG_FIRST_WORD_SANITIZED) != 0); + + make_info(&info, iq, sizeof(iq), false); + len = csi_serialize_frame(&info, frame, sizeof(frame)); + assert(len == CSI_HEADER_SIZE + sizeof(iq)); + assert(memcmp(&frame[CSI_HEADER_SIZE], iq, sizeof(iq)) == 0); + assert((frame[19] & CSI_FLAG_FIRST_WORD_SANITIZED) == 0); + + int8_t short_iq[] = {9, 10}; + make_info(&info, short_iq, sizeof(short_iq), true); + len = csi_serialize_frame(&info, frame, sizeof(frame)); + assert(len == CSI_HEADER_SIZE + sizeof(short_iq)); + assert(frame[CSI_HEADER_SIZE] == 0); + assert(frame[CSI_HEADER_SIZE + 1] == 0); + assert((frame[19] & CSI_FLAG_FIRST_WORD_SANITIZED) != 0); + + puts("CSI invalid-prefix sanitation: PASS"); + return 0; +} diff --git a/v2/crates/wifi-densepose-hardware/src/csi_frame.rs b/v2/crates/wifi-densepose-hardware/src/csi_frame.rs index 412240ec..19cfa0d3 100644 --- a/v2/crates/wifi-densepose-hardware/src/csi_frame.rs +++ b/v2/crates/wifi-densepose-hardware/src/csi_frame.rs @@ -145,13 +145,16 @@ impl PpduType { /// bit 2 : STBC /// bit 3 : LDPC (reserved — not yet populated by firmware) /// bit 4 : 802.15.4 time-sync valid (C6 only) -/// bit 5-7 : reserved +/// bit 5 : ESP-IDF invalid first CSI word was zeroed by firmware +/// bit 6-7 : reserved #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct Adr018Flags { pub bw40: bool, pub stbc: bool, pub ldpc: bool, pub ieee802154_sync_valid: bool, + #[serde(default)] + pub first_word_sanitized: bool, } impl Adr018Flags { @@ -161,6 +164,7 @@ impl Adr018Flags { stbc: (b & 0x04) != 0, ldpc: (b & 0x08) != 0, ieee802154_sync_valid: (b & 0x10) != 0, + first_word_sanitized: (b & 0x20) != 0, } } pub fn to_byte(self) -> u8 { @@ -169,6 +173,7 @@ impl Adr018Flags { if self.stbc { b |= 0x04; } if self.ldpc { b |= 0x08; } if self.ieee802154_sync_valid { b |= 0x10; } + if self.first_word_sanitized { b |= 0x20; } b } } diff --git a/v2/crates/wifi-densepose-hardware/src/esp32_parser.rs b/v2/crates/wifi-densepose-hardware/src/esp32_parser.rs index fffe3a22..20d06066 100644 --- a/v2/crates/wifi-densepose-hardware/src/esp32_parser.rs +++ b/v2/crates/wifi-densepose-hardware/src/esp32_parser.rs @@ -413,15 +413,16 @@ mod tests { #[test] fn adr110_flags_round_trip_all_bits() { - // All known flag bits set: bw40 (0x01) + STBC (0x04) + LDPC (0x08) + 15.4-sync (0x10) = 0x1D - let data = build_test_frame_with_he(6, 1, &[(0, 0); 56], 1, 0x1D); + // All known flag bits set: bw40 + STBC + LDPC + sync + sanitized prefix = 0x3D. + let data = build_test_frame_with_he(6, 1, &[(0, 0); 56], 1, 0x3D); let (frame, _) = Esp32CsiParser::parse_frame(&data).unwrap(); assert!(frame.metadata.adr018_flags.bw40); assert!(frame.metadata.adr018_flags.stbc); assert!(frame.metadata.adr018_flags.ldpc); assert!(frame.metadata.adr018_flags.ieee802154_sync_valid); + assert!(frame.metadata.adr018_flags.first_word_sanitized); // Round-trip the encoder - assert_eq!(frame.metadata.adr018_flags.to_byte(), 0x1D); + assert_eq!(frame.metadata.adr018_flags.to_byte(), 0x3D); } #[test] diff --git a/v2/crates/wifi-densepose-sensing-server/src/adaptive_classifier.rs b/v2/crates/wifi-densepose-sensing-server/src/adaptive_classifier.rs index 3a662d07..f5baecbf 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/adaptive_classifier.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/adaptive_classifier.rs @@ -139,6 +139,39 @@ pub fn features_from_runtime(feat: &serde_json::Value, amps: &[f64]) -> [f64; N_ ] } +/// Preserve the fifth feature's historical meaning for models trained before +/// the server began measuring temporal frequency from a frame window. +/// +/// Version 1 models learned a proxy equal to the strongest subcarrier index +/// multiplied by 0.05. Version 2 and later models consume the measured +/// temporal frequency in hertz. Keeping this translation at the model boundary +/// prevents a server upgrade from silently changing an existing model's input +/// distribution. +pub fn compatible_dominant_frequency( + model_version: u32, + temporal_frequency_hz: f64, + amplitudes: &[f64], +) -> f64 { + if model_version >= 2 { + return if temporal_frequency_hz.is_finite() { + temporal_frequency_hz.max(0.0) + } else { + 0.0 + }; + } + + amplitudes + .iter() + .enumerate() + .filter(|(_, amplitude)| amplitude.is_finite()) + .max_by(|(_, left), (_, right)| { + left.partial_cmp(right) + .unwrap_or(std::cmp::Ordering::Equal) + }) + .map(|(index, _)| index as f64 * 0.05) + .unwrap_or(0.0) +} + /// Compute statistical features from raw subcarrier amplitudes. fn subcarrier_stats(amps: &[f64]) -> (f64, f64, f64, f64, f64, f64, f64, f64) { if amps.is_empty() { @@ -642,3 +675,21 @@ pub fn train_from_recordings(recordings_dir: &Path) -> Result PathBuf { PathBuf::from("data/adaptive_model.json") } + +#[cfg(test)] +mod compatibility_tests { + use super::compatible_dominant_frequency; + + #[test] + fn version_one_keeps_legacy_subcarrier_proxy() { + let amplitudes = [1.0, 2.0, 8.0, 3.0]; + assert_eq!(compatible_dominant_frequency(1, 1.25, &litudes), 0.10); + } + + #[test] + fn version_two_uses_measured_temporal_frequency() { + let amplitudes = [1.0, 20.0, 2.0]; + assert_eq!(compatible_dominant_frequency(2, 1.25, &litudes), 1.25); + assert_eq!(compatible_dominant_frequency(2, f64::NAN, &litudes), 0.0); + } +} diff --git a/v2/crates/wifi-densepose-sensing-server/src/csi.rs b/v2/crates/wifi-densepose-sensing-server/src/csi.rs index 6d0cd900..a49e97c1 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/csi.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/csi.rs @@ -11,6 +11,141 @@ use crate::vital_signs::VitalSigns; const EDGE_MAX_PERSONS: u8 = 4; +/// Estimate a real temporal motion frequency from recent CSI amplitude +/// history. Each frame is reduced to its mean amplitude, detrended, Hann +/// windowed, and evaluated on exact DFT bins. +pub fn estimate_temporal_dominant_frequency_hz( + frame_history: &VecDeque>, + sample_rate_hz: f64, +) -> f64 { + if !sample_rate_hz.is_finite() || sample_rate_hz <= 0.0 { + return 0.0; + } + + let recent_frames: Vec<&Vec> = frame_history + .iter() + .rev() + .take(128) + .collect(); + if recent_frames + .iter() + .any(|frame| frame.is_empty() || frame.iter().any(|value| !value.is_finite())) + { + return 0.0; + } + let samples: Vec = recent_frames + .into_iter() + .rev() + .map(|frame| frame.iter().sum::() / frame.len() as f64) + .collect(); + let n = samples.len(); + if n < 16 { + return 0.0; + } + + let mean = samples.iter().sum::() / n as f64; + let upper_hz = 4.0_f64.min(sample_rate_hz * 0.45); + if upper_hz < 0.1 { + return 0.0; + } + + let mut powers = Vec::new(); + for bin in 1..=(n / 2) { + let frequency_hz = bin as f64 * sample_rate_hz / n as f64; + if !(0.1..=upper_hz).contains(&frequency_hz) { + continue; + } + let mut real = 0.0; + let mut imag = 0.0; + let angle_step = 2.0 * std::f64::consts::PI * bin as f64 / n as f64; + let (step_sin, step_cos) = angle_step.sin_cos(); + let mut angle_cos = 1.0; + let mut angle_sin = 0.0; + for (index, sample) in samples.iter().enumerate() { + let window = + 0.5 * (1.0 - (2.0 * std::f64::consts::PI * index as f64 / (n - 1) as f64).cos()); + let value = (sample - mean) * window; + real += value * angle_cos; + imag -= value * angle_sin; + let next_cos = angle_cos * step_cos - angle_sin * step_sin; + angle_sin = angle_sin * step_cos + angle_cos * step_sin; + angle_cos = next_cos; + } + powers.push((frequency_hz, real * real + imag * imag)); + } + if powers.is_empty() { + return 0.0; + } + let mean_power = powers.iter().map(|(_, power)| power).sum::() / powers.len() as f64; + let (peak_hz, peak_power) = powers + .into_iter() + .max_by(|left, right| { + left.1 + .partial_cmp(&right.1) + .unwrap_or(std::cmp::Ordering::Equal) + }) + .unwrap_or((0.0, 0.0)); + if mean_power <= f64::EPSILON || peak_power / mean_power < 2.0 { + 0.0 + } else { + peak_hz + } +} + +#[cfg(test)] +mod temporal_frequency_tests { + use super::*; + + #[test] + fn recovers_temporal_frequency_in_hertz() { + let sample_rate_hz = 20.0; + let target_hz = 1.2; + let mut history = VecDeque::new(); + for sample in 0..100 { + let t = sample as f64 / sample_rate_hz; + let value = 20.0 + 2.0 * (2.0 * std::f64::consts::PI * target_hz * t).sin(); + history.push_back(vec![value; 56]); + } + let measured = estimate_temporal_dominant_frequency_hz(&history, sample_rate_hz); + assert!((measured - target_hz).abs() <= 0.21, "measured {measured} Hz"); + } + + #[test] + fn abstains_on_short_or_flat_history() { + let short = VecDeque::from(vec![vec![20.0; 56]; 15]); + assert_eq!(estimate_temporal_dominant_frequency_hz(&short, 20.0), 0.0); + let flat = VecDeque::from(vec![vec![20.0; 56]; 100]); + assert_eq!(estimate_temporal_dominant_frequency_hz(&flat, 20.0), 0.0); + let mut corrupt = flat; + corrupt[50][4] = f64::NAN; + assert_eq!(estimate_temporal_dominant_frequency_hz(&corrupt, 20.0), 0.0); + } + + #[test] + fn temporal_frequency_p95_stays_below_server_budget() { + let sample_rate_hz = 50.0; + let mut history = VecDeque::new(); + for sample in 0..128 { + let t = sample as f64 / sample_rate_hz; + let value = 20.0 + 2.0 * (2.0 * std::f64::consts::PI * 1.2 * t).sin(); + history.push_back(vec![value; 256]); + } + let mut timings = Vec::with_capacity(1_000); + for _ in 0..1_000 { + let start = std::time::Instant::now(); + std::hint::black_box(estimate_temporal_dominant_frequency_hz( + std::hint::black_box(&history), + sample_rate_hz, + )); + timings.push(start.elapsed()); + } + timings.sort_unstable(); + let p95 = timings[949]; + eprintln!("temporal dominant frequency p95: {p95:?}"); + assert!(p95 < std::time::Duration::from_millis(10), "p95 {p95:?}"); + } +} + /// Person count is supporting evidence, never an independent occupancy claim. /// Return zero and mark invalid for contradictory or out-of-range firmware. fn sanitize_edge_person_count(presence: bool, raw: u8) -> (u8, bool) { @@ -469,14 +604,7 @@ pub fn extract_features_from_frame( 0.0 }; - let peak_idx = frame - .amplitudes - .iter() - .enumerate() - .max_by(|a, b| a.1.partial_cmp(b.1).unwrap_or(std::cmp::Ordering::Equal)) - .map(|(i, _)| i) - .unwrap_or(0); - let dominant_freq_hz = peak_idx as f64 * 0.05; + let dominant_freq_hz = estimate_temporal_dominant_frequency_hz(frame_history, sample_rate_hz); let threshold = mean_amp * 1.2; let change_points = frame @@ -485,7 +613,9 @@ pub fn extract_features_from_frame( .filter(|w| (w[0] < threshold) != (w[1] < threshold)) .count(); - let temporal_motion_score = if let Some(prev_frame) = frame_history.back() { + // The caller appends the current frame before extraction, so the true + // predecessor is the second item from the back. + let temporal_motion_score = if let Some(prev_frame) = frame_history.iter().rev().nth(1) { let n_cmp = n_sub.min(prev_frame.len()); if n_cmp > 0 { let diff_energy: f64 = (0..n_cmp) @@ -636,13 +766,18 @@ pub fn adaptive_override( .back() .map(|v| v.as_slice()) .unwrap_or(&[]); + let dominant_frequency = adaptive_classifier::compatible_dominant_frequency( + model.version, + features.dominant_freq_hz, + amps, + ); let feat_arr = adaptive_classifier::features_from_runtime( &serde_json::json!({ "variance": features.variance, "motion_band_power": features.motion_band_power, "breathing_band_power": features.breathing_band_power, "spectral_power": features.spectral_power, - "dominant_freq_hz": features.dominant_freq_hz, + "dominant_freq_hz": dominant_frequency, "change_points": features.change_points, "mean_rssi": features.mean_rssi, }), 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 a60c266c..980f04cf 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/field_bridge.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/field_bridge.rs @@ -47,6 +47,9 @@ const MAX_SINGLE_LINK_OCCUPANCY: usize = 3; pub struct BootstrapBackgroundMatch { pub matches_empty: bool, pub score: Option, + pub normalized_residual_z: Option, + pub maturity: f64, + pub reliable: bool, pub residual_energy: f64, pub residual_energy_threshold: f64, pub window_size: usize, @@ -227,6 +230,9 @@ pub fn bootstrap_background_match( Some(BootstrapBackgroundMatch { matches_empty: result.matches_empty, score: result.score, + normalized_residual_z: result.normalized_residual_z, + maturity: result.maturity, + reliable: result.reliable, residual_energy: result.residual_energy, residual_energy_threshold: result.residual_energy_threshold, window_size: result.window_size, @@ -502,6 +508,9 @@ mod tests { .expect("background score"); assert!(background.matches_empty); assert_eq!(background.reference_window_count, 12); + assert!(background.reliable); + assert_eq!(background.maturity, 0.6); + assert!(background.normalized_residual_z.is_some()); assert!(background .score .is_some_and(|score| (0.5..=1.0).contains(&score))); @@ -530,5 +539,6 @@ mod tests { .expect("shifted background result"); assert!(!shifted_background.matches_empty); assert_eq!(shifted_background.score, Some(0.0)); + assert!(shifted_background.reliable); } } diff --git a/v2/crates/wifi-densepose-sensing-server/src/main.rs b/v2/crates/wifi-densepose-sensing-server/src/main.rs index cbbed146..5c159c41 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/main.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/main.rs @@ -915,10 +915,10 @@ const NOVELTY_SKETCH_VERSION: u16 = 1; /// bursts whose intra-burst arrival deltas are tens of microseconds apart — /// a 36 µs delta yields `1/dt ≈ 27 kHz`, which the old `< 1 s` guard let /// straight into the EMA and inflated `csi_fps_ema` by 1–3 orders of -/// magnitude (issue #1180). We reject any delta implying more than 200 fps -/// (4× the physical ceiling, leaving slack for benign arrival jitter); such -/// deltas are burst artifacts, not distinct production intervals. +/// magnitude (issue #1180). We reject sub-5 ms deltas as burst artifacts and +/// cap accepted estimates to the firmware's 50 fps physical ceiling. pub(crate) const MIN_PLAUSIBLE_CSI_DT_SEC: f64 = 0.005; +pub(crate) const MAX_PHYSICAL_CSI_FPS: f64 = 50.0; /// ADR-110 iter 18 — EMA update for per-node CSI fps tracking. /// @@ -934,7 +934,7 @@ pub(crate) fn update_csi_fps_ema(prev_fps: f64, dt_sec: f64) -> Option { if !(dt_sec >= MIN_PLAUSIBLE_CSI_DT_SEC && dt_sec < 1.0) { return None; } - let instantaneous = 1.0 / dt_sec; + let instantaneous = (1.0 / dt_sec).min(MAX_PHYSICAL_CSI_FPS); // y[n] = y[n-1] + (x - y[n-1]) / 8 Some(prev_fps + (instantaneous - prev_fps) / 8.0) } @@ -983,6 +983,15 @@ mod fps_ema_tests { assert!(update_csi_fps_ema(40.0, 0.005).is_some()); } + #[test] + fn accepted_burst_edge_is_capped_to_firmware_ceiling() { + let mut fps = 50.0; + for _ in 0..32 { + fps = update_csi_fps_ema(fps, 0.005).unwrap(); + } + assert!(fps <= 50.0, "reported {fps} Hz above firmware ceiling"); + } + #[test] fn burst_interleaved_with_nominal_stays_in_band() { // A true ~40 fps node whose frames arrive in sub-ms bursts: feeding @@ -1004,6 +1013,18 @@ mod fps_ema_tests { } impl NodeState { + /// Measured sample rate only after enough inter-frame deltas have warmed + /// the EMA. The physical ceiling is enforced again at the consumption + /// boundary so restored or malformed state cannot overclock DSP math. + fn measured_sample_rate_hz(&self) -> Option { + (self.csi_fps_samples >= 5 && self.csi_fps_ema.is_finite()) + .then(|| self.csi_fps_ema.clamp(1.0, MAX_PHYSICAL_CSI_FPS)) + } + + fn effective_sample_rate_hz(&self) -> f64 { + self.measured_sample_rate_hz().unwrap_or(20.0) + } + /// ADR-110 §A0.12 timestamp recovery: given a CSI frame's node-local /// `esp_timer_get_time()` snapshot, return the mesh-aligned epoch /// computed from this node's most recent sync packet — or `None` @@ -1051,7 +1072,7 @@ impl NodeState { // samples; until then fall back to the 20 Hz firmware ceiling. The // §A0.12 capture showed real bench fps ≈ 10, so the measured value // is significantly more accurate than the constant fallback. - let fps = if self.csi_fps_samples >= 5 { self.csi_fps_ema } else { 20.0 }; + let fps = self.effective_sample_rate_hz(); Some(sync.mesh_aligned_us_for_sequence(frame_sequence, fps)) } @@ -1099,7 +1120,7 @@ impl NodeState { is_valid: sync.flags.is_valid, smoothed: sync.flags.smoothed_used, sequence: sync.sequence, - csi_fps_ema: self.csi_fps_ema, + csi_fps_ema: self.effective_sample_rate_hz(), csi_fps_samples: self.csi_fps_samples, staleness_ms: self.latest_sync_at.map(|t| t.elapsed().as_millis() as u64), }) @@ -1163,7 +1184,7 @@ impl NodeState { hr_buffer: VecDeque::with_capacity(8), br_buffer: VecDeque::with_capacity(8), rssi_history: VecDeque::new(), - vital_detector: VitalSignDetector::new(10.0), + vital_detector: VitalSignDetector::new(20.0), latest_vitals: VitalSigns::default(), last_frame_time: None, edge_vitals: None, @@ -1349,7 +1370,7 @@ fn build_node_features( }, rssi_dbm: ns.rssi_history.back().copied().unwrap_or(0.0), last_seen_ms, - frame_rate_hz: 0.0, // Computed elsewhere; not yet plumbed here. + frame_rate_hz: ns.measured_sample_rate_hz().unwrap_or(0.0), stale, novelty_score: ns.last_novelty_score, } @@ -2768,15 +2789,9 @@ fn extract_features_from_frame( 0.0 }; - // ── Dominant frequency via peak subcarrier index ── - let peak_idx = frame - .amplitudes - .iter() - .enumerate() - .max_by(|a, b| a.1.partial_cmp(b.1).unwrap_or(std::cmp::Ordering::Equal)) - .map(|(i, _)| i) - .unwrap_or(0); - let dominant_freq_hz = peak_idx as f64 * 0.05; + // A subcarrier index is a spatial-frequency bin, not temporal hertz. + let dominant_freq_hz = + csi::estimate_temporal_dominant_frequency_hz(frame_history, sample_rate_hz); // ── Change point detection (threshold-crossing count in current frame) ── let threshold = mean_amp * 1.2; @@ -2789,7 +2804,9 @@ fn extract_features_from_frame( // ── Motion score: sliding-window temporal difference ── // Compare current frame against the most recent historical frame. // The difference is normalised by the mean amplitude to be scale-invariant. - let temporal_motion_score = if let Some(prev_frame) = frame_history.back() { + // Callers append the current frame first, making the true predecessor the + // second item from the back. + let temporal_motion_score = if let Some(prev_frame) = frame_history.iter().rev().nth(1) { let n_cmp = n_sub.min(prev_frame.len()); if n_cmp > 0 { let diff_energy: f64 = (0..n_cmp) @@ -3022,13 +3039,18 @@ fn adaptive_override( .back() .map(|v| v.as_slice()) .unwrap_or(&[]); + let dominant_frequency = adaptive_classifier::compatible_dominant_frequency( + model.version, + features.dominant_freq_hz, + amps, + ); let feat_arr = adaptive_classifier::features_from_runtime( &serde_json::json!({ "variance": features.variance, "motion_band_power": features.motion_band_power, "breathing_band_power": features.breathing_band_power, "spectral_power": features.spectral_power, - "dominant_freq_hz": features.dominant_freq_hz, + "dominant_freq_hz": dominant_frequency, "change_points": features.change_points, "mean_rssi": features.mean_rssi, }), @@ -6452,6 +6474,9 @@ async fn calibration_status(State(state): State) -> Json= 5 + && ns.vital_detector.reconfigure_sample_rate(sample_rate_hz) + { + // Never smooth estimates computed against two clocks. + ns.smoothed_hr = 0.0; + ns.smoothed_br = 0.0; + ns.smoothed_hr_conf = 0.0; + ns.smoothed_br_conf = 0.0; + ns.hr_buffer.clear(); + ns.br_buffer.clear(); + } + let raw_vitals = ns .vital_detector .process_frame(&frame.amplitudes, &frame.phases); @@ -10620,6 +10663,20 @@ mod sync_snapshot_helper_tests { assert_eq!(snap.csi_fps_samples, 42); } + #[test] + fn exported_node_rate_is_mature_and_physically_bounded() { + let mut ns = NodeState::new(); + assert_eq!(ns.measured_sample_rate_hz(), None); + + ns.csi_fps_ema = 74.0; + ns.csi_fps_samples = 5; + assert_eq!(ns.measured_sample_rate_hz(), Some(50.0)); + + ns.latest_sync = Some(populated_sync(9)); + ns.latest_sync_at = Some(std::time::Instant::now()); + assert_eq!(ns.sync_snapshot().unwrap().csi_fps_ema, 50.0); + } + #[test] fn observe_csi_frame_arrival_ignores_subms_bursts() { // Issue #1180 regression: a ~40 fps node whose frames are delivered diff --git a/v2/crates/wifi-densepose-sensing-server/src/vital_signs.rs b/v2/crates/wifi-densepose-sensing-server/src/vital_signs.rs index 10558c6c..d6a31068 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/vital_signs.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/vital_signs.rs @@ -346,6 +346,31 @@ impl VitalSignDetector { self.frame_count = 0; } + /// Retune temporal windows to an observed CSI clock. Changes smaller than + /// twenty percent are ignored to avoid resetting on normal arrival jitter. + /// A real clock change clears history so samples from different clocks are + /// never interpreted in one spectrum. + pub fn reconfigure_sample_rate(&mut self, sample_rate: f64) -> bool { + if !sample_rate.is_finite() || sample_rate < 1.0 { + return false; + } + let relative_change = (sample_rate - self.sample_rate).abs() / self.sample_rate.max(1.0); + if relative_change < 0.20 { + return false; + } + self.sample_rate = sample_rate; + self.breathing_capacity = + ((sample_rate * self.breathing_window_secs) as usize).max(1); + self.heartbeat_capacity = + ((sample_rate * self.heartbeat_window_secs) as usize).max(1); + self.reset(); + true + } + + pub fn sample_rate_hz(&self) -> f64 { + self.sample_rate + } + /// Current buffer fill levels for diagnostics. /// Returns (breathing_len, breathing_capacity, heartbeat_len, heartbeat_capacity). pub fn buffer_status(&self) -> (usize, usize, usize, usize) { @@ -842,6 +867,22 @@ mod tests { assert_eq!(hb_len, 0); } + #[test] + fn measured_clock_reconfigures_once_and_clears_mixed_history() { + let mut detector = VitalSignDetector::new(20.0); + let amp = vec![10.0; 56]; + let phase = vec![0.0; 56]; + for _ in 0..32 { + detector.process_frame(&, &phase); + } + assert!(!detector.reconfigure_sample_rate(18.0)); + assert_eq!(detector.sample_rate_hz(), 20.0); + assert!(detector.reconfigure_sample_rate(12.0)); + assert_eq!(detector.sample_rate_hz(), 12.0); + assert_eq!(detector.buffer_status(), (0, 360, 0, 180)); + assert!(!detector.reconfigure_sample_rate(f64::NAN)); + } + #[test] fn test_vital_signs_default() { let vs = VitalSigns::default(); diff --git a/v2/crates/wifi-densepose-signal/src/ruvsense/field_model.rs b/v2/crates/wifi-densepose-signal/src/ruvsense/field_model.rs index 7bba49ef..41596d17 100644 --- a/v2/crates/wifi-densepose-signal/src/ruvsense/field_model.rs +++ b/v2/crates/wifi-densepose-signal/src/ruvsense/field_model.rs @@ -380,6 +380,12 @@ pub struct FieldNormalMode { pub struct EmptyRoomMatch { pub matches_empty: bool, pub score: Option, + /// Robust residual distance from the quiet reference median. + pub normalized_residual_z: Option, + /// Reference coverage relative to the normal calibration target. + pub maturity: f64, + /// Whether the reference set is large and valid enough to suppress change. + pub reliable: bool, pub residual_energy: f64, pub residual_energy_threshold: f64, pub window_size: usize, @@ -644,6 +650,27 @@ fn percentile_95(mut values: Vec) -> Option { values.get(index).copied() } +fn robust_residual_z(reference: &[f64], residual: f64) -> Option { + if !residual.is_finite() || reference.is_empty() { + return None; + } + let mut sorted: Vec = reference + .iter() + .copied() + .filter(|value| value.is_finite() && *value >= 0.0) + .collect(); + if sorted.len() != reference.len() { + return None; + } + sorted.sort_by(|left, right| left.partial_cmp(right).unwrap_or(std::cmp::Ordering::Equal)); + let median = sorted[sorted.len() / 2]; + let mut deviations: Vec = sorted.iter().map(|value| (value - median).abs()).collect(); + deviations.sort_by(|left, right| left.partial_cmp(right).unwrap_or(std::cmp::Ordering::Equal)); + let mad = deviations[deviations.len() / 2]; + let robust_scale = (1.4826 * mad).max((median.abs() * 0.01).max(f64::EPSILON)); + Some((residual - median) / robust_scale) +} + #[cfg(feature = "eigenvalue")] fn percentile_95_usize(mut values: Vec) -> Option { values.sort_unstable(); @@ -1315,12 +1342,20 @@ impl FieldModel { let residual_energy_threshold = modes .empty_room_residual_energy_threshold? .max(EMPTY_ROOM_RESIDUAL_ENERGY_MAX); - let matches_empty = residual_energy <= residual_energy_threshold; let reference_window_count = modes.empty_room_residual_energy_reference.len(); - let score = if !matches_empty { - Some(0.0) - } else if reference_window_count == 0 { + let maturity = (reference_window_count as f64 + / MIN_BACKGROUND_REFERENCE_WINDOWS as f64) + .clamp(0.0, 1.0); + let reliable = reference_window_count >= 10 + && modes + .empty_room_residual_energy_reference + .iter() + .all(|value| value.is_finite() && *value >= 0.0); + let matches_empty = reliable && residual_energy <= residual_energy_threshold; + let score = if !reliable { None + } else if !matches_empty { + Some(0.0) } else { let at_or_below = modes .empty_room_residual_energy_reference @@ -1331,6 +1366,12 @@ impl FieldModel { Some(EmptyRoomMatch { matches_empty, score, + normalized_residual_z: robust_residual_z( + &modes.empty_room_residual_energy_reference, + residual_energy, + ), + maturity, + reliable, residual_energy, residual_energy_threshold, window_size, @@ -2149,6 +2190,9 @@ mod tests { .expect("background match"); assert!(background.matches_empty); assert_eq!(background.reference_window_count, 12); + assert!(background.reliable); + assert_eq!(background.maturity, 0.6); + assert!(background.normalized_residual_z.is_some()); assert!(background .score .is_some_and(|score| (0.5..=1.0).contains(&score))); @@ -2237,6 +2281,51 @@ mod tests { let background = model.empty_room_match(&shifted).expect("shifted match"); assert!(!background.matches_empty); assert_eq!(background.score, Some(0.0)); + assert!(background.reliable); + assert!(background.normalized_residual_z.is_some()); + } + + #[cfg(feature = "eigenvalue")] + #[test] + fn sparse_background_reference_cannot_suppress_runtime_change() { + let config = FieldModelConfig { + n_links: 1, + n_subcarriers: 56, + n_modes: 3, + min_calibration_frames: 500, + min_calibration_duration_s: 0.0, + baseline_expiry_s: 86_400.0, + }; + let mut model = FieldModel::new(config).unwrap(); + let mut calibration = Vec::new(); + for frame_index in 0..600 { + let time = frame_index as f64 * 0.071; + let frame: Vec = (0..56) + .map(|subcarrier| { + let carrier = subcarrier as f64; + 18.0 + carrier * 0.08 + + (time + carrier * 0.13).sin() * 0.8 + + (time * 0.37 + carrier * 0.031).cos() * 0.35 + }) + .collect(); + model.feed_calibration(&[frame.clone()]).unwrap(); + calibration.push(frame); + } + model.finalize_calibration(1_000_000, 0).unwrap(); + model + .modes + .as_mut() + .unwrap() + .empty_room_residual_energy_reference + .truncate(9); + + let result = model + .empty_room_match(&calibration[550..600]) + .expect("background comparison"); + assert!(!result.reliable); + assert!(!result.matches_empty); + assert_eq!(result.score, None); + assert_eq!(result.maturity, 0.45); } #[test] From 82e05d06640bdc2ed1cedb3b7f3323a2e5679b77 Mon Sep 17 00:00:00 2001 From: ruv Date: Sun, 6 Sep 2026 09:37:03 -0400 Subject: [PATCH 09/27] fix(calibration): expire live field models safely --- .../src/field_bridge.rs | 45 +++- .../wifi-densepose-sensing-server/src/main.rs | 228 ++++++++++++++---- .../src/types.rs | 5 + 3 files changed, 228 insertions(+), 50 deletions(-) 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 980f04cf..21c4ee46 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/field_bridge.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/field_bridge.rs @@ -149,6 +149,18 @@ pub fn single_link_config() -> FieldModelConfig { } } +/// Resolve the model status at the observation wall clock. `FieldModel::status` +/// records the state at collection or restore time and does not advance as a +/// long-running server crosses the stale and expiry boundaries. +pub fn calibration_status_at(field: &FieldModel, observed_at_us: u64) -> CalibrationStatus { + match field.status() { + CalibrationStatus::Uncalibrated | CalibrationStatus::Collecting => field.status(), + CalibrationStatus::Fresh | CalibrationStatus::Stale | CalibrationStatus::Expired => { + field.check_freshness(observed_at_us) + } + } +} + /// Estimate occupancy using the FieldModel when calibrated, falling back /// to the score-based heuristic otherwise. /// @@ -158,10 +170,11 @@ pub fn single_link_config() -> FieldModelConfig { pub fn occupancy_or_fallback( field: &FieldModel, frame_history: &VecDeque>, + observed_at_us: u64, smoothed_score: f64, prev_count: usize, ) -> usize { - match field.status() { + match calibration_status_at(field, observed_at_us) { CalibrationStatus::Fresh | CalibrationStatus::Stale => { let frames: Vec> = frame_history .iter() @@ -473,6 +486,36 @@ mod tests { ); } + #[test] + fn occupancy_falls_back_after_expiry_without_a_process_restart() { + let field = fresh_test_model(); + let baseline = field.modes().unwrap().baseline[0].clone(); + let history: VecDeque> = + (0..OCCUPANCY_WINDOW).map(|_| baseline.clone()).collect(); + + assert_eq!(field.status(), CalibrationStatus::Fresh); + assert_eq!( + calibration_status_at(&field, 1_500_000), + CalibrationStatus::Fresh + ); + assert_eq!( + occupancy_or_fallback(&field, &history, 1_500_000, 0.0, 0), + 0, + "a fresh field model may resolve its learned baseline as empty" + ); + + assert_eq!(field.status(), CalibrationStatus::Fresh); + assert_eq!( + calibration_status_at(&field, 4_000_000), + CalibrationStatus::Expired + ); + assert_eq!( + occupancy_or_fallback(&field, &history, 4_000_000, 0.0, 0), + score_to_person_count(0.0, 0), + "an expired in-memory model must fall back even though its cached status is fresh" + ); + } + #[test] fn adaptive_runtime_reference_scores_empty_and_rejects_shift() { let config = FieldModelConfig { diff --git a/v2/crates/wifi-densepose-sensing-server/src/main.rs b/v2/crates/wifi-densepose-sensing-server/src/main.rs index 5c159c41..c2f8790d 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/main.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/main.rs @@ -1730,12 +1730,38 @@ mod adr323_pose_physics_http_tests { const ESP32_OFFLINE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); impl AppStateInner { + fn field_model_status_at(&self, observed_at_unix_ms: u64) -> Option { + self.field_model.as_ref().map(|model| { + field_bridge::calibration_status_at(model, observed_at_unix_ms.saturating_mul(1_000)) + }) + } + + fn field_model_active_at(&self, observed_at_unix_ms: u64) -> bool { + self.field_model_status_at(observed_at_unix_ms) + .is_some_and(|status| status != CalibrationStatus::Expired) + } + + fn bootstrap_baseline_active_at(&self, observed_at_unix_ms: u64) -> bool { + self.bootstrap_baseline_active && self.field_model_active_at(observed_at_unix_ms) + } + + fn explicit_calibration_fresh_at(&self, observed_at_unix_ms: u64) -> bool { + !self.bootstrap_baseline_active + && self.field_model_status_at(observed_at_unix_ms) == Some(CalibrationStatus::Fresh) + } + /// Return the effective data source, accounting for ESP32 frame timeout. /// If the source is "esp32" but no frame has arrived in 5 seconds, returns /// "esp32:offline" so the UI can distinguish active vs stale connections. /// Person count: eigenvalue-based if field model is calibrated, else heuristic. /// Uses global frame_history if populated, otherwise the freshest per-node history. - fn person_count(&self) -> usize { + fn person_count_at(&self, observed_at_unix_ms: u64) -> usize { + // A persisted bootstrap model has negative-only authority. Its only + // allowed occupancy effect is the explicit empty-background suppression + // in `bootstrap_empty_prior_applies`; it must never infer positive count. + if self.bootstrap_baseline_active { + return score_to_person_count(self.smoothed_person_score, self.prev_person_count); + } match self.field_model.as_ref() { Some(fm) => { // A single-link model may score only the source node it was @@ -1769,6 +1795,7 @@ impl AppStateInner { field_bridge::occupancy_or_fallback( fm, history, + observed_at_unix_ms.saturating_mul(1_000), self.smoothed_person_score, self.prev_person_count, ) @@ -1777,6 +1804,10 @@ impl AppStateInner { } } + fn person_count(&self) -> usize { + self.person_count_at(chrono::Utc::now().timestamp_millis().max(0) as u64) + } + /// A restored startup image has negative-only authority. It may suppress /// an empty-room false positive after one complete runtime window, but it /// cannot establish positive presence or publish vital signs. @@ -1955,6 +1986,95 @@ impl AppStateInner { } } +#[cfg(test)] +mod calibration_expiry_tests { + use super::*; + use wifi_densepose_signal::ruvsense::field_model::FieldModelConfig; + + fn finalized_model(calibrated_at_unix_ms: u64) -> FieldModel { + let mut model = FieldModel::new(FieldModelConfig { + n_links: 1, + n_subcarriers: 56, + n_modes: 3, + min_calibration_frames: 10, + min_calibration_duration_s: 0.0, + baseline_expiry_s: 2.0, + }) + .unwrap(); + for frame_index in 0..20 { + let frame = (0..56) + .map(|subcarrier| { + 10.0 + subcarrier as f64 * 0.01 + + (frame_index as f64 * 0.1 + subcarrier as f64 * 0.03).sin() * 0.01 + }) + .collect(); + model.feed_calibration(&[frame]).unwrap(); + } + model + .finalize_calibration(calibrated_at_unix_ms.saturating_mul(1_000), 7) + .unwrap(); + model + } + + fn state_with_model(bootstrap: bool) -> AppStateInner { + let model = finalized_model(1_000); + let baseline = model.modes().unwrap().baseline[0].clone(); + let mut state = AppStateInner::minimal(); + state.frame_history = (0..50).map(|_| baseline.clone()).collect(); + state.field_model = Some(model); + state.bootstrap_baseline_active = bootstrap; + if bootstrap { + state.bootstrap_baseline = Some(BootstrapBaselineMetadata { + authority: bootstrap_baseline::BOOTSTRAP_BASELINE_AUTHORITY, + source_node_ids: vec![5], + source_model_id: "test-model".to_string(), + created_at_unix_ms: 1_000, + expires_at_unix_ms: 3_000, + content_sha256: "00".repeat(32), + }); + } + state + } + + #[test] + fn bootstrap_field_model_remains_negative_only_for_person_count() { + let runtime = state_with_model(false); + assert_eq!(runtime.person_count_at(1_500), 0); + + let bootstrap = state_with_model(true); + assert_eq!( + bootstrap.person_count_at(1_500), + score_to_person_count(0.0, 0), + "a bootstrap prior must not derive a positive or negative count through the runtime model path" + ); + } + + #[tokio::test] + async fn long_running_expiry_is_not_reported_active_for_runtime_or_bootstrap() { + let runtime = Arc::new(RwLock::new(state_with_model(false))); + let Json(runtime_status) = calibration_status(State(runtime)).await; + assert_eq!(runtime_status["active"], false); + assert_eq!(runtime_status["status"], "expired"); + assert_eq!(runtime_status["binding_mode"], "none"); + + let bootstrap = Arc::new(RwLock::new(state_with_model(true))); + let Json(bootstrap_status) = calibration_status(State(bootstrap)).await; + assert_eq!(bootstrap_status["active"], false); + assert_eq!(bootstrap_status["status"], "expired"); + assert_eq!(bootstrap_status["binding_mode"], "none"); + assert_eq!(bootstrap_status["bootstrap_baseline"]["stored"], true); + assert_eq!(bootstrap_status["bootstrap_baseline"]["active"], false); + assert_eq!( + bootstrap_status["bootstrap_baseline"]["calibrated_evidence_authorized"], + false + ); + assert_eq!( + bootstrap_status["bootstrap_baseline"]["numeric_vitals_authorized"], + false + ); + } +} + // ── ESP32 Edge Vitals Packet (ADR-039, magic 0xC511_0002) ──────────────────── /// Decoded vitals packet from ESP32 edge processing pipeline. @@ -6319,7 +6439,9 @@ async fn calibration_start( } // Guard: don't discard an in-progress or fresh calibration if let Some(ref fm) = s.field_model { - match fm.status() { + let observed_at_us = + (chrono::Utc::now().timestamp_millis().max(0) as u64).saturating_mul(1_000); + match field_bridge::calibration_status_at(fm, observed_at_us) { CalibrationStatus::Collecting => { return Json(serde_json::json!({ "success": false, @@ -6423,6 +6545,9 @@ async fn calibration_stop(State(state): State) -> Json) -> Json { let s = state.read().await; let observed_at_unix_ms = chrono::Utc::now().timestamp_millis().max(0) as u64; + let effective_status = s.field_model_status_at(observed_at_unix_ms); + let active = s.field_model_active_at(observed_at_unix_ms); + let bootstrap_active = s.bootstrap_baseline_active_at(observed_at_unix_ms); let bootstrap_background_match = s.bootstrap_background_match(observed_at_unix_ms); let runtime_reference = s.field_model.as_ref().and_then(|model| { model.modes().map(|modes| serde_json::json!({ @@ -6434,21 +6559,22 @@ async fn calibration_status(State(state): State) -> Json) -> Json Json { let expected_model_id = { let s = state.read().await; - if s.bootstrap_baseline_active - || !s.field_model.as_ref().is_some_and(|model| { - matches!(model.status(), CalibrationStatus::Fresh) - }) - { + let observed_at_unix_ms = chrono::Utc::now().timestamp_millis().max(0) as u64; + if s.bootstrap_baseline_active || !s.explicit_calibration_fresh_at(observed_at_unix_ms) { return Json(serde_json::json!({ "success": false, "error_code": "calibration_not_finalized", @@ -6540,8 +6663,9 @@ async fn calibration_promote_bootstrap( while tokio::time::Instant::now() < interval_deadline { let observed = { let s = state.read().await; + let observed_at_unix_ms = chrono::Utc::now().timestamp_millis().max(0) as u64; if s.calibration_model_id.as_deref() != Some(expected_model_id.as_str()) - || s.bootstrap_baseline_active + || !s.explicit_calibration_fresh_at(observed_at_unix_ms) { None } else { @@ -6581,8 +6705,9 @@ async fn calibration_promote_bootstrap( } let mut s = state.write().await; + let observed_at_unix_ms = chrono::Utc::now().timestamp_millis().max(0) as u64; if s.calibration_model_id.as_deref() != Some(expected_model_id.as_str()) - || s.bootstrap_baseline_active + || !s.explicit_calibration_fresh_at(observed_at_unix_ms) { return Json(serde_json::json!({ "success": false, @@ -6661,7 +6786,11 @@ async fn calibration_refine_bootstrap( let (expected_model_id, expected_digest, source_node_id) = { let s = state.read().await; - let Some(metadata) = s.bootstrap_baseline.as_ref().filter(|_| s.bootstrap_baseline_active) + let observed_at_unix_ms = chrono::Utc::now().timestamp_millis().max(0) as u64; + let Some(metadata) = s + .bootstrap_baseline + .as_ref() + .filter(|_| s.bootstrap_baseline_active_at(observed_at_unix_ms)) else { return Json(serde_json::json!({ "success": false, @@ -6706,7 +6835,8 @@ async fn calibration_refine_bootstrap( while tokio::time::Instant::now() < interval_deadline { observed = { let s = state.read().await; - let identity_matches = s.bootstrap_baseline_active + let observed_at_unix_ms = chrono::Utc::now().timestamp_millis().max(0) as u64; + let identity_matches = s.bootstrap_baseline_active_at(observed_at_unix_ms) && s.bootstrap_baseline.as_ref().is_some_and(|metadata| { metadata.source_model_id == expected_model_id && metadata.content_sha256 == expected_digest @@ -6771,7 +6901,8 @@ async fn calibration_refine_bootstrap( } let mut s = state.write().await; - let identity_matches = s.bootstrap_baseline_active + let observed_at_unix_ms = chrono::Utc::now().timestamp_millis().max(0) as u64; + let identity_matches = s.bootstrap_baseline_active_at(observed_at_unix_ms) && s.bootstrap_baseline.as_ref().is_some_and(|metadata| { metadata.source_model_id == expected_model_id && metadata.content_sha256 == expected_digest @@ -6951,11 +7082,10 @@ fn chrono_timestamp() -> u64 { async fn vital_signs_endpoint(State(state): State) -> Json { let s = state.read().await; - let person_count = s.person_count(); - let explicit_calibration_fresh = !s.bootstrap_baseline_active - && s.field_model - .as_ref() - .is_some_and(|model| matches!(model.status(), CalibrationStatus::Fresh)); + let observed_at_unix_ms = chrono::Utc::now().timestamp_millis().max(0) as u64; + let person_count = s.person_count_at(observed_at_unix_ms); + let explicit_calibration_fresh = + s.explicit_calibration_fresh_at(observed_at_unix_ms); let published = vitals_for_publication( &s.latest_vitals, explicit_calibration_fresh, @@ -7036,11 +7166,9 @@ async fn edge_registry_endpoint( /// GET /api/v1/edge-vitals — latest edge vitals from ESP32 (ADR-039). async fn edge_vitals_endpoint(State(state): State) -> Json { let s = state.read().await; - let explicit_single_occupant = !s.bootstrap_baseline_active - && s.field_model - .as_ref() - .is_some_and(|model| matches!(model.status(), CalibrationStatus::Fresh)) - && s.person_count() == 1; + let observed_at_unix_ms = chrono::Utc::now().timestamp_millis().max(0) as u64; + let explicit_single_occupant = s.explicit_calibration_fresh_at(observed_at_unix_ms) + && s.person_count_at(observed_at_unix_ms) == 1; if !explicit_single_occupant { return Json(serde_json::json!({ "status": "abstained", @@ -7654,7 +7782,10 @@ async fn udp_receiver_task( // #803: don't let the saturating activity score // discard count-aware per-node estimates. let count = - aggregate_person_count(s.person_count(), &s.node_states); + aggregate_person_count( + s.person_count_at(observed_at_unix_ms), + &s.node_states, + ); s.prev_person_count = count; count.max(1) // presence=true => at least 1 } @@ -7791,10 +7922,8 @@ async fn udp_receiver_task( heartbeat_confidence: if vitals.presence { 0.7 } else { 0.0 }, signal_quality: vitals.presence_score as f64, }; - let explicit_calibration_fresh = !s.bootstrap_baseline_active - && s.field_model.as_ref().is_some_and(|model| { - matches!(model.status(), CalibrationStatus::Fresh) - }); + let explicit_calibration_fresh = + s.explicit_calibration_fresh_at(observed_at_unix_ms); let published_vitals = vitals_for_publication( &vital_candidates, explicit_calibration_fresh, @@ -8201,7 +8330,10 @@ async fn udp_receiver_task( // #803: don't let the saturating activity score // discard count-aware per-node estimates. let count = - aggregate_person_count(s.person_count(), &s.node_states); + aggregate_person_count( + s.person_count_at(observed_at_unix_ms), + &s.node_states, + ); s.prev_person_count = count; count.max(1) } @@ -8314,10 +8446,8 @@ async fn udp_receiver_task( } else { debounce_room_classification(&mut s, &room_inference) }; - let explicit_calibration_fresh = !s.bootstrap_baseline_active - && s.field_model.as_ref().is_some_and(|model| { - matches!(model.status(), CalibrationStatus::Fresh) - }); + let explicit_calibration_fresh = + s.explicit_calibration_fresh_at(observed_at_unix_ms); let published_vitals = vitals_for_publication( &vitals, explicit_calibration_fresh, diff --git a/v2/crates/wifi-densepose-sensing-server/src/types.rs b/v2/crates/wifi-densepose-sensing-server/src/types.rs index da263049..e86ceaba 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/types.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/types.rs @@ -544,6 +544,10 @@ impl AppStateInner { pub fn person_count(&self) -> usize { use crate::csi::score_to_person_count; use crate::field_bridge; + let observed_at_us = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_micros() as u64) + .unwrap_or(0); match self.field_model.as_ref() { Some(fm) => { let history = if !self.frame_history.is_empty() { @@ -559,6 +563,7 @@ impl AppStateInner { field_bridge::occupancy_or_fallback( fm, history, + observed_at_us, self.smoothed_person_score, self.prev_person_count, ) From 2deb6d2d64ee2bad5236f260d55329524f0b3429 Mon Sep 17 00:00:00 2001 From: ruv Date: Sun, 6 Sep 2026 10:11:14 -0400 Subject: [PATCH 10/27] fix(calibration): require unique raw CSI samples --- ...57-raw-csi-calibration-sample-integrity.md | 89 +++++ .../src/field_bridge.rs | 56 +-- .../wifi-densepose-sensing-server/src/main.rs | 320 +++++++++++++++--- 3 files changed, 388 insertions(+), 77 deletions(-) create mode 100644 docs/adr/ADR-357-raw-csi-calibration-sample-integrity.md diff --git a/docs/adr/ADR-357-raw-csi-calibration-sample-integrity.md b/docs/adr/ADR-357-raw-csi-calibration-sample-integrity.md new file mode 100644 index 00000000..e4eef61f --- /dev/null +++ b/docs/adr/ADR-357-raw-csi-calibration-sample-integrity.md @@ -0,0 +1,89 @@ +# ADR 357: Raw CSI calibration sample integrity + +Status: Accepted + +Date: 2026-09-06 + +## Context + +The empty room field model requires 1,000 accepted observations over at least +600 seconds. The sensing server previously invoked calibration from both the +raw CSI path and the one second edge vital path. Edge vital packets did not add +a CSI observation, so that path repeatedly fed the existing history tail. A +counter could therefore satisfy the frame gate without representing 1,000 +independent CSI packets. + +Node liveness also combined edge vital and CSI traffic. A source emitting only +edge vital summaries could appear live enough to start calibration even though +it had no fresh raw CSI. Finally, cloning the complete per node history before +each feed copied as much as 204,800 bytes for a 100 frame, 256 subcarrier +window even though calibration consumes only the current observation. + +The earlier single installation result recorded in ADR 355 predates this +admission boundary. Its frame count is not accepted as evidence of independent +CSI coverage. It remains useful only as diagnosis of the failure mode and must +not be promoted as a production bootstrap prior. + +## Decision + +1. Only a grid admitted raw CSI packet may advance field calibration. Edge + vital packets update their own health and presentation state but never feed + the field model. +2. Each calibration session stores the last admitted wrapping sequence for + every contributing node. Duplicate sequences are ignored. Normal forward + progress and the `u32::MAX` to zero wrap are accepted. +3. A backward or ambiguous half range sequence latches that node as faulted for + the remainder of the session. Status exposes `sequence_fault_node_ids`, and + finalization returns `calibration_sequence_discontinuity`. The operator must + cancel and restart the empty room capture. +4. A requested source may start calibration only when it has a grid admitted + raw CSI packet newer than five seconds. General node liveness is not enough. + The node endpoint reports both `csi_status` and `csi_last_seen_ms` so clients + can explain the gate before the operator begins. +5. Start, cancel, reset, and process initialization clear sequence admission + state. A failed feed does not advance the sequence cursor, so the same + packet may be retried after a transient model input failure. +6. The field bridge receives the current amplitude slice rather than a cloned + history. Canonical grid normalization remains unchanged. + +## Consequences + +Calibration frame count now means unique, forward raw CSI observations within +one uninterrupted sequence epoch. A vitals only node cannot produce a +bootstrap model. + +One legitimately reordered UDP packet invalidates a ten minute capture. This +is an intentional fail closed tradeoff because the current wire format has no +cryptographic boot epoch and cannot distinguish reordering from a device +restart. A future firmware protocol may add a signed boot identifier and a +bounded reordering window. + +No new raw CSI is persisted. Sequence cursors and fault markers remain bounded +in memory to active calibration sources. The persisted bootstrap format and +its negative only authority are unchanged. + +## Implementation + +1. `NodeState` records a separate accepted CSI arrival clock. +2. `AppStateInner::maybe_feed_calibration_frame` owns source binding, sequence + ordering, fault latching, model feed, and cursor commit. +3. `field_bridge::maybe_feed_calibration` accepts exactly one current amplitude + slice and rejects an empty observation. +4. Calibration start, status, stop, cancel, reset, and node status expose and + enforce the new admission contract. + +## Acceptance test + +Unit tests must prove first packet acceptance, forward progress, duplicate +rejection, source isolation, maximum counter wrap, empty input retry, backward +sequence latching, half range rejection, session clear, status visibility, and +typed stop refusal. + +On physical hardware, begin a Node 5 empty room capture and observe a fresh +sixty second interval. Calibration frame growth must match unique grid admitted +Node 5 CSI sequences. Edge vital packets alone must produce zero growth. The +source must retain `csi_status=active`, sequence faults must remain empty, and +numeric vital output must remain absent. Complete both the 1,000 frame and 600 +second gates, then run the twelve sample server controlled bootstrap holdout. +Do not claim human detection improvement until a separate consented occupied +holdout demonstrates retained recall. 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 21c4ee46..4ba83b0e 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/field_bridge.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/field_bridge.rs @@ -283,31 +283,36 @@ pub fn calibrated_occupancy( }) } -/// Feed the latest frame to the FieldModel during calibration collection. +/// Feed the current CSI frame to the FieldModel during calibration. /// /// Acts while the model is `Uncalibrated` or `Collecting`. The first fed frame /// flips a freshly-started (`Uncalibrated`) model to `Collecting` inside /// `feed_calibration`; without accepting the `Uncalibrated` state here the two /// gates deadlock and the frame count never leaves 0 (calibration/start yields -/// an `Uncalibrated` model that nothing would ever advance). Wraps the latest -/// frame as a single-link observation (n_links=1) and feeds it. -pub fn maybe_feed_calibration(field: &mut FieldModel, frame_history: &VecDeque>) { +/// an `Uncalibrated` model that nothing would ever advance). Edge-vitals +/// packets have no CSI observation and must never call this helper. The caller +/// owns the stateful sequence admission gate. Wraps the current frame as a +/// single-link observation (n_links=1) and feeds it. +pub fn maybe_feed_calibration(field: &mut FieldModel, amplitudes: &[f64]) -> bool { if !matches!( field.status(), CalibrationStatus::Uncalibrated | CalibrationStatus::Collecting - ) { - return; + ) || amplitudes.is_empty() + { + return false; } - if let Some(latest) = frame_history.back() { - // Resample the raw amplitude vector onto the FieldModel's canonical - // 56-tone grid before feeding. Real HT40 nodes stream 128-wide frames; - // feeding those raw made every `feed_calibration` fail DimensionMismatch - // (swallowed at debug level), pinning frame_count at 0 even after the - // status-gate deadlock was fixed. Single-link observation: [1][56]. - let canonical = CALIB_NORMALIZER.resample_to_canonical(latest); - let observations = vec![canonical]; - if let Err(e) = field.feed_calibration(&observations) { + // Resample the raw amplitude vector onto the FieldModel's canonical + // 56-tone grid before feeding. Real HT40 nodes stream 128-wide frames; + // feeding those raw made every `feed_calibration` fail DimensionMismatch + // (swallowed at debug level), pinning frame_count at 0 even after the + // status-gate deadlock was fixed. Single-link observation: [1][56]. + let canonical = CALIB_NORMALIZER.resample_to_canonical(amplitudes); + let observations = vec![canonical]; + match field.feed_calibration(&observations) { + Ok(()) => true, + Err(e) => { tracing::debug!("FieldModel calibration feed: {e}"); + false } } } @@ -418,10 +423,7 @@ mod tests { // n_subcarriers defaults to 56; one single-link frame of that width. let frame = vec![0.5_f64; 56]; - let mut history: VecDeque> = VecDeque::new(); - history.push_back(frame); - - maybe_feed_calibration(&mut field, &history); + assert!(maybe_feed_calibration(&mut field, &frame)); assert_eq!( field.status(), @@ -434,8 +436,8 @@ mod tests { "frame count must advance past 0" ); - // Subsequent frames keep accumulating while Collecting. - maybe_feed_calibration(&mut field, &history); + // A subsequent unique frame keeps accumulating while Collecting. + assert!(maybe_feed_calibration(&mut field, &frame)); assert_eq!(field.calibration_frame_count(), 2); } @@ -450,10 +452,7 @@ mod tests { // 128-wide frame (HT40), NOT the model's 56 — would DimensionMismatch raw. let wide = vec![0.5_f64; 128]; - let mut history: VecDeque> = VecDeque::new(); - history.push_back(wide); - - maybe_feed_calibration(&mut field, &history); + assert!(maybe_feed_calibration(&mut field, &wide)); assert_eq!( field.status(), @@ -467,6 +466,13 @@ mod tests { ); } + #[test] + fn maybe_feed_calibration_rejects_an_empty_observation() { + let mut field = FieldModel::new(single_link_config()).expect("field model"); + assert!(!maybe_feed_calibration(&mut field, &[])); + assert_eq!(field.calibration_frame_count(), 0); + } + #[test] fn calibrated_occupancy_is_fresh_model_only_and_normalizes_runtime_frames() { 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 c2f8790d..8b2bc921 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/main.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/main.rs @@ -849,6 +849,9 @@ struct NodeState { latest_sync: Option, /// Last time a sync packet from this node was received (for staleness). latest_sync_at: Option, + /// Arrival time of the newest grid-admitted raw CSI frame. Edge-vitals + /// packets intentionally do not refresh this clock. + latest_accepted_csi_at: Option, /// Sequence number of the newest CSI frame admitted to `frame_history`. /// Kept alongside the history so multistatic fusion can timestamp the /// exact sample it consumes, rather than the host's UDP arrival time. @@ -1161,6 +1164,7 @@ impl NodeState { sync_valid: bool, now: std::time::Instant, ) -> bool { + self.latest_accepted_csi_at = Some(now); self.latest_csi_sequence = Some(sequence); self.latest_csi_sync_valid = sync_valid; self.observe_csi_frame_arrival(now) @@ -1190,6 +1194,7 @@ impl NodeState { edge_vitals: None, latest_sync: None, latest_sync_at: None, + latest_accepted_csi_at: None, latest_csi_sequence: None, latest_csi_sync_valid: false, csi_fps_ema: 20.0, @@ -1627,6 +1632,12 @@ struct AppStateInner { calibration_model_id: Option, /// Nodes that actually contributed frames to the current calibration. calibration_source_node_ids: std::collections::BTreeSet, + /// Last admitted raw CSI sequence per contributing node. Edge-vitals + /// packets do not carry a new CSI observation and cannot advance this map. + calibration_last_sequences: HashMap, + /// Nodes whose sequence moved backward during the current session. Without + /// a wire boot epoch, the session must stay failed closed after regression. + calibration_sequence_fault_node_ids: std::collections::BTreeSet, // ── ADR-044 §5.2: adaptive rolling-p95 normalization ───────────────────── /// Rolling P95 of `FeatureInfo.variance` over the last ~30 s (600 frames @ 20 Hz). pub(crate) p95_variance: RollingP95, @@ -1729,7 +1740,74 @@ mod adr323_pose_physics_http_tests { /// If no ESP32 frame arrives within this duration, source reverts to offline. const ESP32_OFFLINE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum CalibrationSequenceOrder { + First, + Forward, + Duplicate, + Regression, +} + +fn calibration_sequence_order(previous: Option, sequence: u32) -> CalibrationSequenceOrder { + let Some(previous) = previous else { + return CalibrationSequenceOrder::First; + }; + let delta = sequence.wrapping_sub(previous); + if delta == 0 { + CalibrationSequenceOrder::Duplicate + } else if delta < (1_u32 << 31) { + CalibrationSequenceOrder::Forward + } else { + CalibrationSequenceOrder::Regression + } +} + impl AppStateInner { + /// Admit at most one raw CSI observation per forward node sequence into + /// the current calibration session. This stateful boundary prevents a + /// caller from advancing the model with a replayed history tail. + fn maybe_feed_calibration_frame( + &mut self, + node_id: u8, + sequence: u32, + amplitudes: &[f64], + ) -> bool { + if !calibration_source_accepts(&self.calibration_source_node_ids, node_id) + || self.calibration_sequence_fault_node_ids.contains(&node_id) + { + return false; + } + let previous_sequence = self.calibration_last_sequences.get(&node_id).copied(); + match calibration_sequence_order(previous_sequence, sequence) { + CalibrationSequenceOrder::Duplicate => return false, + CalibrationSequenceOrder::Regression => { + self.calibration_sequence_fault_node_ids.insert(node_id); + warn!( + node_id, + previous_sequence, + sequence, + "Calibration source sequence regressed; restart the empty-room capture" + ); + return false; + } + CalibrationSequenceOrder::First | CalibrationSequenceOrder::Forward => {} + } + let accepted = self + .field_model + .as_mut() + .is_some_and(|field| field_bridge::maybe_feed_calibration(field, amplitudes)); + if accepted { + self.calibration_source_node_ids.insert(node_id); + self.calibration_last_sequences.insert(node_id, sequence); + } + accepted + } + + fn clear_calibration_sequence_state(&mut self) { + self.calibration_last_sequences.clear(); + self.calibration_sequence_fault_node_ids.clear(); + } + fn field_model_status_at(&self, observed_at_unix_ms: u64) -> Option { self.field_model.as_ref().map(|model| { field_bridge::calibration_status_at(model, observed_at_unix_ms.saturating_mul(1_000)) @@ -1972,6 +2050,8 @@ impl AppStateInner { bootstrap_baseline_active: false, calibration_model_id: None, calibration_source_node_ids: std::collections::BTreeSet::new(), + calibration_last_sequences: HashMap::new(), + calibration_sequence_fault_node_ids: std::collections::BTreeSet::new(), p95_variance: RollingP95::new(600, 60), p95_motion_band_power: RollingP95::new(600, 60), p95_spectral_power: RollingP95::new(600, 60), @@ -6288,6 +6368,11 @@ fn calibration_source_accepts( source_node_ids.is_empty() || source_node_ids.contains(&node_id) } +fn calibration_source_has_fresh_csi(node: &NodeState, now: std::time::Instant) -> bool { + node.latest_accepted_csi_at + .is_some_and(|seen| now.saturating_duration_since(seen) < ESP32_OFFLINE_TIMEOUT) +} + #[cfg(test)] mod bootstrap_vital_publication_tests { use super::*; @@ -6309,6 +6394,148 @@ mod bootstrap_vital_publication_tests { assert!(!calibration_source_accepts(&sources, 7)); } + #[test] + fn edge_vitals_liveness_cannot_authorize_raw_csi_calibration() { + let now = std::time::Instant::now(); + let mut node = NodeState::new(); + node.last_frame_time = Some(now); + assert!(!calibration_source_has_fresh_csi(&node, now)); + + node.observe_accepted_csi_frame(9, false, now); + assert!(calibration_source_has_fresh_csi(&node, now)); + assert!(!calibration_source_has_fresh_csi( + &node, + now + ESP32_OFFLINE_TIMEOUT + )); + } + + #[test] + fn calibration_session_counts_each_unique_bound_csi_sequence_once() { + let mut state = AppStateInner::minimal(); + state.field_model = Some( + FieldModel::new(field_bridge::single_link_config()).expect("field model"), + ); + state.calibration_source_node_ids.insert(5); + + let first = vec![0.25; 56]; + assert!(state.maybe_feed_calibration_frame(5, 41, &first)); + assert!(!state.maybe_feed_calibration_frame(5, 41, &first)); + + let second = vec![0.5; 56]; + assert!(state.maybe_feed_calibration_frame(5, 42, &second)); + assert!(!state.maybe_feed_calibration_frame(3, 43, &second)); + + let model = state.field_model.as_ref().expect("field model remains active"); + assert_eq!(model.calibration_frame_count(), 2); + assert_eq!(state.calibration_last_sequences.get(&5), Some(&42)); + assert!(!state.calibration_last_sequences.contains_key(&3)); + } + + #[test] + fn calibration_sequence_regression_latches_session_failure() { + let mut state = AppStateInner::minimal(); + state.field_model = Some( + FieldModel::new(field_bridge::single_link_config()).expect("field model"), + ); + state.calibration_source_node_ids.insert(5); + let frame = vec![0.25; 56]; + + assert!(state.maybe_feed_calibration_frame(5, 10, &frame)); + assert!(state.maybe_feed_calibration_frame(5, 11, &frame)); + assert!(!state.maybe_feed_calibration_frame(5, 10, &frame)); + assert!(!state.maybe_feed_calibration_frame(5, 12, &frame)); + assert!(state.calibration_sequence_fault_node_ids.contains(&5)); + assert_eq!( + state + .field_model + .as_ref() + .expect("field model") + .calibration_frame_count(), + 2 + ); + + state.clear_calibration_sequence_state(); + assert!(state.calibration_sequence_fault_node_ids.is_empty()); + assert!(state.calibration_last_sequences.is_empty()); + } + + #[test] + fn calibration_sequence_wraps_and_failed_empty_input_can_retry() { + let mut state = AppStateInner::minimal(); + state.field_model = Some( + FieldModel::new(field_bridge::single_link_config()).expect("field model"), + ); + state.calibration_source_node_ids.insert(5); + let frame = vec![0.25; 56]; + + assert!(!state.maybe_feed_calibration_frame(5, u32::MAX, &[])); + assert!(state.calibration_last_sequences.is_empty()); + assert!(state.maybe_feed_calibration_frame(5, u32::MAX, &frame)); + assert!(state.maybe_feed_calibration_frame(5, 0, &frame)); + assert!(!state.maybe_feed_calibration_frame(5, 0, &frame)); + assert!(state.maybe_feed_calibration_frame(5, 1, &frame)); + assert_eq!( + state + .field_model + .as_ref() + .expect("field model") + .calibration_frame_count(), + 3 + ); + } + + #[test] + fn calibration_sequence_order_rejects_backward_and_half_range() { + assert_eq!( + calibration_sequence_order(None, 7), + CalibrationSequenceOrder::First + ); + assert_eq!( + calibration_sequence_order(Some(7), 8), + CalibrationSequenceOrder::Forward + ); + assert_eq!( + calibration_sequence_order(Some(8), 8), + CalibrationSequenceOrder::Duplicate + ); + assert_eq!( + calibration_sequence_order(Some(8), 7), + CalibrationSequenceOrder::Regression + ); + assert_eq!( + calibration_sequence_order(Some(0), 1_u32 << 31), + CalibrationSequenceOrder::Regression + ); + assert_eq!( + calibration_sequence_order(Some(u32::MAX), 0), + CalibrationSequenceOrder::Forward + ); + } + + #[tokio::test] + async fn calibration_status_and_stop_fail_closed_after_sequence_regression() { + let mut inner = AppStateInner::minimal(); + inner.field_model = Some( + FieldModel::new(field_bridge::single_link_config()).expect("field model"), + ); + inner.calibration_model_id = Some("cal-model-test".to_string()); + inner.calibration_source_node_ids.insert(5); + inner.calibration_sequence_fault_node_ids.insert(5); + let state = Arc::new(RwLock::new(inner)); + + let Json(status) = calibration_status(State(state.clone())).await; + assert_eq!(status["sequence_fault_node_ids"], serde_json::json!([5])); + + let Json(stopped) = calibration_stop(State(state)).await; + assert_eq!(stopped["success"], false); + assert_eq!( + stopped["error_code"], + "calibration_sequence_discontinuity" + ); + assert_eq!(stopped["model_id"], "cal-model-test"); + assert_eq!(stopped["source_node_ids"], serde_json::json!([5])); + } + fn strong_candidates() -> VitalSigns { VitalSigns { breathing_rate_bpm: Some(15.0), @@ -6420,15 +6647,15 @@ async fn calibration_start( 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) - }); + let source_is_live = s + .node_states + .get(&source_node_id) + .is_some_and(|node| calibration_source_has_fresh_csi(node, now)); 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.", + "error": "The requested calibration source is missing or has no fresh raw CSI.", "source_node_id": source_node_id, })); } @@ -6463,6 +6690,7 @@ async fn calibration_start( s.field_model = Some(fm); s.calibration_model_id = Some(opaque_calibration_model_id()); s.calibration_source_node_ids.clear(); + s.clear_calibration_sequence_state(); if let Some(source_node_id) = query.source_node_id { s.calibration_source_node_ids.insert(source_node_id); } @@ -6482,6 +6710,16 @@ async fn calibration_stop(State(state): State) -> Json = s.calibration_source_node_ids.iter().copied().collect(); + if !s.calibration_sequence_fault_node_ids.is_empty() { + return Json(serde_json::json!({ + "success": false, + "error_code": "calibration_sequence_discontinuity", + "error": "Raw CSI sequence moved backward during capture. Cancel and restart the empty-room calibration.", + "model_id": model_id, + "source_node_ids": source_node_ids, + "sequence_fault_node_ids": s.calibration_sequence_fault_node_ids, + })); + } if let Some(ref mut fm) = s.field_model { // Guard: finalizing before enough empty-room frames have accumulated // is a client-side sequencing error, not a server fault. Return a @@ -6585,6 +6823,7 @@ async fn calibration_status(State(state): State) -> Json) -> Json) -> Json) -> Json 5000; let status = if stale { "stale" } else { "active" }; + let csi_status = if csi_elapsed_ms.is_some_and(|age| { + age < ESP32_OFFLINE_TIMEOUT.as_millis() as u64 + }) { + "active" + } else { + "stale" + }; let rssi = ns.rssi_history.back().copied().unwrap_or(-90.0); let bootstrap_empty = s.bootstrap_baseline_active && s.bootstrap_baseline.as_ref().is_some_and(|metadata| { @@ -7458,6 +7709,9 @@ async fn nodes_endpoint(State(state): State) -> Json before - } else { - false - } - } else { - false - }; - if accepted { - s.calibration_source_node_ids.insert(node_id); - } - } - // Build nodes array with all active nodes. let resolved_positions = node_positions_by_active_id(&s.node_positions_config, &s.node_states, now); @@ -8362,29 +8592,13 @@ async fn udp_receiver_task( sref.engine_bridge.observe_cycle(&sref.node_states, now_ms); } - // Feed field model calibration if active (use per-node history for ESP32). - if let Some(frame_history) = s - .node_states - .get(&node_id) - .map(|ns| ns.frame_history.clone()) - { - let source_allowed = - calibration_source_accepts(&s.calibration_source_node_ids, node_id); - let accepted = if source_allowed { - if let Some(ref mut fm) = s.field_model { - let before = fm.calibration_frame_count(); - field_bridge::maybe_feed_calibration(fm, &frame_history); - fm.calibration_frame_count() > before - } else { - false - } - } else { - false - }; - if accepted { - s.calibration_source_node_ids.insert(node_id); - } - } + // Feed only this grid-admitted raw CSI observation. The + // session boundary deduplicates its wrapping sequence. + s.maybe_feed_calibration_frame( + node_id, + frame.sequence, + &frame.amplitudes, + ); // Build nodes array with all active nodes. ADR-141 output // gating (review finding 1c): when the governed engine @@ -10157,6 +10371,8 @@ async fn main() { bootstrap_baseline_active, calibration_model_id: args.calibrate.then(opaque_calibration_model_id), calibration_source_node_ids: std::collections::BTreeSet::new(), + calibration_last_sequences: HashMap::new(), + calibration_sequence_fault_node_ids: std::collections::BTreeSet::new(), // ADR-044 §5.2: rolling-P95 over ~30 s at 20 Hz; warm-up after 60 samples. p95_variance: RollingP95::new(600, 60), p95_motion_band_power: RollingP95::new(600, 60), From 224c9eb6710426e182e630be8eb55a39535e749a Mon Sep 17 00:00:00 2001 From: ruv Date: Sun, 6 Sep 2026 10:15:27 -0400 Subject: [PATCH 11/27] feat(calibration): expose CSI admission receipts --- docs/adr/ADR-357-raw-csi-calibration-sample-integrity.md | 4 +++- v2/crates/wifi-densepose-sensing-server/src/main.rs | 3 +++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/adr/ADR-357-raw-csi-calibration-sample-integrity.md b/docs/adr/ADR-357-raw-csi-calibration-sample-integrity.md index e4eef61f..74e205db 100644 --- a/docs/adr/ADR-357-raw-csi-calibration-sample-integrity.md +++ b/docs/adr/ADR-357-raw-csi-calibration-sample-integrity.md @@ -39,7 +39,9 @@ not be promoted as a production bootstrap prior. 4. A requested source may start calibration only when it has a grid admitted raw CSI packet newer than five seconds. General node liveness is not enough. The node endpoint reports both `csi_status` and `csi_last_seen_ms` so clients - can explain the gate before the operator begins. + can explain the gate before the operator begins. Calibration status reports + `last_sequence_by_node` so admission growth and sequence identity can be + observed atomically without exposing CSI values. 5. Start, cancel, reset, and process initialization clear sequence admission state. A failed feed does not advance the sequence cursor, so the same packet may be retried after a transient model input failure. diff --git a/v2/crates/wifi-densepose-sensing-server/src/main.rs b/v2/crates/wifi-densepose-sensing-server/src/main.rs index 8b2bc921..3947c218 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/main.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/main.rs @@ -6520,10 +6520,12 @@ mod bootstrap_vital_publication_tests { ); inner.calibration_model_id = Some("cal-model-test".to_string()); inner.calibration_source_node_ids.insert(5); + inner.calibration_last_sequences.insert(5, 41); inner.calibration_sequence_fault_node_ids.insert(5); let state = Arc::new(RwLock::new(inner)); let Json(status) = calibration_status(State(state.clone())).await; + assert_eq!(status["last_sequence_by_node"]["5"], 41); assert_eq!(status["sequence_fault_node_ids"], serde_json::json!([5])); let Json(stopped) = calibration_stop(State(state)).await; @@ -6823,6 +6825,7 @@ async fn calibration_status(State(state): State) -> Json Date: Sun, 6 Sep 2026 10:38:55 -0400 Subject: [PATCH 12/27] fix(calibration): tolerate measured UDP reordering --- ...57-raw-csi-calibration-sample-integrity.md | 44 +++--- .../wifi-densepose-sensing-server/src/main.rs | 126 +++++++++++++++--- 2 files changed, 138 insertions(+), 32 deletions(-) diff --git a/docs/adr/ADR-357-raw-csi-calibration-sample-integrity.md b/docs/adr/ADR-357-raw-csi-calibration-sample-integrity.md index 74e205db..2bf39cf0 100644 --- a/docs/adr/ADR-357-raw-csi-calibration-sample-integrity.md +++ b/docs/adr/ADR-357-raw-csi-calibration-sample-integrity.md @@ -32,9 +32,13 @@ not be promoted as a production bootstrap prior. 2. Each calibration session stores the last admitted wrapping sequence for every contributing node. Duplicate sequences are ignored. Normal forward progress and the `u32::MAX` to zero wrap are accepted. -3. A backward or ambiguous half range sequence latches that node as faulted for - the remainder of the session. Status exposes `sequence_fault_node_ids`, and - finalization returns `calibration_sequence_discontinuity`. The operator must +3. A packet one through four sequence values behind the current high water mark + is bounded UDP reordering. It is recorded and dropped without feeding the + model or moving the high water mark. A backward depth above four, or an + ambiguous half range sequence, latches that node as faulted for the remainder + of the session. Status exposes the policy, reorder window, per node reorder + count, maximum observed depth, and `sequence_fault_node_ids`. Finalization + returns `calibration_sequence_discontinuity` after a fault. The operator must cancel and restart the empty room capture. 4. A requested source may start calibration only when it has a grid admitted raw CSI packet newer than five seconds. General node liveness is not enough. @@ -52,13 +56,19 @@ not be promoted as a production bootstrap prior. Calibration frame count now means unique, forward raw CSI observations within one uninterrupted sequence epoch. A vitals only node cannot produce a -bootstrap model. +bootstrap model. Late packets never become calibration evidence. -One legitimately reordered UDP packet invalidates a ten minute capture. This -is an intentional fail closed tradeoff because the current wire format has no -cryptographic boot epoch and cannot distinguish reordering from a device -restart. A future firmware protocol may add a signed boot identifier and a -bounded reordering window. +A physical Node 5 capture observed sequence 4,119,566 followed by 4,119,563, +4,119,564, and 4,119,565 in one receive batch, then normal forward progress. +The window of four covers that measured depth of three with one frame of +margin. At the firmware acceptance ceiling of 50 Hz, the window represents no +more than 80 milliseconds. Larger regressions remain failed closed. + +The current wire format still has no authenticated boot epoch or stable device +identity. A restart very early in a session or a same endpoint adversary cannot +be distinguished cryptographically. A future ADR 018 revision must carry an +authenticated device identifier and a random per boot identifier before the +server claims exact restart or identity collision detection. No new raw CSI is persisted. Sequence cursors and fault markers remain bounded in memory to active calibration sources. The persisted bootstrap format and @@ -77,15 +87,17 @@ its negative only authority are unchanged. ## Acceptance test Unit tests must prove first packet acceptance, forward progress, duplicate -rejection, source isolation, maximum counter wrap, empty input retry, backward -sequence latching, half range rejection, session clear, status visibility, and -typed stop refusal. +rejection, source isolation, maximum counter wrap, empty input retry, the +measured depth three reorder pattern without a model feed, depth four +acceptance, depth five latching, half range rejection, session clear, receipt +visibility, and typed stop refusal. On physical hardware, begin a Node 5 empty room capture and observe a fresh sixty second interval. Calibration frame growth must match unique grid admitted Node 5 CSI sequences. Edge vital packets alone must produce zero growth. The source must retain `csi_status=active`, sequence faults must remain empty, and -numeric vital output must remain absent. Complete both the 1,000 frame and 600 -second gates, then run the twelve sample server controlled bootstrap holdout. -Do not claim human detection improvement until a separate consented occupied -holdout demonstrates retained recall. +numeric vital output must remain absent. Any bounded late packet must appear in +the reorder receipts while leaving the model count unchanged. Complete both the +1,000 frame and 600 second gates, then run the twelve sample server controlled +bootstrap holdout. Do not claim human detection improvement until a separate +consented occupied holdout demonstrates retained recall. diff --git a/v2/crates/wifi-densepose-sensing-server/src/main.rs b/v2/crates/wifi-densepose-sensing-server/src/main.rs index 3947c218..68f6ada7 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/main.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/main.rs @@ -1635,8 +1635,13 @@ struct AppStateInner { /// Last admitted raw CSI sequence per contributing node. Edge-vitals /// packets do not carry a new CSI observation and cannot advance this map. calibration_last_sequences: HashMap, - /// Nodes whose sequence moved backward during the current session. Without - /// a wire boot epoch, the session must stay failed closed after regression. + /// Late packets inside the bounded UDP reorder window are ignored rather + /// than counted. These receipts make the transport behavior auditable. + calibration_reordered_packets: HashMap, + calibration_max_reorder_depth: HashMap, + /// Nodes whose sequence moved backward beyond the bounded UDP reorder + /// window during the current session. Without a wire boot epoch, the + /// session must stay failed closed after a material discontinuity. calibration_sequence_fault_node_ids: std::collections::BTreeSet, // ── ADR-044 §5.2: adaptive rolling-p95 normalization ───────────────────── /// Rolling P95 of `FeatureInfo.variance` over the last ~30 s (600 frames @ 20 Hz). @@ -1745,9 +1750,16 @@ enum CalibrationSequenceOrder { First, Forward, Duplicate, - Regression, + Reordered(u32), + Discontinuity, } +/// Live Node 5 evidence observed a same-source receive batch ordered as high +/// water mark, then minus three, minus two, minus one. Four covers that +/// measured UDP reorder with one frame of margin. At the firmware's 50 Hz +/// ceiling it spans at most 80 ms; larger backward jumps stay failed closed. +const CALIBRATION_SEQUENCE_REORDER_WINDOW: u32 = 4; + fn calibration_sequence_order(previous: Option, sequence: u32) -> CalibrationSequenceOrder { let Some(previous) = previous else { return CalibrationSequenceOrder::First; @@ -1758,7 +1770,12 @@ fn calibration_sequence_order(previous: Option, sequence: u32) -> Calibrati } else if delta < (1_u32 << 31) { CalibrationSequenceOrder::Forward } else { - CalibrationSequenceOrder::Regression + let reorder_depth = previous.wrapping_sub(sequence); + if reorder_depth <= CALIBRATION_SEQUENCE_REORDER_WINDOW { + CalibrationSequenceOrder::Reordered(reorder_depth) + } else { + CalibrationSequenceOrder::Discontinuity + } } } @@ -1780,13 +1797,31 @@ impl AppStateInner { let previous_sequence = self.calibration_last_sequences.get(&node_id).copied(); match calibration_sequence_order(previous_sequence, sequence) { CalibrationSequenceOrder::Duplicate => return false, - CalibrationSequenceOrder::Regression => { + CalibrationSequenceOrder::Reordered(depth) => { + *self + .calibration_reordered_packets + .entry(node_id) + .or_default() += 1; + self.calibration_max_reorder_depth + .entry(node_id) + .and_modify(|observed| *observed = (*observed).max(depth)) + .or_insert(depth); + debug!( + node_id, + previous_sequence, + sequence, + depth, + "Ignored bounded late CSI packet during calibration" + ); + return false; + } + CalibrationSequenceOrder::Discontinuity => { self.calibration_sequence_fault_node_ids.insert(node_id); warn!( node_id, previous_sequence, sequence, - "Calibration source sequence regressed; restart the empty-room capture" + "Calibration source sequence discontinuity exceeded reorder window; restart the empty-room capture" ); return false; } @@ -1805,6 +1840,8 @@ impl AppStateInner { fn clear_calibration_sequence_state(&mut self) { self.calibration_last_sequences.clear(); + self.calibration_reordered_packets.clear(); + self.calibration_max_reorder_depth.clear(); self.calibration_sequence_fault_node_ids.clear(); } @@ -2051,6 +2088,8 @@ impl AppStateInner { calibration_model_id: None, calibration_source_node_ids: std::collections::BTreeSet::new(), calibration_last_sequences: HashMap::new(), + calibration_reordered_packets: HashMap::new(), + calibration_max_reorder_depth: HashMap::new(), calibration_sequence_fault_node_ids: std::collections::BTreeSet::new(), p95_variance: RollingP95::new(600, 60), p95_motion_band_power: RollingP95::new(600, 60), @@ -6432,7 +6471,7 @@ mod bootstrap_vital_publication_tests { } #[test] - fn calibration_sequence_regression_latches_session_failure() { + fn calibration_bounded_reorder_is_dropped_without_feeding_model() { let mut state = AppStateInner::minimal(); state.field_model = Some( FieldModel::new(field_bridge::single_link_config()).expect("field model"), @@ -6440,11 +6479,14 @@ mod bootstrap_vital_publication_tests { state.calibration_source_node_ids.insert(5); let frame = vec![0.25; 56]; - assert!(state.maybe_feed_calibration_frame(5, 10, &frame)); - assert!(state.maybe_feed_calibration_frame(5, 11, &frame)); - assert!(!state.maybe_feed_calibration_frame(5, 10, &frame)); - assert!(!state.maybe_feed_calibration_frame(5, 12, &frame)); - assert!(state.calibration_sequence_fault_node_ids.contains(&5)); + assert!(state.maybe_feed_calibration_frame(5, 4_119_566, &frame)); + assert!(!state.maybe_feed_calibration_frame(5, 4_119_563, &frame)); + assert!(!state.maybe_feed_calibration_frame(5, 4_119_564, &frame)); + assert!(!state.maybe_feed_calibration_frame(5, 4_119_565, &frame)); + assert!(state.maybe_feed_calibration_frame(5, 4_119_567, &frame)); + assert!(state.calibration_sequence_fault_node_ids.is_empty()); + assert_eq!(state.calibration_reordered_packets.get(&5), Some(&3)); + assert_eq!(state.calibration_max_reorder_depth.get(&5), Some(&3)); assert_eq!( state .field_model @@ -6453,10 +6495,35 @@ mod bootstrap_vital_publication_tests { .calibration_frame_count(), 2 ); + } + + #[test] + fn calibration_sequence_discontinuity_latches_session_failure() { + let mut state = AppStateInner::minimal(); + state.field_model = Some( + FieldModel::new(field_bridge::single_link_config()).expect("field model"), + ); + state.calibration_source_node_ids.insert(5); + let frame = vec![0.25; 56]; + + assert!(state.maybe_feed_calibration_frame(5, 100, &frame)); + assert!(!state.maybe_feed_calibration_frame(5, 95, &frame)); + assert!(!state.maybe_feed_calibration_frame(5, 101, &frame)); + assert!(state.calibration_sequence_fault_node_ids.contains(&5)); + assert_eq!( + state + .field_model + .as_ref() + .expect("field model") + .calibration_frame_count(), + 1 + ); state.clear_calibration_sequence_state(); assert!(state.calibration_sequence_fault_node_ids.is_empty()); assert!(state.calibration_last_sequences.is_empty()); + assert!(state.calibration_reordered_packets.is_empty()); + assert!(state.calibration_max_reorder_depth.is_empty()); } #[test] @@ -6485,7 +6552,7 @@ mod bootstrap_vital_publication_tests { } #[test] - fn calibration_sequence_order_rejects_backward_and_half_range() { + fn calibration_sequence_order_drops_bounded_late_and_rejects_discontinuity() { assert_eq!( calibration_sequence_order(None, 7), CalibrationSequenceOrder::First @@ -6500,11 +6567,19 @@ mod bootstrap_vital_publication_tests { ); assert_eq!( calibration_sequence_order(Some(8), 7), - CalibrationSequenceOrder::Regression + CalibrationSequenceOrder::Reordered(1) + ); + assert_eq!( + calibration_sequence_order(Some(8), 4), + CalibrationSequenceOrder::Reordered(4) + ); + assert_eq!( + calibration_sequence_order(Some(8), 3), + CalibrationSequenceOrder::Discontinuity ); assert_eq!( calibration_sequence_order(Some(0), 1_u32 << 31), - CalibrationSequenceOrder::Regression + CalibrationSequenceOrder::Discontinuity ); assert_eq!( calibration_sequence_order(Some(u32::MAX), 0), @@ -6521,11 +6596,16 @@ mod bootstrap_vital_publication_tests { inner.calibration_model_id = Some("cal-model-test".to_string()); inner.calibration_source_node_ids.insert(5); inner.calibration_last_sequences.insert(5, 41); + inner.calibration_reordered_packets.insert(5, 3); + inner.calibration_max_reorder_depth.insert(5, 3); inner.calibration_sequence_fault_node_ids.insert(5); let state = Arc::new(RwLock::new(inner)); let Json(status) = calibration_status(State(state.clone())).await; assert_eq!(status["last_sequence_by_node"]["5"], 41); + assert_eq!(status["reorder_window_sequences"], 4); + assert_eq!(status["reordered_packets_by_node"]["5"], 3); + assert_eq!(status["max_reorder_depth_by_node"]["5"], 3); assert_eq!(status["sequence_fault_node_ids"], serde_json::json!([5])); let Json(stopped) = calibration_stop(State(state)).await; @@ -6536,6 +6616,8 @@ mod bootstrap_vital_publication_tests { ); assert_eq!(stopped["model_id"], "cal-model-test"); assert_eq!(stopped["source_node_ids"], serde_json::json!([5])); + assert_eq!(stopped["reordered_packets_by_node"]["5"], 3); + assert_eq!(stopped["max_reorder_depth_by_node"]["5"], 3); } fn strong_candidates() -> VitalSigns { @@ -6712,13 +6794,17 @@ async fn calibration_stop(State(state): State) -> Json = s.calibration_source_node_ids.iter().copied().collect(); + let reordered_packets_by_node = s.calibration_reordered_packets.clone(); + let max_reorder_depth_by_node = s.calibration_max_reorder_depth.clone(); if !s.calibration_sequence_fault_node_ids.is_empty() { return Json(serde_json::json!({ "success": false, "error_code": "calibration_sequence_discontinuity", - "error": "Raw CSI sequence moved backward during capture. Cancel and restart the empty-room calibration.", + "error": "Raw CSI sequence moved backward beyond the bounded UDP reorder window. Cancel and restart the empty-room calibration.", "model_id": model_id, "source_node_ids": source_node_ids, + "reordered_packets_by_node": reordered_packets_by_node, + "max_reorder_depth_by_node": max_reorder_depth_by_node, "sequence_fault_node_ids": s.calibration_sequence_fault_node_ids, })); } @@ -6769,6 +6855,8 @@ async fn calibration_stop(State(state): State) -> Json) -> Json Date: Sun, 6 Sep 2026 11:18:59 -0400 Subject: [PATCH 13/27] fix(calibration): bind field models to stable CSI grids --- ...57-raw-csi-calibration-sample-integrity.md | 110 +- .../src/bootstrap_baseline.rs | 154 ++- .../wifi-densepose-sensing-server/src/main.rs | 972 ++++++++++++++++-- 3 files changed, 1080 insertions(+), 156 deletions(-) diff --git a/docs/adr/ADR-357-raw-csi-calibration-sample-integrity.md b/docs/adr/ADR-357-raw-csi-calibration-sample-integrity.md index 2bf39cf0..6cdd25a3 100644 --- a/docs/adr/ADR-357-raw-csi-calibration-sample-integrity.md +++ b/docs/adr/ADR-357-raw-csi-calibration-sample-integrity.md @@ -26,31 +26,49 @@ not be promoted as a production bootstrap prior. ## Decision -1. Only a grid admitted raw CSI packet may advance field calibration. Edge - vital packets update their own health and presentation state but never feed - the field model. +1. Only a packet on the frozen, model specific CSI grid may advance field + calibration. Edge vital packets update their own health and presentation + state but never feed the field model. The independent live inference grid + remains unchanged. 2. Each calibration session stores the last admitted wrapping sequence for every contributing node. Duplicate sequences are ignored. Normal forward progress and the `u32::MAX` to zero wrap are accepted. -3. A packet one through four sequence values behind the current high water mark +3. A packet one through sixteen sequence values behind the current high water mark is bounded UDP reordering. It is recorded and dropped without feeding the - model or moving the high water mark. A backward depth above four, or an + model or moving the high water mark. A backward depth above sixteen, or an ambiguous half range sequence, latches that node as faulted for the remainder of the session. Status exposes the policy, reorder window, per node reorder count, maximum observed depth, and `sequence_fault_node_ids`. Finalization returns `calibration_sequence_discontinuity` after a fault. The operator must cancel and restart the empty room capture. -4. A requested source may start calibration only when it has a grid admitted - raw CSI packet newer than five seconds. General node liveness is not enough. - The node endpoint reports both `csi_status` and `csi_last_seen_ms` so clients - can explain the gate before the operator begins. Calibration status reports - `last_sequence_by_node` so admission growth and sequence identity can be - observed atomically without exposing CSI values. +4. A requested source may start calibration only after a header only, bounded + observation window identifies one stable grid. The selected grid needs at + least ten seconds of evidence, a measured rate of at least 2 Hz, a latest + sample newer than five seconds, and no observed gap of five seconds or more. + Calibration status reports the immutable grid, selection evidence, current + grid age, and `last_sequence_by_node` without exposing CSI values. 5. Start, cancel, reset, and process initialization clear sequence admission state. A failed feed does not advance the sequence cursor, so the same packet may be retried after a transient model input failure. 6. The field bridge receives the current amplitude slice rather than a cloned - history. Canonical grid normalization remains unchanged. + history. A separate bounded runtime history retains only that same frozen + grid, so the model is never trained on one symbol layout and evaluated on a + different layout. Canonical grid normalization remains unchanged. +7. The exact source grid is stored inside the digest protected bootstrap v2 + payload. A restored model rebuilds only its matching runtime history. An + unbound legacy image cannot become an active startup prior. +8. Successful finalization clears the selected grid runtime history. Bootstrap + promotion waits up to 35 seconds for a complete fresh runtime window before + scoring twelve additional samples. This covers a 50 frame window at the 2 Hz + admission floor plus ten seconds of bounded jitter. Calibration observations + therefore cannot enter the held out decision window. +9. Stop and promotion require the frozen grid to remain fresh and reject any + latched sequence fault immediately before persistence. Bootstrap v2 accepts + exactly one source node because the current runtime supports one grid bound + field model. +10. The legacy `--calibrate` startup switch fails closed before opening ports. + Grid evidence does not exist at process start, so operators start the server, + wait ten seconds, then call the explicit single source calibration API. ## Consequences @@ -58,11 +76,20 @@ Calibration frame count now means unique, forward raw CSI observations within one uninterrupted sequence epoch. A vitals only node cannot produce a bootstrap model. Late packets never become calibration evidence. -A physical Node 5 capture observed sequence 4,119,566 followed by 4,119,563, -4,119,564, and 4,119,565 in one receive batch, then normal forward progress. -The window of four covers that measured depth of three with one frame of -margin. At the firmware acceptance ceiling of 50 Hz, the window represents no -more than 80 milliseconds. Larger regressions remain failed closed. +One physical Node 5 capture observed sequence 4,119,566 followed by 4,119,563, +4,119,564, and 4,119,565 in one receive batch. A second independent 90 second +capture observed bounded late packets at depths 9, 10, and 11. The window of +sixteen covers the measured maximum with five positions of margin. At the +firmware acceptance ceiling of 50 Hz, the window represents no more than 320 +milliseconds. Late packets are always dropped. Larger regressions remain +failed closed. + +The same 90 second capture measured three interleaved HT grids on Node 5. The +64 subcarrier stream delivered 436 frames at 4.84 Hz with a 1.56 second maximum +gap. The 128 stream delivered 100 frames at 1.11 Hz with a 5.05 second maximum +gap. The 192 stream delivered 49 frames at 0.54 Hz with an 11.53 second maximum +gap. Only the 64 grid exceeded the 2 Hz admission floor and stayed inside the +five second freshness boundary, so the final field model binds to that grid. The current wire format still has no authenticated boot epoch or stable device identity. A restart very early in a session or a same endpoint adversary cannot @@ -71,33 +98,46 @@ authenticated device identifier and a random per boot identifier before the server claims exact restart or identity collision detection. No new raw CSI is persisted. Sequence cursors and fault markers remain bounded -in memory to active calibration sources. The persisted bootstrap format and -its negative only authority are unchanged. +in memory to active calibration sources. The bootstrap schema advances to v2 +to protect the grid identity while its negative only authority remains +unchanged. ## Implementation -1. `NodeState` records a separate accepted CSI arrival clock. +1. `NodeState` records header only recent grid evidence and a separate bounded + field model history. 2. `AppStateInner::maybe_feed_calibration_frame` owns source binding, sequence ordering, fault latching, model feed, and cursor commit. 3. `field_bridge::maybe_feed_calibration` accepts exactly one current amplitude slice and rejects an empty observation. -4. Calibration start, status, stop, cancel, reset, and node status expose and - enforce the new admission contract. +4. Calibration start, status, stop, cancel, reset, promotion, refinement, and + restart enforce the frozen source and grid contract. +5. `bootstrap_baseline` persists the source grid inside the verified v2 image. +6. Finalization clears training history, and promotion blocks until the model + has a complete fresh scoring window plus twelve separately spaced samples. +7. Startup calibration without measured grid evidence exits with a typed + operator instruction instead of creating a model that can never receive a + frame. ## Acceptance test -Unit tests must prove first packet acceptance, forward progress, duplicate -rejection, source isolation, maximum counter wrap, empty input retry, the -measured depth three reorder pattern without a model feed, depth four -acceptance, depth five latching, half range rejection, session clear, receipt -visibility, and typed stop refusal. +Unit tests must prove stable grid selection, rate and gap rejection, first +packet acceptance, forward progress, duplicate rejection, source and grid +isolation, maximum counter wrap, empty input retry, measured depth eleven +reordering without a model feed, depth sixteen acceptance, depth seventeen +latching, half range rejection, session clear, receipt visibility, protected +grid persistence, and typed stop refusal. +Tests must also prove startup flag refusal, missing and stale grid refusal, +complete holdout isolation, and sequence fault rejection during holdout. On physical hardware, begin a Node 5 empty room capture and observe a fresh -sixty second interval. Calibration frame growth must match unique grid admitted -Node 5 CSI sequences. Edge vital packets alone must produce zero growth. The -source must retain `csi_status=active`, sequence faults must remain empty, and -numeric vital output must remain absent. Any bounded late packet must appear in -the reorder receipts while leaving the model count unchanged. Complete both the -1,000 frame and 600 second gates, then run the twelve sample server controlled -bootstrap holdout. Do not claim human detection improvement until a separate -consented occupied holdout demonstrates retained recall. +sixty second interval. Calibration frame growth must match unique 64 subcarrier +Node 5 sequences while the independent global inference grid remains unchanged. +Edge vital packets and other grids must produce zero growth. The bound grid +must remain active, sequence faults must remain empty, and numeric vital output +must remain absent. Any bounded late packet must appear in the reorder receipts +while leaving the model count unchanged. Complete both the 1,000 frame and 600 +second gates, then run the twelve sample server controlled bootstrap holdout. +Restart and prove that only the persisted grid rebuilds the runtime history. +Do not claim human detection improvement until a separate consented occupied +holdout demonstrates retained recall. 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 291e7e1c..b6ce978c 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/bootstrap_baseline.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/bootstrap_baseline.rs @@ -16,7 +16,7 @@ use wifi_densepose_signal::ruvsense::field_model::{ FieldModel, FieldModelError, FieldModelSnapshotV1, }; -pub const BOOTSTRAP_BASELINE_SCHEMA: &str = "ruview.bootstrap-empty-field-model.v1"; +pub const BOOTSTRAP_BASELINE_SCHEMA: &str = "ruview.bootstrap-empty-field-model.v2"; pub const BOOTSTRAP_BASELINE_AUTHORITY: &str = "bootstrap_only"; pub const BOOTSTRAP_VALIDATION_SAMPLES: usize = 12; pub const BOOTSTRAP_VALIDATION_MIN_EMPTY: usize = 10; @@ -24,13 +24,25 @@ pub const BOOTSTRAP_VALIDATION_MIN_SPACING_MS: u64 = 1_000; pub const BOOTSTRAP_VALIDATION_SAMPLE_TIMEOUT_MS: u64 = 4_000; const BOOTSTRAP_MAX_FILE_BYTES: u64 = 1_048_576; +/// Exact CSI layout used to train the persisted empty room model. +/// +/// Restoring code must require incoming frames to match this binding before +/// the bootstrap image can influence inference. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct BootstrapCsiGrid { + pub n_subcarriers: u16, + pub ppdu_type: u8, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(deny_unknown_fields)] -struct BootstrapPayloadV1 { +struct BootstrapPayloadV2 { schema: String, authority: String, installation_binding_sha256: String, source_node_ids: Vec, + source_grid: BootstrapCsiGrid, source_model_id: String, created_at_unix_ms: u64, expires_at_unix_ms: u64, @@ -39,14 +51,14 @@ struct BootstrapPayloadV1 { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(deny_unknown_fields)] -struct BootstrapImageV1 { - payload: BootstrapPayloadV1, +struct BootstrapImageV2 { + payload: BootstrapPayloadV2, content_sha256: String, } #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] -struct BootstrapImageRawV1 { +struct BootstrapImageRawV2 { payload: Box, content_sha256: String, } @@ -55,6 +67,7 @@ struct BootstrapImageRawV1 { pub struct BootstrapBaselineMetadata { pub authority: &'static str, pub source_node_ids: Vec, + pub source_grid: BootstrapCsiGrid, pub source_model_id: String, pub created_at_unix_ms: u64, pub expires_at_unix_ms: u64, @@ -139,6 +152,7 @@ pub fn store( path: &Path, installation_id: &str, source_node_ids: &[u8], + source_grid: BootstrapCsiGrid, source_model_id: &str, created_at_unix_ms: u64, field_model: &FieldModel, @@ -146,11 +160,12 @@ pub fn store( let snapshot = field_model.export_snapshot()?; let calibrated_at_ms = snapshot.modes.calibrated_at_us / 1_000; let expiry_ms = (snapshot.config.baseline_expiry_s * 1_000.0) as u64; - let payload = BootstrapPayloadV1 { + let payload = BootstrapPayloadV2 { schema: BOOTSTRAP_BASELINE_SCHEMA.to_string(), authority: BOOTSTRAP_BASELINE_AUTHORITY.to_string(), installation_binding_sha256: installation_binding(installation_id)?, source_node_ids: source_node_ids.to_vec(), + source_grid, source_model_id: source_model_id.to_string(), created_at_unix_ms, expires_at_unix_ms: calibrated_at_ms.saturating_add(expiry_ms), @@ -158,7 +173,7 @@ pub fn store( }; validate_payload(&payload, installation_id, created_at_unix_ms)?; let content_sha256 = payload_digest(&payload)?; - let image = BootstrapImageV1 { + let image = BootstrapImageV2 { payload, content_sha256, }; @@ -194,13 +209,13 @@ pub fn load( // Verify the exact payload bytes written by storage before decoding the // floating point model. Equivalent JSON number spellings may serialize // differently, so decode and reserialize is not an integrity contract. - let raw_image: BootstrapImageRawV1 = serde_json::from_slice(&bytes)?; + let raw_image: BootstrapImageRawV2 = serde_json::from_slice(&bytes)?; if hex_sha256(raw_image.payload.get().as_bytes()) != raw_image.content_sha256 { return Err(BootstrapBaselineError::DigestMismatch); } - let payload: BootstrapPayloadV1 = serde_json::from_str(raw_image.payload.get())?; + let payload: BootstrapPayloadV2 = serde_json::from_str(raw_image.payload.get())?; validate_payload(&payload, installation_id, current_unix_ms)?; - let image = BootstrapImageV1 { + let image = BootstrapImageV2 { payload, content_sha256: raw_image.content_sha256, }; @@ -226,7 +241,7 @@ pub fn remove(path: &Path) -> Result { } fn validate_payload( - payload: &BootstrapPayloadV1, + payload: &BootstrapPayloadV2, installation_id: &str, current_unix_ms: u64, ) -> Result<(), BootstrapBaselineError> { @@ -234,17 +249,14 @@ fn validate_payload( || payload.authority != BOOTSTRAP_BASELINE_AUTHORITY || payload.source_model_id.is_empty() || payload.source_model_id.len() > 128 - || payload.source_node_ids.is_empty() - || payload.source_node_ids.len() > 16 - || payload - .source_node_ids - .windows(2) - .any(|pair| pair[0] >= pair[1]) + || payload.source_node_ids.len() != 1 + || !(1..=2_048).contains(&payload.source_grid.n_subcarriers) + || payload.source_grid.ppdu_type > 3 || payload.created_at_unix_ms == 0 || payload.expires_at_unix_ms <= payload.created_at_unix_ms { return Err(BootstrapBaselineError::Malformed( - "invalid schema, authority, identity, node set, or lifetime".into(), + "invalid schema, authority, identity, node set, source grid, or lifetime".into(), )); } if payload.installation_binding_sha256 != installation_binding(installation_id)? { @@ -256,14 +268,15 @@ fn validate_payload( Ok(()) } -fn payload_digest(payload: &BootstrapPayloadV1) -> Result { +fn payload_digest(payload: &BootstrapPayloadV2) -> Result { Ok(hex_sha256(&serde_json::to_vec(payload)?)) } -fn metadata(image: &BootstrapImageV1) -> BootstrapBaselineMetadata { +fn metadata(image: &BootstrapImageV2) -> BootstrapBaselineMetadata { BootstrapBaselineMetadata { authority: BOOTSTRAP_BASELINE_AUTHORITY, source_node_ids: image.payload.source_node_ids.clone(), + source_grid: image.payload.source_grid, source_model_id: image.payload.source_model_id.clone(), created_at_unix_ms: image.payload.created_at_unix_ms, expires_at_unix_ms: image.payload.expires_at_unix_ms, @@ -315,6 +328,11 @@ mod tests { use super::*; use wifi_densepose_signal::ruvsense::field_model::FieldModelConfig; + const TEST_GRID: BootstrapCsiGrid = BootstrapCsiGrid { + n_subcarriers: 192, + ppdu_type: 2, + }; + fn completed_model(now_us: u64) -> FieldModel { let mut model = FieldModel::new(FieldModelConfig { n_links: 1, @@ -340,10 +358,11 @@ mod tests { let path = path_in(dir.path()); let now_ms = 1_000_000; let model = completed_model(now_ms * 1_000); - let stored = store(&path, "home-a", &[5], "model", now_ms, &model).unwrap(); + let stored = store(&path, "home-a", &[5], TEST_GRID, "model", now_ms, &model).unwrap(); let text = String::from_utf8(fs::read(&path).unwrap()).unwrap(); assert!(!text.contains("home-a")); assert!(!text.contains("raw_csi")); + assert_eq!(stored.source_grid, TEST_GRID); let (restored, loaded) = load(&path, "home-a", now_ms + 1).unwrap(); assert_eq!(stored, loaded); @@ -363,18 +382,18 @@ mod tests { let path = path_in(dir.path()); let now_ms = 1_000_000; let model = completed_model(now_ms * 1_000); - store(&path, "home-a", &[5], "model", now_ms, &model).unwrap(); + store(&path, "home-a", &[5], TEST_GRID, "model", now_ms, &model).unwrap(); let mut image: serde_json::Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap(); - image["payload"]["source_model_id"] = serde_json::json!("forged"); + image["payload"]["source_grid"]["n_subcarriers"] = serde_json::json!(64); fs::write(&path, serde_json::to_vec(&image).unwrap()).unwrap(); assert!(matches!( load(&path, "home-a", now_ms + 1), Err(BootstrapBaselineError::DigestMismatch) )); - store(&path, "home-a", &[5], "model", now_ms, &model).unwrap(); + store(&path, "home-a", &[5], TEST_GRID, "model", now_ms, &model).unwrap(); assert!(matches!( load(&path, "home-a", now_ms + 3_600_001), Err(BootstrapBaselineError::Expired) @@ -384,13 +403,90 @@ mod tests { )); } + #[test] + fn store_rejects_invalid_source_grid() { + let dir = tempfile::tempdir().unwrap(); + let path = path_in(dir.path()); + let now_ms = 1_000_000; + let model = completed_model(now_ms * 1_000); + + for source_grid in [ + BootstrapCsiGrid { + n_subcarriers: 0, + ppdu_type: 0, + }, + BootstrapCsiGrid { + n_subcarriers: 2_049, + ppdu_type: 0, + }, + BootstrapCsiGrid { + n_subcarriers: 192, + ppdu_type: 4, + }, + ] { + assert!(matches!( + store(&path, "home-a", &[5], source_grid, "model", now_ms, &model,), + Err(BootstrapBaselineError::Malformed(_)) + )); + } + } + + #[test] + fn store_requires_exactly_one_source_node() { + let dir = tempfile::tempdir().unwrap(); + let path = path_in(dir.path()); + let now_ms = 1_000_000; + let model = completed_model(now_ms * 1_000); + + for source_node_ids in [&[][..], &[5, 7][..]] { + assert!(matches!( + store( + &path, + "home-a", + source_node_ids, + TEST_GRID, + "model", + now_ms, + &model, + ), + Err(BootstrapBaselineError::Malformed(_)) + )); + } + } + + #[test] + fn source_grid_is_required_when_loading_a_v2_image() { + let dir = tempfile::tempdir().unwrap(); + let path = path_in(dir.path()); + let now_ms = 1_000_000; + let model = completed_model(now_ms * 1_000); + store(&path, "home-a", &[5], TEST_GRID, "model", now_ms, &model).unwrap(); + + let mut image: serde_json::Value = + serde_json::from_slice(&fs::read(&path).unwrap()).unwrap(); + image["payload"] + .as_object_mut() + .unwrap() + .remove("source_grid"); + let payload = serde_json::to_vec(&image["payload"]).unwrap(); + let rewritten = format!( + "{{\"payload\":{},\"content_sha256\":\"{}\"}}", + String::from_utf8(payload.clone()).unwrap(), + hex_sha256(&payload) + ); + fs::write(&path, rewritten).unwrap(); + + assert!(matches!( + load(&path, "home-a", now_ms + 1), + Err(BootstrapBaselineError::Json(_)) + )); + } + #[test] fn promotion_gate_requires_twelve_fresh_samples_and_zero_vitals() { assert_eq!(BOOTSTRAP_VALIDATION_MIN_SPACING_MS, 1_000); assert_eq!(BOOTSTRAP_VALIDATION_SAMPLE_TIMEOUT_MS, 4_000); - assert!( - BOOTSTRAP_VALIDATION_SAMPLE_TIMEOUT_MS >= BOOTSTRAP_VALIDATION_MIN_SPACING_MS - ); + assert!(BOOTSTRAP_VALIDATION_SAMPLE_TIMEOUT_MS >= BOOTSTRAP_VALIDATION_MIN_SPACING_MS); let passing = vec![ BootstrapValidationSample { fresh_tick: true, @@ -415,10 +511,10 @@ mod tests { let path = path_in(dir.path()); let now_ms = 1_000_000; let model = completed_model(now_ms * 1_000); - store(&path, "home-a", &[5], "model", now_ms, &model).unwrap(); + store(&path, "home-a", &[5], TEST_GRID, "model", now_ms, &model).unwrap(); let original = fs::read_to_string(&path).unwrap(); - let image: BootstrapImageRawV1 = serde_json::from_str(&original).unwrap(); + let image: BootstrapImageRawV2 = serde_json::from_str(&original).unwrap(); let rewritten_payload = image.payload.get().replacen( "\"min_calibration_duration_s\":0.0", "\"min_calibration_duration_s\":0.00", diff --git a/v2/crates/wifi-densepose-sensing-server/src/main.rs b/v2/crates/wifi-densepose-sensing-server/src/main.rs index 68f6ada7..b843efce 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/main.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/main.rs @@ -329,6 +329,59 @@ impl Esp32Frame { } } +/// Exact CSI symbol grid used by one field model. Subcarrier count alone is +/// insufficient because HT-LTF and HE-LTF use different tone layouts. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +struct CsiGridKey { + n_subcarriers: u16, + ppdu_type: u8, +} + +impl CsiGridKey { + fn from_frame(frame: &Esp32Frame) -> Self { + Self { + n_subcarriers: frame.n_subcarriers, + ppdu_type: frame.ppdu_type.to_byte(), + } + } + + fn as_bootstrap_grid(self) -> bootstrap_baseline::BootstrapCsiGrid { + bootstrap_baseline::BootstrapCsiGrid { + n_subcarriers: self.n_subcarriers, + ppdu_type: self.ppdu_type, + } + } + + fn from_bootstrap_grid(grid: bootstrap_baseline::BootstrapCsiGrid) -> Self { + Self { + n_subcarriers: grid.n_subcarriers, + ppdu_type: grid.ppdu_type, + } + } +} + +#[derive(Debug, Clone, Copy)] +struct RawGridObservation { + grid: CsiGridKey, + observed_at: std::time::Instant, +} + +#[derive(Debug, Clone, Copy, Serialize)] +struct CalibrationGridEvidence { + sample_count: usize, + span_s: f64, + rate_hz: f64, + max_gap_s: f64, + latest_age_s: f64, +} + +#[derive(Debug, Clone, Copy)] +struct CalibrationGridBinding { + source_node_id: u8, + grid: CsiGridKey, + evidence: Option, +} + /// Sensing update broadcast to WebSocket clients #[derive(Debug, Clone, Serialize, Deserialize)] struct SensingUpdate { @@ -886,6 +939,15 @@ struct NodeState { /// the two symbol grids in `frame_history` corrupts variance/baseline /// statistics. See [`NodeState::accept_grid`]. active_grid: Option<(u16, wifi_densepose_hardware::PpduType)>, + /// Header-only recent raw grid observations used to choose one stable, + /// comparable calibration stream. No CSI amplitudes are retained here. + raw_grid_observations: VecDeque, + /// Runtime history for the exact grid bound into the active field model. + /// This must not reuse `frame_history`, whose independent inference grid + /// may be wider and therefore not bin-comparable. + field_model_history: VecDeque>, + field_model_latest_sequence: Option, + field_model_latest_seen: Option, } /// Default EMA alpha for temporal keypoint smoothing (RuVector Phase 2). @@ -909,6 +971,14 @@ const NOVELTY_HISTORY_CAPACITY: usize = 64; /// ADR-084 Pass 3 — feature-vector schema version. Bump on changes to /// subcarrier ordering / normalisation so banks reject stale data. const NOVELTY_SKETCH_VERSION: u16 = 1; +/// Recent header-only evidence window used to choose a stable field-model +/// stream. The rate floor is 20% above the 1,000 / 600 s completion minimum. +const CALIBRATION_GRID_EVIDENCE_WINDOW: std::time::Duration = + std::time::Duration::from_secs(20); +const CALIBRATION_GRID_EVIDENCE_CAPACITY: usize = 1_024; +const CALIBRATION_GRID_MIN_SPAN_S: f64 = 10.0; +const CALIBRATION_GRID_MIN_RATE_HZ: f64 = 2.0; +const CALIBRATION_GRID_MAX_GAP_S: f64 = 5.0; /// Lower plausibility floor (seconds) for a CSI inter-frame delta. /// @@ -1212,9 +1282,129 @@ impl NodeState { ), last_novelty_score: None, active_grid: None, + raw_grid_observations: VecDeque::with_capacity( + CALIBRATION_GRID_EVIDENCE_CAPACITY, + ), + field_model_history: VecDeque::with_capacity(FRAME_HISTORY_CAPACITY), + field_model_latest_sequence: None, + field_model_latest_seen: None, } } + fn observe_raw_grid(&mut self, grid: CsiGridKey, now: std::time::Instant) { + while self + .raw_grid_observations + .front() + .is_some_and(|sample| { + now.saturating_duration_since(sample.observed_at) + > CALIBRATION_GRID_EVIDENCE_WINDOW + }) + { + self.raw_grid_observations.pop_front(); + } + if self.raw_grid_observations.len() == CALIBRATION_GRID_EVIDENCE_CAPACITY { + self.raw_grid_observations.pop_front(); + } + self.raw_grid_observations.push_back(RawGridObservation { + grid, + observed_at: now, + }); + } + + fn calibration_grid_candidates( + &self, + now: std::time::Instant, + ) -> Vec<(CsiGridKey, CalibrationGridEvidence)> { + let mut grouped: Vec<(CsiGridKey, Vec)> = Vec::new(); + for sample in self.raw_grid_observations.iter().filter(|sample| { + now.saturating_duration_since(sample.observed_at) + <= CALIBRATION_GRID_EVIDENCE_WINDOW + }) { + if let Some((_, arrivals)) = grouped + .iter_mut() + .find(|(candidate, _)| *candidate == sample.grid) + { + arrivals.push(sample.observed_at); + } else { + grouped.push((sample.grid, vec![sample.observed_at])); + } + } + grouped + .into_iter() + .filter_map(|(grid, arrivals)| { + let first = *arrivals.first()?; + let last = *arrivals.last()?; + let span_s = last.saturating_duration_since(first).as_secs_f64(); + let latest_age_s = now.saturating_duration_since(last).as_secs_f64(); + let mut max_gap_s = latest_age_s; + for pair in arrivals.windows(2) { + max_gap_s = max_gap_s.max( + pair[1] + .saturating_duration_since(pair[0]) + .as_secs_f64(), + ); + } + let rate_hz = if span_s > 0.0 { + arrivals.len().saturating_sub(1) as f64 / span_s + } else { + 0.0 + }; + Some(( + grid, + CalibrationGridEvidence { + sample_count: arrivals.len(), + span_s, + rate_hz, + max_gap_s, + latest_age_s, + }, + )) + }) + .collect() + } + + fn select_calibration_grid( + &self, + now: std::time::Instant, + ) -> Option<(CsiGridKey, CalibrationGridEvidence)> { + self.calibration_grid_candidates(now) + .into_iter() + .filter(|(grid, evidence)| { + grid.ppdu_type <= 3 + && evidence.span_s >= CALIBRATION_GRID_MIN_SPAN_S + && evidence.rate_hz >= CALIBRATION_GRID_MIN_RATE_HZ + && evidence.max_gap_s < CALIBRATION_GRID_MAX_GAP_S + && evidence.latest_age_s < CALIBRATION_GRID_MAX_GAP_S + }) + .min_by(|(left_grid, left), (right_grid, right)| { + left.max_gap_s + .total_cmp(&right.max_gap_s) + .then_with(|| right.rate_hz.total_cmp(&left.rate_hz)) + .then_with(|| right_grid.n_subcarriers.cmp(&left_grid.n_subcarriers)) + .then_with(|| left_grid.ppdu_type.cmp(&right_grid.ppdu_type)) + }) + } + + fn clear_field_model_history(&mut self) { + self.field_model_history.clear(); + self.field_model_latest_sequence = None; + self.field_model_latest_seen = None; + } + + fn push_field_model_frame( + &mut self, + sequence: u32, + amplitudes: &[f64], + now: std::time::Instant, + ) { + self.field_model_history.push_back(amplitudes.to_vec()); + if self.field_model_history.len() > FRAME_HISTORY_CAPACITY { + self.field_model_history.pop_front(); + } + self.field_model_latest_sequence = Some(sequence); + self.field_model_latest_seen = Some(now); + } + /// ADR-110 / issue #1005 grid gate: decide whether a frame on `grid` /// may enter this node's feature path, and update `active_grid`. /// @@ -1632,6 +1822,9 @@ struct AppStateInner { calibration_model_id: Option, /// Nodes that actually contributed frames to the current calibration. calibration_source_node_ids: std::collections::BTreeSet, + /// Immutable source and CSI symbol grid selected from recent header-only + /// evidence for the current field model. + calibration_grid_binding: Option, /// Last admitted raw CSI sequence per contributing node. Edge-vitals /// packets do not carry a new CSI observation and cannot advance this map. calibration_last_sequences: HashMap, @@ -1754,11 +1947,12 @@ enum CalibrationSequenceOrder { Discontinuity, } -/// Live Node 5 evidence observed a same-source receive batch ordered as high -/// water mark, then minus three, minus two, minus one. Four covers that -/// measured UDP reorder with one frame of margin. At the firmware's 50 Hz -/// ceiling it spans at most 80 ms; larger backward jumps stay failed closed. -const CALIBRATION_SEQUENCE_REORDER_WINDOW: u32 = 4; +/// Two independent live Node 5 captures observed bounded same-source UDP +/// reordering through depth eleven. Sixteen covers that measured maximum with +/// five positions of margin. At the firmware's 50 Hz ceiling it spans at most +/// 320 ms; larger backward jumps stay failed closed. Late packets inside this +/// window are always dropped and never become model evidence. +const CALIBRATION_SEQUENCE_REORDER_WINDOW: u32 = 16; fn calibration_sequence_order(previous: Option, sequence: u32) -> CalibrationSequenceOrder { let Some(previous) = previous else { @@ -1786,10 +1980,16 @@ impl AppStateInner { fn maybe_feed_calibration_frame( &mut self, node_id: u8, + grid: CsiGridKey, sequence: u32, amplitudes: &[f64], + observed_at: std::time::Instant, ) -> bool { - if !calibration_source_accepts(&self.calibration_source_node_ids, node_id) + let binding_matches = self.calibration_grid_binding.is_some_and(|binding| { + binding.source_node_id == node_id && binding.grid == grid + }); + if !binding_matches + || !calibration_source_accepts(&self.calibration_source_node_ids, node_id) || self.calibration_sequence_fault_node_ids.contains(&node_id) { return false; @@ -1827,13 +2027,25 @@ impl AppStateInner { } CalibrationSequenceOrder::First | CalibrationSequenceOrder::Forward => {} } - let accepted = self - .field_model - .as_mut() - .is_some_and(|field| field_bridge::maybe_feed_calibration(field, amplitudes)); + 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) + } else { + field.check_freshness( + (chrono::Utc::now().timestamp_millis().max(0) as u64) + .saturating_mul(1_000), + ) == CalibrationStatus::Fresh + } + }); if accepted { self.calibration_source_node_ids.insert(node_id); self.calibration_last_sequences.insert(node_id, sequence); + if let Some(node) = self.node_states.get_mut(&node_id) { + node.push_field_model_frame(sequence, amplitudes, observed_at); + } } accepted } @@ -1845,6 +2057,51 @@ impl AppStateInner { self.calibration_sequence_fault_node_ids.clear(); } + fn clear_field_model_binding(&mut self) { + self.calibration_grid_binding = None; + for node in self.node_states.values_mut() { + node.clear_field_model_history(); + } + } + + fn calibration_grid_is_fresh( + &self, + binding: CalibrationGridBinding, + now: std::time::Instant, + ) -> bool { + !self + .calibration_sequence_fault_node_ids + .contains(&binding.source_node_id) + && self + .node_states + .get(&binding.source_node_id) + .and_then(|node| node.field_model_latest_seen) + .is_some_and(|seen| { + now.saturating_duration_since(seen) < ESP32_OFFLINE_TIMEOUT + }) + } + + fn field_model_holdout_ready( + &self, + binding: CalibrationGridBinding, + required_window_size: usize, + now: std::time::Instant, + ) -> bool { + self.calibration_grid_binding.is_some_and(|current| { + current.source_node_id == binding.source_node_id && current.grid == binding.grid + }) && self.calibration_grid_is_fresh(binding, now) + && self + .node_states + .get(&binding.source_node_id) + .is_some_and(|node| node.field_model_history.len() >= required_window_size) + } + + fn begin_field_model_holdout(&mut self, binding: CalibrationGridBinding) { + if let Some(node) = self.node_states.get_mut(&binding.source_node_id) { + node.clear_field_model_history(); + } + } + fn field_model_status_at(&self, observed_at_unix_ms: u64) -> Option { self.field_model.as_ref().map(|model| { field_bridge::calibration_status_at(model, observed_at_unix_ms.saturating_mul(1_000)) @@ -1895,7 +2152,7 @@ impl AppStateInner { let history = if let Some(node_id) = bound_source_node_id { self.node_states .get(&node_id) - .map(|state| &state.frame_history) + .map(|state| &state.field_model_history) .unwrap_or(&self.frame_history) } else if !self.frame_history.is_empty() { &self.frame_history @@ -1945,7 +2202,23 @@ impl AppStateInner { let [source_node_id] = metadata.source_node_ids.as_slice() else { return None; }; - let history = &self.node_states.get(source_node_id)?.frame_history; + let binding = self.calibration_grid_binding?; + if binding.source_node_id != *source_node_id + || binding.grid != CsiGridKey::from_bootstrap_grid(metadata.source_grid) + || self + .calibration_sequence_fault_node_ids + .contains(source_node_id) + { + return None; + } + let node = self.node_states.get(source_node_id)?; + if !node.field_model_latest_seen.is_some_and(|seen| { + std::time::Instant::now().saturating_duration_since(seen) + < ESP32_OFFLINE_TIMEOUT + }) { + return None; + } + let history = &node.field_model_history; field_bridge::bootstrap_background_match( field_model, history, @@ -2004,6 +2277,10 @@ impl AppStateInner { /// Number of frames retained in `frame_history` for temporal analysis. /// At 500 ms ticks this covers ~50 seconds; at 100 ms ticks ~10 seconds. const FRAME_HISTORY_CAPACITY: usize = 100; +/// A qualified grid may run at the 2 Hz admission floor. Rebuilding a 50 frame +/// runtime scoring window then needs 25 seconds, so promotion grants ten more +/// seconds for ordinary bounded jitter before failing closed. +const BOOTSTRAP_HOLDOUT_PREWARM_TIMEOUT_MS: u64 = 35_000; type SharedState = Arc>; @@ -2087,6 +2364,7 @@ impl AppStateInner { bootstrap_baseline_active: false, calibration_model_id: None, calibration_source_node_ids: std::collections::BTreeSet::new(), + calibration_grid_binding: None, calibration_last_sequences: HashMap::new(), calibration_reordered_packets: HashMap::new(), calibration_max_reorder_depth: HashMap::new(), @@ -2140,12 +2418,30 @@ mod calibration_expiry_tests { let baseline = model.modes().unwrap().baseline[0].clone(); let mut state = AppStateInner::minimal(); state.frame_history = (0..50).map(|_| baseline.clone()).collect(); + let mut node = NodeState::new(); + node.field_model_history = (0..50).map(|_| baseline.clone()).collect(); + node.field_model_latest_sequence = Some(50); + node.field_model_latest_seen = Some(std::time::Instant::now()); + state.node_states.insert(5, node); + state.calibration_source_node_ids.insert(5); + state.calibration_grid_binding = Some(CalibrationGridBinding { + source_node_id: 5, + grid: CsiGridKey { + n_subcarriers: 64, + ppdu_type: 0, + }, + evidence: None, + }); state.field_model = Some(model); state.bootstrap_baseline_active = bootstrap; if bootstrap { state.bootstrap_baseline = Some(BootstrapBaselineMetadata { authority: bootstrap_baseline::BOOTSTRAP_BASELINE_AUTHORITY, source_node_ids: vec![5], + source_grid: bootstrap_baseline::BootstrapCsiGrid { + n_subcarriers: 64, + ppdu_type: 0, + }, source_model_id: "test-model".to_string(), created_at_unix_ms: 1_000, expires_at_unix_ms: 3_000, @@ -6416,6 +6712,38 @@ fn calibration_source_has_fresh_csi(node: &NodeState, now: std::time::Instant) - mod bootstrap_vital_publication_tests { use super::*; + fn test_grid() -> CsiGridKey { + CsiGridKey { + n_subcarriers: 64, + ppdu_type: 0, + } + } + + fn bind_test_calibration(state: &mut AppStateInner) { + state.calibration_source_node_ids.insert(5); + state.calibration_grid_binding = Some(CalibrationGridBinding { + source_node_id: 5, + grid: test_grid(), + evidence: None, + }); + state.node_states.entry(5).or_insert_with(NodeState::new); + } + + fn feed_test_frame( + state: &mut AppStateInner, + node_id: u8, + sequence: u32, + amplitudes: &[f64], + ) -> bool { + state.maybe_feed_calibration_frame( + node_id, + test_grid(), + sequence, + amplitudes, + std::time::Instant::now(), + ) + } + #[test] fn single_link_calibration_binds_to_first_source() { let mut sources = std::collections::BTreeSet::new(); @@ -6448,21 +6776,136 @@ mod bootstrap_vital_publication_tests { )); } + #[test] + fn calibration_grid_selection_prefers_stable_rate_qualified_stream() { + let start = std::time::Instant::now(); + let mut node = NodeState::new(); + let stable = CsiGridKey { + n_subcarriers: 64, + ppdu_type: 0, + }; + let sparse = CsiGridKey { + n_subcarriers: 192, + ppdu_type: 0, + }; + for index in 0..=48 { + node.observe_raw_grid( + stable, + start + std::time::Duration::from_millis(index * 250), + ); + } + for index in 0..=6 { + node.observe_raw_grid( + sparse, + start + std::time::Duration::from_secs(index * 2), + ); + } + let (selected, evidence) = node + .select_calibration_grid(start + std::time::Duration::from_secs(12)) + .expect("stable grid is eligible"); + assert_eq!(selected, stable); + assert!(evidence.rate_hz >= 3.9); + assert!(evidence.max_gap_s < 5.0); + } + + #[tokio::test] + async fn calibration_start_requires_evidence_then_binds_the_qualified_grid() { + let mut inner = AppStateInner::minimal(); + inner.node_states.insert(5, NodeState::new()); + let state = Arc::new(RwLock::new(inner)); + + let Json(unavailable) = calibration_start( + State(state.clone()), + Query(CalibrationStartQuery { + source_node_id: Some(5), + }), + ) + .await; + assert_eq!(unavailable["success"], false); + assert_eq!(unavailable["error_code"], "calibration_grid_unavailable"); + { + let state = state.read().await; + assert!(state.field_model.is_none()); + assert!(state.calibration_model_id.is_none()); + assert!(state.calibration_source_node_ids.is_empty()); + assert!(state.calibration_grid_binding.is_none()); + } + + let now = std::time::Instant::now(); + let start = now - std::time::Duration::from_secs(10); + { + let mut state = state.write().await; + let node = state.node_states.get_mut(&5).expect("node"); + for index in 0..=40 { + node.observe_raw_grid( + test_grid(), + start + std::time::Duration::from_millis(index * 250), + ); + } + } + let Json(started) = calibration_start( + State(state.clone()), + Query(CalibrationStartQuery { + source_node_id: Some(5), + }), + ) + .await; + assert_eq!(started["success"], true); + assert_eq!(started["source_node_ids"], serde_json::json!([5])); + assert_eq!(started["source_grid"]["n_subcarriers"], 64); + assert_eq!(started["source_grid"]["ppdu_type"], 0); + let state = state.read().await; + assert!(state.field_model.is_some()); + assert!(state.calibration_model_id.is_some()); + assert_eq!(state.calibration_source_node_ids, [5].into_iter().collect()); + assert_eq!( + state.calibration_grid_binding.map(|binding| binding.grid), + Some(test_grid()) + ); + } + + #[test] + fn non_bound_grid_never_enters_field_model_history() { + let mut state = AppStateInner::minimal(); + state.field_model = Some( + FieldModel::new(field_bridge::single_link_config()).expect("field model"), + ); + bind_test_calibration(&mut state); + let wrong_grid = CsiGridKey { + n_subcarriers: 192, + ppdu_type: 0, + }; + assert!(!state.maybe_feed_calibration_frame( + 5, + wrong_grid, + 41, + &[0.25; 192], + std::time::Instant::now(), + )); + assert!(state + .node_states + .get(&5) + .expect("node") + .field_model_history + .is_empty()); + assert!(state.calibration_last_sequences.is_empty()); + } + #[test] fn calibration_session_counts_each_unique_bound_csi_sequence_once() { let mut state = AppStateInner::minimal(); state.field_model = Some( FieldModel::new(field_bridge::single_link_config()).expect("field model"), ); - state.calibration_source_node_ids.insert(5); + bind_test_calibration(&mut state); let first = vec![0.25; 56]; - assert!(state.maybe_feed_calibration_frame(5, 41, &first)); - assert!(!state.maybe_feed_calibration_frame(5, 41, &first)); + assert!(feed_test_frame(&mut state, 5, 41, &first)); + assert!(!feed_test_frame(&mut state, 5, 41, &first)); let second = vec![0.5; 56]; - assert!(state.maybe_feed_calibration_frame(5, 42, &second)); - assert!(!state.maybe_feed_calibration_frame(3, 43, &second)); + assert!(feed_test_frame(&mut state, 5, 42, &second)); + assert!(!feed_test_frame(&mut state, 3, 43, &second)); let model = state.field_model.as_ref().expect("field model remains active"); assert_eq!(model.calibration_frame_count(), 2); @@ -6476,17 +6919,17 @@ mod bootstrap_vital_publication_tests { state.field_model = Some( FieldModel::new(field_bridge::single_link_config()).expect("field model"), ); - state.calibration_source_node_ids.insert(5); + bind_test_calibration(&mut state); let frame = vec![0.25; 56]; - assert!(state.maybe_feed_calibration_frame(5, 4_119_566, &frame)); - assert!(!state.maybe_feed_calibration_frame(5, 4_119_563, &frame)); - assert!(!state.maybe_feed_calibration_frame(5, 4_119_564, &frame)); - assert!(!state.maybe_feed_calibration_frame(5, 4_119_565, &frame)); - assert!(state.maybe_feed_calibration_frame(5, 4_119_567, &frame)); + assert!(feed_test_frame(&mut state, 5, 4_119_566, &frame)); + assert!(!feed_test_frame(&mut state, 5, 4_119_555, &frame)); + assert!(!feed_test_frame(&mut state, 5, 4_119_556, &frame)); + assert!(!feed_test_frame(&mut state, 5, 4_119_557, &frame)); + assert!(feed_test_frame(&mut state, 5, 4_119_567, &frame)); assert!(state.calibration_sequence_fault_node_ids.is_empty()); assert_eq!(state.calibration_reordered_packets.get(&5), Some(&3)); - assert_eq!(state.calibration_max_reorder_depth.get(&5), Some(&3)); + assert_eq!(state.calibration_max_reorder_depth.get(&5), Some(&11)); assert_eq!( state .field_model @@ -6503,12 +6946,12 @@ mod bootstrap_vital_publication_tests { state.field_model = Some( FieldModel::new(field_bridge::single_link_config()).expect("field model"), ); - state.calibration_source_node_ids.insert(5); + bind_test_calibration(&mut state); let frame = vec![0.25; 56]; - assert!(state.maybe_feed_calibration_frame(5, 100, &frame)); - assert!(!state.maybe_feed_calibration_frame(5, 95, &frame)); - assert!(!state.maybe_feed_calibration_frame(5, 101, &frame)); + assert!(feed_test_frame(&mut state, 5, 100, &frame)); + assert!(!feed_test_frame(&mut state, 5, 83, &frame)); + assert!(!feed_test_frame(&mut state, 5, 101, &frame)); assert!(state.calibration_sequence_fault_node_ids.contains(&5)); assert_eq!( state @@ -6532,15 +6975,15 @@ mod bootstrap_vital_publication_tests { state.field_model = Some( FieldModel::new(field_bridge::single_link_config()).expect("field model"), ); - state.calibration_source_node_ids.insert(5); + bind_test_calibration(&mut state); let frame = vec![0.25; 56]; - assert!(!state.maybe_feed_calibration_frame(5, u32::MAX, &[])); + assert!(!feed_test_frame(&mut state, 5, u32::MAX, &[])); assert!(state.calibration_last_sequences.is_empty()); - assert!(state.maybe_feed_calibration_frame(5, u32::MAX, &frame)); - assert!(state.maybe_feed_calibration_frame(5, 0, &frame)); - assert!(!state.maybe_feed_calibration_frame(5, 0, &frame)); - assert!(state.maybe_feed_calibration_frame(5, 1, &frame)); + assert!(feed_test_frame(&mut state, 5, u32::MAX, &frame)); + assert!(feed_test_frame(&mut state, 5, 0, &frame)); + assert!(!feed_test_frame(&mut state, 5, 0, &frame)); + assert!(feed_test_frame(&mut state, 5, 1, &frame)); assert_eq!( state .field_model @@ -6570,11 +7013,11 @@ mod bootstrap_vital_publication_tests { CalibrationSequenceOrder::Reordered(1) ); assert_eq!( - calibration_sequence_order(Some(8), 4), - CalibrationSequenceOrder::Reordered(4) + calibration_sequence_order(Some(32), 16), + CalibrationSequenceOrder::Reordered(16) ); assert_eq!( - calibration_sequence_order(Some(8), 3), + calibration_sequence_order(Some(32), 15), CalibrationSequenceOrder::Discontinuity ); assert_eq!( @@ -6603,7 +7046,7 @@ mod bootstrap_vital_publication_tests { let Json(status) = calibration_status(State(state.clone())).await; assert_eq!(status["last_sequence_by_node"]["5"], 41); - assert_eq!(status["reorder_window_sequences"], 4); + assert_eq!(status["reorder_window_sequences"], 16); assert_eq!(status["reordered_packets_by_node"]["5"], 3); assert_eq!(status["max_reorder_depth_by_node"]["5"], 3); assert_eq!(status["sequence_fault_node_ids"], serde_json::json!([5])); @@ -6620,6 +7063,77 @@ mod bootstrap_vital_publication_tests { assert_eq!(stopped["max_reorder_depth_by_node"]["5"], 3); } + #[tokio::test] + async fn calibration_stop_rejects_missing_or_stale_grid_identity() { + let mut inner = AppStateInner::minimal(); + inner.field_model = Some( + FieldModel::new(field_bridge::single_link_config()).expect("field model"), + ); + inner.calibration_model_id = Some("cal-model-test".to_string()); + inner.calibration_source_node_ids.insert(5); + let state = Arc::new(RwLock::new(inner)); + + let Json(missing) = calibration_stop(State(state.clone())).await; + assert_eq!(missing["success"], false); + assert_eq!(missing["error_code"], "calibration_grid_identity_missing"); + + { + let mut state = state.write().await; + state.calibration_grid_binding = Some(CalibrationGridBinding { + source_node_id: 5, + grid: test_grid(), + evidence: None, + }); + state.node_states.insert(5, NodeState::new()); + } + let Json(stale) = calibration_stop(State(state)).await; + assert_eq!(stale["success"], false); + assert_eq!(stale["error_code"], "calibration_grid_stale"); + } + + #[test] + fn post_finalize_holdout_excludes_training_history_and_sequence_faults() { + let mut state = AppStateInner::minimal(); + bind_test_calibration(&mut state); + let binding = state.calibration_grid_binding.expect("binding"); + let now = std::time::Instant::now(); + { + let node = state.node_states.get_mut(&5).expect("node"); + for sequence in 0..50 { + node.push_field_model_frame(sequence, &[0.25; 64], now); + } + } + assert!(state.field_model_holdout_ready(binding, 50, now)); + + state.begin_field_model_holdout(binding); + assert!(state + .node_states + .get(&5) + .expect("node") + .field_model_history + .is_empty()); + assert!(!state.field_model_holdout_ready(binding, 50, now)); + + { + let node = state.node_states.get_mut(&5).expect("node"); + for sequence in 50..100 { + node.push_field_model_frame(sequence, &[0.5; 64], now); + } + } + assert!(state.field_model_holdout_ready(binding, 50, now)); + state.calibration_sequence_fault_node_ids.insert(5); + assert!(!state.field_model_holdout_ready(binding, 50, now)); + } + + #[test] + fn startup_calibration_flag_fails_closed_before_state_creation() { + assert!(validate_startup_calibration_request(false).is_ok()); + let error = validate_startup_calibration_request(true).expect_err("must fail closed"); + assert!(error.contains("--calibrate")); + assert!(error.contains("10 seconds")); + assert!(error.contains("source_node_id")); + } + fn strong_candidates() -> VitalSigns { VitalSigns { breathing_rate_bpm: Some(15.0), @@ -6729,21 +7243,62 @@ async fn calibration_start( 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| calibration_source_has_fresh_csi(node, now)); - if !source_is_live { + let now = std::time::Instant::now(); + let selected_binding = if let Some(source_node_id) = query.source_node_id { + let Some(node) = s.node_states.get(&source_node_id) else { return Json(serde_json::json!({ "success": false, "error_code": "calibration_source_unavailable", - "error": "The requested calibration source is missing or has no fresh raw CSI.", + "error": "The requested calibration source is missing.", "source_node_id": source_node_id, })); + }; + let candidates = node.calibration_grid_candidates(now); + let Some((grid, evidence)) = node.select_calibration_grid(now) else { + return Json(serde_json::json!({ + "success": false, + "error_code": "calibration_grid_unavailable", + "error": "No raw CSI grid has at least 10 seconds of evidence, a 2 Hz measured rate, and a maximum gap below 5 seconds.", + "source_node_id": source_node_id, + "candidates": candidates.into_iter().map(|(grid, evidence)| serde_json::json!({ + "grid": grid, + "evidence": evidence, + })).collect::>(), + })); + }; + CalibrationGridBinding { + source_node_id, + grid, + evidence: Some(evidence), } - } + } else { + let eligible: Vec = s + .node_states + .iter() + .filter_map(|(&source_node_id, node)| { + node.select_calibration_grid(now).map(|(grid, evidence)| { + CalibrationGridBinding { + source_node_id, + grid, + evidence: Some(evidence), + } + }) + }) + .collect(); + let [binding] = eligible.as_slice() else { + return Json(serde_json::json!({ + "success": false, + "error_code": "calibration_source_required", + "error": "Choose exactly one eligible ESP32 source for room calibration.", + "eligible_sources": eligible.iter().map(|binding| serde_json::json!({ + "source_node_id": binding.source_node_id, + "grid": binding.grid, + "evidence": binding.evidence, + })).collect::>(), + })); + }; + *binding + }; if s.bootstrap_baseline_active { s.field_model = None; s.bootstrap_baseline_active = false; @@ -6774,15 +7329,18 @@ async fn calibration_start( s.field_model = Some(fm); s.calibration_model_id = Some(opaque_calibration_model_id()); s.calibration_source_node_ids.clear(); + s.clear_field_model_binding(); s.clear_calibration_sequence_state(); - if let Some(source_node_id) = query.source_node_id { - s.calibration_source_node_ids.insert(source_node_id); - } + s.calibration_source_node_ids + .insert(selected_binding.source_node_id); + s.calibration_grid_binding = Some(selected_binding); Json(serde_json::json!({ "success": true, "message": "Calibration started — keep room empty while frames accumulate.", "model_id": s.calibration_model_id, "source_node_ids": s.calibration_source_node_ids, + "source_grid": selected_binding.grid, + "grid_evidence": selected_binding.evidence, })) } // ADR-080 #2: FieldModel init error chain stays server-side only. @@ -6794,6 +7352,7 @@ async fn calibration_stop(State(state): State) -> Json = s.calibration_source_node_ids.iter().copied().collect(); + let source_grid = s.calibration_grid_binding.map(|binding| binding.grid); let reordered_packets_by_node = s.calibration_reordered_packets.clone(); let max_reorder_depth_by_node = s.calibration_max_reorder_depth.clone(); if !s.calibration_sequence_fault_node_ids.is_empty() { @@ -6803,11 +7362,43 @@ async fn calibration_stop(State(state): State) -> Json) -> Json { let baseline = modes.baseline_eigenvalue_count; let variance_explained = modes.variance_explained; + let holdout_window_size = modes.baseline_runtime_window_size; + let final_frame_count = fm.calibration_frame_count(); + s.begin_field_model_holdout(binding); info!("Field model calibrated: baseline_eigenvalues={baseline}, variance_explained={variance_explained:.2}"); Json(serde_json::json!({ "success": true, "baseline_eigenvalue_count": baseline, "variance_explained": variance_explained, - "frame_count": fm.calibration_frame_count(), + "frame_count": final_frame_count, "elapsed_s": elapsed_s, + "holdout_window_size": holdout_window_size, "model_id": model_id, "source_node_ids": source_node_ids, + "source_grid": source_grid, "reordered_packets_by_node": reordered_packets_by_node, "max_reorder_depth_by_node": max_reorder_depth_by_node, })) @@ -6872,6 +7468,7 @@ async fn calibration_stop(State(state): State) -> Json) -> Json { let s = state.read().await; + let now = std::time::Instant::now(); let observed_at_unix_ms = chrono::Utc::now().timestamp_millis().max(0) as u64; let effective_status = s.field_model_status_at(observed_at_unix_ms); let active = s.field_model_active_at(observed_at_unix_ms); @@ -6903,6 +7500,23 @@ async fn calibration_status(State(state): State) -> Json) -> Json) -> Json) -> Json, ) -> Json { - let expected_model_id = { + let (expected_model_id, expected_binding, holdout_window_size) = { let s = state.read().await; let observed_at_unix_ms = chrono::Utc::now().timestamp_millis().max(0) as u64; if s.bootstrap_baseline_active || !s.explicit_calibration_fresh_at(observed_at_unix_ms) { @@ -6970,7 +7586,7 @@ async fn calibration_promote_bootstrap( "error": "Finalize an explicit room calibration before validating a startup baseline.", })); } - match s.calibration_model_id.clone() { + let model_id = match s.calibration_model_id.clone() { Some(model_id) => model_id, None => { return Json(serde_json::json!({ @@ -6979,11 +7595,101 @@ async fn calibration_promote_bootstrap( "error": "The active calibration has no server generated model identity.", })); } + }; + let Some(binding) = s.calibration_grid_binding else { + return Json(serde_json::json!({ + "success": false, + "error_code": "calibration_grid_identity_missing", + "error": "The completed calibration has no frozen CSI grid identity.", + })); + }; + if s.calibration_sequence_fault_node_ids + .contains(&binding.source_node_id) + { + return Json(serde_json::json!({ + "success": false, + "error_code": "calibration_sequence_discontinuity", + "error": "The calibration CSI sequence faulted before validation. The model was not stored.", + })); } + let Some(runtime_window_size) = s + .field_model + .as_ref() + .and_then(|model| model.modes()) + .and_then(|modes| modes.baseline_runtime_window_size) + .filter(|window_size| *window_size > 0 && *window_size <= FRAME_HISTORY_CAPACITY) + else { + return Json(serde_json::json!({ + "success": false, + "error_code": "calibration_runtime_window_invalid", + "error": "The completed calibration has no valid runtime scoring window.", + })); + }; + (model_id, binding, runtime_window_size) }; + let prewarm_deadline = tokio::time::Instant::now() + + Duration::from_millis(BOOTSTRAP_HOLDOUT_PREWARM_TIMEOUT_MS); + loop { + let (identity_matches, sequence_faulted, ready, current_window_size) = { + let s = state.read().await; + let observed_at_unix_ms = chrono::Utc::now().timestamp_millis().max(0) as u64; + let identity_matches = s.calibration_model_id.as_deref() + == Some(expected_model_id.as_str()) + && s.explicit_calibration_fresh_at(observed_at_unix_ms) + && s.calibration_grid_binding.is_some_and(|binding| { + binding.source_node_id == expected_binding.source_node_id + && binding.grid == expected_binding.grid + }); + let sequence_faulted = s + .calibration_sequence_fault_node_ids + .contains(&expected_binding.source_node_id); + let current_window_size = s + .node_states + .get(&expected_binding.source_node_id) + .map_or(0, |node| node.field_model_history.len()); + ( + identity_matches, + sequence_faulted, + s.field_model_holdout_ready( + expected_binding, + holdout_window_size, + std::time::Instant::now(), + ), + current_window_size, + ) + }; + if sequence_faulted { + return Json(serde_json::json!({ + "success": false, + "error_code": "calibration_sequence_discontinuity", + "error": "The calibration CSI sequence faulted while preparing held out validation. The model was not stored.", + })); + } + if !identity_matches { + return Json(serde_json::json!({ + "success": false, + "error_code": "calibration_model_changed", + "error": "Calibration changed while preparing held out validation. The model was not stored.", + })); + } + if ready { + break; + } + if tokio::time::Instant::now() >= prewarm_deadline { + return Json(serde_json::json!({ + "success": false, + "error_code": "bootstrap_holdout_prewarm_timeout", + "error": "A complete fresh CSI scoring window was not available before the held out validation timeout. The model was not stored.", + "required_window_size": holdout_window_size, + "current_window_size": current_window_size, + })); + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + let mut samples = Vec::with_capacity(BOOTSTRAP_VALIDATION_SAMPLES); - let mut last_tick = None; + let mut last_sequence = None; let mut next_sample_at = tokio::time::Instant::now(); for _ in 0..BOOTSTRAP_VALIDATION_SAMPLES { tokio::time::sleep_until(next_sample_at).await; @@ -7000,20 +7706,40 @@ async fn calibration_promote_bootstrap( let observed_at_unix_ms = chrono::Utc::now().timestamp_millis().max(0) as u64; if s.calibration_model_id.as_deref() != Some(expected_model_id.as_str()) || !s.explicit_calibration_fresh_at(observed_at_unix_ms) + || !s.calibration_grid_binding.is_some_and(|binding| { + binding.source_node_id == expected_binding.source_node_id + && binding.grid == expected_binding.grid + }) + || s.calibration_sequence_fault_node_ids + .contains(&expected_binding.source_node_id) { None } else { - s.latest_update.as_ref().and_then(|update| { - (Some(update.tick) != last_tick).then_some(( - update.tick, - s.person_count() == 0, - update.vital_signs.is_none(), - )) - }) + s.node_states + .get(&expected_binding.source_node_id) + .and_then(|node| { + let sequence = node.field_model_latest_sequence?; + let fresh = node.field_model_latest_seen.is_some_and(|seen| { + std::time::Instant::now().saturating_duration_since(seen) + < ESP32_OFFLINE_TIMEOUT + }) && Some(sequence) != last_sequence; + let held_out = s.field_model_holdout_ready( + expected_binding, + holdout_window_size, + std::time::Instant::now(), + ); + (fresh && held_out).then_some(( + sequence, + s.person_count() == 0, + s.latest_update + .as_ref() + .is_none_or(|update| update.vital_signs.is_none()), + )) + }) } }; - if let Some((tick, calibrated_empty, vital_signs_absent)) = observed { - last_tick = Some(tick); + if let Some((sequence, calibrated_empty, vital_signs_absent)) = observed { + last_sequence = Some(sequence); sample = BootstrapValidationSample { fresh_tick: true, calibrated_empty, @@ -7042,6 +7768,12 @@ async fn calibration_promote_bootstrap( let observed_at_unix_ms = chrono::Utc::now().timestamp_millis().max(0) as u64; if s.calibration_model_id.as_deref() != Some(expected_model_id.as_str()) || !s.explicit_calibration_fresh_at(observed_at_unix_ms) + || !s.calibration_grid_binding.is_some_and(|binding| { + binding.source_node_id == expected_binding.source_node_id + && binding.grid == expected_binding.grid + }) + || s.calibration_sequence_fault_node_ids + .contains(&expected_binding.source_node_id) { return Json(serde_json::json!({ "success": false, @@ -7077,6 +7809,7 @@ async fn calibration_promote_bootstrap( &path, &installation_id, &source_node_ids, + expected_binding.grid.as_bootstrap_grid(), &expected_model_id, created_at_unix_ms, field_model, @@ -7097,6 +7830,7 @@ async fn calibration_promote_bootstrap( "success": true, "message": "Validated startup baseline stored locally.", "validation": validation, + "holdout_window_size": holdout_window_size, "bootstrap_baseline": stored, "calibrated_evidence_authorized": false, "numeric_vitals_authorized": false, @@ -7118,7 +7852,7 @@ async fn calibration_refine_bootstrap( })); } - let (expected_model_id, expected_digest, source_node_id) = { + let (expected_model_id, expected_digest, source_node_id, expected_grid) = { let s = state.read().await; let observed_at_unix_ms = chrono::Utc::now().timestamp_millis().max(0) as u64; let Some(metadata) = s @@ -7153,6 +7887,7 @@ async fn calibration_refine_bootstrap( metadata.source_model_id.clone(), metadata.content_sha256.clone(), *source_node_id, + CsiGridKey::from_bootstrap_grid(metadata.source_grid), ) }; @@ -7175,13 +7910,19 @@ async fn calibration_refine_bootstrap( metadata.source_model_id == expected_model_id && metadata.content_sha256 == expected_digest && metadata.source_node_ids.as_slice() == [source_node_id] + && CsiGridKey::from_bootstrap_grid(metadata.source_grid) + == expected_grid + }) + && s.calibration_grid_binding.is_some_and(|binding| { + binding.source_node_id == source_node_id + && binding.grid == expected_grid }); if !identity_matches { None } else { s.node_states.get(&source_node_id).and_then(|node| { - let sequence = node.latest_csi_sequence?; - let fresh = node.last_frame_time.is_some_and(|seen| { + let sequence = node.field_model_latest_sequence?; + let fresh = node.field_model_latest_seen.is_some_and(|seen| { std::time::Instant::now().saturating_duration_since(seen) < ESP32_OFFLINE_TIMEOUT }) && Some(sequence) != last_sequence; @@ -7190,7 +7931,7 @@ async fn calibration_refine_bootstrap( } let model = s.field_model.as_ref()?; let recent_frames: Vec> = - node.frame_history.iter().cloned().collect(); + node.field_model_history.iter().cloned().collect(); let residual = model.empty_room_match(&recent_frames)?.residual_energy; let vital_signs_absent = s .latest_update @@ -7241,6 +7982,10 @@ async fn calibration_refine_bootstrap( metadata.source_model_id == expected_model_id && metadata.content_sha256 == expected_digest && metadata.source_node_ids.as_slice() == [source_node_id] + && CsiGridKey::from_bootstrap_grid(metadata.source_grid) == expected_grid + }) + && s.calibration_grid_binding.is_some_and(|binding| { + binding.source_node_id == source_node_id && binding.grid == expected_grid }); if !identity_matches { return Json(serde_json::json!({ @@ -7298,6 +8043,7 @@ async fn calibration_refine_bootstrap( &path, &installation_id, &[source_node_id], + expected_grid.as_bootstrap_grid(), &expected_model_id, created_at_unix_ms, &candidate, @@ -7350,6 +8096,7 @@ async fn calibration_cancel(State(state): State) -> Json) -> Json) -> Json) -> Json std::path::PathBuf { initial } +fn validate_startup_calibration_request(calibrate: bool) -> Result<(), &'static str> { + if calibrate { + Err("--calibrate cannot safely select a source and CSI grid at process start. Start the server normally, wait at least 10 seconds for live grid evidence, then POST /api/v1/calibration/start with one explicit source_node_id.") + } else { + Ok(()) + } +} + #[tokio::main] async fn main() { // Initialize tracing; with the `otel` feature and @@ -9510,6 +10285,11 @@ async fn main() { let mut args = Args::parse(); args.ui_path = coalesce_ui_path(args.ui_path); + if let Err(error) = validate_startup_calibration_request(args.calibrate) { + eprintln!("Error: {error}"); + std::process::exit(2); + } + // Handle --benchmark mode: run vital sign benchmark and exit if args.benchmark { eprintln!("Running vital sign detection benchmark (1000 frames)..."); @@ -10455,17 +11235,25 @@ async fn main() { "Default Room", engine_bridge_multistatic_cfg, ), - field_model: if args.calibrate { - info!("Field model calibration enabled — room should be empty during startup"); - FieldModel::new(field_bridge::single_link_config()).ok() - } else { - bootstrap_field_model - }, + field_model: bootstrap_field_model, installation_id: args.installation_id.clone(), - bootstrap_baseline: bootstrap_metadata, + bootstrap_baseline: bootstrap_metadata.clone(), bootstrap_baseline_active, - calibration_model_id: args.calibrate.then(opaque_calibration_model_id), - calibration_source_node_ids: std::collections::BTreeSet::new(), + calibration_model_id: None, + calibration_source_node_ids: bootstrap_metadata + .as_ref() + .map(|metadata| metadata.source_node_ids.iter().copied().collect()) + .unwrap_or_default(), + calibration_grid_binding: bootstrap_metadata.as_ref().and_then(|metadata| { + let [source_node_id] = metadata.source_node_ids.as_slice() else { + return None; + }; + Some(CalibrationGridBinding { + source_node_id: *source_node_id, + grid: CsiGridKey::from_bootstrap_grid(metadata.source_grid), + evidence: None, + }) + }), calibration_last_sequences: HashMap::new(), calibration_reordered_packets: HashMap::new(), calibration_max_reorder_depth: HashMap::new(), From 97578ee401469570adfbc2450abcf3a18d6dda38 Mon Sep 17 00:00:00 2001 From: ruv Date: Sun, 6 Sep 2026 11:51:09 -0400 Subject: [PATCH 14/27] fix(firmware): make host dead stripping portable --- firmware/esp32-csi-node/test/Makefile | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/firmware/esp32-csi-node/test/Makefile b/firmware/esp32-csi-node/test/Makefile index 19c82dbe..f0714557 100644 --- a/firmware/esp32-csi-node/test/Makefile +++ b/firmware/esp32-csi-node/test/Makefile @@ -39,6 +39,15 @@ CFLAGS = -fsanitize=fuzzer,address,undefined -g -O1 \ STUBS_SRC = stubs/esp_stubs.c MAIN_DIR = ../main +# GNU ld and Apple's ld spell dead section elimination differently. +# Keep the host sanitation test portable across the Linux CI runner and macOS. +UNAME_S := $(shell uname -s) +ifeq ($(UNAME_S),Darwin) +DEAD_STRIP_LDFLAGS := -Wl,-dead_strip +else +DEAD_STRIP_LDFLAGS := -Wl,--gc-sections +endif + # Default fuzz duration (seconds) and jobs FUZZ_DURATION ?= 30 FUZZ_JOBS ?= 1 @@ -55,7 +64,7 @@ all: fuzz_serialize fuzz_edge fuzz_nvs test_adr110 test_vitals test_mmwave_detec # unrelated device-only callback code out of this host executable. test_csi_sanitize: test_csi_sanitize.c $(MAIN_DIR)/csi_collector.c $(STUBS_SRC) cc -std=c99 -Wall -Wextra -Istubs -I$(MAIN_DIR) -ffunction-sections \ - $^ -Wl,-dead_strip -o $@ -lm + $^ $(DEAD_STRIP_LDFLAGS) -o $@ -lm run_csi_sanitize: test_csi_sanitize ./test_csi_sanitize From 86e45980fef93bfdb13859528dd124bcc4775dd9 Mon Sep 17 00:00:00 2001 From: Joe Date: Mon, 7 Sep 2026 08:15:00 -0400 Subject: [PATCH 15/27] chore: ignore host-test binaries by shape, not by a list that goes stale `make host_tests` writes its binaries beside their sources, inside a tracked directory. .gitignore named five of them individually, and the list had fallen behind the Makefile: test_csi_sanitize, test_mmwave_detect and test_thermal were built by the default target, ignored by nothing, and got committed by accident on two contribution branches. Enumerating targets cannot hold -- every new host test needs someone to remember this file. So ignore by shape instead: in that directory every source carries an extension and no binary does. firmware/esp32-csi-node/test/test_* firmware/esp32-csi-node/test/fuzz_* !firmware/esp32-csi-node/test/*.* Verified rather than reasoned about. After building every host test, `git status --porcelain` reports only this file. `git check-ignore` confirms all ten binaries are ignored -- including test_edge_grid_he and test_edge_grid_pre_he, which are not on main yet -- while the .c sources, the Makefile, the README and the corpus seeds stay tracked. Co-Authored-By: claude-flow Claude-Session: https://claude.ai/code/session_01BHNNWbmKvxXrVaEDwXEBNo --- .gitignore | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/.gitignore b/.gitignore index 5197ca4d..a22f7700 100644 --- a/.gitignore +++ b/.gitignore @@ -17,11 +17,16 @@ firmware/esp32-csi-node/sdkconfig.defaults.bak firmware/esp32-hello-world/sdkconfig.old # Host-built firmware test binaries (compiled from test/*.c, not source) -firmware/esp32-csi-node/test/test_adr110 -firmware/esp32-csi-node/test/test_vitals -firmware/esp32-csi-node/test/fuzz_serialize -firmware/esp32-csi-node/test/fuzz_edge -firmware/esp32-csi-node/test/fuzz_nvs +# Compiled host-test and fuzz binaries. The Makefile writes them beside their +# sources, so they land in a tracked directory. Ignore them by SHAPE rather +# than by name: every source here carries an extension and no binary does. +# +# This replaced a hand-maintained list of five targets, which had gone stale -- +# test_csi_sanitize, test_mmwave_detect and test_thermal were all built by +# `make host_tests`, ignored by nothing, and got committed by accident. +firmware/esp32-csi-node/test/test_* +firmware/esp32-csi-node/test/fuzz_* +!firmware/esp32-csi-node/test/*.* firmware/esp32-csi-node/test/*.exe firmware/esp32-csi-node/test/*.obj From 11c014485eee612298c616e522001e6b111667c1 Mon Sep 17 00:00:00 2001 From: Joe Date: Mon, 7 Sep 2026 09:57:16 -0400 Subject: [PATCH 16/27] fix(server): recover the true CSI frame rate through bursty UDP delivery `csi_fps_ema` currently under-reports whenever more than one frame arrives in a burst, and the under-report is proportional to the burst size. The present scheme rejects sub-5 ms deltas and, crucially, does NOT re-anchor on them. Holding the anchor means the next accepted delta spans the whole burst while contributing a single sample -- so a burst carrying N frames is counted as one. That is invisible when a burst holds one frame, which is the case the current comment describes, and an exact halving when it holds two. Two changes, both in the same direction: - Average in the DELTA domain and invert, rather than averaging 1/dt. The reciprocal is unbounded as dt approaches zero, so one 36 us arrival can outweigh hundreds of nominal ones; an average of deltas cannot be dominated that way. This is what makes it safe to stop discarding short deltas. - Re-anchor on every arrival. One delta per frame means the deltas sum to the elapsed time, which is the condition that makes the estimator equal frames/elapsed. Simulated against the same arrival stream, a node genuinely producing 40 fps whose frames are delivered in pairs (four frames per 100 ms): clean 40 fps, evenly delivered before 40.0 after 40.0 bursty 40 fps, pairs per 50 ms before 20.0 after 40.6 The clean-cadence control is unchanged; the bursty case stops halving. MAX_PHYSICAL_CSI_FPS is retained and still enforced at the consumption boundary in `measured_sample_rate_hz`, so restored or malformed state cannot overclock the DSP math. The estimator itself is left unclamped so that a rate above the ceiling remains visible as the anomaly it is rather than being silently flattened to 50. TRADE-OFF, stated plainly: alpha moves from 1/8 to 1/32, so the estimate is about three times slower to settle after a genuine rate change. That is the price of not letting a single short delta swing it, and the steady-state test horizon is widened to match. Tests: `observe_csi_frame_arrival_ignores_subms_bursts` is replaced. It fed three arrivals per 25 ms group -- 120 frames per second, which no single node can emit under the firmware's own 50 fps send ceiling -- and asserted the result should read 40, which only holds if two of every three real frames are discarded. The replacement uses a physically consistent stream and asserts the same 40 fps ground truth. Full suite passes. Co-Authored-By: claude-flow Claude-Session: https://claude.ai/code/session_01BHNNWbmKvxXrVaEDwXEBNo --- .../wifi-densepose-sensing-server/src/main.rs | 173 +++++++++++------- 1 file changed, 103 insertions(+), 70 deletions(-) diff --git a/v2/crates/wifi-densepose-sensing-server/src/main.rs b/v2/crates/wifi-densepose-sensing-server/src/main.rs index b843efce..903c9b07 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/main.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/main.rs @@ -990,9 +990,14 @@ const CALIBRATION_GRID_MAX_GAP_S: f64 = 5.0; /// straight into the EMA and inflated `csi_fps_ema` by 1–3 orders of /// magnitude (issue #1180). We reject sub-5 ms deltas as burst artifacts and /// cap accepted estimates to the firmware's 50 fps physical ceiling. -pub(crate) const MIN_PLAUSIBLE_CSI_DT_SEC: f64 = 0.005; +pub(crate) const MAX_PLAUSIBLE_CSI_DT_SEC: f64 = 1.0; pub(crate) const MAX_PHYSICAL_CSI_FPS: f64 = 50.0; +/// Smoothing factor for the inter-frame delta EMA. 1/32 at ~40 fps is roughly +/// a one-second window: long enough to ride out burst structure, short enough +/// to follow a node whose rate genuinely changes. +const CSI_FPS_EMA_ALPHA: f64 = 1.0 / 32.0; + /// ADR-110 iter 18 — EMA update for per-node CSI fps tracking. /// /// Returns the new EMA value, or `None` if the delta is implausible @@ -1004,85 +1009,109 @@ pub(crate) const MAX_PHYSICAL_CSI_FPS: f64 = 50.0; /// Free function for testability — every transformation that doesn't /// touch the rest of `NodeState` lives outside the `impl` block. pub(crate) fn update_csi_fps_ema(prev_fps: f64, dt_sec: f64) -> Option { - if !(dt_sec >= MIN_PLAUSIBLE_CSI_DT_SEC && dt_sec < 1.0) { + if !(dt_sec > 0.0 && dt_sec < MAX_PLAUSIBLE_CSI_DT_SEC) { return None; } - let instantaneous = (1.0 / dt_sec).min(MAX_PHYSICAL_CSI_FPS); - // y[n] = y[n-1] + (x - y[n-1]) / 8 - Some(prev_fps + (instantaneous - prev_fps) / 8.0) + if !(prev_fps.is_finite() && prev_fps > 0.0) { + return None; + } + // Smooth in the DELTA domain: y[n] = y[n-1] + alpha (dt - y[n-1]), then + // invert. Averaging 1/dt instead lets one short delta dominate the mean -- + // the reciprocal is unbounded as dt approaches zero, so a single 36 us + // arrival outweighs hundreds of nominal ones. Averaging dt is bounded by + // construction, and the deltas sum to the elapsed time, which is what + // makes the result equal frames/elapsed. + let prev_dt = 1.0 / prev_fps; + let dt_ema = prev_dt + (dt_sec - prev_dt) * CSI_FPS_EMA_ALPHA; + if dt_ema <= 0.0 { + return None; + } + Some(1.0 / dt_ema) } #[cfg(test)] mod fps_ema_tests { - use super::update_csi_fps_ema; + use super::{update_csi_fps_ema, MAX_PHYSICAL_CSI_FPS}; #[test] fn steady_10hz_converges_toward_10() { + // The delta-domain EMA uses alpha = 1/32 where the reciprocal-domain + // one used 1/8, so it is deliberately about three times slower to + // settle. That is the cost of not letting a single short delta swing + // the estimate; the horizon here is sized for it. let mut fps = 20.0; - for _ in 0..40 { + for _ in 0..160 { fps = update_csi_fps_ema(fps, 0.100).unwrap(); } - assert!((fps - 10.0).abs() < 0.1, - "expected ~10 Hz after 40 samples at 100 ms intervals, got {fps}"); - } - - #[test] - fn steady_20hz_stays_near_20() { - let mut fps = 20.0; - for _ in 0..20 { - fps = update_csi_fps_ema(fps, 0.050).unwrap(); - } - assert!((fps - 20.0).abs() < 0.05, "expected ~20 Hz, got {fps}"); + assert!( + (fps - 10.0).abs() < 0.1, + "expected ~10 Hz at 100 ms intervals, got {fps}" + ); } #[test] fn nonpositive_dt_rejected() { - assert!(update_csi_fps_ema(15.0, 0.0).is_none()); - assert!(update_csi_fps_ema(15.0, -0.1).is_none()); + assert!(update_csi_fps_ema(40.0, 0.0).is_none()); + assert!(update_csi_fps_ema(40.0, -0.001).is_none()); } #[test] fn long_gap_rejected_as_implausible() { - assert!(update_csi_fps_ema(20.0, 2.0).is_none()); + assert!(update_csi_fps_ema(40.0, 1.5).is_none()); } #[test] - fn subms_burst_delta_rejected() { - // Issue #1180: a 36 µs intra-burst delta implies ~27 kHz and must - // not enter the EMA. Anything below the 5 ms floor is rejected. - assert!(update_csi_fps_ema(40.0, 0.000_036).is_none()); - assert!(update_csi_fps_ema(40.0, 0.001).is_none()); - // Just above the floor is accepted. - assert!(update_csi_fps_ema(40.0, 0.005).is_some()); + fn nonsense_previous_value_rejected() { + assert!(update_csi_fps_ema(0.0, 0.025).is_none()); + assert!(update_csi_fps_ema(f64::NAN, 0.025).is_none()); } #[test] - fn accepted_burst_edge_is_capped_to_firmware_ceiling() { - let mut fps = 50.0; - for _ in 0..32 { - fps = update_csi_fps_ema(fps, 0.005).unwrap(); - } - assert!(fps <= 50.0, "reported {fps} Hz above firmware ceiling"); - } - - #[test] - fn burst_interleaved_with_nominal_stays_in_band() { - // A true ~40 fps node whose frames arrive in sub-ms bursts: feeding - // only the plausible (nominal-cadence) deltas keeps the EMA near the - // ground truth instead of blowing up. Burst deltas are rejected by - // the caller (see NodeState::observe_csi_frame_arrival), so the EMA - // only ever sees the ~25 ms inter-group gaps. + fn a_single_burst_delta_cannot_dominate_the_estimate() { + // Issue #1180. One 36 us arrival among nominal ones. Averaging 1/dt + // put ~27 kHz into the mean; averaging dt cannot, because a delta that + // small barely moves an average of deltas. let mut fps = 40.0; - for _ in 0..40 { - // nominal 25 ms gap (40 fps); intervening sub-ms bursts skipped + for _ in 0..64 { fps = update_csi_fps_ema(fps, 0.025).unwrap(); - assert!(update_csi_fps_ema(fps, 0.000_040).is_none()); + } + let before = fps; + fps = update_csi_fps_ema(fps, 0.000_036).unwrap(); + assert!( + fps < before * 1.05, + "one burst delta moved the estimate from {before} to {fps}" + ); + } + + #[test] + fn bursty_delivery_recovers_the_true_production_rate() { + // A node genuinely producing 40 fps whose frames are delivered in + // pairs ~40 us apart every 50 ms: four frames per 100 ms. + // + // Rejecting the intra-burst delta and holding the anchor measures the + // 50 ms gap between bursts but counts one frame for it, reading 20 -- + // exactly half. Averaging every delta keeps one term per frame and + // recovers 40. + let mut fps = 40.0; + for _ in 0..400 { + fps = update_csi_fps_ema(fps, 0.000_040).unwrap(); + fps = update_csi_fps_ema(fps, 0.049_960).unwrap(); } assert!( - (fps - 40.0).abs() < 1.0, - "EMA should stay within ~1 Hz of the 40 fps ground truth, got {fps}" + (fps - 40.0).abs() < 2.0, + "expected ~40 fps through burst delivery, got {fps}" ); } + + #[test] + fn the_physical_ceiling_still_bounds_what_is_consumed() { + // The estimator itself is unclamped; MAX_PHYSICAL_CSI_FPS is enforced + // at the consumption boundary (measured_sample_rate_hz) so restored or + // malformed state cannot overclock the DSP math. + assert_eq!(MAX_PHYSICAL_CSI_FPS, 50.0); + let clamped = 9_000.0_f64.clamp(1.0, MAX_PHYSICAL_CSI_FPS); + assert_eq!(clamped, 50.0); + } } impl NodeState { @@ -1206,15 +1235,18 @@ impl NodeState { let first_sensing_frame = self.last_frame_time.is_none(); if let Some(prev) = self.last_frame_time { let dt = now.duration_since(prev).as_secs_f64(); - // Burst arrivals (sub-floor dt, issue #1180): do NOT re-anchor on - // them. Keeping the previous anchor means the next genuine - // inter-frame gap measures the true cadence across the whole - // burst instead of intra-burst jitter — so a 50 fps node whose - // frames arrive in 36 µs bursts every 25 ms still reads ~40 fps, - // not 27 kHz. - if dt < MIN_PLAUSIBLE_CSI_DT_SEC { - return false; - } + // Re-anchor on EVERY arrival, including intra-burst ones. + // + // Holding the anchor across a burst measures the interval BETWEEN + // bursts while counting only one frame for it, so a burst carrying + // N frames is under-counted N-fold. That is invisible when a burst + // happens to hold one frame, and an exact halving when it holds + // two. Anchoring every frame keeps one delta per frame, and the + // deltas then sum to the elapsed time -- the condition that makes + // the mean-of-dt estimator equal frames/elapsed. + // + // The unbounded 1/dt blow-up this replaces is handled in + // update_csi_fps_ema by averaging dt rather than its reciprocal. if let Some(new_ema) = update_csi_fps_ema(self.csi_fps_ema, dt) { self.csi_fps_ema = new_ema; self.csi_fps_samples = self.csi_fps_samples.saturating_add(1); @@ -11909,31 +11941,32 @@ mod sync_snapshot_helper_tests { } #[test] - fn observe_csi_frame_arrival_ignores_subms_bursts() { - // Issue #1180 regression: a ~40 fps node whose frames are delivered - // in tight UDP bursts (sub-ms intra-burst deltas) must still report - // ~40 fps, not tens of kHz. Synthesize the arrival stream by adding - // Durations to a base Instant. + fn observe_csi_frame_arrival_recovers_rate_through_udp_bursts() { + // Issue #1180. A node genuinely producing 40 fps whose frames reach + // the socket in pairs: two arrivals ~40 us apart, then the rest of a + // 50 ms period. Four frames per 100 ms is 40 fps. + // + // The scenario is chosen to be physically consistent with the + // firmware's own 50 fps send ceiling. Counting three arrivals per + // 25 ms group would describe 120 frames per second, which no single + // node can emit, so an estimator tuned to report 40 for that stream is + // tuned to discard real frames. use std::time::Duration; let base = std::time::Instant::now(); let mut ns = NodeState::new(); ns.csi_fps_ema = 40.0; // pretend already warmed up ns.csi_fps_samples = 10; - // 30 nominal 25 ms groups, each preceded by a 3-frame sub-ms burst. - for g in 0..30u64 { - let group_t = base + Duration::from_millis(25 * g); + for g in 0..200u64 { + let group_t = base + Duration::from_millis(50 * g); ns.observe_csi_frame_arrival(group_t); - // burst: two extra arrivals 40 µs and 80 µs later — must be - // ignored for rate purposes (anchor must not advance to them). ns.observe_csi_frame_arrival(group_t + Duration::from_micros(40)); - ns.observe_csi_frame_arrival(group_t + Duration::from_micros(80)); } assert!( (ns.csi_fps_ema - 40.0).abs() < 2.0, - "csi_fps_ema must stay near the 40 fps ground truth despite \ - sub-ms bursts, got {}", + "csi_fps_ema must recover the 40 fps ground truth through burst \ + delivery, got {}", ns.csi_fps_ema ); } From 9af1cc66443d1f69a05b983d82757b2ee612c944 Mon Sep 17 00:00:00 2001 From: Joe Date: Mon, 7 Sep 2026 12:26:55 -0400 Subject: [PATCH 17/27] docs(firmware): add the ESP32-C6 operational runbook README.md explains the project; there was nothing that said what to run, in what order, and what to check. This is that, written from the procedures that were actually used on a nine-node C6 fleet. Everything in it was learned by getting it wrong at least once, and the failures it prevents are the silent kind: - The build must concatenate THREE defaults files. IDF discovers sdkconfig.defaults and sdkconfig.defaults. on its own but NOT sdkconfig.defaults.16mb, which is where CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE lives. Build without it and the image is correct in every visible respect and cannot roll back a bad OTA, while ota_rollback_boot_check() sits in the app expecting the capability. - A five-key pre-flash gate, because a stale sdkconfig reading FLASHSIZE=4MB against a 16MB fleet produces an image that silently is not the configuration under test. - What OTA can and cannot reach, per flash region. Bootloader and partition table are USB-only, and there is no bootloader version on the wire -- so "every node reports health" proves the app is current and proves nothing about the bootloader. - A single-board diagnostic flash with a revert path: parse otadata first, back up the 8 KB, and reverting is writing it back rather than rebuilding. - min_heap_kib is a minimum-since-boot watermark, not an instantaneous reading. Sampling it within a minute of a restart produced a wrong "no effect" verdict once; the real answer, once settled, was -56 KiB. - /api/v1/nodes rssi_dbm is not the AP link and swings 20+ dB with no physical change. No code changes. One file. Co-Authored-By: claude-flow Claude-Session: https://claude.ai/code/session_01BHNNWbmKvxXrVaEDwXEBNo --- firmware/esp32-csi-node/RUNBOOK.md | 200 +++++++++++++++++++++++++++++ 1 file changed, 200 insertions(+) create mode 100644 firmware/esp32-csi-node/RUNBOOK.md diff --git a/firmware/esp32-csi-node/RUNBOOK.md b/firmware/esp32-csi-node/RUNBOOK.md new file mode 100644 index 00000000..b67f952a --- /dev/null +++ b/firmware/esp32-csi-node/RUNBOOK.md @@ -0,0 +1,200 @@ +# Firmware runbook — ESP32-C6 CSI node + +Operational procedure. `README.md` explains the project; this says exactly what +to run, in what order, and what to check. Follow it top to bottom. + +If you are an agent: **read this file before touching firmware.** Everything +below was learned by getting it wrong at least once. + +--- + +## 1. Build + +**One command. Copy it.** From the **repository root**, not this directory: + +```bash +MSYS_NO_PATHCONV=1 docker run --rm \ + -v "$(pwd)/firmware/esp32-csi-node:/project" -w /project \ + espressif/idf:v5.4 bash -c \ + "cat sdkconfig.defaults sdkconfig.defaults.16mb sdkconfig.defaults.esp32c6 \ + > sdkconfig.defaults.build && \ + SDKCONFIG_DEFAULTS='sdkconfig.defaults.build' idf.py set-target esp32c6 && \ + idf.py build" +``` + +Takes ~3 minutes cold, well under a minute incremental. + +### The three defaults files are NOT all automatic + +IDF picks up `sdkconfig.defaults` and `sdkconfig.defaults.` on its own. +It does **not** pick up `sdkconfig.defaults.16mb`, and that file is where +`CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE=y` lives. + +Build without it and you get an image that is correct in every visible respect +and **cannot roll back a bad OTA** — while `ota_rollback_boot_check()` sits in +the app expecting the capability. Silent, and only discoverable by pushing a +deliberately bad image. + +That is why the command above concatenates all three explicitly rather than +relying on discovery. + +### Never `rm -rf sdkconfig` without a backup + +The README's headline example opens with `rm -rf build sdkconfig`. `sdkconfig` +is **untracked and gitignored**. `partitions_16mb.csv` was lost to exactly this, +and `CLAUDE.md` warns about it *even when a README documents it*. + +```bash +cp sdkconfig "sdkconfig.backup-$(date +%Y%m%d-%H%M%S)" # then regenerate +``` + +A stale `sdkconfig` is a real hazard, not a hypothetical: one found on +2026-09-03 said `FLASHSIZE=4MB`, `partitions_4mb.csv`, +`DYNAMIC_TX_BUFFER_NUM=64` — against a fleet running 16MB and 128. Anything +built from it would silently not be the configuration under test. + +### Verify before you flash anything + +```bash +grep -E "^CONFIG_(IDF_TARGET|ESPTOOLPY_FLASHSIZE|PARTITION_TABLE_CUSTOM_FILENAME|\ +ESP_WIFI_DYNAMIC_TX_BUFFER_NUM|BOOTLOADER_APP_ROLLBACK_ENABLE)=" sdkconfig +``` + +All five must read: + +| key | expected | +|---|---| +| `CONFIG_IDF_TARGET` | `"esp32c6"` | +| `CONFIG_ESPTOOLPY_FLASHSIZE` | `"16MB"` | +| `CONFIG_PARTITION_TABLE_CUSTOM_FILENAME` | `"partitions_16mb.csv"` | +| `CONFIG_ESP_WIFI_DYNAMIC_TX_BUFFER_NUM` | `128` | +| `CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE` | `y` | + +**Do not use the native `C:\Espressif\...esp-idf-v5.5.5`.** Wrong version, its +venv does not match the default python, and a container-built `build/` refuses +a native build outright. Docker only. + +--- + +## 2. What OTA can and cannot reach + +This is the distinction that decides whether a change needs a cable. + +| region | offset | reachable by OTA? | +|---|---|---| +| bootloader | `0x0` | **NO — USB only** | +| partition table | `0x8000` | **NO — USB only** | +| otadata | `0xF000` | rewritten by OTA | +| ota_0 | `0x20000` | yes (4 MB) | +| ota_1 | `0x420000` | yes (4 MB) | +| coredump | `0x820000` | no | +| storage (fat) | `0x830000` | no | +| nvs | `0x9000` | never written by a flash — provisioning survives | + +**OTA replaces the app partition only.** Anything in the bootloader — +`CONFIG_BOOTLOADER_*`, notably `APP_ROLLBACK_ENABLE` — or any partition-table +change **requires USB on every board**. + +There is **no bootloader version on the wire**. A node's sync packet reports app +health, not bootloader capability, so you cannot determine remotely which boards +have a rollback-capable bootloader. Track it per board, or do a USB pass across +all of them. + +Corollary: "every node reports health" proves the **app** is current and proves +nothing about the bootloader. Do not answer a bootloader question with app +evidence. + +--- + +## 3. Deploying + +### Over the air — app-only changes + +Works end-to-end and is the default for anything that is not a bootloader or +partition change. Roll to **one node**, soak it, then the rest. + +### Over USB — bootloader, partition table, or recovery + +```bash +python -m esptool --chip esp32c6 -b 460800 \ + --before default_reset --after hard_reset write_flash \ + --flash_mode dio --flash_size 16MB --flash_freq 80m \ + 0x0 build/bootloader/bootloader.bin \ + 0x8000 build/partition_table/partition-table.bin \ + 0xf000 build/ota_data_initial.bin \ + 0x20000 build/esp32-csi-node.bin +``` + +`0x9000` is not in that list, so SSID, password and `node_id` survive. + +### Safe single-board diagnostic flash, with a revert path + +Nodes run from whichever slot `otadata` selects. **Parse it before flashing** — +two 32-byte entries at offsets 0 and 0x1000; active slot is +`(max ota_seq - 1) % 2`. + +1. Back up `otadata` (8 KB) **and** the live app slot with `esptool read-flash`. +2. Flash the diagnostic build — it lands in `ota_0` and rewrites `otadata`. +3. **Revert = write the 8 KB `otadata` backup back.** Production in `ota_1` was + never touched, so no rebuild is needed. + +Validate any backup image before trusting it: byte 0 must be `0xE9`, and the +`esp_app_desc_t` magic at offset `0x20` must be `0xABCD5432`. + +**Never flash the whole fleet from a workstation, and never use the server's OTA +endpoint for a diagnostic.** USB, one board, revert path secured first. + +--- + +## 4. Serial + +CH343 USB-serial at 115200. + +To read a running board **without resetting it**, open with +`DtrEnable=$false; RtsEnable=$false`. esptool asserts RTS and *will* reset the +board, which loses the state you were trying to observe. + +--- + +## 5. Changing config without a cable + +Most parameters do not need a flash at all: + +```bash +export RUVIEW_OTA_PSK_FILE= # never inline the key +python config_push.py --node --get +python config_push.py --node --set led_brightness=10 +``` + +Keys marked `assoc: true` in `config_api.c` (`wifi_ssid`, `wifi_password`, +`channel_hop_count`, `dwell_ms`, `csi_channel`) are applied as a **trial**: the +node banks its current values, reboots, and keeps the change only if it +re-associates. Everything else applies without that risk. + +`config_push.py` has **no unset**. To restore a default you must set it +explicitly. Current defaults: + +| key | default | source | +|---|---|---| +| `vital_interval_ms` | 1000 | `Kconfig.projbuild` | +| `swarm_heartbeat_sec` | 30 | `nvs_config.c` | +| `swarm_ingest_sec` | 5 | `nvs_config.c` | +| `beacon_period_ms` | 0 = derive from fleet size | `nvs_config.c` | + +--- + +## 6. Reading a node remotely + +`GET /api/v1/mesh` returns per-node `health`: `min_heap_kib`, `die_c`, +`thermal_state`, `tx_dbm`, `reset_reason`, and from sync proto v3 the TX-path +counters `send_fail` / `rate_skip` / `early_drop`. + +`min_heap_kib` is a **minimum-since-boot watermark**, not an instantaneous +reading. It resets on reboot and only ever falls. Sampling it within a minute of +a restart tells you nothing — that mistake produced a wrong "no effect" verdict +on 2026-09-03; the real answer, once it had settled, was −56 KiB. + +`/api/v1/nodes` `rssi_dbm` is **not** the AP link — it is whatever link landed +most recently and swings 20+ dB with no physical change. Filter `/api/v1/links` +by the AP's BSSIDs instead, and read `csi_fps_ema` alongside, because link count +alone hides a starved node. From e8e4e5cf67f18fcd3c4b7db4409466598eb93371 Mon Sep 17 00:00:00 2001 From: Joe Date: Mon, 7 Sep 2026 12:28:57 -0400 Subject: [PATCH 18/27] fix(firmware): define the Kconfig options this gate is guarded by csi_collector.c guards the mesh-aligned gate on CONFIG_CSI_GATE_MESH_ALIGNED, but nothing defined it. Upstream's Kconfig.projbuild has no such symbol and this branch did not add one, so the #ifdef would never be true and the gate would ship dead: present in the diff, absent from the binary, and silent about it. The A/B this PR offers would have compared the old gate with the old gate. Adds CSI_GATE_MESH_ALIGNED (default y, the gate) and CSI_SEQ_DIAG (default n, the probe that measures its effect rather than assuming it). Co-Authored-By: claude-flow Claude-Session: https://claude.ai/code/session_01BHNNWbmKvxXrVaEDwXEBNo --- firmware/esp32-csi-node/main/Kconfig.projbuild | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/firmware/esp32-csi-node/main/Kconfig.projbuild b/firmware/esp32-csi-node/main/Kconfig.projbuild index 6f4f5bfd..aa3ccaaa 100644 --- a/firmware/esp32-csi-node/main/Kconfig.projbuild +++ b/firmware/esp32-csi-node/main/Kconfig.projbuild @@ -517,3 +517,20 @@ menu "Onboard LED (ADR-183)" colormap, in milli-units (250 = 0.25). Lower = more sensitive (reaches yellow with less motion). endmenu + +config CSI_GATE_MESH_ALIGNED + bool "Align the CSI rate gate to mesh time (enables cross-node pairing)" + default y + help + The per-node elapsed gate ("20 ms since MY last accept") gives every node + an independent phase, so two healthy nodes at 50 Hz accept disjoint + frames and nothing can be paired across them. This aligns the 20 ms + window to mesh time so nodes choose the same windows. + +config CSI_SEQ_DIAG + bool "Log (source MAC, rx_seq) for every CSI callback (ADR-345 phase 2 probe)" + default n + help + Diagnostic. Emits the transmitter address and 802.11 sequence number for + each CSI callback so the effect of the gate above can be measured rather + than assumed. Verbose; leave off in normal operation. From 0fd1c635633515180294155980410371a36e578d Mon Sep 17 00:00:00 2001 From: Joe Date: Mon, 7 Sep 2026 12:43:35 -0400 Subject: [PATCH 19/27] docs(firmware): document the tunables, and which ones depend on the house The Kconfig options were discoverable only by opening menuconfig and reading help text, which requires already knowing they exist. Two of them -- CSI_GATE_MESH_ALIGNED and the thermal throttle pair -- have no universally right value: they depend on walls, transmitters, enclosure and placement. Lists all eight with their defaults, marks which are house-dependent, and says what to measure for each. Notes explicitly that in one measured fleet window alignment bought +0.5 pp of cross-node pairing while rx_seq selection bought +31.7 pp, so nobody inherits the assumption that the default is doing the work. Co-Authored-By: claude-flow Claude-Session: https://claude.ai/code/session_01BHNNWbmKvxXrVaEDwXEBNo --- firmware/esp32-csi-node/RUNBOOK.md | 51 ++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/firmware/esp32-csi-node/RUNBOOK.md b/firmware/esp32-csi-node/RUNBOOK.md index b67f952a..21777b59 100644 --- a/firmware/esp32-csi-node/RUNBOOK.md +++ b/firmware/esp32-csi-node/RUNBOOK.md @@ -198,3 +198,54 @@ on 2026-09-03; the real answer, once it had settled, was −56 KiB. most recently and swings 20+ dB with no physical change. Filter `/api/v1/links` by the AP's BSSIDs instead, and read `csi_fps_ema` alongside, because link count alone hides a starved node. + +--- + +## 7. Tunables, and which ones depend on your house + +`idf.py menuconfig` exposes these. The defaults are what a nine-node ESP32-C6 +fleet in one house settled on; several are **not** universal, and the point of +listing them here is that you can pick and measure rather than inherit someone +else's building. + +| option | default | depends on your house? | +|---|---|---| +| `CSI_GATE_MESH_ALIGNED` | y | **yes** | +| `CSI_SEQ_DIAG` | n | diagnostic only | +| `THERMAL_MONITOR` | y | no | +| `THERMAL_THROTTLE` | n | **yes** | +| `THERMAL_THROTTLE_C` | 72 | **yes** | +| `THERMAL_HYSTERESIS_C` | 8 | no | +| `UPLINK_WATCHDOG` | y | no | +| `UPLINK_WATCHDOG_TIMEOUT_S` | 1200 | **yes** | + +### The ones worth measuring yourself + +**`CSI_GATE_MESH_ALIGNED`** decides whether nodes keep the *same* frames or +merely the same *number* of frames, which is what makes cross-node pairing +possible at all. Whether alignment helps depends on how much your nodes +actually overhear in common, and that is a property of your walls and your +transmitters. In one measured nine-node fleet, aligning windows bought ++0.5 pp of pairing while selecting frames by `rx_seq` bought +31.7 pp -- so do +not assume the default is doing the heavy lifting in your building. + +Turn on `CSI_SEQ_DIAG` to measure it. It logs `(source MAC, rx_seq)` per CSI +callback, which is what lets you compute the fraction of frames two nodes hold +in common instead of guessing. + +**`THERMAL_THROTTLE` / `THERMAL_THROTTLE_C`** depend on enclosure and placement, +not on the chip. A bare bench board never reaches 72 C; the same board sealed in +a printed case on a sunny wall can. Read `die_c` from `/api/v1/mesh` for a few +days before deciding, because throttling transmit power costs range. + +**`UPLINK_WATCHDOG_TIMEOUT_S`** is a bet about your AP. 1200 s suits a network +where a wedge is rare and a reboot is cheap. If your uplink drops briefly and +often, a short timeout turns a recoverable gap into a reboot loop; if it wedges +hard, a long one leaves a node dark for twenty minutes. + +### Measuring rather than guessing + +A gate comparison needs a fixed environment, repeated blocks, and every arm run +on every node -- otherwise node placement is confounded with the setting. The +harness used for the published comparison is in the repository; use it rather +than eyeballing a frame rate, which moves for reasons unrelated to the setting. From ec9da648b80709363d261881e5a1784cd509d52b Mon Sep 17 00:00:00 2001 From: Joe Date: Mon, 7 Sep 2026 12:46:35 -0400 Subject: [PATCH 20/27] docs(firmware): add the gate comparison as a worked example of house-dependence The tunables table says which options depend on the building. This shows what that means with real numbers, and is explicit that they are ours. Nine-node C6 fleet, 2x2 factorial, three Latin-square blocks: seq selection doubled cross-node pairing (0.5605 vs 0.2434, +31.7 pp, ~70x the drift floor) while mesh-time alignment bought +0.5 pp, inside noise. The point is the reasoning, not the ranking. It records WHY that happened here -- these nodes already overheard 72% of transmissions in common, so there was a large shared pool for seq to select from and little left for alignment to recover -- and says plainly that a house with fewer shared transmitters or thicker walls has less for seq to work with and may find alignment matters more. We did not measure such a house and do not claim to know. Also keeps the trade (pairing bought with raw frame rate; edge processing unaffected because separately rate-limited, so the cost lands on the uplink) and the prediction we got wrong: seq is NOT a workaround for broken time sync, which means repairing our mesh split would not have closed the gap. Co-Authored-By: claude-flow Claude-Session: https://claude.ai/code/session_01BHNNWbmKvxXrVaEDwXEBNo --- firmware/esp32-csi-node/RUNBOOK.md | 52 ++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/firmware/esp32-csi-node/RUNBOOK.md b/firmware/esp32-csi-node/RUNBOOK.md index 21777b59..774a15bd 100644 --- a/firmware/esp32-csi-node/RUNBOOK.md +++ b/firmware/esp32-csi-node/RUNBOOK.md @@ -243,6 +243,58 @@ where a wedge is rare and a reboot is cheap. If your uplink drops briefly and often, a short timeout turns a recoverable gap into a reboot loop; if it wedges hard, a long one leaves a node dark for twenty minutes. +### A worked example: the same two options, in one house + +MEASURED on a nine-node ESP32-C6 fleet in a single detached house. **These are +our numbers, not yours.** They are here to show the shape of the decision, and +how much a building can move it. + +Four arms as a 2x2 factorial, 20-minute runs, three Latin-square blocks so slow +drift landed on every arm equally. Metric is the fraction of transmissions held +in common by two or more nodes. + +| arm | mean | +|---|---| +| `elapsed` (per-node gate) | 0.2434 | +| `mesh` (aligned windows) | 0.2480 | +| `seq` (keep `rx_seq` divisible by 8) | **0.5605** | +| `mesh` + `seq` | 0.5452 | + +`seq` roughly doubled pairing, +31.7 pp, about 70x the measured drift floor and +reproduced in all three blocks. Mesh alignment bought **+0.5 pp** -- inside +noise. Stacking them was very slightly *worse* than `seq` alone. + +**Why that happened here, and why yours may differ.** These nodes already +overheard a great deal in common -- two boards side by side heard the same +transmissions 72% of the time -- so there was a large pool of shared +transmissions for `seq` to select from, and little left for window alignment to +recover. A house with fewer shared transmitters, thicker interior walls, or +nodes spread so that few pairs hear the same radios has less for `seq` to work +with, and phase alignment may matter more rather than less. We did not measure +such a house and do not claim to know. + +**It is a trade, not a free win.** At period 8 the `seq` arms carried ~75-95k +transmissions per 20 minutes against ~120-160k ungated: pairing is bought with +raw frame rate. Absolute paired transmissions per minute still rose (2629 vs +2524), because the discarded frames are disproportionately ones only one node +would have kept. Whether that trade is right depends on what consumes your +stream. On-device edge processing was **unaffected** here -- frames processed +stayed ~1900 per node per 4 minutes in both modes, because that pipeline is +separately rate-limited -- so the cost fell on the uplink, not on local sensing. +If your uplink is the scarce resource, that changes the answer. + +**A prediction we got wrong, kept because it is the useful part.** Our mesh had +split into two leader domains with three nodes never synchronising at all. We +expected `seq` to win most on pairs spanning that split. Per-pair yield said +otherwise: same-domain 5.82x, cross-domain 6.24x, involving a never-synced node +**5.16x** -- the lowest, the opposite of the prediction -- and within-group +spread (3.6x-8.1x) dwarfed every between-group difference. + +So `seq` is **not** a workaround for broken time sync; it wins roughly +uniformly, including where sync is healthy. The consequence is negative and +worth having: repairing the mesh split would probably not have closed the gap, +and that effort would have been spent for nothing. + ### Measuring rather than guessing A gate comparison needs a fixed environment, repeated blocks, and every arm run From 60adac05b70bd917957578f73db5e611d3fde687 Mon Sep 17 00:00:00 2001 From: Joe Date: Mon, 7 Sep 2026 12:50:28 -0400 Subject: [PATCH 21/27] docs(firmware): state that our mesh arm was partly an elapsed arm The mesh gate uses mesh-time buckets only while c6_sync_espnow_is_valid(); an unsynced node falls back to the elapsed gate rather than gating on a meaningless epoch. Correct behaviour, but it contaminates the comparison we published: three of nine nodes never synchronised, so for those three the setting under test was silently not in effect. The +0.5 pp should be read as "mesh where sync held, elapsed where it did not", not as a clean fleet-wide measurement. Records the house-dependent cause as well. Time sync is leader-elected and hub-and-spoke: one node broadcasts and the rest follow, so whether every node hears the leader is a property of the building. In a larger or more divided house the fleet fragments into sync domains and any option depending on shared time degrades quietly for the nodes that fell out -- which is exactly the case where someone would most want to trust the measurement. Co-Authored-By: claude-flow Claude-Session: https://claude.ai/code/session_01BHNNWbmKvxXrVaEDwXEBNo --- firmware/esp32-csi-node/RUNBOOK.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/firmware/esp32-csi-node/RUNBOOK.md b/firmware/esp32-csi-node/RUNBOOK.md index 774a15bd..359dfa6d 100644 --- a/firmware/esp32-csi-node/RUNBOOK.md +++ b/firmware/esp32-csi-node/RUNBOOK.md @@ -283,6 +283,23 @@ stayed ~1900 per node per 4 minutes in both modes, because that pipeline is separately rate-limited -- so the cost fell on the uplink, not on local sensing. If your uplink is the scarce resource, that changes the answer. +**A limitation of our own measurement, which cuts against the mesh arm.** The +mesh gate uses mesh-time buckets only while `c6_sync_espnow_is_valid()`; a node +with no valid sync falls back to the elapsed gate rather than gating on a +meaningless epoch. That is the right behaviour, but it means our `mesh` arms +were partly `elapsed` arms: three of nine nodes never synchronised, so for those +three the setting under test was silently not in effect. Read the +0.5 pp as +"mesh where sync held, elapsed where it did not" rather than as a clean +fleet-wide measurement of alignment. + +This is itself a house-dependent property. Time sync here is leader-elected and +hub-and-spoke -- one node broadcasts and the rest follow. Whether every node can +hear that leader is a question about your building, not about the firmware. In a +larger or more divided house the leader may not reach everyone, the fleet +fragments into sync domains, and any option that depends on shared time degrades +quietly for the nodes that fell out. Check `is_leader` and sync validity in +`/api/v1/mesh` before concluding that an alignment setting did or did not help. + **A prediction we got wrong, kept because it is the useful part.** Our mesh had split into two leader domains with three nodes never synchronising at all. We expected `seq` to win most on pairs spanning that split. Per-pair yield said From 3fd38516333efd9107f19997d02c9ba24d72adb2 Mon Sep 17 00:00:00 2001 From: Joe Date: Mon, 7 Sep 2026 12:55:38 -0400 Subject: [PATCH 22/27] docs(firmware): say why seq needs no sync and mesh alignment does The two options differ in kind. Mesh alignment is an agreement between receivers and inherits whatever the sync topology does; selecting by rx_seq needs no agreement, because the 802.11 sequence control field is assigned by the transmitter and is identical at every receiver. Nodes that have never heard of each other, sit in different sync domains, or run with no mesh at all still keep the same subset. The code shows it: the seq test runs before any sync check and reads only the frame, while only the mesh branch calls c6_sync_espnow_is_valid(). That asymmetry is why seq had nothing to degrade in our runs, and it is the thing to weigh if a fleet's sync is fragile -- seq is not just the better performer there, it is the option with no dependency that can quietly fail. Co-Authored-By: claude-flow Claude-Session: https://claude.ai/code/session_01BHNNWbmKvxXrVaEDwXEBNo --- firmware/esp32-csi-node/RUNBOOK.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/firmware/esp32-csi-node/RUNBOOK.md b/firmware/esp32-csi-node/RUNBOOK.md index 359dfa6d..6b924e74 100644 --- a/firmware/esp32-csi-node/RUNBOOK.md +++ b/firmware/esp32-csi-node/RUNBOOK.md @@ -283,6 +283,20 @@ stayed ~1900 per node per 4 minutes in both modes, because that pipeline is separately rate-limited -- so the cost fell on the uplink, not on local sensing. If your uplink is the scarce resource, that changes the answer. +**The two options differ in kind, not just in effect.** Mesh alignment is an +agreement between receivers: it needs a shared clock, so it inherits whatever +the sync topology does. Selecting by `rx_seq` needs no agreement at all. The +802.11 sequence control field is assigned by the TRANSMITTER and is identical +at every receiver, so it is a coordination-free name for one transmission -- +two nodes that have never heard of each other, sit in different sync domains, +or run with no mesh whatsoever still keep the same subset of frames. + +In the code the seq test runs before any sync check and reads only the frame; +only the mesh branch calls `c6_sync_espnow_is_valid()`. That asymmetry is the +reason `seq` had nothing to degrade in our runs, and it is worth weighing +directly: if your fleet's sync is fragile, `seq` is not merely the better +performer, it is the one with no dependency that can quietly fail. + **A limitation of our own measurement, which cuts against the mesh arm.** The mesh gate uses mesh-time buckets only while `c6_sync_espnow_is_valid()`; a node with no valid sync falls back to the elapsed gate rather than gating on a From 977fa26731d14b925e98e1e332a509f2b2010ff7 Mon Sep 17 00:00:00 2001 From: Joe Date: Mon, 7 Sep 2026 13:03:09 -0400 Subject: [PATCH 23/27] docs: list the firmware runbook in the documentation index Same reason as the operational content itself: a procedure nobody can find is a procedure nobody follows. The README's documentation table is where a reader looks, and the runbook was reachable only by browsing the firmware directory. Co-Authored-By: claude-flow Claude-Session: https://claude.ai/code/session_01BHNNWbmKvxXrVaEDwXEBNo --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 38fd3667..607836b1 100644 --- a/README.md +++ b/README.md @@ -719,6 +719,7 @@ Start with the user, build, and calibration guides; expand for the full referenc | [Semantic Primitives — Precision/Recall](docs/integrations/semantic-primitives-metrics.md) | Per-primitive F1 on the held-out paired-capture set: someone-sleeping, possible-distress, room-active, elderly-inactivity-anomaly, meeting, bathroom, fall-risk, bed-exit, no-movement, multi-room. | | [Claude Code / Codex Plugin](plugins/ruview/README.md) | The `ruview` plugin + marketplace — skills, `/ruview-*` commands, agents, and the Codex prompt mirror | | [Portable harness — `npx @ruvnet/ruview`](harness/ruview/README.md) | MetaHarness-minted, host-portable RuView operator harness — `ruview.*` MCP tools + the MEASURED-vs-CLAIMED honesty guardrail enforced in code ([ADR-182](docs/adr/ADR-182-npx-ruview-harness-via-metaharness.md)). A lighter, multi-host companion to the in-repo plugin. | +| [ESP32-C6 Firmware Runbook](firmware/esp32-csi-node/RUNBOOK.md) | What to run, in what order, and what to check: the three-file build that keeps OTA rollback compiled in, a five-key pre-flash gate, which flash regions OTA can and cannot reach, a single-board diagnostic flash with a revert path, and the tunables whose right value depends on your house rather than on the chip. | | [Architecture Decisions](docs/adr/README.md) | 205 ADRs — why each technical choice was made, organized by domain (hardware, signal processing, ML, platform, infrastructure) | | [Domain Models](docs/ddd/README.md) | 8 DDD models (RuvSense, Signal Processing, Training Pipeline, Hardware Platform, Sensing Server, WiFi-Mat, CHCI, rvCSI) — bounded contexts, aggregates, domain events, and ubiquitous language | | [rvCSI — edge RF sensing runtime](https://github.com/ruvnet/rvcsi) | Rust-first / TypeScript-accessible / hardware-abstracted CSI runtime: multi-source ingestion (incl. real nexmon_csi `.pcap` from a **Raspberry Pi 5** / Pi 4 / Pi 3B+ — CYW43455 / BCM43455c0) → validation → DSP → typed events → RuVector RF memory ([ADR-095](docs/adr/ADR-095-rvcsi-edge-rf-sensing-platform.md), [ADR-096](docs/adr/ADR-096-rvcsi-ffi-crate-layout.md), [domain model](docs/ddd/rvcsi-domain-model.md)). Now its own repo — [`ruvnet/rvcsi`](https://github.com/ruvnet/rvcsi) — vendored here under `vendor/rvcsi`; 9 `rvcsi-*` crates on crates.io, `@ruv/rvcsi` on npm, plus a Claude Code plugin. | From 51102b3baf2ee38b7827f32f3afb6d4a7382648a Mon Sep 17 00:00:00 2001 From: Joe Date: Mon, 7 Sep 2026 13:38:40 -0400 Subject: [PATCH 24/27] =?UTF-8?q?docs(adr):=20ADR-345=20=E2=80=94=20per-li?= =?UTF-8?q?nk=20CSI=20attribution=20and=20node-to-node=20ranging?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Records why device-free position kept failing here, measured rather than argued, and the join key built in response. The implementing PRs land without this and are harder to read for it. **Phase is unusable on this hardware.** The bistatic tier needed a signed per-link radial velocity, which requires the common-mode component of CSI phase. Measured across three ESP32-C6 nodes over ~40,000 frames, the frame-to-frame change in common-mode phase is statistically indistinguishable from uniform random: resultant length R = 0.011-0.032 against a 0.000 reference, sd 1.78-1.81 against pi/sqrt(3) = 1.814, no lag-1 autocorrelation. A second, unwrap-free estimator agreed. The mechanism is packet-detection timing quantization. Common-mode phase is -2*pi*f_c*tau, and one ADC sample of jitter at 20 MHz is about 758 rad -- some 120 full wraps, re-randomized every packet. Not calibratable. The bistatic tier is default-off as a result. **Amplitude works but had no parallax.** The three AP->node links leave a single distant transmitter inside a 36.5 degree fan; scalar per-node energy weighting closed with a clean negative result -- magnitude responds to proximity, direction does not. What follows from both: a position tier needs several transmitters seen from several receivers, and fusing those requires knowing that two nodes observed the SAME transmission. Arrival timestamps cannot say so -- the sink sees network jitter, not capture time -- and the per-node sequence counter is private to each node. Hence (tx_mac, rx_seq): the 802.11 sequence control field is assigned by the transmitter and is identical at every receiver, so it names one transmission without any coordination between receivers, no shared clock and no negotiation. Status is Proposed, deliberately. The measurement layer is implemented and validated on hardware; localization is NOT built, and the ADR says so along with the rest of its known limitations -- peer links are ~10x weaker than data frames, three links is below the threshold for tomographic localization regardless of quality, and no position tier consumes the link table. References only ADR-018, ADR-060 and ADR-110, all already present here. Co-Authored-By: claude-flow Claude-Session: https://claude.ai/code/session_01BHNNWbmKvxXrVaEDwXEBNo --- ...5-per-link-csi-and-node-to-node-ranging.md | 193 ++++++++++++++++++ docs/adr/README.md | 1 + 2 files changed, 194 insertions(+) create mode 100644 docs/adr/ADR-345-per-link-csi-and-node-to-node-ranging.md diff --git a/docs/adr/ADR-345-per-link-csi-and-node-to-node-ranging.md b/docs/adr/ADR-345-per-link-csi-and-node-to-node-ranging.md new file mode 100644 index 00000000..d5338c06 --- /dev/null +++ b/docs/adr/ADR-345-per-link-csi-and-node-to-node-ranging.md @@ -0,0 +1,193 @@ +# ADR-345: Per-link CSI attribution and node-to-node ranging + +- **Status**: Proposed — measurement layer implemented and validated on hardware; localization not built +- **Date**: 2026-08-28 +- **Deciders**: Joe +- **Tags**: csi, wire-format, esp32-c6, localization, adr-018, adr-060, adr-110 + +## Context + +Every attempt at device-free position estimation in this repository has failed +against the same wall, and the wall has now been measured twice from different +directions. + +**Phase is unusable.** The bistatic tier needed a signed per-link radial +velocity, which requires the common-mode component of CSI phase. Measured +2026-08-28 across all three ESP32-C6 nodes over ~40,000 frames, the +frame-to-frame change in common-mode phase is statistically indistinguishable +from uniform random — resultant length `R` = 0.011–0.032 against a 0.000 +reference, standard deviation 1.78–1.81 against `pi/sqrt(3)` = 1.814, and no +lag-1 autocorrelation. A second, unwrap-free estimator agreed. The mechanism is +packet-detection timing quantization: common-mode phase is `-2*pi*f_c*tau`, and +one ADC sample of jitter at 20 MHz is `2*pi * 2.412e9 * 50e-9` ~= 758 rad, about +120 full wraps, re-randomized every packet. Not calibratable. The bistatic tier +is default-off as a result (`bistatic_tier_enabled`). + +**Amplitude works but had no parallax.** The three AP -> node links leave a +single distant transmitter within a 36.5 degree fan and converge on the node +cluster. Bearings computed from `room_config.json`: 198.5, 221.2, 235.0 degrees. +Scalar per-node energy weighting was closed with a clean negative result on +2026-08-27 — magnitude responds to proximity, direction does not. + +**The receivers were hearing far more than we recorded.** A node in promiscuous +MGMT+DATA mode with no `filter_mac` produces CSI for every transmitter on the +channel. Measured on node 2: the AP accounted for only **34%** of frames; the +two peer nodes supplied 39% and 27%. Those peer frames are valid measurements +of *node-to-node* channels — but ADR-018's wire format carries no transmitter +address, so the sink could not attribute them. They interleaved into one +`frame_history` with mixed geometry, and the only available remedy +(`filter_mac`, ADR-060) fixes the mixing by discarding ~75% of the data. + +The node-to-node links between the same three boards cross the room from three +sides at **108.7 degrees** of angular spread (bearings 0.0, 73.2, 108.7) versus +36.5 for the AP links — roughly 3x the parallax, with both endpoints known, +fixed, and under our control rather than wherever the router happens to live. +Link count also grows as `N*(N-1)/2`: 3 nodes give 3 links, 6 give 15, 13 give +78. + +## Decision + +Carry the transmitter MAC on the CSI wire and model CSI per **link** +(receiver, transmitter) rather than per **node**, as a layer alongside the +existing per-node model. + +### 1. Wire format v2 + +`CSI_MAGIC_V2 = 0xC5110008`, header 26 bytes: the v1 header unchanged through +byte 19, then the transmitter address (`info->mac`, 802.11 addr2) at bytes +20..25, with I/Q from byte 26. + +`0xC5110002`..`0xC5110007` were already claimed by the vitals, feature and other +edge packets, so v2 takes the next free value rather than an adjacent one — see +`issue_928_magic_collision_tests` for why that matters. The parser accepts both +magics, so a mixed fleet keeps working and v1 nodes simply report no +transmitter. + +### 2. Per-link state (`links.rs`) + +`LinkTable` keyed by `LinkId { rx_node, tx_mac }`, each link holding its own +amplitude history, RSSI EMA, learned resting baseline and motion metric. + +The metric is the mean over subcarriers of the temporal standard deviation of +**AGC-normalized** amplitude. Removing each frame's own mean across subcarriers +is essential rather than cosmetic: the ESP32 applies automatic gain control per +packet, so absolute amplitude jumps between packets for reasons unrelated to the +channel, and any statistic built on the raw level measures the radio's gain +control instead of the room. + +Amplitude only, deliberately — nothing here can depend on phase coherence. + +### 3. Beacon rate + +`BEACON_PERIOD_MS` 100 -> 20. At 10 Hz a peer samples a node-to-node link with a +5 Hz Nyquist while motion Doppler lives at 1–20 Hz; it aliases before it starts. +20 ms matches both the receiver's own `CSI_MIN_PROCESS_INTERVAL_US` ceiling and +the gateway self-ping already used for the AP link. + +### 4. `GET /api/v1/links` and a live UI panel + +The link table was initially reachable only through a 30-second log line, which +is the wrong medium for a quantity whose value is watching it respond while you +walk across the room. `GET /api/v1/links` returns one row per link — `motion`, +`raw_motion`, `rssi_dbm`, `frames`, `kind` — and `LinkMeshPanel` renders them in +the Sensing tab at 1 Hz. `raw_motion` is shown beside `motion` deliberately: a +link whose baseline has not settled reports a healthy raw value and a near-zero +motion, and with only the one number that is indistinguishable from a dead link. + +Node identity is **inferred, not measured**. Nothing in the system records a +node's own MAC — a node reports the addresses it hears, never its own. But a +radio does not receive its own transmissions, so given the set of receivers +currently reporting, a transmitter heard by all of them except exactly one is +that one receiver's own board; a transmitter heard by every receiver is external +infrastructure. `links::infer_transmitting_node` declines (returns `None`) with +fewer than three receivers, where "heard by all but one" and "heard by exactly +one" are the same observation, and whenever more than one receiver is missing, +which a peer out of range produces indistinguishably. The field is named +`tx_node_inferred` and the UI draws the peer id with a trailing `?`. + +Checked against the known MAC map on 2026-08-28 the inference labelled all four +transmitters correctly — nodes 0, 1 and 2 by address, and the AP as external — +with no access to that map. A wrong answer here mislabels a display row; it +never reaches a metric. + +## Consequences + +### Validated on hardware, 2026-08-28 + +All three nodes on wire v2 at 20 ms produced the full mesh: **9 links** (3 AP + +6 directional node-to-node). + +Reciprocity — each physical channel measured independently from both ends: + +| channel | direction A | direction B | ratio | +|---|---|---|---| +| 0 <-> 1 | 2.229 | 1.930 | 1.15 | +| 0 <-> 2 | 1.353 | 1.000 | 1.35 | +| 1 <-> 2 | 1.398 | 1.486 | 1.06 | + +Nothing in the implementation enforces this. Three independent radio pairs +agreeing within 6–35% is the strongest available evidence that the per-link +separation is real rather than an artifact of frame bucketing. + +Link quality is also far more uniform than the AP links: node-to-node raw motion +spans 1.0–2.2 across all six, versus 3.8–17.8 for the three AP links (which +track RSSI: -67, -72, -80). Uniform link quality is desirable for tomographic +localization — no single link dominates the solution. + +### Known limitations + +- **Peer links are ~10x weaker.** A 16-byte ESP-NOW beacon offers far less + energy to estimate a channel response from than a full data frame. Whether + 1.0–2.2 perturbation is sufficient to localize with is **unknown and untested**. +- **Three links is below threshold.** Tomographic localization needs many + crossing links; 3 will not localize regardless of quality. This ADR delivers + the measurement layer, not localization. +- **Localization is not implemented.** No position tier consumes `LinkTable`. + The live position estimate remains `doppler_centroid`. +- **Per-node path regression.** Removing `filter_mac` to admit peer frames + returned the *per-node* `frame_history` to a mixed-transmitter stream, since + it still consumes every frame regardless of source. Node 2's classification + signal is noisier as a result. The fix is to select a link to feed the + per-node path — software filtering that keeps the other links — and it is not + yet done. + +### Scaling limits to settle before more nodes + +- **Airtime.** Every node broadcasts, so N nodes put N/period frames per second + on the channel being sensed. 3 nodes at 20 ms is 150 pps; 13 would be 650 pps. + `BEACON_PERIOD_MS` should become an NVS setting scaled to fleet size. +- **`MAX_LINKS` is 64.** 13 nodes give 78 receiver-transmitter pairs plus the AP + plus neighbours, which overflows. Raise deliberately, with the memory implied. +- **Placement.** Links must *cross* the target area. Nodes spread through a + house give roughly one link per room, each attenuated by walls and perturbed + by anyone anywhere along it — whole-house coarse presence, not position. + Perimeter placement around a single room is the geometry that works. + +## Related bugs found and fixed while implementing + +- **ADR-060 MAC filtering cost ~75% of capture rate.** The 50 Hz rate gate ran + *before* the MAC filter and stamped `s_last_process_us` before the check, so a + frame from any other transmitter consumed the slot and was then discarded. + Enabling `filter_mac` dropped yield from ~42 pps to 6–13. Filtering first + restores it (measured 31–37 pps). A 6-byte `memcmp` is negligible ISR work next + to the CSI processing the gate guards. +- **The entire edge pipeline was dead on ESP32-C6.** `EDGE_MAX_SUBCARRIERS` was + 128, an S3-era assumption; a C6 on an HE-capable AP delivers HE20 frames with + 256 bins, and `process_frame` rejected every one. ADR-110 onboarded the C6 to + the capture path but not this one. Vitals, presence, fall detection and + per-slot counting had never run on these boards. Now target-conditional on + `CONFIG_SOC_WIFI_HE_SUPPORT`, with the four subcarrier-sized arrays hoisted to + static so peak stack is *lower* than it was at 128. +- **A target-conditional constant that failed silently.** `edge_processing.h` + had no `#include` at all, so `CONFIG_SOC_WIFI_HE_SUPPORT` was undefined and + `#if` evaluated it as 0 — selecting the wrong size with no warning. An + undefined identifier in `#if` is 0 in C. This is the same failure mode as the + bug it was fixing, one layer up. + +## References + +- `v2/crates/wifi-densepose-sensing-server/src/links.rs` — per-link state, 8 tests +- `v2/crates/wifi-densepose-sensing-server/src/phase_diag.rs` — the phase measurement +- `firmware/esp32-csi-node/main/csi_collector.c` — wire v2 serialization, filter ordering +- `firmware/esp32-csi-node/main/edge_processing.h` — `EDGE_MAX_SUBCARRIERS` +- ADR-018 (wire format), ADR-060 (MAC filtering), ADR-110 (C6 onboarding) diff --git a/docs/adr/README.md b/docs/adr/README.md index 095c4bcc..f1446db2 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -92,6 +92,7 @@ Statuses: **Proposed** (under discussion), **Accepted** (approved and/or impleme | [ADR-150](ADR-150-rf-foundation-encoder.md) | RF Foundation Encoder: pose-preserving, subject/room/device-invariant CSI embedding | Proposed | | [ADR-151](ADR-151-room-calibration-specialist-training.md) | Per-Room Calibration & Specialized Model Training (room-first → bank of small ruVector specialists) | Proposed | | [ADR-152](ADR-152-wifi-pose-sota-2026-intake.md) | WiFi-Pose SOTA 2026 Intake: geometry-conditioned calibration, external benchmarks, foundation-encoder recipe | Proposed | +| [ADR-345](ADR-345-per-link-csi-and-node-to-node-ranging.md) | Per-link CSI attribution and node-to-node ranging — why phase-based position fails on this hardware (measured), and the coordination-free `(tx_mac, rx_seq)` join key that does not need it | Proposed — measurement layer implemented and validated on hardware; localization not built | | [ADR-348](ADR-348-independent-rust-multivariate-forecasting.md) | Independent Rust multivariate forecasting for RuView | Proposed | | [ADR-349](ADR-349-governed-local-and-fal-forecast-training.md) | Governed local and fal.ai forecast training | Proposed | | [ADR-350](ADR-350-ruvector-predictive-memory-and-ruvllm-boundary.md) | RuVector predictive memory and RuVLLM authority boundary | Proposed | From a4d49f76b96f13843c6d0630b65baa18179f159d Mon Sep 17 00:00:00 2001 From: Dragan Spiridonov Date: Tue, 8 Sep 2026 14:12:41 +0200 Subject: [PATCH 25/27] feat(firmware): add XIAO C6 antenna selection --- .github/workflows/firmware-ci.yml | 21 +++++++ firmware/esp32-csi-node/README.md | 24 ++++++++ firmware/esp32-csi-node/main/CMakeLists.txt | 7 +++ .../esp32-csi-node/main/Kconfig.projbuild | 22 ++++++++ .../esp32-csi-node/main/c6_antenna_select.c | 56 +++++++++++++++++++ .../esp32-csi-node/main/c6_antenna_select.h | 17 ++++++ firmware/esp32-csi-node/main/c6_lp_core.c | 10 +++- firmware/esp32-csi-node/main/main.c | 6 ++ .../sdkconfig.defaults.xiao-external | 4 ++ .../sdkconfig.defaults.xiao-internal | 4 ++ firmware/esp32-csi-node/test/Makefile | 21 +++++-- .../test/test_c6_antenna_select.c | 13 +++++ firmware/esp32-csi-node/version.txt | 2 +- 13 files changed, 200 insertions(+), 7 deletions(-) create mode 100644 firmware/esp32-csi-node/main/c6_antenna_select.c create mode 100644 firmware/esp32-csi-node/main/c6_antenna_select.h create mode 100644 firmware/esp32-csi-node/sdkconfig.defaults.xiao-external create mode 100644 firmware/esp32-csi-node/sdkconfig.defaults.xiao-internal create mode 100644 firmware/esp32-csi-node/test/test_c6_antenna_select.c diff --git a/.github/workflows/firmware-ci.yml b/.github/workflows/firmware-ci.yml index 522ac845..ffc3e55a 100644 --- a/.github/workflows/firmware-ci.yml +++ b/.github/workflows/firmware-ci.yml @@ -98,6 +98,8 @@ jobs: run: | make test_adr110 ./test_adr110 + make test_c6_antenna + ./test_c6_antenna - name: Verify binary size budget working-directory: firmware/esp32-csi-node @@ -184,3 +186,22 @@ jobs: name: esp32-csi-node-firmware-${{ matrix.variant }} path: firmware/esp32-csi-node/release-staging/ retention-days: 90 + + build-c6-idf6: + name: Build XIAO C6 external antenna (ESP-IDF 6.0.2) + runs-on: ubuntu-latest + container: + image: espressif/idf:v6.0.2 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + submodules: recursive + + - name: Build the IDF 6 canary image + working-directory: firmware/esp32-csi-node + run: | + . $IDF_PATH/export.sh + idf.py -D SDKCONFIG_DEFAULTS="sdkconfig.defaults;sdkconfig.defaults.esp32c6;sdkconfig.defaults.xiao-external" set-target esp32c6 + idf.py build + test "$(stat -c%s build/esp32-csi-node.bin)" -le $((1152 * 1024)) + strings build/esp32-csi-node.bin | grep -F "external U.FL" diff --git a/firmware/esp32-csi-node/README.md b/firmware/esp32-csi-node/README.md index 2469814f..382b841a 100644 --- a/firmware/esp32-csi-node/README.md +++ b/firmware/esp32-csi-node/README.md @@ -115,6 +115,30 @@ python -m esptool --chip esp32s3 --port COM7 --baud 460800 \ 0x20000 firmware/esp32-csi-node/build/esp32-csi-node.bin ``` +#### Seeed XIAO ESP32-C6 external antenna + +The XIAO ESP32-C6 has a hardware RF switch between its internal ceramic +antenna and U.FL connector. Generic C6 boards do not share this wiring, so the +firmware leaves GPIO3 and GPIO14 untouched by default. For an XIAO with an +external antenna already attached while powered off, build with one of the +opt-in overlays: + +```bash +# Controlled A condition: internal antenna, RF path stated in the boot log. +idf.py -D SDKCONFIG_DEFAULTS="sdkconfig.defaults;sdkconfig.defaults.esp32c6;sdkconfig.defaults.xiao-internal" \ + set-target esp32c6 +idf.py build + +# Controlled B condition: external U.FL antenna. +idf.py -D SDKCONFIG_DEFAULTS="sdkconfig.defaults;sdkconfig.defaults.esp32c6;sdkconfig.defaults.xiao-external" \ + set-target esp32c6 +idf.py build +``` + +The boot log must report the intended `internal ceramic` or `external U.FL` +path before that measurement is labelled. The standard C6 build keeps the +board default and emits no antenna-path claim. + ### 3. Provision WiFi credentials (no reflash needed) ```bash diff --git a/firmware/esp32-csi-node/main/CMakeLists.txt b/firmware/esp32-csi-node/main/CMakeLists.txt index dc527813..e7011216 100644 --- a/firmware/esp32-csi-node/main/CMakeLists.txt +++ b/firmware/esp32-csi-node/main/CMakeLists.txt @@ -14,6 +14,7 @@ set(SRCS "c6_twt.c" "c6_timesync.c" "c6_lp_core.c" + "c6_antenna_select.c" # ADR-110 D1 workaround — ESP-NOW cross-node sync (works on S3+C6) "c6_sync_espnow.c" # ADR-110 B1/B2 unblock — soft-AP HE/TWT (C6-only when enabled) @@ -48,6 +49,12 @@ if(IDF_TARGET STREQUAL "esp32c6") list(APPEND REQUIRES ieee802154 ulp esp_hw_support) endif() +# ESP-IDF 6 split the temperature sensor driver out of the legacy `driver` +# component. Keep the 5.4 lane working while declaring the v6 dependency. +if(IDF_VERSION_MAJOR GREATER_EQUAL 6) + list(APPEND REQUIRES esp_driver_tsens) +endif() + # ADR-061: Mock CSI generator for QEMU testing + ADR-081 mock radio binding if(CONFIG_CSI_MOCK_ENABLED) list(APPEND SRCS "mock_csi.c" "rv_radio_ops_mock.c") diff --git a/firmware/esp32-csi-node/main/Kconfig.projbuild b/firmware/esp32-csi-node/main/Kconfig.projbuild index 9a51c4fd..404745c5 100644 --- a/firmware/esp32-csi-node/main/Kconfig.projbuild +++ b/firmware/esp32-csi-node/main/Kconfig.projbuild @@ -1,5 +1,27 @@ menu "CSI Node Configuration" + config C6_XIAO_ANTENNA_SELECT + bool "Control the Seeed XIAO ESP32-C6 RF switch" + depends on IDF_TARGET_ESP32C6 + default n + help + Enable only for Seeed XIAO ESP32-C6 boards. The XIAO uses GPIO3 + to enable RF-switch control and GPIO14 to choose its internal or + external antenna. Leave this disabled for generic ESP32-C6 boards + because GPIO3 and GPIO14 may have unrelated functions there. + + choice C6_XIAO_ANTENNA_PATH + prompt "Seeed XIAO ESP32-C6 antenna path" + depends on C6_XIAO_ANTENNA_SELECT + default C6_XIAO_ANTENNA_INTERNAL + + config C6_XIAO_ANTENNA_INTERNAL + bool "Internal ceramic antenna" + + config C6_XIAO_ANTENNA_EXTERNAL + bool "External U.FL antenna" + endchoice + config CSI_NODE_ID int "Node ID (0-255)" default 1 diff --git a/firmware/esp32-csi-node/main/c6_antenna_select.c b/firmware/esp32-csi-node/main/c6_antenna_select.c new file mode 100644 index 00000000..5b034103 --- /dev/null +++ b/firmware/esp32-csi-node/main/c6_antenna_select.c @@ -0,0 +1,56 @@ +#include "c6_antenna_select.h" + +#include "sdkconfig.h" + +#if defined(CONFIG_IDF_TARGET_ESP32C6) && defined(CONFIG_C6_XIAO_ANTENNA_SELECT) +#include "driver/gpio.h" +#include "esp_log.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" + +#define XIAO_RF_SWITCH_POWER_GPIO GPIO_NUM_3 +#define XIAO_RF_SWITCH_SELECT_GPIO GPIO_NUM_14 + +static const char *TAG = "c6_antenna"; +#endif + +esp_err_t c6_xiao_antenna_apply(void) +{ +#if defined(CONFIG_IDF_TARGET_ESP32C6) && defined(CONFIG_C6_XIAO_ANTENNA_SELECT) + gpio_config_t config = { + .pin_bit_mask = (1ULL << XIAO_RF_SWITCH_POWER_GPIO) | + (1ULL << XIAO_RF_SWITCH_SELECT_GPIO), + .mode = GPIO_MODE_OUTPUT, + .pull_up_en = GPIO_PULLUP_DISABLE, + .pull_down_en = GPIO_PULLDOWN_DISABLE, + .intr_type = GPIO_INTR_DISABLE, + }; + esp_err_t err = gpio_config(&config); + if (err != ESP_OK) { + return err; + } + + err = gpio_set_level(XIAO_RF_SWITCH_POWER_GPIO, 0); + if (err != ESP_OK) { + return err; + } + vTaskDelay(pdMS_TO_TICKS(100)); + +#if defined(CONFIG_C6_XIAO_ANTENNA_EXTERNAL) + const c6_xiao_antenna_t antenna = C6_XIAO_ANTENNA_EXTERNAL; + const char *name = "external U.FL"; +#else + const c6_xiao_antenna_t antenna = C6_XIAO_ANTENNA_INTERNAL; + const char *name = "internal ceramic"; +#endif + + err = gpio_set_level(XIAO_RF_SWITCH_SELECT_GPIO, + c6_xiao_antenna_gpio14_level(antenna)); + if (err == ESP_OK) { + ESP_LOGI(TAG, "XIAO ESP32-C6 antenna path: %s", name); + } + return err; +#else + return ESP_OK; +#endif +} diff --git a/firmware/esp32-csi-node/main/c6_antenna_select.h b/firmware/esp32-csi-node/main/c6_antenna_select.h new file mode 100644 index 00000000..905f9c8a --- /dev/null +++ b/firmware/esp32-csi-node/main/c6_antenna_select.h @@ -0,0 +1,17 @@ +#pragma once + +typedef enum { + C6_XIAO_ANTENNA_INTERNAL = 0, + C6_XIAO_ANTENNA_EXTERNAL = 1, +} c6_xiao_antenna_t; + +static inline int c6_xiao_antenna_gpio14_level(c6_xiao_antenna_t antenna) +{ + return antenna == C6_XIAO_ANTENNA_EXTERNAL ? 1 : 0; +} + +#ifdef ESP_PLATFORM +#include "esp_err.h" + +esp_err_t c6_xiao_antenna_apply(void); +#endif diff --git a/firmware/esp32-csi-node/main/c6_lp_core.c b/firmware/esp32-csi-node/main/c6_lp_core.c index d6e096ad..0e425942 100644 --- a/firmware/esp32-csi-node/main/c6_lp_core.c +++ b/firmware/esp32-csi-node/main/c6_lp_core.c @@ -13,7 +13,7 @@ * `ulp_embed_binary(...)` in main/CMakeLists.txt from lp_core/main.c. * * 2. DISABLED — falls back to plain deep-sleep + GPIO wake-up - * (`esp_deep_sleep_enable_gpio_wakeup`). No debounce, no + * (ESP-IDF deep-sleep GPIO wake API). No debounce, no * sub-10 µA floor, but no LP toolchain dependency either. * This is the path the v0.6.6 firmware shipped with. * @@ -26,6 +26,7 @@ #if defined(CONFIG_IDF_TARGET_ESP32C6) && defined(CONFIG_ULP_COPROC_TYPE_LP_CORE) #include "c6_lp_core.h" +#include "esp_idf_version.h" #include "esp_log.h" #include "esp_sleep.h" #include "driver/rtc_io.h" @@ -134,10 +135,17 @@ esp_err_t c6_lp_core_arm(int wake_gpio, bool active_high) #else /* --- Fallback path: plain deep-sleep GPIO wakeup (~10 µA floor) --- */ uint64_t mask = 1ULL << wake_gpio; +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) + esp_sleep_gpio_wake_up_mode_t mode = active_high + ? ESP_GPIO_WAKEUP_GPIO_HIGH + : ESP_GPIO_WAKEUP_GPIO_LOW; + esp_err_t err = esp_sleep_enable_gpio_wakeup_on_hp_periph_powerdown(mask, mode); +#else esp_deepsleep_gpio_wake_up_mode_t mode = active_high ? ESP_GPIO_WAKEUP_GPIO_HIGH : ESP_GPIO_WAKEUP_GPIO_LOW; esp_err_t err = esp_deep_sleep_enable_gpio_wakeup(mask, mode); +#endif if (err != ESP_OK) { ESP_LOGE(TAG, "enable_gpio_wakeup failed: %s", esp_err_to_name(err)); return err; diff --git a/firmware/esp32-csi-node/main/main.c b/firmware/esp32-csi-node/main/main.c index 66089970..9431e86f 100644 --- a/firmware/esp32-csi-node/main/main.c +++ b/firmware/esp32-csi-node/main/main.c @@ -37,6 +37,7 @@ #include "c6_twt.h" /* ADR-110: TWT (no-op stub on S3) */ #include "c6_timesync.h" /* ADR-110: 802.15.4 mesh time-sync (no-op on S3) */ #include "c6_lp_core.h" /* ADR-110: LP-core hibernation (no-op on S3) */ +#include "c6_antenna_select.h" /* XIAO C6 RF switch (opt-in; generic C6 no-op) */ #include "c6_sync_espnow.h" /* ADR-110 D1 workaround: ESP-NOW sync */ #include "c6_softap_he.h" /* ADR-110 B1/B2: HE/TWT soft-AP (no-op when disabled) */ #ifdef CONFIG_CSI_MOCK_ENABLED @@ -331,6 +332,11 @@ void app_main(void) ESP_LOGI(TAG, "%s CSI Node (ADR-018 / ADR-110) — v%s — Node ID: %d", target_name, app_desc->version, g_nvs_config.node_id); + /* Apply the opt-in XIAO RF path before WiFi starts. Generic C6 boards keep + * ownership of GPIO3 and GPIO14 because the default implementation is a + * no-op unless CONFIG_C6_XIAO_ANTENNA_SELECT is enabled. */ + ESP_ERROR_CHECK(c6_xiao_antenna_apply()); + /* Onboard WS2812. C6 wires the LED to GPIO 8; S3 to GPIO 38 (DevKitC-1 v1.0) * or GPIO 48 (DevKitC-1 v1.1 / N16R8 — see #962). On S3 we drive 48 (the * common module). On C6, GPIO 38/48 don't exist (only 0-30) — gate by target. diff --git a/firmware/esp32-csi-node/sdkconfig.defaults.xiao-external b/firmware/esp32-csi-node/sdkconfig.defaults.xiao-external new file mode 100644 index 00000000..2fe67969 --- /dev/null +++ b/firmware/esp32-csi-node/sdkconfig.defaults.xiao-external @@ -0,0 +1,4 @@ +# Seeed XIAO ESP32-C6 external U.FL antenna test overlay. +# Use only with the XIAO board; generic C6 boards may use GPIO3/GPIO14. +CONFIG_C6_XIAO_ANTENNA_SELECT=y +CONFIG_C6_XIAO_ANTENNA_EXTERNAL=y diff --git a/firmware/esp32-csi-node/sdkconfig.defaults.xiao-internal b/firmware/esp32-csi-node/sdkconfig.defaults.xiao-internal new file mode 100644 index 00000000..d60c4a74 --- /dev/null +++ b/firmware/esp32-csi-node/sdkconfig.defaults.xiao-internal @@ -0,0 +1,4 @@ +# Seeed XIAO ESP32-C6 internal ceramic antenna test overlay. +# Keeps RF-switch control enabled so the boot log proves the selected path. +CONFIG_C6_XIAO_ANTENNA_SELECT=y +CONFIG_C6_XIAO_ANTENNA_INTERNAL=y diff --git a/firmware/esp32-csi-node/test/Makefile b/firmware/esp32-csi-node/test/Makefile index f0714557..950eb539 100644 --- a/firmware/esp32-csi-node/test/Makefile +++ b/firmware/esp32-csi-node/test/Makefile @@ -54,10 +54,20 @@ FUZZ_JOBS ?= 1 .PHONY: all clean run_serialize run_edge run_nvs run_all test_adr110 run_adr110 \ test_vitals run_vitals test_mmwave_detect run_mmwave_detect host_tests \ - test_thermal run_thermal test_csi_sanitize run_csi_sanitize + test_thermal run_thermal test_csi_sanitize run_csi_sanitize \ + test_c6_antenna run_c6_antenna all: fuzz_serialize fuzz_edge fuzz_nvs test_adr110 test_vitals test_mmwave_detect \ - test_thermal test_csi_sanitize + test_thermal test_csi_sanitize test_c6_antenna + +# --- XIAO ESP32-C6 RF switch selection --- +# Host-side truth table for the GPIO14 antenna selector. GPIO3 is always driven +# low by the device implementation before this level is applied. +test_c6_antenna: test_c6_antenna_select.c $(MAIN_DIR)/c6_antenna_select.h + cc -std=c99 -Wall -Wextra -I$(MAIN_DIR) -o $@ $< + +run_c6_antenna: test_c6_antenna + ./test_c6_antenna # --- ESP-IDF invalid first-word sanitation --- # Links the production serializer. Function sections and dead stripping keep @@ -112,8 +122,9 @@ test_thermal: test_thermal_hysteresis.c $(MAIN_DIR)/thermal.h run_thermal: test_thermal ./test_thermal -host_tests: run_adr110 run_vitals run_mmwave_detect run_thermal run_csi_sanitize - @echo "Host tests passed (ADR-110 + CSI sanitation + vitals + mmwave + thermal)" +host_tests: run_adr110 run_vitals run_mmwave_detect run_thermal run_csi_sanitize \ + run_c6_antenna + @echo "Host tests passed (ADR-110 + CSI sanitation + vitals + mmwave + thermal + C6 antenna)" # --- Serialize fuzzer --- # Tests csi_serialize_frame() with random wifi_csi_info_t inputs. @@ -150,5 +161,5 @@ run_all: run_serialize run_edge run_nvs clean: rm -f fuzz_serialize fuzz_edge fuzz_nvs test_adr110 test_vitals test_mmwave_detect \ - test_thermal test_csi_sanitize + test_thermal test_csi_sanitize test_c6_antenna rm -rf corpus_serialize/ corpus_edge/ corpus_nvs/ diff --git a/firmware/esp32-csi-node/test/test_c6_antenna_select.c b/firmware/esp32-csi-node/test/test_c6_antenna_select.c new file mode 100644 index 00000000..4429108c --- /dev/null +++ b/firmware/esp32-csi-node/test/test_c6_antenna_select.c @@ -0,0 +1,13 @@ +#include +#include + +#include "c6_antenna_select.h" + +int main(void) +{ + assert(c6_xiao_antenna_gpio14_level(C6_XIAO_ANTENNA_INTERNAL) == 0); + assert(c6_xiao_antenna_gpio14_level(C6_XIAO_ANTENNA_EXTERNAL) == 1); + + puts("PASS: XIAO ESP32-C6 antenna selection truth table"); + return 0; +} diff --git a/firmware/esp32-csi-node/version.txt b/firmware/esp32-csi-node/version.txt index 6201b5f7..ef505616 100644 --- a/firmware/esp32-csi-node/version.txt +++ b/firmware/esp32-csi-node/version.txt @@ -1 +1 @@ -0.8.8 +0.8.10 From 0d1a247bfe3eab8867e035eb54a5ed576b6c680c Mon Sep 17 00:00:00 2001 From: Joe Date: Tue, 8 Sep 2026 19:55:07 -0400 Subject: [PATCH 26/27] fix(sensing-server): a silent node no longer moves its neighbours' coordinates `MultistaticFuser::fuse` addressed node positions by the frame's index in the cohort it was handed. That index is a rank, not an identity: the cohort from `node_frames_from_states_with_guard` is compacted -- stale, out-of-guard and empty-history nodes are dropped before `fuse` ever sees them. So one board missing a beacon re-ranked every node above it and handed each of them its neighbour's coordinates, silently and fleet-wide. `MultiBandCsiFrame` already carries its own `node_id`, so the identity was present at the boundary and simply unused. Positions are now stored keyed by node id and resolved per frame by that id. - `set_node_positions_by_id` takes the keyed map; `main` passes the same `node_positions_by_id` map that already feeds `NodeInfo`, so one parsed identity now serves both the metadata and the effectful path (#1860 fixed only the former). - `set_node_positions(Vec)` is kept as a documented compatibility mapping: entry `i` is node `i`, which is exactly what a bare `--node-positions` list has always meant. `node_positions()` returns that dense view unchanged. - `FusedSensingFrame::node_positions` stays parallel to `node_frames`, but each entry is resolved from its own frame's id, so the pairing survives compaction. Behaviour change: with a bare (unprefixed) `--node-positions` list on a fleet whose ids are not dense and zero-based, a node now reads the entry declared for its id rather than the entry at its rank. That is the defect being fixed; ids given explicitly as `node_id:x,y,z` are unaffected, and so is any fleet whose ids are dense from zero with every node reporting. Tests: sparse ids, reversed configuration order, a configured-but-absent node, an unconfigured node, join/leave churn, the preserved legacy positional mapping, and a negative control pinning the compacted-cohort case. Six of the eight fail against the pre-fix lookup; the legacy-mapping test is expected to pass both ways, and order-independence became structural with keyed storage. Software-only scope. No hardware-accuracy claim is made. Closes #1866 Refs #1860 Co-Authored-By: claude-flow Claude-Session: https://claude.ai/code/session_01BHNNWbmKvxXrVaEDwXEBNo --- .../src/field_bridge.rs | 6 +- .../wifi-densepose-sensing-server/src/main.rs | 18 +- .../src/ruvsense/multistatic.rs | 352 +++++++++++++++++- 3 files changed, 355 insertions(+), 21 deletions(-) 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 2bc7ad80..0199d2c3 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/field_bridge.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/field_bridge.rs @@ -431,8 +431,10 @@ pub fn node_positions_by_id( /// Malformed entries are skipped with a warning log. /// /// Positional view, for consumers that index the list rather than key it by -/// node id — `MultistaticFuser::set_node_positions()` is the one that matters. -/// Use [`parse_node_position_entries`] where identity is needed. +/// node id. Since issue #1866 the fusion path is no longer one of them: it +/// takes the keyed map from [`node_positions_by_id`] via +/// `MultistaticFuser::set_node_positions_by_id()`. Use +/// [`parse_node_position_entries`] where identity is needed. pub fn parse_node_positions(input: &str) -> Vec<[f32; 3]> { parse_node_position_entries(input) .into_iter() diff --git a/v2/crates/wifi-densepose-sensing-server/src/main.rs b/v2/crates/wifi-densepose-sensing-server/src/main.rs index f6752aa3..010beed2 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/main.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/main.rs @@ -11246,13 +11246,17 @@ async fn main() { // precisely the bug being fixed, and a loop inside main() // is unreachable from any test. node_positions_config = field_bridge::node_positions_by_id(&entries); - // The fuser indexes this list positionally, matched to - // per-frame iteration order rather than to node id. Left - // as-is deliberately: this change is about the NodeInfo - // output, and quietly altering fusion semantics inside it - // would be the wrong scope. Order given is order passed. - let positions: Vec<[f32; 3]> = entries.iter().map(|e| e.position).collect(); - fuser.set_node_positions(positions); + // Issue #1866: the same keyed map now goes to the fuser, + // so one parsed identity serves both the NodeInfo output + // and the effectful fusion path. The fuser previously took + // a positional list and addressed it by cohort rank, which + // stops being a node id the moment a stale or out-of-guard + // node is dropped -- every node above the gap silently + // inherited its neighbour's coordinates. `node_positions_by_id` + // still applies the documented legacy rule (an entry with + // no `node_id:` prefix takes its list index as its id), so + // an existing `--node-positions` string keeps its meaning. + fuser.set_node_positions_by_id(node_positions_config.clone()); } } engine_bridge_multistatic_cfg = Some(MultistaticConfig { diff --git a/v2/crates/wifi-densepose-signal/src/ruvsense/multistatic.rs b/v2/crates/wifi-densepose-signal/src/ruvsense/multistatic.rs index 372056bc..8fb43388 100644 --- a/v2/crates/wifi-densepose-signal/src/ruvsense/multistatic.rs +++ b/v2/crates/wifi-densepose-signal/src/ruvsense/multistatic.rs @@ -26,6 +26,7 @@ //! - `ruvector-attn-mincut` for cross-node spectrogram attention gating //! - `ruvector-mincut` for person separation (DynamicMinCut) +use std::collections::HashMap; use std::sync::Arc; use super::cir::{CirConfig, CirEstimator}; @@ -72,6 +73,12 @@ pub struct FusedSensingFrame { /// Per-node multi-band frames (preserved for geometry computations). pub node_frames: Vec, /// Node positions (x, y, z) in meters from deployment configuration. + /// + /// Parallel to [`Self::node_frames`]: `node_positions[i]` is the + /// configured position of `node_frames[i].node_id`, resolved by identity + /// rather than by rank (issue #1866). Nodes with no configured position + /// appear at the origin. Index `i` is this cycle's cohort rank, which is + /// *not* a node id -- read the id from the paired frame. pub node_positions: Vec<[f32; 3]>, /// Number of active nodes contributing to this frame. pub active_nodes: usize, @@ -191,8 +198,16 @@ impl MultistaticConfig { /// disable the CIR path and keep the legacy frequency-domain coherence only. pub struct MultistaticFuser { config: MultistaticConfig, - /// Node positions in 3D space (meters). - node_positions: Vec<[f32; 3]>, + /// Node positions in 3D space (meters), keyed by node id (issue #1866). + /// + /// Keyed rather than indexed because [`Self::fuse`] receives a *compacted* + /// cohort: `node_frames_from_states_with_guard` drops nodes that are + /// stale, outside the timestamp guard, or have an empty history. Under + /// index addressing, one node missing a single beacon re-ranks every node + /// above it and silently hands them their neighbour's coordinates. Each + /// `MultiBandCsiFrame` carries its own `node_id`, so the position is + /// looked up by identity and a silent node moves nobody. + node_positions: HashMap, /// Optional shared CIR estimator (ADR-134). `None` = legacy path only. cir_estimator: Option>, } @@ -212,7 +227,7 @@ impl MultistaticFuser { pub fn new() -> Self { Self { config: MultistaticConfig::default(), - node_positions: Vec::new(), + node_positions: HashMap::new(), cir_estimator: None, } } @@ -221,7 +236,7 @@ impl MultistaticFuser { pub fn with_config(config: MultistaticConfig) -> Self { Self { config, - node_positions: Vec::new(), + node_positions: HashMap::new(), cir_estimator: None, } } @@ -267,11 +282,39 @@ impl MultistaticFuser { fuser } - /// Set node positions for geometric diversity computations. + /// Set node positions from a positional list (**legacy compatibility**). + /// + /// The list index is the node id: `positions[i]` belongs to node `i`. That + /// is the meaning the `--node-positions` flag has always carried when its + /// entries are given without an explicit `node_id:` prefix, so this + /// mapping is preserved exactly rather than reinterpreted. + /// + /// This is only correct for fleets whose ids are dense and zero-based. + /// Prefer [`Self::set_node_positions_by_id`], which states the identity + /// instead of inferring it from ordering. pub fn set_node_positions(&mut self, positions: Vec<[f32; 3]>) { + self.node_positions = positions + .into_iter() + .enumerate() + .map(|(idx, position)| (idx as u8, position)) + .collect(); + } + + /// Set node positions keyed by node id (issue #1866, preferred). + /// + /// The position travels with the board it was measured for. Nodes absent + /// from the map fuse at the origin, exactly as before; nodes present but + /// not currently reporting cost nothing and shift nobody. + pub fn set_node_positions_by_id(&mut self, positions: HashMap) { self.node_positions = positions; } + /// Return the configured position for one node id, if any. + #[must_use] + pub fn node_position(&self, node_id: u8) -> Option<[f32; 3]> { + self.node_positions.get(&node_id).copied() + } + /// Return the configured hard timestamp guard in microseconds. /// /// Callers that assemble frames before invoking [`Self::fuse`] use this @@ -283,11 +326,33 @@ impl MultistaticFuser { self.config.guard_interval_us } - /// Return the current node positions. - pub fn node_positions(&self) -> &[[f32; 3]] { + /// Return the configured positions keyed by node id. + #[must_use] + pub fn node_positions_by_id(&self) -> &HashMap { &self.node_positions } + /// Return the configured positions as a dense id-indexed list + /// (**legacy compatibility view**). + /// + /// Entry `i` is node `i`'s position, with unconfigured ids filled at the + /// origin, so this round-trips [`Self::set_node_positions`]. It is a view + /// of the configuration, *not* of what any particular fusion cycle used — + /// a cycle's cohort is compacted, so read + /// [`FusedSensingFrame::node_positions`] alongside its `node_frames` for + /// that. + #[must_use] + pub fn node_positions(&self) -> Vec<[f32; 3]> { + let Some(&max_id) = self.node_positions.keys().max() else { + return Vec::new(); + }; + let mut dense = vec![[0.0_f32, 0.0, 0.0]; max_id as usize + 1]; + for (&id, &position) in &self.node_positions { + dense[id as usize] = position; + } + dense + } + /// Fuse multiple node frames into a single `FusedSensingFrame`. /// /// When only one node is provided, falls back to single-node mode @@ -360,11 +425,19 @@ impl MultistaticFuser { timestamps.sort_unstable(); let timestamp_us = timestamps[timestamps.len() / 2]; - // Build node positions list, filling with origin for unknown nodes - let positions: Vec<[f32; 3]> = (0..n_nodes) - .map(|i| { + // Build the node positions list parallel to `node_frames`, resolving + // each entry by that frame's own `node_id` (issue #1866). The cohort + // handed to `fuse` is compacted -- stale, out-of-guard and + // empty-history nodes are already dropped -- so a frame's index here + // is its rank in this cycle, not its identity. Addressing by index + // gave every node above a gap its neighbour's coordinates whenever a + // single board missed a beacon. Unknown nodes still fill with the + // origin, unchanged. + let positions: Vec<[f32; 3]> = node_frames + .iter() + .map(|frame| { self.node_positions - .get(i) + .get(&frame.node_id) .copied() .unwrap_or([0.0, 0.0, 0.0]) }) @@ -1064,10 +1137,16 @@ mod tests { #[test] fn node_positions_set_and_retrieved() { + // The legacy positional setter round-trips through the dense view: + // index i in, index i out. Storage is keyed by id underneath, and + // `positions[i]` is documented to mean node i. let mut fuser = MultistaticFuser::new(); let positions = vec![[0.0, 0.0, 1.0], [3.0, 0.0, 1.0]]; fuser.set_node_positions(positions.clone()); - assert_eq!(fuser.node_positions(), &positions[..]); + assert_eq!(fuser.node_positions(), positions); + assert_eq!(fuser.node_position(0), Some([0.0, 0.0, 1.0])); + assert_eq!(fuser.node_position(1), Some([3.0, 0.0, 1.0])); + assert_eq!(fuser.node_position(2), None); } #[test] @@ -1083,6 +1162,255 @@ mod tests { assert_eq!(fused.node_positions[1], [0.0, 0.0, 0.0]); // default } + /// Issue #1866 regressions: a fused contribution must carry the position + /// belonging to its own `node_id`, under every cohort shape the live + /// collector can produce. + /// + /// `node_frames_from_states_with_guard` hands `fuse` a *compacted* cohort: + /// stale, out-of-guard and empty-history nodes are already gone. So the + /// index of a frame is its rank in this cycle, never its identity. + mod positions_keyed_by_node_id { + use super::*; + + fn positions(entries: &[(u8, [f32; 3])]) -> HashMap { + entries.iter().copied().collect() + } + + /// Every frame gets its own node's coordinates -- the core assertion. + fn assert_each_frame_owns_its_position( + configured: &HashMap, + frames: &[MultiBandCsiFrame], + ) { + let mut fuser = MultistaticFuser::new(); + fuser.set_node_positions_by_id(configured.clone()); + let fused = fuser.fuse(frames).expect("cohort fuses"); + + assert_eq!(fused.node_positions.len(), frames.len()); + for (frame, position) in fused.node_frames.iter().zip(&fused.node_positions) { + let expected = configured + .get(&frame.node_id) + .copied() + .unwrap_or([0.0, 0.0, 0.0]); + assert_eq!( + *position, expected, + "node {} received the wrong position", + frame.node_id + ); + } + } + + #[test] + fn sparse_node_ids_each_get_their_own_position() { + // Real fleets use logical ids like 11/12/13. Under rank addressing + // every one of these resolved to the origin, because indices + // 11..13 do not exist in a 3-entry list. + let configured = positions(&[ + (11, [1.0, 0.0, 0.0]), + (12, [2.0, 0.0, 0.0]), + (13, [3.0, 0.0, 0.0]), + ]); + let frames = vec![ + make_node_frame(11, 100, 56, 1.0), + make_node_frame(12, 101, 56, 1.0), + make_node_frame(13, 102, 56, 1.0), + ]; + assert_each_frame_owns_its_position(&configured, &frames); + + let mut fuser = MultistaticFuser::new(); + fuser.set_node_positions_by_id(configured); + let fused = fuser.fuse(&frames).expect("cohort fuses"); + assert_eq!(fused.node_positions[0], [1.0, 0.0, 0.0]); + assert_eq!(fused.node_positions[2], [3.0, 0.0, 0.0]); + } + + #[test] + fn configuration_order_does_not_change_any_position() { + // Insertion order must be irrelevant: identity is stated, not + // inferred from where an entry sat in the config. + let ascending = positions(&[ + (3, [3.0, 0.0, 0.0]), + (5, [5.0, 0.0, 0.0]), + (9, [9.0, 0.0, 0.0]), + ]); + let reversed = positions(&[ + (9, [9.0, 0.0, 0.0]), + (5, [5.0, 0.0, 0.0]), + (3, [3.0, 0.0, 0.0]), + ]); + let frames = vec![ + make_node_frame(3, 100, 56, 1.0), + make_node_frame(5, 101, 56, 1.0), + make_node_frame(9, 102, 56, 1.0), + ]; + + let mut a = MultistaticFuser::new(); + a.set_node_positions_by_id(ascending); + let mut b = MultistaticFuser::new(); + b.set_node_positions_by_id(reversed); + + assert_eq!( + a.fuse(&frames).expect("fuses").node_positions, + b.fuse(&frames).expect("fuses").node_positions + ); + } + + #[test] + fn a_silent_node_does_not_move_the_others() { + // THE REASON THIS IS KEYED BY ID. Node 2 misses a beacon and drops + // out of the cohort. Under rank addressing nodes 3 and 4 shift + // down one slot and silently inherit their neighbours' coordinates + // -- a fleet-wide position error caused by one dropped frame. + let configured = positions(&[ + (1, [1.0, 0.0, 0.0]), + (2, [2.0, 0.0, 0.0]), + (3, [3.0, 0.0, 0.0]), + (4, [4.0, 0.0, 0.0]), + ]); + let mut fuser = MultistaticFuser::new(); + fuser.set_node_positions_by_id(configured); + + let all_up = vec![ + make_node_frame(1, 100, 56, 1.0), + make_node_frame(2, 101, 56, 1.0), + make_node_frame(3, 102, 56, 1.0), + make_node_frame(4, 103, 56, 1.0), + ]; + let two_silent = vec![ + make_node_frame(1, 100, 56, 1.0), + make_node_frame(3, 102, 56, 1.0), + make_node_frame(4, 103, 56, 1.0), + ]; + + let full = fuser.fuse(&all_up).expect("fuses"); + let gapped = fuser.fuse(&two_silent).expect("fuses"); + + assert_eq!(full.node_positions[2], [3.0, 0.0, 0.0]); + assert_eq!(full.node_positions[3], [4.0, 0.0, 0.0]); + // Node 3 is now at rank 1 and node 4 at rank 2, but both keep + // their own coordinates. + assert_eq!(gapped.node_positions[0], [1.0, 0.0, 0.0]); + assert_eq!(gapped.node_positions[1], [3.0, 0.0, 0.0]); + assert_eq!(gapped.node_positions[2], [4.0, 0.0, 0.0]); + } + + #[test] + fn join_and_leave_churn_never_reassigns_a_position() { + // Walk a node through leave -> rejoin. Every cohort shape must + // still pair each frame with its own configured coordinates. + let configured = positions(&[ + (0, [0.0, 0.0, 0.0]), + (1, [1.0, 1.0, 0.0]), + (2, [2.0, 2.0, 0.0]), + (3, [3.0, 3.0, 0.0]), + ]); + let cohorts: Vec> = vec![ + vec![0, 1, 2, 3], + vec![0, 2, 3], + vec![2, 3], + vec![3], + vec![0, 3], + vec![0, 1, 2, 3], + ]; + for ids in cohorts { + let frames: Vec = ids + .iter() + .enumerate() + .map(|(i, &id)| make_node_frame(id, 100 + i as u64, 56, 1.0)) + .collect(); + assert_each_frame_owns_its_position(&configured, &frames); + } + } + + #[test] + fn a_configured_but_absent_node_costs_nothing() { + // Positions may be configured for boards that are not reporting. + // They must not consume a slot or displace anyone. + let configured = positions(&[ + (1, [1.0, 0.0, 0.0]), + (7, [7.0, 0.0, 0.0]), + (42, [42.0, 0.0, 0.0]), + ]); + let frames = vec![ + make_node_frame(1, 100, 56, 1.0), + make_node_frame(7, 101, 56, 1.0), + ]; + assert_each_frame_owns_its_position(&configured, &frames); + + let mut fuser = MultistaticFuser::new(); + fuser.set_node_positions_by_id(configured); + let fused = fuser.fuse(&frames).expect("fuses"); + assert_eq!(fused.node_positions.len(), 2); + assert_eq!(fused.node_positions[1], [7.0, 0.0, 0.0]); + } + + #[test] + fn an_unconfigured_node_still_fuses_at_the_origin() { + // Unchanged fallback behaviour: unknown node -> origin, and it + // must not disturb its neighbours. + let configured = positions(&[(1, [1.0, 0.0, 0.0]), (3, [3.0, 0.0, 0.0])]); + let frames = vec![ + make_node_frame(1, 100, 56, 1.0), + make_node_frame(2, 101, 56, 1.0), + make_node_frame(3, 102, 56, 1.0), + ]; + assert_each_frame_owns_its_position(&configured, &frames); + + let mut fuser = MultistaticFuser::new(); + fuser.set_node_positions_by_id(configured); + let fused = fuser.fuse(&frames).expect("fuses"); + assert_eq!(fused.node_positions[1], [0.0, 0.0, 0.0]); + } + + #[test] + fn legacy_positional_setter_still_means_index_equals_node_id() { + // Documented compatibility mapping: a bare `--node-positions` + // list has always meant "the i-th entry is node i". Preserved + // exactly, so existing deployments are unaffected. + let mut fuser = MultistaticFuser::new(); + fuser.set_node_positions(vec![ + [0.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + [2.0, 0.0, 0.0], + ]); + let frames = vec![ + make_node_frame(0, 100, 56, 1.0), + make_node_frame(1, 101, 56, 1.0), + make_node_frame(2, 102, 56, 1.0), + ]; + let fused = fuser.fuse(&frames).expect("fuses"); + assert_eq!(fused.node_positions[0], [0.0, 0.0, 0.0]); + assert_eq!(fused.node_positions[1], [1.0, 0.0, 0.0]); + assert_eq!(fused.node_positions[2], [2.0, 0.0, 0.0]); + } + + /// Negative control: the pre-fix behaviour must actually be gone. + /// + /// Rank addressing over a dense zero-based config only diverges from + /// id addressing once the cohort is compacted, so this pins the exact + /// case the old code got wrong rather than restating the happy path. + #[test] + fn rank_addressing_would_have_failed_this_cohort() { + let configured = positions(&[ + (0, [0.0, 0.0, 0.0]), + (1, [1.0, 0.0, 0.0]), + (2, [2.0, 0.0, 0.0]), + ]); + let mut fuser = MultistaticFuser::new(); + fuser.set_node_positions_by_id(configured); + + // Node 1 is silent; the cohort is [0, 2]. + let frames = vec![ + make_node_frame(0, 100, 56, 1.0), + make_node_frame(2, 101, 56, 1.0), + ]; + let fused = fuser.fuse(&frames).expect("fuses"); + + // Rank addressing would hand rank 1 the position of node 1. + assert_ne!(fused.node_positions[1], [1.0, 0.0, 0.0]); + assert_eq!(fused.node_positions[1], [2.0, 0.0, 0.0]); + } + } + #[test] fn geometric_diversity_single_node() { assert_eq!(geometric_diversity(&[[0.0, 0.0, 0.0]]), 0.0); From 169597a7d2c9ad54ec7533b339eb38b94ddd4672 Mon Sep 17 00:00:00 2001 From: Joe Date: Wed, 9 Sep 2026 00:23:58 -0400 Subject: [PATCH 27/27] fix(signal): the derived soft guard flagged the schedule it was derived from MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `MultistaticConfig::for_tdm_schedule` set the soft guard to `cycle_us / 3`, reasoned about in its own doc comment as "a normal adjacent-slot pair fuses cleanly". But `fuse_scored` does not compare pairs -- it takes `max(timestamp) - min(timestamp)` across the whole cohort, so the spread it measures is always the first-to-last offset of `(N - 1)` slots. Against that bound: soft < stagger <=> N*slot/3 < (N-1)*slot <=> N/3 < N-1 <=> N > 1.5 which holds for every N >= 2. The derived soft guard never covered the schedule it was derived from, at any fleet size, so a complete healthy cycle was recorded as a `TimestampMismatch` contradiction -- which demotes the BFLD privacy class one step (ADR-137 §2.7 -> ADR-141) and suppresses raw outputs. The constructor's own doc example is the smallest demonstration. It exists to accommodate a MEASURED 18,194 us spread on a 2-slot mesh, and asserts the hard guard clears it -- but the soft guard it derives for that same mesh is 36,000/3 = 12,000 us, below the measurement that motivated the constructor. Soft is now the designed stagger, `(slots - 1) * slot_duration_us`, plus the same 20% jitter headroom the hard guard already takes. The headroom is not cosmetic: the nominal 2-slot stagger is 18,000 us while the observed spread is 18,194 us, so a bare nominal bound would still flag its own motivating cycle. `stagger < cycle` always, so the result stays strictly below the hard guard and a spread approaching a full cycle is still flagged as loose alignment. Why this survived: every existing assertion on `soft_guard_us` is relational (`soft < hard`, `soft >= 1`). None ever compared it to a spread the schedule actually produces. Three tests are added -- the derived guard clears the stagger across 2..13 slots, a full evenly-staggered cycle raises no contradiction, and a spread beyond the stagger is still flagged, so the fix cannot be mistaken for disabling the check. The first two fail against `cycle / 3`; the third passes either way by design. Scope: this corrects the DERIVED path only. The hand-set defaults (60 ms hard / 20 ms soft) are unchanged and remain correct for the 2-slot mesh they were sized against. Software-only scope. No hardware-accuracy claim is made. Co-Authored-By: claude-flow Claude-Session: https://claude.ai/code/session_01BHNNWbmKvxXrVaEDwXEBNo --- .../src/ruvsense/multistatic.rs | 127 +++++++++++++++++- 1 file changed, 123 insertions(+), 4 deletions(-) diff --git a/v2/crates/wifi-densepose-signal/src/ruvsense/multistatic.rs b/v2/crates/wifi-densepose-signal/src/ruvsense/multistatic.rs index 372056bc..8cbad8e5 100644 --- a/v2/crates/wifi-densepose-signal/src/ruvsense/multistatic.rs +++ b/v2/crates/wifi-densepose-signal/src/ruvsense/multistatic.rs @@ -147,8 +147,31 @@ impl MultistaticConfig { /// maximum legitimate spread between two paired node frames in one cycle is /// the full cycle length `tdm_total_slots × slot_duration_us` (last slot vs /// first slot). The hard guard is set to that cycle length plus 20% jitter - /// headroom; the soft guard to ~⅓ of the cycle (a normal adjacent-slot pair - /// fuses cleanly, a near-full-cycle spread is flagged as loose alignment). + /// headroom; the soft guard to the schedule's own **designed stagger**, + /// `(slots - 1) * slot_duration_us` -- the offset between the first and + /// last slot, which is the spread a complete, healthy cycle produces. + /// + /// # Why not ~⅓ of the cycle (issue #1866-adjacent, corrected) + /// + /// The soft guard was previously `cycle_us / 3`, reasoned about as "a + /// normal *adjacent-slot pair* fuses cleanly". But [`MultistaticFuser::fuse_scored`] + /// does not compare pairs -- it takes `max(timestamp) - min(timestamp)` + /// across the **whole cohort**, so the spread it measures is always the + /// first-to-last offset, `(N - 1)` slots. Against that: + /// + /// ```text + /// soft < stagger <=> N*slot/3 < (N-1)*slot <=> N/3 < N-1 <=> N > 1.5 + /// ``` + /// + /// which holds for **every** `N >= 2`. The derived soft guard therefore + /// never covered the schedule it was derived from, at any fleet size, and + /// a complete healthy cycle was flagged as a `TimestampMismatch` + /// contradiction -- which demotes the BFLD privacy class one step + /// (ADR-137 §2.7 -> ADR-141) and suppresses raw outputs. + /// + /// The doc example below is the smallest demonstration: the 2-slot mesh + /// whose **measured** 18,194 µs spread motivated this constructor derived + /// a 12,000 µs soft guard, below its own motivating measurement. /// /// `tdm_total_slots` is clamped to ≥ 1. All other fields take their /// [`Default`] values. @@ -160,6 +183,9 @@ impl MultistaticConfig { /// // reported 18,194 µs 2-slot spread. /// let cfg = MultistaticConfig::for_tdm_schedule(2, 18_000); /// assert!(cfg.guard_interval_us >= 18_194); + /// // ...and the soft guard now covers it too, so a healthy 2-slot cycle + /// // is not recorded as a contradiction. + /// assert!(cfg.soft_guard_us >= 18_194); /// ``` #[must_use] pub fn for_tdm_schedule(tdm_total_slots: usize, slot_duration_us: u64) -> Self { @@ -167,8 +193,19 @@ impl MultistaticConfig { let cycle_us = slots.saturating_mul(slot_duration_us); // +20% jitter headroom on the full cycle. let guard_interval_us = cycle_us.saturating_add(cycle_us / 5).max(1); - // Soft band at ~⅓ cycle, kept strictly below the hard guard. - let soft_guard_us = (cycle_us / 3).clamp(1, guard_interval_us.saturating_sub(1).max(1)); + // Soft band at the schedule's designed stagger -- the first-to-last + // slot offset, which is what `fuse_scored` measures. Kept strictly + // below the hard guard, so a spread approaching a full cycle is still + // flagged as loose alignment. + // +20% jitter headroom, mirroring the hard guard: the *nominal* stagger + // is not the observed one. The 2-slot mesh below has an 18,000 us + // nominal stagger but a MEASURED 18,194 us spread, so a bare nominal + // bound would flag the very cycle it was derived from. Always strictly + // below the hard guard, since stagger < cycle. + let stagger_us = slots.saturating_sub(1).saturating_mul(slot_duration_us); + let soft_guard_us = stagger_us + .saturating_add(stagger_us / 5) + .clamp(1, guard_interval_us.saturating_sub(1).max(1)); Self { guard_interval_us, soft_guard_us, @@ -964,6 +1001,88 @@ mod tests { ); } + /// A derived schedule must not flag its own healthy cycle as a + /// contradiction. + /// + /// Every prior assertion on `soft_guard_us` was *relational* (`soft < + /// hard`, `soft >= 1`), so nothing compared it to a spread the schedule + /// actually produces -- which is how `cycle/3` survived. `fuse_scored` + /// measures `max - min` across the whole cohort, i.e. `(N-1)` slots, so + /// that is what the soft guard must cover. + #[test] + fn derived_soft_guard_covers_the_schedules_own_stagger() { + for (slots, slot_us) in [ + (2_usize, 18_000_u64), // the measured 2-slot mesh in the doc comment + (3, 27_000), + (4, 12_500), + (6, 33_300), + (9, 35_600), // a nine-node fleet: 320 ms cycle + (13, 36_900), + ] { + let cfg = MultistaticConfig::for_tdm_schedule(slots, slot_us); + // Nominal stagger; the derived guard must clear it WITH headroom, + // because observed spread runs slightly over nominal (18,194 us + // measured against an 18,000 us nominal 2-slot stagger). + let stagger = (slots as u64 - 1) * slot_us; + assert!( + cfg.soft_guard_us > stagger, + "{slots} slots x {slot_us} us: soft guard {} us is below the \ + schedule's own first-to-last stagger of {stagger} us, so a \ + complete healthy cycle is recorded as a TimestampMismatch \ + contradiction and demotes the privacy class", + cfg.soft_guard_us + ); + assert!(cfg.soft_guard_us < cfg.guard_interval_us); + } + } + + /// End to end: a full, evenly-staggered cycle fuses with **no** + /// contradiction under its own derived config. + #[test] + fn a_healthy_full_cycle_raises_no_contradiction() { + let (slots, slot_us) = (9_usize, 35_600_u64); + let cfg = MultistaticConfig::for_tdm_schedule(slots, slot_us); + let fuser = MultistaticFuser::with_config(cfg.clone()); + + // Node k transmits in slot k -- the schedule working exactly as designed. + let frames: Vec = (0..slots) + .map(|k| make_node_frame(k as u8, k as u64 * slot_us, 56, 1.0)) + .collect(); + + let (_fused, score) = fuser + .fuse_scored(&frames, 0.0) + .expect("a healthy cycle must fuse"); + assert!( + !score.forces_privacy_demotion(), + "a complete {slots}-slot cycle is the schedule operating normally \ + and must not demote the privacy class; flags: {:?}", + score.contradiction_flags + ); + } + + /// The soft band still does its job: a spread beyond the designed stagger + /// is flagged. + #[test] + fn a_spread_beyond_the_stagger_is_still_flagged() { + let (slots, slot_us) = (9_usize, 35_600_u64); + let cfg = MultistaticConfig::for_tdm_schedule(slots, slot_us); + let hard = cfg.guard_interval_us; + let fuser = MultistaticFuser::with_config(cfg); + + // Two nodes, inside the hard guard but wider than a full cycle's stagger. + let late = hard - 1; + let frames = vec![ + make_node_frame(0, 0, 56, 1.0), + make_node_frame(1, late, 56, 1.0), + ]; + let (_f, score) = fuser.fuse_scored(&frames, 0.0).expect("within hard guard"); + assert!( + score.forces_privacy_demotion(), + "a spread of {late} us exceeds the designed stagger and must still \ + be recorded as loose alignment" + ); + } + /// The derived soft guard stays strictly below the hard guard, and a /// degenerate (0-slot) schedule clamps to a usable config. #[test]