Model/tests/domain/epc_prediction/test_component_accuracy_gate.py
Khalim Conn-Kowlessar 718455e971 feat(epc-prediction): physical-similarity-weighted categorical mode (#1224)
ADR-0029 decision 5: survivors were treated equally; now each neighbour's
vote in the cohort mode decays with its distance from the cohort's physical
centre (floor area from the median, age band from the modal band), so the
mode leans on the most representative neighbours instead of being swayed by
size/era outliers. Scales (size 20 m^2, age weight 0.5) chosen on the
validation corpus; the tight size kernel is load-bearing (looser scales
regress floor_insulation on the fixture).

Corpus (181 SAP-10.2 targets): wall_insulation 83.4->86.2%,
roof_construction 86.2->87.3%, floor_construction 78.8->81.2%,
floor_insulation 92.9->94.1%; net +7.5pp gained vs -1.1pp (two 1-cert dips,
both held on the fixture). Geometry/residuals untouched (template unchanged).

Gate (36-target fixture): zero regression across all 24 floors/ceilings;
ratcheted wall_insulation_type 0.7778->0.8333, floor_construction
0.7500->0.8125, floor_insulation 0.9062->0.9375. Dead _mode/_int_mode
removed (superseded by the weighted variants).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 10:46:51 +00:00

109 lines
4.1 KiB
Python

"""Tier-1 ratcheting Component Accuracy gate (ADR-0030).
Runs the calculator-free leave-one-out scorer over the committed, anonymised
fixture and asserts each per-component classification rate / geometry residual is
no worse than a committed baseline. Because the prediction is deterministic and
the fixture is frozen, every run reproduces the same numbers exactly — so a
failure means a real *regression* in prediction quality, never sample noise.
The floors / ceilings are the currently-measured values and only ever **tighten**
(the repo's no-tolerance-widening ethos applied to an aggregate): when prediction
improves, ratchet the relevant floor up in the same change. The end-to-end
SAP / carbon / PE guards are deliberately *not* here — they need the calculator,
whose API-path residual is a separate workstream; the component floors are the
real gate (ADR-0030).
"""
from pathlib import Path
import pytest
from domain.epc_prediction.validation import (
ComponentAccuracy,
evaluate_component_accuracy,
)
from harness.epc_prediction_corpus import load_corpus
_FIXTURE = Path(__file__).parents[3] / "tests" / "fixtures" / "epc_prediction"
# Minimum classification hit-rate per component (ratchet floors). Tighten — never
# loosen — as prediction improves. Values are the measured rates over the frozen
# 36-target fixture; a 1e-3 tolerance absorbs float rounding only.
_RATE_FLOORS: dict[str, float] = {
"wall_construction": 0.8889,
"wall_insulation_type": 0.8333,
"construction_age_band": 0.6389,
"construction_age_band_pm1": 0.8333,
"roof_construction": 0.7222,
"floor_construction": 0.8125,
"heating_main_fuel": 0.9722,
"heating_main_category": 0.8889,
"heating_main_control": 0.7500,
"water_heating_fuel": 0.9167,
"water_heating_code": 0.8889,
"has_hot_water_cylinder": 0.8889,
"cylinder_insulation_type": 0.1667,
"secondary_heating_type": 0.0000,
"roof_insulation_thickness": 0.4118,
"floor_insulation": 0.9375,
"has_room_in_roof": 0.8333,
"modal_glazing_type": 0.5000,
"has_pv": 1.0000,
"solar_water_heating": 1.0000,
}
# Maximum mean absolute residual per numeric component (ratchet ceilings).
# window_count is deliberately excluded — it is cosmetic for SAP (issue #1222):
# the predicted picture clusters at a mapper-default 4 windows while actuals
# spread 1-21, yet total_window_area (the SAP-relevant signal) stays tight.
_RESIDUAL_CEILINGS: dict[str, float] = {
"floor_area": 12.2175,
"total_window_area": 4.4067,
"building_parts": 0.3333,
"door_count": 0.6389,
}
_TOLERANCE = 1e-3
@pytest.fixture(scope="module")
def accuracy() -> ComponentAccuracy:
if not (_FIXTURE / "_index.json").exists():
pytest.skip(f"no EPC Prediction fixture at {_FIXTURE}")
return evaluate_component_accuracy(load_corpus(_FIXTURE))
def test_fixture_yields_the_expected_target_count(
accuracy: ComponentAccuracy,
) -> None:
# The frozen fixture must still produce its full set of SAP-10.2 targets — a
# drop means the fixture or the target filter changed.
assert accuracy.targets >= 36
@pytest.mark.parametrize("component,floor", sorted(_RATE_FLOORS.items()))
def test_classification_rate_does_not_regress(
accuracy: ComponentAccuracy, component: str, floor: float
) -> None:
# Arrange / Act
rate = accuracy.rate(component)
# Assert — the component is still applicable and at or above its floor.
assert rate is not None, f"{component} had no applicable targets"
assert rate >= floor - _TOLERANCE, (
f"{component} classification regressed: {rate:.4f} < floor {floor:.4f}"
)
@pytest.mark.parametrize("component,ceiling", sorted(_RESIDUAL_CEILINGS.items()))
def test_residual_does_not_regress(
accuracy: ComponentAccuracy, component: str, ceiling: float
) -> None:
# Arrange / Act
mean_abs = accuracy.mean_abs_residual(component)
# Assert — the mean absolute residual is at or below its ceiling.
assert mean_abs is not None, f"{component} had no residuals"
assert mean_abs <= ceiling + _TOLERANCE, (
f"{component} residual regressed: {mean_abs:.4f} > ceiling {ceiling:.4f}"
)