Implement cost-based parent selection with periodic re-evaluation

Cost-based parent selection:
- Replace depth-only parent selection with effective_depth = depth + link_cost
- link_cost computed from locally measured MMP metrics: etx * (1.0 + srtt_ms / 100.0)
- Prevents bottleneck subtrees in heterogeneous networks where a LoRa link
  at depth 1 would otherwise always beat fiber at depth 2
- Configurable hysteresis (default 0.2) prevents marginal parent switches
- Configurable hold-down timer (default 30s) suppresses re-evaluation
  after parent switch
- Mandatory switches (parent lost, root change) bypass both safeguards
- Link costs passed as HashMap parameter to keep TreeState pure

Periodic re-evaluation:
- evaluate_parent() was only called on TreeAnnounce receipt or parent loss;
  after tree stabilization, link degradation went undetected
- Added timer-based re-evaluation (reeval_interval_secs, default 60s) that
  calls evaluate_parent() from the tick handler with current MMP link costs
- Respects existing hold-down and hysteresis safeguards
- Short-circuits when disabled or <2 peers

Design documentation:
- Update 7 design docs to reflect cost-based parent selection
- Replace depth-only algorithm descriptions with effective_depth model
- Replace rejected cumulative path cost spec with local-only design rationale
- Rewrite Example 2 (heterogeneous links) for local-only cost model
- Update config docs: parent_switch_threshold replaced by parent_hysteresis,
  hold_down_secs, reeval_interval_secs

Chaos simulation enhancements:
- fips_overrides with deep merge for per-scenario FIPS config customization
- Explicit topology algorithm for deterministic test graphs
- Control socket querying via fipsctl for tree/MMP snapshot collection
- Edge existence validation in netem manager
- Per-link netem policy overrides
- 9 new chaos scenarios covering cost avoidance, depth-vs-cost tradeoffs,
  stability, mixed topologies, periodic re-evaluation, and bottleneck parent

