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

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 → «стандарт» (код уже маппит так).
This commit is contained in:
Light1YT 2026-06-27 23:02:09 +05:00
parent 41804ed70e
commit 3cafe22c15
2 changed files with 34 additions and 18 deletions

View file

@ -146,8 +146,9 @@ def map_class(target_class: str | None) -> str | None:
"""Live target_class → forecast obj_class. PURE. None/неизвестное → None.
Comfort/Comfort+ сворачиваются в «комфорт», Business/Elite в «бизнес»,
Economy в «эконом» (3 массовых класса §9.7). None None = «без фильтра
класса», вызывающий отдаёт движку дефолтную сетку классов.
Economy в «стандарт» (#1959: нижний массовый класс objective_lots —
«стандарт», эконома в данных нет). 3 массовых класса §9.7. None None =
«без фильтра класса», вызывающий отдаёт движку дефолтную сетку классов.
"""
if target_class is None:
return None

View file

@ -278,11 +278,20 @@ def _room_bucket(rooms_int: int | None) -> str:
# objective_lots — append-per-snapshot (~2.9× инфляция строк), без дедупа сток
# раздут и симметрично раздувает спрос И предложение → деградирует посегментный
# дефицит. Ключ физлота = (project_name, corpus_name, section, floor, lot_number);
# берём строку с MAX(snapshot_date), tie-break MAX(id) (зеркало
# objective_lots_physflat_latest_idx из 100_*.sql). project_name NOT NULL;
# corpus/section/floor/lot_number могут быть NULL (~2.5% лотов) — для таких лотов
# DISTINCT ON схлопывает NULL=NULL в одну группу (неидентифицируемые физлоты, в
# дефицит-расчёте безопасно), для остального — точный per-flat дедуп.
# берём строку с MAX(snapshot_date), tie-break MAX(id). Поддерживается индексом
# objective_lots_physflat_latest_idx (создаётся в миграции 173) — он совпадает с
# ключом и направлениями этого DISTINCT ON → Index Only Scan + Unique без Sort.
# project_name NOT NULL; corpus/section/floor/lot_number могут быть NULL (~2.5%
# лотов) — для таких лотов DISTINCT ON схлопывает NULL=NULL в одну группу
# (неидентифицируемые физлоты, в дефицит-расчёте безопасно), для остального —
# точный per-flat дедуп.
#
# ⚠️ ДЕДУП БЕЗУСЛОВНЫЙ — применяется ко ВСЕМ вызовам compute_market_metrics, не
# только к форсайт-пути. Deep-review (#1959) подтвердил: безопасно для остальных
# потребителей (absorption/MoS/sell_through — отношения; velocity — saturated),
# т.к. inflation симметрична в числителе/знаменателе и съедается шкалой. Дедуп
# здесь касается ТОЛЬКО выборки внутри метрик — дедуп самой сырой таблицы
# objective_lots (для всех platform-консьюмеров) остаётся отдельной задачей #1964.
#
# Сегмент-фильтры (#1959): obj_class (class в objective_lots — lowercase, матчим
# LOWER=LOWER) + room_bucket. ROOM-bucket — Source-B room_area-вокабуляр
@ -369,11 +378,15 @@ _STOCK_SQL = text(
# → velocity/absorption раздуты ~2.9× (раздувают спрос симметрично раздутому
# предложению → дефицит вырождается в 1.0). Дедупим в CTE `latest`, окно по
# contract_date и сегмент-фильтры применяем уже к дедуплицированному набору.
# Ключ/направления дедупа — зеркало _STOCK_SQL / objective_lots_physflat_latest_idx.
# contract_date / rooms_int / area_pd берём из последнего снапшота физлота.
# Ключ/направления дедупа — зеркало _STOCK_SQL (оба поддержаны индексом
# objective_lots_physflat_latest_idx из миграции 173). contract_date / rooms_int /
# area_pd берём из последнего снапшота физлота.
# Сегмент-фильтры (#1959): obj_class (lowercase, LOWER=LOWER) + room_bucket
# (Source-A вокабуляр студия/1/2/3/4/5+) — применяются ПОСЛЕ дедупа (объективны
# по физлоту). ROLLUP остаётся: при room_bucket-фильтре отдаёт {bucket, total}.
# (Source-B room_area-вокабуляр "Студии 15-30"/"1-к 30-45"/"2-к 45-60"/"3-к 60-80"/
# "80+ м²" — ТОЧНОЕ зеркало sales_series.room_area_bucket_of, как в _STOCK_SQL) —
# применяются ПОСЛЕ дедупа (объективны по физлоту: класс/комнатность/площадь не
# меняются между снапшотами). ROLLUP остаётся: при room_bucket-фильтре отдаёт
# {bucket, total}.
_SALES_WINDOW_SQL = text(
"""
WITH latest AS (
@ -452,13 +465,15 @@ def compute_market_metrics(
(имеет смысл для ЕКБ-wide baseline).
#1959 (посегментный дефицит): `obj_class` (lowercase в objective_lots,
матчим регистронезависимо) и `room_bucket` (Source-A вокабуляр
"студия"/"1"/"2"/"3"/"4"/"5+", зеркало `_room_bucket()`) сужают выборку до
одного сегмента. Так base_pace (unit_velocity) и доступный сток становятся
ПОСЕГМЕНТНЫМИ, а не district-wide (иначе все ячейки what_to_build делят один
темп дефицит вырождается в 1.0). None по обеим осям прежнее district-wide
поведение. ВАЖНО: SQL дедуплицирует objective_lots до последнего снапшота на
физлот counts (а значит и confidence) считаются на дедуплицированном наборе.
матчим регистронезависимо) и `room_bucket` (Source-B room_area-вокабуляр
"Студии 15-30"/"1-к 30-45"/"2-к 45-60"/"3-к 60-80"/"80+ м²", зеркало
`sales_series.room_area_bucket_of` what_to_build фильтрует БЕЗ перевода)
сужают выборку до одного сегмента. Так base_pace (unit_velocity) и доступный
сток становятся ПОСЕГМЕНТНЫМИ, а не district-wide (иначе все ячейки
what_to_build делят один темп дефицит вырождается в 1.0). None по обеим осям
прежнее district-wide поведение. ВАЖНО: SQL дедуплицирует objective_lots до
последнего снапшота на физлот counts (а значит и confidence) считаются на
дедуплицированном наборе.
Возвращает MarketMetrics ВСЕГДА (даже на пустых данных): тогда метрики =
None, confidence='low'. Никогда не бросает на отсутствии данных.