Files
houseplan-card/docs/reviews/CODE-REVIEW-451-r1.md
T
2026-09-04 19:47:08 +00:00

302 lines
31 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.
# CODE-REVIEW-451-r1
- **Issue:** #451 «План подтормаживает: диагностика на каждый кадр, реактивная камера, отсутствие фильтра HA-тиков»
- **Этап:** код-ревью, заход r1 (первый действительно проведённый — предыдущая попытка не читала код: ветка не ребейзилась на `dev` без конфликта, цикл не израсходован)
- **Материал:** `git log --oneline origin/dev..HEAD` / `git diff origin/dev...HEAD`, SHA `1850eb18b5e8f0813f9d2efb50694dc4ca1fff26`
- **ТЗ:** `docs/specs/451-render-performance.md` (полный трек, `SPEC-REVIEW-451-r2` зелёный)
- **Вердикт:** см. итог в конце документа
## Скоуп диффа
75 файлов, +3947/‑1166 (без учёта `dist/**`/`custom_components/.../frontend/**`, класс D). Новые модули:
`src/render-invalidation.ts`, `src/houseplan-render-lifecycle.ts`, `src/live-viewport.ts`,
`src/live-hover.ts`, `src/live-editor.ts`, `src/live-interaction-runtime.ts`,
`src/pointer-move-queue.ts`, `src/interaction-types.ts`. Изменены `src/houseplan-card.ts`
(overridden `requestUpdate`, diagnostics, terminal reconciliation), `src/houseplan-editor-runtime.ts`
(pointer-move очереди для plan/decor/backdrop/opening/resize), `src/render-device-snapshot.ts`
(`entityIds`), `src/zigbee-topology-overlay-bridge.ts` (live-layer маркер). Плюс performance harness
(`demo/benchmark_large_house.mjs`, `budgets-large-house-interaction.json`, `card-contract.mjs`,
`evaluate.mjs`, `performance.yml`) и точечные правки существующих `demo/smoke_*.mjs` под новую разметку
`data-hp-live-*`. Геометрия/модель стен, `layout`, `marker.space`, `open_spans` не тронуты — гейт
`npm run invariants` не требуется, что подтверждается и явным «Non-scope» ТЗ.
## Как проверялось
| Гейт | Команда | Результат |
|---|---|---|
| typecheck | `npx tsc --noEmit` | зелёный |
| unit | `npm test` | 1956 tests, 1955 pass, 1 skip, 0 fail |
| build + бандл | `npm run build && cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js` + `diff -rq` на `houseplan-assets` | совпадают байт-в-байт |
| docs fingerprint | `node scripts/check-docs.mjs` (обязателен — diff трогает `src/**`) | «Documentation checks passed (7 files, 12 external links)» |
| any-гейт | не запускал отдельно — новые файлы не содержат `any` (проверено чтением), `npm test`/`typecheck` зелёные | проверено чтением |
| smoke-select | `node scripts/smoke-select.mjs --base origin/dev --head HEAD` | 98 прямых совпадений + 36 слабых из 221 (см. `smoke-select.txt`) |
| smoke (почти полный набор) | все 134 файла из прямых+слабых совпадений, `node demo/smoke_<name>.mjs` по одному | 133 OK, 1 нестабильный (см. ниже, не регрессия) |
| golden | `npm run golden:verify` | 153/153 сценариев `passed`, 0 diff — доказывает AC9 (pixel-equivalence) |
| performance (структурные assert'ы) | `npm run benchmark:large-house-interaction -- --samples=7 --warmups=1` | завершился без throw — структурный контракт (0 full render на hover/pan/editor move, 1 terminal, 0 diagnostics scans) подтверждён на этом SHA |
| performance (абсолютные потолки) | `npm run benchmark:compare -- --absolute-only --budgets=demo/performance/budgets-large-house-interaction.json --candidate=<report>` | **6 проверок красные** — см. находку H1 |
| backend | не запускал — диф не трогает `custom_components/**/*.py` (проверено чтением: `git diff --name-only` не содержит `.py`) | проверено чтением |
| invariants | не запускал — диф не трогает геометрию/`layout`/`marker.space`/`open_spans` (проверено чтением) | проверено чтением |
Не прогонял полный `demo/smoke_*.mjs` (все ~221) и полный `performance.yml` (`Full Performance`,
парный base/candidate на двух чекаутах) — первое избыточно (134 из 221 уже покрывают все «прямые» и
«слабые» совпадения smoke-select, второе требует парного окружения, которое не гейт код-ревью
(PROCESS.md §8: «полные наборы — предрелizный гейт»). Раздельный прогон `--absolute-only` (см. выше)
компенсирует это для абсолютных потолков нового профиля — и именно он нашёл H1.
## Находки
### H1 (High, блокирует) — новый performance-профиль `large-house-interaction-v1` не проходит собственный бюджет
**AC10** требует: «новый interaction profile проходит все structural, absolute и base-relative checks
§11». Структурные проверки (0 full render на hover/pan/camera/editor move, ровно 1 terminal, 0
diagnostics scans, стабильный heavy DOM, ошибка совмещения SVG/HTML ≤1px) — подтверждены, профиль не
бросает исключение. Но абсолютные потолки, которые сам же диф вводит в
`demo/performance/budgets-large-house-interaction.json`, не выполняются при фактическом прогоне.
**Воспроизведение** (детерминировано, не связано со скоростью машины — см. ниже):
```
npm run benchmark:large-house-interaction -- --samples=7 --warmups=1 --output=/tmp/interaction-review7.json
npm run benchmark:compare -- --absolute-only \
--budgets=demo/performance/budgets-large-house-interaction.json \
--candidate=/tmp/interaction-review7.json --output=/tmp/interaction-compare7.json
```
Результат — 6 красных строк:
```
❌ timing.interactionSeriesMs.median | 3501.5 | 3000
❌ timing.editorSeriesMs.median | 1841.9 | 750
❌ longTask.editorSeries.maxSingleMs | 878 | 150
❌ longTask.editorSeries.countP95 | 4 | 3
❌ longTask.editorSeries.totalP95Ms | 1633 | 300
❌ cache.entries.cleanFloor | 140 | 100
```
`cache.entries.cleanFloor` — счётчик записей `Map`, не таймінг, машинно-независим. Проверено на **двух**
независимых прогонах (`--samples=3` и `--samples=7`), значение **140 во всех 10 сэмплах без единого
отклонения**. Для контроля прогнан немодифицированный `large-house-v1` (существует и на `dev`, диф его
не трогает):
```
npm run benchmark:large-house -- --samples=3 --warmups=1 --output=/tmp/base-profile-review.json
# cleanFloor: 100, 100, 100 — ровно документированный потолок (demo/performance/README.md:
# «the reviewed fixture warms exactly 100 deterministic room/physical-body entries»)
```
Значит переполнение специфично именно для новой `editor series` части `large-house-interaction-v1`
(последовательность `_setMode('plan')` → `_tool='resize'` → cancel → `_setMode('decor')` →
`_setMode('view')`), которую этот же диф добавил в `demo/benchmark_large_house.mjs`. Разница ровно
+40 записей (два «лишних» полных прохода флор-кэша сверх документированного одного) коррелирует с тем,
что тайминговые метрики именно `editorSeriesMs`/`longTask.editorSeries.*` превышены в 2,4× и более —
похоже на одну и ту же причину (лишний(е) пересчёт(ы) `_cleanFloor`/`floorMinusBodies` при переключении
режима редактора), а не на независимый шум.
Попытка воспроизвести на упрощённой ручной фикстуре (`page.evaluate` с прямым вызовом `_setMode` без
полного цикла реального pointer-move, который использует бенчмарк) рост `_cleanFloorCache.size` не
показала — то есть причина не в самом факте смены режима, а где-то в фактической
resize/decor-транзакции бенчмарка (drag-preview → cancel) или в стечении с реальными pointer-событиями.
Дальнейшая локализация — задача автора; здесь важен сам факт: **AC10 не выполнен по написанному самим
автором для этой задачи бюджету**, а не только гипотеза о причине.
Тайминговые превышения (`interactionSeriesMs`, `editorSeriesMs`, `longTask.editorSeries.*`) в отличие
от `cleanFloor` теоретически могут быть частично усилены более медленной/разделяемой машиной ревьюера
против выделенной машины владельца (`demo/performance/README.md`: «Local Windows checkout is the
day-to-day environment... local report is diagnostic only»), но margin (1842 мс против потолка 750 мс,
878 мс одна long task против потолка 150 мс) для чистого шума великоват, и коррелирует с детерминированным
`cleanFloor`-превышением. Автору стоит перепроверить оба числа на каноническом Linux CI парном прогоне
(`performance.yml`), но независимо от результата **cache-count уже доказан красным и машинно не
объясним** — это не «непрогнанный дорогой гейт», это прогнанный и красный.
**Почему High, а не Medium.** Задача, ценность 10/10 и P1, явно посвящена именно тому, чтобы новый
перф-контракт «краснел сам по себе, а не только на фоне предыдущего коммита» (issue, раздел «Почему это
не поймал перф-гейт»). Проверка, которую AC10 называет своим доказательством, при фактическом запуске
красная. Это не вопрос «недостаточно полно проверили», а обнаруженный красный автотест по собственному,
введённому этим же диффом контракту — ровно то, что код-ревью обязано поймать (PROCESS.md §2.7:
«ревьюер отвечает за AC… либо доказан автотестом… либо разобран по коду»). Фикс — в скоупе (тот же
`demo/benchmark_large_house.mjs`/бюджет), отдельный issue не заводится.
### M1 (Medium, в скоупе) — обещанный в самом ТЗ «targeted production-bundle smoke» не создан
ТЗ §13.2 и §14 (план реализации, п. 5) явно называют отдельный артефакт: «Targeted `demo/smoke_*.mjs` —
production-bundle render-count и interaction matrix», инструментирующий full-render count, lightweight
paints, diagnostics scans, heavy node identity, writes на PRODUCTION-бандле (не на perf-фикстуре) — и
именно это заявлено как доказательство AC5/AC6/AC7/AC8 (`smoke` в их графе доказательства).
`git diff origin/dev...HEAD --diff-filter=A` показывает: новых файлов `demo/smoke_*.mjs` нет вовсе.
Изменения в существующих смоках (`smoke_decor.mjs`, `smoke_furniture.mjs`, `smoke_decor_text.mjs`,
`smoke_resize_pointer_real_plan.mjs`, `smoke_pan_any_zoom.mjs`, `smoke_room_fit.mjs`,
`smoke_infinite_canvas.mjs`, `smoke_opening_measure.mjs`, `smoke_partition_openings.mjs`,
`smoke_drag_bounds.mjs`, `smoke_room_tooltip_toggle.mjs`, `smoke_ux_fixes.mjs`) — точечная подгонка под
новую разметку `data-hp-live-*`/тайминг RAF, ни один не считает full-render/diagnostics-scan счётчики.
Единственное место, где эти структурные гарантии вообще проверяются — `demo/benchmark_large_house.mjs
--profile=large-house-interaction-v1`, которое подключено только к job'е `performance.yml` → «Full
Performance», запускаемой (см. `demo/performance/README.md`, «CI contracts») **на промоушен в `main`,
раз в неделю и по ручному диспатчу** — не на каждый push в `dev`. Блокирующий `performance_smoke` в
`validate.yml` меряет только `large-house-glow-overlay-v1`, этого профиля не касается. При принятой в
проекте модели прямых коммитов в `dev` без PR (AGENTS.md) это означает: следующий обычный коммит,
который случайно вернёт diagnostics-scan на каждый рендер или полный render на hover, **не будет
пойман никаким гейтом до следующего еженедельного/promotion-прогона** — то есть тем самым классом
слепого пятна, ради которого заведён #451 (issue, раздел «Почему это не поймал перф-гейт»: «гейт
исправно подтверждал, что "не стало хуже", пока пользователь смотрел на 451-миллисекундные фризы»).
Фикс в скоупе: добавить обещанный `demo/smoke_*.mjs` (инструментирование `_renderBody`/
`_bindingStatus` аналогично тому, что уже сделано внутри `benchmark_large_house.mjs`, но на
production-бандле через `demo/serve.mjs`, в постоянно запускаемом наборе).
### M2 (Medium, в скоупе) — нет мутанта в `scripts/mutation-gate.mjs` для новых защитных механизмов
PROCESS.md §2.7: «Мутант в `scripts/mutation-gate.mjs` обязателен, когда защита живёт в продуктовом
коде и проверяется дорогим гейтом (смок, бэкенд, golden): там ревьюер не воспроизведёт отрицательный
прогон второй раз». `git diff origin/dev...HEAD -- scripts/mutation-gate.mjs` — пусто, ни одной новой
записи.
Часть новых защит — чистые юниты, и для них я сам прогнал мутацию и увидел красный тест (см. «Таблица
доказательств» ниже: `classifyHassRenderChange`, `RenderLifecycle.diagnostics`) — этого достаточно,
отдельный мутант не нужен. Но интеграционная проводка в `houseplan-card.ts` (переопределённый
`requestUpdate`, `_flushHa`/terminal reconciliation, RAF-coalesced camera/hover/editor paints) —
юнитами не покрыта и проверяется только дорогим Playwright-бенчмарком (тем же, что в M1/H1). Без
постоянного мутанта в следующем раунде (после того, как H1 будет исправлен и профиль перестанет
краснеть) ревьюер снова не сможет дёшево доказать «тест умеет падать» — придётся заново гонять
браузерный бенчмарк вручную, как это делал я.
### Low — известная нестабильность `smoke_smooth_zoom.mjs` под нагрузкой (не регрессия, к сведению)
При параллельном прогоне 134 смоков (4 конкурентных пакета) `demo/smoke_smooth_zoom.mjs` дал один
красный результат (`wheelRetargetsRunningTween`/`wheelHasIntermediateFrames`/`wheelStreamPersistsOnce`
= false). Проверил отдельно: последовательно (без конкуренции за CPU) — 11/11 прогонов зелёные, как на
этой ветке, так и на чистом `origin/dev`. Затем воспроизвёл красный результат **на обеих** ветках
одинаково при явной конкуренции (4 параллельных `smoke_smooth_zoom.mjs` + 3 тяжёлых смока рядом: 2-3 из
4 падали и на `dev`, и на PR). Вывод: это существующая до #451 чувствительность теста к CPU-контеншну
(reliance на реальные `requestAnimationFrame` тайминги), не регрессия этой задачи. Не блокирует, в
скоуп #451 не входит, отдельный issue не заводится (не находка, а объяснённая флакиность).
## Что проверено и корректно
- **AC1** (HA intake ≠ visual invalidation): `requestUpdate('hass', …)` в `houseplan-card.ts`
делегирует решение `LiveRuntime.hass()` → `classifyHassRenderChange()`; при `'none'`/deferred-`'state'`
intake (`intakeHass`) всё равно выполняется синхронно через `RenderLifecycle.observe`. Доказано
юнитом `test/houseplan-render-lifecycle.test.mjs` («HA intake runs once even when an unrelated visual
update is skipped») + мутацией (см. ниже).
- **AC2** (dependency classifier): `src/render-invalidation.ts`. Структурные top-level поля (`entities`,
`devices`, `themes`, `locale.*`, неизвестный новый ключ) — fail-open на `'structural'`; только `states`
сравнивается по dependency-scoped `entityIds`. Доказано `test/render-invalidation.test.mjs` (4/4) +
мутацией: убрал ранний `return 'none'`→заменил на безусловный `return 'state'` в скомпилированном
`test-build/render-invalidation.js` (не в `src/**`, генерируемый файл) — тест
«an unrelated HA state row does not invalidate the plan frame» покраснел (`expected 'none', actual
'state'`), затем восстановил файл, тест снова зелёный.
- **AC3** (intake переживает пропущенный рендер): dependency projection в `_captureRenderDeviceSnapshot`
(houseplan-card.ts:4563+) собирает entity/device/area из bindings, room temp/hum source, opening
entity refs, live-text, `sun.sun`, vacuum source — доказано чтением и подтверждено проходом смоков
`smoke_linked_virtual_light`, `smoke_static_icon`, `smoke_cover_no_plate`,
`smoke_cover_plate_precedence`, `smoke_entity_parent_dedup`, `smoke_yellow_principle` (все зелёные).
- **AC4** (diagnostics cache): `RenderLifecycle.diagnostics()`/`invalidate()` в
`houseplan-render-lifecycle.ts`. Единственный вызов `houseplanDiagnostics()`-подобной логики теперь и
в `_renderBody` (через `this._renderLife.diagnostics(...)`), и в публичном `houseplanDiagnostics()` —
один источник (см. «Одно число — один источник» ниже). Доказано юнитом («diagnostics scan is cached
and invalidated by tracked state presence») + мутацией: заменил guard `if (this.diagnosticsCache)` на
`if (false && this.diagnosticsCache)` в `test-build/houseplan-render-lifecycle.js` — тест покраснел
(`2 !== 1`, ожидался один скан вместо двух), восстановил, снова зелёный.
- **AC5/AC6/AC7** (0 full render на hover/pan/pinch/camera/editor move, 1 terminal, deferred-relevant-tick
last-wins): структурные assert'ы внутри `demo/benchmark_large_house.mjs` (throw при нарушении)
подтверждены самостоятельным прогоном на этом SHA — все условия выполнены (`irrelevantFullRenders:0`,
`hoverFullRenders:0`, `panMoveFullRenders:[0,0,0,0]`/`terminalFullRenders:[1,1,1,1]`,
`cameraMoveFullRenders:0`/`cameraTerminalFullRenders:1`, `editorMoveFullRenders:[0,0,0]`,
`relevantDuringGestureFullRenders:0` при `relevantDuringGestureIntakes:3`, `diagnosticsScans:0`,
`heavyNodeStable:true`, `overlayErrorPx:0.01`). Тайминги того же прогона см. H1.
- **AC8** (coalescing сохраняет конечную координату/commit/cancel): `pointer-move-queue.ts`
(`queueMicrotask`, гарантированно опустошается до следующего браузерного события) + подключение во
всех точках (`_pointerMove`/device, `_physicalMove`, `_rszMove`, `_bdMove`, `_opPointerMove`, `_dtMove`,
`_decorMoveUpdate`, `_markupMove`) с явным `flush` на terminal (`up`) и `cancel` на отмене. Доказано
юнитом `test/live-editor.test.mjs` («pointer move queue is event-turn coalesced, last-wins and
flushable») + прогоном затронутых production-bundle смоков (`smoke_furniture`, `smoke_decor`,
`smoke_decor_text`, `smoke_drag_bounds`, `smoke_partition_openings`, `smoke_opening_measure`,
`smoke_resize_pointer_real_plan` — все зелёные, включая ровно те сценарии shift-snap/magnet/rotate,
которые чувствительны к потере промежуточного состояния).
- **AC9** (pixel-equivalence): `npm run golden:verify` — 153/153 `passed`, 0 diff, включая
`large-house-zoom-040/250-dark`, `large-house-warm-remount-dark`, junction-серию, hover-серию,
dark/light/RU/EN варианты. Прямое доказательство отсутствия непреднамеренных визуальных изменений.
- **AC11** (implementation-loop gates): typecheck/test/build зелёные (таблица выше).
- **AC12** (schema/backend/i18n/dependencies не изменены): `git diff --name-only` не содержит
`.py`/`i18n`/`translations`/`manifest.json`/`hacs.json` — проверено чтением. Новые кэши
(`RenderLifecycle.diagnosticsCache`, `LiveRuntime`/`live-viewport`/`live-hover`/`live-editor` state) —
все `WeakMap<object,…>`, ключ — сам инстанс карточки; `disconnectedCallback` вызывает
`_liveRt?.dispose()` и `_editorRuntime?._disposeLiveEditor()` (cancel RAF, restore hidden/transparent
DOM, `cancelHouseplanPointerMove`) — bounded per card, очищаются при disconnect, независимы между
несколькими карточками на дашборде (WeakMap не даёт общий каталог).
- **AC13** (документация): `demo/performance/README.md` описывает новый профиль, сценарии, структурные
assertions, абсолютные потолки, local diagnostics-команду; `docs/CHANGELOG.md`/`docs/CHANGELOG.ru.md`
обновлены в том же коммите `c0d61ca3` (`User-Visible: yes`), формулировка «внешний вид и результат
действий не меняются» соответствует AC9/non-scope.
- **Одно число — один источник**: `houseplanDiagnostics()` (публичный support-report) и внутренний
render-path теперь читают один и тот же `RenderLifecycle.diagnostics()` — раньше было два независимых
прохода по маркерам с одинаковой логикой (потенциальное расхождение при будущей правке одного из них),
теперь физически один вызов на одну инвалидацию (доказано юнитом «scans === 1» выше). Другой видимый
дважды параметр — zoom badge (`Math.round(zoom*100)%`) и `_zoom`/`_view`: и DOM-бейдж
(`live-viewport.ts:paintLiveViewport`), и canonical-поле читают один и тот же `frameOf(host)` —
`host._zoom`, без второго независимого округления.
- **Трейлеры**: все 10 коммитов несут `Issue: #451`; ровно один `User-Visible: yes`
(`c0d61ca3`, «fix: keep large-plan interactions off the full render path») с обоими changelog в том
же коммите; остальные `User-Visible: no` (документация/фикс-ап/ребилд бандла) — корректно.
- **Touch/pen/kiosk/fixed-floor контракт** (§9 ТЗ): не даёт отдельной ветки поведения — подтверждено
прохождением `smoke_isometric_live_touch`, `smoke_kiosk`, `smoke_kiosk_pan_lock`, `smoke_fixed_floor`,
`smoke_touch_tips`, `smoke_grid_scale_invariance` (все зелёные) плюс golden dark/light/RU/EN сценарии.
## Чего не проверял
- Полный `demo/smoke_*.mjs` (все ~221) — прогнал 134 (все «прямые» и «слабые» совпадения
smoke-select.mjs), сознательно не прогонял оставшиеся ~87 без связи с диффом (полный набор —
предрелизный гейт, PROCESS.md §8).
- Парный `performance.yml` («Full Performance», base vs candidate на двух чекаутах, реальный Chromium
CI-раннер владельца) — недоступен в этом окружении; вместо него прогнал `--absolute-only` (см. H1,
нашёл реальную красноту) и структурные assert'ы бенчмарка напрямую. Относительное (base-vs-candidate)
сравнение не проверено — вероятно, тоже покраснеет из-за той же причины, что и абсолютное, но это не
проверено напрямую.
- Backend (`python -m pytest tests_backend`) — не запускал, диф не касается `custom_components/**/*.py`
(проверено чтением diff, не исполнением).
- `npm run invariants` — не запускал, диф не касается геометрии/`layout`/`marker.space`/`open_spans`
(проверено чтением diff и Non-scope раздела ТЗ).
- Точная локализация причины лишних 40 записей `cleanFloor` (H1) — воспроизвёл и доказал факт, не нашёл
точную строку кода; это оставлено автору при исправлении H1.
- Ручная проверка в реальном браузере (вне headless Playwright) и на настоящей Home Assistant — вне
доступного окружения; полагался на golden/smoke/unit/performance гейты.
## Материал раунда
- Ветка: `issue/451-render-performance`
- SHA материала: `1850eb18b5e8f0813f9d2efb50694dc4ca1fff26`
- Диапазон: `origin/dev...HEAD` (10 коммитов, `029aba6b`..`1850eb18`)
- ТЗ: `docs/specs/451-render-performance.md`, ревью — `docs/reviews/SPEC-REVIEW-451-r2.md` (зелёное)
## Итог
**Вердикт: красный.** Одна High-находка (H1 — заявленный самим диффом перф-контракт `AC10` красный при
фактическом прогоне `compare.mjs --absolute-only` по собственному новому бюджету, доказано
детерминированным счётчиком `cache.entries.cleanFloor: 140 vs 100` плюс коррелирующими превышениями
таймингов `editorSeriesMs`/`longTask.editorSeries.*`) блокирует переход. Плюс две Medium-находки в
скоупе (M1 — не создан обещанный ТЗ `demo/smoke_*.mjs` для render-count на production-бандле, M2 — нет
мутанта в `scripts/mutation-gate.mjs` для интеграционной проводки, проверяемой только дорогим
бенчмарком). Сама архитектура решения (разделение intake/visual invalidation, dependency classifier,
diagnostics cache, RAF-coalesced live-слои камеры/hover/редакторов, pointer-move очереди) сделана
аккуратно и проверяемо: юнит-защиты действительно ловят мутацию, structural assertions бенчмарка
действительно бросают исключение при нарушении контракта, golden/smoke/typecheck/build/docs — все
зелёные. Возврат автору — на исправление конкретно H1 (и желательно M1/M2 в том же цикле, раз без
High они и так были бы Medium-в-скоупе), без расширения скоупа задачи.
---
<!-- material-anchors: сгенерировано конвейером (#414) -->
## Материал раунда
- Ветка: `issue/451-render-performance`, коммит `1850eb18b5e8` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
- Дерево материала: `30dcff478a0287b209be4c3066972158d6e6b539`
```
git log --all --format='%H %T' | grep 30dcff478a02
```
- ТЗ `docs/specs/451-render-performance.md`, блоб `7c323a29110974aae369077214b9e2a74d9387c1`
```
git log --all --find-object=7c323a29110974aae369077214b9e2a74d9387c1 -- docs/specs/451-render-performance.md
```