"""The Optimiser core — a grouped (multiple-choice) knapsack over per-Option role-1 scores (ADR-0016). Recycles the formulation of the legacy ``GainOptimiser`` / ``CostOptimiser`` (``recommendations/optimiser/``): pick **at most one** Option per Recommendation (disjoint groups, no cross-group exclusion constraints — the Recommendation partition makes selected overlays collision-free), maximising total SAP gain subject to the Scenario budget. The legacy classes solve this as a `mip` MILP; here it is an exact pure-Python multiple-choice knapsack — no native solver dependency, so it runs everywhere and is deterministically testable. This is the warm-start **signal** only: per ADR-0016 the role-1 per-Option scores are approximate (independent-vs-baseline), so the truthful figure comes from the whole-package re-score + greedy repair, not from this selection. Exact enumeration is therefore more than adequate, and at retrofit scale (a handful of Recommendations, a few Options each) the candidate space — ``Π(|group|+1)`` — is tiny. """ from __future__ import annotations import itertools from dataclasses import dataclass from typing import Optional from domain.modelling.recommendation import MeasureOption @dataclass(frozen=True) class ScoredOption: """A candidate Measure Option paired with its role-1 (independent-vs- baseline) SAP gain — the optimiser's input signal. Cost is read from the Option; the gain is supplied by scoring.""" option: MeasureOption sap_gain: float def _option_cost(option: MeasureOption) -> float: if option.cost is None: raise ValueError( f"measure option {option.measure_type!r} has no cost; cannot optimise" ) return option.cost.total def optimise( groups: list[list[ScoredOption]], budget: Optional[float] ) -> 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. choices_per_group: list[list[Optional[ScoredOption]]] = [ [None, *group] for group in groups ] best: list[ScoredOption] = [] best_gain: float = -1.0 best_cost: 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) # 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