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.* (no Math.min, Math.max, Math.floor — use ternaries)
  • No Date.*, no new Date(
  • No setTimeout / setInterval / setImmediate
  • No fetch / XMLHttpRequest
  • No crypto.X dotted 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’s tsconfig.json (strict mode).
  • npm run lint — ESLint per .eslintrc.json (recommended + @typescript-eslint/recommended + prettier). The no-explicit-any warn rule does not affect this slice (no any).
  • 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.


Back to top

Colibri — documentation-first MCP runtime. Apache 2.0 + Commons Clause.

This site uses Just the Docs, a documentation theme for Jekyll.