Model/tests/domain/epc_prediction/test_expired_pairs_gate.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

165 lines
6.1 KiB
Python

"""Tier-1 ratcheting gate for Expired-Enhanced Prediction (ADR-0054).
Replays the pairs harness OFFLINE over the committed, anonymised fixture
(`tests/fixtures/expired_prediction_pairs` — pre-2012 historic records paired
with their lodged SAP-10.2 certs and full postcode cohorts, frozen from the
2,000-postcode national sweep). Both arms run the real production path minus
the network: the raw payloads go through `EpcPropertyDataMapper`, conditioning
through `conditioning_from_historic`, selection through `select_comparables`,
synthesis through `EpcPrediction`. Deterministic, so every run reproduces the
same numbers exactly — a failure is a real regression in the conditioning
path, never sample noise.
Floors are the measured values over the frozen fixture and only ever
**tighten** (the repo's no-tolerance-widening ethos), exactly like the
Component Accuracy gate this extends.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Optional
import pytest
from datatypes.epc.domain.epc_property_data import EpcPropertyData
from datatypes.epc.domain.historic_epc import HistoricEpc
from datatypes.epc.domain.mapper import EpcPropertyDataMapper
from domain.epc_prediction.comparable_properties import (
ComparableProperty,
select_comparables,
)
from domain.epc_prediction.epc_prediction import EpcPrediction
from domain.epc_prediction.historic_conditioning import (
attributes_with_historic_fallback,
conditioning_from_historic,
target_with_conditioning,
)
from domain.epc_prediction.prediction_comparison import (
PredictionComparison,
compare_prediction,
)
from domain.epc_prediction.prediction_target import build_prediction_target
from domain.property.property import PropertyIdentity
from harness.epc_prediction_corpus import comparable_from_payload
_FIXTURE = (
Path(__file__).parents[3]
/ "tests"
/ "fixtures"
/ "expired_prediction_pairs.json"
)
# Conditioned-arm classification floors (hit-rate over the frozen 30-pair /
# 28-scoreable fixture) and residual ceilings — the measured values; tighten,
# never loosen. The general (unconditioned) prediction floors live in
# test_component_accuracy_gate.py; this gate guards the CONDITIONING path.
_CLASSIFICATION_FLOORS: dict[str, float] = {
"construction_age_band": 0.5357,
"construction_age_band_pm1": 0.8214,
"cylinder_insulation_type": 0.8000,
"floor_construction": 0.8636,
"floor_insulation": 1.0000,
"has_hot_water_cylinder": 0.8214,
"has_pv": 0.8929,
"has_room_in_roof": 0.8571,
"heating_main_category": 1.0000,
"heating_main_control": 0.6071,
"heating_main_fuel": 1.0000,
"modal_glazing_type": 0.5000,
"roof_construction": 0.7143,
"roof_insulation_thickness": 0.3333,
"roof_insulation_thickness_pm1": 0.4815,
"secondary_heating_type": 0.1667,
"solar_water_heating": 1.0000,
"wall_construction": 0.8929,
"wall_insulation_type": 0.7500,
"water_heating_code": 1.0000,
"water_heating_fuel": 1.0000,
}
_FLOOR_AREA_MAE_CEILING: Optional[float] = 21.121
def _pairs() -> list[tuple[HistoricEpc, EpcPropertyData, list[ComparableProperty]]]:
corpus = json.loads(_FIXTURE.read_text())
cohorts: dict[str, list[ComparableProperty]] = {
postcode: [
comparable
for token, payload in payloads.items()
if (comparable := comparable_from_payload(token, payload, {})) is not None
]
for postcode, payloads in corpus["cohorts"].items()
}
return [
(
HistoricEpc(**pair["historic"]),
EpcPropertyDataMapper.from_api_response(corpus["actuals"][pair["actual"]]),
cohorts[pair["postcode"]],
)
for pair in corpus["pairs"]
]
def _conditioned_comparisons() -> list[PredictionComparison]:
predictor = EpcPrediction()
comparisons: list[PredictionComparison] = []
for historic, actual, cohort in _pairs():
conditioning = conditioning_from_historic(historic)
attributes = attributes_with_historic_fallback(None, conditioning)
identity = PropertyIdentity(
portfolio_id=0,
postcode=historic.postcode,
address=historic.address,
uprn=int(historic.uprn),
)
target = build_prediction_target(identity, None, attributes)
if target is None:
continue
target = target_with_conditioning(target, conditioning)
loo = [c for c in cohort if c.epc.uprn != int(historic.uprn)]
comparables = select_comparables(target, loo)
if not comparables.members:
continue
predicted = predictor.predict(target, comparables)
comparisons.append(compare_prediction(predicted, actual))
return comparisons
@pytest.fixture(scope="module")
def comparisons() -> list[PredictionComparison]:
if not _FIXTURE.exists():
pytest.skip("expired-pairs fixture not present")
return _conditioned_comparisons()
def test_fixture_yields_the_expected_pair_count(
comparisons: list[PredictionComparison],
) -> None:
# The frozen fixture must keep producing its full set of scoreable pairs —
# a drop means the fixture, the conditioning gate, or selection changed.
# (30 frozen; 2 are gated out / find no comparables, deterministically.)
assert len(comparisons) == 28
@pytest.mark.parametrize("component,floor", sorted(_CLASSIFICATION_FLOORS.items()))
def test_conditioned_classification_rate_does_not_regress(
comparisons: list[PredictionComparison], component: str, floor: float
) -> None:
applicable = [
hit
for comparison in comparisons
if (hit := comparison.categorical_hits.get(component)) is not None
]
assert applicable, component
rate = sum(applicable) / len(applicable)
assert rate >= floor - 1e-3, f"{component}: {rate:.4f} < floor {floor:.4f}"
def test_conditioned_floor_area_mae_does_not_regress(
comparisons: list[PredictionComparison],
) -> None:
if _FLOOR_AREA_MAE_CEILING is None:
pytest.skip("ceiling not yet pinned")
mae = sum(abs(c.floor_area_residual) for c in comparisons) / len(comparisons)
assert mae <= _FLOOR_AREA_MAE_CEILING + 1e-3