Files
houseplan-card/docs/specs/331-junction-limit-precision.md
T
Codex 4423d28579 docs: spec #331 revision 3 — AC6 tells the two sides apart
r2 M-r2-1: AC6 now mirrors AC5's structure with two explicit cases — a
candidate-side TypeError is an honest WS error, a previous-side TypeError
falls back to "no baseline" and the unrelated write passes. An
implementation with swapped or missing asymmetry turns at least one of the
two units red. L2: the risk wording follows §2.3's component-sum phrasing.

Issue: #331
User-Visible: no
2026-08-28 04:30:13 +03:00

181 lines
16 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.
# Issue #331 — пограничная точность ограничений стыков (#329)
Статус: ревизия 3 (r2: M-r2-1 — AC6 различает стороны, L2 — формулировка риска синхронизирована с §2.3. spec-ревью r1: H1 — квантование по формуле канонизации
вместо нативного round, M1 — порог инцидентности 2e-7 и исправленный пример,
M2 — узкий except только для кандидата, M3 — обход по рёбрам вместо DFS,
M4 — USER-GUIDE, L1 — пользовательская фраза §1). Родители: #329 (правила), #330 (производительность —
слита, код проверок актуален по её итогу). Сёстры вне скоупа: #333
(optimize/import-лазейка), #339 (смешанные толщины у острия).
## 1. Сценарий и пользовательский результат
Пользователь заканчивает ресайз или рисование на обычном плане — и получает
отказ «стены слишком близко: 0.00001 см», хотя ничего не нарушал; а сосед,
случайно нарисовавший стену дважды по одной линии, не получает отказа
вовсе. Задача делает пограничные вердикты честными.
Класс симптома «легитимная запись отклонена / нелегитимная пропущена» уже
дважды бил по бетам (#316, #319). Репро на `dev` после #330 (все —
исполнением):
- пара узлов `−1e-8` и `0` (плавающий мусор ресайза до канонизации) — **два
ложных нарушения П4** с `actual = 0.000012 см`: ключи узлов `toFixed(6)`
дают `"-0.000000"` и `"0.000000"`;
- точный дубль стены (клин ровно 0°) не ловится ни П1, ни П4 — худший
вырожденный случай невидим, тогда как 0.5° блокируется;
- цепочка из ~9000 коллинеарных атомов — `RangeError` (обе реализации;
на бэке ушло бы клиенту как `unknown_error` с трейсбеком);
- при двух коллинеарных продолжениях в узле `.find` берёт первое — вторая
ветвь молча теряется, длина стены занижается → ложный отказ П3;
- исключение при проверке КАНДИДАТА пропускает запись (fail-open) — в
противоход установленному fail-closed гарду #278;
- `except Exception` в `_migrated_spaces` маскирует настоящие баги миграции;
- дуга из атомов по ~0.9°/шаг накапливает произвольный поворот, считаясь
одной «стеной» П3.
Результат: на границах вердикты честные и одинаковые в обоих зеркалах;
переполнений стека нет; отказ проверки — это отказ записи, не пропуск.
## 2. Решение (нормативно, оба зеркала симметрично)
### 2.1 Квантованный ключ узла и инцидентность
Ключ узла — координаты, квантованные к **1e-7** по формуле канонизации
этого репозитория: `sign(v) · floor(|v|·1e7 + 0.5) / 1e7` (r1-H1: нативные
`Math.round`/`round()` расходятся на .5-тиках — banker's rounding в python
против round-half-away в JS; `coordinate-canonicalization.ts/.py` уже
используют ровно эту формулу с комментарием о паритете, и ключи обязаны ей
следовать). `−0` нормализуется в `0`; формат строки ключа одинаков в TS и
python. Порог выбран на два порядка грубее канонической сетки хранения
(1e-9) и на порядки тоньше любого осмысленного зазора плана (минимальный
порог правил — 5 см ≈ 4e-4 в норм. координатах).
В П4 пары узлов с евклидовой дистанцией ≤ **2e-7** (по сырым координатам)
считаются ОДНИМ узлом — инцидентность, не нарушение (r1-M1: порог покрывает
пару, севшую на соседние кванты, например `−5.1e-8` и `5.1e-8` с сырой
дистанцией 1.02e-7). Известное следствие для П2: мусорная пара на соседних
квантах остаётся двумя записями в карте валентности — недосчёт не хуже
текущего поведения и валентность никогда не ЗАВЫШАЕТСЯ; помечено в §3.
### 2.2 П1: ноль градусов — честное нарушение
Фильтр `degrees > EPS` снимается: дельта ~0° между лучами узла — нарушение
угла с `actual ≈ 0`. Легальных 0°-клиньев не существует: коллинеарный стык
атомов одной прямой стены даёт в узле 180°-пару, а 0° — всегда дубль или
наложение стен. Точный дубль сегмента становится видим (два одинаковых
луча из одного узла).
### 2.3 Итеративный прогон П3 с максимальной ветвью
`collinearRunLengthUnits` / `collinear_run_length_units` переписываются
итеративно (явный стек, без рекурсии — глубина входа больше не
ограничивает). При развилке коллинеарных продолжений одной толщины ветвь
больше не теряется: обход идёт **по рёбрам с visited-набором** — каждый
атом участвует в прогоне не более одного раза, суммарная работа O(E) на
вызов независимо от числа развилок (r1-M3: полный DFS по вариантам дал бы
комбинаторику; вместо «максимальной из всех путей» прогоном считается
суммарная длина коллинеарной КОМПОНЕНТЫ связности — это консервативнее к
пользователю, чем текущий `.find`, монотонно и дёшево). Бюджеты бенча #330
остаются зелёными без правок — закреплено AC7.
### 2.4 Коллинеарность — к базе цепочки
Допуск ±1° меряется к направлению ПЕРВОГО сегмента прогона, а не к
предыдущему атому — накопление поворота по дуге исчезает. Прямая стена с
легальными микро-изломами (<1° суммарно) остаётся одной стеной; дуга,
набравшая >1° от базы, — нет.
### 2.5 Fail-closed на кандидате
Исключение при вычислении нарушений КАНДИДАТА в `_junctionLimitsIntroduced`
— отказ записи с тостом (ключ `junction.limit_check_failed`, i18n en+ru), по
образцу fail-closed гарда #278. Baseline остаётся fail-open (недоказуемое
наследование не повод отклонять запись) — асимметрия сознательная и
комментируется в коде.
### 2.6 Узкий except в зеркале
`_migrated_spaces` получает флаг стороны (r1-M2 — симметрия с §2.5):
- для **кандидата** — `except (WallSegmentMigrationError, ValueError)` +
`_LOGGER.debug`; TypeError/RecursionError и прочие баги миграции всплывают
в общий обработчик WS честной ошибкой (fail-closed, как §2.5);
- для **previous** — прежний широкий фолбэк «нет базы для наследования» +
`_LOGGER.debug`: недоказуемое наследование не повод блокировать
несвязанную запись (тот самый симптом П1, который задача чинит).
## 3. Границы
- Пороги правил П1–П5 (#329) не меняются; меняются вердикты только
пограничных классов: плавающий мусор (было: ложный отказ), 0°-дубль
(было: невидим), развилка/глубокая цепочка (было: занижение/краш), дуга
(было: одна «стена»).
- Спека #329 §2 дополняется абзацем о квантовании ключей и нуле градусов
(той же правкой, отдельной ревизией её файла).
- Известное следствие §2.1: валентность узла (П2) на мусорной паре соседних
квантов может недосчитать единицу — не хуже текущего поведения и не
завышает (r1-M1).
- Вне скоупа: #333, #339, любые изменения производительности (#330 —
бюджеты бенча обязаны остаться зелёными).
## 4. Acceptance criteria
- **AC1 (ключи/инцидентность).** Пара `−1e-8`/`0` — ноль нарушений П4;
пара «через границу кванта» `−5.1e-8`/`5.1e-8` (сырая дистанция 1.02e-7 ≤
порога 2e-7) — ноль; узлы на дистанции 4 см — по-прежнему нарушение;
ровно 5 см — нет. Квантование на .5-тике (`v = 2.5e-7`) даёт ОДИН и тот
же ключ в TS и python (формула канонизации, r1-H1) — прямой юнит в
паритет-наборе. Оба зеркала.
- **AC2 (0°).** Точный дубль стены — нарушение П1 с `actual < 0.001°`;
прямая стена из двух атомов через узел (180°-пара) — по-прежнему чиста;
T-стык — чист. Оба зеркала.
- **AC3 (итеративность/ветви).** Цепочка 10 000 атомов — ответ без
переполнения (обе реализации); развилка коллинеарных ветвей — длина =
суммарной длине компоненты (юнит с точным ожиданием, обе ветви учтены);
цепочка со 100 развилками подряд — линейное время (входит в бенч-сетку
через юнит-таймаут, r1-M3); короткий доборный атом перепада толщин
остаётся законным (существующий AC3b #329 зелёный).
- **AC4 (дуга).** Дуга 30×0.9° не считается одной стеной (первый атом
короче 20 см в сумме с коллинеарной БАЗЕ частью — отказ П3); прямая с
одним изломом 0.5° — считается.
- **AC5 (fail-closed).** Синтетическое исключение в проверке кандидата
(мутант/подмена) — запись отклонена с тостом `junction.limit_check_failed`;
исключение на baseline — запись проходит. Смок/юнит.
- **AC6 (except).** Два явных случая по образцу AC5 (r2 M-r2-1):
(а) TypeError в миграции **кандидата** — честная ошибка WS, не тихое
«нарушений нет»; (б) TypeError в миграции **previous** — запись
ПРОХОДИТ по фолбэку «нет базы для наследования» (несвязанную правку не
блокирует баг чужой стороны). `WallSegmentMigrationError` в обоих случаях
— прежний фолбэк «нет базы». Реализация с перепутанными сторонами или без
асимметрии красит хотя бы один из двух юнитов. Backend-юниты.
- **AC7 (паритет и регресс).** Паритет-набор расширен всеми классами выше;
все существующие юниты/смоки/мутанты #329+#330 и бенч #330 зелёные без
изменения ожиданий (кроме прямо описанных в §3 пограничных вердиктов).
## 5. План тестов
Юниты границ на каждый пункт §2 в обоих зеркалах + расширение паритета;
смок AC5 (тост отказа при сломанной проверке — через подмену в смоке);
мутанты: `junction-limit-zero-wedge-invisible` (возврат фильтра
`degrees > EPS`), `junction-limit-key-precision-lost` (возврат toFixed(6)),
`junction-limit-branch-dropped` (возврат `.find` первой ветви),
`junction-limit-candidate-fail-open` (возврат `catch { return []; }`).
Бенч #330 — без правок бюджетов.
## 6. Обязательные разделы
- **i18n**: один новый ключ `junction.limit_check_failed` (en+ru).
- **Touch**: не задет.
- **Риски**: (1) квантование ключей меняет вердикты классов входов —
закрыто явным перечнем §3 и паритетом; (2) сумма коллинеарной компоненты
может легализовать ранее отклонявшиеся конфигурации — это исправление
занижения, фиксируется юнитом с точным ожиданием; (3) 0°-нарушение
может всплыть на легаси-планах с дублями — наследование #329 §3
по-прежнему пропускает несвязанные правки (инвариант не трогается).
- **Release**: обычная бета; `docs/USER-GUIDE.ru.md` и `.md` — раздел
«Ограничения стыков стен» дополняется предложением о тосте
`junction.limit_check_failed` (отказ при невозможности проверить, r1-M4);
CHANGELOG (en+ru) одной строкой user-visible
(«точность проверок стыков на границах: ложные отказы устранены, дубли
стен видимы»); откат — чистый revert.