Files
houseplan-card/docs/reviews/SPEC-REVIEW-331-r1.md
T
2026-08-28 01:16:18 +00:00

243 lines
21 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-331-r1
Issue: #331 — «Ограничения стыков (#329): пограничная точность даёт ложные
отказы и невидимые дубли»
ТЗ: `docs/specs/331-junction-limit-precision.md`
Ветка/SHA материала: `issue/331-junction-limit-precision` @ `60e125e9`
Трек: полный (не `small`; аналитик явно назвал критерий, который задача не
проходит: «две поверхности — оба зеркала + барьер карточки», меняются
вердикты граничных классов).
Заход: r1 · блокирующих циклов израсходовано (до этого раунда) 0 из 4.
## Скоуп
ТЗ описывает шесть нормативных правок к #329 (`junction-limits.ts` /
`junction_limits.py`): квантование ключа узла + инцидентность П4, снятие
фильтра `degrees > EPS` в П1, итеративный обход П3 с выбором максимальной
ветви на развилке, коллинеарность дуги к базе прогона, fail-closed на
кандидате, сужение `except Exception` в `_migrated_spaces`. Родитель #329,
сестринские issue вне скоупа (#333, #339) — учтены и явно исключены. Первого
коммита с продуктовым кодом ещё нет — на ветке лежит только файл ТЗ
(`60e125e9`, один файл, спецификация).
## Как проверялось
Прочитаны в порядке из инструкции: `docs/SCOPE.md`, `AGENTS.md`,
`PROCESS.md` §2.4/§7.1, тело issue #331 и оба комментария (S2-аналитика,
хендофф автора ТЗ), `docs/USER-GUIDE.ru.md` (раздел «Ограничения стыков
стен»), канонический документ подсистемы — здесь это не `WALL-THICKNESS.md`
(он про геометрию mitre/bevel, не про правила П1–П5), а собственный файл
родителя `docs/specs/329-junction-limits.md`, использованный как эталон
формата и терминологии. Для проверки числовых утверждений ТЗ прочитан
текущий код: `src/junction-limits.ts`, `custom_components/houseplan/
junction_limits.py`, `src/coordinate-canonicalization.ts` и её питон-зеркало
`coordinate_canonicalization.py` (существующий канонический механизм
округления координат в этом же репозитории — нужен, чтобы проверить, не
изобретает ли ТЗ параллельный, менее надёжный способ).
Гейты `typecheck`/`test`/`build` не гонялись: продуктового кода в диффе нет,
дифф — исключительно новый файл `docs/specs/331-*.md` (класс C). Это стадия
`spec`, а не `code`; §8/§10.2 PROCESS.md к ней не применяются.
## Находки
### High
**H1. Ключ узла квантуется через `round()`, а не через уже принятый в этом
репозитории cross-language-safe механизм — риск разойтись между TS и
Python ровно на точке .5-кванта.**
`docs/specs/331-junction-limit-precision.md:35` предписывает: «Ключ узла —
координаты, квантованные к **1e-7** (`round(v·1e7)/1e7`)... формат строки
ключа одинаков в TS и python». Формулировка не называет, какой именно
`round` имеется в виду, и умалчивает про то, что нативные функции округления
в TS и в Python **расходятся на .5-тиках**: JS `Math.round` всегда округляет
дробную половину в сторону `+∞` (`Math.round(2.5) === 3`,
`Math.round(-0.5) === -0`), а Python `round()` — банковское округление к
чётному (`round(2.5) == 2`, `round(0.5) == 0`).
Это не гипотеза, а задокументированный факт этого же репозитория:
`src/coordinate-canonicalization.ts:47-53` (`canonicalizeNumber`) и её
зеркало `custom_components/houseplan/coordinate_canonicalization.py:20-31`
(`canonicalize_number`) намеренно НЕ используют `round`/`Math.round` —
обе стороны считают `sign * floor(abs(value) * FACTOR + 0.5) / FACTOR`,
и питоновский файл прямо комментирует причину в
`coordinate_canonicalization.py:45`: `# JavaScript Math.round: ties go
toward +infinity, unlike Python round().` Тот же файл содержит второй метод
(`canonicalize_lattice_coordinate`) — оба используют идентичную формулу
именно ради паритета на тиках.
Конкретное воспроизведение расхождения для формулы ТЗ «`round(v·1e7)/1e7`»,
если её реализовать буквально нативными функциями:
`v = 0.00000025` (2.5e-7 в нормализованных координатах — не экзотика, а ровно
середина между двумя соседними квантами, куда попадают, например,
масштабированные «круглые» координаты после `cmToUnits`). `v·1e7 = 2.5`.
`Math.round(2.5) = 3` → ключ `3e-7`. Python `round(2.5) = 2` → ключ `2e-7`.
**Один и тот же candidate получает разные ключи узла на фронте и на
бэкенде** — то есть ровно тот класс дефекта, который в этом же ревью
явно назван дважды дорогим (#258, #259: ключ записи толщины не совпал с
ключом решёточного ребра). AC1 требует паритет «через паритет-набор»,
но паритет-набор проверяет исходы, а не формулу; конкретно эта пара тиков
в него может не попасть, и тогда разъезд ключей останется незамеченным
до продакшна — ровно тем способом, каким приходили #258/#259.
Правка ТЗ: заменить `round(v·1e7)/1e7` на явную, одинаковую в обеих сторонах
формулу с зафиксированным направлением округления половинки (например,
переиспользовать уже принятый в проекте `sign * floor(abs(v)*1e7+0.5)/1e7`
из `coordinate-canonicalization`, а не изобретать новую). Замечание в
скоупе задачи, чинится тем же ТЗ.
### Medium
**M1. Собственный пример AC1 не проходит собственный порог инцидентности.**
`docs/specs/331-junction-limit-precision.md:44` формулирует инцидентность
П4 так: «пары узлов с евклидовой дистанцией **≤ 1e-7** считаются ОДНИМ
узлом... (например `−5.1e-8` и `5.1e-8`)», и AC1 (`:98`) повторяет этот же
пример как «пара «через границу кванта» — ноль [нарушений]».
Арифметика: `|5.1e-8 − (−5.1e-8)| = 1.02e-7`, что **строго больше** 1e-7.
По букве правила эта пара НЕ инцидентна (дистанция не ≤ 1e-7), а значит по
П4 это два разных узла на дистанции 1.02e-7 нормализованных единиц —
величина на четыре порядка меньше реального порога 5 см (≈4e-4), то есть
ровно тот случай, который правило обязано отклонить как «слишком близко».
Реализованная строго по тексту формула провалит собственный пример AC1: либо
порог должен быть не `≤1e-7`, а как минимум `≤1.02e-7` (например `2×` кванта,
что заодно естественно закрывает произвольную пару тиков по разные стороны
границы округления), либо числа примера должны быть скорректированы
(например `−4.9e-8`/`4.9e-8`, дистанция `9.8e-8 ≤ 1e-7`). Как написано,
AC1 неоднозначен ровно на том примере, который должен его иллюстрировать.
**M2. Не решено, распространяется ли fail-closed §2.6 на сторону
`previous`, и не создаёт ли это новый способ отклонить непричастную запись.**
`_migrated_spaces` (`custom_components/houseplan/junction_limits.py:300-330`)
вызывается для ОБЕИХ сторон сравнения: `_migrated_spaces(previous)`
(baseline) и `_migrated_spaces(config)` (candidate). ТЗ §2.6 сужает
`except Exception` до `(WallSegmentMigrationError, ValueError)` для этой
функции целиком, не различая вызовы.
Для candidate это соответствует уже принятому в этом же ТЗ принципу
fail-closed (§2.5). Но для `previous` ТЗ прямо в соседнем пункте (§2.5)
формулирует другой принцип: «Baseline остаётся fail-open (недоказуемое
наследование не повод отклонять запись) — асимметрия сознательная и
комментируется в коде». §2.6 эту асимметрию не упоминает вовсе. Если
`_migrated_spaces(previous)` теперь бросает TypeError/RecursionError на
каком-то экзотическом, но уже существующем (то есть когда-то принятом)
документе, а не глотает его — запись, вообще не касающаяся геометрии стен,
будет честно отклонена ошибкой WS. Это именно симптом «легитимная запись
отклонена», ради устранения которого заведена вся задача (P1, дважды
ударивший по бетам — #316, #319), только теперь по новой причине.
Может быть, это осознанный выбор (surfacing реальных багов миграции важнее
доступности) — но тогда он должен быть назван явно, как назван для §2.5, а
не выведен по умолчанию из общей формулировки. AC6 (`:113`) не говорит, к
какой стороне (previous/candidate/обеим) относится тестовый документ с
TypeError — без этого AC не проверяем однозначно.
**M3. Максимальная ветвь ищется DFS-перебором вариантов; наихудшая
сложность не оценена и не застрахована стресс-тестом, при жёстком
требовании удержать бюджет бенча #330.**
§2.3 (`:58-60`): «При развилке из нескольких коллинеарных продолжений одной
толщины берётся максимальная ветвь (DFS по вариантам, visited на рёбрах)».
Валентность узла ограничена П2 (≤6), но это ограничивает только
ветвление В ОДНОМ узле, а не число узлов-развилок вдоль цепочки: документ,
где подряд идут K развилок по 2-3 равноценные по толщине коллинеарные
продолжения, даёт полный перебор комбинаций порядка `2^K`..`3^K`, а не
линейную сложность. Это тот же класс входа, что уже один раз уронил П3
(рекурсия на длинной цепочке, #331 п.3) — только вместо глубины стека
уязвимое место сместилось на число развилок.
§3 требует бюджеты бенча #330 зелёными без изменений, AC7 — то же самое, но
план тестов (§5) и AC3 проверяют только (а) один прямой прогон на 10 000
атомов (проверяет только стек, без развилок) и (б) «развилку двух
коллинеарных ветвей» — судя по формулировке, единичную, не цепочку из многих
развилок. Ни один AC/мутант не нагружает именно комбинаторику перебора.
Это не значит, что реализация обязательно медленная — но ТЗ утверждает
бюджет бенча гарантированным, не предъявляя для этого доказательства по
самому изменяемому алгоритму.
**M4. Release-артефакты не называют `docs/USER-GUIDE.ru.md`.**
§6 «Release» перечисляет только CHANGELOG. Новый канал отказа —
`junction.limit_check_failed`, тост при поломке САМОЙ проверки — это новое
наблюдаемое пользователем состояние (запись отклонена без названия
конкретного правила П1–П5). Раздел «Ограничения стыков стен» в
`docs/USER-GUIDE.ru.md:420-444` уже документирует ровно этот класс
информации (пороги, где виден отказ, чем отличаются каналы рисования/
Resize/«Толщины») — тем самым методом, каким его заводил #329 (`docs/
specs/329-junction-limits.md` §6: «раздел об ограничениях рисования»). ТЗ
формально можно закрыть без этой правки (гейт `check-docs.mjs` её не
поймает — там нет `src/**`-диффа для этой строки), но тогда справочник
опишет отказы неполно. Фиксируется одной строкой в §6 ТЗ; не блокирует —
можно решить и отклонить с запиской, если авторы считают ошибку проверки
внутренним деталем, а не пользовательским контрактом.
### Low
**L1. §1 не отвечает на два обязательных продуктовых вопроса ТЗ в открытой
форме — какая персона, на какой поверхности, и что человек видит одной
фразой без терминов реализации (PROCESS.md §7.1, AGENTS.md).**
Раздел «Сценарий и пользовательский результат» и особенно строка
«Результат: ...вердикты честные и одинаковые в обоих зеркалах;
переполнений стека нет; отказ проверки — это отказ записи, не пропуск»
написаны в терминах реализации («зеркала», «ключи узлов», «отказ
проверки» вместо «домашний админ теряет правку плана» или подобного).
Персона (Home admin, `docs/SCOPE.md`) и поверхность (редактор плана,
десктоп) не названы явно, хотя подразумеваются списком каналов
(рисование/Resize/«Толщина»). Для сравнения — принятый ТЗ-предшественник
`docs/specs/329-junction-limits.md` §1 формулирует результат как «(а)
нарисовать такое больше нельзя... (б) уже существующие такие планы
рендерятся прилично» — на том же уровне детализации технической
подоплёки, но с явным «что человек увидит». Не блокирует (стиль документа
уже был принят один раз для #329 в этом же скоупе задач), но следующая
редакция должна дать одно предложение такого рода.
## Что проверено и признано корректным
- Скоуп/не-скоуп: сестринские issue #333/#339 явно исключены, границы §3
чёткие, пороги П1–П5 намеренно не меняются — соответствует
`docs/SCOPE.md` (J6, «Keep the plan true as the home evolves»).
- §2.2 (снятие фильтра `degrees > EPS`): прослежена текущая реализация
`checkNodes` (`src/junction-limits.ts:76-95`) — снятие фильтра не создаёт
ложных срабатываний на легитимной прямой стене через узел (ровно 2 луча
на 180° друг от друга всегда дают на круге две дуги ~180°, а не близкую к
нулю, независимо от точности координат) и на T-стыке (3+ луча, но
вырожденная пара образуется только у геометрически совпадающих лучей).
AC2 однозначен и проверяем.
- §2.4 (коллинеарность дуги к базе): числа AC4 (30×0.9°, 20 см, 0.5° излом)
внутренне согласованы с описанным механизмом (накопление к базе вместо
предыдущего атома).
- §2.5 (fail-closed кандидата): согласован с прецедентом #278, канал тоста
и ключ i18n названы, AC5 проверяем смоком/юнитом.
- Один AC на нормативный пункт §2 (1:1, AC1↔§2.1 ... AC6↔§2.6) плюс AC7 на
паритет/регресс — структура полная, план тестов называет способ
доказательства для каждого пункта (юнит/смок/бэкенд-юнит/мутант).
- i18n: один новый ключ, en+ru — соответствует формату остальных
`junction.limit_*` в `src/i18n/en.json`/`ru.json`.
- Touch: не задет, обоснованно (валидация записи, не жест).
- Обязательство обновить сам файл #329 (§2, абзац про квантование и 0°)
учтено — не заводит параллельный источник истины.
## Чего не проверял
- Производительность фактическая (нет кода — только оценка риска в M3 по
описанию алгоритма).
- Полнота списка golden-сцен/скриншотов — не требуется на этой стадии,
задача не меняет рендер (только вердикты записи и один новый текст тоста).
- Backend-паритет численно (нет кода для запуска
`test_parity_with_the_frontend_checks`).
- `docs/CONFIG-COMPATIBILITY.md` — не тронут задачей и не должен быть,
новых полей конфига нет; проверено чтением §3 (наследование данных не
переопределяется).
## Вердикт
High (H1) блокирует переход в «Готово к разработке». Medium (M1–M4) — в
скоупе задачи, чинятся той же правкой ТЗ без отдельного issue. Low (L1) —
на усмотрение автора, можно снять запиской при повторном заходе.
`Вердикт: красный · заход r1 · блокирующих циклов 1/4 · High: 1 · Medium: 4 → в задаче · Документ: docs/reviews/SPEC-REVIEW-331-r1.md`