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

230 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 остаётся существующим диалогом. В строку обслуживания добавляется
отдельный показатель, а существующий счётчик `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. Продуктовых вопросов нет; эти предположения технические и могут быть свободно
скорректированы на ревью без дополнительного решения владельца.