portfolio-optimiser/tests/test_parse_error_feedback_loadbearing.py
Kjell Tore Guttormsen c8f0c8f7c4 feat(p20): the requirement that is RIGHT, and a clause number that is not a price
Three seams, one commit: A, B and C touch the same four modules (run.py carries
the debate task, the grounding composition and the announcement; okf.py carries
one reference-number vocabulary read by both A and B), so splitting them into
three commits would have meant hunk-level staging of entangled files. Stated
rather than silently restructured.

A — the declaration answers with the DOCUMENT's own words. Measured: 13
declarations over round 3 and P17b, not one naming a fasit concept, while the
tool answered {"declared": true, ...} by echoing the caller's own arguments. It
now returns the document's title and req_number, read off Bundle.context_files
(so the type: verdict layer can never be named back), plus the sentence saying
what the declaration binds. A path the base carries as no concept answers with
empty strings rather than refusing. The commission's success_criteria now reach
the DEBATE task through mandate.criteria_block, the one renderer, empty when
there are none — which is what keeps every un-commissioned prompt, and the
golden, byte-identical.

B — a clause number is not a price. THE ORDER'S OWN RULE WAS FELLED BY
MEASUREMENT: it asks to refuse a code that IS declared req_number/prosessnr,
and neither of its two known positives is. n500 declares seksjon 10.4.1..10.4.4
but never the bare 10.4; r761 declares 2727 prosessnr and 2753 seksjon, none of
them 1.10.4, which occurs once, as prose ("iht. vegnormal N200 kap. 1.10.4").
The COMPLEMENT fires on both and closes the hole _ground_against_input already
admits in writing -- "it fails OPEN on a coincidental match". Unanchored run +
requirement-shaped code + the base declares a vocabulary + the code is not in
it -> refused, naming the denominator. All five of kontrakt-sorasen's real
process codes ARE declared and pass, which is what keeps the one context set
built on real codes measurable. Replayed over all 24 codes of round 3 + P17b:
exactly the two known positives flip validated -> rejected, 22 unchanged.

C — a parse failure no longer burns the round ledger blind. _fetch_parsed takes
a BUILDER instead of a finished message list, so the retry carries the parse
reason; measured, kontrakt-sorasen-04 spent 11 of 12 rounds re-asking the same
question. And announced_subject names the routed bases instead of saying "the
portfolio" for a two-base commission.

Suite 1807/5 (from 1781, +26, 0 removed), golden demo-transcript.stdout
BYTE-UNCHANGED (shasum -a 1 of the CONTENT = ea8c534773acdbe41ae68f2c55724d69aaf8be4f),
ruff and mypy clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 06:02:46 +02:00

129 lines
5.8 KiB
Python

