fix(forecast): segment + dedup demand/supply → индекс дефицита больше не −1.00 везде (#1959) #2018

Merged
bot-backend merged 2 commits from fix/deficit-segmentation-1959 into main 2026-06-27 18:18:09 +00:00
Collaborator

Батч 4 аудита (эпик #1953) — главный data-honesty фикс. deep-code-reviewer APPROVE (no blockers), прод-verified.

Проблема

compute_demand_supply_forecast кормил КАЖДУЮ ячейку what_to_build одинаковым district-wide спросом (unit_velocity=847.5/мес, все классы/комнаты) и одинаковым стоком района (n_available=62961) → одинаковый ratio → все deficit_index клампятся в −1.0. Плюс objective_lots = append-per-snapshot (~2.9× инфляция, симметрично бьёт спрос И предложение).

Фикс (сегментация + дедуп)

  • market_metrics.py: compute_market_metrics(+obj_class,+room_bucket); _STOCK_SQL/_SALES_WINDOW_SQL переписаны на DISTINCT ON физ-ключа → latest snapshot, затем агрегат; фильтр класса (LOWER=LOWER) + room-bucket (Source-B room_area, зеркало room_area_bucket_of). Confidence по deduped-счётчикам.
  • demand_supply_forecast.py: base_pace + open-сток теперь посегментные; L2/L3 (class/format-agnostic) исключены из посегментного баланса (иначе double-count → ре-вырождение). Валидирующий vocab-транслятор _market_room_bucket.
  • what_to_build.py/recommendation.py: эконом→стандарт (в objective_lots нет «эконом»; стандарт=483k лотов — старая ячейка матчила 0 строк, тихо дропалась).
  • report_assembler.py: honesty-guard — когда все измеренные ячейки |di|≥0.999, печатать «недостаточно гранулярных данных для посегментного вывода» вместо «строить»/«избегать».
  • Миграция data/sql/173_objective_lots_physflat_idx.sql (renamed из коллизии 100→173): partial-индекс под DISTINCT ON; prod dry-run → Index Only Scan, no Sort на 1.75M.

Прод-verify (66:41:0205010:287, h=24)

Было: все ячейки = velocity 847.5 + n_avail 62961 → deficit −1.0 везде.
Стало: 12 ячеек, 7 разных значений → PASS (дифференцируются). Напр.: комфорт/студия −0.106 (MOI 49.8), бизнес/1-к −0.312, комфорт/1-к −0.772, стандарт/студия −1.0 (MOI 316). MOI комфорт/студия 38.5 vs стандарт/студия 244.3 — точно по спеку.

Blast radius / scope (честно, по deep-review)

Дедуп в compute_market_metrics безусловный → влияет на все 9 потребителей (не только forecast). deep-review протрассировал каждый: безопасно — ratio-потребители (overstock/MOI/absorption) стабильны (дедуп схлопывает оба члена), velocity-потребители в зоне насыщения (847→292/мес → demand-скор 0.991→0.973, негладко лишь на низких velocity), demand_index city-relative (ранжирование сохранено). Считаем физ-квартиры, а не snapshot-строки = честнее. Платформенный дедуп raw-таблицы остаётся #1964.
⚠️ Деплой-окно: индекс 173 на 1.75M строк → краткий SHARE-lock блокирует objective-скрейп-UPSERT на время сборки. Применять вне окна скрейпа (скрейп недельный; сейчас сб).

Качество

  • ruff ; no :name::type; 1424 passed (+ regression-тесты: «ячейки различаются не все −1.0», vocab-транслятор, honesty-guard, dedup-SQL, segmented/unsegmented split).
  • Без изменения схемы → api-types не тронут.

Closes #1959. Coupled follow-up: #1964 (platform-wide raw-table дедуп).

Батч 4 аудита (эпик #1953) — главный data-honesty фикс. deep-code-reviewer ✅ APPROVE (no blockers), прод-verified. ## Проблема `compute_demand_supply_forecast` кормил КАЖДУЮ ячейку `what_to_build` одинаковым district-wide спросом (`unit_velocity=847.5/мес`, все классы/комнаты) и одинаковым стоком района (`n_available=62961`) → одинаковый ratio → все `deficit_index` клампятся в −1.0. Плюс `objective_lots` = append-per-snapshot (~2.9× инфляция, симметрично бьёт спрос И предложение). ## Фикс (сегментация + дедуп) - `market_metrics.py`: `compute_market_metrics(+obj_class,+room_bucket)`; `_STOCK_SQL`/`_SALES_WINDOW_SQL` переписаны на **DISTINCT ON физ-ключа → latest snapshot**, затем агрегат; фильтр класса (`LOWER=LOWER`) + room-bucket (Source-B `room_area`, зеркало `room_area_bucket_of`). Confidence по deduped-счётчикам. - `demand_supply_forecast.py`: base_pace + open-сток теперь посегментные; L2/L3 (class/format-agnostic) исключены из посегментного баланса (иначе double-count → ре-вырождение). Валидирующий vocab-транслятор `_market_room_bucket`. - `what_to_build.py`/`recommendation.py`: `эконом→стандарт` (в objective_lots нет «эконом»; стандарт=483k лотов — старая ячейка матчила 0 строк, тихо дропалась). - `report_assembler.py`: **honesty-guard** — когда все измеренные ячейки `|di|≥0.999`, печатать «недостаточно гранулярных данных для посегментного вывода» вместо «строить»/«избегать». - **Миграция `data/sql/173_objective_lots_physflat_idx.sql`** (renamed из коллизии 100→173): partial-индекс под DISTINCT ON; prod dry-run → Index Only Scan, no Sort на 1.75M. ## Прод-verify (66:41:0205010:287, h=24) **Было:** все ячейки = velocity 847.5 + n_avail 62961 → deficit −1.0 везде. **Стало:** 12 ячеек, **7 разных значений** → PASS (дифференцируются). Напр.: комфорт/студия −0.106 (MOI 49.8), бизнес/1-к −0.312, комфорт/1-к −0.772, стандарт/студия −1.0 (MOI 316). MOI комфорт/студия **38.5** vs стандарт/студия **244.3** — точно по спеку. ## Blast radius / scope (честно, по deep-review) Дедуп в `compute_market_metrics` **безусловный** → влияет на все 9 потребителей (не только forecast). deep-review протрассировал каждый: **безопасно** — ratio-потребители (overstock/MOI/absorption) стабильны (дедуп схлопывает оба члена), velocity-потребители в зоне насыщения (847→292/мес → demand-скор 0.991→0.973, негладко лишь на низких velocity), `demand_index` city-relative (ранжирование сохранено). Считаем физ-квартиры, а не snapshot-строки = честнее. Платформенный дедуп raw-таблицы остаётся #1964. ⚠️ **Деплой-окно:** индекс 173 на 1.75M строк → краткий SHARE-lock блокирует objective-скрейп-UPSERT на время сборки. Применять вне окна скрейпа (скрейп недельный; сейчас сб). ## Качество - ruff ✅; no `:name::type`; **1424 passed** (+ regression-тесты: «ячейки различаются не все −1.0», vocab-транслятор, honesty-guard, dedup-SQL, segmented/unsegmented split). - Без изменения схемы → api-types не тронут. Closes #1959. Coupled follow-up: #1964 (platform-wide raw-table дедуп).
bot-backend added 2 commits 2026-06-27 18:03:18 +00:00
Корень «−1.00 везде» (эпик #1953): compute_demand_supply_forecast брал
district-wide unit_velocity (847.5/мес, ВСЕ классы/комнаты) как спрос и
весь district-сток (~63k доступных) как предложение для КАЖДОЙ ячейки
what_to_build → один и тот же ratio во всех ячейках → все deficit_index
прижаты к −1.0. Плюс objective_lots — append-per-snapshot (~2.9× инфляция
строк), что симметрично раздувало обе базы → даже сегментация без дедупа
осталась бы вырожденной.

Фикс (blast radius — ТОЛЬКО forecast/deficit calc; platform-wide dedup = #1964):
- market_metrics.compute_market_metrics: +obj_class/+room_bucket (+cache key).
  _STOCK_SQL и _SALES_WINDOW_SQL дедуплят до ПОСЛЕДНЕГО снапшота на физлот
  (DISTINCT ON project_name,corpus_name,section,floor,lot_number ORDER BY …
  snapshot_date DESC,id DESC), затем агрегируют. Class-фильтр (LOWER=LOWER,
  class lowercase) + room-bucket (Source-B room_area-вокабуляр, зеркало
  sales_series.room_area_bucket_of → what_to_build фильтрует без перевода).
  ROLLUP/GROUPING сохранён; confidence считается на дедуплицированных counts.
- demand_supply_forecast: base_pace и open-сток теперь ПОСЕГМЕНТНЫЕ
  (market_metrics(obj_class,room_bucket)). При заданном сегменте L2/L3
  (hidden/future) ИСКЛЮЧЕНЫ из баланса — они класс/формат-агностичны, иначе
  двоились бы по всем ячейкам. +_market_room_bucket VOCAB-мост (валидирующий
  pass-through Source-B меток; неизвестное → None = без фильтра, не тихий 0-rows).
- what_to_build/_DEFAULT_CLASSES и recommendation Economy-маппинг: «эконом»→
  «стандарт» (в objective_lots эконома НЕТ, стандарт=483k → раньше ячейка
  матчила 0 строк и молча выпадала).
- report_assembler honesty-guard: если ВСЯ сетка прижата к ±1.0
  (degenerate-fallback) — не эмитим «строить»/«избегать», показываем
  «недостаточно гранулярных данных для посегментного вывода».
- data/sql/173_objective_lots_physflat_idx.sql: partial index под DISTINCT ON
  (Index Only Scan + Unique, без Sort на 1.75M строк; idempotent, BEGIN/COMMIT).

Prod-verify (parcel 66:41:0205010:287, Железнодорожный, h=24): ячейки
ДИФФЕРЕНЦИРУЮТ (12 measured, 7 distinct) вместо all −1.0; MOI комфорт/студия
38.5 vs стандарт/студия 244.3 (точное совпадение с ожидаемым).

Тесты: регрессия «ячейки различаются (не all −1.0)» + vocab-translation +
honesty-guard + посегментное предложение. ruff clean; no :name::type.
docs(forecast): исправить устаревшие комментарии #1959 (deep-review follow-up)
All checks were successful
CI / changes (pull_request) Successful in 8s
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 1m59s
CI / backend-tests (pull_request) Successful in 14m2s
3cafe22c15
Doc-only, без изменений логики (deep-code-reviewer APPROVE):
- market_metrics _STOCK_SQL/_SALES_WINDOW_SQL + docstring: убрать ложную
  отсылку «зеркало … из 100_*.sql» (индекс создаётся ТОЛЬКО в миграции 173).
- _SALES_WINDOW_SQL комментарий + compute_market_metrics docstring: room_bucket
  это Source-B room_area-вокабуляр («Студии 15-30»/«2-к 45-60»/«80+ м²», зеркало
  room_area_bucket_of), НЕ Source-A «студия/1/2/3» — приводим к фактическому CASE.
- market_metrics: добавить комментарий, что DISTINCT-ON дедуп БЕЗУСЛОВНЫЙ (все
  вызовы compute_market_metrics, не только форсайт-путь); deep-review подтвердил
  безопасность для ratio/saturated-velocity консьюмеров; дедуп сырой таблицы
  objective_lots для platform-wide остаётся #1964.
- recommendation.map_class docstring: Economy → «стандарт» (код уже маппит так).
bot-backend merged commit 6aff5a000d into main 2026-06-27 18:18:09 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: lekss361/gendesign#2018
No description provided.