fix(tradein): убрать OR region_code IS NULL из median-SQL location_index
All checks were successful
CI Trade-In / changes (pull_request) Successful in 16s
CI Trade-In / browser-tests (pull_request) Has been skipped
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 / changes (pull_request) Successful in 19s
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m33s

Строгое region_code = CAST(:region_code AS integer), без OR-ветки на NULL.

Второй прод-замер (13.09.2026, полный набор фильтров модуля — is_active,
price sanity, geo_precision, свежесть 14 дней, сегмент, bbox ядра 66):
15 858 строк проходят фильтры, из них 3 с NULL region_code. Медиана с ними
151 101 ₽/м², без них 151 103 — сдвиг 2 рубля на 151 тысяче (0.001%),
для региона 66 статистически ничто.

Мягкий OR NULL был бы лазейкой того же класса, что и сам дефект: в
listings 16 930 NULL-region строк Avito, 24 из них уже лежат в bbox
Москвы (сегодня не проходят по свежести, но могут завтра) — OR NULL
тихо открыл бы им дорогу обратно в московскую медиану тем же путём,
каким туда раньше попадала Московская область.

Тесты: test_median_queries_do_not_admit_null_region_code (обе median-SQL
не содержат "OR region_code" / "region_code IS NULL"), комментарий перед
SQL переписан под новое решение с этими числами.
This commit is contained in:
bot-backend 2026-09-13 13:45:32 +03:00
parent def21dc796
commit 3fae2b4ed8
2 changed files with 28 additions and 10 deletions

View file

@ -218,11 +218,17 @@ def _pct_deviation(local_median_ppm2: float, city_median_ppm2: float) -> float:
# лежало 16 801 лот региона 50 (медиана 251 295 ₽/м²) и 82 719 лотов региона 77 (медиана
# 458 208) — примесь заниженная городскую медиану до 422 222 (7.9%). У областных строк
# `city IS NULL`, фильтровать по имени города нельзя — только по region_code.
# `OR region_code IS NULL` — НЕ дыра для Москвы (внутри её bbox_product_core таких строк
# сегодня 0, проверено на проде), а сохранение прежнего поведения региона 66: там есть
# горстка NULL-region_code листингов (untagged legacy до бэкафилла), которые старый
# запрос (без фильтра по региону вообще) всегда учитывал. Без OR-ветки эта правка
# незаметно сдвинула бы медиану 66, хотя тикет требует byte-for-byte.
#
# СТРОГОЕ равенство, БЕЗ `OR region_code IS NULL`: строка без региона не считается ни за
# один регион. Второй прод-замер (13.09.2026, полный набор фильтров модуля — is_active,
# price sanity, geo_precision, свежесть 14 дней, сегмент, bbox ядра 66): 15 858 строк
# проходят фильтры, из них 3 с NULL region_code; медиана с ними 151 101 ₽/м², без них
# 151 103 — сдвиг 2 рубля на 151 тысяче (0.001%), статистически ничто для 66. Взамен —
# `region_code IS NULL` НЕ постоянная лазейка того же класса, что и сам дефект: в
# `listings` 16 930 NULL-region строк Avito, 24 из них лежат в bbox Москвы и сегодня не
# проходят по свежести — но могут завтра. Мягкий `OR NULL` тихо открыл бы им дорогу
# обратно в московскую медиану ровно тем же путём, каким туда попадала Московская
# область. Три рубля на 66 — цена закрытия дыры, а не повод её оставить.
_MEDIAN_PPM2_LOCAL_SQL = text(
"""
SELECT
@ -236,7 +242,7 @@ _MEDIAN_PPM2_LOCAL_SQL = text(
AND (geo_precision IS DISTINCT FROM 'city')
AND scraped_at > NOW() - (:fresh_days || ' days')::interval
AND (listing_segment IS NULL OR listing_segment = 'vtorichka')
AND (region_code = CAST(:region_code AS integer) OR region_code IS NULL)
AND region_code = CAST(:region_code AS integer)
AND lat BETWEEN CAST(:bbox_south AS double precision)
AND CAST(:bbox_north AS double precision)
AND lon BETWEEN CAST(:bbox_west AS double precision)
@ -262,7 +268,7 @@ _MEDIAN_PPM2_CITYWIDE_SQL = text(
AND (geo_precision IS DISTINCT FROM 'city')
AND scraped_at > NOW() - (:fresh_days || ' days')::interval
AND (listing_segment IS NULL OR listing_segment = 'vtorichka')
AND (region_code = CAST(:region_code AS integer) OR region_code IS NULL)
AND region_code = CAST(:region_code AS integer)
AND lat BETWEEN CAST(:bbox_south AS double precision)
AND CAST(:bbox_north AS double precision)
AND lon BETWEEN CAST(:bbox_west AS double precision)

View file

@ -161,9 +161,21 @@ def test_median_queries_filter_by_region_code() -> None:
for sql in (lc._MEDIAN_PPM2_LOCAL_SQL, lc._MEDIAN_PPM2_CITYWIDE_SQL):
sql_text = str(sql.text)
assert "region_code = CAST(:region_code AS integer)" in sql_text
# NULL region_code остаётся в пуле — иначе региона 66 (где часть legacy-листингов
# не размечена) сместился бы, хотя тикет требует byte-for-byte для 66.
assert "OR region_code IS NULL" in sql_text
def test_median_queries_do_not_admit_null_region_code() -> None:
"""Строгое равенство, БЕЗ `OR region_code IS NULL` — строка без региона не считается
ни за один регион. Второй прод-замер (13.09.2026, полный набор фильтров модуля, bbox
ядра 66): 15 858 строк проходят фильтры, 3 из них с NULL region_code; медиана с ними
151 101 /м², без них 151 103 сдвиг 2 рубля на 151 тысяче (0.001%), для 66
статистически ничто. Мягкий `OR NULL` был бы лазейкой того же класса, что и сам
дефект: в `listings` 16 930 NULL-region строк Avito, 24 из них уже лежат в bbox
Москвы (сегодня не проходят по свежести, но могут завтра) `OR NULL` тихо открыл бы
им дорогу обратно в московскую медиану."""
for sql in (lc._MEDIAN_PPM2_LOCAL_SQL, lc._MEDIAN_PPM2_CITYWIDE_SQL):
sql_text = str(sql.text)
assert "region_code IS NULL" not in sql_text
assert "OR region_code" not in sql_text
def test_local_and_citywide_median_bind_region_code_of_the_given_region() -> None: