diff --git a/docs/specs/330-junction-limits-performance.md b/docs/specs/330-junction-limits-performance.md index b3fddb8c..daded910 100644 --- a/docs/specs/330-junction-limits-performance.md +++ b/docs/specs/330-junction-limits-performance.md @@ -1,6 +1,8 @@ # Issue #330 — производительность ограничений стыков (#329) -Статус: ревизия 1. Родитель: #329 (лимиты выпущены, вердикты корректны — +Статус: ревизия 2 (r1: H1 — бюджеты пересчитаны от профиля, добавлены срезы +§4.5 bucket-П4 и §4.6 «документ текущей версии без повторной миграции»; +H2 — AC4 опирается на новый бенч; M1 — §9). Родитель: #329 (лимиты выпущены, вердикты корректны — подтверждено паритет-тестом и код-ревью r1–r3). Эта задача меняет ТОЛЬКО стоимость вычисления вердиктов; ни один вердикт не меняется ни на бит. Смежный #331 (пограничная точность) сознательно вне скоупа. @@ -29,17 +31,20 @@ Assistant почти на 3 секунды. `MAX_CONFIG_BYTES` = 2 МБ допу Результат для пользователя: сохранение большого плана не подвешивает HA; ресайз на большом плане держит существующий кадровый бюджет. -## 2. Корни (по убыванию вклада) +## 2. Корни (по убыванию вклада; профиль cProfile/бенч, 576 атомов) -1. **Две полные миграции на запись.** `validate_junction_limits` гонит и - `previous`, и `candidate` через `commit_wall_segment_model` (deepcopy + - каноникализация) — ~1.5 с из 2.8 на 12×12. `previous` между записями не - меняется: его миграция и подсчёт нарушений выбрасываются и повторяются. +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²) с тяжёлой константой. Зеркала честно скопировали дефект - друг друга. -3. **Фронтовый baseline без кэша.** `_junctionLimitsIntroduced` строит + сегмента → O(n²): 285 мс py / 290 мс TS. +3. **П4 квадратичен архитектурно** (все пары узлов + узел×сегмент): + 372 мс py / 104 мс TS. Линеаризация П3 его не касается (r1-H1). +4. **Фронтовый baseline без кэша.** `_junctionLimitsIntroduced` строит baseline-нарушения из previousConfig на каждый вызов; в жесте ресайза — на каждый move. @@ -51,10 +56,10 @@ Assistant почти на 3 секунды. `MAX_CONFIG_BYTES` = 2 МБ допу - Спека #329 (наследование по правилу, обе стороны после одной миграции, скоуп применения §3) не меняется. - Вне скоупа: итеративный walk, точность ключей узлов, 0°-дубль (#331); - optimize/import-лазейка (#333); пропуск миграции для уже-v9 документов — - рассмотрен и отклонён: он меняет сравниваемое представление (кандидат от - клиента не каноникализован) и рискует паритетом ради экономии, которую - даёт rev-кэш. + optimize/import-лазейка (#333); ускорение самой атомизации + (`_atomize`/`atomize` квадратичны — общесистемная правка ядра модели с + требованием байт-паритета зеркал; отдельная задача, если §4.6 окажется + недостаточным). ## 4. Решение @@ -98,51 +103,107 @@ validate, инвалидируется несовпадением rev. Повт консервативна: любой реальный 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` по образцу `benchmark_safe_resize.mjs`: -сетка 12×12 (576 атомов), бюджеты в файле бенча: +`demo/benchmark_junction_limits.mjs` по образцу остальных бенчей: сетка +12×12 (576 атомов, легаси и v9-варианты), бюджеты в файле бенча. Бенч меряет +ИМЕННО код лимитов (r1-H2): TS-проверки П1–П5 напрямую и полный +`_junctionLimitViolations` на смонтированной карточке, плюс python-часть +через дочерний процесс. -- фронт: `checkSegmentLengths` ≤ 25 мс, полный `_junctionLimitViolations` - кандидата ≤ 60 мс (p95 из N прогонов); -- бэкенд (запускается тем же бенчем через python3): полный - `validate_junction_limits` с тёплым rev-кэшем ≤ 150 мс, холодный ≤ 900 мс. +Бюджеты — ~2–3× от замеренного после фикса (ловим возврат O(n²), не +дрожание раннера); замеры после фикса по прототипам: -Бюджеты — 2–3× от замеренного после фикса, чтобы бенч ловил регресс класса -«вернули O(n²)», а не дрожание раннера. Включается в перф-джобу CI рядом с -существующими бенчами. +| Метрика | После фикса (ожид.) | Бюджет | +|---|---|---| +| 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` - (инструментированный вызов); полная цепочка живёт в 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, паритет-тест, + ≤ 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-*` зелёные без изменения ожиданий. -- **AC6 (перф-контракт).** Бенч §5 в CI, красный при возврате O(n²) - (проверяется мутантом на «строить индекс на каждый сегмент»). +- **AC7 (перф-контракт).** Бенч §5 в CI, красный при возврате O(n²) — + доказано мутантом «индекс на каждый сегмент». ## 7. План тестов -Юниты: rev-кэш (заполнение, инвалидация, эквивалентность вердиктов), -baseline-кэш фронта (одно вычисление на эпоху), эквивалентность П3 с -индексом и без на фикстурах границ. Бенч §5. Мутанты: `junction-limit-p3- -quadratic-again` (индекс на каждый сегмент — бенч красный), -`junction-limit-baseline-cache-stale` (кэш не инвалидируется по rev — тест -эквивалентности красный). Бэкенд: тест синхронного времени AC1. +Юниты: 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.