Волна 5 эпика #674, перенос ТЗ (класс C). Из 240 файлов `docs/specs/` в `legacy/specs/` уехали 219: на них не ссылается ни один живой файл (код, тесты, скрипты, workflow, документы вне архива и ревью). Остались 21 ТЗ — на которые ссылаются код, ADR, ISOMETRIC, SUN, RADAR, LIGHT (`docs/specs/067`), DECOR-EDITOR, support-relay, и те, на которые ссылаются они сами; README каталога объясняет, где искать остальное. Открытых issue с файлом ТЗ среди перенесённых нет. Относительные ссылки перенесённых файлов переписаны (`../X` → `../../docs/X`, соседние оставшиеся ТЗ → `../../docs/specs/…`) — все 26 резолвятся. Попутно: битая ссылка в `089-isometric-view-stage1.md:8` на удалённый `089-isometric-view.md` — теперь команда `git show` по истории. Строка в `legacy/README.md`. Issue: #682 User-Visible: no Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
17 KiB
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.