mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-22 08:48:38 +00:00
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>
165 lines
6.1 KiB
Python
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
|