[HIGH] tradein: 781 дубль домов не схлопнуть — «независимый идентификатор здания» оказался тем же адресом, а слияние необратимо и без аудита #2690

Open
opened 2026-08-06 00:18:46 +00:00 by bot-backend · 5 comments
Collaborator

Отдельная задача из #2688. Правку сняли из PR перед мержем — три независимых ревью показали, что предложенный ключ обходит защиту, а не усиливает её.

Проблема остаётся: 781 лишняя запись

Измерено независимым от сопоставителя ключом: 653 кластера, 1 434 дома, 781 лишняя строка — 8.3% таблицы домов, 6 389 объявлений (6.8% корпуса) висят на дублях, худший случай шесть записей на одно здание. Это прямое #1772.

Два верхних тира сопоставления не срабатывали ни разу за всю историю (0 из 49 502), то есть 100% домов сматчено слабыми способами — отсюда и дробление.

Почему очевидная починка не годится

Проход схлопывания работает по идентификатору здания, и напрашивалось расширить ключ вторым полем — тем же идентификатором ГАР, который заполняет другой загрузчик. Сухой прогон давал 781 пару вместо нуля.

Но это поле — не независимое наблюдение. Загрузчик проставляет его, сопоставляя нормализованный адрес выражением, побайтово совпадающим с ключом основного прохода. То есть два дома получают «общий идентификатор» тогда и только тогда, когда у них одинаковый адрес после нормализации.

Числа: из 1 434 участников слияния 1 418 (98.9%) получили идентификатор именно так. Пар, где нормализованный адрес совпадает и гео-страж заблокировал бы слияние764 из 781 (97.8%). Пар, опирающихся на два действительно независимых наблюдения, — ноль.

А этот проход идёт с выключенным гео-стражем, на основании «общий идентификатор здания старше близости». Для 764 пар аргумент круговой: идентификатор здесь и есть адрес. Основной проход отказывается слить дома в шести километрах друг от друга, а этот сливает их же.

Что слилось бы

Фильтр по городу у загрузчика стоит только на стороне справочника, на стороне наших домов его нет. Поэтому дом в посёлке штатно наследует идентификатор одноимённой городской улицы:

  • Кедровка, Советская 17 → Екатеринбург, Советская 17 — объявления в 16 км;
  • Кедровка, Советская 20 → Пионерский, Советская — 6 230 м;
  • посёлок Красный, Восточная 7 → Восточная 7 — 8 758 м.

Это дословно тот класс коллизии, который описан в шапке самого модуля схлопывания. Плюс сам нормализованный адрес не однозначен: 7 590 из 37 416 (20.3%) значений накрывают больше одного здания справочника, худшие — 34 и 31 здание на одно значение.

Проверка по координатам объявлений: из 384 пар с геокодом с обеих сторон 70 разъехались больше 250 м, 35 — больше километра, максимум 47 км.

Отдельная проблема: слияние необратимо и не оставляет следа

Проигравшие записи удаляются жёстко вместе с дочерними строками. Единственный след «кто в кого» — строка в логе, а логи контейнера ротируются: самая старая запись сейчас моложе суток, следов прошлых прогонов уже нет. Счётчики прогона хранят только итоги, не соответствие.

Через сутки после слияния нельзя даже назвать, какой дом в какой свернули, не говоря о восстановлении удалённых метаданных. Единственный путь отката — восстановление всей базы на момент до прогона, что выбросит неделю сбора.

Это предусловие для любой будущей работы по схлопыванию: пока нет журнала слияний, каждый проход необратим вслепую.

Что нужно

  1. Журнал слияний — таблица «проигравший, победитель, ключ, время, копия удалённых полей». Без неё дальше двигаться нельзя.
  2. Ключ, независимый от нормализованного адреса. Кандидаты: кадастр здания из надёжного источника (не выведенный геометрией — тот даёт 20% ложных совпадений), идентификатор справочника, полученный не адресным сопоставлением, либо совпадение по нескольким слабым признакам сразу с обязательным гео-стражем.
  3. Либо включить гео-страж и для этого прохода — тогда ключ может остаться адресным, но слияния на километровых расстояниях отсекутся.

Что уже сделано в #2688: исправлено правило выбора победителя (сортировка ставила запись без объявлений выше записи со 192 — 48% пар выбирали пустого победителя), и закрыт приёмник, из-за которого кадастр квартиры попадал бы в ключ здания и порождал новое дробление.

Связано: #2688, #2674, #1772, #2187.

