docs: specify editor writer fixed point

Issue: #477
User-Visible: no
This commit is contained in:
Matysh
2026-09-06 17:52:05 +00:00
committed by claude[bot]
parent 052549fa20
commit 4693d97d2f
2 changed files with 493 additions and 0 deletions
+492
View File
@@ -0,0 +1,492 @@
# ТЗ #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.
+1
View File
@@ -94,6 +94,7 @@ GitHub Issues и GitHub Projects (v2) остаются единственным
| Issue | ТЗ |
|---|---|
| [#478](https://github.com/Matysh/houseplan-card/issues/478) Отказ от persisted-сущности `room_drafts` | [478-remove-room-drafts.md](478-remove-room-drafts.md) |
| [#477](https://github.com/Matysh/houseplan-card/issues/477) Fixed point оптимизатора после штатного редактирования | [477-editor-writer-fixed-point.md](477-editor-writer-fixed-point.md) |
| [#10](https://github.com/Matysh/houseplan-card/issues/10) Roomba live position | [010-vacuum-roomba-live-position.md](010-vacuum-roomba-live-position.md) |
| [#442](https://github.com/Matysh/houseplan-card/issues/442) Атомарный откат отклонённых записей маркера | [442-marker-write-rollback.md](442-marker-write-rollback.md) |
| [#11](https://github.com/Matysh/houseplan-card/issues/11) Vacuum source health | [011-vacuum-source-health.md](011-vacuum-source-health.md) |