Files
houseplan-card/docs/specs/477-editor-writer-fixed-point.md
T
2026-09-06 17:52:05 +00:00

493 lines
33 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.
# ТЗ #477 — fixed point оптимизатора после штатного редактирования
- **Issue:** https://github.com/Matysh/houseplan-card/issues/477
- **Тип / приоритет:** tech debt / P2
- **Трек:** полный — затрагиваются геометрические транзакции, Undo/Redo,
ссылки на комнаты и performance-critical путь рисования стен
- **Оценка:** пользовательская ценность 6/10; ценность для разработки 9/10;
сложность 7/10; риск 7/10
- **Связано:** #229, #248, #276, #282, #291, #296, #383, #461 и #478
## 1. Сценарий
Персона — администратор дома. Поверхности — desktop Plan editor и Background
editor. Администратор один раз приводит существующий план в порядок через
«Оптимизировать планы», а затем продолжает обычную работу: рисует и завершает
цепочки стен, удаляет или объединяет комнаты, меняет мебель и изображения.
После таких штатных операций повторный запуск Optimize не должен находить
работу, которую только что мог и должен был выполнить сам writer. Явная
maintenance остаётся нужна для старых/imported документов и для намеренных
repair-операций, которые меняют авторскую геометрию.
Touch-редакторы остаются best effort по `docs/TOUCH-SUPPORT.md`. View и kiosk не
получают новых жестов или визуальных состояний.
## 2. Что человек увидит до и после
**До:** завершённая прямая цепочка может оставлять несколько отдельных стен;
стена поверх границы комнаты может дожить до Optimize; после удаления или
объединения комнаты Optimize снимает устаревшую привязку устройства; Optimize
также слегка меняет размер и положение мебели после штатного плавного resize.
**После:** завершение цепочки сразу оставляет канонические стены, ссылки на
удалённую комнату обслуживаются той же командой, а разрешённый плавный transform
мебели и изображений Optimize не огрубляет. Кнопки, диалоги, подписи, внешний
вид и обычный порядок действий не меняются.
## 3. Проблема и уточнение после #478
`optimizePlans()` совмещает четыре разных класса работы:
1. миграции старых моделей и импортов;
2. lossless-нормализацию current model;
3. исправление осиротевших ссылок;
4. явные геометрические repairs вроде выпрямления почти-осевой старой стены.
Owner invariant для #477 относится ко второму и третьему классам, но только к
долгу, созданному current writers. В актуальном `dev` подтверждены три дыры:
- две коллинеарные части законченной wall-chain дают
`partitionsMerged > 0`;
- положительная partition поверх masonry комнаты даёт
`partitionsReconciled > 0`, а её проём может дать `openingsRehosted > 0`;
- Delete/Merge room оставляют `marker.room_id` удалённой комнаты, и следующий
Optimize меняет config, снимая эту ссылку.
#478 уже удалил persisted `room_drafts`, перевёл каждый принятый отрезок в
обычную partition и поглощает совпадающие carriers при принятии комнаты. В #477
не входят `room_drafts`, их migration, `redundantDraftsRemoved` и повторная
реализация room-acceptance transaction.
Отдельно #383 установил намеренно непрерывный resize мебели и custom images.
Текущий `alignAllToGrid()` ошибочно считает полученный transform старым
off-grid долгом и двигает его. Возвращать ступенчатый resize нельзя: надо
уточнить, какие поля вообще принадлежат grid contract.
## 4. Нормативный инвариант
Пусть `P` — current-model документ, для которого `optimizePlans(P).changed ===
false`, а `W` — одна завершённая штатная редакторская операция текущей версии.
Тогда:
```text
optimizePlans(W(P)).changed === false
```
Инвариант проверяется на canonical config и layout после общей storage
canonicalization, с тем же owner roster/reference context, который production
передаёт Optimize.
### 4.1 Что считается завершённой операцией
- принятый обычный commit Plan/Background editor;
- явное завершение wall-chain через `Esc`, Reset текущей цепочки, смену
инструмента, режима или пространства, уход с route карточки;
- подтверждённое «оставить стенами» после отказа от всех предложенных rooms;
- Undo или Redo уже завершённой команды.
Один сохранённый terminal segment при ещё активной wall-chain — промежуточное
состояние одной session transaction. Оно сохраняется crash-safe по #478, но до
явного завершения цепочки не обязано быть fixed point. Same-route warm remount
может продолжить эту session. Hard crash/принудительное убийство страницы
считается аварийным прерыванием: данные не теряются, но lossless-швы могут ждать
следующего завершения цепочки или явного Optimize.
### 4.2 Что не входит в инвариант
- input старой модели, import/restore и документы, отредактированные старым
frontend;
- `wallsStraightened` и `wallsStraightenSkipped`: почти-осевая legacy-стена не
меняется молча;
- осознанно сохранённые ambiguity/fail-closed случаи, которые Optimize также
не имеет права чинить;
- временный preview, pointermove, незавершённый drag/dialog и отклонённый write;
- layout room-label `rl_<roomId>`: Optimize текущей версии не удаляет такую
запись при живом пространстве, а её отдельная config+layout transaction не
нужна для доказательства #477.
## 5. Scope
### 5.1 Входит
1. Pure lossless finalizer partitions для одного пространства и набора seed
IDs завершённой цепочки.
2. Fixed-point merge коллинеарных частей одинаковой толщины без удаления
значимых junctions, с rehost проёмов и сохранением legacy projection.
3. Safe reconciliation положительных частей законченной цепочки, полностью или
частично совпадающих с room masonry, с сохранением residuals, толщины и
проёмов.
4. Один finish barrier для всех штатных выходов из wall-chain, включая ветку
«не создавать ни одну предложенную комнату».
5. History-aware применение той же нормализации при Undo/Redo законченных
wall-chain commands без отдельного невидимого шага Undo.
6. Детерминированный rewrite room references при Delete/Merge room:
`marker.room_id` и значения `marker.vacuum.segment_map`.
7. Включение изменённых room references в rollback и Undo/Redo соответствующей
room-команды.
8. Исключение полного transform `furniture` и `image` из grid alignment при
сохранении числовой storage canonicalization и всех других align-правил.
9. Исполняемая матрица current writers → Optimize result, regression/mutation
witnesses и performance witness для terminal-click пути #461.
10. Обновление архитектурной и пользовательской документации.
### 5.2 Не входит
- новая кнопка Finish, новые настройки, toast или тексты;
- изменение рендера, толщины, света, площади либо hit testing;
- изменение face detection и выбора/порядка предложенных комнат;
- автоматическое создание/разделение HA Area;
- изменение миграции v9→v10 или возврат `room_drafts`;
- merge partitions всего пространства на каждом клике;
- автоматическое выпрямление почти-осевых стен;
- удаление room-label layout, remap произвольных future fields или общий redesign
reference model;
- исправление старого/imported долга молча при открытии View;
- изменение непрерывного resize/rotation мебели и custom images.
## 6. Контракт завершения wall-chain
### 6.1 Seed-bounded merge
Finalizer получает current config, `spaceId` и IDs сохранённых partitions
активной цепочки. Он работает на clone и сначала вызывает действующие правила
`mergeCollinearPartitions` с seed scope:
- рассматривается только связный merge-компонент, к которому относится хотя бы
один seed;
- одинаковая `cm`, collinearity/join epsilons, room side, третья partition и
column junction сохраняют контракт #229;
- survivor ID выбирается детерминированно действующим алгоритмом;
- все opening hosts и `x/y/angle` legacy projection обновляются через общий
helper;
- merge выполняется до fixed point, а не одним проходом.
Unrelated partitions нельзя переписывать «заодно»: на исходно оптимизированном
плане у них нет долга, а bounded scope нужен для предсказуемой цены.
### 6.2 Seed-bounded reconciliation
После merge finalizer определяет surviving partitions, происходящие от seed,
и применяет к ним доказательство #276:
- только положительная `cm > 0`, как в обычном Optimize; нулевая стена поверх
room wall в этом issue не поглощается;
- только точное безопасное совпадение с `outer`/`shared` masonry interval;
- частичное совпадение оставляет детерминированные residual partitions;
- неизвестные поля, неоднозначная геометрия, column conflict, orphan host и
overlap проёмов остаются fail-closed без частичной записи;
- проёмы rehost-ятся на канонический wall/residual с сохранением абсолютного
центра, длины, типа, binding/flip/future fields и legacy projection;
- room wall получает итоговую толщину по уже принятому контракту #276.
API reconciliation получает необязательный фильтр IDs; отсутствие фильтра
сохраняет full-space семантику Optimize и room acceptance #478.
### 6.3 Atomic adoption
Finalizer не мутирует live config до полного успеха. Candidate проходит:
1. current wall model commit/identity barrier #282;
2. strict physical geometry check;
3. сравнение junction limits с состоянием до lossless-нормализации;
4. off-grid non-growth guard;
5. canonical storage boundary.
Только затем candidate принимается целиком, обновляются caches и fingerprint
pending physical write, и ставится один save. При отказе исходный config,
session chain и уже видимый план остаются byte-equivalent; переход, который
требовал finish, не продолжается, а используется существующее сообщение
безопасного отказа. Частично merged/rehosted состояние недопустимо.
Finish не создаёт отдельной history command: пользователь рисовал стены, а не
запускал скрытую команду «склеить записи».
### 6.4 Все владельцы завершения
Один общий finish вызывается при:
- `Esc`;
- Reset в context tray;
- смене plan tool;
- переходе Plan → View/Devices/Background;
- смене space через вкладку, swipe или hash/deep-link;
- route departure;
- завершении wall-face batch без принятой комнаты.
Pointer cancel, pinch и второй touch сами по себе не заканчивают и не коммитят
цепочку. Room dialog Cancel возвращает active chain и нормализует её только при
последующем явном finish.
## 7. Undo/Redo и write rollback
Каждый terminal segment сохраняет прежнюю отдельную history command, чтобы
`Ctrl/Cmd+Z` во время активной цепочки удалял ровно последнюю точку. Session
state хранит IDs и остаётся ненормализованным до finish.
History snapshots wall-chain получают session-only metadata с seed IDs. Пока
та же chain активна, `_applyGeometryState` применяет snapshot буквально. После
finish обычный Undo/Redo перед adoption прогоняет lossless finalizer на snapshot:
- Undo последнего segment возвращает канонический результат предыдущего числа
сегментов;
- Redo возвращает канонический результат следующего числа сегментов;
- промежуточные швы и совпадающие carriers не возвращаются в durable config;
- IDs surviving wall/opening hosts детерминированы;
- history не получает дополнительный шаг и не очищается при успешном finish.
Отказ backend write восстанавливает общий pre-chain `before`, очищает
несовместимую history и перечитывает authoritative config по существующему
контракту #314. Finish, успевший пройти локально, не должен мешать этому
rollback.
## 8. Room reference transaction
### 8.1 Delete room
До геометрической мутации writer получает snapshot только тех marker records,
которые ссылаются на удаляемый room ID. В том же config candidate:
- у любого `marker.room_id === deletedId` поле удаляется; `marker.space`,
binding, position, hidden/removed и остальные поля сохраняются;
- из `marker.vacuum.segment_map` удаляются только пары, значение которых равно
`deletedId`; пустой map канонически удаляется вместе с пустым vacuum wrapper
только если в wrapper нет других полей;
- ссылки сканируются во всех markers: room IDs глобально уникальны, а vacuum с
dock на другом этаже может ссылаться на карту/комнату этого пространства.
### 8.2 Merge rooms
Если `dropId` поглощается `keepId`:
- `marker.room_id === dropId` становится `keepId`;
- каждое значение `dropId` в `vacuum.segment_map` становится `keepId`;
- уже существующие ссылки на `keepId` не меняются;
- выбор сохраняемой комнаты в существующем диалоге остаётся единственным
источником `keepId`.
### 8.3 History and rollback
`SpaceGeometryState` не начинает безусловно копировать все markers для каждой
команды. Только Delete/Merge room прикрепляет optional reference snapshot с
точными marker IDs и прежними room-related полями. `_recordGeometry` делает
симметричный `after` snapshot, `_restoreGeometryState*` восстанавливает его
вместе с геометрией.
Поэтому Undo/Redo и synchronous/async rollback возвращают геометрию и ссылки
одной логической операцией. Не связанные marker mutations не переписываются.
Layout `rl_<roomId>` остаётся как сейчас и не входит в snapshot.
## 9. Grid contract для свободных transforms
`alignAllToGrid()` продолжает обрабатывать:
- комнаты и независимые стены;
- columns;
- обычный decor `line`, `rect`, `ellipse`, `text`;
- openings;
- device positions и room-label positions.
Для decor `kind: furniture` и `kind: image` функция:
- сохраняет `x`, `y`, `w`, `h`, `angle`, flip flags и unknown fields
byte-equivalent;
- не увеличивает `moved`, `coordsCanonicalized`, `total`, `maxShift*` из-за их
transform;
- не мешает общей storage canonicalization округлить бинарный числовой шум по
общему формату записи.
Это соответствует #383: placement/move остаются grid/magnet-bound writers, но
плавный resize законно меняет и размер, и top-left относительно фиксированного
угла. Источник этих координат после сохранения неразличим, поэтому exemption
касается всего transform двух типов, включая legacy экземпляры.
## 10. Writer inventory и фиксированная матрица
В репозитории появляется machine-readable/исполняемая таблица. Каждый ряд
начинается с уже оптимизированной current-v10 fixture, применяет production
helper либо browser smoke action и затем вызывает настоящий `optimizePlans()`.
Минимальная матрица:
| Writer / lifecycle | Ожидаемый Optimize |
|---|---|
| terminal append при активной chain | промежуточное исключение; data сохранены |
| finish 2–3 collinear segments | `changed:false`, merge уже выполнен |
| finish chain, продолженной из существующей partition | `changed:false`, survivor/host стабильны |
| finish positive partition поверх outer/shared room wall | `changed:false`, reconcile/rehost уже выполнены |
| ambiguous/unsafe coincidence | `changed:false`, оба writer и Optimize одинаково fail-closed |
| room-face acceptance | `changed:false` — защита результата #478 |
| Delete room с direct/vacuum refs | `changed:false`, refs сняты; Undo/Redo также false |
| Merge rooms с direct/vacuum refs | `changed:false`, refs указывают на keep; Undo/Redo также false |
| Resize, wall thickness, opening add/move/delete, partition/column edit/move/delete/rotate | `changed:false` |
| ordinary decor add/move/edit/delete | `changed:false` |
| continuous furniture/image resize и rotation | `changed:false`, transform не изменён |
| layout marker/room-label move | `changed:false` после writer snapping/canonicalization |
Для строк, где реальный UI writer нельзя вызвать без browser runtime, smoke
обязан вызывать тот же production entry point, а не пересказывать его логику в
test-only surrogate. Матрица может быть разбита на unit и smoke, но один список
case IDs служит coverage manifest; неизвестный новый structural writer должен
требовать явного добавления/исключения.
## 11. Производительность
Terminal click остаётся latency-critical:
- `commitWallChainSegmentGeometry()` не вызывает merge/reconcile/full-space
Optimize;
- существующий `benchmark:wall-draw-click` сохраняет structural contract:
один local physical check, один junction pass, одна config write и одна
history command на клик, без full-space physical checks и generic fallback;
- budgets #461 не ослабляются: median ≤150 ms, max ≤250 ms, remote median ≤
`base × 1.5 + 20 ms` на действующей fixture;
- finish измеряется отдельно после семи segments: ровно один bounded
merge/reconcile barrier и не более одной дополнительной config write;
- удвоение unrelated rooms/partitions не должно более чем в 1.5 раза увеличивать
median finish seeded-chain на общей benchmark fixture плюс 20 ms noise;
- существующие Optimize/coincident/wall-model budgets остаются зелёными.
Если seeded reconciliation невозможно доказать без full-space sweep, задача
возвращается в `S3-spec`; нельзя тихо платить эту цену на каждом клике.
## 12. Совместимость, миграция и безопасность
- persisted schema и `model_version` не меняются;
- новых config/layout полей нет; history metadata session-only;
- неизвестные поля partitions, openings, rooms, markers и vacuum wrapper
сохраняются;
- старые планы не переписываются при View/load; explicit Optimize остаётся
единственным общим maintenance действием;
- current writer не расширяет права: всё остаётся за `_canCommitSpace`, admin
guard и revisioned config write;
- никакие HA service calls не добавляются;
- reference rewrite меняет только exact room IDs выбранной Delete/Merge;
- fail-closed geometry и rollback #282/#314 не ослабляются.
## 13. UX и i18n
Новых элементов UI и новых строк нет. Действующие history labels, toasts и
confirmation dialogs сохраняются. Optimize может реже показывать подтверждение,
потому что current writers больше не оставляют перечисленный долг.
i18n: новых ключей нет; словари `en`, `ru`, `de` не меняются.
## 14. Критерии приёмки
- **AC1 — finished chain fixed point (unit + smoke).** Две/три коллинеарные
части и продолжение существующей partition после каждого способа finish дают
одну каноническую стену; `optimizePlans(...).changed === false`.
- **AC2 — coincident chain fixed point (unit + smoke).** Safe positive overlap
с outer/shared masonry поглощён, residuals/толщина сохранены, opening rehost
сохраняет абсолютную геометрию; следующий Optimize — no-op.
- **AC3 — fail-closed atomicity (unit + mutation).** Ambiguous overlap, unknown
partition field, column conflict, invalid/orphan/overlapping openings и
physical/junction rejection не дают частичной mutation/save/navigation.
- **AC4 — history continuity (unit + smoke + mutation).** Active-chain one-point
Undo остаётся прежним; после finish Undo/Redo проходит канонические snapshots,
не добавляет скрытого history шага и не возвращает optimizer debt.
- **AC5 — all finish owners (source contract + smoke).** Esc, Reset, tool/mode/
space/route transition и rejected room batch проходят один общий finalizer;
pointer cancel/pinch/second touch его не вызывают.
- **AC6 — room reference transaction (unit + smoke + mutation).** Delete снимает,
Merge remap-ит direct и vacuum refs; unknown fields/space/layout сохраняются;
Undo/Redo и rejected write возвращают exact before/after вместе с геометрией.
- **AC7 — continuous transforms stay canonical (unit + smoke + mutation).**
Furniture/image после свободного resize/rotation byte-equivalent до/после
Optimize и не учитываются align report; остальные align categories по-прежнему
притягиваются к сетке.
- **AC8 — writer coverage manifest (unit + source contract).** Все current
structural/layout writer entry points перечислены, каждый имеет fixed-point
witness либо документированное промежуточное/legacy исключение; добавление
writer без строки краснит тест.
- **AC9 — performance (browser benchmark).** Terminal-click structural counters
и бюджеты #461 не ухудшены; finish выполняет одну bounded transaction и
проходит отдельный seeded-scaling budget из §11.
- **AC10 — compatibility/docs (unit + docs gate + review).** Schema/model version,
View/kiosk/touch contract и UI/i18n не изменены; USER-GUIDE, CANVAS,
ARCHITECTURE и STATUS описывают новый writer invariant и furniture exemption.
## 15. План тестов и обязательные негативные свидетели
1. `test/writer-fixed-point.test.mjs`: pure finalizer, room refs и Optimize
oracle для AC1/2/3/6/7.
2. `test/align-grid.test.mjs`: furniture/image exemption и контрольный ordinary
decor, который обязан продолжать двигаться.
3. `test/command-stack.test.mjs` либо runtime-focused test: tagged snapshots,
active vs finished chain, no extra command.
4. `test/draft-live-commit.test.mjs`: terminal click не вызывает finalizer.
5. `demo/smoke_edit_walk.mjs` или отдельный deterministic browser smoke:
production finish owners, Delete/Merge + Undo/Redo, Optimize no-op.
6. `demo/benchmark_wall_draw_click.mjs`: прежние budgets и новые terminal
structural counters; отдельный finish/scaling measurement.
7. `scripts/mutation-gate.mjs`:
- отключить вызов finalizer у одного finish owner → AC5 красный;
- снять candidate-before-adopt guard → AC3 красный;
- не normalise tagged history restore → AC4 красный;
- не rewrite `room_id`/`segment_map` → AC6 красный;
- вернуть furniture/image в align loop → AC7 красный;
- вызвать reconcile из terminal click → AC9 structural witness красный.
На S7 ревьюер заполняет таблицу «AC · чем доказан · чем краснеет» по §2.7
`PROCESS.md`. Положительный прогон без названной отрицательной мутации не
закрывает защитный AC.
## 16. Риски
1. **History IDs расходятся после merge.** Снижается tagged snapshots и
нормализацией только после окончания session chain.
2. **Rehost сдвигает проём или legacy projection.** Снижается общим helper #229/
#276 и абсолютными geometry assertions.
3. **Finish тормозит большой план.** Снижается seed scope, отдельным benchmark и
запретом работы на terminal click.
4. **Reference Undo перезаписывает чужую правку marker.** Снижается точечным
optional snapshot и действующим очищением history после внешнего config
revision.
5. **Furniture exemption сохраняет старый off-grid transform.** Это намеренная
цена совместимости #383: без provenance нельзя отличить legacy transform от
легального continuous resize; пользовательская геометрия важнее скрытого
выравнивания.
6. **Fail-closed finish блокирует навигацию.** Это действующая safety semantics;
тест обязан доказать отсутствие частичной записи и понятный existing toast.
## 17. Откат
Feature flag не нужен: поведение восстанавливает инвариант без нового режима.
Откат — revert продуктового коммита целиком:
- schema/data version не требуют downgrade;
- сохранённые merged/reconciled partitions совместимы с предыдущей current
версией;
- remapped/cleared room refs совместимы и восстанавливаются из обычного backup;
- furniture/image transforms не переписываются при внедрении, поэтому revert
только вернёт старое поведение следующего Optimize.
## 18. Release-артефакты
Поведение заметно пользователю, поэтому в том же продуктовом коммите нужны:
- короткие RU/EN changelog bullets со ссылкой на #477: законченные стены больше
не требуют повторной Optimize; Optimize не двигает плавно изменённую мебель;
- `docs/USER-GUIDE.md`: завершение wall-chain и Optimize;
- `docs/CANVAS.md`: grid contract и furniture/image exemption;
- `docs/ARCHITECTURE.md`: writer fixed-point barrier, history/reference scope;
- `docs/STATUS.md`: текущее состояние;
- обновление `docs/specs/README.md`.
Golden/screenshots не требуются: визуальный результат должен остаться тем же,
кроме отсутствия нежелательного сдвига после Optimize. Security report не
требуется. Performance artifact обязателен только для изменённого wall-chain
finish/terminal-click профиля и перечислен в §11/§15.
## 19. Принято предположительно, поменять свободно на ревью ТЗ
1. Seed-filter добавляется в общий reconciliation helper как optional параметр;
вызовы Optimize и #478 без фильтра остаются byte-equivalent.
2. History tagging — session-only поле snapshot, а не новая persisted модель.
3. Room reference rewrite вынесен в pure helper; layout room labels намеренно
остаются вне этой транзакции.
4. Exact набор файлов может измениться после декомпозиции, но предполагается:
`src/houseplan-editor-runtime.ts`, `src/wall-merge.ts`,
`src/coincident-partitions.ts`, новый pure helper fixed-point/reference,
`src/align-grid.ts`, соответствующие tests/demo scripts и документы §18.