mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-30 11:49:16 +00:00
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
190 lines
17 KiB
Markdown
190 lines
17 KiB
Markdown
# 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.
|