25 KiB
CODE-REVIEW-330-r2
Issue: #330 — «Ограничения стыков (#329) блокируют event loop HA и дёргают ресайз на больших планах»
Этап: code (PROCESS.md §2.7)
Заход: r2 · блокирующих циклов израсходовано 1 из 4 (до этого раунда)
Ветка: issue/330-junction-limits-performance, HEAD ревью — 85636a65
0. Почему разбор полный, а не по дельте
Предыдущий вердикт (code-review r1, 2026-08-28T00:00:48Z) не назвал SHA, на
котором он получен — это само по себе процессное упущение (см. ниже). Автор
в ответном комментарии называет два SHA до финального ребейза: a7500973
(HEAD на момент публикации r1) и 7513f93d (докс-фикс, слитый до публикации
вердикта). Оба объекта в текущем дереве отсутствуют:
$ git cat-file -t a7500973 → fatal: Not a valid object name
$ git cat-file -t 7513f93d → fatal: Not a valid object name
Ветка была перебазирована на ушедший вперёд dev через мердж #62 («ребейз
над мерджем i18n-registry», коммит 85636a65) уже ПОСЛЕ публикации r1. Это
ровно исключение §7.2 из брифинга: «ребейз на ушедший вперёд dev — после
ребейза это другой код». Пословный git diff <r1 SHA>..HEAD невозможен и не
имел бы смысла — базовые деревья разошлись. Разбор ниже полный, по
git diff origin/dev...HEAD, с явным закрытием находок r1 отдельным
разделом.
Процессное наблюдение (не находка в код, не считается в M/H): ни в вердикте r1, ни в вердикте предыдущего code-review-раунда SHA не был назван текстом. Формат §7.2 этого не требует явно, но по факту это стоило времени на этот раунд — рекомендую в следующих раундах явно указывать SHA в теле вердикта, а не только в документе.
1. Скоуп
Диапазон origin/dev...HEAD, 31 файл, +1731/-258:
custom_components/houseplan/junction_limits.py— линейный byNode-индекс П3, bucket-решётка П4,_migrated_spacesбез ремиграции v9 (§4.6),space_violation_counts/baseline_counts(§4.2).custom_components/houseplan/websocket_api.py— цепочка валидаторовws_config_set/ws_plan_optimizeвasync_add_executor_job(§4.1), запись/чтениеrt.junction_baselineпо rev (§4.2).custom_components/houseplan/store.py— новое поле рантаймаjunction_baseline.src/junction-limits.ts— те же три среза на фронте (byNode-индекс, bucket-решётка, зеркальная логика).src/houseplan-card.ts—_junctionLimitViolationsпринимаетsharedGeometry(§4.7, общий проход топологии/кладки на все комнаты),_junctionLimitsIntroduced— кэш baseline по (identity,_cfgEpoch) (§4.4) и путь «v9 как есть» (§4.6 на фронте).demo/benchmark_junction_limits.mjs,package.json,.github/workflows/validate.yml— новый перф-контракт §5/AC7, вшитый в jobperformance_smoke(не только в еженедельныйmutation-gate.yml).demo/smoke_junction_limits.mjs— счётчик вызовов_junctionLimitViolationsза один жест ресайза (заявленная поведенческая часть AC4).scripts/mutation-gate.mjs— три новых мутанта (П3, П4, «протухший baseline-кэш»).test/junction-limits.test.mjs,tests_backend/test_junction_limits.py,tests_backend/test_ha_websocket.py— юниты эквивалентности и AC1.docs/CHANGELOG.md/.ru.md— одна строка, User-Visible: yes, в том же коммите (c90f5bf0), что и код.- Скриншоты/
screenshots.json— механический пересчёт отпечатка (src/** изменился), содержимое интерфейса не менялось.
Вердикты П1–П5 не меняются нигде в дифф — заявление автора подтверждено
чтением: единственная содержательная логика (_length, _distance_to_segment,
условия нарушений, epsilons) не тронута; менялись только структуры данных
вокруг неё (индексация, кэш, порядок обхода).
2. Как проверялось
Все команды выполнены на этом SHA в этой рабочей копии; результат — вывод ниже, не «verified» без числа.
| Гейт | Команда | Результат |
|---|---|---|
| Типы | npx tsc --noEmit |
чисто, без вывода |
| Юниты фронта | npm test |
1419 passed, 1 skipped, 0 failed |
| Юниты бэкенда (чистый python, без HA) | python3 -m pytest tests_backend/test_junction_limits.py -q |
13 passed |
| Юниты бэкенда (HA-харнесс) | python3 -m pytest tests_backend -q (pytest-homeassistant-custom-component установлен в этой сессии) |
423 passed, 1 skipped, 1 error — ошибка теardown (_run_safe_shutdown_loop thread-leak assert) воспроизводится и на НЕ тронутом этой веткой тесте (test_issue_244_...) при отдельном прогоне того же файла — окружение, не диффа. Тест AC1 (test_330_config_set_validators_run_in_the_executor) прошёл исполнением: 1 passed. |
| Перф-контракт §5/AC7 | npm run benchmark:junction-limits |
pass: true; tsFullCandidateMs 150.7/400 (2.65×), pyWarm 67.1/300, pyCold 2463/5000 |
| Сборка + идентичность бандла | npm run build + git status --short (чисто) + diff dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js |
идентичны, git status чист после ребилда |
| Докс-гейт | node scripts/check-docs.mjs |
«Documentation checks passed (7 files, 10 external links)» |
| Инварианты модели | не гонял отдельной командой — npm test уже включает test/model-invariants.test.mjs на всех моделях проекта (зелёный); задача не вводит новую конфигурацию для точечной проверки npm run invariants -- --config |
|
| Смок-выборка | node scripts/smoke-select.mjs --base origin/dev --head HEAD → 7 прямых совпадений + 1 «зарегистрированная связь» (см. §3) |
все 8 + 3 названных в AC6 (island_rooms, room_resize, resize_pointer_real_plan) прогнаны — 11/11 OK (после node scripts/bundle-sync.mjs, без которого demo/srv/assets/houseplan-card.js отсутствовал и смоки падали таймаутом на билд, не на код) |
Мутационная проверка (ручная, не полный mutation-gate.mjs) |
вручную применил 3 патча из scripts/mutation-gate.mjs (П3-снова-квадратичен, П4-снова-перебор, протухший baseline-кэш) и прогнал их guard-команды |
П3 и П4 ловятся бенчем (pass:false, tsSegmentLengthsMs 402.6/60 и tsNodeDistancesMs 207.4/80 соответственно); третий мутант — см. находку M1 ниже, там дисциплина «тест умеет падать» провалилась на смоке |
| golden | не гонял | diff не трогает рендер/геометрию/стили/слои — только порядок вычислений и кэш валидатора; визуальный результат не может измениться этим кодом |
| performance-профили сверх §5 | не гонял | не названы в AC сверх §5/AC7 |
3. Смок-выборка — вывод инструмента
Изменено файлов src/**: 2 · символов на изменённых строках: 17
Прямое совпадение (7): smoke_junction_holes, smoke_decor, smoke_glow_fail_dark,
smoke_glow, smoke_grid_scale_invariance, smoke_junction_limits, smoke_space_scale_defaults
Зарегистрированная связь (1): smoke_real_plan_masonry ← wallBodiesGeometry
Плюс три смока, названных в AC6 автором (island_rooms, room_resize,
resize_pointer_real_plan) — не входят в прямые совпадения инструмента для
этого diff, но названы в AC, поэтому прогнаны. Итог: 11 смоков, все OK.
4. Находки
M1 (в скоупе) — заявленная «поведенческая половина» AC4 не различает наличие и отсутствие кэша
Файл: demo/smoke_junction_limits.mjs:185-217; коммит c90f5bf0 и
подтверждающий комментарий автора («Поведенческая половина AC4 (N move →
N+1 вычислений на настоящем pointer-жесте)»).
Смок считает вызовы _junctionLimitViolations за весь жест ресайза и
проверяет resizeBaselineCachedPerGesture = jlCalls > 0 && jlCalls <= 12.
Я вручную воспроизвёл ровно тот мутант, что заведён в
scripts/mutation-gate.mjs под id junction-limit-baseline-cache-stale
(отключил запись в _junctionBaselineCache — кэш никогда не срабатывает,
baseline пересчитывается на каждый move) и прогнал сам смок (не юнит,
который guard мутанта вызывает вместо него):
DEBUG jlCalls= 12 (кэш полностью отключён)
DEBUG jlCalls= 11 (немодифицированный код, кэш работает)
OK (оба раза)
Разница между «кэш работает» и «кэш полностью выключен» — один вызов из
двенадцати, и порог <= 12 пропускает оба случая. Смок физически не может
отличить рабочий кэш от полностью удалённого — он зелёный в обоих случаях.
Причина в том, что счётчик window.__jlCalls ловит вообще все обращения к
_junctionLimitViolations за время жеста (включая вызовы вне
_junctionLimitsIntroduced, например из рендера/подсветки), а не только
экономию на baseline — сигнал тонет в шуме, и заявленная арифметика
«N+1 против 2N» на практике не воспроизводится этим жестом ни при каком
разумном пороге.
Это ровно тот класс дефекта, который автор сам поймал для ЮНИТ-теста тем же
приёмом в 7aaf5a72 («тест умеет падать» — контракт перепрятали в
проверку по исходнику именно потому, что первая версия юнита не ловила
мутанты). Для смока тот же самоконтроль не был применён: заявление в
коммите, что поведенческая защита AC4 «живёт в смоке», не подтверждается.
Фактическая защита AC4 сейчас — только regex-проверка по исходнику
(test/junction-limits.test.mjs:380-399, ищет литеральную строку
cached.epoch === this._cfgEpoch). Она реально работает как барьер (я
проверял: мой мутант меняет именно эту подстроку и юнит падает), но это
защита от одной конкретной формы регресса (удаление/подмена этого сравнения
в тексте), а не от логической. Сама РЕАЛИЗАЦИЯ кэша в продакшен-коде
корректна — я прочитал _junctionLimitsIntroduced (houseplan-card.ts:7503-7549)
и логика верна: WeakMap по identity previousConfig, инвалидация по
_cfgEpoch, который сам ведёт себя как установившийся паттерн в этом файле
(бампается в ~30 местах при любом реальном изменении _serverCfg/эпизода
редактирования). Это находка про КАЧЕСТВО ТЕСТА, а не про баг в фиче.
Почему в скоупе и Medium: AC4 ТЗ требует доказательства «жест ресайза считает baseline один раз»; заявленное доказательство decorative. Без High это возвращается автору жёлтым, а не заводится отдельным issue — находка про код и тесты ЭТОЙ задачи, не соседнего поведения.
Что нужно поправить: либо ужесточить порог до значения, которое
реально разделяет 11 и 12 (например, точное равенство ожидаемому числу
вызовов для конкретной синтетической последовательности mouse.move — тогда
и сам тест обязан быть проверен на этом же мутанте), либо честно
переформулировать комментарий/заявление AC4: поведенческая защита не
добавляет покрытия сверх юнита по исходнику, и это нужно признать в
документе, а не заявлять как отдельную гарантию.
5. Закрытие раунда r1
| Находка r1 | Чем закрыта | Где видно |
|---|---|---|
| H1 — докс-гейт красный (screenshots.json/PNG не из одного прогона) | Уже было закрыто 7513f93d до публикации вердикта; после второго ребейза (85636a65) фингерпринт и PNG пересобраны ещё раз тем же правилом |
node scripts/check-docs.mjs → «Documentation checks passed» на этом SHA (проверено мной заново) |
| H2 — бюджеты §5 калиброваны с запасом 1.14× вместо 2-3×, бенч не в CI-воркфлоу | Таблица замеров с обеих машин, бюджеты пересчитаны от худшей; шаг npm run benchmark:junction-limits добавлен в job performance_smoke в validate.yml |
.github/workflows/validate.yml:543-547; сам прогон на этом SHA — pass:true, запас 2.65× (150.7/400) |
M1 — обещанный AC1-тест на самом деле мерил только тёплый validate_junction_limits, не websocket_api.py/hass |
Новый тест test_330_config_set_validators_run_in_the_executor патчит validate_junction_limits обёрткой-шпионом внутри реального HA-харнесса, вердикты (принято/отклонено junction_limit_angle) сверены |
tests_backend/test_ha_websocket.py:2322-2385; я прогнал его исполнением (pytest -k 330 → 1 passed), не только чтением |
| M2 — §4.6-эквивалентность фронта проверена только текстовым паттерном; python-паритет на одной фикстуре | Три граничные фикстуры (as-is==through-migration) на TS (test/junction-limits.test.mjs:401) и на python (tests_backend/test_junction_limits.py:423, три полигона: spike/box/narrow) |
Оба набора прогнаны: TS — часть зелёного npm test; python — часть зелёного pytest tests_backend/test_junction_limits.py (13/13) |
Все четыре находки r1 закрыты по существу, подтверждено исполнением (не только чтением) там, где это было в моих силах в этой песочнице.
6. Унаследовано из r1 (без повторной проверки)
- Общая архитектура срезов §4.1–§4.6 (rev-кэш, byNode-индекс П3, «v9 как есть» §4.6) — признана в code-review r1 корректной по коду и эквивалентной по вердиктам; в этом раунде я перечитал тот же код заново (диф не менялся с r1 в этой части, кроме docs/CHANGELOG и бенч-калибровки) и подтверждаю тот же вывод самостоятельно, а не по доверию к r1 — это не наследование, а совпавший результат независимой проверки.
- Ничего из r1 не наследуется слепо: поскольку исходный SHA r1 недостижим после ребейза (см. §0), весь код перечитан заново в этом раунде, а не предположен на основании старого вердикта.
7. Что проверено и корректно
- Эквивалентность вердиктов П1–П5 до и после — не изменилась ни в одной
строке содержательной логики; подтверждено чтением и параллельно
зелёными паритет-тестами TS↔Python (
test_parity_with_the_frontend_checks, уже существовавший до #330, прошёл в составе 13/13). - Bucket-решётка П4: прочитал построчно оба зеркала (
junction_limits.py:223-291,junction-limits.ts:188-247|); граничный случай (узел и сегмент в соседних ячейках решётки, но ближе порога) закрыт паддингом bbox сегмента наmin_unitsи проверен тестомtest_330_p4_bucket_matches_bruteforce_on_cell_borders(4 кейса, включая ровно 4 и 5 см от порога) — прогнан, зелёный. Ассиметрия «узел проверяется в 9 соседних ячейках, сегмент — только в своей» корректна по построению: паддинг сегмента компенсирует то, что для узлов (точек) эквивалентного паддинга нет. - §4.6 «v9 как есть»: перечитан барьер в
websocket_api.py:1325(schema всё равно валидирует документ целиком независимо от пропуска ремиграции) — риск «подделать v9 без каталога» закрыт независимо от нового теста. - Executor-обёртка в
ws_config_set/ws_plan_optimize:write_lockдержится вокругawait, поэтому сериализация записей не нарушена; ошибкаtoo_largeвws_plan_optimizeкорректно вынесена из executor-функции обратно в вызывающий код передconnection.send_error(сеть — дело event loop, не executor-потока). rt.junction_baseline: инвалидация «по факту», а не по всем путям записи —ws_plan_optimizeне обновляет кэш вовсе (и не обязан: он не проверяет ограничения стыков ни до, ни после #330 — не регрессия), поэтому следующийconfig/setпросто промахнётся по rev и пересчитаетpreviousс нуля; корректность не зависит от того, кто ещё пишет rev.- §4.7 общий проход:
preflight.wallGeometry, переданный в_junctionLimitsIntroducedкакcandidateGeometry, построен (_rszSpaceCandidateGeometry) из ТОЙ ЖЕ пары (spaceId,sp), что иlimitCandidate— не подмена по случайному кэшу; проверено чтением обеих функций (houseplan-card.ts:9104-9121, 9156-9175). - CHANGELOG — одна строка в обоих файлах, в том же коммите (
c90f5bf0), что и код, приUser-Visible: yes. - Одно число — один источник: diff не добавляет и не меняет ни одной видимой пользователю величины (только внутренние тайминги/пороги бенчмарка, невидимые пользователю) — раздел неприменим.
8. Чего не проверял и почему
- golden:verify — не гонял; diff не трогает рендер, геометрию, стили или слои, только порядок/кэш вычислений валидатора стыков.
- Полная матрица
demo/smoke_*.mjs(194 шт.) — не гонял; задача не меняет ничего, что затрагивает широкие символы (_cfgEpoch,_serverCfg,_spaceинструмент явно исключил как «не широкие» в данном случае), 11 выбранных/названных в AC смоков — зелёные. benchmark_safe_resize— не гонял; это тот самый бенч, про который spec-review r1 (H2) установил, что он не вызывает код лимитов вообще — нерелевантен этой задаче.- Полный
scripts/mutation-gate.mjsпрогон (все ~40+ мутантов) — не гонял; это еженедельный гейт (PROCESS.md §8), не гейт ревью. Три НОВЫХ мутанта проверил вручную построчно (см. §2, §4) — этого достаточно для вопроса «умеет ли тест, который я прогонял, падать». npm run invariants -- --config <...>отдельной командой — не гонял; задача не вводит новую конфигурацию/фикстуру геометрии за пределами того, что уже покрытоnpm test(который включаетmodel-invariants.test.mjsна моделях проекта).- Ручное тестирование в браузере — вне цикла ревью по регламенту; браузерные смоки — единственная замена, они прогнаны.
9. Итог
Один Medium в скоупе (M1 — заявленная поведенческая защита AC4 не дискриминирует регресс, эмпирически подтверждено), High нет. Основная перф-архитектура (§4.1–§4.7), эквивалентность вердиктов и перф-контракт §5 подтверждены исполнением, а не только чтением или доверием к предыдущему раунду.