fix(mera/estimate): убран предикат d.rooms в коридоре ДКП — он был вторым фильтром по площади (#3256) #3445

Merged
bot-backend merged 4 commits from fix/3256-asking-to-sold-buckets into main 2026-09-11 21:59:19 +00:00
5 changed files with 331 additions and 18 deletions

View file

@ -2185,8 +2185,11 @@ def get_street_deals(
"""ДКП-сделки Росреестра по улице целевого адреса.
Open dataset Росреестра агрегирует адреса до улицы (без номера дома).
Поэтому это per-street view, не per-house. Фильтр по rooms + area
сужает выборку до квартир-аналогов.
Поэтому это per-street view, не per-house. До квартир-аналогов выборку
сужает полоса площади ±area_tolerance; комнатность клиента в фильтр НЕ
входит `deals.rooms` не комнатность, а синтетика из той же площади
(#3256, см. estimator._fetch_dkp_corridor). `rooms` остаётся параметром
ручки: он описывает запрос и попадает в лог, но не в WHERE.
После PR-A (#549) таблица deals содержит только ДКП (ДДУ-первичка отфильтрована
в import-rosreestr.sh).
@ -2250,7 +2253,10 @@ def get_street_deals(
AND address ILIKE :street_pattern
AND address ~* :street_regex
{city_filter}
AND rooms = CAST(:rooms AS integer)
-- #3256: фильтра по rooms нет — deals.rooms синтезирована из площади
-- (тот же CASE 30/44/62/85, что area_bucket), т.е. это был второй
-- ступенчатый фильтр по площади поверх полосы ±15% ниже. Развёрнуто
-- в комментарии estimator._fetch_dkp_corridor.
AND area_m2 BETWEEN :area_min AND :area_max
AND deal_date > NOW() - (CAST(:period_months AS integer) || ' months')::interval
AND price_rub > 0
@ -2261,7 +2267,6 @@ def get_street_deals(
"street_pattern": "%" + street_name + "%",
"street_regex": r"\m" + street_name + r"\M",
"target_city": target_city.lower() if target_city else None,
"rooms": rooms,
"area_min": area_min,
"area_max": area_max,
"period_months": period_months,
@ -2272,12 +2277,16 @@ def get_street_deals(
)
if not rows:
# #3256: лог называет ТОТ ключ, которым искали. Комнатность клиента в
# выборку не входит (deals.rooms — синтетика из площади), поэтому она
# печатается как контекст запроса, а не как параметр фильтра.
logger.info(
"street-deals: no rows found street=%r rooms=%d area=%.1f±%.0f%%",
"street-deals: no rows found street=%r area=%.1f±%.0f%% "
"(ключ по комнатам не применяется, #3256; комнатность клиента=%d)",
street_name,
rooms,
area_m2,
area_tolerance * 100,
rooms,
)
return StreetDealsResponse(
street=street_name,
@ -2305,12 +2314,14 @@ def get_street_deals(
top10 = [_deal_to_analog(dict(r)) for r in rows[:10]]
logger.info(
"street-deals: street=%r rooms=%d area=%.1f count=%d median_ppm2=%.0f",
"street-deals: street=%r area=%.1f±%.0f%% count=%d median_ppm2=%.0f "
"(ключ по комнатам не применяется, #3256; комнатность клиента=%d)",
street_name,
rooms,
area_m2,
area_tolerance * 100,
count,
median_ppm2,
rooms,
)
return StreetDealsResponse(

View file

@ -1846,8 +1846,13 @@ def _fetch_dkp_corridor(
"""#652: коридор ₽/м² по реальным ДКП-сделкам Росреестра для target.
Reuse паттерна street-deals (api/v1/trade_in.py): извлекаем улицу из адреса,
фильтруем `deals` (source='rosreestr', та же rooms, площадь ±tolerance, окно
фильтруем `deals` (source='rosreestr', площадь ±tolerance, окно
period_months) и нормализуем per-. Возвращаем low/median/high /м².
Фильтра по rooms тут НЕТ осознанно (#3256): `deals.rooms` — бакет площади,
а не комнатность, поэтому предикат по нему был вторым фильтром по площади
и вырезал полосу ±tolerance (у 180 клиентов целиком). См. якорь в
deploy/import-rosreestr.sh. `rooms` в сигнатуре остаётся как гейт
«запрос без комнатности не оценка», но в SQL не уходит.
ADVISORY caller не клампит, только сурфейсит + опциональная пометка.
Best-effort: None при отсутствии улицы / сделок / любой ошибке.
NB: при n>=clamp_min_n коридор клампит headline + radius-floor (НЕ чисто
@ -1877,6 +1882,34 @@ def _fetch_dkp_corridor(
return None
area_min = area * (1.0 - area_tolerance)
area_max = area * (1.0 + area_tolerance)
# #3256: предиката по `d.rooms` здесь НЕТ НАМЕРЕННО — и это не послабление
# фильтра, а снятие ВТОРОГО фильтра по площади.
#
# `deals.rooms` — не комнатность, а синтетика из площади: Росреестр комнатность
# не отдаёт, import-rosreestr.sh пишет в эту колонку тот же CASE 30/44/62/85, что
# и `asking_to_sold_ratio.area_bucket()`. Прод-замер 2026-09-11: 321 559 из
# 321 560 сделок удовлетворяют `rooms == area_bucket(area_m2)`, max(rooms) = 4.
# Значит `d.rooms = X` тождественно `d.area_m2 ∈ [граница_X, граница_X+1)`, т.е.
# ЛЮБОЙ предикат по rooms — это ещё одна полоса по площади поверх `area_m2
# BETWEEN :area_min AND :area_max` ниже, режущая её по границам бакета.
#
# Замер по 1179 реальным запросам (trade_in_estimates, 2026-09-12), доля полосы
# ±15%, которая переживает предикат:
# d.rooms = комнаты клиента → медиана 77.8%, у 180 клиентов ОТ ПОЛОСЫ НЕ
# ОСТАЁТСЯ НИЧЕГО (пересечение пусто ⇒ коридор невозможен в принципе:
# «3 комнаты, 100 м²» → [85,115] ∩ bucket3[62,85) = ∅; у 5+ комнат — всегда,
# в deals.rooms нет значения > 4);
# d.rooms = area_bucket(area) → медиана 90.0%, ноль пустых, но у 902 из 1179
# полоса всё ещё усечена — ровно там, где площадь клиента легла близко к
# границе бакета (44.0 м² → сохраняется 50% полосы), т.е. величина усечения
# определяется не моделью, а случайным положением метража;
# без предиката → 100% по построению.
# Коридор строится по «похожим по площади сделкам на этой улице», и полоса ±15%
# уже ровно это и выражает — второй ступенчатый фильтр поверх неё только портит
# выборку. Историю с промежуточным вариантом `area_bucket` см. в #3256.
#
# `rooms` в сигнатуре ОСТАЁТСЯ: он всё ещё гейтит вызов (запрос без комнатности —
# не оценка) и не даёт молча посчитать коридор на полупустом payload.
# #C1/#C2 city-scope (консолидировано, п.3 dedup #2488↔#2489): сделка обязана
# быть в ГОРОДЕ целевого адреса. city приходит из _resolve_target_city (lower-case,
# словарь ~30 крупных городов обл.66 вкл. ЕКБ + все sweep-города). deals.city в БД
@ -1897,7 +1930,7 @@ def _fetch_dkp_corridor(
AND d.region_code = CAST(:region_code AS int)
AND d.address ILIKE :street_pattern
AND d.address ~* :street_regex
AND d.rooms = CAST(:rooms AS integer)
-- #3256: фильтра по d.rooms нет — см. комментарий выше
AND d.area_m2 BETWEEN :area_min AND :area_max
AND d.deal_date > NOW()
- (CAST(:period_months AS integer) || ' months')::interval
@ -1916,7 +1949,6 @@ def _fetch_dkp_corridor(
{
"street_pattern": "%" + street_name + "%",
"street_regex": r"\m" + street_name + r"\M",
"rooms": rooms,
"area_min": area_min,
"area_max": area_max,
"period_months": period_months,
@ -1982,7 +2014,7 @@ def _fetch_dkp_corridor(
AND d.region_code = CAST(:region_code AS int)
AND d.city IS NOT NULL
AND LOWER(d.city) = CAST(:target_city AS text)
AND d.rooms = CAST(:rooms AS integer)
-- #3256: фильтра по d.rooms нет — см. комментарий выше
AND d.area_m2 BETWEEN :area_min AND :area_max
AND d.deal_date > NOW()
- (CAST(:period_months AS integer) || ' months')::interval
@ -1993,7 +2025,6 @@ def _fetch_dkp_corridor(
),
{
"target_city": city.lower(),
"rooms": rooms,
"area_min": area_min,
"area_max": area_max,
"period_months": period_months,
@ -4931,7 +4962,6 @@ async def estimate_quality(
db,
lat=geo.lat,
lon=geo.lon,
rooms=payload.rooms,
area=payload.area_m2,
radius_m=base_radius_m,
)
@ -6599,8 +6629,14 @@ def _is_plausible_deal(
def _fetch_deals(
db: Session, *, lat: float, lon: float, rooms: int, area: float, radius_m: int
db: Session, *, lat: float, lon: float, area: float, radius_m: int
) -> list[dict[str, Any]]:
"""#3256: параметр `rooms` УБРАН, а не просто перестал попадать в SQL.
Сделки Росреестра не несут комнатность (см. _fetch_dkp_corridor), поэтому
сравнивать её здесь не с чем оставленный «на будущее» аргумент только
приглашал бы вернуть предикат, который на самом деле фильтровал по площади.
"""
rows = (
db.execute(
text(
@ -6614,7 +6650,10 @@ def _fetch_deals(
ST_Distance(geom::geography, ST_MakePoint(:lon, :lat)::geography) AS distance_m
FROM deals
WHERE ST_DWithin(geom::geography, ST_MakePoint(:lon, :lat)::geography, :radius)
AND rooms = :rooms
-- #3256: фильтра по rooms нет — deals.rooms синтезирована из площади
-- (тот же CASE 30/44/62/85, что area_bucket), т.е. это был второй
-- ступенчатый фильтр по площади поверх полосы ±15% ниже. Развёрнуто
-- в комментарии _fetch_dkp_corridor.
AND area_m2 BETWEEN :area_min AND :area_max
AND deal_date > NOW() - (:months || ' months')::interval
AND price_rub > 0
@ -6626,7 +6665,6 @@ def _fetch_deals(
"lat": lat,
"lon": lon,
"radius": radius_m,
"rooms": rooms,
"area_min": area * (1 - AREA_TOLERANCE),
"area_max": area * (1 + AREA_TOLERANCE),
"months": DEALS_PERIOD_MONTHS,

View file

@ -78,7 +78,42 @@ CAVEATS (read these before trusting the numbers)
MAPE и бьёт по классам с редкой застройкой сильнее прочих: часть перекоса
по 4+ комнатам цена такой привязки, а не ошибка модели. Любой замер, где
сделка связывается с КОНКРЕТНЫМ зданием по геометрии (материал стен,
этажность, цена собственного дома), этим скомпрометирован.
этажность, цена собственного дома), этим скомпрометирован. СКЛАДЫВАЕТСЯ
с (e) ниже (неправильное МЕСТО + неправильный СЕГМЕНТ), см. там же.
(e) АНАЛОГИ ПОДБИРАЮТСЯ ПО СИНТЕТИЧЕСКОЙ КОМНАТНОСТИ (#3256). `deals.rooms` —
не комнатность, а бакет площади (границы 30/44/62/85, см. import-rosreestr.sh
и asking_to_sold_ratio.area_bucket): прод-замер 2026-09-11 321 559 из
321 560 сделок удовлетворяют rooms == area_bucket(area_m2), max(rooms) = 4.
Харнес отдаёт это значение в `_fetch_analogs(rooms=deal.rooms)`, который
матчит его с РЕАЛЬНОЙ `listings.rooms`. В прод приходит настоящая комнатность
клиента, т.е. для сделок 85 м² харнес меряет ДРУГОЙ пул аналогов, чем прод:
4-комнатные объявления вместо 3-комнатных. Внутри полосы 85-120 м² медиана
/м² по комнатам 203 692 / 162 896 / 129 735 / 100 000 (2/3/4/5 комнат),
шаг 20-26%. Прод-фикстура 11.09 это подтверждает: медиана аналогов бакета
«4 >=85» = 130 000 /м², т.е. ровно 4-комнатная полоса, хотя 66% вторички
этого метража 3-комнатная. СЛЕДСТВИЕ: бакеты `per_area_bucket` «3 62-85»
и «4 >=85» НЕ ГОДЯТСЯ как цель калибровки `asking_to_sold_ratios` они
меряют смещение чужого пула аналогов, а не промах коэффициента.
ОСТАЛЬНЫЕ БАКЕТЫ НЕ «ЧИСТЫ», а лишь МЕНЬШЕ СМЕЩЕНЫ. Прод-замер 2026-09-12
по ТОМУ ЖЕ пулу, который видит `_fetch_analogs` (is_active, свежесть
LISTINGS_FRESH_DAYS=14, вторичка, регион 66), доля объявлений с
`rooms == area_bucket(area_m2)`:
бакет 0 69.8% (n=2536), 1 63.5% (n=5977), 2 60.1% (n=6176),
бакет 3 54.6% (n=4117), 4 30.9% (n=2088).
Т.е. в бакетах 0-3 модальная комнатность СОВПАДАЕТ с бакетом (подмена
сдвигает пул на соседнюю комнатность у 30-45% лотов, направление в среднем
не одностороннее), а в бакете 4 мода 3 комнаты (54.5% пула), и совпадение
всего 30.9%: там подмена систематически ПЕРЕКЛЮЧАЕТ пул на 4-комнатный.
NB: #3256 снял синтетический ключ со СДЕЛОЧНОЙ стороны (deals в
`_fetch_dkp_corridor`/`_fetch_deals`/`/street-deals`), а этот перекос живёт
на ЛИСТИНГОВОЙ стороне харнеса (`_fetch_analogs(rooms=deal.rooms)`) и
остаётся в силе.
(d) И (e) СКЛАДЫВАЮТСЯ, А НЕ СПОРЯТ: (d) says «сделка привязана к центроиду
улицы, а не к дому» аналоги берутся из неправильного МЕСТА; (e) says
«комнатность аналога синтетическая» из неправильного СЕГМЕНТА. Оба бьют
сильнее всего по крупному метражу (85 м²), поэтому наблюдаемый перекос
«4+ комнаты» это их СУММА, и списывать его целиком на любой один из них
(а тем более на модель) нельзя.
PERFORMANCE
-----------

View file

@ -0,0 +1,217 @@
"""#3256: сделочная сторона НЕ фильтруется по `deals.rooms`.
`deals.rooms` Росреестра синтетика из площади: import-rosreestr.sh пишет туда тот
же CASE 30/44/62/85, что `asking_to_sold_ratio.area_bucket`. Прод-замер 2026-09-11:
321 559 из 321 560 сделок удовлетворяют `rooms == area_bucket(area_m2)`,
`max(rooms) = 4`. Значит `d.rooms = X` тождественно `d.area_m2 [граница_X,
граница_X+1)` ЛЮБОЙ предикат по rooms был вторым, ступенчатым фильтром по площади
поверх полосы `area_m2 BETWEEN :area_min AND :area_max`, стоящей рядом.
Прод-замер по 1179 реальным запросам (trade_in_estimates, 2026-09-12) какая доля
полосы ±15% переживает предикат:
d.rooms = комнаты клиента медиана 77.8%, у 180 запросов полоса вырезана
ЦЕЛИКОМ (пересечение пусто коридора нет никогда);
d.rooms = area_bucket(area) медиана 90.0%, пустых нет, но у 902 из 1179 полоса
всё ещё усечена границей бакета;
без предиката 100% по построению.
Тесты ПО ЗНАЧЕНИЮ: фейковая сессия перехватывает SQL и bind-параметры, уходящие в
Postgres. На origin/main в каждом из трёх мест есть и предикат `rooms = ...`, и
bind-параметр `rooms` тесты краснеют ФАКТОМ фильтра, а не отсутствием атрибута.
"""
from __future__ import annotations
import os
import re
from pathlib import Path
from typing import Any
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from app.api.v1.trade_in import get_street_deals
from app.services.estimator import _fetch_deals, _fetch_dkp_corridor
from app.tasks.asking_to_sold_ratio import area_bucket
_EKB_ADDRESS = "Екатеринбург, улица Ленина, 5"
# Путь к импортёру: tests/ → backend/ → tradein-mvp/ → deploy/
_IMPORTER = Path(__file__).resolve().parents[2] / "deploy" / "import-rosreestr.sh"
class _Result:
"""Пустой результат: и `.mappings().all()`, и итерация дают ноль строк."""
def mappings(self) -> _Result:
return self
def all(self) -> list[Any]:
return []
def __iter__(self):
return iter(())
class _RecordingDB:
"""Сессия-перехватчик: копит (sql, params) каждого execute, строк не отдаёт."""
def __init__(self) -> None:
self.calls: list[tuple[str, dict[str, Any]]] = []
def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> _Result:
self.calls.append((str(getattr(stmt, "text", stmt)), dict(params or {})))
return _Result()
def _rooms_predicates(db: _RecordingDB) -> list[str]:
"""Строки SQL, где по rooms стоит СРАВНЕНИЕ (предикат), из всех запросов к deals.
Ищем `rooms =` / `rooms IN` / `rooms BETWEEN` (с необязательным алиасом `d.`).
Колонка `rooms` в SELECT-списке витрины не предикат и сюда не попадает.
"""
rx = re.compile(r"\b(?:\w+\.)?rooms\s*(?:=|<|>|IN\b|BETWEEN\b)", re.IGNORECASE)
hits: list[str] = []
for sql, _params in db.calls:
if "deals" not in sql.lower():
continue
hits += [line.strip() for line in sql.splitlines() if rx.search(line)]
return hits
def _rooms_binds(db: _RecordingDB) -> list[Any]:
"""Значения bind-параметра `rooms`, ушедшие в запросы к deals (должно быть пусто)."""
return [p["rooms"] for sql, p in db.calls if "rooms" in p and "deals" in sql.lower()]
def _area_bands(db: _RecordingDB) -> list[tuple[float, float]]:
return [
(p["area_min"], p["area_max"])
for sql, p in db.calls
if "area_min" in p and "deals" in sql.lower()
]
# ── ДКП-коридор (клампит headline, ставит radius-floor, кормит deals-fallback) ──
def test_dkp_corridor_has_no_rooms_predicate() -> None:
"""«3 комнаты, 100 м²»: в deals уходит ТОЛЬКО полоса площади, без rooms.
С предикатом `rooms=3` ( площадь 62-85) пересечение с полосой [85, 115] ПУСТО
коридор не строился вовсе. С предикатом `rooms=area_bucket(100)=4` ( площадь
85) полоса усекалась до [85, 115] (нижние 8.5 м² отрезаны без причины).
"""
db = _RecordingDB()
_fetch_dkp_corridor(db, address=_EKB_ADDRESS, rooms=3, area=100.0, city="екатеринбург")
assert db.calls, "запрос к deals вообще не ушёл"
assert _rooms_predicates(db) == []
assert _rooms_binds(db) == []
def test_dkp_corridor_area_band_is_the_only_similarity_filter() -> None:
"""Полоса ±15% доходит до SQL целиком И остаётся ЕДИНСТВЕННЫМ фильтром похожести.
Вторая половина утверждения та, что краснеет: на origin/main рядом с полосой
стоит `d.rooms = ...`, т.е. фильтров по площади там два (полоса и бакет), и
итоговое окно их пересечение.
"""
db = _RecordingDB()
_fetch_dkp_corridor(db, address=_EKB_ADDRESS, rooms=2, area=50.0, city="екатеринбург")
assert _area_bands(db) == [(50.0 * 0.85, 50.0 * 1.15)]
assert _rooms_predicates(db) == []
def test_dkp_corridor_five_room_client_is_reachable() -> None:
"""5+ комнат: в deals.rooms значения >4 нет, предикат `rooms=5` не нашёл бы ничего."""
db = _RecordingDB()
_fetch_dkp_corridor(db, address=_EKB_ADDRESS, rooms=5, area=140.0, city="екатеринбург")
assert _rooms_predicates(db) == []
assert _rooms_binds(db) == []
def test_dkp_corridor_query_does_not_depend_on_client_rooms() -> None:
"""Один метраж — один и тот же SQL и параметры, какой бы комнатность ни назвали.
Прямое свойство синтетической колонки: `deals.rooms` = f(area_m2), значит выборка
сделок не имеет права зависеть от комнатности клиента ни через что.
"""
seen: set[tuple] = set()
for rooms in (1, 2, 3, 4, 5, 6):
db = _RecordingDB()
_fetch_dkp_corridor(db, address=_EKB_ADDRESS, rooms=rooms, area=95.0, city="екатеринбург")
seen.add(tuple((sql, tuple(sorted(p.items()))) for sql, p in db.calls))
assert len(seen) == 1, "выборка сделок всё ещё зависит от комнатности клиента"
# ── Радиусные сделки (блок «реальные сделки рядом» + expected_sold) ───────────
def test_fetch_deals_has_no_rooms_predicate() -> None:
"""`_fetch_deals` (радиус) — тот же ключ: только полоса площади."""
db = _RecordingDB()
_fetch_deals(db, lat=56.83, lon=60.6, area=130.0, radius_m=1000)
assert _rooms_predicates(db) == []
assert _rooms_binds(db) == []
assert _area_bands(db) == [(130.0 * 0.85, 130.0 * 1.15)]
# ── Витрина «сделки на улице» ────────────────────────────────────────────────
def test_street_deals_has_no_rooms_predicate() -> None:
"""GET /street-deals: «3 комнаты, 92 м²» — витрина не опустошается предикатом."""
db = _RecordingDB()
get_street_deals(address=_EKB_ADDRESS, area_m2=92.0, rooms=3, db=db) # type: ignore[arg-type]
assert _rooms_predicates(db) == []
assert _rooms_binds(db) == []
# ── ЯКОРЬ: импортёр всё ещё пишет в deals.rooms бакет площади ─────────────────
def _parse_importer_case() -> list[tuple[float, int]]:
"""Границы CASE `<area → rooms` из import-rosreestr.sh + значение ветки ELSE.
Возвращает [(граница, значение), ...] + [(inf, ELSE)]. Падает, если CASE
исчез/переименован это тоже сигнал «импортёр переписали, вернись в #3256».
"""
src = _IMPORTER.read_text(encoding="utf-8")
m = re.search(r"CASE\s+(WHEN\s+area\s*<.+?ELSE\s+\d+\s+END)\s+AS\s+rooms", src, re.S)
assert m, "CASE ... AS rooms не найден в import-rosreestr.sh — см. якорь #3256"
body = m.group(1)
edges = [
(float(a), int(b))
for a, b in re.findall(r"WHEN\s+area\s*<\s*([\d.]+)\s+THEN\s+(\d+)", body)
]
else_m = re.search(r"ELSE\s+(\d+)\s+END", body)
assert else_m
return [*edges, (float("inf"), int(else_m.group(1)))]
def test_importer_case_still_matches_area_bucket() -> None:
"""ЯКОРЬ #3256: границы импортёра совпадают с area_bucket() — иначе правка мертва.
Ни один другой тест не покраснеет, если импортёр начнёт писать НАСТОЯЩУЮ
комнатность: все потребители просто продолжат молча ключеваться площадью (точнее,
перестанут ключеваться вовсе, а area-полоса начнёт означать не то). Этот тест
единственное место, которое свяжет два файла и заставит вернуться в #3256.
"""
edges = _parse_importer_case()
assert [e for e, _v in edges] == [30.0, 44.0, 62.0, 85.0, float("inf")], (
"границы CASE в import-rosreestr.sh разошлись с area_bucket() — см. якорь #3256"
)
assert [v for _e, v in edges] == [0, 1, 2, 3, 4]
def test_importer_case_and_area_bucket_agree_pointwise() -> None:
"""Та же сверка по значениям: на границах и между ними CASE == area_bucket()."""
edges = _parse_importer_case()
def importer_rooms(area: float) -> int:
for edge, value in edges:
if area < edge:
return value
raise AssertionError("ELSE-ветка не покрыла area")
for area in (0.1, 18.0, 29.9, 30.0, 43.9, 44.0, 61.9, 62.0, 84.9, 85.0, 120.0, 200.0):
assert importer_rooms(area) == area_bucket(area), f"расхождение на area={area}"

View file

@ -68,6 +68,18 @@ docker exec "$SRC_PG" psql -U "$SRC_USER" -d "$SRC_DB" -v ON_ERROR_STOP=on -c "
trim(city) || ', ' || trim(street) AS address,
region_code AS region_code,
trim(city) AS city,
-- ЯКОРЬ #3256: это НЕ комнатность, а бакет площади. Границы 30/44/62/85
-- обязаны совпадать с asking_to_sold_ratio.area_bucket() — совпадение
-- проверяет tests/test_3256_deals_rooms_key.py, который ПАРСИТ этот CASE.
-- ПОМЕНЯЕШЬ НА РЕАЛЬНУЮ КОМНАТНОСТЬ — вернись в #3256: потребители
-- (estimator._fetch_dkp_corridor, _fetch_deals, API /street-deals) сейчас
-- НЕ фильтруют по deals.rooms ИМЕННО потому, что здесь синтетика; с
-- настоящей комнатностью предикат нужно вернуть, иначе все сайты молча
-- продолжат ключеваться площадью.
-- ОСТАВШИЙСЯ потребитель, который ВСЁ ЕЩЁ фильтрует по d.rooms:
-- TVF street_sales_vs_listings (data/sql/211_sales_vs_listings_segment_guard.sql:89),
-- её зовёт API /sales-vs-listings (trade_in.py). Там ключ асимметричный —
-- d.rooms синтетика, l.rooms настоящая, — копипастой из #3256 не чинится.
CASE
WHEN area < 30 THEN 0 WHEN area < 44 THEN 1
WHEN area < 62 THEN 2 WHEN area < 85 THEN 3