@@ -302,20 +302,28 @@ describe("flat cooperative candidate — identity-carry and evaluate lanes", ()
302302 expect ( scheduler . entries . length ) . toBeGreaterThan ( 0 ) ;
303303 const candidate = activeCandidateOf ( model ) ;
304304
305- // Partial build: three one-unit slices, then a delta that touches all
306- // three change classes — an update that flips a survivor across the new
307- // filter (e: 60 -> 100 leaves lte 60), a removal, and an insert that
305+ // The M2 chunk unit completes this 8-row build sweep in the SYNCHRONOUS
306+ // first slice, so the delta lands right here — before any flush, while
307+ // the transition is still in flight (terminal unit pending) — touching
308+ // all three change classes: an update that flips a survivor across the
309+ // new filter (e: 60 -> 100 leaves lte 60), a removal, and an insert that
308310 // lands inside the new filter.
309- scheduler . flushOne ( ) ;
310- scheduler . flushOne ( ) ;
311- scheduler . flushOne ( ) ;
311+ //
312+ // Non-vacuity guard: the transition must NOT have settled when the
313+ // transaction lands, or `append` never runs and this pin proves nothing
314+ // about the upgrade path. A future unit-granularity change that settles
315+ // this fixture synchronously fails HERE instead of re-vacuating the pin.
316+ expect ( model . getState ( ) . status ) . toMatchObject ( { kind : "rebuilding" } ) ;
312317 expect (
313318 model . applyTransaction ( {
314319 update : [ { id : "e" , changes : { score : 100 } } ] ,
315320 remove : [ "c" ] ,
316321 add : [ { id : "i" , team : "Z" , score : 55 , note : "g" , rank : 0 } ] ,
317322 } ) ,
318323 ) . toMatchObject ( { updated : 1 , removed : 1 , added : 1 } ) ;
324+ // The delta was queued into the LIVE candidate: its replay units are now
325+ // outstanding (8 swept rows against 8 + 2*3 + 1 total units).
326+ expect ( candidate . completedRows ) . toBeLessThan ( candidate . totalRows ) ;
319327 scheduler . flushAll ( ) ;
320328 await transition . finished ;
321329
@@ -352,8 +360,14 @@ describe("flat cooperative candidate — identity-carry and evaluate lanes", ()
352360 const transition = model . setQuery ( COMBINED_QUERY ) ;
353361 expect ( scheduler . entries . length ) . toBeGreaterThan ( 0 ) ;
354362
355- scheduler . flushOne ( ) ;
356- scheduler . flushOne ( ) ;
363+ // The M2 chunk unit finishes this 8-row build sweep in the SYNCHRONOUS
364+ // first slice, so the grown-slot delta lands before any flush — while
365+ // the transition is still in flight (terminal unit pending), which is
366+ // when `append`'s widening must run. Non-vacuity guard: a settled
367+ // transition would never call `append`, so a future granularity change
368+ // that settles this fixture synchronously fails HERE instead of
369+ // re-vacuating the pin.
370+ expect ( model . getState ( ) . status ) . toMatchObject ( { kind : "rebuilding" } ) ;
357371 const inserted : Row [ ] = Array . from ( { length : 30 } , ( _ , index ) => ( {
358372 id : `j${ index } ` ,
359373 team : "Z" ,
@@ -487,6 +501,208 @@ describe("flat cooperative candidate — identity-carry and evaluate lanes", ()
487501 model . dispose ( ) ;
488502 } ) ;
489503
504+ test ( "the identity sweep processes one whole slot chunk per unit (M2 amendment)" , async ( ) => {
505+ // M2 amendment (#490): the identity lane's build unit is ONE slot-vector
506+ // chunk, not one row. The fixture's 8 rows share chunk 0, and the ticking
507+ // 1ms-budget clock ends every slice after its first unit — so the
508+ // SYNCHRONOUS first slice alone must complete all 8 rows, while the
509+ // terminal unit is still pending (one-row units would show 1 here).
510+ const { model, scheduler } = createFixture ( ) ;
511+ const transition = model . setQuery ( COMBINED_QUERY ) ;
512+ expect ( scheduler . entries . length ) . toBeGreaterThan ( 0 ) ;
513+ const candidate = activeCandidateOf ( model ) ;
514+ expect ( candidate . completedRows ) . toBe ( FIXTURE_ROWS . length ) ;
515+ expect ( model . getState ( ) . status ) . toMatchObject ( { kind : "rebuilding" } ) ;
516+
517+ // The next unit is the terminal one: the sweep is exhausted, the delta
518+ // queue is empty, so this single flush settles the transition.
519+ scheduler . flushOne ( ) ;
520+ expect ( model . getState ( ) . status ) . toEqual ( { kind : "ready" } ) ;
521+ await transition . finished ;
522+ expectSettledEqualsCold ( model , FIXTURE_ROWS , COMBINED_QUERY ) ;
523+ model . dispose ( ) ;
524+ } ) ;
525+
526+ test ( "a >1-chunk identity sweep takes one build unit per chunk and settles equal to cold (M2 amendment)" , async ( ) => {
527+ // 1_100 rows span two slot-vector chunks (1_024 + 76), so the build takes
528+ // exactly TWO chunk units: the synchronous first slice completes chunk 0,
529+ // the next slice completes the 76-row partial chunk — a sweep that
530+ // skipped the last partial chunk, or advanced by chunks instead of
531+ // populated slots, cannot pass both progress pins AND the cold oracle.
532+ const rows : Row [ ] = Array . from ( { length : 1_100 } , ( _ , index ) => ( {
533+ id : `r${ String ( index ) . padStart ( 4 , "0" ) } ` ,
534+ team : index % 2 === 0 ? "X" : "Y" ,
535+ score : index % 100 ,
536+ note : `n${ String ( index ) . padStart ( 4 , "0" ) } ` ,
537+ rank : 2_000 - index ,
538+ } ) ) ;
539+ const { model, scheduler } = createFixture ( { rows } ) ;
540+ const transition = model . setQuery ( COMBINED_QUERY ) ;
541+ expect ( scheduler . entries . length ) . toBeGreaterThan ( 0 ) ;
542+ const candidate = activeCandidateOf ( model ) ;
543+ expect ( candidate . totalRows ) . toBe ( 1_100 ) ;
544+ expect ( candidate . completedRows ) . toBe ( 1_024 ) ;
545+
546+ scheduler . flushOne ( ) ;
547+ expect ( candidate . completedRows ) . toBe ( 1_100 ) ;
548+ expect ( model . getState ( ) . status ) . toMatchObject ( { kind : "rebuilding" } ) ;
549+
550+ scheduler . flushAll ( ) ;
551+ await transition . finished ;
552+ expect ( candidate . completedRows ) . toBe ( candidate . totalRows ) ;
553+ expectSettledEqualsCold ( model , rows , COMBINED_QUERY ) ;
554+ model . dispose ( ) ;
555+ } ) ;
556+
557+ test ( "an absent slot-vector chunk costs no build unit (M2 amendment)" , async ( ) => {
558+ // The M2 amendment's "skipped without spending the unit" claim: an ABSENT
559+ // chunk (undefined in the captured vector's table — all holes) must not
560+ // consume a build unit. The fixture engineers a genuinely absent middle
561+ // chunk: 2_100 rows span chunks 0-2, every row owning a slot in the full
562+ // aligned chunk 1 (slots 1_024-2_047) is removed, and a settled
563+ // evaluate-lane transition rebuilds `recordsBySlot` from entries — which
564+ // allocates NO chunk for an all-hole span. The identity sweep must then
565+ // finish its build in exactly TWO units (chunks 0 and 2).
566+ const rows : Row [ ] = Array . from ( { length : 2_100 } , ( _ , index ) => ( {
567+ id : `r${ String ( index ) . padStart ( 4 , "0" ) } ` ,
568+ team : index % 2 === 0 ? "X" : "Y" ,
569+ score : index % 100 ,
570+ note : `n${ String ( index ) . padStart ( 4 , "0" ) } ` ,
571+ rank : 3_000 - index ,
572+ } ) ) ;
573+ const { model, scheduler } = createFixture ( { rows } ) ;
574+
575+ // Remove by SLOT, not by construction order: the ids are looked up from
576+ // the live root so allocator behavior cannot skew which rows go.
577+ const seededRoot = rootOf ( model ) ;
578+ const removeIds : string [ ] = [ ] ;
579+ for ( const [ rowId , record ] of seededRoot . rows . entries ( ) ) {
580+ if ( record . slot >= 1_024 && record . slot < 2_048 )
581+ removeIds . push ( String ( rowId ) ) ;
582+ }
583+ expect ( removeIds ) . toHaveLength ( 1_024 ) ;
584+ expect ( model . applyTransaction ( { remove : removeIds } ) ) . toMatchObject ( {
585+ removed : 1_024 ,
586+ } ) ;
587+ // An evaluate-lane transition (comparator change on the ACTIVE sort
588+ // column) republishes the slot vector via `slotVectorFromEntries`.
589+ const replacement = derivationsWithDescendingNote ( ) ;
590+ const rebuild = model . setDerivations ( replacement ) ;
591+ scheduler . flushAll ( ) ;
592+ await rebuild . finished ;
593+
594+ // Fixture non-vacuity: chunk 1 is genuinely ABSENT — a defined-but-empty
595+ // chunk would degrade this pin into the ordinary chunk-scan case.
596+ const captured = rootOf ( model ) ;
597+ expect ( captured . recordsBySlot . chunks ) . toHaveLength ( 3 ) ;
598+ expect ( captured . recordsBySlot . chunks [ 0 ] ) . toBeDefined ( ) ;
599+ expect ( captured . recordsBySlot . chunks [ 1 ] ) . toBeUndefined ( ) ;
600+ expect ( captured . recordsBySlot . chunks [ 2 ] ) . toBeDefined ( ) ;
601+
602+ // Identity-lane set-query under the fixture's one-unit slices: unit 1 is
603+ // chunk 0 (1_024 rows), unit 2 must be chunk 2 (52 rows) — the absent
604+ // chunk in between is skipped inside the SAME unit, for free.
605+ const transition = model . setQuery ( COMBINED_QUERY ) ;
606+ expect ( scheduler . entries . length ) . toBeGreaterThan ( 0 ) ;
607+ const candidate = activeCandidateOf ( model ) ;
608+ expect ( candidate . totalRows ) . toBe ( 1_076 ) ;
609+ expect ( candidate . completedRows ) . toBe ( 1_024 ) ;
610+
611+ scheduler . flushOne ( ) ;
612+ expect ( candidate . completedRows ) . toBe ( 1_076 ) ;
613+ expect ( model . getState ( ) . status ) . toMatchObject ( { kind : "rebuilding" } ) ;
614+
615+ scheduler . flushAll ( ) ;
616+ await transition . finished ;
617+ expect ( candidate . completedRows ) . toBe ( candidate . totalRows ) ;
618+ const remaining = new Set ( removeIds ) ;
619+ expectSettledEqualsCold (
620+ model ,
621+ rows . filter ( ( row ) => ! remaining . has ( row . id ) ) ,
622+ COMBINED_QUERY ,
623+ replacement ,
624+ ) ;
625+ model . dispose ( ) ;
626+ } ) ;
627+
628+ test ( "a mid-flight delta on a >1-chunk identity sweep still upgrades and settles equal to cold (M2 amendment)" , async ( ) => {
629+ // The chunk unit shrank the mid-build window (the 8-row M1 fixtures now
630+ // finish their sweep in the synchronous first slice), so THIS pin owns
631+ // the identity lane's upgrade path under chunk units: the delta lands
632+ // after chunk 0 but before the partial chunk, forcing `upgradeForReplay`
633+ // plus per-row replay units mid-sweep. The 40-row add also grows the
634+ // slot space past the captured bitset's word coverage (1_100 slots pad
635+ // to 1_120 bits), keeping the `append`-time `cloneMembership` widening
636+ // under live mid-flight coverage now that the M1 widening pin's window
637+ // closes in the synchronous slice.
638+ const rows : Row [ ] = Array . from ( { length : 1_100 } , ( _ , index ) => ( {
639+ id : `r${ String ( index ) . padStart ( 4 , "0" ) } ` ,
640+ team : index % 2 === 0 ? "X" : "Y" ,
641+ score : index % 100 ,
642+ note : `n${ String ( index ) . padStart ( 4 , "0" ) } ` ,
643+ rank : 2_000 - index ,
644+ } ) ) ;
645+ const { model, scheduler } = createFixture ( { rows } ) ;
646+ const transition = model . setQuery ( COMBINED_QUERY ) ;
647+ const candidate = activeCandidateOf ( model ) ;
648+ expect ( candidate . completedRows ) . toBe ( 1_024 ) ;
649+ const added : Row [ ] = Array . from ( { length : 40 } , ( _ , index ) => ( {
650+ id : `z${ String ( index ) . padStart ( 2 , "0" ) } ` ,
651+ team : "Z" ,
652+ score : 41 , // passes lte 60 — every grown-slot row is VISIBLE.
653+ note : `zz${ String ( index ) . padStart ( 2 , "0" ) } ` ,
654+ rank : 3_000 + index ,
655+ } ) ) ;
656+ expect (
657+ model . applyTransaction ( {
658+ update : [ { id : "r0001" , changes : { score : 55 } } ] ,
659+ remove : [ "r0002" ] ,
660+ add : added ,
661+ } ) ,
662+ ) . toMatchObject ( { updated : 1 , removed : 1 , added : 40 } ) ;
663+ // Non-vacuity for the widening claim: a grown slot really lands past the
664+ // captured bitset's 1_120-bit word coverage.
665+ const target = rootOf ( model ) ;
666+ const addedSlots = added . map ( ( row ) => target . rows . get ( row . id ) ! . slot ) ;
667+ expect ( Math . max ( ...addedSlots ) ) . toBeGreaterThanOrEqual ( 1_120 ) ;
668+ scheduler . flushAll ( ) ;
669+ await transition . finished ;
670+
671+ const finalRows : Row [ ] = rows
672+ . filter ( ( row ) => row . id !== "r0002" )
673+ . map ( ( row ) => ( row . id === "r0001" ? { ...row , score : 55 } : row ) ) ;
674+ finalRows . push ( ...added ) ;
675+ expectSettledEqualsCold ( model , finalRows , COMBINED_QUERY ) ;
676+ expect ( candidate . completedRows ) . toBe ( candidate . totalRows ) ;
677+ model . dispose ( ) ;
678+ } ) ;
679+
680+ test ( "slot holes in the captured chunk are skipped for free and never counted (M2 amendment)" , async ( ) => {
681+ // Removing rows BEFORE the transition leaves holes in the captured slot
682+ // vector's chunk 0. The chunk unit must advance `completedRows` by the
683+ // POPULATED slots only: counting holes (or chunk scans) would overshoot
684+ // `totalRows` and break the terminal accounting below.
685+ const { model, scheduler } = createFixture ( ) ;
686+ expect ( model . applyTransaction ( { remove : [ "c" , "f" ] } ) ) . toMatchObject ( {
687+ removed : 2 ,
688+ } ) ;
689+ const transition = model . setQuery ( COMBINED_QUERY ) ;
690+ expect ( scheduler . entries . length ) . toBeGreaterThan ( 0 ) ;
691+ const candidate = activeCandidateOf ( model ) ;
692+ expect ( candidate . totalRows ) . toBe ( FIXTURE_ROWS . length - 2 ) ;
693+ expect ( candidate . completedRows ) . toBe ( FIXTURE_ROWS . length - 2 ) ;
694+
695+ scheduler . flushAll ( ) ;
696+ await transition . finished ;
697+ expect ( candidate . completedRows ) . toBe ( candidate . totalRows ) ;
698+ expectSettledEqualsCold (
699+ model ,
700+ FIXTURE_ROWS . filter ( ( row ) => row . id !== "c" && row . id !== "f" ) ,
701+ COMBINED_QUERY ,
702+ ) ;
703+ model . dispose ( ) ;
704+ } ) ;
705+
490706 test ( "a settled delta-free flat transition accounts for every row (completed === total)" , async ( ) => {
491707 const { model, scheduler } = createFixture ( ) ;
492708 const transition = model . setQuery ( COMBINED_QUERY ) ;
0 commit comments