JsonCachingEpcClient caches at the raw layer (cert payloads verbatim,
search rows as JSON) so warm re-runs replay through retry+mapper+parsing
in minutes AND the cache doubles as integration-test fixture material —
the ADR-0030 corpus pattern (anonymised raw JSON through the mapper),
which pickled domain objects could never be. Wired into the pairs
harness (--cache-dir). roof_construction_code_sweep pins the RdSAP roof
code table the basement-code-6 way: single-building-part certs only,
code x description-prefix purity table. Also fixes the tfa telemetry key
rename that left the agreement row empty.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Age band equality -> ±1-band neighbourhood; TFA ±5% -> ±20%; whitelist
core validated (fuel 95% / type 91% / wall 79% stable); roof-insulation
collateral flagged for follow-up. CONTEXT.md term updated to match.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Evidence (439-pair harness, PR #1466): historic-vs-new age band agrees
52% exactly but 90% within one band (assessors re-band, skewing newer);
TFA agrees 45% within ±5% but 82% within ±20%. Equality/±5% steered the
cohort toward stale values where they engaged and relaxed everywhere
else. Band definitions are public so the harness's ladder replay shares
one source of truth.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Strip the pre-RdSAP-17 deprecation rider and alias the SAP-prefixed
forms before the code-table lookup. Raises the historic fuel resolution
rate from ~22% of pairs to ~92% of dump rows.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The 2000-postcode sweep died at ~884 on a strict-raising RdSAP-17.1 cert
(missing multiple_glazed_proportion) inside the pair-check, losing the
whole run's output. get_by_uprn and the cohort fetch now skip-and-log per
item; telemetry rows append as produced and carry raw values (age band,
TFA, fuel text) so band-width questions are answerable post hoc.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Per pair, replay the age->fuel->TFA conditioning ladder over the plain
arm's cohort to record ENGAGED vs RELAXED per filter, and record whether
each historic stable attribute still agrees with the newly lodged cert
(the direct staleness measurement). Emitted as JSONL (--telemetry) and
aggregated into two diagnosis tables appended to the report.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Classification hit-rates for every compare_prediction component, all five
numeric residuals, and the secondary calculator-floored SAP residual
(calc(predicted) − lodged), plain vs conditioned side by side — the same
metric shape as the prediction-corpus gate (ADR-0030). Pair-check now
precedes the cohort fetch so a national postcode sweep only pays the
expensive search-by-postcode for shards that actually hold a pair.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Finds pre-2012 (S3 backup) x SAP-10.2 (new API) cert pairs per postcode,
predicts each from its leave-one-out cohort with and without ADR-0054
conditioning, and reports compare_prediction hit rates side by side.
Evidence for the stable-attribute whitelist, not a CI gate.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Off by default like the other two prediction collaborators; the pipeline
turns on Expired-Enhanced Prediction by passing a resolver over the
historic S3 backup.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Ingestion's lookup chain becomes: new EPC API -> historic backup by exact
UPRN -> plain prediction. A found record's stable attributes fill the
gaps Landlord Overrides left (overrides always win) and enrich the target
with age band / fuel / TFA band; the persisted predicted-slot row carries
source="expired". Reader unwired or shard miss -> behaviour unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
EpcSource widens to "expired" (ADR-0054; the column is TEXT — no
migration). "predicted"/"expired" form one slot family: _slot_sources
routes every slot read and slot-clearing delete through the family, so a
re-ingestion flipping the flavour replaces the row instead of stranding
its sibling. FakeEpcRepo mirrors the family.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Age band (Table S1 letter), main fuel code, and a ±5% floor-area band —
the first numeric-tolerance filter — each ride _maybe_filter, so an
unresolved attribute (None) is inactive and a starving filter relaxes.
Existing callers pass no new fields and are behaviourally untouched.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Pins behaviour the exact-match slice already carried (max by
lodgement_date; ISO dates sort lexicographically).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Settled in a grilling session with Khalim: the pre-2012 backup feeds EPC
Prediction landlord-override-style (stable attrs as relax-ladder filters,
TFA as a ±5% band, exact-UPRN lookup, source="expired"), never trusted as
current state; scoped to the historic source; validated by a pairs harness.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Review ask (dancafc): resolver/repository locals now carry explicit types
(matches: list[ScoredHistoricEpc], records: list[HistoricEpc], df:
pd.DataFrame, ...) so the flow reads without chasing callee signatures.
CLAUDE.md's Type Safety section gains the rule so future sessions enforce it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The row→domain mapper now names all 93 constructor arguments explicitly
instead of splatting a lowercased dict, takes a plain Mapping (a
DataFrame.to_dict("records") row) instead of a pandas Series, and ignores
columns the domain type doesn't know. A missing/renamed CSV column fails
loudly as a KeyError at the row. Both iterrows() call sites move to
to_dict("records") — pandas-stubs types iterrows' Series unparameterized,
which strict mode rejects. pandas-stubs + boto3-stubs[s3] make the stack
check clean: pyright strict is now 0 errors across the PR's files.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
421 slab -> Other storage heaters (0.00); 422/423 integrated -> 0.20;
424/425 screed/timber -> 0.90/0.50 — per the Grid 1 row labels that name
the codes (PDF p.184). Closes the None all-low-rate fallback that
over-credited 7-hour-metered underfloor dwellings.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
RdSAP 10 Specification (10-06-2025) §5.2.5: "If a dwelling or part of a
dwelling has commercial premises above record as another dwelling
above" — so code 9 ("another premises above") must get the same
zero-heat-loss treatment as code 7 ("another dwelling above"), per the
spec's own "there is no heat loss through the roof of a building part
that has the same dwelling above or another dwelling above."
The previous fix mapped both to None, which only avoided the mapper
crash — it didn't achieve zero heat loss. heat_transmission.py's
per-part suppression (part_roof_is_party) only fires when
roof_construction_type CONTAINS the literal string "another dwelling
above"; that string was only ever set on the Elmhurst ingestion path.
The API path mapped 7 (and, until now, 9) to None, so the suppression
never fired for any API-derived cert — has_exposed_roof (the
dwelling_type-label-only fallback) was the sole gate, with no way to
know about a per-part party-ceiling lodgement. Both codes now map to
"(another dwelling above)" so the existing suppression logic fires on
both ingestion paths, matching what
domain/epc/property_overrides/roof_party_ceiling_guard.py already does
independently for the landlord-override pipeline (same spec citation).
Verified against the motivating cert (uprn 10013320122): roof_w_per_k
now correctly computes to 0.0. Full RdSAP-21.0.1 corpus (1000 certs)
re-run: within-0.5 unchanged at 77.8%, MAE improves marginally
(0.6364 vs 0.637 ceiling) — no regression.