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

17 KiB
Raw Blame History

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.