mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-01 20:29:00 +00:00
438 lines
26 KiB
Markdown
438 lines
26 KiB
Markdown
# Issue #291 — единый барьер записи lattice-координат
|
||
|
||
- **Issue:** https://github.com/Matysh/houseplan-card/issues/291
|
||
- **Статус:** первая редакция для внешнего ревью; канонический статус задаётся метками issue
|
||
- **Тип / приоритет:** tech-debt / P1
|
||
- **Оценка:** пользовательская ценность 7/10; ценность для разработки 10/10;
|
||
сложность 10/10; риск 10/10
|
||
- **Область:** frontend/backend config+layout persistence boundary, near-grid
|
||
coordinate canonicalization, explicit Optimize report, imports/Undo/recovery,
|
||
invariant and mutation gates
|
||
- **Модель данных:** JSON schema, Store/storage/model version и типы полей не
|
||
меняются; координаты остаются numbers
|
||
- **Связано:** #198, #223, #224, #248, #258, #278, #282–#284,
|
||
`docs/adr/282-wall-geometry-representation.md`,
|
||
`docs/CONFIG-COMPATIBILITY.md`, `docs/CANVAS.md`
|
||
|
||
## 1. Сценарий и измеренная причина
|
||
|
||
Stage 0 (#283) показала, что на двух живых пространствах владельца 65,38% и
|
||
78,77% координат лежат рядом с узлом решётки, но не равны canonical double
|
||
этого узла. Худшее отклонение — `8×10⁻⁸` шага. Авторской off-grid geometry
|
||
почти нет.
|
||
|
||
Текущий storage barrier #224 округляет allow-listed numbers до девяти
|
||
десятичных знаков. Для шага `1/240` это создаёт стабильную десятичную запись,
|
||
но не canonical IEEE-754 result `Math.round(v×240)/240`. Поэтому независимые
|
||
пути вычисления одного узла получают разные bits: wall key уезжает в соседний
|
||
bucket (#258), а Optimize после round-trip снова видит работу (#248).
|
||
|
||
Смена schema на integer node ids для устранения этого явления не нужна.
|
||
Нужна одна boundary-функция, которую невозможно обойти обычным writer path.
|
||
|
||
## 2. Решения владельца
|
||
|
||
Зафиксированы в #284:
|
||
|
||
1. На lattice canonicalization распространяются и plan geometry, и позиции
|
||
маркеров/room labels в layout.
|
||
2. Дальняя authored geometry не притягивается к узлу; она сохраняется и
|
||
перечисляется в отчёте.
|
||
3. После migration и после произвольной editing session noise population
|
||
должна быть нулевой.
|
||
4. Видимый отчёт Optimize остаётся компактным: общий счётчик исправленных
|
||
координат и максимальный физический сдвиг, затем строки только для
|
||
пространств, где действительно были изменения, с их счётчиками.
|
||
Неизменённые пространства не перечисляются.
|
||
|
||
## 3. Пользовательский контекст и результат
|
||
|
||
**Кто и где:** владелец дома с несколькими этажами/пространствами, который
|
||
импортировал старый план либо продолжает редактировать его в Plan. Проблема
|
||
проявляется в обслуживающем действии Optimize и после обычной записи geometry,
|
||
хотя на экране субпиксельный хвост координаты неразличим.
|
||
|
||
**До:** один визуальный узел может иметь разные double-биты в зависимости от
|
||
истории вычислений. Пользователь видит повторную «работу» Optimize, неверный
|
||
wall key или последующий дефект стыка, но не может связать его с координатным
|
||
шумом.
|
||
|
||
**После:** любой сохранённый config/layout не содержит near-node noise. Обычная
|
||
запись убирает только измеренный невидимый хвост без перемещения authored
|
||
off-grid geometry. Перед явным Optimize пользователь видит отдельное от
|
||
физических перемещений описание координатной очистки, решает Confirm/Cancel и
|
||
получает один Undo. В доме с несколькими этажами отчёт показывает общий итог и
|
||
только затронутые пространства, не превращая диалог в полный технический лог.
|
||
|
||
## 4. Термины и scalar contract
|
||
|
||
### 4.1 Одна решётка и одна граница noise
|
||
|
||
- `GRID_N = 240`, canonical node: `Math.round(value × GRID_N) / GRID_N`;
|
||
- deviation измеряется в шагах:
|
||
`abs(value × GRID_N − round(value × GRID_N))`;
|
||
- `exact`: deviation строго `0`;
|
||
- `noise`: `0 < deviation < 1e-4` шага;
|
||
- `off-grid`: deviation `>= 1e-4` шага.
|
||
|
||
`1e-4` — существующая измерительная граница Stage 0 и единый shared constant
|
||
для invariant, TypeScript и Python. Она на четыре порядка меньше шага и выше
|
||
измеренного ULP-хвоста. Literal «дальше половины шага» из исследования
|
||
выражает запрет видимого snap: математически расстояние до *ближайшего* узла
|
||
никогда не бывает больше половины шага, поэтому рабочее разделение намеренного
|
||
off-grid и noise обязано использовать измеренный `1e-4` threshold.
|
||
|
||
### 4.2 Две канонизации, не одна рекурсивная
|
||
|
||
`canonicalizeLatticeCoordinate(value)`:
|
||
|
||
- finite exact/noise value возвращает canonical nearest node double;
|
||
- finite off-grid value не grid-snap'ится и сохраняет действующую
|
||
nine-decimal storage canonicalization #224;
|
||
- `-0` становится `+0`;
|
||
- non-number/non-finite проходит без изменения до существующей validation.
|
||
|
||
`canonicalizeScalar(value)` сохраняет nine-decimal contract #224 для
|
||
allow-listed angles, transforms, lengths и normalized ratios, которые не
|
||
обязаны быть lattice nodes.
|
||
|
||
Рекурсивное округление любых numbers запрещено: unknown/future fields,
|
||
physical centimetres, colours, brightness, vacuum calibration и остальные
|
||
неперечисленные значения byte-equivalent.
|
||
|
||
## 5. Allow-list lattice fields
|
||
|
||
Near-node canonicalization применяется только к coordinate/size components:
|
||
|
||
- room `poly[*][0..1]`; legacy rect `x/y/w/h`;
|
||
- lossless endpoints `walls[].a/b`;
|
||
- `room_drafts[].points`, `partitions[].a/b`, `wall_columns[].center`,
|
||
`open_spans[].a/b`;
|
||
- opening `x/y` только как coordinate components; diagonal/wall-bound values,
|
||
попадающие в off-grid population, не притягиваются независимо к lattice;
|
||
- decor line endpoints и grid-bound origin/size fields existing allow-list;
|
||
- layout position `x/y` для device markers, room labels и сохранённых future
|
||
owners.
|
||
|
||
Остаются scalar-only: plan backdrop transforms, angles, opening length/host
|
||
`t`, decor angle/scale, column angle и marker angle. Их existing
|
||
nine-decimal semantics не меняются.
|
||
|
||
Allow-list реализуется зеркально в frontend и backend и закрепляется общей
|
||
fixture. Добавление нового persisted coordinate field требует обновить оба
|
||
runtime и registry/test в одном коммите.
|
||
|
||
## 6. Непроходимый write barrier
|
||
|
||
### 6.1 Frontend
|
||
|
||
Все product mutations могут работать с immutable/local candidate, но ни один
|
||
config/layout/position payload не отправляется и не принимается в committed
|
||
state до общей boundary-функции:
|
||
|
||
- serialized config write канонизирует целый candidate и frontend принимает
|
||
ровно его;
|
||
- individual layout write канонизирует position;
|
||
- localStorage/cache канонизирует full layout;
|
||
- Optimize/import/Undo/recovery responses принимаются в том exact виде,
|
||
который подтвердил backend, без повторной несовместимой формулы.
|
||
|
||
Не требуется расставлять snap по десяткам assignments. Source-guard перечисляет
|
||
все outbound config/layout writer methods и падает при новом writer, который
|
||
не проходит общий boundary.
|
||
|
||
### 6.2 Backend
|
||
|
||
`async_save_config_state()` и `async_save_layout_state()` остаются единственной
|
||
Store boundary для config/layout. Их shared payload builders применяют Python
|
||
mirror до `Store.async_save`; import, optimize, undo, recovery и ordinary WS
|
||
writers обязаны сходиться туда. Direct Store write config/layout вне boundary
|
||
запрещён source/AST guard'ом. Operational stores (trails, virtual lights) не
|
||
являются plan geometry и не канонизируются.
|
||
|
||
Backend validation возвращает canonical candidate, поэтому stale/old frontend
|
||
не может занести noise. Frontend/Python shared fixture сравнивает точные JSON
|
||
numbers, а не tolerance.
|
||
|
||
## 7. Existing data и explicit Optimize report
|
||
|
||
Read существующего Store не переписывает данные. Первая последующая обычная
|
||
config/layout write проходит barrier и устраняет near-node noise во всём
|
||
candidate; это невидимый sub-pixel canonicalization, уже разрешённый owner.
|
||
|
||
Явный **Optimize plans** остаётся немедленным bulk-path и добавляет отчёт:
|
||
|
||
- total `latticeCoordinatesCanonicalized`;
|
||
- `latticeCoordinatesFar` — untouched off-grid coordinate components;
|
||
- maximum shift для canonicalized noise в physical units;
|
||
- ниже — только изменённые пространства с их
|
||
`latticeCoordinatesCanonicalized` и `latticeCoordinatesFar`; пространства с
|
||
нулём изменений не показываются;
|
||
- layout entries входят в общий итог и, если UI имеет отдельного именованного
|
||
owner для layout, показываются по тому же правилу «только изменённые»;
|
||
- maximum shift для canonicalized noise в physical units не смешивается с
|
||
видимым `moved/maxShiftCm` обычного grid alignment.
|
||
|
||
Optimize не пытается snap'ить far population этой задачей; действующий explicit
|
||
Align-to-grid pass может предлагать видимое движение отдельно по своему
|
||
контракту. UI обязан различать `noise canonicalized` и `elements moved`.
|
||
|
||
Confirm сохраняет exact preview config/layout revision-guarded transaction и
|
||
один Undo. Cancel/close не пишет. Повторный Optimize после storage/event/cold
|
||
reload даёт нулевой noise counter и no-op, если других maintenance changes нет.
|
||
|
||
### 7.1 i18n-контракт отчёта
|
||
|
||
Новые строки не расширяют общий `gs.optimize_changes`: координатная
|
||
канонизация имеет отдельный maximum и не должна смешиваться с обычным
|
||
grid-alignment. Добавляются два ключа с нейтральными числовыми формулировками,
|
||
не требующими отдельной pluralisation:
|
||
|
||
| Ключ | English | Русский |
|
||
|---|---|---|
|
||
| `gs.optimize_lattice_summary` | `Noisy coordinate values canonicalized: {n}; maximum movement: {cm} cm.` | `Канонизировано шумовых значений координат: {n}; максимальный сдвиг: {cm} см.` |
|
||
| `gs.optimize_lattice_space` | `{space}: coordinate values canonicalized: {n}; off-grid values left unchanged: {far}.` | `{space}: канонизировано значений координат: {n}; оставлено значений вне сетки: {far}.` |
|
||
|
||
`gs.optimize_lattice_summary` показывается только при `n > 0`.
|
||
`gs.optimize_lattice_space` показывается только для затронутых пространств;
|
||
layout без собственного пользовательского имени входит только в summary.
|
||
Placeholder `{cm}` передаёт измеренный maximum с тремя значащими цифрами:
|
||
обычной десятичной записью при `|cm| >= 0.001`, с удалением незначащих нулей,
|
||
и scientific notation при `0 < |cm| < 0.001`. Ненулевой maximum никогда не
|
||
округляется до `0` и не использует политику округления вверх до `0.1 см` из
|
||
обычного grid-align report; например, `0.000033 cm` показывается как
|
||
`3.30e-5 cm`.
|
||
Кнопки Confirm/Cancel/Undo и общий lossy-warning используют существующие ключи.
|
||
|
||
## 8. Scope
|
||
|
||
### Входит
|
||
|
||
- lattice-aware scalar + shared constants в TS/Python/invariant;
|
||
- typed allow-list config/layout/position traversal;
|
||
- frontend/backend boundary and bypass guards;
|
||
- existing-data cleanup через next write и explicit Optimize report/Undo;
|
||
- import/export/restore/recovery parity;
|
||
- real/noisy fixture acceptance, exact idempotence range, mutation and
|
||
performance;
|
||
- compatibility/canvas/architecture/testing/user docs и оба changelog.
|
||
|
||
### Не входит
|
||
|
||
- integer storage schema, stable wall ids или planar graph from ADR #282;
|
||
- выпрямление законного уступа `316×1` (#290);
|
||
- renderer corridor #288, partial resize #289 или union algorithm #278;
|
||
- grid snap authored off-grid geometry без отдельного Optimize permission;
|
||
- изменение grid resolution, unit system или physical cm;
|
||
- очистка source fixtures с noise.
|
||
|
||
## 9. Acceptance criteria
|
||
|
||
### AC1. Scalar idempotence и exact bits
|
||
|
||
Для 4801 nodes `k/240`, `k=-2400..2400`:
|
||
|
||
- canonicalization идемпотентна;
|
||
- canonical node не меняется;
|
||
- nine-decimal noise form каждого выбранного node превращается точно в тот же
|
||
JS/Python double/JSON number;
|
||
- negative range и `-0` корректны;
|
||
- off-grid `0.06`, `0.2875` и boundary values вокруг `1e-4` не grid-snap'ятся.
|
||
|
||
### AC2. Shared allow-list parity
|
||
|
||
Одна fixture содержит все lattice fields, scalar fields, unknown siblings,
|
||
diagonal opening и future layout owner. TS и Python outputs deep-equal expected;
|
||
input immutable. Scalar/unknown/off-grid values сохраняют #224 semantics.
|
||
|
||
### AC3. Живой профиль после migration равен нулю
|
||
|
||
На privacy-minimized real-plan clones после boundary/Optimize:
|
||
|
||
```
|
||
npm run invariants -- --config <candidate> --lattice
|
||
шум у узла 0 (0.00%) — ближе 0.0001 шага, но не точно
|
||
```
|
||
|
||
для обоих spaces и layout. `checkWallKeys`, `checkMixedRoleRecords`, references
|
||
и production geometry preflight дают ноль violations. Far population остаётся
|
||
и совпадает с preview report.
|
||
|
||
### AC4. Произвольная editing session не возвращает noise
|
||
|
||
Поведение доказывается композицией, а не вторым mega-smoke, дублирующим все
|
||
редакторы:
|
||
|
||
- production-bundle smokes `smoke_wall_chain_thickness`, `smoke_room_resize`,
|
||
`smoke_opening_preview`, `smoke_free_walls`, `smoke_decor` и
|
||
`smoke_drag_bounds` проходят реальные controller paths для Walls chain,
|
||
Resize, opening, partition, column, decor, device marker и room label;
|
||
- executable `coordinate-write-barrier-guard.test` доказывает, что у этих
|
||
controllers нет отдельного outbound config/layout writer и что добавление
|
||
writer мимо общей boundary красит gate;
|
||
- `smoke_lattice_write_barrier` исполняет все найденные production writers на
|
||
шумном candidate и после каждой принятой config/layout пары проверяет именно
|
||
`latticeProfile(...).noise === 0`;
|
||
- `smoke_optimize_coordinate_canonicalization` отдельно проходит bulk write,
|
||
reload/export-equivalent server snapshot и повторный no-op.
|
||
|
||
Таким образом после каждого committed pair `latticeProfile.noise === 0`, а
|
||
операция не может потребовать отдельного ручного snap call в каждом controller.
|
||
Это явная запись способа доказательства AC4 по PROCESS.md §7.1; отдельный
|
||
гигантский session-smoke не является дополнительным требованием.
|
||
|
||
### AC5. Write barrier нельзя обойти
|
||
|
||
Executable source/AST guard знает все frontend outbound config/layout writers и
|
||
backend config/layout Store writers. Добавленный test writer с direct payload
|
||
или `Store.async_save` мимо boundary красит gate. Operational non-geometry
|
||
stores остаются разрешены explicit allow-list.
|
||
|
||
### AC6. Source fixtures остаются шумными
|
||
|
||
`test/fixtures/real-plan-*.json` не переписываются implementation/formatting
|
||
tools. Existing profile pins (`noise >= 100`) остаются зелёными на raw files;
|
||
tests работают только с clones. Отдельно доказано: barrier на clone даёт ноль,
|
||
а raw fixture после теста byte/hash-equivalent.
|
||
|
||
### AC7. Optimize report и transaction
|
||
|
||
Preview показывает общий счётчик canonicalized coordinates, maximum physical
|
||
noise shift и строки только для пространств с ненулевыми изменениями;
|
||
неизменённые пространства отсутствуют. Сумма строк согласована с total; far не
|
||
считается moved, maximum noise shift не занижается. Cancel — zero writes; stale
|
||
revision — no partial pair; Confirm — один config/layout transaction и один
|
||
Undo; reload/cold read deep-equal preview; второй run no-op.
|
||
|
||
### AC8. Compatibility paths
|
||
|
||
Full/space/plan-only import, export, duplicate remap, backup restore, optimize
|
||
undo, pending recovery и ordinary old-client config/layout write заканчиваются
|
||
одинаковой canonical pair. Unknown fields и valid off-grid geometry survive.
|
||
No schema/storage/model version bump.
|
||
|
||
### AC9. Polyclip and structural regression
|
||
|
||
Точная co-incidence после snap проходит #278 failure-isolation fixtures и full
|
||
wall body tests. Barrier не является поводом ослабить preflight или удалять
|
||
локальную union isolation. #288/#289/#290 exact-grid regressions сохраняют свои
|
||
ожидаемые результаты независимо от noise cleanup.
|
||
|
||
### AC10. Мутанты
|
||
|
||
Обязательны:
|
||
|
||
- `Math.round`/Python `round-equivalent` → truncation;
|
||
- порог меньше measured `8×10⁻⁸` либо больше authored boundary;
|
||
- пропуск layout `x/y`;
|
||
- direct frontend writer bypass;
|
||
- direct backend Store bypass;
|
||
- рекурсивная канонизация unknown number.
|
||
|
||
Каждый mutant убивается AC1–AC6.
|
||
|
||
### AC11. Performance
|
||
|
||
Boundary traversal линейный и выполняется один раз на serialized payload, не на
|
||
pointermove/render tick. Large config save/Optimize benchmark не регрессирует
|
||
действующий p95 более чем на 20% same-run baseline и не добавляет второго full
|
||
deep clone сверх существующего candidate contract.
|
||
|
||
### AC12. Локальные гейты
|
||
|
||
- `npm run typecheck`;
|
||
- `npm test`;
|
||
- `npm run build` и bundle parity;
|
||
- backend canonicalization/validation tests;
|
||
- `node scripts/check-docs.mjs`;
|
||
- targeted write-barrier/Optimize smoke, invariants, mutation and benchmark.
|
||
|
||
Полные golden, smoke, performance и Linux HA harness выполняются перед beta.
|
||
|
||
## 10. Совместимость, UX, touch и security
|
||
|
||
JSON numbers и field shapes не меняются, old clients continue reading. Old
|
||
writer cannot persist near-node noise because backend owns the boundary. Read
|
||
не мигрирует silently; next write canonicalizes tiny measured tails, explicit
|
||
Optimize gives report and Undo.
|
||
|
||
View/touch/kiosk appearance не меняется на видимом масштабе. Device-marker drag
|
||
на touch получает тот же final boundary; Plan editor остаётся desktop-first.
|
||
Новых HA actions, external data, URL/HTML и permission boundaries нет.
|
||
|
||
## 11. Риски и меры
|
||
|
||
- Ошибочно широкий threshold может притянуть намеренную off-grid geometry.
|
||
Мера: measured `1e-4`, boundary cases AC1, typed allow-list AC2 и сохранение
|
||
far population AC3/AC7.
|
||
- TS и Python могут получить разные округления одного узла и снова создать
|
||
wall-key drift. Мера: shared exact fixture/JSON bits AC1–AC2 и запрет
|
||
tolerance-сравнения.
|
||
- Новый writer может обойти barrier. Мера: executable frontend/backend guard и
|
||
bypass mutants AC5/AC10.
|
||
- Full-candidate traversal может удвоить clone/save cost. Мера: один boundary
|
||
pass и same-run benchmark AC11.
|
||
- Обычная следующая запись очистит старый noise без отдельного Optimize. Мера:
|
||
граница ограничена доказанным невидимым хвостом; authored off-grid и unknown
|
||
fields byte-equivalent, а explicit bulk path сохраняет preview/Cancel/Undo.
|
||
|
||
## 12. Откат
|
||
|
||
Чистый revert implementation-коммита прекращает новую canonicalization; schema
|
||
и Store migration откатывать не требуется. Уже сохранённые canonical lattice
|
||
bits не восстанавливаются автоматически: они семантически равны прежнему узлу,
|
||
а обратное внесение noise было бы новой порчей. Явный Optimize до следующей
|
||
операции можно отменить штатным Undo, который хранит pre-transaction snapshot.
|
||
|
||
## 13. Ожидаемые файлы
|
||
|
||
Product/frontend:
|
||
|
||
- `src/coordinate-canonicalization.ts`;
|
||
- `src/houseplan-card.ts` только для boundary/report UI integration;
|
||
- `src/align-grid.ts`, `src/plan-optimizer.ts` для report accounting;
|
||
- `src/i18n/en.json`, `src/i18n/ru.json`.
|
||
|
||
Backend:
|
||
|
||
- `custom_components/houseplan/coordinate_canonicalization.py`;
|
||
- central store/validation/websocket paths только если guard выявит обход.
|
||
|
||
Tests/evidence:
|
||
|
||
- shared coordinate fixture и TS/Python tests;
|
||
- optimizer/align/import/recovery tests;
|
||
- source/bypass guard;
|
||
- real-plan clone smoke/invariants;
|
||
- mutation registry and save/Optimize benchmark.
|
||
|
||
Документация:
|
||
|
||
- `docs/CONFIG-COMPATIBILITY.md`, `docs/CANVAS.md`, `docs/ARCHITECTURE.md`,
|
||
`docs/USER-GUIDE.md`, `docs/USER-GUIDE.ru.md`, `docs/TESTING.md`, ADR note;
|
||
- `docs/CHANGELOG.md`, `docs/CHANGELOG.ru.md`.
|
||
|
||
## 14. Release
|
||
|
||
Implementation-коммит имеет `Issue: #291`, `User-Visible: yes` и оба
|
||
changelog. Visual baselines не перепринимаются без semantic change; если
|
||
Optimize dialog меняется визуально, targeted golden/docs screenshots
|
||
принимаются только из штатного Linux artifact после bundle sync.
|
||
|
||
## 15. Принятые технические предположения
|
||
|
||
1. Owner intent «не трогать дальние» означает не grid-snap'ить Stage-0
|
||
`offGrid` population; existing invisible nine-decimal scalar storage
|
||
canonicalization #224 сохраняется.
|
||
2. Existing Store не переписывается на read. Immediate bulk path — Optimize;
|
||
ordinary next write применяет общий barrier ко всему candidate.
|
||
3. `PLAN_MODEL_VERSION` не повышается: pass data-driven и идемпотентен, schema
|
||
и read semantics не меняются.
|
||
4. Diagonal wall-bound coordinates, не попадающие в noise population, остаются
|
||
off-grid; их независимый X/Y snap запрещён.
|
||
5. Touch editor: best effort; View/kiosk fully supported.
|
||
6. Layout без самостоятельного пользовательского имени агрегируется только в
|
||
общий итог; если UI уже показывает именованного layout-owner, для него
|
||
действует то же правило отчёта, что для пространства: строка появляется
|
||
только при ненулевых изменениях.
|