31 KiB
CODE-REVIEW-451-r1
- Issue: #451 «План подтормаживает: диагностика на каждый кадр, реактивная камера, отсутствие фильтра HA-тиков»
- Этап: код-ревью, заход r1 (первый действительно проведённый — предыдущая попытка не читала код: ветка не ребейзилась на
devбез конфликта, цикл не израсходован) - Материал:
git log --oneline origin/dev..HEAD/git diff origin/dev...HEAD, SHA1850eb18b5e8f0813f9d2efb50694dc4ca1fff26 - ТЗ:
docs/specs/451-render-performance.md(полный трек,SPEC-REVIEW-451-r2зелёный) - Вердикт: см. итог в конце документа
Скоуп диффа
75 файлов, +3947/‑1166 (без учёта dist/**/custom_components/.../frontend/**, класс D). Новые модули:
src/render-invalidation.ts, src/houseplan-render-lifecycle.ts, src/live-viewport.ts,
src/live-hover.ts, src/live-editor.ts, src/live-interaction-runtime.ts,
src/pointer-move-queue.ts, src/interaction-types.ts. Изменены src/houseplan-card.ts
(overridden requestUpdate, diagnostics, terminal reconciliation), src/houseplan-editor-runtime.ts
(pointer-move очереди для plan/decor/backdrop/opening/resize), src/render-device-snapshot.ts
(entityIds), src/zigbee-topology-overlay-bridge.ts (live-layer маркер). Плюс performance harness
(demo/benchmark_large_house.mjs, budgets-large-house-interaction.json, card-contract.mjs,
evaluate.mjs, performance.yml) и точечные правки существующих demo/smoke_*.mjs под новую разметку
data-hp-live-*. Геометрия/модель стен, layout, marker.space, open_spans не тронуты — гейт
npm run invariants не требуется, что подтверждается и явным «Non-scope» ТЗ.
Как проверялось
| Гейт | Команда | Результат |
|---|---|---|
| typecheck | npx tsc --noEmit |
зелёный |
| unit | npm test |
1956 tests, 1955 pass, 1 skip, 0 fail |
| build + бандл | npm run build && cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js + diff -rq на houseplan-assets |
совпадают байт-в-байт |
| docs fingerprint | node scripts/check-docs.mjs (обязателен — diff трогает src/**) |
«Documentation checks passed (7 files, 12 external links)» |
| any-гейт | не запускал отдельно — новые файлы не содержат any (проверено чтением), npm test/typecheck зелёные |
проверено чтением |
| smoke-select | node scripts/smoke-select.mjs --base origin/dev --head HEAD |
98 прямых совпадений + 36 слабых из 221 (см. smoke-select.txt) |
| smoke (почти полный набор) | все 134 файла из прямых+слабых совпадений, node demo/smoke_<name>.mjs по одному |
133 OK, 1 нестабильный (см. ниже, не регрессия) |
| golden | npm run golden:verify |
153/153 сценариев passed, 0 diff — доказывает AC9 (pixel-equivalence) |
| performance (структурные assert'ы) | npm run benchmark:large-house-interaction -- --samples=7 --warmups=1 |
завершился без throw — структурный контракт (0 full render на hover/pan/editor move, 1 terminal, 0 diagnostics scans) подтверждён на этом SHA |
| performance (абсолютные потолки) | npm run benchmark:compare -- --absolute-only --budgets=demo/performance/budgets-large-house-interaction.json --candidate=<report> |
6 проверок красные — см. находку H1 |
| backend | не запускал — диф не трогает custom_components/**/*.py (проверено чтением: git diff --name-only не содержит .py) |
проверено чтением |
| invariants | не запускал — диф не трогает геометрию/layout/marker.space/open_spans (проверено чтением) |
проверено чтением |
Не прогонял полный demo/smoke_*.mjs (все ~221) и полный performance.yml (Full Performance,
парный base/candidate на двух чекаутах) — первое избыточно (134 из 221 уже покрывают все «прямые» и
«слабые» совпадения smoke-select, второе требует парного окружения, которое не гейт код-ревью
(PROCESS.md §8: «полные наборы — предрелizный гейт»). Раздельный прогон --absolute-only (см. выше)
компенсирует это для абсолютных потолков нового профиля — и именно он нашёл H1.
Находки
H1 (High, блокирует) — новый performance-профиль large-house-interaction-v1 не проходит собственный бюджет
AC10 требует: «новый interaction profile проходит все structural, absolute и base-relative checks
§11». Структурные проверки (0 full render на hover/pan/camera/editor move, ровно 1 terminal, 0
diagnostics scans, стабильный heavy DOM, ошибка совмещения SVG/HTML ≤1px) — подтверждены, профиль не
бросает исключение. Но абсолютные потолки, которые сам же диф вводит в
demo/performance/budgets-large-house-interaction.json, не выполняются при фактическом прогоне.
Воспроизведение (детерминировано, не связано со скоростью машины — см. ниже):
npm run benchmark:large-house-interaction -- --samples=7 --warmups=1 --output=/tmp/interaction-review7.json
npm run benchmark:compare -- --absolute-only \
--budgets=demo/performance/budgets-large-house-interaction.json \
--candidate=/tmp/interaction-review7.json --output=/tmp/interaction-compare7.json
Результат — 6 красных строк:
❌ timing.interactionSeriesMs.median | 3501.5 | 3000
❌ timing.editorSeriesMs.median | 1841.9 | 750
❌ longTask.editorSeries.maxSingleMs | 878 | 150
❌ longTask.editorSeries.countP95 | 4 | 3
❌ longTask.editorSeries.totalP95Ms | 1633 | 300
❌ cache.entries.cleanFloor | 140 | 100
cache.entries.cleanFloor — счётчик записей Map, не таймінг, машинно-независим. Проверено на двух
независимых прогонах (--samples=3 и --samples=7), значение 140 во всех 10 сэмплах без единого
отклонения. Для контроля прогнан немодифицированный large-house-v1 (существует и на dev, диф его
не трогает):
npm run benchmark:large-house -- --samples=3 --warmups=1 --output=/tmp/base-profile-review.json
# cleanFloor: 100, 100, 100 — ровно документированный потолок (demo/performance/README.md:
# «the reviewed fixture warms exactly 100 deterministic room/physical-body entries»)
Значит переполнение специфично именно для новой editor series части large-house-interaction-v1
(последовательность _setMode('plan') → _tool='resize' → cancel → _setMode('decor') →
_setMode('view')), которую этот же диф добавил в demo/benchmark_large_house.mjs. Разница ровно
+40 записей (два «лишних» полных прохода флор-кэша сверх документированного одного) коррелирует с тем,
что тайминговые метрики именно editorSeriesMs/longTask.editorSeries.* превышены в 2,4× и более —
похоже на одну и ту же причину (лишний(е) пересчёт(ы) _cleanFloor/floorMinusBodies при переключении
режима редактора), а не на независимый шум.
Попытка воспроизвести на упрощённой ручной фикстуре (page.evaluate с прямым вызовом _setMode без
полного цикла реального pointer-move, который использует бенчмарк) рост _cleanFloorCache.size не
показала — то есть причина не в самом факте смены режима, а где-то в фактической
resize/decor-транзакции бенчмарка (drag-preview → cancel) или в стечении с реальными pointer-событиями.
Дальнейшая локализация — задача автора; здесь важен сам факт: AC10 не выполнен по написанному самим
автором для этой задачи бюджету, а не только гипотеза о причине.
Тайминговые превышения (interactionSeriesMs, editorSeriesMs, longTask.editorSeries.*) в отличие
от cleanFloor теоретически могут быть частично усилены более медленной/разделяемой машиной ревьюера
против выделенной машины владельца (demo/performance/README.md: «Local Windows checkout is the
day-to-day environment... local report is diagnostic only»), но margin (1842 мс против потолка 750 мс,
878 мс одна long task против потолка 150 мс) для чистого шума великоват, и коррелирует с детерминированным
cleanFloor-превышением. Автору стоит перепроверить оба числа на каноническом Linux CI парном прогоне
(performance.yml), но независимо от результата cache-count уже доказан красным и машинно не
объясним — это не «непрогнанный дорогой гейт», это прогнанный и красный.
Почему High, а не Medium. Задача, ценность 10/10 и P1, явно посвящена именно тому, чтобы новый
перф-контракт «краснел сам по себе, а не только на фоне предыдущего коммита» (issue, раздел «Почему это
не поймал перф-гейт»). Проверка, которую AC10 называет своим доказательством, при фактическом запуске
красная. Это не вопрос «недостаточно полно проверили», а обнаруженный красный автотест по собственному,
введённому этим же диффом контракту — ровно то, что код-ревью обязано поймать (PROCESS.md §2.7:
«ревьюер отвечает за AC… либо доказан автотестом… либо разобран по коду»). Фикс — в скоупе (тот же
demo/benchmark_large_house.mjs/бюджет), отдельный issue не заводится.
M1 (Medium, в скоупе) — обещанный в самом ТЗ «targeted production-bundle smoke» не создан
ТЗ §13.2 и §14 (план реализации, п. 5) явно называют отдельный артефакт: «Targeted demo/smoke_*.mjs —
production-bundle render-count и interaction matrix», инструментирующий full-render count, lightweight
paints, diagnostics scans, heavy node identity, writes на PRODUCTION-бандле (не на perf-фикстуре) — и
именно это заявлено как доказательство AC5/AC6/AC7/AC8 (smoke в их графе доказательства).
git diff origin/dev...HEAD --diff-filter=A показывает: новых файлов demo/smoke_*.mjs нет вовсе.
Изменения в существующих смоках (smoke_decor.mjs, smoke_furniture.mjs, smoke_decor_text.mjs,
smoke_resize_pointer_real_plan.mjs, smoke_pan_any_zoom.mjs, smoke_room_fit.mjs,
smoke_infinite_canvas.mjs, smoke_opening_measure.mjs, smoke_partition_openings.mjs,
smoke_drag_bounds.mjs, smoke_room_tooltip_toggle.mjs, smoke_ux_fixes.mjs) — точечная подгонка под
новую разметку data-hp-live-*/тайминг RAF, ни один не считает full-render/diagnostics-scan счётчики.
Единственное место, где эти структурные гарантии вообще проверяются — demo/benchmark_large_house.mjs --profile=large-house-interaction-v1, которое подключено только к job'е performance.yml → «Full
Performance», запускаемой (см. demo/performance/README.md, «CI contracts») на промоушен в main,
раз в неделю и по ручному диспатчу — не на каждый push в dev. Блокирующий performance_smoke в
validate.yml меряет только large-house-glow-overlay-v1, этого профиля не касается. При принятой в
проекте модели прямых коммитов в dev без PR (AGENTS.md) это означает: следующий обычный коммит,
который случайно вернёт diagnostics-scan на каждый рендер или полный render на hover, не будет
пойман никаким гейтом до следующего еженедельного/promotion-прогона — то есть тем самым классом
слепого пятна, ради которого заведён #451 (issue, раздел «Почему это не поймал перф-гейт»: «гейт
исправно подтверждал, что "не стало хуже", пока пользователь смотрел на 451-миллисекундные фризы»).
Фикс в скоупе: добавить обещанный demo/smoke_*.mjs (инструментирование _renderBody/
_bindingStatus аналогично тому, что уже сделано внутри benchmark_large_house.mjs, но на
production-бандле через demo/serve.mjs, в постоянно запускаемом наборе).
M2 (Medium, в скоупе) — нет мутанта в scripts/mutation-gate.mjs для новых защитных механизмов
PROCESS.md §2.7: «Мутант в scripts/mutation-gate.mjs обязателен, когда защита живёт в продуктовом
коде и проверяется дорогим гейтом (смок, бэкенд, golden): там ревьюер не воспроизведёт отрицательный
прогон второй раз». git diff origin/dev...HEAD -- scripts/mutation-gate.mjs — пусто, ни одной новой
записи.
Часть новых защит — чистые юниты, и для них я сам прогнал мутацию и увидел красный тест (см. «Таблица
доказательств» ниже: classifyHassRenderChange, RenderLifecycle.diagnostics) — этого достаточно,
отдельный мутант не нужен. Но интеграционная проводка в houseplan-card.ts (переопределённый
requestUpdate, _flushHa/terminal reconciliation, RAF-coalesced camera/hover/editor paints) —
юнитами не покрыта и проверяется только дорогим Playwright-бенчмарком (тем же, что в M1/H1). Без
постоянного мутанта в следующем раунде (после того, как H1 будет исправлен и профиль перестанет
краснеть) ревьюер снова не сможет дёшево доказать «тест умеет падать» — придётся заново гонять
браузерный бенчмарк вручную, как это делал я.
Low — известная нестабильность smoke_smooth_zoom.mjs под нагрузкой (не регрессия, к сведению)
При параллельном прогоне 134 смоков (4 конкурентных пакета) demo/smoke_smooth_zoom.mjs дал один
красный результат (wheelRetargetsRunningTween/wheelHasIntermediateFrames/wheelStreamPersistsOnce
= false). Проверил отдельно: последовательно (без конкуренции за CPU) — 11/11 прогонов зелёные, как на
этой ветке, так и на чистом origin/dev. Затем воспроизвёл красный результат на обеих ветках
одинаково при явной конкуренции (4 параллельных smoke_smooth_zoom.mjs + 3 тяжёлых смока рядом: 2-3 из
4 падали и на dev, и на PR). Вывод: это существующая до #451 чувствительность теста к CPU-контеншну
(reliance на реальные requestAnimationFrame тайминги), не регрессия этой задачи. Не блокирует, в
скоуп #451 не входит, отдельный issue не заводится (не находка, а объяснённая флакиность).
Что проверено и корректно
- AC1 (HA intake ≠ visual invalidation):
requestUpdate('hass', …)вhouseplan-card.tsделегирует решениеLiveRuntime.hass()→classifyHassRenderChange(); при'none'/deferred-'state'intake (intakeHass) всё равно выполняется синхронно черезRenderLifecycle.observe. Доказано юнитомtest/houseplan-render-lifecycle.test.mjs(«HA intake runs once even when an unrelated visual update is skipped») + мутацией (см. ниже). - AC2 (dependency classifier):
src/render-invalidation.ts. Структурные top-level поля (entities,devices,themes,locale.*, неизвестный новый ключ) — fail-open на'structural'; толькоstatesсравнивается по dependency-scopedentityIds. Доказаноtest/render-invalidation.test.mjs(4/4) + мутацией: убрал раннийreturn 'none'→заменил на безусловныйreturn 'state'в скомпилированномtest-build/render-invalidation.js(не вsrc/**, генерируемый файл) — тест «an unrelated HA state row does not invalidate the plan frame» покраснел (expected 'none', actual 'state'), затем восстановил файл, тест снова зелёный. - AC3 (intake переживает пропущенный рендер): dependency projection в
_captureRenderDeviceSnapshot(houseplan-card.ts:4563+) собирает entity/device/area из bindings, room temp/hum source, opening entity refs, live-text,sun.sun, vacuum source — доказано чтением и подтверждено проходом смоковsmoke_linked_virtual_light,smoke_static_icon,smoke_cover_no_plate,smoke_cover_plate_precedence,smoke_entity_parent_dedup,smoke_yellow_principle(все зелёные). - AC4 (diagnostics cache):
RenderLifecycle.diagnostics()/invalidate()вhouseplan-render-lifecycle.ts. Единственный вызовhouseplanDiagnostics()-подобной логики теперь и в_renderBody(черезthis._renderLife.diagnostics(...)), и в публичномhouseplanDiagnostics()— один источник (см. «Одно число — один источник» ниже). Доказано юнитом («diagnostics scan is cached and invalidated by tracked state presence») + мутацией: заменил guardif (this.diagnosticsCache)наif (false && this.diagnosticsCache)вtest-build/houseplan-render-lifecycle.js— тест покраснел (2 !== 1, ожидался один скан вместо двух), восстановил, снова зелёный. - AC5/AC6/AC7 (0 full render на hover/pan/pinch/camera/editor move, 1 terminal, deferred-relevant-tick
last-wins): структурные assert'ы внутри
demo/benchmark_large_house.mjs(throw при нарушении) подтверждены самостоятельным прогоном на этом SHA — все условия выполнены (irrelevantFullRenders:0,hoverFullRenders:0,panMoveFullRenders:[0,0,0,0]/terminalFullRenders:[1,1,1,1],cameraMoveFullRenders:0/cameraTerminalFullRenders:1,editorMoveFullRenders:[0,0,0],relevantDuringGestureFullRenders:0приrelevantDuringGestureIntakes:3,diagnosticsScans:0,heavyNodeStable:true,overlayErrorPx:0.01). Тайминги того же прогона см. H1. - AC8 (coalescing сохраняет конечную координату/commit/cancel):
pointer-move-queue.ts(queueMicrotask, гарантированно опустошается до следующего браузерного события) + подключение во всех точках (_pointerMove/device,_physicalMove,_rszMove,_bdMove,_opPointerMove,_dtMove,_decorMoveUpdate,_markupMove) с явнымflushна terminal (up) иcancelна отмене. Доказано юнитомtest/live-editor.test.mjs(«pointer move queue is event-turn coalesced, last-wins and flushable») + прогоном затронутых production-bundle смоков (smoke_furniture,smoke_decor,smoke_decor_text,smoke_drag_bounds,smoke_partition_openings,smoke_opening_measure,smoke_resize_pointer_real_plan— все зелёные, включая ровно те сценарии shift-snap/magnet/rotate, которые чувствительны к потере промежуточного состояния). - AC9 (pixel-equivalence):
npm run golden:verify— 153/153passed, 0 diff, включаяlarge-house-zoom-040/250-dark,large-house-warm-remount-dark, junction-серию, hover-серию, dark/light/RU/EN варианты. Прямое доказательство отсутствия непреднамеренных визуальных изменений. - AC11 (implementation-loop gates): typecheck/test/build зелёные (таблица выше).
- AC12 (schema/backend/i18n/dependencies не изменены):
git diff --name-onlyне содержит.py/i18n/translations/manifest.json/hacs.json— проверено чтением. Новые кэши (RenderLifecycle.diagnosticsCache,LiveRuntime/live-viewport/live-hover/live-editorstate) — всеWeakMap<object,…>, ключ — сам инстанс карточки;disconnectedCallbackвызывает_liveRt?.dispose()и_editorRuntime?._disposeLiveEditor()(cancel RAF, restore hidden/transparent DOM,cancelHouseplanPointerMove) — bounded per card, очищаются при disconnect, независимы между несколькими карточками на дашборде (WeakMap не даёт общий каталог). - AC13 (документация):
demo/performance/README.mdописывает новый профиль, сценарии, структурные assertions, абсолютные потолки, local diagnostics-команду;docs/CHANGELOG.md/docs/CHANGELOG.ru.mdобновлены в том же коммитеc0d61ca3(User-Visible: yes), формулировка «внешний вид и результат действий не меняются» соответствует AC9/non-scope. - Одно число — один источник:
houseplanDiagnostics()(публичный support-report) и внутренний render-path теперь читают один и тот жеRenderLifecycle.diagnostics()— раньше было два независимых прохода по маркерам с одинаковой логикой (потенциальное расхождение при будущей правке одного из них), теперь физически один вызов на одну инвалидацию (доказано юнитом «scans === 1» выше). Другой видимый дважды параметр — zoom badge (Math.round(zoom*100)%) и_zoom/_view: и DOM-бейдж (live-viewport.ts:paintLiveViewport), и canonical-поле читают один и тот жеframeOf(host)—host._zoom, без второго независимого округления. - Трейлеры: все 10 коммитов несут
Issue: #451; ровно одинUser-Visible: yes(c0d61ca3, «fix: keep large-plan interactions off the full render path») с обоими changelog в том же коммите; остальныеUser-Visible: no(документация/фикс-ап/ребилд бандла) — корректно. - Touch/pen/kiosk/fixed-floor контракт (§9 ТЗ): не даёт отдельной ветки поведения — подтверждено
прохождением
smoke_isometric_live_touch,smoke_kiosk,smoke_kiosk_pan_lock,smoke_fixed_floor,smoke_touch_tips,smoke_grid_scale_invariance(все зелёные) плюс golden dark/light/RU/EN сценарии.
Чего не проверял
- Полный
demo/smoke_*.mjs(все ~221) — прогнал 134 (все «прямые» и «слабые» совпадения smoke-select.mjs), сознательно не прогонял оставшиеся ~87 без связи с диффом (полный набор — предрелизный гейт, PROCESS.md §8). - Парный
performance.yml(«Full Performance», base vs candidate на двух чекаутах, реальный Chromium CI-раннер владельца) — недоступен в этом окружении; вместо него прогнал--absolute-only(см. H1, нашёл реальную красноту) и структурные assert'ы бенчмарка напрямую. Относительное (base-vs-candidate) сравнение не проверено — вероятно, тоже покраснеет из-за той же причины, что и абсолютное, но это не проверено напрямую. - Backend (
python -m pytest tests_backend) — не запускал, диф не касаетсяcustom_components/**/*.py(проверено чтением diff, не исполнением). npm run invariants— не запускал, диф не касается геометрии/layout/marker.space/open_spans(проверено чтением diff и Non-scope раздела ТЗ).- Точная локализация причины лишних 40 записей
cleanFloor(H1) — воспроизвёл и доказал факт, не нашёл точную строку кода; это оставлено автору при исправлении H1. - Ручная проверка в реальном браузере (вне headless Playwright) и на настоящей Home Assistant — вне доступного окружения; полагался на golden/smoke/unit/performance гейты.
Материал раунда
- Ветка:
issue/451-render-performance - SHA материала:
1850eb18b5e8f0813f9d2efb50694dc4ca1fff26 - Диапазон:
origin/dev...HEAD(10 коммитов,029aba6b..1850eb18) - ТЗ:
docs/specs/451-render-performance.md, ревью —docs/reviews/SPEC-REVIEW-451-r2.md(зелёное)
Итог
Вердикт: красный. Одна High-находка (H1 — заявленный самим диффом перф-контракт AC10 красный при
фактическом прогоне compare.mjs --absolute-only по собственному новому бюджету, доказано
детерминированным счётчиком cache.entries.cleanFloor: 140 vs 100 плюс коррелирующими превышениями
таймингов editorSeriesMs/longTask.editorSeries.*) блокирует переход. Плюс две Medium-находки в
скоупе (M1 — не создан обещанный ТЗ demo/smoke_*.mjs для render-count на production-бандле, M2 — нет
мутанта в scripts/mutation-gate.mjs для интеграционной проводки, проверяемой только дорогим
бенчмарком). Сама архитектура решения (разделение intake/visual invalidation, dependency classifier,
diagnostics cache, RAF-coalesced live-слои камеры/hover/редакторов, pointer-move очереди) сделана
аккуратно и проверяемо: юнит-защиты действительно ловят мутацию, structural assertions бенчмарка
действительно бросают исключение при нарушении контракта, golden/smoke/typecheck/build/docs — все
зелёные. Возврат автору — на исправление конкретно H1 (и желательно M1/M2 в том же цикле, раз без
High они и так были бы Medium-в-скоупе), без расширения скоупа задачи.
Материал раунда
- Ветка:
issue/451-render-performance, коммит1850eb18b5e8— ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет. - Дерево материала:
30dcff478a0287b209be4c3066972158d6e6b539git log --all --format='%H %T' | grep 30dcff478a02 - ТЗ
docs/specs/451-render-performance.md, блоб7c323a29110974aae369077214b9e2a74d9387c1git log --all --find-object=7c323a29110974aae369077214b9e2a74d9387c1 -- docs/specs/451-render-performance.md