feat(1b): fang den råe modell-svarteksten ved parse-feil

Fase 1b funn 1 lukket. generate._fetch_parsed kastet hvert uparsebart modellsvar i
except: continue, så prosjektets første levende kjøring brant tolv runder på formatfeil
og etterlot null tegn av det modellen faktisk sa. Enhver videre betalt kjøring ville
vært gjetning.

HVOR teksten overflates er avgjort av en måling, ikke av symmetri med Steg 5:
meter.tick_round() raiser BudgetExceeded INNE i _fetch_parsed, og uten mandat fanger
ingen den, så på nøyaktig den stien fangsten finnes for returnerer generate_via_llm
ingenting. Et felt på GenerationResult ville vært blindt for den, og et outbox-artefakt
skrevet etter kjøringen likeså. Sinken speiler meter: en kaller-eid akkumulator hvis
innhold kalleren holder uansett hvordan løkka endte. Artefaktet skrives fra en finally,
ikke except BudgetExceeded, og kun når noe faktisk feilet.

Iron Law: testfila rød ved collection FØR modulen fantes. Seks mutasjoner mot HELE
suiten, alle røde, hver med sin egen signatur; grønn kontroll 859 passed / 4 skipped
(fra 854). Den skarpeste er trunkering som BEHOLDER sentinelen: da faller kun
verbatim-asserten, som er det som beviser at den ene testen bærer den egenskapen.

Samme økt: mutasjonsmålingen økt 34 utsatte for de to Fase 5-gatene er kjørt. Fire
preflight-mutasjoner mot hele suiten, alle røde på riktig test og ingen annen (detach
fallbacken 2 røde, snu presedensen, presence i stedet for truthiness, avslaget navngir
kun vårt navn). To handover-mutasjoner kjørt MÅLRETTET mot egen testfil under tidspress,
ikke mot hele suiten — uttalt, ikke skjult (drop uv.lock, bygg fra arbeidstreet i stedet
for tracked files). De to DEPLOY.md-mutasjonene gjenstår: git archive leser HEAD, ikke
arbeidstreet, så de krever en midlertidig commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WLQd8ojQ9xwxhB8vsETYBs
This commit is contained in:
Kjell Tore Guttormsen 2026-08-14 11:41:04 +02:00
commit e3718908d0
5 changed files with 413 additions and 7 deletions

View file

@ -531,6 +531,30 @@ Python ≥3.10. MAF (`agent-framework-core` 1.9.0). Pakkehåndtering: `uv`. To b
import), eksempelet er gyldig pipeline-input inkl. `FeedbackContract` (RØD på skjema-/kontrakt-drift,
på en throwaway-kopi — aldri den git-tracked fixturen), og sim-ens markør følger artefakt-fila (RØD i
det øyeblikk personaen re-inlines).
- **Den råe svarteksten fanges i en KALLER-EID SINK, ikke i en returverdi (Fase 1b, funn 1):**
`generate._fetch_parsed` kastet hvert uparsebart modellsvar i `except: continue`, så prosjektets
første levende kjøring brant tolv runder på formatfeil og etterlot **null tegn** av det modellen
faktisk sa — enhver videre betalt kjøring ville vært gjetning. **HVOR teksten overflates er avgjort
av en MÅLING, ikke av symmetri med Steg 5:** `meter.tick_round()` raiser `BudgetExceeded` INNE i
`_fetch_parsed`, og uten mandat fanger ingen den (`run.py`s ene `except BudgetExceeded` er
mandat-armen) — så på nøyaktig den stien fangsten finnes for, RETURNERER `generate_via_llm`
ingenting. Et felt på `GenerationResult` (Steg 5-formen) er derfor blindt for den, og et
outbox-artefakt skrevet ETTER kjøringen likeså. Sinken speiler i stedet `meter`: en kaller-eid
akkumulator løkka muterer, hvis innhold kalleren holder uansett hvordan løkka endte. Steg 5s
«returverdi, ikke out-parameter» gjelder en verdi som NÅR kalleren; her gjør den ikke det, og å
kopiere regelen blindt ville gjenoppbygd defekten ett lag opp. Artefaktet
`{run_id}-parse-failures.json` skrives fra en **`finally`**, ikke `except BudgetExceeded` — enhver
exception ut av genereringen ødelegger samme bevis, og en liste over exception-typer er en liste
som blir foreldet. **Teksten er VERBATIM** (en forkortelse gjør beviset om til en parafrase), og
fila skrives KUN når noe faktisk feilet, så dens tilstedeværelse ER signalet. Byte-determinisme
påstås IKKE for dette ene artefaktet — innholdet er en levende modells prosa. Load-bearing MÅLT
(`tests/test_parse_failure_capture_loadbearing.py`), seks mutasjoner alle røde mot HELE suiten +
grønn kontroll 859/4: detach fangsten (3 røde) · flytt skrivingen ut av `finally` (1 rød, KUN
budsjett-testen) · detach run-wiringen (2 røde, generate-testen grønn) · skriv artefaktet alltid
(kontrollen + den eksisterende `a5`-inerthetstesten) · trunker teksten til 40 tegn (3 røde) ·
trunker til 100 tegn slik at sentinelen OVERLEVER (1 rød — verbatim-asserten alene, den skarpe
diskriminatoren). Ærlighets-grense: `_charge_usage` kan raise FØR parse, og et svar tapt der er
ikke en parse-feil og fanges ikke.
- **Overleverings-pakka ER `git archive HEAD`, aldri en kuratert kopi (Fase 5):**
`scripts/make-handover-package.sh` bygger én zip en ekstern organisasjon deployer uten å klone
repoet. **Tracked files only er hele eksponerings-kontrollen**`STATE.md`, `*.local.md` og

