From 4d73d031a477c06c5b28366bd9e3883ebec421f1 Mon Sep 17 00:00:00 2001 From: Sergey Matyunin Date: Sun, 23 Aug 2026 00:56:25 +0300 Subject: [PATCH] docs: specify optimize write-reload idempotence Issue: #248 User-Visible: no --- docs/specs/248-optimize-idempotence.md | 279 +++++++++++++++++++++++++ docs/specs/README.md | 1 + 2 files changed, 280 insertions(+) create mode 100644 docs/specs/248-optimize-idempotence.md diff --git a/docs/specs/248-optimize-idempotence.md b/docs/specs/248-optimize-idempotence.md new file mode 100644 index 00000000..6bfa9d46 --- /dev/null +++ b/docs/specs/248-optimize-idempotence.md @@ -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. diff --git a/docs/specs/README.md b/docs/specs/README.md index e6f4a5fb..1408eb21 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -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