Витрина «сделки против объявлений» перестаёт пустовать: снят предикат по синтетической комнатности сделок #3461

Merged
bot-backend merged 2 commits from fix/3451-tvf-deals-rooms into main 2026-09-12 09:03:06 +00:00
Collaborator

Closes #3451.

TVF street_sales_vs_listings() фильтровала сделки по d.rooms, а deals.rooms у источника rosreestrне комнатность, а бакет площади: импортёр пишет туда CASE WHEN area < 30 THEN 0 ... ELSE 4 END, и на проде 321 559 строк из 321 560 удовлетворяют rooms == area_bucket(area_m2). Предикат работал вторым фильтром по площади поверх полосы ±15 %, которую функция считает сама.

Та же патология снята в #3256 (PR #3445) на четырёх сделочных площадках эстиматора — здесь последний оставшийся потребитель.

Ключ асимметричный

  • d.rooms = p_roomsснят (синтетика из площади);
  • l.rooms = p_roomsоставлен: у объявлений комнатность настоящая, это единственный признак ассортимента на листинговой стороне. «Починить симметрично» = потерять его.

Прод-замер (2026-09-12, только чтение)

1160 реальных клиентских запросов из trade_in_estimates, улица извлеклась у 954 (у 206 — известная H1 «адрес вне словаря», к этой правке отношения не имеет). Считалось тем же путём, что у продукта: street/city резолвятся extract_street_name() / _resolve_target_city().

было стало
непустой ответ /sales-vs-listings 805 (84.4 %) 899 (94.2 %)
сделок в выборке суммарно 68 147 88 816
из них с подобранным объявлением 30 830 39 193
выборка сократилась хоть у кого-то 0 из 954

Впервые непустых — 94 клиента. В issue стоял прогноз «те же 180»: та оценка бралась по коридору эстиматора (другие period/tolerance и другой street-pattern), в PR кладётся измеренное, а не предсказанное.

