mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
ci: перекалибровать общий потолок spaceSwitchMs изометрии по ряду CI (#675)
spaceSwitchMs изометрии — одно холодное переключение этажа со сборкой 2.5D-геометрии; он растёт с каждой стадией 2.5D, и потолок 1800 мс из #89 stage 1 стал самим уровнем. Медиана perf-smoke на hosted-раннере: 1500 мс (09-09…09-12, 12 прогонов) → 1668 (09-19…09-25, 6) → 1798 после #649 (9, σ 42, максимум 1867,7). Попытки одного SHA расходятся на 0,4 %, разные прогоны одного SHA — до 5,5 %: больше образцов вердикт не меняют, рычаг — потолок. 2200 мс: +17,8 % над наблюдённым максимумом и ниже единственной настоящей регрессии окна (2719,6 мс, #583 до решётки, 09-16); удвоение уровня краснеет с запасом. Одно число в трёх файлах: смок = полный профиль (#473 AC4), плотный двойник = исторический (#160). Коэффициент и допуск полного сравнения не тронуты. Обоснование — demo/performance/README.md, тест закрепляет число и три точки ряда. Issue: #675 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:
@@ -99,6 +99,22 @@ still below the known failed pre-optimization result (3501.5 ms). This aggregate
|
||||
is a catastrophic guard; the full workflow's base-relative comparison remains
|
||||
the detector for smaller regressions.
|
||||
|
||||
The isometric `spaceSwitchMs` ceiling is 2200 ms, shared by the smoke, the full
|
||||
isometric profile and its Stage 3 dense twin (#675). The metric is one cold
|
||||
floor switch that builds the second floor's 2.5D geometry, so it grows with
|
||||
every 2.5D stage, and the original 1800 ms from #89 stage 1 had become the level
|
||||
itself. The hosted-runner smoke median rose from 1500 ms (2026-09-09 to 09-12,
|
||||
12 runs) to 1668 ms (09-19 to 09-25, 6 runs) and 1798 ms after #649 (9 runs, σ
|
||||
42 ms, maximum 1867.7 ms). The median inside a run is already stable (re-run
|
||||
attempts of one SHA differ by 0.4 %), separate runs of one SHA differ by up to
|
||||
5.5 %, and the level itself moved by 20 %: more samples would not change the
|
||||
verdict, so the ceiling is the lever. 2200 ms leaves 17.8 % over that maximum
|
||||
and stays below the only real isometric regression of the window, 2719.6 ms
|
||||
(#583 before its lattice fix, 2026-09-16); doubling today's level fails by a
|
||||
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 dedicated `performance.yml` workflow is the full comparison. It runs on
|
||||
every `main` promotion, weekly and on manual dispatch for an important beta or
|
||||
performance-sensitive change. It checks out the candidate and its base SHA,
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
},
|
||||
"spaceSwitchMs": {
|
||||
"stat": "median",
|
||||
"hardMaxMs": 1800
|
||||
"hardMaxMs": 2200
|
||||
},
|
||||
"stateUpdateMs": {
|
||||
"stat": "median",
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"viewToggleMs": { "stat": "median", "maxRegressionRatio": 0.2, "noiseAllowanceMs": 100, "hardMaxMs": 1500 },
|
||||
"openingUpdateMs": { "stat": "median", "maxRegressionRatio": 0.2, "noiseAllowanceMs": 75, "hardMaxMs": 1000 },
|
||||
"overlayInteractionMs": { "stat": "median", "maxRegressionRatio": 0.2, "noiseAllowanceMs": 75, "hardMaxMs": 1000 },
|
||||
"spaceSwitchMs": { "stat": "median", "maxRegressionRatio": 0.2, "noiseAllowanceMs": 100, "hardMaxMs": 1800 },
|
||||
"spaceSwitchMs": { "stat": "median", "maxRegressionRatio": 0.2, "noiseAllowanceMs": 100, "hardMaxMs": 2200 },
|
||||
"stateUpdateMs": { "stat": "median", "maxRegressionRatio": 0.2, "noiseAllowanceMs": 75, "hardMaxMs": 1000 },
|
||||
"resizePreviewMs": { "stat": "median", "maxRegressionRatio": 0.2, "noiseAllowanceMs": 150, "hardMaxMs": 2200 },
|
||||
"panZoomMs": { "stat": "median", "maxRegressionRatio": 0.2, "noiseAllowanceMs": 60, "hardMaxMs": 600 },
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"modelReadyMs": { "stat": "median", "maxRegressionRatio": 0.2, "noiseAllowanceMs": 200, "hardMaxMs": 3000 },
|
||||
"firstStableRenderMs": { "stat": "median", "maxRegressionRatio": 0.2, "noiseAllowanceMs": 250, "hardMaxMs": 3500 },
|
||||
"viewToggleMs": { "stat": "median", "maxRegressionRatio": 0.2, "noiseAllowanceMs": 100, "hardMaxMs": 1500 },
|
||||
"spaceSwitchMs": { "stat": "median", "maxRegressionRatio": 0.2, "noiseAllowanceMs": 100, "hardMaxMs": 1800 },
|
||||
"spaceSwitchMs": { "stat": "median", "maxRegressionRatio": 0.2, "noiseAllowanceMs": 100, "hardMaxMs": 2200 },
|
||||
"stateUpdateMs": { "stat": "median", "maxRegressionRatio": 0.2, "noiseAllowanceMs": 75, "hardMaxMs": 1000 },
|
||||
"resizePreviewMs": { "stat": "median", "maxRegressionRatio": 0.2, "noiseAllowanceMs": 150, "hardMaxMs": 2200 },
|
||||
"panZoomMs": { "stat": "median", "maxRegressionRatio": 0.2, "noiseAllowanceMs": 60, "hardMaxMs": 600 },
|
||||
|
||||
@@ -188,6 +188,33 @@ import { readFileSync } from 'node:fs';
|
||||
|
||||
const readBudget = (name) => JSON.parse(readFileSync(new URL(`../demo/performance/${name}`, import.meta.url), 'utf8'));
|
||||
|
||||
// Синтетический отчёт для `compare --absolute-only`: все метрики по 1 мс,
|
||||
// кроме переданных в `timings`.
|
||||
const absoluteSmokeReport = (smoke, timings = {}) => ({
|
||||
schema: 2, profile: smoke.profile, sourceSha: '1'.repeat(40), buildFingerprint: 'fixture',
|
||||
runtime: { node: 'v22.0.0', chromium: '1.2.3', platform: 'linux', arch: 'x64' },
|
||||
fixture: { rooms: 60 },
|
||||
summary: Object.fromEntries(Object.keys(smoke.timings).map((metric) => {
|
||||
const value = timings[metric] ?? 1;
|
||||
return [metric, { median: value, p95: value, min: value, max: value }];
|
||||
})),
|
||||
longTasks: { maxSingleMs: 1, countP95: 1, totalP95Ms: 1 },
|
||||
...(smoke.profile === 'large-house-isometric-v1' ? { effectiveProjection: ['iso'] } : {}),
|
||||
rows: [0, 1, 2].map(() => ({
|
||||
heapGrowthBytes: 1, preciseGc: true,
|
||||
longTasks: Object.fromEntries(['load', ...Object.keys(smoke.longTaskWindows ?? {})]
|
||||
.map((name) => [name, { supported: true, count: 1, maxMs: 1, totalMs: 1 }])),
|
||||
cacheEntries: { ...smoke.cacheEntries },
|
||||
cacheGrowth: Object.fromEntries(Object.keys(smoke.cacheGrowth).map((key) => [key, 0])),
|
||||
renderedDevices: smoke.renderedDevices,
|
||||
...(smoke.profile === 'large-house-isometric-v1'
|
||||
? {
|
||||
effectiveProjection: 'iso',
|
||||
isoStructuralBuilds: { supported: true, initial: 1, beforeHaUpdate: 2, afterHaUpdate: 2, haUpdateDelta: 0 },
|
||||
} : {}),
|
||||
})),
|
||||
});
|
||||
|
||||
for (const [smokeName, fullName] of [
|
||||
['budgets-isometric-smoke.json', 'budgets-large-house-isometric.json'],
|
||||
['budgets-interaction-smoke.json', 'budgets-large-house-interaction.json'],
|
||||
@@ -214,33 +241,11 @@ for (const [smokeName, fullName] of [
|
||||
|
||||
// Пригодность для --absolute-only: синтетический отчёт под потолками
|
||||
// проходит, первый кадр как у de215578 (9 870 мс) — красный.
|
||||
const build = (firstFrame) => ({
|
||||
schema: 2, profile: smoke.profile, sourceSha: '1'.repeat(40), buildFingerprint: 'fixture',
|
||||
runtime: { node: 'v22.0.0', chromium: '1.2.3', platform: 'linux', arch: 'x64' },
|
||||
fixture: { rooms: 60 },
|
||||
summary: Object.fromEntries(Object.keys(smoke.timings).map((metric) => {
|
||||
const value = metric === 'firstStableRenderMs' ? firstFrame : 1;
|
||||
return [metric, { median: value, p95: value, min: value, max: value }];
|
||||
})),
|
||||
longTasks: { maxSingleMs: 1, countP95: 1, totalP95Ms: 1 },
|
||||
...(smoke.profile === 'large-house-isometric-v1' ? { effectiveProjection: ['iso'] } : {}),
|
||||
rows: [0, 1, 2].map(() => ({
|
||||
heapGrowthBytes: 1, preciseGc: true,
|
||||
longTasks: Object.fromEntries(['load', ...Object.keys(smoke.longTaskWindows ?? {})]
|
||||
.map((name) => [name, { supported: true, count: 1, maxMs: 1, totalMs: 1 }])),
|
||||
cacheEntries: { ...smoke.cacheEntries },
|
||||
cacheGrowth: Object.fromEntries(Object.keys(smoke.cacheGrowth).map((key) => [key, 0])),
|
||||
renderedDevices: smoke.renderedDevices,
|
||||
...(smoke.profile === 'large-house-isometric-v1'
|
||||
? {
|
||||
effectiveProjection: 'iso',
|
||||
isoStructuralBuilds: { supported: true, initial: 1, beforeHaUpdate: 2, afterHaUpdate: 2, haUpdateDelta: 0 },
|
||||
} : {}),
|
||||
})),
|
||||
});
|
||||
const ok = evaluatePerformanceBudget({ candidate: build(1), budgets: smoke, absoluteOnly: true });
|
||||
const ok = evaluatePerformanceBudget({ candidate: absoluteSmokeReport(smoke), budgets: smoke, absoluteOnly: true });
|
||||
assert.deepEqual(ok.failures, [], 'отчёт под потолками обязан проходить');
|
||||
const regressed = evaluatePerformanceBudget({ candidate: build(9870), budgets: smoke, absoluteOnly: true });
|
||||
const regressed = evaluatePerformanceBudget({
|
||||
candidate: absoluteSmokeReport(smoke, { firstStableRenderMs: 9870 }), budgets: smoke, absoluteOnly: true,
|
||||
});
|
||||
assert.ok(regressed.failures.some((check) => check.id === 'timing.firstStableRenderMs.median'),
|
||||
'первый кадр 9 870 мс обязан краснеть');
|
||||
});
|
||||
@@ -257,6 +262,33 @@ test('interaction aggregate keeps hosted-runner headroom without weakening compo
|
||||
assert.equal(smoke.timings.editorSeriesMs.hardMaxMs, 750);
|
||||
});
|
||||
|
||||
// #675: обоснование потолка и ряд замеров — demo/performance/README.md,
|
||||
// «CI contracts». Три файла держат одно число: смок повторяет полный профиль
|
||||
// (#473 AC4), плотный двойник — исторический (#160).
|
||||
test('isometric space switch ceiling covers the 2.5D runner level and still catches #583 (#675)', () => {
|
||||
const files = [
|
||||
'budgets-isometric-smoke.json',
|
||||
'budgets-large-house-isometric.json',
|
||||
'budgets-isometric-stage3-dense.json',
|
||||
];
|
||||
for (const file of files)
|
||||
assert.equal(readBudget(file).timings.spaceSwitchMs.hardMaxMs, 2200, `${file}: общий потолок spaceSwitchMs`);
|
||||
const smoke = readBudget('budgets-isometric-smoke.json');
|
||||
const red = (ms) => evaluatePerformanceBudget({
|
||||
candidate: absoluteSmokeReport(smoke, { spaceSwitchMs: ms }), budgets: smoke, absoluteOnly: true,
|
||||
}).failures.some((check) => check.id === 'timing.spaceSwitchMs.median');
|
||||
assert.equal(red(1867.7), false, 'максимум уровня 2.5D на hosted-раннере (прогон 36306131709) проходит');
|
||||
assert.equal(red(2719.6), true, 'провал #583 до решётки (прогон 35070397356) краснеет и по spaceSwitchMs');
|
||||
assert.equal(red(2 * 1798.3), true, 'удвоение текущего уровня краснеет');
|
||||
// Потолок — не детектор тренда: полный прогон по-прежнему сравнивает с базой
|
||||
// того же раннера, и рычагом не стали ни коэффициент, ни допуск.
|
||||
for (const file of files.slice(1)) {
|
||||
const budget = readBudget(file).timings.spaceSwitchMs;
|
||||
assert.equal(budget.maxRegressionRatio, 0.2, `${file}: коэффициент не рычаг`);
|
||||
assert.equal(budget.noiseAllowanceMs, 100, `${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');
|
||||
|
||||
Reference in New Issue
Block a user