mirror of
https://github.com/ruvnet/RuView.git
synced 2026-10-21 14:12:34 -05:00
Merge PR #2109 (aepod) into release integration
This commit is contained in:
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,
|
||||
|
||||
Reference in new issue
Block a user