fix(tradein/avito): верное объяснение границы окна и критерий приёмки по глубине (#2674)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 7s
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 2m52s
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 7s
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 2m52s
Разбор ревью PR #2685. Выбор окна в 7 суток подтверждён непредвзятым замером по одному прогону (run 2990, exhaustive 02.08, 2184 датированных строки): W=2 -> 147, W=6 -> 446, W=7 -> 1275, W=12 -> 1278. Шестёрка теряет две трети семёрки, а 7..12 — плато в +3 лота, то есть семёрка стоит на самой дешёвой его точке. Потеря окна 2 занижена мной в первом заходе: не 3.26x, а 8.7x. Механизм объяснён неверно. Пик на возрасте ровно 7 — не недельный авто-подъём Авито, а квантование нашего же парсера относительных дат: «неделю назад» -> ровно today-7, «две недели назад» -> today-14, возрасты 8..13 по этому пути недостижимы. То самое плато (3 лота из 2184) это и доказывает: при реальном подъёме полоса 8..13 была бы заполнена. Вывод от этого только крепнет — шестёрка режет не по пику распределения, а по границе квантования и теряет бакет «неделю назад» целиком, а внутри него реальный возраст от 7 до 13 суток. 51% не воспроизводится: 1212 из 3714 датированных наблюдений — 32.6%. Пятьдесят один получается только на знаменателе, урезанном возрастами 0-13. Цена по запросам описана неверно и в опасную сторону. Рост не пропорционален лотам: стоимость бакета — ceil(свежих/50) страниц с полом 1-2, при окне 7 на бакет выходит ~15-20 свежих (1275 на 77 бакетов), то есть меньше страницы. Большинство бакетов как стояло на 1-2 страницах, так и останется. Верхняя граница честная и продом пережитая: полный обход без отсечки — 6 ч 59 мин (run 295) и 2 ч 34 мин (run 2990). Отсюда же переписан критерий приёмки: ждать «9-10 тысяч собранных лотов» нельзя, это уведёт в ложный вывод. Прогон с окном 2 уже собирал 2804 лота, потому что первые страницы всё равно полные — объём почти не сдвинется, сдвинется глубина. Считать надо лоты с listing_date в полосе [D-7, D-3] и число страниц из лог-строки paginated=. Впечатана мина на случай отката такта: ни миграция (GREATEST только расширяет), ни планировщик (расширяет до такта, не сужает) окно не сузят, поэтому interval_days 7 -> 1 при окне 7 даст восьмикратный охват каждый день. Такт и окно менять вместе. int(params.get("interval_days", 1)) падал на значении null в jsonb — соседний параметр строкой выше обрабатывался через явную проверку на None, этот нет.
This commit is contained in:
parent
e4ac0365cf
commit
6d76328168
3 changed files with 43 additions and 24 deletions
|
|
@ -17,30 +17,41 @@
|
|||
-- (57%) структурно вне поля зрения источника.
|
||||
--
|
||||
-- ЧИСЛА, обосновывающие новое значение (прод, tradein):
|
||||
-- * 2026-06-21 — единственный день, когда оба Avito-обхода отработали успешно:
|
||||
-- инкрементальный (окно 2) run 297 — 2804 unique, exhaustive (без отсечки)
|
||||
-- run 295 — 9992 unique. Окно в 2 суток достаёт 28.1% того, что достаёт
|
||||
-- полный обход; 7188 лотов (71.9%) лежат ниже отсечки.
|
||||
-- * Когорта run 295, не виденная после 2026-06-23 (listing_date заморожен):
|
||||
-- в полосе [D-2, D] — 189 лотов, в полосе [D-7, D-3] — ещё 427. Расширение
|
||||
-- окна 2 -> 7 берёт в 3.26 раза больше лотов.
|
||||
-- * Гистограмма (last_seen_at::date - listing_date) по avito за последние 20
|
||||
-- суток: возраст 0-2 — 594 лота, возраст ровно 7 — 1212 лотов (51% всех
|
||||
-- датированных наблюдений). У Avito недельный авто-подъём: sortTimeStamp
|
||||
-- сдвигается кратно 7 суткам, поэтому на возрасте ровно 7 стоит пик.
|
||||
-- Структурный минимум бездырочного покрытия — 6 (при 6 полосы соседних
|
||||
-- прогонов смыкаются), но 6 режет ровно по этому пику. 7 = такт: полосы
|
||||
-- [D-7, D] и [D, D+7] смыкаются с однодневным перехлёстом, который
|
||||
-- покрывает дрейф расписания (замер: last_run 2026-08-03 13:37 ->
|
||||
-- next_run_at 2026-08-10 14:16 = +7 суток 39 минут за цикл).
|
||||
-- * Замер по ОДНОМУ прогону (run 2990, exhaustive 2026-08-02, 2317 строк
|
||||
-- снапшота / 2184 с датой) — сколько лотов достаёт окно шириной W суток:
|
||||
-- W=2 -> 147 W=6 -> 446 W=7 -> 1275 W=12 -> 1278
|
||||
-- Три вывода. Окно 2 теряет в 8.7 раза (147 против 1275). Шестёрка теряет
|
||||
-- две трети семёрки (446 против 1275) — это обрыв, а не экономия. И 7..12 —
|
||||
-- ПЛАТО: +3 лота на пять дополнительных суток окна, то есть семёрка стоит
|
||||
-- на самой дешёвой точке плато, а не является компромиссом.
|
||||
-- * Обрыв на 7 и плато за ним — свойство НЕ Avito, а КВАНТОВАНИЯ нашего же
|
||||
-- парсера относительных дат (_parse_relative_date, providers/avito/serp.py):
|
||||
-- «неделю назад» -> ровно today-7, «две недели назад» -> ровно today-14.
|
||||
-- Возрасты 8..13 по этому пути недостижимы — на проде их 3 лота из 2184
|
||||
-- (это и есть плато). Значит бакет «возраст 7» — не «поднятые ровно неделю
|
||||
-- назад», а ВСЁ, чему реально от 7 до 13 суток. W=6 режет не по пику
|
||||
-- распределения, а по ГРАНИЦЕ КВАНТОВАНИЯ и теряет бакет целиком.
|
||||
-- * Тот же бакет в общей выборке: (last_seen_at::date - listing_date) по
|
||||
-- avito за 20 суток — возраст 0-2 = 594, возраст ровно 7 = 1212 из 3714
|
||||
-- датированных наблюдений (32.6%).
|
||||
-- * Полосы соседних прогонов [D-7, D] и [D, D+7] смыкаются с суточным
|
||||
-- перехлёстом, который покрывает дрейф расписания (замер: last_run
|
||||
-- 2026-08-03 13:37 -> next_run_at 2026-08-10 14:16 = +7 суток 39 минут).
|
||||
--
|
||||
-- ЦЕНА ПО ЗАПРОСАМ. Пагинация останавливается по глубине окна, поэтому число
|
||||
-- страниц растёт примерно как число лотов в окне: ~3.3x к прогону. Прогоны с
|
||||
-- окном 2 при delay=1.0: 2804 lots / 40 мин (run 297) .. 3989 lots / 99 мин
|
||||
-- (run 620) ≈ 150-370 страниц. Окно 7 -> ≈ 500-1200 страниц НА ПРОГОН, но
|
||||
-- прогон теперь недельный, а не ежедневный: до 206 система платила те же
|
||||
-- 150-370 страниц СЕМЬ раз в неделю (~1050-2600). После этой правки —
|
||||
-- ~500-1200 в неделю, то есть по-прежнему примерно вдвое дешевле, чем до 206.
|
||||
-- ЦЕНА ПО ЗАПРОСАМ растёт НЕ пропорционально лотам. Стоимость бакета —
|
||||
-- ceil(свежих / 50) страниц с полом в 1-2 страницы; при окне 7 на бакет
|
||||
-- приходится ~15-20 свежих (1275 лотов на 77 бакетов = 7 комнатностей x 11
|
||||
-- ценовых seed-брекетов) — МЕНЬШЕ одной страницы. Большинство бакетов как
|
||||
-- стояло на 1-2 страницах, так и останется, глубже пойдут только плотные.
|
||||
-- Верхняя граница честная и продом уже пережитая: полный обход без отсечки
|
||||
-- вообще — 6 ч 59 мин (run 295) и 2 ч 34 мин (run 2990); окно 13-15 UTC
|
||||
-- ограничивает только СТАРТ прогона, не длительность.
|
||||
--
|
||||
-- ВОЗВРАЩАЕТЕ ЕЖЕДНЕВНЫЙ ТАКТ — ВЕРНИТЕ И ОКНО. Ни эта миграция (GREATEST
|
||||
-- только расширяет), ни планировщик (расширяет до такта, не сужает) окно НЕ
|
||||
-- сузят. interval_days 7 -> 1 при incremental_days = 7 даст ежедневный прогон
|
||||
-- с семисуточной глубиной, то есть восьмикратный охват КАЖДЫЙ день. Такт и
|
||||
-- окно менять одной правкой.
|
||||
--
|
||||
-- Значение НЕ хардкодим числом 7, а выводим из фактического interval_days строки:
|
||||
-- если такт когда-нибудь поменяют снова, повторный прогон файла (или ручной
|
||||
|
|
|
|||
|
|
@ -87,6 +87,11 @@ async def test_daily_cadence_keeps_window() -> None:
|
|||
assert await _incremental_days_passed({"incremental_days": 2}) == 2
|
||||
|
||||
|
||||
async def test_null_interval_days_does_not_crash() -> None:
|
||||
"""`"interval_days": null` в jsonb приезжает сюда как None, а int(None) — TypeError."""
|
||||
assert await _incremental_days_passed({"interval_days": None, "incremental_days": 2}) == 2
|
||||
|
||||
|
||||
async def test_no_window_stays_exhaustive() -> None:
|
||||
"""Строка без incremental_days = полный обход; такт не должен её «инкрементализировать»."""
|
||||
assert await _incremental_days_passed({"interval_days": 7}) is None
|
||||
|
|
|
|||
|
|
@ -526,7 +526,10 @@ async def _job_avito_full_load(
|
|||
# incremental_days=2 из миграции 129 → 3 календарных дня из 7 в поле зрения.
|
||||
# Два независимых литерала, которые обязаны совпадать, однажды уже разъехались —
|
||||
# поэтому расхождение чиним здесь, а не только данными.
|
||||
interval_days = max(1, int(params.get("interval_days", 1)))
|
||||
# None-safe так же, как incremental_days выше: `"interval_days": null` в jsonb
|
||||
# приезжает сюда как None, а int(None) — TypeError.
|
||||
_interval_days = params.get("interval_days")
|
||||
interval_days = max(1, int(_interval_days)) if _interval_days is not None else 1
|
||||
if incremental_days is not None and incremental_days < interval_days:
|
||||
logger.warning(
|
||||
"avito_full_load: окно ретроспективы incremental_days=%d уже такта "
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue