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)