portfolio-optimiser/tests/test_parse_failure_capture_loadbearing.py
Kjell Tore Guttormsen da5f10f140 feat(s2c): debatten navigerer basen i stedet for aa faa den utlevert [skip-docs]
MAJOR-3/S7a-3 gjorde utforskningen billig og lot pipelinen staa. Maalt paa K2
(630 konsepter, S7bs eget instrument, kjent-positiv-kontrollen reprodusert
eksakt FOER bruk): okf.bundle_context er 648 962 o200k-tokens og rir i TRE
kopier = 1 947 342 = 99,1 % av en kjoerings prompt-tokens.

Et premiss i maaledokumentet ble presisert foerst: de tre kopiene er tre
DEBATT-turer (proposer x2, checker x1), mens genererings-prompten er 156
tokens, fordi gen_context = debate_output or context. Det avgjorde formen -
generering trengte ingen egen soem, for aa binde `context` binder
siste-utvei-fallbacken ved konstruksjon.

run_project sender naa en PEKER (fast tekst + erklaert bundle_id + antall
konseptdokumenter i scope + stigen, O(1) i korpuset) og gir debatten de SAMME
fire verktoeyene utforskningen bruker - explore.navigator_tools gjenbrukt,
aldri en andre kopi av policyen.

Etter: 753 tokens like-for-like (samme manus, samme fire prompter, -99,96 %)
og 8 942 med en debatt som faktisk gaar stigen (-99,5 %), mot operatoerens
terskel 195 000 = 4,6 % av taket. Validert besparelse og validatorens dom er
UENDRET (850 000 NOK av 3 852 500, 2 av 5 felt paa stage 4 og 5, samme
dom-noekkel), og utforskningens 18 355 er uendret til tokenet.

§4.1a maatte flytte, ikke forsvinne: dimensjonsfilteret bodde i renderingen og
bor naa i VERKTOEYENE, paa begge trinn - en listing som skjuler et fremmed
dokument mens read_file serverer det paa sti er et filter i navnet alene.
okf.in_dimension er eneste predikat.

Sporet er kaller-eid (ExplorationToolRecorder -> RunResult.debate_tool_calls ->
{run_id}-debate.json fra en finally) og skrives ogsaa TOMT: en debatt som
navigerer ingenting ER S2c-regresjonen, saa den maa kunne leses.

Load-bearing MAALT: aatte mutasjoner roede mot HELE suiten, groenn kontroll
1306/5 (fra 1295/5), golden demo-transcript.stdout BYTE-UENDRET
(shasum -a 1 av innholdet = ea8c534773acdbe41ae68f2c55724d69aaf8be4f).
M7 falsifiserte seg selv, ikke gaten - staar som maalt.

Maaling: docs/2026-09-04-s2c-debatt-k2.md

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 18:04:21 +02:00

289 lines
14 KiB
Python

