P0 Индекс дефицита −1.00 ВЕЗДЕ — basis mismatch (спрос сегмент vs предложение район) + honesty-guard (эпик #1953) #1959

Closed
opened 2026-06-27 08:46:04 +00:00 by bot-backend · 1 comment
Collaborator

Часть аудита #1953. Главный data-honesty баг — обесценивает весь Раздел 6.

B5/C3/C4. Индекс дефицита ∈ [−1,+1] показывает −1.00 для ВСЕХ горизонтов (6/12/18/24), классов (бизнес/комфорт/эконом), форматов (студия..4-5к).

  • Root (basis mismatch): projected_demand = base_pace × β × macro × h, где base_pace — district-wide unit_velocity (847.5 ед/мес, все классы/форматы); сегмент (obj_class/room_bucket) уходит только в compute_demand_no_op. projected_supply (45301→38304) — весь сток района (premise_kind=квартира), тоже без среза по классу/формату. → сегментный спрос делится на полный районный сток → всегда oversupply → clamp −1.0. (Лог-шкала с насыщением sat=2.0 — корректна; вырожден ВХОД.)
  • Прод: demand 3930→15721 vs supply 38304→45301 — несопоставимые базисы.

Fix (сегментировать ОБА базиса согласованно):

  1. Спрос: в compute_demand_supply_forecast передавать сегмент в велосити — расширить compute_market_metrics фильтрами class+room_bucket (objective_lots.class, rooms_int есть в схеме).
  2. Предложение: добавить class/room фильтр в _SUPPLY_LAYERS_SQL (L1 objective несёт class/rooms; L2/L3 без класса — распределять по доле / помечать low-confidence). Один PR с обоими.

Honesty-guard (немедленно, до полного фикса): когда deficit упёрт в ±1.0 на ВСЕХ горизонтах И сегментах — НЕ печатать категоричные вердикты «избегать»/«рекомендован», а показывать «недостаточно гранулярных данных для посегментного вывода». (Связано: advisory-движок задокументирован как НЕ-для-инвест-решений, но льёт категоричные числа в клиентский отчёт.)

Следствия, закрываемые этим же фиксом: 6.2 коллапс сценариев, 6.4 квартирография «всё −1.00 избегать».

Часть аудита #1953. **Главный data-honesty баг — обесценивает весь Раздел 6.** **B5/C3/C4.** `Индекс дефицита` ∈ [−1,+1] показывает −1.00 для ВСЕХ горизонтов (6/12/18/24), классов (бизнес/комфорт/эконом), форматов (студия..4-5к). - Root (basis mismatch): `projected_demand = base_pace × β × macro × h`, где `base_pace` — district-wide `unit_velocity` (847.5 ед/мес, **все классы/форматы**); сегмент (`obj_class`/`room_bucket`) уходит только в `compute_demand_no_op`. `projected_supply` (45301→38304) — весь сток района (`premise_kind=квартира`), тоже без среза по классу/формату. → сегментный спрос делится на полный районный сток → всегда oversupply → clamp −1.0. (Лог-шкала с насыщением sat=2.0 — корректна; вырожден ВХОД.) - Прод: demand 3930→15721 vs supply 38304→45301 — несопоставимые базисы. **Fix (сегментировать ОБА базиса согласованно):** 1. Спрос: в `compute_demand_supply_forecast` передавать сегмент в велосити — расширить `compute_market_metrics` фильтрами class+room_bucket (`objective_lots.class`, `rooms_int` есть в схеме). 2. Предложение: добавить class/room фильтр в `_SUPPLY_LAYERS_SQL` (L1 objective несёт class/rooms; L2/L3 без класса — распределять по доле / помечать low-confidence). Один PR с обоими. **Honesty-guard (немедленно, до полного фикса):** когда deficit упёрт в ±1.0 на ВСЕХ горизонтах И сегментах — НЕ печатать категоричные вердикты «избегать»/«рекомендован», а показывать «недостаточно гранулярных данных для посегментного вывода». (Связано: advisory-движок задокументирован как НЕ-для-инвест-решений, но льёт категоричные числа в клиентский отчёт.) Следствия, закрываемые этим же фиксом: 6.2 коллапс сценариев, 6.4 квартирография «всё −1.00 избегать».
Author
Collaborator

Валидация на проде (read-only) — спек реализации

Вердикт: сегментация обоих базисов НЕОБХОДИМА, но НЕДОСТАТОЧНА. Нужен симметричный дедуп objective_lots (#1964 — coupled). Проверено на живом проде для 66:41:0205010:287 (Железнодорожный).

Root cause (воспроизведён)

compute_demand_supply_forecast (demand_supply_forecast.py:603,683): demand = district-wide unit_velocity (847.5/мес, ВСЕ классы/комнаты); supply = весь сток района (49231). Сегмент идёт только в β/macro (≈1.0). → каждая ячейка what_to_build = 847.5×h ÷ 49231 → идентичный −1.0 везде.

Блокер: #1964 инфляция структурна и СИММЕТРИЧНА

objective_lots = append-per-snapshot, не UPSERT. Одна физ-квартира = новый id каждый скрейп. Космонавтов 11 = 18 534 строк, но 4 159 уникальных физ-квартир (4.5×). Физ-ключ (project_name, corpus_name, section, floor, lot_number) = 100% non-NULL, стабилен. Инфляция бьёт обе стороны ~4.4× (и _SALES_WINDOW_SQL спроса тоже!):

Базис RAW DEDUP (latest/flat)
Available supply (район) 62 961 14 018
Velocity (район, /мес) 847.5 212.7

→ дедуп ОБЯЗАН быть симметричным (только supply → искусственный дефицит).

Результат фикса (сегмент + дедуп) — НЕ вырожденный

сегмент vel/мес deficit h6/h12/h18/h24 MOI мес
комфорт/студия 48.2 −1.0/−1.0/−0.19/+0.73 38.5
комфорт/3 20.8 −1.0/−1.0/−0.66/+0.10 46.4
бизнес/1 1.8 −1.0/−1.0/−0.39/+0.45 41.5
комфорт/1 73.3 −1.0/−1.0/−0.85/−0.14 50.3
стандарт/студия 4.0 −1.0 (все h) 244.2

Ячейки различаются по сегменту и горизонту (−1.0…+0.73) + MOI дискриминирует насыщенные. Честный нюанс: Железнодорожный реально затоварен (40–310 мес стока) → многие ячейки корректно −1.0 на коротких горизонтах; цель #1959 — сделать ячейки РАЗНЫМИ, не нарисовать дефицит. Сегментация БЕЗ дедупа проваливается (инфляция давит ratio).

Coverage (feasible): Железнодорожный — class 100% non-NULL (комфорт/стандарт/бизнес, lowercase), rooms_int 100% (0–4).

Спек реализации

  • market_metrics.py (_STOCK_SQL L276, _SALES_WINDOW_SQL L324, compute_market_metrics L354): добавить room_bucket param + в cache-key; переписать оба SQL на (a) дедуп DISTINCT ON (физ-ключ) ... ORDER BY ..., snapshot_date DESC, id DESC, (b) фильтр LOWER(class) + room-bucket (как _SOURCE_B_SQL в sales_series.py:389). CAST(:x AS t), не ::.
  • demand_supply_forecast.py:603: пробросить obj_class=spec.obj_class, room_bucket=spec.room_bucket в compute_market_metrics. ⚠️ Vocab-mismatch (load-bearing): SegmentSpec.room_bucket из what_to_build = Source-B лейблы («2-к 45-60»), а market_metrics = «студия/1/2/3». Нужен транслятор, иначе фильтр тихо вернёт 0 строк. Подтвердить vocab what_to_build._DEFAULT_ROOM_BUCKETS перед wiring.
  • future_supply.py L312 / supply_layers.py _L1_OPEN_SQL L187: сегментированный open-сток считать из ДЕДУПнутого objective_lots (переиспользовать deduped n_available из market_metrics), L2/L3 исключить из посегментного баланса (class-agnostic, room нет в domrf → double-count). Отдельно дедупнуть compute_layer1_open = ядро #1964 (coupled, флаг backend-engineer).
  • Migration (data/sql/NN_*.sql, next seq — DISTINCT ON по 1.76M без индекса = sort): CREATE INDEX IF NOT EXISTS objective_lots_physflat_latest_idx ON objective_lots (project_name,corpus_name,section,floor,lot_number,snapshot_date DESC,id DESC) WHERE premise_kind='квартира'; BEGIN/COMMIT, идемпотентно. Рассмотреть v_objective_lots_latest как единый dedup-источник. Применить миграцию ПЕРЕД кодом.
  • Thin segments: existing None-propagation (deduped n=0 → metrics None → ячейка drop). _confidence должен считать по DEDUPED counts (raw 4.4× завышает).
  • Honesty-guard (из тикета): пока не выкатили — когда deficit упёрт в ±1.0 на ВСЕХ ячейках, не печатать категоричные «избегать».

Регресс-тест (pass = ячейки НЕ все равны): h=24 комфорт/студия≈+0.73, бизнес/1≈+0.45, комфорт/1≈−0.14, стандарт/студия=−1.0; MOI комфорт/студия≈38.5 vs стандарт/студия≈244.

#1959 теперь = coupled с #1964 (дедуп). Батч 4.

## Валидация на проде (read-only) — спек реализации **Вердикт: сегментация обоих базисов НЕОБХОДИМА, но НЕДОСТАТОЧНА. Нужен симметричный дедуп `objective_lots` (#1964 — coupled).** Проверено на живом проде для 66:41:0205010:287 (Железнодорожный). ### Root cause (воспроизведён) `compute_demand_supply_forecast` (`demand_supply_forecast.py:603,683`): demand = district-wide `unit_velocity` (847.5/мес, ВСЕ классы/комнаты); supply = весь сток района (49231). Сегмент идёт только в β/macro (≈1.0). → каждая ячейка `what_to_build` = 847.5×h ÷ 49231 → **идентичный −1.0 везде**. ### Блокер: #1964 инфляция структурна и СИММЕТРИЧНА `objective_lots` = append-per-snapshot, не UPSERT. Одна физ-квартира = новый id каждый скрейп. Космонавтов 11 = 18 534 строк, но **4 159 уникальных** физ-квартир (4.5×). Физ-ключ `(project_name, corpus_name, section, floor, lot_number)` = 100% non-NULL, стабилен. Инфляция бьёт **обе** стороны ~4.4× (и `_SALES_WINDOW_SQL` спроса тоже!): | Базис | RAW | DEDUP (latest/flat) | |---|---|---| | Available supply (район) | 62 961 | 14 018 | | Velocity (район, /мес) | 847.5 | 212.7 | → дедуп ОБЯЗАН быть симметричным (только supply → искусственный дефицит). ### Результат фикса (сегмент + дедуп) — НЕ вырожденный | сегмент | vel/мес | deficit h6/h12/h18/h24 | MOI мес | |---|---|---|---| | комфорт/студия | 48.2 | −1.0/−1.0/−0.19/**+0.73** | 38.5 | | комфорт/3 | 20.8 | −1.0/−1.0/−0.66/**+0.10** | 46.4 | | бизнес/1 | 1.8 | −1.0/−1.0/−0.39/**+0.45** | 41.5 | | комфорт/1 | 73.3 | −1.0/−1.0/−0.85/**−0.14** | 50.3 | | стандарт/студия | 4.0 | −1.0 (все h) | 244.2 | Ячейки **различаются** по сегменту и горизонту (−1.0…+0.73) + MOI дискриминирует насыщенные. **Честный нюанс:** Железнодорожный реально затоварен (40–310 мес стока) → многие ячейки корректно −1.0 на коротких горизонтах; цель #1959 — сделать ячейки РАЗНЫМИ, не нарисовать дефицит. Сегментация БЕЗ дедупа проваливается (инфляция давит ratio). ### Coverage (feasible): Железнодорожный — class 100% non-NULL (комфорт/стандарт/бизнес, lowercase), rooms_int 100% (0–4). ### Спек реализации - **`market_metrics.py`** (`_STOCK_SQL` L276, `_SALES_WINDOW_SQL` L324, `compute_market_metrics` L354): добавить `room_bucket` param + в cache-key; переписать оба SQL на (a) дедуп `DISTINCT ON (физ-ключ) ... ORDER BY ..., snapshot_date DESC, id DESC`, (b) фильтр `LOWER(class)` + room-bucket (как `_SOURCE_B_SQL` в `sales_series.py:389`). CAST(:x AS t), не `::`. - **`demand_supply_forecast.py:603`**: пробросить `obj_class=spec.obj_class, room_bucket=spec.room_bucket` в `compute_market_metrics`. ⚠️ **Vocab-mismatch (load-bearing)**: `SegmentSpec.room_bucket` из `what_to_build` = Source-B лейблы («2-к 45-60»), а `market_metrics` = «студия/1/2/3». Нужен транслятор, иначе фильтр тихо вернёт 0 строк. Подтвердить vocab `what_to_build._DEFAULT_ROOM_BUCKETS` перед wiring. - **`future_supply.py` L312 / `supply_layers.py` `_L1_OPEN_SQL` L187**: сегментированный open-сток считать из ДЕДУПнутого `objective_lots` (переиспользовать deduped n_available из market_metrics), L2/L3 **исключить** из посегментного баланса (class-agnostic, room нет в domrf → double-count). Отдельно дедупнуть `compute_layer1_open` = ядро #1964 (coupled, флаг backend-engineer). - **Migration** (`data/sql/NN_*.sql`, next seq — DISTINCT ON по 1.76M без индекса = sort): `CREATE INDEX IF NOT EXISTS objective_lots_physflat_latest_idx ON objective_lots (project_name,corpus_name,section,floor,lot_number,snapshot_date DESC,id DESC) WHERE premise_kind='квартира';` BEGIN/COMMIT, идемпотентно. Рассмотреть `v_objective_lots_latest` как единый dedup-источник. **Применить миграцию ПЕРЕД кодом.** - **Thin segments**: existing None-propagation (deduped n=0 → metrics None → ячейка drop). `_confidence` должен считать по DEDUPED counts (raw 4.4× завышает). - **Honesty-guard** (из тикета): пока не выкатили — когда deficit упёрт в ±1.0 на ВСЕХ ячейках, не печатать категоричные «избегать». **Регресс-тест (pass = ячейки НЕ все равны):** h=24 комфорт/студия≈+0.73, бизнес/1≈+0.45, комфорт/1≈−0.14, стандарт/студия=−1.0; MOI комфорт/студия≈38.5 vs стандарт/студия≈244. → #1959 теперь = coupled с #1964 (дедуп). Батч 4.
Sign in to join this conversation.
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#1959
No description provided.