mirror of
https://github.com/jmcorgan/fips.git
synced 2026-10-05 19:18:25 +00:00
709 lines
29 KiB
Rust
709 lines
29 KiB
Rust
//! Local spanning tree state for a node.
|
||
|
||
use alloc::collections::{BTreeMap, BTreeSet};
|
||
use core::fmt;
|
||
|
||
use super::core::ParentEval;
|
||
use super::limits::FlapDampener;
|
||
use super::{CoordEntry, ParentDeclaration, TreeCoordinate};
|
||
use crate::NodeAddr;
|
||
use crate::proto::mmp::delivery::{Acks, LinkEvidence, ResendReason};
|
||
|
||
/// What a parent-loss recovery did: whether the tree state changed, and
|
||
/// whether the recovery switch was the one that armed a dampening episode.
|
||
///
|
||
/// Crate-internal on purpose. The published entry point is
|
||
/// [`TreeState::handle_parent_lost`], whose `bool` return this type must not
|
||
/// displace.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub(crate) struct ParentLoss {
|
||
/// Whether the tree state changed and the caller should re-announce.
|
||
pub(crate) changed: bool,
|
||
/// Whether the recovery switch armed a flap dampening episode.
|
||
pub(crate) dampened: bool,
|
||
}
|
||
|
||
/// Local spanning tree state for a node.
|
||
///
|
||
/// Contains this node's declaration, coordinates, and view of peers'
|
||
/// tree positions. State is bounded by O(P × D) where P is peer count
|
||
/// and D is tree depth.
|
||
pub struct TreeState {
|
||
/// This node's NodeAddr.
|
||
my_node_addr: NodeAddr,
|
||
/// This node's current parent declaration.
|
||
my_declaration: ParentDeclaration,
|
||
/// This node's current coordinates (computed from declaration chain).
|
||
pub(super) my_coords: TreeCoordinate,
|
||
/// The current elected root (smallest reachable node_addr).
|
||
pub(super) root: NodeAddr,
|
||
/// Each peer's most recent parent declaration.
|
||
peer_declarations: BTreeMap<NodeAddr, ParentDeclaration>,
|
||
/// Each peer's full ancestry to root.
|
||
peer_ancestry: BTreeMap<NodeAddr, TreeCoordinate>,
|
||
/// Per-peer delivery tracking for sent TreeAnnounces.
|
||
acks: Acks,
|
||
/// The declaration sequence last recorded as sent to each peer.
|
||
announced: BTreeMap<NodeAddr, u64>,
|
||
/// Hysteresis factor for cost-based parent re-selection (0.0-1.0).
|
||
parent_hysteresis: f64,
|
||
/// Flap-dampening / hold-down state machine.
|
||
flap: FlapDampener,
|
||
/// Whether this node is a Leaf-profile node.
|
||
///
|
||
/// A Leaf holds a single upstream Full peer, sends no tree announces, and
|
||
/// must never self-elect as tree root: peers refuse a non-Full node as a
|
||
/// parent (`non_full_peers()` skip), so a Leaf that self-elected would form
|
||
/// an isolated second root and partition the mesh. When `true`, the node is
|
||
/// excluded from root self-election and attaches under its upstream instead,
|
||
/// holding that subtree's coordinate for its own routing (never announced, so
|
||
/// the coordinate's `self < root` is safe). Defaults to `false`
|
||
/// (tree-participating); the shell sets it from the node profile. NonRouting
|
||
/// nodes keep `false` — they announce, so the same relaxation would emit a
|
||
/// wire-invalid coordinate; a global-min NonRouting node is a separate open
|
||
/// problem for the leaf/non-routing tree-participation model.
|
||
self_is_leaf: bool,
|
||
}
|
||
|
||
impl TreeState {
|
||
/// Create initial tree state for a node (as root candidate).
|
||
///
|
||
/// The node starts as its own root until it learns of a smaller node_addr.
|
||
/// Initial sequence is 1 per protocol spec; `now_secs` is the injected
|
||
/// wall-clock Unix time in seconds stamped onto the initial declaration.
|
||
pub fn new(my_node_addr: NodeAddr, now_secs: u64) -> Self {
|
||
let timestamp = now_secs;
|
||
let my_declaration = ParentDeclaration::self_root(my_node_addr, 1, timestamp);
|
||
let my_coords = TreeCoordinate::root_with_meta(my_node_addr, 1, timestamp);
|
||
|
||
Self {
|
||
my_node_addr,
|
||
my_declaration,
|
||
my_coords,
|
||
root: my_node_addr,
|
||
peer_declarations: BTreeMap::new(),
|
||
peer_ancestry: BTreeMap::new(),
|
||
acks: Acks::new(),
|
||
announced: BTreeMap::new(),
|
||
parent_hysteresis: 0.0,
|
||
flap: FlapDampener::new(),
|
||
self_is_leaf: false,
|
||
}
|
||
}
|
||
|
||
/// Get this node's NodeAddr.
|
||
pub fn my_node_addr(&self) -> &NodeAddr {
|
||
&self.my_node_addr
|
||
}
|
||
|
||
/// Get this node's current declaration.
|
||
pub fn my_declaration(&self) -> &ParentDeclaration {
|
||
&self.my_declaration
|
||
}
|
||
|
||
/// Get this node's current coordinates.
|
||
pub fn my_coords(&self) -> &TreeCoordinate {
|
||
&self.my_coords
|
||
}
|
||
|
||
/// Test-only override of this node's coordinates, bypassing the
|
||
/// parent/declaration state machine. Lets routing tests place the node at
|
||
/// an arbitrary tree position to exercise coordinate-based classification.
|
||
#[cfg(test)]
|
||
pub(crate) fn set_my_coords_for_test(&mut self, coords: TreeCoordinate) {
|
||
self.root = *coords.root_id();
|
||
self.my_coords = coords;
|
||
}
|
||
|
||
/// Get the current root.
|
||
pub fn root(&self) -> &NodeAddr {
|
||
&self.root
|
||
}
|
||
|
||
/// Check if this node is currently the root.
|
||
pub fn is_root(&self) -> bool {
|
||
self.root == self.my_node_addr
|
||
}
|
||
|
||
/// Get coordinates for a peer, if known.
|
||
pub fn peer_coords(&self, peer_id: &NodeAddr) -> Option<&TreeCoordinate> {
|
||
self.peer_ancestry.get(peer_id)
|
||
}
|
||
|
||
/// Get declaration for a peer, if known.
|
||
pub fn peer_declaration(&self, peer_id: &NodeAddr) -> Option<&ParentDeclaration> {
|
||
self.peer_declarations.get(peer_id)
|
||
}
|
||
|
||
/// Number of known peers.
|
||
pub fn peer_count(&self) -> usize {
|
||
self.peer_declarations.len()
|
||
}
|
||
|
||
/// Iterate over all peer node IDs.
|
||
pub fn peer_ids(&self) -> impl Iterator<Item = &NodeAddr> {
|
||
self.peer_declarations.keys()
|
||
}
|
||
|
||
/// Add or update a peer's tree state.
|
||
///
|
||
/// Returns true if the state was updated (new or fresher declaration).
|
||
pub fn update_peer(
|
||
&mut self,
|
||
declaration: ParentDeclaration,
|
||
ancestry: TreeCoordinate,
|
||
) -> bool {
|
||
let peer_id = *declaration.node_addr();
|
||
|
||
// Check if this is a fresh update
|
||
if let Some(existing) = self.peer_declarations.get(&peer_id)
|
||
&& !declaration.is_fresher_than(existing)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
self.peer_declarations.insert(peer_id, declaration);
|
||
self.peer_ancestry.insert(peer_id, ancestry);
|
||
true
|
||
}
|
||
|
||
/// Remove a peer from the tree state.
|
||
pub fn remove_peer(&mut self, peer_id: &NodeAddr) {
|
||
self.peer_declarations.remove(peer_id);
|
||
self.peer_ancestry.remove(peer_id);
|
||
self.acks.remove(peer_id);
|
||
self.announced.remove(peer_id);
|
||
}
|
||
|
||
/// Record a TreeAnnounce the transport accepted for `peer`, carrying our
|
||
/// declaration sequence `seq` and sent with link counter `counter`, so it
|
||
/// stays outstanding until the peer's receiver reports show it arrived.
|
||
///
|
||
/// The transport accepting a frame is not delivery. A lost announce would
|
||
/// leave the peer on our old tree position until the next announce, which
|
||
/// on a node with one peer may never come. The announce's content changes
|
||
/// only with the declaration sequence, so a sequence other than the one
|
||
/// last recorded for `peer` starts a new lineage with fresh resend
|
||
/// budgets, and a resend or periodic re-broadcast of the same declaration
|
||
/// spends from its lineage's budget.
|
||
pub fn record_announce(
|
||
&mut self,
|
||
peer: NodeAddr,
|
||
seq: u64,
|
||
counter: u64,
|
||
link: &LinkEvidence,
|
||
now_ms: u64,
|
||
) {
|
||
let fresh = self.announced.insert(peer, seq) != Some(seq);
|
||
self.acks.record(peer, fresh, counter, link, now_ms);
|
||
}
|
||
|
||
/// Decide whether the outstanding TreeAnnounce to `peer` must be resent,
|
||
/// by the shared receiver-report rule ([`Acks::check`]).
|
||
///
|
||
/// Marks nothing: on `Some`, the caller marks the peer's announce pending
|
||
/// so the ordinary send path delivers the current declaration.
|
||
pub fn check_announce(
|
||
&mut self,
|
||
peer: &NodeAddr,
|
||
link: &LinkEvidence,
|
||
now_ms: u64,
|
||
) -> Option<ResendReason> {
|
||
self.acks.check(peer, link, now_ms)
|
||
}
|
||
|
||
/// Whether a TreeAnnounce to `peer` is still awaiting confirmation.
|
||
pub fn announce_outstanding(&self, peer: &NodeAddr) -> bool {
|
||
self.outstanding_counter(peer).is_some()
|
||
}
|
||
|
||
/// The link counter of the TreeAnnounce to `peer` awaiting confirmation.
|
||
pub fn outstanding_counter(&self, peer: &NodeAddr) -> Option<u64> {
|
||
self.acks.outstanding(peer)
|
||
}
|
||
|
||
/// The declaration sequence of the TreeAnnounce to `peer` awaiting
|
||
/// confirmation.
|
||
pub fn outstanding_seq(&self, peer: &NodeAddr) -> Option<u64> {
|
||
self.outstanding_counter(peer)?;
|
||
self.announced.get(peer).copied()
|
||
}
|
||
|
||
/// Set how long a TreeAnnounce the receiver reports cannot check waits
|
||
/// before its fallback resend. Defaults to
|
||
/// [`FALLBACK_MS`](crate::proto::mmp::delivery::FALLBACK_MS).
|
||
pub fn set_fallback(&mut self, ms: u64) {
|
||
self.acks.set_fallback(ms);
|
||
}
|
||
|
||
/// Update this node's parent selection.
|
||
///
|
||
/// Call this when switching parents. Updates the declaration and coordinates.
|
||
/// Returns true if flap dampening was just engaged due to this switch.
|
||
/// Only records a flap when the parent actually changes.
|
||
///
|
||
/// `timestamp` is the escaping wall-clock Unix seconds stamped onto the new
|
||
/// declaration; `now_ms` is the monotonic milliseconds driving the
|
||
/// flap-dampening timers (the two clock bases must not be crossed).
|
||
pub fn set_parent(
|
||
&mut self,
|
||
parent_id: NodeAddr,
|
||
sequence: u64,
|
||
timestamp: u64,
|
||
now_ms: u64,
|
||
) -> bool {
|
||
let parent_changed = self.is_root() || *self.my_declaration.parent_id() != parent_id;
|
||
self.my_declaration =
|
||
ParentDeclaration::new(self.my_node_addr, parent_id, sequence, timestamp);
|
||
self.flap.mark_switch(now_ms);
|
||
// Record switch for flap detection only when parent actually changes;
|
||
// coordinates will be recomputed when ancestry is available
|
||
if parent_changed {
|
||
self.flap.record_parent_switch(now_ms)
|
||
} else {
|
||
false
|
||
}
|
||
}
|
||
|
||
/// Update this node's coordinates based on current parent's ancestry.
|
||
///
|
||
/// Defensive: if extending the parent's ancestry would put `self` at the
|
||
/// minimum (because `self` is smaller than the parent's root), the
|
||
/// declaration is demoted to self-root in place. The caller is responsible
|
||
/// for re-signing the declaration after this call (do `set_parent → recompute_coords → sign_declaration`,
|
||
/// not `set_parent → sign_declaration → recompute_coords`).
|
||
pub fn recompute_coords(&mut self) {
|
||
if self.my_declaration.is_root() {
|
||
self.my_coords = TreeCoordinate::root_with_meta(
|
||
self.my_node_addr,
|
||
self.my_declaration.sequence(),
|
||
self.my_declaration.timestamp(),
|
||
);
|
||
self.root = self.my_node_addr;
|
||
return;
|
||
}
|
||
|
||
let parent_id = self.my_declaration.parent_id();
|
||
if let Some(parent_coords) = self.peer_ancestry.get(parent_id) {
|
||
let parent_root = *parent_coords.root_id();
|
||
if !self.self_is_leaf && self.my_node_addr <= parent_root {
|
||
// Prepending self would put a smaller-or-equal node at depth 0,
|
||
// breaking the "advertised root = min path entry" invariant.
|
||
// Demote to self-root rather than emit a path peers will reject.
|
||
//
|
||
// A Leaf is exempt: it keeps the `[self, parent, …, root]`
|
||
// coordinate even when `self <= parent_root`, so it lives as a
|
||
// depth-N member of its upstream's tree rather than demoting to
|
||
// an isolated self-root (which would partition the mesh). Safe
|
||
// because a Leaf never announces this coordinate; it reaches
|
||
// peers only via session-carried coords, which are not subject
|
||
// to the root-min `validate_semantics`.
|
||
let seq = self.my_declaration.sequence();
|
||
let ts = self.my_declaration.timestamp();
|
||
self.my_declaration = ParentDeclaration::self_root(self.my_node_addr, seq, ts);
|
||
self.my_coords = TreeCoordinate::root_with_meta(self.my_node_addr, seq, ts);
|
||
self.root = self.my_node_addr;
|
||
return;
|
||
}
|
||
// Our coords = [self_entry] ++ parent_coords entries
|
||
let self_entry = CoordEntry::new(
|
||
self.my_node_addr,
|
||
self.my_declaration.sequence(),
|
||
self.my_declaration.timestamp(),
|
||
);
|
||
let mut entries = vec![self_entry];
|
||
entries.extend_from_slice(parent_coords.entries());
|
||
self.my_coords = TreeCoordinate::new(entries).expect("non-empty path");
|
||
self.root = *self.my_coords.root_id();
|
||
}
|
||
}
|
||
|
||
/// Smallest root_id visible across known peers.
|
||
pub fn smallest_visible_root(&self) -> Option<NodeAddr> {
|
||
self.peer_ancestry.values().map(|c| *c.root_id()).min()
|
||
}
|
||
|
||
/// Whether this node should be the tree root: either there are no peers,
|
||
/// or our NodeAddr is `<=` every visible root.
|
||
///
|
||
/// A Leaf never self-elects as root while it has a peer to attach under: it
|
||
/// cannot forward transit, peers refuse it as a parent, and a Leaf-root
|
||
/// would partition the mesh. An isolated Leaf (no visible root) is still its
|
||
/// own root, which is harmless.
|
||
pub fn should_be_root(&self) -> bool {
|
||
match self.smallest_visible_root() {
|
||
Some(sr) => !self.self_is_leaf && self.my_node_addr <= sr,
|
||
None => true,
|
||
}
|
||
}
|
||
|
||
/// Promote self to root with an incremented sequence number.
|
||
///
|
||
/// `now_secs` is the injected wall-clock Unix time in seconds stamped onto
|
||
/// the new self-root declaration. Caller must `sign_declaration` afterwards
|
||
/// before sending the result.
|
||
pub fn become_root(&mut self, now_secs: u64) {
|
||
let new_seq = self.my_declaration.sequence() + 1;
|
||
let timestamp = now_secs;
|
||
self.my_declaration = ParentDeclaration::self_root(self.my_node_addr, new_seq, timestamp);
|
||
self.recompute_coords();
|
||
}
|
||
|
||
/// Calculate tree distance to a peer.
|
||
pub fn distance_to_peer(&self, peer_id: &NodeAddr) -> Option<usize> {
|
||
self.peer_ancestry
|
||
.get(peer_id)
|
||
.map(|coords| self.my_coords.distance_to(coords))
|
||
}
|
||
|
||
/// Find the best next hop toward a destination using greedy tree routing.
|
||
///
|
||
/// Returns the peer that minimizes tree distance to the destination,
|
||
/// but only if that peer is strictly closer than we are (prevents
|
||
/// routing loops at local minima). Tie-breaks equal distance by
|
||
/// smallest node_addr.
|
||
///
|
||
/// Returns `None` if:
|
||
/// - No peers have coordinates
|
||
/// - Destination is in a different tree (different root)
|
||
/// - No peer is closer to the destination than we are
|
||
///
|
||
/// `skip_peers` contains peers that should not be used as transit
|
||
/// (e.g., non-routing and leaf nodes).
|
||
pub fn find_next_hop(
|
||
&self,
|
||
dest_coords: &TreeCoordinate,
|
||
skip_peers: &BTreeSet<NodeAddr>,
|
||
) -> Option<NodeAddr> {
|
||
if self.my_coords.root_id() != dest_coords.root_id() {
|
||
return None;
|
||
}
|
||
|
||
let my_distance = self.my_coords.distance_to(dest_coords);
|
||
|
||
let mut best: Option<(NodeAddr, usize)> = None;
|
||
|
||
for (peer_id, peer_coords) in &self.peer_ancestry {
|
||
if skip_peers.contains(peer_id) {
|
||
continue;
|
||
}
|
||
let distance = peer_coords.distance_to(dest_coords);
|
||
|
||
let dominated = match &best {
|
||
None => true,
|
||
Some((best_id, best_dist)) => {
|
||
distance < *best_dist || (distance == *best_dist && peer_id < best_id)
|
||
}
|
||
};
|
||
|
||
if dominated {
|
||
best = Some((*peer_id, distance));
|
||
}
|
||
}
|
||
|
||
match best {
|
||
Some((peer_id, distance)) if distance < my_distance => Some(peer_id),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
/// Mark this node as a Leaf (or not), gating it out of root self-election.
|
||
///
|
||
/// A Leaf attaches under its upstream Full peer and never becomes root; see
|
||
/// the `self_is_leaf` field docs. Set from the node profile at construction.
|
||
pub fn set_self_is_leaf(&mut self, is_leaf: bool) {
|
||
self.self_is_leaf = is_leaf;
|
||
}
|
||
|
||
/// Set the parent hysteresis factor (0.0-1.0).
|
||
pub fn set_parent_hysteresis(&mut self, hysteresis: f64) {
|
||
self.parent_hysteresis = hysteresis.clamp(0.0, 1.0);
|
||
}
|
||
|
||
/// Set the hold-down duration after parent switches.
|
||
pub fn set_hold_down(&mut self, secs: u64) {
|
||
self.flap.set_hold_down(secs);
|
||
}
|
||
|
||
/// Configure flap dampening parameters.
|
||
pub fn set_flap_dampening(&mut self, threshold: u32, window_secs: u64, dampening_secs: u64) {
|
||
self.flap
|
||
.set_flap_dampening(threshold, window_secs, dampening_secs);
|
||
}
|
||
|
||
/// How long a dampening episode suppresses discretionary parent switching,
|
||
/// after the configured value is clamped.
|
||
///
|
||
/// Crate-internal: it feeds a log field on the engagement warning.
|
||
pub(crate) fn dampening_secs(&self) -> u64 {
|
||
self.flap.dampening_secs()
|
||
}
|
||
|
||
/// Check if flap dampening is currently active. `now_ms` is the injected
|
||
/// monotonic time in milliseconds.
|
||
pub fn is_flap_dampened(&self, now_ms: u64) -> bool {
|
||
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 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).
|
||
pub(crate) fn evaluate_parent(
|
||
&self,
|
||
peer_costs: &BTreeMap<NodeAddr, f64>,
|
||
skip_peers: &BTreeSet<NodeAddr>,
|
||
) -> ParentEval {
|
||
if self.peer_ancestry.is_empty() {
|
||
return ParentEval::None;
|
||
}
|
||
|
||
// Find the smallest root visible across all peers
|
||
let mut smallest_root: Option<NodeAddr> = None;
|
||
for coords in self.peer_ancestry.values() {
|
||
let peer_root = coords.root_id();
|
||
smallest_root = Some(match smallest_root {
|
||
None => *peer_root,
|
||
Some(current) => {
|
||
if *peer_root < current {
|
||
*peer_root
|
||
} else {
|
||
current
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
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
|
||
// `None` lets the caller promote us via `become_root` / `should_be_root`.
|
||
// Picking any peer here would produce an invalid path, since prepending
|
||
// `self` to that peer's ancestry would put `self` at depth 0 and the
|
||
// peer's larger root at the tail — violating "advertised root = min path
|
||
// entry" and getting rejected by recipients' `validate_semantics`.
|
||
//
|
||
// A Leaf is exempt: it must not self-elect as root (peers refuse it as a
|
||
// parent, so it would partition the mesh). It falls through to select its
|
||
// upstream Full peer as parent. The resulting
|
||
// `self < root` coordinate is invalid on the wire, but a Leaf never
|
||
// announces it (`send_tree_announce_to_peer` is Leaf-gated); it is used
|
||
// only for the Leaf's own routing and propagates to peers via the
|
||
// coords carried on its session frames, which are not root-validated.
|
||
if !self.self_is_leaf && self.my_node_addr <= smallest_root {
|
||
return ParentEval::None;
|
||
}
|
||
|
||
// Among peers that reach the smallest root, find the lowest effective_depth.
|
||
// effective_depth(peer) = peer.depth + link_cost_to_peer
|
||
let mut best_peer: Option<(NodeAddr, f64)> = None; // (peer_addr, effective_depth)
|
||
for (peer_id, coords) in &self.peer_ancestry {
|
||
if *coords.root_id() != smallest_root {
|
||
continue;
|
||
}
|
||
// Skip non-routing/leaf peers (can't forward transit)
|
||
if skip_peers.contains(peer_id) {
|
||
continue;
|
||
}
|
||
// Reject candidates whose ancestry contains us (would create a loop)
|
||
if coords.contains(&self.my_node_addr) {
|
||
continue;
|
||
}
|
||
// If any peer has MMP cost data, only consider measured peers.
|
||
// This prevents freshly connected peers (no SRTT, default cost 1.0)
|
||
// from appearing artificially cheap. During cold start (no peer has
|
||
// MMP data, peer_costs is empty), fall back to default cost 1.0.
|
||
let cost = match peer_costs.get(peer_id) {
|
||
Some(&c) => c,
|
||
None if peer_costs.is_empty() => 1.0,
|
||
None => continue,
|
||
};
|
||
let eff_depth = coords.depth() as f64 + cost;
|
||
match &best_peer {
|
||
None => best_peer = Some((*peer_id, eff_depth)),
|
||
Some((best_id, best_eff)) => {
|
||
if eff_depth < *best_eff || (eff_depth == *best_eff && peer_id < best_id) {
|
||
best_peer = Some((*peer_id, eff_depth));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
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 ParentEval::None;
|
||
}
|
||
|
||
// --- 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()
|
||
&& !self
|
||
.peer_ancestry
|
||
.contains_key(self.my_declaration.parent_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 ParentEval::Mandatory(best_peer_id);
|
||
}
|
||
|
||
// We're root but shouldn't be (peers have a smaller root) — always switch
|
||
if self.is_root() {
|
||
return ParentEval::Mandatory(best_peer_id);
|
||
}
|
||
|
||
// --- 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,
|
||
// treat as maximally expensive so any measured candidate can win.
|
||
// If peer_costs is empty (cold start), use default cost 1.0.
|
||
let current_parent_cost = peer_costs
|
||
.get(self.my_declaration.parent_id())
|
||
.copied()
|
||
.unwrap_or(if peer_costs.is_empty() {
|
||
1.0
|
||
} else {
|
||
f64::INFINITY
|
||
});
|
||
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,
|
||
// 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 ParentEval::Discretionary(best_peer_id);
|
||
}
|
||
|
||
ParentEval::None
|
||
}
|
||
|
||
/// Handle loss of current parent.
|
||
///
|
||
/// Tries to find an alternative parent among remaining peers.
|
||
/// If none available, becomes its own root (increments sequence).
|
||
///
|
||
/// Returns `true` if the tree state changed (caller should re-announce).
|
||
///
|
||
/// `now_secs` is the injected wall-clock Unix seconds stamped onto the new
|
||
/// declaration; `now_ms` is the monotonic milliseconds driving the parent
|
||
/// re-evaluation's flap timers (the two clock bases must not be crossed).
|
||
pub fn handle_parent_lost(
|
||
&mut self,
|
||
peer_costs: &BTreeMap<NodeAddr, f64>,
|
||
now_secs: u64,
|
||
now_ms: u64,
|
||
) -> bool {
|
||
self.recover(peer_costs, now_secs, now_ms).changed
|
||
}
|
||
|
||
/// Handle loss of current parent, reporting whether the recovery switch
|
||
/// itself armed a flap dampening episode.
|
||
///
|
||
/// Same recovery as [`TreeState::handle_parent_lost`], which delegates
|
||
/// here. A caller holding a metrics handle uses this one so the
|
||
/// engagement can be counted and logged; the published signature stays
|
||
/// `bool`.
|
||
pub(crate) fn recover(
|
||
&mut self,
|
||
peer_costs: &BTreeMap<NodeAddr, f64>,
|
||
now_secs: u64,
|
||
now_ms: u64,
|
||
) -> ParentLoss {
|
||
// 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;
|
||
let dampened = self.set_parent(new_parent, new_seq, now_secs, now_ms);
|
||
self.recompute_coords();
|
||
return ParentLoss {
|
||
changed: true,
|
||
dampened,
|
||
};
|
||
}
|
||
|
||
// No alternative: become own root. This branch never calls
|
||
// `set_parent`, so it cannot arm a dampening episode.
|
||
let new_seq = self.my_declaration.sequence() + 1;
|
||
self.my_declaration = ParentDeclaration::self_root(self.my_node_addr, new_seq, now_secs);
|
||
self.recompute_coords();
|
||
ParentLoss {
|
||
changed: true,
|
||
dampened: false,
|
||
}
|
||
}
|
||
|
||
/// Mutable access to this node's declaration.
|
||
///
|
||
/// Exposed so the shell can write back a signature after signing: the
|
||
/// declaration data + `signing_bytes()` live in-core, but the key-crypto
|
||
/// (the schnorr sign) is a shell-driven boundary (§6), mirroring discovery.
|
||
pub(crate) fn my_declaration_mut(&mut self) -> &mut ParentDeclaration {
|
||
&mut self.my_declaration
|
||
}
|
||
|
||
/// Check if this node's declaration is signed.
|
||
pub fn is_declaration_signed(&self) -> bool {
|
||
self.my_declaration.is_signed()
|
||
}
|
||
}
|
||
|
||
impl fmt::Debug for TreeState {
|
||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||
f.debug_struct("TreeState")
|
||
.field("my_node_addr", &self.my_node_addr)
|
||
.field("root", &self.root)
|
||
.field("is_root", &self.is_root())
|
||
.field("depth", &self.my_coords.depth())
|
||
.field("peers", &self.peer_count())
|
||
.finish()
|
||
}
|
||
}
|