Адреса МЕРЫ: «Сулимова, 3/2» и «Сулимова, 32» больше не один дом — дробь в номере не теряется при сопоставлении с ГАР и схлопывании дублей #3578

Merged
bot-backend merged 5 commits from fix/canon-addr-slash into main 2026-09-17 12:57:39 +00:00
Collaborator

Мержить после #3558. Ветка стоит на голове fix/houses-dedup (e3616e36): миграция 322 берёт тело функции из 311 и меняет в нём один шаг. Пока #3558 не смержен, в диффе этого PR видны и его коммиты; после его мержа останется один коммит 3d9eaa27. Если #3558 перепишут, эту ветку надо перебазировать. Если смержить этот PR раньше, 311 откатит функцию к версии без дроби.

#2189: канон-ключ адреса терял дробь в номере дома

Что было. Последний шаг tradein_canon_addr оставлял только [а-я0-9]. Поэтому «Сулимова, 3/2» и «Сулимова, 32» давали один ключ сулимова32, как и «10/7» и «107», «4/1» и «41». В ГАР это разные дома с разными house_guid: «ул. Сулимова, 3/2» — 414 квартир, «ул. Сулимова, 32» — 120. У матчера ГАР↔houses (_MATCH_SQL) с фильтром по городу из общего ключа берётся строка с max(flat_count). Отсюда «Сулимова 32 → 414 кв.» из ревизии DaData 02.07.

