mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
df066cb486
* ci: add fifth integration shard
* ci: weight integration shards from timing data
* ci: shard app-router e2e
* ci: rerun optimization experiment
* ci: add sixth weighted integration shard
* ci: rebalance weighted integration shards
* ci: shard unit tests
* ci: rebalance integration shards from current timings
* ci: move weighted integration shard list from YAML into script + timing manifest
* ci: move weighted integration shard list from YAML into script + timing manifest
* feat(ci): derive integration shard weights from real CI timings with provenance
Integration shard weights lived in a hand-seeded flat path->ms map
("aggregation": "manual seed"). A reviewer could not tell a measured
number from a guess, and the guesses were wrong: favicon-short-circuit
was seeded at 5s but runs ~35s in CI across five runs, a 7x under-weight
that mis-packed the shards. The seed had no provenance and no way to
regenerate from real data.
Restructure the manifest to a v2 provenance model: per file estimateMs
(the weight the planner uses), plus medianMs/p75Ms/samples and a
generatedFrom.runs list, an estimator metric, and generatedAt. Add
scripts/ci-integration-timings-refresh.mjs to aggregate Vitest blob
reports downloaded from successful CI runs (p75 per file, nearest-rank)
and rewrite the manifest deterministically, failing closed when the
blobs do not cover every discovered file. The manifest here was
regenerated from 5 successful runs (30 blobs); all six shards now pack
to 84s.
Extract planning and blob parsing into scripts/lib/* so the fragile
Vite+ blob-parser probe lives in one place. Replace the O(files*shards)
lightest-group scan with an O(n log m) binary min-heap and collapse the
three duplicated local-search move/swap helpers into one makespanAfter +
transfer primitive. Behavior preserved: the --check gate still verifies
every file lands in exactly one shard.
Harden --check to fail closed on no discovered files, missing, stale,
malformed/zero/negative timings, shard-count drift, and bucket coverage.
Add an advisory --recommend mode that models the optimal shard count from
real weights and flags when integration has dropped below the competing
cross-job bottleneck. It is advisory only and never runs in CI; the count
stays declarative in manifest.shardTotal with the matrix enforced
against it.
* ci: pass integration shard file list via env to avoid template injection
The integration shard step expanded ${{ steps.shard.outputs.files }}
directly into the run: block. That output is a list of test file paths
discovered from `vp test list`, and on pull_request runs a filename is
attacker-controllable: a fork PR adding a file whose name contains shell
metacharacters would inject it into the runner shell. GitHub code
scanning (zizmor) flagged this as template-injection, alert 163.
Route the file list and the other computed values through env vars and
reference them in the script, leaving $SHARD_FILES unquoted so the shell
still word-splits it into separate file arguments. The shell now treats
the value as data, never as script text. Verified with zizmor: the
pre-fix workflow reports template-injection on this line, the fixed
workflow reports no findings.
* feat(ci): require refresh blobs to back the claimed --run provenance
The refresh tool recorded every --run id as provenance but only checked
that each discovered file had at least one timing sample. Passing five
--run ids with blobs for a single complete run still produced a manifest
claiming five-run provenance while every file held one sample. The
manifest could claim stronger provenance than the blob directory backs.
A test file runs in exactly one shard per run, so one complete run
yields exactly one sample per file. Require samples === runIds.length for
every discovered file: too few means a claimed run's blobs are missing,
too many means the directory holds blobs beyond the claimed runs.
--allow-partial relaxes the check to "at least one sample per file" for
the re-run-failed-shard case while still recording the true per-file
sample count.
* experiment: run integration at 5 shards to benchmark the latency/cost knee
Temporary, for benchmarking only. Repacks the same provenance weights
into 5 integration shards instead of 6 (manifest shardTotal and matrix
set to 5, Check gate updated to match) so the 5 vs 6 trade-off can be
measured with the same weights, unit split, and E2E split. To be
reverted to 6 after the run is captured.
* experiment: go aggressive on wall-clock (8 integration, 3 unit, 3 app-router E2E)
Runner minutes are free on this public repo, so the objective is pure
wall-clock. Attack the whole critical-path cluster at once: integration
to 8 shards (~63s test load each, near the per-file floor), unit to 3,
and the app-router E2E project to 3-way so none of them becomes the new
ceiling once the others drop. Report job left as-is. Benchmarking only;
final counts settle after the run lands.
* ci: set integration to 10 shards, the wall-clock floor on free CI
Public repo, so runner minutes are free and the objective is pure
wall-clock. At 10 shards each integration shard carries ~51s of test
load; combined with the serial report tail this brings the integration
critical path down to roughly where the un-shardable create-next-app
(windows) job sits, so additional shards stop moving the overall wall.
Keeps unit at 3 shards and the app-router E2E project at 3-way from the
prior step. Benchmarking continues; counts can still change.
* fix(ci): default refresh shard count to the existing manifest, not a constant
ci-integration-timings-refresh.mjs defaulted --shard-total to a hardcoded
6. The documented refresh command in ci.yml omits --shard-total, so once
the matrix moved past 6 shards, following the advertised workflow rewrote
shardTotal: 6 into the manifest and the next run failed the Verify
integration shard manifest step with shard-count drift.
Default to the current manifest's shardTotal instead. manifest.shardTotal
is the single source of truth for the count: the matrix mirrors it and
--check enforces no drift, so a plain refresh now preserves whatever the
matrix uses. An explicit --shard-total still overrides it for an
intentional count change, and a missing count with no existing manifest
now fails with a clear message instead of silently picking a number.
Found by Codex review on a31c99c4.
* refactor(ci): share integration shard CLI helpers
* refactor(ci): clarify shard local search
* refactor(ci): drop doubled flag prefix in refresh shard-total error
The invalid --shard-total message reconstructed the flag as
'--shard-total=<value>', printing a doubled prefix
('Invalid --shard-total: --shard-total=abc'). parseFlag already
returns just the value, so print it directly to match the planner
CLI's wording.
* ci(shard): warn on timing drift, enforce shard count at selection
The integration shard check fails closed when a discovered file is
missing from the timing manifest, so adding one integration test reds CI
until someone hand-refreshes scripts/ci-integration-timings.json. The
per-file weights are only a load-balancing hint: a missing or stale
weight costs a little shard balance, never test correctness or coverage.
Gating on a freshness signal blocks contributors (and forks, which run
the secret-free ci.yml against the committed manifest) for an imbalance
worth a few seconds on one shard.
checkPlan now returns warnings separately from errors. Missing and stale
files become warnings; the structural invariants (schema, shard-count
drift, zero discovery, dropped or duplicated file) stay fail-closed. The
check job prints warnings as ::warning:: annotations and exits 0, so the
plan stays valid and a maintainer refreshes the manifest at leisure.
Separately, runShard packed into whatever N/M the workflow passed while
only --check compared the manifest to --shard-total, so a future edit
could drift the matrix count from the manifest and silently drop or
double-run tests at the point tests are selected. Guard
manifest.shardTotal against the requested total in runShard too, dying on
a mismatch instead of producing a malformed plan.
* docs(ci): clarify missing timing warning
* fix(ci): harden integration shard refresh
467 lines
16 KiB
JavaScript
467 lines
16 KiB
JavaScript
// Pure planning logic for the integration shard matrix: manifest schema
|
|
// validation, weight lookup, balanced bin-packing, and the CI gate checks.
|
|
//
|
|
// The committed manifest (scripts/ci-integration-timings.json) records per-file
|
|
// timing provenance. The planner only consumes `estimateMs`; the median/p75/
|
|
// samples fields exist so a reviewer can trust where each weight came from.
|
|
|
|
import { median, percentile } from "./vitest-blob-timings.mjs";
|
|
|
|
export const MANIFEST_VERSION = 2;
|
|
export const SUITE = "integration";
|
|
export const ESTIMATOR_METRIC = "p75";
|
|
|
|
// Weight used when the planner meets a file with no manifest entry. The CI
|
|
// `--check` gate warns on such files, so newly added tests keep running until
|
|
// the manifest is refreshed with real timings.
|
|
export const DEFAULT_TIMING_MS = 5_000;
|
|
|
|
// ── manifest → weights ──────────────────────────────────────────────────
|
|
|
|
export function manifestWeights(manifest) {
|
|
const weights = new Map();
|
|
for (const [file, entry] of Object.entries(manifest.files)) {
|
|
weights.set(file, entry.estimateMs);
|
|
}
|
|
return weights;
|
|
}
|
|
|
|
function weightOf(weights, file) {
|
|
return weights.get(file) ?? DEFAULT_TIMING_MS;
|
|
}
|
|
|
|
// ── manifest validation ─────────────────────────────────────────────────
|
|
|
|
// Returns a list of human-readable errors. Empty list means valid. Pure: never
|
|
// exits or logs, so callers control reporting.
|
|
export function validateManifest(manifest) {
|
|
const errors = [];
|
|
|
|
if (typeof manifest !== "object" || manifest === null || Array.isArray(manifest)) {
|
|
return ["Timing manifest must be a JSON object"];
|
|
}
|
|
if (manifest.version !== MANIFEST_VERSION) {
|
|
errors.push(
|
|
`Manifest version must be ${MANIFEST_VERSION}, got ${JSON.stringify(manifest.version)}`,
|
|
);
|
|
}
|
|
if (manifest.suite !== SUITE) {
|
|
errors.push(`Manifest suite must be "${SUITE}", got ${JSON.stringify(manifest.suite)}`);
|
|
}
|
|
if (!Number.isInteger(manifest.shardTotal) || manifest.shardTotal < 1) {
|
|
errors.push(
|
|
`Manifest shardTotal must be a positive integer, got ${JSON.stringify(manifest.shardTotal)}`,
|
|
);
|
|
}
|
|
if (typeof manifest.generatedAt !== "string" || manifest.generatedAt.length === 0) {
|
|
errors.push("Manifest generatedAt must be a non-empty ISO timestamp");
|
|
}
|
|
const runs = manifest.generatedFrom?.runs;
|
|
if (!Array.isArray(runs) || runs.length === 0) {
|
|
errors.push(
|
|
"Manifest generatedFrom.runs must list at least one source CI run (provenance is required)",
|
|
);
|
|
}
|
|
if (manifest.estimator?.metric !== ESTIMATOR_METRIC) {
|
|
errors.push(
|
|
`Manifest estimator.metric must be "${ESTIMATOR_METRIC}", got ${JSON.stringify(manifest.estimator?.metric)}`,
|
|
);
|
|
}
|
|
|
|
if (
|
|
typeof manifest.files !== "object" ||
|
|
manifest.files === null ||
|
|
Array.isArray(manifest.files)
|
|
) {
|
|
errors.push("Manifest must contain a 'files' map");
|
|
return errors;
|
|
}
|
|
const fileEntries = Object.entries(manifest.files);
|
|
if (fileEntries.length === 0) {
|
|
errors.push("Manifest 'files' map is empty");
|
|
}
|
|
for (const [file, entry] of fileEntries) {
|
|
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
|
|
errors.push(`Malformed timing for ${file}: must be an object with estimateMs`);
|
|
continue;
|
|
}
|
|
errors.push(...positiveIntFieldErrors(file, entry, "estimateMs"));
|
|
errors.push(...positiveIntFieldErrors(file, entry, "p75Ms"));
|
|
errors.push(...positiveIntFieldErrors(file, entry, "medianMs"));
|
|
if (!Number.isInteger(entry.samples) || entry.samples < 1) {
|
|
errors.push(
|
|
`Invalid samples for ${file}: must be a positive integer, got ${JSON.stringify(entry.samples)}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
return errors;
|
|
}
|
|
|
|
function positiveIntFieldErrors(file, entry, field) {
|
|
const value = entry[field];
|
|
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
return [`Invalid ${field} for ${file}: must be a finite number, got ${JSON.stringify(value)}`];
|
|
}
|
|
if (!Number.isInteger(value)) return [`Invalid ${field} for ${file}: ${value} is not an integer`];
|
|
if (value <= 0) return [`Invalid ${field} for ${file}: ${value} is not positive`];
|
|
return [];
|
|
}
|
|
|
|
// ── bin packing ─────────────────────────────────────────────────────────
|
|
|
|
function groupLoad(group, weights) {
|
|
let total = 0;
|
|
for (const file of group.files) total += weightOf(weights, file);
|
|
return total;
|
|
}
|
|
|
|
function makespan(groups, weights) {
|
|
let max = 0;
|
|
for (const group of groups) max = Math.max(max, groupLoad(group, weights));
|
|
return max;
|
|
}
|
|
|
|
// Longest-processing-time greedy: assign heaviest files first to the currently
|
|
// lightest shard. A binary min-heap keyed by (load, index) finds the lightest
|
|
// shard in O(log m) instead of scanning all shards per file, so packing is
|
|
// O(n log m) overall. Ties break on lowest shard index for determinism.
|
|
export function greedyPack(files, weights, groupTotal) {
|
|
const groups = Array.from({ length: groupTotal }, (_, index) => ({ index, files: [] }));
|
|
const heap = new MinHeap((a, b) => a.load - b.load || a.index - b.index);
|
|
for (const group of groups) heap.push({ load: 0, index: group.index });
|
|
|
|
const ordered = [...files].sort((a, b) => {
|
|
const diff = weightOf(weights, b) - weightOf(weights, a);
|
|
return diff !== 0 ? diff : a.localeCompare(b);
|
|
});
|
|
|
|
for (const file of ordered) {
|
|
const lightest = heap.pop();
|
|
groups[lightest.index].files.push(file);
|
|
lightest.load += weightOf(weights, file);
|
|
heap.push(lightest);
|
|
}
|
|
|
|
return groups;
|
|
}
|
|
|
|
// Deterministic local search that lowers the makespan after the greedy seed.
|
|
// Each round targets the single heaviest shard and tries the cheapest fix that
|
|
// strictly reduces the global max: move one file out, swap one file, then swap
|
|
// two files. All three reduce to "transfer a set out of A and a set into A from
|
|
// B"; `makespanAfter` scores any such transfer from a single primitive.
|
|
export function localImprove(groups, weights) {
|
|
for (;;) {
|
|
const currentMax = makespan(groups, weights);
|
|
const byLoad = [...groups].sort(
|
|
(a, b) => groupLoad(b, weights) - groupLoad(a, weights) || a.index - b.index,
|
|
);
|
|
const heavy = byLoad[0];
|
|
|
|
const improvement =
|
|
firstImprovingTransfer(
|
|
groups,
|
|
weights,
|
|
currentMax,
|
|
moveOneFileCandidates(heavy, byLoad, weights),
|
|
) ??
|
|
firstImprovingTransfer(
|
|
groups,
|
|
weights,
|
|
currentMax,
|
|
swapOneFileCandidates(heavy, byLoad, weights),
|
|
) ??
|
|
firstImprovingTransfer(
|
|
groups,
|
|
weights,
|
|
currentMax,
|
|
swapTwoFileCandidates(heavy, byLoad, weights),
|
|
);
|
|
|
|
if (!improvement) break;
|
|
transfer(improvement.from, improvement.to, improvement.outFiles, improvement.inFiles);
|
|
}
|
|
}
|
|
|
|
function firstImprovingTransfer(groups, weights, currentMax, candidates) {
|
|
for (const candidate of candidates) {
|
|
if (
|
|
makespanAfter(
|
|
groups,
|
|
candidate.from,
|
|
candidate.to,
|
|
candidate.outMs,
|
|
candidate.inMs,
|
|
weights,
|
|
) < currentMax
|
|
) {
|
|
return candidate;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function* moveOneFileCandidates(heavy, byLoad, weights) {
|
|
for (let i = byLoad.length - 1; i >= 1; i--) {
|
|
const other = byLoad[i];
|
|
for (const file of heaviestFirst(heavy.files, weights)) {
|
|
yield transferCandidate(heavy, other, [file], [], weights);
|
|
}
|
|
}
|
|
}
|
|
|
|
function* swapOneFileCandidates(heavy, byLoad, weights) {
|
|
for (let i = byLoad.length - 1; i >= 1; i--) {
|
|
const other = byLoad[i];
|
|
for (const out of heaviestFirst(heavy.files, weights)) {
|
|
const dOut = weightOf(weights, out);
|
|
for (const back of lightestFirst(other.files, weights)) {
|
|
const dIn = weightOf(weights, back);
|
|
if (dIn >= dOut) break;
|
|
yield transferCandidate(heavy, other, [out], [back], weights);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function* swapTwoFileCandidates(heavy, byLoad, weights) {
|
|
const light = byLoad[byLoad.length - 1];
|
|
if (heavy.index === light.index || heavy.files.length < 2 || light.files.length < 2) return;
|
|
|
|
const outs = heaviestFirst(heavy.files, weights).slice(0, 4);
|
|
const backs = lightestFirst(light.files, weights).slice(0, 4);
|
|
for (let a = 0; a < outs.length; a++) {
|
|
for (let b = a + 1; b < outs.length; b++) {
|
|
for (let c = 0; c < backs.length; c++) {
|
|
for (let d = c + 1; d < backs.length; d++) {
|
|
yield transferCandidate(heavy, light, [outs[a], outs[b]], [backs[c], backs[d]], weights);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function transferCandidate(from, to, outFiles, inFiles, weights) {
|
|
return {
|
|
from,
|
|
to,
|
|
outFiles,
|
|
inFiles,
|
|
outMs: filesLoad(outFiles, weights),
|
|
inMs: filesLoad(inFiles, weights),
|
|
};
|
|
}
|
|
|
|
function filesLoad(files, weights) {
|
|
return files.reduce((total, file) => total + weightOf(weights, file), 0);
|
|
}
|
|
|
|
// Makespan if `dOut` ms moves out of A and `dIn` ms moves in (B is the inverse).
|
|
function makespanAfter(groups, a, b, dOut, dIn, weights) {
|
|
let max = 0;
|
|
for (const group of groups) {
|
|
let load = groupLoad(group, weights);
|
|
if (group.index === a.index) load += dIn - dOut;
|
|
else if (group.index === b.index) load += dOut - dIn;
|
|
max = Math.max(max, load);
|
|
}
|
|
return max;
|
|
}
|
|
|
|
function transfer(a, b, outFiles, inFiles) {
|
|
a.files = a.files.filter((f) => !outFiles.includes(f));
|
|
b.files = b.files.filter((f) => !inFiles.includes(f));
|
|
a.files.push(...inFiles);
|
|
b.files.push(...outFiles);
|
|
}
|
|
|
|
function heaviestFirst(files, weights) {
|
|
return [...files].sort(
|
|
(a, b) => weightOf(weights, b) - weightOf(weights, a) || a.localeCompare(b),
|
|
);
|
|
}
|
|
|
|
function lightestFirst(files, weights) {
|
|
return [...files].sort(
|
|
(a, b) => weightOf(weights, a) - weightOf(weights, b) || a.localeCompare(b),
|
|
);
|
|
}
|
|
|
|
export function pack(files, weights, groupTotal) {
|
|
const groups = greedyPack(files, weights, groupTotal);
|
|
if (groupTotal >= 2) localImprove(groups, weights);
|
|
return groups;
|
|
}
|
|
|
|
// Advisory shard-count model. Sweeps candidate counts and estimates the
|
|
// integration critical path (one shard's fixed overhead + its test load +
|
|
// the merge/report job). It deliberately does NOT drive CI: the count stays a
|
|
// declarative manual choice in the manifest, because the real CI wall-clock is
|
|
// gated by whichever job is slowest overall (often app-router E2E or unit), and
|
|
// adding integration shards past that competing bottleneck buys ~0 wall-clock
|
|
// while burning runner minutes. Pass targetMs (the competing bottleneck) to get
|
|
// a concrete recommendation: the smallest count that drops integration at or
|
|
// below it.
|
|
export function recommendShardCount({ files, weights, maxShards, overheadMs, reportMs, targetMs }) {
|
|
const totalMs = files.reduce((sum, f) => sum + weightOf(weights, f), 0);
|
|
const rows = [];
|
|
for (let shards = 1; shards <= maxShards; shards++) {
|
|
const groups = pack(files, weights, shards);
|
|
const maxGroupMs = makespan(groups, weights);
|
|
const criticalPathMs = overheadMs + maxGroupMs + reportMs;
|
|
const runnerMs = totalMs + shards * overheadMs + reportMs;
|
|
const meetsTarget = targetMs !== undefined && criticalPathMs <= targetMs;
|
|
rows.push({ shards, maxGroupMs, criticalPathMs, runnerMs, meetsTarget });
|
|
}
|
|
|
|
// With a competing bottleneck, the smallest count that reaches it is optimal:
|
|
// fewer shards miss it, more shards only add cost. Without one, fall back to
|
|
// the lowest critical path (the maximum sweep), but the caller should prefer
|
|
// passing a target.
|
|
const recommended =
|
|
targetMs === undefined
|
|
? rows[rows.length - 1].shards
|
|
: (rows.find((r) => r.meetsTarget)?.shards ?? rows[rows.length - 1].shards);
|
|
|
|
return { rows, recommended, targetMs };
|
|
}
|
|
|
|
export function planSummary(groups, weights) {
|
|
return groups.map((group) => {
|
|
const head = group.files.slice(0, 3).join(" ");
|
|
const rest = group.files.length > 3 ? ` +${group.files.length - 3} more` : "";
|
|
return `${group.index + 1}/${groups.length} ${Math.round(groupLoad(group, weights) / 1000)}s ${head}${rest}`;
|
|
});
|
|
}
|
|
|
|
// ── CI gate ─────────────────────────────────────────────────────────────
|
|
|
|
// `errors` are fail-closed structural invariants (bad schema, shard-count
|
|
// drift, zero discovery, dropped/duplicated file). `warnings` are weight-
|
|
// freshness drift (unknown or stale file): they degrade balance, not
|
|
// correctness, so adding/removing a test must not red CI. Pure.
|
|
export function checkPlan({ discovered, manifest, shardTotal }) {
|
|
const errors = [];
|
|
const warnings = [];
|
|
|
|
if (discovered.length === 0) {
|
|
errors.push("Discovered zero integration test files — something is wrong");
|
|
}
|
|
|
|
errors.push(...validateManifest(manifest));
|
|
|
|
// Schema must be sound before comparing file sets / packing.
|
|
if (errors.length > 0) return { errors, warnings, groups: [] };
|
|
|
|
if (manifest.shardTotal !== shardTotal) {
|
|
errors.push(
|
|
`Shard-count drift: manifest shardTotal is ${manifest.shardTotal} but CI requested ${shardTotal}. ` +
|
|
"Refresh the manifest with --shard-total matching the matrix.",
|
|
);
|
|
}
|
|
|
|
const manifestFiles = Object.keys(manifest.files);
|
|
const missing = discovered.filter((f) => !(f in manifest.files));
|
|
for (const f of missing) {
|
|
warnings.push(
|
|
`No timing for ${f}: packed at ${DEFAULT_TIMING_MS}ms until the manifest is refreshed`,
|
|
);
|
|
}
|
|
const stale = manifestFiles.filter((f) => !discovered.includes(f));
|
|
for (const f of stale) {
|
|
warnings.push(`Stale manifest entry (no longer discovered): ${f}, drop on next refresh`);
|
|
}
|
|
|
|
const weights = manifestWeights(manifest);
|
|
const groups = pack(discovered, weights, shardTotal);
|
|
|
|
// Bucket coverage: every discovered file lands in exactly one shard.
|
|
const placed = groups.flatMap((g) => g.files);
|
|
const placedSet = new Set();
|
|
for (const f of placed) {
|
|
if (placedSet.has(f)) errors.push(`Duplicate file in packed output: ${f}`);
|
|
placedSet.add(f);
|
|
}
|
|
for (const f of discovered) {
|
|
if (!placedSet.has(f)) errors.push(`File dropped during packing: ${f}`);
|
|
}
|
|
|
|
return { errors, warnings, groups };
|
|
}
|
|
|
|
// ── manifest building (refresh) ─────────────────────────────────────────
|
|
|
|
// Build a deterministic v2 manifest from aggregated timing samples. File keys
|
|
// are sorted so the serialized bytes are stable given the same inputs.
|
|
export function buildManifest({ samples, shardTotal, runs, generatedAt }) {
|
|
const files = {};
|
|
for (const file of [...samples.keys()].sort((a, b) => a.localeCompare(b))) {
|
|
const durations = samples.get(file);
|
|
const p75 = Math.round(percentile(durations, 75));
|
|
files[file] = {
|
|
estimateMs: p75,
|
|
medianMs: Math.round(median(durations)),
|
|
p75Ms: p75,
|
|
samples: durations.length,
|
|
};
|
|
}
|
|
|
|
return {
|
|
version: MANIFEST_VERSION,
|
|
suite: SUITE,
|
|
shardTotal,
|
|
generatedAt,
|
|
generatedFrom: { runs },
|
|
estimator: { metric: ESTIMATOR_METRIC },
|
|
files,
|
|
};
|
|
}
|
|
|
|
// ── tiny binary min-heap ────────────────────────────────────────────────
|
|
|
|
class MinHeap {
|
|
constructor(compare) {
|
|
this.compare = compare;
|
|
this.items = [];
|
|
}
|
|
|
|
get size() {
|
|
return this.items.length;
|
|
}
|
|
|
|
push(item) {
|
|
const items = this.items;
|
|
items.push(item);
|
|
let i = items.length - 1;
|
|
while (i > 0) {
|
|
const parent = (i - 1) >> 1;
|
|
if (this.compare(items[i], items[parent]) >= 0) break;
|
|
[items[i], items[parent]] = [items[parent], items[i]];
|
|
i = parent;
|
|
}
|
|
}
|
|
|
|
pop() {
|
|
const items = this.items;
|
|
const top = items[0];
|
|
const last = items.pop();
|
|
if (items.length > 0) {
|
|
items[0] = last;
|
|
let i = 0;
|
|
for (;;) {
|
|
const left = 2 * i + 1;
|
|
const right = 2 * i + 2;
|
|
let smallest = i;
|
|
if (left < items.length && this.compare(items[left], items[smallest]) < 0) smallest = left;
|
|
if (right < items.length && this.compare(items[right], items[smallest]) < 0)
|
|
smallest = right;
|
|
if (smallest === i) break;
|
|
[items[i], items[smallest]] = [items[smallest], items[i]];
|
|
i = smallest;
|
|
}
|
|
}
|
|
return top;
|
|
}
|
|
}
|