# CODE-REVIEW-271-r1 - Issue: [#271](https://github.com/Matysh/houseplan-card/issues/271) — «Degree-3 узел достраивает короткий луч до 8×H» - Этап: code (PROCESS.md §2.7) - Заход: r1 (первый код-ревью цикл; спека прошла ревью зелёным в отдельном r1 — `docs/reviews/SPEC-REVIEW-271-r1.md`) - Материал: `git log --oneline origin/dev..HEAD` = `396b391` (fix), `77143d4` (docs: спек-ревью), `482b2be` (docs: спека) - Коммит реализации: `396b391a93cc228bd6b779b9985db87e853bfa50`, трейлеры `Issue: #271` / `User-Visible: yes`, оба changelog в этом же коммите — корректно. - Вердикт: **красный** (High: 1, Medium: 1 в скоупе) ## Скоуп Диапазон `origin/dev...HEAD` — один продуктовый файл `src/wall-thickness.ts` (добавлено поле `supports: MultiWallNodeRaySupport[]` на канонический луч multi-wall узла и ограничение локальной реконструкции реальной длиной support'а вместо `8×H`), плюс тесты (`test/wall-thickness.test.mjs`, `test/golden-matrix.test.mjs`), таргетированный smoke (`demo/smoke_junction_patch_resilience.mjs`), golden matrix/harness (`demo/golden/matrix.mjs`, `demo/golden/harness.mjs`), мутация (`scripts/mutation-gate.mjs`), реестр smoke-связей (`scripts/smoke-links.mjs`) и документация (ARCHITECTURE/WALL-THICKNESS/TESTING/USER-GUIDE×2/CHANGELOG×2 + `docs/images/screenshots.json`). Соответствует заявленным «Ожидаемым файлам» §9 ТЗ. Привязка к продукту подтверждена: J1 (план не должен показывать несуществующую архитектуру) и J6 (одна каноническая геометрия для всех потребителей) — сценарий и симптом (`docs/SCOPE.md`). ## Как проверялось | Гейт | Команда | Результат | |---|---|---| | Типы | `npx tsc --noEmit` | зелёный, без вывода | | Юнит | `npm test` | 1163 passed, 0 failed | | Сборка | `npm run build` | зелёный | | Синхронизация трёх копий бандла | `npm run bundle:sync` | зелёный; `git status --short` после — пусто (байт-в-байт) | | Документация/скриншот-отпечаток | `node scripts/check-docs.mjs` | «Documentation checks passed (7 files, 10 external links)» | | Целевая мутация | `node scripts/mutation-gate.mjs --id=multi-wall-finite-ray-disabled` | поймано 1/1, чистый прогон зелёный | | Выбор smoke по дельте | `node scripts/smoke-select.mjs --base origin/dev --head HEAD` | назвал `smoke_junction_patch_resilience.mjs`, `smoke_multiwall_junction.mjs` (обе — «зарегистрированная связь») | | Smoke #197/#271 | `node demo/smoke_junction_patch_resilience.mjs` | OK, все поля true, включая новые `*StopsAtFiniteRayEndpoint` | | Smoke #249 | `node demo/smoke_multiwall_junction.mjs` | OK | | **Целевой semantic golden (AC6)** | `node demo/golden/run.mjs --mode=capture --scenario=junction-patch-resilience-plan-dark` | **error**: `golden finite multi-wall ray contract failed` | | **Целевой semantic golden (AC6)** | `node demo/golden/run.mjs --mode=capture --scenario=junction-patch-resilience-view-dark` | **error**: та же ошибка | | Независимая проверка чтением | прямой вызов `buildMultiWallNodeMap`/`wallBodiesGeometry` из `test-build/wall-thickness.js` на той же фикстуре | подтвердил причину ошибки на уровне сырой геометрии (см. находку H-1) | `npm run golden:verify` не запускался — инструмент сам это запрещает вне полной матрицы (`policy.mjs: golden verify must run the complete matrix`), что и является предусмотренным prerelease-гейтом (§8/AC9). Но именно поэтому диагностический `--mode=capture --scenario=` — единственный способ исполнить добавленную семантическую проверку AC6 локально, и он был обязателен для этих двух конкретных сценариев, потому что diff добавляет в них новую проверку (`absentWallProbes`) целиком под этот issue. ## Находки ### [High] AC6 не выполняется: собственная семантическая golden-проверка issue падает на обоих сценариях **Файлы:** `demo/golden/matrix.mjs:266,271` (значение `absentWallProbes[1]`), `demo/golden/harness.mjs:582-588` (проверка, которая на нём падает). **Воспроизведение:** ``` node demo/golden/run.mjs --mode=capture --scenario=junction-patch-resilience-plan-dark # → error junction-patch-resilience-plan-dark # artifacts/golden/golden-report.json: # "error": "page.evaluate: Error: golden finite multi-wall ray contract # failed: junction-patch-resilience-plan-dark" ``` То же для `junction-patch-resilience-view-dark`. Точечной проверкой (клонирую сценарий с одним `absentWallProbes` элементом за раз) показано, что падает именно второй пробник — `[0.936524285, 0.345833333]`; первый (`[0.420833333, 0.37625]`, тот же, что в `smoke_junction_patch_resilience.mjs`) действительно пуст. **Причина (подтверждена прямым вызовом продукт-функций на той же фикстуре `test/fixtures/197-junction-patch.json`, без браузера):** Узел `(887.5, 345.833)` (в render units) имеет три луча: ``` u=[1,0] halfDepth=6.25 length=1.3819039326657503 ← искомый короткий луч из таблицы issue u=[-1,0] halfDepth=9.1667 length=195.833... u=[0,-1] halfDepth=12.083 length=295.833... ``` Это в точности совпадает со строкой из issue/ТЗ: `1 / (887.500, 345.833) | 1.381904 | 96.667` — реализация действительно ограничивает этот луч `1.3819`, а не `96.667`. Сам фикс на этом узле работает правильно. Но зонд `[936.524285, 345.833]` находится в `887.5 + 49.02` — то есть внутри совершенно другого, независимого и полностью легитимного интервала стены: ``` room_8: a=[888.8819039326658, 345.8333], b=[979.1666666666666, 345.8333], cm=22, half=9.1666... ``` Этот интервал начинается сразу там же, где заканчивается короткий луч (`888.88`), и тянется до `979.17` — то есть физически это настоящая, непрерывная (без зазора) кладка `room_8`, никак не связанная с 8×H-багом. Прямой point-in-polygon расчёт против `wallBodiesGeometry(...).geom` подтверждает: `probe1 → true` (внутри), `probe0 → false` (пусто, как и ожидается). Значит: между реальным концом короткого луча (`1.3819`) и следующим независимым сегментом (`888.88`) зазора попросту нет — вдоль этого направления в принципе не существует точки, которая была бы одновременно «дальше короткого луча» и «пуста», потому что там сразу начинается другая настоящая стена. §6.3 ТЗ прямо предвидел этот случай («если другой независимый ray/body законно занимает точку за этим торцом, final union остаётся заполненным им») — но зонд был выбран без проверки этого условия. **Почему это блокирует:** AC6 требует, чтобы «до pixel diff harness проверял, что два probes за finite endpoint пусты» — с добавленным кодом это происходит, и один из двух пробников красный. Это не спекуляция и не пропущенный гейт: гейт исполнялся (дважды, разными методами — Chromium и прямой вызов), и оба раза воспроизвёл одну и ту же ошибку. Если это попадёт в предрелизный `npm run golden:verify` (§8), CI-джоб `golden` станет красным на `dev` до следующей задачи — тот же класс инцидента, что уже стоил продукту #237. Хендофф-комментарий автора не упоминает запуск этого сценария вообще (перечислены typecheck/test/bundle:sync/check-docs/mutation-gate/два smoke) — гейт добавлен в этот же коммит, но не был исполнен ни разу. **Что нужно исправить:** заменить `absentWallProbes[1]` на координату, которая действительно лежит в пустоте за реальным концом искомого короткого луча (либо выбрать другой узел/направление для второй иллюстрации из таблицы issue, для которого такая пустая точка вообще существует — у этого конкретного направления её нет, зазора между сегментами нет). Требуется перепрогнать оба golden-сценария до зелёного после правки. ### [Medium, в скоупе] AC4 не имеет собственного fixture/smoke с проёмом ТЗ (§8, AC4) и сама формулировка issue («Обязательные регресс-тесты», п. 3) требуют fixture с коротким перпендикулярным лучом рядом с дверью и targeted browser smoke с реальным SVG/shadow probe, доказывающие: opening slot остаётся пустым, короткий луч не заходит в slot, light/sun occluder не получает лишней площади, а opening tunnel/symbol contract не меняется. Ни новый unit-тест (`test/wall-thickness.test.mjs:840`, `openings: []`), ни переиспользуемая фикстура `test/fixtures/197-junction-patch.json` (`openings` отсутствует как ключ), ни `test/fixtures/249-multiwall-junction.json` не содержат ни одного проёма. Единственная косвенная проверка — `lightAndSunStopAtFiniteRayEndpoint` в `smoke_junction_patch_resilience.mjs` — проверяет тот же общий occluder-механизм, но не рядом с дверью и без проверки самого opening slot/tunnel/symbol. **Проверено чтением, не исполнением:** `bevelMultiWallBody()` вызывается в `wallBodiesGeometry()` (`src/wall-thickness.ts:2686`) строго до цикла вырезания проёмов (`src/wall-thickness.ts:2688-2705`), который работает как чистое булево вычитание по `openingWallIndex`, построенному из `walls`/`wallIntervals` — эта функция в диффе не тронута. Значит вырезание проёма не может вести себя иначе только потому, что рядом finite ray; фикс равно применяется вне зависимости от наличия проёма поблизости, и вероятность отдельного, специфичного для проёма дефекта архитектурно мала. Именно поэтому это Medium, а не High: живого дефекта не нахожу, но обязательное по своему же ТЗ регресс-покрытие именно того производственного симптома, который запустил issue (тёмное пятно у двери на `2-1.png`), отсутствует. В скоупе задачи, чинится в этом же цикле по правилу §2.7 (Medium без High — жёлтый; здесь High уже есть, поэтому общий вердикт красный, но это не меняет маршрут находки — не заводится отдельным issue). ## Что проверено и корректно - **AC1** (node map хранит конечную длину, permutation/order-independent) — доказано автотестом `test/wall-thickness.test.mjs:840` (deep-equal supports, reversed/permuted intervals дают одинаковую подпись). Тест умеет падать: мутация `multi-wall-finite-ray-disabled` красит именно этот тест-файл (через ту же комбинированную проверку, что и AC2, — см. ниже). - **AC2** (короткий луч не достраивается) — доказано автотестом и независимо — прямым вызовом `buildMultiWallNodeMap`/`wallBodiesGeometry` я воспроизвёл узел `(887.5,345.833)` и подтвердил `length=1.3819...`, а не `96.667`; probe `[0,10]` внутри, `[0,100]` и `roomGeom`-эквивалент — снаружи (тест). Совпадает с числами из таблицы issue. - **AC3** (длинные rays/join #249 не обрезаны) — весь существующий набор `#249`/`#261`/`#197` зелёный; area-бейзлайн `#197`-фикстуры пересчитан с явным комментарием, объясняющим уменьшение (`124568.27...→124244.27...`), величина уменьшения (≈324 ед²) правдоподобна для одного убранного фантомного прямоугольника. - **AC5** (все поверхности используют finite result) — доказано smoke: `planStopsAtFiniteRayEndpoint`, `viewStopsAtFiniteRayEndpoint`, `kioskStopsAtFiniteRayEndpoint`, `staticStopsAtFiniteRayEndpoint`, `hiddenIsoStopsAtFiniteRayEndpoint`, `lightAndSunStopAtFiniteRayEndpoint`, `cleanFloorOwnsAreaAfterFiniteEndpoint` — все `true` при реальном прогоне в Chromium. - **AC7** (мутант ловит регрессию) — `node scripts/mutation-gate.mjs --id=multi-wall-finite-ray-disabled` подтверждает: чистый прогон зелёный, патч `supportExtent = extent` красит тест. Мутация задевает ровно ту строку (`src/wall-thickness.ts` в `bevelMultiWallBody`), которая реализует контракт §6.1/6.3 ТЗ. - **AC8** (приватность/детерминизм)** — проверено чтением: fixture — переиспользование уже прошедшей ревью анонимизированной `197-junction-patch.json`, приватные `1.json`/`2.json` не коммитятся (в диффе их нет); `renderNeverWritesConfig` в smoke подтверждает отсутствие мутации config. - **Failure isolation §6.5** — проверено чтением: `try/catch` вокруг цикла по узлам в `bevelMultiWallBody` не тронут, `canonicalRays`-фильтр (`Number.isFinite && > 0/eps`) исключает только невалидный ray/node, не весь план. - **Compatibility §7** — persisted config/model version не тронуты (diff ограничен внутренним представлением `MultiWallNodeRay`/локальной реконструкцией); нет нового глобального `O(E²)` — `canonicalSupports`им `O(k²)` только по числу дублей на одном направлении одного узла, что и раньше было O(дублей) на дедупликации. - **Трейлеры и changelog** — один коммит `396b391`, `Issue: #271`, `User-Visible: yes`, оба changelog внутри того же коммита — соответствует §7 PROCESS. - **Документация** — `ARCHITECTURE.md`/`WALL-THICKNESS.md`/`TESTING.md`/оба `USER-GUIDE` обновлены точно в терминах реализованного контракта (non-dominated finite `(half-depth, length)` supports); `docs/images/screenshots.json` — только `sourceFingerprint` изменился, все `imageSha256` совпадают (PNG не тронуты) — подтверждено `check-docs.mjs` и сверкой diff вручную. - **smoke-links** — `scripts/smoke-links.mjs` дополнен новыми символами (`buildMultiWallNodeMap`, `MultiWallNodeRay`, `MultiWallNodeRaySupport`) с причиной; `smoke-select` подтвердил именно эти два smoke как «зарегистрированная связь», оба фактически прогнаны. ## Чего не проверял и почему - **Полный `npm run golden:verify`** — инструмент сам требует полную матрицу (не принимает `--scenario` в verify-режиме); это осознанный prerelease-гейт по AC9/§8, не подмена. Вместо этого исполнен диагностический `--mode=capture --scenario=` для обоих сценариев, добавленных этим диффом — что и вскрыло находку H-1. - **Полная smoke-матрица (174 сценария)** — не запускал; `smoke-select` назвал только 2 релевантных, оба прогнаны. Diff не расширяет поверхность за пределы multi-wall reconstruction, широких символов (>34 смоков) не затронуто. - **`npm run invariants`** — не запускал. Diff не трогает хранение записей толщины, `layout`, `marker.space`, `open_spans` или ключи решёточных рёбер — только внутреннее представление `MultiWallNodeRay` и локальную реконструкцию тела в уже существующей структурной фазе, читающую уже разрешённые `WallInterval`. Ссылочная целостность конфигурации не может быть задета этим диффом; проверено чтением diff (`git diff --stat` показывает единственный продуктовый файл `src/wall-thickness.ts`, без затрагивания `setWallThickness`/хранения ключей). - **`python -m pytest tests_backend`** — не запускал, `custom_components/**/*.py` не тронут (только скомпилированный `frontend/houseplan-card.js`). - **Performance-профили** — не названы в AC, geometry hot-path не меняет алгоритмической сложности (см. Compatibility выше). - **Полный golden/perf/Linux HA harness** — предрелизный гейт по PROCESS §8/AC9 «Release-артефакты» ТЗ, сознательно не гоняется в цикле ревью. ## Итог Реализация верно устраняет корневую причину (потерю длины луча в `buildMultiWallNodeMap`/`bevelMultiWallBody`) и большинство AC подтверждены автотестами и независимым чтением/пересчётом. Но собственная семантическая golden-проверка, которую сам диф добавляет для AC6, красная при реальном исполнении — это High, блокирует. Плюс отсутствует обязательное по ТЗ door-adjacent регресс-покрытие AC4 — Medium, в скоупе. Оба возвращаются автору в этом же цикле, отдельный issue не заводится.