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); } } }