From 717be3d960763f0dd1529d28bb1950b45bd5b15a Mon Sep 17 00:00:00 2001 From: Johnathan Corgan Date: Mon, 23 Feb 2026 14:00:00 +0000 Subject: [PATCH] Restrict bloom filter propagation to tree edges, update design docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gate peer_inbound_filters() to only collect from tree peers (parent and children), so outgoing filter computation merges only tree-sourced information. All peers still receive FilterAnnounce messages and store filters locally for routing queries — the restriction is only on what gets merged into outgoing filters. This prevents bloom filter saturation where mesh shortcuts cause every node's filter to converge toward the full network. With tree-only merge, filters contain subtree (from children) + complement (from parent) + single-hop mesh views. Implementation: - Add is_tree_peer() helper to determine tree parent/child relationship - Gate peer_inbound_filters() to tree peers only (single control point) - Trigger bloom filter exchange on tree relationship changes - Add est_entries, set_bits, fill ratio, and tree_peer fields to FilterAnnounce send/receive debug logs - Add test_bloom_filter_split_horizon test verifying directional asymmetry: upward filters contain only the child's subtree, downward filters contain only the complement - Add print_filter_cardinality diagnostic helper for test inspection Design docs: - fips-bloom-filters.md: Add directional asymmetry and mesh peer filter subsections, update per-peer filter model, saturation mitigation, implementation status table - fips-mesh-operation.md: Update filter propagation description, add directional asymmetry, tree relationship change trigger - fips-intro.md: Rewrite bloom propagation paragraph for tree-only merge --- docs/design/fips-bloom-filters.md | 71 ++++++-- docs/design/fips-intro.md | 23 ++- docs/design/fips-mesh-operation.md | 30 ++-- src/node/bloom.rs | 36 ++-- src/node/handlers/dispatch.rs | 2 +- src/node/mod.rs | 20 +++ src/node/tests/bloom.rs | 275 +++++++++++++++++++++++++---- src/node/tree.rs | 13 ++ 8 files changed, 396 insertions(+), 74 deletions(-) diff --git a/docs/design/fips-bloom-filters.md b/docs/design/fips-bloom-filters.md index 391af87..585ad79 100644 --- a/docs/design/fips-bloom-filters.md +++ b/docs/design/fips-bloom-filters.md @@ -64,8 +64,14 @@ distance calculation makes the actual forwarding decision. A bloom filter with 8,192 bits and k = 5 saturates (FPR approaches 100%) around 3,000–4,000 entries. Beyond this, every query returns "maybe" and the -filter provides no candidate selection value. Hub nodes approaching this -threshold effectively fall back to tree-distance-only routing. +filter provides no candidate selection value. + +Tree-only merge propagation mitigates saturation by limiting each filter's +content to tree-relevant entries. A node's outgoing filter to its parent +contains only its subtree; its outgoing filter to a child contains the +complement. Neither filter contains entries from mesh shortcuts' transitive +information, keeping filter occupancy proportional to the node's position +in the tree rather than the total mesh connectivity. ## Per-Peer Filter Model @@ -79,7 +85,14 @@ The outbound filter for peer Q is computed by merging: 1. **This node's own identity** (node_addr) 2. **Leaf-only dependents** (if any — future direction) -3. **All other peers' inbound filters except Q's** (split-horizon exclusion) +3. **Tree peers' inbound filters except Q's** (tree-only merge with + split-horizon exclusion) + +Only filters from **tree peers** (parent and children in the spanning tree) +are merged into outgoing filter computation. Filters from non-tree mesh +peers are stored locally for routing queries but are not propagated +transitively. This prevents bloom filter saturation where mesh shortcuts +cause every node's filter to converge toward the full network. ### Split-Horizon Exclusion @@ -89,13 +102,38 @@ creating a routing loop where Q thinks it can reach a destination through this node, and this node thinks it can reach the same destination through Q. Split-horizon is computed per-peer: the outbound filter for peer Q merges -all inbound filters except Q's. For a node with peers A, B, C: +all tree peer inbound filters except Q's. + +### Directional Asymmetry + +Because merge is restricted to tree peers, outgoing filters exhibit +directional asymmetry along tree edges: + +- **Upward (child → parent)**: Contains the child's subtree — the child's + own identity plus all entries merged from its children's filters +- **Downward (parent → child)**: Contains the complement — the parent's + own identity plus entries from all other tree peers (siblings' subtrees + and the parent's own parent direction) + +Together, the upward and downward filters for a tree edge cover the entire +network with no overlap (excluding the node itself at the split point). + +### Mesh Peer Filters + +All peers — including non-tree mesh shortcuts — still **receive** +FilterAnnounce messages and **store** received filters locally. These +stored filters are consulted during routing (step 3 of `find_next_hop()`) +for single-hop shortcut discovery. However, mesh peer filters contain +only the mesh peer's own tree-propagated information, not transitive +entries from the broader network. + +For a node with tree peers A, B and mesh peer C: | Outbound to | Includes entries from | | ----------- | -------------------- | -| A | Self + B's filter + C's filter | -| B | Self + A's filter + C's filter | -| C | Self + A's filter + B's filter | +| A | Self + B's filter (tree-only merge, excluding A) | +| B | Self + A's filter (tree-only merge, excluding B) | +| C | Self + A's filter + B's filter (tree-only merge, C excluded as non-tree) | ## Filter Propagation @@ -121,10 +159,16 @@ cooldown period are coalesced into a single announcement. ### Propagation Scope -Filters propagate unboundedly — there is no TTL on filter propagation. At -steady state, every reachable destination appears in at least one peer's -filter. New nodes added to the network propagate through the entire mesh -within O(D × 500ms) where D is the network diameter. +Filters propagate transitively through tree edges only. Since the spanning +tree is a connected subgraph covering all nodes, every reachable +destination still appears in at least one tree peer's filter at steady +state. New nodes propagate through the tree within O(depth × 500ms) where +depth is the tree depth. + +Mesh shortcuts provide single-hop filter visibility (the mesh peer's own +filter) but do not contribute to transitive propagation. This bounds the +information in each filter to tree-relevant entries rather than the full +network. ## Filter Expiration @@ -232,10 +276,13 @@ negotiation not present in v1. | 1 KB bloom filter (size_class 1) | **Implemented** | | 5 hash functions | **Implemented** | | Split-horizon filter computation | **Implemented** | +| Tree-only merge propagation | **Implemented** | +| Directional asymmetry (subtree/complement) | **Implemented** | | Per-peer filter maintenance | **Implemented** | | Event-driven updates | **Implemented** | | 500ms rate limiting | **Implemented** | -| FilterAnnounce gossip | **Implemented** | +| FilterAnnounce gossip (all peers) | **Implemented** | +| Filter cardinality logging | **Implemented** | | Size class negotiation | Future direction | | Folding support | Future direction | | Adaptive filter sizing | Future direction | diff --git a/docs/design/fips-intro.md b/docs/design/fips-intro.md index 73272ec..7f5c5a2 100644 --- a/docs/design/fips-intro.md +++ b/docs/design/fips-intro.md @@ -350,22 +350,27 @@ routing — bloom filters narrow the set of peers worth considering, and the actual forwarding decision ranks those candidates by tree distance and link quality. -Filters propagate via gossip, with each node computing outbound filters by -merging the filters received from its other peers (a +Filters propagate transitively through tree edges, with each node computing +outbound filters by merging the filters received from its tree peers (parent +and children) using a [split-horizon](https://en.wikipedia.org/wiki/Split_horizon_route_advertisement) -technique borrowed from distance-vector routing). At steady state, filters -represent the entire reachable network. +technique borrowed from distance-vector routing. All peers — including +non-tree mesh shortcuts — receive FilterAnnounce messages, but only tree +peers' filters are merged into outgoing computation. This prevents filter +saturation where mesh shortcuts would cause every filter to converge toward +the full network. See [fips-bloom-filters.md](fips-bloom-filters.md) for filter parameters and mathematical properties. ![Bloom filter propagation on a spanning tree](fips-bloom-propagation.svg) -Every filter is computed identically regardless of link type: the outbound -filter for peer Q merges this node's identity with all inbound filters -except Q's (split-horizon exclusion). The protocol makes no distinction -between tree parents, tree children, or mesh peers — the asymmetry visible -in the diagram is a consequence of tree topology, not a different operation. +The outbound filter for peer Q merges this node's identity with tree peer +inbound filters except Q's (split-horizon exclusion). This creates +directional asymmetry: upward filters (child → parent) contain the child's +subtree, while downward filters (parent → child) contain the complement. +Mesh peers receive filters but their inbound filters are not merged +transitively — they provide single-hop shortcut visibility only. A node with multiple peers receives genuinely different filters from each. In the diagram, R receives {B, D, E} from B and {C, F} from C — two disjoint diff --git a/docs/design/fips-mesh-operation.md b/docs/design/fips-mesh-operation.md index 779ee04..c16b08c 100644 --- a/docs/design/fips-mesh-operation.md +++ b/docs/design/fips-mesh-operation.md @@ -100,25 +100,33 @@ decision uses tree coordinate distance to rank those candidates. ### How Filters Propagate -Nodes exchange **FilterAnnounce** messages with direct peers. Each +Nodes exchange **FilterAnnounce** messages with all direct peers. Each FilterAnnounce replaces the previous filter for that peer — there is no incremental update. -Filter computation uses **split-horizon exclusion**: the outbound filter -for peer Q is computed by merging the local node's own identity, its -leaf-only dependents (if any), and the filters received from all other -peers *except* Q. This prevents echo loops where a node advertises back -to Q the destinations it learned from Q. +Filter computation uses **tree-only merge with split-horizon exclusion**: +the outbound filter for peer Q is computed by merging the local node's own +identity, its leaf-only dependents (if any), and the inbound filters from +tree peers (parent and children) *except* Q. Filters from non-tree mesh +peers are stored locally for routing queries but are not merged into +outgoing filters. This prevents saturation where mesh shortcuts cause +filters to converge toward the full network. -Filters propagate unboundedly (no TTL). At steady state, every reachable -destination appears in at least one peer's filter. +The restriction creates **directional asymmetry**: upward filters +(child → parent) contain the child's subtree, while downward filters +(parent → child) contain the complement. Together they cover the entire +network. + +Filters propagate transitively through tree edges. At steady state, every +reachable destination appears in at least one tree peer's filter. ### Update Triggers Filter updates are event-driven, not periodic: - Peer connects or disconnects -- A peer's incoming filter changes +- A peer's incoming filter changes (triggers recomputation for other peers) +- Tree relationship changes (new parent, new child, parent switch) - Local state changes (new identity, leaf-only dependent changes) Updates are rate-limited at 500ms to prevent storms during topology changes. @@ -509,8 +517,8 @@ When traffic to a destination stops: 1. **Session idles out** (90s) — session torn down 2. **Coordinate caches expire** (300s) — transit nodes forget coordinates -3. **Bloom filters remain** — they have no TTL, so reachability information - persists +3. **Bloom filters remain** — they have no TTL, so tree-propagated + reachability information persists When traffic resumes: diff --git a/src/node/bloom.rs b/src/node/bloom.rs index 75518e6..56a48f9 100644 --- a/src/node/bloom.rs +++ b/src/node/bloom.rs @@ -9,7 +9,7 @@ use crate::NodeAddr; use super::{Node, NodeError}; use std::collections::HashMap; -use tracing::{debug, trace}; +use tracing::debug; impl Node { /// Collect inbound filters from all peers for outgoing filter computation. @@ -19,7 +19,9 @@ impl Node { fn peer_inbound_filters(&self) -> HashMap { let mut filters = HashMap::new(); for (addr, peer) in &self.peers { - if let Some(filter) = peer.inbound_filter() { + if self.is_tree_peer(addr) + && let Some(filter) = peer.inbound_filter() + { filters.insert(*addr, filter.clone()); } } @@ -71,13 +73,21 @@ impl Node { self.send_encrypted_link_message(peer_addr, &encoded).await?; // Record send and store the filter for change detection + debug!( + peer = %self.peer_display_name(peer_addr), + seq = announce.sequence, + est_entries = format_args!("{:.0}", sent_filter.estimated_count()), + set_bits = sent_filter.count_ones(), + fill = format_args!("{:.1}%", sent_filter.fill_ratio() * 100.0), + tree_peer = self.is_tree_peer(peer_addr), + "Sent FilterAnnounce" + ); self.bloom_state.record_update_sent(*peer_addr, now_ms); self.bloom_state.record_sent_filter(*peer_addr, sent_filter); if let Some(peer) = self.peers.get_mut(peer_addr) { peer.clear_filter_update_needed(); } - trace!(peer = %self.peer_display_name(peer_addr), seq = announce.sequence, "Sent FilterAnnounce"); Ok(()) } @@ -156,18 +166,24 @@ impl Node { .map(|d| d.as_millis() as u64) .unwrap_or(0); + debug!( + from = %self.peer_display_name(from), + seq = announce.sequence, + est_entries = format_args!("{:.0}", announce.filter.estimated_count()), + set_bits = announce.filter.count_ones(), + fill = format_args!("{:.1}%", announce.filter.fill_ratio() * 100.0), + tree_peer = self.is_tree_peer(from), + "Received FilterAnnounce" + ); + // Store on peer if let Some(peer) = self.peers.get_mut(from) { peer.update_filter(announce.filter, announce.sequence, now_ms); } - debug!( - from = %self.peer_display_name(from), - seq = announce.sequence, - "Received FilterAnnounce" - ); - - // Check which peers' outgoing filters actually changed + // Check which peers' outgoing filters actually changed. + // All peers receive filters, but only tree peers' inbound filters + // are merged into outgoing computation (tree-only propagation). let peer_addrs: Vec = self.peers.keys().copied().collect(); let peer_filters = self.peer_inbound_filters(); self.bloom_state diff --git a/src/node/handlers/dispatch.rs b/src/node/handlers/dispatch.rs index 7342e12..d16ea5f 100644 --- a/src/node/handlers/dispatch.rs +++ b/src/node/handlers/dispatch.rs @@ -127,7 +127,7 @@ impl Node { } } - // Bloom filter cleanup: clear state for removed peer, mark remaining + // Bloom filter cleanup: clear state for removed peer, mark all remaining peers self.bloom_state.remove_peer_state(node_addr); let remaining_peers: Vec = self.peers.keys().copied().collect(); self.bloom_state.mark_all_updates_needed(remaining_peers); diff --git a/src/node/mod.rs b/src/node/mod.rs index 75fa5a1..ad3b6c7 100644 --- a/src/node/mod.rs +++ b/src/node/mod.rs @@ -972,6 +972,26 @@ impl Node { // === Routing === + /// Check if a peer is a tree neighbor (parent or child in the spanning tree). + /// + /// Returns true if the peer is our current tree parent, or if the peer + /// has declared us as their parent (making them our child). + pub(crate) fn is_tree_peer(&self, peer_addr: &NodeAddr) -> bool { + // Peer is our parent + if !self.tree_state.is_root() + && self.tree_state.my_declaration().parent_id() == peer_addr + { + return true; + } + // Peer is our child (their declaration names us as parent) + if let Some(decl) = self.tree_state.peer_declaration(peer_addr) + && decl.parent_id() == self.node_addr() + { + return true; + } + false + } + /// Find next hop for a destination node address. /// /// Routing priority: diff --git a/src/node/tests/bloom.rs b/src/node/tests/bloom.rs index 98f5184..cc6285a 100644 --- a/src/node/tests/bloom.rs +++ b/src/node/tests/bloom.rs @@ -1,26 +1,36 @@ //! Bloom filter integration tests. //! -//! Verifies that bloom filters are exchanged between peers and that -//! filter propagation works correctly across multi-hop networks. +//! Verifies that bloom filters are exchanged between all peers and that +//! filter content propagates only through tree edges (tree-only propagation). use super::spanning_tree::*; use super::*; -/// Verify that all peer pairs have exchanged bloom filters and each -/// peer's inbound filter contains the peer's own node_addr. +/// Derive the tree edges from the converged spanning tree state. /// -/// Also verifies propagation: for each node, check that destinations -/// reachable through a peer's filter include the peer's direct neighbors. -fn verify_bloom_filter_exchange(nodes: &[TestNode], edges: &[(usize, usize)]) { - // Build adjacency for hop distance computation - let n = nodes.len(); - let mut adj = vec![vec![]; n]; - for &(i, j) in edges { - adj[i].push(j); - adj[j].push(i); +/// For each non-root node, finds the parent relationship and returns +/// the corresponding edge as (child_index, parent_index). +fn get_tree_edges(nodes: &[TestNode]) -> Vec<(usize, usize)> { + let mut edges = Vec::new(); + for (i, tn) in nodes.iter().enumerate() { + let ts = tn.node.tree_state(); + if !ts.is_root() { + let parent_addr = ts.my_declaration().parent_id(); + if let Some(j) = nodes + .iter() + .position(|n| n.node.node_addr() == parent_addr) + { + edges.push((i, j)); + } + } } + edges +} - // Every peer pair must have exchanged filters +/// Verify that all peer pairs on the given edges have exchanged bloom +/// filters and each peer's inbound filter contains the peer's own +/// node_addr. +fn verify_filter_exchange(nodes: &[TestNode], edges: &[(usize, usize)]) { for &(i, j) in edges { let j_addr = *nodes[j].node.node_addr(); let i_addr = *nodes[i].node.node_addr(); @@ -67,24 +77,33 @@ fn verify_bloom_filter_exchange(nodes: &[TestNode], edges: &[(usize, usize)]) { i ); } +} - // Verify propagation: each node's filter from a peer should - // contain addresses of the peer's direct neighbors (which were - // merged into the peer's outgoing filter). - for &(i, j) in edges { +/// Verify propagation along tree edges: each node's filter from a tree +/// peer should contain addresses of the peer's tree neighbors (which +/// were merged into the peer's outgoing filter via tree-only propagation). +fn verify_tree_propagation(nodes: &[TestNode], tree_edges: &[(usize, usize)]) { + let n = nodes.len(); + let mut tree_adj = vec![vec![]; n]; + for &(i, j) in tree_edges { + tree_adj[i].push(j); + tree_adj[j].push(i); + } + + for &(i, j) in tree_edges { let j_addr = *nodes[j].node.node_addr(); let peer_j = nodes[i].node.get_peer(&j_addr).unwrap(); let filter = peer_j.inbound_filter().unwrap(); - // All of j's direct neighbors (except i) should be in j's filter to i - for &neighbor_idx in &adj[j] { + // All of j's tree neighbors (except i) should be in j's filter to i + for &neighbor_idx in &tree_adj[j] { if neighbor_idx == i { continue; // j excludes i's direction from i's filter } let neighbor_addr = *nodes[neighbor_idx].node.node_addr(); assert!( filter.contains(&neighbor_addr), - "Node {}'s filter from node {} should contain node {}'s neighbor {} (addr={})", + "Node {}'s filter from node {} should contain node {}'s tree neighbor {} (addr={})", i, j, j, @@ -101,7 +120,12 @@ async fn test_bloom_filter_10_nodes() { let edges = generate_random_edges(10, 20, 123); let mut nodes = run_tree_test(10, &edges, false).await; verify_tree_convergence(&nodes); - verify_bloom_filter_exchange(&nodes, &edges); + // All peers exchange filters + verify_filter_exchange(&nodes, &edges); + // Content propagation only along tree edges + let tree_edges = get_tree_edges(&nodes); + verify_tree_propagation(&nodes, &tree_edges); + print_filter_cardinality(&nodes); cleanup_nodes(&mut nodes).await; } @@ -111,7 +135,9 @@ async fn test_bloom_filter_star() { let edges: Vec<(usize, usize)> = vec![(0, 1), (0, 2), (0, 3), (0, 4)]; let mut nodes = run_tree_test(5, &edges, false).await; verify_tree_convergence(&nodes); - verify_bloom_filter_exchange(&nodes, &edges); + verify_filter_exchange(&nodes, &edges); + let tree_edges = get_tree_edges(&nodes); + verify_tree_propagation(&nodes, &tree_edges); // Hub (node 0) sends each spoke a filter containing the other spokes let hub_addr = *nodes[0].node.node_addr(); @@ -142,7 +168,7 @@ async fn test_bloom_filter_star() { /// 8-node chain: verify full propagation. /// /// Chain: 0-1-2-3-4-5-6-7. Each node's outgoing filter is the merge -/// of its own address plus all peer inbound filters (excluding the +/// of its own address plus all tree peer inbound filters (excluding the /// destination peer). This means entries propagate through the entire /// chain: node 1 merges node 2's filter, which contains node 3's /// entries, and so on. Both endpoints should see all other nodes. @@ -152,7 +178,9 @@ async fn test_bloom_filter_chain_propagation() { vec![(0, 1), (1, 2), (2, 3), (3, 4), (4, 5), (5, 6), (6, 7)]; let mut nodes = run_tree_test(8, &edges, false).await; verify_tree_convergence(&nodes); - verify_bloom_filter_exchange(&nodes, &edges); + verify_filter_exchange(&nodes, &edges); + let tree_edges = get_tree_edges(&nodes); + verify_tree_propagation(&nodes, &tree_edges); let addrs: Vec = nodes.iter().map(|tn| *tn.node.node_addr()).collect(); @@ -193,17 +221,22 @@ async fn test_bloom_filter_chain_propagation() { cleanup_nodes(&mut nodes).await; } -/// 5-node ring: every node should see all others (all within 2-hop reach). +/// 5-node ring: every node should see all others via peer filters. +/// +/// All peers receive filters. Content propagates through the tree +/// (N-1=4 tree edges). Every node is reachable through at least one +/// peer's filter. #[tokio::test] async fn test_bloom_filter_ring() { let edges: Vec<(usize, usize)> = vec![(0, 1), (1, 2), (2, 3), (3, 4), (4, 0)]; let mut nodes = run_tree_test(5, &edges, false).await; verify_tree_convergence(&nodes); - verify_bloom_filter_exchange(&nodes, &edges); + // All peers (including the non-tree edge) receive filters + verify_filter_exchange(&nodes, &edges); + let tree_edges = get_tree_edges(&nodes); + verify_tree_propagation(&nodes, &tree_edges); - // In a 5-node ring, each node has 2 peers. Through each peer, - // the other 3 nodes are at most 2 hops away. So every node should - // be reachable via at least one peer's filter. + // Every node should be reachable via at least one peer's filter for i in 0..5 { for j in 0..5 { if i == j { @@ -225,6 +258,183 @@ async fn test_bloom_filter_ring() { cleanup_nodes(&mut nodes).await; } +/// Print filter cardinality for all peer relationships (diagnostic helper). +/// +/// Useful with `--nocapture` to inspect filter sizes and tree/mesh distinction. +fn print_filter_cardinality(nodes: &[TestNode]) { + println!("\n === Filter Cardinality ==="); + for (i, tn) in nodes.iter().enumerate() { + for (j, other) in nodes.iter().enumerate() { + if i == j { + continue; + } + let addr = *other.node.node_addr(); + if let Some(peer) = tn.node.get_peer(&addr) { + if let Some(filter) = peer.inbound_filter() { + let is_tree = tn.node.is_tree_peer(&addr); + println!( + " n{} <- n{}: est={:.1} set_bits={} fill={:.1}% tree={}", + i, + j, + filter.estimated_count(), + filter.count_ones(), + filter.fill_ratio() * 100.0, + is_tree, + ); + } + } + } + } +} + +/// Compute the set of node indices in a subtree rooted at `subtree_root`, +/// given a tree adjacency list and the actual root of the whole tree. +fn collect_subtree( + subtree_root: usize, + parent: Option, + tree_adj: &[Vec], +) -> Vec { + let mut result = vec![subtree_root]; + for &neighbor in &tree_adj[subtree_root] { + if Some(neighbor) != parent { + result.extend(collect_subtree(neighbor, Some(subtree_root), tree_adj)); + } + } + result +} + +/// 7-node tree: verify split-horizon asymmetry between upward and downward filters. +/// +/// Creates a pure tree topology and verifies that: +/// - Upward filters (child→parent) contain only the child's subtree +/// - Downward filters (parent→child) contain only the complement +/// - Cardinality estimates match expected subtree sizes +/// +/// The tree structure formed depends on which node gets the lowest NodeAddr +/// (becomes root), but the split-horizon property holds regardless. +#[tokio::test] +async fn test_bloom_filter_split_horizon() { + // Pure tree: 7 nodes, 6 edges + let edges: Vec<(usize, usize)> = + vec![(0, 1), (0, 2), (1, 3), (1, 4), (2, 5), (5, 6)]; + let mut nodes = run_tree_test(7, &edges, false).await; + verify_tree_convergence(&nodes); + verify_filter_exchange(&nodes, &edges); + let tree_edges = get_tree_edges(&nodes); + verify_tree_propagation(&nodes, &tree_edges); + + let addrs: Vec = nodes.iter().map(|tn| *tn.node.node_addr()).collect(); + + // Build the actual tree adjacency from converged state + let n = nodes.len(); + let mut tree_adj = vec![vec![]; n]; + for &(child, parent) in &tree_edges { + tree_adj[child].push(parent); + tree_adj[parent].push(child); + } + + // Find the root + let root_idx = nodes + .iter() + .position(|tn| tn.node.tree_state().is_root()) + .expect("Should have exactly one root"); + + print_filter_cardinality(&nodes); + + // For each tree edge (child, parent), verify split-horizon: + // - child's filter to parent contains child's subtree only + // - parent's filter to child contains the complement only + for &(child_idx, parent_idx) in &tree_edges { + let child_subtree = collect_subtree(child_idx, Some(parent_idx), &tree_adj); + let complement: Vec = (0..n) + .filter(|i| !child_subtree.contains(i)) + .collect(); + + // --- Upward filter: child → parent --- + // This is stored as parent's inbound filter from child + let filter_up = nodes[parent_idx] + .node + .get_peer(&addrs[child_idx]) + .unwrap() + .inbound_filter() + .unwrap(); + + // Should contain all nodes in child's subtree + for &idx in &child_subtree { + assert!( + filter_up.contains(&addrs[idx]), + "Upward filter (n{}→n{}): should contain subtree member n{} but doesn't", + child_idx, parent_idx, idx + ); + } + + // Should NOT contain nodes in the complement + for &idx in &complement { + assert!( + !filter_up.contains(&addrs[idx]), + "Upward filter (n{}→n{}): should NOT contain complement member n{} but does", + child_idx, parent_idx, idx + ); + } + + // Cardinality should match subtree size + let up_est = filter_up.estimated_count(); + assert!( + (up_est - child_subtree.len() as f64).abs() < 1.5, + "Upward filter (n{}→n{}): expected ~{} entries, got {:.1}", + child_idx, parent_idx, child_subtree.len(), up_est + ); + + // --- Downward filter: parent → child --- + // This is stored as child's inbound filter from parent + let filter_down = nodes[child_idx] + .node + .get_peer(&addrs[parent_idx]) + .unwrap() + .inbound_filter() + .unwrap(); + + // Should contain all nodes in the complement + for &idx in &complement { + assert!( + filter_down.contains(&addrs[idx]), + "Downward filter (n{}→n{}): should contain complement member n{} but doesn't", + parent_idx, child_idx, idx + ); + } + + // Should NOT contain nodes in child's subtree (except: split-horizon + // excludes the child's direction, but child itself is NOT in parent's + // outgoing filter to child — parent merges child's filter into filters + // for OTHER peers, not back to child) + for &idx in &child_subtree { + assert!( + !filter_down.contains(&addrs[idx]), + "Downward filter (n{}→n{}): should NOT contain subtree member n{} but does", + parent_idx, child_idx, idx + ); + } + + // Cardinality should match complement size + let down_est = filter_down.estimated_count(); + assert!( + (down_est - complement.len() as f64).abs() < 1.5, + "Downward filter (n{}→n{}): expected ~{} entries, got {:.1}", + parent_idx, child_idx, complement.len(), down_est + ); + + // Together, subtree + complement = all nodes + assert_eq!( + child_subtree.len() + complement.len(), + n, + "Subtree + complement should cover all {} nodes", + n + ); + } + + cleanup_nodes(&mut nodes).await; +} + /// 100-node random graph: bloom filter exchange at scale. #[tokio::test] async fn test_bloom_filter_convergence_100_nodes() { @@ -235,6 +445,9 @@ async fn test_bloom_filter_convergence_100_nodes() { let edges = generate_random_edges(NUM_NODES, TARGET_EDGES, SEED); let mut nodes = run_tree_test(NUM_NODES, &edges, false).await; verify_tree_convergence(&nodes); - verify_bloom_filter_exchange(&nodes, &edges); + verify_filter_exchange(&nodes, &edges); + let tree_edges = get_tree_edges(&nodes); + verify_tree_propagation(&nodes, &tree_edges); + print_filter_cardinality(&nodes); cleanup_nodes(&mut nodes).await; } diff --git a/src/node/tree.rs b/src/node/tree.rs index 5a2b630..148b4ab 100644 --- a/src/node/tree.rs +++ b/src/node/tree.rs @@ -189,6 +189,11 @@ impl Node { "Processed TreeAnnounce" ); + // If this peer is (now) a tree peer, ensure bloom filter exchange + if self.is_tree_peer(from) { + self.bloom_state.mark_update_needed(*from); + } + // Re-evaluate parent selection if let Some(new_parent) = self.tree_state.evaluate_parent() { let new_seq = self.tree_state.my_declaration().sequence() + 1; @@ -214,6 +219,10 @@ impl Node { ); self.send_tree_announce_to_all().await; + + // Tree structure changed — trigger bloom filter exchange with all peers + let all_peers: Vec = self.peers.keys().copied().collect(); + self.bloom_state.mark_all_updates_needed(all_peers); } else if !self.tree_state.is_root() && *self.tree_state.my_declaration().parent_id() == *from { @@ -249,6 +258,10 @@ impl Node { "Parent ancestry changed, re-announcing" ); self.send_tree_announce_to_all().await; + + // Coords changed — trigger bloom filter exchange with all peers + let all_peers: Vec = self.peers.keys().copied().collect(); + self.bloom_state.mark_all_updates_needed(all_peers); } } }