Отдельная задача из #2688. Правку **сняли из PR перед мержем** — три независимых ревью показали, что предложенный ключ обходит защиту, а не усиливает её. ## Проблема остаётся: 781 лишняя запись Измерено независимым от сопоставителя ключом: **653 кластера, 1 434 дома, 781 лишняя строка — 8.3% таблицы домов, 6 389 объявлений (6.8% корпуса) висят на дублях**, худший случай шесть записей на одно здание. Это прямое #1772. Два верхних тира сопоставления не срабатывали **ни разу за всю историю** (0 из 49 502), то есть 100% домов сматчено слабыми способами — отсюда и дробление. ## Почему очевидная починка не годится Проход схлопывания работает по идентификатору здания, и напрашивалось расширить ключ вторым полем — тем же идентификатором ГАР, который заполняет другой загрузчик. Сухой прогон давал 781 пару вместо нуля. **Но это поле — не независимое наблюдение.** Загрузчик проставляет его, сопоставляя нормализованный адрес выражением, **побайтово совпадающим** с ключом основного прохода. То есть два дома получают «общий идентификатор» тогда и только тогда, когда у них одинаковый адрес после нормализации. Числа: из 1 434 участников слияния **1 418 (98.9%)** получили идентификатор именно так. Пар, где нормализованный адрес совпадает **и гео-страж заблокировал бы слияние** — **764 из 781 (97.8%)**. Пар, опирающихся на два действительно независимых наблюдения, — **ноль**. А этот проход идёт с **выключенным гео-стражем**, на основании «общий идентификатор здания старше близости». Для 764 пар аргумент круговой: идентификатор здесь и есть адрес. Основной проход отказывается слить дома в шести километрах друг от друга, а этот сливает их же. ## Что слилось бы Фильтр по городу у загрузчика стоит **только на стороне справочника**, на стороне наших домов его нет. Поэтому дом в посёлке штатно наследует идентификатор одноимённой городской улицы: - Кедровка, Советская 17 → Екатеринбург, Советская 17 — объявления в **16 км**; - Кедровка, Советская 20 → Пионерский, Советская — **6 230 м**; - посёлок Красный, Восточная 7 → Восточная 7 — **8 758 м**. Это дословно тот класс коллизии, который описан в шапке самого модуля схлопывания. Плюс сам нормализованный адрес не однозначен: **7 590 из 37 416 (20.3%)** значений накрывают больше одного здания справочника, худшие — 34 и 31 здание на одно значение. Проверка по координатам **объявлений**: из 384 пар с геокодом с обеих сторон **70 разъехались больше 250 м, 35 — больше километра**, максимум 47 км. ## Отдельная проблема: слияние необратимо и не оставляет следа Проигравшие записи удаляются жёстко вместе с дочерними строками. Единственный след «кто в кого» — строка в логе, а логи контейнера ротируются: самая старая запись сейчас **моложе суток**, следов прошлых прогонов уже нет. Счётчики прогона хранят только итоги, не соответствие. **Через сутки после слияния нельзя даже назвать, какой дом в какой свернули**, не говоря о восстановлении удалённых метаданных. Единственный путь отката — восстановление всей базы на момент до прогона, что выбросит неделю сбора. Это **предусловие** для любой будущей работы по схлопыванию: пока нет журнала слияний, каждый проход необратим вслепую. ## Что нужно 1. **Журнал слияний** — таблица «проигравший, победитель, ключ, время, копия удалённых полей». Без неё дальше двигаться нельзя. 2. **Ключ, независимый от нормализованного адреса.** Кандидаты: кадастр здания из надёжного источника (не выведенный геометрией — тот даёт 20% ложных совпадений), идентификатор справочника, полученный не адресным сопоставлением, либо совпадение по нескольким слабым признакам сразу с обязательным гео-стражем. 3. **Либо включить гео-страж и для этого прохода** — тогда ключ может остаться адресным, но слияния на километровых расстояниях отсекутся. Что уже сделано в #2688: исправлено правило выбора победителя (сортировка ставила запись **без объявлений** выше записи со 192 — 48% пар выбирали пустого победителя), и закрыт приёмник, из-за которого кадастр квартиры попадал бы в ключ здания и порождал новое дробление. Связано: #2688, #2674, #1772, #2187.
Author
Collaborator

Существенная поправка к этой задаче: проход НЕ остановлен, он живой и удаляет каждую неделю

Задача (и мои комментарии к ней) описывает слияние как то, что остановлено на грани. Остановлено было расширение ключа. Сам проход работает и удаляет дома шестую неделю подряд.

Проверено на проде:

scrape_schedules: house_dedup_merge | enabled=t | dry_run=false | такт 7 дней
                  следующий запуск  2026-08-08 04:15 UTC

27.06  losers_deleted=2    children_repointed=11   listings_repointed=9
04.07  losers_deleted=39   children_repointed=107  listings_repointed=307
11.07  losers_deleted=31   children_repointed=72   listings_repointed=49
18.07  losers_deleted=9    children_repointed=322  listings_repointed=70
25.07  losers_deleted=6    children_repointed=13   listings_repointed=7
01.08  losers_deleted=32   children_repointed=66   listings_repointed=34
                    ─────
                     119 домов удалено, 476 объявлений переехало

