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

11 KiB
Raw Blame History

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-команд; кэши — только в памяти процесса.