fix(tradein): гейт против сдвига разряда (×10) в offer_price_history у всех писателей + миграция 286 (#3376) #3383

Merged
bot-backend merged 4 commits from fix/3376-price-history-decimal-slips into main 2026-09-06 00:19:34 +00:00
Collaborator

Closes #3376 (пп. 1-3).

Дефект. В offer_price_history есть точки с ценой ровно ×10/÷10 к соседям — потерянный разряд в priceHistory источника (не рынок). Прод-замер 06.09: по всем источникам (domklik/cian/yandex; у avito одна триггерная строка — не трогаем). Оценщик таблицу не читает; читают витрина (trade_in.py, mera.py), лендинг (landing_stats — медианный торг #3223), admin.

Гейт на записиscraper_kit/offer_price_history.py::drop_decimal_slips(points, price_of, *, current_price, listing_id) -> (kept, dropped) рядом с validate_diff_percent: точка выбрасывается, если скачок к предыдущей ОСТАВЛЕННОЙ точке в [9.5, 10.5] в любую сторону и следующая точка (или текущая цена объявления, если следующей нет) возвращается к базе [0.9, 1.1]; без второго свидетеля точка остаётся. Warning с listing_id/prev/cur/next. Подключён во всех писателях вне тестов и data/sql/:

  • providers/domclick/detail.py:832 — + SELECT текущей цены как свидетель; diff_percent соседа пересчитывается от настоящей базы;
  • providers/cian/detail.py:487 — серия сортируется по change_time перед гейтом; свидетель — _snap_row.price_rub;
  • app/services/yandex_price_history.py:72 — подключён, но структурно сработать не может (вставляемая точка = текущая цена лота, второй свидетель совпадает с проверяемой) — потолок назван в коде; прод-строки yandex чистит миграция;
  • НЕ подключён scripts/local-cian/playwright_history.py — разовый локальный скрипт на голом psycopg, scraper_kit не импортирует.
    decimal_slips_dropped — в лог-строках писателей (в counters пайплайна не протянут: save_detail_enrichment -> bool проверяется в 6 местах, смена типа — отдельный диф).

Миграция data/sql/286_offer_price_history_decimal_slips.sql (286 свободен по всем рефам origin): только loader-строки (change_time <> recorded_at), LAG/LEAD по (listing_id, change_time, id), последняя точка — против listings.price_rub; DELETE спайков + пересчёт diff_percent следующей строки по формуле 285 (|x|>100 → NULL); lock_timeout 5s, temp-таблица ON COMMIT DROP, RAISE NOTICE по источникам, RAISE EXCEPTION при >200; идемпотентна (второй прогон 0/0). Триггер listings_price_change_trg не тронут.

Поправка к замеру из issue: пример 377000 → 3770000 → 3720000 — дефектная точка 377000 (÷10), а 3770000 — возврат; в 76 строках замера пара «провал + возврат» посчитана дважды. Предзамер на проде (SELECT из ветки) — в комментарии ниже.

Тесты (tests/scrapers/test_domclick_detail.py, где живут unit-тесты validate_diff_percent): 6 на гейт (спайк вверх/вниз, последняя точка со свидетелем и без, честный ×2, ×10 без возврата) + 1 на писателя: в INSERT уходят [3720000, 3770000] без 377000, diff_percent соседа 1.34 вместо 900.0. Чужая фикстура test_3253_domclick_house_fields.py дополнена fetchone=lambda: None (писатель теперь читает цену листинга).

Фальсификация (гейт заглушен return list(points), 0): assert [3720000, 377000, 3770000] == [3720000, 3770000] и ещё 3 — красное по значению, спайк-точка попадает в INSERT; три негатива остаются зелёными и с заглушкой (нечувствительны по построению). Восстановлено.

Прогоны: полный backend 5482 passed, 35 skipped (rc=0, || rc=$?); -k migration_numbering rc=0; ruff check/format rc=0; check-migration-lock-timeout.py зелёный.

Приёмка на проде: после деплоя RAISE NOTICE миграции == предзамер; count(*) FILTER (WHERE abs(diff_percent) > 100) у loader-строк domklik → 0 (было 42); повтор предзамера → 0 строк; медианный торг #3223 до/после — в комментарии.

Closes #3376 (пп. 1-3). **Дефект.** В `offer_price_history` есть точки с ценой ровно ×10/÷10 к соседям — потерянный разряд в `priceHistory` источника (не рынок). Прод-замер 06.09: по всем источникам (domklik/cian/yandex; у avito одна триггерная строка — не трогаем). Оценщик таблицу не читает; читают витрина (`trade_in.py`, `mera.py`), лендинг (`landing_stats` — медианный торг #3223), admin. **Гейт на записи** — `scraper_kit/offer_price_history.py::drop_decimal_slips(points, price_of, *, current_price, listing_id) -> (kept, dropped)` рядом с `validate_diff_percent`: точка выбрасывается, если скачок к предыдущей ОСТАВЛЕННОЙ точке в `[9.5, 10.5]` в любую сторону **и** следующая точка (или текущая цена объявления, если следующей нет) возвращается к базе `[0.9, 1.1]`; без второго свидетеля точка остаётся. Warning с listing_id/prev/cur/next. Подключён во всех писателях вне тестов и `data/sql/`: - `providers/domclick/detail.py:832` — + SELECT текущей цены как свидетель; `diff_percent` соседа пересчитывается от настоящей базы; - `providers/cian/detail.py:487` — серия сортируется по `change_time` перед гейтом; свидетель — `_snap_row.price_rub`; - `app/services/yandex_price_history.py:72` — подключён, но структурно сработать не может (вставляемая точка = текущая цена лота, второй свидетель совпадает с проверяемой) — потолок назван в коде; прод-строки yandex чистит миграция; - НЕ подключён `scripts/local-cian/playwright_history.py` — разовый локальный скрипт на голом psycopg, `scraper_kit` не импортирует. `decimal_slips_dropped` — в лог-строках писателей (в counters пайплайна не протянут: `save_detail_enrichment -> bool` проверяется в 6 местах, смена типа — отдельный диф). **Миграция `data/sql/286_offer_price_history_decimal_slips.sql`** (286 свободен по всем рефам origin): только loader-строки (`change_time <> recorded_at`), LAG/LEAD по `(listing_id, change_time, id)`, последняя точка — против `listings.price_rub`; DELETE спайков + пересчёт `diff_percent` следующей строки по формуле 285 (`|x|>100 → NULL`); `lock_timeout 5s`, temp-таблица `ON COMMIT DROP`, `RAISE NOTICE` по источникам, `RAISE EXCEPTION` при >200; идемпотентна (второй прогон 0/0). Триггер `listings_price_change_trg` не тронут. **Поправка к замеру из issue:** пример `377000 → 3770000 → 3720000` — дефектная точка `377000` (÷10), а `3770000` — возврат; в 76 строках замера пара «провал + возврат» посчитана дважды. Предзамер на проде (SELECT из ветки) — в комментарии ниже. **Тесты** (`tests/scrapers/test_domclick_detail.py`, где живут unit-тесты `validate_diff_percent`): 6 на гейт (спайк вверх/вниз, последняя точка со свидетелем и без, честный ×2, ×10 без возврата) + 1 на писателя: в `INSERT` уходят `[3720000, 3770000]` без `377000`, `diff_percent` соседа `1.34` вместо `900.0`. Чужая фикстура `test_3253_domclick_house_fields.py` дополнена `fetchone=lambda: None` (писатель теперь читает цену листинга). **Фальсификация** (гейт заглушен `return list(points), 0`): `assert [3720000, 377000, 3770000] == [3720000, 3770000]` и ещё 3 — красное по значению, спайк-точка попадает в INSERT; три негатива остаются зелёными и с заглушкой (нечувствительны по построению). Восстановлено. **Прогоны:** полный backend `5482 passed, 35 skipped` (rc=0, `|| rc=$?`); `-k migration_numbering` rc=0; ruff check/format rc=0; `check-migration-lock-timeout.py` зелёный. **Приёмка на проде:** после деплоя `RAISE NOTICE` миграции == предзамер; `count(*) FILTER (WHERE abs(diff_percent) > 100)` у loader-строк domklik → 0 (было 42); повтор предзамера → 0 строк; медианный торг #3223 до/после — в комментарии.
bot-backend added 1 commit 2026-09-05 21:38:53 +00:00
fix(tradein): гейт против сдвига разряда в offer_price_history (#3376)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 11s
CI / changes (pull_request) Successful in 14s
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 5m4s
142967d064
В priceHistory источников встречаются точки ровно ×10/÷10 к соседям с
возвратом к базе следующей же точкой — это потерянный разряд у источника,
а не рынок. Прод-замер 06.09.2026: 76 таких строк у 55 объявлений
(domklik 48, cian 21, yandex 6; единственная avito-строка — триггерная).
Читатели колонки — медианный торг лендинга (#3223), админка, /scrapers.

drop_decimal_slips живёт рядом с validate_diff_percent — на той же единой
границе записи, что и гейт #3225, и подключён ко ВСЕМ писателям истории
(domclick/detail.py, cian/detail.py, yandex_price_history.py). Критерий
требует двух свидетелей: скачок ×10 к предыдущей точке И возврат к базе у
следующей; у последней точки серии свидетель — текущая цена объявления,
нет и её → точку не трогаем (без второго свидетеля ×10 может быть честной
сменой цены). У domklik после выброса пересчитывается diff_percent
соседа: парсер считал его от базы, которой больше нет.

Миграция 286 чистит уже собранные строки тем же критерием и только у
загрузчика (change_time <> recorded_at): триггерные строки — это живые
смены listings.price_rub, у них другая база отсчёта (см. 285). Падает,
если кандидатов больше 200.
Author
Collaborator

Предзамер миграции 286 на проде (2026-09-06, SELECT из ветки, ничего не менял):

source to_delete listings to_recompute
cian 12 10 12
domklik 23 21 23
yandex 0 0 0
итого 35 31 35

yandex 0 — у всех шести ×10-точек нет второго свидетеля (нет возврата, текущая цена лота = спайк): по правилу гейта остаются, честно.

Ожидаемый остаток |diff_percent| > 100 у loader-строк domklik после миграции — 20, а не 0 (было 42). Разобрал все 20:

  1. 12 строк — зеркальный случай, сдвиг в ПЕРВОЙ точке серии: 330000 → 3300000 → (нет), текущая цена 3300000; 420000 → 4200000 → 4500000; 1400000 → 14000000 → 13000000. Дефектная точка — первая (÷10), у неё нет «предыдущей», и критерий «скачок к prev + возврат next» её не видит, а следующая (база) получает diff 900 %. Свидетели для первой точки есть: next и next-next (или текущая цена объявления), они согласны между собой в [0.9, 1.1].
  2. 8 строк — честные скачки ×2–×6.6 (4369000 → 9499789, 5000000 → 12500000, 1790000 → 11790000): не сдвиг разряда; гейт на записи validate_diff_percent такие занулил бы (|x|>100 = «другая величина»), а в базе они остались с 117–559 %.

Доработка (после deep-ревью, одним заходом): (а) симметричное правило для первой точки серии — выбрасывать, если она ×10/÷10 к next и next согласен с next-next-или-текущей-ценой; в гейте и в миграции; (б) финальный шаг миграции — |diff_percent| > 100 у loader-строк всех источников → NULL, как делает гейт на записи (п.1 issue). Тогда приёмка → 0 выполняется по построению, и предзамер станет ~47 удалений + ~8 занулений.

**Предзамер миграции 286 на проде (2026-09-06, SELECT из ветки, ничего не менял):** | source | to_delete | listings | to_recompute | |---|---:|---:|---:| | cian | 12 | 10 | 12 | | domklik | 23 | 21 | 23 | | yandex | 0 | 0 | 0 | | **итого** | **35** | **31** | **35** | yandex 0 — у всех шести ×10-точек нет второго свидетеля (нет возврата, текущая цена лота = спайк): по правилу гейта остаются, честно. **Ожидаемый остаток `|diff_percent| > 100` у loader-строк domklik после миграции — 20, а не 0** (было 42). Разобрал все 20: 1. **12 строк — зеркальный случай, сдвиг в ПЕРВОЙ точке серии**: `330000 → 3300000 → (нет)`, текущая цена 3300000; `420000 → 4200000 → 4500000`; `1400000 → 14000000 → 13000000`. Дефектная точка — первая (÷10), у неё нет «предыдущей», и критерий «скачок к prev + возврат next» её не видит, а следующая (база) получает diff 900 %. Свидетели для первой точки есть: next и next-next (или текущая цена объявления), они согласны между собой в [0.9, 1.1]. 2. **8 строк — честные скачки ×2–×6.6** (`4369000 → 9499789`, `5000000 → 12500000`, `1790000 → 11790000`): не сдвиг разряда; гейт на записи `validate_diff_percent` такие занулил бы (`|x|>100` = «другая величина»), а в базе они остались с 117–559 %. Доработка (после deep-ревью, одним заходом): (а) симметричное правило для первой точки серии — выбрасывать, если она ×10/÷10 к next **и** next согласен с next-next-или-текущей-ценой; в гейте и в миграции; (б) финальный шаг миграции — `|diff_percent| > 100` у loader-строк всех источников → NULL, как делает гейт на записи (п.1 issue). Тогда приёмка `→ 0` выполняется по построению, и предзамер станет ~47 удалений + ~8 занулений.
Author
Collaborator

Deep-ревью (06.09): — три HIGH и два MEDIUM, все закрываются доработкой в этой же ветке:

  • HIGH-1/2 миграция: lag() берёт предыдущую СЫРУЮ строку (включая удаляемые тем же проходом), а гейт — предыдущую ОСТАВЛЕННУЮ → на 1M → 10M → 1M → 10M миграция удаляет честную точку (гейт оставляет [1M,1M], миграция [1M]); на 1M → 10M → 1.05M → 9.9M второй прогон удаляет ещё одну — не идемпотентна (124 таких серии в переборе ревьюера). Переписываю выбор кандидатов как PL/pgSQL-зеркало гейта; идемпотентность доказывается property-тестом gate(gate(s)) == gate(s) на переборе серий; плюс L-4 (NOT EXISTS по триггерной строке с той же ценой).
  • HIGH-3 yandex: проводка мертва структурно (2 точки, свидетель последней = она сама) и миграция yandex не тронет (0 «вниз») — проводка снимается, честный потолок в комментарии, follow-up #3385.
  • M-1 cian: при выбросе точки diff_percent соседа не пересчитывался (-90 вместо 0.0) — переносится пересчёт из domclick.
  • M-2: ожидаемое число в шапке — под двухсвидетельским критерием (35 по предзамеру + правило первой точки), не 76.
  • Плюс из моего прод-разбора: правило первой точки серии (12 из 20 остатков domklik) и финальное зануление |diff_percent| > 100 у loader-строк всех источников (п.1 issue) → приёмка «→ 0» по построению.

Ревью подтвердило: numeric-деление (не bigint), diff_percent numeric(5,2) влезает, пересчёт от оставшихся строк, честный ×10 без возврата не режется, номер 286 свободен, писатели покрыты все (кроме локального dev-скрипта). Для прод-проверки в ветку добавится dry-run (ROLLBACK, два прохода: второй обязан дать 0/0/0) — прогоню на проде до мержа.

Deep-ревью (06.09): ❌ — три HIGH и два MEDIUM, все закрываются доработкой в этой же ветке: - **HIGH-1/2 миграция:** `lag()` берёт предыдущую СЫРУЮ строку (включая удаляемые тем же проходом), а гейт — предыдущую ОСТАВЛЕННУЮ → на `1M → 10M → 1M → 10M` миграция удаляет честную точку (гейт оставляет `[1M,1M]`, миграция `[1M]`); на `1M → 10M → 1.05M → 9.9M` второй прогон удаляет ещё одну — не идемпотентна (124 таких серии в переборе ревьюера). Переписываю выбор кандидатов как PL/pgSQL-зеркало гейта; идемпотентность доказывается property-тестом `gate(gate(s)) == gate(s)` на переборе серий; плюс L-4 (`NOT EXISTS` по триггерной строке с той же ценой). - **HIGH-3 yandex:** проводка мертва структурно (2 точки, свидетель последней = она сама) и миграция yandex не тронет (0 «вниз») — проводка снимается, честный потолок в комментарии, follow-up #3385. - **M-1 cian:** при выбросе точки `diff_percent` соседа не пересчитывался (`-90` вместо `0.0`) — переносится пересчёт из domclick. - **M-2:** ожидаемое число в шапке — под двухсвидетельским критерием (35 по предзамеру + правило первой точки), не 76. - Плюс из моего прод-разбора: правило первой точки серии (12 из 20 остатков domklik) и финальное зануление `|diff_percent| > 100` у loader-строк всех источников (п.1 issue) → приёмка «→ 0» по построению. Ревью подтвердило: numeric-деление (не bigint), `diff_percent numeric(5,2)` влезает, пересчёт от оставшихся строк, честный ×10 без возврата не режется, номер 286 свободен, писатели покрыты все (кроме локального dev-скрипта). Для прод-проверки в ветку добавится dry-run (`ROLLBACK`, два прохода: второй обязан дать 0/0/0) — прогоню на проде до мержа.
Light1YT added 1 commit 2026-09-05 22:54:18 +00:00
fix(tradein): выборка миграции 286 повторяет гейт 1:1, правило первой точки (#3376)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
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 4m56s
721ceb9876
Ревью нашло, что миграция и код ловили РАЗНОЕ. Миграция брала базой предыдущую
СЫРУЮ строку (lag), гейт — предыдущую ОСТАВЛЕННУЮ. На 1M→10M→1M→10M (цена 1M)
lag-версия удаляла честную точку, на 1M→10M→1.05M→9.9M — не была идемпотентной
(второй прогон доедал 9.9M). Теперь кандидаты выбирает PL/pgSQL-цикл, пошагово
повторяющий drop_decimal_slips, а правило первой точки — отдельным INSERT..SELECT
уже по ОСТАВШИМСЯ строкам.

Правило первой точки — из прод-разбора: 12 из 20 остатков domklik это серии вида
330 000 → 3 300 000 (текущая цена 3 300 000) и 420 000 → 4 200 000 → 4 500 000,
где дефектная точка ПЕРВАЯ и базы слева у неё нет. Свидетелей по-прежнему два:
×10 ко второй точке И подтверждение второй третьей-или-текущей-ценой. Решение по
первой точке принимается по kept-серии, а не по сырой, — иначе гейт теряет
идемпотентность (перебор ловит 1122 таких прогона).

Идемпотентность доказана НА ГЕЙТЕ: property-тест gate(gate(s)) == gate(s) по всем
сериям длины 2-6 (19 525 серий × 3 текущие цены). Фальсифицирован обеими
поломками — сырая база даёт 136 красных прогонов, сырые соседи первой точки 1122.
Раз SQL зеркалит гейт, свойство переносится на миграцию.

Ещё в 286: третий свидетель ПРОТИВ удаления (цена подтверждена триггерной строкой
того же объявления — значит она реально наблюдалась в listings.price_rub) и
финальный шаг |diff_percent| > 100 → NULL по всем источникам, то же правило, что
validate_diff_percent на записи. Ожидаемое число удалений в шапке — 35 + ~12 из
двухсвидетельского предзамера, а не 76 (то была односвидетельская цифра).
Прогон обеих фаз дважды с ROLLBACK — tradein-mvp/scripts/sql/286_dryrun.sql.

yandex: проводка гейта снята как мёртвая. На том пути серия из двух точек, а
свидетель последней — текущая цена лота, то есть она же сама: ветка по построению
не могла выбросить ничего. Оставлен честный комментарий-потолок и ссылка на
follow-up (отлов требует DELETE на следующем наблюдении).

cian: после выброса точки соседу пересчитывается diff_percent (было только у
domclick). domclick: цена листинга берётся RETURNING'ом у UPDATE вместо отдельного
SELECT по PK, пересчёт вынесен в общий recompute_diff_percent с гейтом на пустую
цену (ручной ingest кладёт price_changes из JSONL без валидации).
bot-backend added 1 commit 2026-09-05 22:57:07 +00:00
docs(#3376): ссылка на follow-up #3385 вместо TODO в yandex_price_history
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
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 4m52s
dc63b1830b
Author
Collaborator

Dry-run миграции 286 на проде (06.09 ~23:05 UTC, scripts/sql/286_dryrun.sql, транзакция с ROLLBACK, первое живое исполнение PL/pgSQL):

Предзамеры: первая точка (сырые строки) — cian 2, domklik 13, yandex 6; |diff_percent| > 100 — cian 2, domklik 42.

pass1: deleted=55 (cian=14, domklik=35, yandex=6) recomputed=47 nulled=9 (domklik=9)
pass2: deleted=0 recomputed=0 nulled=0
  • 55 < порога 200; второй проход в той же транзакции — ноль по всем трём: идемпотентность подтверждена на реальных данных, не только property-тестом.
  • yandex 6 — все по правилу первой точки (price.previous ÷10 к текущей цене лота). Замечание для ревью: у yandex вторая точка и «текущая цена» — одно и то же значение из одного батча, т.е. два свидетеля правила первой точки здесь не независимы; у domklik/cian последняя точка истории и listings.price_rub тоже берутся с одной карточки. Это принято в исходном правиле для последней точки (deep-ревью-1), но стоит записать явно.
  • nulled=9 (domklik) — честные скачки ×2–×6.6, остаются с ценой, без «процента другой величины»; приёмка → 0 по |diff|>100 выполняется по построению.

Ветка: 721ceb98 + dc63b183 (#TODO-yandex-slip#3385).

**Dry-run миграции 286 на проде (06.09 ~23:05 UTC, `scripts/sql/286_dryrun.sql`, транзакция с ROLLBACK, первое живое исполнение PL/pgSQL):** Предзамеры: первая точка (сырые строки) — cian 2, domklik 13, yandex 6; `|diff_percent| > 100` — cian 2, domklik 42. ``` pass1: deleted=55 (cian=14, domklik=35, yandex=6) recomputed=47 nulled=9 (domklik=9) pass2: deleted=0 recomputed=0 nulled=0 ``` - 55 < порога 200; второй проход в той же транзакции — ноль по всем трём: идемпотентность подтверждена на реальных данных, не только property-тестом. - yandex 6 — все по правилу первой точки (`price.previous` ÷10 к текущей цене лота). Замечание для ревью: у yandex вторая точка и «текущая цена» — одно и то же значение из одного батча, т.е. два свидетеля правила первой точки здесь не независимы; у domklik/cian последняя точка истории и `listings.price_rub` тоже берутся с одной карточки. Это принято в исходном правиле для последней точки (deep-ревью-1), но стоит записать явно. - `nulled=9` (domklik) — честные скачки ×2–×6.6, остаются с ценой, без «процента другой величины»; приёмка `→ 0` по `|diff|>100` выполняется по построению. Ветка: 721ceb98 + dc63b183 (`#TODO-yandex-slip` → #3385).
Author
Collaborator

Замер «до» для приёмки #3223 (медианный торг, landing_stats._PRICE_MOVES_SQL, span 14 дн., abs(pct) <= 50), прод 06.09 ~23:10 UTC: n=7789, снижавших 3811, медиана −2.056 %/мес (в таблице landing_stats сейчас −2.05 при sample 3799 от 05:36). Строк с |pct| > 50 в выборке 0 — ×10-точки как первая/последняя уже отсекались фильтром, так что после 286 ожидаю сдвиг в третьем знаке, не больше; сверю тем же запросом после деплоя.

**Замер «до» для приёмки #3223 (медианный торг, `landing_stats._PRICE_MOVES_SQL`, span 14 дн., `abs(pct) <= 50`), прод 06.09 ~23:10 UTC:** n=7789, снижавших 3811, медиана **−2.056 %/мес** (в таблице `landing_stats` сейчас −2.05 при sample 3799 от 05:36). Строк с `|pct| > 50` в выборке 0 — ×10-точки как первая/последняя уже отсекались фильтром, так что после 286 ожидаю сдвиг в третьем знаке, не больше; сверю тем же запросом после деплоя.
Author
Collaborator

Deep-ревью 2 (06.09): ⚠️ minor, блокеров нет. Проверено исполнением, не чтением: одноразовый postgres:16 с настоящим файлом 286 на переборе 58 575 серий (336 900 строк) — удалённые id совпали с выходом drop_decimal_slips побайтово, повторный прогон 0/0/0; read-only прогон гейта по всем 63 217 прод-строкам загрузчика — те же 55 id, что даёт SQL. Мутации property-теста воспроизвелись до штуки (136 / 1122, первые падающие серии дословно). RETURNING+rowcount проверен на живом psycopg3 (-1 после fetchone() на пустом RETURNING). Номер 286 свободен по 432 refs; dry-run-файл под авто-применение не попадает.

Разложение 55 удалений по свидетелю (прод): внутри серии по следующей точке — cian 12, domklik 22; первая точка по третьей точке — domklik 4; первая точка по listings.price_rub — cian 2, domklik 9, yandex 6. Во всех 17 случаях со свидетелем-ценой значение точно равно второй точке (одно наблюдение в двух местах; у yandex буквально одна переменная); третья точка как свидетель отбраковывает 73 % кандидатов, цена объявления — 5.6 %.

Решение (консервативно, DELETE необратим): для правила первой точки свидетель — только третья точка истории; fallback на listings.price_rub снимается в гейте, миграции и dry-run. Ожидание становится 38 (34 внутри серий + 4 первых точки; cian 12, domklik 26, yandex 0) — yandex целиком уходит в #3385; 17 строк с вырожденным свидетелем остаются с diff_percent → NULL финальным шагом. Плюс косметика: числа в шапке 286 и RAISE EXCEPTION (47 → 38 под критерием), «76» из докстринга, пометка о нулевом эффекте L-4 на проде, ponytail: о строковой сортировке change_time у cian, «писатели на живом тракте». Перед мержем — выгрузка удаляемых строк в файл на хосте и повторный dry-run (ожидание 38 / pass2 0).

**Deep-ревью 2 (06.09): ⚠️ minor, блокеров нет.** Проверено исполнением, не чтением: одноразовый postgres:16 с настоящим файлом 286 на переборе 58 575 серий (336 900 строк) — удалённые id совпали с выходом `drop_decimal_slips` побайтово, повторный прогон 0/0/0; read-only прогон гейта по всем 63 217 прод-строкам загрузчика — те же 55 id, что даёт SQL. Мутации property-теста воспроизвелись до штуки (136 / 1122, первые падающие серии дословно). `RETURNING`+`rowcount` проверен на живом psycopg3 (`-1` после `fetchone()` на пустом RETURNING). Номер 286 свободен по 432 refs; dry-run-файл под авто-применение не попадает. **Разложение 55 удалений по свидетелю (прод):** внутри серии по следующей точке — cian 12, domklik 22; первая точка по третьей точке — domklik 4; первая точка по `listings.price_rub` — cian 2, domklik 9, yandex 6. **Во всех 17 случаях со свидетелем-ценой значение точно равно второй точке** (одно наблюдение в двух местах; у yandex буквально одна переменная); третья точка как свидетель отбраковывает 73 % кандидатов, цена объявления — 5.6 %. **Решение (консервативно, DELETE необратим):** для правила первой точки свидетель — только третья точка истории; fallback на `listings.price_rub` снимается в гейте, миграции и dry-run. Ожидание становится **38** (34 внутри серий + 4 первых точки; cian 12, domklik 26, yandex 0) — yandex целиком уходит в #3385; 17 строк с вырожденным свидетелем остаются с `diff_percent → NULL` финальным шагом. Плюс косметика: числа в шапке 286 и `RAISE EXCEPTION` (47 → 38 под критерием), «76» из докстринга, пометка о нулевом эффекте L-4 на проде, `ponytail:` о строковой сортировке `change_time` у cian, «писатели на живом тракте». Перед мержем — выгрузка удаляемых строк в файл на хосте и повторный dry-run (ожидание 38 / pass2 0).
Light1YT added 1 commit 2026-09-06 00:13:36 +00:00
fix(#3376): первая точка — только по третьей точке истории; числа под критерием
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 10s
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 4m58s
1b86ba7e06
Author
Collaborator

Dry-run v3 на проде (06.09 ~23:45 UTC, голова 1b86ba7e, ROLLBACK):

Предзамеры: первая точка (сырые строки) — domklik 4 (cian/yandex 0); |diff_percent| > 100 — cian 2, domklik 42.

pass1: deleted=38 (cian=12, domklik=26) recomputed=38 nulled=17 (cian=1, domklik=16)
pass2: deleted=0 recomputed=0 nulled=0

Ровно ожидание из шапки 286 (34 + 4 = 38; yandex 0). nulled=17 — это 44 строки с |diff|>100 минус удалённые спайки и минус пересчитанные соседи; после прохода |diff|>100 у loader-строк — 0.

Бэкап перед необратимым DELETE: тем же проходом под ROLLBACK выгружены все строки offer_price_history затрагиваемых объявлений плюс все loader-строки с |diff|>100333 строки (COPY 333), файл на хосте Poincare ~/backups/oph_286_backup_20260906.csv (вне git). Откат при необходимости — \copy обратно по id.

**Dry-run v3 на проде (06.09 ~23:45 UTC, голова 1b86ba7e, ROLLBACK):** Предзамеры: первая точка (сырые строки) — **domklik 4** (cian/yandex 0); `|diff_percent| > 100` — cian 2, domklik 42. ``` pass1: deleted=38 (cian=12, domklik=26) recomputed=38 nulled=17 (cian=1, domklik=16) pass2: deleted=0 recomputed=0 nulled=0 ``` Ровно ожидание из шапки 286 (34 + 4 = 38; yandex 0). `nulled=17` — это 44 строки с `|diff|>100` минус удалённые спайки и минус пересчитанные соседи; после прохода `|diff|>100` у loader-строк — 0. **Бэкап перед необратимым DELETE:** тем же проходом под ROLLBACK выгружены все строки `offer_price_history` затрагиваемых объявлений плюс все loader-строки с `|diff|>100` — **333 строки** (`COPY 333`), файл на хосте Poincare `~/backups/oph_286_backup_20260906.csv` (вне git). Откат при необходимости — `\copy` обратно по `id`.
bot-backend merged commit 33444b7eab into main 2026-09-06 00:19:34 +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#3383
No description provided.