feat(modelling): optimise_min_cost — least-cost-to-target selector (#1152 follow-up)

Exact-enumeration sibling to optimise(): pick <=1 option per group to minimise
total cost subject to total gain >= target_gain and cost <= budget (None =
unconstrained). Ties broken toward higher gain ('recommend more'). Returns None
when no package within budget reaches the target (caller falls back to
max-gain); a non-positive target is met by the empty package. This is the
warm-start objective for an Increasing EPC goal per the ADR-0016 amendment
(least-cost-to-target, not max-gain). Dependency-blind for now; ventilation-aware
selection lands in a later slice.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-03 15:31:26 +00:00
parent 5620f49f18
commit 05a4f5f84a
2 changed files with 156 additions and 0 deletions

View file

@ -90,6 +90,42 @@ def optimise(
return best
def optimise_min_cost(
groups: list[list[ScoredOption]],
budget: Optional[float],
target_gain: float,
) -> 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``
(None = unconstrained) the least-cost-to-target objective (ADR-0016
amendment). Exact enumeration over every pick-one-or-skip-per-group package.
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."""
choices_per_group: list[list[Optional[ScoredOption]]] = [
[None, *group] for group in groups
]
best: Optional[list[ScoredOption]] = None
best_cost: float = 0.0
best_gain: float = 0.0
for combo in itertools.product(*choices_per_group):
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)
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.
if best is None or (-total_cost, total_gain) > (-best_cost, best_gain):
best, best_cost, best_gain = selected, total_cost, total_gain
return best
class Scorer(Protocol):
"""The whole-package scoring primitive — `PackageScorer` satisfies it.
Kept structural so the repair loop is testable with a stub scorer."""

View file

@ -19,6 +19,7 @@ from domain.modelling.optimisation.optimiser import (
OptimisedPackage,
ScoredOption,
optimise,
optimise_min_cost,
optimise_package,
)
from domain.modelling.scoring.package_scorer import Score
@ -203,6 +204,125 @@ def test_within_budget_partial_selection_prefers_the_higher_gain_option() -> Non
assert _selected_types(selection) == {"loft_insulation"}
# --- optimise_min_cost: least-cost-to-target selection (ADR-0016 amendment) ---
def test_min_cost_picks_the_cheapest_package_that_reaches_the_target() -> None:
# Arrange — two packages both clear the target gain; one is cheaper.
groups: list[list[ScoredOption]] = [
[
_scored("loft_insulation", gain=10.0, cost=2000.0),
_scored("external_wall_insulation", gain=15.0, cost=3000.0),
],
]
# Act
selection = optimise_min_cost(groups, budget=10000.0, target_gain=10.0)
# Assert — least-cost-to-target takes the +10 @ £2000, NOT the higher-gain
# +15 @ £3000 (no overshoot, surplus budget unspent).
assert selection is not None
assert _selected_types(selection) == {"loft_insulation"}
def test_min_cost_combines_groups_to_reach_the_target_at_least_cost() -> None:
# Arrange — no single option reaches +10; the cheapest combo that does is
# cavity (+6, £1000) + loft (+4, £1500) = +10 @ £2500, beating EWI (+10,
# £8000).
groups: list[list[ScoredOption]] = [
[
_scored("cavity_wall_insulation", gain=6.0, cost=1000.0),
_scored("external_wall_insulation", gain=10.0, cost=8000.0),
],
[_scored("loft_insulation", gain=4.0, cost=1500.0)],
]
# Act
selection = optimise_min_cost(groups, budget=10000.0, target_gain=10.0)
# Assert
assert selection is not None
assert _selected_types(selection) == {
"cavity_wall_insulation",
"loft_insulation",
}
def test_min_cost_breaks_cost_ties_toward_the_higher_gain() -> None:
# Arrange — two equally-priced packages both reach the target; prefer the
# one with more headroom ("recommend more" on a tie).
groups: list[list[ScoredOption]] = [
[
_scored("cavity_wall_insulation", gain=10.0, cost=2000.0),
_scored("external_wall_insulation", gain=14.0, cost=2000.0),
],
]
# Act
selection = optimise_min_cost(groups, budget=10000.0, target_gain=10.0)
# Assert
assert selection is not None
assert _selected_types(selection) == {"external_wall_insulation"}
def test_min_cost_returns_none_when_target_unreachable_within_budget() -> None:
# Arrange — the only target-reaching package costs more than the budget.
groups: list[list[ScoredOption]] = [
[_scored("external_wall_insulation", gain=10.0, cost=8000.0)],
]
# Act
selection = optimise_min_cost(groups, budget=5000.0, target_gain=10.0)
# Assert — infeasible (caller falls back to max-gain).
assert selection is None
def test_min_cost_returns_none_when_no_package_reaches_the_target() -> None:
# Arrange — even everything together falls short of the target gain.
groups: list[list[ScoredOption]] = [
[_scored("cavity_wall_insulation", gain=6.0, cost=1000.0)],
[_scored("loft_insulation", gain=3.0, cost=1500.0)],
]
# Act
selection = optimise_min_cost(groups, budget=None, target_gain=10.0)
# Assert
assert selection is None
def test_min_cost_unbudgeted_picks_cheapest_reaching_target_not_everything() -> None:
# Arrange — no budget cap, but min-cost still means cheapest-to-target, not
# "install everything".
groups: list[list[ScoredOption]] = [
[_scored("cavity_wall_insulation", gain=10.0, cost=1000.0)],
[_scored("loft_insulation", gain=4.0, cost=1500.0)],
]
# Act — cavity alone (+10 @ £1000) already reaches the target.
selection = optimise_min_cost(groups, budget=None, target_gain=10.0)
# Assert — loft is left off; it would only add cost past the target.
assert selection is not None
assert _selected_types(selection) == {"cavity_wall_insulation"}
def test_min_cost_non_positive_target_selects_nothing() -> None:
# Arrange — a target already met (gain 0 needed) is reached by the empty
# package at zero cost.
groups: list[list[ScoredOption]] = [
[_scored("cavity_wall_insulation", gain=6.0, cost=1000.0)],
]
# Act
selection = optimise_min_cost(groups, budget=5000.0, target_gain=0.0)
# Assert — the cheapest target-reaching package is the empty one.
assert selection == []
def test_repair_adds_an_untreated_group_option_to_close_the_undershoot() -> None:
# Arrange — role-1 under-counts roof (signal 0 → warm-start skips it), but
# its true re-scored gain (+4) is what closes the target.