Step 2 of the 5-step chain (CLAUDE.md §6). Encodes the public surface of src/domains/consensus/adaptive-fanout.ts and the invariants the test suite (adaptive-fanout.test.ts) verifies.
period_epochs defaults to 5n (s08 §Adaptive fanout). FanoutTracker constructor accepts an optional override; recomputeIfDue accepts a per-call override (defaults to the constructor value or 5n).
1.2 Module-level guarantees
Pure data: no socket, no DB, no env, no console, no Date/wall-clock, no Math.random, no async, no floats.
bigint throughout: every numeric parameter and return value is bigint; no implicit Number widening.
Deterministic: identical inputs (constructor period + sequence of trackExchange calls + recomputeIfDue query) produce identical outputs.
§2. clamp(x, lo, hi)
Aspect
Spec
Inputs
x, lo, hi: bigint. Caller responsibility to pass lo <= hi.
Output
x clamped to [lo, hi] — x < lo ? lo : x > hi ? hi : x.
Order
Single ternary; no intermediate min/max calls.
Errors
None — no throw on lo > hi (different from κ clamp); narrow helper, caller-controlled.
§3. computeFanout(connectivity_score)
Aspect
Spec
Input
connectivity_score: bigint — number of live peers (caller-supplied or from FanoutTracker.currentScore()).
Stage 1 — score clamp
const score = clamp(connectivity_score, 0n, 12n); — negative scores treated as 0; > 12 treated as 12.
Stage 2 — raw
const raw = 15n - score;
Stage 3 — fanout clamp
raw < 3n ? 3n : raw > 10n ? 10n : raw — minimum 3, maximum 10.
Output
bigint in [3n, 10n].
3.1 Worked-table fixture (s08 §Adaptive fanout)
score
clamped
raw
fanout
0n
0n
15n
10n
3n
3n
12n
10n
5n
5n
10n
10n
7n
7n
8n
8n
10n
10n
5n
5n
12n
12n
3n
3n
3.2 Clamping edge cases
input
output
reason
-1n
10n
clamped to 0 → 15-0=15 → clamped to 10
-100n
10n
same
13n
3n
clamped to 12 → 15-12=3
100n
3n
clamped to 12 → 3
§4. FanoutTracker class
4.1 Internal state (private)
exchanges: Map<string, ExchangeRecord[]> — per-peer history of {ok, epoch} tuples.
period_epochs: bigint — constructor-supplied or default 5n.
score: bigint — current score (latest recomputeIfDue result, or 0n pre-first-recompute).
4.2 trackExchange(peer_id, success, epoch)
Aspect
Spec
Effect
Appends {ok: success, epoch} to exchanges.get(peer_id) (creating the entry on first sight).
Idempotence
Multiple calls in the same epoch accumulate — they do not overwrite.
Side effects
None outside exchanges Map.
Errors
None — accepts any string peer_id (validation is out of scope; gossip-wire enforces sender_id shape).
If current_epoch - last_recompute_epoch < (period_epochs ?? this.period_epochs), return null.
Live-peer definition
Peer is live iff it has ≥ 1 record with ok === true whose epoch is within [current_epoch - period, current_epoch] (inclusive at both ends; old records expire).
Score
clamp(BigInt(live_peer_count), 0n, 12n) — i.e. the live count, capped at 12.
Side effects
Updates this.score to the new score. Records older than current_epoch - period are pruned to keep memory bounded; peers with zero remaining records are deleted from the map.
Return
The new score (bigint) if recompute fired; null otherwise.
4.4 currentScore() / currentFanout()
currentScore(): bigint — returns the latest computed score (initially 0n before any successful recomputeIfDue).
A peer with only failed exchanges in the window is NOT live.
Group D1
I13
A peer with at least one successful exchange in the window IS live.
Group D2
I14
A peer whose last success is older than current - period is NOT live (pruned).
Group D3
I15
Live-peer count is clamped to [0n, 12n] before being returned as the score.
Group D4 (13+ live peers → score=12n)
I16
After recompute, the tracker discards records older than current - period (memory bound).
Group E1
I17
currentScore() is 0n until the first successful recomputeIfDue.
Group F1
I18
currentFanout() is computeFanout(currentScore()) at all times.
Group F2
I19
The source file adaptive-fanout.ts contains no Math.*, no Date.*, no Math.random, no float literals, no setTimeout, no fetch, no async/await.
Group G (static scanner)
§6. Failure modes
recomputeIfDue with current < last — period gate is the inequality current - last >= period. With bigint subtraction this would yield a negative value, which is < period, so the gate returns null. No throw. (Caller policy: clocks are monotonic; this is a tracker-side belt-and-braces.)
trackExchange with epoch in the past — accepted unconditionally; the next recomputeIfDue will treat the record as out-of-window if it’s older than current - period.
Empty tracker (trackExchange never called) recompute at current - last >= period — returns 0n (zero live peers → score 0 → fanout 10).
§7. Out of scope
Wiring the score into a real peer selector.
Sharing score with remote peers.
Persisting across process restarts.
Multi-tracker (per-fork) sharding — single tracker per node.