Files
houseplan-card/docs/specs/224-config-coordinate-canonicalization.md
T
2026-08-22 14:28:22 +03:00

361 lines
25 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 #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.