fix(tradein-estimator): extend cohort filter to Tier S + Tier H, fix late_soviet overlap
PR #519 added cohort hard-filter but only to Tier W's inline SQL. Tier S (address ILIKE) and Tier H (year ±15) share _COMMON_WHERE which was not extended — for target_year=1978 (brezhnev), Tier H allowed year_built ∈ [1963, 1993] and a 1965 khrushchev or 1992 late_soviet match could win before cohort filter ever evaluated. Fixes (reviewer feedback on PR #519, item #1): * Append cohort clause to _COMMON_WHERE so Tier S and Tier H inherit it. * Add cohort_year_min/max binds to base_params (auto-propagates to both). * Tier W's inline copy stays — not converted to _COMMON_WHERE (relevance_score CASE expressions require inline form). Comment near _COMMON_WHERE documents the dual code path requirement. Also (reviewer item #5): * Fix late_soviet range 1985-1999 → 1990-1999. Previous overlap with brezhnev 1970-1989 + first-match semantics made late_soviet effectively cover only 1990-1999 anyway — rename clarifies actual coverage. Add parametrized pytest test_target_cohort_range covering None, out-of-range, boundary cases, and the audit's target (1978 → brezhnev).
This commit is contained in:
parent
7cc7fafeeb
commit
dd774032d8
2 changed files with 59 additions and 1 deletions
|
|
@ -71,7 +71,7 @@ COHORTS = (
|
|||
# (cohort_name, year_min_inclusive, year_max_inclusive)
|
||||
('khrushchev', 1955, 1969), # Хрущёвки 5-эт
|
||||
('brezhnev', 1970, 1989), # Брежневка кирпич/панель 9–12-эт
|
||||
('late_soviet', 1985, 1999), # Поздний СССР (overlap с brezhnev intentional)
|
||||
('late_soviet', 1990, 1999), # Поздний СССР (no overlap; first-match would never pick old range)
|
||||
('2000s', 2000, 2010), # Ранние новостройки
|
||||
('modern', 2011, 2100), # Современные ЖК
|
||||
)
|
||||
|
|
@ -1023,7 +1023,20 @@ _COMMON_WHERE = """
|
|||
AND is_active = true
|
||||
AND scraped_at > NOW() - (:fresh_days || ' days')::interval
|
||||
AND price_rub > 0
|
||||
-- Когортный фильтр (PR 10): применяется к Tier S и Tier H через _COMMON_WHERE.
|
||||
-- Tier W имеет свою копию этого блока в inline SQL. Если cohort_year_min IS NULL —
|
||||
-- фильтр прозрачен. CAST обязателен — psycopg3 prepared statement не выводит
|
||||
-- тип $N при IS NULL в predicate (см. PR #518 fix).
|
||||
AND (
|
||||
CAST(:cohort_year_min AS integer) IS NULL
|
||||
OR year_built IS NULL
|
||||
OR year_built BETWEEN CAST(:cohort_year_min AS integer)
|
||||
AND CAST(:cohort_year_max AS integer)
|
||||
)
|
||||
"""
|
||||
# Note: Tier W has its own inline copy of the cohort clause (PR #519 line
|
||||
# ~1280). Не удалять — Tier W не использует _COMMON_WHERE из-за inline
|
||||
# relevance_score CASE expressions. Both code paths must stay in sync.
|
||||
|
||||
|
||||
def _fetch_analogs(
|
||||
|
|
@ -1073,6 +1086,8 @@ def _fetch_analogs(
|
|||
"area_max": area_max,
|
||||
"fresh_days": LISTINGS_FRESH_DAYS,
|
||||
"max_per_addr": MAX_ANALOGS_PER_ADDRESS,
|
||||
"cohort_year_min": cohort_year_min,
|
||||
"cohort_year_max": cohort_year_max,
|
||||
}
|
||||
|
||||
# ── Tier S: same building ─────────────────────────────────────────────────
|
||||
|
|
|
|||
43
tradein-mvp/backend/tests/test_estimator_cohort.py
Normal file
43
tradein-mvp/backend/tests/test_estimator_cohort.py
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
"""Unit tests for cohort filter helpers in estimator.py.
|
||||
|
||||
PR 10 (2026-05-24) — covers _target_cohort_range edge cases:
|
||||
None, out-of-range, exact boundaries, first-match semantics.
|
||||
"""
|
||||
import os
|
||||
|
||||
# Settings requires DATABASE_URL at init time. Set dummy DSN before any app import.
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql://test:test@localhost/test_db")
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services.estimator import _target_cohort_range
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("year", "expected"),
|
||||
[
|
||||
(None, None), # no year → no cohort
|
||||
(1900, None), # out-of-range below → no cohort
|
||||
(1954, None), # below khrushchev floor
|
||||
(1955, (1955, 1969)), # khrushchev floor
|
||||
(1960, (1955, 1969)), # khrushchev mid
|
||||
(1969, (1955, 1969)), # khrushchev ceiling
|
||||
(1970, (1970, 1989)), # brezhnev floor
|
||||
(1978, (1970, 1989)), # brezhnev (target audit case)
|
||||
(1988, (1970, 1989)), # brezhnev pre-overlap fix held
|
||||
(1989, (1970, 1989)), # brezhnev ceiling
|
||||
(1990, (1990, 1999)), # late_soviet floor (post-PR10 fix)
|
||||
(1995, (1990, 1999)), # late_soviet mid
|
||||
(1999, (1990, 1999)), # late_soviet ceiling
|
||||
(2000, (2000, 2010)), # 2000s floor
|
||||
(2010, (2000, 2010)), # 2000s ceiling
|
||||
(2011, (2011, 2100)), # modern floor
|
||||
(2024, (2011, 2100)), # modern current
|
||||
(2100, (2011, 2100)), # modern ceiling
|
||||
(2101, None), # above modern ceiling → no cohort
|
||||
(2200, None), # far future → no cohort
|
||||
],
|
||||
)
|
||||
def test_target_cohort_range(year, expected):
|
||||
"""First-match cohort mapping; out-of-range and None → None."""
|
||||
assert _target_cohort_range(year) == expected
|
||||
Loading…
Add table
Reference in a new issue