P3.3.3 — Gossip Adaptive Fanout — Behavioral Contract

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.

§1. Surface

1.1 Exports

export function clamp(x: bigint, lo: bigint, hi: bigint): bigint;
export function computeFanout(connectivity_score: bigint): bigint;

export interface ExchangeRecord {
  readonly ok: boolean;
  readonly epoch: bigint;
}

export class FanoutTracker {
  constructor(period_epochs?: bigint);
  trackExchange(peer_id: string, success: boolean, epoch: bigint): void;
  recomputeIfDue(
    current_epoch: bigint,
    last_recompute_epoch: bigint,
    period_epochs?: bigint,
  ): bigint | null;
  currentScore(): bigint;
  currentFanout(): bigint;
}

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).

4.3 recomputeIfDue(current_epoch, last_recompute_epoch, period_epochs?)

Aspect Spec
Period gate 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).
  • currentFanout(): bigint — returns computeFanout(currentScore()). Initially 10n (because computeFanout(0n) = 10n).

§5. Invariants

ID Statement Tested by
I1 computeFanout(score) ∈ [3n, 10n] for every bigint score. Group A1 (worked table) + A2 (clamping)
I2 Worked table fixture matches s08 §Adaptive fanout exactly for score ∈ {0,3,5,7,10,12}. Group A1
I3 Clamping is left-symmetric: computeFanout(-N) = computeFanout(0n) = 10n for any N > 0. Group A2
I4 Clamping is right-symmetric: computeFanout(12n + N) = computeFanout(12n) = 3n for any N >= 0. Group A2
I5 clamp(x, lo, hi) returns x unchanged when lo <= x <= hi. Group A3
I6 clamp(x, lo, hi) returns lo when x < lo, hi when x > hi. Group A3
I7 trackExchange does not throw on any string peer_id. Group B1
I8 trackExchange appends, never overwrites — a peer can have multiple records in the same epoch. Group B2
I9 recomputeIfDue returns null when current - last < period_epochs. Group C1
I10 recomputeIfDue returns the new score when current - last >= period_epochs. Group C2
I11 Default period_epochs = 5n (constructor default + recomputeIfDue default). Group C3
I12 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.

§8. References

  • docs/audits/p3-3-3-adaptive-fanout-audit.md (Step 1)
  • docs/spec/s08-gossip.md §Adaptive fanout
  • docs/guides/implementation/task-prompts/p3.1-theta-consensus.md §P3.3.3 (source prompt)
  • src/domains/rules/builtins.tsclamp pattern reference
  • src/__tests__/domains/consensus/time-anchors.test.ts — Group 8 static scanner pattern

Back to top

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

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