mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-30 11:49:16 +00:00
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
239 lines
20 KiB
Markdown
239 lines
20 KiB
Markdown
# 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.
|