proto/stp: hoist the parent flap/hold-down veto to the shell for a clock-free classify core

evaluate_parent no longer reads the injected clock: it returns a ParentEval of
Mandatory, Discretionary, or None, and the flap/hold-down veto is applied at the
edge via a new TreeState::is_switch_suppressed(now_ms), gating only the
discretionary arm. classify_announce/classify_periodic take the pre-computed
switch_suppressed bool instead of now_ms, so the whole classify ladder is
clock-free; the shell callers (tree announce/periodic re-eval, MMP first-RTT
re-eval, handle_parent_lost) compute the veto verdict. The no-coords parent
case stays discretionary (veto-gated) exactly as before. Behavior unchanged;
the veto tests now assert the moved responsibility.
This commit is contained in:
Johnathan Corgan
2026-07-08 19:00:24 +00:00
parent 309a91d293
commit dc9334e725
7 changed files with 260 additions and 102 deletions
+13 -5
View File
@@ -13,6 +13,7 @@ use crate::proto::mmp::{
LinkReportKind, LinkReportSnapshot, MmpAction, PeerLivenessSnapshot, ReceiverReport, RrLog,
SenderReport,
};
use crate::proto::stp::ParentEval;
use std::time::{Duration, Instant};
use tracing::{debug, info, trace, warn};
@@ -198,11 +199,18 @@ impl Node {
.map(|d| d.as_secs())
.unwrap_or(0);
let mono_now_ms = crate::time::mono_ms();
if let Some(new_parent) = self.tree_state.evaluate_parent(
&peer_costs,
&std::collections::BTreeSet::new(),
mono_now_ms,
) {
// Compute the flap-dampening / hold-down veto at the edge; a mandatory
// switch bypasses it, a discretionary one is taken only if not suppressed.
let switch_suppressed = self.tree_state.is_switch_suppressed(mono_now_ms);
let new_parent = match self
.tree_state
.evaluate_parent(&peer_costs, &std::collections::BTreeSet::new())
{
ParentEval::Mandatory(p) => Some(p),
ParentEval::Discretionary(p) if !switch_suppressed => Some(p),
ParentEval::Discretionary(_) | ParentEval::None => None,
};
if let Some(new_parent) = new_parent {
let new_seq = self.tree_state.my_declaration().sequence() + 1;
let flap_dampened =
self.tree_state
+14 -2
View File
@@ -329,8 +329,17 @@ impl Node {
// the wall-clock `now_ms` above used for the peer's tree position). Read
// once and threaded into classify + the state mutators.
let mono_now_ms = crate::time::mono_ms();
// Compute the flap-dampening / hold-down veto at the edge; the classify core
// is clock-free and consumes only this pre-computed verdict.
let switch_suppressed = self.tree_state.is_switch_suppressed(mono_now_ms);
match Stp::classify_announce(&self.tree_state, *from, &peer_costs, &skip, mono_now_ms) {
match Stp::classify_announce(
&self.tree_state,
*from,
&peer_costs,
&skip,
switch_suppressed,
) {
TreeDecision::Switch {
new_parent,
new_seq,
@@ -583,8 +592,11 @@ impl Node {
// Monotonic ms for the flap-dampening / hold-down timers, read once and
// threaded into classify + the state mutators.
let mono_now_ms = crate::time::mono_ms();
// Compute the flap-dampening / hold-down veto at the edge; the classify core
// is clock-free and consumes only this pre-computed verdict.
let switch_suppressed = self.tree_state.is_switch_suppressed(mono_now_ms);
match Stp::classify_periodic(&self.tree_state, &peer_costs, &skip, mono_now_ms) {
match Stp::classify_periodic(&self.tree_state, &peer_costs, &skip, switch_suppressed) {
TreeDecision::Switch {
new_parent,
new_seq,
+62 -19
View File
@@ -3,10 +3,13 @@
//! The post-`update_peer` parent-switch / self-root / loop-drop / ancestry-update
//! ladder that the async `node::tree` handler inlines is extracted here as a pure
//! decision: [`Stp::classify_announce`] reads a consistent `TreeState` plus the
//! shell-passed facts (`peer_costs`, `skip`, and the monotonic `now_ms` the
//! ranking's hold-down / flap-dampening suppression checks read) and returns a
//! [`TreeDecision`] the shell drives. No I/O, no tracing, no keys, no mutation;
//! the injected `now_ms` is the only time input, threaded to `evaluate_parent`.
//! shell-passed facts (`peer_costs`, `skip`, and the pre-computed `switch_suppressed`
//! verdict of the flap-dampening / hold-down veto) and returns a [`TreeDecision`]
//! the shell drives. No I/O, no tracing, no keys, no mutation — and no clock: the
//! classify core is clock-free. The shell reads the monotonic clock at the edge,
//! computes the veto verdict via `TreeState::is_switch_suppressed`, and passes the
//! resulting bool in; `evaluate_parent` distinguishes mandatory from discretionary
//! switches and the ladder applies the veto only to the discretionary arm.
//!
//! [`TreeState`] lives beside this module in `proto/stp/state.rs`.
@@ -76,22 +79,55 @@ pub(crate) enum TreeDecision {
NoChange,
}
/// Outcome of `TreeState::evaluate_parent`: whether a parent switch is warranted
/// and, if so, whether it is mandatory (bypasses the flap/hold-down veto) or
/// discretionary (the shell applies the veto before switching).
pub(crate) enum ParentEval {
/// Path-breaking / root-correcting switch — always taken, veto bypassed.
Mandatory(NodeAddr),
/// Improvement switch — taken only if the shell's veto is not active.
Discretionary(NodeAddr),
/// No switch warranted.
None,
}
impl ParentEval {
/// The switch target if one is warranted IGNORING the veto (i.e. treating a
/// discretionary candidate as taken). Convenience for tests/paths that do not
/// engage the veto. Returns `None` only for `ParentEval::None`.
#[cfg(test)]
pub(crate) fn switch_target(&self) -> Option<NodeAddr> {
match self {
ParentEval::Mandatory(a) | ParentEval::Discretionary(a) => Some(*a),
ParentEval::None => Option::None,
}
}
}
impl Stp {
/// Classify an inbound, already-validated TreeAnnounce into a [`TreeDecision`].
///
/// Pure: reads the post-`update_peer` `tree` plus the shell-passed `peer_costs`
/// (per-peer link cost) and `skip` (non-full/leaf peers excluded from parent
/// candidacy — empty on master, `non_full_peers()` on next). Mirrors the inline
/// ladder in `node::tree::handle_tree_announce`: parent-switch, else self-root,
/// else (same-parent) loop-drop or ancestry-update, else no change.
/// (per-peer link cost), `skip` (non-full/leaf peers excluded from parent
/// candidacy — empty on master, `non_full_peers()` on next), and the
/// pre-computed `switch_suppressed` veto verdict (the shell reads the clock and
/// calls `TreeState::is_switch_suppressed`; a discretionary switch is skipped
/// while it is true, a mandatory switch ignores it). Mirrors the inline ladder
/// in `node::tree::handle_tree_announce`: parent-switch, else self-root, else
/// (same-parent) loop-drop or ancestry-update, else no change.
pub(crate) fn classify_announce(
tree: &TreeState,
from: NodeAddr,
peer_costs: &BTreeMap<NodeAddr, f64>,
skip: &BTreeSet<NodeAddr>,
now_ms: u64,
switch_suppressed: bool,
) -> TreeDecision {
if let Some(new_parent) = tree.evaluate_parent(peer_costs, skip, now_ms) {
let switch = match tree.evaluate_parent(peer_costs, skip) {
ParentEval::Mandatory(p) => Some(p),
ParentEval::Discretionary(p) if !switch_suppressed => Some(p),
ParentEval::Discretionary(_) | ParentEval::None => None,
};
if let Some(new_parent) = switch {
let new_seq = tree.my_declaration().sequence() + 1;
TreeDecision::Switch {
new_parent,
@@ -120,20 +156,27 @@ impl Stp {
/// Classify a periodic parent re-evaluation into a [`TreeDecision`].
///
/// Pure: reads the current `tree` plus the shell-passed `peer_costs` and `skip`
/// (empty on master, `non_full_peers()` on next). Mirrors the periodic ladder in
/// `node::tree::check_periodic_parent_reeval`: parent-switch, else self-root,
/// else re-broadcast for eventual consistency. Unlike `classify_announce`, the
/// periodic path has no same-parent loop-drop / ancestry-update arms — a periodic
/// tick has no announcing peer, so those cases never arise; the no-change tail is
/// a re-broadcast rather than a true no-op.
/// Pure: reads the current `tree` plus the shell-passed `peer_costs`, `skip`
/// (empty on master, `non_full_peers()` on next), and the pre-computed
/// `switch_suppressed` veto verdict (see `classify_announce`; a discretionary
/// switch is skipped while true, a mandatory switch ignores it). Mirrors the
/// periodic ladder in `node::tree::check_periodic_parent_reeval`: parent-switch,
/// else self-root, else re-broadcast for eventual consistency. Unlike
/// `classify_announce`, the periodic path has no same-parent loop-drop /
/// ancestry-update arms — a periodic tick has no announcing peer, so those cases
/// never arise; the no-change tail is a re-broadcast rather than a true no-op.
pub(crate) fn classify_periodic(
tree: &TreeState,
peer_costs: &BTreeMap<NodeAddr, f64>,
skip: &BTreeSet<NodeAddr>,
now_ms: u64,
switch_suppressed: bool,
) -> TreeDecision {
if let Some(new_parent) = tree.evaluate_parent(peer_costs, skip, now_ms) {
let switch = match tree.evaluate_parent(peer_costs, skip) {
ParentEval::Mandatory(p) => Some(p),
ParentEval::Discretionary(p) if !switch_suppressed => Some(p),
ParentEval::Discretionary(_) | ParentEval::None => None,
};
if let Some(new_parent) = switch {
let new_seq = tree.my_declaration().sequence() + 1;
TreeDecision::Switch {
new_parent,
+1 -1
View File
@@ -32,7 +32,7 @@ use thiserror::Error;
use crate::{IdentityError, NodeAddr};
pub use coordinate::{CoordEntry, TreeCoordinate};
pub(crate) use core::{Stp, TreeDecision};
pub(crate) use core::{ParentEval, Stp, TreeDecision};
pub use state::{ParentDeclaration, TreeState};
pub use wire::TreeAnnounce;
pub(crate) use wire::{
+53 -35
View File
@@ -3,6 +3,7 @@
use alloc::collections::{BTreeMap, BTreeSet};
use core::fmt;
use super::core::ParentEval;
use super::limits::FlapDampener;
use super::{CoordEntry, TreeCoordinate};
use crate::NodeAddr;
@@ -316,28 +317,38 @@ impl TreeState {
self.flap.is_flap_dampened(now_ms)
}
/// Whether a *discretionary* parent switch should currently be suppressed by the
/// flap-dampening / hold-down veto. `now_ms` is the injected monotonic time.
/// Mandatory switches ignore this. Read at the shell edge; the classify core is
/// clock-free and takes the resulting bool.
pub fn is_switch_suppressed(&self, now_ms: u64) -> bool {
self.flap.is_hold_down_active(now_ms) || self.flap.is_flap_dampened(now_ms)
}
/// Evaluate whether to switch parents based on current peer tree state.
///
/// Uses effective_depth (depth + link_cost) for parent comparison.
/// `peer_costs` maps each peer's NodeAddr to its link cost (from local
/// MMP measurements). Missing entries default to 1.0 (optimistic).
///
/// Returns `Some(peer_node_addr)` if a parent switch is recommended,
/// or `None` if the current parent is adequate.
/// Returns a [`ParentEval`] describing whether a parent switch is warranted:
/// `Mandatory` (path-breaking / root-correcting — always taken), `Discretionary`
/// (an improvement the caller applies only if its veto is inactive), or `None`.
///
/// This core is clock-free: it no longer applies the flap-dampening / hold-down
/// veto. The caller reads the clock, computes the veto verdict via
/// [`is_switch_suppressed`](Self::is_switch_suppressed), and suppresses the
/// `Discretionary` arm at the edge; `Mandatory` switches bypass the veto.
///
/// `skip_peers` contains peers that should not be considered as parent
/// candidates (e.g., non-routing and leaf nodes that don't forward transit).
///
/// `now_ms` is the injected monotonic time in milliseconds, used only for
/// the hold-down / flap-dampening suppression checks below.
pub fn evaluate_parent(
pub(crate) fn evaluate_parent(
&self,
peer_costs: &BTreeMap<NodeAddr, f64>,
skip_peers: &BTreeSet<NodeAddr>,
now_ms: u64,
) -> Option<NodeAddr> {
) -> ParentEval {
if self.peer_ancestry.is_empty() {
return None;
return ParentEval::None;
}
// Find the smallest root visible across all peers
@@ -356,7 +367,10 @@ impl TreeState {
});
}
let smallest_root = smallest_root?;
let smallest_root = match smallest_root {
Some(r) => r,
None => return ParentEval::None,
};
// If our own NodeAddr is smaller than (or equal to) the smallest visible
// root, we are the network's smallest node and must be root. Returning
@@ -366,7 +380,7 @@ impl TreeState {
// peer's larger root at the tail — violating "advertised root = min path
// entry" and getting rejected by recipients' `validate_semantics`.
if self.my_node_addr <= smallest_root {
return None;
return ParentEval::None;
}
// Among peers that reach the smallest root, find the lowest effective_depth.
@@ -404,14 +418,17 @@ impl TreeState {
}
}
let (best_peer_id, best_eff_depth) = best_peer?;
let (best_peer_id, best_eff_depth) = match best_peer {
Some(b) => b,
None => return ParentEval::None,
};
// If already using this peer as parent, no switch needed
if *self.my_declaration.parent_id() == best_peer_id && !self.is_root() {
return None;
return ParentEval::None;
}
// --- Mandatory switches (bypass hold-down and hysteresis) ---
// --- Mandatory switches (bypass the shell's hold-down / flap veto) ---
// If our current parent is gone from peer_ancestry, our path is broken — always switch
if !self.is_root()
@@ -419,32 +436,24 @@ impl TreeState {
.peer_ancestry
.contains_key(self.my_declaration.parent_id())
{
return Some(best_peer_id);
return ParentEval::Mandatory(best_peer_id);
}
// Switching roots (smaller root found) → always switch
if smallest_root < self.root || (self.is_root() && smallest_root < self.my_node_addr) {
return Some(best_peer_id);
return ParentEval::Mandatory(best_peer_id);
}
// We're root but shouldn't be (peers have a smaller root) — always switch
if self.is_root() {
return Some(best_peer_id);
return ParentEval::Mandatory(best_peer_id);
}
// --- Hold-down: suppress non-mandatory re-evaluation after recent switch ---
if self.flap.is_hold_down_active(now_ms) {
return None;
}
// --- Flap dampening: suppress after excessive parent switches ---
if self.flap.is_flap_dampened(now_ms) {
return None;
}
// --- Same root, cost-aware comparison with hysteresis ---
// --- Discretionary switches (the caller applies the veto before taking) ---
//
// Same root, cost-aware comparison with hysteresis. Everything below is
// veto-gated at the edge: the shell suppresses these `Discretionary` results
// while hold-down / flap-dampening is active.
// Current parent's effective_depth.
// If peer_costs is non-empty but current parent has no entry,
@@ -461,15 +470,17 @@ impl TreeState {
let current_parent_coords = self.peer_ancestry.get(self.my_declaration.parent_id());
let current_parent_eff = match current_parent_coords {
Some(coords) => coords.depth() as f64 + current_parent_cost,
None => return Some(best_peer_id), // Parent has no coords — treat as lost
// Parent has no coords — treat as lost. This sat BELOW the veto, so it
// is veto-gated: Discretionary, not Mandatory.
None => return ParentEval::Discretionary(best_peer_id),
};
// Apply hysteresis: only switch if candidate is significantly better
if best_eff_depth < current_parent_eff * (1.0 - self.parent_hysteresis) {
return Some(best_peer_id);
return ParentEval::Discretionary(best_peer_id);
}
None
ParentEval::None
}
/// Handle loss of current parent.
@@ -488,8 +499,15 @@ impl TreeState {
now_secs: u64,
now_ms: u64,
) -> bool {
// Try to find an alternative parent
if let Some(new_parent) = self.evaluate_parent(peer_costs, &BTreeSet::new(), now_ms) {
// Try to find an alternative parent. The veto is computed at the edge and
// applied only to a discretionary result; a mandatory switch bypasses it.
let suppressed = self.is_switch_suppressed(now_ms);
let alt = match self.evaluate_parent(peer_costs, &BTreeSet::new()) {
ParentEval::Mandatory(p) => Some(p),
ParentEval::Discretionary(p) if !suppressed => Some(p),
ParentEval::Discretionary(_) | ParentEval::None => None,
};
if let Some(new_parent) = alt {
let new_seq = self.my_declaration.sequence() + 1;
self.set_parent(new_parent, new_seq, now_secs, now_ms);
self.recompute_coords();
+29 -11
View File
@@ -3,7 +3,7 @@
use std::collections::{BTreeMap, BTreeSet};
use super::util::{make_coords, make_costs, make_node_addr};
use crate::proto::stp::{ParentDeclaration, TreeState};
use crate::proto::stp::{ParentDeclaration, ParentEval, TreeState};
#[test]
fn test_flap_dampening_engages_after_threshold() {
@@ -43,11 +43,16 @@ fn test_flap_dampening_engages_after_threshold() {
assert!(dampened);
assert!(state.is_flap_dampened(3000));
// evaluate_parent should return None for non-mandatory switches
// Make peer_b much better than peer_a
// The flap-dampening veto now lives at the shell edge, not inside
// evaluate_parent. The clock-free core still finds peer_b as a discretionary
// candidate (peer_b much better than peer_a); the shell suppresses it because
// dampening is active — same net "no switch" as before.
let costs = make_costs(&[(1, 10.0), (2, 1.0)]);
let result = state.evaluate_parent(&costs, &BTreeSet::new(), 3000);
assert_eq!(result, None); // suppressed by flap dampening
assert!(state.is_switch_suppressed(3000)); // veto active at the edge
assert!(matches!(
state.evaluate_parent(&costs, &BTreeSet::new()),
ParentEval::Discretionary(p) if p == peer_b
));
}
#[test]
@@ -82,8 +87,13 @@ fn test_flap_dampening_allows_mandatory_switches() {
// Remove current parent (peer_a) — this is a mandatory switch
state.remove_peer(&peer_a);
let result = state.evaluate_parent(&BTreeMap::new(), &BTreeSet::new(), 3000);
assert_eq!(result, Some(peer_b)); // mandatory switch bypasses dampening
// Dampening is still active at the edge, but a parent-lost switch is mandatory
// and bypasses the veto: the core returns Mandatory and the switch is taken.
assert!(state.is_switch_suppressed(3000)); // veto still active
assert!(matches!(
state.evaluate_parent(&BTreeMap::new(), &BTreeSet::new()),
ParentEval::Mandatory(p) if p == peer_b
));
}
#[test]
@@ -119,9 +129,13 @@ fn test_flap_dampening_expires() {
// With 0-second duration, dampening should have already expired
assert!(!state.is_flap_dampened(3000));
// evaluate_parent should work normally now
// Dampening has expired: the veto is no longer active at the edge, so the
// discretionary switch to peer_b resumes and is taken.
let costs = make_costs(&[(1, 10.0), (2, 1.0)]);
let result = state.evaluate_parent(&costs, &BTreeSet::new(), 3000);
assert!(!state.is_switch_suppressed(3000)); // veto cleared
let result = state
.evaluate_parent(&costs, &BTreeSet::new())
.switch_target();
assert_eq!(result, Some(peer_b)); // not suppressed
}
@@ -156,9 +170,13 @@ fn test_flap_dampening_below_threshold() {
assert!(!state.is_flap_dampened(3000));
// evaluate_parent should still work normally
// Dampening never engaged, so the veto is inactive at the edge and the
// discretionary switch to peer_b is taken normally.
let costs = make_costs(&[(1, 10.0), (2, 1.0)]);
let result = state.evaluate_parent(&costs, &BTreeSet::new(), 3000);
assert!(!state.is_switch_suppressed(3000)); // veto never engaged
let result = state
.evaluate_parent(&costs, &BTreeSet::new())
.switch_target();
assert_eq!(result, Some(peer_b)); // not suppressed
}
+88 -29
View File
@@ -3,7 +3,7 @@
use std::collections::{BTreeMap, BTreeSet};
use super::util::{add_peer, make_coords, make_costs, make_node_addr, make_tree_state};
use crate::proto::stp::{ParentDeclaration, TreeCoordinate, TreeState};
use crate::proto::stp::{ParentDeclaration, ParentEval, TreeCoordinate, TreeState};
// ===== ParentDeclaration Tests =====
@@ -214,7 +214,9 @@ fn test_evaluate_parent_picks_smallest_root() {
make_coords(&[7, 2]),
);
let result = state.evaluate_parent(&BTreeMap::new(), &BTreeSet::new(), 1000);
let result = state
.evaluate_parent(&BTreeMap::new(), &BTreeSet::new())
.switch_target();
assert_eq!(result, Some(peer3));
}
@@ -240,7 +242,9 @@ fn test_evaluate_parent_prefers_shallowest_depth() {
make_coords(&[2, 3, 4, 0]),
);
let result = state.evaluate_parent(&BTreeMap::new(), &BTreeSet::new(), 1000);
let result = state
.evaluate_parent(&BTreeMap::new(), &BTreeSet::new())
.switch_target();
assert_eq!(result, Some(peer1));
}
@@ -258,7 +262,9 @@ fn test_evaluate_parent_stays_root_when_smallest() {
);
assert_eq!(
state.evaluate_parent(&BTreeMap::new(), &BTreeSet::new(), 1000),
state
.evaluate_parent(&BTreeMap::new(), &BTreeSet::new())
.switch_target(),
None
);
}
@@ -283,7 +289,9 @@ fn test_evaluate_parent_no_switch_when_already_best() {
// Now evaluate — should return None since peer1 is already our parent
assert_eq!(
state.evaluate_parent(&BTreeMap::new(), &BTreeSet::new(), 1000),
state
.evaluate_parent(&BTreeMap::new(), &BTreeSet::new())
.switch_target(),
None
);
}
@@ -294,7 +302,9 @@ fn test_evaluate_parent_no_peers() {
let state = TreeState::new(my_node, 1000);
assert_eq!(
state.evaluate_parent(&BTreeMap::new(), &BTreeSet::new(), 1000),
state
.evaluate_parent(&BTreeMap::new(), &BTreeSet::new())
.switch_target(),
None
);
}
@@ -329,7 +339,9 @@ fn test_evaluate_parent_depth_threshold() {
make_coords(&[3, 0]),
);
let result = state.evaluate_parent(&BTreeMap::new(), &BTreeSet::new(), 1000);
let result = state
.evaluate_parent(&BTreeMap::new(), &BTreeSet::new())
.switch_target();
assert_eq!(result, Some(peer3));
}
@@ -351,7 +363,9 @@ fn test_evaluate_parent_rejects_loop_candidate() {
// Should return None — the only candidate creates a loop
assert_eq!(
state.evaluate_parent(&BTreeMap::new(), &BTreeSet::new(), 1000),
state
.evaluate_parent(&BTreeMap::new(), &BTreeSet::new())
.switch_target(),
None
);
}
@@ -378,7 +392,9 @@ fn test_evaluate_parent_picks_loop_free_over_loopy() {
make_coords(&[2, 3, 4, 0]),
);
let result = state.evaluate_parent(&BTreeMap::new(), &BTreeSet::new(), 1000);
let result = state
.evaluate_parent(&BTreeMap::new(), &BTreeSet::new())
.switch_target();
assert_eq!(result, Some(peer2));
}
@@ -685,7 +701,9 @@ fn test_effective_depth_selects_lower_cost_deeper_peer() {
);
let costs = make_costs(&[(1, 6.0), (2, 1.01)]);
let result = state.evaluate_parent(&costs, &BTreeSet::new(), 1000);
let result = state
.evaluate_parent(&costs, &BTreeSet::new())
.switch_target();
assert_eq!(result, Some(peer_b));
}
@@ -711,7 +729,9 @@ fn test_effective_depth_equal_cost_degenerates_to_depth() {
);
let costs = make_costs(&[(1, 1.0), (2, 1.0)]);
let result = state.evaluate_parent(&costs, &BTreeSet::new(), 1000);
let result = state
.evaluate_parent(&costs, &BTreeSet::new())
.switch_target();
assert_eq!(result, Some(peer1));
}
@@ -736,7 +756,9 @@ fn test_effective_depth_tiebreak_by_node_addr() {
);
let costs = make_costs(&[(1, 1.0), (2, 1.0)]);
let result = state.evaluate_parent(&costs, &BTreeSet::new(), 1000);
let result = state
.evaluate_parent(&costs, &BTreeSet::new())
.switch_target();
assert_eq!(result, Some(peer1)); // smaller NodeAddr
}
@@ -769,7 +791,9 @@ fn test_hysteresis_prevents_marginal_switch() {
state.recompute_coords();
let costs = make_costs(&[(1, 2.5), (2, 2.2)]);
let result = state.evaluate_parent(&costs, &BTreeSet::new(), 1000);
let result = state
.evaluate_parent(&costs, &BTreeSet::new())
.switch_target();
assert_eq!(result, None); // marginal improvement blocked by hysteresis
}
@@ -802,7 +826,9 @@ fn test_hysteresis_allows_significant_switch() {
state.recompute_coords();
let costs = make_costs(&[(1, 6.0), (2, 1.01)]);
let result = state.evaluate_parent(&costs, &BTreeSet::new(), 1000);
let result = state
.evaluate_parent(&costs, &BTreeSet::new())
.switch_target();
assert_eq!(result, Some(peer_b));
}
@@ -828,7 +854,9 @@ fn test_cold_start_default_cost() {
);
// Empty cost map — all peers get default 1.0
let result = state.evaluate_parent(&BTreeMap::new(), &BTreeSet::new(), 1000);
let result = state
.evaluate_parent(&BTreeMap::new(), &BTreeSet::new())
.switch_target();
assert_eq!(result, Some(peer1)); // shallowest wins
}
@@ -859,8 +887,14 @@ fn test_hold_down_suppresses_reeval() {
// Peer_b now offers better cost, but hold-down suppresses
let costs = make_costs(&[(1, 5.0), (2, 1.0)]);
state.set_parent_hysteresis(0.0); // no hysteresis, only hold-down
let result = state.evaluate_parent(&costs, &BTreeSet::new(), 1000);
assert_eq!(result, None); // suppressed by hold-down
// The hold-down veto now lives at the shell edge, not inside evaluate_parent.
// The clock-free core still finds peer_b as a discretionary candidate; the shell
// suppresses it because hold-down is active — same net "no switch" as before.
assert!(state.is_switch_suppressed(1000)); // hold-down active at the edge
assert!(matches!(
state.evaluate_parent(&costs, &BTreeSet::new()),
ParentEval::Discretionary(p) if p == peer_b
));
}
#[test]
@@ -889,8 +923,13 @@ fn test_mandatory_switch_bypasses_hold_down() {
// Remove peer_a (parent lost) — should bypass hold-down
state.remove_peer(&peer_a);
let result = state.evaluate_parent(&BTreeMap::new(), &BTreeSet::new(), 1000);
assert_eq!(result, Some(peer_b)); // mandatory switch
// Hold-down is active at the edge, but a parent-lost switch is mandatory and
// bypasses the veto: the core returns Mandatory and the switch is taken anyway.
assert!(state.is_switch_suppressed(1000)); // hold-down active
assert!(matches!(
state.evaluate_parent(&BTreeMap::new(), &BTreeSet::new()),
ParentEval::Mandatory(p) if p == peer_b
));
}
#[test]
@@ -931,12 +970,16 @@ fn test_heterogeneous_7node_avoids_bottleneck() {
);
// Without costs (all 1.0): picks peer 1 (smaller addr) — correct by luck
let result_no_cost = state.evaluate_parent(&BTreeMap::new(), &BTreeSet::new(), 1000);
let result_no_cost = state
.evaluate_parent(&BTreeMap::new(), &BTreeSet::new())
.switch_target();
assert_eq!(result_no_cost, Some(peer1));
// With costs: fiber (1.01) vs LoRa (6.0) — fiber wins definitively
let costs = make_costs(&[(1, 1.01), (2, 6.0)]);
let result_with_cost = state.evaluate_parent(&costs, &BTreeSet::new(), 1000);
let result_with_cost = state
.evaluate_parent(&costs, &BTreeSet::new())
.switch_target();
assert_eq!(result_with_cost, Some(peer1));
// Now test the critical case: node 5 currently has LoRa parent (peer 2).
@@ -945,14 +988,18 @@ fn test_heterogeneous_7node_avoids_bottleneck() {
state.recompute_coords();
assert_eq!(state.my_coords().depth(), 2); // depth 2 through LoRa peer
let result_switch = state.evaluate_parent(&costs, &BTreeSet::new(), 1000);
let result_switch = state
.evaluate_parent(&costs, &BTreeSet::new())
.switch_target();
assert_eq!(result_switch, Some(peer1)); // switches away from LoRa bottleneck
// With hysteresis enabled, still switches because the cost difference is large
state.set_parent_hysteresis(0.2);
// current_parent_eff = 1 + 6.0 = 7.0, best_eff = 1 + 1.01 = 2.01
// threshold = 7.0 * 0.8 = 5.6, 2.01 < 5.6 → switch
let result_hyst = state.evaluate_parent(&costs, &BTreeSet::new(), 1000);
let result_hyst = state
.evaluate_parent(&costs, &BTreeSet::new())
.switch_target();
assert_eq!(result_hyst, Some(peer1));
}
@@ -988,14 +1035,18 @@ fn test_cost_degradation_triggers_switch() {
// Initial: both fiber-like costs. Node picks peer_a (smaller addr).
let initial_costs = make_costs(&[(1, 1.05), (2, 1.08)]);
let result = state.evaluate_parent(&initial_costs, &BTreeSet::new(), 1000);
let result = state
.evaluate_parent(&initial_costs, &BTreeSet::new())
.switch_target();
assert_eq!(result, Some(peer_a));
state.set_parent(peer_a, 1, 1000, 1000);
state.recompute_coords();
// Verify stable: no switch with same costs
let result = state.evaluate_parent(&initial_costs, &BTreeSet::new(), 1000);
let result = state
.evaluate_parent(&initial_costs, &BTreeSet::new())
.switch_target();
assert_eq!(result, None);
// Peer A's link degrades significantly (LoRa-like latency + loss)
@@ -1003,7 +1054,9 @@ fn test_cost_degradation_triggers_switch() {
// best_eff = 1 + 1.08 = 2.08
// threshold = 7.0 * 0.8 = 5.6, 2.08 < 5.6 → switch
let degraded_costs = make_costs(&[(1, 6.0), (2, 1.08)]);
let result = state.evaluate_parent(&degraded_costs, &BTreeSet::new(), 1000);
let result = state
.evaluate_parent(&degraded_costs, &BTreeSet::new())
.switch_target();
assert_eq!(result, Some(peer_b));
}
@@ -1036,7 +1089,9 @@ fn test_cost_improvement_within_hysteresis_no_switch() {
// best_eff = 1 + 1.5 = 2.5
// threshold = 3.0 * 0.8 = 2.4, 2.5 > 2.4 → no switch
let costs = make_costs(&[(1, 2.0), (2, 1.5)]);
let result = state.evaluate_parent(&costs, &BTreeSet::new(), 1000);
let result = state
.evaluate_parent(&costs, &BTreeSet::new())
.switch_target();
assert_eq!(result, None);
}
@@ -1058,7 +1113,9 @@ fn test_single_peer_no_reeval_benefit() {
// Initial selection: picks the only peer
let costs = make_costs(&[(1, 1.05)]);
let result = state.evaluate_parent(&costs, &BTreeSet::new(), 1000);
let result = state
.evaluate_parent(&costs, &BTreeSet::new())
.switch_target();
assert_eq!(result, Some(peer_a));
state.set_parent(peer_a, 1, 1000, 1000);
@@ -1066,6 +1123,8 @@ fn test_single_peer_no_reeval_benefit() {
// Even with terrible cost, no switch (no alternative)
let bad_costs = make_costs(&[(1, 50.0)]);
let result = state.evaluate_parent(&bad_costs, &BTreeSet::new(), 1000);
let result = state
.evaluate_parent(&bad_costs, &BTreeSet::new())
.switch_target();
assert_eq!(result, None);
}