"""Load-bearing: the model's RAW reply must survive a parse failure (Fase 1b, funn 1).
The gap, measured on the project's first live run (``docs/2026-08-14-fase1b-forste-levende-kjoring.md``):
``generate._fetch_parsed`` caught ``(ValidationError, ValueError, TypeError)`` and ``continue``-d.
The text the model actually produced — the thing that was PAID for and the only evidence of WHY it
did not parse — was dropped on the floor. The live run burned all twelve rounds on parse failures
and died with ``BudgetExceeded``, and no artefact anywhere held a single character of what the model
said. Every further paid run would have been a guess.
**WHERE the text surfaces is decided by a measurement, not by symmetry with Step 5.**
``BudgetExceeded`` is raised by ``meter.tick_round()`` INSIDE ``_fetch_parsed``, so it propagates out
of ``generate_via_llm`` before any value is returned, and (without a mandate) nothing catches it —
``run.py``'s only ``except BudgetExceeded`` is the mandate arm. So:
* a typed RETURN VALUE (``GenerationResult.parse_failures``, mirroring Step 5's ``refinements``) is
blind to exactly the one path the capture exists for: the returning path is the path where the run
already succeeded in parsing something;
* an outbox artefact written AFTER the run (``write_outbox``, run.py step 7) is blind for the same
reason — ``run_project`` never reaches it.
The seam is therefore a CALLER-OWNED SINK plus an artefact written in a ``finally``:
``run_project`` owns the ``list[ParseFailure]``, hands it to ``generate_via_llm`` beside ``meter``,
and writes ``{run_id}-parse-failures.json`` however the evaluation ended. The sink mirrors ``meter``
exactly — a caller-owned accumulator the loop mutates, whose contents the caller still holds after an
exception. Step 5's "a returned value cannot be silently lost by a caller that forgets to pass a
collector" governs a value that REACHES the caller; here it does not, and copying that rule blindly
would have rebuilt the defect one layer up.
Five tests, load-bearing as a set. Each detach point is RED on its own:
* T1 — the budget-exhausted run (the live 1b shape) still writes the artefact
(RED when the capture is detached, when the write is not in a ``finally``, or when run.py does not
hand the sink over);
* T2 — the sink receives the reply VERBATIM at the ``generate_via_llm`` level
(RED when the capture is detached OR when the text is truncated/summarised on the way in);
* T3 — the CONTROL: a run whose replies all parse writes NO artefact
(RED on a "write it always" implementation, which would make the artefact's presence meaningless);
* T4 — a run that COMPLETES writes the artefact too
(RED on an implementation that only writes from the exception arm — the mirror of T1);
* T5 — the marker gate: the sentinel is ABSENT from the bundle, so a positive assert on it cannot be
satisfied by bundle context leaking into a prompt (repo rule for bundle-driven tests).
They drive the CANONICAL ``ScriptedChatClient`` through its ``reply_selector`` seam (S2.5
consolidation) and key on the generation prompt's own instruction line, so the debate's replies —
which are never parsed — cannot be mistaken for a capture.
"""
from __future__ import annotations
import json
from collections.abc import Callable
from pathlib import Path
import pytest
from portfolio_optimiser.budget import Budget, BudgetExceeded, TokenMeter
from portfolio_optimiser.generate import ParseFailure, generate_via_llm
from portfolio_optimiser.reference_domain import load_reference_projects
from portfolio_optimiser.run import RunResult, run_project
from portfolio_optimiser.simulation import ScriptedChatClient
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)"}
_PROJECT_ID = "BYGG-KONTOR-NORD"
#: The instruction line ``generate._build_messages`` puts in EVERY generation prompt and nowhere
#: else — the one identifier that separates a generation call from a debate turn.
_GENERATION_MARK = "Respond with ONLY a JSON object"
#: A reply shaped like what a chatty model actually returns: prose, a fenced block, newlines and
#: non-ASCII — NOT JSON. The hex sentinel is what the positive asserts key on; it is gated absent
#: from the bundle by T5, so bundle context cannot satisfy them. Deliberately >200 characters, so a
#: truncating capture (``text[:80]``) is measurable rather than merely suspected.
_MALFORMED = (
"Certainly! Here is my analysis of the project.\n\n"
"PARSE-FAIL-SENTINEL-7f3a9c: the måling below is prose, not the requested object.\n\n"
"```\n"
"measure: Behovsstyrt belysning\n"
"claimed_saving_nok: about 30 000 NOK (approx.)\n"
"```\n\n"
"Let me know if you would like me to format this as JSON instead!"
)
_SENTINEL = "PARSE-FAIL-SENTINEL-7f3a9c"
#: BYGG-KONTOR-NORD: affected total 300000 x 1.0 -> degenerate Monte Carlo P90 = 90000, so a claim
#: of 30000 validates (same arithmetic as test_a5_per_approach_artifacts_loadbearing).
_VALID_REPLY = (
'{"measure":"Behovsstyrt belysning i fellesarealer","affected_items":'
'[{"code":"ENERGI-TOTAL-EL","quantity":300000,"unit_cost":1.0}],'
'"claimed_saving_nok":30000}'
)
def _factory(
select: Callable[[str, str], str],
) -> Callable[[str], ScriptedChatClient]:
def factory(role: str) -> ScriptedChatClient:
return ScriptedChatClient(role=role, reply_selector=select, default_reply="ok")
return factory
def _always_malformed(blob: str, _role: str) -> str:
return _MALFORMED if _GENERATION_MARK in blob else "ok"
def _malformed_then_valid(failures: int) -> Callable[[str, str], str]:
"""Fail to parse ``failures`` times, then answer with a proposal that validates. The counter is
keyed on the GENERATION prompt only, so debate turns do not consume it."""
seen = {"n": 0}
def _select(blob: str, _role: str) -> str:
if _GENERATION_MARK not in blob:
return "ok"
seen["n"] += 1
return _MALFORMED if seen["n"] <= failures else _VALID_REPLY
return _select
async def _run(
select: Callable[[str, str], str],
outbox_dir: Path,
*,
run_id: str,
max_rounds: int,
) -> RunResult:
result = await run_project(
_PROJECT_ID,
"local",
docs_dir=str(BUNDLE_DIR),
bundle_dir=str(BUNDLE_DIR),
verdict_input=_VERDICT_INPUT,
store=VerdictStore(verdicts=[]),
client_factory=_factory(select),
outbox_dir=str(outbox_dir),
run_id=run_id,
# The ROUND cap is the bound under test; tokens are left effectively unbounded so a failure
# here can only be the round ledger (the live 1b shape: "rounds limit=12 observed=13").
meter=TokenMeter(Budget(max_tokens=10**9, max_rounds=max_rounds)),
)
assert isinstance(result, RunResult)
return result
def _artefact(outbox_dir: Path, run_id: str) -> Path:
return outbox_dir / f"{run_id}-parse-failures.json"
def _failures(outbox_dir: Path, run_id: str) -> list[dict[str, str]]:
payload = json.loads(_artefact(outbox_dir, run_id).read_text(encoding="utf-8"))
assert payload["run_id"] == run_id
failures = payload["parse_failures"]
assert isinstance(failures, list)
return failures
# --------------------------------------------------------------------------------------------
# T1 — the live 1b shape: the run dies inside the generation loop, and the text survives anyway.
# --------------------------------------------------------------------------------------------
async def test_budget_exhausted_run_still_writes_the_raw_text(tmp_path: Path) -> None:
"""The measured 1b failure, reproduced offline: every reply is unparseable, the round ledger is
exhausted inside ``_fetch_parsed``, and ``BudgetExceeded`` leaves ``run_project`` as an
exception. The artefact must exist ANYWAY — this is the path a return value cannot reach.
RED when the capture is detached, when the write is moved out of the ``finally``, or when
``run.py`` stops handing the sink to ``generate_via_llm``."""
outbox_dir = tmp_path / "outbox"
run_id = "run-parse-exhausted"
with pytest.raises(BudgetExceeded) as excinfo:
await _run(_always_malformed, outbox_dir, run_id=run_id, max_rounds=3)
# Prove the event this test claims actually happened, and that it is the ROUND ledger — an
# assert on the artefact alone could not tell an exhausted run from a completed one.
assert excinfo.value.kind == "rounds"
failures = _failures(outbox_dir, run_id)
# max_rounds=3 -> ticks 1..3 each fetch a reply and fail to parse; tick 4 raises. So exactly
# three replies were paid for, and exactly three must be recoverable.
assert len(failures) == 3
assert all(_SENTINEL in f["text"] for f in failures)
assert all(f["error"] for f in failures), (
"the parse error itself must be recorded, not just why"
)
# --------------------------------------------------------------------------------------------
# T2 — the capture itself, at the generate level: VERBATIM, not summarised.
# --------------------------------------------------------------------------------------------
async def test_the_sink_receives_the_reply_verbatim(tmp_path: Path) -> None:
"""``generate_via_llm`` appends to the caller's sink at the moment the parse fails, and the text
is the model's reply BYTE-FOR-BYTE. A capture that truncates, strips or summarises would leave
the operator reading a paraphrase of the evidence.
RED when the capture is detached, and RED when the text is shortened on the way in."""
# A reference project rather than a hand-built one: this test never reaches the validator (no
# reply ever parses), so the project only has to be a real one the prompt can name.
project = load_reference_projects()[0]
client = ScriptedChatClient(reply_selector=_always_malformed, role="proposer")
sink: list[ParseFailure] = []
with pytest.raises(BudgetExceeded):
await generate_via_llm(
client,
project,
"",
TokenMeter(Budget(max_tokens=10**9, max_rounds=2)),
max_attempts=3,
parse_failures=sink,
)
assert len(sink) == 2
assert [f.text for f in sink] == [_MALFORMED, _MALFORMED], "the reply must arrive verbatim"
# --------------------------------------------------------------------------------------------
# T3 — the CONTROL: no parse failure, no artefact.
# --------------------------------------------------------------------------------------------
async def test_a_run_whose_replies_parse_writes_no_artefact(tmp_path: Path) -> None:
"""A clean run must leave the outbox exactly as it was before this seam existed. Without this
control an implementation that writes the file unconditionally passes T1 and T4, and the
artefact's PRESENCE would then say nothing about whether anything failed to parse.
RED when the ``if parse_failures`` guard is dropped."""
outbox_dir = tmp_path / "outbox"
run_id = "run-parse-clean"
result = await _run(_malformed_then_valid(0), outbox_dir, run_id=run_id, max_rounds=8)
assert result.outcome is not None
assert not _artefact(outbox_dir, run_id).exists()
# The pre-existing artefacts are untouched — the addition is inert on the clean path.
# ``-debate.json`` (S2c) is written on EVERY bundle-path run that has an outbox,
# unlike this artefact whose PRESENCE is its signal: a debate that opened nothing is
# the S2c regression itself, so it must be readable rather than inferred from a
# file that is not there.
written = sorted(p.name for p in outbox_dir.iterdir())
assert written == [
f"{run_id}-debate.json",
f"{run_id}-outcome.json",
f"{run_id}-proposal.json",
f"{run_id}-runconfig.json",
]
# --------------------------------------------------------------------------------------------
# T4 — the mirror of T1: a run that COMPLETES writes the artefact too.
# --------------------------------------------------------------------------------------------
async def test_completed_run_writes_the_artefact_too(tmp_path: Path) -> None:
"""One unparseable reply, then a proposal that validates: the run completes normally and the
discarded first reply is still recoverable. Without this, an implementation that writes only
from the exception arm would pass T1 and silently lose every parse failure on runs that
eventually succeeded — the common case once prompting improves.
RED when the write happens only on the exception path."""
outbox_dir = tmp_path / "outbox"
run_id = "run-parse-recovered"
result = await _run(_malformed_then_valid(1), outbox_dir, run_id=run_id, max_rounds=8)
assert result.outcome is not None
failures = _failures(outbox_dir, run_id)
assert len(failures) == 1
assert _SENTINEL in failures[0]["text"]
# --------------------------------------------------------------------------------------------
# T5 — the marker gate.
# --------------------------------------------------------------------------------------------
def test_the_sentinel_is_absent_from_the_bundle() -> None:
"""Repo rule for bundle-driven tests: a marker asserted as PRESENT must be absent from the
bundle, or bundle context reaching the prompt could satisfy the assert on its own."""
hits = [
path.name
for path in BUNDLE_DIR.rglob("*")
if path.is_file() and _SENTINEL in path.read_text(encoding="utf-8", errors="ignore")
]
assert hits == []