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
|
from domain.epc.property_overrides.glazing_type import GlazingType
|
||||||
|
|
||||||
# "N% <base glazing type>" — the era (2002 / pre-2002) is irrelevant to whether it
|
# "N% <base glazing type> [era]" — the base type decides whether it is a mix; the
|
||||||
# is a mix, so only the base type is captured.
|
# era ("2002 or later" / "pre-2002") is captured too, so a dominant double/triple
|
||||||
_PERCENT_TYPE = re.compile(r"(\d+)%\s*(single|double|triple|secondary)")
|
# 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
|
# 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.
|
# as that type (a near-uniform reglaze), not deferred.
|
||||||
_UNIFORM_THRESHOLD_PERCENT = 90
|
_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]:
|
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
|
must resolve to ``GlazingType.MIXED`` (no overlay → the cert is kept), never to
|
||||||
a single dominant type that would flatten every window (ADR-0042).
|
a single dominant type that would flatten every window (ADR-0042).
|
||||||
|
|
||||||
Recognises the structured ``"N% <type>, M% <type>"`` split and returns
|
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-
|
* ``MIXED`` for a genuine mix — two or more glazing types present and none
|
||||||
uniform assertion or an unparseable description, so the LLM classifier still
|
dominant (≥ the uniform threshold);
|
||||||
resolves it (a uniform reglaze is applied; varied phrasings are the LLM's job).
|
* 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())
|
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:
|
if len(base_types) < 2:
|
||||||
# One (or no) glazing type named — a uniform assertion, not a mix.
|
# One (or no) glazing type named — a uniform assertion, not a mix.
|
||||||
return None
|
return None
|
||||||
dominant_percent, dominant_type = max(
|
dominant_percent, dominant_type, dominant_era = max(
|
||||||
((int(percent), base_type) for percent, base_type in matches),
|
((int(percent), base_type, era) for percent, base_type, era in matches),
|
||||||
key=lambda pair: pair[0],
|
key=lambda triple: triple[0],
|
||||||
)
|
)
|
||||||
if dominant_percent >= _UNIFORM_THRESHOLD_PERCENT:
|
if dominant_percent >= _UNIFORM_THRESHOLD_PERCENT:
|
||||||
# One type dominates — a near-uniform assertion, not a mix. Single glazing
|
# One type dominates — a near-uniform assertion, not a mix. A split is fully
|
||||||
# is era-free, so a dominant-single split is fully determined: apply SINGLE
|
# determined (and safe to apply deterministically) when the dominant type
|
||||||
# deterministically (the LLM otherwise flattens it onto the minority double
|
# carries no era ambiguity: single glazing is era-free, and a dominant
|
||||||
# type). A dominant double/triple still carries era ambiguity (pre-2002 /
|
# double/triple whose era is stated ("pre-2002" / "2002 or later") is
|
||||||
# 2002 / unknown age), so it stays the LLM's job.
|
# equally pinned. In those cases claim the member ourselves — the LLM
|
||||||
if dominant_type == "single":
|
# otherwise flattens the split onto the *minority* type. A dominant
|
||||||
return GlazingType.SINGLE
|
# double/triple with an unstated or non-canonical era ("unknown age") still
|
||||||
return None
|
# 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
|
return GlazingType.MIXED
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,15 @@
|
||||||
"""Backfill glazing overrides where a >= 90%-single dwelling was flattened onto its
|
"""Backfill glazing overrides where a dominant (>= 90%) split was flattened onto
|
||||||
*minority* double type.
|
its *minority* type.
|
||||||
|
|
||||||
Sibling to ``scripts/reclassify_mixed_glazing.py``. That script rescued genuine
|
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
|
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
|
LLM classifier. But for a dominant split the LLM sometimes latched onto the
|
||||||
later, 96% Single Glazing") the LLM had latched onto the 4% minority and written a
|
minority type — a dominant-*single* dwelling ("4% Double glazing 2002 or later,
|
||||||
"Double glazing…" value. ``glazing_mix_guard`` now resolves a dominant-single
|
96% Single Glazing") written as "Double glazing…", or the symmetric dominant-double
|
||||||
split deterministically to ``SINGLE`` (single glazing is era-free), so this
|
case written as "Single glazing". ``glazing_mix_guard`` now resolves any
|
||||||
backfill fixes the rows written before that guard change.
|
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
|
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
|
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
|
Idempotent — only rows whose stored value differs from the guard's target member
|
||||||
are touched.
|
are touched.
|
||||||
|
|
||||||
python -m scripts.lisasrequest.reclassify_dominant_single_glazing # dry run
|
python -m scripts.lisasrequest.reclassify_dominant_glazing # dry run
|
||||||
python -m scripts.lisasrequest.reclassify_dominant_single_glazing --apply # write
|
python -m scripts.lisasrequest.reclassify_dominant_glazing --apply # write
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -107,7 +109,7 @@ def main() -> int:
|
||||||
|
|
||||||
if args.apply and audit:
|
if args.apply and audit:
|
||||||
out = _REPO_ROOT / "scripts" / "lisasrequest" / (
|
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:
|
with out.open("w", newline="") as fh:
|
||||||
w = csv.writer(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
|
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(
|
@pytest.mark.parametrize(
|
||||||
"description",
|
"description",
|
||||||
[
|
[
|
||||||
"100% double glazing 2002 or later", # uniform — one type
|
"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
|
"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
|
"some double glazing and a bit of single", # unparseable — no percentages
|
||||||
"",
|
"",
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_uniform_or_unparseable_glazing_defers_to_the_llm(description: str) -> None:
|
def test_uniform_or_ambiguous_glazing_defers_to_the_llm(description: str) -> None:
|
||||||
# The guard only claims a genuine percentage mix; a uniform assertion (applied
|
# The guard only claims a genuine mix or a fully-determined dominant split. A
|
||||||
# as that type) or a varied phrasing is left for the LLM classifier.
|
# 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
|
# Act
|
||||||
result = glazing_mix_guard(description)
|
result = glazing_mix_guard(description)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue