diff --git a/tradein-mvp/backend/app/services/location_index.py b/tradein-mvp/backend/app/services/location_index.py index 8e437b9d..8e1c67f3 100644 --- a/tradein-mvp/backend/app/services/location_index.py +++ b/tradein-mvp/backend/app/services/location_index.py @@ -168,16 +168,39 @@ def _pct_deviation(local_median_ppm2: float, city_median_ppm2: float) -> float: # price_per_m2 BETWEEN sanity-границы — не бизнес-калибровка, а защита от битых строк # (см. _PRICE_PER_M2_SANITY_MIN/MAX выше). # -# #2660 свежесть + сегмент — оба предиката ЗЕРКАЛЯТ _COMMON_WHERE эстиматора: -# - scraped_at > NOW() - LISTINGS_FRESH_DAYS: `is_active` на проде не означает -# «живо» — деактиватор протухших работает не на всех источниках, и в пуле -# висят объявления, не виденные месяцами. Прод-замер (пул location_index, -# bbox ЕКБ): 30 222 активных строк, свежих 11 453; медиана 172 984 ₽/м² по -# всему пулу против 163 363 по свежим. Порог тот же, что у эстиматора -# (LISTINGS_FRESH_DAYS) — витрина обязана смотреть на тот же пул, что и цена. -# - listing_segment guard (#1186): новостройки — девелоперский прайс, он -# завышает медиану и локальную, и городскую (прод: 19 003 новостройки из -# 30 222; медиана вторички 147 632 ₽/м²). NULL = legacy вторичка до м.011. +# #2660 свежесть + сегмент — оба предиката ЗЕРКАЛЯТ _COMMON_WHERE эстиматора. +# Вклад у них РАЗНЫЙ, и не тот, на который легко подумать. Прод-разложение +# (2026-08-05, пул location_index — bbox ЕКБ + sanity ₽/м² + geo_precision): +# +# было (только is_active) 30 222 строк 172 984 ₽/м² +# + только свежесть 11 453 строк 163 363 ₽/м² +# + только сегмент 11 219 строк 147 632 ₽/м² +# стало (оба) 7 715 строк 147 368 ₽/м² +# +# - listing_segment guard (#1186) — ЭТО и есть исправление смещения: из −14.8% +# сдвига городской медианы он даёт −14.7 п.п. Девелоперский прайс новостроек +# завышал и локальную, и городскую медиану. NULL = legacy вторичка до м.011. +# Мертвецы, кстати, живут почти целиком тут же: из 18 769 протухших строк +# пула 15 265 — новостройки, и гард выносит их заодно. +# - scraped_at > NOW() - LISTINGS_FRESH_DAYS — даёт ПОВЕРХ сегмента всего +# −0.18 п.п. Для ЭТОЙ метрики он не коррекция смещения, а СТРАХОВКА на +# будущее (пул совпадает с пулом цены; если завтра протухнет вторичка — +# виджет не соврёт), и страховка не бесплатная: выбрасывает 3 504 вторичных +# строки, из которых 2 724 — живые объявления, отскрейпленные 15-30 дней +# назад. Пул −31%, шум растёт: на центре ЕКБ (r=800) n падает 423 → 86, а +# сам индекс гуляет по выбору окна на 12-14 п.п. (7д +75.7% / 14д +77.0% / +# 21д +79.1% / 30д +64.7%) — при n=86 это в пределах шума выборки медианы. +# Размен «меньше смещения ↔ больше дисперсии» сделан осознанно: старое число +# было предвзятым, новое — шумным, но честным. Окно менять здесь НЕ надо, +# LISTINGS_FRESH_DAYS живёт в estimator.py (см. импорт выше). +# +# НОВЫЙ РЕЖИМ ОТКАЗА (знать обязательно): свежесть связала витрину со здоровьем +# СБОРА. Встанет скрейпинг на LISTINGS_FRESH_DAYS — городская выборка не наберёт +# MIN_SAMPLE_SIZE, и "insufficient_data" прилетит ВСЕМ пользователям разом; до +# этой правки виджет продолжал бы показывать устаревшее число. Учитывая, что +# #2574 — ровно месяц молчаливой поломки сбора, сценарий не гипотетический. +# Деградация честная (прочерк, а не выдуманное число), но она теперь массовая. +# # Порог MIN_SAMPLE_SIZE после сужения пула набирается реже, но лестница радиусов # упирается в отказ редко — прод-симуляция на 246 реальных точках оценок: # insufficient_data 0 → 1 точка (0.4%), 800м хватает 241 точке из 246. diff --git a/tradein-mvp/backend/tests/services/test_location_index.py b/tradein-mvp/backend/tests/services/test_location_index.py index 4b715fe2..cee70c8c 100644 --- a/tradein-mvp/backend/tests/services/test_location_index.py +++ b/tradein-mvp/backend/tests/services/test_location_index.py @@ -162,10 +162,21 @@ def test_median_queries_exclude_novostroyki() -> None: def test_freshness_window_is_the_estimator_constant_not_a_copy() -> None: """Второе определение свежести разъедется с эстиматором при первой перекалибровке — - витрина начнёт показывать пул, отличный от того, на котором считается цена.""" + витрина начнёт показывать пул, отличный от того, на котором считается цена. + + Проверяем ИСХОДНИК, а не равенство значений: CPython кэширует малые int (−5…256), + поэтому `lc.LISTINGS_FRESH_DAYS is estimator.LISTINGS_FRESH_DAYS` проходит и для + скопированного литерала `LISTINGS_FRESH_DAYS = 14` — то есть стережёт не то, что + обещает. Отсутствие присваивания в модуле = константа может быть только импортом. + """ + import inspect + from app.services import estimator - assert lc.LISTINGS_FRESH_DAYS is estimator.LISTINGS_FRESH_DAYS + assert "LISTINGS_FRESH_DAYS =" not in inspect.getsource( + lc + ), "константа скопирована в location_index — она должна ИМПОРТИРОВАТЬСЯ из estimator" + assert lc.LISTINGS_FRESH_DAYS == estimator.LISTINGS_FRESH_DAYS def test_both_median_queries_bind_the_same_freshness_window() -> None: