From 2dc466f359c8e141805873be021d29a0de18cc71 Mon Sep 17 00:00:00 2001 From: Johnathan Corgan Date: Mon, 9 Mar 2026 00:10:31 +0000 Subject: [PATCH] Add estimated mesh size from bloom filter cardinality Compute network size estimate by summing bloom filter estimated entry counts from the spanning tree parent (upward) and children (downward), leveraging the tree's non-overlapping partition property. Uses the standard formula n = -(m/k) * ln(1 - X/m) already in BloomFilter. Surfaces the estimate in three places: - show_status JSON: "estimated_mesh_size" field - Periodic info log at MMP log interval (default 30s) - fipstop Node dashboard State section as "mesh: ~N" --- src/bin/fipstop/ui/dashboard.rs | 7 +++ src/control/queries.rs | 1 + src/node/handlers/rx_loop.rs | 1 + src/node/mod.rs | 80 +++++++++++++++++++++++++++++++++ 4 files changed, 89 insertions(+) diff --git a/src/bin/fipstop/ui/dashboard.rs b/src/bin/fipstop/ui/dashboard.rs index 8662e46..4fa81dd 100644 --- a/src/bin/fipstop/ui/dashboard.rs +++ b/src/bin/fipstop/ui/dashboard.rs @@ -130,6 +130,11 @@ fn draw_state(frame: &mut Frame, data: &serde_json::Value, area: Rect) { let links = helpers::u64_field(data, "link_count"); let transports = helpers::u64_field(data, "transport_count"); let connections = helpers::u64_field(data, "connection_count"); + let mesh_size = data + .get("estimated_mesh_size") + .and_then(|v| v.as_u64()) + .map(|n| format!("~{n}")) + .unwrap_or_else(|| "-".into()); let lines = vec![ Line::from(vec![ @@ -153,6 +158,8 @@ fn draw_state(frame: &mut Frame, data: &serde_json::Value, area: Rect) { Span::styled(transports, count), Span::styled(" connections: ", label), Span::styled(connections, count), + Span::styled(" mesh: ", label), + Span::styled(mesh_size, count), ]), ]; diff --git a/src/control/queries.rs b/src/control/queries.rs index de2c339..1624555 100644 --- a/src/control/queries.rs +++ b/src/control/queries.rs @@ -58,6 +58,7 @@ pub fn show_status(node: &Node) -> Value { "pid": pid, "exe_path": exe_path, "uptime_secs": uptime_secs, + "estimated_mesh_size": node.estimated_mesh_size(), "forwarding": serde_json::to_value(&fwd).unwrap_or_default(), }) } diff --git a/src/node/handlers/rx_loop.rs b/src/node/handlers/rx_loop.rs index e961612..f264306 100644 --- a/src/node/handlers/rx_loop.rs +++ b/src/node/handlers/rx_loop.rs @@ -114,6 +114,7 @@ impl Node { self.process_pending_retries(now_ms).await; self.check_tree_state().await; self.check_bloom_state().await; + self.compute_mesh_size(); self.check_mmp_reports().await; self.check_session_mmp_reports().await; self.check_link_heartbeats().await; diff --git a/src/node/mod.rs b/src/node/mod.rs index 11d812a..7632004 100644 --- a/src/node/mod.rs +++ b/src/node/mod.rs @@ -378,6 +378,12 @@ pub struct Node { /// Timestamp of last congestion detection log (rate-limited to 5s). last_congestion_log: Option, + // === Mesh Size Estimate === + /// Cached estimated mesh size (computed once per tick from bloom filters). + estimated_mesh_size: Option, + /// Timestamp of last mesh size log emission. + last_mesh_size_log: Option, + // === Display Names === /// Human-readable names for configured peers (alias or short npub). /// Populated at startup from peer config. @@ -496,6 +502,8 @@ impl Node { retry_pending: HashMap::new(), last_parent_reeval: None, last_congestion_log: None, + estimated_mesh_size: None, + last_mesh_size_log: None, peer_aliases: HashMap::new(), host_map, }) @@ -596,6 +604,8 @@ impl Node { retry_pending: HashMap::new(), last_parent_reeval: None, last_congestion_log: None, + estimated_mesh_size: None, + last_mesh_size_log: None, peer_aliases: HashMap::new(), host_map, } @@ -852,6 +862,76 @@ impl Node { &mut self.bloom_state } + // === Mesh Size Estimate === + + /// Get the cached estimated mesh size. + pub fn estimated_mesh_size(&self) -> Option { + self.estimated_mesh_size + } + + /// Compute and cache the estimated mesh size from bloom filters. + /// + /// Uses the spanning tree partition: parent's filter covers nodes reachable + /// upward, children's filters cover disjoint subtrees downward. The sum + /// of estimated entry counts plus one (self) approximates total network size. + pub(crate) fn compute_mesh_size(&mut self) { + let my_addr = *self.tree_state.my_node_addr(); + let parent_id = *self.tree_state.my_declaration().parent_id(); + let is_root = self.tree_state.is_root(); + + let mut total: f64 = 1.0; // count self + let mut child_count: u32 = 0; + let mut has_data = false; + + // Parent's filter: nodes reachable upward through the tree + if !is_root + && let Some(parent) = self.peers.get(&parent_id) + && let Some(filter) = parent.inbound_filter() + { + total += filter.estimated_count(); + has_data = true; + } + + // Children's filters: each child's subtree is disjoint + for (peer_addr, peer) in &self.peers { + if let Some(decl) = self.tree_state.peer_declaration(peer_addr) + && *decl.parent_id() == my_addr + { + child_count += 1; + if let Some(filter) = peer.inbound_filter() { + total += filter.estimated_count(); + has_data = true; + } + } + } + + if !has_data { + self.estimated_mesh_size = None; + return; + } + + let size = total.round() as u64; + self.estimated_mesh_size = Some(size); + + // Periodic logging (reuse MMP default interval: 30s) + let now = std::time::Instant::now(); + let should_log = match self.last_mesh_size_log { + None => true, + Some(last) => now.duration_since(last) >= std::time::Duration::from_secs( + self.config.node.mmp.log_interval_secs, + ), + }; + if should_log { + tracing::info!( + estimated_mesh_size = size, + peers = self.peers.len(), + children = child_count, + "Mesh size estimate" + ); + self.last_mesh_size_log = Some(now); + } + } + // === Coord Cache === /// Get the coordinate cache.