diff --git a/docs/specs/258-wall-key-storage-roundtrip.md b/docs/specs/258-wall-key-storage-roundtrip.md new file mode 100644 index 00000000..b1cef4cd --- /dev/null +++ b/docs/specs/258-wall-key-storage-roundtrip.md @@ -0,0 +1,357 @@ +# Issue #258 — канонический wall key после Optimize и storage round-trip + +- Дата: 2026-08-23 +- Тип: bug · приоритет P1 +- Оценка: пользовательская ценность 9/10 · ценность для разработки 9/10 · сложность 6/10 · риск 8/10 +- Issue: [#258](https://github.com/Matysh/houseplan-card/issues/258) +- Ветка: `issue/258-wall-key-storage-roundtrip` +- Статус ТЗ: первая редакция, ожидает ревью + +Канонические документы: `docs/SCOPE.md`, `docs/WALL-THICKNESS.md`, +`docs/CONFIG-COMPATIBILITY.md`, `docs/TOUCH-SUPPORT.md`, +`docs/USER-GUIDE.ru.md`, `docs/ARCHITECTURE.md`, `docs/TESTING.md`. +Связанные, но не дублирующие задачи: +[#248](https://github.com/Matysh/houseplan-card/issues/248), +[#249](https://github.com/Matysh/houseplan-card/issues/249), +[#253](https://github.com/Matysh/houseplan-card/issues/253) и +[#254](https://github.com/Matysh/houseplan-card/issues/254). + +## 1. Сценарий и персона + +Администратор дома обновляется до `v1.67.0-beta.4` и запускает +«Общие настройки → Оптимизировать планы» для обычного сохранённого плана. +После подтверждения в местах T-образного схода толстых стен появляются белые +треугольные клинья: сами стены остаются, но часть физического узла или чистого +пола рассчитывается так, будто один интервал имеет нулевую толщину. + +Это регресс J1/J6 из `docs/SCOPE.md`: View обязан правдиво показывать дом, а +штатное обслуживание не должно повреждать его представление. Ошибка видна +членам семьи и в kiosk, хотя создаёт её административное действие. + +## 2. Что человек увидит до и после + +**До:** один Optimize способен создать либо закрепить пару `key` и `a/b`, +полученную из разных floating-point представлений одного ребра. В View, kiosk, +Static, Plan и скрытой изометрии это может дать белый клин или незаполненный +участок в T-стыке. Повторное открытие и reload не лечат вид. + +**После:** тот же план сразу рисуется с непрерывной кладкой и чистым полом, +независимо от того, какая из двух известных совместимых строк midpoint-key уже +сохранена. Нажатие Optimize показывает обычный точный preview, канонически +переписывает несовпадающий ключ при Apply и после записи/reload становится +no-op. Нового сообщения, предупреждения или отдельной команды ремонта нет. + +## 3. Подтверждённая причина + +### 3.1 Одна геометрия, два числовых представления + +Шаг нормализованной сетки равен `1 / 240`. Узел `83 / 240` в памяти равен +`0.34583333333333333`, а действующий storage-barrier #224 записывает его как +`0.345833333`. Оба значения обозначают один узел и отличаются только на +`3.33e-10`. + +`wallKey(a, b, pitch)` сначала считает midpoint, затем применяет +`Math.round(midpoint / pitch)`. У ребра нечётной длины в шагах midpoint лежит +ровно на полуцелом индексе. Для экспортированного ребра это даёт: + +```text +точные grid endpoints → 47.500000000 → 48 → 0.200000 +9-digit persisted endpoints → 47.499999960 → 47 → 0.195833 +``` + +Строковый ключ поэтому зависит не от физического ребра, а от того, до или после +storage-roundtrip его вычислили. + +### 3.2 Потребители расходятся + +`lookupWall()` сначала сравнивает key, затем использует legacy midpoint fallback +с допуском ровно `pitch / 2`. Ошибка попадает на границу допуска, и результат +решают последние биты. В подтверждённом минимальном прогоне функция вернула +`null`, тогда как `thicknessCmAt()` нашла для того же ребра 29 см через +lossless endpoints `a/b`. + +`cmsForPoly()`, node map и другие structural consumers не все проходят через +один и тот же endpoint fallback. Поэтому одна запись одновременно становится +положительной для wall body и нулевой для части узла/заливки. Изменения #249 +сделали расхождение заметным в общей multi-wall geometry, но не являются +источником разных ключей. + +### 3.3 Почему Optimize участвует + +Optimize снапит комнаты в полной double-точности, нормализует/rekey-ит wall +intervals и только затем приводит итоговую пару config/layout к девяти знакам. +Пути #253 могут переупаковать exact wall entries, когда в пространстве реально +двинулось хотя бы одно другое ребро. В результате key вычисляется на одной +стороне storage-barrier, а `a/b` после barrier оказываются на другой. + +Экспорт после Optimize закономерно снова содержит `0.345833333`: это не +доказательство, что `alignAllToGrid()` не дошёл до вершины. Внутреннее точное +значение было записано общим девятизнаковым writer-контрактом #224. + +## 4. Scope + +В задачу входят: + +1. единая детерминированная генерация существующего midpoint/direction key для + точных grid endpoints и их девятизнакового persisted-представления; +2. сохранение текущего строкового формата key и `walls[]` schema; +3. строгий same-span lookup по lossless `a/b` для уже сохранённых несовпадающих + пар до legacy midpoint fallback; +4. один результат толщины для wall profile, multi-wall node map, masonry, + paper/clean floor, fills и light barriers; +5. каноническая перепаковка несовпадающего exact wall key явным Optimize и + идемпотентность после frontend/backend storage-roundtrip; +6. модельный инвариант `key ↔ a/b`, применимый к lossless wall entries и ко всем + fixture-моделям проекта; +7. unit, mutation, production-bundle smoke и визуальное доказательство + исправленного T-стыка; +8. актуализация архитектурной, wall-thickness и testing-документации и двух + changelog. + +## 5. Non-scope + +Не входят: + +- новый формат ключа на основе строковой пары endpoints и массовая миграция + всех `walls[]`; +- изменение `GRID_N`, шага сетки, девятизнаковой storage-точности или allowlist + канонизации #224; +- автоматический запуск или Apply Optimize при открытии плана; +- расширение midpoint tolerance, чтобы он мог хватать соседний интервал; +- изменение правил atomic/parent thickness inheritance, virtual spans, + partial Resize либо нормализации micro-interval; +- изменение bevel-формулы, радиуса `1.25 × H` или иной геометрии #249 для + корректно найденных интервалов; +- новая ошибка/предупреждение, i18n, настройка или пользовательский repair UI; +- backend schema, Store version, `PLAN_MODEL_VERSION`, WebSocket API и + permissions. + +## 6. Контракт ключа + +### 6.1 Стабильная near-grid нормализация + +Перед вычислением midpoint и direction каждая конечная координата endpoint, +лежащая от ближайшего grid node не дальше единого малого key-epsilon, считается +этим узлом. Key-epsilon: + +- scale-relative к переданному `pitch`; +- не меньше погрешности девятизнакового storage-roundtrip; +- строго на порядки меньше половины grid step; +- един для генерации key и строгого same-span сравнения. + +Рекомендуемое действующее значение — +`max(pitch × 1e-6, 1e-9)` в координатах config; при `coordScale` оно переводится +в координаты вызывающего. Это совпадает с уже используемой точностью +`rekeyWallsAfterMove()` и не снапит легитимную off-grid геометрию. + +После такой нормализации сохраняется текущая формула и текущий внешний формат: + +```text +,@ +``` + +Для endpoints на узлах `12` и `83` полуцелая ничья всегда получает тот же +результат, что и точное grid-представление, включая отрицательные координаты и +обратное направление отрезка. Формат, precision и angle bucket не меняются. + +### 6.2 Строгий lossless same-span lookup + +Порядок разрешения wall entry: + +1. точное совпадение канонической строки key; +2. для записей с валидными `a/b` — совпадение обоих endpoints с endpoints + запроса в прямом либо обратном порядке внутри key-epsilon; +3. существующий legacy midpoint/direction fallback для key-only данных. + +Шаг 2 означает **то же самое физическое растяжение**, а не containment: +длинный parent `[0..10]` не является same-span для atomic child `[0..4]`. +Parent inheritance остаётся в существующем явном `exactCoveringWall()` / +`cmsForPoly()` контракте. Midpoint tolerance не увеличивается и не становится +способом ремонта lossless-записей. + +Если у entry есть невалидные/неполные endpoints, он проходит только прежний +key/legacy путь. Новый resolver не мутирует вход и не переписывает key во время +рендера. + +### 6.3 Один источник для structural consumers + +Все consumers, которым нужна толщина именно текущего atomic interval, получают +один resolved wall entry. Недопустимо, чтобы `lookupWall()` возвращал `null`, а +`thicknessCmAt()` для того же exact span возвращал положительное значение. + +Это относится минимум к: + +- `roomWallProfile()` / `wallIntervals()`; +- multi-wall node classification и bounded masonry #249; +- wall body, hatch, paper и clean floor; +- room fill/hover, Glow/sun/light occlusion; +- Plan, View, kiosk, Static и hidden Iso. + +Специализированное наследование parent-run для atomic children остаётся +отдельным уровнем и не дублируется в renderers. + +## 7. Optimize, данные и compatibility + +### 7.1 Явная канонизация + +Optimize на affected lossless entry возвращает entry, у которого: + +- `key === wallKey(a, b, GRID_STEP_N)` по новому стабильному контракту; +- `a/b` уже находятся в действующем девятизнаковом persisted-представлении; +- `cm` и физический интервал не меняются; +- повторный вызов в памяти и после `canonicalizeConfigGeometry()` возвращает + `changed:false` и нулевые change-счётчики по контракту #248. + +Preview и Cancel не пишут. Apply/Undo сохраняют существующую атомарность, +revisions и whole-pair transaction. Исправление key считается технической +канонизацией стен в существующем отчёте; новая отдельная строка не нужна. + +### 7.2 Уже затронутые планы + +План с любой из двух известных midpoint-key строк рисуется правильно сразу +после загрузки благодаря same-span `a/b`; пользователю не требуется сначала +нажать Optimize. Простое чтение не меняет config. Следующий явный Optimize +приводит key к одному представлению и остаётся обратимым существующим server +Undo. + +### 7.3 Legacy и новые версии + +- `{key, cm}` без endpoints остаётся читаемым по прежнему fallback и не получает + выдуманные endpoints. +- Unknown/future поля wall entry сохраняются действующими clone/write путями. +- Schema, `model_version`, Store minor и импорт/экспорт не меняются. +- Старая версия карты продолжает читать строковый key и `a/b`; обратной миграции + данных нет. + +## 8. Модельный инвариант + +`scripts/model-invariants.mjs` добавляет отдельное нарушение +`wall_key_mismatch` для wall entry с валидными `a/b`, если сохранённый key не +равен каноническому key этих endpoints при `pitch = 1 / 240`. + +Инвариант: + +- не импортирует runtime TypeScript и остаётся исполнимым на сыром export JSON; +- имеет parity-test с production `wallKey()` на матрице положительных, + отрицательных, reversed, odd/even и nine-digit координат, чтобы две формулы + не расходились молча; +- не роняет legacy key-only entry; +- сообщает space id, сохранённый key и ожидаемый key; +- входит в общий fixture sweep `npm test` и ловит минимизированные оба варианта + из #258. + +Сам инвариант диагностический: runtime не отвергает импорт и не удаляет запись +из-за mismatch. Product repair задают §§6–7. + +## 9. UX, touch, accessibility, performance и security + +Новых UI, текста, focus/keyboard/ARIA контрактов нет. View и kiosk получают +тот же исправленный structural path, что desktop. Maintenance UI остаётся +desktop-first; touch editor — best effort по `docs/TOUCH-SUPPORT.md`, но +результат Apply не зависит от способа ввода. + +Генерация key остаётся `O(1)`. Same-span scan не ухудшает асимптотику текущего +`lookupWall()` (`O(W)`), не добавляет обход rooms и не выполняется отдельно на +HA state tick вне существующего structural cache. Model invariant — `O(W)`. +Общий prerelease performance smoke остаётся release-blocking; отдельный бюджет +не нужен. + +Новых внешних данных, сетевых вызовов, HTML/CSS, permissions или service calls +нет. Limits и validation wall arrays сохраняются; security surface не меняется. + +## 10. Acceptance criteria + +| AC | Критерий | Доказательство | +|---|---|---| +| AC1 | Production key path возвращает одну строку для точных и девятизнаковых endpoints одного grid span: обе пары из issue, положительные/отрицательные координаты, odd/even длина, reversed endpoints, прямой `wallKey()` в config-space и `keyOf()` через публичные consumers в render-space. Действующий внешний формат и angle bucket не меняются. | `unit`: table-driven `test/wall-thickness.test.mjs`; mutant, убирающий near-grid endpoint normalization. | +| AC2 | Для affected entry со старым key `0.200000` и с переписанным key `0.195833` строгий lookup находит 29/28 см по same-span endpoints; соседний, parent, partial и параллельный interval не принимаются. `lookupWall()` и `thicknessCmAt()` не расходятся на exact span. | `unit`: lookup matrix + отрицательные containment/collision cases; mutant, убирающий endpoint fallback или заменяющий same-span на containment. | +| AC3 | Минимизированный Optimize fixture с affected wall и независимым реально снапнутым ребром после Apply содержит `key === wallKey(a,b)`, сохраняет `cm/a/b`, проходит девятизнаковый round-trip и на втором вызове даёт `changed:false` с нулевыми change-счётчиками. Preview не мутирует вход. | `unit`: `test/plan-optimizer.test.mjs` + shared fixture с числами #258; storage-boundary regression #248 остаётся зелёной. | +| AC4 | Модельный инвариант сообщает оба mismatch из #258 как `wall_key_mismatch`, печатает stored/expected, пропускает исправленные и key-only записи и совпадает с production `wallKey()` на общей матрице. Все shipped fixture-модели проходят. | `unit`: `test/model-invariants.test.mjs`; CLI JSON/text assertions; parity guard с `test-build/wall-thickness.js`. | +| AC5 | Один affected T-node остаётся заполненным и без белого клина до и после Optimize/reload во всех structural consumers: Plan/View/kiosk/Static/hidden Iso, clean floor и light barrier. Theme/HA tick не меняет и не перестраивает structural path. | targeted production-bundle `smoke` на минимизированном fixture; semantic path/fill probes до/после Apply и cache assertions. | +| AC6 | Golden light/dark фиксирует affected T-node с видимыми тремя стенами и отсутствием белого клина; semantic assertions доказывают непустой правильный crop. Любой baseline принимается только из полного Linux CI artifact по reviewed-процессу. | `golden`: отдельный либо расширенный сценарий matrix/harness + `test/golden-matrix.test.mjs`; visual review artifact. | +| AC7 | Legacy key-only, parent-run inheritance, partial Resize #253, multi-wall bevel #249, Optimize idempotence #248, openings и two-ray joins сохраняют прежнее поведение. Render/read не мутирует config, schema/version не меняются. | существующие unit/smoke regressions; deep-equality входа; targeted mutations. | +| AC8 | Implementation loop и обязательная документационная provenance зелёные. | `npm run typecheck`; `npm test`; `npm run build`; bundle parity; `node scripts/check-docs.mjs`. Полные golden/smoke/performance/HA — перед бетой. | + +## 11. План реализации + +1. В `src/wall-thickness.ts` вынести единый near-grid endpoint/key epsilon и + применить его внутри `wallKey()` без изменения формата строки. +2. Добавить private same-span resolver для lossless endpoints и включить его в + `lookupWall()` между exact-key и legacy midpoint fallback. Не переиспользовать + covering fallback. +3. Убедиться, что `wallEntry()`, rekey, normalize и render-space `keyOf()` + автоматически используют тот же production key source; не добавлять вторую + формулу в optimizer. +4. Добавить shared regression fixture с обеими парами #258 и реальным + Optimize-trigger, затем unit/mutation matrix AC1–AC4. +5. Расширить `scripts/model-invariants.mjs`, его text/JSON report и parity-test. +6. Добавить production-bundle smoke и golden fixture/crop AC5–AC6. Golden + baseline принимать только через полный Linux artifact после visual review. +7. Обновить `docs/WALL-THICKNESS.md`, `docs/ARCHITECTURE.md`, + `docs/CONFIG-COMPATIBILITY.md`, `docs/TESTING.md`, при необходимости точную + фразу Optimize в user guide и оба changelog. + +В цикле реализации запускаются только typecheck, unit и build. Targeted smoke, +golden и performance выполняются по установленному review/prerelease процессу; +backend/HA-harness не требуется локально, если Python не меняется. + +## 12. Риски и меры + +| Риск | Мера | +|---|---| +| Near-grid normalization меняет законный off-grid key | Epsilon ограничен storage-noise и на порядки меньше grid half-step; AC1 содержит near-but-legitimate negative cases за пределом epsilon. | +| Endpoint fallback распространяет parent thickness на child | Требуется равенство обеих пар endpoints, не containment; AC2 сохраняет AUD-159B6-01 и #201. | +| Исправлен один key producer, другой продолжает писать старое | `wallEntry()`/`keyOf()` используют production `wallKey`; Optimize round-trip и invariant parity проверяют композицию целиком. | +| Runtime чинит картинку, но сохраняет mismatch навсегда | AC3 требует явной канонизации Optimize и второй no-op после storage boundary. | +| Invariant дублирует TS-формулу и расходится | Общая table-driven parity-проверка production против standalone invariant implementation входит в `npm test`. | +| Исправленный интервал меняет unrelated wall geometry | Golden diff и targeted multi-wall/two-ray/resize/opening regressions; никаких baseline updates без объяснимого affected crop. | +| Дополнительный scan попадает в live hot path | Он остаётся внутри уже линейного lookup и существующего structural cache; prerelease performance smoke блокирует выпуск. | + +## 13. Release-артефакты + +Implementation-коммит получает `User-Visible: yes` и включает: + +- `docs/CHANGELOG.md` и `docs/CHANGELOG.ru.md` со ссылкой на #258; +- `docs/WALL-THICKNESS.md` — стабильный compatibility key и приоритет exact + same-span endpoints; +- `docs/ARCHITECTURE.md` — key/storage boundary и единый structural resolver; +- `docs/CONFIG-COMPATIBILITY.md` — канонизация wall key вместе с `a/b` явным + Optimize; +- `docs/TESTING.md` — unit/invariant/smoke/golden evidence; +- `docs/USER-GUIDE.ru.md` и английский эквивалент только если уточняется + видимое обещание Optimize; +- test fixtures, smoke/golden scenario и синхронные production bundles. + +Docs screenshots переснимаются канонической GitHub job и принимаются только из +её полного artifact, если `check-docs` требует новый fingerprint. Golden +baseline — только из полного Linux CI artifact с `Release:` и +`Baseline-Reviewed:` trailers. Отдельных i18n, backend, schema, security или +performance artifacts нет. + +## 14. Откат + +Откат — revert implementation-коммита и соответствующих tests/docs/baseline. +Schema и формат ключа не меняются, поэтому data rollback не нужен. Уже +канонизированные Optimize записи остаются читаемыми старой версией. На старой +версии белый клин может вернуться для affected persisted-пары, но данные стены +и её `cm/a/b` не теряются. + +## 15. Принятые предположения + +1. Текущий midpoint/direction key остаётся compatibility index; lossless `a/b` + являются авторитетной идентичностью нового wall entry. +2. Нормализуется только endpoint, уже находящийся внутри существующего + scale-relative storage-noise epsilon от grid node; произвольная off-grid + координата не снапится ради key. +3. Для затронутых записей immediate render repair обязателен без auto-write; + persistent repair выполняет явный Optimize либо конкретная последующая + редакторская операция, которая по своему существующему контракту уже + перепаковывает именно затронутый wall interval. Обычный unrelated Save не + обязан переписывать key. +4. Несовпадающий key сам по себе не делает импорт невалидным и не позволяет + удалить entry: invariant диагностирует, а runtime читает exact span. +5. Достаточно одного общего smoke/golden T-fixture с обеими key-вариациями, + если semantic probes отдельно доказывают все structural consumers AC5. +6. Python/backend production code не меняется: девятизнаковый writer уже + является правильным authority, а frontend key обязан быть устойчив к его + результату. diff --git a/docs/specs/README.md b/docs/specs/README.md index da37316d..4eabbfc2 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -130,6 +130,7 @@ GitHub Issues и GitHub Projects (v2) остаются единственным | [#251](https://github.com/Matysh/houseplan-card/issues/251) Доступность контроллера не наследуется от управляемой цели | [251-controller-target-availability.md](251-controller-target-availability.md) | | [#252](https://github.com/Matysh/houseplan-card/issues/252) Понятная и безопасная очистка забытых позиций в Optimize | [252-optimize-orphan-layout-report.md](252-optimize-orphan-layout-report.md) | | [#253](https://github.com/Matysh/houseplan-card/issues/253) Resize не теряет интервалы толщины стен | [253-resize-wall-thickness.md](253-resize-wall-thickness.md) | +| [#258](https://github.com/Matysh/houseplan-card/issues/258) Канонический wall key после Optimize и storage round-trip | [258-wall-key-storage-roundtrip.md](258-wall-key-storage-roundtrip.md) | ## P3