diff --git a/domain/epc/property_overrides/glazing_mix_guard.py b/domain/epc/property_overrides/glazing_mix_guard.py index ea6aa7cdc..5d59e4fa1 100644 --- a/domain/epc/property_overrides/glazing_mix_guard.py +++ b/domain/epc/property_overrides/glazing_mix_guard.py @@ -5,12 +5,35 @@ from typing import Optional from domain.epc.property_overrides.glazing_type import GlazingType -# "N% " — the era (2002 / pre-2002) is irrelevant to whether it -# is a mix, so only the base type is captured. -_PERCENT_TYPE = re.compile(r"(\d+)%\s*(single|double|triple|secondary)") +# "N% [era]" — the base type decides whether it is a mix; the +# era ("2002 or later" / "pre-2002") is captured too, so a dominant double/triple +# can be resolved deterministically when its era is stated (see the guard below). +# An absent or non-canonical era ("unknown age", "(SAP 9.94)") leaves the era group +# empty, so that dominant split stays the LLM's job. +_PERCENT_TYPE = re.compile( + r"(\d+)%\s*(single|double|triple|secondary)(?:\s*glazing)?" + r"(?:[\s,]*(pre-?2002|2002 or later))?" +) # One base type at or above this share is a uniform assertion, not a mix — applied # as that type (a near-uniform reglaze), not deferred. _UNIFORM_THRESHOLD_PERCENT = 90 +# A dominant base type + stated era → the fully-determined canonical member. Single +# glazing is era-free, so it needs no era. Double/triple with no (or non-canonical) +# era is absent here and falls through to the LLM. +_DOMINANT_MEMBER: dict[tuple[str, Optional[str]], GlazingType] = { + ("single", None): GlazingType.SINGLE, + ("double", "2002 or later"): GlazingType.DOUBLE_POST_2002, + ("double", "pre-2002"): GlazingType.DOUBLE_PRE_2002, + ("triple", "2002 or later"): GlazingType.TRIPLE_POST_2002, + ("triple", "pre-2002"): GlazingType.TRIPLE_PRE_2002, +} + + +def _canonical_era(era: str) -> Optional[str]: + """Normalise a matched era phrase to the key used in ``_DOMINANT_MEMBER``.""" + if not era: + return None + return "2002 or later" if "2002 or later" in era else "pre-2002" def glazing_mix_guard(description: str) -> Optional[GlazingType]: @@ -23,28 +46,36 @@ def glazing_mix_guard(description: str) -> Optional[GlazingType]: must resolve to ``GlazingType.MIXED`` (no overlay → the cert is kept), never to a single dominant type that would flatten every window (ADR-0042). - Recognises the structured ``"N% , M% "`` split and returns - ``MIXED`` when it is a genuine mix — two or more glazing types present and none - dominant (≥ the uniform threshold). Returns ``None`` for a uniform / near- - uniform assertion or an unparseable description, so the LLM classifier still - resolves it (a uniform reglaze is applied; varied phrasings are the LLM's job). + Recognises the structured ``"N% , M% "`` split and returns: + + * ``MIXED`` for a genuine mix — two or more glazing types present and none + dominant (≥ the uniform threshold); + * the fully-determined member for a dominant near-uniform split whose type + carries no era ambiguity — single glazing (era-free), or a dominant + double/triple whose era is stated ("pre-2002" / "2002 or later"); the LLM + would otherwise flatten such a split onto its *minority* type; + * ``None`` otherwise — an unparseable description, or a dominant double/triple + with an unstated/non-canonical era ("unknown age") that still carries genuine + era ambiguity — so the LLM classifier resolves it. """ matches = _PERCENT_TYPE.findall(description.lower()) - base_types = {base_type for _, base_type in matches} + base_types = {base_type for _, base_type, _ in matches} if len(base_types) < 2: # One (or no) glazing type named — a uniform assertion, not a mix. return None - dominant_percent, dominant_type = max( - ((int(percent), base_type) for percent, base_type in matches), - key=lambda pair: pair[0], + dominant_percent, dominant_type, dominant_era = max( + ((int(percent), base_type, era) for percent, base_type, era in matches), + key=lambda triple: triple[0], ) if dominant_percent >= _UNIFORM_THRESHOLD_PERCENT: - # One type dominates — a near-uniform assertion, not a mix. Single glazing - # is era-free, so a dominant-single split is fully determined: apply SINGLE - # deterministically (the LLM otherwise flattens it onto the minority double - # type). A dominant double/triple still carries era ambiguity (pre-2002 / - # 2002 / unknown age), so it stays the LLM's job. - if dominant_type == "single": - return GlazingType.SINGLE - return None + # One type dominates — a near-uniform assertion, not a mix. A split is fully + # determined (and safe to apply deterministically) when the dominant type + # carries no era ambiguity: single glazing is era-free, and a dominant + # double/triple whose era is stated ("pre-2002" / "2002 or later") is + # equally pinned. In those cases claim the member ourselves — the LLM + # otherwise flattens the split onto the *minority* type. A dominant + # double/triple with an unstated or non-canonical era ("unknown age") still + # carries genuine era ambiguity, so it stays the LLM's job (falls through to + # ``None``). + return _DOMINANT_MEMBER.get((dominant_type, _canonical_era(dominant_era))) return GlazingType.MIXED diff --git a/scripts/lisasrequest/reclassify_dominant_single_glazing.py b/scripts/lisasrequest/reclassify_dominant_glazing.py similarity index 82% rename from scripts/lisasrequest/reclassify_dominant_single_glazing.py rename to scripts/lisasrequest/reclassify_dominant_glazing.py index 295ae88a1..95dd59c9f 100644 --- a/scripts/lisasrequest/reclassify_dominant_single_glazing.py +++ b/scripts/lisasrequest/reclassify_dominant_glazing.py @@ -1,13 +1,15 @@ -"""Backfill glazing overrides where a >= 90%-single dwelling was flattened onto its -*minority* double type. +"""Backfill glazing overrides where a dominant (>= 90%) split was flattened onto +its *minority* type. Sibling to ``scripts/reclassify_mixed_glazing.py``. That script rescued genuine mixes (no type >= 90%) to ``MIXED``; it deliberately left near-uniform rows to the -LLM classifier. But for a dominant-*single* split ("4% Double glazing 2002 or -later, 96% Single Glazing") the LLM had latched onto the 4% minority and written a -"Double glazing…" value. ``glazing_mix_guard`` now resolves a dominant-single -split deterministically to ``SINGLE`` (single glazing is era-free), so this -backfill fixes the rows written before that guard change. +LLM classifier. But for a dominant split the LLM sometimes latched onto the +minority type — a dominant-*single* dwelling ("4% Double glazing 2002 or later, +96% Single Glazing") written as "Double glazing…", or the symmetric dominant-double +case written as "Single glazing". ``glazing_mix_guard`` now resolves any +era-unambiguous dominant split deterministically — SINGLE (era-free), or a +double/triple whose era is stated — so this backfill fixes the rows written before +that guard change. Uses the SAME guard as the live path, so the backfill and the classifier cannot drift. SCOPED TO ONE PORTFOLIO (``--portfolio``, default 796 = Hyde) and only @@ -20,8 +22,8 @@ row changed (property_id, uprn, old value, new value) so the change is reversibl Idempotent — only rows whose stored value differs from the guard's target member are touched. - python -m scripts.lisasrequest.reclassify_dominant_single_glazing # dry run - python -m scripts.lisasrequest.reclassify_dominant_single_glazing --apply # write + python -m scripts.lisasrequest.reclassify_dominant_glazing # dry run + python -m scripts.lisasrequest.reclassify_dominant_glazing --apply # write """ from __future__ import annotations @@ -107,7 +109,7 @@ def main() -> int: if args.apply and audit: out = _REPO_ROOT / "scripts" / "lisasrequest" / ( - f"reclassify_dominant_single_glazing_{args.portfolio}_audit.csv" + f"reclassify_dominant_glazing_{args.portfolio}_audit.csv" ) with out.open("w", newline="") as fh: w = csv.writer(fh) diff --git a/tests/domain/epc/test_glazing_mix_guard.py b/tests/domain/epc/test_glazing_mix_guard.py index 2e839a5df..de1faccf0 100644 --- a/tests/domain/epc/test_glazing_mix_guard.py +++ b/tests/domain/epc/test_glazing_mix_guard.py @@ -38,19 +38,45 @@ def test_dominant_single_glazing_resolves_to_single(description: str) -> None: assert result is GlazingType.SINGLE +@pytest.mark.parametrize( + ("description", "expected"), + [ + # >= 90% double with a *stated* era is era-pinned — the LLM otherwise + # flattens it onto the 4% minority single (the symmetric bug to the + # dominant-single case above). + ("96% double glazing 2002 or later, 4% single glazing", GlazingType.DOUBLE_POST_2002), + ("95% double glazing pre-2002, 5% single glazing", GlazingType.DOUBLE_PRE_2002), + ("90% double glazing 2002 or later, 10% single glazing", GlazingType.DOUBLE_POST_2002), + # Triple is era-bearing too — resolve when the era is stated. + ("92% triple glazing pre-2002, 8% single glazing", GlazingType.TRIPLE_PRE_2002), + ("91% triple glazing 2002 or later, 9% single glazing", GlazingType.TRIPLE_POST_2002), + ], +) +def test_dominant_era_stated_double_or_triple_resolves_deterministically( + description: str, expected: GlazingType +) -> None: + # Act + result = glazing_mix_guard(description) + + # Assert — a dominant double/triple whose era is stated is fully determined, so + # the guard claims it rather than trusting the LLM. + assert result is expected + + @pytest.mark.parametrize( "description", [ "100% double glazing 2002 or later", # uniform — one type - "96% double glazing 2002 or later, 4% single glazing", # >= 90% double -> era ambiguity + "90% double glazing unknown age, 10% single glazing", # dominant but era unstated "80% double glazing 2002 or later, 20% double glazing pre-2002", # same base type "some double glazing and a bit of single", # unparseable — no percentages "", ], ) -def test_uniform_or_unparseable_glazing_defers_to_the_llm(description: str) -> None: - # The guard only claims a genuine percentage mix; a uniform assertion (applied - # as that type) or a varied phrasing is left for the LLM classifier. +def test_uniform_or_ambiguous_glazing_defers_to_the_llm(description: str) -> None: + # The guard only claims a genuine mix or a fully-determined dominant split. A + # uniform assertion, a dominant split whose era is unstated ("unknown age" — the + # era is genuinely ambiguous), or a varied phrasing is left for the LLM. # Act result = glazing_mix_guard(description)