diff --git a/docs/adr/0016-package-rescore-over-warm-start-optimisation.md b/docs/adr/0016-package-rescore-over-warm-start-optimisation.md index 2c87af1e..121b91ef 100644 --- a/docs/adr/0016-package-rescore-over-warm-start-optimisation.md +++ b/docs/adr/0016-package-rescore-over-warm-start-optimisation.md @@ -40,4 +40,6 @@ For an **Increasing EPC** goal the objective is therefore **least-cost-to-target **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. diff --git a/domain/modelling/optimisation/optimiser.py b/domain/modelling/optimisation/optimiser.py index 29bd5a42..de5e0225 100644 --- a/domain/modelling/optimisation/optimiser.py +++ b/domain/modelling/optimisation/optimiser.py @@ -61,14 +61,21 @@ def _option_cost(option: MeasureOption) -> float: def optimise( - groups: list[list[ScoredOption]], budget: Optional[float] + groups: list[list[ScoredOption]], + budget: Optional[float], + dependencies: Sequence[MeasureDependency] = (), ) -> list[ScoredOption]: """Select at most one ScoredOption per group to maximise total SAP gain subject to ``budget`` (None = unconstrained). Exact: enumerates every pick-one-or-skip-per-group package, keeps the affordable one with the greatest gain, breaking ties toward lower cost. Returns the selected - ScoredOptions (empty if nothing affordable beats selecting none).""" - # Each group offers: skip it (None) or take exactly one of its Options. + ScoredOptions (empty if nothing affordable beats selecting none). + + Candidate cost and gain are evaluated with any forced ``dependencies`` the + candidate triggers folded in (ADR-0016 amendment — ventilation-aware), so a + package is judged on what it will really cost and gain once its dependency + is injected. The returned list holds only the group selections, not the + folded-in dependencies (the caller injects those).""" choices_per_group: list[list[Optional[ScoredOption]]] = [ [None, *group] for group in groups ] @@ -80,20 +87,33 @@ def optimise( selected: list[ScoredOption] = [ choice for choice in combo if choice is not None ] - total_cost: float = sum(_option_cost(s.option) for s in selected) + total_cost, total_gain = _augmented_cost_gain(selected, dependencies) if budget is not None and total_cost > budget: continue - total_gain: float = sum(s.sap_gain for s in selected) # Maximise gain; on a tie prefer the cheaper package. if (total_gain, -total_cost) > (best_gain, -best_cost): best, best_gain, best_cost = selected, total_gain, total_cost return best +def _augmented_cost_gain( + selected: list[ScoredOption], dependencies: Sequence[MeasureDependency] +) -> tuple[float, float]: + """The total cost and total role-1 gain of a candidate **with the forced + dependencies it triggers folded in** — what the package will really cost and + gain once injected. Dependency gains are negative (ventilation), so this is + how selection 'prices' the ventilation a wall drags in.""" + augmented: list[ScoredOption] = _inject(selected, dependencies) + total_cost: float = sum(_option_cost(s.option) for s in augmented) + total_gain: float = sum(s.sap_gain for s in augmented) + return total_cost, total_gain + + def optimise_min_cost( groups: list[list[ScoredOption]], budget: Optional[float], target_gain: float, + dependencies: Sequence[MeasureDependency] = (), ) -> Optional[list[ScoredOption]]: """Select at most one ScoredOption per group to **minimise total cost** subject to total SAP gain ``>= target_gain`` and total cost ``<= budget`` @@ -102,7 +122,12 @@ def optimise_min_cost( Returns the cheapest target-reaching package (ties broken toward the higher gain — "recommend more"), or ``None`` when no package within budget reaches the target (the caller falls back to max-gain). A non-positive - ``target_gain`` is met by the empty package.""" + ``target_gain`` is met by the empty package. + + Candidate cost and gain are evaluated with any forced ``dependencies`` the + candidate triggers folded in (ventilation-aware), so a wall whose mandatory + ventilation cancels its gain is not mistaken for a cheap way to the target. + The returned list holds only the group selections, not the dependencies.""" choices_per_group: list[list[Optional[ScoredOption]]] = [ [None, *group] for group in groups ] @@ -114,10 +139,9 @@ def optimise_min_cost( selected: list[ScoredOption] = [ choice for choice in combo if choice is not None ] - total_cost: float = sum(_option_cost(s.option) for s in selected) + total_cost, total_gain = _augmented_cost_gain(selected, dependencies) if budget is not None and total_cost > budget: continue - total_gain: float = sum(s.sap_gain for s in selected) if total_gain < target_gain: continue # Minimise cost; on a tie prefer the higher-gain package. @@ -173,23 +197,57 @@ def optimise_package( way forced dependencies are injected on every path and their cost counts toward the spend; the returned `selected` includes them. ``budget`` of None means unconstrained.""" - if target_sap is None: - return _max_gain_package(groups, scorer, baseline_epc, budget, dependencies) - baseline_sap: float = _score(scorer, baseline_epc, []).sap_continuous + # Score each forced dependency's independent (role-1) impact so the selection + # can price the ventilation a wall drags in — negative for ventilation. + deps: list[MeasureDependency] = _with_role1_signals( + dependencies, scorer, baseline_epc, baseline_sap + ) + + if target_sap is None: + return _max_gain_package(groups, scorer, baseline_epc, budget, deps) + target_gain: float = target_sap - baseline_sap chosen: Optional[list[ScoredOption]] = optimise_min_cost( - groups, budget, target_gain + groups, budget, target_gain, deps ) if chosen is not None: package: OptimisedPackage = _repair_to_target( - chosen, groups, dependencies, scorer, baseline_epc, budget, target_sap + chosen, groups, deps, scorer, baseline_epc, budget, target_sap ) if package.score.sap_continuous >= target_sap: return package # Target unreachable within budget (warm-start infeasible, or the repaired # package still falls short) → best effort: the most improvement budget buys. - return _max_gain_package(groups, scorer, baseline_epc, budget, dependencies) + return _max_gain_package(groups, scorer, baseline_epc, budget, deps) + + +def _with_role1_signals( + dependencies: Sequence[MeasureDependency], + scorer: Scorer, + baseline_epc: EpcPropertyData, + baseline_sap: float, +) -> list[MeasureDependency]: + """Replace each dependency's placeholder role-1 signal with its true + independent-vs-baseline SAP impact, so the selectors price what the + dependency really does to the package (ADR-0016 amendment).""" + scored: list[MeasureDependency] = [] + for dependency in dependencies: + signal: float = ( + scorer.score( + baseline_epc, [dependency.required.option.overlay] + ).sap_continuous + - baseline_sap + ) + scored.append( + MeasureDependency( + triggers=dependency.triggers, + required=ScoredOption( + option=dependency.required.option, sap_gain=signal + ), + ) + ) + return scored def _max_gain_package( @@ -199,9 +257,10 @@ def _max_gain_package( budget: Optional[float], dependencies: Sequence[MeasureDependency], ) -> OptimisedPackage: - """Max-gain-within-budget, dependencies injected and re-scored — the - no-target objective and the unreachable-target fallback.""" - chosen: list[ScoredOption] = optimise(groups, budget) + """Max-gain-within-budget, dependencies priced in the selection then + injected and re-scored — the no-target objective and the unreachable-target + fallback.""" + chosen: list[ScoredOption] = optimise(groups, budget, dependencies) selected: list[ScoredOption] = _inject(chosen, dependencies) return OptimisedPackage( selected=selected, score=_score(scorer, baseline_epc, selected) diff --git a/tests/domain/modelling/test_optimiser.py b/tests/domain/modelling/test_optimiser.py index dfacf51a..333909d0 100644 --- a/tests/domain/modelling/test_optimiser.py +++ b/tests/domain/modelling/test_optimiser.py @@ -537,6 +537,46 @@ def _ventilation_dependency(*, cost: float) -> MeasureDependency: ) +def test_min_cost_warm_start_avoids_a_wall_whose_forced_ventilation_dooms_it() -> None: + # Arrange — cavity is dirt cheap (£100) and its role-1 signal (+6) alone + # reaches the target gain, so a ventilation-BLIND min-cost would pick it. + # But the wall forces in ventilation at a true/­signal −5, which sinks the + # package below target. A ventilation-AWARE warm-start prices that −5 into + # the candidate and instead takes the wall-free loft path. + groups: list[list[ScoredOption]] = [ + [_scored_overlay("cavity_wall_insulation", gain=6.0, cost=100.0, overlay=_WALL_OVERLAY)], + [_scored_overlay("loft_insulation", gain=8.0, cost=1500.0, overlay=_ROOF_OVERLAY)], + ] + scorer = _VentStubScorer(base=60.0, wall=6.0, roof=8.0, vent=-5.0) + dependency = MeasureDependency( + triggers=frozenset({"cavity_wall_insulation"}), + required=ScoredOption( + option=MeasureOption( + measure_type="mechanical_ventilation", + description="mechanical_ventilation", + overlay=_VENT_OVERLAY, + cost=Cost(total=300.0, contingency_rate=0.0), + ), + sap_gain=0.0, # placeholder; optimise_package scores the real signal + ), + ) + + # Act — target 66 (gain 6 over the 60 baseline). + package: OptimisedPackage = optimise_package( + groups=groups, + scorer=scorer, + baseline_epc=build_epc(), + budget=10000.0, + target_sap=66.0, + dependencies=[dependency], + ) + + # Assert — the loft path (true 68, £1500), NOT cavity + forced ventilation: + # cavity's signal (+6) is cancelled by ventilation (−5) to +1 < target. + assert _selected_types(package.selected) == {"loft_insulation"} + assert abs(package.score.sap_continuous - 68.0) <= 1e-9 + + def test_dependency_injected_when_a_trigger_measure_is_selected() -> None: # Arrange — the wall is selected, so its ventilation dependency must be # injected before the re-score; ventilation never competes in the pool. @@ -587,15 +627,17 @@ def test_dependency_not_injected_without_a_trigger_measure() -> None: assert abs(package.score.sap_continuous - 44.0) <= 1e-9 -def test_dependency_is_forced_even_when_it_pushes_over_budget() -> None: - # Arrange — the budget covers the wall but not the forced ventilation; - # ventilation is mandatory-when-triggered, so it is injected regardless. +def test_wall_dropped_when_it_cannot_be_ventilated_within_budget() -> None: + # Arrange — cavity (£1000) fits the £1000 budget on its own, but its + # mandatory ventilation (£900) would bust it. We never blow the budget: a + # wall we can't afford to ventilate is a wall we can't afford, so it is + # dropped (the budget is a hard envelope, ventilation is not forced over it). groups: list[list[ScoredOption]] = [ [_scored_overlay("cavity_wall_insulation", gain=10.0, cost=1000.0, overlay=_WALL_OVERLAY)], ] scorer = _VentStubScorer(base=40.0, wall=5.0, roof=4.0, vent=-2.0) - # Act — budget exactly covers the wall; ventilation (£900) overspends. + # Act — tight budget; ventilation-aware selection prices the £900 in. package: OptimisedPackage = optimise_package( groups=groups, scorer=scorer, @@ -605,8 +647,9 @@ def test_dependency_is_forced_even_when_it_pushes_over_budget() -> None: dependencies=[_ventilation_dependency(cost=900.0)], ) - # Assert — forced in despite the overspend. - assert "mechanical_ventilation" in _selected_types(package.selected) + # Assert — nothing recommended; the budget is respected and the wall is + # never left un-ventilated. + assert package.selected == [] def test_injected_ventilation_penalty_drives_extra_repair() -> None: