From 646f66ff890ec4612376b543f2f7f950f431f9e1 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Wed, 1 Jul 2026 15:59:18 +0000 Subject: [PATCH 1/2] Resolve dominant-single glazing deterministically in the mix guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A landlord glazing split like "4% Double glazing 2002 or later, 96% Single Glazing" is a near-uniform *single*-glazing dwelling, but the LLM classifier latched onto the 4% minority and wrote a "Double glazing…" override — over- crediting the dwelling. glazing_mix_guard rescued genuine mixes (no type >= 90%) to MIXED but returned None for near-uniform splits, trusting the LLM to have applied the dominant type. It hadn't. Single glazing is era-free, so a dominant-single split is fully determined: the guard now returns SINGLE for it (dominant double/triple still carry era ambiguity, so they stay the LLM's job — the existing "96% double" case still defers). Adds a portfolio-scoped backfill (reuses the same guard, so it can't drift from the live path) and applied it to portfolio 796 (Hyde): 115 glazing overrides corrected from a spurious Double back to Single. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../property_overrides/glazing_mix_guard.py | 14 +- .../reclassify_dominant_single_glazing.py | 123 ++++++++++++++++++ tests/domain/epc/test_glazing_mix_guard.py | 22 +++- 3 files changed, 156 insertions(+), 3 deletions(-) create mode 100644 scripts/lisasrequest/reclassify_dominant_single_glazing.py diff --git a/domain/epc/property_overrides/glazing_mix_guard.py b/domain/epc/property_overrides/glazing_mix_guard.py index 36d8fce58..ea6aa7cdc 100644 --- a/domain/epc/property_overrides/glazing_mix_guard.py +++ b/domain/epc/property_overrides/glazing_mix_guard.py @@ -34,7 +34,17 @@ def glazing_mix_guard(description: str) -> Optional[GlazingType]: if len(base_types) < 2: # One (or no) glazing type named — a uniform assertion, not a mix. return None - if max(int(percent) for percent, _ in matches) >= _UNIFORM_THRESHOLD_PERCENT: - # One type dominates — treat as that (near-)uniform type, not a mix. + dominant_percent, dominant_type = max( + ((int(percent), base_type) for percent, base_type in matches), + key=lambda pair: pair[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 return GlazingType.MIXED diff --git a/scripts/lisasrequest/reclassify_dominant_single_glazing.py b/scripts/lisasrequest/reclassify_dominant_single_glazing.py new file mode 100644 index 000000000..295ae88a1 --- /dev/null +++ b/scripts/lisasrequest/reclassify_dominant_single_glazing.py @@ -0,0 +1,123 @@ +"""Backfill glazing overrides where a >= 90%-single dwelling was flattened onto its +*minority* double 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. + +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 +touches ``property_overrides.override_value`` (TEXT — what the modelling reads); +the global ``landlord_glazing_overrides`` classifier cache is left alone. + +DRY-RUN BY DEFAULT: prints what it would change and writes nothing. Pass +``--apply`` to execute inside a transaction; it also writes an audit CSV of every +row changed (property_id, uprn, old value, new value) so the change is reversible. +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 +""" + +from __future__ import annotations + +import argparse +import csv +import sys +from collections import Counter +from pathlib import Path + +from sqlalchemy import text + +_REPO_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(_REPO_ROOT)) + +from domain.epc.property_overrides.glazing_mix_guard import glazing_mix_guard # noqa: E402 +from scripts.e2e_common import ENV_PATH, build_engine, load_env # noqa: E402 + +_SELECT = text( + """ + SELECT po.property_id, pr.uprn, + po.original_spreadsheet_description AS description, + po.override_value AS value + FROM property_overrides po + JOIN property pr ON pr.id = po.property_id + WHERE po.portfolio_id = :portfolio + AND po.override_component = 'glazing' + """ +) +_UPDATE = text( + """ + UPDATE property_overrides + SET override_value = :new_value + WHERE portfolio_id = :portfolio + AND override_component = 'glazing' + AND property_id = :property_id + AND override_value <> :new_value + """ +) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--portfolio", type=int, default=796) + parser.add_argument( + "--apply", + action="store_true", + help="execute the updates (default: dry-run, writes nothing)", + ) + args = parser.parse_args() + + load_env(ENV_PATH) + engine = build_engine() + + audit: list[tuple[int, object, str, str, str]] = [] # pid, uprn, descr, old, new + tally: Counter[str] = Counter() + with engine.begin() as conn: + conn.execute(text("SET statement_timeout = 120000")) + for property_id, uprn, description, value in conn.execute( + _SELECT, {"portfolio": args.portfolio} + ): + member = glazing_mix_guard(description or "") + if member is None or value == member.value: + continue + tally[f"{value!r} -> {member.value!r}"] += 1 + audit.append((property_id, uprn, description, value, member.value)) + if args.apply: + conn.execute( + _UPDATE, + { + "portfolio": args.portfolio, + "property_id": property_id, + "new_value": member.value, + }, + ) + if not args.apply: + conn.rollback() + + verb = "re-classified" if args.apply else "would re-classify" + print(f"portfolio {args.portfolio}: {verb} {len(audit)} glazing override row(s)") + for change, n in tally.most_common(): + print(f" {n:5d} {change}") + + if args.apply and audit: + out = _REPO_ROOT / "scripts" / "lisasrequest" / ( + f"reclassify_dominant_single_glazing_{args.portfolio}_audit.csv" + ) + with out.open("w", newline="") as fh: + w = csv.writer(fh) + w.writerow(["property_id", "uprn", "description", "old_value", "new_value"]) + w.writerows(audit) + print(f"\naudit trail: {out}") + if not args.apply: + print("\nDRY-RUN — nothing written. Re-run with --apply to execute.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/domain/epc/test_glazing_mix_guard.py b/tests/domain/epc/test_glazing_mix_guard.py index 04e351cd3..2e839a5df 100644 --- a/tests/domain/epc/test_glazing_mix_guard.py +++ b/tests/domain/epc/test_glazing_mix_guard.py @@ -18,11 +18,31 @@ def test_a_genuine_percentage_mix_classifies_as_mixed() -> None: assert result is GlazingType.MIXED +@pytest.mark.parametrize( + "description", + [ + "4% double glazing 2002 or later, 96% single glazing", + "10% double glazing 2002 or later, 90% single glazing", # exactly at threshold + "5% double glazing pre-2002, 95% single glazing", + ], +) +def test_dominant_single_glazing_resolves_to_single(description: str) -> None: + # The bug: the LLM flattened a >= 90%-single dwelling onto its *minority* double + # type. Single glazing is era-free, so a dominant-single split is fully + # determined — the guard must claim it (SINGLE) rather than defer to the LLM. + + # Act + result = glazing_mix_guard(description) + + # Assert + assert result is GlazingType.SINGLE + + @pytest.mark.parametrize( "description", [ "100% double glazing 2002 or later", # uniform — one type - "96% double glazing 2002 or later, 4% single glazing", # >= 90% -> uniform + "96% double glazing 2002 or later, 4% single glazing", # >= 90% double -> era ambiguity "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 "", From deec22302a0dbf0293640c2864292eb4bc3ccf34 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Thu, 2 Jul 2026 08:07:13 +0000 Subject: [PATCH 2/2] Resolve dominant double/triple glazing when the era is stated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../property_overrides/glazing_mix_guard.py | 71 +++++++++++++------ ...zing.py => reclassify_dominant_glazing.py} | 22 +++--- tests/domain/epc/test_glazing_mix_guard.py | 34 +++++++-- 3 files changed, 93 insertions(+), 34 deletions(-) rename scripts/lisasrequest/{reclassify_dominant_single_glazing.py => reclassify_dominant_glazing.py} (82%) 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)