From 5e31816881f7437019d2b0b871959638a53ff6eb Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 08:34:27 +0300 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd --- demo/benchmark_large_house.mjs | 31 ++++++++++++++++++++++++++++++ demo/performance/README.md | 20 ++++++++++++++++++- test/performance-workflow.test.mjs | 30 +++++++++++++++++++++++++++++ 3 files changed, 80 insertions(+), 1 deletion(-) diff --git a/demo/benchmark_large_house.mjs b/demo/benchmark_large_house.mjs index 1570e50d..62f32c57 100644 --- a/demo/benchmark_large_house.mjs +++ b/demo/benchmark_large_house.mjs @@ -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); diff --git a/demo/performance/README.md b/demo/performance/README.md index abc4b133..353f5ab3 100644 --- a/demo/performance/README.md +++ b/demo/performance/README.md @@ -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. diff --git a/test/performance-workflow.test.mjs b/test/performance-workflow.test.mjs index 638958fa..38ca5f27 100644 --- a/test/performance-workflow.test.mjs +++ b/test/performance-workflow.test.mjs @@ -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