docs(modelling): handover — optimiser objective realigned to least-cost-to-target

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-03 16:21:31 +00:00
parent 641c1bd7f6
commit 660dc54246

View file

@ -1,6 +1,6 @@
# HANDOVER — Modelling stage rebuild
**Branch:** `feature/bill-derivation` (worktree `/workspaces/home/hestia-worktrees/model-assemble-new-backend`). **HEAD:** `02afc04c`.
**Branch:** `feature/bill-derivation` (worktree `/workspaces/home/hestia-worktrees/model-assemble-new-backend`). **HEAD:** `641c1bd7`.
**PRD:** GitHub `Hestia-Homes/Model#1152`, sliced into #1153#1161. **All slices #1153#1161 closed.**
## Issue status
@ -79,6 +79,17 @@ Forks resolved with the user (AskUserQuestion): **guard now** (skip when already
Gotchas for the next agent: the ventilation Product/contingency must exist for any not-yet-ventilated dwelling (the generator fetches the Product at build time, not inject-time); the stub scorer in `test_optimiser.py` indexes `building_parts[MAIN]`, so vent-only overlays need the separate `_VentStubScorer`.
## Optimiser objective realigned to least-cost-to-target (`5620f49f``641c1bd7`)
A `/grill-with-docs` pass found the rebuild had the **wrong optimiser objective**: it maximised SAP gain within budget (target as a repair floor), whereas the legacy `StrategicOptimiser.solve()` Case 1 (the intended behaviour) is **min-cost subject to gain ≥ target and cost ≤ budget, fall back to max-gain only if the target is unreachable**. ADR-0016 was amended (it had specified the wrong objective). 4 slices, all green:
- **`05a4f5f8`** — `optimise_min_cost(groups, budget, target_gain, dependencies=())`: exact-enumeration sibling to `optimise`; cheapest package reaching `target_gain` within budget (ties → higher gain), `None` if unreachable.
- **`2bf42d04`** — `optimise_package` rewired: target present → min-cost warm-start → inject → re-score → repair toward target; if warm-start infeasible or repaired package still short on the true score → `_max_gain_package` fallback. No target → max-gain (unchanged). Stops at the target, no overshoot into a higher band, surplus budget unspent.
- **`af501fce`** — **ventilation-aware selection**: `_with_role1_signals` scores each dependency's true (negative) role-1 impact (was a `0.0` placeholder); `_augmented_cost_gain` folds the triggered dependency into every candidate's cost+gain in both selectors. Stops min-cost picking a wall whose mandatory ventilation (1 to 5 SAP) it can't justify, or whose £900 a wall-free package would avoid.
- **`641c1bd7`** — orchestrator needed **no change** (already threads budget/target/deps); added an end-to-end pin (band-D property + goal D = already met → Plan with no measures).
Decisions locked (in the ADR amendment): target predicate `sap_continuous ≥ band_floor` (e.g. ≥ 69 for C — conservative, no legacy `allow_slack`); **budget is a hard envelope** — a wall whose ventilation would bust the budget is **dropped, not forced over** (reverses the earlier "forced regardless of budget" call; presence still guaranteed for any *selected* wall); warm-start-on-signal + re-score + repair kept (not exhaustive re-score) for scalability; "recommend slightly more than land short" is satisfied by the conservative floor + repair, not by spending budget for headroom.
## What's left
**Deferred fronts** (open, post-#1161): exclusion-filtering of the candidate pool (deferred from #1160); a **Bill-Derivation slice** that re-runs bills on the post-package EPC to fill the deferred energy/bill columns (`plan.post_energy_consumption`/`post_energy_bill`, `recommendation.kwh_savings`/`energy_cost_savings`); persist **unselected alternatives** (`default=False` rows linked via `plan_id`) for the swap-in UX — open ADR-0016 question: what impact figure they carry; promote `ProductRepository` to the DB+file composite; non-EPC goal objectives (Energy Savings, Reducing CO2) in the optimiser. Possible extension of the ventilation trigger set to roof insulation (now a one-line data edit in `MEASURES_NEEDING_VENTILATION`); and making the dependency builder lazy (thunk) so the Product is only fetched when a trigger is actually selected.