From ccadb7779eca5edbaa813544d3c140a9a4bcb6ca Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 28 Aug 2026 01:52:31 +0300 Subject: [PATCH] =?UTF-8?q?docs:=20spec=20#330=20=E2=80=94=20junction=20li?= =?UTF-8?q?mits=20performance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/specs/330-junction-limits-performance.md | 148 ++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 docs/specs/330-junction-limits-performance.md diff --git a/docs/specs/330-junction-limits-performance.md b/docs/specs/330-junction-limits-performance.md new file mode 100644 index 00000000..b3fddb8c --- /dev/null +++ b/docs/specs/330-junction-limits-performance.md @@ -0,0 +1,148 @@ +# 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-команд; кэши — только в памяти процесса.