portfolio-optimiser/tests/test_mandate_run_loadbearing.py
Kjell Tore Guttormsen 61bf5b78ea feat(run): every commissioned approach is evaluated, and every one is reported
Carrying an approach into the prompt only half-answers krav 1. The expert asked
for their approaches to be CONCRETELY EVALUATED, which means each must reach a
verdict and each verdict must be visible. `run_project(mandate=...)` evaluates
every commissioned approach in turn — the run's own proposal last, when allowed —
each under the SAME meter. No new loop: the caps already in force are the bound.

`RunResult.coverage` is the settlement, one row per approach: validated (with the
figure), rejected (with the validator's reason verbatim), or not_evaluated (with
why). `not_evaluated` is the row that earns the type its keep — an approach the
run never reached must be reported as unreached, because an omitted row is
indistinguishable from an approach nobody ordered. That silence is the defect
class krav 1 is asking us to remove.

Budget exhaustion mid-list is reported, not swallowed. But if the FIRST approach
exhausts it there is nothing honest to return, so BudgetExceeded propagates
exactly as before — a run that produced nothing must still fail loudly.

RunResult stays single-outcome (portfolio aggregation, outbox artefacts and HITL
keying all rest on that). The choice is deterministic: highest validated saving,
ties by mandate order — never whichever ran last.

Load-bearing MEASURED against the whole 702-test suite. FIVE mutations red:
evaluate only the first approach (5 red) · drop the rejected rows (3) · ignore
allow_own_proposals (1) · select produced[-1] (1) · select produced[0] (1).

The sixth measurement is why this commit exists in this shape: the ordering
mutation FIRST STAYED GREEN. The test had placed the bigger approach last, where
"highest saving" and "whichever ran last" give the same answer, so an
order-dependent implementation passed it. A scenario that cannot separate two
implementations proves nothing about either — the test now pins BOTH orderings,
and each mutation direction fails one of them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ULCqjLF61rehj5cZmdUoR3
2026-08-05 16:05:48 +02:00

202 lines
8.7 KiB
Python

