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