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

@ -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).