View file

@ -44,6 +44,23 @@ class GenerationError(RuntimeError):
"""No parseable proposal could be produced within the attempt budget."""
@dataclass(frozen=True)
class ParseFailure:
"""One model reply that did NOT parse into the typed IR, kept VERBATIM (Fase 1b, funn 1).
``text`` is the reply exactly as the model produced it never truncated, stripped or
summarised. It is the thing the run PAID for and the only evidence of *why* the reply did not
parse; a paraphrase would make the next paid run a guess again, which is the defect this type
exists to close. ``error`` names the parse error itself (``json.JSONDecodeError`` vs a pydantic
``ValidationError`` are very different diagnoses: leaked prose vs a wrong-shaped object).
Collected into a CALLER-OWNED sink rather than returned see ``generate_via_llm``.
"""
text: str
error: str
@dataclass(frozen=True)
class GenerationResult:
"""What one ``generate_via_llm`` call produced: the outcome, and the falsification history that
@ -163,6 +180,7 @@ async def generate_via_llm(
max_attempts: int = 3,
baseline: CostBaseline | None = None,
approach: Approach | None = None,
parse_failures: list[ParseFailure] | None = None,
) -> GenerationResult:
"""Async LLM path: non-streaming chat -> parse -> validate, with TWO bounded retry kinds,
the meter checked in this loop:
@ -189,6 +207,19 @@ async def generate_via_llm(
falsified per ATTEMPT like any other rejection and its reason feeds the next attempt's prompt
through the SAME informed-refinement path (Step 5), which is why no new loop appears here.
``parse_failures`` (Fase 1b, funn 1) is a CALLER-OWNED sink: every reply that fails to parse is
appended to it VERBATIM, at the moment it fails. It is an out-parameter and not part of the
return value ON PURPOSE, and the reason is measured rather than stylistic. ``meter.tick_round``
raises ``BudgetExceeded`` inside the inner fetch loop, so on the path this capture exists for
a model whose replies never parse, which burns the round ledger this function raises and
returns NOTHING. That is exactly the live Fase-1b failure. A field on ``GenerationResult`` (the
Step-5 ``refinements`` shape) would be blind to it, as would any artefact written by the caller
*after* a successful return. The sink mirrors ``meter`` instead: a caller-owned accumulator this
loop mutates, whose contents the caller still holds however the loop ended. 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, so the rule is cited and departed from deliberately. That
a caller can forget is answered by a test on the wiring, not by a shape that cannot work.
Returns a ``GenerationResult``: the ``ValidatedProposal | Rejection`` outcome plus every
rejection that was fed back into a later attempt's prompt. Surfacing that history changes
nothing about the loop's BOUND — ``max_attempts`` and ``meter.tick_round`` are exactly as
@ -204,7 +235,14 @@ async def generate_via_llm(
_charge_usage(meter, reply)
try:
return _parse_ir(reply.text, project)
except (ValidationError, ValueError, TypeError):
except (ValidationError, ValueError, TypeError) as exc:
# Capture BEFORE the retry: this reply was paid for, and once ``continue`` runs the
# only record of what the model actually said is gone (Fase 1b, funn 1). Verbatim —
# the operator is diagnosing a format failure, so any shortening removes evidence.
if parse_failures is not None:
parse_failures.append(
ParseFailure(text=reply.text, error=f"{type(exc).__name__}: {exc}")
)
continue
last: Rejection | None = None

View file

@ -31,6 +31,7 @@ called by ``run_project``, not a public authoring API (contrast ``verdicts.write
from __future__ import annotations
import json
from collections.abc import Mapping, Sequence
from pathlib import Path
from typing import TYPE_CHECKING, Any
@ -131,6 +132,39 @@ def outcome_payload(
}
def write_parse_failures(
outbox_dir: str,
run_id: str,
*,
failures: Sequence[Mapping[str, str]],
) -> Path:
"""Write ``{run_id}-parse-failures.json`` — the raw model replies that did NOT parse into the
typed IR (Fase 1b, funn 1) and return its path.
**This is the only outbox artefact written from a ``finally``**, because it is the only one whose
subject is a run that may never finish: the measured 1b failure exhausted the round ledger inside
the generation loop and left ``run_project`` as a ``BudgetExceeded``, so the proposal/outcome
writers below were never reached. An artefact that recorded parse failures only for runs that
survived them would be silent for exactly the runs that need it.
Takes plain mappings (the caller flattens ``generate.ParseFailure``), so this module stays
MAF-free ``generate`` imports ``agent_framework``, and importing it here would drag MAF into
the RAW output layer.
Byte-determinism is NOT claimed for this file, unlike its two neighbours: its content is a live
model's prose, which is not reproducible by construction. It uses the same ``_dump`` form for
consistency of reading, not to pin bytes. The caller writes it only when there is at least one
failure, so the file's PRESENCE is itself the signal that something did not parse."""
directory = Path(outbox_dir)
directory.mkdir(parents=True, exist_ok=True)
path = directory / f"{run_id}-parse-failures.json"
path.write_text(
_dump({"run_id": run_id, "parse_failures": [dict(f) for f in failures]}),
encoding="utf-8",
)
return path
def write_run_config(
config_dir: str,
run_id: str,

View file

@ -54,7 +54,7 @@ from portfolio_optimiser.datasource import (
retrieve_chunks,
)
from portfolio_optimiser.dimension import Dimension, admits, load_dimension
from portfolio_optimiser.generate import generate_via_llm
from portfolio_optimiser.generate import ParseFailure, generate_via_llm
from portfolio_optimiser.ir import SavingsProposal
from portfolio_optimiser.mandate import (
OWN_PROPOSAL_ID,
@ -649,20 +649,46 @@ async def run_project(
# is untouched, and the history is accumulated here in call order — one entry per approach that
# needed correcting, concatenated (see ``RunResult.refinements`` for that honesty limit).
refinements: list[Rejection] = []
# Fase 1b, funn 1: the raw replies that did not parse. Owned HERE, beside ``meter``, and handed
# down — not read back off a return value. ``generate_via_llm`` raises ``BudgetExceeded`` from
# inside its own fetch loop when the round ledger runs out on unparseable replies (the measured
# live failure), and on that path it returns nothing at all; a caller-owned accumulator is the
# only shape that still holds the evidence afterwards. Concatenated across commissioned
# approaches rather than keyed per approach, mirroring ``RunResult.refinements``' honesty limit.
parse_failures: list[ParseFailure] = []
async def _evaluate(approach: Approach | None) -> ValidatedProposal | Rejection:
generated = await generate_via_llm(
proposer_client, project, gen_context, meter, baseline=baseline, approach=approach
proposer_client,
project,
gen_context,
meter,
baseline=baseline,
approach=approach,
parse_failures=parse_failures,
)
refinements.extend(generated.refinements)
return generated.outcome
coverage: tuple[ApproachOutcome, ...] = ()
evaluated: tuple[tuple[str, ValidatedProposal | Rejection], ...] = ()
if mandate is None:
validator_outcome = await _evaluate(None)
else:
validator_outcome, coverage, evaluated = await _evaluate_mandate(mandate, _evaluate)
try:
if mandate is None:
validator_outcome = await _evaluate(None)
else:
validator_outcome, coverage, evaluated = await _evaluate_mandate(mandate, _evaluate)
finally:
# ``finally``, not ``except BudgetExceeded``: the round ledger is today's known way out, but
# any exception leaving generation destroys the same evidence, and a per-exception-type list
# is a list that goes stale. Written only when something actually failed to parse, so the
# file's presence is the signal (a run whose replies all parse leaves the outbox unchanged).
if outbox_dir is not None and parse_failures:
assert run_id is not None # narrowed by the step-0 guard (no wall-clock default)
outbox.write_parse_failures(
outbox_dir,
run_id,
failures=[{"text": f.text, "error": f.error} for f in parse_failures],
)
proposal = validator_outcome.proposal
# 6. First-class provenance stamp (authoritative; independent of MAF Annotation).

View file

@ -0,0 +1,284 @@
"""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.
written = sorted(p.name for p in outbox_dir.iterdir())
assert written == [
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 == []