fix(tradein/dedup): остаток схлопывания домов становится измеряемым числом, а не оценкой (#2690) #2820

Merged
bot-backend merged 1 commit from fix/2690-dedup-residual into main 2026-08-10 11:18:20 +00:00
Collaborator

Summary

#2690 п.2 (ключ) и п.3 (правило выбора победителя). Кода в ключе и в страже не тронуто —
менять оказалось нечего, и это измеренный вывод, а не пауза.

п.3 — дефект уже исправлен. Задача говорит, что «сортировка ставит дом без объявлений
первым». На актуальном коде это неверно: listing_cnt DESC NULLS LAST живёт в _KEEPER_ORDER
с 1a577fe7 (#2674, 06.08 00:11 UTC), в проде подтверждено чтением кода из контейнера.
Проверено задним числом по журналу house_merge_log — 821 слияние прогона 08.08:

замер значение
слияний, где победитель беднее проигравшего по объявлениям 0 из 821
контрфактика: сколько кластеров старое правило отдало бы пустому победителю 6 из 762 (8 объявлений)

Ловушка замера, вынесенная в комментарий кода: listings.scraped_at для «что было у победителя
до слияния» не годится#2206 двигает его при каждом ре-подтверждении живого листинга,
и по нему получается 207 несуществующих «худших победителей». Честные базы — снимок
listings_snapshots до даты слияния и монотонный listings.id; обе дают 0.

п.2 — независимого наблюдения нет ни одного. Проверены все поля houses, не предположены:
кадастр (2648 заполнено, все различны, все от DaData по нашему же адресу), ФИАС (3678, все
различны), ГАР-guid (441 из 458 пар делят канон), ЖКХ-guid (ставится WHERE gar_house_guid = …,
т.е. это тот же ГАР-guid у 4268 из 4663; 193 из 194 «добавочных» пар приходят кадастровым
KNN-фолбэком), portal-id'ы (различны по построению), координаты (наблюдение, но не идентичность
— уже работают стражем), год+этажность (ложный свидетель: совпадают у 306 пар, отвергнутых
стражем дальше 250 м, и лишь у 18 из 391, где страж молчит).

Что вместо ключа. Остаток фиксируется числом, которое не протухает: перепись после обоих
проходов пишет в counters прогона, сколько однокононных строк осталось и ПОЧЕМУ. Разовый замер
живёт недолго — «781 лишняя строка» из шапки задачи через четыре дня стала 963.

Корзины намеренно не складываются в один «остаток» — «страж молчит» и «страж отверг» это
противоположные факты:

корзина прод 2026-08-10
residual_rows / residual_listings 963 / 1765
residual_no_geom — страж молчит (нет координат у стороны) 326
residual_far — страж отверг по существу (>250 м, медиана 1084 м) → это не дубли 568
residual_cross_fias — разные ФИАС, заведомо разные здания 8
residual_mergeable — прошло все стражи и не слилось → растяжка, ожидание 0 61

Гео-ограждение 250 м не тронуто. Расшивка уже слитого не предлагается.

Test plan

  • pytest tests/test_house_dedup_merge.py — 42 passed, 5 skipped (live-DB self-skip)
  • соседние: test_scraper_kit_scheduler_parity, test_scraper_adapters_contracts,
    tasks/test_backfill_house_coords_from_listings — 80 passed
  • красные на старом коде: 4 из 4 новых тестов падают на origin/main
    (KeyError: 'residual_rows', нет _RESIDUAL_SQL), остальные 38 там зелёные
  • ruff check + ruff format --check — чисто; scripts/check-migration-lock-timeout.py
    ✓ блокирующий DDL прикрыт lock_timeout (миграций в PR нет)
  • прод-сверка ДО мержа (read-only): отрендеренный _RESIDUAL_SQL на боевой БД даёт
    ровно то, что независимо намерено вручную — 963 | 1765 | 8 | 326 | 568 | 61
  • post-deploy: ближайший прогон расписания (house_dedup_merge, пятница 04:15 UTC) —
    в scrape_runs.counters должны появиться шесть residual_*; критерий ДО факта:
    residual_mergeable = 0 (проход не оставил своей же работы) и
    residual_rows ≈ 902 ± 40 (963 − 61 сольются, плюс недельный приток)

Refs #2690

## Summary #2690 п.2 (ключ) и п.3 (правило выбора победителя). Кода в ключе и в страже не тронуто — менять оказалось нечего, и это измеренный вывод, а не пауза. **п.3 — дефект уже исправлен.** Задача говорит, что «сортировка ставит дом без объявлений первым». На актуальном коде это неверно: `listing_cnt DESC NULLS LAST` живёт в `_KEEPER_ORDER` с 1a577fe7 (#2674, 06.08 00:11 UTC), в проде подтверждено чтением кода из контейнера. Проверено **задним числом по журналу** `house_merge_log` — 821 слияние прогона 08.08: | замер | значение | |---|---| | слияний, где победитель беднее проигравшего по объявлениям | **0 из 821** | | контрфактика: сколько кластеров старое правило отдало бы пустому победителю | 6 из 762 (8 объявлений) | Ловушка замера, вынесенная в комментарий кода: `listings.scraped_at` для «что было у победителя до слияния» **не годится** — #2206 двигает его при каждом ре-подтверждении живого листинга, и по нему получается 207 несуществующих «худших победителей». Честные базы — снимок `listings_snapshots` до даты слияния и монотонный `listings.id`; обе дают 0. **п.2 — независимого наблюдения нет ни одного.** Проверены все поля `houses`, не предположены: кадастр (2648 заполнено, все различны, все от DaData по нашему же адресу), ФИАС (3678, все различны), ГАР-guid (441 из 458 пар делят канон), ЖКХ-guid (ставится `WHERE gar_house_guid = …`, т.е. это тот же ГАР-guid у 4268 из 4663; 193 из 194 «добавочных» пар приходят кадастровым KNN-фолбэком), portal-id'ы (различны по построению), координаты (наблюдение, но не идентичность — уже работают стражем), год+этажность (ложный свидетель: совпадают у 306 пар, отвергнутых стражем дальше 250 м, и лишь у 18 из 391, где страж молчит). **Что вместо ключа.** Остаток фиксируется числом, которое не протухает: перепись после обоих проходов пишет в `counters` прогона, сколько однокононных строк осталось и ПОЧЕМУ. Разовый замер живёт недолго — «781 лишняя строка» из шапки задачи через четыре дня стала 963. Корзины намеренно не складываются в один «остаток» — «страж молчит» и «страж отверг» это противоположные факты: | корзина | прод 2026-08-10 | |---|---:| | `residual_rows` / `residual_listings` | 963 / 1765 | | `residual_no_geom` — страж молчит (нет координат у стороны) | 326 | | `residual_far` — страж отверг по существу (>250 м, медиана 1084 м) → **это не дубли** | 568 | | `residual_cross_fias` — разные ФИАС, заведомо разные здания | 8 | | `residual_mergeable` — прошло все стражи и не слилось → растяжка, ожидание 0 | 61 | Гео-ограждение 250 м не тронуто. Расшивка уже слитого не предлагается. ## Test plan - [x] `pytest tests/test_house_dedup_merge.py` — 42 passed, 5 skipped (live-DB self-skip) - [x] соседние: `test_scraper_kit_scheduler_parity`, `test_scraper_adapters_contracts`, `tasks/test_backfill_house_coords_from_listings` — 80 passed - [x] **красные на старом коде**: 4 из 4 новых тестов падают на `origin/main` (`KeyError: 'residual_rows'`, нет `_RESIDUAL_SQL`), остальные 38 там зелёные - [x] `ruff check` + `ruff format --check` — чисто; `scripts/check-migration-lock-timeout.py` — `✓ блокирующий DDL прикрыт lock_timeout` (миграций в PR нет) - [x] **прод-сверка ДО мержа** (read-only): отрендеренный `_RESIDUAL_SQL` на боевой БД даёт ровно то, что независимо намерено вручную — `963 | 1765 | 8 | 326 | 568 | 61` - [ ] post-deploy: ближайший прогон расписания (`house_dedup_merge`, пятница 04:15 UTC) — в `scrape_runs.counters` должны появиться шесть `residual_*`; критерий ДО факта: `residual_mergeable = 0` (проход не оставил своей же работы) и `residual_rows ≈ 902 ± 40` (963 − 61 сольются, плюс недельный приток) Refs #2690
bot-backend added 1 commit 2026-08-10 11:13:23 +00:00
fix(tradein/dedup): остаток схлопывания домов становится измеряемым числом, а не оценкой
All checks were successful
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (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 3m58s
e1586d1307
#2690 просил ключ, независимый от нормализованного адреса. Проверил на живой таблице
все поля houses — независимого наблюдения нет ни одного, и это вывод, а не пауза.

  cadastral_number  2648 заполнено, ВСЕ 2648 значений различны → схлопывает ноль. Все 2648
                    несут dadata_enriched_at и house_fias_id, то есть это ответ DaData на
                    нашу же строку адреса. Второй кадастр (KNN-подсказка листингов) отвергнут
                    ещё в #2674: 20.1% значений накрывают >1 здания ГАР.
  house_fias_id     3678 заполнено, все различны → ФИАС-проход сегодня сливает 0 строк.
  gar_house_guid    перемерено: из 458 пар с общим guid 441 делят канон (guid его повторяет),
                    17 нет — и 5 из них дальше 250 м, худшая 5064 км. Круговой как был.
  zhkh_house_guid   выглядит внешним реестром и им не является: loader ставит его
                    WHERE gar_house_guid = <guid>, т.е. это И ЕСТЬ ГАР-guid у 4268 из 4663.
                    Из 194 пар с РАЗНЫМ каноном 193 приходят кадастровым фолбэком (та же
                    KNN-подсказка), и 30 из 31 пары дальше 250 м — тоже он.
  source+ext_house_id / cian_internal_house_id / yandex_jk_id — различны по построению / 39 / 0.
  координаты        настоящее независимое наблюдение, но не идентичность: у соседей общий двор.
                    Уже используются единственным осмысленным способом — как страж.
  год+этажность     ложный свидетель: из 391 пары, которую страж判 не может рассудить, оба поля
                    совпадают у 18 (у 357 есть NULL), зато у 306 пар, отвергнутых стражем
                    дальше 250 м, они совпадают — признак подтвердил бы заведомо неверное.

Поэтому ключ не усиливаю. Вместо этого фиксирую остаток числом, которое живёт: перепись
после обоих проходов пишет в counters прогона, сколько однокононных строк осталось и ПОЧЕМУ.
Разовый замер протухает быстро — «781 лишняя строка» из шапки задачи через четыре дня стала
963, после того как прогон удалил 821.

Корзины намеренно НЕ складываются в один «остаток»: «страж молчит» (нет координат у стороны)
и «страж отверг» (дальше 250 м) — противоположные факты. Прод 2026-08-10, 963 строки:
568 из них дальше 250 м, медиана 1084 м — это вообще не дубли, канон-ключ о них врёт.
Отдельная корзина residual_mergeable — растяжка на сам проход: прошло все стражи и не слилось,
ожидание 0.

Перепись делит с канон-проходом один и тот же префикс кластеризации (_ranked_cte) — своя копия
разъехалась бы с проходом, который описывает, и разъезд был бы невидим. Падение переписи не
роняет слияние: слияние — продукт, счётчик — прибор.

Прод-сверка ДО мержа: отрендеренный _RESIDUAL_SQL на боевой БД даёт ровно то, что независимо
намерено вручную — 963 / 1765 объявлений / 326 молчит / 568 отверг / 8 cross-fias / 61 сольётся.

Попутно две правки честности шапки:
  * снято утверждение «cadastral_number is 100% NULL on prod» — неверно с 2648 строк, и вывод
    про адресный ключ на нём больше не держится;
  * записан результат проверки правила выбора победителя (#2690 п.3). Правило было починено
    в #2674 (listing_cnt DESC NULLS LAST) и проверено задним числом по house_merge_log: из 821
    слияния 08.08 ноль выбрали победителя беднее проигравшего. Контрфактика старого правила на
    тех же кластерах — 6 из 762 забрали бы пустого победителя. Там же предупреждение: мерить
    победителя до слияния по listings.scraped_at нельзя, #2206 двигает его при ре-подтверждении
    и порождает 207 несуществующих «худших победителей».

Гео-ограждение 250 м не тронуто. Расшивка уже слитого не предлагается.
Тесты красные на старом коде: 4 из 4 (KeyError residual_rows / нет _RESIDUAL_SQL).

Refs #2690
bot-backend merged commit 0ed0140c9e into main 2026-08-10 11:18:20 +00:00
bot-backend deleted branch fix/2690-dedup-residual 2026-08-10 11:18:20 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
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#2820
No description provided.