portfolio-optimiser/tests/test_step5_history_loadbearing.py
Kjell Tore Guttormsen d6f3359fae feat(step5): the falsification that informed the next hypothesis now leaves the loop
generate_via_llm consumed each validator Rejection internally (`last`), fed it into the
next attempt's prompt, and dropped it. So Step 5 was real but unobservable: a caller could
see THAT a proposal validated, never that it validated on attempt 2 after the deterministic
validator falsified attempt 1. It was the one step of the eight with no output to show.

The seam is a typed return value -- GenerationResult(outcome, refinements) -- rather than an
out-parameter or a callback: a returned value cannot be silently lost by a caller that forgets
to pass a collector, and mypy forces every call site to acknowledge it.

refinements carries ONLY rejections that were actually fed back. When the attempt budget runs
out the final rejection IS outcome; counting it here would be double-counting, and the bounded
control test goes red on the collect-everything implementation that gets this wrong.

The loop's bound is untouched: max_attempts and meter.tick_round stand, and `last` still drives
the prompt alone, so prompt growth is unchanged. run.py accumulates across _evaluate calls, so
_evaluate_mandate is untouched; RunResult.refinements defaults (the coverage precedent) and is
concatenated across approaches rather than keyed per approach -- stated as an honesty limit.

The simulation now shows it: the scripted proposer overclaims 250000, which the validator
falsifies against P90 = 90000, and the corrected 30000 validates. Only the overclaim is
scripted -- the rejection is computed. scripted_factory takes a per-role reply selector so this
needs no second scripted client body.

README records the two accuracy changes only (Step 5 is now inspectable; the simulation trace
shows the correction). The level-2 publishing claim stays deferred until after the demo (O4).

Load-bearing MEASURED against the full suite with a control, four mutations all red:
detach the returned history (4 tests) - collect-everything (control only) - detach the run
wiring (2 tests) - revert the simulation's proposer to a constant (the demo-protection test).
Control: 759 passed / 4 skipped; ruff, format and mypy clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017CcWFcREUi6YPjEpN3ACDP
2026-08-06 15:12:06 +02:00

203 lines
10 KiB
Python