Миграция 135 сеяла это расписание как enabled=false … DESTRUCTIVE. Кто-то его включил, и с тех пор оно исполняется молча.

По замеру тем же выражением, что и код, следующий прогон удалит 93 дома.

Числа 781 в этой задаче относятся к другому

Замер read-only на текущем коде:

проход слияний
по ФИАС 0 (3678 значений, все различные — дублей нет вовсе)
по канон-адресу, страж включён 93
по канон-адресу, страж выключен 1586

То есть 781 — это про gar_house_guid, которого в ключе нет и не было. Вывод задачи при этом не колеблется: 1586 против 93 — цена гео-стража примерно шестнадцатикратная, ровно тот масштаб, вокруг которого спор.

Что изменилось сегодня и что нет

Изменилось: слияние стало обратимым. PR #2740 — журнал house_merge_log (полный снимок удаляемой строки, состояние победителя ДО переноса метаданных, перенесённые и уничтоженные дочерние строки, основание, расстояние) плюс функция отката, проверенная сквозным прогоном на живом Postgres. Журнал пишется в той же транзакции, что и слияние. Успел к прогону 08.08.

Не изменилось: ключ схлопывания, правило выбора победителя (то самое, где сортировка DESC в Postgres даёт NULLS FIRST) и гео-страж — не тронуты. Правка нейтральна к тому, каким проход станет.

Не обратимы задним числом: те 119 домов, которые уже удалены. Журнала на момент их слияния не было.

Что осталось решить

Вопрос «нужны ли эти 93» открыт ровно так же, как был открыт вопрос про ключ. Теперь его можно решать без риска — но никто этих 93 не заказывал, они произойдут по расписанию, которое было засеяно выключенным. Выношу это владельцу отдельным пунктом.

Побочно: единственная поведенческая проверка модуля была мертва

Четыре теста на живой БД самоотключаются без Postgres, поэтому в CI не запускались ни разу и разошлись со схемой — на чистом main против верной схемы они падают. Починены. Это третий за сутки случай одного класса (тесты сайдкара, деселект в конфигурации, теперь эти): проверка, которая молча пропускается, со временем перестаёт быть верной, и об этом узнают только когда на неё понадобится опереться.

## Существенная поправка к этой задаче: проход НЕ остановлен, он живой и удаляет каждую неделю Задача (и мои комментарии к ней) описывает слияние как то, что **остановлено на грани**. Остановлено было **расширение ключа**. Сам проход работает и удаляет дома шестую неделю подряд. Проверено на проде: ``` scrape_schedules: house_dedup_merge | enabled=t | dry_run=false | такт 7 дней следующий запуск 2026-08-08 04:15 UTC 27.06 losers_deleted=2 children_repointed=11 listings_repointed=9 04.07 losers_deleted=39 children_repointed=107 listings_repointed=307 11.07 losers_deleted=31 children_repointed=72 listings_repointed=49 18.07 losers_deleted=9 children_repointed=322 listings_repointed=70 25.07 losers_deleted=6 children_repointed=13 listings_repointed=7 01.08 losers_deleted=32 children_repointed=66 listings_repointed=34 ───── 119 домов удалено, 476 объявлений переехало ``` Миграция 135 сеяла это расписание как `enabled=false … DESTRUCTIVE`. Кто-то его включил, и с тех пор оно исполняется молча. **По замеру тем же выражением, что и код, следующий прогон удалит 93 дома.** ## Числа 781 в этой задаче относятся к другому Замер read-only на текущем коде: | проход | слияний | |---|---:| | по ФИАС | **0** (3678 значений, все различные — дублей нет вовсе) | | по канон-адресу, страж включён | **93** | | по канон-адресу, страж выключен | **1586** | То есть 781 — это про `gar_house_guid`, которого в ключе нет и не было. Вывод задачи при этом не колеблется: **1586 против 93 — цена гео-стража примерно шестнадцатикратная**, ровно тот масштаб, вокруг которого спор. ## Что изменилось сегодня и что нет **Изменилось:** слияние стало обратимым. PR #2740 — журнал `house_merge_log` (полный снимок удаляемой строки, состояние победителя ДО переноса метаданных, перенесённые и уничтоженные дочерние строки, основание, расстояние) плюс функция отката, проверенная сквозным прогоном на живом Postgres. Журнал пишется в той же транзакции, что и слияние. Успел к прогону 08.08. **Не изменилось:** ключ схлопывания, правило выбора победителя (то самое, где сортировка `DESC` в Postgres даёт NULLS FIRST) и гео-страж — не тронуты. Правка нейтральна к тому, каким проход станет. **Не обратимы задним числом:** те 119 домов, которые уже удалены. Журнала на момент их слияния не было. ## Что осталось решить Вопрос «нужны ли эти 93» открыт ровно так же, как был открыт вопрос про ключ. Теперь его можно решать без риска — но **никто этих 93 не заказывал**, они произойдут по расписанию, которое было засеяно выключенным. Выношу это владельцу отдельным пунктом. ## Побочно: единственная поведенческая проверка модуля была мертва Четыре теста на живой БД самоотключаются без Postgres, поэтому **в CI не запускались ни разу** и разошлись со схемой — на чистом `main` против верной схемы они падают. Починены. Это третий за сутки случай одного класса (тесты сайдкара, деселект в конфигурации, теперь эти): **проверка, которая молча пропускается, со временем перестаёт быть верной, и об этом узнают только когда на неё понадобится опереться.**
Author
Collaborator

