fix/tradein-ttl-effective-cap (PR #2907, merged into main while this branch was in
flight) already claimed 264/265 for deactivate_stale_avito_cap_mult /
deactivate_stale_yandex_cap_mult. Renumbers this branch's
264_seed_deactivate_stale_null_segment_yandex_cian.sql -> 266_seed_... (git mv +
_manifest_applied.txt entry moved after 264/265 + self-references in the migration
header and in test_deactivate_stale_listings.py's _MIGRATION_264 constant/test names).
Merges main's bool-guard (ttl_days/cap_mult reject bool) + CAP_MULT ceiling +
per-source cap_mult calibration with this branch's null_segment_only kwarg
(explicit `listing_segment IS NULL` predicate, since ANY(:segments) never matches
NULL) -- both features apply to the same deactivate_stale_listings() call site in
product_handlers.py and the same function signature/docstring in
deactivate_stale_avito.py, so every conflict was signature/docstring-level, not
logic-level (git already auto-merged the function body correctly since the two
features touch disjoint lines below the signature). Also reconciled the module
docstring's "known gap" note (main) to reflect that the NULL-segment slice it
measured (cian 211 / yandex 523 rows >60d) is now closed by this migration --
novostroyki stays open, unrelated to this branch.
544 yandex + 224 cian active rows carry listing_segment=NULL (legacy rows
predating migration 011, plus a small trickle that can never self-heal since
the upsert ON CONFLICT never rewrites listing_segment on re-scrape). 94-97%
of them are frozen at ~86 days old, yet the estimator's Tier A (same-building)
and Tier C (micro-radius) anchor queries filter only is_active=true -- no
freshness column -- so these stale asking prices anchor live valuations.
deactivate_stale_yandex/_cian (migration 115) already run at TTL=30 but scope
segments=['vtorichka'] only: `= ANY(CAST(:segments AS text[]))` never matches
NULL, so the NULL bucket was invisible to both existing jobs and to
avito/domklik/n1 (which are source-blanket or vtorichka-only respectively).
Adds null_segment_only kwarg to deactivate_stale_listings() building an
explicit `listing_segment IS NULL` predicate (confirmations/revisit-floor
builders extended in parallel for correctness, though both gates are kept
off for this slice -- population too small for thresholds calibrated on a
full vtorichka sweep, would permanently skip as unhealthy). Two new
scrape_schedules rows (migration 264) run it per source, untouched
novostroyki/vtorichka jobs unaffected.
TTL=60d (vs 30d for vtorichka): revisit-floor is not computable here (rows
out of dedicated sweep scope have no revisit-gap history), so the margin is
folded into the TTL directly -- 2.26x/1.4x over the measured p99 revisit gaps
already on file for cian/yandex vtorichka (26.6d/43.0d). Bimodal age
distribution means this costs almost nothing in coverage (30d vs 60d: 744 vs
734 deactivated).
First run: 734 of 768 NULL rows deactivated (211 cian, 523 yandex), 34 remain
(fresher than 60d, still incidentally re-touched). Comparable pool
(is_active AND segment IN (NULL, vtorichka)) after: cian -2.7% (7739->7528),
yandex -10.2% (5133->4610) -- yandex crosses the 10% flag threshold. All 734
removed rows already had scraped_at frozen >60d, i.e. already excluded from
Tier S/H (which do filter freshness, 14-60d window) -- the drop is real for
is_active headcount but zero-impact there; it only prunes Tier A/C, where it
removes stale prices rather than live comps.
Separate finding (not fixed here): base.py's ON CONFLICT DO UPDATE omits
listing_segment from SET entirely, so a legacy NULL row can never heal even
though cian/yandex SERP always compute segment deterministically on re-scrape.