Model/domain/modelling/optimiser.py
Khalim Conn-Kowlessar 77983caed8 feat(modelling): Optimiser core — exact grouped knapsack (#1160)
Slice 1 of #1160. Recycles the GainOptimiser/CostOptimiser formulation
(≤1 Option per Recommendation, maximise SAP gain subject to budget) as a
clean typed DDD function — but as an exact pure-Python multiple-choice
knapsack rather than the legacy `mip` MILP, since mip's CBC backend does
not load on aarch64 (so the legacy solver path can't run / be tested
here). At retrofit scale the candidate space Π(|group|+1) is tiny, so
exhaustive enumeration is exact and instant; ADR-0016 only needs the
knapsack as a warm-start signal anyway (the truthful figure comes from
the whole-package re-score + repair, next slice).

`optimise(groups, budget) -> list[ScoredOption]`: maximise total gain,
tie-break toward lower cost, skip-per-group covers "select none". 6 tests
(budget-bound selection, ≤1/group, unconstrained, budget-too-small,
empty groups, partial-affordability); pyright strict clean.

Multi-phase remains descoped (ADR-0005) — single-phase optimiser.

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

74 lines
3 KiB
Python

"""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