Почему безопасно

  • Миграция 300 = тело 211 минус ровно одна строка (это проверяет test_only_the_rooms_predicate_differs_from_211); сигнатура и RETURNS TABLE побайтово те же — иначе CREATE OR REPLACE создал бы вторую перегрузку вместо замены (грабли #2627).
  • Сегментный гард #2660/#1186 и city-предикаты #2583 H4 перенесены дословно, тест это проверяет.
  • deal_rooms: int в SalesListingPair остаётся обязательным: ветка ELSE в CASE импортёра ловит и NULL-площадь, прод подтверждает 0 NULL в deals.rooms при source='rosreestr'. Контракт API не меняется.
  • Тело прогнано EXPLAIN'ом на боевой БД (только чтение) — планировщик принимает.

Фальсификация тестов (оба направления)

  • вернул AND d.rooms = p_rooms → красные test_deals_side_has_no_rooms_predicate, test_only_the_rooms_predicate_differs_from_211;
  • снял заодно AND l.rooms = p_rooms («починил симметрично») → красный test_listings_side_keeps_rooms_predicate.

Полный сьют tradein-mvp/backend: 5946 passed, 35 skipped. ruff clean.

Якорь в deploy/import-rosreestr.sh обновлён: потребителей, фильтрующих по d.rooms, больше нет.

Приёмка на проде после деплоя

Повторить замер тем же скриптом и увидеть долю непустых ответов ≈94 % (сейчас 84.4 %).

Closes #3451. TVF `street_sales_vs_listings()` фильтровала сделки по `d.rooms`, а `deals.rooms` у источника `rosreestr` — **не комнатность, а бакет площади**: импортёр пишет туда `CASE WHEN area < 30 THEN 0 ... ELSE 4 END`, и на проде 321 559 строк из 321 560 удовлетворяют `rooms == area_bucket(area_m2)`. Предикат работал вторым фильтром по площади поверх полосы ±15 %, которую функция считает сама. Та же патология снята в #3256 (PR #3445) на четырёх сделочных площадках эстиматора — здесь последний оставшийся потребитель. ### Ключ асимметричный - `d.rooms = p_rooms` — **снят** (синтетика из площади); - `l.rooms = p_rooms` — **оставлен**: у объявлений комнатность настоящая, это единственный признак ассортимента на листинговой стороне. «Починить симметрично» = потерять его. ### Прод-замер (2026-09-12, только чтение) 1160 реальных клиентских запросов из `trade_in_estimates`, улица извлеклась у 954 (у 206 — известная H1 «адрес вне словаря», к этой правке отношения не имеет). Считалось **тем же путём, что у продукта**: street/city резолвятся `extract_street_name()` / `_resolve_target_city()`. | | было | стало | |---|---|---| | непустой ответ `/sales-vs-listings` | 805 (84.4 %) | **899 (94.2 %)** | | сделок в выборке суммарно | 68 147 | **88 816** | | из них с подобранным объявлением | 30 830 | **39 193** | | выборка сократилась хоть у кого-то | — | **0 из 954** | Впервые непустых — 94 клиента. В issue стоял прогноз «те же 180»: та оценка бралась по коридору эстиматора (другие period/tolerance и другой street-pattern), в PR кладётся **измеренное**, а не предсказанное. ### Почему безопасно - Миграция 300 = тело 211 **минус ровно одна строка** (это проверяет `test_only_the_rooms_predicate_differs_from_211`); сигнатура и `RETURNS TABLE` побайтово те же — иначе `CREATE OR REPLACE` создал бы вторую перегрузку вместо замены (грабли #2627). - Сегментный гард #2660/#1186 и city-предикаты #2583 H4 перенесены дословно, тест это проверяет. - `deal_rooms: int` в `SalesListingPair` остаётся обязательным: ветка `ELSE` в CASE импортёра ловит и NULL-площадь, прод подтверждает 0 NULL в `deals.rooms` при `source='rosreestr'`. Контракт API не меняется. - Тело прогнано `EXPLAIN`'ом на боевой БД (только чтение) — планировщик принимает. ### Фальсификация тестов (оба направления) - вернул `AND d.rooms = p_rooms` → красные `test_deals_side_has_no_rooms_predicate`, `test_only_the_rooms_predicate_differs_from_211`; - снял заодно `AND l.rooms = p_rooms` («починил симметрично») → красный `test_listings_side_keeps_rooms_predicate`. Полный сьют `tradein-mvp/backend`: **5946 passed, 35 skipped**. ruff clean. Якорь в `deploy/import-rosreestr.sh` обновлён: потребителей, фильтрующих по `d.rooms`, больше нет. ### Приёмка на проде после деплоя Повторить замер тем же скриптом и увидеть долю непустых ответов ≈94 % (сейчас 84.4 %).
bot-backend added 1 commit 2026-09-12 08:27:37 +00:00
fix(mera/sales-vs-listings): снят предикат d.rooms в TVF — он был вторым фильтром по площади (#3451)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 13s
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 5m23s
4053adad06
`street_sales_vs_listings()` фильтровала сделки по `d.rooms`, а `deals.rooms` у
источника 'rosreestr' — не комнатность, а бакет площади: импортёр пишет туда
`CASE WHEN area < 30 THEN 0 ... ELSE 4 END`, и на проде 321 559 строк из 321 560
удовлетворяют `rooms == area_bucket(area_m2)`. Предикат работал вторым фильтром
по площади поверх полосы ±15 %, которую функция считает сама: клиенту 49 м² / 1к
полоса 41.7–56.4 м² урезалась до «меньше 44 м²».

Ровно эта патология снята в #3256 (PR #3445) на четырёх сделочных площадках
эстиматора. Здесь — последний оставшийся потребитель, и ключ асимметричный:
`d.rooms` снят, `l.rooms` ОСТАВЛЕН (у объявлений комнатность настоящая, это
единственный признак ассортимента на листинговой стороне).

Миграция 300 = тело 211 минус ровно одна строка; сигнатура и RETURNS TABLE
побайтово те же (иначе CREATE OR REPLACE создал бы вторую перегрузку, #2627),
сегментный гард #2660/#1186 и city-предикаты #2583 H4 перенесены дословно.

Прод-замер (2026-09-12, 1160 реальных клиентских запросов из trade_in_estimates,
улица извлеклась у 954; тем же путём, что у продукта — extract_street_name /
_resolve_target_city):
  - непустой ответ /sales-vs-listings: 805 (84.4 %) → 899 (94.2 %), впервые
    непустых 94 клиента;
  - сделок в выборке: 68 147 → 88 816;
  - из них с подобранным объявлением (то, что показывается парами): 30 830 → 39 193;
  - выборка не сократилась ни у кого (0 из 954) — предикат умел только резать.
Прогноз в issue был «те же 180 клиентов»; измерено 94 — оценка 180 бралась по
коридору эстиматора с другими period/tolerance, в файл положено измеренное.

Тело проверено EXPLAIN'ом на боевой БД (только чтение) — планировщик принимает.

Тесты: tests/test_migration_300_sales_vs_listings_deals_rooms.py — статические
гарды. Фальсификация обоих направлений: вернул `d.rooms = p_rooms` → красные
test_deals_side_has_no_rooms_predicate + test_only_the_rooms_predicate_differs_from_211;
снял заодно `l.rooms = p_rooms` («починил симметрично») → красный
test_listings_side_keeps_rooms_predicate. Полный сьют: 5946 passed, 35 skipped.

Якорь в deploy/import-rosreestr.sh обновлён: потребителей, фильтрующих по
d.rooms, больше нет.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Light1YT added 1 commit 2026-09-12 08:55:47 +00:00
docs(mera/sales-vs-listings): якорь и докстринг по замечаниям ревью PR #3461
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 11s
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m15s
de2d67c7f9
Ревью справедливо поймало два места, где текст после снятия предиката стал
неточным:

1. Якорь в deploy/import-rosreestr.sh обещал «потребителей, фильтрующих по
   d.rooms, больше нет» — это верно только про предикаты РАВЕНСТВА.
   app/tasks/asking_to_sold_ratio.py:148,152 по-прежнему КЛЮЧУЕТСЯ этим
   бакетом (GROUP BY LEAST(GREATEST(rooms,0),4)) и намеренно зеркалит ту же
   синтетику на листинговой стороне (#2620). Прежняя формулировка сказала бы
   будущему редактору, что проверять некого, — а в сценарии «поменяли CASE на
   реальную комнатность» вернулся бы именно #2620.

2. Докстринг GET /sales-vs-listings обещал listing «с такими же rooms». После
   снятия предиката это верно для пары запрос↔объявление, но не для пары
   сделка↔объявление: deal_rooms может не совпадать с запрошенным rooms.

Кода правка не касается. Полный сьют: 5946 passed, 35 skipped; ruff чист.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Author
Collaborator

По замечаниям ревью — замерено показываемое число, не только объём

Замечание было точное: PR мерил объём (доля непустых, счётчики строк), а пользователю видно median_discount_pct. Механизм сдвига, который назвало ревью, тоже конкретный: сделочная сторона теперь занимает всю полосу ±15 %, листинговая по-прежнему сужена реальной комнатностью, discount_pct считается по абсолютным ценам → ожидался систематический сдвиг «продали дороже ask» и перескок через SANE_DISCOUNT_MAX_PCT = 20.

Проверил. Сдвиг не подтвердился, направление оказалось противоположным предсказанному. Те же 1160 запросов / 369 различных ключей, гейт эндпойнта повторён один в один (MIN_PAIRS=10, MIN_DISTINCT_LISTINGS=2, диапазон −35…+20), только чтение:

исход для клиента было стало
медианный торг показан 231 269
погашен: мало пар 315 320
погашен: одно объявление 81 108
погашен: вне диапазона 77 83
пар нет вовсе 250 174
показанный торг было (n=231) стало (n=269)
медиана −7.38 % −7.63 %
p25 −16.70 % −16.32 %
p75 −3.65 % −3.65 %

Медиана сдвинулась на 0.25 п.п. в сторону скидки, а не в плюс. Обратный эффект, которого справедливо опасалось ревью, существует, но мал и назван числом: «вне диапазона» +6, и ровно один ключ перешёл из «показано» в «погашено» — Ясная, 65 м², 2к: было −3.23 % на 13 парах, стало «вне диапазона» на 21 паре / 3 объявлениях. Обратного перехода (показано → погашено) больше нигде нет; в другую сторону выиграли 38 клиентов.

Прецедент из шапки 211 (−18.18 % → −17.11 %) соблюдён: число до/после названо.

Остальные два замечания — поправлены (коммит de2d67c7)

  1. Якорь import-rosreestr.sh:79. Было «потребителей, фильтрующих по d.rooms, больше нет» — верно только для предикатов равенства. Дописано: app/tasks/asking_to_sold_ratio.py:148,152 по-прежнему ключуется этим бакетом и намеренно зеркалит ту же синтетику на листинговой стороне (#2620); смена CASE на реальную комнатность вернёт именно #2620, а не только вопрос предикатов.
  2. Докстринг trade_in.py — убрано обещание «listing с такими же rooms»: после правки это верно для пары запрос↔объявление, но не сделка↔объявление; сказано прямо, что deal_rooms может не совпасть с запрошенным rooms.

Полный сьют после правок: 5946 passed, 35 skipped, ruff чист.

На вопрос про LIMIT

Потолка нет, знаю: TVF отдаёт все пары, эндпойнт сериализует их целиком, карточка рендерит 10. Прод сейчас ~71 → ~93 пары на ответ. Существовало до правки, выросло на 30 %. В этом PR не трогаю.

## По замечаниям ревью — замерено показываемое число, не только объём Замечание было точное: PR мерил объём (доля непустых, счётчики строк), а пользователю видно `median_discount_pct`. Механизм сдвига, который назвало ревью, тоже конкретный: сделочная сторона теперь занимает всю полосу ±15 %, листинговая по-прежнему сужена реальной комнатностью, `discount_pct` считается по абсолютным ценам → ожидался систематический сдвиг «продали дороже ask» и перескок через `SANE_DISCOUNT_MAX_PCT = 20`. **Проверил. Сдвиг не подтвердился, направление оказалось противоположным предсказанному.** Те же 1160 запросов / 369 различных ключей, гейт эндпойнта повторён один в один (`MIN_PAIRS=10`, `MIN_DISTINCT_LISTINGS=2`, диапазон −35…+20), только чтение: | исход для клиента | было | стало | |---|---|---| | **медианный торг показан** | 231 | **269** | | погашен: мало пар | 315 | 320 | | погашен: одно объявление | 81 | 108 | | погашен: вне диапазона | 77 | **83** | | пар нет вовсе | 250 | **174** | | показанный торг | было (n=231) | стало (n=269) | |---|---|---| | медиана | −7.38 % | **−7.63 %** | | p25 | −16.70 % | −16.32 % | | p75 | −3.65 % | −3.65 % | Медиана сдвинулась на **0.25 п.п. в сторону скидки**, а не в плюс. Обратный эффект, которого справедливо опасалось ревью, существует, но мал и назван числом: «вне диапазона» +6, и ровно **один** ключ перешёл из «показано» в «погашено» — `Ясная, 65 м², 2к`: было −3.23 % на 13 парах, стало «вне диапазона» на 21 паре / 3 объявлениях. Обратного перехода (показано → погашено) больше нигде нет; в другую сторону выиграли 38 клиентов. Прецедент из шапки 211 (−18.18 % → −17.11 %) соблюдён: число до/после названо. ## Остальные два замечания — поправлены (коммит `de2d67c7`) 1. **Якорь `import-rosreestr.sh:79`.** Было «потребителей, фильтрующих по `d.rooms`, больше нет» — верно только для предикатов равенства. Дописано: `app/tasks/asking_to_sold_ratio.py:148,152` по-прежнему **ключуется** этим бакетом и намеренно зеркалит ту же синтетику на листинговой стороне (#2620); смена CASE на реальную комнатность вернёт именно #2620, а не только вопрос предикатов. 2. **Докстринг `trade_in.py`** — убрано обещание «listing с такими же rooms»: после правки это верно для пары запрос↔объявление, но не сделка↔объявление; сказано прямо, что `deal_rooms` может не совпасть с запрошенным `rooms`. Полный сьют после правок: **5946 passed, 35 skipped**, ruff чист. ## На вопрос про `LIMIT` Потолка нет, знаю: TVF отдаёт все пары, эндпойнт сериализует их целиком, карточка рендерит 10. Прод сейчас ~71 → ~93 пары на ответ. Существовало до правки, выросло на 30 %. В этом PR не трогаю.
bot-backend merged commit 5f2810b8c6 into main 2026-09-12 09:03:06 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: lekss361/gendesign#3461
No description provided.