12 new unit tests, 667 total passing, clippy clean.
This commit is contained in:
Johnathan Corgan
2026-02-23 17:15:20 +00:00
parent 717be3d960
commit 0d93a19e07
26 changed files with 1675 additions and 200 deletions
+10 -2
View File
@@ -337,6 +337,10 @@ pub struct Node {
/// are exhausted.
retry_pending: HashMap<NodeAddr, retry::RetryState>,
// === Periodic Parent Re-evaluation ===
/// Timestamp of last periodic parent re-evaluation (for pacing).
last_parent_reeval: Option<std::time::Instant>,
// === Display Names ===
/// Human-readable names for configured peers (alias or short npub).
/// Populated at startup from peer config.
@@ -368,7 +372,8 @@ impl Node {
// Initialize tree state with signed self-declaration
let mut tree_state = TreeState::new(node_addr);
tree_state.set_parent_switch_threshold(config.node.tree.parent_switch_threshold);
tree_state.set_parent_hysteresis(config.node.tree.parent_hysteresis);
tree_state.set_hold_down(config.node.tree.hold_down_secs);
tree_state
.sign_declaration(&identity)
.expect("signing own declaration should never fail");
@@ -432,6 +437,7 @@ impl Node {
std::time::Duration::from_millis(coords_response_interval_ms),
),
retry_pending: HashMap::new(),
last_parent_reeval: None,
peer_aliases: HashMap::new(),
})
}
@@ -451,7 +457,8 @@ impl Node {
// Initialize tree state with signed self-declaration
let mut tree_state = TreeState::new(node_addr);
tree_state.set_parent_switch_threshold(config.node.tree.parent_switch_threshold);
tree_state.set_parent_hysteresis(config.node.tree.parent_hysteresis);
tree_state.set_hold_down(config.node.tree.hold_down_secs);
tree_state
.sign_declaration(&identity)
.expect("signing own declaration should never fail");
@@ -518,6 +525,7 @@ impl Node {
std::time::Duration::from_millis(coords_response_interval_ms),
),
retry_pending: HashMap::new(),
last_parent_reeval: None,
peer_aliases: HashMap::new(),
}
}
+76 -4
View File
@@ -3,6 +3,8 @@
//! Handles building, sending, and receiving TreeAnnounce messages,
//! including periodic root refresh and rate-limited propagation.
use std::collections::HashMap;
use crate::protocol::TreeAnnounce;
use crate::NodeAddr;
@@ -194,8 +196,11 @@ impl Node {
self.bloom_state.mark_update_needed(*from);
}
// Re-evaluate parent selection
if let Some(new_parent) = self.tree_state.evaluate_parent() {
// Re-evaluate parent selection with current link costs
let peer_costs: HashMap<NodeAddr, f64> = self.peers.iter()
.map(|(addr, peer)| (*addr, peer.link_cost()))
.collect();
if let Some(new_parent) = self.tree_state.evaluate_parent(&peer_costs) {
let new_seq = self.tree_state.my_declaration().sequence() + 1;
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
@@ -268,9 +273,73 @@ impl Node {
/// Periodic tree maintenance, called from the tick handler.
///
/// Sends pending rate-limited announces.
/// Sends pending rate-limited announces and checks for periodic
/// parent re-evaluation based on current MMP link costs.
pub(super) async fn check_tree_state(&mut self) {
self.send_pending_tree_announces().await;
self.check_periodic_parent_reeval().await;
}
/// Periodic parent re-evaluation based on current MMP link costs.
///
/// Self-paces using `last_parent_reeval` and the configured
/// `reeval_interval_secs`. When a better parent is found, follows
/// the same switch flow as TreeAnnounce-triggered switches.
async fn check_periodic_parent_reeval(&mut self) {
let interval_secs = self.config.node.tree.reeval_interval_secs;
if interval_secs == 0 {
return;
}
// Need at least 2 peers for a meaningful comparison
if self.peers.len() < 2 {
return;
}
let now = std::time::Instant::now();
let interval = std::time::Duration::from_secs(interval_secs);
if let Some(last) = self.last_parent_reeval {
if now.duration_since(last) < interval {
return;
}
}
self.last_parent_reeval = Some(now);
let peer_costs: HashMap<NodeAddr, f64> = self.peers.iter()
.map(|(addr, peer)| (*addr, peer.link_cost()))
.collect();
if let Some(new_parent) = self.tree_state.evaluate_parent(&peer_costs) {
let new_seq = self.tree_state.my_declaration().sequence() + 1;
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
self.tree_state.set_parent(new_parent, new_seq, timestamp);
if let Err(e) = self.tree_state.sign_declaration(&self.identity) {
warn!(error = %e, "Failed to sign declaration after periodic parent re-eval");
return;
}
self.tree_state.recompute_coords();
self.coord_cache.clear();
info!(
new_parent = %self.peer_display_name(&new_parent),
new_seq = new_seq,
new_root = %self.tree_state.root(),
depth = self.tree_state.my_coords().depth(),
trigger = "periodic",
"Parent switched via periodic cost re-evaluation"
);
self.send_tree_announce_to_all().await;
let all_peers: Vec<NodeAddr> = self.peers.keys().copied().collect();
self.bloom_state.mark_all_updates_needed(all_peers);
}
}
/// Handle tree state cleanup when a peer is removed.
@@ -286,7 +355,10 @@ impl Node {
self.tree_state.remove_peer(node_addr);
if was_parent {
let changed = self.tree_state.handle_parent_lost();
let peer_costs: HashMap<NodeAddr, f64> = self.peers.iter()
.map(|(addr, peer)| (*addr, peer.link_cost()))
.collect();
let changed = self.tree_state.handle_parent_lost(&peer_costs);
if changed {
// Re-sign the new declaration
if let Err(e) = self.tree_state.sign_declaration(&self.identity) {