Model/scripts/build_expired_pairs_corpus.py
Khalim Conn-Kowlessar 71bdd080c0 Expired-pairs integration gate: frozen single-file corpus + ratcheting floors 🟩
30 pairs (28 deterministically scoreable) from the 2,000-postcode sweep,
frozen as ONE anonymised raw-payload JSON (pairs + cohorts + actuals — a
thousand per-cert files would drown the PR diff). The gate replays the
whole conditioning path offline — mapper, conditioning, selection,
synthesis, comparison — in ~9s; floors are the measured values, tighten-
only. comparable_from_payload is extracted from the corpus loader so both
fixture formats share one payload->ComparableProperty path; the builder
(build_expired_pairs_corpus.py) refreezes from the raw-JSON disk cache.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 13:05:18 +00:00

140 lines
5.6 KiB
Python

"""Freeze a subsample of harness pairs into the committed integration fixture.
Turns the pairs harness's live evidence into a deterministic, offline
regression gate (the ADR-0030 corpus pattern): anonymised RAW API payloads
loaded through ``EpcPropertyDataMapper``, so the gate keeps exercising the
mapper and survives domain-dataclass changes. Layout, extending the
``tests/fixtures/epc_prediction`` conventions:
ONE json file (a thousand per-cert files would drown a PR diff):
pairs: [{postcode, uprn, actual, historic: {...}}]
cohorts: {postcode: {token: anonymised payload}}
actuals: {token: anonymised payload} (the lodged SAP-10.2 certs)
Reads the raw-JSON disk cache (scripts/epc_disk_cache.py) for everything the
API served, and the historic S3 backup for the pre-2012 records. Pairs are
subsampled deterministically (sorted, strided) from the harness telemetry.
Usage:
python scripts/build_expired_pairs_corpus.py \
--telemetry pairs_telemetry.jsonl --cache-dir .epc_cache \
--out tests/fixtures/expired_prediction_pairs.json --sample 30
"""
from __future__ import annotations
import argparse
import dataclasses
import json
import sys
from pathlib import Path
from typing import Any, Optional
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from datatypes.epc.domain.historic_epc import HistoricEpc # noqa: E402
from domain.postcode import Postcode # noqa: E402
from harness.epc_prediction_corpus import anonymise_payload, stable_hash # noqa: E402
# Free-text fields blanked on the frozen historic record; the postcode is kept
# (coarse open data, the shard key) and the address becomes a stable token so
# nothing joins back to a household.
_HISTORIC_PII_BLANK = ("address1", "address2", "address3", "posttown")
def anonymise_historic(record: HistoricEpc) -> dict[str, str]:
row = dataclasses.asdict(record)
for field in _HISTORIC_PII_BLANK:
row[field] = ""
row["address"] = stable_hash("addr", record.address) if record.address else ""
row["lmk_key"] = stable_hash("lmk", record.lmk_key) if record.lmk_key else ""
return row
def _read_cache(cache_dir: Path, key: str) -> Optional[Any]:
path = cache_dir / f"{key}.json"
return json.loads(path.read_text()) if path.exists() else None
def subsample(rows: list[dict[str, Any]], count: int) -> list[dict[str, Any]]:
"""A deterministic spread across the telemetry: sort, stride."""
ordered = sorted(rows, key=lambda r: (str(r["postcode"]), str(r["uprn"])))
if len(ordered) <= count:
return ordered
stride = len(ordered) // count
return ordered[::stride][:count]
def main() -> None: # pragma: no cover - IO composition
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--telemetry", type=Path, required=True)
parser.add_argument("--cache-dir", type=Path, required=True)
parser.add_argument("--out", type=Path, required=True)
parser.add_argument("--sample", type=int, default=30)
args = parser.parse_args()
from repositories.historic_epc.historic_epc_resolver import HistoricEpcResolver
from repositories.historic_epc.historic_epc_s3_repository import (
HistoricEpcS3Repository,
)
resolver = HistoricEpcResolver(HistoricEpcS3Repository.with_default_s3_client())
telemetry = [json.loads(line) for line in args.telemetry.read_text().splitlines()]
chosen = subsample(telemetry, args.sample)
cohorts: dict[str, dict[str, Any]] = {}
actuals: dict[str, Any] = {}
pairs: list[dict[str, Any]] = []
for row in chosen:
postcode, uprn = str(row["postcode"]), str(row["uprn"])
historic = resolver.record_for_uprn(uprn, postcode)
if historic is None:
print(f"{postcode} {uprn}: historic record gone — skipped", file=sys.stderr)
continue
search = _read_cache(args.cache_dir, f"search_uprn_{uprn}")
if not search:
print(f"{postcode} {uprn}: uprn search not cached — skipped", file=sys.stderr)
continue
latest = max(search, key=lambda r: str(r["registration_date"]))
actual_raw = _read_cache(args.cache_dir, f"cert_{latest['certificate_number']}")
cohort_search = _read_cache(args.cache_dir, f"search_pc_{postcode}")
if actual_raw is None or cohort_search is None:
print(f"{postcode} {uprn}: cert/cohort not cached — skipped", file=sys.stderr)
continue
if postcode not in cohorts:
payloads: dict[str, Any] = {}
for result in cohort_search:
raw = _read_cache(
args.cache_dir, f"cert_{result['certificate_number']}"
)
if raw is None:
continue
token = stable_hash("cert", str(result["certificate_number"]))
payloads[token] = anonymise_payload(raw)
cohorts[postcode] = payloads
actual_token = stable_hash("cert", str(latest["certificate_number"]))
actuals[actual_token] = anonymise_payload(actual_raw)
pairs.append(
{
"postcode": str(Postcode(postcode)),
"uprn": uprn,
"actual": actual_token,
"historic": anonymise_historic(historic),
}
)
print(f"{postcode} {uprn}: frozen", file=sys.stderr)
args.out.parent.mkdir(parents=True, exist_ok=True)
args.out.write_text(
json.dumps(
{"pairs": pairs, "cohorts": cohorts, "actuals": actuals}, indent=1
)
)
print(f"{len(pairs)} pairs frozen across {len(cohorts)} postcodes -> {args.out}")
if __name__ == "__main__":
main()