Record ADR-0047 roof sentinel eligibility + lodgement-sentinel glossary entry

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-07-02 10:55:57 +00:00
parent c1169b2d39
commit 9e1a194b27
2 changed files with 113 additions and 0 deletions

View file

@ -105,6 +105,10 @@ _Avoid_: patches (deprecated), corrections, manual EPC, edits
Resolving a **Landlord Description** (unbounded free-text a landlord supplies for one component — "CWI" / "Cav filled" / "cavity insulated" all name one thing) onto a **Recognised Internal Description** via an LLM classifier, persisted in the `landlord_*_overrides` table (`source=classifier`) as a reviewed cache. Four vocabularies are kept **distinct** and must not be conflated: a **Landlord Description** (unbounded input); a **Recognised Internal Description** (the closed target taxonomy — e.g. a `MainHeatingSystemType` archetype — each binding to a Simulation Overlay); a **Lodged Description** (the gov-EPC `main_heating[].description` rendering, e.g. "Room heaters, electric" — only an example of which system *types* occur, never a map key); and the **SAP main heating code** (Table 4a/4b, what the calculator consumes). The classifier maps Landlord → Recognised Internal → SAP code. When it cannot confidently place the text it emits **`None`** (no overlay → the lodged EPC stands, surfaced to the user as "no suitable match"), **never the nearest wrong archetype** — the target taxonomy must be complete enough that a real system always has a correct home, so the classifier never overflows into a garbage-drawer archetype (ADR-0041).
_Avoid_: "the LLM mapper is unreliable" (the failure mode is a too-small target taxonomy, not LLM language ability); conflating the landlord input vocabulary with the gov-EPC lodged rendering or the RdSAP entry-tool catalogue; treating a deterministic dict as a *replacement* for the LLM rather than a reviewed cache of its output
**Lodgement Sentinels (ND / NI / AB)**:
The RdSAP string sentinels a cert may lodge in place of a numeric value (e.g. `roof_insulation_thickness`). They are data, not noise, and are **not interchangeable**: **`ND`** (Not Defined) — no data; resolve to the construction age band's **as-built** state (roof as-built: AD uninsulated, EF limited insulation, G+ insulated). **`NI`** — insulation **present** but thickness unknown (RdSAP 10 §5.11.4: score as 50 mm); never "no insulation". **`AB`** — As Built; resolve by age band like ND. The mapper deliberately passes roof sentinels through raw — the calculator's resolution depends on distinguishing them (ADR-0047). Scoring and eligibility may legitimately resolve the same sentinel differently (`NI`: 50 mm to the U-cascade, not-eligible to the roof generator).
_Avoid_: reading `ND` as "no insulation"; reading `NI` as "no insulation" (it means the opposite); normalising sentinels at the mapper boundary; comparing a sentinel string against an int (`"ND" != 0` is always True)
### Modelling
**Effective EPC**:

View file

