feat(modelling): ventilation-aware selection — price the forced dependency in

The warm-start (and max-gain fallback) now price each forced Measure Dependency
the candidate triggers, not just inject it afterwards: optimise/optimise_min_cost
fold dependencies into each candidate's cost+gain via _augmented_cost_gain, and
optimise_package scores each dependency's true role-1 signal (_with_role1_signals)
instead of the 0.0 placeholder. This stops the min-cost objective (i) ignoring the
~£900 a wall drags in (a wall-free package reaching target can be cheaper) and
(ii) picking a small-gain wall whose mandatory ventilation (down to -5 SAP) makes
it net-negative, which repair cannot un-pick.

Budget is now a hard envelope: the constraint applies to the augmented (measure +
its ventilation) cost, so a wall that fits alone but whose ventilation would bust
the budget is DROPPED rather than forced over budget. This reverses the earlier
'forced regardless of budget' call (which made sense when selection was
ventilation-blind). Safety invariant intact — presence still injected on every
path; we just never recommend a wall we can't afford to ventilate. ADR-0016
amendment updated. 94 modelling+orchestration tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-03 16:16:26 +00:00
parent 2bf42d046e
commit af501fce0e
3 changed files with 127 additions and 23 deletions

View file

@ -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.

View file

@ -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)

View file

@ -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: