fix(estimator): убрать предикат по deals.rooms, а не подставлять в него area-бакет
Разворот предыдущего коммита ветки (a780e3e6) на корень: вместо подстановки `area_bucket(area)` в предикат `d.rooms = ...` предикат УДАЛЁН во всех трёх местах. ПОЧЕМУ НЕ БАКЕТ. `deals.rooms` — синтетика из площади (321 559 из 321 560 сделок удовлетворяют `rooms == area_bucket(area_m2)`, max(rooms)=4), значит `d.rooms = X` тождественно `d.area_m2 ∈ [граница_X, граница_X+1)`. Это ВТОРОЙ, ступенчатый фильтр по площади поверх полосы `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 полоса всё ещё усечена: 44.0 м² → сохраняется 50% полосы, 62.0 м² → 50%, 82.6 м² → 59.7%. Величину усечения задаёт не модель, а случайное положение метража относительно границ 30/44/62/85. без предиката 100% по построению Т.е. бакет-ключ чинит катастрофический случай (пустое пересечение) и оставляет произвольное усечение у 76.5% запросов. Полоса ±15% уже выражает «похожие по площади сделки» — второго фильтра по тому же признаку быть не должно. ЗАМЕР ЭФФЕКТА НА ЦЕНУ (1179 запросов, все три пути влияния коридора на headline: cap/floor, sufficiency-гейт #oblast-E, deals-headline-fallback; листинговая сторона берётся из сохранённой оценки, коридор пересчитан на сегодняшнем снимке deals для всех вариантов, поэтому сравнение apples-to-apples; реплика сверена с ПРОДОВЫМ SQL на 58 оценках × 3 варианта — 174/174 совпадений): коридор доступен n>=3: 769 → 850 (бакет, +86/−5) → 874 (без ключа, +105/−0) n>=10: 567 → 623 (бакет, +66/−10) → 691 (без ключа, +126/−2) сдвиг headline vs текущий прод бакет без ключа клиентов сдвинулось 28 120 медиана сдвига +1.0% −1.6% p10 / p90 −30.6% / +6.1% −8.6% / +4.3% сдвиг > ±10% 10 (все вниз) 10 (7 вниз, 3 вверх) сдвиг > ±25% 4 2 по путям (медиана сдвига): бакет без ключа cap/floor, радиусная медиана +3.9% (p10 −36.3%) −1.7% (p10 −5.9%) cap/floor, якорь Tier C −10.1% (5 сдвигов, 4 из них >10% вниз) −0.8% sufficiency-гейт −0.4% +0.3% deals-fallback +3.5% (p10 −20.8%) −0.1% якорь Tier A 0 (коридор не влияет: cap exempt, floor требует anchor_tier is None) Вариант без ключа даёт больше покрытия (+105/−0 против +86/−5), сдвиг с медианой около нуля и БЕЗ кластера сильных падений, тогда как бакет-ключ несёт кластер Tier C с медианой −10.1%. Худший случай (−41.8%, Малышева 84, 1к/54 м²: премиальный лот прижимается cap'ом к коридору улицы) ОБЩИЙ для обоих вариантов — он появляется от самого факта наличия коридора, а не от выбора ключа. УТОЧНЕНИЕ ФАКТА ИЗa780e3e6: «у 818 клиентов выборка не меняется» — неверно, их 793. Скрипт классифицировал через `min(max(rooms,0),4) == area_bucket`, из-за чего 27 клиентов с 5-6 комнатами попали в «совпадающие», хотя у них выборка меняется с пустой на непустую. (Практического прироста они всё равно не получают: их метраж 158-456 м² в основном вне окна импорта `area BETWEEN 18 AND 200`.) ЯКОРЬ ПРОТИВ МОЛЧАЛИВОГО ВОЗВРАТА. Ни один тест не краснел, если импортёр начнёт писать настоящую комнатность. tests/test_3256_deals_rooms_key.py теперь ПАРСИТ CASE из deploy/import-rosreestr.sh и сверяет его границы с `area_bucket()` (поточечно, на границах и между ними); у самого CASE стоит комментарий-якорь «поменяешь на реальную комнатность — вернись в #3256». Каверза (e) харнеса: формулировка «бакеты 0-2 чисты» УБРАНА как неверная. Замер по тому же пулу, который видит `_fetch_analogs` (свежесть 14 дней, вторичка, регион 66): совпадение rooms == area_bucket — бакет 0: 69.8%, 1: 63.5%, 2: 60.1%, 3: 54.6%, 4: 30.9%. В бакетах 0-3 модальная комнатность совпадает с бакетом, в бакете 4 — нет (мода 3, 54.5% пула). Добавлена перекрёстная ссылка: каверзы (d) и (e) СКЛАДЫВАЮТСЯ (неправильное МЕСТО + неправильный СЕГМЕНТ), а не спорят. Логи витрины `/street-deals` называли `rooms=%d` комнатностью клиента, хотя фильтра по ней в запросе уже нет — теперь печатают фактический ключ (полосу площади), а комнатность помечена как контекст запроса. НЕ входит в этот PR (заводится отдельно): TVF `street_sales_vs_listings` (data/sql/211_*.sql:89,113) — там асимметричный ключ (`d.rooms` синтетика, `l.rooms` настоящая), копипастой не чинится; каверза (e) для app/tasks/landing_showcase_deals.py:415/426. Refs #3256
This commit is contained in:
parent
a780e3e66e
commit
16d99e0f1a
5 changed files with 227 additions and 102 deletions
|
|
@ -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).
|
||||
|
|
@ -2197,7 +2200,6 @@ def get_street_deals(
|
|||
_resolve_target_city,
|
||||
extract_street_name,
|
||||
)
|
||||
from app.tasks.asking_to_sold_ratio import area_bucket
|
||||
|
||||
now = datetime.now(tz=UTC)
|
||||
# #1381: отображаемое окно должно совпадать с SQL-фильтром ниже, который
|
||||
|
|
@ -2251,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
|
||||
|
|
@ -2262,12 +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,
|
||||
# #3256: deals.rooms — синтетика из площади (тот же CASE 30/44/62/85,
|
||||
# что area_bucket), а не комнатность. Сравнение с РЕАЛЬНЫМИ комнатами
|
||||
# клиента опустошает витрину «сделки на улице» ровно там, где площадь
|
||||
# нетипична для комнатности (и ВСЕГДА при 5+ комнатах). Ключуем тем же
|
||||
# area-бакетом — см. развёрнутый комментарий в estimator._fetch_dkp_corridor.
|
||||
"rooms": area_bucket(area_m2),
|
||||
"area_min": area_min,
|
||||
"area_max": area_max,
|
||||
"period_months": period_months,
|
||||
|
|
@ -2278,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,
|
||||
|
|
@ -2311,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(
|
||||
|
|
|
|||
|
|
@ -1877,20 +1877,34 @@ def _fetch_dkp_corridor(
|
|||
return None
|
||||
area_min = area * (1.0 - area_tolerance)
|
||||
area_max = area * (1.0 + area_tolerance)
|
||||
# #3256: `deals.rooms` — НЕ комнатность, а синтетика из площади (Росреестр
|
||||
# комнатность не отдаёт; import-rosreestr.sh пишет тот же CASE 30/44/62/85, что
|
||||
# area_bucket()). Прод-замер 2026-09-11: 321 559 из 321 560 сделок удовлетворяют
|
||||
# rooms == area_bucket(area_m2), max(rooms) = 4. Значит предикат
|
||||
# `d.rooms = <РЕАЛЬНЫЕ комнаты клиента>` — переодетый фильтр по площади, который
|
||||
# ПРОТИВОРЕЧИТ area-полосе ниже, как только у клиента нетипичная для его площади
|
||||
# комнатность: у «3 комнаты, 100 м²» пересечение [85,115] ∩ bucket3(62-85) пусто,
|
||||
# а у любого клиента с 5+ комнатами пусто ВСЕГДА (в deals.rooms нет значения >4).
|
||||
# Замер по 1177 реальным запросам (trade_in_estimates): у 359 (30.5%) ключ не
|
||||
# совпадал с area-бакетом, коридор был пуст у 46.2% из них против 8.2% у
|
||||
# совпадающих; у «3 комнаты, ≥85 м²» — 83.5% пустых, у 5-6 комнат — 100%.
|
||||
# Ключуем сделочную сторону тем же area-бакетом: для клиентов, у которых
|
||||
# комнатность и так совпадает с бакетом (818 из 1177), выборка не меняется.
|
||||
deals_rooms_key = area_bucket(area)
|
||||
# #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 в БД
|
||||
|
|
@ -1911,7 +1925,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
|
||||
|
|
@ -1930,7 +1944,6 @@ def _fetch_dkp_corridor(
|
|||
{
|
||||
"street_pattern": "%" + street_name + "%",
|
||||
"street_regex": r"\m" + street_name + r"\M",
|
||||
"rooms": deals_rooms_key, # #3256: area-бакет, см. комментарий выше
|
||||
"area_min": area_min,
|
||||
"area_max": area_max,
|
||||
"period_months": period_months,
|
||||
|
|
@ -1996,7 +2009,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
|
||||
|
|
@ -2007,7 +2020,6 @@ def _fetch_dkp_corridor(
|
|||
),
|
||||
{
|
||||
"target_city": city.lower(),
|
||||
"rooms": deals_rooms_key, # #3256: area-бакет, см. выше
|
||||
"area_min": area_min,
|
||||
"area_max": area_max,
|
||||
"period_months": period_months,
|
||||
|
|
@ -4945,7 +4957,6 @@ async def estimate_quality(
|
|||
db,
|
||||
lat=geo.lat,
|
||||
lon=geo.lon,
|
||||
rooms=payload.rooms,
|
||||
area=payload.area_m2,
|
||||
radius_m=base_radius_m,
|
||||
)
|
||||
|
|
@ -6613,8 +6624,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(
|
||||
|
|
@ -6628,7 +6645,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
|
||||
|
|
@ -6640,11 +6660,6 @@ def _fetch_deals(
|
|||
"lat": lat,
|
||||
"lon": lon,
|
||||
"radius": radius_m,
|
||||
# #3256: тот же ключ, что в _fetch_dkp_corridor — deals.rooms
|
||||
# синтезирована из площади, поэтому сравнивать её с РЕАЛЬНОЙ
|
||||
# комнатностью клиента значит молча опустошать выборку (у 5+ комнат
|
||||
# — всегда, в deals.rooms нет значения > 4).
|
||||
"rooms": area_bucket(area),
|
||||
"area_min": area * (1 - AREA_TOLERANCE),
|
||||
"area_max": area * (1 + AREA_TOLERANCE),
|
||||
"months": DEALS_PERIOD_MONTHS,
|
||||
|
|
|
|||
|
|
@ -78,7 +78,8 @@ 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 из
|
||||
|
|
@ -92,8 +93,27 @@ CAVEATS (read these before trusting the numbers)
|
|||
«4 >=85» = 130 000 ₽/м², т.е. ровно 4-комнатная полоса, хотя 66% вторички
|
||||
этого метража — 3-комнатная. СЛЕДСТВИЕ: бакеты `per_area_bucket` «3 62-85»
|
||||
и «4 >=85» НЕ ГОДЯТСЯ как цель калибровки `asking_to_sold_ratios` — они
|
||||
меряют смещение чужого пула аналогов, а не промах коэффициента. Бакеты 0-2
|
||||
чисты: там площадь задаёт комнатность почти однозначно.
|
||||
меряют смещение чужого пула аналогов, а не промах коэффициента.
|
||||
ОСТАЛЬНЫЕ БАКЕТЫ НЕ «ЧИСТЫ», а лишь МЕНЬШЕ СМЕЩЕНЫ. Прод-замер 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
|
||||
-----------
|
||||
|
|
|
|||
|
|
@ -1,21 +1,30 @@
|
|||
"""#3256: сделочная сторона ключуется area-бакетом, а не комнатностью клиента.
|
||||
"""#3256: сделочная сторона НЕ фильтруется по `deals.rooms`.
|
||||
|
||||
`deals.rooms` Росреестра — синтетика из площади (import-rosreestr.sh пишет тот же
|
||||
CASE 30/44/62/85, что `asking_to_sold_ratio.area_bucket`). Прод-замер 2026-09-11:
|
||||
`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`. Поэтому предикат `deals.rooms = <РЕАЛЬНЫЕ комнаты клиента>` —
|
||||
переодетый фильтр по площади, который молча опустошает выборку, как только
|
||||
комнатность клиента нетипична для его метража (а при 5+ комнатах — всегда).
|
||||
`max(rooms) = 4`. Значит `d.rooms = X` тождественно `d.area_m2 ∈ [граница_X,
|
||||
граница_X+1)` — ЛЮБОЙ предикат по rooms был вторым, ступенчатым фильтром по площади
|
||||
поверх полосы `area_m2 BETWEEN :area_min AND :area_max`, стоящей рядом.
|
||||
|
||||
Тесты ПО ЗНАЧЕНИЮ: фейковая сессия перехватывает bind-параметры, уходящие в
|
||||
Postgres, и мы сверяем ИМЕННО ЧИСЛО в ключе `rooms`. На origin/main туда уходит
|
||||
комнатность клиента (3 / 5 / 6), т.е. тест краснеет значением, а не отсутствием
|
||||
атрибута.
|
||||
Прод-замер по 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")
|
||||
|
|
@ -26,6 +35,9 @@ 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()`, и итерация дают ноль строк."""
|
||||
|
|
@ -51,84 +63,149 @@ class _RecordingDB:
|
|||
return _Result()
|
||||
|
||||
|
||||
def _rooms_keys(db: _RecordingDB) -> list[Any]:
|
||||
"""Значения bind-параметра `rooms` из всех запросов к deals."""
|
||||
return [p["rooms"] for _sql, p in db.calls if "rooms" in p]
|
||||
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
|
||||
|
||||
|
||||
# ── ДКП-коридор (клампит headline и ставит radius-floor) ──────────────────────
|
||||
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 test_dkp_corridor_keys_deals_by_area_bucket_not_client_rooms() -> None:
|
||||
"""«3 комнаты, 100 м²»: в deals уходит бакет 4, а не 3.
|
||||
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()
|
||||
]
|
||||
|
||||
С ключом 3 пересечение `rooms=3` (62-85 м²) с area-полосой [85, 115] ПУСТО —
|
||||
коридор не строится вовсе. Прод-замер по 1177 реальным запросам: у «3 комнаты,
|
||||
≥85 м²» коридор пуст в 83.5% случаев против 8.2% у клиентов, чья комнатность
|
||||
совпадает с area-бакетом.
|
||||
|
||||
# ── ДКП-коридор (клампит 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 _rooms_keys(db) == [4]
|
||||
assert _rooms_keys(db) == [area_bucket(100.0)]
|
||||
assert db.calls, "запрос к deals вообще не ушёл"
|
||||
assert _rooms_predicates(db) == []
|
||||
assert _rooms_binds(db) == []
|
||||
|
||||
|
||||
def test_dkp_corridor_five_room_client_gets_reachable_key() -> None:
|
||||
"""5 комнат: в deals.rooms значения >4 не существует — ключ 5 не найдёт ничего."""
|
||||
db = _RecordingDB()
|
||||
_fetch_dkp_corridor(db, address=_EKB_ADDRESS, rooms=5, area=140.0, city="екатеринбург")
|
||||
keys = _rooms_keys(db)
|
||||
assert keys == [4]
|
||||
assert max(keys) <= 4 # прод-инвариант: max(deals.rooms) = 4
|
||||
|
||||
|
||||
def test_dkp_corridor_unchanged_when_rooms_matches_bucket() -> None:
|
||||
"""Клиент с типичной комнатностью (2 комнаты, 50 м²) — выборка та же, что была.
|
||||
|
||||
818 из 1177 реальных запросов попадают сюда: у них ключ и был равен area-бакету,
|
||||
поэтому правка обязана быть для них байт-в-байт нейтральной.
|
||||
"""
|
||||
def test_dkp_corridor_keeps_full_area_band() -> None:
|
||||
"""Полоса ±15% доходит до SQL целиком — это единственный фильтр «похожести»."""
|
||||
db = _RecordingDB()
|
||||
_fetch_dkp_corridor(db, address=_EKB_ADDRESS, rooms=2, area=50.0, city="екатеринбург")
|
||||
assert _rooms_keys(db) == [2]
|
||||
assert _area_bands(db) == [(50.0 * 0.85, 50.0 * 1.15)]
|
||||
|
||||
|
||||
def test_dkp_corridor_key_depends_on_area_only() -> None:
|
||||
"""Один метраж — один ключ, какой бы комнатность ни назвал клиент.
|
||||
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) == []
|
||||
|
||||
Это и есть свойство синтетической колонки: `deals.rooms` = f(area_m2), значит
|
||||
ключ не имеет права зависеть ни от чего другого.
|
||||
|
||||
def test_dkp_corridor_query_does_not_depend_on_client_rooms() -> None:
|
||||
"""Один метраж — один и тот же SQL и параметры, какой бы комнатность ни назвали.
|
||||
|
||||
Прямое свойство синтетической колонки: `deals.rooms` = f(area_m2), значит выборка
|
||||
сделок не имеет права зависеть от комнатности клиента ни через что.
|
||||
"""
|
||||
keys = []
|
||||
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="екатеринбург")
|
||||
keys.extend(_rooms_keys(db))
|
||||
assert keys == [4, 4, 4, 4, 4, 4]
|
||||
seen.add(tuple((sql, tuple(sorted(p.items()))) for sql, p in db.calls))
|
||||
assert len(seen) == 1, "выборка сделок всё ещё зависит от комнатности клиента"
|
||||
|
||||
|
||||
# ── Радиусные сделки (блок «реальные сделки рядом» + expected_sold) ───────────
|
||||
|
||||
|
||||
def test_fetch_deals_keys_by_area_bucket() -> None:
|
||||
"""`_fetch_deals` (радиус) — тот же ключ: 6 комнат, 130 м² → бакет 4."""
|
||||
def test_fetch_deals_has_no_rooms_predicate() -> None:
|
||||
"""`_fetch_deals` (радиус) — тот же ключ: только полоса площади."""
|
||||
db = _RecordingDB()
|
||||
_fetch_deals(db, lat=56.83, lon=60.6, rooms=6, area=130.0, radius_m=1000)
|
||||
assert _rooms_keys(db) == [4]
|
||||
|
||||
|
||||
def test_fetch_deals_unchanged_for_typical_studio() -> None:
|
||||
"""Студия 25 м² (комнатность 0) — ключ прежний, выборка не меняется."""
|
||||
db = _RecordingDB()
|
||||
_fetch_deals(db, lat=56.83, lon=60.6, rooms=0, area=25.0, radius_m=1000)
|
||||
assert _rooms_keys(db) == [0]
|
||||
_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_keys_by_area_bucket() -> None:
|
||||
"""GET /street-deals: «3 комнаты, 92 м²» → в deals уходит 4, иначе витрина пуста."""
|
||||
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_keys(db) == [4]
|
||||
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}"
|
||||
|
|
|
|||
|
|
@ -68,6 +68,14 @@ 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 ИМЕННО потому, что здесь синтетика; с
|
||||
-- настоящей комнатностью предикат нужно вернуть, иначе все сайты молча
|
||||
-- продолжат ключеваться площадью.
|
||||
CASE
|
||||
WHEN area < 30 THEN 0 WHEN area < 44 THEN 1
|
||||
WHEN area < 62 THEN 2 WHEN area < 85 THEN 3
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue