fix(ci): run allocation gates on Node 26

This commit is contained in:
BreezeDelegate committed 2026-10-25 03:15:11 +01:00
1 parent 2dca762e4a
commit bbfb23a765
8 files changed
+46 -48

No files matched your search

@@ -0,0 +1 @@
- Run the existing GC-bracketed allocation regression gates on Node 26 as well as Node 24. Keep the current byte budgets so CI catches allocation regressions on both supported runtimes (#39).
+8 -9
View File
@@ -3148,9 +3148,9 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with
> style-switch persistence state. Browser/GPU performance comparison remains
> operator-side; the final accounting records only comparable population
> reductions and deterministic Node allocation/solve measurements. Those
> allocation gates are calibrated on Node.js 24.14.x; `package.json` permits
> supported product runtimes on Node 24 or 26, while the allocation runner
> separately enforces Node 24 for these two calibrated probes. Each probe
> allocation gates are calibrated on both supported Node majors (24 and 26);
> `package.json` permits those same product runtimes, and the existing CI matrix
> executes the two calibrated probes on both entries. Each probe
> compiles synchronously and discards explicit-GC
> transition chunks before applying the unchanged byte ceilings. The unit
> runner executes ordinary test files with Node's default parallelism, then
@@ -4987,12 +4987,11 @@ and unreachable upstream (502/504) separately from road geometry.
> **Reading `npm test` totals:** the count depends on the Node major. The two
> GC-bracketed allocation microbenchmarks (`src/data/focusAllocations.test.mjs`
> = 1 test, `src/overlays/worldOverlayAllocation.test.mjs` = 13) only RUN on the
> calibrated Node 24 runtime; on any other major the runner skips both files and
> their 14 tests are absent from the total. A branch total quoted without its
> Node version is therefore not reproducible. As of the fly_route cinematic
> branch: **2,281 on Node 25.6.1** (allocation suites skipped) = 2,255 on
> `main` + 26 route pins; the same tree on Node 24 reports 2,295.
> = 1 test, `src/overlays/worldOverlayAllocation.test.mjs` = 13) RUN on both
> supported Node 24 and Node 26. Unsupported majors may still skip those files
> unless `GEV_REQUIRE_ALLOCATION_GATE=1` is set; every current supported CI
> matrix entry is calibrated and therefore executes them. Historical totals recorded from unsupported Node 25
> runs can therefore remain lower because their 14 allocation tests were skipped.
- **Camera verbs** (`src/cameraVerbs.js`) — one motion slot, driven per clock tick. `move_camera` orbits/pans/tilts/rotates; `fly_route` is a cinematic dolly along an existing route annotation. `rotate_globe` rotates the world-space camera rigidly around Earth's center using its screen-up axis, preserving geocentric range and framing. Left/right describe visible globe motion, while `rotate` turns the camera in place. Omitted globe direction/mode default to right/continuous; explicit values prevail. Other camera motions keep their once default. Admission requires 3D, no Cockpit/tracking owner and the whole globe inside the existing keyhole circle, using the shared keyhole/projection geometry and vertical camera FOV with a rotation-local bounding-sphere cone for off-axis containment. Canvas-edge clipping alone does not block a rigid rotation or its unchanged Globe View preset. There is no altitude cutoff. An already smaller, fully framed globe rotates directly; repeated nudges, direction and speed changes recheck current framing without demanding a reset merely because ellipsoid altitude varied. A close-up or overlap refuses without moving the camera. When the unchanged Globe View preset fits, voice says “Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’” without asking a permission question or automatically resetting. Explicit Reset/Show Globe View commands remain direct; an explicit reset-and-rotate request waits for reset success before requesting rotation. If OpenAI dispatches both tools together, rotation waits for earlier same-response resets and is skipped if any fails or is cancelled. OpenAI rejects later tool calls in the same response after a globe refusal, including calls arriving while globe admission is pending, and preserves response-scoped `tool_choice: none` for the refusal follow-up; Gemini rejects further tool execution, including queued sibling calls, until fresh typed input or admitted speech. Model interruption, advisory captions and microphone toggles do not release that guard. There is no pending Yes/No offer. Outside the refused response, explicit-command interpretation and avoiding repeated resets remain model-guided; a distinct model-issued reset call is not an app-enforced proof of user intent. A failed standalone reset can still be retried by the model, and a reset result does not guarantee the camera has fully settled. Before suggesting a reset, a cloned camera checks the unchanged Globe View destination against the same keyhole/depth rules without moving the live camera. If that preset cannot fit, `globe-reset-unavailable` asks for further zoom and the refusal instructs further zoom instead of a reset; this also covers ultrawide views. Unsupported view geometry or non-WGS84 projection assumptions fail closed without the closer-zoom instruction. Supported camera inputs/navigation intents cancel rotation before their action. Finite radius greater than the ellipsoid's maximum radius proves whole-arc clearance at constant centre-distance; no low-altitude swept solver is needed. The unchanged Globe View reset preset remains 18,000,000 m. Terrain and buildings remain outside the guarantee.
- **The route dolly is shaped, not linear** (2026-08-20). A trapezoid speed profile (smoothstep up, cruise, smoothstep down, distance taken as the closed-form integral) eases both ends without changing the pace — duration is still `totalM / ROUTE_M_S[speed]`, with one exception: a 0.5 s minimum keeps a degenerate route from being an instant teleport, so routes under 10 m (slow) / 20 m (normal) / 45 m (fast) fly SLOWER than the speed word, never faster. Turns bank up to **10°** (a 90° street corner settles near 7.5°), measured as a triangular pulse over a 4 s window centred on the camera so the roll leads in and unwinds after. Altitude breathes ±20 m around the 260 m mean and lifts up to 26 m into turns. Pitch is LOCKED at −32°; heading comes from a gaze that leads the path by 6.5 s of travel. `prefers-reduced-motion` zeroes the roll and the altitude shaping and keeps the easing.
+12 -12
View File
@@ -239,11 +239,11 @@ function satisfiesEngines(version, range) {
* re-resolve the interpreter from PATH and hand the gate a different Node than
* the one the check believes it selected.
* @param {string} version e.g. "v24.19.0"
* @returns {boolean} true only for a parseable Node 24.
* @returns {boolean} true for a parseable supported Node 24 or 26.
*/
function isCalibratedAllocationRuntime(version) {
const [maj] = String(version || '').trim().replace(/^v/, '').split('.').map((n) => Number.parseInt(n, 10));
return maj === 24;
return maj === 24 || maj === 26;
}
/**
@@ -546,7 +546,7 @@ const check = (spec) => { CHECKS.push(spec); };
// ─── A · REPO GATES ───────────────────────────────────────────────────────
check({
id: 'A1', group: 'A', desc: 'Node runtime satisfies package.json engines (allocation budgets are pinned to Node 24)',
id: 'A1', group: 'A', desc: 'Node runtime satisfies package.json engines (allocation budgets cover supported Node majors)',
run: async () => {
const pkg = JSON.parse(readFileSync(resolve(REPO_ROOT, 'package.json'), 'utf8'));
const range = pkg.engines?.node || '(unset)';
@@ -557,7 +557,7 @@ check({
if (satisfied) return pass(`node ${process.versions.node} satisfies "${range}"`);
// An off-range runtime is an environment problem, not a product defect —
// but it silently disables the allocation gate, so say so loudly. A3 then
// runs that gate under a discovered Node 24.
// runs that gate under a discovered calibrated Node 24 fallback.
return env.node24
? skip(`node ${process.versions.node} is OUTSIDE "${range}"; A3 runs the allocation gate under ${env.node24.label}, but prefer running the whole L9 pass on Node 24`, 'ENV')
: fail(`node ${process.versions.node} is OUTSIDE "${range}" and no Node 24 runtime was found — the allocation gate cannot run at all`);
@@ -579,13 +579,13 @@ check({
});
check({
id: 'A3', group: 'A', desc: 'Allocation microbenchmarks actually EXECUTE (they silently skip off Node 24)',
id: 'A3', group: 'A', desc: 'Allocation microbenchmarks actually EXECUTE on supported Node majors',
run: async () => {
// npm test prints "[unit] SKIPPED n allocation microbenchmarks" and still
// exits 0 when the runtime is not Node 24. A green suite is therefore NOT
// proof the gate ran. Force it, under Node 24 when one is discoverable.
// exits 0 when the runtime is outside the calibrated supported majors. A green
// suite is therefore NOT proof the gate ran. Force it under a calibrated runtime.
//
// ALWAYS invoke the Node 24 BINARY directly — never `npm test`. npm
// ALWAYS invoke the calibrated Node binary directly — never `npm test`. npm
// re-resolves the interpreter from PATH and can land back on the system
// Node even when THIS process is already 24: running the whole matrix under
// `mise exec [email protected] --`, the npm shell-out still re-execed system
@@ -597,10 +597,10 @@ check({
const v = await sh(bin, ['--version'], { timeoutMs: 60000 });
const version = (v.out || '').trim();
if (!isCalibratedAllocationRuntime(version)) {
// run-unit-tests.mjs refuses to measure calibrated budgets off Node 24,
// run-unit-tests.mjs refuses to measure calibrated budgets off supported Node majors,
// so a wrong binary means the gate never ran. That is this check failing
// to establish its own claim — not evidence about the product.
return crash(`the runtime selected for the allocation gate (${label}) reports ${version || 'no parseable version'}, not Node 24 — the gate would refuse or silently skip, so this check verified nothing`);
return crash(`the runtime selected for the allocation gate (${label}) reports ${version || 'no parseable version'}, not Node 24 or 26 — the gate would refuse or silently skip, so this check verified nothing`);
}
const r = await sh(bin, [resolve(REPO_ROOT, 'scripts/run-unit-tests.mjs')], {
timeoutMs: 600000, env: { GEV_REQUIRE_ALLOCATION_GATE: '1' },
@@ -611,9 +611,9 @@ check({
};
const [maj] = process.versions.node.split('.').map(Number);
if (maj === 24) return runGate(process.execPath, 'this runtime');
if (maj === 24 || maj === 26) return runGate(process.execPath, 'this runtime');
if (env.node24) return runGate(env.node24.bin, env.node24.label);
return skip(`no Node 24 runtime found (running ${process.versions.node}); the gate SKIPS silently — install Node 24 and re-run`, 'OWNER-RUN');
return skip(`no calibrated Node 24/26 runtime found (running ${process.versions.node}); the gate SKIPS silently — install a supported Node runtime and re-run`, 'OWNER-RUN');
},
});
+11 -11
View File
@@ -8,15 +8,16 @@ export const ALLOCATION_TEST_FILES = Object.freeze([
'src/overlays/worldOverlayAllocation.test.mjs',
]);
/** Whether this runtime matches the one the allocation budgets were calibrated on. */
/** Whether this runtime matches a supported major the allocation budgets are calibrated on. */
export function isCalibratedAllocationRuntime(version = process.versions.node) {
return Number.parseInt(String(version).split('.')[0], 10) === 24;
const major = Number.parseInt(String(version).split('.')[0], 10);
return major === 24 || major === 26;
}
/** Require the runtime on which allocation budgets were calibrated. */
export function assertNode24AllocationRuntime(version = process.versions.node) {
/** Require a runtime on which allocation budgets are calibrated. */
export function assertCalibratedAllocationRuntime(version = process.versions.node) {
if (!isCalibratedAllocationRuntime(version)) {
throw new Error(`Allocation budgets require the calibrated Node 24 runtime; received ${version}`);
throw new Error(`Allocation budgets require calibrated Node 24 or 26; received ${version}`);
}
return version;
}
@@ -86,19 +87,18 @@ export function runUnitTests() {
const parallelStatus = runTests(['--test', ...plan.parallel]);
if (parallelStatus !== 0) return parallelStatus;
// The GC-bracketed budgets are calibrated on Node 24 and are meaningless on
// other allocators. A contributor's suite must stay green on any supported
// engine (package.json permits >=24), so uncalibrated runtimes skip the
// The GC-bracketed budgets are calibrated on every supported Node major.
// Unsupported runtimes may still run ordinary tests, so uncalibrated runtimes skip the
// probes with a warning. Set GEV_REQUIRE_ALLOCATION_GATE=1 (pinned CI /
// release batteries) to make an uncalibrated runtime a hard failure.
if (!isCalibratedAllocationRuntime()) {
if (process.env.GEV_REQUIRE_ALLOCATION_GATE === '1') {
assertNode24AllocationRuntime();
assertCalibratedAllocationRuntime();
}
console.warn(
`[unit] SKIPPED ${ALLOCATION_TEST_FILES.length} allocation microbenchmarks: `
+ `budgets are calibrated for Node 24, running ${process.versions.node}. `
+ 'Run under Node 24 (or set GEV_REQUIRE_ALLOCATION_GATE=1 to fail instead).',
+ `budgets are calibrated for Node 24 and 26, running ${process.versions.node}. `
+ 'Run under Node 24 or 26 (or set GEV_REQUIRE_ALLOCATION_GATE=1 to fail instead).',
);
return 0;
}
+1 -1
View File
@@ -13,7 +13,7 @@ import { isCalibratedAllocationRuntime } from '../../scripts/run-unit-tests.mjs'
*/
test('converged focus treatment stays within the GC-bracketed allocation budget', (t) => {
if (!isCalibratedAllocationRuntime()) {
return t.skip(`allocation budgets are calibrated for Node 24; running ${process.versions.node}`);
return t.skip(`allocation budgets are calibrated for Node 24 or 26; running ${process.versions.node}`);
}
const result = spawnSync(
process.execPath,
+1 -1
View File
@@ -333,7 +333,7 @@ function runAllocationProbe(entryCount, profile = 'generic') {
for (const workload of WORKLOADS) {
test(`steady moving-source frames stay in budget ${workload.name}`, (t) => {
if (!CALIBRATED_ALLOCATION_RUNTIME) {
return t.skip(`allocation budgets are calibrated for Node 24; running ${process.versions.node}`);
return t.skip(`allocation budgets are calibrated for Node 24 or 26; running ${process.versions.node}`);
}
const payload = runAllocationProbe(workload.entries, workload.profile);
+2 -1
View File
@@ -127,9 +127,10 @@ test('an unevaluable engines range reports unknown rather than guessing', () =>
// A full pass run under `mise exec node@24 --` still re-execed system Node 25
// through npm; the forced gate refused ("received 25.6.1") and A3 reported it
// as a product failure. A3 now invokes the binary directly and checks it.
test('only a parseable Node 24 counts as the calibrated allocation runtime', () => {
test('every supported Node major counts as a calibrated allocation runtime', () => {
assert.equal(isCalibratedAllocationRuntime('v24.19.0'), true);
assert.equal(isCalibratedAllocationRuntime('24.19.0'), true, 'the v prefix is optional');
assert.equal(isCalibratedAllocationRuntime('v26.3.0'), true);
assert.equal(isCalibratedAllocationRuntime('v25.6.1'), false, 'the npm re-resolution case');
assert.equal(isCalibratedAllocationRuntime('v22.21.1'), false);
for (const junk of ['', ' ', 'node', undefined, null]) {
+10 -13
View File
@@ -7,7 +7,7 @@ import {
ALLOCATION_TEST_FILES,
UNIT_TEST_ROOTS,
allocationTestArgs,
assertNode24AllocationRuntime,
assertCalibratedAllocationRuntime,
buildUnitTestPlan,
discoverUnitTestFiles,
isCalibratedAllocationRuntime,
@@ -87,30 +87,27 @@ test('the runner discovers every test file in the repository', () => {
}
});
test('allocation runtime calibration is explicit and pinned to Node 24', () => {
assert.equal(assertNode24AllocationRuntime('24.19.0'), '24.19.0');
test('allocation runtime calibration covers both supported Node majors', () => {
assert.equal(assertCalibratedAllocationRuntime('24.19.0'), '24.19.0');
assert.throws(
() => assertNode24AllocationRuntime('22.23.1'),
/calibrated Node 24 runtime/,
);
assert.throws(
() => assertNode24AllocationRuntime('26.0.0'),
/calibrated Node 24 runtime/,
() => assertCalibratedAllocationRuntime('22.23.1'),
/calibrated Node 24 or 26/,
);
assert.equal(assertCalibratedAllocationRuntime('26.0.0'), '26.0.0');
assert.equal(isCalibratedAllocationRuntime('24.19.0'), true);
assert.equal(isCalibratedAllocationRuntime('22.23.1'), false);
assert.equal(isCalibratedAllocationRuntime('26.3.0'), false);
assert.equal(isCalibratedAllocationRuntime('26.3.0'), true);
});
test('npm test stays green on every supported engine, not only the calibrated one', () => {
test('npm test calibrates both supported engines while retaining off-range warnings', () => {
// package.json wiring: `npm test` must invoke this runner, and the engines
// range it advertises must not be narrower than what the runner tolerates.
const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
assert.equal(pkg.scripts.test, 'node scripts/run-unit-tests.mjs');
const enginesNode = String(pkg.engines?.node || '');
assert.ok(enginesNode, 'engines.node must be declared');
// The runner throws for uncalibrated runtimes ONLY behind the explicit
// opt-in env; by default it skips, so a supported non-24 engine cannot fail.
// Unsupported runtimes only skip probes without the explicit opt-in hard gate.
// Node 26 is calibrated and runs those probes with the same budgets.
const runner = readFileSync(new URL('../scripts/run-unit-tests.mjs', import.meta.url), 'utf8');
assert.match(runner, /GEV_REQUIRE_ALLOCATION_GATE/);
assert.match(runner, /SKIPPED .*allocation microbenchmarks/);