# Issue #224 — Канонические координаты на каждой записи - Дата: 2026-08-22 - Тип: bug / storage invariant · приоритет P1 - Оценка: пользовательская ценность 6/10 · ценность для разработки 8/10 · сложность 5/10 · риск 5/10 - Issue: [#224](https://github.com/Matysh/houseplan-card/issues/224) - Ветка: `issue/224-config-coordinate-canonicalization` Канонические документы: `docs/SCOPE.md`, `docs/CANVAS.md`, `docs/CONFIG-COMPATIBILITY.md`, `docs/USER-GUIDE.ru.md`, `docs/TESTING.md`. Связанные задачи: #218 и #223. ## 1. Сценарий, персона и момент Администратор много раз редактирует реальный план: делит и меняет комнаты, перемещает проёмы, стены, декор и устройства, импортирует или восстанавливает план. Обычная арифметика JavaScript создаёт числа, которые описывают одну точку, но отличаются последними битами IEEE-754. Пользователь этого не видит и не должен обслуживать вручную: после любой штатной записи House Plan хранит одну устойчивую форму геометрии. Сценарий относится к `docs/SCOPE.md` J6: план остаётся достоверным при развитии дома и повторных правках. #218 оставляет потребителя устойчивым к старым данным, #223 вручную очищает уже накопленный шум, а #224 не даёт новому шуму закрепиться. ## 2. Что человек увидит до и после **До:** визуально одинаковые общие вершины могут сохраниться как разные double. Через много правок это статистически ломает объединение комнат, Glow и другие геометрические операции. Для уже повреждённого плана требуется Optimize. **После:** Save, перемещение устройства, импорт, Optimize/Undo и восстановление после перезапуска сохраняют геометрию в одной числовой форме. Внешний вид и точность размещения не меняются; планы, уже содержащие шум, очищаются при следующей записи либо сразу явной кнопкой Optimize из #223. Нового UI, уведомления или настройки нет. ## 3. Подтверждённая причина В backend `_COORD`, `_GEOM`, extents, углы и масштабы проверяют конечность и диапазон, но возвращают исходный `float`. `CONFIG_SCHEMA`, `LAYOUT_SCHEMA` и `POS_SCHEMA` поэтому пропускают ULP-шум без изменения. Frontend `_writeConfig()` отправляет текущий mutable `_serverCfg`, а `_persistLayout()` — текущую позицию. Сервер не возвращает канонический payload, и карточка обновляет только revision. Даже если очистить данные лишь на backend, открытая вкладка продолжит считать геометрию по сырому локальному объекту до перезагрузки. Кроме обычных WebSocket-команд, `async_save_config_state()` и `async_save_layout_state()` вызываются импортом, Optimize/Undo, startup recovery, setup migration и geometry repair. Один guard только в декораторе WebSocket не даёт абсолютной гарантии. ## 4. Scope - единый чистый контракт квантования в Python и TypeScript; - явная канонизация геометрических полей в `CONFIG_SCHEMA`, `LAYOUT_SCHEMA` и `POS_SCHEMA`; - общий storage barrier для всех внутренних config/layout writers; - канонизация локального frontend config и layout до отправки; - одинаковый allowlist полей на обеих сторонах, без рекурсивного обхода всех чисел; - no-op для повторной обычной записи того же канонического config/layout без новой ревизии, store write, event или потери Undo-снимка; - lazy-очистка старых данных при следующей записи и совместимость с #223; - общая fixture, backend/frontend unit tests и mutation guards; - документация числового storage-инварианта. ## 5. Non-scope - фоновая или startup-миграция всех существующих планов; - привязка произвольной геометрии к узлам сетки либо изменение `snapN()`; - изменение `GRID_STEP_N`, `cell_cm`, координатной системы, допустимых диапазонов или model/storage version; - ослабление защитных сравнений и union-fallback из #218; - канонизация live HA states, температуры, влажности, яркости или LQI; - изменение vacuum affine calibration; - новый UI, i18n-строки, toast, warning или telemetry; - общая pre-write проверка топологии плана из #199; - исправление самостоятельных geometry bugs, обнаруженных тестовой fixture. ## 6. Числовой контракт ### 6.1 Одна операция Для конечного числа `v` из allowlist: ```text factor = 1_000_000_000 q = sign(v) * floor(abs(v) * factor + 0.5) / factor result = 0, если q == 0, иначе q ``` Это округление до **9 десятичных знаков**, ближайшая половина — от нуля, `-0` нормализуется в `+0`. Встроенные Python `round` и JavaScript `Math.round` не являются контрактом: их tie-поведение различается. Все канонизируемые значения ограничены так, что `abs(v) * factor` остаётся ниже `Number.MAX_SAFE_INTEGER`. Повторное применение даёт тот же IEEE-754 double. При максимальном `cell_cm=1000` сдвиг координаты не превышает примерно `0,00012 см` (1,2 мкм); для угла — `5e-10°`. Это квантование представления, не snapping к сетке. Невалидные числа по-прежнему отклоняет существующая schema. Чистый frontend helper не исправляет `NaN`/Infinity и не расширяет диапазоны: вне schema он оставляет нечисловое/не-конечное поле как есть, чтобы сервер оставался авторитетом валидации. ### 6.2 Явный allowlist | Объект | Канонизируются | |---|---| | `spaces[]` | `plan_x`, `plan_y`, `plan_scale`, `plan_scale_x`, `plan_scale_y`, `plan_angle` | | `rooms[]` | rect `x/y/w/h`; каждая компонента `poly[][]` | | `walls[]` | компоненты exact endpoints `a/b` | | `openings[]` | `x/y/angle/length`; `host.t` | | `decor[]` line | `x1/y1/x2/y2` | | `decor[]` rect/ellipse/furniture | `x/y/w/h/angle` | | `decor[]` text | `x/y/scale/angle` | | `room_drafts[]` | компоненты `points[][]` | | `partitions[]` | компоненты `a/b` | | `wall_columns[]` | компоненты `center`; square `angle` | | `open_spans[]` | компоненты `a/b` | | `markers[]` | `angle` | | layout records | только `x/y` | Отсутствующие optional-поля не материализуются. Массивы, порядок объектов, строки, boolean, `null` и неизвестные/future поля сохраняются. ### 6.3 Явный negative contract Не меняются: - `cell_cm`, `plan_aspect` и все четыре компоненты `view_box`; - физические `cm`/`*_cm`, ширина линий, размер текста в сантиметрах; - `settings`: opacity, fill alpha, temperature limits, compass north, font/presentation scales и прочие числовые настройки; - marker `size`, `ripple_size`, `glow_radius_cm`, brightness и value data; - все шесть коэффициентов каждой `vacuum.calibration[map_id]`; - layout `s`, `k` и любые неизвестные metadata fields; - цвета и любые числа внутри неизвестных extension objects. ## 7. Write barriers и revisions ### 7.1 Backend schema Один pure Python helper выполняет allowlist traversal после структурной валидации. Его используют `CONFIG_SCHEMA`, `LAYOUT_SCHEMA` и `POS_SCHEMA`, поэтому карточка, импорт, ручной клиент и будущие endpoints получают одинаковый результат. Helper не мутирует вход. ### 7.2 Общий storage barrier `async_save_config_state()` и `async_save_layout_state()` повторно применяют тот же idempotent helper к live `config`/`layout` перед сборкой store payload. Это защищает внутренних writers, которые законно обходят WebSocket schema. Возвращаемый payload содержит именно записанный канонический объект; dependent reconciliation получает его же. Известные recovery snapshots создаются уже каноническими: - `optimize_backup.config/layout`; - `optimize_pending.config/layout`; - `repair_backup.positions`. Старый noisy snapshot, найденный при startup recovery, может быть прочитан, но его live target проходит barrier до записи. Он не способен вернуть шум в доступные через `config/get` или `layout/get` данные. Никакой рекурсивной канонизации произвольной metadata store не выполняется. ### 7.3 Обычная no-op запись Для `config/set`, `layout/set` и `layout/update` порядок такой: 1. authentication, size/range/schema и CAS-проверки выполняются как раньше; 2. semantic delta validation выполняется относительно текущего объекта; 3. server сравнивает канонический live candidate с текущим live payload; 4. при равенстве отвечает `ok: true` с текущим `rev`; 5. store не пишется, revision не растёт, update event не публикуется, Optimize/Import Undo snapshot не удаляется и file collection не запускается. Неверный `expected_rev` остаётся конфликтом даже для совпадающего payload: no-op не превращается в обход optimistic locking. У `layout/update` по-прежнему нет нового CAS-поля; равенство проверяется под общим `write_lock`. Команды с собственной наблюдаемой транзакцией — import apply, Optimize/Undo, geometry repair/Undo и migrations — сохраняют существующую семантику revisions. Канонизация сама по себе не добавляет им дополнительную ревизию. ## 8. Frontend convergence Новый pure TypeScript helper зеркалит Python contract и allowlist. `_writeConfig()` перед `callWS`: 1. удаляет legacy segments как сейчас; 2. строит канонический immutable candidate; 3. принимает candidate в `_serverCfg` до отправки и синхронно инвалидирует geometry-derived caches/epoch; 4. отправляет ровно этот объект. Принятие происходит до `await`, поэтому ответ более ранней записи не перетирает правки, сделанные во время запроса. Очередь и debounce сохраняются. Если после первой отправки появились новые правки, следующий элемент очереди канонизирует актуальный `_serverCfg`. `_persistLayout()` канонизирует `x/y` каждой dirty position, записывает тот же record в `_layout`, `_sentPos`, server request и cached snapshot. `s`, `k` и future fields сохраняются. Local-storage fallback также хранит канонический layout. Никакой server echo нового payload не вводится. ## 9. Optimize, импорт и Undo Import preview/apply уже проходит `CONFIG_SCHEMA`/`LAYOUT_SCHEMA`; тест фиксирует, что внешний JSON с noisy geometry даёт канонический candidate и live store. Экспорт может выдавать канонизированную копию, но не меняет store и revision. Optimize из #223 остаётся способом немедленно обработать старый план и показать отдельный счётчик. После #224 обычные последующие записи уже не накапливают новый ULP-шум. Undo восстанавливает прежнюю **семантическую геометрию и неизвестные поля**, но не исходные noisy bits. Снимок создаётся каноническим, а storage barrier остаётся последней защитой. Поэтому прежняя формулировка #223 «Undo restores the original noisy bits» заменяется в канонических документах на «Undo restores the original geometry in canonical representation». Пользовательский срок жизни, one-deep slot, freshness guard и доступность Undo не меняются. ## 10. Data compatibility, migration и losslessness Формат остаётся JSON number, новые persisted-поля не добавляются, `PLAN_MODEL_VERSION` и версии Store не меняются. Старые карточки читают канонические значения как обычные валидные числа. При загрузке старый live store не переписывается и revision не меняется. Первая последующая запись объекта канонизирует весь allowlist этого config или layout. Для немедленной массовой очистки пользователь запускает #223. Канонизация copy-on-write: входные объекты и recovery candidates не мутируются. Unknown/future поля и их числовые значения сохраняются побитово. Порядок массивов и ключей не является частью контракта, но реализация не должна намеренно пересортировывать их. ## 11. i18n, accessibility и security Новых строк и элементов интерфейса нет; keyboard, touch, focus и screen-reader контракты не меняются. Golden должен остаться визуально прежним. Backend остаётся единственным авторитетом authentication, permission, limits, semantic validation и optimistic locking. Helper не исполняет строки, не обходит schema, не добавляет внешних запросов и не раскрывает данные. ## 12. Acceptance criteria | AC | Критерий | Доказательство | |---|---|---| | AC1 | Python и TypeScript округляют noisy, positive/negative tie, boundary и `-0` до exact одинаковых double при точности 9. | Общая JSON fixture; backend и frontend focused units. | | AC2 | `CONFIG_SCHEMA`, `LAYOUT_SCHEMA`, `POS_SCHEMA` канонизируют все и только allowlist-поля §6.2. | Parameterized schema matrix + negative matrix §6.3. | | AC3 | Повторная канонизация идемпотентна и не мутирует input; unknown fields сохраняются. | Deep-freeze/identity units на обеих сторонах. | | AC4 | `config/set`, `layout/set`, `layout/update`: первая noisy-запись создаёт одну canonical revision; повторная canonical запись возвращает тот же rev без save/event/Undo invalidation. | HA websocket tests со spy store/event bus; stale CAS negative case. | | AC5 | Прямые storage writers сохраняют canonical live config/layout и передают exact config в virtual-light reconciliation. | Focused store unit с обходом schema. | | AC6 | Frontend после начала Save держит тот же canonical config/layout, который отправлен; правка во время in-flight write не теряется. | Queue/debounce unit с controlled promises. | | AC7 | Import внешнего noisy JSON даёт canonical preview/candidate/store; startup recovery не возвращает шум. | Backend import/recovery tests; mutant `import-path-bypasses-schema`. | | AC8 | Optimize и Optimize/Import/repair Undo сохраняют прежнюю семантическую геометрию, one-deep lifecycle и unknown fields, но live result canonical. | Existing transaction tests + noisy snapshot variants. | | AC9 | Fixture из #218 после schema/write имеет совпадающие общие вершины; union и Glow clip-path не пусты. | Frontend geometry regression на реальной fixture + backend fixture parity. | | AC10 | Диагональные и off-grid объекты с отличием больше шага квантования не прилипают к сетке; максимальный сдвиг укладывается в §6.1. | Numeric tolerance unit. | | AC11 | Temperature/brightness/colors, calibration, view_box, physical sizes и presentation scales сохраняются exact. | Negative fixture и mutant `quantization-hits-allowlist`. | | AC12 | Visual golden не меняется; три bundle-копии синхронны. | Предрелизный golden verify и bundle parity. | | AC13 | Implementation gates зелёные. | `typecheck`, `unit`, `build`; full golden/smoke/performance — перед бетой. | ## 13. План реализации и автотестов 1. Добавить pure Python и TypeScript modules с одной именованной precision constant, scalar helper и явными config/layout walkers. 2. Подключить Python walkers к трём schema и двум storage helpers. 3. Добавить no-op branches в три обычных WebSocket writer-а после CAS и semantic checks, но до save/cleanup/events. 4. Подключить frontend helper к `_writeConfig()`, dirty layout и localStorage. 5. Канонизировать известные recovery snapshots при создании; обновить Optimize/Import/repair recovery tests. 6. Создать общую fixture в `test/fixtures/`: noisy allowlist, tie/`-0`, полный denylist и expected object. Оба runner-а читают один файл. 7. Добавить regression fixture #218, import/recovery/no-op tests и четыре mutation entries из issue. 8. Обновить canonical docs и release artifacts. Mutation IDs: - `schema-quantization-removed`; - `frontend-writes-raw-coords`; - `quantization-hits-allowlist`; - `import-path-bypasses-schema`. В implementation-цикле выполняются только `typecheck`, `unit`, `build`. Golden, smoke и performance выполняются общими gate-ами перед бетой. Linux CI остаётся каноном полного HA-harness. ## 14. Риски, performance и rollback | Риск | Мера | |---|---| | Python/JS расходятся на половинах или negative zero | Явная формула вместо native round; одна fixture; exact double assertions. | | Walker задевает пользовательские данные | Закрытый allowlist, denylist fixture, unknown numeric sentinel и mutant. | | Внутренний writer обходит schema | Повторный idempotent storage barrier и прямые store/recovery tests. | | No-op обходит CAS или съедает Undo | CAS до equality; spy tests на save/event/metadata. | | Frontend принимает устаревший snapshot | Candidate принимается до await; controlled concurrent-write test. | | Undo #223 снова вносит шум | Канонический snapshot плюс storage barrier; обновлённый regression contract. | | Большой config дорожает на Save | Один линейный allowlist traversal; лимит config 2 MB; без работы в render/live-state tick. | Время — `O(n)` по persisted geometry, память — `O(n)` только для изменяемого candidate; point layout frontend остаётся `O(1)`, backend и сейчас сериализует весь layout store. Отдельный runtime performance baseline не вводится; общий предрелизный gate обязан остаться зелёным. Rollback — revert implementation-коммита. Уже канонизированные значения валидны для старой версии и не требуют обратной миграции. Возвращать потерянные последние биты бессмысленно и невозможно, но это не пользовательские данные: максимальное изменение ограничено §6.1. ## 15. Release-артефакты Изменение пользовательски значимо как исправление долговечности плана. Implementation-коммит имеет `User-Visible: yes` и включает: - `docs/CHANGELOG.md` и `docs/CHANGELOG.ru.md` со ссылкой на #224; - `docs/CANVAS.md` — storage canonicalisation против grid snapping; - `docs/CONFIG-COMPATIBILITY.md` — lazy, lossless write contract; - `docs/USER-GUIDE.ru.md` — обычные записи предотвращают новый шум, Optimize обслуживает старые данные; - `docs/TESTING.md` — no-op/schema/import/Undo/mutation coverage и исправление прежней формулировки #223; - `docs/STATUS.md` — фактическая unreleased-линия; - unit fixtures, mutation entries и синхронные production bundles. Новых i18n, screenshots, golden baseline, schema migration, security artifact или отдельного performance artifact нет. Перед бетой выполняются общие golden/smoke/performance gates. ## 16. Принятые технические предположения Принято предположительно, поменять свободно на ревью ТЗ: 1. Точность 9 знаков едина для координат, normalized extents, углов и геометрических scale. Переход на 10–12 меняет constants/fixture и оценку максимального сдвига, но не UX или формат. 2. Marker `angle` и backdrop/decor transforms — геометрия; marker size, room-label `k`, `view_box` и физические размеры — presentation/calibration и не входят в allowlist. 3. Undo обещает восстановить смысл геометрии, а не невидимый IEEE-754 мусор. Иной выбор разрушит абсолютный storage-инвариант #224. 4. No-op обязателен для обычных config/layout writes. Maintenance-команды сохраняют собственные revision/Undo contracts и лишь не получают дополнительной ревизии от canonicalisation. 5. Старые live stores не мигрируют при чтении; immediate bulk treatment остаётся #223.