7474// Usage:
7575// node scripts/partition-test-shards.mjs <turbo-ls.json> --shard N/M \
7676// [--exclude <pkg>]...
77+ // node scripts/partition-test-shards.mjs --check-drift <run-summary.json>... \
78+ // [--label <text>]
7779// node scripts/partition-test-shards.mjs --self-test
7880//
81+ // `--check-drift` is the half that keeps the dataset honest AFTER it is
82+ // written (#16173). Every Test Core shard already passes `--summarize`, so the
83+ // run it just finished has written the measured truth to `.turbo/runs/`; this
84+ // mode reads that back, compares it to what this script PREDICTED for the same
85+ // packages, and reds past MAX_MEASURED_OVER_PREDICTED. Without it the dataset
86+ // rots silently in one direction and the only instrument that notices is a
87+ // shard killed by the job timeout -- which is a shard that produced NO reading
88+ // while the rollup read green.
89+ //
7990// The weight dataset is scripts/test-shard-timings.json, regenerated by
8091// scripts/measure-test-shard-timings.mjs. It is required, not optional: this
8192// script refuses to shard rather than fall back to the old file-count proxy.
@@ -94,6 +105,7 @@ import { fileURLToPath } from 'node:url';
94105import process from 'node:process' ;
95106
96107import { isEntrypoint } from './invoked-as.mjs' ;
108+ import { samplesFromSummary } from './measure-test-shard-timings.mjs' ;
97109
98110const REPO_ROOT = path . resolve ( path . dirname ( fileURLToPath ( import . meta. url ) ) , '..' ) ;
99111const TIMINGS_PATH = path . join ( REPO_ROOT , 'scripts' , 'test-shard-timings.json' ) ;
@@ -113,6 +125,34 @@ export const SHARD_COUNT = 6;
113125// numerator up.
114126export const MAX_SHARD_OVER_MEAN = 1.3 ;
115127
128+ // The factor a shard's MEASURED test total may exceed its PREDICTED total by
129+ // before `--check-drift` reds. The durable half of #16173.
130+ //
131+ // Everything above balances a GENERATED dataset, and a generated file that
132+ // nothing re-measures rots in one direction only: suites get slower, the
133+ // numbers stay put, and the shard that drifted heavy reads as perfectly
134+ // balanced on paper right up until the job timeout kills it. That is not a
135+ // hypothesis. The reading this bound is written against, from run 34009395649
136+ // attempt 2 (job 101422473016 is the attempt-1 leg that was killed):
137+ //
138+ // @objectstack /cli predicted 458.15s measured 1231.52s = 2.69x
139+ //
140+ // -- one package, 68% of a 30-minute wall, on a shard the previous attempt had
141+ // already lost at 30m05s. Every step was green; the split's own max/mean read
142+ // 1.00x, because a perfectly balanced split of stale numbers is still perfectly
143+ // balanced. Nothing in this repo compared a prediction to an outcome, so the
144+ // only instrument that ever noticed was a killed job.
145+ //
146+ // 1.5 is the smallest round factor satisfying both ends:
147+ //
148+ // - it must fire well below the 2.69x measured above, or the gate would have
149+ // been green straight through the incident it exists to catch;
150+ // - it must sit ABOVE MAX_SHARD_OVER_MEAN, because a dataset accurate to
151+ // within the balance bound cannot be the thing that breaks balance. Gating
152+ // tighter than the split's own tolerance reds on drift the partitioner is
153+ // built to absorb, and a gate that reds on healthy input gets muted.
154+ export const MAX_MEASURED_OVER_PREDICTED = 1.5 ;
155+
116156// Where a `packages.items[].path` actually points.
117157//
118158// The document has two writers -- `turbo ls`, which emits repo-relative paths,
@@ -267,6 +307,64 @@ export function balanceOf(bins, items = null) {
267307 return { totals, sum, mean, max, min : Math . min ( ...totals ) , ratio : mean === 0 ? 1 : max / mean , floor } ;
268308}
269309
310+ // Compare what a shard was PREDICTED to cost against what it actually cost.
311+ //
312+ // The comparison is over the INTERSECTION of two sets, and both restrictions
313+ // are load-bearing:
314+ //
315+ // - a package measured on this shard but absent from the dataset contributes
316+ // to NEITHER total. Its weight came from the test-file-count estimate in
317+ // weighPackage(), so calling the estimate wrong would red on a brand-new
318+ // package rather than on a rotted dataset entry. It is named in
319+ // `unpredicted` instead, because a shard full of estimates is its own
320+ // (quieter) signal that a refresh is due.
321+ // - a package in the dataset but not in this summary contributes to neither
322+ // either. A shard runs a subset -- of the six bins, and on a pull_request
323+ // of `turbo ls --affected` on top of that -- so charging a shard for
324+ // packages it never ran would make the ratio a function of the diff.
325+ //
326+ // Cache hits and failed suites never reach here: the measurements come from
327+ // samplesFromSummary(), which drops both. That is deliberate reuse rather than
328+ // a second reader -- the generator's ~0s-for-a-replayed-suite hazard is the
329+ // same hazard here, pointing the other way (a cached shard would read as
330+ // enormously FASTER than predicted and quietly vouch for a rotted dataset).
331+ export function driftReport ( measured , timings , factor = MAX_MEASURED_OVER_PREDICTED ) {
332+ const rows = [ ] ;
333+ const unpredicted = [ ] ;
334+ let predictedTotal = 0 ;
335+ let measuredTotal = 0 ;
336+ for ( const [ name , seconds ] of measured ) {
337+ if ( ! Object . hasOwn ( timings . packages , name ) ) {
338+ unpredicted . push ( name ) ;
339+ continue ;
340+ }
341+ const predicted = timings . packages [ name ] ;
342+ predictedTotal += predicted ;
343+ measuredTotal += seconds ;
344+ rows . push ( { name, predicted, measured : seconds , overshoot : seconds - predicted } ) ;
345+ }
346+ unpredicted . sort ( ( a , b ) => a . localeCompare ( b , 'en' ) ) ;
347+ // Sorted by ABSOLUTE overshoot, not by ratio: the reader of a red verdict
348+ // wants the package that cost the shard its minutes, and a 0.1s package that
349+ // came in at 5x its 0.02s entry is noise wearing the biggest ratio.
350+ rows . sort ( ( a , b ) => b . overshoot - a . overshoot || a . name . localeCompare ( b . name , 'en' ) ) ;
351+ // `predictedTotal > 0` is the guard against a verdict of Infinity, which is
352+ // what a shard carrying only zero-weight entries would otherwise produce --
353+ // a red naming no cause. Zero measured packages is the same state and reads
354+ // the same way: NOT MEASURED is not a pass, and it is not a failure either.
355+ const measurable = rows . length > 0 && predictedTotal > 0 ;
356+ const ratio = measurable ? measuredTotal / predictedTotal : null ;
357+ return {
358+ rows,
359+ unpredicted,
360+ predictedTotal,
361+ measuredTotal,
362+ ratio,
363+ measurable,
364+ drifted : measurable && ratio > factor ,
365+ } ;
366+ }
367+
270368// Reads the package list out of a `turbo ls --output=json` payload, asserting
271369// two independent properties. They fail for different reasons and both are
272370// loud, because the failure this whole file guards against is the quiet one --
@@ -347,11 +445,12 @@ const SELF_TEST_BATTERIES = Object.freeze({
347445 // remedy is the one the pin itself names -- pick a new inversion pair from
348446 // the dataset -- never lowering this number.
349447 'the balancing pins (#10472)' : 16 ,
448+ 'predicted-vs-measured drift (#16173)' : 9 ,
350449} ) ;
351450
352451// DELETING an entry silences that battery's floor exactly as effectively as
353452// zeroing it, so the roster's own size is pinned too.
354- const SELF_TEST_BATTERY_FLOOR = 7 ;
453+ const SELF_TEST_BATTERY_FLOOR = 8 ;
355454
356455// The key an assertion is filed under when no battery is open. It is not a
357456// declared battery, so it reds by the same set difference rather than silently
@@ -693,6 +792,97 @@ function selfTest() {
693792 } ) ;
694793 }
695794
795+ // -- PREDICTED VS MEASURED (#16173) -------------------------------------
796+ //
797+ // The balancing pins above all read the dataset as GIVEN. None of them can
798+ // fail because a number in it is wrong: a perfectly balanced split of stale
799+ // weights satisfies every one of them, and did, at 1.00x max/mean, on the
800+ // very build whose shard 1 was killed by the 30-minute wall. These pin the
801+ // one comparison that can tell a good dataset from a rotted one.
802+ battery ( 'predicted-vs-measured drift (#16173)' ) ;
803+ const driftTimings = { packages : { slow : 100 , fine : 50 , zero : 0 } , rate : 2 } ;
804+ const measuredMap = ( o ) => new Map ( Object . entries ( o ) ) ;
805+
806+ // The card's own reading, to scale: 100s predicted, 269s measured = 2.69x.
807+ const drifted = driftReport ( measuredMap ( { slow : 269 } ) , driftTimings ) ;
808+ check ( ( ) => {
809+ if ( ! drifted . drifted ) {
810+ throw new Error ( `drift: a ${ drifted . ratio ?. toFixed ( 2 ) } x gap was not reported as drift` ) ;
811+ }
812+ } ) ;
813+ check ( ( ) => {
814+ if ( Math . abs ( drifted . ratio - 2.69 ) > 1e-9 ) throw new Error ( `drift: ratio was ${ drifted . ratio } ` ) ;
815+ } ) ;
816+
817+ // A suite that ran a little long is NOT drift -- the bound sits above
818+ // MAX_SHARD_OVER_MEAN precisely so ordinary runner variance stays green.
819+ check ( ( ) => {
820+ if ( driftReport ( measuredMap ( { slow : 140 } ) , driftTimings ) . drifted ) {
821+ throw new Error ( 'drift: a 1.4x reading red under a 1.5x bound' ) ;
822+ }
823+ } ) ;
824+ // The boundary itself: `>` not `>=`, so exactly at the bound is still green.
825+ check ( ( ) => {
826+ if ( driftReport ( measuredMap ( { slow : 150 } ) , driftTimings ) . drifted ) {
827+ throw new Error ( 'drift: a reading exactly AT the bound was called a breach' ) ;
828+ }
829+ } ) ;
830+
831+ // A package the dataset has never seen is weighed by ESTIMATE in
832+ // weighPackage(), so charging the estimate to the dataset would red on a new
833+ // package instead of on a rotted entry. Excluded from both totals, named.
834+ const withNew = driftReport ( measuredMap ( { slow : 100 , 'brand-new' : 900 } ) , driftTimings ) ;
835+ check ( ( ) => {
836+ if ( withNew . drifted ) throw new Error ( 'drift: an UNMEASURED package was charged to the dataset' ) ;
837+ } ) ;
838+ check ( ( ) => {
839+ if ( withNew . unpredicted . join ( ) !== 'brand-new' ) {
840+ throw new Error ( `drift: unpredicted was ${ withNew . unpredicted . join ( ) } ` ) ;
841+ }
842+ } ) ;
843+
844+ // The mirror restriction: a dataset entry this shard never ran must not
845+ // inflate the predicted side. `fine` and `zero` are in driftTimings and not
846+ // in the summary; if they counted, 100/150 would read as a fast shard and
847+ // vouch for the dataset.
848+ check ( ( ) => {
849+ if ( driftReport ( measuredMap ( { slow : 269 } ) , driftTimings ) . predictedTotal !== 100 ) {
850+ throw new Error ( 'drift: a package this shard never ran inflated the predicted total' ) ;
851+ }
852+ } ) ;
853+
854+ // Infinity is a red naming no cause. A shard carrying only zero-weight
855+ // entries -- and a shard carrying nothing at all -- is NOT MEASURED, which is
856+ // neither a pass nor a failure.
857+ check ( ( ) => {
858+ const z = driftReport ( measuredMap ( { zero : 30 } ) , driftTimings ) ;
859+ if ( z . measurable || z . drifted || z . ratio !== null ) {
860+ throw new Error ( `drift: a zero predicted total produced ratio ${ z . ratio } ` ) ;
861+ }
862+ } ) ;
863+
864+ // THE ONE THAT MATTERS FOR THE READING, and the reason this reuses the
865+ // generator's extractor instead of parsing summaries a second time: a cache
866+ // HIT replays a stored log in milliseconds. Read as a measurement it says the
867+ // suite got ~1000x FASTER than predicted -- a shard that would vouch, loudly
868+ // and in the wrong direction, for whatever the dataset happens to say. Both
869+ // legs go through samplesFromSummary(), which drops replays and failures.
870+ const replayed = samplesFromSummary (
871+ { tasks : [
872+ { taskId : 'slow#test' , task : 'test' , package : 'slow' , cache : { status : 'HIT' } ,
873+ execution : { startTime : 0 , endTime : 40 , exitCode : 0 } } ,
874+ { taskId : 'fine#test' , task : 'test' , package : 'fine' , cache : { status : 'MISS' } ,
875+ execution : { startTime : 0 , endTime : 200_000 , exitCode : 1 } } ,
876+ ] } ,
877+ 'drift pin'
878+ ) ;
879+ check ( ( ) => {
880+ const r = driftReport ( replayed . samples , driftTimings ) ;
881+ if ( r . measurable ) {
882+ throw new Error ( 'drift: a summary of one replay and one failure was read as a measurement' ) ;
883+ }
884+ } ) ;
885+
696886 // -- The floor: every declared battery RAN, and ran its cases (#13489) ----
697887 //
698888 // Evaluated after every battery has had its chance and BEFORE the verdict, so
@@ -749,6 +939,94 @@ function selfTest() {
749939 return SELF_TEST_VERDICT ;
750940}
751941
942+ // `--check-drift`: the shard just measured itself, so read that back.
943+ //
944+ // Every verdict this prints is one of exactly three, and NOT MEASURED is a
945+ // first-class one rather than a quiet pass. A shard whose test tasks were all
946+ // cache replays has said nothing about the dataset, and reporting that as OK is
947+ // the #4690 shape -- a check that read nothing reporting as a check that found
948+ // nothing wrong.
949+ function checkDrift ( argv ) {
950+ const inputs = [ ] ;
951+ let label = 'this shard' ;
952+ for ( let i = 0 ; i < argv . length ; i ++ ) {
953+ const arg = argv [ i ] ;
954+ if ( arg === '--check-drift' ) continue ;
955+ else if ( arg === '--label' ) label = argv [ ++ i ] ;
956+ else if ( arg . startsWith ( '--' ) ) throw new Error ( `unrecognized argument: ${ arg } ` ) ;
957+ else inputs . push ( arg ) ;
958+ }
959+ if ( inputs . length === 0 ) {
960+ console . error (
961+ 'usage: partition-test-shards.mjs --check-drift <run-summary.json>... [--label <text>]'
962+ ) ;
963+ process . exit ( 1 ) ;
964+ }
965+ // A package normally appears in exactly one summary -- it runs on exactly one
966+ // shard -- so this merge is for the case where it does not (a directory
967+ // holding several runs, or every shard's summary handed over at once). The
968+ // LONGEST window wins, deliberately unlike the generator's median: the
969+ // generator is choosing a weight to balance FUTURE splits with, where one
970+ // unlucky leg must not ratchet the dataset upward forever, while this is
971+ // asking whether a shard fits inside a wall, and the leg that answers that is
972+ // the slow one.
973+ const merged = new Map ( ) ;
974+ for ( const input of inputs ) {
975+ const { samples } = samplesFromSummary ( JSON . parse ( readFileSync ( input , 'utf8' ) ) , input ) ;
976+ for ( const [ name , seconds ] of samples ) {
977+ merged . set ( name , Math . max ( merged . get ( name ) ?? 0 , seconds ) ) ;
978+ }
979+ }
980+ const timings = loadTimings ( ) ;
981+ const report = driftReport ( merged , timings ) ;
982+ const skipped =
983+ report . unpredicted . length === 0
984+ ? ''
985+ : ` ${ report . unpredicted . length } package(s) carry no dataset entry and were excluded ` +
986+ `(estimated, not predicted): ${ report . unpredicted . join ( ', ' ) } .` ;
987+
988+ if ( ! report . measurable ) {
989+ console . error (
990+ `shard-timing-drift: NOT MEASURED -- ${ label } finished no test task that was both a cache ` +
991+ 'MISS and carried a dataset entry, so this run says nothing about whether ' +
992+ `scripts/test-shard-timings.json is still true.${ skipped } `
993+ ) ;
994+ return ;
995+ }
996+
997+ const head =
998+ `${ report . measuredTotal . toFixed ( 1 ) } s measured vs ${ report . predictedTotal . toFixed ( 1 ) } s predicted ` +
999+ `across ${ report . rows . length } package(s) = ${ report . ratio . toFixed ( 2 ) } x ` +
1000+ `(bound ${ MAX_MEASURED_OVER_PREDICTED } x)` ;
1001+ const worst = report . rows
1002+ . slice ( 0 , 5 )
1003+ . map (
1004+ ( r ) =>
1005+ ` ${ r . name } : predicted ${ r . predicted . toFixed ( 1 ) } s, measured ${ r . measured . toFixed ( 1 ) } s ` +
1006+ `(${ r . predicted > 0 ? `${ ( r . measured / r . predicted ) . toFixed ( 2 ) } x, ` : '' } ` +
1007+ `${ r . overshoot >= 0 ? '+' : '' } ${ r . overshoot . toFixed ( 1 ) } s)`
1008+ )
1009+ . join ( '\n' ) ;
1010+
1011+ if ( ! report . drifted ) {
1012+ console . error ( `shard-timing-drift: OK -- ${ label } , ${ head } .${ skipped } ` ) ;
1013+ return ;
1014+ }
1015+ console . error (
1016+ `shard-timing-drift: DRIFT -- ${ label } , ${ head } .${ skipped } \n` +
1017+ ' Heaviest overshoots:\n' +
1018+ `${ worst } \n` +
1019+ ' scripts/test-shard-timings.json no longer describes this workspace, so the shard split\n' +
1020+ ' is balancing a quantity that is not the runtime. Refresh it -- see\n' +
1021+ ' scripts/measure-test-shard-timings.mjs for the two refresh paths -- and ⛔ do NOT\n' +
1022+ ' hand-edit the dataset or raise this bound to absorb the gap. Expect the refresh to red\n' +
1023+ " this script's own balance pins if a single suite has outgrown the acceptance bound:\n" +
1024+ ' that is those pins working, and the remedy they name is splitting that suite below\n' +
1025+ ' package granularity, never a different shard count.'
1026+ ) ;
1027+ process . exit ( 1 ) ;
1028+ }
1029+
7521030function main ( ) {
7531031 const argv = process . argv . slice ( 2 ) ;
7541032 if ( argv . includes ( '--self-test' ) ) {
@@ -762,6 +1040,10 @@ function main() {
7621040 }
7631041 return ;
7641042 }
1043+ if ( argv . includes ( '--check-drift' ) ) {
1044+ checkDrift ( argv ) ;
1045+ return ;
1046+ }
7651047
7661048 let listPath = null ;
7671049 let shardSpec = null ;
0 commit comments