27 KiB
CODE-REVIEW-451-r2
- Issue: #451 «План подтормаживает: диагностика на каждый кадр, реактивная камера, отсутствие фильтра HA-тиков»
- Этап: код-ревью, заход r2
- Материал:
git log --oneline origin/dev..HEAD/git diff origin/dev...HEAD, точный SHAcb68492c990b27e91c35130fa3a77806bd157c5e(веткаissue/451-render-performance) - Предыдущий раунд:
CODE-REVIEW-451-r1, SHA1850eb18b5e8f0813f9d2efb50694dc4ca1fff26, вердикт красный, High 1 / Medium 2 (SHA назван в самом документе r1,Материал:; в комментарии-вердикте issue SHA не упомянут — это несоответствие оформления, не находка по существу) - ТЗ:
docs/specs/451-render-performance.md(полный трек,SPEC-REVIEW-451-r2зелёный) - Вердикт: см. итог в конце документа
Дельта раунда
git diff 1850eb18..cb68492c — 8 источников кода/скриптов вне dist/**/custom_components/.../frontend/**
(класс D, бинарные копии бандла синхронизированы автоматически и не разбираются построчно):
| Файл | Изменение |
|---|---|
src/junction-limits.ts |
новая экспортируемая junctionLimitViolations() — общая реализация full/affected-room проверки, вынесенная из houseplan-editor-runtime.ts; добавлен lightweight-режим (переиспользует переданный multiWallNodes, не пересобирает wallBodiesGeometry) |
src/houseplan-editor-runtime.ts |
_junctionLimitViolations делегирует в новую функцию с roomIds; _rszProjectPreview больше не вызывает _rszSpaceCandidateGeometry/_checkSpacePhysicalGeometry на каждый move — использует lightweight junction-check, ограниченную changedRoomIds; полная проверка (_rszCandidateRenderable) осталась только в finish(); _rszAcceptPreview/_rszCancelDrag управляют _cfgEpoch/_physicalBodiesCache.key точнее; _renderResizeLayer/_renderOpenings/_renderDecorLayer получили фильтры roomIds/onlyIds/onlyId |
src/live-editor.ts |
resize-preview теперь рисует только затронутые стены (resizePreviewWalls, читает room.poly/`wall.a |
src/houseplan-card.ts |
_terminalFrame (0/1/2): _renderBody() возвращает noChange на кадре отмены resize/decor, если состояние байт-в-байт совпадает с исходным; requestUpdate сбрасывает флаг при любом именованном свойстве |
src/resize-controller.ts + test/resize-controller.test.mjs |
cancel() возвращает restoreEpoch, чтобы _rszCancelDrag мог откатить _cfgEpoch, а не только _wallUnionCache |
demo/benchmark_large_house.mjs |
измерение longTask.editorSeries разбито на три отдельных окна (по одному на resize/resize/decor-часть), вместо одного окна на весь editor-сценарий |
scripts/bundle-budget.mjs |
INITIAL_VIEW_GZIP_CEILING 297 000 → 298 000, с обоснованием |
scripts/mutation-gate.mjs + demo/smoke_render_invalidation.mjs (новый) |
закрывают M1/M2 r1 |
Геометрия и ссылки на неё прямо затронуты (junction-limits.ts, чтение wall.a/b/cm/key,
room.poly, multiWallNodesForGeometry, wallBodiesGeometry, innerContourForRoom) — разбор
не сокращается до «только заявленных находок», гейты по геометрии прогнаны (см. ниже).
Как проверялось
| Гейт | Команда | Результат |
|---|---|---|
| typecheck | npx tsc --noEmit |
зелёный |
| unit | npm test |
1956 tests, 1955 pass, 1 skip, 0 fail |
| build + бандл | npm run build && node scripts/bundle-sync.mjs + cmp/diff -rq трёх копий |
совпадают байт-в-байт |
| docs fingerprint | node scripts/check-docs.mjs --external (обязателен — diff трогает src/**) |
красный, см. H1 |
| any-гейт | node scripts/no-new-any.mjs --base f8b3445d --head cb68492c (та же пара, что использует CI-джоба этого пуша) |
красный, см. H2 |
| invariants | npm run invariants -- --config <экспорт large-house фикстуры> (diff трогает геометрию/wall-thickness) |
«Инварианты выполнены: ссылки разрешимы, записи толщины находятся» |
| smoke-select (по дельте) | node scripts/smoke-select.mjs --base 1850eb18 --head cb68492c |
58 прямых, 27 слабых, 1 зарегистрированная связь (smoke_real_plan_masonry.mjs ← wallBodiesGeometry) |
| smoke (все прямые + зарегистрированная + 5 смежных по физической геометрии) | 66 файлов, по одному node demo/smoke_<name>.mjs (список — в приложении к комментарию) |
64 OK, 1 красный воспроизводимо (smoke_room_resize.mjs, см. H3), 1 красный только под конкуренцией за CPU при последовательном прогоне всех 66 (smoke_edit_walk.mjs; повторный изолированный прогон — 6/6 OK, тот же класс флакиности, что и smoke_smooth_zoom в r1, не регрессия) |
| golden | npm run golden:verify |
153/153, 0 diff |
| performance (структурные assert'ы) | npm run benchmark:large-house-interaction -- --samples=7 --warmups=1 (×3 независимых прогона) |
завершается без throw каждый раз |
| performance (абсолютные потолки) | npm run benchmark:compare -- --absolute-only --budgets=demo/performance/budgets-large-house-interaction.json на всех 3 прогонах |
cache.entries.cleanFloor = 100/100 во всех трёх (совпадает с заявленным автором числом, H1 r1 в этой части закрыт); но в каждом из 3 прогонов минимум один тайминговый чек красный (longTask.editorSeries.maxSingleMs 231–324 vs 150 во всех трёх; изредка также timing.interactionSeriesMs.median и longTask.editorSeries.totalP95Ms) — см. «Под вопросом» ниже |
| backend | не запускал — диф не трогает custom_components/**/*.py (проверено чтением: git diff 1850eb18..cb68492c --name-only не содержит .py) |
проверено чтением |
| мутация | node scripts/mutation-gate.mjs --id=render-invalidation-renders-irrelevant-ha |
«поймано 1 из 1» — M2 r1 подтверждён рабочим |
CI на этом же SHA (cb68492c, run 33918076536) независимо подтверждает H1 и H2: джоба
«Предполётные проверки» красная на DOCS: failure, джоба «Фронтенд: типы, юниты, мутанты,
синхрон бандла» красная на «Новый код не добавляет any: 11» — обе с теми же файлами/строками,
что и локальный прогон ниже. Всё, что стоит за этими двумя джобами по конвейеру (браузерные
смоки, перф-смок, golden), было skipped, то есть CI не подтверждает и не опровергает
performance-часть отдельно от того, что нашёл я локально.
Находки
H1 (High, блокирует) — отпечаток скриншотов документации устарел, docs-джоба красная
node scripts/check-docs.mjs --external (та же команда, что в validate.yml:59) завершается:
ERROR screenshot source fingerprint is stale; run npm run build && node demo/docs/capture.mjs
visualFingerprint() считается по всему src/** (#245: версия не в счёт, но код — да), а
раунд r2 меняет src/houseplan-card.ts, src/houseplan-editor-runtime.ts,
src/junction-limits.ts, src/live-editor.ts, src/resize-controller.ts без сопутствующего
npm run build && node demo/docs/capture.mjs. Автор прогонял этот гейт в r1 (документ
CODE-REVIEW-451-r1 фиксирует «Documentation checks passed»), но в комментарии о фиксе r1
(Исправления по CODE-REVIEW r1, 2026-09-04T20:48:54Z) check-docs не упомянут вовсе — гейт
пропущен именно там, где diff тронул src/** заново. Ровно этот класс пропуска уже дважды
оставлял dev с красной docs-джобой до следующей задачи (#230, #234 → #237); здесь он пойман
до слияния. Фикс не меняет ни одного пикселя (перерисовка внутренняя), поэтому обновление —
чисто отпечаток, npm run build && node demo/docs/capture.mjs.
Подтверждено независимо: CI-прогон 33918076536 на этом же SHA, джоба «Предполётные проверки»,
DOCS: failure.
H2 (High, блокирует) — новый код добавляет 11 непрокомментированных any, гейт красный
node scripts/no-new-any.mjs --base f8b3445d --head cb68492c (пара база/head, которую
использовала CI-джоба этого пуша) находит 11 новых явных any на добавленных строках без
обоснования // any-ok: …:
src/houseplan-card.ts:7792, src/junction-limits.ts:51,54,57,73,
src/live-editor.ts:61,62,65,157,163,164
Все — в коде, который непосредственно реализует ТЗ этого раунда (сигнатуры
junctionLimitViolations/LiveEditorHost, читающие config/space/room как any). Часть
можно типизировать через уже существующие интерфейсы (ServerConfig, SpaceModel), часть —
обосновать комментарием на той же строке; гейт не требует нулевого any в принципе (в src/**
их 1034 в 49 файлах), а требует явного решения по каждой НОВОЙ строке.
Подтверждено независимо: тот же CI-прогон, джоба «Фронтенд: типы, юниты, мутанты, синхрон бандла», шаг «Новый код не добавляет any», идентичный список строк.
H3 (High, блокирует) — resize потерял видимую fail-closed реакцию на невозможную геометрию посреди жеста
node demo/smoke_room_resize.mjs падает воспроизводимо (проверено дважды подряд, изолированно
и в общем прогоне — одинаково):
FAILED (2):
- safe_resize.preflight_visible_reason: expected true, got false
- safe_resize.preflight_reason_once: expected 1, got 0
Смок (существовал до #451, не новый) подставляет card._checkSpacePhysicalGeometry = () => ({ ok: false }) и ожидает, что во время протяжки (pointermove, до pointerup) карточка
покажет тост «last safe position/последн…» ровно один раз. До этого раунда _rszProjectPreview
на каждый move вызывал _rszSpaceCandidateGeometry() → _checkSpacePhysicalGeometry() и
отклонял проекцию с reason: 'physical-geometry', если проверка проваливалась — именно так
жест «останавливался на последней безопасной позиции» с видимой причиной. Правка H1-r1 убрала
этот вызов из _rszProjectPreview целиком (осталась только lightweight junction-limit проверка,
ограниченная changedRoomIds, — другой класс проверки: буквенные лимиты узлов/клиренса, а не
собираемость геометрии стен). Полная проверка (_checkSpacePhysicalGeometry) осталась только в
_rszCandidateRenderable, вызываемой из _rszUp() → finish() — то есть только по
pointerup, с общим resize.commit_failed тостом вместо специфичного отказа посреди жеста.
Строка // #329 AC7a: a step that would ADD a junction-limit violation is never projected, so the drag stops at the last allowed position… (houseplan-editor-runtime.ts:3657) осталась в коде
рядом с местом, где ссылалась на удалённую проверку — комментарий теперь описывает только
junction-limit ветку, а не physical-geometry, но текст не обновлён и вводит в заблуждение.
Ни один юнит-тест не покрывает новую junctionLimitViolations()/lightweight-режим отдельно —
единственная проверка всей цепочки жила в этом смоке, и правка её не заметила именно потому, что
npm test эту ветку не касается (только браузерный смок её и держал). Это тот же структурный
разрыв, который M2 r1 уже называл для другой части того же диффа: дорогой гейт — единственный
свидетель контракта.
Почему High. Регресс в контракте, существовавшем до #451 (не новая функциональность этого
issue), ловится существующим (не добавленным этим PR) тестом, воспроизводится детерминированно.
User-Visible: no в трейлере коммита cb68492c в этом свете неточен: сценарий редкий
(конкурентное структурное изменение во время жеста), но при его наступлении пользователь раньше
видел объяснение и жест останавливался, а теперь видит только общий отказ после отпускания
курсора — то есть разное поведение, а не «незаметная» оптимизация.
Под вопросом, не поднимаю до находки
longTask.editorSeries.maxSingleMs (потолок 150 мс) красный во всех 3 независимых прогонах
--samples=7 --warmups=1 на этом SHA (231, 324, 231 мс), при этом cache.entries.cleanFloor
(машинно-независимый счётчик, из-за которого r1 был красным) держит ровно 100/100 во всех трёх —
то есть структурная часть H1-r1 закрыта чисто, а тайминговая часть держится у самой границы с
переменным исходом (в одном из трёх прогонов краснели ещё timing.interactionSeriesMs.median и
longTask.editorSeries.totalP95Ms, в двух других — нет). Отличие от r1: там margin был ~6×
(878 мс против потолка 150) и детерминирован; здесь ~1.5–2× и не детерминирован — характернее
для шумной/разделяемой машины ревьюера, чем для структурного регресса (demo/performance/README.md
прямо называет локальный прогон диагностическим). CI не добрался до перф-джобы на этом SHA
(skipped из-за H1/H2), так что канонического парного Linux-сравнения по этому раунду ещё нет ни
у кого. Не поднимаю до отдельной находки, но и не закрываю: автору нужен чистый прогон
performance.yml (или локальный на менее нагруженной машине) после того, как H1/H2/H3 будут
исправлены, прежде чем считать AC10 доказанным на этом SHA.
Закрытие раунда r1
| Находка r1 | Чем закрыта | Где видно |
|---|---|---|
H1 (перф-бюджет large-house-interaction-v1 красный, включая машинно-независимый cache.entries.cleanFloor 140≠100) |
Resize-preview больше не строит канонический union/full-room clearance на каждый move — только lightweight junction-check по changedRoomIds |
cache.entries.cleanFloor = 100/100 в трёх независимых прогонах этого раунда (см. таблицу выше); тайминговая часть бюджета — см. «Под вопросом» |
| M1 (нет production-smoke на full-render/diagnostics-scan счётчики) | Добавлен demo/smoke_render_invalidation.mjs |
файл существует, node demo/smoke_render_invalidation.mjs → OK |
M2 (нет мутанта в scripts/mutation-gate.mjs для интеграционной проводки) |
Добавлен мутант render-invalidation-renders-irrelevant-ha |
node scripts/mutation-gate.mjs --id=render-invalidation-renders-irrelevant-ha → «поймано 1 из 1» |
Унаследовано из r1 (не перепроверялось в этом раунде)
Со ссылкой на CODE-REVIEW-451-r1 на SHA 1850eb18, за пределами того, чего касается дельта
1850eb18..cb68492c:
- AC1 (HA intake ≠ visual invalidation), AC2 (dependency classifier), AC3 (terminal
reconciliation вне resize/decor cancel-ветки), AC4 (RAF-coalesced hover/camera paths вне
editor-preview), AC9 (pixel-equivalence) — код этих путей дельтой не тронут; повторно
перечитан не был. AC9 переподтверждён косвенно свежим прогоном
golden:verify(153/153) в этом раунде, остальное — по r1. - Скоуп диффа (75 файлов на SHA r1) вне восьми файлов, изменённых в этом раунде — не перечитывался повторно.
- Backend/invariants-обоснование «диф не трогает
custom_components/**/*.py» — верно и для дельты этого раунда (см. таблицу выше), проверено заново, не только унаследовано. - Трейлеры
Issue:/User-Visible:коммита r1 (f8b3445d,1850eb18и предыдущие) — не перепроверялись; трейлеры коммитаcb68492cпроверены заново (см. H3 про неточностьUser-Visible: no).
Что проверено и корректно
_rszCandidateRenderable/_checkSpacePhysicalGeometryостаются полной, дорогой проверкой наfinish()(пойнтерап) — итоговый коммит геометрии по-прежнему фейлится закрыто; регресс H3 касается только видимой обратной связи ПОСРЕДИ жеста, не итоговой записи (доказано:roomPolyдо/после в смоке не меняется —preflight_no_commitв том же смоке зелёный).resize-controller.ts:epochBefore/restoreEpochдля cancel корректно откатывают_cfgEpochтолько при совпаденииsnapshotIdentity, иначеnull— соответствующие тестыtest/resize-controller.test.mjs(оба сценария) проходят._terminalFrameвhouseplan-card.ts: сбрасывается на любую именованную реактивную запись (requestUpdate(name, …)сname !== undefined) до того, как_renderBody()может вернутьnoChange— то есть «проглотить» кадр может только сам вызывающий cancel-путь, а не случайное внешнее свойство;npm test(1955/1955) иdemo/smoke_render_invalidation.mjsне показали расхождений DOM.- Единственный источник числа: диф не вводит новых пользовательски видимых величин (внутренний
перф-рефакторинг),
npm testвключаетtest/single-source-numbers.test.mjs— зелёный. INITIAL_VIEW_GZIP_CEILING297 000 → 298 000 обоснован в комментарии тем же коммитом, общий бюджет 300 000 и долг #367 не изменены — проверено чтениемscripts/bundle-budget.mjs.- Инварианты модели (
npm run invariants) на конфиге изdemo/fixtures/large-house.mjs: «ссылки разрешимы, записи толщины находятся» — новаяjunctionLimitViolations()не потеряла соответствие ключей записей толщины решёточным рёбрам на этой фикстуре.
Чего не проверял и почему
- Полный набор
demo/smoke_*.mjs(222 файла) — избыточно: 58 прямых + 1 зарегистрированная связь смок-селектора плюс 5 смежных по физической геометрии (smoke_wall_junctions,smoke_wall_union_isolation,smoke_near_orthogonal_junction,smoke_multiwall_strip_containment,smoke_glow_geometry_resilience) дают 64/66 живых прогонов по затронутым модулям — уже нашли реальный регресс (H3). Оставшиеся 27 «слабых» совпадений (общее имя_mode/_baseVb/_physicalBodiesCache/cellCm/NORM_W) не гонял: все пять физически-геометрических смоков из этого же семейства прошли, дополнительный сигнал от оставшихся маловероятен, а полный набор — предрелизный гейт (PROCESS.md §8), не гейт ревью. - Полный
performance.yml(Full Performance, парный base/candidate на двух чекаутах) — CI это не запускала на этом SHA (skipped из-за H1/H2), у меня нет второго чекаута под парное сравнение; заменено тройным--absolute-onlyпрогоном на кандидате (см. «Под вопросом»). python -m pytest tests_backend— диф не трогаетcustom_components/**/*.py(проверено чтениемgit diff --name-only).- Мутация для новой
junctionLimitViolations()/lightweight-режима отдельно — не потребовалась: H3 уже показывает красный тест по этому пути; добавлять мутант к сломанному коду преждевременно, стоит сделать после фикса H3, иначе он зафиксирует текущее (неверное) поведение. - Повторный запуск
smoke_edit_walk.mjsбольше двух раз для статистики флакиности — не требовалось: тот же класс поведения (падает только под конкуренцией за CPU при плотном последовательном прогоне 66 браузерных смоков подряд, чисто 6/6 в изоляции), что уже описан и принят как некритичный в r1 дляsmoke_smooth_zoom.
Итог
Вердикт: красный. Три High-находки, все в скоупе задачи и чинятся в ней же (без отдельных
issue): H1 — обновить отпечаток документации (npm run build && node demo/docs/capture.mjs),
H2 — типизировать или обосновать 11 новых any, H3 — вернуть full physical-geometry preflight
(или эквивалентную защиту) в путь _rszProjectPreview, чтобы посреди resize-жеста
невозможная геометрия снова отклонялась видимо, а не только по pointerup. После фикса —
чистый повторный прогон npm run benchmark:large-house-interaction/compare для снятия вопроса
из раздела «Под вопросом».
Материал раунда
- Ветка:
issue/451-render-performance, коммитcb68492c990b— ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет. - Дерево материала:
aaa8519cee54bf2a81f9e922e583d42d7f594ab4git log --all --format='%H %T' | grep aaa8519cee54 - ТЗ
docs/specs/451-render-performance.md, блоб7c323a29110974aae369077214b9e2a74d9387c1git log --all --find-object=7c323a29110974aae369077214b9e2a74d9387c1 -- docs/specs/451-render-performance.md