docs(spec): define resize wall carrier contract

Issue: #298
User-Visible: no
This commit is contained in:
Sergey Matyunin
2026-08-24 21:53:54 +03:00
parent 4feeebc355
commit 45066631ae
2 changed files with 353 additions and 0 deletions
@@ -0,0 +1,352 @@
# Issue #298 — Resize сохраняет wall records на решётке и на carrier
- **Issue:** https://github.com/Matysh/houseplan-card/issues/298
- **Статус:** первая редакция для внешнего ревью; канонический статус задаётся
метками issue
- **Тип / приоритет:** bug / P1
- **Оценка:** пользовательская ценность 10/10; ценность для разработки 10/10;
сложность 7/10; риск 8/10
- **Область:** fixed-topology Resize, exact wall records, live preview/commit,
grid barrier, real-plan invariant smoke и mutation coverage
- **Модель данных:** schema и model version не меняются; исправляются только
записи, которые переносит новый Resize-жест
- **Связано:** #253, #277, #289, #291, #293, #297,
`docs/RESIZE.md`, `docs/WALL-THICKNESS.md`, `docs/CANVAS.md`
## 1. Сценарий и подтверждённая причина
Пользователь безопасно сдвигает одну стену комнаты. Moving wall едет
параллельно себе, а две соседние стены меняют длину. После жеста визуально всё
может выглядеть правдоподобно, но запись толщины другой стены получает endpoint,
которого нет ни среди вершин room polygons, ни на границе wall carrier. На
следующем редактировании это проявляется потерей кладки, ошибкой Optimize или
сломавшейся ручкой Resize.
На обеих приложенных к issue реальных fixtures причина подтверждена в
`rekeyWallsAfterMove()`. Для точки `p` на старом ребре helper вычисляет
относительную долю `t`, затем возвращает точку с той же долей на новом ребре.
Когда fixed-topology Resize двигает только один endpoint бокового ребра,
внутренний endpoint wall record тоже пропорционально уезжает. Так `-85`
становится `-82.457`, хотя такой geometry boundary в новом polygon нет.
Текущий commit guard проверяет только мультимножество `cm` и число open spans.
Он не доказывает, что все exact wall records лежат на carriers нового плана,
поэтому повреждённая запись сохраняется.
## 2. Пользовательский результат
Resize продолжает выглядеть и управляться как после #277/#293. Отличие в
сохранённых данных: перемещаются только wall endpoints, для которых существует
однозначное соответствие старой и новой topology vertex. Остальные endpoints
не интерполируются и остаются на своей физической границе. После commit все
записи толщины остаются на grid и на room-wall carriers; последующие Resize,
Optimize и рендер не получают скрытую повреждённую геометрию.
Если lossless correspondence доказать нельзя, кандидат не сохраняется. Live
preview остаётся на последней валидной позиции, а при отсутствии валидной
позиции жест завершается существующей локализованной ошибкой Resize без config
write и Undo entry. Автоматического снапа настоящей off-grid координаты или
догадки по ближайшей стене нет.
## 3. Fixed-topology correspondence
### 3.1 Источник истины
Для каждого затронутого room edge Resize уже имеет параллельную пару
`old edge → new edge` из immutable pre-drag snapshot и exact candidate.
Topology signature гарантирует одинаковое число и циклическую identity
вершин. На этой основе строится таблица соответствия:
`old room vertex → new room vertex`.
В таблицу входят только действительно изменившиеся vertices. Одинаковая
старая точка, принадлежащая двум room copies общей стены, обязана иметь ровно
одну новую destination. Несколько разных destinations означают конфликт и
отменяют кандидат; порядок rooms или wall records не выбирает победителя.
### 3.2 Exact wall records
Пара `a/b` является identity exact wall record; compatibility `key` только
пересчитывается из итогового span.
Каждая исходная exact запись обрабатывается из immutable snapshot:
1. запись делится на атомы в endpoints перекрывающихся затронутых old edges;
2. endpoint атома заменяется только если он равен old topology vertex из
таблицы correspondence в пределах canonical coordinate epsilon;
3. interior endpoint без vertex correspondence остаётся byte-equivalent —
относительная доля длины ребра для него не вычисляется;
4. нулевые атомы удаляются, совместимые соседние атомы с одинаковым `cm`
склеиваются только когда их endpoints точно совпали и они коллинеарны;
5. для каждого изменённого span заново строится compatibility key, а `cm` и
известные совместимые поля сохраняются.
Длинная запись, пересекающая затронутый и незатронутый пролёты, обязана
разделиться на границе old edge: только endpoint затронутого атома следует за
вершиной. Нельзя affine-масштабировать целую запись или её внутреннюю точку.
### 3.3 Legacy key-only records
Legacy запись без валидных `a/b` не имеет длины и endpoints, поэтому ей нельзя
изобретать атомы. Разрешён только однозначный whole-edge fallback: старый key
точно соответствует целому изменённому edge и переносится на его новый key.
Projected-midpoint перенос по относительной доле запрещён в production Safe
Resize. Если legacy key затронут, но whole-edge соответствие неоднозначно,
кандидат fail closed и не сохраняется. Незатронутые legacy records остаются
byte-equivalent.
Исторический generic scale/rotate helper может остаться для изолированных
pure-тестов старых преобразований, но production Safe Resize обязан вызывать
fixed-topology API. Переиспользование proportional `t` в этом path запрещено.
## 4. Carrier и lattice preflight
После wall rekey, но до принятия live preview, product code проверяет exact
candidate целиком.
Для каждой exact wall entry:
- `a` и `b` конечны и лежат на canonical grid либо отличаются не больше
действующего near-node storage epsilon;
- весь открытый span между `a` и `b` покрывается непрерывным объединением
коллинеарных room edges; недостаточно проверить только midpoint;
- нет зазора между carrier atoms и нет участка, принадлежащего только
продолжению оси за пределами стены;
- compatibility key согласован с итоговыми `a/b`;
- `cm` остаётся в допустимом диапазоне, а мультимножество исходных физических
значений толщины не теряется.
Проверка допускает одну compact record через несколько смежных коллинеарных
room edges, но не допускает запись через разрыв. Independent partitions не
являются carrier для `space.walls`: их толщина хранится своей partition
geometry. Open spans по-прежнему проходят существующий отдельный rekey и
carrier validation.
`LATTICE_NOISE_STEPS` из измерительного гейта отличает форматный near-node шум
от настоящего off-grid значения. Исправление не округляет авторскую
координату, удалённую от grid: такой candidate отклоняется. Уже сохранённые
старые off-grid records не мигрируют при загрузке и не меняются без жеста.
## 5. Preview, commit и failure semantics
1. Preview каждый раз строится из immutable pre-drag rooms/walls/open spans,
а не из предыдущего кадра.
2. Fixed-topology mapping и carrier/lattice preflight являются частью одного
candidate builder, которым пользуются preview и pointerup.
3. `_serverCfg`, history и queued save не меняются до успешного pointerup.
4. Commit принимает только уже показанный exact preview и повторно проверяет
snapshot/plan signature. Отдельного второго rekey нет.
5. Конфликт correspondence, неоднозначный legacy record либо carrier/lattice
failure отклоняет кандидат целиком. Частичная запись запрещена.
6. После runtime reject сохраняется последний валидный preview; если валидного
ненулевого preview не было, config/history byte-equivalent исходному.
7. Undo/Redo восстанавливают rooms, openings, walls и open spans целиком.
Предсказуемый конфликт, который можно доказать в eligibility, должен сделать
handle disabled до pointer capture. Непредсказуемый runtime reject использует
уже существующее сообщение `resize.commit_failed`; новый persisted reason или
новый UX в этой задаче не вводится.
## 6. Scope
### Входит
- fixed-topology rekey exact и legacy wall records;
- endpoint correspondence без proportional interpolation;
- product carrier/lattice preflight до preview/commit;
- точные регрессии обеих fixtures из #298;
- обновление `smoke_edit_walk`/`KNOWN`, mutation и performance evidence;
- canonical Resize/wall-thickness/testing docs и оба changelog.
### Не входит
- изменение eligibility, safe range, pointer UX или topology #277/#293;
- автоматический ремонт уже сохранённого bad plan через Optimize;
- schema migration, integer-coordinate storage либо общий ADR #282;
- смешанные shared/exterior роли #289/#299;
- изменение толщины пользователем, renderer wall union или opening geometry;
- touch parity Plan editor сверх общего safety floor.
## 7. Acceptance criteria
### AC1. Exact repro второго этажа
На `real-plan-second-floor.json` production Resize выполняет указанную в issue
последовательность `room-a`, edge 2, `x=96 → 100` grid steps. Запись исходного
горизонтального span `y=304` сохраняет endpoint на существующей topology
boundary; значение `-85` не превращается в `-82.457` или другую
пропорциональную точку.
После preview и commit нет `off_lattice_coordinate`, `wall_carrier`, wall-key,
reference и physical-geometry нарушений. Moving/shared rooms и openings
сохраняют контракты #277/#293.
**Доказательство:** fixture-backed pure/unit regression и production-bundle
pointer smoke; assert проверяет конкретную identity записи до/после, а не
только отсутствие исключения.
### AC2. Exact repro первого этажа
На `real-plan-first-floor.json` последовательность `room-b`, edge 0,
`x=49 → 52` не создаёт endpoint `59.538` на wall record линии `y=155`.
Итоговые endpoints принадлежат реальным vertices/carrier boundaries
`17/52/57/101` согласно новому candidate.
**Доказательство:** отдельный fixture-backed regression с exact endpoint,
carrier/lattice и unchanged-record assertions.
### AC3. Untouched records действительно не меняются
Table-driven unit покрывает non-shared wall, exact shared pair, длинную запись,
пересекающую moved и untouched spans, reversed orientation и несколько
одинаковых `cm`. Запись без overlap и без moved endpoint остаётся deep/byte
equivalent, включая key и порядок. Затронутые записи сохраняют все значения
`cm`; коллизия destinations отклоняет весь candidate.
### AC4. Legacy compatibility не угадывается
- unambiguous whole-edge key переезжает на новый whole-edge key;
- untouched key-only record не меняется;
- key-only midpoint на части изменившего длину edge не переносится
пропорционально и приводит к fail-closed candidate;
- exact record всегда использует `a/b`, даже если старый compatibility key
неверен.
**Доказательство:** pure unit и production-preview reject с нулём config/history
writes.
### AC5. Carrier preflight проверяет весь span
Positive cases: одна room edge и непрерывная цепочка нескольких коллинеарных
room edges. Negative cases: оба endpoints на carriers, но между ними разрыв;
midpoint на продолжении за пределами edge; endpoint на independent partition;
настоящая off-grid coordinate дальше `LATTICE_NOISE_STEPS`; conflicting shared
destinations. Каждый negative candidate отклоняется до сохранения.
### AC6. Шесть edit-walk запусков больше не несут этот долг
Проходят:
```text
node demo/smoke_edit_walk.mjs --seed 1 --plan real-plan-second-floor.json
node demo/smoke_edit_walk.mjs --seed 2 --plan real-plan-second-floor.json
node demo/smoke_edit_walk.mjs --seed 3 --plan real-plan-second-floor.json
node demo/smoke_edit_walk.mjs --seed 1 --plan real-plan-first-floor.json
node demo/smoke_edit_walk.mjs --seed 2 --plan real-plan-first-floor.json
node demo/smoke_edit_walk.mjs --seed 3 --plan real-plan-first-floor.json
```
Для результатов Resize нет `off_lattice_coordinate` и `wall_carrier`.
`KNOWN` обновляется в том же implementation-коммите только для исправленных
kinds. Независимый долг `mixed_role_record` #299 не скрывается и не считается
регрессией #298.
### AC7. Preview/commit/Undo атомарны
Valid pointer drag показывает exact candidate до release, затем создаёт ровно
один config write и один Undo entry. Undo возвращает byte-equivalent исходные
rooms/walls/open spans; Redo возвращает тот же exact candidate. Forced carrier
failure даёт ноль writes/history и существующую локализованную ошибку один раз.
### AC8. Мутационный страж
Mutation возвращает proportional `t` mapping для interior endpoint либо
отключает carrier preflight. AC1/AC2 или AC5 обязаны падать. Blanket-disable
Resize не проходит positive pointer scenario AC7 и существующие #293 smokes.
### AC9. Производительность и локальные гейты
Mapping строится один раз на candidate из уже ограниченного набора затронутых
edges. Нельзя добавлять глобальное pairwise сравнение всех records со всеми
room edges на каждый `pointermove` без подготовленного carrier index. p95 budget
Resize из `docs/RESIZE.md` сохраняется; если hot path меняется, targeted
benchmark сравнивается с baseline.
Обязательны:
- `npm run typecheck`;
- `npm test`;
- `npm run build` и bundle parity;
- `node scripts/check-docs.mjs`;
- targeted Resize pointer smoke, шесть edit-walk запусков и mutation gate.
Полные golden, smoke, performance и Linux HA harness выполняются перед beta.
## 8. Совместимость, touch и security
Schema/storage/model version не меняются. Старые планы читаются без фоновой
перезаписи; исторический off-grid долг остаётся видимым до явной правки или
отдельного Optimize repair. Новый commit только запрещает Safe Resize создавать
новый долг.
Plan editor остаётся desktop-first. Touch — best effort, но safety floor общий:
single-pointer drag не пишет invalid candidate, pinch/pan, pointercancel и lost
capture не создают config/history entries. Новых HA actions, сетевых запросов,
HTML/CSS input или security boundaries нет.
## 9. Риски и меры
- **Слишком широкий vertex match** может двигать соседний interior endpoint.
Мера: canonical epsilon, explicit correspondence и exact AC1–AC3.
- **Слишком узкий match** потеряет толщину moved wall. Мера: positive
non-shared/shared/long-record matrix и `checkWallRecordsPreserved`.
- **Проверка только endpoints/midpoint** пропустит разрыв carrier. Мера: full
interval coverage AC5.
- **Legacy fallback снова введёт интерполяцию.** Мера: explicit fail-closed AC4
и mutant AC8.
- **Соседняя #299 меняет тот же `KNOWN`.** Мера: #298 удаляет только два своих
kinds и при rebase сохраняет независимые mixed-role строки.
- **Новый global validation замедлит pointermove.** Мера: подготовленный index
и benchmark AC9.
## 10. Откат
Откат — полный revert implementation-коммита вместе с tests/docs/`KNOWN`.
Миграция или восстановление schema не нужны. Конфиги, уже сохранённые новой
версией, используют прежнюю схему и читаются старой версией.
## 11. Ожидаемые файлы и release artifacts
Product code:
- `src/wall-thickness.ts` — fixed-topology rekey;
- `src/houseplan-card.ts` — candidate integration и fail-closed preflight;
- отдельный pure carrier helper допускается, если не дублирует invariant model.
Tests/evidence:
- `test/wall-thickness.test.mjs` и/или targeted Resize unit;
- `demo/smoke_edit_walk.mjs`, включая точное обновление `KNOWN`;
- production-bundle Resize pointer smoke;
- mutation registry и targeted benchmark при изменении hot path.
Документация:
- `docs/RESIZE.md`, `docs/WALL-THICKNESS.md`, `docs/TESTING.md`;
- при изменении архитектурной границы — `docs/ARCHITECTURE.md`;
- `docs/CHANGELOG.md`, `docs/CHANGELOG.ru.md`.
Визуальный дизайн не меняется, поэтому новый golden baseline не ожидается.
Если штатный docs screenshot всё же изменится, принимается только artifact
Linux workflow после визуального review и bundle sync.
Implementation-коммит имеет terminal trailers:
```text
Issue: #298
User-Visible: yes
```
Issue не закрывается вручную: она закрывается пакетно при выпуске beta.
## 12. Принятые технические предположения
1. Зафиксированные в issue gestures и tracked real-plan fixtures являются
достаточным privacy-safe regression input; новые приватные данные не нужны.
2. `space.walls` относится только к room-wall carriers. Independent partitions
хранят толщину в собственном объекте и не оправдывают wall record вне room
boundary.
3. Existing `resize.commit_failed` достаточно для редкого runtime reject;
новый user-facing reason не требуется, пока eligibility не меняется.
4. Near-node storage noise из #291 отличается от настоящего repro #298;
исправление не расширяет snap tolerance.
+1
View File
@@ -77,6 +77,7 @@ GitHub Issues и GitHub Projects (v2) остаются единственным
| [#280](https://github.com/Matysh/houseplan-card/issues/280) Backend принимает доказанный Optimize rehost | [280-optimize-rehost-validation.md](280-optimize-rehost-validation.md) |
| [#281](https://github.com/Matysh/houseplan-card/issues/281) Честный Resize после outer-partition reconciliation | [281-resize-zero-range.md](281-resize-zero-range.md) |
| [#293](https://github.com/Matysh/houseplan-card/issues/293) Активная рукоятка Resize выполняет pointer-жест | [293-resize-pointer-noop.md](293-resize-pointer-noop.md) |
| [#298](https://github.com/Matysh/houseplan-card/issues/298) Resize сохраняет wall records на решётке и на carrier | [298-resize-wall-thickness-carrier.md](298-resize-wall-thickness-carrier.md) |
## P2