Files
houseplan-card/docs/specs/330-junction-limits-performance.md
T
Codex 3f73eced95 docs: spec #330 revision 2 — budgets from the profile, П4 bucket, no re-migration for current-version documents
r1-H1 was right twice: the rev cache never touched the candidate's migration,
and П4 is architecturally quadratic. Profiled instead of guessing: the money
is not in deepcopy (3 ms) but in _atomize (663k distance calls), and it runs
even for a document that already carries the current catalogue — 815 ms
python / 69 ms TS for a no-op migration. Two new cuts follow: §4.5 bucket
index for П4 (prototype: 372→44 ms, identical verdicts) and §4.6 current-
version documents are used as-is (an explicit revision of the "both sides
through one migration" wording, guarded by a new parity case: v9 input gives
the same verdict with and without migration).

r1-H2: AC4 now rests on the new benchmark that actually exercises the
junction code; benchmark_safe_resize is named as a non-proof. r1-M1: §9
adds the mandatory i18n/touch/risks/release sections.

Budgets in §5 are recomputed from measured post-fix prototypes with a 2-3x
allowance, including an honest row for the one-off cold legacy case.

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

210 lines
17 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)
Статус: ревизия 2 (r1: H1 — бюджеты пересчитаны от профиля, добавлены срезы
§4.5 bucket-П4 и §4.6 «документ текущей версии без повторной миграции»;
H2 — AC4 опирается на новый бенч; M1 — §9). Родитель: #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-вход даёт тот же
вердикт с миграцией и без» на фикстурах границ, а барьер модели ниже по
конвейеру по-прежнему валидирует документ целиком.
## 5. Перф-контракт (новый, в CI)
`demo/benchmark_junction_limits.mjs` по образцу остальных бенчей: сетка
12×12 (576 атомов, легаси и v9-варианты), бюджеты в файле бенча. Бенч меряет
ИМЕННО код лимитов (r1-H2): TS-проверки П1–П5 напрямую и полный
`_junctionLimitViolations` на смонтированной карточке, плюс python-часть
через дочерний процесс.
Бюджеты — ~2–3× от замеренного после фикса (ловим возврат O(n²), не
дрожание раннера); замеры после фикса по прототипам:
| Метрика | После фикса (ожид.) | Бюджет |
|---|---|---|
| TS `checkSegmentLengths` (П3, 576) | ~15 мс | ≤ 40 мс |
| TS `checkNodeDistances` (П4, 576) | ~15 мс | ≤ 40 мс |
| TS полный набор П1–П5 кандидата v9 (без повторной миграции, §4.6) | ~40 мс | ≤ 100 мс |
| py `validate_junction_limits`, тёплый (v9 + rev-кэш) | ~100 мс | ≤ 250 мс |
| py `validate_junction_limits`, холодный (легаси-обе-стороны) | ~1.7 с | ≤ 3.5 с — только в executor (AC1), одноразовый случай первой записи после обновления |
## 6. Acceptance criteria
- **AC1 (event loop).** Запись 576-атомного конфига блокирует event loop
≤ 50 мс: backend-тест меряет синхронную часть `ws_config_set`
(инструментированный вызов); CPU-цепочка живёт в executor. Холодный
легаси-случай подчиняется тому же лимиту loop-времени.
- **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.