Implement FilterAnnounce send/receive, remove TTL/K-hop scoping

Add bloom filter reachability announcement protocol:
- FilterAnnounce encode/decode (wire format 0x20, 1035 bytes)
- node/bloom.rs: send/receive with debounce, split-horizon loop prevention
- Handler wiring: dispatch, tick, peer promotion/removal, cross-connection
- Five integration tests: 10-node, star, chain, ring, 100-node convergence

Remove TTL/K-hop mechanism from code and design docs after discovering
that per-entry TTL scoping is fundamentally incompatible with flat bloom
filter merge + regeneration architecture. Each node re-originates filters
with fresh TTL, making propagation unbounded regardless of TTL value.
Split-horizon remains the primary loop prevention mechanism.

Document spanning tree known limitations (v1) in spanning-tree-dynamics.md.

316 tests pass, clean build, zero warnings.
This commit is contained in:
Johnathan Corgan
2026-02-11 03:16:25 +00:00
parent 7bc5b21c3a
commit 5d7af5b478
11 changed files with 758 additions and 159 deletions

View File

@@ -156,7 +156,6 @@ Peer
│ // Bloom filter (inbound—what's reachable through them)
├── inbound_filter: BloomFilter
├── filter_sequence: u64
├── filter_ttl: u8
├── filter_received_at: Timestamp
├── pending_filter_update: bool // we owe them an update
@@ -246,9 +245,8 @@ BloomState
Stored on Peer:
- `inbound_filter`: what they advertise to us (4KB Bloom filter)
- `inbound_filter`: what they advertise to us (1KB Bloom filter)
- `filter_sequence`: freshness/dedup
- `filter_ttl`: remaining propagation hops
- `filter_received_at`: for staleness detection
### Computed (On-Demand)
@@ -259,11 +257,11 @@ Outgoing filter to peer Q is computed, not stored:
outbound_filter(Q) =
own_node_addr
leaf_dependents
{ entries from peer[P].inbound_filter for all P ≠ Q where filter_ttl > 0 }
{ entries from peer[P].inbound_filter for all P ≠ Q }
```
TTL is decremented on contributed entries. Recomputation is cheap (4KB filter,
7 hashes) so on-demand is preferred over cache invalidation complexity.
Recomputation is cheap (1KB filter, 5 hashes) so on-demand is preferred over
cache invalidation complexity.
---

View File

@@ -187,7 +187,6 @@ node's filter indicates which destinations are reachable through it.
```text
FilterAnnounce {
sequence: u64, // For freshness/deduplication
ttl: u8, // Remaining propagation hops
filter: BloomFilter, // Variable size based on size_class
}
@@ -220,9 +219,6 @@ Receivers can fold larger filters down to their preferred size.
**sequence**: Monotonic counter for this node's filter. Allows receivers to
detect stale or duplicate announcements.
**ttl**: Remaining propagation depth. Starts at K (typically 2), decremented
each hop. At TTL=0, entries are not propagated further.
**hash_count**: Number of hash functions used. v1 uses k=5, which is optimal
for 800-1,600 entries in a 1 KB filter.
@@ -244,10 +240,12 @@ A node's outgoing filter to peer Q contains:
1. This node's own node_addr
2. Node_ids of leaf-only dependents (nodes using this node as sole peer)
3. Entries from filters received from other peers (not Q) with TTL > 0
3. Entries merged from filters received from all other peers (not Q)
This creates K-hop reachability scope. With K=2, entries propagate ~4 hops
before TTL exhaustion.
This split-horizon merge (excluding the destination peer's own filter from
the computation) prevents a node's entries from being echoed back to it,
providing loop prevention. Filters propagate transitively through the
network without any hop limit.
### 3.4 Exchange Rules
@@ -273,10 +271,10 @@ Rate limiting:
```text
1. Store: peer_filters[P] = received.filter
2. If received.ttl > 0:
- Include entries in next announcement to other peers
- Decrement TTL for propagated entries
3. Recompute own outgoing filters if changed
2. Recompute outgoing filters for all other peers:
- For each peer Q (Q != P):
outgoing[Q] = merge(self_filter, peer_filters[all peers except Q])
- If outgoing[Q] changed, send FilterAnnounce to Q
```
### 3.5 Filter Expiration
@@ -435,7 +433,6 @@ handshake completion.
| ROOT_REFRESH_INTERVAL | 30 min | Root regenerates timestamp |
| ROOT_TIMEOUT | 60 min | Root declaration considered stale |
| TREE_ENTRY_TTL | 5-10 min | Individual entry expiration |
| FILTER_TTL_HOPS | 2 | Bloom filter propagation depth |
| ANNOUNCE_MIN_INTERVAL | 500 ms | Rate limit for announcements |
| LOOKUP_TTL | 8 | Discovery request propagation limit |
| LOOKUP_TIMEOUT | 5 sec | Time to wait for response |
@@ -575,10 +572,9 @@ Propagates Bloom filter reachability information.
│ ├────────┼──────────────────┼───────────┼───────────────────────────────┤ │
│ │ 0 │ msg_type │ 1 byte │ 0x11 │ │
│ │ 1 │ sequence │ 8 bytes │ u64 LE, monotonic counter │ │
│ │ 9 │ ttl │ 1 byte │ Remaining propagation hops │ │
│ │ 10 │ hash_count │ 1 byte │ Number of hash functions (5) │ │
│ │ 11 │ size_class 1 byte │ Filter size: 512 << class │ │
│ │ 12 │ filter_bits │ variable │ 512 << size_class bytes │ │
│ │ 9 │ hash_count │ 1 byte │ Number of hash functions (5) │ │
│ │ 10 │ size_class │ 1 byte │ Filter size: 512 << class │ │
│ │ 11 │ filter_bits │ variable │ 512 << size_class bytes │ │
│ └────────┴──────────────────┴───────────┴───────────────────────────────┘ │
│ │
│ Size classes (powers of 2 for foldability): │
@@ -587,8 +583,8 @@ Propagates Bloom filter reachability information.
│ 2 = 2,048 bytes (16,384 bits) - Reserved for future │
│ 3 = 4,096 bytes (32,768 bits) - Reserved for future │
│ │
│ v1 total payload: 1 + 8 + 1 + 1 + 1 + 1024 = 1036 bytes │
│ With link overhead: 1065 bytes │
│ v1 total payload: 1 + 8 + 1 + 1 + 1024 = 1035 bytes
│ With link overhead: 1064 bytes │
│ │
├─────────────────────────────────────────────────────────────────────────────┤
│ BLOOM FILTER STRUCTURE │
@@ -621,12 +617,11 @@ Propagates Bloom filter reachability information.
PLAINTEXT BYTES:
11 ← msg_type = FilterAnnounce
2A 00 00 00 00 00 00 00 ← sequence = 42
02 ← ttl = 2 (will propagate 2 more hops)
05 ← hash_count = 5
01 ← size_class = 1 (1 KB filter)
[1024 bytes of filter bits] ← Bloom filter
Total: 1036 bytes
Total: 1035 bytes
```
### A.3 LookupRequest (0x12)

View File

@@ -11,8 +11,8 @@ For spanning tree dynamics and convergence, see [spanning-tree-dynamics.md](span
FIPS routing combines three mechanisms:
1. **Bloom filters**: Fast reachability lookup for nearby destinations (within
K-hop scope)
1. **Bloom filters**: Fast reachability lookup for destinations reachable
through peers
2. **Discovery protocol**: Query-based lookup for distant destinations
3. **Greedy tree routing**: Coordinate-based forwarding using spanning tree
position
@@ -34,7 +34,7 @@ coordinates handle the latter.
| Scale | Nodes | Bloom Filter Role |
|-------|-------|-------------------|
| Small private network | 100-1,000 | Covers entire network |
| Modest public network | ~1,000,000 | Covers K-hop neighborhood |
| Modest public network | ~1,000,000 | Covers transitive peer neighborhood |
| Internet-scale | Billions | Out of scope (requires different architecture) |
The primary design target is networks up to ~1M nodes.
@@ -64,11 +64,10 @@ traffic tunnels through that peer.
### Parameters
| Parameter | Value | Rationale |
|-----------|-------|-----------|
| Filter size | 1 KB (8,192 bits) | Sized for expected occupancy with margin |
| Hash functions | 5 | Optimal for 800-1,600 entries at this size |
| Scope (K) | 2 | Effective ~4-hop reach with TTL propagation |
| Parameter | Value | Rationale |
|----------------|-------------------|--------------------------------------------|
| Filter size | 1 KB (8,192 bits) | Sized for expected occupancy with margin |
| Hash functions | 5 | Optimal for 800-1,600 entries at this size |
### Mathematical Foundation
@@ -99,21 +98,15 @@ For 1% FPR: m ≈ 9.6n bits. For 5% FPR: m ≈ 6.2n bits.
### Expected Filter Occupancy
Filter occupancy depends on K-hop scope and node degree, **not** total network
size. The TTL mechanism bounds entries regardless of network scale.
**Nodes within h hops in a tree (branching factor b = d-1):**
```text
nodes_within_h_hops = (b^(h+1) - 1) / (b - 1)
```
For d=8 (b=7), K=2: each peer's 2-hop neighborhood ≈ 57 nodes.
Filter occupancy depends on network topology and node degree. In practice,
filters reach a natural equilibrium determined by the network's structure —
merging peer filters transitively means each filter converges to represent
the node's reachable neighborhood.
**Outgoing filter to peer Q contains:**
- Self (1 entry)
- Entries from (d-1) other peers' filters, with overlap
- Entries from (d-1) other peers' filters (excluding Q), with overlap
**Expected occupancy by node degree:**
@@ -203,17 +196,14 @@ A node's outgoing filter to peer Q contains:
1. This node's own Node ID
2. Node IDs of leaf-only dependents
3. Entries from filters received from other peers (not Q) with TTL > 0
3. Entries merged from filters received from all other peers (not Q)
This creates K-hop reachability scope through TTL-based propagation.
### K-Hop Scope Emergence
With TTL starting at K=2:
- Entries propagate ~2K hops before stopping
- Each node's filter contains destinations within ~4-hop effective range
- Bounded by O(d^2K) entries regardless of total network size
Filters propagate transitively through the network. Each node merges all
inbound peer filters (excluding the destination peer) into its outgoing
filter — this split-horizon approach prevents a node's own entries from
being echoed back to it, providing loop prevention. Propagation is
unbounded; filters naturally converge as the Bloom filter's fixed size
limits information density.
### Expiration
@@ -611,7 +601,7 @@ enum RouteState {
When nodes join/leave:
- Bloom filter updates propagate (bounded by K-hop scope)
- Bloom filter updates propagate through affected peers
- Affected sessions may need re-establishment
- Discovery queries for newly-joined nodes

View File

@@ -21,6 +21,7 @@ The protocol is based on Yggdrasil v0.5's CRDT gossip design.
8. [Cost Metrics and Parent Selection](#8-cost-metrics-and-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)
---
@@ -1059,6 +1060,102 @@ initial exchange).
---
## Known Limitations (v1 Implementation)
The following limitations exist in the current implementation relative to the
design described in this document. They are documented here to guide future
work.
### Known Limitation: Root Timeout Not Enforced
The design specifies a 60-minute root timeout (§1 timing parameters, §6
partition detection) after which nodes should treat the root as departed and
re-elect. The current implementation does not track root entry timestamps or
perform staleness checks.
**Impact**: If the root node disappears permanently without a graceful
disconnect, remaining nodes retain stale root state indefinitely. Nodes that
lose their direct parent will re-elect locally (via `handle_parent_lost()`),
but nodes with an intact path to a now-departed root will not detect the
failure.
**Required fix**: Track the timestamp of the most recent root declaration in
`TreeState`. In `check_tree_state()` (called every 1s from the RX loop),
compare against `root_timeout` (default 60 min). On expiration, treat it as
root loss — increment sequence number, become own root, and re-announce.
### Known Limitation: No TTL on Tree Entries
The design specifies a 5-10 minute TTL on tree entries (§8 timing
parameters). Peer entries in `TreeState` are never expired; they persist until
explicitly removed by peer disconnection.
**Impact**: Stale ancestry information from departed nodes remains in
`TreeState`, potentially affecting coordinate computation. In practice, this
is partially mitigated by parent loss handling, but entries for non-parent
peers that depart without a graceful disconnect will linger.
**Required fix**: Add a `last_seen` timestamp to peer entries in `TreeState`.
In `check_tree_state()`, expire entries older than `tree_entry_ttl`. When
entries expire, re-evaluate parent selection if the expired entry was the
current parent.
### Known Limitation: No Partition Detection
The design describes partition detection via gossip staleness (§6) where
nodes detect isolation when root announcements stop arriving and the root
entry eventually expires, triggering independent partition operation.
**Impact**: Without root timeout enforcement (see above), partitioned nodes
cannot detect that they've lost connectivity to the root. They continue with
stale coordinates rather than forming an independent partition with a local
root. This affects only the case where the path to root is broken at some
intermediate point — direct parent loss is handled correctly.
**Required fix**: Depends on root timeout implementation. Once root timeout
is enforced, partition detection follows naturally: a node whose root entry
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
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:
- 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.
### Known Limitation: Integration Test Gaps
Unit tests for `TreeState` and `TreeCoordinate` are comprehensive, and basic
integration tests verify TreeAnnounce exchange and parent ancestry
propagation. However, the following failure scenarios lack test coverage:
- Root node failure and network-wide re-election
- Network partition formation and independent operation
- Partition healing and root convergence
- Stale entry cleanup (depends on TTL implementation)
- Parent flapping under rapid topology changes
These tests are blocked on or related to the limitations above and should be
added as each limitation is resolved.
---
## Summary
The gossip-based spanning tree protocol achieves distributed coordination