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
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.
474583const 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+
476600test (
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