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