P3.3.3 — Gossip Adaptive Fanout — Execution Packet
Step 3 of the 5-step chain (CLAUDE.md §6). Gates Step 4 (implement).
§1. Files to write
| Path | Lines (est.) | Purpose |
|---|---|---|
src/domains/consensus/adaptive-fanout.ts |
~150 | clamp + computeFanout + FanoutTracker class with epoch-based recompute |
src/__tests__/domains/consensus/adaptive-fanout.test.ts |
~250 | Worked table + clamping + tracker lifecycle + live-peer semantics + static scanner |
§2. Implementation outline — adaptive-fanout.ts
§1. File banner (per gossip-wire.ts pattern, lines 1–57)
- Module purpose, surface list, determinism note, references, forbidden-token self-scan
§2. Imports — NONE (pure module, no node:crypto, no messages.ts)
§3. Types
- export interface ExchangeRecord { ok: boolean; epoch: bigint }
§4. Pure helpers
- export function clamp(x: bigint, lo: bigint, hi: bigint): bigint
— `x < lo ? lo : x > hi ? hi : x`
- export function computeFanout(connectivity_score: bigint): bigint
— const score = clamp(connectivity_score, 0n, 12n)
— const raw = 15n - score
— return raw < 3n ? 3n : raw > 10n ? 10n : raw
§5. FanoutTracker class
- Private: exchanges: Map<string, ExchangeRecord[]>, period_epochs: bigint, score: bigint
- constructor(period_epochs: bigint = 5n)
- trackExchange(peer_id: string, success: boolean, epoch: bigint): void
— push to map (insert if absent)
- recomputeIfDue(current_epoch, last_recompute_epoch, period_epochs?): bigint | null
— period = override ?? this.period_epochs
— if (current_epoch - last_recompute_epoch < period) return null
— cutoff = current_epoch - period
— for each peer, filter records to those with epoch >= cutoff; delete peer if empty
— live_count = count of peers with at least one ok=true record in remaining window
— this.score = clamp(BigInt(live_count), 0n, 12n)
— return this.score
- currentScore(): bigint — returns this.score
- currentFanout(): bigint — returns computeFanout(this.score)
2.1 Determinism guardrails (mirrored in static scanner)
- No
Math.*(noMath.min,Math.max,Math.floor— use ternaries) - No
Date.*, nonew Date( - No
setTimeout/setInterval/setImmediate - No
fetch/XMLHttpRequest - No
crypto.Xdotted access - No
process.hrtime/nextTick - No
async/await - No float literals (e.g.
1.0,0.5) - No
[native code]
BigInt(live_count) is the only allowed Number → bigint conversion (live_count is a number from Map.size traversal — coerce explicitly).
2.2 Memory bound
After recomputeIfDue, records older than current_epoch - period are pruned. Peers with zero remaining records are deleted from the map. This keeps the in-memory footprint at most O(peers × period_epochs × max-exchanges-per-epoch) and prevents unbounded growth.
§3. Test outline — adaptive-fanout.test.ts
Group A — pure helpers
A1. Worked table fixture (s08 §Adaptive fanout)
[[0n, 10n], [3n, 10n], [5n, 10n], [7n, 8n], [10n, 5n], [12n, 3n]]
A2. computeFanout clamping
- score=-1n → 10n (clamped to 0n)
- score=-100n → 10n
- score=13n → 3n (clamped to 12n)
- score=100n → 3n
A3. clamp helper
- clamp(5n, 0n, 10n) → 5n
- clamp(-3n, 0n, 10n) → 0n
- clamp(15n, 0n, 10n) → 10n
- clamp(0n, 0n, 10n) → 0n (boundary)
- clamp(10n, 0n, 10n) → 10n (boundary)
Group B — trackExchange
B1. Accepts any peer_id string without throw
B2. Multiple exchanges in same epoch accumulate (not overwrite)
Group C — recomputeIfDue period gate
C1. current - last < period → returns null
C2. current - last >= period → returns new score
C3. Default period_epochs = 5n (constructor default + recomputeIfDue default)
- Recompute at current=4, last=0 → null (delta=4 < 5)
- Recompute at current=5, last=0 → bigint (delta=5 >= 5)
Group D — live-peer semantics
D1. Peer with only failed exchanges in window → NOT live → score=0n → fanout=10n
D2. Peer with ≥1 successful exchange in window → live → counted
D3. Peer whose last success is older than (current - period) → NOT live (record pruned)
D4. 13 live peers → score clamped to 12n → fanout=3n
Group E — memory bound
E1. After recompute, records older than cutoff are pruned; peer with all records expired is removed from map
Group F — currentScore / currentFanout
F1. currentScore() = 0n initially (before any recompute)
F2. currentFanout() = computeFanout(currentScore()) at all times
- initial: 0n → 10n
- after 8-live-peer recompute: 8n → 7n (15-8=7, in [3,10])
Group G — static forbidden-token scanner
G1. adaptive-fanout.ts source contains no Math.*, no Date.*, no setTimeout,
no setInterval, no setImmediate, no fetch, no XMLHttpRequest, no
crypto.X dotted access, no process.hrtime, no nextTick, no async/await,
no float literals, no [native code]. JSDoc stripped before scanning.
3.1 Test count estimate
Per-group case counts:
- Group A: 6 (table) + 4 (clamping) + 5 (clamp) = 15
- Group B: 2 cases
- Group C: 3 cases
- Group D: 4 cases
- Group E: 1 case
- Group F: 2 cases
- Group G: 1 case (single static scanner)
Total ≈ 28 cases. Within the 12–20 estimated; if Jest groups some describe.each blocks under single test functions, count may compress. Acceptable range per task prompt: +12–20.
§4. Build / lint / test gates
npm run build— TypeScript compilation against the worktree’stsconfig.json(strict mode).npm run lint— ESLint per.eslintrc.json(recommended + @typescript-eslint/recommended + prettier). Theno-explicit-anywarn rule does not affect this slice (noany).npm test— Jest ESM run. Baseline 2970; expect 2982–2990 after this slice.
§5. Commit sequence
| Commit | Scope |
|---|---|
| 1 | audit(p3-3-3-adaptive-fanout): inventory surface (already shipped — ac2300c8) |
| 2 | contract(p3-3-3-adaptive-fanout): behavioral contract (already shipped — 229289a7) |
| 3 | packet(p3-3-3-adaptive-fanout): execution plan (this commit) |
| 4 | feat(p3-3-3-adaptive-fanout): connectivity-scaled gossip fanout [3,10] |
| 5 | verify(p3-3-3-adaptive-fanout): test evidence |
§6. Forbidden actions
--no-verify,--amend,--force-push- Edits to main checkout
Math.*,Date.*,Math.random- Float literals
- New npm deps
- Socket / DB / file I/O in the runtime module
- Editing files outside the listed paths
§7. Out of scope
Same as contract §7 — no gossip-loop integration, no persistence, no remote sharing, no multi-tracker sharding.