# ТЗ #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_`: 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_` остаётся как сейчас и не входит в 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.