test(perf): time switchCycle warm and fail on a floor build inside it (#735)

Every large-house sample mounts a new card that has visited only floors 1
and 2 before the twelve-switch cycle, so the cycle's second step was always
the first visit to floor 3: 20 new clean-floor entries, and in 2.5D one Iso
geometry entry plus one structural build. In large-house-interaction-v1 the
editor series also moves the config epoch that keys the clean-floor cache,
so floor 1 was cold as well. That one cold step was about half of
switchCycleMs, which the README and the cycle comment describe as warmed
navigation, and a 35% warm regression drowned in it.

The runner now visits every fixture floor once in cycle order after the
settings dialog closes, outside every timed and Long Task window, and
returns to floor 2, so the cycle still starts with 2 -> 1. A guard snapshots
the hot caches and the 2.5D structural build counter around the window and
fails the sample when anything grew. Caches an older base lacks read as 0
and its null counter is not judged, so a v1.78.0 base still passes.

Budgets, hardMaxMs, metric names, the report schema, profiles and the
workflow are unchanged. Base and candidate are both measured by the
candidate runner, so the comparison is unaffected; the absolute
switchCycleMs level steps down, which the README now explains. A unit
anchor pins the warm-up position and the guard message.

Issue: #735
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
This commit is contained in:
Claude
2026-10-01 06:41:26 +00:00
committed by claude[bot]
parent b417ec4c9e
commit 5e31816881
3 changed files with 80 additions and 1 deletions
+31
View File
@@ -1121,6 +1121,24 @@ try {
card._settingsDialog = null;
await card.updateComplete;
// #735: every sample mounts a new card, and until here it has visited
// only floors 1 and 2. The cycle would otherwise pay a first visit to
// floor 3 (and, after the interaction editor series moves the config
// epoch, to floor 1 again) inside its window, so one cold floor build
// dominated a metric documented as warmed navigation. Visit every
// fixture floor once in cycle order outside every timed and Long Task
// window, then return to floor 2 so the cycle still starts with 2 -> 1.
for (let floor = 1; floor <= fixture.counts.floors; floor++) {
card._pickSpace(`perf-floor-${floor}`);
await card.updateComplete;
await frame();
}
card._pickSpace('perf-floor-2');
await card.updateComplete;
await frame();
const switchCycleCachesBefore = cacheSnapshot(card);
const switchCycleBuildsBefore = isoStructuralBuildCount(card);
const switchCycle = await duration(async () => {
for (let index = 0; index < 12; index++) {
card._pickSpace(`perf-floor-${(index % fixture.counts.floors) + 1}`);
@@ -1131,6 +1149,19 @@ try {
await new Promise((done) => setTimeout(done, 0));
}
});
// #735 guard: a warmed cycle builds nothing. Any grown hot cache or a
// structural 2.5D build inside the window means a cold floor visit
// leaked back into switchCycleMs. Caches an older base lacks read as 0
// and cannot grow; its build counter is null and is not judged.
const switchCycleCachesAfter = cacheSnapshot(card);
const switchCycleBuildsAfter = isoStructuralBuildCount(card);
const switchCycleBuilt = Object.keys(switchCycleCachesAfter)
.filter((key) => switchCycleCachesAfter[key] > switchCycleCachesBefore[key])
.map((key) => `${key} +${switchCycleCachesAfter[key] - switchCycleCachesBefore[key]}`);
if (switchCycleBuildsBefore != null && switchCycleBuildsAfter !== switchCycleBuildsBefore)
switchCycleBuilt.push(`isoStructuralBuilds +${switchCycleBuildsAfter - switchCycleBuildsBefore}`);
if (switchCycleBuilt.length)
throw new Error(`${profile} switchCycle built a floor inside the window: ${switchCycleBuilt.join(', ')}`);
await forceGc();
const cacheBefore = cacheSnapshot(card);
+19 -1
View File
@@ -66,11 +66,29 @@ high-tail guard rather than a population estimate:
- model readiness and first stable render;
- space switch, HA state update, pan/zoom and opening the settings dialog;
- a shared-wall room-resize preview which is cancelled before persistence;
- a twelve-switch navigation cycle;
- a twelve-switch navigation cycle; since #735 every fixture floor is visited
once before its window, and a floor build inside the window (any grown hot
cache or a 2.5D structural build) fails the sample;
- Long Tasks for every measured window;
- heap growth after four additional navigation rounds with forced GC;
- hot-cache size and growth after the same warmed cycles.
Issue #735 moved the first visit to each floor out of the navigation cycle.
Every sample mounts a new card that had seen only floors 1 and 2, so the cycle
used to pay one cold floor-3 build inside its window (in
`large-house-interaction-v1` also a floor-1 rebuild, because the editor series
moves the config epoch that keys the clean-floor cache). That single step was
about half of `switchCycleMs` and hid the warmed switches the metric is
meant to describe; a cold floor visit stays measured by `spaceSwitchMs`. The
change is a level step, not a regression or a speed-up of the card: base and
candidate are still measured by the candidate runner in the same run, so the
comparison is unaffected, but absolute `switchCycleMs` history and the
profile's Long Task sums before and after #735 are not comparable. Local
diagnostics showed the median falling by roughly 1.6–2.8 times (for example
`large-house-v1` about 2.0 s to 0.9 s); the exact-SHA Full Performance medians
on both sides of the change are recorded in #735. Budgets and `hardMaxMs` did
not change.
Every report is tied to the source fingerprint embedded by Rollup. A stale
bundle is a hard failure.
+30
View File
@@ -175,6 +175,36 @@ test('#570 current Stage 4 runner fails closed on the agreed observable DOM cont
'both isometric profiles must reject a structural rebuild on the HA-only window');
});
test('#735 switchCycle times warmed navigation and fails on a floor build inside its window', () => {
// Every sample mounts a new card that has seen only floors 1 and 2 before
// the cycle, so the first visit to floor 3 (and, in the interaction
// profile, a rebuilt floor 1) used to dominate switchCycleMs. The runner
// visits every fixture floor once after the settings dialog closes and
// before the timed window, then guards the window against any build.
const runner = readFileSync(new URL('../demo/benchmark_large_house.mjs', import.meta.url), 'utf8');
const settingsClosed = runner.indexOf('card._settingsDialog = null;');
const cycleStart = runner.indexOf('const switchCycle = await duration(');
assert.ok(settingsClosed > 0 && cycleStart > settingsClosed,
'the switch cycle must follow the closed settings dialog');
const warmup = runner.slice(settingsClosed, cycleStart);
assert.match(warmup,
/for \(let floor = 1; floor <= fixture\.counts\.floors; floor\+\+\) \{\s*card\._pickSpace\(`perf-floor-\$\{floor\}`\);\s*await card\.updateComplete;\s*await frame\(\);\s*\}/,
'every fixture floor must be visited once before the switchCycle window');
assert.match(warmup,
/card\._pickSpace\('perf-floor-2'\);\s*await card\.updateComplete;\s*await frame\(\);\s*const switchCycleCachesBefore = cacheSnapshot\(card\);\s*const switchCycleBuildsBefore = isoStructuralBuildCount\(card\);\s*$/,
'the cycle must still start from floor 2 with the guard snapshot taken last');
assert.ok(!warmup.includes('duration(') && !warmup.includes('startLongTaskWindow('),
'the warm-up must stay outside every timed and Long Task window');
const afterCycle = runner.slice(cycleStart, runner.indexOf('await forceGc();', cycleStart));
for (const contract of [
'const switchCycleCachesAfter = cacheSnapshot(card);',
'const switchCycleBuildsAfter = isoStructuralBuildCount(card);',
'switchCycleCachesAfter[key] > switchCycleCachesBefore[key]',
'switchCycleBuildsBefore != null && switchCycleBuildsAfter !== switchCycleBuildsBefore',
'${profile} switchCycle built a floor inside the window: ',
]) assert.ok(afterCycle.includes(contract), `missing #735 switchCycle guard: ${contract}`);
});
test('#347: a rewritten before forces the full run instead of guessing the range', () => {
// Force-push kills github.event.before; the merge-base fallback then
// guessed a range that hid a real custom_components/** diff behind two doc