diff --git a/docs/specs/223-optimize-coordinate-canonicalization.md b/docs/specs/223-optimize-coordinate-canonicalization.md new file mode 100644 index 00000000..8e1fb6da --- /dev/null +++ b/docs/specs/223-optimize-coordinate-canonicalization.md @@ -0,0 +1,217 @@ +# Issue #223 — Optimize канонизирует координаты без floating-point шума + +- Дата: 2026-08-20 +- Тип: bug / maintenance canonicalisation · приоритет P1 +- Оценка: пользовательская ценность 8/10 · ценность для разработки 7/10 · сложность 4/10 · риск 4/10 +- Issue: [#223](https://github.com/Matysh/houseplan-card/issues/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 остаётся существующим диалогом. В строку обслуживания добавляется +отдельный показатель: + +- RU: «канонизировано координат: {p}»; +- EN: «coordinates canonicalized: {p}». + +Показатель выводится в общей строке всегда, включая ноль, так же как действующие +счётчики миграций/планов/стен/виртуальных фрагментов. При единственном изменении +из-за 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*`. | Units для room poly, rect/decor/layout и off-grid negative case. | +| AC5 | Второй запуск над candidate возвращает `changed: false`, `coordsCanonicalized: 0` и побитово/глубоко тот же JSON. | Idempotence unit. | +| AC6 | RU/EN preview показывает новый счётчик; случай `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()`. +Она считает только успешные near-node записи по контракту §6; все существующие +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. | +| 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. Новый счётчик включается в существующее число «обслужено записей» итогового + toast, хотя единица там исторически агрегирует разные виды обслуживания. +4. Точное имя targeted smoke и размещение tracked wrapper являются техническим + решением, если AC и explicit-Optimize boundary сохраняются. +5. Продуктовых вопросов нет; эти предположения технические и могут быть свободно + скорректированы на ревью без дополнительного решения владельца. diff --git a/docs/specs/README.md b/docs/specs/README.md index 19a8608b..7e8645fa 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -61,6 +61,7 @@ GitHub Issues и GitHub Projects (v2) остаются единственным | [#219](https://github.com/Matysh/houseplan-card/issues/219) Единая палитра замков и glyph на оранжевых подложках | [219-lock-orange-palette.md](219-lock-orange-palette.md) | | [#205](https://github.com/Matysh/houseplan-card/issues/205) Продолжение следа после короткой остановки пылесоса | [205-vacuum-trail-resume-grace.md](205-vacuum-trail-resume-grace.md) | | [#226](https://github.com/Matysh/houseplan-card/issues/226) Entity-marker не дублируется родительским HA-устройством | [226-entity-parent-dedup.md](226-entity-parent-dedup.md) | +| [#223](https://github.com/Matysh/houseplan-card/issues/223) Optimize канонизирует координаты без floating-point шума | [223-optimize-coordinate-canonicalization.md](223-optimize-coordinate-canonicalization.md) | ## P2