feat(tradein/backtest): hermetic estimator regression gate — frozen fixture + baseline (#1966 PR 3/3) #1997

Merged
lekss361 merged 3 commits from feat/1966-backtest-fixture-gate into main 2026-06-27 12:55:36 +00:00
2 changed files with 97 additions and 40 deletions
Showing only changes of commit 1648da01c3 - Show all commits

View file

@ -89,6 +89,7 @@ from __future__ import annotations
import argparse import argparse
import dataclasses import dataclasses
import gzip
import json import json
import logging import logging
import math import math
@ -1194,15 +1195,6 @@ def _sanitize_json(obj: Any) -> Any:
return str(obj) return str(obj)
def _to_hashable(obj: Any) -> Any:
"""Normalise a JSON-plain value into a hashable key (list→tuple, dict→sorted items)."""
if isinstance(obj, list | tuple):
return tuple(_to_hashable(x) for x in obj)
if isinstance(obj, dict):
return tuple(sorted((str(k), _to_hashable(v)) for k, v in obj.items()))
return obj
def _coerce_ratio_return(ret: Any) -> tuple[Any, Any]: def _coerce_ratio_return(ret: Any) -> tuple[Any, Any]:
"""Recorded ``[ratio, basis]`` → ``(ratio, basis)`` tuple (unpacked at the call site).""" """Recorded ``[ratio, basis]`` → ``(ratio, basis)`` tuple (unpacked at the call site)."""
return (ret[0], ret[1]) return (ret[0], ret[1])
@ -1218,30 +1210,50 @@ def _coerce_qis_return(ret: Any) -> dict[str, float]:
return {str(k): v for k, v in dict(ret).items()} return {str(k): v for k, v in dict(ret).items()}
def load_fixture(path: str) -> dict[str, Any]:
"""Load a backtest fixture JSON — transparently handles gzip (``.gz``) files.
The committed fixture is gzipped (~3 MB raw); a plain ``.json`` path is also
accepted for ad-hoc runs. Exported for the CI regression-gate test.
"""
if str(path).endswith(".gz"):
with gzip.open(path, "rt", encoding="utf-8") as fh:
return json.loads(fh.read())
return json.loads(Path(path).read_text(encoding="utf-8"))
def _make_call_stub( def _make_call_stub(
calls: list[Any], *, label: str, coerce: Callable[[Any], Any] calls: list[Any], *, label: str, coerce: Callable[[Any], Any]
) -> Callable[[Any], Any]: ) -> Callable[[Any], Any]:
"""Build an exact-match replay stub from recorded ``[arg, return]`` pairs. """Build an ORDER-based (FIFO) replay stub from recorded ``[arg, return]`` pairs.
Each recorded entry is ``[serialized_single_arg, serialized_return]``. The The stub returns the recorded RETURNS in invocation order and IGNORES the arg
stub looks up the (normalised) positional arg and raises ``KeyError`` on an value. Rationale: the DB-derived ratio / quarter-index is a FROZEN input when
unseen arg that must never happen for a faithful fixture (the spine is estimator logic later shifts e.g. ``median_ppm2``, the regression gate must
deterministic, so replay requests exactly the args capture recorded). surface that as a clean metric diff vs the baseline, NOT mask it as a lookup
``coerce`` maps the JSON-plain recorded return back to the live callable's miss. Matching on the exact computed float arg is also not bit-stable across
platforms (libm last-ulp jitter), so a Linux-captured fixture would crash when
replayed off-Linux. The recorded arg is kept (for debuggability) but never
matched. Each callable fires 1×/deal today (so index 0 in practice); the FIFO
stays correct if a call site ever loops. Calling the stub MORE times than
recorded raises RuntimeError control flow diverged from capture.
``coerce`` maps each JSON-plain recorded return back to the live callable's
return type (tuple / dict) so unpacking at the call site behaves identically. return type (tuple / dict) so unpacking at the call site behaves identically.
""" """
table: dict[Any, Any] = {} returns = [coerce(ret) for _arg, ret in calls]
for arg, ret in calls: idx = 0
table[_to_hashable(_sanitize_json(arg))] = coerce(ret)
def _stub(arg: Any) -> Any: def _stub(_arg: Any) -> Any:
key = _to_hashable(_sanitize_json(arg)) nonlocal idx
if key not in table: if idx >= len(returns):
raise KeyError( raise RuntimeError(
f"{label}: unseen arg {arg!r} during replay — fixture is not faithful " f"{label}: replay made call #{idx + 1} but fixture recorded only "
f"(recorded {len(table)} call(s))" f"{len(returns)} — control flow diverged from capture"
) )
return table[key] ret = returns[idx]
idx += 1
return ret
return _stub return _stub
@ -1249,8 +1261,8 @@ def _make_call_stub(
def replay_fixture(fixture: dict[str, Any]) -> dict[str, Any]: def replay_fixture(fixture: dict[str, Any]) -> dict[str, Any]:
"""Replay a frozen backtest fixture through the full spine — hermetic, ZERO DB. """Replay a frozen backtest fixture through the full spine — hermetic, ZERO DB.
For every captured deal record: rebuild the ``GeocodeResult``, build exact-match For every captured deal record: rebuild the ``GeocodeResult``, build order-based
stubs for the 3 DB callables from the recorded call-lists, call (FIFO) stubs for the 3 DB callables from the recorded call-lists, call
``_price_from_inputs`` with the frozen kwargs, and rebuild the ``Prediction`` ``_price_from_inputs`` with the frozen kwargs, and rebuild the ``Prediction``
EXACTLY as ``_predict_full_spine`` does. Runs the harness's own EXACTLY as ``_predict_full_spine`` does. Runs the harness's own
``_compute_full_metrics`` over the replayed predictions plus a city-wide ``_compute_full_metrics`` over the replayed predictions plus a city-wide
@ -1847,9 +1859,9 @@ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
"--from-fixture", "--from-fixture",
metavar="PATH", metavar="PATH",
default=None, default=None,
help="Hermetic replay: load the frozen fixture at PATH, re-run the spine " help="Hermetic replay: load the frozen fixture at PATH (gzip-transparent — "
"offline (ZERO DB) via replay_fixture, and print the metrics JSON. No DB " "a .gz path is decompressed on the fly), re-run the spine offline (ZERO DB) "
"connection is opened.", "via replay_fixture, and print the metrics JSON. No DB connection is opened.",
) )
p.add_argument( p.add_argument(
"--update-baseline", "--update-baseline",
@ -1867,7 +1879,7 @@ def main(argv: list[str] | None = None) -> int:
# ── Hermetic replay path (#1966 PR 3/3) — ZERO DB, no SessionLocal opened. ── # ── Hermetic replay path (#1966 PR 3/3) — ZERO DB, no SessionLocal opened. ──
if args.from_fixture: if args.from_fixture:
fixture = json.loads(Path(args.from_fixture).read_text(encoding="utf-8")) fixture = load_fixture(args.from_fixture)
metrics = replay_fixture(fixture) metrics = replay_fixture(fixture)
print(json.dumps(metrics, ensure_ascii=False, indent=2, sort_keys=True)) print(json.dumps(metrics, ensure_ascii=False, indent=2, sort_keys=True))
if args.update_baseline: if args.update_baseline:

View file

@ -20,9 +20,14 @@ import os
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
import gzip
import json import json
import tempfile
from pathlib import Path
from typing import Any from typing import Any
import pytest
from scripts import backtest_estimator as bt from scripts import backtest_estimator as bt
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@ -222,14 +227,54 @@ def test_replay_fixture_segments_span_multiple_bands() -> None:
assert len(non_empty) >= 3 assert len(non_empty) >= 3
def test_unseen_callable_arg_raises_keyerror() -> None: def test_replay_is_arg_insensitive_order_based() -> None:
# A fixture whose recorded ratio call does not match the median the spine # Order-based (FIFO) replay returns the recorded ratio REGARDLESS of the arg
# actually computes must surface a clear KeyError (not a silent wrong answer). # value the spine actually computes — so a recorded arg that can never equal
# the live median (999_999.0) still replays cleanly. This is the cross-platform
# robustness contract: a Linux-captured fixture must replay off-Linux even when
# libm last-ulp jitter shifts the computed median_ppm2 by an ulp.
fixture = _build_fixture() fixture = _build_fixture()
fixture["deals"][0]["ratio_calls"] = [[999_999.0, [0.9, "per_rooms_all"]]] fixture["deals"][0]["ratio_calls"] = [[999_999.0, [0.95, "per_rooms_all"]]]
try: metrics = bt.replay_fixture(fixture)
# Replay succeeded (no KeyError) and still priced every deal.
assert metrics["expected_sold"]["overall"]["n"] == len(fixture["deals"])
# The recorded ratio (0.95) was applied to deal 1 (median 100k → expected_sold
# ppm² 95k), so its signed error vs SOLD 100k is -5% — proving the recorded
# RETURN drove the result, not the (mismatched) arg.
econom = metrics["expected_sold"]["per_segment"]["эконом"]
assert econom["n"] == 1
assert econom["median_bias_pct"] == pytest.approx(-5.0)
def test_replay_exhaustion_raises_runtime_error() -> None:
# If a callable's recorded list is SHORTER than the spine's call count (here:
# the spine calls ratio_resolver once but nothing is recorded), the exhaustion
# guard must raise a clear RuntimeError — control flow diverged from capture,
# NOT a silent wrong answer.
fixture = _build_fixture()
fixture["deals"][0]["ratio_calls"] = [] # spine will still request the ratio
with pytest.raises(RuntimeError) as exc:
bt.replay_fixture(fixture) bt.replay_fixture(fixture)
except KeyError as exc: assert "ratio_resolver" in str(exc.value)
assert "ratio_resolver" in str(exc) assert "diverged from capture" in str(exc.value)
else:
raise AssertionError("expected KeyError for an unrecorded ratio_resolver arg")
def test_load_fixture_plain_and_gzip_roundtrip() -> None:
# load_fixture is gzip-transparent: a .gz path is decompressed, a plain .json
# path is read directly. Both must yield a replay-able fixture dict.
fixture = _build_fixture()
payload = json.dumps(fixture, ensure_ascii=False)
with tempfile.TemporaryDirectory() as d:
plain = Path(d) / "fx.json"
plain.write_text(payload, encoding="utf-8")
gz = Path(d) / "fx.json.gz"
with gzip.open(gz, "wt", encoding="utf-8") as fh:
fh.write(payload)
loaded_plain = bt.load_fixture(str(plain))
loaded_gz = bt.load_fixture(str(gz))
assert loaded_plain == loaded_gz == fixture
# Both load paths produce identical replay metrics.
m_plain = bt.replay_fixture(loaded_plain)
m_gz = bt.replay_fixture(loaded_gz)
assert json.dumps(m_plain, sort_keys=True) == json.dumps(m_gz, sort_keys=True)