Resolve dominant double/triple glazing when the era is stated

Addresses PR #1402 review: the guard only claimed dominant-single, leaving
the symmetric bug open — the LLM can flatten a dominant-double/triple split
onto its minority single (e.g. "96% Double glazing 2002 or later, 4% Single").

A dominant double/triple whose era is explicit ("pre-2002" / "2002 or later")
is just as fully determined as era-free single, so the guard now claims it via
_DOMINANT_MEMBER. Only a genuinely ambiguous era ("unknown age", unstated) still
defers to the LLM — the "96% double -> None" contract now holds solely for the
era-unknown case, not the era-stated one.

Backfill script reuses the same guard, so it now corrects any dominant split;
renamed reclassify_dominant_single_glazing.py -> reclassify_dominant_glazing.py
to match. Tests cover double/triple x pre-2002/2002-or-later and the still-
deferred unknown-age case; 14 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jun-te Kim 2026-07-02 08:07:13 +00:00
parent 646f66ff89
commit deec22302a
3 changed files with 93 additions and 34 deletions

View file

@ -5,12 +5,35 @@ from typing import Optional
from domain.epc.property_overrides.glazing_type import GlazingType
# "N% <base glazing type>" — 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% <base glazing type> [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% <type>, M% <type>"`` 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% <type>, M% <type>"`` 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

View file

@ -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)

View file

@ -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)