Files
houseplan-card/docs/specs/223-optimize-coordinate-canonicalization.md
2026-08-20 19:32:15 +00:00

21 KiB
Raw Permalink Blame History

Issue #223 — Optimize канонизирует координаты без floating-point шума

  • Дата: 2026-08-20
  • Тип: bug / maintenance canonicalisation · приоритет P1
  • Оценка: пользовательская ценность 8/10 · ценность для разработки 7/10 · сложность 4/10 · риск 4/10
  • Issue: #223
  • Ветка: issue/223-optimize-coordinate-canonicalization

Канонические документы: docs/SCOPE.md, docs/CANVAS.md, docs/USER-GUIDE.ru.md, docs/CONFIG-COMPATIBILITY.md, docs/TESTING.md.

1. Сценарий, персона и момент

Администратор обслуживает старый либо импортированный план через явное действие «Общие настройки → Оптимизировать планы». Координаты комнат визуально уже лежат на сетке, но содержат накопленный IEEE-754 шум величиной в один или несколько ULP. Пользователь ожидает, что Optimize сохранит геометрию без видимого сдвига, запишет канонические узлы сетки и тем самым восстановит надёжную работу объединения комнат, Glow и другой downstream boolean-геометрии.

2. Что человек увидит до и после

До: Optimize считает значения в пределах EPS уже выровненными и возвращает исходные числа бит-в-бит. В воспроизводимом плане из шести комнат preview сообщает, что изменений нет, хотя шум около 5.5e-17 сохраняется и объединение комнат может не распознать общую границу.

После: тот же явный Optimize предлагает изменение без видимого перемещения: сдвинуто элементов — 0, но отдельно сообщает количество канонизированных координат. После подтверждения общие вершины имеют ровно одинаковые числовые значения, объединение комнат и Glow не зависят от прежнего ULP-шума. Повторный Optimize сообщает, что изменений нет.

3. Подтверждённая причина

snapN() в src/align-grid.ts вычисляет ближайший узел s, но при abs(s - v) <= EPS возвращает v. Защитное правило было введено ради идемпотентности, однако фактически консервирует почти канонический ввод. Это противоречит обещанию явного Optimize из docs/CANVAS.md: maintenance-действие должно переписывать persisted-геометрию в каноническую форму.

Потребительская устойчивость из #218 остаётся необходимой страховкой на чтении, но не очищает источник данных. #199 — будущая общая проверка кандидата перед записью, а не исправление данной канонизации. Задачи не являются дубликатами.

4. Scope

  • snapN() всегда возвращает вычисленный ближайший узел сетки для конечного числа, в том числе когда отличие не превышает EPS;
  • alignAllToGrid() отдельно считает канонические замены без заметного перемещения и учитывает их в changed;
  • AlignReport и OptimizeReport получают поле coordsCanonicalized;
  • preview и итоговый toast Optimize на русском и английском объясняют случай moved = 0, changed = true;
  • сохраняются preview/Cancel/Apply/server Undo, идемпотентность, неизвестные поля конфигурации и действующая model/schema compatibility;
  • добавляются unit, targeted production-bundle smoke и mutation guard;
  • актуализируются документы о канонизации координат.

5. Non-scope

  • автоматическая канонизация при каждом Save, импорте, чтении или рендере;
  • изменение шага сетки, EPS, координатной системы или допустимого пользовательского размещения между узлами;
  • замена устойчивых geometry-сравнений из #218 строгим равенством;
  • общая pre-apply валидация/барьер из #199;
  • исправление иных optimizer-pass, стен, проёмов либо миграций модели;
  • новая persisted-схема, model-version, backend API или HA permission;
  • новый control, жест, route либо предупреждение помимо существующего preview.

6. Контракт канонизации и отчёта

Для каждого конечного нормализованного числа v, переданного в snapN(), канонический результат равен:

Math.round(v / GRID_STEP_N) * GRID_STEP_N.

Результат должен быть точно равен этому вычисленному узлу (===), а повторное применение не должно менять значение. NaN и бесконечности сохраняют прежнее поведение и возвращаются без преобразования.

coordsCanonicalized — число отдельных координатных компонент/узлов, для которых в ходе alignAllToGrid() одновременно выполнено:

  1. конечный результат snapN(v) численно отличается от входа;
  2. абсолютная разница не превышает EPS;
  3. результат действительно записан в candidate.

Считается каждое место использования координаты, а не уникальное числовое значение и не объект целиком. Для прямоугольника его ближняя и дальняя стороны считаются как координатные компоненты, даже когда дальняя сторона вычислена как x + w. Заметное выравнивание с разницей > EPS остаётся только в moved и не дублируется в coordsCanonicalized. Изменение угла проёма, удаление draft и канонизация стен/спанов не входят в новый счётчик.

AlignResult.changed истинно при moved > 0 или coordsCanonicalized > 0; остальные существующие причины изменения сохраняют свои текущие контракты. В optimizePlans() новый счётчик переносится без потери в OptimizeReport. maxShift, maxShiftCm и maxSpace не растут от замен в пределах EPS: пользовательское обещание о физическом сдвиге остаётся честным.

7. UX, запись и Undo

Preview остаётся существующим диалогом. В строку обслуживания добавляется отдельный показатель, а существующий счётчик canonicalized получает однозначное название по своей единице измерения:

  • RU: «обновлено пространств: {c}; устранён шум координат: {p}»;
  • EN: «spaces updated: {c}; noisy coordinate values removed: {p}».

c по-прежнему означает число пространств, где переписано представление open_spans/open_to/walls; p означает число отдельных coordinate values по §6. Два разных показателя не используют один термин «канонизировано» в одном предложении. Оба выводятся в общей строке всегда, включая ноль, так же как действующие счётчики миграций/стен/виртуальных фрагментов. При единственном изменении из-за ULP-шума диалог не показывает «изменений нет»: он показывает нулевой видимый сдвиг и положительный coordsCanonicalized.

Итоговый toast использует новый показатель в сумме обслуженных записей, поэтому после Apply не сообщает 0 обслуженных записей. Cancel не пишет candidate. Apply отправляет тот же exact candidate через существующую backend-транзакцию; Undo возвращает исходные noisy-значения в рамках действующего срока жизни резервной копии.

Новых focus, keyboard, touch и screen-reader взаимодействий нет. Действующий admin-only safety floor и подтверждение сохраняются.

8. Данные, compatibility и миграция

Persisted schema и PLAN_MODEL_VERSION не меняются. Числа остаются обычными JSON number; меняется только их exact representation после добровольного Optimize. Неизвестные поля, backdrop calibration, view boxes, unattached layout entries и файлы сохраняются. Входные config и layout не мутируются.

Старые версии карточки читают получившиеся координаты как валидные значения на той же сетке. Фоновая миграция не запускается. Повторный optimizePlans() над собственным результатом возвращает глубокий эквивалент, changed: false и coordsCanonicalized: 0.

9. Acceptance criteria

AC Критерий Доказательство
AC1 Для канонического узла с добавленным/вычтенным ULP snapN() возвращает ближайший вычисленный узел точно, а не исходный шум. Focused align-grid unit; mutant snapn-returns-input-near-node.
AC2 Значение дальше EPS продолжает выравниваться как раньше; не конечные числа сохраняют прежний результат. Boundary unit matrix.
AC3 Воспроизводимый fixture из шести комнат после Optimize имеет точно совпадающие общие вершины, changed: true, moved: 0, coordsCanonicalized > 0; union/downstream geometry успешно строится. plan-optimizer regression unit на fixture из #223/#218.
AC4 Счётчик считает отдельные компоненты только при разнице <= EPS, не дублирует заметно перемещённые элементы и не увеличивает maxShift*. Принятая партиция и wall_column учитываются; у партиции с hostedFit = false вычисленный, но не записанный snap не учитывается и noisy endpoints сохраняются. Units для room poly, rect/decor/layout, применённой и отклонённой partition, wall column и off-grid negative case.
AC5 Второй запуск над candidate возвращает changed: false, coordsCanonicalized: 0 и побитово/глубоко тот же JSON. Idempotence unit.
AC6 RU/EN preview разными терминами показывает «обновлённые пространства» (canonicalized) и «устранённый шум координат» (coordsCanonicalized); случай moved=0 не превращается в «нет изменений»; итоговый toast учитывает очищенные координаты. i18n/UI unit + targeted production-bundle browser smoke.
AC7 Preview не мутирует входы; Cancel ничего не пишет; Apply сохраняет exact candidate; Undo восстанавливает исходные noisy-координаты и неизвестные поля. Optimizer immutability unit + targeted browser/backend smoke.
AC8 Обычные read/render/Save без явного Optimize не переписывают persisted config; устойчивость #218 остаётся зелёной. Existing regression suite + focused negative unit.
AC9 Документация описывает явную exact-канонизацию и значение нового счётчика; три bundle-копии синхронны. Docs check, bundle parity check.
AC10 Рабочие gates зелёные. typecheck, unit, build, targeted smoke, mutation gate.

