# 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 и (б) непомеченной догадки о видимом поведении, которую процесс требует помечать явно, даже когда цена ошибки невелика.