Skip to content

Commit 630f6c8

Browse files
dzhelezovclaude
andcommitted
test(realtime): RG2 fuzzer revision — reorgedBlocks payload assertion + honest scope + non-vacuity floors
Committee revision (glm/codex/reviewer) on the RG2 silent-gap fuzzer, turning three review findings into real coverage and candor: 1. REORG PAYLOAD assertion (was a false-green). `ChainModel.apply` now returns the model's rolled-back suffix on a reorg; the harness asserts the emitted `reorgedBlocks` (by hash, in order) EQUALS that suffix. Closes the gap where the right `commonAncestor` was emitted with an empty/mis-sized `reorgedBlocks` — the chain reconstruction (ancestor-keyed) passed while downstream rollback would break. Mutation-verified: reconcile `reorgedBlocks -> []` now FAILS here (`expected [ [] ] to deeply equal [ Array(1) ]`) though it stays invisible to the chain equality check. 2. Header candor. The header claimed a 204/409 interleave that never happened. The generator now really prefixes a server-concurs 409 (previousBlocks == the seeded ring anchor) on ~1/4 of sequences, driving the shell's parsePreviousBlocks -> ring-match rewind -> reconnect path on the wire while leaving the delivered sequence (and the model's prediction) unchanged — zero model-fidelity risk. Genuine fork-rewind 409 and same-block child redelivery are now explicitly OUT OF SCOPE with cross-refs to the wire tests that cover them. Finality is documented as a deliberate isolation, "NOT the full realtime trichotomy". 3. Non-vacuity FLOORS. Per-class + per-wire-shape coverage tallies with lower-bound assertions, so a future generator edit that collapsed to all-append (or dropped the 409/204 interleave) fails loudly instead of passing vacuously. Verified green at FUZZ_N=100000. CONTRACT vs OBEDIENCE caveat added: this proves the consumer OBEYS reconcile's contract in its event loop, not that the contract itself is correct (reconcile's spec is pinned by the pure-function unit tests). No production change. Both-version gates green (0.16.8 / 0.16.9, 362 each), biome clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent f11a223 commit 630f6c8

1 file changed

Lines changed: 204 additions & 37 deletions

File tree

portal/portal-realtime-fuzz.test.ts

Lines changed: 204 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@
22
* RG2 — silent-gap property fuzzer (realtime campaign plan item (e).2).
33
*
44
* WHAT IT PROVES — the TRICHOTOMY. The fork's brand is "loud-restart, never-wrong-data". A stream of
5-
* adversarial block deliveries (dropped / duplicated / out-of-order / mutated hash|parentHash /
6-
* interleaved 204-idle & 409-fork phases) driven through the REAL realtime consumer
7-
* `portalRealtimeEvents` must resolve EVERY delivery into exactly one of three outcomes:
5+
* adversarial block deliveries (dropped / duplicated / out-of-order / mutated hash|parentHash),
6+
* interleaved with 204-idle re-polls and server-concurs 409 fork-negotiation rounds, driven through the
7+
* REAL realtime consumer `portalRealtimeEvents` must resolve EVERY delivery into exactly one of three
8+
* outcomes:
89
* 1. APPENDED — the code's realized chain grew by that block (model chain matches), or
910
* 2. RECONCILED — a reorg (window rolled back to the right ancestor, new fork adopted) or a
1011
* duplicate (idempotent no-op, no state change), or
@@ -27,15 +28,35 @@
2728
* actually emits exercises the append + rollback + fatal control flow end-to-end, so a regression that
2829
* dropped a block, mis-rolled a reorg, or swallowed a gap would be caught here where the pure-function
2930
* test could not see it. We feed blocks via a scripted `fetchImpl` /stream mock (the same seam every
30-
* existing realtime test uses) and interleave 204 (idle) and 409 (fork-negotiation) responses so the
31-
* I/O shell's own paths are on the wire too.
31+
* existing realtime test uses).
3232
*
33-
* FINALITY IS HELD INERT for the core trichotomy (`finalizedHead` stays at/below the window base, poll
34-
* disabled): a mid-sequence finalize prunes the private window and advances the anchor, which changes
35-
* what `reconcile` sees and would make the reference model depend on finalize timing rather than pure
36-
* chain structure. Finality/finalize is exhaustively covered by the hand-written realtime tests and
37-
* the B1/RT-G10 watchdog suites; this fuzzer isolates the append|reorg|dup|gap decision, which is where
38-
* a silent gap lives. (A separate 409/204 interleave still drives the shell's negotiation paths.)
33+
* SCOPE — what this fuzzer OBEYS vs proves, and what it deliberately does NOT model:
34+
* • CONTRACT vs OBEDIENCE. The reference model's `classify` structurally parallels `reconcile`'s
35+
* CONTRACT (append|duplicate|reorg|gap by hash/parentHash structure). This fuzzer proves the
36+
* consumer OBEYS that contract in its event loop end-to-end — NOT that the contract itself is
37+
* correct. A shared contract-level misconception (both `reconcile` and `classify` wrong the same
38+
* way) would be invisible here; `reconcile`'s own correctness is pinned by the pure-function unit
39+
* tests. Do not over-trust this as a proof of the reconcile SPEC.
40+
* • 409 fork-negotiation — SERVER-CONCURS baseline ONLY. Some sequences prefix a 409 whose
41+
* `previousBlocks` AGREES with the model's own canonical ring (the anchor). That drives the shell's
42+
* real 409 code path on the wire (parsePreviousBlocks → ring-match rewind → reconnect) while leaving
43+
* the DELIVERED block sequence — and thus the reference model's predicted chain — unchanged, so
44+
* there is no model-fidelity risk. A GENUINE fork-rewind 409 (previousBlocks naming a DIFFERENT
45+
* canonical chain, driving a mid-stream cursor rewind that re-delivers divergent blocks) is OUT OF
46+
* SCOPE here — modeling it would couple the reference model to the shell's cursor arithmetic and
47+
* risk a false green. That path is covered end-to-end by the dedicated wire tests
48+
* (`portal-realtime-wire.test.ts` — "1-block orphan at tip HEALS via 409 fork negotiation", T1/T5).
49+
* • SAME-BLOCK CHILD REDELIVERY — out of scope. The consumer is driven with NO `shouldRedeliver`
50+
* predicate, so the `duplicate && redelivered` re-emit branch is inert here; a bare duplicate is an
51+
* idempotent no-op. The redelivery handshake is covered by the wire same-block-child-redelivery
52+
* tests (issue #26 T5).
53+
* • FINALITY held INERT. `finalizedHead` stays at/below the window base (poll effectively disabled): a
54+
* mid-sequence finalize prunes the private window and advances the anchor, which changes what
55+
* `reconcile` sees and would make the reference model depend on finalize timing rather than pure
56+
* chain structure. This is a DELIBERATE isolation of the block-delivery append|reorg|dup|gap
57+
* decision (where a silent gap lives) — it is NOT the full realtime trichotomy. Finalize/prune
58+
* interleaving is exhaustively covered by the hand-written realtime tests and the B1/RT-G10
59+
* watchdog suites.
3960
*
4061
* REPRODUCIBILITY. A seeded mulberry32 PRNG (NOT Math.random). Each sequence's seed is derived from a
4162
* base seed; on ANY trichotomy violation the failing seed is printed so the exact sequence repros. The
@@ -169,6 +190,11 @@ class ChainModel {
169190
* duplicate if it IS the tip / the anchor re-delivered; reorg if the parent is a known earlier
170191
* window block or the anchor; gap otherwise) but is written independently against the model's own
171192
* state so it is not a copy of the code under test.
193+
*
194+
* SCOPE (see the file header): because `classify` mirrors the reconcile CONTRACT, this fuzzer proves
195+
* the consumer OBEYS that contract in its event loop — NOT that the contract itself is correct. A
196+
* shared contract-level misconception would be invisible here; reconcile's own correctness is the job
197+
* of the pure-function unit tests. Future readers: do not over-trust this as a spec proof.
172198
*/
173199
classify(next: Light): Expected {
174200
const tip = this.tip();
@@ -189,27 +215,38 @@ class ChainModel {
189215
return 'gap';
190216
}
191217

192-
/** Advance the model to the realized chain a CORRECT consumer must hold after processing `next`. */
193-
apply(next: Light, cls: Expected): void {
218+
/**
219+
* Advance the model to the realized chain a CORRECT consumer must hold after processing `next`.
220+
* Returns the REORGED SUFFIX for a `reorg` (the window blocks that were strictly above the common
221+
* ancestor before this delivery — exactly the `reorgedBlocks` a correct consumer must emit on the
222+
* reorg event), and `undefined` for every other class. Used by the harness to assert the emitted
223+
* `reorgedBlocks` payload, not just the rollback depth.
224+
*/
225+
apply(next: Light, cls: Expected): Light[] | undefined {
194226
if (cls === 'append') {
195227
this.window.push(next);
196228
this.seen.add(next.hash);
197229

198-
return;
230+
return undefined;
199231
}
200232
if (cls === 'reorg') {
233+
let reorged: Light[];
201234
if (next.parentHash === this.anchor.hash) {
235+
// fork at the finality boundary: the WHOLE window is reorged away, anchor is the common ancestor.
236+
reorged = [...this.window];
202237
this.window = [next];
203238
} else {
204239
const idx = this.window.findIndex((b) => b.hash === next.parentHash);
240+
reorged = this.window.slice(idx + 1);
205241
this.window = this.window.slice(0, idx + 1);
206242
this.window.push(next);
207243
}
208244
this.seen.add(next.hash);
209245

210-
return;
246+
return reorged;
211247
}
212248
// duplicate / gap: no state change (a gap additionally fatals the consumer — handled by the caller).
249+
return undefined;
213250
}
214251
}
215252

@@ -241,13 +278,14 @@ function replayRealized(
241278
}
242279
if (e.type === 'reorg') {
243280
const anchorHash = e.block.hash;
244-
let cut = chain.length;
281+
// Roll the window back to (and including) the common ancestor. If the ancestor is not in the
282+
// reconstructed chain (it is the finality anchor, off-window), the whole window is rolled back.
283+
let cut = 0;
245284
for (let k = chain.length - 1; k >= 0; k--) {
246285
if (chain[k]!.hash === anchorHash) {
247286
cut = k + 1;
248287
break;
249288
}
250-
cut = k;
251289
}
252290
chain.length = cut;
253291
}
@@ -356,11 +394,30 @@ function buildSequence(
356394
// ─────────────────────────────── the property ───────────────────────────────
357395

358396
/**
359-
* Run ONE sequence end-to-end and assert the trichotomy. Returns nothing; throws (via expect) with the
360-
* seed embedded on any violation. Re-derives the model here (in lockstep with the consumer's events) so
361-
* the assertion is against a freshly-computed independent chain, not the generator's bookkeeping.
397+
* Per-class delivery + wire-shape tallies accumulated across the whole run so a FUTURE generator edit
398+
* that silently made every sequence an all-append no-op (vacuity) fails LOUDLY at the coverage floor
399+
* asserted after the run — the property "the fuzzer exercised each trichotomy branch" is itself tested.
400+
*/
401+
type Coverage = {
402+
append: number;
403+
duplicate: number;
404+
reorg: number;
405+
gap: number;
406+
/** sequences whose reconstructed chain is NON-EMPTY (a genuine append/reorg path, not a trivial pass) */
407+
nonEmptyChains: number;
408+
/** connections scripted as a server-concurs 409 fork-negotiation round (issue #33 shell path) */
409+
conn409: number;
410+
/** connections scripted as a 204 idle re-poll (the shell's no-data path) */
411+
conn204: number;
412+
};
413+
414+
/**
415+
* Run ONE sequence end-to-end and assert the trichotomy. Throws (via expect) with the seed embedded on
416+
* any violation; otherwise returns this sequence's contribution to the run-wide coverage tallies. Re-
417+
* derives the model here (in lockstep with the consumer's events) so the assertion is against a freshly-
418+
* computed independent chain, not the generator's bookkeeping.
362419
*/
363-
async function runSequence(seed: number, anchor: Light): Promise<void> {
420+
async function runSequence(seed: number, anchor: Light): Promise<Coverage> {
364421
const { blocks, expected, endsInGap } = buildSequence(seed, anchor);
365422

366423
const ac = new AbortController();
@@ -370,7 +427,27 @@ async function runSequence(seed: number, anchor: Light): Promise<void> {
370427
// `min(500, floor(pollMs/2))`), so 10^5 sequences stay fast; finality is still INERT because the
371428
// probed head equals the anchor (never above the window base ⇒ `takeFinalized` yields no finalizedTip
372429
// and no finalize event fires however often the cadence polls). See the file header.
373-
const conns: Conn[] = [{ status: 200, blocks }, { status: 204 }];
430+
//
431+
// 409 INTERLEAVE (server-concurs baseline — see the file header SCOPE note). On a fraction of seeds we
432+
// PREFIX a 409 whose `previousBlocks` is exactly the seeded ring's anchor entry `{anchor.number,
433+
// anchor.hash}`. On the first request the shell's cursor sits at `anchor.number + 1` with the ring
434+
// holding the anchor's hash, so this 409 MATCHES the ring at the anchor: the shell rewinds `cursor` to
435+
// `anchor.number + 1` (i.e. UNCHANGED) and re-opens — driving the real 409 code path (parsePreviousBlocks
436+
// → ring-match rewind → reconnect) on the wire while the DELIVERED block sequence, and thus the reference
437+
// model's predicted chain, is left identical. So the trichotomy assertion holds ACROSS the reconnect with
438+
// zero model-fidelity risk. A genuine fork-rewind 409 that re-delivers a divergent chain is out of scope
439+
// here (covered by the wire tests) — see the header.
440+
const use409 = (seed & 0x3) === 0; // ~1/4 of sequences prefix a server-concurs 409
441+
const conns: Conn[] = use409
442+
? [
443+
{
444+
status: 409,
445+
previousBlocks: [{ number: anchor.number, hash: anchor.hash }],
446+
},
447+
{ status: 200, blocks },
448+
{ status: 204 },
449+
]
450+
: [{ status: 200, blocks }, { status: 204 }];
374451
const iter = portalRealtimeEvents({
375452
portalUrl: 'http://portal',
376453
headers: {},
@@ -429,8 +506,13 @@ async function runSequence(seed: number, anchor: Light): Promise<void> {
429506
// assert it EQUALS the independently-derived canonical chain (processing every delivery the model
430507
// did NOT predict as a terminating gap). This is the load-bearing assertion — the ONLY way the two
431508
// can differ with no throw is a silently-skipped block, which is exactly a trichotomy violation.
509+
// Alongside it we capture, per reorg step, the model's ROLLED-BACK SUFFIX (the window blocks that
510+
// were strictly above the common ancestor) so the emitted reorg events' `reorgedBlocks` payload can
511+
// be checked below — a reconstruction keyed on commonAncestor alone would miss a regression that
512+
// emitted the right ancestor with a wrong/empty `reorgedBlocks`.
432513
const model = new ChainModel(anchor);
433514
const processed = endsInGap ? expected.length - 1 : expected.length;
515+
const expectedReorgSuffixes: string[][] = [];
434516
for (let k = 0; k < processed; k++) {
435517
const b = blocks[k]!;
436518
const light: Light = {
@@ -439,7 +521,10 @@ async function runSequence(seed: number, anchor: Light): Promise<void> {
439521
parentHash: b.header.parentHash,
440522
timestamp: b.header.timestamp,
441523
};
442-
model.apply(light, expected[k]!);
524+
const reorged = model.apply(light, expected[k]!);
525+
if (reorged !== undefined) {
526+
expectedReorgSuffixes.push(reorged.map((r) => r.hash));
527+
}
443528
}
444529
const modelChain = model.window.map((b) => ({
445530
hash: b.hash,
@@ -452,6 +537,30 @@ async function runSequence(seed: number, anchor: Light): Promise<void> {
452537
`SILENT-GAP TRICHOTOMY VIOLATION: the consumer's realized chain diverged from the canonical model with no fatal (${seedTag}). ` +
453538
`expected classes=${expected.join(',')}${endsInGap ? ' [last=gap→fatal]' : ''}`,
454539
).toEqual(modelChain);
540+
541+
// 3. REORG PAYLOAD: the emitted reorg events, in order, must carry the model's rolled-back suffix as
542+
// their `reorgedBlocks` (keyed on hash). This closes the false-green where the right commonAncestor
543+
// is emitted with an empty/mis-sized `reorgedBlocks` — the reconstruction above (ancestor-keyed)
544+
// would still pass, but downstream rollback/redelivery would break. Same count, same hashes, order.
545+
const emittedReorgSuffixes = events
546+
.filter((e): e is Extract<RtEvent, { type: 'reorg' }> => e.type === 'reorg')
547+
.map((e) => e.reorgedBlocks.map((r) => r.hash));
548+
549+
expect(
550+
emittedReorgSuffixes,
551+
`REORG PAYLOAD VIOLATION: the consumer's emitted reorgedBlocks diverged from the model's rolled-back suffix (${seedTag}). ` +
552+
`expected classes=${expected.join(',')}`,
553+
).toEqual(expectedReorgSuffixes);
554+
555+
return {
556+
append: expected.filter((c) => c === 'append').length,
557+
duplicate: expected.filter((c) => c === 'duplicate').length,
558+
reorg: expected.filter((c) => c === 'reorg').length,
559+
gap: endsInGap ? 1 : 0,
560+
nonEmptyChains: modelChain.length > 0 ? 1 : 0,
561+
conn409: use409 ? 1 : 0,
562+
conn204: 1, // every sequence scripts exactly one 204 idle re-poll before exhaustion
563+
};
455564
}
456565

457566
// ─────────────────────────────── the driver ───────────────────────────────
@@ -473,30 +582,88 @@ const ANCHOR: Light = {
473582
// via this explicit override, and the heavy bar (FUZZ_N=100000 ⇒ ~510s cap) has headroom.
474583
const TIMEOUT_MS = Math.max(15_000, N * 5 + 10_000);
475584

585+
// NON-VACUITY FLOORS. Sane lower bounds each trichotomy class (and each wire shape) MUST clear over a
586+
// DEFAULT run, so a future generator edit that silently collapsed to all-append (or dropped the 409/204
587+
// interleave) fails LOUDLY here instead of passing vacuously. Deliberately conservative — the observed
588+
// counts at DEFAULT_N=2500 are far higher (thousands of appends, hundreds of each reconcile class); these
589+
// floors only assert "each branch was genuinely exercised", scaled down for a small FUZZ_N. (committee)
590+
const floors = (n: number) => ({
591+
append: Math.max(5, n),
592+
duplicate: Math.max(3, Math.floor(n / 8)),
593+
reorg: Math.max(3, Math.floor(n / 8)),
594+
gap: Math.max(3, Math.floor(n / 8)),
595+
nonEmptyChains: Math.max(3, Math.floor(n / 8)),
596+
conn409: Math.max(3, Math.floor(n / 8)),
597+
conn204: n, // every sequence scripts exactly one 204
598+
});
599+
476600
test(
477601
`RG2 silent-gap fuzzer: ${N} adversarial sequences, trichotomy (append | reorg/dup | fatal) never silently skips`,
478602
async () => {
479-
let violations = 0;
480-
let firstFailSeed: number | undefined;
603+
const cov: Coverage = {
604+
append: 0,
605+
duplicate: 0,
606+
reorg: 0,
607+
gap: 0,
608+
nonEmptyChains: 0,
609+
conn409: 0,
610+
conn204: 0,
611+
};
481612
for (let i = 0; i < N; i++) {
482613
const seed = (BASE_SEED + i * 0x9e3779b1) >>> 0; // golden-ratio stride → well-spread seeds
483614
try {
484-
await runSequence(seed, ANCHOR);
615+
const c = await runSequence(seed, ANCHOR);
616+
cov.append += c.append;
617+
cov.duplicate += c.duplicate;
618+
cov.reorg += c.reorg;
619+
cov.gap += c.gap;
620+
cov.nonEmptyChains += c.nonEmptyChains;
621+
cov.conn409 += c.conn409;
622+
cov.conn204 += c.conn204;
485623
} catch (err) {
486-
violations += 1;
487-
if (firstFailSeed === undefined) {
488-
firstFailSeed = seed;
489-
// Surface the first failure loudly and immediately with its repro seed.
490-
const msg = err instanceof Error ? err.message : String(err);
491-
throw new Error(
492-
`RG2 fuzzer FAILED on sequence ${i} (seed=0x${seed.toString(16)}). Repro exactly this sequence: ` +
493-
`FUZZ_N=1 FUZZ_SEED=${seed} (BASE_SEED becomes this seed and i=0 reuses it). Underlying: ${msg}`,
494-
);
495-
}
624+
// Throw-on-FIRST violation with the exact repro seed — deterministic, so one repro is enough; a
625+
// "collect all failures" counter would only ever reach 1 before this throw, so we don't keep one.
626+
const msg = err instanceof Error ? err.message : String(err);
627+
throw new Error(
628+
`RG2 fuzzer FAILED on sequence ${i} (seed=0x${seed.toString(16)}). Repro exactly this sequence: ` +
629+
`FUZZ_N=1 FUZZ_SEED=${seed} (BASE_SEED becomes this seed and i=0 reuses it). Underlying: ${msg}`,
630+
);
496631
}
497632
}
498633

499-
expect(violations).toBe(0);
634+
// NON-VACUITY GUARD: assert every class + wire shape cleared its floor, so the run PROVABLY exercised
635+
// each branch of the trichotomy rather than passing on a degenerate all-append (or interleave-free)
636+
// generator. A future edit that starves a branch fails HERE, loudly, with the observed tallies.
637+
const floor = floors(N);
638+
const covMsg = `observed coverage over ${N} sequences: ${JSON.stringify(cov)} vs floors ${JSON.stringify(floor)}`;
639+
expect(
640+
cov.append,
641+
`append underexercised — ${covMsg}`,
642+
).toBeGreaterThanOrEqual(floor.append);
643+
expect(
644+
cov.duplicate,
645+
`duplicate underexercised — ${covMsg}`,
646+
).toBeGreaterThanOrEqual(floor.duplicate);
647+
expect(
648+
cov.reorg,
649+
`reorg underexercised — ${covMsg}`,
650+
).toBeGreaterThanOrEqual(floor.reorg);
651+
expect(
652+
cov.gap,
653+
`gap/fatal underexercised — ${covMsg}`,
654+
).toBeGreaterThanOrEqual(floor.gap);
655+
expect(
656+
cov.nonEmptyChains,
657+
`non-empty reconstructed chains underexercised (would pass vacuously on trivial chains) — ${covMsg}`,
658+
).toBeGreaterThanOrEqual(floor.nonEmptyChains);
659+
expect(
660+
cov.conn409,
661+
`409 fork-negotiation interleave underexercised — ${covMsg}`,
662+
).toBeGreaterThanOrEqual(floor.conn409);
663+
expect(
664+
cov.conn204,
665+
`204 idle interleave underexercised — ${covMsg}`,
666+
).toBeGreaterThanOrEqual(floor.conn204);
500667
},
501668
TIMEOUT_MS,
502669
);

0 commit comments

Comments
 (0)