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:
parent
5bd8e1caa1
commit
e3718908d0
5 changed files with 413 additions and 7 deletions
24
CLAUDE.md
24
CLAUDE.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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], ...] = ()
|
||||
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).
|
||||
|
|
|
|||
284
tests/test_parse_failure_capture_loadbearing.py
Normal file
284
tests/test_parse_failure_capture_loadbearing.py
Normal 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 == []
|
||||
Loading…
Add table
Add a link
Reference in a new issue