"""Load-bearing: a run EVALUATES every commissioned approach, and REPORTS what happened to each
(Trekk A3/A4 — krav 1 and 2).
Krav 1 is only half-met by carrying an approach into the prompt (``test_mandate_generation_
loadbearing.py``): the expert asked for their approaches to be *concretely evaluated*, which means
each one must reach a verdict and each verdict must be visible. The defect class this pins is
silence — an approach that was commissioned but never evaluated, or evaluated and quietly dropped
because a different one won, is indistinguishable from one that was never ordered.
Detach points, each RED on its own:
* run one generation instead of one per approach -> the coverage report loses rows;
* drop the rejected row (report only what validated) -> the expert's approach vanishes silently;
* drop the own-proposal row -> "og/eller" becomes "or".
The control (``test_no_mandate_runs_exactly_one_generation_with_empty_coverage``) proves the whole
addition is inert without a mandate: today's single-shot path, unchanged.
"""
from __future__ import annotations
from collections.abc import Callable
from pathlib import Path
import pytest
from portfolio_optimiser.mandate import OWN_PROPOSAL_ID, Approach, Mandate
from portfolio_optimiser.simulation import ScriptedChatClient
from portfolio_optimiser.run import run_project
from portfolio_optimiser.validator import Rejection, ValidatedProposal
from portfolio_optimiser.verdicts import VerdictStore
BUNDLE_DIR = Path(__file__).resolve().parents[1] / "shared" / "examples" / "bygg-energi-mikro"
_VERDICT_INPUT = {"decision": "approved", "rationale": "expert reviewed (sim)"}
# BYGG-KONTOR-NORD: affected total = 300000 x 1.0 -> degenerate Monte Carlo P90 = 0.30 x 300000
# = 90000. A claim <= 90000 validates; a claim above it is REJECTED by the deterministic validator.
def _reply(measure: str, claimed: int) -> str:
return (
f'{{"measure":"{measure}","affected_items":'
f'[{{"code":"ENERGI-TOTAL-EL","quantity":300000,"unit_cost":1.0}}],'
f'"claimed_saving_nok":{claimed}}}'
)
# Labels are chosen to be ABSENT from the bundle's own prose: "LED-retrofit" appears in 6 of the
# bundle's files, so a client keyed on it would match every prompt through the context and prove
# nothing about which approach was bound to which call.
_LED = Approach(id="led-retrofit", label="Behovsstyrt belysning i fellesarealer")
_HVAC = Approach(id="hvac-swap", label="Utskifting av ventilasjonsaggregat")
#: LED validates (30k <= cap); HVAC is above the cap -> the validator rejects it.
_REPLY_BY_LABEL = {
_LED.label: _reply("Behovsstyrt belysning i fellesarealer", 30_000),
_HVAC.label: _reply("Utskifting av ventilasjonsaggregat", 200_000),
}
_DEFAULT_REPLY = _reply("Systemets eget forslag", 20_000)
def _select_reply(blob: str, _role: str) -> str:
"""Reply according to WHICH approach the prompt carries — so a per-approach outcome can only
differ if the loop really bound that approach to that call. Plugged into the CANONICAL
``ScriptedChatClient`` selector seam rather than a copied ``_inner_get_response`` body (S2.5
consolidation guard)."""
return next((r for label, r in _REPLY_BY_LABEL.items() if label in blob), _DEFAULT_REPLY)
def _factory(sink: list[str]) -> Callable[[str], ScriptedChatClient]:
def factory(role: str) -> ScriptedChatClient:
return ScriptedChatClient(
sink=sink, role=role, reply_selector=_select_reply, default_reply=_DEFAULT_REPLY
)
return factory
def _generation_prompts(sink: list[str]) -> list[str]:
"""Generation-call prompts only (``_build_messages`` embeds 'SavingsProposal'), isolated from
the debate-round prompts sharing the sink."""
return [p for p in sink if "SavingsProposal" in p]
async def _run(mandate: Mandate | None, sink: list[str]):
return await run_project(
"BYGG-KONTOR-NORD",
"local",
docs_dir=str(BUNDLE_DIR),
bundle_dir=str(BUNDLE_DIR),
verdict_input=_VERDICT_INPUT,
store=VerdictStore(verdicts=[]),
client_factory=_factory(sink),
mandate=mandate,
)
def _row(result, approach_id: str):
return next((c for c in result.coverage if c.id == approach_id), None)
async def test_every_commissioned_approach_is_evaluated() -> None:
"""Both commissioned approaches are generated for — one prompt each, each bound to its own
approach. One generation for two approaches means only one was ever evaluated."""
sink: list[str] = []
mandate = Mandate(
objective="Cut energy cost without rebuilding.",
approaches=(_LED, _HVAC),
allow_own_proposals=False,
)
result = await _run(mandate, sink)
prompts = _generation_prompts(sink)
assert sum(_LED.label in p for p in prompts) >= 1
assert sum(_HVAC.label in p for p in prompts) >= 1
assert {c.id for c in result.coverage} == {"led-retrofit", "hvac-swap"}
async def test_a_rejected_approach_is_reported_not_dropped() -> None:
"""The approach the validator rejected is STILL on the report, with the reason — this is the
silence the coverage report exists to prevent."""
sink: list[str] = []
mandate = Mandate(
objective="Cut energy cost without rebuilding.",
approaches=(_LED, _HVAC),
allow_own_proposals=False,
)
result = await _run(mandate, sink)
led, hvac = _row(result, "led-retrofit"), _row(result, "hvac-swap")
assert led is not None and hvac is not None
assert led.status == "validated"
assert hvac.status == "rejected"
assert hvac.detail, "a rejected approach must carry the validator's reason, not a bare status"
async def test_own_proposal_is_reported_alongside_commissioned_ones() -> None:
"""'og/eller': with ``allow_own_proposals`` the system's own candidate is evaluated too, and
reported under its own reserved row rather than merged into an expert's."""
sink: list[str] = []
mandate = Mandate(
objective="Cut energy cost without rebuilding.",
approaches=(_LED,),
allow_own_proposals=True,
)
result = await _run(mandate, sink)
assert {c.id for c in result.coverage} == {"led-retrofit", OWN_PROPOSAL_ID}
@pytest.mark.parametrize("big_first", [True, False])
async def test_outcome_is_the_best_validated_candidate_deterministically(big_first: bool) -> None:
"""With more than one validated approach the run still returns ONE outcome (portfolio
aggregation, outbox and HITL keying rest on that), chosen by highest validated saving.
BOTH orderings are exercised, and that is the whole point: a first run of this test placed the
bigger approach LAST, where "highest saving" and "whichever ran last" give the same answer — an
order-dependent implementation passed it (MEASURED: the mutation stayed green). A test whose
scenario cannot separate the two implementations proves nothing about either. With both
orderings pinned, ``produced[-1]`` fails one case and ``produced[0]`` fails the other.
"""
sink: list[str] = []
big = Approach(id="big", label="Behovsstyrt belysning i fellesarealer") # 30k, validates
small = Approach(id="small", label="Nattsenking av temperatur") # default reply, 20k
mandate = Mandate(
objective="Cut energy cost without rebuilding.",
approaches=(big, small) if big_first else (small, big),
allow_own_proposals=False,
)
result = await _run(mandate, sink)
assert isinstance(result.outcome, ValidatedProposal)
assert result.outcome.proposal.claimed_saving_nok == 30_000
assert all(c.status == "validated" for c in result.coverage)
async def test_all_rejected_still_returns_a_rejection_and_full_coverage() -> None:
"""When nothing validates the run still reports every approach — and the outcome stays a
typed ``Rejection`` (never an empty or fabricated success)."""
sink: list[str] = []
hvac2 = Approach(id="hvac-2", label="Utskifting av ventilasjonsaggregat")
mandate = Mandate(
objective="Cut energy cost without rebuilding.",
approaches=(_HVAC, hvac2),
allow_own_proposals=False,
)
result = await _run(mandate, sink)
assert isinstance(result.outcome, Rejection)
assert {c.id for c in result.coverage} == {"hvac-swap", "hvac-2"}
assert all(c.status == "rejected" for c in result.coverage)
async def test_no_mandate_runs_exactly_one_generation_with_empty_coverage() -> None:
"""CONTROL: without a mandate the run is byte-for-byte the pre-Trekk-A one — a single
generation, and no coverage report claiming approaches nobody commissioned."""
sink: list[str] = []
result = await _run(None, sink)
assert len(_generation_prompts(sink)) == 1
assert result.coverage == ()
assert isinstance(result.outcome, ValidatedProposal)