Model/docs/adr/0016-package-rescore-over-warm-start-optimisation.md
Khalim Conn-Kowlessar af501fce0e feat(modelling): ventilation-aware selection — price the forced dependency in
The warm-start (and max-gain fallback) now price each forced Measure Dependency
the candidate triggers, not just inject it afterwards: optimise/optimise_min_cost
fold dependencies into each candidate's cost+gain via _augmented_cost_gain, and
optimise_package scores each dependency's true role-1 signal (_with_role1_signals)
instead of the 0.0 placeholder. This stops the min-cost objective (i) ignoring the
~£900 a wall drags in (a wall-free package reaching target can be cheaper) and
(ii) picking a small-gain wall whose mandatory ventilation (down to -5 SAP) makes
it net-negative, which repair cannot un-pick.

Budget is now a hard envelope: the constraint applies to the augmented (measure +
its ventilation) cost, so a wall that fits alone but whose ventilation would bust
the budget is DROPPED rather than forced over budget. This reverses the earlier
'forced regardless of budget' call (which made sense when selection was
ventilation-blind). Safety invariant intact — presence still injected on every
path; we just never recommend a wall we can't afford to ventilate. ADR-0016
amendment updated. 94 modelling+orchestration tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 16:16:26 +00:00

10 KiB
Raw Permalink Blame History

Package re-scoring over warm-start optimisation, not marginal cascade or full enumeration

Modelling scores each Measure Option once, independently against the baseline Effective EPC (deduplicated per distinct Simulation Overlay, so identical overlays are scored once). It runs a grouped-knapsack MILP over those per-Option scores to get a candidate package, injects any forced Measure Dependencies (e.g. ventilation) into that package, composes the selected + injected overlays into one throwaway EpcPropertyData, and re-scores the whole package on the deterministic SAP10 calculator for the truthful figure. If the true package SAP undershoots the Scenario goal, it greedy-adds the unselected Option with the best residual SAP-per-£ and re-scores, repeating until the target is met or the budget is exhausted.

The reason for the split is that SAP impact is sub-additive — summed independent per-Option scores overestimate the combined effect, so the MILP optimum is a signal, not the truth. Because the calculator is deterministic and fast (ADR-0009), accuracy is bought by re-scoring the chosen package, not by making the optimiser's per-measure inputs accurate. The optimiser only has to rank measures well enough to seed a near-right package; the calculator supplies the real number.

We rejected two alternatives:

  • Marginal cascade scores (the legacy approach): score measure N assuming measures 1..N-1 are present. These telescope to the true total only if every measure is selected; the optimiser dropping a middle measure invalidates every downstream marginal. It adds the cascade's complexity for an accuracy the package re-score already provides.
  • Full package enumeration / ML-scoring the cross-product (the path ADR-0005 §14 anticipated): combinatorial in #Recommendations × #Options. With realistic option counts (wall × roof × floor × heating-bundle × PV × …) the cross-product is intractable. The warm-start + re-score + repair loop reaches a truthful, near-optimal package without ever materialising it.

This resolves the open question deferred in ADR-0005 §14.

