gendesign/backend/app/services/forecasting/what_to_build.py
Light1YT 41804ed70e fix(forecast): посегментный+дедуплицированный индекс дефицита (#1959)
Корень «−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.
2026-06-27 22:50:58 +05:00

368 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""§9.7 «что строить» — ранкер сегментной сетки по индексу дефицита (ТЗ §9.7).
#981 (952-B, Site Finder v2 / «GG-форсайт» ТЗ §9.7), EPIC 8 «что строить +
доступность». Это **СБОРОЧНЫЙ слой над #980**: прогоняет уже-смерженный
центральный движок `compute_demand_supply_forecast` (§9.8) по СЕТКЕ сегментов
(класс × room_bucket [× price_bucket]) для участка/горизонта и РАНЖИРУЕТ ячейки по
`deficit_index` (по убыванию = сила сигнала «строить»). Это продуктовый ответ §9.7
«строить студии? двушки? какой класс?». Своей §9.x-математики НЕ пересобирает —
вызывает #980 per-cell. Всё ДЕТЕРМИНИРОВАННО, БЕЗ LLM; своего SQL НЕТ.
ИНТЕРПРЕТАЦИЯ ИНДЕКСА (наследуется от #980 _deficit_index, см. его docstring):
• deficit_index > 0 → НЕДОобеспечен (спроса больше предложения) = СИГНАЛ СТРОИТЬ.
• deficit_index = 0 → баланс спрос↔предложение.
• deficit_index < 0 → ЗАТОВАРКА (предложения больше спроса) = ИЗБЕГАТЬ.
• deficit_index None → тонкие данные (предложение неизмеримо) — ячейка ОТБРАСЫВАЕТСЯ.
Ранжируем по убыванию: топ списка = самый сильный сигнал «строить именно это».
ADVISORY (КРИТИЧНО — честность): КАЖДАЯ ячейка наследует advisory-статус #980 и его
жёсткий потолок confidence ≤ 'medium' (движок не провалидирован до бэктеста #951).
Поэтому и весь ранкинг СОВЕТУЮЩИЙ: `generated_advisory` ВСЕГДА True. Цифры — для
explainability/прототипа, не основание для инвест-решения.
СЕТКА (сознательно НЕ взрываем до 1000 ячеек): основные оси — room_bucket × class
(дефолтная сетка из bucket-вокабуляра sales_series). price_bucket опционален
(по умолчанию один «без фильтра цены» проход — иначе ×4 ячеек ради оси, которую
#980 §9.5 учитывает грубо). Все оси — named-константы; вызывающий может сузить.
room_bucket source-семантика (см. sales_series.SegmentSpec): дефолтные ключи —
Source-B room_area-вокабуляр ('Студии 15-30' / '1-к 30-45' / …), т.к. это вывод
room_area_bucket_of. #980 пробрасывает room_bucket в §9.4/§9.5 profile; для агрегатов
класс/район Source-A ('студия'/'1'/'2'/…) — вызывающий может передать свой набор.
Graceful (дух demand_supply_forecast): пустая сетка / все ячейки None → ПУСТОЙ
ранкинг (не crash). Сбой #980 на отдельной ячейке → ячейка пропускается, остальные
ранжируются. Детерминированно (стабильная сортировка с tie-break по segment-ключу).
"""
from __future__ import annotations
import logging
from collections.abc import Sequence
from dataclasses import dataclass
from typing import Any, Literal
from sqlalchemy.orm import Session
from app.services.forecasting.demand_supply_forecast import compute_demand_supply_forecast
from app.services.forecasting.sales_series import (
ROOM_AREA_BUCKET_1K,
ROOM_AREA_BUCKET_2K,
ROOM_AREA_BUCKET_3K,
ROOM_AREA_BUCKET_LARGE,
ROOM_AREA_BUCKET_STUDIO,
SegmentSpec,
)
logger = logging.getLogger(__name__)
Confidence = Literal["high", "medium", "low"]
# ── Named-константы (дефолтная сетка) ─────────────────────────────────────────
# Дефолтные классы недвижимости (ось «класс»). ЕКБ-вокабуляр Объектива в
# «человеческом» регистре — SQL внутри §9.x нормализует регистр. 3 массовых класса
# (премиум опускаем из дефолта — тонкий сегмент, обычно low-данные; вызывающий
# может добавить). Держим явной константой для детерминизма сетки.
# #1959: нижний массовый класс objective_lots.class — «стандарт», НЕ «эконом»
# (verified на prod: стандарт=483k лотов, эконома НЕТ → раньше эта ячейка матчила
# 0 строк → молча выпадала из сетки; посегментный дефицит её не видел).
_DEFAULT_CLASSES: tuple[str, ...] = ("стандарт", "комфорт", "бизнес")
# Дефолтные room-bucket'ы (ось «формат») — Source-B room_area-вокабуляр
# (вывод room_area_bucket_of). 5 форматов: студии / 1-к / 2-к / 3-к / 80+. Это
# продуктовая ось §9.7 «какой формат строить».
_DEFAULT_ROOM_BUCKETS: tuple[str, ...] = (
ROOM_AREA_BUCKET_STUDIO,
ROOM_AREA_BUCKET_1K,
ROOM_AREA_BUCKET_2K,
ROOM_AREA_BUCKET_3K,
ROOM_AREA_BUCKET_LARGE,
)
# Дефолтная ось цены: один проход БЕЗ price-фильтра (None). Сознательно не дробим
# по price_bucket в дефолте — иначе сетка ×4 ради оси, которую #980 §9.5 учитывает
# лишь грубо (price_tier в profile). Вызывающий может передать реальные bucket'ы.
_DEFAULT_PRICE_BUCKETS: tuple[str | None, ...] = (None,)
# Ранг уверенности для tie-break (выше = лучше). Зеркало demand_supply_forecast.
_CONFIDENCE_RANK: dict[Confidence, int] = {"low": 0, "medium": 1, "high": 2}
@dataclass(frozen=True)
class RankedSegment:
"""Одна ячейка сетки §9.7 с её сигналом дефицита (наследует #980 на горизонте).
`deficit_index` ∈ [1,+1]: >0 = строить (недообеспечен), <0 = избегать
(затоварка). Ячейки с deficit_index None (тонкие данные) в ранкинг НЕ попадают
(отброшены ранкером). `confidence` ≤ 'medium' (наследуется от #980 advisory-cap).
"""
segment: dict[str, str | None]
deficit_index: float # не None в ранкинге (None-ячейки отброшены)
balance_units: float | None # demand supply (>0 дефицит / <0 затоварка)
confidence: Confidence # ≤ 'medium' (#980 advisory-cap)
projected_demand_units: float | None = None # #1745: спрос на горизонт (квартир)
def as_dict(self) -> dict[str, Any]:
return {
"segment": dict(self.segment),
"deficit_index": _round_or_none(self.deficit_index, 3),
"balance_units": _round_or_none(self.balance_units, 1),
"projected_demand_units": _round_or_none(self.projected_demand_units, 1),
"confidence": self.confidence,
}
@dataclass(frozen=True)
class WhatToBuildRanking:
"""§9.7 ранжированный ответ «что строить» для участка/горизонта (ТЗ §9.7).
`ranked` отсортирован по `deficit_index` УБЫВАНИЕ (топ = сильнейший сигнал
«строить»); ячейки с deficit_index None отброшены (тонкие данные). Пустой
`ranked` = вся сетка тонкая / нет данных (graceful, не crash).
`generated_advisory` ВСЕГДА True — каждая ячейка наследует advisory-статус #980;
ранкинг СОВЕТУЮЩИЙ, не основание для инвест-решения.
"""
district: str | None
cad_num: str
horizon_months: int
ranked: list[RankedSegment] # DESC по deficit_index; None-ячейки отброшены
n_cells_scanned: int # сколько ячеек сетки прогнали (включая отброшенные)
n_cells_ranked: int # сколько попало в ранкинг (deficit_index не None)
generated_advisory: bool # ВСЕГДА True (наследует advisory #980)
def as_dict(self) -> dict[str, Any]:
return {
"district": self.district,
"cad_num": self.cad_num,
"horizon_months": self.horizon_months,
"ranked": [r.as_dict() for r in self.ranked],
"n_cells_scanned": self.n_cells_scanned,
"n_cells_ranked": self.n_cells_ranked,
"generated_advisory": self.generated_advisory,
}
def _round_or_none(value: float | None, digits: int) -> float | None:
return round(value, digits) if value is not None else None
# ──────────────────────────────────────────────────────────────────────────────
# Pure-логика — без БД, полностью юнит-тестируемо.
# ──────────────────────────────────────────────────────────────────────────────
def _rank_key(cell: RankedSegment) -> tuple[float, int, str]:
"""Ключ сортировки ячейки сетки для ранжирования §9.7. PURE.
Сортируем по (deficit_index DESC, confidence DESC, segment-ключ ASC):
1. deficit_index — primary (НЕГАТИВ для DESC через sorted без reverse-флага,
чтобы tie-break'и шли в естественном ASC-порядке): больше дефицит = выше.
2. confidence — tie-break: при равном индексе выше та ячейка, чьему сигналу
больше доверия (negate ранга для DESC).
3. segment-ключ — финальный СТАБИЛЬНЫЙ tie-break (детерминизм при равных
index+confidence): лексикографический по компактной строке сегмента.
Используется как `sorted(cells, key=_rank_key)` — БЕЗ reverse: негативы
primary/secondary дают убывание, а строковый ключ — возрастание. PURE.
Args:
cell: ячейка сетки (deficit_index не None — None-ячейки отброшены до сорта).
Returns:
Кортеж-ключ (deficit_index, confidence_rank, segment_key).
"""
confidence_rank = _CONFIDENCE_RANK.get(cell.confidence, 0)
return (-cell.deficit_index, -confidence_rank, _segment_key(cell.segment))
def _segment_key(segment: dict[str, str | None]) -> str:
"""Стабильная компактная строка сегмента для детерминированного tie-break. PURE.
Конкатенация осей в фиксированном порядке (class|room|district|price); None →
пустая строка. Не для отображения — только для устойчивой сортировки.
"""
return "|".join(
str(segment.get(axis) or "")
for axis in ("obj_class", "room_bucket", "district", "price_bucket")
)
def _build_grid(
*,
district: str | None,
classes: Sequence[str],
room_buckets: Sequence[str],
price_buckets: Sequence[str | None],
) -> list[SegmentSpec]:
"""Декартова сетка SegmentSpec из осей класс × room × price. PURE.
По одному SegmentSpec на комбинацию (class, room_bucket, price_bucket); district
проставляется во все (общий для участка). Порядок детерминирован (class-внешний,
room-средний, price-внутренний) — стабильный n_cells_scanned. PURE.
Args:
district: район участка (общий для всех ячеек).
classes: ось классов.
room_buckets: ось форматов (room_bucket).
price_buckets: ось цены (может содержать None = «без фильтра цены»).
Returns:
Список SegmentSpec — len = |classes|×|room_buckets|×|price_buckets|.
"""
grid: list[SegmentSpec] = []
for obj_class in classes:
for room_bucket in room_buckets:
for price_bucket in price_buckets:
grid.append(
SegmentSpec(
obj_class=obj_class,
room_bucket=room_bucket,
district=district,
price_bucket=price_bucket,
)
)
return grid
# ──────────────────────────────────────────────────────────────────────────────
# DB-оркестратор — тонкий, graceful. Pure-логика выше тестируется без него.
# ──────────────────────────────────────────────────────────────────────────────
def rank_segments(
db: Session,
*,
district: str | None,
cad_num: str,
horizon_months: int,
classes: Sequence[str] = _DEFAULT_CLASSES,
room_buckets: Sequence[str] = _DEFAULT_ROOM_BUCKETS,
price_buckets: Sequence[str | None] = _DEFAULT_PRICE_BUCKETS,
rate_path: dict[int, float] | None = None,
) -> WhatToBuildRanking:
"""§9.7 ранжировать сегменты по индексу дефицита для участка/горизонта.
ADVISORY (каждая ячейка наследует advisory-статус #980, см. module docstring) —
НЕ подключать в production-эндпоинт. СБОРКА над #980, НЕ пересчёт §9.x.
Прогоняет `compute_demand_supply_forecast` (#980) по сетке класс × room × price
(дефолт — _build_grid из named-констант). Для КАЖДОЙ ячейки берёт прогноз на
`horizon_months`, собирает (segment, deficit_index, balance_units, confidence),
ОТБРАСЫВАЕТ ячейки с deficit_index None (тонкие данные — предложение неизмеримо),
сортирует по `deficit_index` УБЫВАНИЕ (tie-break: confidence ↓, затем стабильный
segment-ключ). Топ списка = сильнейший сигнал «строить именно это».
Graceful: пустая сетка / все ячейки None → ПУСТОЙ ранкинг (не crash). Сбой #980
на ячейке → ячейка пропускается, остальные ранжируются. Детерминированно.
Args:
db: SQLAlchemy sync Session.
district: район участка (None → ЕКБ-wide).
cad_num: кадастровый номер участка — вход #980 (§9.7 конкуренты).
horizon_months: горизонт прогноза (мес) — ОДИН (продуктовый «на горизонте»).
classes: ось классов (по умолчанию _DEFAULT_CLASSES).
room_buckets: ось форматов (по умолчанию _DEFAULT_ROOM_BUCKETS, Source-B).
price_buckets: ось цены (по умолчанию _DEFAULT_PRICE_BUCKETS = [None]).
rate_path: сценарный {horizon: key_rate} для #980; None → hold_last_rate.
Returns:
WhatToBuildRanking — DESC-ранкинг (всегда; пустой при тонкой сетке).
"""
grid = _build_grid(
district=district,
classes=classes,
room_buckets=room_buckets,
price_buckets=price_buckets,
)
cells: list[RankedSegment] = []
for spec in grid:
cell = _forecast_cell(
db,
spec=spec,
district=district,
cad_num=cad_num,
horizon_months=horizon_months,
rate_path=rate_path,
)
if cell is not None:
cells.append(cell)
ranked = sorted(cells, key=_rank_key)
logger.info(
"what_to_build: district=%s cad_num=%s horizon=%d scanned=%d ranked=%d "
"top_segment=%s top_deficit=%s (ADVISORY)",
district,
cad_num,
horizon_months,
len(grid),
len(ranked),
ranked[0].segment if ranked else None,
_round_or_none(ranked[0].deficit_index, 3) if ranked else None,
)
return WhatToBuildRanking(
district=district,
cad_num=cad_num,
horizon_months=horizon_months,
ranked=ranked,
n_cells_scanned=len(grid),
n_cells_ranked=len(ranked),
generated_advisory=True,
)
def _forecast_cell(
db: Session,
*,
spec: SegmentSpec,
district: str | None,
cad_num: str,
horizon_months: int,
rate_path: dict[int, float] | None,
) -> RankedSegment | None:
"""Прогнать #980 для ОДНОЙ ячейки сетки → RankedSegment | None. Graceful.
Вызывает compute_demand_supply_forecast на единственном горизонте, берёт его
deficit_index/balance_units/confidence. None при: deficit_index None (тонкие
данные — ячейка вне ранкинга), пустом результате, ИЛИ сбое #980 (не валим всю
сетку — ячейка просто выпадает). Детерминированно.
Returns:
RankedSegment или None (отбрасывается выше по стеку).
"""
try:
forecasts = compute_demand_supply_forecast(
db,
spec=spec,
district=district,
cad_num=cad_num,
horizons=[horizon_months],
rate_path=rate_path,
)
except Exception:
logger.exception(
"what_to_build: forecast failed for cell segment=%s horizon=%d",
spec.as_dict(),
horizon_months,
)
return None
if not forecasts:
return None
forecast = forecasts[0]
if forecast.deficit_index is None:
# Тонкие данные (предложение неизмеримо) — ячейка вне ранкинга (НЕ 0-сигнал).
return None
return RankedSegment(
segment=forecast.segment,
deficit_index=forecast.deficit_index,
balance_units=forecast.balance_units,
confidence=forecast.confidence,
projected_demand_units=forecast.projected_demand_units,
)