Что сделано.

  1. Миграция 322_canon_keep_slash_in_house_number.sql. Тело функции из 311 без изменений, кроме последнего шага: S6 оставляет [а-я0-9/], новый S7 убирает «/», если хотя бы с одной стороны не цифра. Получается «3/2» → 3/2 и «3 / 2» → 3/2. Угловые адреса («Гагарина, 27 / Первомайская, 81») и «204Г/1» дают прежний ключ. Сравнение тел 311 и 322 без комментариев отличается ровно этим шагом.
  2. REINDEX INDEX gar_house_flats_canon_idx: у 7237 индексируемых строк значение меняется. Стоит под SET LOCAL lock_timeout = '5s' (гейт #2752 зелёный).
  3. В той же транзакции сбрасываются canon_addr-сопоставления, которые держались только на потерянной дроби. Матчер пары никогда не снимает: дом «Высоцкого, 4/2», пришитый к ГАР «42» (725 квартир), при перематче новым ключом пары не найдёт и останется с чужими квартирами. Критерий: ключи дома и его ГАР-строки различаются, но совпадают без «/». Сопоставления, разошедшиеся с ключом по другим причинам, не трогаются: на проде таких 4, у них адрес сменился после матча. Методы dadata_fias и dadata_fias_revision тоже не трогаются. Порог остановки 100: если под сброс попадёт больше, миграция падает целиком.

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

Замер на проде (только чтение, 17.09.2026; новое тело подставлено в SELECT как выражение)

Сначала проверил само выражение: встроенная копия тела 311 расходится с функцией на проде (тело 147) ровно у 44 домов, то есть у тех же 44 домов, что в замере #3558.

houses (49 204):

  • ключ меняется у 2549 домов;
  • новых ключей, за которыми стоят несколько старых: 0;
  • распадаются 47 прежних канон-кластеров, это 113 пар домов. Ближе 250 м ни одной (минимум 256 м), общего ФИАС ни у одной, разный ФИАС у 5. Например, «Сулимова, 3/2» (9255) и «Сулимова, 32» (373560): 478 м, ФИАС разный. Страж 250 м эти пары и раньше не сливал, так что число слияний house_dedup_merge не меняется.

gar_house_flats: «/» между цифрами встречается в 7237 строках с квартирами (регион 66 — 938, 77 — 2711, 50 — 3588). Новых ключей, за которыми стоят несколько старых: 0.

Симуляция _MATCH_SQL. Считал только по ключам, которых касается дробь: 3071 дом, 11 032 ГАР-строки. Правило как в коде: для региона 66 фильтр ЕКБ и max(flat_count), для 77 и 50 без фильтра и неоднозначный ключ не матчится.

регион ГАР сопоставлений было → стало сменили ГАР-дом потеряли нашли неоднозначных ключей было → стало
66 399 → 364 3 35 0 17 → 9
77 259 → 242 0 23 6 38 → 12
50 400 → 448 0 19 67 348 → 249

Все 77 потерянных пар я просмотрел глазами: каждая — дробь против числа («Удельная, 1/1» ↔ ГАР «11», «Ландау, 5/1» ↔ «51», «Высоцкого, 4/2» ↔ «42»). Ещё 5 — мусорные адреса из одного числа («152», «79»), пришитые к ГАР «мкр. 1/5, 2» и «пгт. Скоропусковский, 7/9». Сомнительны две: «Первомайская, 04А/04Б» ↔ ГАР «0/4а»/«0/4б» (регион 50), возможно, это тот же дом, у которого источник потерял дробь. Все 3 смены ГАР-дома в регионе 66 приходятся на дома, которые ревизия DaData уже исправила (373560 Сулимова 32, 377517 Краснолесья 103, 378914 Краснолесья 107). Новый матчер ставит им тот же ГАР-дом, что и DaData. Старый при перематче затёр бы исправления ревизии: UPDATE не смотрит на gar_match_method.

Сброс в миграции (точный SELECT по условию миграции): 47 домов (66 — 7, 77 — 21, 50 — 19), 3836 чужих квартир, 180 активных объявлений. Дома в ЕКБ: 10503, 10616, 11000, 372434, 378098, 378284, 378350.

Тесты

  • tests/test_2189_canon_keeps_slash.py (новый, 4 теста на живом Postgres, записи в skip_allowlist.txt):
    • test_real_canon_keeps_slash_between_digits проверяет значения функции, поставленной миграциями: пары 3/2↔32, 10/7↔107, 4/1↔41 дают разные ключи; «3 / 2» совпадает с «3/2»; угловой адрес, «204Г/1», «Шаумяна,20»/«Шаумяна, 20» и «17Ар-н» дают прежние ключи;
    • test_real_gar_match_picks_the_house_with_the_same_number: настоящий match_houses_to_gar на ГАР-строках «3/2» (414) и «32» (120). Дом «32» получает 120, дом «3 / 2» получает 414;
    • test_real_dedup_does_not_merge_slash_and_plain_number: «32» и «3/2» без ФИАС в 10 м друг от друга остаются двумя домами;
    • test_migration_322_resets_only_matches_held_by_the_lost_slash: прогон настоящего файла 322 на временных таблицах. Сбрасываются «32→3/2» и «4/1→41». Верная пара «3/2→3/2», метод dadata_fias_revision и расхождение по другой причине (ЖК-префикс) остаются.
  • test_house_dedup_merge.py: в test_real_canon_strips_district_glued_to_house_number (#3558) ожидания рассветная81/цвиллинга76 заменены на рассветная8/1/цвиллинга7/6. В test_real_fias_pass_cross_guard_and_identity_carryover пара B «Клара1772, 32»/«3/2» заменена на «ул.»/«улица» с одним номером. Зачем: после 322 у старой пары разные ключи, и страж разных ФИАС тест не проверял. Со снятым стражем тест со старой парой оставался зелёным (проверено), с новой краснеет: AssertionError: different fias must veto a same-canon merge.
  • Весь сьют tradein-mvp/backend. Ветка (голова #3558, в ней main a130303c) на живом PostGIS со схемой из всех data/sql: 6418 passed, 1 skipped, 4 failed. С заглушечным DSN: 6369 passed, 50 skipped, 4 failed. Все 4 падения — test_3466_corridor_tier_a и test_estimator_radius_floor: 'Settings' object has no attribute 'estimate_corridor_clamp_*'. На чистом origin/main a130303c они падают так же, их чинит #3572.
  • Свежий origin/main 85d455e3#3572), слитый локально с этой веткой, на живом PostGIS: 6422 passed, 1 skipped, rc=0.
  • ruff check app tests: rc=0. ruff format --check по изменённым файлам: rc=0. scripts/check-migration-lock-timeout.py --selftest и основной прогон: rc=0.

Фальсификация

S6 в 322 возвращён к '[^а-я0-9]', миграция применена к тестовой базе (копия файла в scratchpad, после прогона восстановлена, diff -q чист):

E           AssertionError: assert 'сулимова32' == 'сулимова3/2'
E           At index 0 diff: (900189, 'g2189-3-2', 414, 'canon_addr') != (900189, 'g2189-32', 120, 'canon_addr')
E           assert {900189} == {900189, 900190}
E           At index 0 diff: (1, 'g-3-2', 414, True, 'canon_addr') != (1, None, None, False, None)
E           AssertionError: assert 'рассветная81' == 'рассветная8/1'
FAILED tests/test_2189_canon_keeps_slash.py::test_real_canon_keeps_slash_between_digits
FAILED tests/test_2189_canon_keeps_slash.py::test_real_gar_match_picks_the_house_with_the_same_number
FAILED tests/test_2189_canon_keeps_slash.py::test_real_dedup_does_not_merge_slash_and_plain_number
FAILED tests/test_2189_canon_keeps_slash.py::test_migration_322_resets_only_matches_held_by_the_lost_slash
FAILED tests/test_house_dedup_merge.py::test_real_canon_strips_district_glued_to_house_number
5 failed, 46 passed

Со старой функцией дома «32» и «3/2» в одной точке сливаются, а «32» получает 414 квартир.

S7 заменён на регэксп, который ничего не ловит (дробь остаётся где угодно):

E           AssertionError: assert 'гагарина27/первомайская81' == 'гагарина27первомайская81'

Из миграции удалён блок сброса:

E           At index 0 diff: (1, 'g-3-2', 414, True, 'canon_addr') != (1, None, None, False, None)

Из условия сброса снято «совпадают без "/"»:

E           At index 4 diff: (5, None, None, False, None) != (5, 'g-58', 249, True, 'canon_addr')

Во всех случаях файл восстановлен из копии (diff -q чист), миграция применена заново, тесты снова зелёные.

Причина красного CI после слияния с main

После слияния main (8bd16e6a) в CI упал ровно один тест: test_real_gar_match_picks_the_house_with_the_same_number, (900189, None, None, None) != (900189, 'g2189-32', 120, 'canon_addr'). Воспроизведено на живом postgis/postgis:16-3.4 со схемой, собранной как в ci-tradein.yml (все data/sql через ls | sort, ON_ERROR_STOP): 1 failed, 3 passed.

Причина — не гео-страж #3558 и не миграции 311/322. Замер на тех же строках фикстуры:

  • канон-ключи верные и со «/»: дом 900189 → сулимова218932, дом 900190 → сулимова21893/2; ГАР g2189-32сулимова218932, g2189-3-2сулимова21893/2;
  • JOIN по канону даёт правильные пары (900189↔g2189-32, 900190↔g2189-3-2);
  • обе пары отсекает предикат CAST(h.region_code AS text) = gp.region_code: он пришёл в _MATCH_SQL из #3575 (b9c89641, #2583 H5). У домов фикстуры region_code не задан (у колонки нет DEFAULT), сравнение даёт NULL, и UPDATE не трогает ни один дом.

Гео-страж 250 м здесь ни при чём: этот тест вообще не вызывает house_dedup_merge, а у соседнего теста дедупа координаты есть, и он проходил.

Дом без региона не получает guid намеренно: это решение #3575, записанное в его тесте (регион дома неизвестен — не доказуемо) и в runbook. Поэтому исправлена фикстура: домам задан region_code 66, как у ГАР-строк и у прогона region_code="66". Проверка канона со «/» осталась прежней (коммит 21f9c295).

Фальсификация. В 322 шаг S6 вручную возвращён к [^а-я0-9], то есть дробь снова теряется. Схема пересобрана так же, как в CI, прогнан файл теста: rc=1, 4 failed. Исправленный тест падает по существу, а не из-за региона:

E  At index 0 diff: (900189, 'g2189-3-2', 414, 'canon_addr') != (900189, 'g2189-32', 120, 'canon_addr')
E  AssertionError: assert 'сулимова32' == 'сулимова3/2'
E  assert {900189} == {900189, 900190}
E  At index 0 diff: (1, 'g-3-2', 414, True, 'canon_addr') != (1, None, None, False, None)

Файл восстановлен из HEAD, схема пересобрана, tradein_canon_addr('ул. Сулимова, 3/2') = сулимова3/2.

Свежий main влит ещё раз (f0d98934). На голове ветки заглушечный прогон давал rc=1 при 0 failed: три живых теста #3577 не были записаны в skip_allowlist. В main это исправил #3584; слияние затронуло только allowlist, конфликтов не было.

Прогоны на f0d98934:

  • tests/test_2189_canon_keeps_slash.py: 4 passed, rc=0;
  • test_house_dedup_merge.py + test_gar_flats_loader.py: 77 passed, rc=0;
  • полный сьют на живой БД: 6536 passed, 1 skipped (WeasyPrint без нативных библиотек на macOS), rc=0;
  • на заглушке: 6465 passed, 72 skipped, rc=0, неучтённых пропусков нет;
  • ruff check: rc=0; ruff format --check по .py-файлам ветки: rc=0.

Для владельца, не правка: на проде (чтение, 17.09) у 623 из 49 233 домов нет region_code, у 148 из них guid уже есть. После #3575 такие дома новых ГАР-пар не получат.

Чего этот PR не делает (поэтому Refs, а не Closes)

  • Второй класс из #2189, срезание посёлков в составе ЕКБ (Кедровка/Шувакиш/Полеводство/Чусовское Озеро/Сысерть), здесь не тронут. В самой задаче предложены два пути: сохранять название посёлка в ключе или сузить канон до запасного пути при отсутствии ФИАС. Выбор за владельцем, и затрагивает он и матчер, и кластерный ключ дедупа.
  • Перематч ГАР не запускается. Ни миграция, ни деплой его не делают. Новые пары (регион 50 — 67, регион 77 — 6) появятся только после ручного --match-only.
  • Наблюдение, не правка: матчер не ограничивает дома регионом. Устранено в main #3575 (b9c89641, #2583 H5), изменение влито в ветку. Пример ниже больше не воспроизводится. Прогон региона 66 сопоставляет с ГАР Екатеринбурга и дома из Балашихи («мкр. Балашиха-1, проспект Ленина, 2/5» ↔ «ул. Ленина, 25»).

Деплой

  • Миграция 322: CREATE OR REPLACE FUNCTION, REINDEX INDEX gar_house_flats_canon_idx (201 513 строк, по замеру #3558 около 6 с) и UPDATE 47 строк houses. Всё в одной транзакции под lock_timeout 5s. Если в момент деплоя идёт загрузка ГАР или перематч, миграция упадёт LockNotAvailable и откатится целиком; тогда деплой повторить. gar_house_flats читают только ручной загрузчик и перематч, пользовательский путь не задет.
  • Пересоздаются контейнеры образа backend: tradein-backend и tradein-scraper (в нём планировщик). Гейт: перед деплоем проверить scrape_runs.status='running'.
  • В логе деплоя ждём NOTICE: houses: сброшено ГАР-сопоставлений, державшихся на потерянной дроби = 47 (±несколько). Если больше 100, миграция остановится сама.
  • По желанию владельца, после деплоя: python -m app.tasks.gar_flats_load --match-only --region 66 (и 77, 50).

Приёмка на проде (критерии записаны до факта)

Сразу после деплоя 311+322 (ожидается 18.09.2026, иначе в день деплоя):

  1. SELECT tradein_canon_addr('Екатеринбург, ул. Сулимова, 3/2'), tradein_canon_addr('Екатеринбург, ул. Сулимова, 32') возвращает сулимова3/2 | сулимова32.
  2. Индекс перестроен: под SET enable_seqscan=off запрос SELECT house_num FROM gar_house_flats WHERE flat_count>0 AND tradein_canon_addr(norm_address)='сулимова3/2' AND region_code='66' возвращает 3/2, план — Index/Bitmap Scan по gar_house_flats_canon_idx.
  3. SELECT count(*) FROM houses WHERE gar_house_guid IS NOT NULL AND id IN (10503,10616,11000,372434,378098,378284,378350) возвращает 0.
  4. Инвариант: число canon_addr-сопоставлений, у которых ключи дома и ГАР-строки различаются, но совпадают без «/», равно 0 (запрос — условие UPDATE из миграции, переписанное как SELECT count(*)).
  5. Если после деплоя запускали перематч региона 66: у 373560, 377517, 378914 gar_house_guid не изменился (ГАР «32», «103», «107»).

Refs #2189

🤖 Generated with Claude Code

**Мержить после #3558.** Ветка стоит на голове `fix/houses-dedup` (e3616e36): миграция 322 берёт тело функции из 311 и меняет в нём один шаг. Пока #3558 не смержен, в диффе этого PR видны и его коммиты; после его мержа останется один коммит `3d9eaa27`. Если #3558 перепишут, эту ветку надо перебазировать. Если смержить этот PR раньше, 311 откатит функцию к версии без дроби. ## #2189: канон-ключ адреса терял дробь в номере дома **Что было.** Последний шаг `tradein_canon_addr` оставлял только `[а-я0-9]`. Поэтому «Сулимова, 3/2» и «Сулимова, 32» давали один ключ `сулимова32`, как и «10/7» и «107», «4/1» и «41». В ГАР это разные дома с разными `house_guid`: «ул. Сулимова, 3/2» — 414 квартир, «ул. Сулимова, 32» — 120. У матчера ГАР↔houses (`_MATCH_SQL`) с фильтром по городу из общего ключа берётся строка с max(flat_count). Отсюда «Сулимова 32 → 414 кв.» из ревизии DaData 02.07. **Что сделано.** 1. Миграция `322_canon_keep_slash_in_house_number.sql`. Тело функции из 311 без изменений, кроме последнего шага: S6 оставляет `[а-я0-9/]`, новый S7 убирает «/», если хотя бы с одной стороны не цифра. Получается «3/2» → `3/2` и «3 / 2» → `3/2`. Угловые адреса («Гагарина, 27 / Первомайская, 81») и «204Г/1» дают прежний ключ. Сравнение тел 311 и 322 без комментариев отличается ровно этим шагом. 2. `REINDEX INDEX gar_house_flats_canon_idx`: у 7237 индексируемых строк значение меняется. Стоит под `SET LOCAL lock_timeout = '5s'` (гейт #2752 зелёный). 3. В той же транзакции сбрасываются canon_addr-сопоставления, которые держались только на потерянной дроби. Матчер пары никогда не снимает: дом «Высоцкого, 4/2», пришитый к ГАР «42» (725 квартир), при перематче новым ключом пары не найдёт и останется с чужими квартирами. Критерий: ключи дома и его ГАР-строки различаются, но совпадают без «/». Сопоставления, разошедшиеся с ключом по другим причинам, не трогаются: на проде таких 4, у них адрес сменился после матча. Методы `dadata_fias` и `dadata_fias_revision` тоже не трогаются. Порог остановки 100: если под сброс попадёт больше, миграция падает целиком. **Почему новое правило не склеивает разные дома.** Новый ключ только уточняет старый: если убрать из него все «/», получится старый ключ. Значит, у двух адресов с одинаковым новым ключом совпадал и старый. Правка может только развести дома. Прод это подтверждает, см. ниже. ## Замер на проде (только чтение, 17.09.2026; новое тело подставлено в SELECT как выражение) Сначала проверил само выражение: встроенная копия тела 311 расходится с функцией на проде (тело 147) ровно у 44 домов, то есть у тех же 44 домов, что в замере #3558. **houses (49 204):** - ключ меняется у **2549** домов; - новых ключей, за которыми стоят несколько старых: **0**; - распадаются **47** прежних канон-кластеров, это **113 пар** домов. Ближе 250 м **ни одной** (минимум 256 м), общего ФИАС **ни у одной**, разный ФИАС у 5. Например, «Сулимова, 3/2» (9255) и «Сулимова, 32» (373560): 478 м, ФИАС разный. Страж 250 м эти пары и раньше не сливал, так что число слияний `house_dedup_merge` не меняется. **gar_house_flats:** «/» между цифрами встречается в 7237 строках с квартирами (регион 66 — 938, 77 — 2711, 50 — 3588). Новых ключей, за которыми стоят несколько старых: 0. **Симуляция `_MATCH_SQL`.** Считал только по ключам, которых касается дробь: 3071 дом, 11 032 ГАР-строки. Правило как в коде: для региона 66 фильтр ЕКБ и max(flat_count), для 77 и 50 без фильтра и неоднозначный ключ не матчится. | регион ГАР | сопоставлений было → стало | сменили ГАР-дом | потеряли | нашли | неоднозначных ключей было → стало | |---|---|---:|---:|---:|---| | 66 | 399 → 364 | 3 | 35 | 0 | 17 → 9 | | 77 | 259 → 242 | 0 | 23 | 6 | 38 → 12 | | 50 | 400 → 448 | 0 | 19 | 67 | 348 → 249 | Все 77 потерянных пар я просмотрел глазами: каждая — дробь против числа («Удельная, 1/1» ↔ ГАР «11», «Ландау, 5/1» ↔ «51», «Высоцкого, 4/2» ↔ «42»). Ещё 5 — мусорные адреса из одного числа («152», «79»), пришитые к ГАР «мкр. 1/5, 2» и «пгт. Скоропусковский, 7/9». Сомнительны две: «Первомайская, 04А/04Б» ↔ ГАР «0/4а»/«0/4б» (регион 50), возможно, это тот же дом, у которого источник потерял дробь. Все 3 смены ГАР-дома в регионе 66 приходятся на дома, которые ревизия DaData уже исправила (373560 Сулимова 32, 377517 Краснолесья 103, 378914 Краснолесья 107). Новый матчер ставит им тот же ГАР-дом, что и DaData. Старый при перематче затёр бы исправления ревизии: UPDATE не смотрит на `gar_match_method`. **Сброс в миграции (точный SELECT по условию миграции):** **47** домов (66 — 7, 77 — 21, 50 — 19), 3836 чужих квартир, 180 активных объявлений. Дома в ЕКБ: 10503, 10616, 11000, 372434, 378098, 378284, 378350. ## Тесты - `tests/test_2189_canon_keeps_slash.py` (новый, 4 теста на живом Postgres, записи в `skip_allowlist.txt`): - `test_real_canon_keeps_slash_between_digits` проверяет значения функции, поставленной миграциями: пары 3/2↔32, 10/7↔107, 4/1↔41 дают разные ключи; «3 / 2» совпадает с «3/2»; угловой адрес, «204Г/1», «Шаумяна,20»/«Шаумяна, 20» и «17Ар-н» дают прежние ключи; - `test_real_gar_match_picks_the_house_with_the_same_number`: настоящий `match_houses_to_gar` на ГАР-строках «3/2» (414) и «32» (120). Дом «32» получает 120, дом «3 / 2» получает 414; - `test_real_dedup_does_not_merge_slash_and_plain_number`: «32» и «3/2» без ФИАС в 10 м друг от друга остаются двумя домами; - `test_migration_322_resets_only_matches_held_by_the_lost_slash`: прогон настоящего файла 322 на временных таблицах. Сбрасываются «32→3/2» и «4/1→41». Верная пара «3/2→3/2», метод `dadata_fias_revision` и расхождение по другой причине (ЖК-префикс) остаются. - `test_house_dedup_merge.py`: в `test_real_canon_strips_district_glued_to_house_number` (#3558) ожидания `рассветная81`/`цвиллинга76` заменены на `рассветная8/1`/`цвиллинга7/6`. В `test_real_fias_pass_cross_guard_and_identity_carryover` пара B «Клара1772, 32»/«3/2» заменена на «ул.»/«улица» с одним номером. **Зачем:** после 322 у старой пары разные ключи, и страж разных ФИАС тест не проверял. Со снятым стражем тест со старой парой оставался зелёным (проверено), с новой краснеет: `AssertionError: different fias must veto a same-canon merge`. - Весь сьют `tradein-mvp/backend`. Ветка (голова #3558, в ней main a130303c) на живом PostGIS со схемой из всех `data/sql`: **6418 passed, 1 skipped, 4 failed**. С заглушечным DSN: **6369 passed, 50 skipped, 4 failed**. Все 4 падения — `test_3466_corridor_tier_a` и `test_estimator_radius_floor`: `'Settings' object has no attribute 'estimate_corridor_clamp_*'`. На чистом origin/main a130303c они падают так же, их чинит #3572. - Свежий origin/main 85d455e3 (с #3572), слитый локально с этой веткой, на живом PostGIS: **6422 passed, 1 skipped, rc=0**. - `ruff check app tests`: rc=0. `ruff format --check` по изменённым файлам: rc=0. `scripts/check-migration-lock-timeout.py --selftest` и основной прогон: rc=0. ## Фальсификация S6 в 322 возвращён к `'[^а-я0-9]'`, миграция применена к тестовой базе (копия файла в scratchpad, после прогона восстановлена, `diff -q` чист): ``` E AssertionError: assert 'сулимова32' == 'сулимова3/2' E At index 0 diff: (900189, 'g2189-3-2', 414, 'canon_addr') != (900189, 'g2189-32', 120, 'canon_addr') E assert {900189} == {900189, 900190} E At index 0 diff: (1, 'g-3-2', 414, True, 'canon_addr') != (1, None, None, False, None) E AssertionError: assert 'рассветная81' == 'рассветная8/1' FAILED tests/test_2189_canon_keeps_slash.py::test_real_canon_keeps_slash_between_digits FAILED tests/test_2189_canon_keeps_slash.py::test_real_gar_match_picks_the_house_with_the_same_number FAILED tests/test_2189_canon_keeps_slash.py::test_real_dedup_does_not_merge_slash_and_plain_number FAILED tests/test_2189_canon_keeps_slash.py::test_migration_322_resets_only_matches_held_by_the_lost_slash FAILED tests/test_house_dedup_merge.py::test_real_canon_strips_district_glued_to_house_number 5 failed, 46 passed ``` Со старой функцией дома «32» и «3/2» в одной точке сливаются, а «32» получает 414 квартир. S7 заменён на регэксп, который ничего не ловит (дробь остаётся где угодно): ``` E AssertionError: assert 'гагарина27/первомайская81' == 'гагарина27первомайская81' ``` Из миграции удалён блок сброса: ``` E At index 0 diff: (1, 'g-3-2', 414, True, 'canon_addr') != (1, None, None, False, None) ``` Из условия сброса снято «совпадают без "/"»: ``` E At index 4 diff: (5, None, None, False, None) != (5, 'g-58', 249, True, 'canon_addr') ``` Во всех случаях файл восстановлен из копии (`diff -q` чист), миграция применена заново, тесты снова зелёные. ## Причина красного CI после слияния с main После слияния main (8bd16e6a) в CI упал ровно один тест: `test_real_gar_match_picks_the_house_with_the_same_number`, `(900189, None, None, None) != (900189, 'g2189-32', 120, 'canon_addr')`. Воспроизведено на живом postgis/postgis:16-3.4 со схемой, собранной как в ci-tradein.yml (все `data/sql` через `ls | sort`, `ON_ERROR_STOP`): 1 failed, 3 passed. **Причина — не гео-страж #3558 и не миграции 311/322.** Замер на тех же строках фикстуры: - канон-ключи верные и со «/»: дом 900189 → `сулимова218932`, дом 900190 → `сулимова21893/2`; ГАР `g2189-32` → `сулимова218932`, `g2189-3-2` → `сулимова21893/2`; - JOIN по канону даёт правильные пары (900189↔g2189-32, 900190↔g2189-3-2); - обе пары отсекает предикат `CAST(h.region_code AS text) = gp.region_code`: он пришёл в `_MATCH_SQL` из #3575 (b9c89641, #2583 H5). У домов фикстуры `region_code` не задан (у колонки нет DEFAULT), сравнение даёт NULL, и UPDATE не трогает ни один дом. Гео-страж 250 м здесь ни при чём: этот тест вообще не вызывает `house_dedup_merge`, а у соседнего теста дедупа координаты есть, и он проходил. Дом без региона не получает guid намеренно: это решение #3575, записанное в его тесте (`регион дома неизвестен — не доказуемо`) и в runbook. Поэтому исправлена фикстура: домам задан `region_code` 66, как у ГАР-строк и у прогона `region_code="66"`. Проверка канона со «/» осталась прежней (коммит 21f9c295). **Фальсификация.** В 322 шаг S6 вручную возвращён к `[^а-я0-9]`, то есть дробь снова теряется. Схема пересобрана так же, как в CI, прогнан файл теста: rc=1, 4 failed. Исправленный тест падает по существу, а не из-за региона: ``` E At index 0 diff: (900189, 'g2189-3-2', 414, 'canon_addr') != (900189, 'g2189-32', 120, 'canon_addr') E AssertionError: assert 'сулимова32' == 'сулимова3/2' E assert {900189} == {900189, 900190} E At index 0 diff: (1, 'g-3-2', 414, True, 'canon_addr') != (1, None, None, False, None) ``` Файл восстановлен из HEAD, схема пересобрана, `tradein_canon_addr('ул. Сулимова, 3/2')` = `сулимова3/2`. **Свежий main влит ещё раз (f0d98934).** На голове ветки заглушечный прогон давал rc=1 при 0 failed: три живых теста #3577 не были записаны в skip_allowlist. В main это исправил #3584; слияние затронуло только allowlist, конфликтов не было. **Прогоны на f0d98934:** - `tests/test_2189_canon_keeps_slash.py`: 4 passed, rc=0; - `test_house_dedup_merge.py` + `test_gar_flats_loader.py`: 77 passed, rc=0; - полный сьют на живой БД: 6536 passed, 1 skipped (WeasyPrint без нативных библиотек на macOS), rc=0; - на заглушке: 6465 passed, 72 skipped, rc=0, неучтённых пропусков нет; - `ruff check`: rc=0; `ruff format --check` по .py-файлам ветки: rc=0. Для владельца, не правка: на проде (чтение, 17.09) у 623 из 49 233 домов нет `region_code`, у 148 из них guid уже есть. После #3575 такие дома новых ГАР-пар не получат. ## Чего этот PR не делает (поэтому Refs, а не Closes) - **Второй класс из #2189, срезание посёлков в составе ЕКБ** (Кедровка/Шувакиш/Полеводство/Чусовское Озеро/Сысерть), здесь не тронут. В самой задаче предложены два пути: сохранять название посёлка в ключе или сузить канон до запасного пути при отсутствии ФИАС. Выбор за владельцем, и затрагивает он и матчер, и кластерный ключ дедупа. - **Перематч ГАР не запускается.** Ни миграция, ни деплой его не делают. Новые пары (регион 50 — 67, регион 77 — 6) появятся только после ручного `--match-only`. - ~~Наблюдение, не правка: матчер не ограничивает дома регионом.~~ Устранено в main #3575 (b9c89641, #2583 H5), изменение влито в ветку. Пример ниже больше не воспроизводится. Прогон региона 66 сопоставляет с ГАР Екатеринбурга и дома из Балашихи («мкр. Балашиха-1, проспект Ленина, 2/5» ↔ «ул. Ленина, 25»). ## Деплой - Миграция 322: `CREATE OR REPLACE FUNCTION`, `REINDEX INDEX gar_house_flats_canon_idx` (201 513 строк, по замеру #3558 около 6 с) и UPDATE 47 строк houses. Всё в одной транзакции под `lock_timeout 5s`. Если в момент деплоя идёт загрузка ГАР или перематч, миграция упадёт LockNotAvailable и откатится целиком; тогда деплой повторить. `gar_house_flats` читают только ручной загрузчик и перематч, пользовательский путь не задет. - Пересоздаются контейнеры образа backend: tradein-backend и tradein-scraper (в нём планировщик). **Гейт:** перед деплоем проверить `scrape_runs.status='running'`. - В логе деплоя ждём `NOTICE: houses: сброшено ГАР-сопоставлений, державшихся на потерянной дроби = 47` (±несколько). Если больше 100, миграция остановится сама. - По желанию владельца, после деплоя: `python -m app.tasks.gar_flats_load --match-only --region 66` (и 77, 50). ## Приёмка на проде (критерии записаны до факта) Сразу после деплоя 311+322 (ожидается 18.09.2026, иначе в день деплоя): 1. `SELECT tradein_canon_addr('Екатеринбург, ул. Сулимова, 3/2'), tradein_canon_addr('Екатеринбург, ул. Сулимова, 32')` возвращает `сулимова3/2 | сулимова32`. 2. Индекс перестроен: под `SET enable_seqscan=off` запрос `SELECT house_num FROM gar_house_flats WHERE flat_count>0 AND tradein_canon_addr(norm_address)='сулимова3/2' AND region_code='66'` возвращает `3/2`, план — Index/Bitmap Scan по `gar_house_flats_canon_idx`. 3. `SELECT count(*) FROM houses WHERE gar_house_guid IS NOT NULL AND id IN (10503,10616,11000,372434,378098,378284,378350)` возвращает **0**. 4. Инвариант: число canon_addr-сопоставлений, у которых ключи дома и ГАР-строки различаются, но совпадают без «/», равно **0** (запрос — условие UPDATE из миграции, переписанное как SELECT count(*)). 5. Если после деплоя запускали перематч региона 66: у 373560, 377517, 378914 `gar_house_guid` не изменился (ГАР «32», «103», «107»). Refs #2189 🤖 Generated with [Claude Code](https://claude.com/claude-code)
bot-backend added 5 commits 2026-09-17 10:01:59 +00:00
ФИАС-проход house_dedup_merge шёл без гео-стража (#2187: «общий ФИАС-UUID и
есть здание»). Посылка неверна: house_fias_id — ответ DaData на НАШУ строку
адреса, и «улица Кирова, 4» без города получает тот же UUID, что и дом в
другом городе. Прогон 05.09 (house_merge_log, merge_pass='fias'): 408 слияний,
129 дальше 250 м, 53 дальше 3 км, максимум 362 км, у 67 нет координат на одной
из сторон.

Теперь у обоих проходов один и тот же страж (250 м, координаты у обеих сторон),
параметр apply_geo_guard удалён — асимметрию нельзя вернуть флагом.

Тест по значению на живой Postgres: тот же ФИАС в 5 км и с пустыми координатами
не сливается, тот же ФИАС в 10 м сливается, журнал пишет geo_guard=true.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fix(tradein/dedup): дома с районом, приклеенным к номеру («17Ар-н Академический»), сливаются со своими двойниками (#1772)
Some checks failed
CI Trade-In / changes (pull_request) Successful in 19s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Failing after 24s
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 6m47s
e2b1a8adee
Канон-ключ tradein_canon_addr не срезал район, приклеенный к номеру дома без
запятой (наследие слипа «29р-н» из #1773): «ул. Евгения Савкова, 17Ар-н
Академический» давал «евгениясавкова17арнакадемический», а «44Бр-н» терял
литеру (токен-срез принимал «бр» за бульвар). Двойник «ул. Евгения Савкова,17А»
с таким домом в один кластер не попадал, и перепись остатка его не видела.

Миграция 311: тело 147 плюс шаг «<цифра>[литера]р-н <название>» → «<цифра>[литера]»
сразу после lower. Якорь на цифре обязателен: без него «мкр-н Кутузовский, 2»
превращается в «мк2». Прод 17.09 (read-only): канон меняется у 44 из 49 159
домов, у 20 появляется двойник в пределах 250 м, ГАР-сторона не затронута
(0 из 3,27 млн строк с шаблоном).

Тест по значению на функции, установленной миграциями, и сквозное слияние:
склеенный адрес и чистый двойник сливаются, «17» без литеры остаётся отдельным.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CI / changes краснел гейтом #2752: «блокирующий DDL без lock_timeout (REINDEX INDEX
gar_house_flats_canon_idx)». Добавлен SET LOCAL lock_timeout = '5s' сразу под BEGIN.

REINDEX оценён замером на проде 17.09 (только чтение):
- gar_house_flats 3,27 млн строк / 942 МБ heap (1,5 ГБ с индексами), индекс частичный
  (flat_count > 0), 201 513 строк, 8,8 МБ;
- эквивалент перестройки (seq scan + tradein_canon_addr в один поток) 5,6 с — столько
  таблица закрыта для записи и для планировщика любого запроса к ней; читают её только
  ручной ГАР-загрузчик и ре-матч, пользовательский путь не задет;
- шаг S0b меняет ключ у 0 из 201 513 индексируемых строк: сегодня REINDEX ничего не
  меняет, но оставлен в той же транзакции, что и тело функции, чтобы строки, загруженные
  до деплоя, не остались со старым ключом. Прежнее обоснование («следующая загрузка ГАР»)
  было неверным: новые строки и так получают ключ нового тела.

REINDEX CONCURRENTLY возможен (раннер гонит файл psql'ем в autocommit, образец 270), но
не взят: ждёт все транзакции базы старше своего снимка (сборщики держат idle in
transaction минутами), lock_timeout не терпит, оборванный оставляет невалидный *_ccnew,
тело функции коммитится раньше индекса.

Тест по значению на живом Postgres: 311 гоняется как в раннере, пока «загрузчик» держит
ROW EXCLUSIVE на gar_house_flats — миграция падает LockNotAvailable быстрее 10 с, тело
функции откатывается (xmin строки pg_proc не меняется). Без SET LOCAL тест красный:
DID NOT RAISE — миграция дождалась загрузчика.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Слить свежий origin/main в fix/houses-dedup (вместо rebase: ветка уже опубликована, push без --force)
Some checks failed
CI Trade-In / backend-tests (pull_request) Failing after 8m21s
CI Trade-In / changes (pull_request) Successful in 15s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 17s
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
e3616e3611
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fix(tradein/canon): канон-ключ адреса больше не теряет дробь в номере дома — «Сулимова, 3/2» ≠ «32» (#2189)
Some checks failed
CI Trade-In / backend-tests (pull_request) Failing after 8m8s
CI Trade-In / changes (pull_request) Successful in 24s
CI / changes (pull_request) Successful in 31s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / backend-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 / openapi-codegen-check (pull_request) Has been skipped
3d9eaa2704
tradein_canon_addr последним шагом оставлял только [а-я0-9], и «3/2», «10/7», «4/1»
схлопывались в «32», «107», «41». Матчер ГАР при фильтре по городу брал из общего
ключа строку с max(flat_count): дом «Высоцкого, 4/2» получил 725 квартир ГАР-дома «42».

Миграция 322: тело 311 без изменений, кроме последнего шага — «/» остаётся, если с обеих
сторон цифры. Новый ключ — только уточнение старого (без «/» он равен старому), поэтому
склеить новые пары правка не может. Прод 17.09 (read-only): ключ меняется у 2549 из
49 204 домов, распавшиеся канон-кластеры — 113 пар, ни одной ближе 250 м и ни одной с
общим ФИАС. Затем REINDEX функционального индекса и сброс 47 canon_addr-сопоставлений,
державшихся только на потерянной дроби (порог остановки 100).

Живой тест B в test_real_fias_pass_cross_guard_and_identity_carryover держался на паре
«32»/«3/2»: после 322 у неё разные ключи, и страж разных ФИАС в тесте не участвовал
(со снятым стражем тест оставался зелёным). Пара заменена на «ул.»/«улица».

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Light1YT added 1 commit 2026-09-17 10:49:26 +00:00
Merge remote-tracking branch 'origin/main' into fix/canon-addr-slash
All checks were successful
CI / changes (pull_request) Successful in 27s
CI Trade-In / backend-tests (pull_request) Successful in 8m40s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / changes (pull_request) Successful in 21s
145a3a7944
Light1YT added 1 commit 2026-09-17 11:29:10 +00:00
Merge remote-tracking branch 'origin/main' into fix/canon-addr-slash
Some checks failed
CI Trade-In / changes (pull_request) Successful in 13s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI / changes (pull_request) Successful in 17s
CI / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Failing after 5m46s
8bd16e6ad0
Light1YT added 2 commits 2026-09-17 12:51:15 +00:00
После слияния main красным стал test_real_gar_match_picks_the_house_with_the_same_number:
оба дома получили (None, None, None). Канон-ключи и пары верные («сулимова21893/2» ↔
g2189-3-2, «сулимова218932» ↔ g2189-32); строки отсекает новый предикат _MATCH_SQL из
#3575 (b9c89641) `CAST(h.region_code AS text) = gp.region_code` — у фикстуры region_code
NULL. Это задуманное поведение #3575 (дом без региона guid не получает), поэтому чинится
фикстура: region_code 66, как у ГАР-строк и прогона. Проверка канона со «/» не менялась.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Merge remote-tracking branch 'origin/main' into fix/canon-addr-slash
All checks were successful
CI / changes (pull_request) Successful in 15s
CI Trade-In / changes (pull_request) Successful in 12s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m26s
f0d98934bd
bot-backend merged commit a1c7f8ea92 into main 2026-09-17 12:57:39 +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#3578
No description provided.