diff --git a/docs/adr/0061-fabric-first-is-a-two-phase-optimisation.md b/docs/adr/0061-fabric-first-is-a-two-phase-optimisation.md new file mode 100644 index 000000000..31f1794c4 --- /dev/null +++ b/docs/adr/0061-fabric-first-is-a-two-phase-optimisation.md @@ -0,0 +1,73 @@ +--- +status: accepted (extends ADR-0016) +--- + +# Fabric First is a two-phase optimisation with strict envelope priority + +Landlords with a fabric-first retrofit policy require the building envelope — +insulation and windows — to be treated before heating systems and renewables +are considered. The legacy engine carried this as `enforce_fabric_first` on +the plan-API request body (`funding_optimiser.optimise_with_scenarios`): an +optimiser pass over fabric-only measures with the full budget, then a second +pass over the remainder with the leftover budget and the residual target, +summing approximate per-measure SAP points. The new engine needed the same +capability on the truthful-re-score Optimiser core (ADR-0016). + +Decided in a grilling session with Khalim, 2026-07-09. + +## Decision + +**`fabric_first` is a Scenario attribute, and the Optimiser honours it as two +sequential `optimise_package` phases in which the envelope has strict first +claim on the budget** (`optimise_package_fabric_first`, ADR-0016 core reused +per phase). + +- **The flag lives on the `scenario` table** (FE-owned Drizzle schema: + `fabric_first boolean NOT NULL DEFAULT false`), mirrored in `ScenarioModel` + and the domain `Scenario`. A Plan's provenance stays derivable from its + Scenario row alone — unlike the legacy request-body flag, the same + scenario_id cannot produce differently-constrained plans. **Deploy order**: + the Drizzle migration must land before this mirror, or every scenario read + crashes on the missing column. +- **Fabric = the building envelope** (`FABRIC_MEASURE_TYPES`): wall (CWI / + EWI / IWI), roof (loft / sloping-ceiling / flat-roof), floor (suspended / + solid) insulation, plus double / secondary glazing — the legacy list + exactly. Lighting, tune-ups, secondary-heating removal, heating and solar + all wait for phase 2. Mechanical ventilation is unclassified: it is never + selected, only injected as a forced Measure Dependency (ADR-0016) of the + fabric that triggers it, in whichever phase that happens — and only once. +- **Phase 1** runs `optimise_package` over the fabric groups with the full + budget: least-cost-to-target, repair, max-gain fallback. If the truthful + post-fabric score meets the Scenario target, the package is fabric-only — + surplus budget is left unspent, per the ADR-0016 no-overshoot doctrine. +- **Phase 2** (target unmet, or no target) optimises every group phase 1 did + not consume — non-fabric, plus fabric groups it left unpicked, which may + re-enter on their post-fabric worth — under the leftover budget. Candidates + are valued **against the fabric-applied dwelling**: warm-start signals are + re-scored and every package re-score is prefixed with the phase-1 overlays + (`_PrefixedScorer`), so a heating system whose worth changes once the + envelope is treated is re-ranked truthfully, and the returned Score remains + the whole-package figure against the true baseline (bills and the role-3 + cascade stay honest). +- **Strict priority, not target-aware compromise**: phase 1 commits the + max-gain fabric package even when a cheaper fabric/heating split would have + reached the target — a £4,000 budget buys floor insulation and leaves the + £3,200 boiler unaffordable, and the target is missed rather than the fabric + skipped. This is the landlord's explicit trade. +- **Every goal honours the flag**, not just Increasing EPC: with no SAP + target the two phases are both max-gain, so fabric still gets first claim + on the budget. +- **No Plan-contract change**: the Scenario row is the provenance; the + best-practice cascade already orders fabric before heating in the persisted + measures. + +## Consequences + +- The Modelling orchestrator branches once, on `scenario.fabric_first`, + between `optimise_package` and `optimise_package_fabric_first`; everything + downstream (attribution, bills, persistence) is unchanged. +- Phase 2 re-scores each remaining Option once against the post-fabric + dwelling — a handful of extra calculator calls per fabric-first Plan. +- A fabric-first Plan can undershoot a target a plain Plan would have reached + within the same budget. This is by design and should be communicated when + scenario results are compared.