Files
houseplan-card/docs/reviews/CODE-REVIEW-330-r2.md
T
2026-08-28 00:30:31 +00:00

25 KiB
Raw Blame History

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, вшитый в job performance_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 подтверждены исполнением, а не только чтением или доверием к предыдущему раунду.