Files
fips/src/node/tests/routing.rs
T
Johnathan Corgan 2f8e97c0ab Implement greedy routing with bloom filter priority
Add the full next-hop routing algorithm to Node::find_next_hop():
- Local delivery, direct peer, bloom filter candidates, greedy tree
  routing fallback, with (link_cost, tree_distance, node_addr) ordering
- select_best_candidate() scores by peer→dest distance (not us→peer)
  with self-distance check to prevent routing loops
- TreeState::find_next_hop() for greedy tree routing with progress
  guarantee
- ActivePeer::link_cost() placeholder (constant 1.0) for future link
  quality metrics

Add routing tests including 100-node all-pairs reachability simulation
(9900/9900 delivered, 0 loops, avg 4.0 hops, max 8).

Update fips-routing.md to reflect bloom filter routing as the primary
forwarding mechanism, with greedy tree routing as fallback during
convergence windows.
2026-02-11 16:55:14 +00:00

519 lines
16 KiB
Rust

//! Routing integration tests.
//!
//! Tests the full Node::find_next_hop() routing logic including bloom
//! filter priority, greedy tree routing, and tie-breaking.
use super::*;
use crate::bloom::BloomFilter;
use crate::tree::{ParentDeclaration, TreeCoordinate};
use spanning_tree::{
cleanup_nodes, drain_all_packets, generate_random_edges, initiate_handshake, make_test_node,
run_tree_test, verify_tree_convergence, TestNode,
};
use std::collections::HashSet;
// === Local delivery ===
#[test]
fn test_routing_local_delivery() {
let node = make_node();
let my_addr = *node.node_addr();
assert!(node.find_next_hop(&my_addr).is_none());
}
// === Direct peer ===
#[test]
fn test_routing_direct_peer() {
let mut node = make_node();
let transport_id = TransportId::new(1);
let link_id = LinkId::new(1);
let (conn, identity) = make_completed_connection(&mut node, link_id, transport_id, 1000);
let peer_addr = *identity.node_addr();
node.add_connection(conn).unwrap();
node.promote_connection(link_id, identity, 2000).unwrap();
let result = node.find_next_hop(&peer_addr);
assert!(result.is_some());
assert_eq!(result.unwrap().node_addr(), &peer_addr);
}
// === No route ===
#[test]
fn test_routing_unknown_destination() {
let node = make_node();
let unknown = make_node_addr(99);
assert!(node.find_next_hop(&unknown).is_none());
}
// === Bloom filter priority ===
#[test]
fn test_routing_bloom_filter_hit() {
let mut node = make_node();
let transport_id = TransportId::new(1);
// Create two peers
let link_id1 = LinkId::new(1);
let (conn1, id1) = make_completed_connection(&mut node, link_id1, transport_id, 1000);
let peer1_addr = *id1.node_addr();
node.add_connection(conn1).unwrap();
node.promote_connection(link_id1, id1, 2000).unwrap();
let link_id2 = LinkId::new(2);
let (conn2, id2) = make_completed_connection(&mut node, link_id2, transport_id, 1000);
let peer2_addr = *id2.node_addr();
node.add_connection(conn2).unwrap();
node.promote_connection(link_id2, id2, 2000).unwrap();
// Destination not directly connected
let dest = make_node_addr(99);
// Add dest to peer1's bloom filter only
let peer1 = node.get_peer_mut(&peer1_addr).unwrap();
let mut filter = BloomFilter::new();
filter.insert(&dest);
peer1.update_filter(filter, 1, 3000);
// Should route through peer1 (bloom filter hit)
let result = node.find_next_hop(&dest);
assert!(result.is_some());
assert_eq!(result.unwrap().node_addr(), &peer1_addr);
// Peer2 should NOT be selected (no filter hit)
assert_ne!(result.unwrap().node_addr(), &peer2_addr);
}
#[test]
fn test_routing_bloom_filter_multiple_hits_tiebreak() {
let mut node = make_node();
let transport_id = TransportId::new(1);
// Create three peers
let mut peer_addrs = Vec::new();
for i in 1..=3 {
let link_id = LinkId::new(i);
let (conn, id) = make_completed_connection(&mut node, link_id, transport_id, 1000);
let addr = *id.node_addr();
peer_addrs.push(addr);
node.add_connection(conn).unwrap();
node.promote_connection(link_id, id, 2000).unwrap();
}
let dest = make_node_addr(99);
// Add dest to ALL peers' bloom filters
for &addr in &peer_addrs {
let peer = node.get_peer_mut(&addr).unwrap();
let mut filter = BloomFilter::new();
filter.insert(&dest);
peer.update_filter(filter, 1, 3000);
}
// All peers have equal link_cost (1.0) and no tree coords set,
// so tree distance is usize::MAX for all. Tie-break by smallest node_addr.
let result = node.find_next_hop(&dest);
assert!(result.is_some());
let smallest_addr = peer_addrs.iter().min().unwrap();
assert_eq!(result.unwrap().node_addr(), smallest_addr);
}
// === Greedy tree routing ===
#[test]
fn test_routing_tree_fallback() {
let mut node = make_node();
let transport_id = TransportId::new(1);
let my_addr = *node.node_addr();
// Create a peer
let link_id = LinkId::new(1);
let (conn, id) = make_completed_connection(&mut node, link_id, transport_id, 1000);
let peer_addr = *id.node_addr();
node.add_connection(conn).unwrap();
node.promote_connection(link_id, id, 2000).unwrap();
// Set up tree state through the public API.
// We're root, peer is our child. The peer has a subtree below it.
// TreeState::new() already makes us the root with coords [my_addr].
// Add peer as child of us.
let peer_coords = TreeCoordinate::from_addrs(vec![peer_addr, my_addr]).unwrap();
node.tree_state_mut().update_peer(
ParentDeclaration::new(peer_addr, my_addr, 1, 1000),
peer_coords,
);
// Destination: a node under our peer in the tree
let dest = make_node_addr(99);
let dest_coords =
TreeCoordinate::from_addrs(vec![dest, peer_addr, my_addr]).unwrap();
// Put dest coords in the cache
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
node.coord_cache_mut().insert(dest, dest_coords, now_ms);
// No bloom filter hit — should fall back to tree routing.
// Our distance to dest: 2 (root → peer → dest)
// Peer's distance to dest: 1 (peer → dest)
// Peer is closer, so it's the next hop.
let result = node.find_next_hop(&dest);
assert!(result.is_some());
assert_eq!(result.unwrap().node_addr(), &peer_addr);
}
#[test]
fn test_routing_tree_no_coords_in_cache() {
let mut node = make_node();
let transport_id = TransportId::new(1);
// Create a peer
let link_id = LinkId::new(1);
let (conn, id) = make_completed_connection(&mut node, link_id, transport_id, 1000);
node.add_connection(conn).unwrap();
node.promote_connection(link_id, id, 2000).unwrap();
// Destination not in bloom filters and not in coord cache
let dest = make_node_addr(99);
assert!(node.find_next_hop(&dest).is_none());
}
// === Integration: converged network ===
#[tokio::test]
async fn test_routing_chain_topology() {
// Build a 4-node chain: 0 -- 1 -- 2 -- 3
let mut nodes = vec![
make_test_node().await,
make_test_node().await,
make_test_node().await,
make_test_node().await,
];
// Connect the chain
initiate_handshake(&mut nodes, 0, 1).await;
initiate_handshake(&mut nodes, 1, 2).await;
initiate_handshake(&mut nodes, 2, 3).await;
// Converge tree and bloom filters
drain_all_packets(&mut nodes, false).await;
// Verify tree convergence
let root = nodes.iter().map(|n| *n.node.node_addr()).min().unwrap();
for tn in &nodes {
assert_eq!(
*tn.node.tree_state().root(),
root,
"Tree not converged"
);
}
// Populate coord caches: each node caches the far-end node's coords
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
let node3_addr = *nodes[3].node.node_addr();
let node3_coords = nodes[3].node.tree_state().my_coords().clone();
nodes[0]
.node
.coord_cache_mut()
.insert(node3_addr, node3_coords, now_ms);
let node0_addr = *nodes[0].node.node_addr();
let node0_coords = nodes[0].node.tree_state().my_coords().clone();
nodes[3]
.node
.coord_cache_mut()
.insert(node0_addr, node0_coords, now_ms);
// Node 0 should be able to route toward node 3.
// The next hop should be node 1 (only peer of node 0).
let hop = nodes[0].node.find_next_hop(&node3_addr);
assert!(hop.is_some(), "Node 0 should find route to node 3");
let node1_addr = *nodes[1].node.node_addr();
assert_eq!(
hop.unwrap().node_addr(),
&node1_addr,
"Node 0's next hop to node 3 should be node 1"
);
// Node 3 should route toward node 0 via node 2.
let hop = nodes[3].node.find_next_hop(&node0_addr);
assert!(hop.is_some(), "Node 3 should find route to node 0");
let node2_addr = *nodes[2].node.node_addr();
assert_eq!(
hop.unwrap().node_addr(),
&node2_addr,
"Node 3's next hop to node 0 should be node 2"
);
}
#[tokio::test]
async fn test_routing_bloom_preferred_over_tree() {
// Build a 3-node triangle: 0 -- 1, 0 -- 2, 1 -- 2
let mut nodes = vec![
make_test_node().await,
make_test_node().await,
make_test_node().await,
];
initiate_handshake(&mut nodes, 0, 1).await;
initiate_handshake(&mut nodes, 0, 2).await;
initiate_handshake(&mut nodes, 1, 2).await;
drain_all_packets(&mut nodes, false).await;
// Create a destination beyond the network
let dest = make_node_addr(99);
// Add dest to peer 2's bloom filter (from node 0's perspective)
let peer2_addr = *nodes[2].node.node_addr();
let peer2 = nodes[0].node.get_peer_mut(&peer2_addr).unwrap();
let mut filter = BloomFilter::new();
filter.insert(&dest);
peer2.update_filter(filter, 100, 50000);
// Even though we could use tree routing (if coords were cached),
// the bloom filter hit should be preferred.
let hop = nodes[0].node.find_next_hop(&dest);
assert!(hop.is_some(), "Should route via bloom filter");
assert_eq!(
hop.unwrap().node_addr(),
&peer2_addr,
"Should pick peer with bloom filter hit"
);
}
// === Multi-hop forwarding simulation ===
/// Result of simulating multi-hop packet forwarding.
#[derive(Debug)]
enum ForwardResult {
/// Packet reached the destination in the given number of hops.
Delivered(usize),
/// Routing returned None at the given node index (no route).
NoRoute { at_node: usize, hops: usize },
/// Routing loop detected (visited the same node twice).
Loop { at_node: usize, hops: usize },
}
/// Build a NodeAddr → node index lookup table.
fn build_addr_index(nodes: &[TestNode]) -> std::collections::HashMap<NodeAddr, usize> {
nodes
.iter()
.enumerate()
.map(|(i, tn)| (*tn.node.node_addr(), i))
.collect()
}
/// Simulate multi-hop forwarding from source to destination.
///
/// At each hop, calls `find_next_hop` on the current node and follows
/// the result to the next node. Terminates on delivery, routing failure,
/// or loop detection.
fn simulate_forwarding(
nodes: &[TestNode],
addr_index: &std::collections::HashMap<NodeAddr, usize>,
src: usize,
dst: usize,
) -> ForwardResult {
let dest_addr = *nodes[dst].node.node_addr();
let max_hops = nodes.len(); // can't take more hops than nodes
let mut current = src;
let mut visited = HashSet::new();
visited.insert(current);
for hop in 0..max_hops {
let next = nodes[current].node.find_next_hop(&dest_addr);
match next {
None => {
// find_next_hop returns None for local delivery (dest == self)
if *nodes[current].node.node_addr() == dest_addr {
return ForwardResult::Delivered(hop);
}
return ForwardResult::NoRoute {
at_node: current,
hops: hop,
};
}
Some(peer) => {
let next_addr = *peer.node_addr();
// Is next hop the destination?
if next_addr == dest_addr {
return ForwardResult::Delivered(hop + 1);
}
// Find the node index for the next hop
let next_idx = match addr_index.get(&next_addr) {
Some(&idx) => idx,
None => {
return ForwardResult::NoRoute {
at_node: current,
hops: hop,
};
}
};
// Loop detection
if visited.contains(&next_idx) {
return ForwardResult::Loop {
at_node: next_idx,
hops: hop + 1,
};
}
visited.insert(next_idx);
current = next_idx;
}
}
}
ForwardResult::NoRoute {
at_node: current,
hops: max_hops,
}
}
/// 100-node random graph: verify all-pairs routing reachability.
///
/// After tree and bloom filter convergence, simulates multi-hop packet
/// forwarding between every pair of nodes. Every packet must be delivered
/// without loops.
#[tokio::test]
async fn test_routing_reachability_100_nodes() {
const NUM_NODES: usize = 100;
const TARGET_EDGES: usize = 250;
const SEED: u64 = 42;
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);
// Populate coord caches: every node learns every other node's coordinates.
// In production this happens via SessionSetup/LookupResponse; here we
// inject them directly so routing can make progress-based decisions.
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
// Collect all (addr, coords) pairs first to avoid borrow issues
let all_coords: Vec<(NodeAddr, TreeCoordinate)> = nodes
.iter()
.map(|tn| (*tn.node.node_addr(), tn.node.tree_state().my_coords().clone()))
.collect();
for node in &mut nodes {
for &(ref addr, ref coords) in &all_coords {
if addr != node.node.node_addr() {
node.node.coord_cache_mut().insert(*addr, coords.clone(), now_ms);
}
}
}
let addr_index = build_addr_index(&nodes);
let mut total_pairs = 0;
let mut total_hops = 0usize;
let mut max_hops = 0usize;
let mut failures = Vec::new();
let mut loops = Vec::new();
// Test all pairs
for src in 0..NUM_NODES {
for dst in 0..NUM_NODES {
if src == dst {
continue;
}
total_pairs += 1;
match simulate_forwarding(&nodes, &addr_index, src, dst) {
ForwardResult::Delivered(hops) => {
total_hops += hops;
if hops > max_hops {
max_hops = hops;
}
}
ForwardResult::NoRoute { at_node, hops } => {
failures.push((src, dst, at_node, hops));
}
ForwardResult::Loop { at_node, hops } => {
loops.push((src, dst, at_node, hops));
}
}
}
}
let delivered = total_pairs - failures.len() - loops.len();
let avg_hops = if delivered > 0 {
total_hops as f64 / delivered as f64
} else {
0.0
};
eprintln!(
"\n === Routing Reachability ({} nodes) ===",
NUM_NODES
);
eprintln!(
" Pairs tested: {} | Delivered: {} | Failed: {} | Loops: {}",
total_pairs,
delivered,
failures.len(),
loops.len()
);
eprintln!(
" Hops: avg={:.1} max={}",
avg_hops, max_hops
);
if !failures.is_empty() {
let show = failures.len().min(10);
eprintln!(" First {} failures:", show);
for &(src, dst, at_node, hops) in &failures[..show] {
eprintln!(
" {} -> {}: stuck at node {} after {} hops",
src, dst, at_node, hops
);
}
}
if !loops.is_empty() {
let show = loops.len().min(10);
eprintln!(" First {} loops:", show);
for &(src, dst, at_node, hops) in &loops[..show] {
eprintln!(
" {} -> {}: loop at node {} after {} hops",
src, dst, at_node, hops
);
}
}
assert!(
loops.is_empty(),
"Detected {} routing loops out of {} pairs",
loops.len(),
total_pairs
);
assert!(
failures.is_empty(),
"Detected {} routing failures out of {} pairs",
failures.len(),
total_pairs
);
cleanup_nodes(&mut nodes).await;
}