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
+6 -2
View File
@@ -148,7 +148,9 @@ Controls tree construction and parent selection.
| Parameter | Type | Default | Description |
|----------------------------------------|-------|---------|--------------------------------------------------|
| `node.tree.announce_min_interval_ms` | u64 | `500` | Per-peer TreeAnnounce rate limit |
| `node.tree.parent_switch_threshold` | usize | `1` | Min depth improvement required to switch parents |
| `node.tree.parent_hysteresis` | f64 | `0.2` | Cost improvement fraction required for same-root parent switch (0.01.0) |
| `node.tree.hold_down_secs` | u64 | `30` | Suppress non-mandatory re-evaluation after parent switch |
| `node.tree.reeval_interval_secs` | u64 | `60` | Periodic cost-based parent re-evaluation interval (0 = disabled) |
### Bloom Filter (`node.bloom.*`)
@@ -346,7 +348,9 @@ node:
recent_expiry_secs: 10
tree:
announce_min_interval_ms: 500
parent_switch_threshold: 1
parent_hysteresis: 0.2 # cost improvement fraction for parent switch
hold_down_secs: 30 # suppress re-evaluation after switch
reeval_interval_secs: 60 # periodic cost-based re-evaluation (0 = disabled)
bloom:
update_debounce_ms: 500
session:
+7 -5
View File
@@ -460,11 +460,13 @@ low-latency links converge quickly. Each metric carries both short-term and
long-term exponentially weighted moving averages, enabling detection of
quality changes against a stable baseline.
MMP's primary role today is operator visibility — periodic log lines report
per-link RTT, loss, jitter, and goodput. The metrics also provide the
foundation for quality-aware routing: MMP computes an Expected Transmission
Count (ETX) from bidirectional delivery ratios, which will feed into
candidate ranking once link-cost routing is enabled.
MMP serves dual roles: operator visibility and cost-based parent selection.
Periodic log lines report per-link RTT, loss, jitter, and goodput. MMP
computes an Expected Transmission Count (ETX) from bidirectional delivery
ratios, which feeds into cost-based parent selection where each node
evaluates `effective_depth = depth + link_cost` using
`link_cost = etx * (1.0 + srtt_ms / 100.0)`. ETX is not yet used in
`find_next_hop()` candidate ranking for data forwarding.
See [fips-mesh-layer.md](fips-mesh-layer.md) for MMP operating modes, report
scheduling, and the spin bit design.
+5 -4
View File
@@ -405,8 +405,8 @@ work together to build and maintain the mesh, and
## Metrics Measurement Protocol (MMP)
Each active peer link runs an instance of the Metrics Measurement Protocol,
providing per-link quality metrics to the operator and (in future) to the
routing layer for link-cost decisions.
providing per-link quality metrics to the operator and to the spanning tree
layer for cost-based parent selection.
### Metrics Tracked
@@ -422,8 +422,9 @@ fields in the FMP wire format:
- **OWD trend** — One-way delay trend (µs/s, signed). Indicates congestion
buildup before loss occurs.
- **ETX** — Expected Transmission Count, computed from bidirectional delivery
ratios. Not yet wired into routing decisions (link cost is currently
constant).
ratios. Used in cost-based parent selection via
`link_cost = etx * (1.0 + srtt_ms / 100.0)`; not yet used in
`find_next_hop()` candidate ranking.
- **Dual EWMA trends** — Short-term (α=1/4) and long-term (α=1/32) trend
indicators for both RTT and loss, enabling change detection.
+7 -5
View File
@@ -52,8 +52,8 @@ Nodes self-organize into a spanning tree through distributed parent selection:
No election protocol — this is a consequence of each node independently
preferring lower-addressed roots.
2. **Parent selection**: Each node selects a single parent from among its
direct peers based on which offers the best path to root (considering
depth improvement threshold).
direct peers based on which offers the lowest effective depth (tree depth
weighted by local link cost).
3. **Coordinate computation**: Once a node has a parent, its coordinate is
computed from its ancestry path.
@@ -190,8 +190,9 @@ When bloom filters identify multiple candidate peers, they are ranked by a
composite key:
1. **link_cost** — Per-link quality metric. ETX is computed from bidirectional
delivery ratios in MMP metrics but is not yet wired into `find_next_hop()`
candidate ranking; link cost remains constant in the current implementation.
delivery ratios in MMP metrics and is used in cost-based parent selection
via `effective_depth = depth + link_cost`, but is not yet wired into
`find_next_hop()` candidate ranking.
2. **tree_distance** — Coordinate-based distance to destination through this
peer
3. **node_addr** — Deterministic tie-breaker
@@ -621,7 +622,8 @@ recovery).
| Discovery reverse-path routing | **Implemented** |
| Error signal rate limiting | **Implemented** |
| Leaf-only operation | Future direction |
| Link cost metrics (ETX) | Future direction |
| Link cost in parent selection (ETX) | **Implemented** |
| Link cost in candidate ranking | Future direction |
| Discovery path accumulation | Future direction |
## References
+36 -15
View File
@@ -35,28 +35,46 @@ reconverge to a single tree.
## Parent Selection
Each node selects a single parent from among its direct peers. Parent
selection follows these rules:
selection uses cost-weighted depth to balance tree depth against link
quality.
### Selection Criteria
1. **Find the smallest root** visible across all peers' TreeAnnounce messages
2. Among peers that can reach that root, prefer the one offering the
**shallowest depth** (shortest path to root)
3. Apply the **depth improvement threshold**: switching parents requires the
proposed parent to offer a path at least 1 hop shallower than the current
parent (when the root is the same)
2. Compute **effective depth** for each candidate peer:
`effective_depth = peer.depth + link_cost`, where
`link_cost = etx * (1.0 + srtt_ms / 100.0)` using locally measured MMP
metrics. When MMP metrics have not yet converged, `link_cost` defaults to
1.0, preserving pure depth-based behavior as a graceful fallback.
3. Apply **hysteresis**: switch parents only when the best candidate's
effective depth is significantly better than the current parent's:
`best_eff_depth < current_eff_depth * (1.0 - parent_hysteresis)`
(default `parent_hysteresis = 0.2`, requiring 20% improvement)
### Immediate Switch Triggers
### Mandatory Switch Triggers
Three conditions bypass the depth threshold and trigger immediate parent
reselection:
Two conditions bypass both hysteresis and the hold-down timer, triggering
immediate parent reselection:
1. **Parent loss**: Current parent is no longer in the peer set (link
broken, peer disconnected)
2. **Better root**: A peer advertises a smaller root than the current
tree's root — always switch regardless of depth
3. **Depth improvement**: Same root, but the proposed parent offers depth
at least `PARENT_SWITCH_THRESHOLD` (1 hop) better than the current parent
tree's root — always switch regardless of effective depth
### Stability Mechanisms
- **Hold-down timer** (`hold_down_secs`, default 30s): After any parent
switch, non-mandatory re-evaluation is suppressed to allow MMP metrics
to stabilize on the new link. Mandatory switches (parent loss, root
change) bypass the hold-down.
- **Periodic re-evaluation** (`reeval_interval_secs`, default 60s):
Re-evaluates parent selection using current MMP link costs, independent
of TreeAnnounce traffic. This catches link degradation after the tree
has stabilized and TreeAnnounce gossip has stopped.
- **Local-only metrics**: Link costs use only locally measured MMP data
(ETX and SRTT). No cumulative path costs are propagated and no wire
format changes are required. This avoids the trust problems inherent
in self-reported cost metrics in a permissionless network.
### After Parent Change
@@ -228,7 +246,9 @@ Example: In a 1000-node network with depth 10 and 5 peers, a node stores
| Parameter | Default | Description |
| --------- | ------- | ----------- |
| PARENT_SWITCH_THRESHOLD | 1 hop | Minimum depth improvement for same-root switch |
| PARENT_HYSTERESIS | 0.2 (20%) | Fractional improvement in effective depth required for same-root switch |
| HOLD_DOWN_SECS | 30s | Suppress non-mandatory re-evaluation after parent switch |
| REEVAL_INTERVAL_SECS | 60s | Periodic cost-based parent re-evaluation interval |
| ANNOUNCE_MIN_INTERVAL | 500ms | Minimum between announcements to same peer |
| ROOT_TIMEOUT | 60 min | Root declaration considered stale (not yet enforced; heartbeat cascading covers common case) |
| TREE_ENTRY_TTL | 510 min | Individual entry expiration |
@@ -238,7 +258,9 @@ Example: In a 1000-node network with depth 10 and 5 peers, a node stores
| Feature | Status |
| ------- | ------ |
| Root election (smallest node_addr) | **Implemented** |
| Parent selection with depth threshold | **Implemented** |
| Cost-based parent selection with hysteresis | **Implemented** |
| Hold-down timer after parent change | **Implemented** |
| Periodic cost-based parent re-evaluation | **Implemented** |
| Coordinate computation | **Implemented** |
| TreeAnnounce gossip | **Implemented** |
| Signature verification (outer) | **Implemented** |
@@ -247,7 +269,6 @@ Example: In a 1000-node network with depth 10 and 5 peers, a node stores
| Coord cache flush on parent change | **Implemented** |
| Root timeout enforcement | Planned |
| Tree entry TTL enforcement | Planned |
| Hold-down timer after parent change | Planned |
| Per-ancestry-entry signatures | Future direction |
## References
+6 -3
View File
@@ -410,9 +410,12 @@ MTU, and its own liveness tracking.
### Transport Quality and Path Selection
Transport characteristics (latency, bandwidth, reliability) affect path
quality but are not currently factored into routing decisions. The spanning
tree parent selection uses a depth improvement threshold but does not
consider transport quality. This is a potential area for future optimization.
quality. The spanning tree parent selection factors in link quality through
cost-based effective depth (`effective_depth = depth + link_cost`), where
`link_cost` is derived from locally measured MMP metrics (ETX and SRTT).
This allows the tree to prefer lower-latency, lower-loss links when the
quality difference is significant. Link cost is not yet used in
`find_next_hop()` candidate ranking for data forwarding.
## References
+125 -109
View File
@@ -24,7 +24,7 @@ The protocol is based on Yggdrasil v0.5's CRDT gossip design.
8. [Parent Selection](#8-parent-selection)
9. [Steady State Behavior](#9-steady-state-behavior)
10. [Worked Examples](#10-worked-examples)
11. [Known Limitations (v1 Implementation)](#known-limitations-v1-implementation)
11. [Known Limitations](#known-limitations)
---
@@ -380,8 +380,10 @@ Link B ←→ C fails:
### Reconvergence Dynamics
**Stability threshold**: To prevent flapping, a node only changes parent when
the improvement exceeds a threshold. In v1, this is a depth difference of at
least `PARENT_SWITCH_THRESHOLD` (1 hop). See §8 for details.
the improvement exceeds cost-based hysteresis (`parent_hysteresis`, default
0.2 = 20% improvement required). A hold-down timer (`hold_down_secs`,
default 30s) further suppresses non-mandatory re-evaluation after a switch.
See §8 for details.
**Sequence number advancement**: Each parent change increments the sequence
number. Nodes observing rapid sequence increases can detect instability and
@@ -421,7 +423,7 @@ Nodes detect they're partitioned when:
2. **Root unreachable**: No peer has path to current root
**Detection via gossip staleness** (not currently implemented — see
[Known Limitations](#known-limitations-v1-implementation)):
[Known Limitations](#known-limitations)):
In principle, nodes would also detect partitions through root entry staleness:
if no fresh root announcements arrive within a timeout, the root is presumed
@@ -579,100 +581,114 @@ see traffic to consider the link alive.
Parent selection determines tree structure and routing efficiency.
### v1 Implementation: Depth-Only Selection
### Cost-Based Selection with Effective Depth
The current implementation uses tree depth as the sole selection metric, with a
threshold to prevent thrashing between equivalent-depth paths.
The implementation uses cost-weighted depth to balance tree depth against link
quality. Each candidate parent is evaluated by its **effective depth** — the
tree depth plus a local link cost penalty derived from MMP metrics.
**Algorithm** (`TreeState::evaluate_parent()` in `tree.rs`):
**Algorithm** (`TreeState::evaluate_parent()` in `tree/state.rs`):
```
evaluate_parent():
evaluate_parent(peer_costs: HashMap<NodeAddr, f64>):
// 1. Find smallest root reachable through any peer
smallest_root = min(peer.root for peer in peers_with_coords)
if self == smallest_root and is_root:
return None // Already root, no change
// 2. Among peers reaching smallest_root, find shallowest
best_peer = min(
[p for p in peers if p.root == smallest_root],
key=lambda p: p.depth
)
proposed_depth = best_peer.depth + 1
// 2. Compute effective depth for each candidate
for each peer with peer.root == smallest_root:
link_cost = peer_costs.get(peer) or 1.0 // default optimistic
peer.effective_depth = peer.depth + link_cost
best_peer = min(candidates, key=effective_depth, tiebreak=node_addr)
if best_peer == current_parent:
return None // Already using best
// 3. Always switch if parent is gone or root is changing
// 3. Mandatory switches — bypass hysteresis and hold-down
if current_parent not in peers:
return best_peer // Path broken
return best_peer // Parent lost
if current_root != smallest_root:
return best_peer // Better root found
// 4. For same root: require depth improvement ≥ threshold
current_depth = my_coords.depth()
if current_depth >= proposed_depth + PARENT_SWITCH_THRESHOLD:
// 4. Hold-down check — suppress non-mandatory switches
if last_parent_switch + hold_down > now:
return None // Too soon after last switch
// 5. Hysteresis — require significant improvement
current_parent_eff = current_parent.depth + peer_costs.get(current_parent)
if best_eff_depth < current_parent_eff * (1.0 - parent_hysteresis):
return best_peer
return None // Not enough improvement
```
**Constants**:
**Parameters**:
```
PARENT_SWITCH_THRESHOLD = 1 // Minimum depth improvement to switch parents
parent_hysteresis = 0.2 // 20% improvement required for same-root switch
hold_down_secs = 30 // Suppress re-evaluation after parent switch
reeval_interval_secs = 60 // Periodic re-evaluation independent of TreeAnnounce
```
This means a proposed parent must offer a path at least 1 hop shallower than
the current parent (under the same root) to trigger a switch. Root changes
always trigger a switch regardless of depth.
**What this means for tree structure**: The v1 algorithm produces minimum-depth
trees, which minimizes coordinate path length and hop count for coordinate-based
greedy routing. However, it does not account for link quality—a high-latency or
lossy link at depth 1 is preferred over a fast link at depth 2.
### v2 Planned: Cost Metrics
The following cost-based parent selection is planned but not yet implemented.
**Cost components**:
- **Latency** (primary): `cost_latency = round_trip_time_ms`
- **Packet loss** (reliability): `cost_loss = 1 / (1 - loss_rate)`
transforms loss rate into multiplicative cost (10% loss → 1.11, 50% → 2.0)
- **Bandwidth** (capacity): `cost_bandwidth = reference_bandwidth / actual_bandwidth`
**Combined cost**: Weighted combination with application-tunable weights:
**Link cost formula** (`ActivePeer::link_cost()` in `peer/active.rs`):
```
effective_cost = w_latency * cost_latency
+ w_loss * cost_loss
+ w_bandwidth * cost_bandwidth
link_cost = etx * (1.0 + srtt_ms / 100.0)
```
**Path cost to root**: Recursive — each node advertises its cumulative cost,
allowing neighbors to compute total path cost:
Where ETX (Expected Transmission Count) comes from bidirectional MMP delivery
ratios and SRTT (Smoothed Round-Trip Time) from MMP timestamp-echo. When MMP
metrics have not yet converged, `link_cost` defaults to 1.0, preserving
depth-only behavior as a graceful fallback.
```
path_cost(peer) = link_cost(self, peer) + peer.path_cost_to_root
```
**What this means for tree structure**: The algorithm can prefer a deeper parent
with a better link over a shallower parent with a poor link, when the effective
depth difference is significant enough to overcome hysteresis. For example, a
fiber link at depth 2 (effective depth ≈ 3.01) beats a LoRa link at depth 1
(effective depth ≈ 7.32 with 500ms RTT and 5% loss). In homogeneous networks
where all links have similar quality, effective depth tracks tree depth closely
and the algorithm produces minimum-depth trees as before.
**Stability threshold**: Hysteresis with both absolute and relative components:
**Periodic re-evaluation**: `evaluate_parent()` is event-driven — called on
TreeAnnounce receipt or parent loss. After the tree stabilizes and TreeAnnounce
traffic stops, link degradation goes undetected. The periodic re-evaluation
timer (`reeval_interval_secs`) calls `evaluate_parent()` from the tick handler
with current MMP link costs, independent of TreeAnnounce traffic.
```
stability_threshold = base_threshold + current_cost * relative_threshold
```
### Design Rationale: Local-Only Cost Metrics
**Cost measurement**: Active probing (periodic RTT measurement), passive
observation (inferred from protocol message timing), and exponential smoothing
(`alpha = 0.10.3`) to balance responsiveness with stability.
The original design considered cumulative path costs (OSPF-style, where each
hop adds its link cost and the total is advertised in TreeAnnounce). This
approach was rejected for three independent reasons:
**Implementation prerequisites**: The cost-based algorithm requires changes to
the TreeAnnounce wire format to carry path cost values, and a measurement
subsystem for link quality metrics. See Section 10, Example 2 for how
cost-based selection would affect tree structure in heterogeneous networks.
1. **Unverifiable self-reporting**: In a permissionless network, a node can
claim any path cost. There is no mechanism for neighbors to verify that
the reported cumulative cost is truthful. A malicious node advertising
zero cost would attract traffic as a transit node.
2. **No shared metric semantics**: Different links measure different things.
A LoRa link's 500ms RTT and a fiber link's 1ms RTT are both "round-trip
time" but represent fundamentally different physical constraints.
Accumulating them into a single path cost obscures per-hop information
that is more useful when evaluated locally.
3. **Accumulation amplifies error**: Small measurement noise at each hop
compounds across the path. A 5-hop path accumulates 5x the measurement
error of a single hop, while providing no more actionable information
than the local link cost to each candidate parent.
The local-only approach uses `link_cost = etx * (1.0 + srtt_ms / 100.0)`,
where both components are locally measured via MMP. The RTT weighting
addresses a blind spot in ETX alone: a clean-but-slow link (LoRa with 0%
loss) gets ETX = 1.0, identical to fiber. The SRTT factor distinguishes them
— a 500ms LoRa link gets cost ≈ 6.0 versus fiber at ≈ 1.01.
No wire format changes are required. TreeAnnounce messages continue to carry
depth (not cost), and each node independently evaluates its direct links
using trusted local measurements.
---
@@ -874,48 +890,48 @@ Physical topology:
node_addr ordering: B < A < D < E < C
```
**Cost calculation** (using bandwidth as primary):
**Local link costs** (using `link_cost = etx * (1.0 + srtt_ms / 100.0)`):
```
Link costs (normalized to 1 Gbps = 1):
A ═ B: cost = 1
B ═ D: cost = 1
D ═ E: cost = 1
C — D: cost = 1000 (1 Mbps)
A ~ C: cost = 100000 (9600 bps)
Assumed MMP measurements after convergence:
A ═ B: fiber, 1ms RTT, 0% loss → link_cost = 1.0 * (1 + 1/100) ≈ 1.01
B ═ D: fiber, 1ms RTT, 0% loss → link_cost = 1.0 * (1 + 1/100) ≈ 1.01
D ═ E: fiber, 1ms RTT, 0% loss → link_cost = 1.0 * (1 + 1/100) ≈ 1.01
C — D: DSL, 20ms RTT, 2% loss → link_cost = 1.04 * (1 + 20/100) ≈ 1.25
A ~ C: radio, 500ms RTT, 5% loss→ link_cost = 1.11 * (1 + 500/100) ≈ 6.66
```
**Tree formation with costs**:
**Tree formation with effective depth**:
```
Root = B (smallest node_addr)
Root = B (smallest node_addr, depth 0)
Parent selection:
├── A: peers are B (cost 1), C (cost 100000)
│ └── Selects B (much lower cost)
Parent selection (each node evaluates effective_depth = peer.depth + link_cost):
├── A: peers are B (depth 0, cost 1.01 → eff 1.01), C (depth ?, cost 6.66)
│ └── Selects B (lowest effective depth)
├── D: peers are B (cost 1), C (cost 1000), E (cost 1)
└── Selects B (direct, cost 1)
├── D: peers are B (depth 0, cost 1.01 → eff 1.01),
C (depth ?, cost 1.25), E (depth ?, cost 1.01)
│ └── Selects B (direct, lowest effective depth)
├── E: peer is D
│ └── Path to B: E → D → B, cost = 1 + 1 = 2
│ └── Selects D
├── E: peer is D (depth 1, cost 1.01 → eff 2.01)
│ └── Selects D (only candidate)
└── C: peers are A (cost 100000), D (cost 1000)
└── Path through A: 100000 + 1 = 100001
└── Path through D: 1000 + 1 = 1001
└── Selects D (much lower cost despite higher local cost)
└── C: peers are A (depth 1, cost 6.66 → eff 7.66),
D (depth 1, cost 1.25 → eff 2.25)
└── Selects D (eff 2.25 vs 7.66 — much lower)
Resulting tree:
B (root)
B (root, depth 0)
/ \
A D
A D (both depth 1)
|\
E C
E C (both depth 2)
```
**Note**: C chooses D despite A being "closer" in hops, because total path
cost through D is lower.
**Note**: C chooses D despite both being at depth 1 — the DSL link to D
(eff 2.25) far beats the radio link to A (eff 7.66). With local-only costs,
each node evaluates only its direct link quality, not cumulative path cost.
**Radio link failure**:
@@ -925,11 +941,10 @@ If A ~ C radio fails:
└── C loses a potential backup path, but current tree unchanged
If D — C DSL fails:
├── C loses parent
├── C loses parent (mandatory switch — bypasses hysteresis and hold-down)
├── C's only remaining peer is A (radio)
├── C selects A as parent
── C's path to root: C → A → B (cost 100001)
└── Tree reconverges with C as child of A
├── C selects A as parent (eff depth = 1 + 6.66 = 7.66)
── Tree reconverges with C as child of A at depth 2
```
### Example 3: Network Partition and Healing
@@ -1019,7 +1034,7 @@ initial exchange).
---
## Known Limitations (v1 Implementation)
## Known Limitations
The following limitations exist in the current implementation relative to the
design described in this document. They are documented here to guide future
@@ -1079,26 +1094,27 @@ expires and has no peer with a fresher root declaration is partitioned. It
becomes its own root and announces, allowing the partition to converge
independently.
### Known Limitation: Limited Stability Mechanisms
### Known Limitation: Remaining Stability Gaps
The implementation includes basic hysteresis (`PARENT_SWITCH_THRESHOLD = 1`
depth difference required to switch parents), but the temporal stability
mechanisms described in the design are not implemented:
The primary stability mechanisms are implemented:
- **Cost-based hysteresis** (`parent_hysteresis = 0.2`): requires 20%
effective depth improvement to switch parents under the same root
- **Hold-down timer** (`hold_down_secs = 30`): suppresses non-mandatory
re-evaluation after a parent switch, allowing MMP metrics to stabilize
- **Periodic re-evaluation** (`reeval_interval_secs = 60`): catches link
degradation after tree stabilization independent of TreeAnnounce traffic
Remaining gaps:
- No hold timer on parent changes (minimum time before next switch)
- No sequence number advancement rate limiting
- No announcement suppression during transient topology changes
- No minimum stable state duration before re-announcing
**Impact**: Rapid topology changes (e.g., a flapping link) could cause
excessive announcement traffic and repeated coordinate recomputation.
Currently mitigated by per-peer rate limiting on TreeAnnounce sends, but
the source node is not throttled.
**Proposed fix**: Add a hold-down timer (e.g., 5-10s) after each parent
change during which further parent switches are suppressed unless the current
parent is lost entirely. Track announcement rate and suppress if exceeding a
threshold.
**Impact**: A rapidly flapping link could still cause moderate announcement
traffic. The hold-down timer limits the rate of parent switches (at most
one non-mandatory switch per 30s), and per-peer rate limiting (500ms)
bounds announcement frequency, but the source node's announcement rate is
not independently throttled.
### Known Limitation: Integration Test Gaps
@@ -1123,7 +1139,7 @@ The gossip-based spanning tree protocol achieves distributed coordination
through:
1. **Deterministic root election** - Smallest node_addr, no negotiation needed
2. **Local parent selection** - Each node independently chooses best path to root
2. **Cost-aware parent selection** - Each node independently chooses lowest effective depth to root using local link metrics
3. **CRDT merge semantics** - Conflicts resolved by sequence number, then timestamp
4. **Bounded state** - O(peers × depth) entries per node, not O(network size)
5. **Depth-proportional convergence** - Scales with tree height, not node count
+26 -5
View File
@@ -190,23 +190,44 @@ pub struct TreeConfig {
/// Per-peer TreeAnnounce rate limit in ms (`node.tree.announce_min_interval_ms`).
#[serde(default = "TreeConfig::default_announce_min_interval_ms")]
pub announce_min_interval_ms: u64,
/// Min depth improvement to switch parents (`node.tree.parent_switch_threshold`).
#[serde(default = "TreeConfig::default_parent_switch_threshold")]
pub parent_switch_threshold: usize,
/// Hysteresis factor for cost-based parent re-selection (`node.tree.parent_hysteresis`).
///
/// Only switch parents when the candidate's effective_depth is better than
/// `current_effective_depth * (1.0 - parent_hysteresis)`. Range: 0.0-1.0.
/// Set to 0.0 to disable hysteresis (switch on any improvement).
#[serde(default = "TreeConfig::default_parent_hysteresis")]
pub parent_hysteresis: f64,
/// Hold-down period after parent switch in seconds (`node.tree.hold_down_secs`).
///
/// After switching parents, suppress re-evaluation for this duration to allow
/// MMP metrics to stabilize on the new link. Set to 0 to disable.
#[serde(default = "TreeConfig::default_hold_down_secs")]
pub hold_down_secs: u64,
/// Periodic parent re-evaluation interval in seconds (`node.tree.reeval_interval_secs`).
///
/// How often to re-evaluate parent selection based on current MMP link costs,
/// independent of TreeAnnounce traffic. Catches link degradation after the
/// tree has stabilized. Set to 0 to disable.
#[serde(default = "TreeConfig::default_reeval_interval_secs")]
pub reeval_interval_secs: u64,
}
impl Default for TreeConfig {
fn default() -> Self {
Self {
announce_min_interval_ms: 500,
parent_switch_threshold: 1,
parent_hysteresis: 0.2,
hold_down_secs: 30,
reeval_interval_secs: 60,
}
}
}
impl TreeConfig {
fn default_announce_min_interval_ms() -> u64 { 500 }
fn default_parent_switch_threshold() -> usize { 1 }
fn default_parent_hysteresis() -> f64 { 0.2 }
fn default_hold_down_secs() -> u64 { 30 }
fn default_reeval_interval_secs() -> u64 { 60 }
}
/// Bloom filter (`node.bloom.*`).
+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) {
+15 -4
View File
@@ -470,11 +470,22 @@ impl ActivePeer {
/// Link cost for routing decisions.
///
/// Returns a scalar cost where lower is better. Currently returns a
/// constant (all links equal). Future versions will compute from RTT,
/// loss rate, and throughput measurements.
/// Returns a scalar cost where lower is better (1.0 = ideal).
/// Computed as RTT-weighted ETX: `etx * (1.0 + srtt_ms / 100.0)`.
///
/// Returns 1.0 (optimistic default) when MMP metrics are not yet
/// available, matching depth-only parent selection behavior.
pub fn link_cost(&self) -> f64 {
1.0
match self.mmp() {
Some(mmp) => {
let etx = mmp.metrics.etx;
match mmp.metrics.srtt_ms() {
Some(srtt_ms) => etx * (1.0 + srtt_ms / 100.0),
None => 1.0,
}
}
None => 1.0,
}
}
/// When this peer was authenticated.
+64 -25
View File
@@ -2,6 +2,7 @@
use std::collections::HashMap;
use std::fmt;
use std::time::{Duration, Instant};
use super::{CoordEntry, ParentDeclaration, TreeCoordinate, TreeError};
use crate::{Identity, NodeAddr};
@@ -24,8 +25,12 @@ pub struct TreeState {
peer_declarations: HashMap<NodeAddr, ParentDeclaration>,
/// Each peer's full ancestry to root.
peer_ancestry: HashMap<NodeAddr, TreeCoordinate>,
/// Minimum depth improvement required to switch parents (same root).
parent_switch_threshold: usize,
/// Hysteresis factor for cost-based parent re-selection (0.0-1.0).
parent_hysteresis: f64,
/// Hold-down period after parent switch (0 = disabled).
hold_down: Duration,
/// Timestamp of last parent switch (for hold-down enforcement).
last_parent_switch: Option<Instant>,
}
impl TreeState {
@@ -48,7 +53,9 @@ impl TreeState {
root: my_node_addr,
peer_declarations: HashMap::new(),
peer_ancestry: HashMap::new(),
parent_switch_threshold: 1,
parent_hysteresis: 0.0,
hold_down: Duration::ZERO,
last_parent_switch: None,
}
}
@@ -130,6 +137,7 @@ impl TreeState {
/// Call this when switching parents. Updates the declaration and coordinates.
pub fn set_parent(&mut self, parent_id: NodeAddr, sequence: u64, timestamp: u64) {
self.my_declaration = ParentDeclaration::new(self.my_node_addr, parent_id, sequence, timestamp);
self.last_parent_switch = Some(Instant::now());
// Coordinates will be recomputed when ancestry is available
}
@@ -209,18 +217,25 @@ impl TreeState {
}
}
/// Set the parent switch threshold.
pub fn set_parent_switch_threshold(&mut self, threshold: usize) {
self.parent_switch_threshold = threshold;
/// 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.hold_down = Duration::from_secs(secs);
}
/// Evaluate whether to switch parents based on current peer tree state.
///
/// v1 algorithm: depth-based, no latency/loss metrics.
/// 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.
pub fn evaluate_parent(&self) -> Option<NodeAddr> {
pub fn evaluate_parent(&self, peer_costs: &HashMap<NodeAddr, f64>) -> Option<NodeAddr> {
if self.peer_ancestry.is_empty() {
return None;
}
@@ -248,30 +263,36 @@ impl TreeState {
return None;
}
// Among peers that reach the smallest root, find the shallowest
let mut best_peer: Option<(NodeAddr, usize)> = None; // (peer_addr, depth)
// 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;
}
let depth = coords.depth();
let cost = peer_costs.get(peer_id).copied().unwrap_or(1.0);
let eff_depth = coords.depth() as f64 + cost;
match &best_peer {
None => best_peer = Some((*peer_id, depth)),
Some((_, best_depth)) => {
if depth < *best_depth {
best_peer = Some((*peer_id, depth));
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_depth) = best_peer?;
let (best_peer_id, best_eff_depth) = best_peer?;
// If already using this peer as parent, no switch needed
if *self.my_declaration.parent_id() == best_peer_id && !self.is_root() {
return None;
}
// --- Mandatory switches (bypass hold-down and hysteresis) ---
// 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 Some(best_peer_id);
@@ -282,18 +303,36 @@ impl TreeState {
return Some(best_peer_id);
}
// Same root: require depth improvement ≥ threshold
if self.is_root() {
// We're root but shouldn't be (peers have a smaller root) — always switch
if self.is_root() {
return Some(best_peer_id);
}
// Compare depth: our current depth vs what we'd get through best_peer
// Our new depth would be best_depth + 1
let current_depth = self.my_coords.depth();
let proposed_depth = best_depth + 1;
// --- Hold-down: suppress non-mandatory re-evaluation after recent switch ---
if current_depth >= proposed_depth + self.parent_switch_threshold {
if !self.hold_down.is_zero()
&& self
.last_parent_switch
.is_some_and(|last| last.elapsed() < self.hold_down)
{
return None;
}
// --- Same root, cost-aware comparison with hysteresis ---
// Current parent's effective_depth
let current_parent_cost = peer_costs
.get(self.my_declaration.parent_id())
.copied()
.unwrap_or(1.0);
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
};
// 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);
}
@@ -306,9 +345,9 @@ impl TreeState {
/// If none available, becomes its own root (increments sequence).
///
/// Returns `true` if the tree state changed (caller should re-announce).
pub fn handle_parent_lost(&mut self) -> bool {
pub fn handle_parent_lost(&mut self, peer_costs: &HashMap<NodeAddr, f64>) -> bool {
// Try to find an alternative parent
if let Some(new_parent) = self.evaluate_parent() {
if let Some(new_parent) = self.evaluate_parent(peer_costs) {
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
+430 -8
View File
@@ -1,3 +1,5 @@
use std::collections::HashMap;
use super::*;
fn make_node_addr(val: u8) -> NodeAddr {
@@ -378,7 +380,7 @@ fn test_evaluate_parent_picks_smallest_root() {
make_coords(&[7, 2]),
);
let result = state.evaluate_parent();
let result = state.evaluate_parent(&HashMap::new());
assert_eq!(result, Some(peer3));
}
@@ -404,7 +406,7 @@ fn test_evaluate_parent_prefers_shallowest_depth() {
make_coords(&[2, 3, 4, 0]),
);
let result = state.evaluate_parent();
let result = state.evaluate_parent(&HashMap::new());
assert_eq!(result, Some(peer1));
}
@@ -421,7 +423,7 @@ fn test_evaluate_parent_stays_root_when_smallest() {
make_coords(&[1, 0]),
);
assert_eq!(state.evaluate_parent(), None);
assert_eq!(state.evaluate_parent(&HashMap::new()), None);
}
#[test]
@@ -443,7 +445,7 @@ fn test_evaluate_parent_no_switch_when_already_best() {
state.recompute_coords();
// Now evaluate — should return None since peer1 is already our parent
assert_eq!(state.evaluate_parent(), None);
assert_eq!(state.evaluate_parent(&HashMap::new()), None);
}
#[test]
@@ -451,7 +453,7 @@ fn test_evaluate_parent_no_peers() {
let my_node = make_node_addr(5);
let state = TreeState::new(my_node);
assert_eq!(state.evaluate_parent(), None);
assert_eq!(state.evaluate_parent(&HashMap::new()), None);
}
#[test]
@@ -484,7 +486,7 @@ fn test_evaluate_parent_depth_threshold() {
make_coords(&[3, 0]),
);
let result = state.evaluate_parent();
let result = state.evaluate_parent(&HashMap::new());
assert_eq!(result, Some(peer3));
}
@@ -512,7 +514,7 @@ fn test_handle_parent_lost_finds_alternative() {
// Remove peer1 (parent lost)
state.remove_peer(&peer1);
let changed = state.handle_parent_lost();
let changed = state.handle_parent_lost(&HashMap::new());
assert!(changed);
// Should have switched to peer2
@@ -540,7 +542,7 @@ fn test_handle_parent_lost_becomes_root() {
// Remove peer1 (only parent)
state.remove_peer(&peer1);
let changed = state.handle_parent_lost();
let changed = state.handle_parent_lost(&HashMap::new());
assert!(changed);
assert!(state.is_root());
@@ -685,3 +687,423 @@ fn test_find_next_hop_best_of_multiple() {
let dest = make_coords(&[7, 3, 1, 0]);
assert_eq!(state.find_next_hop(&dest), Some(make_node_addr(3)));
}
// === Cost-based parent selection tests ===
/// Build a peer_costs map from (addr_byte, cost) pairs.
fn make_costs(entries: &[(u8, f64)]) -> HashMap<NodeAddr, f64> {
entries
.iter()
.map(|&(addr, cost)| (make_node_addr(addr), cost))
.collect()
}
#[test]
fn test_effective_depth_selects_lower_cost_deeper_peer() {
// Peer A at depth 1 with high cost (LoRa), peer B at depth 2 with low cost (fiber).
// effective_depth(A) = 1 + 6.0 = 7.0
// effective_depth(B) = 2 + 1.01 = 3.01
// Should select B despite being deeper.
let my_node = make_node_addr(5);
let mut state = TreeState::new(my_node);
let peer_a = make_node_addr(1);
let peer_b = make_node_addr(2);
let root = make_node_addr(0);
// Peer A: depth 1
state.update_peer(
ParentDeclaration::new(peer_a, root, 1, 1000),
make_coords(&[1, 0]),
);
// Peer B: depth 2
state.update_peer(
ParentDeclaration::new(peer_b, make_node_addr(3), 1, 1000),
make_coords(&[2, 3, 0]),
);
let costs = make_costs(&[(1, 6.0), (2, 1.01)]);
let result = state.evaluate_parent(&costs);
assert_eq!(result, Some(peer_b));
}
#[test]
fn test_effective_depth_equal_cost_degenerates_to_depth() {
// Both peers at cost 1.0 (default). Should pick shallowest, same as v1.
let my_node = make_node_addr(5);
let mut state = TreeState::new(my_node);
let peer1 = make_node_addr(1);
let peer2 = make_node_addr(2);
let root = make_node_addr(0);
// Peer 1: depth 1
state.update_peer(
ParentDeclaration::new(peer1, root, 1, 1000),
make_coords(&[1, 0]),
);
// Peer 2: depth 3
state.update_peer(
ParentDeclaration::new(peer2, make_node_addr(3), 1, 1000),
make_coords(&[2, 3, 4, 0]),
);
let costs = make_costs(&[(1, 1.0), (2, 1.0)]);
let result = state.evaluate_parent(&costs);
assert_eq!(result, Some(peer1));
}
#[test]
fn test_effective_depth_tiebreak_by_node_addr() {
// Two peers with identical effective_depth. Smaller NodeAddr wins.
let my_node = make_node_addr(5);
let mut state = TreeState::new(my_node);
let peer1 = make_node_addr(1);
let peer2 = make_node_addr(2);
let root = make_node_addr(0);
// Both at depth 1, cost 1.0 → effective_depth 2.0
state.update_peer(
ParentDeclaration::new(peer1, root, 1, 1000),
make_coords(&[1, 0]),
);
state.update_peer(
ParentDeclaration::new(peer2, root, 1, 1000),
make_coords(&[2, 0]),
);
let costs = make_costs(&[(1, 1.0), (2, 1.0)]);
let result = state.evaluate_parent(&costs);
assert_eq!(result, Some(peer1)); // smaller NodeAddr
}
#[test]
fn test_hysteresis_prevents_marginal_switch() {
// Current parent eff_depth 3.5, candidate 3.2.
// With 20% hysteresis, threshold = 3.5 * 0.8 = 2.8.
// 3.2 > 2.8, so no switch.
let my_node = make_node_addr(5);
let mut state = TreeState::new(my_node);
state.set_parent_hysteresis(0.2);
let peer_a = make_node_addr(1); // current parent
let peer_b = make_node_addr(2); // candidate
let root = make_node_addr(0);
// Peer A: depth 1, cost 2.5 → eff 3.5
state.update_peer(
ParentDeclaration::new(peer_a, root, 1, 1000),
make_coords(&[1, 0]),
);
// Peer B: depth 1, cost 2.2 → eff 3.2
state.update_peer(
ParentDeclaration::new(peer_b, root, 1, 1000),
make_coords(&[2, 0]),
);
// Set peer_a as current parent
state.set_parent(peer_a, 1, 1000);
state.recompute_coords();
let costs = make_costs(&[(1, 2.5), (2, 2.2)]);
let result = state.evaluate_parent(&costs);
assert_eq!(result, None); // marginal improvement blocked by hysteresis
}
#[test]
fn test_hysteresis_allows_significant_switch() {
// Current parent eff_depth 7.0, candidate 3.01.
// With 20% hysteresis, threshold = 7.0 * 0.8 = 5.6.
// 3.01 < 5.6, so switch occurs.
let my_node = make_node_addr(5);
let mut state = TreeState::new(my_node);
state.set_parent_hysteresis(0.2);
let peer_a = make_node_addr(1); // current parent (LoRa)
let peer_b = make_node_addr(2); // candidate (fiber)
let root = make_node_addr(0);
// Peer A: depth 1, cost 6.0 → eff 7.0
state.update_peer(
ParentDeclaration::new(peer_a, root, 1, 1000),
make_coords(&[1, 0]),
);
// Peer B: depth 2, cost 1.01 → eff 3.01
state.update_peer(
ParentDeclaration::new(peer_b, make_node_addr(3), 1, 1000),
make_coords(&[2, 3, 0]),
);
// Set peer_a as current parent
state.set_parent(peer_a, 1, 1000);
state.recompute_coords();
let costs = make_costs(&[(1, 6.0), (2, 1.01)]);
let result = state.evaluate_parent(&costs);
assert_eq!(result, Some(peer_b));
}
#[test]
fn test_cold_start_default_cost() {
// Peer with no cost entry in map gets default 1.0.
// This degenerates to depth-only selection.
let my_node = make_node_addr(5);
let mut state = TreeState::new(my_node);
let peer1 = make_node_addr(1);
let peer2 = make_node_addr(2);
let root = make_node_addr(0);
// Peer 1: depth 1, peer 2: depth 3
state.update_peer(
ParentDeclaration::new(peer1, root, 1, 1000),
make_coords(&[1, 0]),
);
state.update_peer(
ParentDeclaration::new(peer2, make_node_addr(3), 1, 1000),
make_coords(&[2, 3, 4, 0]),
);
// Empty cost map — all peers get default 1.0
let result = state.evaluate_parent(&HashMap::new());
assert_eq!(result, Some(peer1)); // shallowest wins
}
#[test]
fn test_hold_down_suppresses_reeval() {
// After a parent switch, re-evaluation returns None during hold-down.
let my_node = make_node_addr(5);
let mut state = TreeState::new(my_node);
state.set_hold_down(60); // 60s hold-down
let peer_a = make_node_addr(1);
let peer_b = make_node_addr(2);
let root = make_node_addr(0);
state.update_peer(
ParentDeclaration::new(peer_a, root, 1, 1000),
make_coords(&[1, 0]),
);
state.update_peer(
ParentDeclaration::new(peer_b, root, 1, 1000),
make_coords(&[2, 0]),
);
// Switch to peer_a (sets last_parent_switch)
state.set_parent(peer_a, 1, 1000);
state.recompute_coords();
// 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);
assert_eq!(result, None); // suppressed by hold-down
}
#[test]
fn test_mandatory_switch_bypasses_hold_down() {
// Parent loss during hold-down still triggers switch.
let my_node = make_node_addr(5);
let mut state = TreeState::new(my_node);
state.set_hold_down(60); // 60s hold-down
let peer_a = make_node_addr(1);
let peer_b = make_node_addr(2);
let root = make_node_addr(0);
state.update_peer(
ParentDeclaration::new(peer_a, root, 1, 1000),
make_coords(&[1, 0]),
);
state.update_peer(
ParentDeclaration::new(peer_b, root, 1, 1000),
make_coords(&[2, 0]),
);
// Switch to peer_a
state.set_parent(peer_a, 1, 1000);
state.recompute_coords();
// Remove peer_a (parent lost) — should bypass hold-down
state.remove_peer(&peer_a);
let result = state.evaluate_parent(&HashMap::new());
assert_eq!(result, Some(peer_b)); // mandatory switch
}
#[test]
fn test_heterogeneous_7node_avoids_bottleneck() {
// 7-node topology simulating a mixed fiber/LoRa network:
//
// 0 (root)
// ├── 1 (fiber, cost 1.01) — depth 1
// │ ├── 3 (fiber, cost 1.01) — depth 2
// │ └── 4 (fiber, cost 1.01) — depth 2
// ├── 2 (LoRa, cost 6.0) — depth 1
// │ └── 5 (fiber, cost 1.01) — depth 2 (inherits LoRa bottleneck!)
// └── 6 (wifi, cost 1.07) — depth 1
//
// Node 5 is connected to both node 2 (LoRa parent, depth 1) and
// node 1 (fiber, depth 1). Without cost-awareness, node 5 could
// pick node 2 as parent (both at depth 1, tiebreak by addr).
// With cost-awareness, node 5 should pick node 1 (eff 2.01) over
// node 2 (eff 7.0).
let root = make_node_addr(0);
// Test from node 5's perspective
let my_node = make_node_addr(5);
let mut state = TreeState::new(my_node);
let peer1 = make_node_addr(1); // fiber peer at depth 1
let peer2 = make_node_addr(2); // LoRa peer at depth 1
// Both peers reach root 0 at depth 1
state.update_peer(
ParentDeclaration::new(peer1, root, 1, 1000),
make_coords(&[1, 0]),
);
state.update_peer(
ParentDeclaration::new(peer2, root, 1, 1000),
make_coords(&[2, 0]),
);
// Without costs (all 1.0): picks peer 1 (smaller addr) — correct by luck
let result_no_cost = state.evaluate_parent(&HashMap::new());
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);
assert_eq!(result_with_cost, Some(peer1));
// Now test the critical case: node 5 currently has LoRa parent (peer 2).
// Even without hysteresis, it should want to switch to fiber (peer 1).
state.set_parent(peer2, 1, 1000);
state.recompute_coords();
assert_eq!(state.my_coords().depth(), 2); // depth 2 through LoRa peer
let result_switch = state.evaluate_parent(&costs);
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);
assert_eq!(result_hyst, Some(peer1));
}
// =====================================================================
// Cost degradation tests (periodic re-evaluation scenarios)
// =====================================================================
//
// These test evaluate_parent() with changing cost maps, validating the
// scenarios that periodic re-evaluation is designed to catch: link
// quality changes after the tree has stabilized.
#[test]
fn test_cost_degradation_triggers_switch() {
// Node 5 has two peers at depth 1. Initially both have similar costs
// (both fiber). After stabilization, peer A's link degrades (becomes
// LoRa-like). Re-evaluation with updated costs should trigger a switch.
let my_node = make_node_addr(5);
let mut state = TreeState::new(my_node);
state.set_parent_hysteresis(0.2);
let peer_a = make_node_addr(1);
let peer_b = make_node_addr(2);
let root = make_node_addr(0);
state.update_peer(
ParentDeclaration::new(peer_a, root, 1, 1000),
make_coords(&[1, 0]),
);
state.update_peer(
ParentDeclaration::new(peer_b, root, 1, 1000),
make_coords(&[2, 0]),
);
// 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);
assert_eq!(result, Some(peer_a));
state.set_parent(peer_a, 1, 1000);
state.recompute_coords();
// Verify stable: no switch with same costs
let result = state.evaluate_parent(&initial_costs);
assert_eq!(result, None);
// Peer A's link degrades significantly (LoRa-like latency + loss)
// current_parent_eff = 1 + 6.0 = 7.0
// 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);
assert_eq!(result, Some(peer_b));
}
#[test]
fn test_cost_improvement_within_hysteresis_no_switch() {
// Node 5 has parent peer_a. Peer_b's cost improves slightly but
// stays within the hysteresis band. Re-evaluation should not switch.
let my_node = make_node_addr(5);
let mut state = TreeState::new(my_node);
state.set_parent_hysteresis(0.2);
let peer_a = make_node_addr(1);
let peer_b = make_node_addr(2);
let root = make_node_addr(0);
state.update_peer(
ParentDeclaration::new(peer_a, root, 1, 1000),
make_coords(&[1, 0]),
);
state.update_peer(
ParentDeclaration::new(peer_b, root, 1, 1000),
make_coords(&[2, 0]),
);
state.set_parent(peer_a, 1, 1000);
state.recompute_coords();
// Peer B slightly better: cost 1.5 vs peer A cost 2.0
// current_parent_eff = 1 + 2.0 = 3.0
// 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);
assert_eq!(result, None);
}
#[test]
fn test_single_peer_no_reeval_benefit() {
// With only one peer, evaluate_parent should select it initially,
// but once it's our parent, re-evaluation returns None regardless
// of cost changes (no alternative exists).
let my_node = make_node_addr(5);
let mut state = TreeState::new(my_node);
let peer_a = make_node_addr(1);
let root = make_node_addr(0);
state.update_peer(
ParentDeclaration::new(peer_a, root, 1, 1000),
make_coords(&[1, 0]),
);
// Initial selection: picks the only peer
let costs = make_costs(&[(1, 1.05)]);
let result = state.evaluate_parent(&costs);
assert_eq!(result, Some(peer_a));
state.set_parent(peer_a, 1, 1000);
state.recompute_coords();
// Even with terrible cost, no switch (no alternative)
let bad_costs = make_costs(&[(1, 50.0)]);
let result = state.evaluate_parent(&bad_costs);
assert_eq!(result, None);
}
@@ -0,0 +1,91 @@
# Bottleneck Parent: 10-node focused LoRa bottleneck test
#
# Explicit topology with two LoRa links that create bottleneck parent
# candidates. Tests that nodes avoid choosing LoRa parents when fiber
# alternatives exist at the same or slightly greater depth.
#
# Topology:
#
# n01 (root)
# / | \ \
# f f f f
# / | \ \
# n02 n03 n04 n05
# | / | \ |
# LoRa f f LoRa
# | / | \ |
# n06 n07 n08 n09
# |
# f
# |
# n10
#
# Edges and link types:
# Fiber: n01-n02, n01-n03, n01-n04, n01-n05,
# n03-n06, n03-n07, n04-n08, n08-n10
# LoRa: n02-n06, n05-n09
#
# Cross-links: n03-n08 (fiber) — gives n08 a fiber alternative to n04
#
# Test subjects:
# - n06 has LoRa (n02, depth 1) and fiber (n03, depth 1) — should pick n03
# - n09 has only LoRa (n05) — no alternative, stuck with LoRa parent
scenario:
name: "bottleneck-parent"
seed: 42
duration_secs: 120
topology:
algorithm: explicit
num_nodes: 10
params:
adjacency:
- [n01, n02]
- [n01, n03]
- [n01, n04]
- [n01, n05]
- [n02, n06]
- [n03, n06]
- [n03, n07]
- [n03, n08]
- [n04, n08]
- [n05, n09]
- [n08, n10]
subnet: "172.20.0.0/24"
ip_start: 10
netem:
enabled: true
default_policy:
delay_ms: [1, 5]
jitter_ms: [0, 1]
loss_pct: [0, 0.5]
link_policies:
# LoRa links (high delay, moderate loss)
- edges: ["n02-n06", "n05-n09"]
policy:
delay_ms: [400, 600]
jitter_ms: [50, 100]
loss_pct: [5, 15]
mutation:
interval_secs: {min: 30, max: 60}
fraction: 0.2
policies:
normal:
delay_ms: [1, 5]
loss_pct: [0, 0.5]
link_flaps:
enabled: false
traffic:
enabled: true
max_concurrent: 2
interval_secs: {min: 15, max: 30}
duration_secs: {min: 5, max: 10}
parallel_streams: 2
logging:
rust_log: "info"
output_dir: "./sim-results"
@@ -0,0 +1,66 @@
# Cost-Based Parent Selection: Bottleneck Avoidance Test
#
# Topology (explicit 4-node diamond):
#
# n01 (root — smallest addr)
# / \
# fiber fiber
# / \
# n02 n03
# \ /
# LoRa fiber
# \ /
# n04 (test subject)
#
# n04 has two candidate parents at depth 1: n02 (via LoRa) and n03 (via
# fiber). Cost-based selection should pick n03 because:
# effective_depth(n02) = 1 + ~6.0 (LoRa) = ~7.0
# effective_depth(n03) = 1 + ~1.01 (fiber) = ~2.01
#
# Validation: tree snapshot shows n04's parent is n03.
scenario:
name: "cost-avoidance"
seed: 42
duration_secs: 120
topology:
algorithm: explicit
num_nodes: 4
params:
adjacency:
- [n01, n02]
- [n01, n03]
- [n02, n04]
- [n03, n04]
subnet: "172.20.0.0/24"
ip_start: 10
netem:
enabled: true
default_policy:
# Fiber-like
delay_ms: [1, 5]
jitter_ms: [0, 1]
loss_pct: [0, 0.5]
link_policies:
# LoRa link from n02 to n04
- edges: ["n02-n04"]
policy:
delay_ms: [400, 600]
jitter_ms: [50, 100]
loss_pct: [5, 15]
link_flaps:
enabled: false
traffic:
enabled: true
max_concurrent: 2
interval_secs: {min: 15, max: 30}
duration_secs: {min: 5, max: 10}
parallel_streams: 2
logging:
rust_log: "info"
output_dir: "./sim-results"
@@ -0,0 +1,78 @@
# Cost-Based Parent Selection: Mixed Technology 7-Node Test
#
# Topology (explicit, multiple link types):
#
# n01 (root)
# / | \
# fiber | fiber LoRa
# / | \
# n02 n03 n04
# | | \ |
# fiber fiber \ fiber
# | | wifi |
# n05 n06 n07
#
# Cross-links: n03-n05 (fiber), n04-n06 (wifi)
#
# Test subjects:
# - n06 has edges to n03 (fiber) and n04 (wifi) — should prefer n03
# - n04's link to root is LoRa, so n04 has high-cost parent link
# - n07 connects only to n04 (no choice, stuck with LoRa upstream)
#
# Validation: tree snapshot shows n06's parent is n03 (not n04).
scenario:
name: "cost-mixed-7node"
seed: 42
duration_secs: 180
topology:
algorithm: explicit
num_nodes: 7
params:
adjacency:
- [n01, n02]
- [n01, n03]
- [n01, n04]
- [n02, n05]
- [n03, n06]
- [n04, n07]
- [n03, n05]
- [n04, n06]
subnet: "172.20.0.0/24"
ip_start: 10
netem:
enabled: true
default_policy:
# Fiber-like
delay_ms: [1, 5]
jitter_ms: [0, 1]
loss_pct: [0, 0.5]
link_policies:
# LoRa link from n01 to n04
- edges: ["n01-n04"]
policy:
delay_ms: [400, 600]
jitter_ms: [50, 100]
loss_pct: [5, 15]
# WiFi link from n04 to n06
- edges: ["n04-n06"]
policy:
delay_ms: [5, 20]
jitter_ms: [2, 5]
loss_pct: [1, 3]
link_flaps:
enabled: false
traffic:
enabled: true
max_concurrent: 3
interval_secs: {min: 10, max: 25}
duration_secs: {min: 5, max: 15}
parallel_streams: 4
logging:
rust_log: "info"
output_dir: "./sim-results"
+86
View File
@@ -0,0 +1,86 @@
# Periodic Cost Re-evaluation Test
#
# Topology (explicit 4-node diamond):
#
# n01 (root)
# / \
# fiber fiber
# / \
# n02 n03
# \ /
# fiber fiber
# \ /
# n04 (test subject)
#
# Initial state: All links are fiber. n04 has two candidate parents at
# depth 1: n02 and n03. Both have identical costs (~1.01), so n04 picks
# n02 (smaller NodeAddr, tiebreak rule).
#
# Mutation: Stochastic netem mutation with a single "degraded" policy
# (LoRa-like: 400-600ms delay, 5-15% loss). With fraction=0.5, on
# average 2 of 4 edges degrade each round. Over 12 mutation rounds
# (180s / 15s interval), n02-n04 will be degraded in some rounds.
#
# Expected behavior: When n02-n04 is degraded and n03-n04 stays fiber
# (or vice versa), periodic re-evaluation detects the cost asymmetry
# and switches parents. Look for "trigger = periodic" in logs.
#
# FIPS overrides: reeval_interval_secs=15 (vs default 60) to increase
# the chance of catching a cost asymmetry within the mutation window.
#
# Validation: grep n04 logs for "Parent switched via periodic cost
# re-evaluation" (trigger=periodic). If mutation never creates enough
# asymmetry in a particular seed, the test still validates that periodic
# re-eval runs without interference.
scenario:
name: "cost-reeval"
seed: 42
duration_secs: 180
topology:
algorithm: explicit
num_nodes: 4
params:
adjacency:
- [n01, n02]
- [n01, n03]
- [n02, n04]
- [n03, n04]
subnet: "172.20.0.0/24"
ip_start: 10
netem:
enabled: true
default_policy:
# Fiber-like baseline
delay_ms: [1, 5]
jitter_ms: [0, 1]
loss_pct: [0, 0.5]
mutation:
interval_secs: {min: 12, max: 18}
fraction: 0.5
policies:
degraded:
delay_ms: [400, 600]
jitter_ms: [50, 100]
loss_pct: [5, 15]
link_flaps:
enabled: false
traffic:
enabled: true
max_concurrent: 1
interval_secs: {min: 15, max: 30}
duration_secs: {min: 5, max: 10}
parallel_streams: 2
logging:
rust_log: "info"
output_dir: "./sim-results"
fips_overrides:
node:
tree:
reeval_interval_secs: 15
@@ -0,0 +1,72 @@
# Cost-Based Parent Selection: Hysteresis Stability Test
#
# Topology (explicit 4-node diamond, symmetric):
#
# n01 (root)
# / \
# wifi wifi
# / \
# n02 n03
# \ /
# wifi wifi
# \ /
# n04 (test subject)
#
# All links are WiFi-like with similar characteristics. Aggressive
# netem mutation shifts link qualities every 10-20s, but the changes
# stay within the 20% hysteresis band. n04 should pick one parent
# and mostly stick with it — flapping indicates insufficient hysteresis.
#
# Validation: count "Parent switched" in n04 logs, expect <= 5 switches
# over the full 180s duration.
scenario:
name: "cost-stability"
seed: 42
duration_secs: 180
topology:
algorithm: explicit
num_nodes: 4
params:
adjacency:
- [n01, n02]
- [n01, n03]
- [n02, n04]
- [n03, n04]
subnet: "172.20.0.0/24"
ip_start: 10
netem:
enabled: true
default_policy:
# WiFi baseline
delay_ms: [5, 20]
jitter_ms: [2, 5]
loss_pct: [1, 3]
mutation:
interval_secs: {min: 10, max: 20}
fraction: 1.0
policies:
slightly_better:
delay_ms: [3, 8]
jitter_ms: [1, 3]
loss_pct: [0, 1]
slightly_worse:
delay_ms: [15, 25]
jitter_ms: [3, 8]
loss_pct: [2, 4]
link_flaps:
enabled: false
traffic:
enabled: true
max_concurrent: 2
interval_secs: {min: 15, max: 30}
duration_secs: {min: 5, max: 15}
parallel_streams: 2
logging:
rust_log: "info"
output_dir: "./sim-results"
@@ -0,0 +1,73 @@
# Cost-Based Parent Selection: Deeper Fiber Beats Shallow LoRa
#
# Topology (explicit 4-node):
#
# n01 (root)
# / \
# fiber LoRa
# / \
# n02 n04 (test subject)
# | /
# fiber fiber
# | /
# n03
#
# n04 has two candidate parents:
# - n01 (root, depth 0) via LoRa: effective_depth = 0 + ~6.0 = ~6.0
# - n03 (depth 2) via fiber: effective_depth = 2 + ~1.01 = ~3.01
#
# Without cost-based selection, n04 would pick n01 (depth 0 < depth 2).
# With cost-based selection, n04 should pick n03 (lower effective_depth
# despite being 2 levels deeper).
#
# This tests the core depth-vs-cost tradeoff: a much deeper fiber path
# should beat a direct LoRa link to root when the cost difference is
# large enough.
#
# Validation: tree snapshot shows n04's parent is n03, n04's depth is 3.
scenario:
name: "depth-vs-cost"
seed: 42
duration_secs: 120
topology:
algorithm: explicit
num_nodes: 4
params:
adjacency:
- [n01, n02]
- [n02, n03]
- [n03, n04]
- [n01, n04]
subnet: "172.20.0.0/24"
ip_start: 10
netem:
enabled: true
default_policy:
# Fiber-like
delay_ms: [1, 5]
jitter_ms: [0, 1]
loss_pct: [0, 0.5]
link_policies:
# LoRa link from n01 to n04
- edges: ["n01-n04"]
policy:
delay_ms: [400, 600]
jitter_ms: [50, 100]
loss_pct: [5, 15]
link_flaps:
enabled: false
traffic:
enabled: true
max_concurrent: 1
interval_secs: {min: 15, max: 30}
duration_secs: {min: 5, max: 10}
parallel_streams: 2
logging:
rust_log: "info"
output_dir: "./sim-results"
@@ -0,0 +1,103 @@
# Mixed Technology: 10-node heterogeneous network
#
# Explicit topology with LoRa, WiFi, and fiber links. Tests that
# cost-based parent selection produces a tree favoring low-cost paths
# when multiple link technologies coexist.
#
# Topology:
#
# n01 (root)
# / | \
# f f f
# / | \
# n02 n03 n04
# | \ | \ | \
# f LoRa f f LoRa f
# | \ | | \ |
# n05 n06 n07 n08 n09
# \ /
# wifi---wifi
# n10
#
# Edges and link types:
# Fiber: n01-n02, n01-n03, n01-n04, n02-n05, n03-n07, n04-n09
# LoRa: n02-n06, n04-n08
# WiFi: n03-n06, n08-n10
# Fiber: n03-n08, n06-n10
#
# Test subjects:
# - n06 has fiber (n03) and LoRa (n02) parents — should pick n03
# - n08 has fiber (n03) and LoRa (n04) parents — should pick n03
#
# Netem mutation shifts fiber-only links between normal and degraded.
scenario:
name: "mixed-technology"
seed: 42
duration_secs: 180
topology:
algorithm: explicit
num_nodes: 10
params:
adjacency:
- [n01, n02]
- [n01, n03]
- [n01, n04]
- [n02, n05]
- [n02, n06]
- [n03, n06]
- [n03, n07]
- [n03, n08]
- [n04, n08]
- [n04, n09]
- [n06, n10]
- [n08, n10]
subnet: "172.20.0.0/24"
ip_start: 10
netem:
enabled: true
default_policy:
# Fiber-like defaults
delay_ms: [1, 5]
jitter_ms: [0, 1]
loss_pct: [0, 0.5]
link_policies:
# LoRa links (high latency, moderate loss)
- edges: ["n02-n06", "n04-n08"]
policy:
delay_ms: [400, 600]
jitter_ms: [50, 100]
loss_pct: [5, 15]
# WiFi links (moderate latency, low loss)
- edges: ["n03-n06", "n08-n10"]
policy:
delay_ms: [5, 20]
jitter_ms: [2, 5]
loss_pct: [1, 3]
mutation:
interval_secs: {min: 30, max: 60}
fraction: 0.2
policies:
normal:
delay_ms: [1, 10]
loss_pct: [0, 1]
degraded:
delay_ms: [50, 100]
jitter_ms: [10, 30]
loss_pct: [3, 8]
link_flaps:
enabled: false
traffic:
enabled: true
max_concurrent: 3
interval_secs: {min: 10, max: 30}
duration_secs: {min: 5, max: 15}
parallel_streams: 4
logging:
rust_log: "info"
output_dir: "./sim-results"
+32 -3
View File
@@ -3,9 +3,23 @@
from __future__ import annotations
import os
from copy import deepcopy
import yaml
from .topology import SimTopology
def _deep_merge(base: dict, override: dict) -> dict:
"""Recursively merge override into base (override wins on conflicts)."""
result = deepcopy(base)
for key, value in override.items():
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
result[key] = _deep_merge(result[key], value)
else:
result[key] = deepcopy(value)
return result
# Path to the shared node config template
_TEMPLATE_PATH = os.path.join(
os.path.dirname(__file__), "..", "configs", "node.template.yaml"
@@ -41,7 +55,10 @@ def generate_peers_block(
def generate_node_config(
topology: SimTopology, node_id: str, outbound_peers: list[str]
topology: SimTopology,
node_id: str,
outbound_peers: list[str],
fips_overrides: dict | None = None,
) -> str:
"""Generate a complete FIPS config YAML for one node."""
template = _load_template()
@@ -54,6 +71,12 @@ def generate_node_config(
config = config.replace("{{NPUB}}", node.npub)
config = config.replace("{{NSEC}}", node.nsec)
config = config.replace("{{PEERS}}", peers_yaml)
if fips_overrides:
parsed = yaml.safe_load(config)
merged = _deep_merge(parsed, fips_overrides)
config = yaml.dump(merged, default_flow_style=False, sort_keys=False)
return config
@@ -67,13 +90,19 @@ def generate_npubs_env(topology: SimTopology) -> str:
return "\n".join(lines) + "\n"
def write_configs(topology: SimTopology, output_dir: str):
def write_configs(
topology: SimTopology,
output_dir: str,
fips_overrides: dict | None = None,
):
"""Write all node configs and npubs.env to the output directory."""
os.makedirs(output_dir, exist_ok=True)
outbound = topology.directed_outbound()
for node_id in topology.nodes:
config = generate_node_config(topology, node_id, outbound[node_id])
config = generate_node_config(
topology, node_id, outbound[node_id], fips_overrides
)
path = os.path.join(output_dir, f"{node_id}.yaml")
with open(path, "w") as f:
f.write(config)
+103
View File
@@ -0,0 +1,103 @@
"""Control socket querying via docker exec.
Queries FIPS nodes' control sockets to observe runtime state (tree
structure, MMP metrics, peers) without requiring fipsctl in the
container. Uses a Python one-liner inside docker exec since python3
is available in the Docker image.
"""
from __future__ import annotations
import json
import logging
from .docker_exec import docker_exec, docker_exec_quiet
from .topology import SimTopology
log = logging.getLogger(__name__)
# Default control socket path inside containers (XDG_RUNTIME_DIR is
# typically unset in Docker, so fips falls back to /tmp).
CONTROL_SOCKET = "/tmp/fips-control.sock"
def query_node(container: str, command: str, timeout: int = 10) -> dict | None:
"""Send a command to a node's control socket, return the data dict.
Returns None if the query fails (node down, socket not ready, etc.).
"""
# Python one-liner that connects to the Unix socket, sends the JSON
# command, and prints the response. Runs inside the container.
script = (
"import socket,json,sys; "
"s=socket.socket(socket.AF_UNIX,socket.SOCK_STREAM); "
f"s.connect('{CONTROL_SOCKET}'); "
f"s.sendall(json.dumps({{'command':'{command}'}}).encode()+b'\\n'); "
"s.shutdown(socket.SHUT_WR); "
"chunks=[]; "
"[chunks.append(d) for d in iter(lambda:s.recv(65536),b'')]; "
"print(b''.join(chunks).decode())"
)
stdout = docker_exec_quiet(container, f"python3 -c \"{script}\"", timeout=timeout)
if stdout is None:
return None
try:
response = json.loads(stdout.strip())
except json.JSONDecodeError as e:
log.warning("Invalid JSON from %s: %s", container, e)
return None
if response.get("status") != "ok":
msg = response.get("message", "unknown error")
log.warning("Control query %s on %s failed: %s", command, container, msg)
return None
return response.get("data", {})
def query_tree(container: str) -> dict | None:
"""Query a node's spanning tree state."""
return query_node(container, "show_tree")
def query_mmp(container: str) -> dict | None:
"""Query a node's MMP metrics."""
return query_node(container, "show_mmp")
def query_peers(container: str) -> dict | None:
"""Query a node's peer list with MMP metrics."""
return query_node(container, "show_peers")
def snapshot_all_trees(topology: SimTopology) -> dict[str, dict]:
"""Query show_tree on all nodes, return {node_id: tree_data}.
Nodes that fail to respond are omitted from the result.
"""
result = {}
for node_id in sorted(topology.nodes):
container = topology.container_name(node_id)
data = query_tree(container)
if data is not None:
result[node_id] = data
else:
log.warning("No tree data from %s", node_id)
return result
def snapshot_all_mmp(topology: SimTopology) -> dict[str, dict]:
"""Query show_mmp on all nodes, return {node_id: mmp_data}.
Nodes that fail to respond are omitted from the result.
"""
result = {}
for node_id in sorted(topology.nodes):
container = topology.container_name(node_id)
data = query_mmp(container)
if data is not None:
result[node_id] = data
else:
log.warning("No MMP data from %s", node_id)
return result
+33 -3
View File
@@ -17,7 +17,7 @@ import random
from dataclasses import dataclass, field
from .docker_exec import docker_exec_quiet, is_container_running
from .scenario import BandwidthConfig, NetemConfig, NetemPolicy
from .scenario import BandwidthConfig, LinkPolicyOverride, NetemConfig, NetemPolicy
from .topology import SimTopology
log = logging.getLogger(__name__)
@@ -91,12 +91,41 @@ class NetemManager:
rate = rng.choice(bandwidth.tiers_mbps)
self._edge_rates[(a, b)] = rate
self._edge_rates[(b, a)] = rate
# Per-edge policy overrides: canonical "nXX-nYY" -> NetemPolicy
# Build a set of canonical edge strings for validation
topo_edge_strs = {"-".join(sorted([a, b])) for a, b in topology.edges}
self._edge_overrides: dict[str, NetemPolicy] = {}
for override in config.link_policies:
policy = override.policy
if policy is None and override.policy_name:
policy = config.mutation.policies.get(override.policy_name)
if policy is None:
continue
for edge_str in override.edges:
if edge_str not in topo_edge_strs:
log.warning(
"link_policy edge %s not in topology — override ignored",
edge_str,
)
self._edge_overrides[edge_str] = policy
if self._edge_overrides:
log.info(
"Per-link policy overrides: %d edges",
len(self._edge_overrides),
)
def _htb_rate(self, node_id: str, peer_id: str) -> str:
"""Return the HTB rate string for a link direction."""
rate = self._edge_rates.get((node_id, peer_id), 0)
return f"{rate}mbit" if rate > 0 else "1gbit"
def _policy_for_edge(self, node_a: str, node_b: str) -> NetemPolicy:
"""Return the netem policy for an edge, checking overrides first."""
canonical = "-".join(sorted([node_a, node_b]))
if canonical in self._edge_overrides:
return self._edge_overrides[canonical]
return self.config.default_policy
def setup_initial(self):
"""Set up HTB qdiscs and initial netem on all containers."""
if self._edge_rates:
@@ -128,8 +157,9 @@ class NetemManager:
class_id = f"1:{idx}"
netem_handle = f"{idx + 10}:"
# Sample initial params from default policy
params = self._sample_policy(self.config.default_policy)
# Sample initial params (per-edge override or default policy)
policy = self._policy_for_edge(node_id, peer_id)
params = self._sample_policy(policy)
rate = self._htb_rate(node_id, peer_id)
rate_mbit = self._edge_rates.get((node_id, peer_id), 0)
+32 -2
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import json
import logging
import os
import random
@@ -11,6 +12,7 @@ import time
from .compose import generate_compose
from .config_gen import write_configs
from .control import snapshot_all_mmp, snapshot_all_trees
from .docker_exec import docker_compose
from .links import LinkManager
from .logs import AnalysisResult, analyze_logs, collect_logs, write_sim_metadata
@@ -108,7 +110,7 @@ class SimRunner:
config_dir = os.path.normpath(
os.path.join(docker_network_dir, "generated-configs", "sim")
)
write_configs(self.topology, config_dir)
write_configs(self.topology, config_dir, self.scenario.fips_overrides)
log.info("Wrote node configs to %s", config_dir)
# 3. Generate docker-compose.yml
@@ -153,6 +155,7 @@ class SimRunner:
wait = max(10, n) # Heuristic: ~1s per node, minimum 10s
log.info("Waiting %ds for mesh convergence...", wait)
self._sleep(wait)
self._take_snapshot("warmup")
def _simulation_loop(self):
"""Main event loop driving stochastic behavior."""
@@ -233,11 +236,14 @@ class SimRunner:
log.info("Restoring downed links...")
self.link_mgr.restore_all()
# Restore stopped nodes (needed for log collection)
# Restore stopped nodes (needed for snapshots and log collection)
if self.node_mgr:
log.info("Restoring stopped nodes...")
self.node_mgr.restore_all()
# Take final tree snapshot while nodes are still running
self._take_snapshot("final")
# Collect logs before stopping containers
container_names = [
self.topology.container_name(nid) for nid in sorted(self.topology.nodes)
@@ -273,6 +279,30 @@ class SimRunner:
return result
def _take_snapshot(self, label: str):
"""Query all nodes via control socket and save tree/MMP snapshots."""
if not self.topology:
return
log.info("Taking %s snapshot...", label)
tree_snap = snapshot_all_trees(self.topology)
mmp_snap = snapshot_all_mmp(self.topology)
tree_path = os.path.join(self.output_dir, f"tree-snapshot-{label}.json")
mmp_path = os.path.join(self.output_dir, f"mmp-snapshot-{label}.json")
os.makedirs(self.output_dir, exist_ok=True)
with open(tree_path, "w") as f:
json.dump(tree_snap, f, indent=2)
with open(mmp_path, "w") as f:
json.dump(mmp_snap, f, indent=2)
log.info(
"Snapshot %s: %d/%d tree, %d/%d mmp responses",
label,
len(tree_snap),
len(self.topology.nodes),
len(mmp_snap),
len(self.topology.nodes),
)
def _schedule_next(self, now: float, interval) -> float:
"""Schedule the next event using a Range interval."""
return now + self.rng.uniform(interval.min, interval.max)
+67 -1
View File
@@ -49,10 +49,25 @@ class NetemMutationConfig:
policies: dict[str, NetemPolicy] = field(default_factory=dict)
@dataclass
class LinkPolicyOverride:
"""Per-edge netem policy override.
Edges are specified as "nXX-nYY" strings in canonical form (sorted
alphabetically). Either ``policy`` (inline) or ``policy_name``
(reference to a named mutation policy) must be set, not both.
"""
edges: list[str] = field(default_factory=list)
policy: NetemPolicy | None = None
policy_name: str | None = None
@dataclass
class NetemConfig:
enabled: bool = False
default_policy: NetemPolicy = field(default_factory=NetemPolicy)
link_policies: list[LinkPolicyOverride] = field(default_factory=list)
mutation: NetemMutationConfig = field(default_factory=NetemMutationConfig)
@@ -115,6 +130,10 @@ class Scenario:
node_churn: NodeChurnConfig = field(default_factory=NodeChurnConfig)
bandwidth: BandwidthConfig = field(default_factory=BandwidthConfig)
logging: LoggingConfig = field(default_factory=LoggingConfig)
# Raw YAML dict appended to each generated FIPS node config.
# Allows scenarios to override any FIPS config parameter
# (e.g., node.tree.reeval_interval_secs).
fips_overrides: dict = field(default_factory=dict)
def _parse_range(data, name: str) -> Range:
@@ -173,6 +192,16 @@ def load_scenario(path: str) -> Scenario:
s.netem.enabled = nc.get("enabled", False)
if "default_policy" in nc:
s.netem.default_policy = _parse_netem_policy(nc["default_policy"])
if "link_policies" in nc:
for lp_data in nc["link_policies"]:
override = LinkPolicyOverride(
edges=lp_data.get("edges", []),
)
if "policy" in lp_data:
override.policy = _parse_netem_policy(lp_data["policy"])
if "policy_name" in lp_data:
override.policy_name = lp_data["policy_name"]
s.netem.link_policies.append(override)
if "mutation" in nc:
mc = nc["mutation"]
s.netem.mutation.interval_secs = _parse_range(
@@ -233,6 +262,9 @@ def load_scenario(path: str) -> Scenario:
s.logging.rust_log = lg.get("rust_log", "info")
s.logging.output_dir = lg.get("output_dir", "./sim-results")
# FIPS config overrides (raw YAML dict appended to node configs)
s.fips_overrides = raw.get("fips_overrides", {})
# Validation
_validate(s)
@@ -245,11 +277,45 @@ def _validate(s: Scenario):
raise ValueError("topology.num_nodes must be >= 2")
if s.topology.num_nodes > 250:
raise ValueError("topology.num_nodes must be <= 250 (subnet limit)")
if s.topology.algorithm not in ("random_geometric", "erdos_renyi", "chain"):
if s.topology.algorithm not in ("random_geometric", "erdos_renyi", "chain", "explicit"):
raise ValueError(f"Unknown topology algorithm: {s.topology.algorithm}")
if s.topology.algorithm == "explicit":
adj = s.topology.params.get("adjacency")
if not adj or not isinstance(adj, list):
raise ValueError("explicit topology requires params.adjacency list")
node_ids = set()
for i, pair in enumerate(adj):
if not isinstance(pair, (list, tuple)) or len(pair) != 2:
raise ValueError(
f"explicit adjacency[{i}]: expected [nodeA, nodeB], got {pair}"
)
node_ids.update(str(p) for p in pair)
if len(node_ids) != s.topology.num_nodes:
raise ValueError(
f"explicit adjacency references {len(node_ids)} nodes "
f"but num_nodes is {s.topology.num_nodes}"
)
if s.duration_secs < 1:
raise ValueError("duration_secs must be >= 1")
# Validate link_policies
for i, lp in enumerate(s.netem.link_policies):
if not lp.edges:
raise ValueError(f"netem.link_policies[{i}]: edges list is empty")
if lp.policy is not None and lp.policy_name is not None:
raise ValueError(
f"netem.link_policies[{i}]: specify policy or policy_name, not both"
)
if lp.policy is None and lp.policy_name is None:
raise ValueError(
f"netem.link_policies[{i}]: must specify policy or policy_name"
)
if lp.policy_name and lp.policy_name not in s.netem.mutation.policies:
raise ValueError(
f"netem.link_policies[{i}]: policy_name '{lp.policy_name}' "
f"not found in mutation.policies"
)
# Validate ranges
if s.netem.enabled and s.netem.mutation.policies:
s.netem.mutation.interval_secs.validate("netem.mutation.interval_secs")
+26
View File
@@ -130,6 +130,17 @@ def generate_topology(
elif config.algorithm == "erdos_renyi":
p = config.params.get("p", 0.3)
edges = _generate_erdos_renyi(node_ids, p, rng)
elif config.algorithm == "explicit":
adjacency = config.params.get("adjacency")
if not adjacency:
raise ValueError("explicit topology requires params.adjacency")
edges = _generate_explicit(adjacency)
# Validate all referenced nodes exist
for a, b in edges:
if a not in nodes:
raise ValueError(f"explicit adjacency references unknown node {a}")
if b not in nodes:
raise ValueError(f"explicit adjacency references unknown node {b}")
else:
raise ValueError(f"Unknown algorithm: {config.algorithm}")
@@ -212,6 +223,21 @@ def _generate_erdos_renyi(
return edges
def _generate_explicit(adjacency: list) -> set[tuple[str, str]]:
"""Build edges from an explicit adjacency list.
Each entry should be a 2-element list like ["n01", "n02"].
"""
edges = set()
for i, pair in enumerate(adjacency):
if not isinstance(pair, (list, tuple)) or len(pair) != 2:
raise ValueError(
f"explicit adjacency[{i}]: expected [nodeA, nodeB], got {pair}"
)
edges.add(_make_edge(str(pair[0]), str(pair[1])))
return edges
def _make_edge(a: str, b: str) -> tuple[str, str]:
"""Canonical edge representation (sorted)."""
return (min(a, b), max(a, b))