From 00e121056affefde187578b692ddb98bd2c133fa Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 27 Jun 2026 22:33:09 +0300 Subject: [PATCH] chore(tradein/backtest): --resolve-house-id flag to measure Tier-S + IMV anchor (#2002) --- .../backend/scripts/backtest_estimator.py | 178 ++++++++++++++++-- .../backend/tests/test_backtest_estimator.py | 24 +++ 2 files changed, 185 insertions(+), 17 deletions(-) diff --git a/tradein-mvp/backend/scripts/backtest_estimator.py b/tradein-mvp/backend/scripts/backtest_estimator.py index ffa1e65f..12d1c777 100644 --- a/tradein-mvp/backend/scripts/backtest_estimator.py +++ b/tradein-mvp/backend/scripts/backtest_estimator.py @@ -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( diff --git a/tradein-mvp/backend/tests/test_backtest_estimator.py b/tradein-mvp/backend/tests/test_backtest_estimator.py index 788407d6..292764e2 100644 --- a/tradein-mvp/backend/tests/test_backtest_estimator.py +++ b/tradein-mvp/backend/tests/test_backtest_estimator.py @@ -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. # --------------------------------------------------------------------------- #