"""P20 DEL C — a parse failure that does not burn the round ledger, and an announcement that
names what it is about.
**C1, what was measured (P19 F4).** ``_fetch_parsed`` retried a malformed reply with the
BYTE-IDENTICAL prompt. One round-3 run, ``kontrakt-sorasen-04``, left a
``{run_id}-parse-failures.json`` with ELEVEN rows, every one of them the same failure
(``claimed_saving_nok`` ≤ 0) — eleven of the run's twelve rounds, spent re-asking a question the
model had already answered the same wrong way, because nothing ever told it what was wrong. Step
5's ``prior_rejection`` carries VALIDATOR rejections; a reply that never parsed never reaches a
validator, so no existing block could carry it.
**C2, what was measured (P17b F5).** ``--across-bundle`` takes no ``--project-id``, so the
announcement — the one thing printed before the first paid call — said "Run mandate for the
portfolio" for a commission dispatched across two named knowledge bases.
What each arm pins:
(a) the reason reaches the NEXT attempt's prompt, verbatim, asserted on what the client received;
(b) attempt 1 is byte-identical: a run whose first reply parses sends the pre-P20 prompt;
(c) the block is per-RETRY — once a reply parses, the next attempt does not carry a stale reason;
(d) the evidence artefact is unchanged: the verbatim text is still captured (funn 1 stands);
(e) the announcement names the routed bases by their DECLARED ids;
(f) an unresolvable base falls back to its directory name rather than refusing — the
``dimension_label`` precedent, so announcing never changes which error an operator sees;
(g) a single-project run and a portfolio pass announce exactly as before.
"""
from __future__ import annotations
import asyncio
from pathlib import Path
from typing import Any
from portfolio_optimiser.budget import Budget, TokenMeter
from portfolio_optimiser.generate import ParseFailure, _build_messages, generate_via_llm
from portfolio_optimiser.reference_domain import Project
from portfolio_optimiser.run import announced_subject
from portfolio_optimiser.simulation import ScriptedChatClient
_EXAMPLES = Path(__file__).resolve().parents[1] / "shared" / "examples"
_TUNNEL = _EXAMPLES / "tunnel-hauglia"
#: The exact failure ``kontrakt-sorasen-04`` produced eleven times: a well-formed JSON object
#: whose ``claimed_saving_nok`` is 0, refused by pydantic before any validator sees it.
_UNPARSEABLE = (
'{"measure":"m","affected_items":[{"code":"C-1","quantity":1000,"unit_cost":100}],'
'"claimed_saving_nok":0}'
)
#: The grounding this loop declares: P7 requires the code to occur in the input verbatim.
_CONTEXT = "The project price schedule carries cost line C-1."
_VALID = (
'{"measure":"m","affected_items":[{"code":"C-1","quantity":1000,"unit_cost":100}],'
'"claimed_saving_nok":5000}'
)
def _project() -> Project:
return Project(id="p", name="n", description="d", currency="NOK", cost_items=(), docs_dir=".")
def _generate(replies: list[str]) -> tuple[Any, list[str], list[ParseFailure]]:
"""Drive the real loop with the repo's ONE scripted client, recording every prompt sent."""
sink: list[str] = []
failures: list[ParseFailure] = []
client = ScriptedChatClient(script=list(replies), sink=sink, role="proposer")
result = asyncio.run(
generate_via_llm(
client,
_project(),
_CONTEXT,
TokenMeter(Budget(max_tokens=100_000, max_rounds=8)),
parse_failures=failures,
)
)
return result, sink, failures
# ------------------------------------------------------------------------------- C1
def test_the_parse_reason_reaches_the_next_attempts_prompt() -> None:
"""(a) The retry is no longer blind — asserted on what the CLIENT received."""
_result, sink, _failures = _generate([_UNPARSEABLE, _VALID])
assert len(sink) >= 2, sink
assert "could not be PARSED" in sink[1]
assert "claimed_saving_nok" in sink[1]
def test_the_first_prompt_is_byte_identical_to_the_pre_p20_one() -> None:
"""(b) Without this, (a) could be satisfied by always appending the block."""
_result, sink, _failures = _generate([_VALID])
assert "could not be PARSED" not in sink[0]
assert sink[0] == _build_messages(_project(), _CONTEXT)[0].text
def test_a_reason_does_not_survive_a_reply_that_parsed() -> None:
"""(c) Per-RETRY, like ``prior_rejection`` is per-attempt: no stale instruction."""
assert "could not be PARSED" not in _build_messages(_project(), "c")[0].text
carried = _build_messages(_project(), "c", parse_error="ValueError: x")[0].text
assert "ValueError: x" in carried
def test_the_verbatim_evidence_is_still_captured() -> None:
"""(d) Fase 1b funn 1 stands: the paid reply's own text survives the retry."""
_result, _sink, failures = _generate([_UNPARSEABLE, _VALID])
assert len(failures) == 1
assert failures[0].text == _UNPARSEABLE
assert failures[0].error.startswith("ValidationError")
# ------------------------------------------------------------------------------- C2
def test_the_announcement_names_the_routed_bases() -> None:
"""(e) Two bases, two declared ids — not "the portfolio"."""
assert announced_subject(None, (str(_TUNNEL),)) == "tunnel-hauglia"
def test_an_unresolvable_base_falls_back_to_its_directory_name(tmp_path: Path) -> None:
"""(f) Naming never changes which error an operator sees (the dimension_label precedent)."""
missing = tmp_path / "not-a-base"
assert announced_subject(None, (str(missing),)) == "not-a-base"
def test_the_two_older_subjects_are_unchanged() -> None:
"""(g) A named project wins; no bases at all is still the portfolio."""
assert announced_subject("proj-1", ()) == "proj-1"
assert announced_subject("proj-1", ("ignored",)) == "proj-1"
assert announced_subject(None, ()) == "the portfolio"