Files
houseplan-card/docs/specs/330-junction-limits-performance.md
T
Codex ccadb7779e docs: spec #330 — junction limits performance
Four cuts, zero verdict changes: the ws_config_set validator chain moves to
the executor, the previous-document violation counts are cached by
config_rev, П3 builds its node index once per check in both mirrors, and the
frontend baseline is cached per config epoch. A new benchmark with budgets
pins the class of regression (O(n²) returning) in CI.

Measured on dev 2c20f2dc: a 576-atom plan costs 2.8 s in the HA event loop
per config write today; the spec's acceptance bar is ≤50 ms of loop time.

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

149 lines
11 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)
Статус: ревизия 1. Родитель: #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. Корни (по убыванию вклада)
1. **Две полные миграции на запись.** `validate_junction_limits` гонит и
`previous`, и `candidate` через `commit_wall_segment_model` (deepcopy +
каноникализация) — ~1.5 с из 2.8 на 12×12. `previous` между записями не
меняется: его миграция и подсчёт нарушений выбрасываются и повторяются.
2. **П3 квадратичен в обеих реализациях.** `collinearRunLengthUnits` /
`collinear_run_length_units` строит индекс узлов заново для КАЖДОГО
сегмента → O(n²) с тяжёлой константой. Зеркала честно скопировали дефект
друг друга.
3. **Фронтовый baseline без кэша.** `_junctionLimitsIntroduced` строит
baseline-нарушения из previousConfig на каждый вызов; в жесте ресайза —
на каждый move.
## 3. Границы
- Вердикты П1–П5 байт-идентичны текущим на любом входе. Существующие юниты
границ, паритет-тест TS↔Python и все три мутанта #329 проходят без правок
их семантики (мутантам разрешено обновить якоря, если строки сместились).
- Спека #329 (наследование по правилу, обе стороны после одной миграции,
скоуп применения §3) не меняется.
- Вне скоупа: итеративный walk, точность ключей узлов, 0°-дубль (#331);
optimize/import-лазейка (#333); пропуск миграции для уже-v9 документов —
рассмотрен и отклонён: он меняет сравниваемое представление (кандидат от
клиента не каноникализован) и рискует паритетом ради экономии, которую
даёт rev-кэш.
## 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».
## 5. Перф-контракт (новый, в CI)
`demo/benchmark_junction_limits.mjs` по образцу `benchmark_safe_resize.mjs`:
сетка 12×12 (576 атомов), бюджеты в файле бенча:
- фронт: `checkSegmentLengths` ≤ 25 мс, полный `_junctionLimitViolations`
кандидата ≤ 60 мс (p95 из N прогонов);
- бэкенд (запускается тем же бенчем через python3): полный
`validate_junction_limits` с тёплым rev-кэшем ≤ 150 мс, холодный ≤ 900 мс.
Бюджеты — 2–3× от замеренного после фикса, чтобы бенч ловил регресс класса
«вернули O(n²)», а не дрожание раннера. Включается в перф-джобу CI рядом с
существующими бенчами.
## 6. Acceptance criteria
- **AC1 (event loop).** Запись 576-атомного конфига блокирует event loop
≤ 50 мс: backend-тест меряет время синхронной части `ws_config_set`
(инструментированный вызов); полная цепочка живёт в executor.
- **AC2 (П3 линейный).** 576 атомов: `checkSegmentLengths` ≤ 25 мс (фронт),
`check_segment_lengths` ≤ 100 мс (python) — бенч из §5; вердикты на
фикстурах паритет-теста не изменились (существующий тест).
- **AC3 (rev-кэш).** Две подряд записи с одним previous: вторая не вызывает
`commit_wall_segment_model` для previous (тест со счётчиком вызовов через
monkeypatch); смена rev инвалидирует кэш; вердикты идентичны пути без
кэша на обеих фикстурах (унаследованное нарушение / новое нарушение).
- **AC4 (фронт-кэш).** В одном жесте ресайза baseline считается один раз:
юнит на `_junctionLimitsIntroduced` со счётчиком, плюс существующий
`benchmark_safe_resize` держит прежний pointer-бюджет на large-house.
- **AC5 (семантика не тронута).** Все существующие юниты #329, паритет-тест,
смоки `smoke_junction_limits`/`smoke_island_rooms`/`smoke_room_resize` и
мутанты `junction-limit-*` зелёные без изменения ожиданий.
- **AC6 (перф-контракт).** Бенч §5 в CI, красный при возврате O(n²)
(проверяется мутантом на «строить индекс на каждый сегмент»).
## 7. План тестов
Юниты: rev-кэш (заполнение, инвалидация, эквивалентность вердиктов),
baseline-кэш фронта (одно вычисление на эпоху), эквивалентность П3 с
индексом и без на фикстурах границ. Бенч §5. Мутанты: `junction-limit-p3-
quadratic-again` (индекс на каждый сегмент — бенч красный),
`junction-limit-baseline-cache-stale` (кэш не инвалидируется по rev — тест
эквивалентности красный). Бэкенд: тест синхронного времени AC1.
## 8. Откат
Чистый revert: изменения не трогают формат данных, конфиг и контракт
WebSocket-команд; кэши — только в памяти процесса.