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