@ -0,0 +1,109 @@
# Roof Eligibility Resolves Sentinel Thickness by Age Band (vaulted joins sloping; ND ≠ NI)
Property 742265 (uprn 10033526327, cert 2780-3922-3202-6042-9200) lodges a
`Pitched (vaulted ceiling)` roof (gov-API `roof_construction` 5) with
`roof_insulation_thickness = "ND"` and got **no roof Recommendation**, leaving
its plan short of the scenario's goal band on an unlimited budget. Two
generator gaps compound (ADR-0021's dispatch table has no vaulted row, and its
uninsulated trigger `!= 0` compares the `"ND"` *string* against int 0, so it
never fires), while the SAP10 Calculation already handles both: it treats
vaulted as a slope-following ceiling and resolves the `"ND"` sentinel via the
roof description / age-band defaults (`heat_transmission.py` — the
`is_sloping_ceiling` disjunction and the raw-sentinel inspection). The
generator diverged from the calculator — the exact split ADR-0021 set out to
prevent.
## Decision
**Amends ADR-0021.** The roof Recommendation Generator changes in two ways;
the mapper and the calculator change in none.
**1. Vaulted is a sloping ceiling.** The dispatch matches the same substring
disjunction the calculator uses (`"sloping ceiling" in roof_type or "vaulted"
in roof_type`): a vaulted ceiling has no loft void, so its one applicable
Measure is `sloping_ceiling_insulation` (rafters, 100 mm) — never the loft
fallback. ADR-0021's dispatch table gains the row:
| Roof type (`roof_construction_type` substring) | Measure | Uninsulated trigger | Overlay |
|---|---|---|---|
| `sloping ceiling` **or `vaulted`** | `sloping_ceiling_insulation` | resolved thickness 0 (below) | → 100 mm |
**2. Sentinel-aware uninsulated trigger, resolved by age band — codes, not
descriptions.** `roof_insulation_thickness` is `Optional[Union[str, int]]`; a
lodged string sentinel is data, not noise. The generator resolves eligibility
for the two pitched branches (sloping/vaulted and loft) as:
| Lodged value | Meaning (RdSAP 10) | Eligibility |
|---|---|---|
| int `0` | explicitly uninsulated | eligible (unchanged) |
| int `> 0` | measured thickness | not eligible (unchanged) |
| `"NI"` | insulation **present**, thickness unknown (§5.11.4: assume 50 mm) | **not eligible** |
| `"ND"` / `None` | not defined — resolve to the age band's **as-built** state | eligible iff as-built uninsulated (bands **AD**) |
Age-band as-built states (construction age code): **AD uninsulated · EF
limited insulation · G+ insulated.** Only AD resolves to eligible. This
extends the codebase's existing precedent (`_PRE_1950_AGE_CODES = {A,B,C,D}`
in the mapper's code-8 rule; pinned by cert 001479 Ext2 at U=2.30) from the
scoring path to eligibility.
**Coherence rule (load-bearing):** sentinel resolution must never make an
undefined cert *more* eligible than an explicitly-lodged one. An explicit
50 mm lodgement is not eligible today, so `"ND"` on a band-E/F ("limited
insulation") dwelling must also resolve to not-eligible — recommending a
top-up to the dwelling whose insulation we *don't* know while withholding it
from the identical dwelling whose thin insulation we *do* know would be
incoherent. Whether limited-insulation **top-up** should be a Measure at all
is a separate product question that applies equally to explicit thin
lodgements; it is out of scope here (see Consequences).
**The mapper stays untouched — the `"ND"` passthrough is load-bearing.** The
calculator inspects the *raw* lodgement to distinguish explicit `int(0)`
(drop the description; uninsulated) from `"NI"` (keep the description; §5.11.4
retrofit-50 mm) from `"ND"` (defer to description / age default). Normalising
sentinels at the mapper boundary would change scoring inputs for every ND-roof
cert in the corpus for zero eligibility benefit.
**Two homes for sentinel resolution, deliberately.** Scoring asks "what
thickness should the U-cascade see" (`NI` → 50 mm); eligibility asks "is this
roof a candidate" (`NI` → no). Same sentinel, different questions, different
answers — a single shared resolver would just relocate the split into its call
sites. Each home carries a regression test pinning the *disagreement* as
intentional. Consolidate only if a third consumer appears (rule of three).
## Considered options
- **Normalise `ND` → 0 in the mapper** (extend the code-8 rule to code 5 and
to the `"ND"` string). Rejected: changes calculator inputs corpus-wide —
`roof_thickness_explicitly_zero` would fire and drop the roof description
the §5.11 resolution depends on — risking the pinned Elmhurst accuracy
corpus to fix an eligibility bug.
- **Defer to `roofs[].description`** ("no insulation (assumed)") for ND
resolution, mirroring the calculator exactly. Rejected: eligibility should
key on codes, not free-text description parsing; descriptions drift by
lodgement software and the age-band code carries the same as-built signal
deterministically.
- **Gate eligibility on the calculator's effective roof U-value.** Rejected:
no other generator gates on scored output (walls/floors gate on lodged
attributes), and it re-couples eligibility to scoring against ADR-0016's
separation.
- **Treat `"ND"` alone as uninsulated** (sentinel-literal). Rejected: ND means
*not defined*, not *none* — a band-G ND roof is almost certainly insulated
as built and would get a junk Recommendation.
## Consequences
- 742265 (band B, vaulted, ND) becomes eligible: `sloping_ceiling_insulation`
over 55.82 m² (~£2.2k, verified by local re-model). Any pitched-loft cert
lodging `"ND"` with age band AD is likewise un-withheld.
- The **flat branch is deliberately untouched**: its inverted convention
(`None` = uninsulated, per the Elmhurst mapping) means an `"ND"` flat roof
is *not eligible* today and stays so — a separately-verifiable follow-up,
not silently folded into this change.
- **Open follow-up (product):** should limited-insulation dwellings (explicit
thin lodgements *and* ND + bands EF alike) be offered an insulation
top-up? Today neither is; changing that is a trigger redesign, not a
sentinel question.
- ADR-0021's "one dispatching generator, one Measure per roof" and its
dispatch order (sloping → flat → no-access → loft) are unchanged; the
sloping test simply matches one more substring, ahead of the fallbacks as
before.