Skip to content

Commit 2688087

Browse files
committed
wip(devx): partition-test-shards gains --check-drift (predicted vs measured)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Vbw3RPgdtqesx4azk9SbW8
1 parent 0c5d035 commit 2688087

1 file changed

Lines changed: 283 additions & 1 deletion

File tree

scripts/partition-test-shards.mjs

Lines changed: 283 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,8 +74,19 @@
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';
94105
import process from 'node:process';
95106

96107
import { isEntrypoint } from './invoked-as.mjs';
108+
import { samplesFromSummary } from './measure-test-shard-timings.mjs';
97109

98110
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
99111
const TIMINGS_PATH = path.join(REPO_ROOT, 'scripts', 'test-shard-timings.json');
@@ -113,6 +125,34 @@ export const SHARD_COUNT = 6;
113125
// numerator up.
114126
export 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+
7521030
function 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

Comments
 (0)