Соседний замер, и сразу поправка к тому, как я его сначала описал.

В #2704 я написал, что отсутствие координат — «судя по всему, настоящее содержание #2690». Прочитав эту задачу целиком, снимаю формулировку: здесь измерено другое и другим ключом. Ваши 764 из 781 блокируются расстоянием (гео-страж отверг бы) и круговой природой ключа — идентификатор ГАР ставится по тому же нормализованному адресу. Это самостоятельный дефект, к координатам не сводящийся.

Что действительно рядом — вынес в #2771: 1945 домов (20.2%) вообще без координат, и в основном проходе схлопывания по канон-адресу 1024 проигравших отбраковываются не расстоянием, а отсутствием координат у одной из сторон.

Разница существенная и в обе стороны:

  • ваши 764 — ограждение сработало по существу: дома действительно далеко друг от друга, слияние было бы ошибкой;
  • мои 1024 — ограждение не смогло высказаться: нечего сравнивать.

Поэтому #2771 не следует читать как «поднимет число слияний». Он даёт ограждению возможность оценить эти пары; часть после этого сольётся, часть будет отвергнута правильно.

Одно наблюдение из #2771 прямо усиливает вашу шапку. Коллизия Кедровка/Екатеринбург, которую вы приводите между двумя записями, встречается и внутри одной: 17 домов имеют объявления, разбросанные дальше 10 км, худший — 384 км на пяти объявлениях «ул. Кирова,4». То есть сопоставитель сшивает в одну запись дома разных населённых пунктов, и это видно без всякого схлопывания. Ещё один довод, что слабые тиры сопоставления — корень, а не ключ слияния.

Соседний замер, и сразу поправка к тому, как я его сначала описал. В #2704 я написал, что отсутствие координат — «судя по всему, настоящее содержание #2690». Прочитав эту задачу целиком, снимаю формулировку: здесь измерено другое и другим ключом. Ваши 764 из 781 блокируются **расстоянием** (гео-страж отверг бы) и круговой природой ключа — идентификатор ГАР ставится по тому же нормализованному адресу. Это самостоятельный дефект, к координатам не сводящийся. Что действительно рядом — вынес в **#2771**: 1945 домов (20.2%) вообще без координат, и в основном проходе схлопывания по канон-адресу **1024 проигравших** отбраковываются не расстоянием, а **отсутствием** координат у одной из сторон. Разница существенная и в обе стороны: - ваши 764 — ограждение **сработало по существу**: дома действительно далеко друг от друга, слияние было бы ошибкой; - мои 1024 — ограждение **не смогло высказаться**: нечего сравнивать. Поэтому #2771 не следует читать как «поднимет число слияний». Он даёт ограждению возможность оценить эти пары; часть после этого сольётся, часть будет отвергнута правильно. Одно наблюдение из #2771 прямо усиливает вашу шапку. Коллизия Кедровка/Екатеринбург, которую вы приводите между двумя записями, встречается и **внутри одной**: 17 домов имеют объявления, разбросанные дальше 10 км, худший — 384 км на пяти объявлениях «ул. Кирова,4». То есть сопоставитель сшивает в одну запись дома разных населённых пунктов, и это видно без всякого схлопывания. Ещё один довод, что слабые тиры сопоставления — корень, а не ключ слияния.
Author
Collaborator

Проверка на проде 2026-08-07: сделан п.1, п.2 и п.3 не тронуты

п.1 (журнал слияний) — на проде. Таблица house_merge_log существует, 16 колонок,
включая полный снимок удаляемой строки и состояние победителя до переноса:

merged_at · batch_id · run_id · initiator · merge_pass · cluster_key · geo_guard · distance_m
norm_address · loser_id · keeper_id · loser_row(jsonb) · keeper_before(jsonb)
children_repointed(jsonb) · children_deleted(jsonb)

Функция отката тоже на проде: house_merge_undo(2 аргумента). Индексы по batch_id,
keeper_id, loser_id на месте.

Записей 0 — прогонов слияния с момента деплоя не было; последний был 01.08 (32 удалённых),
ближайший — 2026-08-08 04:15 UTC. То есть журнал ещё не проверен на настоящем прогоне,
только сквозным тестом.

п.2 (ключ, независимый от нормализованного адреса) и п.3 (гео-страж для этого прохода) —
не сделано.
Ни одной правки ключа схлопывания, правила выбора победителя или гео-стража после
c86a5378 нет. Расписание при этом живое и разрушительное как было:

