Merge PR #2109 (aepod) into release integration

This commit is contained in:
ruv committed 2026-10-05 11:44:33 -04:00
commit bb22f1b185
2 files changed
+223 -2

No files matched your search

@@ -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<String> {
let mapping: Vec<String> = 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() {
@@ -2014,6 +2014,9 @@ struct AppStateInner {
/// same convention `MultistaticFuser::fuse` uses. See
/// `node_positions_by_active_id`.
node_positions_config: HashMap<u8, [f32; 3]>,
/// 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<u8>,
/// 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<SharedState>) -> Json<serde_json::Valu
"reason": p.reason().map(|r| r.as_str()),
})),
},
"node_positions": node_positions_status(&s.node_positions_config, &s.node_states),
}))
}
/// `/api/v1/status` view of `--node-positions` (issue #1804): which node ids
/// are configured, and which known nodes fall back to `DEFAULT_NODE_POSITION`.
fn node_positions_status(
node_positions_config: &HashMap<u8, [f32; 3]>,
node_states: &HashMap<u8, NodeState>,
) -> serde_json::Value {
let mut configured: Vec<u8> = 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<SharedState>) -> Json<serde_json::Value> {
let s = state.read().await;
let uptime = s.start_time.elapsed().as_secs();
@@ -10218,6 +10239,9 @@ async fn nodes_endpoint(State(state): State<SharedState>) -> Json<serde_json::Va
"person_count_valid": ns.edge_vitals
.as_ref()
.map(|vitals| vitals.person_count_valid),
// Issue #1804: false means no --node-positions entry names this
// node, so it reports the shared default position.
"position_configured": s.node_positions_config.contains_key(&id),
})
})
.collect();
@@ -10279,6 +10303,64 @@ fn node_positions_by_active_id(
.collect()
}
/// Position reported for a node with no `--node-positions` entry.
const DEFAULT_NODE_POSITION: [f64; 3] = [2.0, 0.0, 1.5];
/// Node ids with no `--node-positions` entry, sorted ascending.
///
/// Every such node reports `DEFAULT_NODE_POSITION`, so several of them sit on
/// the same point. Issue #1804: nothing said which nodes those were.
fn default_position_node_ids(
node_positions_config: &HashMap<u8, [f32; 3]>,
node_ids: impl IntoIterator<Item = u8>,
) -> Vec<u8> {
let mut ids: Vec<u8> = 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<u8, [f32; 3]>,
node_states: &HashMap<u8, NodeState>,
warned: &mut std::collections::HashSet<u8>,
now: std::time::Instant,
) -> Vec<u8> {
let fresh = node_states
.iter()
.filter(|(_, n)| node_is_fresh(n, now))
.map(|(&id, _)| id);
let newly: Vec<u8> = 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<u8> = 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<NodeInfo> = 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<NodeInfo> = 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,