Files
houseplan-card/docs/reviews/SPEC-REVIEW-197-r1.md
T
2026-08-19 09:36:36 +00:00

261 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`.