diff --git a/docs/specs/331-junction-limit-precision.md b/docs/specs/331-junction-limit-precision.md new file mode 100644 index 00000000..f1e388d9 --- /dev/null +++ b/docs/specs/331-junction-limit-precision.md @@ -0,0 +1,142 @@ +# Issue #331 — пограничная точность ограничений стыков (#329) + +Статус: ревизия 1. Родители: #329 (правила), #330 (производительность — +слита, код проверок актуален по её итогу). Сёстры вне скоупа: #333 +(optimize/import-лазейка), #339 (смешанные толщины у острия). + +## 1. Сценарий и пользовательский результат + +Класс симптома «легитимная запись отклонена / нелегитимная пропущена» уже +дважды бил по бетам (#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** (`round(v·1e7)/1e7`) с +нормализацией `−0 → 0`; формат строки ключа одинаков в TS и python. Порог +выбран на два порядка грубее канонической сетки хранения (1e-9) и на +порядки тоньше любого осмысленного зазора плана (минимальный порог правил — +5 см ≈ 4e-4 в норм. координатах): плавающий мусор сливается, реальная +геометрия неразличимой не становится. + +В П4 пары узлов с евклидовой дистанцией ≤ **1e-7** считаются ОДНИМ узлом +(инцидентность, не нарушение) — закрывает случай «через границу кванта» +(например `−5.1e-8` и `5.1e-8`). + +### 2.2 П1: ноль градусов — честное нарушение + +Фильтр `degrees > EPS` снимается: дельта ~0° между лучами узла — нарушение +угла с `actual ≈ 0`. Легальных 0°-клиньев не существует: коллинеарный стык +атомов одной прямой стены даёт в узле 180°-пару, а 0° — всегда дубль или +наложение стен. Точный дубль сегмента становится видим (два одинаковых +луча из одного узла). + +### 2.3 Итеративный прогон П3 с максимальной ветвью + +`collinearRunLengthUnits` / `collinear_run_length_units` переписываются +итеративно (явный стек, без рекурсии — глубина входа больше не +ограничивает). При развилке из нескольких коллинеарных продолжений одной +толщины берётся **максимальная** ветвь (DFS по вариантам, visited на рёбрах) +— вторая ветвь больше не теряется молча. + +### 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`: `except Exception` → `except (WallSegmentMigrationError, +ValueError)` + `_LOGGER.debug` с причиной. TypeError/RecursionError и прочие +баги миграции всплывают в общий обработчик WS честной ошибкой. + +## 3. Границы + +- Пороги правил П1–П5 (#329) не меняются; меняются вердикты только + пограничных классов: плавающий мусор (было: ложный отказ), 0°-дубль + (было: невидим), развилка/глубокая цепочка (было: занижение/краш), дуга + (было: одна «стена»). +- Спека #329 §2 дополняется абзацем о квантовании ключей и нуле градусов + (той же правкой, отдельной ревизией её файла). +- Вне скоупа: #333, #339, любые изменения производительности (#330 — + бюджеты бенча обязаны остаться зелёными). + +## 4. Acceptance criteria + +- **AC1 (ключи/инцидентность).** Пара `−1e-8`/`0` — ноль нарушений П4; + пара «через границу кванта» (`−5.1e-8`/`5.1e-8`) — ноль; узлы на + дистанции 4 см — по-прежнему нарушение; ровно 5 см — нет. Оба зеркала, + через паритет-набор. +- **AC2 (0°).** Точный дубль стены — нарушение П1 с `actual < 0.001°`; + прямая стена из двух атомов через узел (180°-пара) — по-прежнему чиста; + T-стык — чист. Оба зеркала. +- **AC3 (итеративность/ветви).** Цепочка 10 000 атомов — ответ без + переполнения (обе реализации); развилка двух коллинеарных ветвей — длина + = максимальной ветви (юниты с точным ожиданием), короткий доборный атом + перепада толщин остаётся законным (существующий AC3b #329 зелёный). +- **AC4 (дуга).** Дуга 30×0.9° не считается одной стеной (первый атом + короче 20 см в сумме с коллинеарной БАЗЕ частью — отказ П3); прямая с + одним изломом 0.5° — считается. +- **AC5 (fail-closed).** Синтетическое исключение в проверке кандидата + (мутант/подмена) — запись отклонена с тостом `junction.limit_check_failed`; + исключение на baseline — запись проходит. Смок/юнит. +- **AC6 (except).** Битая форма документа (TypeError в миграции) — честная + ошибка WS, не тихое «нарушений нет»; `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**: обычная бета; CHANGELOG (en+ru) одной строкой user-visible + («точность проверок стыков на границах: ложные отказы устранены, дубли + стен видимы»); откат — чистый revert.