Files
houseplan-card/docs/specs/331-junction-limit-precision.md
T
Codex 508945c088 fix: a 0° pair is the shared-wall model, not a duplicate — field revert of #331 §2.2
Red dev caught it ninety minutes after the merge: smoke_plan_drawing_repairs
and smoke_resize_pointer_real_plan went red because the new "a 0° wedge is
always a duplicate" rule refused two ordinary edits — creating a room over
an existing partition ring (#308's legal overlay) and resizing a wall until
it lands on a neighbour's. The premise was wrong at the model level: a
shared wall of two adjacent rooms IS two co-located owner atoms on one line,
so every shared-wall node carries a legitimate 0° pair by construction.
Bisection pinned the exact cut: with only the 0° rule reverted, both smokes
are green again; keys, incidence, the iterative walk and fail-closed stay.

Spec revision 4 records the revert and returns "an exact duplicate wall is
invisible to П1" to the status of a KNOWN LIMITATION — an honest detector
needs owner identity, which is a separate decision for the owner to make.
The zero-wedge mutant is removed with its rule; the .5-tick parity unit now
observes quantisation through valence instead of the retired duplicate
visibility; changelogs drop the over-promise.

Issue: #331
User-Visible: yes
2026-08-28 05:20:45 +03:00

190 lines
17 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)
Статус: ревизия 4 — ПОЛЕВОЙ ОТКАТ §2.2 после мержа: 0°-пара между
атомами НЕ нарушение и не может им быть — общая стена смежных комнат это
два co-located атома-владельца по одной линии, 0° в каждом её узле легален
по построению, а ресайз до совпадения со стеной соседа — обычная правка.
Красный dev (смоки resize_pointer_real_plan, plan_drawing_repairs) вскрыл
это через полтора часа после мержа; «дубль стены невидим» возвращается в
статус ИЗВЕСТНОГО ОГРАНИЧЕНИЯ П1 — его честное решение требует различения
владельцев атомов и выходит за рамки задачи. AC2 сужен до «0°-пары легальны,
настоящий клин ловится, 180°/T-стык чисты». (ревизия 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: ноль градусов — ОТКАЧЕНО ревизией 4
Первоначальное решение («0° — всегда дубль») опровергнуто полем: общая
стена смежных комнат — два co-located атома-владельца, 0°-пара в каждом её
узле легальна по построению; перегородка поверх стены комнаты (#308) — тот
же класс. Фильтр `degrees > EPS` сохраняется; точный дубль стены остаётся
известным ограничением П1 (для честного обнаружения нужно различение
владельцев атомов — отдельная задача, если владелец захочет).
### 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.