chore(tradein/backtest): --resolve-house-id flag to measure Tier-S + IMV anchor (#2002)
All checks were successful
CI / changes (pull_request) Successful in 8s
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

This commit is contained in:
bot-backend 2026-06-27 22:33:09 +03:00
parent 3187419eb8
commit 00e121056a
2 changed files with 185 additions and 17 deletions

View file

@ -60,9 +60,12 @@ CAVEATS (read these before trusting the numbers)
network valuation sources (Avito-IMV on-demand eval, Yandex valuation,
Cian valuation) are NOT exercised (``imv_eval=None``,
``yandex/cian_val_present=False``). The populated `house_imv_evaluations`
anchor IS injected, but it keys on house_id which the harness does not
resolve (target_house_id=None) in practice no IMV anchor fires. Real
estimate accuracy with the network layers may differ.
anchor IS injected, but it keys on house_id: by default the harness leaves
``target_house_id=None`` no IMV anchor fires. Pass ``--resolve-house-id``
(#2002) to resolve each deal's canonical house via ``match_house_readonly``
so the Tier-S analog selector + the house IMV anchor fire, mirroring prod;
the resulting reach is reported in the ``house_id_resolution`` block. Real
estimate accuracy with the on-demand network layers may still differ.
(c) ДКП TRUE MARKET a registered ДКП price is what the parties declared
to rosreestr; it can diverge from the genuine transaction price (tax
optimisation, related-party sales, etc.).
@ -1054,17 +1057,83 @@ def _predict_for_deal(
return _percentile(prices, 0.5)
@dataclass
class _HouseIdResolution:
"""Coverage counters for ``--resolve-house-id`` (Tier-S + IMV anchor reach).
Populated only on the full-engine path when the flag is on. ``imv_reachable``
counts deals whose resolved house hit a band-compatible ``house_imv_evaluations``
row i.e. ``_fetch_house_imv_anchor`` returned a non-None anchor that would
actually fire so it measures the IMV anchor's true reach, not bare row
existence. Surfaced as the ``house_id_resolution`` block in ``--json``.
"""
total: int = 0 # deals processed in the full-engine path under the flag
resolved: int = 0 # deals where match_house_readonly returned an id
imv_reachable: int = 0 # of resolved, those whose house_imv anchor fired
def as_json(self) -> dict[str, int]:
return {
"resolved": self.resolved,
"total": self.total,
"imv_reachable": self.imv_reachable,
}
def _resolve_house_id_for_deal(db: Session, deal: DealSample) -> int | None:
"""Resolve a deal's canonical house_id (read-only) — mirrors prod (#2002).
Calls ``match_house_readonly(db, address=, lat=, lon=)`` exactly as
``estimate_quality`` does (estimator.py:2554-2569): read-only, never INSERTs a
house, takes no advisory lock. Returns ``match[0]`` (the house_id), or ``None``
on no-match / empty-or-malformed tuple / any exception best-effort, exactly
like prod which degrades to the geo tiers when no house resolves.
A defensive ``db.rollback()`` runs if the lookup raises so a poisoned tx does
not bleed into the per-deal spine queries (consistent with the loop's own
rollback-on-failure handling).
"""
try:
from app.services.matching.houses import ( # type: ignore[import-not-found]
match_house_readonly,
)
except ImportError: # pragma: no cover — fallback for adhoc invocation
import sys
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from app.services.matching.houses import match_house_readonly
try:
match = match_house_readonly(db, address=deal.address, lat=deal.lat, lon=deal.lon)
except Exception as exc: # pragma: no cover — defensive, mirrors prod best-effort
logger.warning("deal %s house_id resolution failed (graceful): %s", deal.id, exc)
db.rollback()
return None
if not match:
return None
try:
return int(match[0])
except (TypeError, ValueError, IndexError): # pragma: no cover — malformed return
logger.warning("deal %s house match returned malformed tuple: %r", deal.id, match)
return None
def _select_analogs_full(
db: Session, deal: DealSample, est: SimpleNamespace
db: Session,
deal: DealSample,
est: SimpleNamespace,
*,
target_house_id: int | None = None,
) -> tuple[list[dict[str, Any]], str, bool, bool]:
"""Replicate estimate_quality's analog tier ladder for one deal (#1966).
Mirrors the Tier0(cohort)A(1 km)wide(2 km)widearea(±25 %) sequence
EXACTLY, including the MIN_ANALOGS_TIER_0 gate and the <5 / <3 widen
thresholds and the "only adopt the wider tier when it returns MORE lots"
guards. target_house_id is always None here (the harness does not resolve
canonical houses), so Tier S(canonical) never fires same as a fresh
estimate without a house match.
guards. ``target_house_id`` defaults to None same as a fresh estimate
without a house match, so Tier S(canonical) never fires. When the harness is
run with ``--resolve-house-id`` (#2002) a resolved id is threaded in and the
Tier-S ``WHERE house_id_fk = :target_house_id`` selector fires, mirroring prod.
Returns ``(listings, analog_tier, fallback_used, area_widened)``.
"""
@ -1080,7 +1149,7 @@ def _select_analogs_full(
area=deal.area_m2,
radius_m=m.DEFAULT_RADIUS_M,
full_address=deal.address,
target_house_id=None,
target_house_id=target_house_id,
year_built=deal.year_built,
house_type=deal.house_type,
total_floors=deal.total_floors,
@ -1103,7 +1172,7 @@ def _select_analogs_full(
area=deal.area_m2,
radius_m=m.DEFAULT_RADIUS_M,
full_address=deal.address,
target_house_id=None,
target_house_id=target_house_id,
year_built=deal.year_built,
house_type=deal.house_type,
total_floors=deal.total_floors,
@ -1119,7 +1188,7 @@ def _select_analogs_full(
area=deal.area_m2,
radius_m=m.FALLBACK_RADIUS_M,
full_address=deal.address,
target_house_id=None,
target_house_id=target_house_id,
year_built=deal.year_built,
house_type=deal.house_type,
total_floors=deal.total_floors,
@ -1139,7 +1208,7 @@ def _select_analogs_full(
radius_m=m.FALLBACK_RADIUS_M,
area_tolerance=0.25,
full_address=deal.address,
target_house_id=None,
target_house_id=target_house_id,
year_built=deal.year_built,
house_type=deal.house_type,
total_floors=deal.total_floors,
@ -1345,6 +1414,8 @@ def _predict_full_spine(
est: SimpleNamespace,
*,
capture: list[dict[str, Any]] | None = None,
target_house_id: int | None = None,
resolution: _HouseIdResolution | None = None,
) -> Prediction | None:
"""Predict one deal through the FULL deterministic spine (#1966).
@ -1362,11 +1433,20 @@ def _predict_full_spine(
When ``capture`` is a list, every deal that yields a prediction also appends a
fully JSON-plain replay record (frozen kwargs + the 3 callables' recorded
``[arg -> return]`` call-lists) see ``--dump-fixture`` / ``replay_fixture``.
``target_house_id`` (#2002) is None by default — the historical harness
behaviour, where Tier-S(canonical) and the house Avito-IMV anchor never fire.
When ``--resolve-house-id`` is on the caller resolves it and threads it here so
those paths become measurable; ``resolution`` (when provided) is the coverage
accumulator whose ``imv_reachable`` counter this function bumps once it knows
whether the house IMV anchor actually resolved a row for this deal.
"""
m = est.m
settings = est.settings
listings, analog_tier, fallback_used, area_widened = _select_analogs_full(db, deal, est)
listings, analog_tier, fallback_used, area_widened = _select_analogs_full(
db, deal, est, target_house_id=target_house_id
)
# ── Pre-fetch the spine inputs (same calls estimate_quality hoists) ───────
dkp_raw = m._fetch_dkp_corridor(db, address=deal.address, rooms=deal.rooms, area=deal.area_m2)
@ -1374,6 +1454,10 @@ def _predict_full_spine(
# estimate_quality (estimator.py L2862-2881) — disabled / no-area / no-address
# → ([], None) instead of an unconditional fetch.
if settings.estimate_same_building_anchor_enabled and deal.area_m2 and deal.address:
# NB(#2002): kept at None deliberately — the deliverable scopes the
# resolved-id threading to the Tier-S analog ladder + the house IMV anchor.
# Prod ALSO resolves this same-building anchor (estimator.py:2782); flip to
# `target_house_id=target_house_id` here if/when full prod parity is wanted.
anchor_comps, anchor_tier = m._fetch_anchor_comps(
db,
address=deal.address,
@ -1386,8 +1470,13 @@ def _predict_full_spine(
else:
anchor_comps, anchor_tier = [], None
imv_anchor = m._fetch_house_imv_anchor(
db, target_house_id=None, rooms=deal.rooms, area=deal.area_m2
db, target_house_id=target_house_id, rooms=deal.rooms, area=deal.area_m2
)
# #2002: count IMV reach — a resolved house whose house_imv_evaluations row
# actually fires the anchor (band-compatible match → non-None). Counted before
# the median<=0 skip below so reach reflects row availability, not pricing.
if resolution is not None and target_house_id is not None and imv_anchor is not None:
resolution.imv_reachable += 1
# Synthetic geo = the deal's own coords + address, confidence "exact" — the
# faithful analog of estimate_quality's client-coords fast path. Real deal
@ -1658,7 +1747,12 @@ def run_backtest(
def run_backtest_full(
db: Session, *, sample: int, since: str, dump_fixture: str | None = None
db: Session,
*,
sample: int,
since: str,
dump_fixture: str | None = None,
resolve_house_id: bool = False,
) -> dict[str, Any]:
"""Drive the FULL-spine read-only backtest and return a metrics dict (#1966).
@ -1677,6 +1771,12 @@ def run_backtest_full(
the 3 DB callables' recorded call-lists are frozen into a committed JSON file
(schema_version=1) so ``replay_fixture`` can re-score offline against a frozen
baseline (the CI regression gate). This is the only path that writes a file.
When ``resolve_house_id`` is True (#2002, ``--resolve-house-id``) each deal's
canonical house_id is resolved per ``match_house_readonly`` and threaded into
the Tier-S analog ladder + the house Avito-IMV anchor, mirroring prod, and a
``house_id_resolution`` coverage block (resolved / total / imv_reachable) is
attached to the returned metrics. Default False byte-identical prior output.
"""
est = _import_estimator_full()
deals = _load_sample(db, sample=sample, since=since)
@ -1689,10 +1789,24 @@ def run_backtest_full(
sold_ppm2_all: list[float] = [d.sold_ppm2 for d in deals]
pred_ppm2_all: list[float] = [] # asking headline median_ppm2 (priced deals)
capture: list[dict[str, Any]] | None = [] if dump_fixture else None
resolution: _HouseIdResolution | None = _HouseIdResolution() if resolve_house_id else None
for i, deal in enumerate(deals, start=1):
target_house_id: int | None = None
if resolution is not None:
resolution.total += 1
target_house_id = _resolve_house_id_for_deal(db, deal)
if target_house_id is not None:
resolution.resolved += 1
try:
pr = _predict_full_spine(db, deal, est, capture=capture)
pr = _predict_full_spine(
db,
deal,
est,
capture=capture,
target_house_id=target_house_id,
resolution=resolution,
)
except Exception as exc:
# Read-only: a failed SELECT can poison the tx → rollback so the next
# deal's queries run clean. Skip this deal (counts as no-prediction).
@ -1743,6 +1857,17 @@ def run_backtest_full(
"price_segments_ppm2": [list(seg) for seg in PRICE_SEGMENTS_PPM2],
}
# #2002: house_id resolution coverage — the key Tier-S + IMV reach number.
# Only emitted under --resolve-house-id so the default output stays unchanged.
if resolution is not None:
metrics["house_id_resolution"] = resolution.as_json()
logger.info(
"house_id resolution: resolved=%d/%d imv_reachable=%d",
resolution.resolved,
resolution.total,
resolution.imv_reachable,
)
if dump_fixture is not None and capture is not None:
_write_fixture(dump_fixture, capture=capture, since=since, est=est)
@ -1844,6 +1969,18 @@ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
action="store_true",
help="Emit machine-readable JSON instead of the text table.",
)
p.add_argument(
"--resolve-house-id",
action="store_true",
help="FULL engine only: resolve each deal's canonical house_id via "
"match_house_readonly(address, lat, lon) and thread it into the analog "
"tier ladder + house Avito-IMV anchor, mirroring prod estimate_quality "
"(estimator.py:2554-2569). Lets the Tier-S 'same-building' selector and "
"_fetch_house_imv_anchor actually fire (they key on house_id, otherwise "
"None → never resolved). Adds a 'house_id_resolution' coverage block to "
"the JSON output. Default OFF → byte-identical to the prior behaviour, so "
"the frozen regression gate is untouched.",
)
# #1966 PR 3/3 — fixture capture + hermetic replay. --dump-fixture (DB run,
# full engine) and --from-fixture (NO DB) are mutually exclusive modes.
fixture_mode = p.add_mutually_exclusive_group()
@ -1895,10 +2032,12 @@ def main(argv: list[str] | None = None) -> int:
raise SystemExit("--update-baseline requires --from-fixture")
if args.dump_fixture and args.engine != "full":
raise SystemExit("--dump-fixture is only supported with --engine full")
if args.resolve_house_id and args.engine != "full":
raise SystemExit("--resolve-house-id is only supported with --engine full")
logger.info(
"backtest start: engine=%s sample=%d since=%s radius=%dm "
"rooms_tolerance=%d holdout_split=%s dump_fixture=%s",
"rooms_tolerance=%d holdout_split=%s dump_fixture=%s resolve_house_id=%s",
args.engine,
args.sample,
args.since,
@ -1906,13 +2045,18 @@ def main(argv: list[str] | None = None) -> int:
args.rooms_tolerance,
args.holdout_split,
args.dump_fixture,
args.resolve_house_id,
)
db = _session()
try:
if args.engine == "full":
metrics = run_backtest_full(
db, sample=args.sample, since=args.since, dump_fixture=args.dump_fixture
db,
sample=args.sample,
since=args.since,
dump_fixture=args.dump_fixture,
resolve_house_id=args.resolve_house_id,
)
else:
metrics = run_backtest(

View file

@ -504,6 +504,30 @@ def test_argparse_engine_rejects_unknown() -> None:
bt._parse_args(["--engine", "bogus"])
# --------------------------------------------------------------------------- #
# #2002 --resolve-house-id flag + its coverage accumulator.
# --------------------------------------------------------------------------- #
def test_argparse_resolve_house_id_defaults_off() -> None:
# Default OFF keeps the harness byte-identical so the regression gate stands.
assert bt._parse_args([]).resolve_house_id is False
def test_argparse_resolve_house_id_override() -> None:
assert bt._parse_args(["--resolve-house-id"]).resolve_house_id is True
def test_house_id_resolution_as_json_shape() -> None:
res = bt._HouseIdResolution(total=10, resolved=4, imv_reachable=2)
assert res.as_json() == {"resolved": 4, "total": 10, "imv_reachable": 2}
def test_house_id_resolution_defaults_zero() -> None:
res = bt._HouseIdResolution()
assert res.as_json() == {"resolved": 0, "total": 0, "imv_reachable": 0}
# --------------------------------------------------------------------------- #
# #1966 _bucketize_confidence / _segment_label — pure bucketing.
# --------------------------------------------------------------------------- #