mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
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:
parent
646f66ff89
commit
deec22302a
3 changed files with 93 additions and 34 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue