portfolio-optimiser/tests/test_mandate_generation_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

100 lines
4.6 KiB
Python

"""Load-bearing: a COMMISSIONED approach reaches the proposer, and gets no discount from the
deterministic validator (Trekk A3).
The seam this pins is the whole point of krav 1: a domain expert names an approach, and the
hypothesis prompt must actually carry it — otherwise the run merely *claims* to evaluate what was
ordered. Two detach points, and each must go RED on its own:
* drop the approach block from ``_build_messages`` -> the expert's label/description never reaches
the model (``test_commissioned_approach_reaches_the_proposer``);
* let a commissioned approach bypass ``validate_proposal`` -> an expert's wish would outrank the
deterministic gate (``test_commissioned_approach_gets_no_validator_discount``).
The control (``test_no_approach_keeps_the_base_prompt_byte_identical``) proves the addition is
inert when no mandate is given: a test that can only go green proves nothing.
"""
from __future__ import annotations
import pytest
from spikes._harness import FakeChatClient, message_texts
from portfolio_optimiser.budget import Budget, TokenMeter
from portfolio_optimiser.generate import _build_messages, generate_via_llm
from portfolio_optimiser.mandate import Approach
from portfolio_optimiser.reference_domain import load_reference_projects
from portfolio_optimiser.validator import Rejection, ValidatedProposal
_VALID = (
'{"project_id":"FV42-GSV-E1","measure":"Reduce scope",'
'"affected_items":[{"code":"05.2","quantity":4300,"unit_cost":215},'
'{"code":"03.1","quantity":1800,"unit_cost":310}],"claimed_saving_nok":200000}'
)
#: Same shape and a value the IR itself accepts, but above the method cap (30% of the ~1.48M
#: affected total = ~445k) -> the DETERMINISTIC VALIDATOR is what must reject it, not the parser.
_INFEASIBLE = (
'{"project_id":"FV42-GSV-E1","measure":"Reduce scope",'
'"affected_items":[{"code":"05.2","quantity":4300,"unit_cost":215},'
'{"code":"03.1","quantity":1800,"unit_cost":310}],"claimed_saving_nok":800000}'
)
_APPROACH = Approach(
id="led-retrofit",
label="LED retrofit of office lighting",
description="Operations believes the fixtures are original and run far past their rated life.",
)
@pytest.fixture(scope="module")
def project():
return load_reference_projects()[0] # FV42-GSV-E1
def _meter() -> TokenMeter:
return TokenMeter(Budget(max_tokens=10**9, max_rounds=20))
def test_commissioned_approach_reaches_the_proposer(project) -> None:
"""The expert's own words — label AND description — reach the hypothesis prompt VERBATIM.
The description is the part the model cannot infer from cost data (it is the expert's reason
for wanting this tried), so paraphrasing it away would quietly discard the domain knowledge.
"""
text = message_texts(_build_messages(project, "ctx", approach=_APPROACH))[0]
assert _APPROACH.label in text
assert _APPROACH.description in text
def test_no_approach_keeps_the_base_prompt_byte_identical(project) -> None:
"""CONTROL: without a mandate the prompt is byte-identical to the pre-Trekk-A one, so every
existing run and golden is untouched (mirrors ``prior_rejection=None``)."""
base = message_texts(_build_messages(project, "ctx"))[0]
assert base == message_texts(_build_messages(project, "ctx", approach=None))[0]
assert "Propose ONE concrete cost-saving measure for this project." in base
assert _APPROACH.label not in base
async def test_commissioned_approach_gets_no_validator_discount(project) -> None:
"""A commissioned approach whose numbers do not hold is REJECTED — the deterministic gate is
blocking, and being asked for by a domain expert is not a reason to pass it.
This is the one rule krav 1 must not be allowed to erode: the expert directs *what is
evaluated*, never *what is approved*.
"""
client = FakeChatClient(scripted=[_INFEASIBLE], default_reply=_INFEASIBLE)
result = await generate_via_llm(
client, project, "", _meter(), max_attempts=1, approach=_APPROACH
)
assert isinstance(result.outcome, Rejection)
async def test_commissioned_approach_still_validates_when_the_numbers_hold(project) -> None:
"""...and the same commissioned path DOES produce a validated proposal when the numbers are
feasible — so the rejection above is the validator working, not the approach path being
broken end to end."""
client = FakeChatClient(scripted=[_VALID], default_reply=_VALID)
result = await generate_via_llm(
client, project, "", _meter(), max_attempts=1, approach=_APPROACH
)
assert isinstance(result.outcome, ValidatedProposal)
assert _APPROACH.label in client.received_texts[0][0] # it went through the commissioned prompt