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

33 KiB
Raw Permalink Blame History

ТЗ #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 — одна завершённая штатная редакторская операция текущей версии. Тогда:

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.