Files
houseplan-card/docs/specs/330-junction-limits-performance.md
T
Codex ddfca3a865 fix: close code-review 330-r1 — budgets from the slowest machine, the bench in Validate, AC1 through the execution thread (#330)
H2: the benchmark budgets were calibrated on the author's sandbox with a
1.14x margin — the review runner measured tsFullCandidateMs at 169-171 ms
against a 100 ms ceiling. Budgets now keep the spec's 2-3x allowance over
the SLOWEST observed machine, and the benchmark runs as a step of the
Validate perf job on every push (it needs no browser and no bundle), not
only inside the weekly mutation gate.

M1: the promised AC1 backend test exists now and does what AC1 means: it
patches validate_junction_limits with a thread-recording wrapper inside the
real HA harness — on the event loop that would be MainThread — and proves
the verdicts survived the move (a clean write is accepted, a write adding a
spike is refused with junction_limit_angle). Spec revision 4 rewrites AC1
around this invariant instead of a fragile millisecond assertion.

M2: §4.6 equivalence is now behavioural on both sides (three boundary
fixtures each: as-is counts equal through-migration counts, TS and python),
and the parity suite gained the §7 boundary fixtures (exact 15°, exact
20 cm, the thickness-step filler run, exact 5 cm).

H1 was already closed by 7513f93d (the review ran on the previous HEAD):
check-docs is green on this tree — the screenshots and their manifest come
from one capture run.

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

20 KiB
Raw Blame History

Issue #330 — производительность ограничений стыков (#329)

Статус: ревизия 4 (код-ревью r1: H1 — скриншоты согласованы; H2 — бюджеты §5 пересчитаны от худшей наблюдавшейся машины и бенч включён в перф-джобу Validate; M1 — AC1-тест через поток исполнения в HA-харнесе; M2 — эквивалентность §4.6 и паритет расширены границами. r1: H1 — бюджеты пересчитаны от профиля, добавлены срезы §4.5 bucket-П4 и §4.6 «документ текущей версии без повторной миграции»; H2 — AC4 опирается на новый бенч; M1 — §9. Ревизия 3, собственная находка при написании бенча: §4.7 — П5 пересчитывал полную топологию и union кладки НА КАЖДУЮ КОМНАТУ, 4.2 с на кандидата; в скоуп добавлен разделяемый проход). Родитель: #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-вход даёт тот же вердикт с миграцией и без» на фикстурах границ, а барьер модели ниже по конвейеру по-прежнему валидирует документ целиком.

4.7 П5: один разделяемый проход топологии и кладки (ревизия 3)

Бенч §5 вскрыл то, чего не показали точечные замеры П1–П4: в _junctionLimitViolations КАЖДАЯ комната звала innerContourForRoom без разделяемых кэшей, и та пересчитывала полную узловую топологию (multiWallNodesForGeometry) и полный union кладки (wallBodiesGeometry) для себя — 144 комнаты × полный polyclip-проход = 4.2 с на одного кандидата. Функция уже принимает оба кэша (рендер их передаёт).

Решение: (1) узловая топология считается один раз на проверку; (2) union кладки — только когда узловая карта непуста (при пустой innerContourForRoom возвращает собственный inset комнаты, не читая union); (3) в ресайзе кандидату передаётся артефакт кладки из СОБСТВЕННОГО preflight-прохода этого же pointermove — union никогда не оплачивается дважды. Замер: 4.2 с → 88 мс полный кандидат на сетке бенча; на large-house (узлы есть) union 170–270 мс платится один раз на проверку, а в ресайзе — ноль раз (переиспользован preflight).

5. Перф-контракт (новый, в CI)

demo/benchmark_junction_limits.mjs по образцу остальных бенчей: сетка 12×12 (576 атомов, легаси и v9-варианты), бюджеты в файле бенча. Бенч меряет ИМЕННО код лимитов (r1-H2): TS-проверки П1–П5 напрямую и полный _junctionLimitViolations на смонтированной карточке, плюс python-часть через дочерний процесс.

Бюджеты — ~2–3× от замеренного после фикса (ловим возврат O(n²), не дрожание раннера); замеры после фикса по прототипам:

Замеры двух машин (r1-H2: бюджет калибруется от ХУДШЕЙ наблюдавшейся, не от машины автора; запас 2–3× — от неё):

Метрика Песочница CI-раннер ревью Бюджет
TS checkSegmentLengths (П3, 576) 11 мс ~20 мс ≤ 60 мс
TS checkNodeDistances (П4, 576) 19 мс ~30 мс ≤ 80 мс
TS полный набор П1–П5 кандидата v9 (§4.6) 88 мс 169–171 мс ≤ 400 мс
py validate_junction_limits, тёплый (v9 + rev-кэш) 36–45 мс — ≤ 300 мс
py validate_junction_limits, холодный (легаси-обе-стороны) ~1.6 с — ≤ 5 с — только в executor (AC1), одноразовый случай первой записи после обновления

Бенч включён в перф-джобу Validate («Перф-смок») отдельным шагом — он не требует браузера и бандла и красится на каждом пуше, не раз в неделю.

6. Acceptance criteria

  • AC1 (event loop). CPU-цепочка валидаторов ws_config_set исполняется вне event loop: HA-тест патчит validate_junction_limits обёрткой, записывающей поток исполнения, — на loop это был бы MainThread, — и сверяет, что вердикты не изменились (чистая запись принята, новое нарушение отклонено стабильным кодом). Прямой замер миллисекунд loop в юните хрупок и заменён этим инвариантом: всё дорогое — в executor по построению, включая холодный легаси-случай.
  • 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.