# SPEC-REVIEW-197-r1 - **Issue:** https://github.com/Matysh/houseplan-card/issues/197 - **ТЗ под ревью:** [`docs/specs/197-junction-patch-fail-dark.md`](https://github.com/Matysh/houseplan-card/blob/issue/197-junction-patch-fail-dark/docs/specs/197-junction-patch-fail-dark.md) (коммит `fe7b28f`), обычный трек — не `small`/`trivial` - **Роль:** ревьюер ТЗ (не автор), этап `S4-spec-review` - **Трек:** обычный, лимит циклов ревью ТЗ — 4 (§4 PROCESS.md) - **Цикл:** r1/4 ## Скоуп ревью ТЗ #197 предлагает исправление: один вырожденный `virtualJunctionPatches()` patch (шум IEEE-754 в координатах, из-за которого `union()` в `polyclip-ts` бросает исключение) обнуляет весь результат `wallBodiesGeometry()`, поскольку цикл добавления junction-патчей (`src/wall-thickness.ts:1760-1761`) — в отличие от соседних циклов room-ring и wall-edge — не изолирует отказ одного элемента. Заявленное решение: численная стабилизация координат patch + per-patch try/catch, без изменения persisted-схемы, i18n, touch-контракта, оптимизатора и без правки #198/#199 (сознательно вынесены отдельно). Не в скоупе ревью: продуктовый код. На ветке `issue/197-junction-patch-fail-dark` лежит один коммит (`fe7b28f`, `Issue: #197 · User-Visible: no`) — только ТЗ и правка `docs/specs/README.md`, реализации нет. Гейты (`typecheck`/`test`/`build`) не прогонялись — на этапе ревью ТЗ продуктового кода не существует, что вне скоупа этапа (PROCESS.md §2.4/§8). Вместо этого проверялась исполнимость **фактических утверждений** ТЗ о текущем поведении `src/wall-thickness.ts` на `origin/dev`, поскольку именно они — единственное обоснование, почему решение верно и почему AC1/AC2 будут доказывать то, что заявлено. ## Как проверялось 1. Прочитаны целиком `docs/SCOPE.md`, `AGENTS.md`, `PROCESS.md` (действующая редакция, §1, §2.2, §2.4, §5, §7.1, §8) и `docs/WALL-THICKNESS.md`. 2. Прочитано тело issue #197 и все три комментария (аналитика владельца — ценность 9/9, сложность 6, риск 8, `P2`, `bug`, обычный трек; занятие; хендофф на ревью). 3. Прочитан весь текст ТЗ (`docs/specs/197-junction-patch-fail-dark.md`, 409 строк) и сверены обязательные разделы §7.1 PROCESS.md — присутствуют все: сценарий и персона (§1), что человек увидит до/после (§2), проблема и причина (§3), скоуп/не-скоуп (§5/§6), контракт поведения (§7), UX/i18n/touch (§10), модель данных и миграция (§9), AC1…AC11 с доказательством (§11), план автотестов (§12), риски (§14), откат (§15), release-артефакты (§16). Плюс продуктовые разделы (персона/поверхность/момент; фраза «что увидит» без терминов реализации) — на месте. 4. Прочитан код `src/wall-thickness.ts` (`wallBodiesGeometry`, `virtualJunctionPatches`, `wallIntervals`, `openEps`) и подтверждено построчно: room-ring loop (`:1730-1739`) и wall-edge loop (`:1744-1756`) имеют per-piece `try/catch`; junction-patch loop (`:1760-1761`) — нет, и любое исключение из его `union()` долетает до общего `catch` на `:1790`, обнуляя весь `wallBodiesGeometry()`. **Совпадает с диагнозом ТЗ дословно.** 5. Прочитан `docs/specs/141-wall-junctions.md` (`git fetch` тело issue #141 через `gh issue view` показало не то, что ожидалось — актуальный заголовок issue #141 на GitHub («Стыки перегородок под углом остаются с зубцами») не про virtual-junction fail-dark; проверка ушла на уровень спецификации, которая шире исходного тикета). Раздел 7.7 «Ошибки вычисления» этой спецификации действительно фиксирует общий архитектурный контракт: «Malformed legacy input не должен превращать видимую стену в прозрачность… Boolean failure не разрешается маскировать исчезновением кладки». Ссылка ТЗ #197 на #141 как источник продуктового контракта **обоснована** — это не догадка, выданная за решение, а корректная опора на уже принятый архитектурный принцип, просто зафиксированный в спеке, а не в теле issue. 6. **Ключевая проверка — самостоятельное исполнение заявленной причинно-следственной цепочки** (см. «Находки», High-1). Собран `test-build/` (`npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs`), написан скрипт, воспроизводящий ровно вызов, который ТЗ приводит в §3: «комнаты ×NORM_W, затем `wallBodiesGeometry(rooms, walls, openCuts, [], GRID_STEP_N, 5, GRID_PITCH, NORM_W, [])`» — с полным fixture из issue (проверено: 8 комнат, 25 walls, 3 open_spans, совпадает с заявленным). Результат разошёлся с заявленным; подробности и повторяемая команда — в находке. 7. Проверена структурная целостность ссылок ТЗ: #198 и #199 существуют, открыты, не дубликаты (`gh issue view 198/199`), в `S1-new`, что соответствует «смежные находки, аналитика ещё впереди» — согласуется с текстом ТЗ, который явно выносит их из скоупа. 8. AC1–AC11 прочитаны на однозначность и способ доказательства: у каждого указан метод (`unit`/`smoke`/`golden`/`ревью кода`/сочетание), формулировки не допускают двух прочтений, кроме зависимости AC1/AC2 от fixture, разобранной в находке High-1. ## Находки ### High-1 — центральное фактическое утверждение ТЗ (единственный патч → крах `union()` → `null`) не воспроизводится точным сценарием, который сам ТЗ описывает как «подтверждено исполнением» **Файл:** `docs/specs/197-junction-patch-fail-dark.md`, §3 «Подтверждённая проблема и причина» (и производные от неё AC1, AC2 в §11). **Что заявлено.** §3 ТЗ и аналитический комментарий владельца в issue утверждают: на `origin/dev` `19e92e0`, с анонимизированным fixture из issue и вызовом `wallBodiesGeometry(rooms, walls, openCuts, [], GRID_STEP_N, 5, GRID_PITCH, NORM_W, [])` (комнаты предварительно умножены на `NORM_W = 1000`), `virtualJunctionPatches()` создаёт **ровно один** patch, а `wallBodiesGeometry(...)` возвращает `null` — то есть дефект «подтверждён исполнением», а не выведен логически. На этом стоят AC1 («тест красный на исходном dev, где результат равен `null`») и AC2 («fixture создаёт ровно один ожидаемый virtual-junction patch»). **Что получено при независимом повторении.** HEAD этой ветки (`fe7b28f`) не содержит изменений в `src/` относительно `19e92e0` (`git diff 19e92e0 fe7b28f --stat -- src/` — пусто), то есть повторение идёт на том же коде, который тестировал автор. Собран `test-build/` тем же способом, каким его собирает `npm test` (`npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs`). Взят фикстур **дословно** из тела issue #197 (проверено программно: 8 `rooms`, 25 `walls`, 3 `open_spans` — совпадает с заявленным), выполнено ровно то преобразование и тот вызов, которые описывает §3 ТЗ: ```js import { wallBodiesGeometry, virtualJunctionPatches } from './test-build/wall-thickness.js'; import { GRID_STEP_N, GRID_PITCH, NORM_W } from './test-build/space-geometry.js'; const coordScale = 1000; const rooms = fixture.rooms.map(r => ({ ...r, poly: r.poly.map(([x, y]) => [x * coordScale, y * coordScale]) })); const walls = fixture.walls.map(w => ({ ...w, a: [w.a[0] * coordScale, w.a[1] * coordScale], b: [w.b[0] * coordScale, w.b[1] * coordScale] })); const openCuts = fixture.open_spans.map(s => [s.a[0] * coordScale, s.a[1] * coordScale, s.b[0] * coordScale, s.b[1] * coordScale]); virtualJunctionPatches(rooms, walls, openCuts, GRID_STEP_N, fixture.cell_cm, GRID_PITCH, coordScale); // -> [] (0 патчей, не 1) wallBodiesGeometry(rooms, walls, openCuts, [], GRID_STEP_N, fixture.cell_cm, GRID_PITCH, coordScale, []); // -> { geom, paperGeom, depthUnits, openingIndex } — НЕ null, area(geom) ≈ 99518.06 ``` Результат воспроизводится детерминированно (запускался многократно, всегда одинаково): **ноль** virtual-junction patches вместо заявленного одного, и **валидная непустая geometry** вместо `null`. Проверены дополнительные вариации масштаба (координаты в конфиг-пространстве `coordScale = 1`, разные сочетания `pitch`/`gridPitch`) — ни один вариант, соответствующий описанным в ТЗ или в коде конвенциям масштабирования (комментарий `src/wall-thickness.ts:115-116` «`coordScale = NORM_W` with `pitch = GRID_STEP_N`… config-space edges use `coordScale = 1`»), не воспроизводит `null`. **Расследование, почему patch не строится.** У узла `[620.833…, 550]` (реальный T между стенами 20 см — тот самый, что описан в issue как место дефекта) `virtualJunctionPatches()` требует ⩾2 «касающихся» интервалов с `half > 0`. Прямая проверка `thicknessCmAt()`/`lookupWall()` на этом fixture показала: сама стена `{"key":"0.754167,0.550000@0.0000","cm":20,"a":[0.6208…,0.55], "b":[0.8875,0.55]}` корректно разрешается `lookupWall()` на **весь** пролёт, но `thicknessCmAt()` в его середине после атомарного разбиения на подынтервалы (разбиение возникает из-за частичного перекрытия room_6 и room_8 вдоль этого же `y = 0.55`) возвращает **0**, а не 20/22. Из-за этого у узла остаётся только один интервал с `half > 0` (вертикальная стена), патч не формируется вовсе — крах `union()`, который заявлен как причина, физически не может произойти, потому что до него код не доходит. Это отдельный, самостоятельно воспроизводимый дефект (заведён как #201, `bug`/`P2`/`S1-new`, со ссылкой на #197) — сам по себе вне скоупа #197, но именно он «съедает» патч, который ТЗ считает существующим и падающим. **Почему это блокирует, а не просто интересное наблюдение.** AC1 и AC2 — не частности, они физически формируют regression-тест, который должен «уметь падать» на незащищённом коде (обязательное условие §12 ТЗ и §2.7 PROCESS.md). Если фикстур, как описано в §3, не даёт ни patch, ни `null`, разработчик не сможет написать red-тест по AC1/AC2 буквально по тексту ТЗ — а раз этот тест не может упасть на исходном коде, он не доказывает то, ради чего задача существует (PROCESS.md §2.7: «ревьюер убедился, что тест умеет падать»). Дальше есть три равно вероятных объяснения, и без ответа на них разработка рискует чинить не тот код: 1. reproduction procedure в ТЗ (деление исходных production-координат на `NORM_W` при анонимизации → обратное умножение на `NORM_W` в тесте) не гарантирует битовое совпадение с исходными числами: деление и умножение на 1000 не обязаны быть точным round-trip для произвольного `double`, а весь сюжет issue — именно про чувствительность `union()` к разнице в 1 ULP. Тогда нужен **другой** способ передать fixture в тест (например, без промежуточного деления/анонимизации через `NORM_W`, а с координатами, зафиксированными на исходном масштабе); 2. #201 (тихое обнуление толщины на частичном перекрытии трёх комнат) — самостоятельная причина, из-за которой на ЭТОМ конкретном fixture заявленный патч не возникает; после её исправления сценарий ТЗ может воспроизвестись как заявлено — но тогда порядок работ («что делаем первым») из продуктового становится техническим вопросом, который ТЗ #197 не рассматривает вовсе; 3. диагноз §3 ТЗ в целом требует уточнения на заново подтверждённом fixture (не обязательно неверен по существу — код действительно не изолирует `union()` одного patch, это подтверждено чтением кода независимо от fixture, — но опора именно на «дефект воспроизведён исполнением» с этими конкретными числами недостоверна). **Рекомендация.** Прежде чем issue уходит в `S5-ready`, автор обязан: заново подтвердить `null`/один-patch на fixture так, чтобы это мог повторить кто угодно (например, приложив ровно тот код воспроизведения, который использовался, а не только текстовое описание шагов), либо скорректировать §3/AC1/AC2 под fixture, который действительно падает на `origin/dev`. Технический характер причины (round-trip координат) не делает находку продуктовым вопросом владельцу — это ровно тот случай, который ревью решает сам (PROCESS.md §7.1): «Всё, чего пользователь не наблюдает, агенты решают сами». Продуктовый контракт §4/§7 ТЗ (patch fail не должен гасить кладку) не оспаривается и, по прочтению кода независимо от fixture, обоснован верно — речь только о доказательной базе AC1/AC2 на этом конкретном наборе данных. ## Что проверено и корректно - **Диагноз механизма отказа (без привязки к конкретному fixture).** Отсутствие per-piece `try/catch` вокруг junction-patch loop (`src/wall-thickness.ts:1760-1761`) в отличие от двух соседних циклов — подтверждено чтением кода дословно, независимо от вопроса, воспроизводит ли именно этот fixture крах. Это реальный, объективно существующий разрыв в изоляции отказа, и предложенное исправление (§7.2 ТЗ: transactional per-patch fallback) адресует именно его. - **Продуктовый контракт.** Ссылка на `docs/specs/141-wall-junctions.md` §7.6/§7.7 как источник уже принятого архитектурного решения («boolean failure не маскирует исчезновение кладки») подтверждена чтением этого документа — не выдумана и не выдана вслепую за факт. - **Полнота обязательных разделов ТЗ.** Все разделы §7.1 PROCESS.md на месте, однозначны, у каждого AC указан способ доказательства. - **Скоуп/не-скоуп.** Границы с #198 (вне-сеточный micro-interval) и #199 (self-check Optimize) проведены чётко и не пересекаются с #197. - **Трек.** Обычный трек обоснован верно (сложность 6, риск 8, несколько поверхностей) — `small`/`trivial` были бы нарушением критериев §5/§5.1 PROCESS.md. - **Технические предположения (§17).** Помечены явно как «assumed, change freely» в соответствии с PROCESS.md §7.1 — никакой техническое решение не выдано за продуктовый факт без пометки. - **Модель данных, i18n, touch, миграция, откат, release-артефакты** — корректно отражают «без изменений», согласуется с характером задачи (чистая геометрическая правка без нового UX). - **Открытых продуктовых вопросов к владельцу не осталось** — единственный найденный пробел (High-1) технический, а не продуктовый: ни «что видит пользователь», ни «объём видимых изменений» здесь не решаются, значит, по PROCESS.md §7.1 он не эскалируется владельцу, а решается автором и ревью. ## Чего не проверял - **Численная стабилизация (§7.1 контракт, §17.1 tolerance).** Не проверялось экспериментально, действительно ли округление patch-координат с точностью `coordScale × 10⁻¹²` (или альтернатива) устраняет крах `union()` **в реальном крашащем сценарии** — поскольку у меня на fixture из issue крах не наступил вовсе (High-1), проверить «лечит ли предложенный numeric step именно эту причину» было нечем. Требует повторной проверки после разбора находки High-1. - **AC3–AC8, AC10, AC11 на уровне реального прогона** — не запускал `npm test`/`npm run build`/browser smoke: на этапе ревью ТЗ продуктового кода нет (только два doc-файла в коммите), прогон гейтов реализации вне скоупа этапа (PROCESS.md §2.4/§8). Формулировки этих AC оценивались только на однозначность текста и адекватность заявленного метода доказательства. - **Полный набор regression-фикстур #123/#141/#150/#172** (AC6) — не прогонялся; полагался на то, что они уже зелёные на `dev` (не тронуты этим ТЗ) и что их перечисление в AC6 корректно охватывает риск регрессии. - **golden/визуальная сцена (AC9)** — не создавалась и не оценивалась визуально: на этапе ТЗ сцены не существует. - **Полный causal chain #201** — заведённый по итогам этого ревью issue зафиксирован на уровне voспроизведённого факта (`thicknessCmAt` возвращает 0 для корректно объявленного интервала при частичном перекрытии трёх комнат), но не диагностирован до конкретной строки/функции-виновника; это аналитика #201, не эта работа. ## Вердикт High: 1 (центральное фактическое обоснование AC1/AC2 не подтверждено независимым исполнением точно описанного в ТЗ сценария) · Medium: 1 → #201 (тихое обнуление толщины на частичном перекрытии трёх комнат — самостоятельный воспроизводимый дефект, обнаруженный в процессе проверки High-1, заведён отдельным issue со ссылкой на #197, не блокирует #197 по существу) · Low: 0. **Красный.** ТЗ возвращается автору для повторной проверки причинно-следственной цепочки на исполняемом fixture (см. рекомендацию в High-1) и корректировки §3/AC1/AC2 по результату — либо доказательством, что первоначальный fixture всё же воспроизводит `null` иным путём вызова, либо заменой fixture/подхода на тот, что действительно падает на `origin/dev`.