"""Step 5 load-bearing seam, part 2 (målbilde §5/§7): the intermediate falsification history must
ESCAPE ``generate_via_llm`` and reach the run's result — otherwise Step 5 is real but invisible.
The gap (verified in code before this file existed): the informed-refinement loop captured each
attempt's ``Rejection`` in a local ``last``, fed it into the next prompt, and then **dropped it**.
``generate_via_llm`` returned only the final ``ValidatedProposal | Rejection``, so a caller could
observe THAT a proposal validated but never that it validated *on attempt 2, after the deterministic
validator falsified attempt 1*. Step 5 was the one step of the eight with no observable output.
The seam is a typed return value (``GenerationResult``), not an out-parameter or a callback: a
returned value cannot be silently dropped by a caller that forgets to pass a collector, and mypy
forces every call site to acknowledge it. Nothing about the loop's BOUND changes — ``max_attempts``
and ``meter.tick_round`` are untouched (§6: "refine until good enough" without a cap is forbidden).
**The honesty line these tests pin** — ``refinements`` holds only the rejections that were actually
FED BACK into a later attempt's prompt. When the attempt budget runs out, the final rejection IS
``outcome``; it informed nothing and must not be double-counted as a refinement. An implementation
that simply collects every rejection it sees passes the positive test and FAILS the bounded control,
which is exactly why the control is here.
Four tests, load-bearing as a set:
- the history reaches the caller carrying the SAME rejection the next prompt received (RED if the
seam is detached, i.e. the history is computed internally and dropped again);
- the bounded control pins fed-back-only (RED on a collect-everything implementation);
- the run-level wiring: ``RunResult.refinements`` carries it out of ``run_project`` (RED if run.py
drops what generation returned — the seam would exist but the demo still could not show Step 5);
- the simulation genuinely exercises it (RED if the scripted proposer reverts to a constant reply,
which would leave the seam built but the demo one step shorter, silently).
They drive the CANONICAL ``ScriptedChatClient`` through its ``reply_selector`` seam rather than
defining another ``_inner_get_response`` body (S2.5 consolidation), so the proposer used here is the
same content-keyed one the simulation uses.
"""
from __future__ import annotations
from pathlib import Path
from portfolio_optimiser.budget import Budget, TokenMeter
from portfolio_optimiser.generate import generate_via_llm
from portfolio_optimiser.reference_domain import Project, load_reference_projects
from portfolio_optimiser.run import RunResult, run_project
from portfolio_optimiser.simulation import (
ScriptedChatClient,
scripted_factory,
simulate_learning_loop,
)
from portfolio_optimiser.validator import (
Rejection,
ValidatedProposal,
proposal_for,
validate_proposal,
)
# Same fixture arithmetic as test_step5_refine_loadbearing: FV42-GSV-E1 codes 05.2 + 03.1 ->
# affected total 1_482_500 -> degenerate Monte Carlo P90 = 444_750. 800_000 parses (< affected
# total) but exceeds P90 -> rejected; 200_000 validates. Both proposals are built via proposal_for,
# so their quantity/unit_cost ARE the project's cost lines and the S4.0 baseline stage passes.
_CODES = ["05.2", "03.1"]
_BAD_CLAIM = 800_000
_CORRECTED_CLAIM = 200_000
_BUNDLE_DIR = Path(__file__).resolve().parents[1] / "shared" / "examples" / "bygg-energi-mikro"
def _meter() -> TokenMeter:
# max_rounds well above max_attempts so max_attempts -- not BudgetExceeded -- is the bound.
return TokenMeter(Budget(max_tokens=10**9, max_rounds=20))
def _fixture() -> tuple[Project, str, str, str, Rejection]:
"""The shared reject-then-correct fixture: the bad proposal's JSON, the corrected one's, the
flip key (the rejected claim value, which appears ONLY once the validator's reason is fed back),
and the rejection the validator itself produces for the bad proposal — computed with the SAME
validator the SUT uses, so the reason is byte-identical to the one the loop feeds back."""
project = load_reference_projects()[0] # FV42-GSV-E1
bad = proposal_for(project, _CODES, claimed_saving_nok=_BAD_CLAIM)
corrected = proposal_for(project, _CODES, claimed_saving_nok=_CORRECTED_CLAIM)
rej = validate_proposal(bad)
assert isinstance(rej, Rejection), "fixture invariant: the BAD claim must reject"
return (
project,
bad.model_dump_json(),
corrected.model_dump_json(),
f"{bad.claimed_saving_nok:.0f}",
rej,
)
async def test_falsification_history_escapes_the_generate_loop() -> None:
"""LOAD-BEARING: the rejection that informed attempt 2 is RETURNED to the caller, carrying the
verbatim reason attempt 2's prompt received. Goes RED when the seam is detached (the history is
computed internally and dropped again)."""
project, bad_json, corrected_json, flip_key, rej = _fixture()
sink: list[str] = []
client = ScriptedChatClient(
sink=sink,
reply_selector=lambda prompt, _role: corrected_json if flip_key in prompt else bad_json,
)
# context="" so the flip token cannot pre-exist in attempt 1's prompt.
result = await generate_via_llm(client, project, "", _meter(), max_attempts=3)
assert isinstance(result.outcome, ValidatedProposal), (
"fixture invariant: the proposer corrects on attempt 2 and that proposal validates"
)
# The seam itself: attempt 1's falsification is observable from OUTSIDE the loop.
assert len(result.refinements) == 1, (
"the falsification that informed attempt 2 did not escape generate_via_llm"
)
# Green-but-dead guard: it is the REAL rejection (reason + the rejected proposal), not a
# placeholder -- and it is byte-identical to what the next prompt was given.
assert result.refinements[0].reason == rej.reason
assert result.refinements[0].proposal.claimed_saving_nok == _BAD_CLAIM
assert result.refinements[0].reason in sink[1], (
"the returned rejection is not the one that was fed into the next attempt's prompt"
)
async def test_history_holds_only_fed_back_rejections_when_budget_runs_out() -> None:
"""CONTROL + HONESTY LINE: a proposer that never fixes its claim produces ``max_attempts``
rejections, of which only the first ``max_attempts - 1`` were ever fed into a later prompt. The
final one IS ``outcome``. RED on a collect-everything implementation, which is the obvious wrong
way to build this seam."""
project, bad_json, _corrected, _flip, _rej = _fixture()
client = ScriptedChatClient(bad_json)
result = await generate_via_llm(client, project, "", _meter(), max_attempts=3)
assert isinstance(result.outcome, Rejection)
assert client.call_count == 3, "control: the loop stays bounded by max_attempts"
assert len(result.refinements) == 2, (
"only the rejections that INFORMED a later attempt belong in the history; the final "
"rejection is the outcome and informed nothing"
)
assert all(r.reason == result.outcome.reason for r in result.refinements)
async def test_run_result_carries_the_falsification_history(tmp_path: Path) -> None:
"""RUN-LEVEL WIRING: what generation returns must survive to ``RunResult`` — otherwise the seam
exists but Step 5 is still invisible to the demo. RED if run.py drops ``refinements``.
Drives the ROAD path (a reference project, so the S4.0 baseline is always anchored) with the
same content-keyed proposer the simulation uses."""
project, bad_json, corrected_json, flip_key, _rej = _fixture()
docs = tmp_path / "docs"
docs.mkdir()
(docs / "kilde.md").write_text("Cost saving measure candidates for the project.\n", "utf-8")
sink: list[str] = []
result = await run_project(
project.id,
"local",
docs_dir=str(docs),
verdict_input={"decision": "approved", "rationale": "fixture verdict"},
client_factory=scripted_factory(
{
"proposer": lambda prompt, _role: (
corrected_json if flip_key in prompt else bad_json
),
"checker": "VERDICT: APPROVE",
},
sink,
),
max_rounds=3,
)
assert isinstance(result, RunResult)
assert isinstance(result.outcome, ValidatedProposal), (
"fixture invariant: the corrected proposal validates"
)
assert len(result.refinements) == 1, (
"run_project dropped the falsification history returned by generation"
)
assert result.refinements[0].proposal.claimed_saving_nok == _BAD_CLAIM
async def test_simulation_actually_exercises_step_five(tmp_path: Path) -> None:
"""DEMO PROTECTION: the offline simulation must genuinely go through a falsification before it
validates — otherwise Step 5 is buildable but unshown, which is the state this whole seam exists
to leave behind. RED the moment the scripted proposer reverts to a constant reply: with nothing
to correct, ``refinements`` is empty and the demo silently loses a step.
The rejection itself is NOT scripted: the proposer only overclaims. That 250000 exceeds the P90
of 90000 is computed by the deterministic validator, which is the part worth showing."""
result = await simulate_learning_loop(str(_BUNDLE_DIR), str(tmp_path))
assert isinstance(result.run_a.outcome, ValidatedProposal), (
"the corrected proposal must still validate — Step 5 ends in a proposal, not a dead end"
)
assert len(result.run_a.refinements) == 1, (
"the simulation validated on the first attempt — the scripted reject-then-correct sequence "
"is gone, so the demo cannot show Step 5"
)
rejected = result.run_a.refinements[0]
assert rejected.proposal.claimed_saving_nok == 250_000
assert (
result.run_a.outcome.proposal.claimed_saving_nok < rejected.proposal.claimed_saving_nok
), (
"the corrected proposal must claim LESS than the falsified one — otherwise the refinement "
"did not respond to the falsification"
)