fix(tradein): тест ловит копию константы, а не equality; честный комментарий про вклад свежести (#2660)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 8s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 2m44s

По ревью PR #2664.

1. test_freshness_window_is_the_estimator_constant_not_a_copy проверял
   `lc.LISTINGS_FRESH_DAYS is estimator.LISTINGS_FRESH_DAYS` — CPython кэширует
   малые int, поэтому скопированный литерал `LISTINGS_FRESH_DAYS = 14` тест бы
   ПРОШЁЛ, хотя докстринг обещает ловить ровно это. Прошлая фальсификация
   срабатывала лишь потому, что откат удалял имя целиком (AttributeError).
   Теперь проверяем исходник через inspect.getsource — фальсифицировано
   подстановкой копии литерала вместо импорта: тест краснеет.

2. Комментарий в location_index.py приписывал свежести чужую заслугу.
   Прод-разложение: из −14.8% сдвига городской медианы −14.7 п.п. даёт
   сегментный гард и лишь −0.18 п.п. свежесть. Для этой метрики свежесть —
   не коррекция смещения, а страховка на будущее, оплаченная третью пула
   (3 504 вторичных строки, из них 2 724 живые) и ростом дисперсии: на центре
   ЕКБ n 423 → 86, индекс гуляет по выбору окна на 12-14 п.п. Размен верный,
   но он должен быть написан как размен.

   Там же задокументирован новый режим отказа: свежесть связала витрину со
   здоровьем сбора — встанет скрейпинг на 14 дней, и insufficient_data
   прилетит всем пользователям разом. Учитывая, что #2574 это месяц молчаливой
   поломки сбора, сценарий не гипотетический.

Окно свежести не меняю — вопрос вынесен отдельно.

Refs #2660
This commit is contained in:
bot-backend 2026-08-05 23:13:22 +05:00
parent 837ad8cfd4
commit d173163025
2 changed files with 46 additions and 12 deletions

View file

@ -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.

View file

@ -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 (5256),
поэтому `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: