From 51e2f2b510c33bd98a2d278bdbbb983ee292a5db Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 13:54:35 +0300 Subject: [PATCH] test(perf): pull the switchCycleMs ceilings down to the warmed level (#747) Since #735 switchCycleMs times the warmed twelve-switch cycle, and the absolute ceilings of 7000 ms (flat) and 8000 ms (2.5D) sat 7.6-10.2 times above the level. performance_smoke judges only these ceilings, so between full runs the warm floor switch that #694/#725 just sped up was guarded only against a several-fold collapse. Series: every Full Performance run after #735, both sides (the base is measured by the candidate runner, so it is warm too), 7 samples each - 36821241343 (#735, base 76558bf2), 36838891001 (#740), 36838952536 (#742) and 36839009721 (#739), the last three against dev 7ff2b5ae. flat large-house-v1 / plan-snap / interaction 666.9-812.7 ms 2.5D large-house-isometric / stage3-dense 766.5-1333.9 ms The 2.5D maximum is the dense pair of 36838952536, whose base on the same runner read 1249.7 ms against 849.9-982.4 ms elsewhere: runner noise the series is meant to contain. No 3-sample performance_smoke median is in the series yet; those profiles join Validate only on a src/** diff. Rule (as #692, #675 falls in the same band): one number per family, the first multiple of 50 ms at or above 1.15 x M and no higher than 1.2 x M, M being the family's maximum median: flat 1.15 x 812.7 = 934.6 -> 950 (+16.9 %), 2.5D 1.15 x 1333.9 = 1534.0 -> 1550 (+16.2 %). One number per family keeps the smoke = full (#473 AC4), plan-snap/interaction "every original ceiling" and dense = historical (#160) contracts; the price is wider headroom for the faster profiles. The base-relative comparison of the full workflow (0.35 / 0.2, 250 ms) is unchanged and stays the detector for smaller growth. The new test pins both families: one ceiling in every file of a family, every point of the series passes the smoke budget with --absolute-only, the ceiling follows the rule and stays inside [1.15, 1.2] x M, doubling the level fails, and the full profiles' ratio and noise allowance are unchanged. 7000 left in any flat file or a ceiling under 1.15 x M reds it. The README records the series and the reasoning. Issue: #747 User-Visible: no Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd --- demo/performance/README.md | 29 ++++++++- .../budgets-interaction-smoke.json | 2 +- demo/performance/budgets-isometric-smoke.json | 2 +- .../budgets-isometric-stage3-dense.json | 2 +- .../budgets-large-house-interaction.json | 2 +- .../budgets-large-house-isometric.json | 2 +- .../budgets-large-house-plan-snap.json | 2 +- demo/performance/budgets.json | 2 +- test/performance-budget.test.mjs | 63 +++++++++++++++++++ 9 files changed, 98 insertions(+), 8 deletions(-) diff --git a/demo/performance/README.md b/demo/performance/README.md index 353f5ab3..e0eecbc4 100644 --- a/demo/performance/README.md +++ b/demo/performance/README.md @@ -87,7 +87,8 @@ 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. +not change in #735; #747 then brought the `switchCycleMs` ceilings down to the +warmed level (see "CI contracts" below). Every report is tied to the source fingerprint embedded by Rollup. A stale bundle is a hard failure. @@ -146,6 +147,32 @@ wide margin. Occasional hosted runners are about a quarter faster, which a ceiling does not mind. Smaller growth stays the job of the full workflow's base-relative comparison. +The `switchCycleMs` ceilings are 950 ms for the flat family (`budgets.json`, +plan-snap, interaction and the interaction smoke) and 1550 ms for the 2.5D +family (both isometric profiles and the isometric smoke), one number per family +(#747). Since #735 the metric is the warmed twelve-switch cycle, and the former +7000 and 8000 ms sat 7.6–10.2 times above the #735 medians. The series is every +Full Performance run after #735, both sides with 7 samples — the base is +measured by the candidate runner, so it is warm too: 36821241343 (#735, base +`76558bf2`), 36838891001 (#740), 36838952536 (#742) and 36839009721 (#739), the +last three against `dev` `7ff2b5ae`. Flat medians span 666.9–812.7 ms (maximum: +`large-house-v1`, the base of 36838891001). 2.5D medians span 766.5–1333.9 ms; +the maximum is `isometric-stage3-dense-v1` in 36838952536, whose base on the +same runner read 1249.7 ms against 849.9–982.4 ms in the other runs — runner +noise of 27–47 %, which the series is meant to contain. Each ceiling is the +first multiple of 50 ms at or above 1.15 × that maximum and no higher than 1.2 × +(the #692 band, which #675 also falls into): +16.9 % and +16.2 %. Doubling the +level fails by a wide margin. The price of one number per family is wider +headroom for the faster profiles (`large-house-isometric-v1`, maximum 1135.7 ms, +gets +36 %); it keeps the plan-snap and interaction contracts of "every original +ceiling" and the dense twin equal to the historical profile. Smaller growth +stays the job of the full workflow's base-relative comparison (unchanged: 0.35 +flat, 0.2 2.5D, 250 ms noise allowance), and a floor built inside the window +already fails the sample through the #735 structural guard, not through time. No +3-sample `performance_smoke` median is in the series yet: those profiles join +Validate only on a `src/**` diff, so the first beta candidate after #747 is +their check. + The 2.5D View toggle (`viewToggleMs`) is reported by the isometric profiles but budgeted by none of them (#720, owner decision in #694 on 2026-09-30). Switching Flat ↔ 2.5D is a one-off General settings change, not something View or kiosk diff --git a/demo/performance/budgets-interaction-smoke.json b/demo/performance/budgets-interaction-smoke.json index 0ae8e069..5cce2f53 100644 --- a/demo/performance/budgets-interaction-smoke.json +++ b/demo/performance/budgets-interaction-smoke.json @@ -61,7 +61,7 @@ }, "switchCycleMs": { "stat": "median", - "hardMaxMs": 7000 + "hardMaxMs": 950 } }, "longTasks": { diff --git a/demo/performance/budgets-isometric-smoke.json b/demo/performance/budgets-isometric-smoke.json index 33b0363c..d2859076 100644 --- a/demo/performance/budgets-isometric-smoke.json +++ b/demo/performance/budgets-isometric-smoke.json @@ -33,7 +33,7 @@ }, "switchCycleMs": { "stat": "median", - "hardMaxMs": 8000 + "hardMaxMs": 1550 } }, "longTasks": { diff --git a/demo/performance/budgets-isometric-stage3-dense.json b/demo/performance/budgets-isometric-stage3-dense.json index b3ff9882..33da50da 100644 --- a/demo/performance/budgets-isometric-stage3-dense.json +++ b/demo/performance/budgets-isometric-stage3-dense.json @@ -12,7 +12,7 @@ "resizePreviewMs": { "stat": "median", "maxRegressionRatio": 0.2, "noiseAllowanceMs": 150, "hardMaxMs": 2200 }, "panZoomMs": { "stat": "median", "maxRegressionRatio": 0.2, "noiseAllowanceMs": 60, "hardMaxMs": 600 }, "settingsDialogMs": { "stat": "median", "maxRegressionRatio": 0.2, "noiseAllowanceMs": 100, "hardMaxMs": 1200 }, - "switchCycleMs": { "stat": "median", "maxRegressionRatio": 0.2, "noiseAllowanceMs": 250, "hardMaxMs": 8000 } + "switchCycleMs": { "stat": "median", "maxRegressionRatio": 0.2, "noiseAllowanceMs": 250, "hardMaxMs": 1550 } }, "longTasks": { "maxSingleMs": 3000, diff --git a/demo/performance/budgets-large-house-interaction.json b/demo/performance/budgets-large-house-interaction.json index 929f985d..e89340ac 100644 --- a/demo/performance/budgets-large-house-interaction.json +++ b/demo/performance/budgets-large-house-interaction.json @@ -91,7 +91,7 @@ "stat": "median", "maxRegressionRatio": 0.35, "noiseAllowanceMs": 250, - "hardMaxMs": 7000 + "hardMaxMs": 950 } }, "longTasks": { diff --git a/demo/performance/budgets-large-house-isometric.json b/demo/performance/budgets-large-house-isometric.json index 109ac8c5..0ae0a4b1 100644 --- a/demo/performance/budgets-large-house-isometric.json +++ b/demo/performance/budgets-large-house-isometric.json @@ -10,7 +10,7 @@ "resizePreviewMs": { "stat": "median", "maxRegressionRatio": 0.2, "noiseAllowanceMs": 150, "hardMaxMs": 2200 }, "panZoomMs": { "stat": "median", "maxRegressionRatio": 0.2, "noiseAllowanceMs": 60, "hardMaxMs": 600 }, "settingsDialogMs": { "stat": "median", "maxRegressionRatio": 0.2, "noiseAllowanceMs": 100, "hardMaxMs": 1200 }, - "switchCycleMs": { "stat": "median", "maxRegressionRatio": 0.2, "noiseAllowanceMs": 250, "hardMaxMs": 8000 } + "switchCycleMs": { "stat": "median", "maxRegressionRatio": 0.2, "noiseAllowanceMs": 250, "hardMaxMs": 1550 } }, "longTasks": { "maxSingleMs": 3000, diff --git a/demo/performance/budgets-large-house-plan-snap.json b/demo/performance/budgets-large-house-plan-snap.json index cf6ba77c..c09e1742 100644 --- a/demo/performance/budgets-large-house-plan-snap.json +++ b/demo/performance/budgets-large-house-plan-snap.json @@ -55,7 +55,7 @@ "stat": "median", "maxRegressionRatio": 0.35, "noiseAllowanceMs": 250, - "hardMaxMs": 7000 + "hardMaxMs": 950 } }, "longTasks": { diff --git a/demo/performance/budgets.json b/demo/performance/budgets.json index 09a9c6dd..4189ddb4 100644 --- a/demo/performance/budgets.json +++ b/demo/performance/budgets.json @@ -49,7 +49,7 @@ "stat": "median", "maxRegressionRatio": 0.35, "noiseAllowanceMs": 250, - "hardMaxMs": 7000 + "hardMaxMs": 950 } }, "longTasks": { diff --git a/test/performance-budget.test.mjs b/test/performance-budget.test.mjs index 72ef7a95..2b5da6dd 100644 --- a/test/performance-budget.test.mjs +++ b/test/performance-budget.test.mjs @@ -307,6 +307,69 @@ test('isometric space switch ceiling covers the 2.5D runner level and still catc } }); +// #747: обоснование потолков `switchCycleMs` и ряд — demo/performance/README.md, +// «CI contracts». Ряд — медианы Full Performance после #735 (7 образцов, обе +// стороны: база меряется раннером кандидата, окно тёплое) в порядке +// [база, кандидат] прогонов 36821241343 (#735, база 76558bf2), 36838891001 +// (#740), 36838952536 (#742), 36839009721 (#739); у трёх последних база — +// `dev` 7ff2b5ae. Смоковых медиан (3 образца) в ряду пока нет. +const SWITCH_CYCLE_FAMILIES = { + flat: { + ceiling: 950, + smoke: 'budgets-interaction-smoke.json', + files: ['budgets.json', 'budgets-large-house-plan-snap.json', 'budgets-large-house-interaction.json', + 'budgets-interaction-smoke.json'], + full: { maxRegressionRatio: 0.35, noiseAllowanceMs: 250 }, + medians: { + 'large-house-v1': [733.4, 683.3, 812.7, 749.9, 750.3, 800, 716.7, 717.9], + 'large-house-plan-snap-v1': [716.7, 700.1, 762.6, 701, 733.3, 783.2, 749.9, 750.8], + 'large-house-interaction-v1': [716.1, 754.1, 766.5, 666.9, 720.3, 750.1, 700.4, 703.4], + }, + }, + isometric: { + ceiling: 1550, + smoke: 'budgets-isometric-smoke.json', + files: ['budgets-large-house-isometric.json', 'budgets-isometric-stage3-dense.json', 'budgets-isometric-smoke.json'], + full: { maxRegressionRatio: 0.2, noiseAllowanceMs: 250 }, + medians: { + 'large-house-isometric-v1': [1069.5, 1051, 866.5, 766.5, 1134.9, 1100.4, 1135.7, 1089], + 'isometric-stage3-dense-v1': [966.7, 983.2, 982.4, 916.5, 1249.7, 1333.9, 849.9, 803.6], + }, + }, +}; + +for (const [family, spec] of Object.entries(SWITCH_CYCLE_FAMILIES)) { + test(`#747 switchCycleMs (${family}): потолок над тёплым уровнем после #735 с запасом на шум раннера`, () => { + // Одно число на семью: смок = полный (#473 AC4), plan-snap и interaction + // сохраняют потолки large-house-v1, плотный двойник — исторические (#160). + for (const file of spec.files) { + const budget = readBudget(file).timings.switchCycleMs; + assert.equal(budget.hardMaxMs, spec.ceiling, `${file}: общий потолок switchCycleMs семьи ${family}`); + assert.equal(budget.stat, 'median', `${file}: метрика — медиана, как у соседей`); + } + const smoke = readBudget(spec.smoke); + const red = (ms) => evaluatePerformanceBudget({ + candidate: absoluteSmokeReport(smoke, { switchCycleMs: ms }), budgets: smoke, absoluteOnly: true, + }).failures.some((check) => check.id === 'timing.switchCycleMs.median'); + const series = Object.values(spec.medians).flat(); + for (const median of series) assert.equal(red(median), false, `${median} из ряда краснеет на ${spec.ceiling}`); + const level = Math.max(...series); + // Правило #747: первое кратное 50 мс не ниже 1.15 × M и не выше 1.2 × M — + // полоса #692, в которую попадает и #675. + assert.equal(spec.ceiling, Math.ceil((level * 1.15) / 50) * 50, `потолок — первое кратное 50 над 1.15 × ${level}`); + assert.ok(spec.ceiling >= level * 1.15, 'запас над максимумом ряда не меньше 15 %'); + assert.ok(spec.ceiling <= level * 1.2, 'и не больше 20 %: потолок остаётся гардом'); + assert.equal(red(2 * level), true, 'удвоение уровня краснеет'); + // Меньшее ловит относительное сравнение полного прогона: его коэффициент и + // допуск не рычаг. + for (const file of spec.files.filter((name) => name !== spec.smoke)) { + const budget = readBudget(file).timings.switchCycleMs; + assert.equal(budget.maxRegressionRatio, spec.full.maxRegressionRatio, `${file}: коэффициент не рычаг`); + assert.equal(budget.noiseAllowanceMs, spec.full.noiseAllowanceMs, `${file}: допуск не рычаг`); + } + }); +} + test('boundary collision search restores the ordinary isometric allowances (#585)', () => { const isometric = readBudget('budgets-large-house-isometric.json'); const dense = readBudget('budgets-isometric-stage3-dense.json');