Two defects the mutation battery and the paid round found, both measured before being touched. (1) code_forms described the WRONG candidate. Every per-approach artefact copied the run's stamp and overrode only validator_decision, so an artefact about approach 2 reported approach 1's codes. Measured in BOTH round 3 and round 4 -- and stress.py, which reads this field before re-deriving, then produced an EMPTY prose_codes for every approach but the first, which is what round 3's table was built on. The field's own comment already says it is stamped "off the proposal being stamped"; run-level was the drift, not the intent. Model, citations and token usage stay the run's, because they are the run's. (2) The C2 announcement seam had no witness. Mutation C-iii reverted the call site to `args.project_id or "the portfolio"` and the WHOLE suite stayed green (1808/5): all three arms drove announced_subject directly. The missing arm drives main() on a free dry run and reads the announcement off STDOUT, where an operator reads it, and is red against exactly that mutation. Sixteen mutations, ALL red against the whole suite. Green control 1809/5 (from 1781, +28, 0 removed), golden demo-transcript.stdout BYTE-UNCHANGED (shasum -a 1 of the CONTENT = ea8c534773acdbe41ae68f2c55724d69aaf8be4f). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
168 lines
7 KiB
Python
168 lines
7 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
|
|
import json
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import pytest
|
|
|
|
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_cli_prints_the_routed_bases(
|
|
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
|
) -> None:
|
|
"""(e2) The WIRING, not the renderer.
|
|
|
|
MEASURED: mutation C-iii reverted the call site to ``args.project_id or "the portfolio"`` and
|
|
the WHOLE suite stayed green (1808/5) — the renderer had three arms and the call site none, so
|
|
the seam C2 exists for was unwitnessed. This arm drives ``main()`` on a free dry run and reads
|
|
the announcement off stdout, which is where an operator reads it.
|
|
"""
|
|
from portfolio_optimiser.run import main
|
|
|
|
mandate = tmp_path / "m.json"
|
|
mandate.write_text(
|
|
json.dumps({"objective": "cut cost", "approaches": [], "allow_own_proposals": True}),
|
|
encoding="utf-8",
|
|
)
|
|
rc = main(
|
|
[
|
|
"--across-bundle",
|
|
str(_TUNNEL),
|
|
"--mandate",
|
|
str(mandate),
|
|
"--run-id",
|
|
"r1",
|
|
"--outbox-dir",
|
|
str(tmp_path / "out"),
|
|
"--live-dry-run",
|
|
]
|
|
)
|
|
out = capsys.readouterr().out
|
|
assert rc == 0, out
|
|
assert "Run mandate for tunnel-hauglia" in out
|
|
assert "the portfolio" not in out
|
|
|
|
|
|
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"
|