Consequences

  • Calculator calls per Property per Scenario(# distinct Simulation Overlays) for the per-Option pass + (a few package re-scores) in the repair loop — bounded, never the cross-product. The Option-dedup-by-Overlay invariant is what keeps the per-Option pass cheap.
  • A forced Measure Dependency must be injected into the package before the re-score, so its real SAP contribution — negative for ventilation — lands in the truthful figure and in the undershoot/repair decision. (The legacy bug was adding ventilation as a cost-only line after scoring, which silently overstated the package and undershot the real target.)
  • The optimiser is a clean grouped knapsack: pick ≤1 Option per Recommendation, groups disjoint, no cross-group mutual-exclusion constraints — the Recommendation partition (no two Recommendations write the same (building part, field)) makes selected overlays collision-free by construction.
  • Greedy repair can overspend relative to a global re-optimise. Accepted for bounded calculator calls and simplicity; re-solving the MILP with the corrected package score fed back as a constraint is the fallback if greedy proves too loose in practice.
  • Per-Option scores are approximate by design (independent-vs-baseline) and must never be persisted or surfaced as a measure's "true" impact — only the package re-score is truthful. Measure-level impact shown to users is derived from the final scored package, not from step A.
  • Three distinct scoring roles, each with one job: (1) per-Option independent-vs-baseline → optimiser input (approximate signal, never surfaced); (2) whole-package re-score → truthful package total; (3) final-package marginal cascade → per-measure attribution for display. Role 3 runs only on the selected set, applied in best-practice prescribed order (walls → roof → ventilation → … per the legacy Recommendations class), so attribution(mᵢ) = score(m₁..mᵢ) score(m₁..mᵢ₋₁); the marginals telescope exactly to the package total (role 2) with no residual. The "drop a middle measure" inaccuracy cannot occur because the actual final set is scored, not a hypothetical. The selected package is the cascade unit; ordering within it follows the best-practice sequence.
  • The package-scoring primitive is reusable. "Compose selected overlays → throwaway EpcPropertyData → calculator" serves both the optimiser's package re-score (role 2) and a future endpoint that re-scores a user-assembled plan live (the FE toggling Rolled-over Options on/off). Because the calculator is fast, live re-score is the accurate path the moment a user deviates from the optimiser's selection. Note the trap this avoids: summing stored per-measure figures across a user-edited selection re-introduces the sub-additivity overestimate — a user-edited plan must be re-scored as a package, never summed from stored attributions.

Amendment (2026-06-03): the optimiser objective is least-cost-to-target, not maximum gain

The original decision above got the warm-start objective wrong. It framed the grouped knapsack as maximise SAP gain subject to budget and the target as a floor the repair tops up to. The rebuild faithfully implemented that — and it is the wrong objective. The legacy StrategicOptimiser.solve() (recommendations/optimiser/StrategicOptimiser.py, Case 1) is the intended behaviour, and it is the opposite primary objective:

min cost subject to gain ≥ target and cost ≤ budget; only if that is infeasible, max gain subject to cost ≤ budget.

For an Increasing EPC goal the objective is therefore least-cost-to-target — the cheapest package that reaches the goal band. This is the common case (most users want "reach band C as cheaply as possible," not "spend the budget for maximum SAP").

  • No budget → cheapest package that reaches the target, no spend cap (legacy Case 3).
  • Budget, target reachable → cheapest package that reaches the target band; it stops at the target and does not overshoot into a higher band, leaving surplus budget unspent (the "don't overshoot" property falls out of cost-minimisation — you stop at the cheapest package in band C, so you never climb into B). The within-band headroom is not maximised — least cost wins, e.g. SAP 70 @ £2k is chosen over SAP 75 @ £3k.
  • Budget, target unreachable → fall back to maximum improvement within budget (best effort below target). "Unreachable" is judged on the true re-scored SAP after repair, not the signal.
  • Goals other than Increasing EPC set no target and stay max-gain-within-budget (a separate deferred front).

What is unchanged: the warm-start-on-signal → inject dependencies → re-score-for-truth → greedy-repair structure, the three scoring roles, and the dependency-injection rule all stand. We keep the signal-based warm-start (and re-score+repair) rather than exhaustively re-scoring every candidate package, for the same scalability reason the original rejected full enumeration — the cross-product is tiny at fabric-only scale today but explodes as heating/PV/windows land. Only the warm-start's selection rule changes (min-cost-to-target instead of max-gain), plus the two points below.

Target predicate. Reaching the target is sap_continuous ≥ band_floor (e.g. ≥ 69.0 for C) — the continuous band floor, the conservative choice (it sits ~0.5 SAP above the rounding threshold of 68.5, so the rounded SAP lands safely in band). The legacy allow_slack buffer is not carried over: it existed to hedge the MILP's approximate summed gains, a hedge our re-score + repair already provides. Combined with the "recommend slightly more than land short" preference, the conservative floor + repair-to-true-target reliably hit the band, often with a little headroom, while the recommended cost remains a safe over-estimate.

Ventilation-aware selection. Because a forced Measure Dependency (ventilation) carries a real cost (~£900) and a negative SAP (typically 1 to 3, occasionally 5), the warm-start must price the dependency it will trigger, not just inject it afterwards. So the dependency is folded into each candidate during selection (via the same _inject, with the ventilation Option carrying a real negative role-1 signal instead of a 0.0 placeholder) — otherwise the min-cost selection (i) ignores the £900 a wall drags in, so a wall-free package that reaches target can be cheaper than the "least-cost" pick, and (ii) at large negative ventilation can select a small-gain wall whose mandatory ventilation makes it net-negative, which repair cannot un-pick. Enforcement is now in two places: presence_inject on the final selected set on every path (warm-start, each repair step, max-gain fallback), guaranteeing ventilation whenever a trigger is present; awareness — the same _inject folded into candidate evaluation so the objective prices it. Presence was always guaranteed by ADR-0016; awareness is the new part.

The budget is a hard envelope — ventilation is not forced over it. This supersedes an earlier decision that a forced dependency was "injected regardless of budget." Now that selection prices the dependency, the budget constraint applies to the augmented (measure + its triggered ventilation) cost: a wall that fits the budget alone but whose mandatory ventilation would exceed it is dropped, not forced over budget. The safety invariant is untouched (we never recommend an insulated wall without ventilation) — the choice at the boundary is "do both and overspend" vs "do neither," and we do neither. A wall you can't afford to ventilate is a wall you can't afford; blowing the user's stated budget for a compliance measure is the worse surprise. The consequence: if a property's only route to the target is a wall it cannot afford to ventilate, the optimiser returns a below-target best-effort package (or nothing) rather than an over-budget one.

This supersedes the original framing of the warm-start objective (lines above describing "maximise gain … undershoots the goal") and the "re-solving the MILP" fallback note; the rest of ADR-0016 stands.