10. План реализации и тестов

В alignAllToGrid() вводится локальный tracked-механизм над чистым snapN(). Он возвращает snapped value и признак near-node replacement отдельно, а вклад в coordsCanonicalized подтверждается только в месте фактической записи значения в candidate. В частности, partitions добавляет вклад четырёх endpoints лишь в ветке snappedLength > EPS && hostedFit; отклонённая ветка оставляет и координаты, и счётчик без изменения. wall_columns и остальные безусловно записываемые grid-bound call sites подтверждают вклад сразу после присваивания. Счётчик остаётся контекстом одного maintenance-pass и не добавляет глобального состояния в базовый helper.

Unit matrix расширяется в test/align-grid.test.mjs и test/plan-optimizer.test.mjs. Реальный noisy fixture должен сохранять точные исходные числа, чтобы тест падал при возврате старой ветки return v. Targeted smoke запускает Optimize через собранный production bundle и проверяет Preview/Cancel/Apply/Undo вместе с RU/EN строкой. Golden baseline не требуется: компоновка диалога не меняется, добавляется текстовый счётчик; перед бетой выполняется общий golden verify по процессу.

Mutation entry возвращает старое поведение near-node snapN() либо отключает tracked increment. Чистая ветка зелёная, мутант обязан падать на focused unit. Полные golden/smoke/performance выполняются перед бетой, а в implementation- цикле — typecheck, unit, build и targeted smoke из этого ТЗ.

11. Риски, производительность и security

Риск Мера
Счётчик вводит пользователя в заблуждение как число объектов Точное название «координат» и семантика отдельных компонент в §6.
Два разных счётчика выглядят одной категорией канонизации Нейтральное «обновлено пространств» и точное «устранён шум координат» явно называют разные единицы и действия; занятый термин нормализованных координат не используется для c.
Отклонённый snap партиции попадает в отчёт Вклад подтверждается только после фактической записи; отдельный hostedFit=false unit.
Near-node rewrite ошибочно считается физическим сдвигом Раздельные counters; moved и maxShift* не растут при <= EPS.
Идемпотентность нарушается из-за недвоичного шага сетки Результат определяется тем же выражением узла; exact second-run unit для всех call sites.
Часть координат остаётся noisy Все grid-bound вызовы snapN() в batch переводятся на tracked wrapper; реальный fixture.
Изменится обычный Save/render Helper вызывается только в существующем explicit Optimize pipeline; AC8.

Проход остаётся линейным по уже обходившимся координатам и добавляет одну exact- проверку и сравнение на компоненту. Работы в render/state tick, новых сетевых запросов, данных третьим сторонам, HTML-инъекций или изменений HA permissions нет. Отдельный performance/security gate не требуется.

12. Release-артефакты и rollback

Изменение пользовательское. Implementation-коммит имеет User-Visible: yes и включает:

  • docs/CHANGELOG.md и docs/CHANGELOG.ru.md со ссылкой на #223;
  • docs/USER-GUIDE.ru.md — exact-канонизацию и новый показатель Optimize;
  • docs/CANVAS.md — разграничение live snapping и явного maintenance-pass;
  • docs/TESTING.md — unit/smoke/mutation coverage;
  • docs/STATUS.md — фактическую release-линию;
  • RU/EN i18n, unit, targeted smoke, mutation entry и три синхронные bundle- копии.

Новая schema/migration, backend, screenshot baseline, performance baseline и security artifact не нужны. Rollback — revert implementation-коммита. Уже канонизированные координаты остаются валидными и визуально эквивалентными; вернуть прежние noisy-биты можно только существующим Undo Optimize либо backup.

13. Принятые предположения

  1. coordsCanonicalized считает координатные компоненты/узлы, а не объекты: именно это даёт детерминированный отчёт для polygon и box call sites.
  2. Near-node означает abs(s - v) <= EPS; граница включительна и не меняет действующую константу tolerance.
  3. Существующий canonicalized переименовывается только в пользовательском тексте: структура и семантика report field не меняются.
  4. Новый счётчик включается в существующее число «обслужено записей» итогового toast, хотя единица там исторически агрегирует разные виды обслуживания.
  5. Точное имя targeted smoke и размещение tracked-механизма являются техническим решением, если AC и explicit-Optimize boundary сохраняются.
  6. Продуктовых вопросов нет; эти предположения технические и могут быть свободно скорректированы на ревью без дополнительного решения владельца.