fix(tradein): гейт правдоподобия на «медианный торг» — не показывать артефакт пейринга как рыночный факт (#2666) #2671

Merged
bot-backend merged 2 commits from fix/2666-discount-plausibility-gate into main 2026-08-05 19:20:09 +00:00
Collaborator

Что было

GET /sales-vs-listings отдавал median_discount_pct без всякой проверки правдоподобия. Сегментный гард #2660 (миграция 211) убрал 225 предвзятых пар «ДКП вторички ↔ лот застройщика» — это его цель, — но у 74 сделок DISTINCT ON подставил другого партнёра, и поштучные значения разъехались: по %Космонавтов% 2-комн. медиана уехала с −11.9% на +36.4%, то есть пользователю было бы написано «продали на 36% дороже, чем просили».

Корень унаследованный и не в гарде: пейринг ДКП↔объявление идёт по улице без номера дома (data_quality="street_only", ADR #721). На протяжённой улице сделка и объявление стоят в разных домах и разных ценовых классах, и discount_pct перестаёт быть торгом. Пейринг этот PR не чинит (это ADR-уровень) — он перестаёт показывать число, которому нельзя верить.

Что стало

Гейт на median_discount_pct в get_sales_vs_listings() — два признака, любой из них гасит число:

  1. пар меньше SALES_VS_LISTINGS_MIN_PAIRS (10);
  2. значение вне [SALES_VS_LISTINGS_SANE_DISCOUNT_MIN_PCT, ..._MAX_PCT] = [−60%, +20%].

Форма отказа — не пустота. Новое поле ответа median_discount_explanation по образцу confidence_explanation оценщика: непустая строка = медиана посчиталась, но не прошла гейт. Фронт рендерит её вместо числа — в StreetDealsCard.tsx отдельной строкой linkage-hint, в v2/mappers.ts отдельным предложением в note. Пустое место пользователь читает как поломку виджета, а не как честность.

Событие пишется в лог (sales-vs-listings: median_discount gated street=… n_pairs=… value=…), чтобы частота гейта была видна в проде, а не только в этом PR.

Как выбраны пороги и по каким данным

Все цифры — прод-БД tradein, только чтение, 2026-08-05 (миграция 211 на проде уже применена, значит замер отражает пост-#2664 реальность).

Метод: не гипотетические улицы, а симуляция эндпоинта на реальных пользовательских запросахSELECT DISTINCT address, area_m2, rooms FROM trade_in_estimates (309 адресов / 1 040 оценок), для каждого те же extract_street_name + _resolve_target_city + вызов street_sales_vs_listings() с продовыми дефолтами (180 / 0.15 / 24). 238 запросов дали сделки, 128 из них — хотя бы одну пару; они и есть популяция.

MIN_PAIRS = 10

Бутстрап по 12 «плотным» группам (n ≥ 60 пар): берём подвыборку размера k и смотрим, насколько её медиана отклоняется от медианы полной выборки (400 повторов на группу).

k p50 |отклонения| p90 |отклонения| доля |откл| > 10 п.п.
3 8.4 23.7 43.1%
5 6.2 18.8 32.3%
10 4.3 12.0 16.0%
15 3.6 9.9 9.8%
20 3.1 8.2 5.4%

Кривая ломается ровно на 10: участок 5→10 снимает 6.8 п.п. шума, 10→15 — уже только 2.1 п.п., при том что каждые +5 к порогу стоят ещё ~8-10% улиц. Плюс это тот же порог малой выборки, что уже принят в продукте (settings.sell_time_sensitivity_min_n_lots = 10) — новой дисциплины не заводим.

Санитарный диапазон [−60%, +20%] — асимметричный намеренно

Все 128 медиан, отсортированные (в %):

−87 −67 −67 −67 −64 −64 −59 … −18 … −7 −6 −5 −2 +1 +3 +5 +7 +10 +10 +11 +11 +11 +17 │ +34 +34 +34 +35 +39 +52 +52 +52 +70 +82 +103

Верх (+20%). Положительный хвост разорван: после +16.9 пусто до +33.7. Отсечка +20% попадает в пустой промежуток, то есть режет отдельный кластер, а не край континуума. Сверху её подпирает рынок: ни один городской бакет asking_to_sold_ratios не даёт плюса вообще (max ratio 0.9132 = −8.7% торга), так что «продали на +20% дороже ask» — уже вдвое дальше любого рыночно объяснимого плюса.

Низ (−60%). Разрыва нет — минус идёт сплошняком от −5% до −87%, и это ожидаемо: у большого отрицательного торга есть механизм (занижение цены в ДКП), в отличие от большого плюса. Поэтому граница грубая, «заведомо не рынок»: худший городской бакет (студии, ratio 0.7623) = −23.8%, −60% в 2.5 раза глубже. Честно: эта граница подперта слабее верхней и режет 6 групп из 128 (−87 … −64).

Сколько реальных улиц попадёт под гейт

групп доля
с хотя бы одной парой 128 100%
число сохраняют 64 50%
гасит «мало пар» (n < 10) 59 46%
гасит диапазон (дополнительно к первому) 5 4%

По уникальным (улица, комнаты): 72 всего, 34 сохраняют число. Диапазон дополнительно к порогу пар режет ровно 5 групп: Ленина 2к (n=259, −64.2%) ×2 дубля, Щорса 2к (n=21, +34.2%), Шаумяна 1к (n=11, +34.6%), Красных Командиров 3к (n=11, +81.5%) — то есть именно тот класс «плюс в разы», ради которого заведён issue, и который порог пар сам по себе не ловит.

Виджет при этом остаётся целым: сделки, медиана ₽/м², диапазон, linkage_rate_pct и per-pair discount_pct считаются мимо гейта — гаснет ровно строка «медианный торг».

Что НЕ входит

  • Пейринг по улице не чинится. Это ADR #721 и отдельная задача; здесь только отказ показывать его артефакты.
  • Per-pair discount_pct не гасится. Отдельная пара — наблюдаемый факт (вот сделка, вот объявление, вот ссылка), а не оценка по улице. Гейт про сводное число.
  • linkage_rate_pct не трогается. Просадка 57.5% → 46.9% после #2664 честная. Отдельно проверил вторую половину замечания из issue: предупреждение «данные по улице, а не по дому» уже есть на обеих поверхностях — в StreetDealsCard.tsx это блок при data_quality === "street_only", в v2/mappers.ts — хвост note «Данные по улице, не по дому.» Нового текста не добавлял, чтобы не дублировать.
  • Пункты 2 и 3 issue #2666 (шум ±20 п.п. в индексе локации, зависимость витрины от здоровья сбора) — не в этом PR.

Test plan

tradein-mvp/backend/tests/test_sales_vs_listings.py — 18 passed.

Новое:

  • test_median_discount_gated_when_too_few_pairs — 9 пар → числа нет, объяснение называет и фактическое число пар, и порог;
  • test_median_discount_kept_at_min_pairs_boundary — ровно 10 пар проходят (граница включительная);
  • test_median_discount_gated_when_implausibly_positive — 11 пар по +36.4% (кейс из issue) → числа нет;
  • test_median_discount_gated_when_implausibly_negative — 11 пар по −70% → числа нет;
  • test_median_discount_kept_at_sane_range_boundaries — +20.0% и −60.0% проходят;
  • test_median_discount_normal_case_unchanged — 11 пар, медиана −17.0% → число как прежде;
  • test_median_discount_gate_leaves_pairs_and_linkage_untouched — гейт не трогает linkage и per-pair.

Обновлены три существующих теста, чьи фикстуры (1 и 5 пар) теперь под порогом: happy_path и left_join_no_listing проверяют гашение сводной медианы при сохранении пар, median_discount расширен до 11 пар, чтобы продолжать проверять саму арифметику медианы.

Фальсификация (патч-метод, без stash):

  1. Откат только гейта в api/v1/trade_in.py (схема и тесты на месте) → 6 failed: happy_path, left_join_no_listing, gated_when_too_few_pairs, gated_when_implausibly_positive, gated_when_implausibly_negative, gate_leaves_pairs_and_linkage_untouched.
  2. Три теста «число сохраняется» на этом шаге зелёные и покраснеть не могут — они сторожат противоположное направление (гейт не должен быть слишком жадным). Отдельный прогон с ужесточёнными порогами (MIN_PAIRS 10→11, MAX +20.0→+19.9, MIN −60→−16) кладёт именно их: 5 failed, включая все три kept_* / normal_case_unchanged.

Прочее:

  • ruff check + ruff format --check на изменённых файлах — чисто; pre-commit прошёл.
  • Фронт: tsc --noEmit и eslint на изменённых файлах — чисто.
  • Полный pytest tests бэкенда trade-in: 3398 passed, 1 failed — test_search_api.py::test_search_cache_hit (401 вместо 200). Падение предсуществующее: воспроизведено на чистом origin/main тем же патч-методом, к этому PR отношения не имеет.

Что проверить руками после деплоя

Открыть оценку по улице с малым числом пар и убедиться, что вместо «медианный торг …» видна причина, а карточка (сделки / медиана ₽/м² / диапазон / таблица пар) на месте.

Refs #2666

## Что было `GET /sales-vs-listings` отдавал `median_discount_pct` без всякой проверки правдоподобия. Сегментный гард #2660 (миграция 211) убрал 225 предвзятых пар «ДКП вторички ↔ лот застройщика» — это его цель, — но у 74 сделок `DISTINCT ON` подставил другого партнёра, и поштучные значения разъехались: по `%Космонавтов%` 2-комн. медиана уехала с **−11.9% на +36.4%**, то есть пользователю было бы написано «продали на 36% дороже, чем просили». Корень унаследованный и не в гарде: пейринг ДКП↔объявление идёт **по улице без номера дома** (`data_quality="street_only"`, ADR #721). На протяжённой улице сделка и объявление стоят в разных домах и разных ценовых классах, и `discount_pct` перестаёт быть торгом. Пейринг этот PR не чинит (это ADR-уровень) — он перестаёт показывать число, которому нельзя верить. ## Что стало Гейт на `median_discount_pct` в `get_sales_vs_listings()` — два признака, любой из них гасит число: 1. пар меньше `SALES_VS_LISTINGS_MIN_PAIRS` (10); 2. значение вне `[SALES_VS_LISTINGS_SANE_DISCOUNT_MIN_PCT, ..._MAX_PCT]` = `[−60%, +20%]`. **Форма отказа — не пустота.** Новое поле ответа `median_discount_explanation` по образцу `confidence_explanation` оценщика: непустая строка = медиана посчиталась, но не прошла гейт. Фронт рендерит её **вместо** числа — в `StreetDealsCard.tsx` отдельной строкой `linkage-hint`, в `v2/mappers.ts` отдельным предложением в `note`. Пустое место пользователь читает как поломку виджета, а не как честность. Событие пишется в лог (`sales-vs-listings: median_discount gated street=… n_pairs=… value=…`), чтобы частота гейта была видна в проде, а не только в этом PR. ## Как выбраны пороги и по каким данным Все цифры — прод-БД `tradein`, только чтение, 2026-08-05 (миграция 211 на проде уже применена, значит замер отражает пост-#2664 реальность). Метод: не гипотетические улицы, а **симуляция эндпоинта на реальных пользовательских запросах** — `SELECT DISTINCT address, area_m2, rooms FROM trade_in_estimates` (309 адресов / 1 040 оценок), для каждого те же `extract_street_name` + `_resolve_target_city` + вызов `street_sales_vs_listings()` с продовыми дефолтами (180 / 0.15 / 24). 238 запросов дали сделки, **128 из них — хотя бы одну пару**; они и есть популяция. ### MIN_PAIRS = 10 Бутстрап по 12 «плотным» группам (n ≥ 60 пар): берём подвыборку размера k и смотрим, насколько её медиана отклоняется от медианы полной выборки (400 повторов на группу). | k | p50 \|отклонения\| | p90 \|отклонения\| | доля \|откл\| > 10 п.п. | |---|---|---|---| | 3 | 8.4 | 23.7 | 43.1% | | 5 | 6.2 | 18.8 | 32.3% | | **10** | **4.3** | **12.0** | **16.0%** | | 15 | 3.6 | 9.9 | 9.8% | | 20 | 3.1 | 8.2 | 5.4% | Кривая ломается ровно на 10: участок 5→10 снимает 6.8 п.п. шума, 10→15 — уже только 2.1 п.п., при том что каждые +5 к порогу стоят ещё ~8-10% улиц. Плюс это тот же порог малой выборки, что уже принят в продукте (`settings.sell_time_sensitivity_min_n_lots = 10`) — новой дисциплины не заводим. ### Санитарный диапазон [−60%, +20%] — асимметричный намеренно Все 128 медиан, отсортированные (в %): ``` −87 −67 −67 −67 −64 −64 −59 … −18 … −7 −6 −5 −2 +1 +3 +5 +7 +10 +10 +11 +11 +11 +17 │ +34 +34 +34 +35 +39 +52 +52 +52 +70 +82 +103 ``` **Верх (+20%).** Положительный хвост **разорван**: после +16.9 пусто до +33.7. Отсечка +20% попадает в пустой промежуток, то есть режет отдельный кластер, а не край континуума. Сверху её подпирает рынок: ни один городской бакет `asking_to_sold_ratios` не даёт плюса вообще (max ratio 0.9132 = −8.7% торга), так что «продали на +20% дороже ask» — уже вдвое дальше любого рыночно объяснимого плюса. **Низ (−60%).** Разрыва нет — минус идёт сплошняком от −5% до −87%, и это ожидаемо: у большого отрицательного торга есть механизм (занижение цены в ДКП), в отличие от большого плюса. Поэтому граница грубая, «заведомо не рынок»: худший городской бакет (студии, ratio 0.7623) = −23.8%, −60% в 2.5 раза глубже. Честно: эта граница подперта слабее верхней и режет 6 групп из 128 (−87 … −64). ### Сколько реальных улиц попадёт под гейт | | групп | доля | |---|---|---| | с хотя бы одной парой | 128 | 100% | | число сохраняют | **64** | **50%** | | гасит «мало пар» (n < 10) | 59 | 46% | | гасит диапазон (дополнительно к первому) | 5 | 4% | По уникальным (улица, комнаты): 72 всего, 34 сохраняют число. Диапазон дополнительно к порогу пар режет ровно 5 групп: `Ленина` 2к (n=259, −64.2%) ×2 дубля, `Щорса` 2к (n=21, **+34.2%**), `Шаумяна` 1к (n=11, **+34.6%**), `Красных Командиров` 3к (n=11, **+81.5%**) — то есть именно тот класс «плюс в разы», ради которого заведён issue, и который порог пар сам по себе не ловит. Виджет при этом остаётся целым: сделки, медиана ₽/м², диапазон, `linkage_rate_pct` и per-pair `discount_pct` считаются мимо гейта — гаснет ровно строка «медианный торг». ## Что НЕ входит - **Пейринг по улице не чинится.** Это ADR #721 и отдельная задача; здесь только отказ показывать его артефакты. - **Per-pair `discount_pct` не гасится.** Отдельная пара — наблюдаемый факт (вот сделка, вот объявление, вот ссылка), а не оценка по улице. Гейт про сводное число. - **`linkage_rate_pct` не трогается.** Просадка 57.5% → 46.9% после #2664 честная. Отдельно проверил вторую половину замечания из issue: **предупреждение «данные по улице, а не по дому» уже есть на обеих поверхностях** — в `StreetDealsCard.tsx` это блок при `data_quality === "street_only"`, в `v2/mappers.ts` — хвост `note` «Данные по улице, не по дому.» Нового текста не добавлял, чтобы не дублировать. - **Пункты 2 и 3 issue #2666** (шум ±20 п.п. в индексе локации, зависимость витрины от здоровья сбора) — не в этом PR. ## Test plan `tradein-mvp/backend/tests/test_sales_vs_listings.py` — 18 passed. Новое: - `test_median_discount_gated_when_too_few_pairs` — 9 пар → числа нет, объяснение называет и фактическое число пар, и порог; - `test_median_discount_kept_at_min_pairs_boundary` — ровно 10 пар проходят (граница включительная); - `test_median_discount_gated_when_implausibly_positive` — 11 пар по +36.4% (кейс из issue) → числа нет; - `test_median_discount_gated_when_implausibly_negative` — 11 пар по −70% → числа нет; - `test_median_discount_kept_at_sane_range_boundaries` — +20.0% и −60.0% проходят; - `test_median_discount_normal_case_unchanged` — 11 пар, медиана −17.0% → число как прежде; - `test_median_discount_gate_leaves_pairs_and_linkage_untouched` — гейт не трогает linkage и per-pair. Обновлены три существующих теста, чьи фикстуры (1 и 5 пар) теперь под порогом: `happy_path` и `left_join_no_listing` проверяют гашение сводной медианы при сохранении пар, `median_discount` расширен до 11 пар, чтобы продолжать проверять саму арифметику медианы. **Фальсификация (патч-метод, без stash):** 1. Откат только гейта в `api/v1/trade_in.py` (схема и тесты на месте) → **6 failed**: `happy_path`, `left_join_no_listing`, `gated_when_too_few_pairs`, `gated_when_implausibly_positive`, `gated_when_implausibly_negative`, `gate_leaves_pairs_and_linkage_untouched`. 2. Три теста «число сохраняется» на этом шаге **зелёные и покраснеть не могут** — они сторожат противоположное направление (гейт не должен быть слишком жадным). Отдельный прогон с ужесточёнными порогами (MIN_PAIRS 10→11, MAX +20.0→+19.9, MIN −60→−16) кладёт именно их: **5 failed**, включая все три `kept_*` / `normal_case_unchanged`. Прочее: - `ruff check` + `ruff format --check` на изменённых файлах — чисто; pre-commit прошёл. - Фронт: `tsc --noEmit` и `eslint` на изменённых файлах — чисто. - Полный `pytest tests` бэкенда trade-in: 3398 passed, 1 failed — `test_search_api.py::test_search_cache_hit` (401 вместо 200). **Падение предсуществующее**: воспроизведено на чистом `origin/main` тем же патч-методом, к этому PR отношения не имеет. ## Что проверить руками после деплоя Открыть оценку по улице с малым числом пар и убедиться, что вместо «медианный торг …» видна причина, а карточка (сделки / медиана ₽/м² / диапазон / таблица пар) на месте. Refs #2666
bot-backend added 1 commit 2026-08-05 18:51:38 +00:00
fix(tradein): гейт правдоподобия на «медианный торг» — не показывать артефакт пейринга как рыночный факт (#2666)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 8s
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 / frontend-checks (pull_request) Successful in 1m10s
CI Trade-In / backend-tests (pull_request) Successful in 2m51s
b88535425e
/sales-vs-listings отдавал median_discount_pct без всякой проверки: после
сегментного гарда #2660 по `%Космонавтов%` 2-комн. значение уехало с −11.9%
на +36.4%, то есть пользователю написали бы «продали на 36% дороже, чем
просили». Корень унаследованный — пейринг ДКП↔объявление идёт по улице без
номера дома (ADR #721), так что на длинной улице в пару попадают квартиры
разных ценовых классов. Пейринг здесь не чиним, перестаём показывать число,
которому нельзя верить.

Пороги подобраны по проду (симуляция эндпоинта на 238 реальных
пользовательских запросах из trade_in_estimates, 128 дали хотя бы одну пару):
- MIN_PAIRS = 10 — бутстрап по 12 плотным группам: p90 отклонения медианы
  подвыборки от полной 18.8 п.п. при k=5, 12.0 при k=10, 9.9 при k=15.
  Кривая ломается на 10; совпадает с уже принятым в продукте
  sell_time_sensitivity_min_n_lots.
- Санитарный диапазон [−60%, +20%] — асимметричный. Сверху распределение
  разорвано (…+16.9, пусто, +33.7…+103.1), отсечка попадает в разрыв; ни один
  городской бакет asking_to_sold_ratios не даёт плюса вообще (max 0.9132).
  Снизу разрыва нет (у большого минуса есть механизм — занижение цены в ДКП),
  граница грубая «заведомо не рынок»: 2.5× худшего бакета (студии, −23.8%).

Форма отказа — не пустота: новое поле median_discount_explanation по образцу
confidence_explanation оценщика, фронт рендерит его вместо числа. Гаснет ровно
строка «медианный торг»: сделки, медиана ₽/м², диапазон, linkage_rate_pct и
per-pair discount_pct не трогаются.
Light1YT added 1 commit 2026-08-05 19:16:23 +00:00
fix(tradein): отказ гейта — факт про выборку вместо обещания надёжности (#2666)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 7s
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 / frontend-checks (pull_request) Successful in 1m6s
CI Trade-In / backend-tests (pull_request) Successful in 2m46s
77ae08f207
Правки по ревью PR #2671.

Текст «надёжная медиана начинается от 10» обещал то, чего мы гарантировать не
можем: пары — псевдореплики (одно объявление переиспользуется на многих
сделках, на живом кейсе Космонавтов 2-комн. 42 пары стоят на 2 различных
объявлениях), и 10 пар надёжности не дают. Теперь отказ сообщает факт: сколько
пар есть и что на такой выборке медиана гуляет на десятки п.п.

Формулировка диапазонной ветки укорочена: она дублировала street_only-
дисклеймер, который идёт следующим блоком. Проверено скриншотом отрендеренной
карточки — две формулировки подряд читались как стена текста; теперь три
однострочных хинта, на 820px — по две строки, переполнения нет.

В шапку секции добавлен потолок гейта, найденный ревью: бутстрап пересэмплировал
ПАРЫ, т.е. мерил дисперсию со стороны сделок, а доминирует дисперсия со стороны
ОБЪЯВЛЕНИЙ (джекнайф p90 17.3 п.п., max 63.8); 22 из 64 переживших групп стоят
на одном объявлении. Плюс нижняя граница оказалась слишком мягкой, а не строгой:
26 из 64 показываемых значений ниже −23.8%, самое глубокое −58.5%. Оба пункта —
отдельная задача, здесь только зафиксированы, чтобы порог не перечитали как
гарантию.

Тесты: пустое утверждение "1" in explanation (всегда истинно из-за "10")
заменено на «всего 1 —». Добавлены два недостающих — отсутствие пар со скидкой
даёт explanation=None, и порядок проверок (3 пары по +80% отчитываются «мало
пар», а не «вне диапазона»).
Author
Collaborator

Правки по ревью — коммит 77ae08f2. Тесты 20 passed, ruff/tsc/eslint чисто.

1. Формулировка больше не обещает надёжность

Было: «…надёжная медиана начинается от 10» — обещание, которого мы дать не можем.
Стало факт про выборку:

Медианный торг не показываем: пар «сделка ↔ объявление» всего 5 — на такой выборке медиана гуляет на десятки процентных пунктов.

Порог из текста убран совсем. Тест test_median_discount_gated_when_too_few_pairs теперь дополнительно проверяет, что слова «надёжн» в объяснении нет.

2. Пустое утверждение в тесте

"1" in explanation"всего 1 —" in explanation (голое "1" было всегда истинно как подстрока "10"). Тот же приём в остальных проверках: "всего 9 —", "всего 3 —".

3. Два недостающих теста

  • test_median_discount_explanation_absent_when_no_pairs_at_all — 12 сделок, ни одной пары → median_discount_pct is None и median_discount_explanation is None. Фальсификация: выставил объяснение безусловно → тест красный (вместе с 4 «число сохраняется»).
  • test_too_few_pairs_reported_before_out_of_range — 3 пары по +80% отчитываются «всего 3», а не «неправдоподобное значение». Фальсификация: поменял порядок условий → тест красный (4 failed).

4. Скриншоты — и они поймали ещё один дефект

Снял локальным next dev на /ui-preview/estimate (реальный StreetDealsCard, фикстура временно подменена на прод-значения, после съёмки возвращена — в диффе её нет).

Прод-числа для живого кейса, отдельно перепроверил: Космонавтов, 2-комн, 50 м² → 117 сделок, 42 пары, 2 различных объявления, медиана +38.80% → срабатывает ветка диапазона.

Дефект, который был виден только на экране: фикстура стояла house_linked, а прод при наличии сделок всегда отдаёт street_only — значит следом за моей строкой рисуется штатный дисклеймер «Данные по улице, не по конкретному дому…». Моя формулировка говорила ровно то же («на протяжённой улице в пару могли попасть квартиры разных ценовых классов»), и два объяснения подряд читались как стена текста. Укоротил до «— такого торга на рынке не бывает», причину договаривает существующий блок.

По ширине: на 1280px все три хинта однострочные, на 820px — по две строки, переполнения и трёх строк нет.

Скриншоты отрендеренной карточки «По вашей улице» (см. комментарий ревью, п. 4).

Гейт диапазона — Космонавтов 2-комн (1280px)
Гейт диапазона — Космонавтов 2-комн (1280px)

Гейт диапазона — узкая ширина 820px
Гейт диапазона — узкая ширина 820px

Гейт «мало пар» (1280px)
Гейт «мало пар» (1280px)

5. Про псевдореплики — принято, в код внесено как потолок

Сам гейт не менял, но в шапку секции добавил ваши числа с явной пометкой, что бутстрап мерил не тот источник дисперсии: он пересэмплировал ПАРЫ (сторона сделок), а доминирует сторона ОБЪЯВЛЕНИЙ — джекнайф p90 17.3 п.п. и max 63.8 п.п., 22 из 64 переживших групп стоят на одном объявлении, настоящий рычаг — «пар ≥ 10 И объявлений ≥ 2» (42 из 128). Плюс зафиксировал, что нижняя граница слишком мягкая, а не строгая: 26 из 64 показываемых значений ниже −23.8%, самое глубокое −58.5%, и абсурдный минус опаснее абсурдного плюса, потому что выглядит правдоподобно.

Смысл записи — чтобы следующий читатель не принял MIN_PAIRS = 10 за гарантию: это пол, и его бессмысленно двигать в любую сторону, пока считаются пары, а не объявления. Жду вашу отдельную задачу и в этом PR тему не трогаю.

Правки по ревью — коммит `77ae08f2`. Тесты 20 passed, ruff/tsc/eslint чисто. ## 1. Формулировка больше не обещает надёжность Было: «…надёжная медиана начинается от 10» — обещание, которого мы дать не можем. Стало факт про выборку: > Медианный торг не показываем: пар «сделка ↔ объявление» всего 5 — на такой выборке медиана гуляет на десятки процентных пунктов. Порог из текста убран совсем. Тест `test_median_discount_gated_when_too_few_pairs` теперь дополнительно проверяет, что слова «надёжн» в объяснении нет. ## 2. Пустое утверждение в тесте `"1" in explanation` → `"всего 1 —" in explanation` (голое `"1"` было всегда истинно как подстрока `"10"`). Тот же приём в остальных проверках: `"всего 9 —"`, `"всего 3 —"`. ## 3. Два недостающих теста - `test_median_discount_explanation_absent_when_no_pairs_at_all` — 12 сделок, ни одной пары → `median_discount_pct is None` **и** `median_discount_explanation is None`. Фальсификация: выставил объяснение безусловно → тест красный (вместе с 4 «число сохраняется»). - `test_too_few_pairs_reported_before_out_of_range` — 3 пары по +80% отчитываются «всего 3», а не «неправдоподобное значение». Фальсификация: поменял порядок условий → тест красный (4 failed). ## 4. Скриншоты — и они поймали ещё один дефект Снял локальным `next dev` на `/ui-preview/estimate` (реальный `StreetDealsCard`, фикстура временно подменена на прод-значения, после съёмки возвращена — в диффе её нет). Прод-числа для живого кейса, отдельно перепроверил: **Космонавтов, 2-комн, 50 м² → 117 сделок, 42 пары, 2 различных объявления, медиана +38.80%** → срабатывает ветка диапазона. **Дефект, который был виден только на экране:** фикстура стояла `house_linked`, а прод при наличии сделок всегда отдаёт `street_only` — значит следом за моей строкой рисуется штатный дисклеймер «Данные по улице, не по конкретному дому…». Моя формулировка говорила ровно то же («на протяжённой улице в пару могли попасть квартиры разных ценовых классов»), и два объяснения подряд читались как стена текста. Укоротил до «— такого торга на рынке не бывает», причину договаривает существующий блок. По ширине: на 1280px все три хинта однострочные, на 820px — по две строки, переполнения и трёх строк нет. Скриншоты отрендеренной карточки «По вашей улице» (см. комментарий ревью, п. 4). **Гейт диапазона — Космонавтов 2-комн (1280px)** ![Гейт диапазона — Космонавтов 2-комн (1280px)](https://git.gendsgn.ru/attachments/fcac9ca7-7e94-4b6e-8d5c-900e210b272d) **Гейт диапазона — узкая ширина 820px** ![Гейт диапазона — узкая ширина 820px](https://git.gendsgn.ru/attachments/2d2d463f-8cfe-4ccb-88db-0cfb8ac1bdcb) **Гейт «мало пар» (1280px)** ![Гейт «мало пар» (1280px)](https://git.gendsgn.ru/attachments/85d0a8c5-337d-41c4-b428-c931d58835fd) ## 5. Про псевдореплики — принято, в код внесено как потолок Сам гейт не менял, но в шапку секции добавил ваши числа с явной пометкой, что бутстрап мерил не тот источник дисперсии: он пересэмплировал ПАРЫ (сторона сделок), а доминирует сторона ОБЪЯВЛЕНИЙ — джекнайф p90 17.3 п.п. и max 63.8 п.п., 22 из 64 переживших групп стоят на одном объявлении, настоящий рычаг — «пар ≥ 10 И объявлений ≥ 2» (42 из 128). Плюс зафиксировал, что нижняя граница слишком мягкая, а не строгая: 26 из 64 показываемых значений ниже −23.8%, самое глубокое −58.5%, и абсурдный минус опаснее абсурдного плюса, потому что выглядит правдоподобно. Смысл записи — чтобы следующий читатель не принял `MIN_PAIRS = 10` за гарантию: это пол, и его бессмысленно двигать в любую сторону, пока считаются пары, а не объявления. Жду вашу отдельную задачу и в этом PR тему не трогаю.
bot-backend merged commit 4e9e4f558e into main 2026-08-05 19:20:09 +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#2671
No description provided.