docs: specify optimize write-reload idempotence

Issue: #248
User-Visible: no
This commit is contained in:
Sergey Matyunin
2026-08-23 00:56:25 +03:00
parent 6473d5e4b3
commit 4d73d031a4
2 changed files with 280 additions and 0 deletions
+279
View File
@@ -0,0 +1,279 @@
# Issue #248 — Optimize остаётся идемпотентной после записи и reload
- Дата: 2026-08-23
- Тип: bug · приоритет P2
- Оценка: пользовательская ценность 6/10 · ценность для разработки 8/10 · сложность 4/10 · риск 4/10
- Issue: [#248](https://github.com/Matysh/houseplan-card/issues/248)
- Ветка: `issue/248-optimize-idempotence`
- Статус ТЗ: первая редакция, на ревью
Канонические документы: `docs/SCOPE.md`, `docs/CANVAS.md`,
`docs/CONFIG-COMPATIBILITY.md`, `docs/TOUCH-SUPPORT.md`,
`docs/USER-GUIDE.ru.md`, `docs/TESTING.md`; связанные задачи:
[#223](https://github.com/Matysh/houseplan-card/issues/223) и
[#224](https://github.com/Matysh/houseplan-card/issues/224).
## 1. Сценарий и персона
Администратор дома открывает «Общие настройки → Оптимизировать планы»,
просматривает отчёт и подтверждает обслуживание старого либо импортированного
плана. Сразу после успешной записи он открывает действие повторно, не меняя
план. Это проверка результата: после первого прохода инструмент обязан показать,
что план уже приведён к текущей модели и исправлять больше нечего.
Сценарий относится к J6 из `docs/SCOPE.md`: план должен оставаться правдивым и
обслуживаемым по мере развития дома. Действие находится на desktop-first
административной поверхности; View и kiosk только читают сохранённый результат.
## 2. Что человек увидит до и после
**До:** после успешной оптимизации второе нажатие подряд снова предлагает
применить изменения и сообщает об «устранённом шуме координат», хотя между
запусками план не менялся.
**После:** первый запуск по-прежнему показывает и применяет реальные изменения,
а второй запуск после server event, повторного чтения config/layout или полной
перезагрузки страницы показывает существующее состояние «исправлять нечего».
Ни одна строка, кнопка или счётчик не переименовывается.
## 3. Подтверждённая причина
Причина из первоначальной гипотезы issue уточнена по актуальному `dev`.
`ws_plan_optimize` передаёт в storage helpers сырые аргументы `msg`, однако
`async_save_config_state()` и `async_save_layout_state()` уже являются общим
барьером #224 и канонизируют их. `optimize_pending` также канонизирован. Поэтому
постоянная запись и crash-intent не расходятся из-за отсутствующего вызова
backend helper.
Расходятся два корректных по отдельности числовых контракта:
1. сетка имеет шаг `GRID_STEP_N = 1 / 240`; `snapN()` возвращает двоичный
результат вроде `0.004166666666666667`;
2. storage contract #224 округляет allowlist persisted-геометрии до девяти
десятичных знаков и возвращает после reload `0.004166667`;
3. следующий `snapN()` снова получает первый вариант; разница `3.33e-10`
меньше `EPS`, поэтому `alignAllToGrid()` считает её новым
`coordsCanonicalized` и `optimizePlans().changed` снова становится `true`.
Минимальный прогон текущих production helpers доказал:
```text
первый результат в памяти changed=false, coordsCanonicalized=0
тот же результат после barrier changed=true, coordsCanonicalized=6
```
Проблема затрагивает не только один масштаб: шаг нормализованной сетки одинаков,
а `cell_cm` меняет физическую цену шага, не его persisted-представление.
## 4. Scope
В задачу входят:
- единое каноническое представление результата Optimize, совпадающее с
действующим девятизнаковым storage contract #224;
- вычисление `changed` и всех change-счётчиков по паре config/layout, которая
действительно может быть записана и затем прочитана;
- идемпотентность в памяти, после frontend canonicalization, после штатного
backend handler, после server-event reload и после startup recovery;
- сохранение побайтового равенства target config/layout в durable intent и в
конечных live store payload;
- unit/backend regression и mutation guards на обе половины пары;
- уточнение канонических документов, что идемпотентность проверяется через
write/reload boundary, а не только повторным вызовом pure optimizer.
## 5. Non-scope
Не входят:
- изменение шага сетки `1 / 240`, `EPS`, `cell_cm` или допустимых координат;
- изменение точности, формулы или allowlist канонизации #224;
- автоматический запуск Optimize, новая миграция Store/model version;
- изменение preview, toast, Undo, названий счётчиков или i18n;
- исправление wall/open-span алгоритмов, кроме доказательства, что их результат
не создаёт повторную работу на write/reload boundary;
- ослабление schema, CAS, permission, preflight либо crash-recovery гарантий;
- визуальные изменения плана.
## 6. Контракт поведения
### 6.1 Каноническая пара Optimize
`optimizePlans(config, layout)` возвращает config/layout уже в том же числовом
представлении, которое создают `canonicalizeConfigGeometry()` и
`canonicalizeLayoutGeometry()` из #224. Повторное применение этих helpers к
результату deep-equal и не меняет ни одного allowlist-поля.
Все optimizer passes могут выполнять внутренние вычисления с большей точностью,
но окончательная пара, сравнение с входом и возвращаемое значение используют
storage-canonical boundary. Derived geometry — размеры прямоугольников,
wall-bound позиции и углы проёмов, endpoints стен/спанов и layout — входит в это
же правило; исправление только room polygon недостаточно.
`PLAN_MODEL_VERSION` повышается только если storage-canonical config/layout
содержит реальное изменение, как и до задачи. Более новая версия модели не
понижается.
### 6.2 Идемпотентность и отчёт
Для результата первого успешного Optimize должны быть эквивалентны три входа:
1. объект, возвращённый pure optimizer;
2. объект после TypeScript canonicalization helpers;
3. объект, прочитанный `config/get` + `layout/get` после backend handler или
завершения `optimize_pending` при startup.
Для каждого из них следующий запуск возвращает:
- `changed === false`;
- config/layout deep-equal входу;
- `moved`, `coordsCanonicalized`, `rotated`, `removedDrafts`, `migrated`,
`canonicalized`, `wallsMerged`, `spansMerged`, `partitionsMerged` и счётчики
repair — нулевые;
- `maxShift`, `maxShiftCm` — `0`, `maxSpace` — пустая строка.
`total` остаётся диагностическим количеством просмотренных элементов и не обязан
быть нулём. Если внутренний проход временно получил другое double, но
storage-canonical итог совпадает с входом, это не исправление и не может попасть
в пользовательский отчёт.
Первый запуск на реально noisy/off-grid/legacy входе сохраняет действующие
счётчики и upper-bound обещание §9.5 `docs/CANVAS.md`; задача не превращает
настоящие изменения в no-op.
### 6.3 Backend pair transaction
После schema validation handler формирует один storage-canonical target:
- `optimize_pending.config/layout` содержит этот target;
- config store и layout store после успешного commit содержат тот же target;
- startup finisher записывает тот же target;
- recovery metadata и revisions не входят в сравнение самих config/layout;
- final layout удаляет `optimize_pending`, сохраняет актуальный
`optimize_backup` и прежний lifecycle Undo.
Проверка должна сравнивать структуры exact/deep-equal, а не только отдельные
координаты. Байтовое равенство понимается как одинаковый JSON payload при одной
и той же стабильной сериализации; порядок ключей не становится новой частью
публичной модели.
### 6.4 Reload и concurrency
События `houseplan_config_updated` / `houseplan_layout_updated` и полная
перезагрузка страницы могут заменить optimistic state данными сервера, но не
создают новый Optimize candidate. CAS, revision increments и запись one-deep
backup первого реального Optimize не меняются. Повторный preview сам по себе не
пишет store и не меняет Undo.
## 7. UX, i18n, accessibility и touch
Новых элементов UI и строк нет. Используется существующая ветка
`gs.align_none`; существующий change-preview и итоговый toast первого запуска
остаются прежними.
Keyboard/focus/screen-reader контракты не меняются. Touch editor:
**best effort / intentionally degraded**, как и вся maintenance-поверхность;
исправление числового результата одинаково на desktop и touch. View/kiosk,
включая действия устройств, не затронуты.
## 8. Модель данных, compatibility и миграция
Persisted schema, ключи, Store version и `PLAN_MODEL_VERSION` не меняются.
Точность девять знаков, scalar formula, allowlist/denylist и lazy-write контракт
#224 сохраняются. Старые и новые версии читают результат как обычные JSON number.
Миграции нет. Уже сохранённый plan исправляется обычным первым Optimize; после
него новый инвариант не позволяет maintenance-циклу повторяться. Unknown/future
поля сохраняются по контракту #224 и не канонизируются рекурсивно.
## 9. Acceptance criteria
| AC | Критерий | Доказательство |
|---|---|---|
| AC1 | На существующих optimizer fixtures, общей coordinate-canonicalization fixture и отдельном двухмасштабном плане первый реальный проход возвращает storage-canonical config/layout, а второй в памяти и после TypeScript write-barrier даёт `changed:false`, нулевые change-счётчики и deep-equal пару. | `unit`: `test/plan-optimizer.test.mjs` + shared fixture; mutation, убирающая final canonical boundary. |
| AC2 | Grid-bound room rect/poly/draft, decor, partition/column, layout; wall-bound opening; exact wall/open-span endpoints не создают повторную работу после девятизнакового round-trip. Реальный off-grid и ULP-noisy вход по-прежнему меняется один раз и сохраняет честный отчёт/maxShift. | `unit`: parameterized surface matrix на `cell_cm` 1/3/5/1000; существующие align-grid tests; mutant, возвращающий raw `1/240` на boundary. |
| AC3 | `houseplan/plan/optimize` сохраняет config/layout exact равными target внутри durable pending и final store; normal completion и startup recovery дают одну пару, revisions/Undo metadata корректны. Удаление канонизации у config или layout half краснит тест. | `backend`: handler/store spies + recovery pytest на общей JSON fixture; два mutation guards. |
| AC4 | Композиционное frontend→backend→reload доказательство использует одну fixture: Node получает Optimize candidate и expected canonical pair; Python schema/handler сохраняет exact ту же pair; повторный Node Optimize на expected pair — no-op. | Общая fixture, frontend unit и backend pytest; fixture parity guard запрещает независимые expected-копии. |
| AC5 | После server-event reload и после полного remount существующий диалог показывает `gs.align_none`; первый реальный Optimize сохраняет прежние preview/Apply/toast/Undo, повторный preview не пишет и не инвалидирует Undo. | targeted production-bundle browser smoke с mocked WS events/get; чтение кода Undo lifecycle. |
| AC6 | Нет visual/i18n/schema/performance/touch-регрессии; implementation loop зелёный. | `typecheck`, `unit`, `build`; bundle parity. Golden/smoke/performance — общие предрелизные гейты. |
## 10. План реализации и тестов
1. В `src/plan-optimizer.ts` применить существующие TypeScript helpers #224 к
окончательной config/layout pair до сравнения, model-version decision и
возврата результата. Не создавать вторую формулу округления.
2. Согласовать change-report с окончательным persisted delta: no-op pair не
несёт фантомных change-счётчиков. Если потребуется, канонизировать отдельные
grid-bound результаты раньше, но только через общий helper.
3. Расширить `test/plan-optimizer.test.mjs` matrix всеми категориями §6.1 и
масштабами; добавить общий fixture round-trip, читаемый Node и Python.
4. Расширить backend websocket/recovery tests: перехватить pending write,
завершить commit, перечитать stores и сравнить обе halves с fixture exact.
5. Добавить mutation guards минимум для отсутствующего optimizer boundary,
сырой config half и сырой layout half.
6. Добавить targeted production-bundle smoke на первое применение, update
events/reload и второй no-op preview. Smoke пишется сейчас, но исполняется
перед передачей кода на ревью согласно текущему процессу.
7. Обновить `docs/CANVAS.md`, `docs/CONFIG-COMPATIBILITY.md`,
`docs/TESTING.md`, пользовательскую документацию и оба changelog.
В implementation-цикле запускаются только `typecheck`, `unit`, `build`.
Targeted browser smoke выполняется перед передачей кода на review; полный
golden/smoke/performance и Linux HA harness — перед бетой по общему процессу.
## 11. Риски, performance, security и rollback
| Риск | Мера |
|---|---|
| Phantom counters скрывают реальную правку | `changed` и report сверяются с final canonical pair; noisy/off-grid negative fixtures обязаны остаться changed. |
| Исправлена только room poly, цикл остаётся на derived geometry | Полная surface matrix §AC2 и общий config/layout walker #224. |
| Frontend/Backend expected расходятся | Одна JSON fixture, обе реализации читают её; exact whole-pair assertions. |
| Recovery пишет не то, что normal path | Перехват pending + normal completion + startup recovery в AC3. |
| Канонизация случайно расширит allowlist | Используется существующий helper; denylist/unknown sentinels #224 остаются зелёными. |
| Базовый optimizer станет заметно дороже | Один дополнительный `O(n)` copy-on-write traversal только при открытии maintenance dialog; не render/live-state loop. |
Память и время остаются `O(n)` по persisted config/layout. Объём ограничен
существующими schema limits; отдельный performance budget не вводится, общий
предрелизный performance gate обязан остаться зелёным.
Authentication, admin-only permission, size limits, schema, CAS и preflight не
меняются. Новых данных, внешних запросов и security surface нет.
Rollback — revert implementation-коммита. Формат данных не меняется, поэтому
обратная миграция не нужна. На старой версии повторный phantom Optimize может
вернуться, но сохранённая девятизнаковая геометрия остаётся валидной.
## 12. Release-артефакты
Изменение пользовательски видимо как исправление ложной повторной работы.
Implementation-коммит получает `User-Visible: yes` и включает:
- `docs/CHANGELOG.md` и `docs/CHANGELOG.ru.md` со ссылкой на #248;
- `docs/CANVAS.md` — идемпотентность через storage/reload boundary;
- `docs/CONFIG-COMPATIBILITY.md` — композиция Optimize с девятизнаковым writer;
- `docs/USER-GUIDE.ru.md` — второй запуск после reload сообщает, что чинить
нечего;
- `docs/TESTING.md` — shared fixture, backend pair/recovery и mutation coverage;
- `docs/STATUS.md` — только если текущая unreleased-сводка перечисляет
исправления этого уровня;
- unit/backend/smoke fixture и синхронные production bundles.
Новых i18n, screenshots, golden baseline, schema/model migration,
security-артефакта или отдельного performance-артефакта нет. Golden не должен
измениться; перед бетой выполняются общие гейты.
## 13. Принятые технические предположения
Принято предположительно, поменять свободно на ревью ТЗ:
1. Авторитетным persisted-представлением остаётся девятизнаковый контракт #224;
сетка подстраивает свой возвращаемый candidate под storage, а не наоборот.
2. Сквозное доказательство компонуется из Node unit и Python backend test через
одну fixture. Запуск Node subprocess из pytest не нужен и не становится
runtime/build dependency интеграции.
3. «Побайтово» означает exact JSON structure/value при стабильной сериализации,
а не новый публичный контракт порядка ключей Python/JavaScript objects.
4. Если final canonical pair deep-equal входу, change-счётчики равны нулю;
`total` может отражать реально выполненный обход.
5. Исправление не требует менять backend production code, если тест докажет,
что общий writer #224 уже сохраняет pending и live pair одинаково. Тесты
backend всё равно обязательны, потому что именно этот шов является частью AC.
+1
View File
@@ -126,6 +126,7 @@ GitHub Issues и GitHub Projects (v2) остаются единственным
| [#238](https://github.com/Matysh/houseplan-card/issues/238) Размеры проёма до внутренних физических границ | [238-opening-inner-distances.md](238-opening-inner-distances.md) |
| [#242](https://github.com/Matysh/houseplan-card/issues/242) Символ проёма по центру толщины стены | [242-opening-symbol-center.md](242-opening-symbol-center.md) |
| [#244](https://github.com/Matysh/houseplan-card/issues/244) Восстановление маркеров с мёртвой ссылкой на пространство | [244-orphan-space-references.md](244-orphan-space-references.md) |
| [#248](https://github.com/Matysh/houseplan-card/issues/248) Идемпотентный Optimize после записи и reload | [248-optimize-idempotence.md](248-optimize-idempotence.md) |
## P3