diff --git a/tradein-mvp/backend/app/tasks/deactivate_stale_avito.py b/tradein-mvp/backend/app/tasks/deactivate_stale_avito.py index 7b898079..9248a45c 100644 --- a/tradein-mvp/backend/app/tasks/deactivate_stale_avito.py +++ b/tradein-mvp/backend/app/tasks/deactivate_stale_avito.py @@ -231,16 +231,28 @@ _REVISIT_FLOOR_SEGMENT_FILTER = "\n AND l.listing_segment = ANY(CAST(:s # ttl_days. Замер (_REVISIT_TAIL, 40 суток): avito p99 = 42.1 сут -- ВЫШЕ его же # потолка 20. То есть для avito дефолтный CAP_MULT=2 может резать ttl ниже # собственного хвоста обхода -- ровно тот false-kill, ради которого пол вообще -# заведён (см. комментарий выше). У cian/yandex (потолок 60) и domklik (потолок 28 -# при хвосте 3.1) такого разрыва нет -- множитель 2 для них калиброван верно. +# заведён (см. комментарий выше). domklik (потолок 28 при хвосте 3.1) разрыва не +# имеет -- множитель 2 для него калиброван верно. +# +# YANDEX -- ТА ЖЕ ДЫРА, НАЙДЕНА ПОЗЖЕ (ревью круга 3, 2026-08-15). Строка выше до +# этой правки утверждала, что cian/yandex с потолком 60 тоже в порядке -- это было +# верно для cian (live-пол сейчас 31.1), но НЕ для yandex: ЖИВЫЕ полы из +# scrape_runs.counters (deactivate_stale_yandex, 2026-08-10..08-15) -- 75/75/75/39/ +# 52/54, а прямой live-замер той же percentile_disc(0.99)-формулы сегодня даёт 79.2 +# (n=1961 подтверждений за 3 суток). И то, и другое ВЫШЕ потолка 60 -- тот же +# false-kill класс, что у avito, статический p99=43.0 (_REVISIT_TAIL) для yandex +# устарел и вводит в заблуждение. cap_mult для yandex откалиброван отдельной +# миграцией (265_deactivate_stale_yandex_cap_mult.sql, cap_mult=3 -> потолок 90) -- +# см. её комментарий про то, почему это НЕ меняет число деактивированных строк +# следующим прогоном (0 активных строк источника старше 39 суток на момент замера). # # ПОЭТОМУ cap_mult -- параметр функции (как revisit_floor_quantile, min_confirmations), -# не голая константа: default = CAP_MULT для источников, где 2x достаточно, но -# расписание может переопределить через default_params (JSON-колонка scrape_schedules, -# ключ "cap_mult") для источника с непропорционально длинным хвостом -- см. миграцию -# для avito, поднимающую cap_mult до 6 (потолок 60 суток, тот же порядок, что у -# cian/yandex, и с запасом выше и статического p99=42.1, и живого прод-пика 52, -# замеренного 2026-08-10..12). +# не голая константа: default = CAP_MULT для источников, где 2x достаточно (cian, +# domklik), но расписание может переопределить через default_params (JSON-колонка +# scrape_schedules, ключ "cap_mult") для источника с непропорционально длинным +# хвостом -- см. миграции для avito (cap_mult=6, потолок 60, с запасом выше +# статического p99=42.1 и живого прод-пика 52, замеренного 2026-08-10..12) и yandex +# (cap_mult=3, потолок 90, с запасом выше живого пола 79.2, замеренного 2026-08-15). CAP_MULT = 2 @@ -426,10 +438,23 @@ def deactivate_stale_listings( активный пул источника; cap_mult<1 (например 0.5) опускает потолок НИЖЕ заданного оператором ttl_days -- прямое нарушение инварианта «потолок не может понизить TTL ниже настроенного», который проверяет - test_cap_never_lowers_ttl_below_configured_value). + test_cap_never_lowers_ttl_below_configured_value), ИЛИ ttl_days/cap_mult -- + bool (найдено ревью круга 3, 2026-08-15: `cap_mult < 1` пропускает `True` -- + `bool` наследует `int`, `True < 1` ложно, а `ttl_days * True` == `ttl_days`, + то есть потолок = сам ttl_days и пол молча отключается, никакого ValueError. + jsonb `true`/`false` вместо числа -- ровно та опечатка в расписании, ради + которой оба guard'а вообще написаны, поэтому bool отклоняется явной + type-проверкой ДО числового сравнения для обоих параметров). """ counters: dict[str, int] = {"deactivated": 0} try: + # bool -- подкласс int в Python, поэтому `True < 1` (False) и `False <= 0` + # (True) НЕ ловят опечатку `"ttl_days": true` / `"cap_mult": true` в jsonb: + # `ttl_days * True` == `ttl_days`, `cap_mult=True` даёт потолок == ttl_days и + # молча отключает пол (см. Raises выше). Проверка типа -- ДО числового + # сравнения, иначе bool проскакивает мимо него необнаруженным. + if isinstance(ttl_days, bool): + raise ValueError(f"ttl_days must be a number, not bool: {ttl_days!r}") if ttl_days <= 0: raise ValueError(f"ttl_days must be positive, got {ttl_days!r}") @@ -440,6 +465,8 @@ def deactivate_stale_listings( # запланированный способ задать cap_mult -- вписать его руками в jsonb # default_params расписания (см. миграцию для avito), т.е. именно там опечатка # 0 / 0.5 вместо 6 -- реальный риск, а не гипотетика. + if isinstance(cap_mult, bool): + raise ValueError(f"cap_mult must be a number, not bool: {cap_mult!r}") if cap_mult < 1: raise ValueError(f"cap_mult must be >= 1, got {cap_mult!r}") diff --git a/tradein-mvp/backend/data/sql/264_deactivate_stale_avito_cap_mult.sql b/tradein-mvp/backend/data/sql/264_deactivate_stale_avito_cap_mult.sql index 2fed659b..33128201 100644 --- a/tradein-mvp/backend/data/sql/264_deactivate_stale_avito_cap_mult.sql +++ b/tradein-mvp/backend/data/sql/264_deactivate_stale_avito_cap_mult.sql @@ -13,19 +13,46 @@ -- -- ПОЧЕМУ ИМЕННО AVITO. Дефолт CAP_MULT=2 даёт разный АБСОЛЮТНЫЙ потолок на разных -- источниках (множитель от ttl_days), и ломается там, где хвост переобхода --- источника НЕ пропорционален его ttl_days: --- источник/сегмент p99 хвоста ttl_days потолок при cap_mult=2 --- domklik vtorichka 3.1 14 28 (запас есть) --- cian vtorichka 26.6 30 60 (запас есть) --- yandex vtorichka 43.0 30 60 (запас есть) --- avito все сегменты 42.1 10 20 (ХВОСТ ВЫШЕ ПОТОЛКА) --- У avito p99=42.1 суток ВЫШЕ его же дефолтного потолка 20 -- дефолтный cap_mult=2 --- может резать пол ниже собственного хвоста обхода, то есть ровно тот false-kill, --- ради которого пол вообще заведён. cian/yandex/domklik разрыва не имеют, дефолт --- cap_mult=2 для них калиброван верно, миграция их не трогает. +-- источника НЕ пропорционален его ttl_days. Таблица ниже -- ЖИВЫЕ полы из +-- scrape_runs.counters (ttl_days_effective/revisit_floor_days по каждой job'е за +-- 2026-08-10..08-15, ПЕРЕСЧИТАНО ревью круга 3 2026-08-15 -- прежняя версия таблицы +-- брала статический p99 из _REVISIT_TAIL (40-суточный замер на более раннюю дату) +-- и по нему ошибочно утверждала «yandex 43.0 -> потолок 60, запас есть»; live-полы +-- показывают обратное, см. ниже), а не по статической константе: +-- источник/сегмент живой пол (6 прогонов) ttl_days потолок cap_mult=2 +-- domklik vtorichka 23/24/25/skip/skip/skip 14 28 (запас есть) +-- cian vtorichka 34/34/37/27/27/32 30 60 (запас есть) +-- yandex vtorichka 75/75/75/39/52/54 30 60 (ХВОСТ ВЫШЕ) +-- avito все сегменты 52/52/52/7/8/9 10 20 (ХВОСТ ВЫШЕ) +-- У avito p99=42.1 суток (_REVISIT_TAIL) и живой пик 52 -- ВЫШЕ его же дефолтного +-- потолка 20: дефолтный cap_mult=2 может резать пол ниже собственного хвоста +-- обхода, то есть ровно тот false-kill, ради которого пол вообще заведён. -- --- ПОЧЕМУ 6. Потолок 60 = 10 * 6 -- тот же порядок, что у cian/yandex (60), с запасом --- выше и статического p99=42.1 (_REVISIT_TAIL, tests/test_deactivate_stale_revisit_floor.py), +-- YANDEX -- ТА ЖЕ ДЫРА, что и у avito, но найдена ПОЗЖЕ (при первой версии этой +-- миграции статический p99=43.0 ошибочно считался достаточным запасом). Живой пол +-- yandex/vtorichka держится 39-75 суток шесть прогонов подряд, а прямой live-замер +-- 2026-08-15 (та же percentile_disc(0.99)-формула, что и в проде) даёт 79.2 суток +-- (n=1961 подтверждений за 3 суток) -- выше потолка 60 при дефолтном cap_mult=2. +-- Калибровка yandex вынесена в ОТДЕЛЬНУЮ миграцию +-- (265_deactivate_stale_yandex_cap_mult.sql, cap_mult=3 -> потолок 90), не сюда -- +-- эта миграция специфична для avito по имени и назначению, смешивать источники в +-- одном файле хуже для git-истории калибровок. cian и domklik разрыва не имеют, +-- дефолт cap_mult=2 для них по-прежнему калиброван верно, эта миграция их не трогает. +-- +-- ЧИСЛЕННЫЙ ЭФФЕКТ (обе миграции, 264+265, live-замер 2026-08-15): на пул активных +-- строк не влияет ни у одного из четырёх источников -- next-run deactivated=0 что до, +-- что после калибровки. У avito и cian живой пол (12/32 суток) уже ниже потолка -- +-- калибровка cap_mult просто не участвует в min(). У yandex 0 активных строк старше +-- 39 суток вообще (весь "просроченный" хвост младше того возраста, где потолок +-- 60 vs 90 может разойтись), поэтому даже БЕЗ калибровки (дефолт cap_mult=2, +-- потолок 60 < живой пол 79.2) next-run deactivated тоже 0 -- калибровка убирает +-- будущий риск (потолок бы капал ttl_days_effective 79->60 в counters и резал бы +-- ниже собственного хвоста обхода, как только появятся строки в возрастной полосе +-- 60-90 суток), а не текущее число. domklik заблокирован гейтом здоровья +-- (confirmations 94 < min_confirmations 200) -- до потолка/пола дело не доходит. +-- +-- ПОЧЕМУ 6. Потолок 60 = 10 * 6 -- тот же порядок, что у cian (60, дефолт cap_mult=2), +-- с запасом выше и статического p99=42.1 (_REVISIT_TAIL, tests/test_deactivate_stale_revisit_floor.py), -- и живого прод-пика: floor=52 три прогона подряд 2026-08-10..08-12 -- (scrape_runs.counters, status=done, confirmations 6934..7138, гейт здоровья -- пропустил). Без этой калибровки в проде остаётся дефолт cap_mult=2 (потолок 20) diff --git a/tradein-mvp/backend/data/sql/265_deactivate_stale_yandex_cap_mult.sql b/tradein-mvp/backend/data/sql/265_deactivate_stale_yandex_cap_mult.sql new file mode 100644 index 00000000..7fa1acb6 --- /dev/null +++ b/tradein-mvp/backend/data/sql/265_deactivate_stale_yandex_cap_mult.sql @@ -0,0 +1,57 @@ +-- 265_deactivate_stale_yandex_cap_mult.sql +-- Калибрует потолок эффективного TTL (cap_mult) для yandex (#TTL-CAP круг 3, 2026-08-15). +-- +-- ЗАЧЕМ. Та же дыра, что закрыта для avito миграцией +-- 264_deactivate_stale_avito_cap_mult.sql (см. её комментарий про механизм петли), +-- но обнаружена на yandex позже: первая версия 264 утверждала, что дефолтный +-- CAP_MULT=2 (потолок 60 при ttl_days=30) для yandex "калиброван верно" на +-- основании статического p99=43.0 (_REVISIT_TAIL, замер на более раннюю дату). +-- +-- ЖИВОЙ ЗАМЕР, из-за которого миграция существует. scrape_runs.counters +-- (deactivate_stale_yandex, 2026-08-10..08-15) держал ttl_days_effective 75/75/75/ +-- 39/52/54 шесть прогонов подряд при deactivated=0 -- то есть пол ВСЕ ЭТИ ДНИ был +-- выше потолка 60. Прямой live-замер той же percentile_disc(0.99)-формулы, что и в +-- коде (app/tasks/deactivate_stale_avito.py, _build_revisit_floor_sql), 2026-08-15 +-- даёт 79.2 суток (n=1961 подтверждений за окно 3 суток). Оба замера выше потолка +-- 60 -- ровно тот false-kill, ради которого пол #2659 вообще заведён: без калибровки +-- потолок капал бы ttl_days_effective yandex до 60 в counters уже сегодня и резал бы +-- ниже собственного хвоста обхода, как только в пуле появятся строки возрастом +-- 60-90 суток (сейчас таких 0 -- см. ЧИСЛЕННЫЙ ЭФФЕКТ ниже). +-- +-- ПОЧЕМУ 3. Потолок 90 = 30 * 3 -- запас ~14% над живым пиком 79.2, той же +-- пропорции, что и у avito (потолок 60 против пика 52 -- запас ~15%, см. 264). +-- Меньший cap_mult=2 (потолок 60) уже сейчас ниже пика 79.2. Больший cap_mult +-- намеренно не берём -- дальнейший рост пола означает не "медленный, но живой +-- обход", а кандидата в mёртвый источник, для которого есть отдельный гейт +-- здоровья (min_confirmations), а не растягивание потолка до бесконечности (см. +-- комментарий у CAP_MULT в deactivate_stale_avito.py). +-- +-- ЧИСЛЕННЫЙ ЭФФЕКТ (live-замер 2026-08-15): 0 активных строк yandex/vtorichka +-- старше 39 суток вообще (запрос: count(*) FROM listings WHERE source='yandex' AND +-- listing_segment='vtorichka' AND is_active=true AND last_seen_at < NOW() - +-- INTERVAL 'N days', N=39/52/54/60/75/79 -- везде 0). Next-run deactivated=0 что +-- при дефолтном cap_mult=2 (потолок 60, капает пол), что при cap_mult=3 из этой +-- миграции (потолок 90, не капает) -- эта миграция убирает БУДУЩИЙ риск +-- false-kill при появлении строк в полосе 60-90 суток, а не текущее число +-- деактиваций. Ветка #TTL-CAP не сжимает пул ни у одного из четырёх источников -- +-- см. 264 для остальных трёх. +-- +-- ЗАВИСИМОСТИ: 052_scrape_schedules.sql (таблица + UNIQUE(source)), 219 (тот же +-- приём -- UPDATE default_params через jsonb ?, min_confirmations), 264 (тот же +-- приём для avito, cap_mult -- параметр deactivate_stale_listings). +-- ТОЛЬКО данные (UPDATE default_params), DDL нет. +-- Идемпотентность + уважение к ручной настройке: ключ проставляется лишь там, где +-- его ещё нет, поэтому повторный прогон файла не затирает подкрученное оператором +-- значение. Снять/поднять потолок вручную: cap_mult в default_params +-- (deactivate_stale_yandex), 1 -> потолок = сам ttl_days (см. guard cap_mult < 1 +-- в deactivate_stale_listings -- ниже 1 отклоняется до любого SQL). + +BEGIN; + +UPDATE scrape_schedules +SET default_params = default_params || jsonb_build_object('cap_mult', 3), + updated_at = NOW() +WHERE source = 'deactivate_stale_yandex' + AND NOT default_params ? 'cap_mult'; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/_manifest_applied.txt b/tradein-mvp/backend/data/sql/_manifest_applied.txt index 01075fff..e12af2a2 100644 --- a/tradein-mvp/backend/data/sql/_manifest_applied.txt +++ b/tradein-mvp/backend/data/sql/_manifest_applied.txt @@ -253,3 +253,4 @@ 262_scrape_schedules_seed_oblast_city_sweeps_wave2.sql 263_scrape_schedules_wave2_cian_newbuilding_only_false.sql 264_deactivate_stale_avito_cap_mult.sql +265_deactivate_stale_yandex_cap_mult.sql diff --git a/tradein-mvp/backend/tests/test_deactivate_stale_revisit_floor.py b/tradein-mvp/backend/tests/test_deactivate_stale_revisit_floor.py index d791e7e0..fbfc49f5 100644 --- a/tradein-mvp/backend/tests/test_deactivate_stale_revisit_floor.py +++ b/tradein-mvp/backend/tests/test_deactivate_stale_revisit_floor.py @@ -162,18 +162,57 @@ def test_effective_ttl_covers_every_proven_false_kill(monkeypatch: pytest.Monkey ), f"{slice_name}: UPDATE получил не поднятый TTL — пол посчитан и выброшен" +def _read_cap_mult_from_migration(filename: str, *, source: str) -> int: + """Читает cap_mult из UPDATE default_params миграции -- НЕ хардкодит дубль в тесте. + + Найдено ревью круга 3 2026-08-15: раньше тест ниже принимал cap_mult=6 как + аргумент напрямую, захардкоженный прямо в теле теста. Мутация значения в + 264_deactivate_stale_avito_cap_mult.sql (6 -> 2) НЕ трогала вход теста вовсе -- + набор оставался зелёным при любом реальном значении в миграции, то есть + калибровка нигде не была пином, только упоминанием в комментарии. Здесь + значение читается ИЗ ФАЙЛА миграции regex'ом, а ожидаемый результат + (ttl_days_effective, ttl_floor_capped) остаётся зафиксированным числом в самом + тесте -- так дрейф калибровки в миграции ломает тест, как и задумано. + """ + migration = Path(__file__).resolve().parents[1] / "data" / "sql" / filename + src = migration.read_text("utf-8") + # Порядок в файле -- jsonb_build_object('cap_mult', N) в SET, ЗАТЕМ WHERE source + # = '' ниже (см. 264/265_*.sql). DOTALL матчит перевод строки между ними; + # source в regex -- страховка от чтения не того UPDATE, если файл когда-нибудь + # станет мульти-source (сейчас в каждом файле ровно один UPDATE). + match = re.search( + r"jsonb_build_object\('cap_mult',\s*(\d+)\).*?WHERE\s+source\s*=\s*'" + + re.escape(source) + + r"'", + src, + re.DOTALL, + ) + assert match is not None, ( + f"{filename} сменил формат UPDATE default_params для source={source!r} -- " + "обнови regex в _read_cap_mult_from_migration" + ) + return int(match.group(1)) + + def test_avito_prod_floor_is_capped_by_calibrated_cap_mult(monkeypatch: pytest.MonkeyPatch) -> None: """Пиннит калибровку cap_mult=6 для avito (миграция 264_deactivate_stale_avito_cap_mult.sql) на измеренном прод-поле _PROD_FLOORS ("avito/все сегменты" = 69.7, замер 2026-08-09). + cap_mult -- ВХОД теста, читается ИЗ ФАЙЛА миграции (regex), не хардкодится + здесь: дрейф калибровки в 264_*.sql (например 6 -> 2) меняет вход, но НЕ + ожидаемый результат ниже (60/70) -- эти числа пинят калибровку саму по себе, + поэтому дрейф ломает тест, как и задумано (см. _read_cap_mult_from_migration). + С дефолтным cap_mult=2 потолок avito (20 сут) РЕЖЕТ ниже собственного хвоста переобхода p99=42.1 (_REVISIT_TAIL) -- ровно тот false-kill, ради которого пол заведён. С калиброванным cap_mult=6 потолок 60 сут -- выше и p99=42.1, и живого прод-пика 52 (замер 08-10..08-12), и этого гипотетического замера 69.7 (капается - ровно на 60, не пропускается как есть). Без этого теста калибровка cap_mult=6 - нигде не пиннится числом -- только упоминается в комментарии/миграции. + ровно на 60, не пропускается как есть). """ + calibrated_cap_mult = _read_cap_mult_from_migration( + "264_deactivate_stale_avito_cap_mult.sql", source="deactivate_stale_avito" + ) source, segments, ttl_days, floor = _PROD_FLOORS["avito/все сегменты"] db = _FakeDB(floor_days=floor) out = _run( @@ -183,13 +222,48 @@ def test_avito_prod_floor_is_capped_by_calibrated_cap_mult(monkeypatch: pytest.M ttl_days=ttl_days, segments=segments, revisit_floor_quantile=task_mod.DEFAULT_REVISIT_FLOOR_QUANTILE, - cap_mult=6, + cap_mult=calibrated_cap_mult, ) - assert out["ttl_days_effective"] == 60, "cap_mult=6 * ttl_days=10 обязан дать потолок 60" + assert out["ttl_days_effective"] == 60, "cap_mult из миграции 264 обязан дать потолок 60" assert out["ttl_floor_capped"] == 1 assert out["ttl_days_floor_raw"] == 70, "ceil(69.7) == 70 -- пол считается по real-числу" +def test_yandex_prod_floor_is_not_capped_by_calibrated_cap_mult( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Пиннит калибровку cap_mult=3 для yandex (миграция + 265_deactivate_stale_yandex_cap_mult.sql, найдено ревью круга 3 2026-08-15) на + измеренном прод-поле _PROD_FLOORS ("yandex/vtorichka" = 74.3). + + cap_mult -- ВХОД теста, читается ИЗ ФАЙЛА миграции 265 (тот же приём, что и у + avito выше): дрейф калибровки в 265_*.sql ломает тест. + + С дефолтным cap_mult=2 потолок yandex (60 сут) РЕЖЕТ живой пол (75-79 сут, + scrape_runs.counters 08-10..08-15 и live-замер 08-15) -- та же дыра, что у + avito, найдена позже (первая версия 264 ошибочно считала yandex безопасным по + устаревшему статическому p99=43.0). С калиброванным cap_mult=3 потолок 90 сут + выше живого пика 79.2 -- пол 74.3 из этого теста НЕ капается, эффективный TTL + равен сырому полу (75, ceil(74.3)). + """ + calibrated_cap_mult = _read_cap_mult_from_migration( + "265_deactivate_stale_yandex_cap_mult.sql", source="deactivate_stale_yandex" + ) + source, segments, ttl_days, floor = _PROD_FLOORS["yandex/vtorichka"] + db = _FakeDB(floor_days=floor) + out = _run( + db, + monkeypatch, + listing_source=source, + ttl_days=ttl_days, + segments=segments, + revisit_floor_quantile=task_mod.DEFAULT_REVISIT_FLOOR_QUANTILE, + cap_mult=calibrated_cap_mult, + ) + assert out["ttl_days_effective"] == 75, "ceil(74.3) == 75, потолок 90 не должен резать" + assert "ttl_floor_capped" not in out, "потолок 90 выше живого пола 74.3 -- капать нечего" + + def test_false_kill_ages_sit_inside_the_old_ttl(monkeypatch: pytest.MonkeyPatch) -> None: """Замер согласован сам с собой: снимали ровно на границе TTL=30, не раньше.""" assert _FALSE_KILL_AGE_MIN < 30.0 <= _FALSE_KILL_AGE_MAX diff --git a/tradein-mvp/backend/tests/test_deactivate_stale_ttl_cap.py b/tradein-mvp/backend/tests/test_deactivate_stale_ttl_cap.py index bacd78f6..8045a548 100644 --- a/tradein-mvp/backend/tests/test_deactivate_stale_ttl_cap.py +++ b/tradein-mvp/backend/tests/test_deactivate_stale_ttl_cap.py @@ -355,6 +355,52 @@ def test_cap_mult_non_numeric_fails_safe_before_any_sql(monkeypatch: pytest.Monk assert db.executed == [] +# ── cap_mult / ttl_days -- bool (найдено ревью круга 3, 2026-08-15) ───────────── +# bool -- подкласс int в Python: `True < 1` ложно, `True <= 0` ложно. Числовые +# guard'ы выше (`cap_mult < 1`, `ttl_days <= 0`) поэтому НЕ ловят jsonb `true` в +# default_params расписания -- ровно тот класс опечатки, ради которого guard'ы +# вообще написаны. `cap_mult=True` даёт потолок == ttl_days (ttl_days * True == +# ttl_days) -- пол молча отключается без единого ValueError. `ttl_days=True` даёт +# ttl_days == 1 -- TTL молча меняется на 1 сутки. Явная type-проверка ловит оба +# ДО числового сравнения и ДО любого SQL. + + +def test_cap_mult_true_is_rejected_before_any_sql(monkeypatch: pytest.MonkeyPatch) -> None: + """cap_mult=True: `True < 1` ложно -- без явной type-проверки потолок = ttl_days + (пол молча отключается) вместо ValueError. Воспроизведено на HEAD ветки.""" + db = _FakeDB(floor_days=52.0) + with pytest.raises(ValueError): + _run(db, monkeypatch, ttl_days=10, revisit_floor_quantile=0.99, cap_mult=True) + assert db.executed == [] + + +def test_cap_mult_false_is_rejected_before_any_sql(monkeypatch: pytest.MonkeyPatch) -> None: + """cap_mult=False уже ловится `cap_mult < 1` (False == 0), но type-guard идёт + первым -- проверяем, что путь всё равно ValueError, а не иной exception.""" + db = _FakeDB(floor_days=52.0) + with pytest.raises(ValueError): + _run(db, monkeypatch, ttl_days=10, revisit_floor_quantile=0.99, cap_mult=False) + assert db.executed == [] + + +def test_ttl_days_true_is_rejected_before_any_sql(monkeypatch: pytest.MonkeyPatch) -> None: + """ttl_days=True: `True <= 0` ложно -- без явной type-проверки TTL молча + становится 1 сутки (True ведёт себя как int 1) вместо ValueError.""" + db = _FakeDB(floor_days=52.0) + with pytest.raises(ValueError): + _run(db, monkeypatch, ttl_days=True, revisit_floor_quantile=0.99) + assert db.executed == [] + + +def test_ttl_days_false_is_rejected_before_any_sql(monkeypatch: pytest.MonkeyPatch) -> None: + """ttl_days=False уже ловится `ttl_days <= 0` (False == 0), но type-guard идёт + первым -- проверяем, что путь всё равно ValueError.""" + db = _FakeDB(floor_days=52.0) + with pytest.raises(ValueError): + _run(db, monkeypatch, ttl_days=False, revisit_floor_quantile=0.99) + assert db.executed == [] + + def test_cap_mult_zero_fails_the_run_via_mark_failed(monkeypatch: pytest.MonkeyPatch) -> None: """Тот же контракт, что и ttl_days<=0: run помечается failed, а не остаётся 'running', и НИ ОДНА строка не деактивируется (в отличие от воспроизведённого на HEAD дефекта, где