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