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
202 lines
8.7 KiB
Python
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)
|