Files
houseplan-card/docs/reviews/SPEC-REVIEW-366-r1.md
T
2026-08-29 07:41:18 +00:00

230 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# SPEC-REVIEW-366-r1
Issue: #366 — «Glow #20: движущиеся ворота на cover-сущности вызывают до ~100
пересчётов геометрии света за одно открытие»
Этап: ТЗ на ревью (S4-spec-review), лёгкий трек (`small`)
Заход: r1 · блокирующих циклов ревью ТЗ израсходовано 0 из 2 (лимит §4 для лёгкого трека)
Материал: тело issue #366 на момент ревью, дерево `dev` на SHA `f2e365726a6e28a9acf03b6a37b4d2099770229d`
Вердикт: **жёлтый**
## Скоуп
Проблема реальна и локализована точно: `openingLightStateSignature`
(`src/logic.ts:344-357`) строит ключ кэша барьеров света через
`amount.toFixed(3)`, а `amount` для проёма типа door/gate с cover-сущностью,
публикующей `current_position` в процентах, меняется на каждый процент хода
(`openingAmount()`, `src/logic.ts` ~§320-330). Каждое новое значение —
новая сигнатура → новый ключ LRU-пула (`_lightBarrierPool`,
`src/houseplan-card.ts:10368-10373`) → полный пересчёт `physicalBodyParts`
(`:10404-10412`) и вырезов (`:10383-10402`). Направление фикса (квантование
amount к сетке 0.05 в единственной точке сборки) — то, что уже описано в
проблеме автором issue как предпочтительный вариант; предложенный контракт
ему соответствует.
Задача действительно укладывается в `docs/SCOPE.md`: это не новая функция, а
починка перформанс-дефекта уже принятой в J1/J7 (живой обзор дома) отрисовки
Glow (#20), заметного на wall-планшете — ключевой поверхности для персон
«household members» / «guests». Small-трек обоснован: одна поверхность (вход
`_lightBarriers`), геометрические алгоритмы и миграции не тронуты, сложность
≤3 по формулировке автора выглядит правдоподобно.
## Как проверялось
Ревью ТЗ, код ещё не написан — сборка/тесты/смоки не прогонялись, это
предмет код-ревью. Материал: тело issue #366, `docs/SCOPE.md`, `AGENTS.md`,
`PROCESS.md`, `docs/LIGHT.md`, текущее состояние `src/logic.ts` и
`src/houseplan-card.ts` на `dev` (сверка описанных в ТЗ точек кода с
реальным деревом), issue #20 (контекст, откуда взялась Glow-через-дверь и
где лежит существующий смок), `test/logic.test.mjs` (существующие юниты на
`openingLightStateSignature`), `demo/smoke_glow.mjs`,
`demo/smoke_junction_limits.mjs`.
Что именно сверено по коду:
- `src/houseplan-card.ts:10352-10361` — единственное место, где строится
`passageStates` и вызывается `this._openingAmt(o)`; результат этого поля
(`amount`) действительно единолично питает и `openingLightStateSignature`
(`:10362-10366`), и длину выреза через `openingLightApertureLength`
(`:10383-10388`), и масштаб `lightPartitionCuts`/`scalePartitionOpeningCut`
(`:10399-10402`). Формулировка К1 «единственная точка» подтверждена —
квантование там действительно закрывает все три потребителя разом, а не
только два названных в тексте ТЗ явно (сигнатура + длина выреза; про
partition-cut ТЗ не говорит отдельно, но он неизбежно накрывается тем же
местом).
- `src/houseplan-card.ts:12413-12420` (`_openingAmt`) — единственный
производитель значения; другие потребители того же метода (рендер
створки, `:8816`, `:12473`, `:12594`) действительно отдельные вызовы,
которые контракт К1 не трогает — «анимация остаётся плавной» подтверждается.
- `src/houseplan-card.ts:9347-9357` (`_physicalBodiesR`, обычные стены) и
`:7512-7522` (`_partitionOpeningCuts`, структурные вырезы) используют
`_cfgEpoch`/конфиг напрямую, а не `_openingAmt` — эта часть геометрии вне
скоупа задачи по факту кода, не только по декларации ТЗ.
- Формула квантования (`Math.round(clamp(x)/0.05)*0.05`) проверена в Node на
примерах из AC1: `0→0`, `1→1` точно; `0.30` и `0.31` дают один и тот же
float (`0.30000000000000004`); `0.33` даёт другой (`0.35000000000000003`);
`NaN/-1/2` зажимаются в `0`/`0`/`1`. Все утверждения AC1 воспроизводимы,
плавающая точка не портит проверяемые равенства.
- Полный диапазон 0..1 с шагом 0.05 даёт 21 узел — AC2 «≤21 сигнатура» и К2
«≤21 пересчёт» математически согласованы.
- `test/logic.test.mjs` содержит текущие юниты на `openingLightStateSignature`
с амплитудами `1`, `0.5`, `0`, `-2→0`, `2→1` — все совпадают с узлами сетки
0.05, так что при добавлении квантования выше по стеку (не внутри самой
функции) эти юниты не потребуют правки, как и утверждает AC3.
- `demo/smoke_glow.mjs:598-631` — существующий смок реально двигает
`cover.glow_dynamic_door` через `current_position` и снимает скриншоты на
позициях `0`, `50` (и `100` раньше по тексту, :360) — обе используемые
доли (`0`, `0.5`, `1.0`) точно совпадают с узлами сетки 0.05, так что этот
смок останется зелёным без правки ассертов — подтверждает AC4 фактически,
но не по названию (см. находку M1 ниже).
- `demo/smoke_junction_limits.mjs` — прочитан начало файла: это гвард лимитов
на запись стыков стен из #329 (проверка отказа сохранения, а не Glow и не
`_openingAmt`). Связи с изменённым кодом по факту не нашёл — см. M1.
- `docs/LIGHT.md`, раздел «Caching» (строки 139-147) документирует сам факт
сигнатуры по amount, но не даёт числового контракта точности — квантование
формально его не нарушает, но раздел станет неполным без упоминания шага
(см. M2).
## Находки
### M1 (Medium, в скоупе) — AC4 не называет проверяемое доказательство однозначно
`AC4` в ТЗ: «существующие смоки/golden #20 зелёные без правки ассертов (свип
прогоняется по делу: `smoke_door_glow*` — имя уточню по факту — плюс
`smoke_junction_limits` как полевой сосед)».
Файла `demo/smoke_door_glow*.mjs` не существует (`ls demo/smoke_*.mjs` —
проверено). Реальный тест, гоняющий именно cover-driven amount на дверь/ворота
через Glow — `demo/smoke_glow.mjs` (строки 598-631, двигает
`glow_dynamic_door` по `current_position`). Автор сам помечает имя как
неопределённое («уточню по факту») — это ровно то, что §2.5 DoR запрещает:
«у каждого [AC] указано, чем он доказывается» должно быть фактом на момент
ухода в `S5-ready`, а не обещанием уточнить позже. Второй названный смок,
`smoke_junction_limits`, судя по его коду (проверка отказа записи при
нарушении лимитов стыков стен, #329) не имеет видимой связи с
`_openingAmt`/Glow/`passageStates` — названо «полевым соседом» без указания,
какое именно совпадающее поведение он защищает.
**Почему в скоупе и чинится здесь:** правка ТЗ-текстовая, без нового решения
— заменить неопределённое имя на `demo/smoke_glow.mjs` (он уже доказывает
AC4 по факту, просто назван неверно) и либо обосновать релевантность
`smoke_junction_limits` конкретной связью, либо убрать его и заменить
результатом `node scripts/smoke-select.mjs --base origin/dev --head HEAD`,
когда код появится — эта команда есть в гейтах и предназначена ровно для
такого выбора.
### M2 (Medium, в скоупе) — визуальная деградация подана как факт, а не как решение с owner-видимостью
Контракт К2: «Визуальная цена: вырез света ступает по 5% длины проёма — на
реальных планах неразличимо». Это утверждение о том, что *увидит человек* —
ровно категория, которая по §7.1 адресуется владельцу («что человек видит»),
и ровно тот шаблон, который процесс называет «худшим видом дефекта»: догадка
о поведении, поданная как факт, без пометки «принято предположительно».
По существу: до этой правки амплитуда cover входит в сигнатуру с точностью
`toFixed(3)` (≈0.1%), т.е. вырез света визуально отслеживает движение почти
непрерывно (ценой ~1000 пересчётов на полный ход — это и есть баг). После
правки вырез будет физически перескакивать по 5%-ным узлам — не «то же самое
чуть грубее», а намеренный отказ от непрерывности в обмен на
производительность. Сам issue в разделе «Направление фикса» explicitly
называет квант и debounce равноценными альтернативами и не выбирает между
ними — то есть на момент постановки issue выбор ещё не был сделан
владельцем, а ТЗ на S3 сделало его молча.
Моя собственная оценка правдоподобия (не решение вместо владельца, а
основание для важности этой находки): проём двери/ворот на плане обычно
80–120 см, шаг 5% — это 4–6 см видимого скачка мягкого (blurred, см.
`docs/LIGHT.md:130-132`, кромка размыта на 1.5-3.5px) выреза на масштабе
комнаты — вероятно действительно малозаметно, особенно на фоне того, что
сейчас деградация проявляется как подёргивание всей карточки. Но это
«вероятно» — моя оценка, не зафиксированный факт, и распорядиться ею должен
или явный блок «принято предположительно, ревьюер/владелец может оспорить»,
или batched-вопрос владельцу с дефолтом «квант 0.05, деградация
незначительна» — вариант, который стоит владельцу секунд по формату §7.1.
**Почему Medium, не High:** обратимость полная (один revert, конфиг не
затронут), риск — эстетический на фиче, которую сам владелец в #20 назвал
`lowest priority` из-за её редкой применимости (contact-сенсор на *внутренней*
двери — редкость). Блокировать цикл ради этого нет оснований, но оставить
неотмеченной догадку как факт — тоже нельзя.
**Как чинится:** добавить в ТЗ явный блок вида «принято предположительно:
шаг 0.05 визуально неразличим на типовых проёмах 80–120 см; читатель волен
оспорить» — это удовлетворяет и `M2`, и общее требование «не бывает сложной
задачи без единого открытого вопроса» (сейчас в ТЗ такого блока нет вовсе).
Отдельный поход к владельцу не обязателен — цена ошибки низкая и обратимая,
достаточно того, чтобы предположение было промаркировано как предположение,
а не как решённый факт.
### L1 (Low, не блокирует) — строки в ТЗ отстали от `dev` на единицы
`houseplan-card.ts:10361` (в ТЗ) — на текущем `dev` это `:10360`
(`amount: this._openingAmt(o)`), аналогично `:10362`→фактически те же
строки описания сигнатуры сдвинуты на 1. Смысл описания точен (я нашёл
нужное место по цитате кода, не по номеру), правка не требуется для выхода
в разработку, но при финальном коммите номера стоит свериться заново — за
время ревью dev не двигался, но между S3 и S6 может.
### L2 (Low, не блокирует) — `docs/LIGHT.md` не назван в release-артефактах
Раздел «Caching» `docs/LIGHT.md:139-147` описывает сигнатуру по amount как
часть контракта подсистемы, но не удельную точность. Квантование меняет этот
контракт (сигнатура теперь дискретна по построению, а не «просто округлена
для ключа») и вводит намеренную визуальную ступенчатость — то, что канонический
документ подсистемы должен фиксировать по `AGENTS.md` («канонический документ
затронутой подсистемы»). ТЗ называет из release-артефактов только
changelog en+ru. Снимается легко: одна строка в LIGHT.md, тем же коммитом, не
блокирует переход в `S5-ready`; фиксирую как Low с правом автора либо
поправить, либо снять с запиской, если сочтёт достаточным changelog.
## Что проверено и корректно
- Технический выбор единственной точки квантования (`passageStates`)
реально исключает риск рассинхронизации «сигнатура vs геометрия» (принцип
«одно число — один источник», §8 PROCESS.md) — geometry (`rlen`,
partition-cut) и cache-key берут один и тот же квантованный `amount` из
одного и того же объекта, поэтому исключён класс дефектов вида #234/#233.
- AC1 и AC2 математически корректны и воспроизводимы (проверено расчётом в
Node, см. выше) — квантование к сетке 0.05 действительно даёт ≤21 узел,
0 и 1 точные, тестовые примеры (`0.30`/`0.31` совпадают, `0.30`/`0.33`
различны) подтверждаются без артефактов плавающей точки.
- AC3 (binary-двери байт-в-байт) обеспечен архитектурно: квантование
применяется выше `openingLightStateSignature`, а не внутри неё, и
существующие юниты, гоняющие функцию напрямую с сырыми amount, её не
затрагивают.
- AC5 (мутационный тест «квант → identity» красит AC2) выполним: замена
`quantizeOpeningLightAmount` на identity вернёт ~1000 различных сигнатур на
свипе 0.001 вместо ≤21, ассерт «≤21» упадёт детерминированно — тест умеет
падать по построению AC, не только по обещанию.
- Скоуп геометрии/миграций/touch действительно не затронут: структурные стены
(`_physicalBodiesR`) и partition-cut вне light-пути (`_partitionOpeningCuts`)
используют конфиг/epoch напрямую, не `_openingAmt` — small-трек обоснован
фактически, не только декларативно.
- Откат описан верно и достаточен: один revert, кэш самоинвалидируется
сигнатурой, конфиг не участвует — миграции назад не требуется.
- `User-Visible: yes` с указанием обоих changelog учтено верно, учитывая
находку M2 про формулировку.
## Чего не проверял
- Код не написан — `typecheck`/`test`/`build`, смоки, golden, инварианты
модели не прогонялись: на этапе ревью ТЗ это не предмет проверки, будет
предметом код-ревью (S7). Явно не мой гейт на этом этапе.
- Не проверял реальным глазом (браузер/скриншот) визуальную
неразличимость 5%-ступени — только оценка масштаба (см. M2); это довод в
пользу пометить решение как предположение, а не довод «всё в порядке».
- Не искал исчерпывающе все смоки/golden, задевающие `current_position` —
проверил только те, что явно упоминают `current_position`
(`smoke_glow`, `smoke_cover_no_plate`, `smoke_cover_tap`); последние два
проверяю по коду не относящимися к light-пути (тестируют плашку/тап
cover, не Glow), поэтому не разбирал их подробно.
- `npm run inventory`/точные счётчики тестов не считал — не нужно на этом
этапе.
## Итог
0 High, 2 Medium в скоупе (M1, M2), 2 Low (L1, L2, на усмотрение автора).
Технически контракт К1/К2 и AC1-AC3, AC5 — корректны и доказуемы; ТЗ
возвращается автору не из-за ошибки в решении, а из-за (а) незакрытого
доказательства AC4 и (б) непомеченной догадки о видимом поведении, которую
процесс требует помечать явно, даже когда цена ошибки невелика.