diff --git a/CONTEXT.md b/CONTEXT.md index 32ce5c3bc..e0b4a8493 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -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: A–D uninsulated, E–F 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**: diff --git a/docs/adr/0047-roof-eligibility-resolves-sentinel-thickness-by-age-band.md b/docs/adr/0047-roof-eligibility-resolves-sentinel-thickness-by-age-band.md new file mode 100644 index 000000000..d4c1721ce --- /dev/null +++ b/docs/adr/0047-roof-eligibility-resolves-sentinel-thickness-by-age-band.md @@ -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 **A–D**) | + +Age-band as-built states (construction age code): **A–D uninsulated · E–F +limited insulation · G+ insulated.** Only A–D 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 A–D 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 E–F 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.