Restrict bloom filter propagation to tree edges, update design docs

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
This commit is contained in:
Johnathan Corgan
2026-02-23 14:00:00 +00:00
parent dc89edf60b
commit 717be3d960
8 changed files with 396 additions and 74 deletions
+59 -12
View File
@@ -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,0004,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 |
+14 -9
View File
@@ -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
+19 -11
View File
@@ -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:
+26 -10
View File
@@ -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<NodeAddr, BloomFilter> {
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<NodeAddr> = self.peers.keys().copied().collect();
let peer_filters = self.peer_inbound_filters();
self.bloom_state
+1 -1
View File
@@ -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<NodeAddr> = self.peers.keys().copied().collect();
self.bloom_state.mark_all_updates_needed(remaining_peers);
+20
View File
@@ -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:
+244 -31
View File
@@ -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<NodeAddr> = 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<usize>,
tree_adj: &[Vec<usize>],
) -> Vec<usize> {
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<NodeAddr> = 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<usize> = (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;
}
+13
View File
@@ -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<NodeAddr> = 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<NodeAddr> = self.peers.keys().copied().collect();
self.bloom_state.mark_all_updates_needed(all_peers);
}
}
}