Model/backend/documents_parser/tests/test_pashub_sap_accuracy.py
Daniel Roth 98aa0127a7 Keep swallowing the control gap's boundary form in the accuracy harness 🟪
The control gap surfaces as UnmappedSapCode on main and as UnmappedPasHubLabel
once the #1557 control mapper lands (unmapped community-heating control), so the
harness must swallow both; only the closed fuel-specific MissingMainFuelType is
dropped as a fuel-drop tripwire.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 17:40:50 +00:00

191 lines
7.5 KiB
Python

"""End-to-end accuracy test for the pashub site-notes extractor.
Chain under test (per fixture):
pashub RdSAP site-note PDF
-> parse_site_notes_pdf (extractor + `from_site_notes` mapper)
-> EpcPropertyData
-> Sap10Calculator().calculate -> SapResult.sap_score_continuous
vs pashub's own `pre_sap` rating (from hubspot_deal_data)
Fixtures live in `fixtures/pashub_accuracy/` with a `manifest.json`; they are
built by `scripts/build_pashub_accuracy_fixtures.py` from the Guinness GMCA
project (see that script for provenance). `pre_sap` (e.g. `"C73"` -> 73) is
pashub's *own* RdSAP figure, so this gauge conflates two independent error
sources — our extractor+mapper AND our SAP-10 calculator (which alone lands
within 0.5 on only ~79% of the RdSAP corpus, see
`tests/infrastructure/epc_client/test_sap_accuracy_corpus.py`). It is therefore
a **hybrid** gate:
* `test_pashub_fixture_computes` — per fixture: the extractor must produce a
calculable `EpcPropertyData`. Any *unexpected* exception is a hard fail (a
real extractor/mapper bug on that property). The one *known* remaining gap —
the pashub mapper not coding a main-heating `main_heating_control` label to a
SAP code — is `xfail`ed dynamically (see `_KNOWN_GAPS` for its two forms), so
these flip to passing once that mapper fix lands. The main-fuel-code gap
(`Bulk LPG`, and the `Fuel:`-form Mains Gas the extractor was dropping) was
closed in #1558, so `MissingMainFuelType` is *no longer* swallowed — a
fixture that regresses to a dropped fuel now hard-fails.
* `test_pashub_sap_accuracy_aggregate` — over the fixtures that compute:
fraction within 0.5 SAP of `pre_sap`, and MAE. Ratcheting floor/ceiling
(never loosen), mirroring the corpus gauge. While every fixture is blocked
on the main-heating-control gap the aggregate has no data and `xfail`s; once
that mapper fix lands, ratchet the constants below to the observed values.
"""
from __future__ import annotations
import json
from dataclasses import dataclass
from functools import lru_cache
from pathlib import Path
from typing import Optional
import pytest
from backend.documents_parser.parser import parse_site_notes_pdf
from datatypes.epc.domain.mapper import UnmappedPasHubLabel
from domain.sap10_calculator.calculator import Sap10Calculator
from domain.sap10_calculator.exceptions import UnmappedSapCode
# Known in-progress pashub main-heating string→SAP-code mapper gaps, fixed
# field-by-field. The remaining gap is the `main_heating_control` label, which
# surfaces in one of two forms: `UnmappedSapCode` (raw label reaches the
# calculator) or, once the control mapper (#1557) lands, `UnmappedPasHubLabel`
# (strict-raised at the boundary for a control label not yet mapped — e.g. the
# cohort's community-heating charging control). Both xfail until that fix lands.
# The main-fuel-code gaps are closed (mains-gas code #1554; "Bulk LPG" + the
# `Fuel:`-form Mains Gas the extractor was dropping #1558), so `MissingMainFuelType`
# is deliberately *not* here — a fuel-drop regression hard-fails instead of hiding.
_KNOWN_GAPS: tuple[type[Exception], ...] = (UnmappedSapCode, UnmappedPasHubLabel)
_FIXTURES_DIR = Path(__file__).parent / "fixtures" / "pashub_accuracy"
_MANIFEST_PATH = _FIXTURES_DIR / "manifest.json"
# --- Ratchets (never loosen), mirroring test_sap_accuracy_corpus.py. ----------
# TODO(pashub-control-code): every fixture is currently blocked on the pashub
# main-heating-control mapper gap, so the aggregate xfails and these bootstrap
# values are never asserted. Once that mapper fix lands and the aggregate first
# computes, ratchet these to the observed within-0.5 and MAE.
_MIN_WITHIN_HALF: float = 0.0
_MAX_SAP_MAE: float = 100.0
_KNOWN_GAP_REASON = (
"pashub `from_site_notes` mapper does not int-code a main-heating "
"`main_heating_control` label to a SAP code; fix in progress field-by-field"
)
@dataclass(frozen=True)
class PashubFixture:
deal_id: str
uprn: Optional[int]
pre_sap_raw: str
pre_sap_score: int
pdf: str
@property
def path(self) -> Path:
return _FIXTURES_DIR / self.pdf
@dataclass(frozen=True)
class Outcome:
"""Result of running one fixture through the pipeline."""
blocked: bool # blocked on the known main-heating-control gap (xfail territory)
reason: Optional[str]
sap_continuous: Optional[float]
diff: Optional[float] # abs(our SAP - pre_sap)
def _load_manifest() -> list[PashubFixture]:
if not _MANIFEST_PATH.exists():
return []
data = json.loads(_MANIFEST_PATH.read_text())
return [PashubFixture(**entry) for entry in data["fixtures"]]
_FIXTURES: list[PashubFixture] = _load_manifest()
_BY_ID: dict[str, PashubFixture] = {f.deal_id: f for f in _FIXTURES}
_IDS: list[str] = [f.deal_id for f in _FIXTURES]
@lru_cache(maxsize=None)
def _evaluate(deal_id: str) -> Outcome:
"""Run one fixture end-to-end. Shared across the two tests via the cache.
Only `UnmappedSapCode` is swallowed (the known control-label gap). Every
other exception propagates — a real extractor/mapper bug the caller must
surface.
"""
fixture = _BY_ID[deal_id]
try:
epc = parse_site_notes_pdf(str(fixture.path), uprn=fixture.uprn)
result = Sap10Calculator().calculate(epc)
except _KNOWN_GAPS as exc:
return Outcome(blocked=True, reason=str(exc), sap_continuous=None, diff=None)
diff = abs(result.sap_score_continuous - fixture.pre_sap_score)
return Outcome(
blocked=False,
reason=None,
sap_continuous=result.sap_score_continuous,
diff=diff,
)
@pytest.mark.skipif(not _FIXTURES, reason="no pashub_accuracy fixtures/manifest")
@pytest.mark.parametrize("deal_id", _IDS)
def test_pashub_fixture_computes(deal_id: str) -> None:
"""The extractor must yield a calculable dwelling for each real property."""
outcome = _evaluate(deal_id)
if outcome.blocked:
pytest.xfail(f"{_KNOWN_GAP_REASON}: {outcome.reason}")
assert outcome.sap_continuous is not None
assert 1 <= outcome.sap_continuous <= 100
@pytest.mark.skipif(not _FIXTURES, reason="no pashub_accuracy fixtures/manifest")
def test_pashub_sap_accuracy_aggregate(capsys: pytest.CaptureFixture[str]) -> None:
"""SAP within-0.5 of pashub's `pre_sap`, aggregated over computable fixtures."""
diffs: list[float] = []
blocked = 0
errored = 0
for deal_id in _IDS:
try:
outcome = _evaluate(deal_id)
except Exception: # noqa: BLE001 - a per-fixture bug; scored there, not here
errored += 1
continue
if outcome.blocked:
blocked += 1
continue
assert outcome.diff is not None
diffs.append(outcome.diff)
if not diffs:
pytest.xfail(
f"no fixture computes a SAP score "
f"({blocked} blocked on the main-heating-control gap, {errored} errored); "
f"{_KNOWN_GAP_REASON}"
)
n = len(diffs)
within_half = sum(1 for d in diffs if d < 0.5) / n
sap_mae = sum(diffs) / n
with capsys.disabled():
print(
f"\n[pashub Guinness | {n} computed / {blocked} blocked / "
f"{errored} errored]"
f"\n SAP within-0.5 = {within_half:.1%} MAE = {sap_mae:.3f}"
)
assert within_half >= _MIN_WITHIN_HALF, (
f"SAP within-0.5 {within_half:.1%} fell below floor "
f"{_MIN_WITHIN_HALF:.0%}"
)
assert sap_mae <= _MAX_SAP_MAE, (
f"SAP MAE {sap_mae:.3f} exceeded ceiling {_MAX_SAP_MAE}"
)