Files
houseplan-card/docs/specs/248-optimize-idempotence.md
T
2026-08-23 01:13:27 +03:00

280 lines
22 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 #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`
- Статус ТЗ: утверждено (SPEC-REVIEW-248-r1 green), реализовано в ветке задачи
Канонические документы: `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 (Low из SPEC-REVIEW-248-r1);
- `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.