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 889a7c229..2e3f5d5bb 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/field_bridge.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/field_bridge.rs @@ -434,6 +434,32 @@ pub fn node_positions_by_id( map } +/// Boot-time note for entries given without a `node_id:` prefix. +/// +/// Those entries are keyed by list index from 0, so on a fleet numbered from 1 +/// the first entry goes to a node that does not exist and node 1 takes the +/// second entry. The rule stays as it is, since existing configs depend on it; +/// this only says it out loud. Returns `None` when every entry names its node. +pub fn positional_entries_note(entries: &[NodePositionEntry]) -> Option { + let mapping: Vec = entries + .iter() + .enumerate() + .filter(|(_, e)| e.node_id.is_none()) + .map(|(idx, _)| format!("entry {idx} -> node {idx}")) + .collect(); + if mapping.is_empty() { + return None; + } + Some(format!( + "{} node position entr{} without a node_id: prefix {} keyed by list index \ + from 0 ({}); use node_id:x,y,z to name the node explicitly", + mapping.len(), + if mapping.len() == 1 { "y" } else { "ies" }, + if mapping.len() == 1 { "is" } else { "are" }, + mapping.join(", ") + )) +} + /// Parse node positions from a semicolon-delimited string. /// /// Format: `"x,y,z;x,y,z;..."` where each coordinate is an `f32`. @@ -646,6 +672,35 @@ mod tests { } } + /// Issue #1804: the unprefixed form maps entry 0 to node 0, so on a fleet + /// numbered from 1 node 1 takes the second entry. The boot note says so. + #[test] + fn positional_entries_note_names_the_index_mapping() { + let note = positional_entries_note(&parse_node_position_entries("0,0,2.5;4,0,2.5")) + .expect("unprefixed entries produce a note"); + assert!(note.contains("keyed by list index from 0"), "{note}"); + assert!( + note.contains("entry 0 -> node 0, entry 1 -> node 1"), + "{note}" + ); + } + + #[test] + fn positional_entries_note_lists_only_unprefixed_entries() { + let note = positional_entries_note(&parse_node_position_entries("5:1,1,1;2,2,2")) + .expect("one unprefixed entry"); + assert!(note.starts_with("1 node position entry"), "{note}"); + assert!(note.contains("entry 1 -> node 1"), "{note}"); + assert!(!note.contains("entry 0"), "{note}"); + } + + #[test] + fn positional_entries_note_is_silent_when_every_entry_names_its_node() { + let entries = parse_node_position_entries("1:0,0,2.5;2:4,0,2.5"); + assert_eq!(positional_entries_note(&entries), None); + assert_eq!(positional_entries_note(&[]), None); + } + /// A duplicate id keeps the later entry rather than silently holding two. #[test] fn a_duplicate_node_id_keeps_the_later_entry() { diff --git a/v2/crates/wifi-densepose-sensing-server/src/main.rs b/v2/crates/wifi-densepose-sensing-server/src/main.rs index 65b385ef1..667b3524b 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/main.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/main.rs @@ -2014,6 +2014,9 @@ struct AppStateInner { /// same convention `MultistaticFuser::fuse` uses. See /// `node_positions_by_active_id`. node_positions_config: HashMap, + /// Node ids already logged as using `DEFAULT_NODE_POSITION`, so the + /// notice fires once per id rather than every cycle (issue #1804). + default_position_noted: std::collections::HashSet, /// Governed trust-path bridge (ADR-135..146): runs the same live frames /// through the privacy/provenance/witness control plane. Does not alter /// person-count behavior; its trust state (witness, effective class, @@ -2678,6 +2681,7 @@ impl AppStateInner { last_tracker_instant: None, multistatic_fuser: MultistaticFuser::new(), node_positions_config: HashMap::new(), + default_position_noted: std::collections::HashSet::new(), engine_bridge: engine_bridge::EngineBridge::new( wifi_densepose_bfld::PrivacyMode::PrivateHome, 1, @@ -7177,9 +7181,26 @@ async fn health_ready(State(state): State) -> Json, + node_states: &HashMap, +) -> serde_json::Value { + let mut configured: Vec = node_positions_config.keys().copied().collect(); + configured.sort_unstable(); + serde_json::json!({ + "configured_node_ids": configured, + "default_position": DEFAULT_NODE_POSITION, + "default_position_node_ids": + default_position_node_ids(node_positions_config, node_states.keys().copied()), + }) +} + async fn health_system(State(state): State) -> Json { let s = state.read().await; let uptime = s.start_time.elapsed().as_secs(); @@ -10218,6 +10239,9 @@ async fn nodes_endpoint(State(state): State) -> Json, + node_ids: impl IntoIterator, +) -> Vec { + let mut ids: Vec = node_ids + .into_iter() + .filter(|id| !node_positions_config.contains_key(id)) + .collect(); + ids.sort_unstable(); + ids.dedup(); + ids +} + +/// Log once per node id when a fresh node falls back to the default position. +/// +/// WARN when `--node-positions` was given, since a node it does not name is +/// usually an id mismatch; INFO when no positions were configured at all. +/// Returns the ids logged this call. +fn note_default_position_nodes( + node_positions_config: &HashMap, + node_states: &HashMap, + warned: &mut std::collections::HashSet, + now: std::time::Instant, +) -> Vec { + let fresh = node_states + .iter() + .filter(|(_, n)| node_is_fresh(n, now)) + .map(|(&id, _)| id); + let newly: Vec = default_position_node_ids(node_positions_config, fresh) + .into_iter() + .filter(|id| warned.insert(*id)) + .collect(); + if newly.is_empty() { + return newly; + } + if node_positions_config.is_empty() { + info!( + "node(s) {newly:?} use the default position {DEFAULT_NODE_POSITION:?} \ + (no --node-positions configured)" + ); + } else { + let mut configured: Vec = node_positions_config.keys().copied().collect(); + configured.sort_unstable(); + warn!( + "node(s) {newly:?} have no --node-positions entry and use the default \ + position {DEFAULT_NODE_POSITION:?}; configured node ids are {configured:?}" + ); + } + newly +} + #[cfg(test)] mod node_positions_by_active_id_tests { use super::*; @@ -10357,6 +10439,65 @@ mod node_positions_by_active_id_tests { let resolved = node_positions_by_active_id(&cfg(&[]), &node_states, now); assert_eq!(resolved.get(&11), None); } + + /// Issue #1804: only ids the config names count as configured; duplicates + /// collapse and the result is sorted. + #[test] + fn default_position_node_ids_lists_unconfigured_nodes_sorted() { + let configured = cfg(&[(11, [0.0; 3]), (12, [0.0; 3]), (2, [0.0; 3])]); + assert_eq!( + default_position_node_ids(&configured, [3, 1, 2, 3]), + vec![1, 3] + ); + assert!(default_position_node_ids(&configured, [2, 11]).is_empty()); + } + + #[test] + fn default_position_note_fires_once_per_fresh_node() { + let now = std::time::Instant::now(); + let configured = cfg(&[(11, [1.0, 0.0, 0.0])]); + let mut node_states = HashMap::new(); + node_states.insert(1, active_node(now)); + node_states.insert(11, active_node(now)); + let mut stale = NodeState::new(); + stale.last_frame_time = Some(now - std::time::Duration::from_secs(30)); + node_states.insert(5, stale); + let mut warned = std::collections::HashSet::new(); + + let first = note_default_position_nodes(&configured, &node_states, &mut warned, now); + assert_eq!( + first, + vec![1], + "configured and stale nodes are not reported" + ); + + let again = note_default_position_nodes(&configured, &node_states, &mut warned, now); + assert!(again.is_empty(), "a node is reported once"); + + // The node state can be evicted and recreated; the id is still known. + node_states.remove(&1); + node_states.insert(1, active_node(now)); + node_states.insert(2, active_node(now)); + let later = note_default_position_nodes(&configured, &node_states, &mut warned, now); + assert_eq!(later, vec![2]); + } + + #[test] + fn status_view_lists_configured_and_default_position_nodes() { + let now = std::time::Instant::now(); + let configured = cfg(&[(13, [0.0; 3]), (11, [0.0; 3]), (12, [0.0; 3])]); + let mut node_states = HashMap::new(); + for id in [3u8, 1, 2] { + node_states.insert(id, active_node(now)); + } + let view = node_positions_status(&configured, &node_states); + assert_eq!(view["configured_node_ids"], serde_json::json!([11, 12, 13])); + assert_eq!( + view["default_position_node_ids"], + serde_json::json!([1, 2, 3]) + ); + assert_eq!(view["default_position"], serde_json::json!([2.0, 0.0, 1.5])); + } } /// A node that has not reported for this long is removed from `node_states`. @@ -10698,6 +10839,15 @@ async fn udp_receiver_task( // Build nodes array with all active nodes. let resolved_positions = node_positions_by_active_id(&s.node_positions_config, &s.node_states, now); + { + let sref: &mut AppStateInner = &mut s; + note_default_position_nodes( + &sref.node_positions_config, + &sref.node_states, + &mut sref.default_position_noted, + now, + ); + } let active_nodes: Vec = s .node_states .iter() @@ -10708,7 +10858,7 @@ async fn udp_receiver_task( position: resolved_positions .get(&id) .copied() - .unwrap_or([2.0, 0.0, 1.5]), + .unwrap_or(DEFAULT_NODE_POSITION), amplitude: vec![], subcarrier_count: 0, // Vitals-only path; still expose the sync snapshot @@ -11215,6 +11365,15 @@ async fn udp_receiver_task( let suppress_raw = s.engine_bridge.suppress_raw_outputs(); let resolved_positions = node_positions_by_active_id(&s.node_positions_config, &s.node_states, now); + { + let sref: &mut AppStateInner = &mut s; + note_default_position_nodes( + &sref.node_positions_config, + &sref.node_states, + &mut sref.default_position_noted, + now, + ); + } let active_nodes: Vec = s .node_states .iter() @@ -11225,7 +11384,7 @@ async fn udp_receiver_task( position: resolved_positions .get(&id) .copied() - .unwrap_or([2.0, 0.0, 1.5]), + .unwrap_or(DEFAULT_NODE_POSITION), amplitude: if suppress_raw { vec![] } else { @@ -12963,6 +13122,12 @@ async fn main() { "Configured {} node positions for multistatic fusion", entries.len() ); + // Issue #1804: say how unprefixed entries map to node ids. + // The parsed count alone does not show whether any entry + // matches a live node. + if let Some(note) = field_bridge::positional_entries_note(&entries) { + warn!("{note}"); + } // 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 @@ -12990,6 +13155,7 @@ async fn main() { fuser }, node_positions_config, + default_position_noted: std::collections::HashSet::new(), engine_bridge: engine_bridge::EngineBridge::new( wifi_densepose_bfld::PrivacyMode::PrivateHome, 1,