house_dedup_merge | enabled=true | dry_run=false | такт 7 дней | next 2026-08-08 04:15 UTC

То есть завтра утром проход снова пойдёт с тем же ключом, тем же дефектным правилом выбора
победителя и с выключенным гео-стражем — только теперь обратимо. Обратимость снимает
катастрофу, но не отвечает на вопрос задачи.

Оставляю открытой под п.2 и п.3. Решение по завтрашнему прогону — за владельцем (#2704 п.1).

## Проверка на проде 2026-08-07: сделан п.1, п.2 и п.3 не тронуты **п.1 (журнал слияний) — на проде.** Таблица `house_merge_log` существует, 16 колонок, включая полный снимок удаляемой строки и состояние победителя до переноса: ``` merged_at · batch_id · run_id · initiator · merge_pass · cluster_key · geo_guard · distance_m norm_address · loser_id · keeper_id · loser_row(jsonb) · keeper_before(jsonb) children_repointed(jsonb) · children_deleted(jsonb) ``` Функция отката тоже на проде: `house_merge_undo(2 аргумента)`. Индексы по `batch_id`, `keeper_id`, `loser_id` на месте. **Записей 0** — прогонов слияния с момента деплоя не было; последний был 01.08 (32 удалённых), ближайший — **2026-08-08 04:15 UTC**. То есть журнал ещё не проверен на настоящем прогоне, только сквозным тестом. **п.2 (ключ, независимый от нормализованного адреса) и п.3 (гео-страж для этого прохода) — не сделано.** Ни одной правки ключа схлопывания, правила выбора победителя или гео-стража после `c86a5378` нет. Расписание при этом живое и разрушительное как было: ``` house_dedup_merge | enabled=true | dry_run=false | такт 7 дней | next 2026-08-08 04:15 UTC ``` То есть завтра утром проход снова пойдёт с тем же ключом, тем же дефектным правилом выбора победителя и с выключенным гео-стражем — только теперь обратимо. Обратимость снимает катастрофу, но не отвечает на вопрос задачи. Оставляю открытой под п.2 и п.3. Решение по завтрашнему прогону — за владельцем (#2704 п.1).
Author
Collaborator

Поправка к центральному доводу: ноль у верхнего тира — «неприменимо», а не «ни разу не сработал»

В шапке сказано: два верхних тира сопоставления не срабатывали ни разу за всю историю (0 из 49 502), значит 100% домов сматчено слабыми способами. Проверил все три тира отдельно — для двух это верно, для третьего нет.

cadastr_exact и fias_exact — да, ноль по существу.

source_exact — ноль по построению. Tier 1 возвращает результат из matching/houses.py:189-194, не вызывая _upsert_house_source. То есть его метка физически не может попасть в таблицу, по которой велась перепись. Ноль там означает «этот тир в статистику не пишет», а не «этот тир не работает».

Разница существенная для вывода задачи: считать третий тир мёртвым нельзя, и «100% сматчено слабыми способами» из этих данных не следует.

Фактическая перепись по 50 228 записям:

способ доля
fingerprint 58.93%
new (создан заново) 22.84%
geo_proximity 18.21%

Второе: ограничение соседнего замера снято, но обратный вывод не проходит

В #2771 и #2777 разброс объявлений внутри дома мерился только по домам без координат (108 домов). Это ограничение реальное: среди домов с координатами радиус больше 125 м у 733 домов, 22 205 объявлений.

Но читать это как «733 дефекта сопоставления» нельзя — радиус там загрязнён геокодером, а не только сопоставителем. Худший дом всей таблицы (id 12615, радиус 2086.6 км) — это 112 объявлений в одной точке плюс одно с пустым адресом и координатами 59.393 / 24.674, то есть Балтийское море.

Отдельная находка того же разбора: 94 объявления на 47 домах привязаны к дому при пустом адресе. Это самостоятельный дефект — привязка состоялась без того признака, по которому она должна была состояться.

Что это меняет для задачи

Диагноз «дробление из-за слабых тиров» остаётся, но опирается теперь на 58.93% + 18.21%, а не на «100%». И прежде чем усиливать ключ, стоит закрыть привязку при пустом адресе — иначе усиление получит на вход мусор.

Гео-ограждение по-прежнему трогать не надо: 764 из 781 ваших пар оно отвергает по существу, дома действительно далеко друг от друга.

## Поправка к центральному доводу: ноль у верхнего тира — «неприменимо», а не «ни разу не сработал» В шапке сказано: два верхних тира сопоставления не срабатывали **ни разу за всю историю (0 из 49 502)**, значит 100% домов сматчено слабыми способами. Проверил все три тира отдельно — для двух это верно, для третьего **нет**. `cadastr_exact` и `fias_exact` — да, ноль по существу. **`source_exact` — ноль по построению.** Tier 1 возвращает результат из `matching/houses.py:189-194`, **не вызывая `_upsert_house_source`**. То есть его метка физически не может попасть в таблицу, по которой велась перепись. Ноль там означает «этот тир в статистику не пишет», а не «этот тир не работает». Разница существенная для вывода задачи: считать третий тир мёртвым нельзя, и «100% сматчено слабыми способами» из этих данных не следует. Фактическая перепись по 50 228 записям: | способ | доля | |---|---| | fingerprint | 58.93% | | new (создан заново) | 22.84% | | geo_proximity | 18.21% | ## Второе: ограничение соседнего замера снято, но обратный вывод не проходит В #2771 и #2777 разброс объявлений внутри дома мерился **только по домам без координат** (108 домов). Это ограничение реальное: среди домов **с** координатами радиус больше 125 м у **733** домов, 22 205 объявлений. Но читать это как «733 дефекта сопоставления» **нельзя** — радиус там загрязнён геокодером, а не только сопоставителем. Худший дом всей таблицы (id 12615, радиус 2086.6 км) — это 112 объявлений в одной точке плюс **одно** с пустым адресом и координатами 59.393 / 24.674, то есть Балтийское море. Отдельная находка того же разбора: **94 объявления на 47 домах привязаны к дому при пустом адресе**. Это самостоятельный дефект — привязка состоялась без того признака, по которому она должна была состояться. ## Что это меняет для задачи Диагноз «дробление из-за слабых тиров» остаётся, но опирается теперь на 58.93% + 18.21%, а не на «100%». И прежде чем усиливать ключ, стоит закрыть привязку при пустом адресе — иначе усиление получит на вход мусор. Гео-ограждение по-прежнему трогать не надо: 764 из 781 ваших пар оно отвергает **по существу**, дома действительно далеко друг от друга.
Author
Collaborator

п.2 и п.3 закрыты замером. Перемер дублей на сегодня, PR #2820 (смержен)

Перемер: числа из шапки устарели за четверо суток

Шапка: 653 кластера / 1434 дома / 781 лишняя строка / 6389 объявлений — это замер по
gar_house_guid
от 06.08, ключом, который сама же задача и отвергла. Перемер тем ключом,
которым проход реально работает
(канон-адрес), 2026-08-10, после схлопывания 821 дома 08.08:

сегодня
канон-кластеров 858
домов в кластерах 1821
лишних строк 963
объявлений на лишних 1765
худший кластер 24 строки

963 лишние строки — это не 963 дубля. Взаимоисключающая разбивка:

строк объявлений
сольётся в ближайший прогон 61 72 медиана 40 м
страж молчит (нет координат у стороны) 326 960 судить нечем
страж отверг по существу (>250 м) 568 716 медиана 1084 м
cross-fias (разные ФИАС) 8 17 медиана 9795 м

То есть 59% «остатка» — это вообще не дубли: дома разнесены на километр, канон-ключ о них
врёт (тот самый класс «Кедровка/Екатеринбург» из шапки, только внутри одного ключа). Из 326
«страж молчит» координаты из объявлений можно поднять у 72; у остальных 254 наблюдения
координат нет нигде — это территория #2771, не эта задача.

ФИАС-проход сегодня сливает 0: 3678 значений house_fias_id, все различны. Асимметрия
«у fias-прохода страж выключен» существует, но не задействована — ни одной строки в журнале.

п.3 — дефект исправлен ДО этой задачи, проверено задним числом

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

Проверка по журналу house_merge_log — прогон 08.08, 821 слияние (первый на исправленном
правиле, у обеих сторон лежат снимки):

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

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

п.2 — независимого наблюдения нет ни одного. Это ответ, а не пауза

Проверены все поля houses, а не предположены:

кандидат что показал живой замер
cadastral_number 2648 заполнено, все 2648 значений различны → схлопывает ноль. Все 2648 несут dadata_enriched_at и house_fias_id: это ответ DaData на нашу же строку адреса. Второй кадастр (KNN-подсказка листингов) отвергнут ещё в #2674 — 20.1% значений накрывают >1 здания ГАР
house_fias_id 3678, все различны → 0 слияний, та же DaData-природа
gar_house_guid перемерено: из 458 пар с общим guid 441 делят канон, 17 нет — и 5 из этих 17 дальше 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 строк — кластеризовать нечего
координаты (92.3%, не 94%) настоящее независимое наблюдение, но не идентичность: у соседей общий двор. Уже используются единственным осмысленным способом — стражем
год постройки + этажность ложный свидетель: из 391 пары, где страж молчит, оба поля совпадают у 18 (у 357 есть NULL); зато у 306 пар, отвергнутых стражем дальше 250 м, они совпадают. Признак подтвердил бы заведомо неверное

Вывод: ключ не усиливаем. То, что достаёт канон-ключ + страж 250 м, — это потолок,
а не отставание. Гео-ограждение не тронуто, расшивка уже слитого не предлагается.

Что сделано вместо ключа

PR #2820 (смержен): остаток фиксируется числом, которое живёт. Перепись после обоих
проходов пишет в scrape_runs.counters шесть значений: residual_rows, residual_listings,
residual_no_geom, residual_far, residual_cross_fias, residual_mergeable. Разовый замер
протухает быстро — «781» из шапки через четверо суток стал 963.

Корзины намеренно не складываются в один «остаток»: «страж молчит» и «страж отверг» —
противоположные факты, и их сумма читается как долг, которого нет. residual_mergeable
растяжка на сам проход: прошло все стражи и не слилось, ожидание 0.

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

Прод-сверка ДО мержа (read-only): отрендеренный _RESIDUAL_SQL на боевой БД дал ровно то,
что независимо намерено руками — 963 | 1765 | 8 | 326 | 568 | 61.
Критерий post-deploy, записан ДО факта: ближайший прогон расписания (пятница 04:15 UTC) —
в counters появляются шесть residual_*, при этом residual_mergeable = 0 и
residual_rows ≈ 902 ± 40 (963 − 61 сольются, плюс недельный приток).

Опровергнутые предпосылки, включая мои прежние

  1. «Правило выбора победителя не тронуто» (мои комментарии 06.08 и 07.08) — неверно.
    Оно было починено в #2674 за 10 часов до первого из этих комментариев; вторая
    формулировка («правок нет после c86a5378») формально верна и вводит в заблуждение,
    потому что правка была до. Шапка задачи при этом говорит правду: «исправлено».
  2. «cadastral_number is 100% NULL on prod» (шапка модуля, со ссылкой на миграцию 040) —
    неверно: 2648 из 9179. Вывод «адрес — единственный ключ» остаётся, но держится теперь
    на том, что все 2648 значений различны, а не на том, что их нет.
  3. «Остаток = столько-то дублей» — неверно в любой редакции: 568 из 963 строк отвергнуты
    стражем на медиане 1084 м, это разные здания.
  4. «207 слияний выбрали худшего победителя» — мой собственный артефакт замера (см. выше), 0.
  5. Координаты «стало 94%» (#2771) — на сегодня 8473 из 9179 = 92.3%.

Со стороны п.2 и п.3 задача закрыта. Открытым остаётся только то, что в неё не входит:
72 строки, которым координаты можно поднять из объявлений (#2771), и 254, у которых наблюдения
координат нет вовсе.

## п.2 и п.3 закрыты замером. Перемер дублей на сегодня, PR #2820 (смержен) ### Перемер: числа из шапки устарели за четверо суток Шапка: 653 кластера / 1434 дома / 781 лишняя строка / 6389 объявлений — это замер **по gar_house_guid** от 06.08, ключом, который сама же задача и отвергла. Перемер **тем ключом, которым проход реально работает** (канон-адрес), 2026-08-10, после схлопывания 821 дома 08.08: | | сегодня | |---|---:| | канон-кластеров | 858 | | домов в кластерах | 1821 | | **лишних строк** | **963** | | объявлений на лишних | 1765 | | худший кластер | 24 строки | **963 лишние строки — это не 963 дубля.** Взаимоисключающая разбивка: | | строк | объявлений | | |---|---:|---:|---| | сольётся в ближайший прогон | 61 | 72 | медиана 40 м | | страж **молчит** (нет координат у стороны) | 326 | 960 | судить нечем | | страж **отверг по существу** (>250 м) | 568 | 716 | медиана 1084 м | | cross-fias (разные ФИАС) | 8 | 17 | медиана 9795 м | То есть **59% «остатка» — это вообще не дубли**: дома разнесены на километр, канон-ключ о них врёт (тот самый класс «Кедровка/Екатеринбург» из шапки, только внутри одного ключа). Из 326 «страж молчит» координаты из объявлений можно поднять у **72**; у остальных 254 наблюдения координат нет нигде — это территория #2771, не эта задача. ФИАС-проход сегодня сливает **0**: 3678 значений `house_fias_id`, все различны. Асимметрия «у fias-прохода страж выключен» существует, но не задействована — ни одной строки в журнале. ### п.3 — дефект исправлен ДО этой задачи, проверено задним числом Задача называет правило дефектным: «сортировка ставит дом **без** объявлений первым». На актуальном коде это **неверно**: `listing_cnt DESC NULLS LAST` живёт в `_KEEPER_ORDER` с 1a577fe7 (#2674, смержен 06.08 00:11 UTC); в контейнере прода код тот же (прочитан). Проверка по журналу `house_merge_log` — прогон 08.08, 821 слияние (первый на исправленном правиле, у обеих сторон лежат снимки): | замер | значение | |---|---:| | слияний, где победитель беднее проигравшего по объявлениям | **0 из 821** | | контрфактика: кластеров, где старое правило взяло бы пустого победителя | 6 из 762 | | объявлений, которые при этом уехали бы на пустую строку | 8 | **Опровергнутая предпосылка (моя собственная, по ходу замера):** первый прогон дал «207 худших победителей». Это артефакт базы: я считал, что было у победителя до слияния, через `listings.scraped_at < merged_at`, а #2206 двигает `scraped_at` при **каждом ре-подтверждении** живого листинга — половина строк победителя «родилась» задним числом. По двум честным базам (снимок в `listings_snapshots` до даты слияния; монотонный `listings.id` ниже границы батча) получается 0 и 0. Ловушка вписана в код, чтобы следующий не наступил. ### п.2 — независимого наблюдения нет ни одного. Это ответ, а не пауза Проверены **все** поля `houses`, а не предположены: | кандидат | что показал живой замер | |---|---| | `cadastral_number` | 2648 заполнено, **все 2648 значений различны** → схлопывает ноль. Все 2648 несут `dadata_enriched_at` и `house_fias_id`: это ответ DaData на нашу же строку адреса. Второй кадастр (KNN-подсказка листингов) отвергнут ещё в #2674 — 20.1% значений накрывают >1 здания ГАР | | `house_fias_id` | 3678, все различны → 0 слияний, та же DaData-природа | | `gar_house_guid` | перемерено: из 458 пар с общим guid **441 делят канон**, 17 нет — и 5 из этих 17 дальше 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 строк — кластеризовать нечего | | координаты (92.3%, не 94%) | настоящее независимое наблюдение, но **не идентичность**: у соседей общий двор. Уже используются единственным осмысленным способом — стражем | | год постройки + этажность | **ложный свидетель**: из 391 пары, где страж молчит, оба поля совпадают у 18 (у 357 есть NULL); зато у **306** пар, отвергнутых стражем дальше 250 м, они совпадают. Признак подтвердил бы заведомо неверное | **Вывод:** ключ не усиливаем. То, что достаёт канон-ключ + страж 250 м, — это **потолок**, а не отставание. Гео-ограждение не тронуто, расшивка уже слитого не предлагается. ### Что сделано вместо ключа PR **#2820** (смержен): остаток фиксируется **числом, которое живёт**. Перепись после обоих проходов пишет в `scrape_runs.counters` шесть значений: `residual_rows`, `residual_listings`, `residual_no_geom`, `residual_far`, `residual_cross_fias`, `residual_mergeable`. Разовый замер протухает быстро — «781» из шапки через четверо суток стал 963. Корзины намеренно **не складываются** в один «остаток»: «страж молчит» и «страж отверг» — противоположные факты, и их сумма читается как долг, которого нет. `residual_mergeable` — растяжка на сам проход: прошло все стражи и не слилось, ожидание 0. Перепись делит с проходом один и тот же префикс кластеризации (`_ranked_cte`) — своя копия разъехалась бы с тем, что описывает, невидимо. Падение переписи не роняет слияние. **Прод-сверка ДО мержа** (read-only): отрендеренный `_RESIDUAL_SQL` на боевой БД дал ровно то, что независимо намерено руками — `963 | 1765 | 8 | 326 | 568 | 61`. **Критерий post-deploy, записан ДО факта:** ближайший прогон расписания (пятница 04:15 UTC) — в counters появляются шесть `residual_*`, при этом `residual_mergeable = 0` и `residual_rows ≈ 902 ± 40` (963 − 61 сольются, плюс недельный приток). ### Опровергнутые предпосылки, включая мои прежние 1. **«Правило выбора победителя не тронуто»** (мои комментарии 06.08 и 07.08) — неверно. Оно было починено в #2674 за 10 часов **до** первого из этих комментариев; вторая формулировка («правок нет **после** c86a5378») формально верна и вводит в заблуждение, потому что правка была **до**. Шапка задачи при этом говорит правду: «исправлено». 2. **«cadastral_number is 100% NULL on prod»** (шапка модуля, со ссылкой на миграцию 040) — неверно: 2648 из 9179. Вывод «адрес — единственный ключ» остаётся, но держится теперь на том, что все 2648 значений **различны**, а не на том, что их нет. 3. **«Остаток = столько-то дублей»** — неверно в любой редакции: 568 из 963 строк отвергнуты стражем на медиане 1084 м, это разные здания. 4. **«207 слияний выбрали худшего победителя»** — мой собственный артефакт замера (см. выше), 0. 5. **Координаты «стало 94%»** (#2771) — на сегодня 8473 из 9179 = **92.3%**. Со стороны п.2 и п.3 задача закрыта. Открытым остаётся только то, что в неё не входит: 72 строки, которым координаты можно поднять из объявлений (#2771), и 254, у которых наблюдения координат нет вовсе.
lekss361 added the
data
priority/p2
scope/backend
scope/db
tech-debt
tradein
labels 2026-08-16 10:25:16 +00:00
Sign in to join this conversation.
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#2690
No description provided.