Files
houseplan-card/docs/specs/330-junction-limits-performance.md
T
Codex ddfca3a865 fix: close code-review 330-r1 — budgets from the slowest machine, the bench in Validate, AC1 through the execution thread (#330)
H2: the benchmark budgets were calibrated on the author's sandbox with a
1.14x margin — the review runner measured tsFullCandidateMs at 169-171 ms
against a 100 ms ceiling. Budgets now keep the spec's 2-3x allowance over
the SLOWEST observed machine, and the benchmark runs as a step of the
Validate perf job on every push (it needs no browser and no bundle), not
only inside the weekly mutation gate.

M1: the promised AC1 backend test exists now and does what AC1 means: it
patches validate_junction_limits with a thread-recording wrapper inside the
real HA harness — on the event loop that would be MainThread — and proves
the verdicts survived the move (a clean write is accepted, a write adding a
spike is refused with junction_limit_angle). Spec revision 4 rewrites AC1
around this invariant instead of a fragile millisecond assertion.

M2: §4.6 equivalence is now behavioural on both sides (three boundary
fixtures each: as-is counts equal through-migration counts, TS and python),
and the parity suite gained the §7 boundary fixtures (exact 15°, exact
20 cm, the thickness-step filler run, exact 5 cm).

H1 was already closed by 7513f93d (the review ran on the previous HEAD):
check-docs is green on this tree — the screenshots and their manifest come
from one capture run.

Issue: #330
User-Visible: no
2026-08-28 03:07:44 +03:00

239 lines
20 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.
# Issue #330 — производительность ограничений стыков (#329)
Статус: ревизия 4 (код-ревью r1: H1 — скриншоты согласованы; H2 — бюджеты §5 пересчитаны от худшей наблюдавшейся машины и бенч включён в перф-джобу Validate; M1 — AC1-тест через поток исполнения в HA-харнесе; M2 — эквивалентность §4.6 и паритет расширены границами. r1: H1 — бюджеты пересчитаны от профиля, добавлены срезы
§4.5 bucket-П4 и §4.6 «документ текущей версии без повторной миграции»;
H2 — AC4 опирается на новый бенч; M1 — §9. Ревизия 3, собственная находка
при написании бенча: §4.7 — П5 пересчитывал полную топологию и union кладки
НА КАЖДУЮ КОМНАТУ, 4.2 с на кандидата; в скоуп добавлен разделяемый проход). Родитель: #329 (лимиты выпущены, вердикты корректны —
подтверждено паритет-тестом и код-ревью r1–r3). Эта задача меняет ТОЛЬКО
стоимость вычисления вердиктов; ни один вердикт не меняется ни на бит.
Смежный #331 (пограничная точность) сознательно вне скоупа.
## 1. Сценарий и пользовательский результат
Замеры на `dev` `2c20f2dc`, синтетическая сетка комнат 300×300 см, стены
15 см, легаси-хранение (худший и одновременно типичный случай: первый
структурный write после обновления карточки):
| План | Бэкенд `validate_junction_limits` | из них 1 миграция | П3 | П4 |
|---|---|---|---|---|
| 6×6, 144 атома | 189 мс | 53 мс | 16 мс | 23 мс |
| 12×12, 576 атомов | **2 835 мс** | 754 мс | 285 мс | 376 мс |
Вся цепочка валидаторов `ws_config_set` выполняется синхронно в event loop
HA под write_lock — на 576 атомах каждое сохранение замораживает ВЕСЬ Home
Assistant почти на 3 секунды. `MAX_CONFIG_BYTES` = 2 МБ допускает документы
ещё на порядок больше. Гайдлайн HA: >50 мс в event loop недопустимо.
Фронтенд, те же 576 атомов: `checkSegmentLengths` — 289 мс/вызов,
`checkNodeDistances` — 105 мс; в ресайзе проверка кандидата зовётся на
каждый pointermove, а baseline (клон конфига + миграция + П1–П5 + polyclip
по комнатам) пересчитывается там же, хотя в течение жеста неизменен.
Результат для пользователя: сохранение большого плана не подвешивает HA;
ресайз на большом плане держит существующий кадровый бюджет.
## 2. Корни (по убыванию вклада; профиль cProfile/бенч, 576 атомов)
1. **Миграция обоих документов на каждую запись.** По профилю деньги НЕ в
deepcopy (3 мс): 1.7 с одной миграции — это `_migrate_space → _atomize`
(663 тыс. вызовов `_distance_to_segment`, квадратичная атомизация), и она
выполняется **даже для документа, уже несущего актуальный каталог**:
migrate(v9) = 815 мс python / 69 мс TS. `previous` к тому же неизменен
между записями — его миграция и подсчёт выбрасываются и повторяются.
2. **П3 квадратичен в обеих реализациях.** `collinearRunLengthUnits` /
`collinear_run_length_units` строит индекс узлов заново для КАЖДОГО
сегмента → O(n²): 285 мс py / 290 мс TS.
3. **П4 квадратичен архитектурно** (все пары узлов + узел×сегмент):
372 мс py / 104 мс TS. Линеаризация П3 его не касается (r1-H1).
4. **Фронтовый baseline без кэша.** `_junctionLimitsIntroduced` строит
baseline-нарушения из previousConfig на каждый вызов; в жесте ресайза —
на каждый move.
## 3. Границы
- Вердикты П1–П5 байт-идентичны текущим на любом входе. Существующие юниты
границ, паритет-тест TS↔Python и все три мутанта #329 проходят без правок
их семантики (мутантам разрешено обновить якоря, если строки сместились).
- Спека #329 (наследование по правилу, обе стороны после одной миграции,
скоуп применения §3) не меняется.
- Вне скоупа: итеративный walk, точность ключей узлов, 0°-дубль (#331);
optimize/import-лазейка (#333); ускорение самой атомизации
(`_atomize`/`atomize` квадратичны — общесистемная правка ядра модели с
требованием байт-паритета зеркал; отдельная задача, если §4.6 окажется
недостаточным).
## 4. Решение
### 4.1 Бэкенд: цепочка валидаторов в executor
В `ws_config_set` вынести CPU-часть цепочки (validate_wall_model_transition,
CONFIG_SCHEMA, marker/passage/host-валидаторы, validate_junction_limits) в
`async_add_executor_job` одной функцией над данными: вход — `msg["config"]`
и снапшот `data.get("config")`, выход — validated config либо типизированная
ошибка. Применение результата (`msg["config"].clear()/update`) и отправка
ответа остаются в loop. write_lock сохраняется — исключается только
блокировка loop, не сериализация записей. Паттерн в кодовой базе есть:
parse/merge импорта уже выполняются в executor.
`ws_plan_optimize` — тем же способом (он уже страдает той же синхронной
CONFIG_SCHEMA + миграцией на больших планах).
### 4.2 Бэкенд: кэш baseline по config_rev
На runtime-объекте — один слот `_junction_baseline: (config_rev, counts)`,
где counts — счётчики нарушений previous ПО ПРАВИЛАМ (то, что и потребляет
сравнение; сами документы не хранятся). Заполняется лениво при первом
validate, инвалидируется несовпадением rev. Повторные записи одного клиента
перестают мигрировать previous вовсе. `validate_junction_limits` получает
опциональный параметр `baseline_counts`; его отсутствие — прежний путь
(миграция previous), чтобы прямые вызовы и тесты не изменились.
### 4.3 Обе реализации: линейный П3
`checkSegmentLengths` / `check_segment_lengths` строит byNode-индекс один
раз и передаёт его в прогон коллинеарной цепочки; публичная сигнатура
`collinearRunLengthUnits(segment, segments)` сохраняется (индекс — третий
опциональный аргумент; без него строится как раньше — для прямых вызовов
из тестов).
### 4.4 Фронтенд: кэш baseline на конфиг-эпоху
`_junctionLimitsIntroduced` кэширует baseline-нарушения по ключу
`${spaceId}|${cfgEpoch}` (один слот, как _wallUnionCache). `_cfgEpoch`
инкрементится при каждом изменении конфига, поэтому инвалидация бесплатна и
консервативна: любой реальный write сбрасывает кэш. Жест ресайза считает
baseline один раз вместо «на каждый move».
### 4.5 Обе реализации: bucket-индекс для П4 (r1-H1)
П4 переводится с «все пары узлов + каждый узел × каждый сегмент» на
пространственную сетку с шагом `minDistance`: узлы и bbox сегментов
(расширенные на порог) раскладываются по ячейкам, кандидаты берутся из
9-окрестности. Прототип на python: 372 → 44 мс на 576 атомах, вердикты
идентичны. Реализуется в обоих зеркалах симметрично; вердикты байт-идентичны
(паритет-тест).
### 4.6 Документ текущей версии не мигрируется повторно (r1-H1)
Профиль показал: `commit_wall_segment_model` тратит 0.8 с python / 70 мс TS
даже на документе, который УЖЕ несёт актуальный каталог (`model_version ==
WALL_SEGMENT_MODEL_VERSION`) — атомизация гоняется заново ради нулевой
дельты. В путях лимитов такой документ используется как есть, без повторной
миграции; легаси-документ мигрируется прежним путём.
Это ОСОЗНАННАЯ ревизия формулировки «обе стороны после одной миграции»
(#329, CONFIG-COMPATIBILITY): её цель — не сравнивать документ без каталога
с мигрированным; у v9-документа каталог уже есть, цель достигнута без
работы. Риск «клиент прислал неканонический v9» закрывается требованием
эквивалентности: паритет-тест дополняется кейсом «v9-вход даёт тот же
вердикт с миграцией и без» на фикстурах границ, а барьер модели ниже по
конвейеру по-прежнему валидирует документ целиком.
### 4.7 П5: один разделяемый проход топологии и кладки (ревизия 3)
Бенч §5 вскрыл то, чего не показали точечные замеры П1–П4: в
`_junctionLimitViolations` КАЖДАЯ комната звала `innerContourForRoom` без
разделяемых кэшей, и та пересчитывала полную узловую топологию
(`multiWallNodesForGeometry`) и полный union кладки (`wallBodiesGeometry`)
для себя — 144 комнаты × полный polyclip-проход = 4.2 с на одного кандидата.
Функция уже принимает оба кэша (рендер их передаёт).
Решение: (1) узловая топология считается один раз на проверку; (2) union
кладки — только когда узловая карта непуста (при пустой
`innerContourForRoom` возвращает собственный inset комнаты, не читая union);
(3) в ресайзе кандидату передаётся артефакт кладки из СОБСТВЕННОГО
preflight-прохода этого же pointermove — union никогда не оплачивается
дважды. Замер: 4.2 с → 88 мс полный кандидат на сетке бенча; на
large-house (узлы есть) union 170–270 мс платится один раз на проверку, а в
ресайзе — ноль раз (переиспользован preflight).
## 5. Перф-контракт (новый, в CI)
`demo/benchmark_junction_limits.mjs` по образцу остальных бенчей: сетка
12×12 (576 атомов, легаси и v9-варианты), бюджеты в файле бенча. Бенч меряет
ИМЕННО код лимитов (r1-H2): TS-проверки П1–П5 напрямую и полный
`_junctionLimitViolations` на смонтированной карточке, плюс python-часть
через дочерний процесс.
Бюджеты — ~2–3× от замеренного после фикса (ловим возврат O(n²), не
дрожание раннера); замеры после фикса по прототипам:
Замеры двух машин (r1-H2: бюджет калибруется от ХУДШЕЙ наблюдавшейся, не
от машины автора; запас 2–3× — от неё):
| Метрика | Песочница | CI-раннер ревью | Бюджет |
|---|---|---|---|
| TS `checkSegmentLengths` (П3, 576) | 11 мс | ~20 мс | ≤ 60 мс |
| TS `checkNodeDistances` (П4, 576) | 19 мс | ~30 мс | ≤ 80 мс |
| TS полный набор П1–П5 кандидата v9 (§4.6) | 88 мс | 169–171 мс | ≤ 400 мс |
| py `validate_junction_limits`, тёплый (v9 + rev-кэш) | 36–45 мс | — | ≤ 300 мс |
| py `validate_junction_limits`, холодный (легаси-обе-стороны) | ~1.6 с | — | ≤ 5 с — только в executor (AC1), одноразовый случай первой записи после обновления |
Бенч включён в перф-джобу Validate («Перф-смок») отдельным шагом — он не
требует браузера и бандла и красится на каждом пуше, не раз в неделю.
## 6. Acceptance criteria
- **AC1 (event loop).** CPU-цепочка валидаторов `ws_config_set` исполняется
вне event loop: HA-тест патчит `validate_junction_limits` обёрткой,
записывающей поток исполнения, — на loop это был бы MainThread, — и
сверяет, что вердикты не изменились (чистая запись принята, новое
нарушение отклонено стабильным кодом). Прямой замер миллисекунд loop в
юните хрупок и заменён этим инвариантом: всё дорогое — в executor по
построению, включая холодный легаси-случай.
- **AC2 (П3+П4 линейные).** Бюджеты §5 для `checkSegmentLengths` и
`checkNodeDistances` в обоих зеркалах; вердикты на фикстурах паритет-теста
не изменились (существующий тест).
- **AC3 (rev-кэш).** Две подряд записи с одним previous: вторая не считает
нарушения previous заново (тест со счётчиком через monkeypatch); смена rev
инвалидирует кэш; вердикты идентичны пути без кэша на обеих фикстурах
(унаследованное нарушение / новое нарушение).
- **AC4 (фронт-кэш).** В одном жесте ресайза baseline считается один раз —
юнит со счётчиком на `_junctionLimitsIntroduced`; бюджет полного набора
П1–П5 кандидата — по НОВОМУ бенчу §5 (r1-H2: `benchmark_safe_resize`
код лимитов не вызывает и доказательством не является).
- **AC5 (эквивалентность §4.6).** Для документа текущей версии вердикты
«как есть» и «через миграцию» совпадают: паритет-тест дополняется этим
кейсом на фикстурах границ (14°/15°, 19/20 см, T-стык, доборный атом);
легаси-документ по-прежнему мигрируется (существующий тест H1 из #329
остаётся зелёным).
- **AC6 (семантика не тронута).** Все существующие юниты #329, паритет-тест,
смоки `smoke_junction_limits`/`smoke_island_rooms`/`smoke_room_resize` и
мутанты `junction-limit-*` зелёные без изменения ожиданий.
- **AC7 (перф-контракт).** Бенч §5 в CI, красный при возврате O(n²) —
доказано мутантом «индекс на каждый сегмент».
## 7. План тестов
Юниты: rev-кэш (заполнение, инвалидация, эквивалентность вердиктов);
baseline-кэш фронта (одно вычисление на эпоху); эквивалентность П3/П4 с
индексами и без на фикстурах границ; эквивалентность §4.6 (v9 как есть ==
v9 через миграцию). Бенч §5 в перф-джобе. Мутанты:
`junction-limit-p3-quadratic-again` (индекс П3 на каждый сегмент — бенч
красный), `junction-limit-p4-bruteforce-again` (bucket отключён — бенч
красный), `junction-limit-baseline-cache-stale` (кэш не инвалидируется по
rev — тест эквивалентности красный). Бэкенд: тест синхронного времени AC1.
## 8. Откат
Чистый revert: изменения не трогают формат данных, конфиг и контракт
WebSocket-команд; кэши — только в памяти процесса.
## 9. Обязательные разделы (r1-M1)
- **i18n**: новых ключей нет — задача не меняет ни один пользовательский
текст.
- **Touch**: не задет — меняется стоимость вычислений, не жесты и не
пороги; touch-контракт (docs/TOUCH-SUPPORT.md) не упоминает лимиты.
- **Риски**: (1) executor под write_lock — блокировки loop нет, но время
ответа записи сохраняется; смягчение — AC1 меряет именно loop-время;
(2) §4.6 меняет представление, на котором считаются лимиты для
v9-документов — закрыто AC5-эквивалентностью; (3) bucket-П4 —
геометрическая эквивалентность brute-force закрыта юнитами границ (4/5 см)
и паритетом.
- **Release-артефакты**: обычная бета; CHANGELOG (EN+RU) — одна строка
user-visible («сохранение и ресайз больших планов быстрее, HA не
замирает»); фингерпринт бандла пересобирается штатно; миграций конфига
нет, откат §8 — чистый revert.