Order 20260908T195801Z. Findings 4 and 5 from the S7 acid test, then the two things finding 99 measured and deliberately did not fix (D3, D2). No user-facing surface changes: no new flag, no new command, no changed output contract. Both seams are internal (the pre-pass rendering, and the shape a tool answers a model with), so [skip-docs] rather than a README edit that would describe nothing an operator can do differently. FINDING 4 -- MEASURED, NOTHING BUILT. K2's price schedule IS readable without guessing (8 column spans, 71 of 91 non-blank rows give >= 2 cells, the split stable for K = 2..64). But 0 of 92 rows name all three of code/quantity/unit_cost -- also under a looser substring match -- and 0 of 91 data rows carry code + quantity + amount. The triple is not formatted away; it is not in the document. It is a price SUMMARY plus nine rate cards whose unit-price columns are empty (pre-award). The order's binding decision rule therefore falls against building: --derive-cost-baseline keeps refusing, and MAJOR-4's own honesty limit holds. FINDING 5 -- BUILT. Measured on the actual rendering path (concept_text, not the raw file): the delivered excerpt is 104 lines / 67 245 chars, carrying 208 interior whitespace runs, 117 of them >= 100 and the longest 887 -- 56 806 of 67 245 characters = 84.5 %, over 72 of 104 lines. collapse_padding, called from _data_blocks (the one renderer both arms share, and therefore AFTER verify_against_bundle -- collapsing in concept_text would break every payload's own digest), gives -72.4 %: line count invariant, non-whitespace byte-identical, leading indentation untouched, no number changed. F99-D3 -- read_file / read_dir / read_bundle now RETURN their refusal. MAF turns a tool raise into "Error: Function failed." (_tools.py:1410-1432, :1427) and counts it against DEFAULT_MAX_CONSECUTIVE_ERRORS_PER_REQUEST = 3, so everything the refusing arm knows is destroyed on the way out. The gates are unchanged; the property they exist for -- the reason travels, the bytes never do -- is now asserted explicitly on the returned value. The arm is keyed on named classes, never bare Exception, because ExplorationError is itself a RuntimeError subclass. F99-D2 -- the invariant row, plus one for finding 5 (a stated deviation from "one row only": finding 5 is a separately built seam and the ledger's standing rule requires its own row). 19 existing arms rewritten, never deleted and never weakened: where the class carried a distinction, the refusal KIND carries it now. 13 mutations, all red against the whole suite (W1-W5, M1-M8), each restored from scratchpad with shasum -c. Control 1543 passed / 5 skipped (from 1529/5, a strict superset, 0 removed). Golden demo-transcript.stdout unchanged (shasum -a 1 of the CONTENT = ea8c534773acdbe41ae68f2c55724d69aaf8be4f). Measurement: docs/2026-09-08-funn-4-5-og-read-nekt.md Co-Authored-By: Claude <Opus 5>
349 lines
16 KiB
Python
349 lines
16 KiB
Python
"""The verdict layer is REFUSED by ``read_file``, however the path was found.
|
|
|
|
The gap S2c stated as an honesty limit and left open (``docs/2026-09-04-s2c-debatt-k2.md``, closing
|
|
section): ``read_file`` is path-addressable to the ``type: verdict`` layer. No listing names it —
|
|
``Bundle.context_files`` drops it at every level, so neither ``read_bundle`` nor ``read_dir`` nor
|
|
``bundle_context`` ever mentions a verdict — but a GUESSED path reached one, and reaching one that
|
|
way bypasses the gated ExpeL fold that is the only sanctioned route from a prior expert judgement
|
|
into a hypothesis (målbilde §4/§5). MEASURED before this work: ``read_file`` on the fixture base's
|
|
``verdict-led-fro.md`` returned all 2 883 characters of it.
|
|
|
|
The property was inherited from S7a-3 and, since S2c handed the DEBATE the navigator's four tools,
|
|
became reachable from the pipeline as well — which is why the rule lives in ONE place, the tool,
|
|
rather than in two callers. Rendering cannot hold it: S2c measured that a filter in the listing
|
|
while ``read_file`` still serves the bytes is "a filter in name only", and prompt text cannot hold
|
|
it either, because a model-chosen path is untrusted input by construction.
|
|
|
|
Arms, each with a named detach point:
|
|
|
|
* **(1) the exploration path refuses.** ``navigator_tools`` called directly, ``dimension=None`` —
|
|
the exploration's own call. CONTROL: an ordinary concept file in the SAME base still reads, so
|
|
the refusal is caused by the layer and not by an unreachable fixture.
|
|
* **(2) the debate path refuses.** The same tool as ``run_project`` builds it, driven through the
|
|
real seam, plus a BEHAVIOURAL run whose proposer manuscript asks for the verdict by path.
|
|
* **(3) the gated route still works.** The seed verdict's realization signal still reaches the
|
|
hypothesis prompt through the ExpeL fold — the refusal closes the ungated door, not the door.
|
|
* **(4) a refused read is RECORDED, never silent.** The call stands in ``debate_tool_calls`` and in
|
|
``{run_id}-debate.json`` with its path, while the verdict's body reaches NO prompt. Recording is
|
|
gated on its own: MEASURED, moving the recorder's append after ``call_next`` empties the trace
|
|
for exactly this call, because a refused invocation never returns.
|
|
* **(5) the rule reads the DOCUMENT, not the walk.** A verdict file no index links to — never
|
|
navigated, therefore absent from ``Bundle.verdicts`` — is refused too. "However the path was
|
|
found" is the whole point: gating on the navigation would leave the guessed path to an unlinked
|
|
judgement open, which is the defect in its own disguise.
|
|
* **(6) the ladder is intact.** ``index.md`` and ordinary concept files still read whole.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import shutil
|
|
from collections.abc import Callable
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from agent_framework import BaseChatClient
|
|
|
|
from portfolio_optimiser import okf
|
|
from portfolio_optimiser.explore import VerdictLayerRefused, navigator_tools
|
|
from portfolio_optimiser.run import run_project
|
|
from portfolio_optimiser.simulation import ScriptedChatClient, scripted_factory
|
|
from portfolio_optimiser.validator import ValidatedProposal
|
|
from portfolio_optimiser.verdicts import seed_store_from_bundle
|
|
|
|
BUNDLE_DIR = Path(__file__).resolve().parents[1] / "shared" / "examples" / "bygg-energi-mikro"
|
|
_PID = "BYGG-KONTOR-NORD"
|
|
_VERDICT_FILE = "verdict-led-fro.md"
|
|
_CONCEPT_FILE = "metode-ipmvp-a.md"
|
|
_VERDICT_INPUT = {"decision": "approved", "rationale": "expert reviewed (sim)"}
|
|
|
|
_VALID_REPLY = (
|
|
'{"measure":"LED-retrofit","affected_items":'
|
|
'[{"code":"ENERGI-TOTAL-EL","quantity":300000,"unit_cost":1.0}],'
|
|
'"claimed_saving_nok":30000}'
|
|
)
|
|
_CHECKER_REPLY = "Reasoning holds.\nVERDICT: APPROVE"
|
|
|
|
#: Written into a COPY of the base, into the verdict body alone. A sentinel of our own rather than
|
|
#: a phrase lifted out of the shipped file: the ExpeL fold legitimately renders parts of a verdict
|
|
#: into the generation prompt, so a probe made of the file's own prose could not tell the ungated
|
|
#: read from the gated one.
|
|
_VERDICT_SENTINEL = "VERDICT-LEAK-SENTINEL-q4w5e6"
|
|
#: The exact ``_verdict_rationale`` fewshot string (test_step1_expel_loadbearing's marker).
|
|
_REALIZATION_SIGNAL = "realiseringsgrad=0.82"
|
|
|
|
|
|
def _tools(bundle_dir: str, dimension: str | None = None) -> dict[str, Any]:
|
|
return {t.name: t for t in navigator_tools([bundle_dir], dimension=dimension)}
|
|
|
|
|
|
async def _invoke(tool: Any, **arguments: Any) -> str:
|
|
"""A tool's answer as text (S2c's helper: ``invoke`` returns ``[Content]``, and a test that
|
|
stringified the list would compare object reprs and pass against anything)."""
|
|
return "".join(getattr(c, "text", "") or "" for c in await tool.invoke(arguments=arguments))
|
|
|
|
|
|
def _base_with_a_marked_verdict(tmp_path: Path) -> str:
|
|
"""A copy of the fixture base whose verdict body carries ``_VERDICT_SENTINEL``."""
|
|
copy = tmp_path / "bundle"
|
|
shutil.copytree(BUNDLE_DIR, copy)
|
|
verdict = copy / _VERDICT_FILE
|
|
verdict.write_text(
|
|
verdict.read_text(encoding="utf-8") + f"\n{_VERDICT_SENTINEL}\n", encoding="utf-8"
|
|
)
|
|
return str(copy)
|
|
|
|
|
|
def _base_with_an_unlinked_verdict(tmp_path: Path) -> str:
|
|
"""A copy of the fixture base plus a ``type: verdict`` file NO index links to, so navigation
|
|
never reaches it and it is absent from ``Bundle.verdicts``."""
|
|
copy = tmp_path / "unlinked"
|
|
shutil.copytree(BUNDLE_DIR, copy)
|
|
(copy / "verdict-orphan.md").write_text(
|
|
"---\ntype: verdict\ntitle: Orphan judgement\ndecision: approved\n---\n\n"
|
|
f"{_VERDICT_SENTINEL}\n",
|
|
encoding="utf-8",
|
|
)
|
|
return str(copy)
|
|
|
|
|
|
def _recording_factory(
|
|
sink: list[str], *, script: dict[str, Any] | None = None
|
|
) -> Callable[[str], BaseChatClient]:
|
|
"""S2c's recording factory: every prompt blob (text PLUS function call/result contents) lands
|
|
in ``sink``. ``Message.text`` alone measures a context-bearing prompt at a few characters."""
|
|
|
|
def factory(role: str) -> BaseChatClient:
|
|
if script is not None and role in script:
|
|
client: BaseChatClient = scripted_factory(script, [])(role)
|
|
else:
|
|
client = ScriptedChatClient(
|
|
_CHECKER_REPLY if role == "checker" else _VALID_REPLY, role=role
|
|
)
|
|
original = client._inner_get_response # type: ignore[attr-defined]
|
|
|
|
def recording(*, messages, options, stream=False, **kwargs): # type: ignore[no-untyped-def]
|
|
parts: list[str] = []
|
|
for message in messages:
|
|
text = getattr(message, "text", "") or ""
|
|
if text:
|
|
parts.append(text)
|
|
for content in getattr(message, "contents", ()) or ():
|
|
for attr in ("result", "arguments"):
|
|
value = getattr(content, attr, None)
|
|
if value:
|
|
parts.append(str(value))
|
|
sink.append("\n".join(parts))
|
|
return original(messages=messages, options=options, stream=stream, **kwargs)
|
|
|
|
client._inner_get_response = recording # type: ignore[attr-defined,method-assign]
|
|
return client
|
|
|
|
return factory
|
|
|
|
|
|
# ------------------------------------------------------------------- (1) the exploration path
|
|
|
|
|
|
async def test_the_exploration_path_refuses_a_verdict_document(tmp_path) -> None:
|
|
"""LOAD-BEARING (1): the exploration's own call — ``dimension=None``, which admits every
|
|
dimension — still refuses the verdict layer.
|
|
|
|
Detach point: remove the gate from ``read_file`` → RED."""
|
|
bundle_dir = _base_with_a_marked_verdict(tmp_path)
|
|
bundle_id = okf.reconcile_bundle_id(bundle_dir).id
|
|
tools = _tools(bundle_dir)
|
|
|
|
answer = await _invoke(tools["read_file"], bundle_id=bundle_id, path=_VERDICT_FILE)
|
|
assert answer.startswith(f"REFUSED ({VerdictLayerRefused.__name__})")
|
|
# F99-D3 made the refusal a RETURN VALUE, so the property the gate exists for has to be
|
|
# asserted on that value: the reason travels, the bytes never do.
|
|
assert _VERDICT_SENTINEL not in answer
|
|
|
|
|
|
async def test_an_ordinary_document_in_the_same_base_still_reads(tmp_path) -> None:
|
|
"""CAUSALITY CONTROL for (1): a concept file in the SAME base reads whole, so the refusal above
|
|
is caused by the layer rather than by a fixture nothing can open."""
|
|
bundle_dir = _base_with_a_marked_verdict(tmp_path)
|
|
bundle_id = okf.reconcile_bundle_id(bundle_dir).id
|
|
|
|
body = await _invoke(_tools(bundle_dir)["read_file"], bundle_id=bundle_id, path=_CONCEPT_FILE)
|
|
assert "IPMVP" in body, f"the control document did not read: {body[:120]!r}"
|
|
|
|
|
|
async def test_no_listing_names_the_verdict_either(tmp_path) -> None:
|
|
"""The OTHER half of "no listing names it": ``read_bundle`` still hides the layer, so the two
|
|
rungs agree. Without this the gate could be green while the listing advertised the document it
|
|
then refuses — the disagreement S2c's ``in_dimension`` rule exists to prevent."""
|
|
bundle_dir = _base_with_a_marked_verdict(tmp_path)
|
|
bundle_id = okf.reconcile_bundle_id(bundle_dir).id
|
|
|
|
listing = await _invoke(_tools(bundle_dir)["read_bundle"], bundle_id=bundle_id)
|
|
assert _VERDICT_FILE not in listing
|
|
|
|
|
|
# ------------------------------------------------------------------------ (2) the debate path
|
|
|
|
|
|
async def test_the_debate_path_refuses_a_verdict_document(tmp_path, monkeypatch) -> None:
|
|
"""LOAD-BEARING (2): the tool ``run_project`` hands the debate refuses the same document.
|
|
|
|
Built through the REAL seam rather than by calling ``navigator_tools`` again: the two callers
|
|
share one construction, and a test that rebuilt the tools itself would prove nothing about what
|
|
the pipeline actually gave its agents (S2c arm (e)'s own form).
|
|
|
|
Detach point: remove the gate from ``read_file`` → RED."""
|
|
import portfolio_optimiser.run as run_module
|
|
|
|
bundle_dir = _base_with_a_marked_verdict(tmp_path)
|
|
bundle_id = okf.reconcile_bundle_id(bundle_dir).id
|
|
captured: list[list[Any]] = []
|
|
original = run_module.fresh_workflow
|
|
|
|
def spy(*args: Any, **kwargs: Any) -> Any:
|
|
captured.append(list(kwargs.get("tools") or []))
|
|
return original(*args, **kwargs)
|
|
|
|
monkeypatch.setattr(run_module, "fresh_workflow", spy)
|
|
sink: list[str] = []
|
|
await run_project(
|
|
_PID,
|
|
"local",
|
|
docs_dir=bundle_dir,
|
|
bundle_dir=bundle_dir,
|
|
verdict_input=_VERDICT_INPUT,
|
|
client_factory=_recording_factory(sink),
|
|
)
|
|
|
|
assert captured, "the debate was never built"
|
|
read_file = next(t for t in captured[0] if getattr(t, "name", "") == "read_file")
|
|
answer = await _invoke(read_file, bundle_id=bundle_id, path=_VERDICT_FILE)
|
|
assert answer.startswith(f"REFUSED ({VerdictLayerRefused.__name__})")
|
|
assert _VERDICT_SENTINEL not in answer
|
|
|
|
|
|
# --------------------------------------------------------------- (3) the gated route survives
|
|
|
|
|
|
async def test_the_gated_expel_route_still_reaches_the_hypothesis(
|
|
make_recording_client_factory,
|
|
) -> None:
|
|
"""LOAD-BEARING (3): the sanctioned door is untouched — the seed verdict's realization signal
|
|
still reaches the generation prompt through the Step-1 ExpeL fold.
|
|
|
|
This is what separates a gate from a wall. A refusal that also closed the fold would look
|
|
identical on arms (1), (2) and (4) and would have removed the loop's whole learning path."""
|
|
store = seed_store_from_bundle(str(BUNDLE_DIR))
|
|
assert store.verdicts, "precondition: the bundle seeds exactly one verdict"
|
|
factory, recorded = make_recording_client_factory(_VALID_REPLY)
|
|
|
|
result = await run_project(
|
|
_PID,
|
|
"local",
|
|
docs_dir=str(BUNDLE_DIR),
|
|
bundle_dir=str(BUNDLE_DIR),
|
|
verdict_input=_VERDICT_INPUT,
|
|
store=store,
|
|
client_factory=factory,
|
|
)
|
|
|
|
assert isinstance(result.outcome, ValidatedProposal)
|
|
gen_prompts = [p for p in recorded if "SavingsProposal" in p]
|
|
assert gen_prompts, "the generation call must have happened"
|
|
assert any(_REALIZATION_SIGNAL in p for p in gen_prompts), (
|
|
"the prior verdict no longer reaches the hypothesis prompt — the refusal closed the GATED "
|
|
"route as well, which is a wall rather than a gate"
|
|
)
|
|
|
|
|
|
# ------------------------------------------------------------------ (4) the refusal is traced
|
|
|
|
|
|
async def test_a_refused_read_is_recorded_and_leaks_nothing(tmp_path) -> None:
|
|
"""LOAD-BEARING (4): the debate asks for the verdict by path; the read is refused, the CALL is
|
|
in the trace with its path, and the verdict's body reaches no prompt.
|
|
|
|
Both halves are needed and each has its own detach point. Without the recording an operator
|
|
reading ``{run_id}-debate.json`` after a paid run cannot tell a run that tried the ungated door
|
|
from one that never did — MEASURED: moving the recorder's append after ``call_next`` empties
|
|
the trace for exactly a refused call, because the invocation never returns. Without the leak
|
|
probe the gate could be recording refusals while the bytes left anyway."""
|
|
bundle_dir = _base_with_a_marked_verdict(tmp_path)
|
|
bundle_id = okf.reconcile_bundle_id(bundle_dir).id
|
|
outbox_dir = tmp_path / "outbox"
|
|
sink: list[str] = []
|
|
|
|
result = await run_project(
|
|
_PID,
|
|
"local",
|
|
docs_dir=bundle_dir,
|
|
bundle_dir=bundle_dir,
|
|
verdict_input=_VERDICT_INPUT,
|
|
outbox_dir=str(outbox_dir),
|
|
run_id="verdict-gate",
|
|
client_factory=_recording_factory(
|
|
sink,
|
|
script={
|
|
"proposer": [
|
|
{"call": "read_file", "args": {"bundle_id": bundle_id, "path": _VERDICT_FILE}},
|
|
_VALID_REPLY,
|
|
],
|
|
"checker": _CHECKER_REPLY,
|
|
},
|
|
),
|
|
)
|
|
|
|
leaking = [i for i, prompt in enumerate(sink) if _VERDICT_SENTINEL in prompt]
|
|
assert not leaking, (
|
|
f"the verdict body reached prompt(s) {leaking} — a guessed path still walks around the "
|
|
"gated ExpeL fold"
|
|
)
|
|
observed = [(c.name, c.bundle_id, c.path) for c in result.debate_tool_calls]
|
|
assert ("read_file", bundle_id, _VERDICT_FILE) in observed, (
|
|
f"the refused read left no trace: {observed} — a refusal nobody can see is a refusal "
|
|
"nobody can audit"
|
|
)
|
|
payload = json.loads((outbox_dir / "verdict-gate-debate.json").read_text(encoding="utf-8"))
|
|
assert {"name": "read_file", "bundle_id": bundle_id, "path": _VERDICT_FILE} in payload[
|
|
"tool_calls"
|
|
], f"the artefact does not carry the refused call: {payload['tool_calls']}"
|
|
|
|
|
|
# ------------------------------------------------------ (5) the document, not the walk, decides
|
|
|
|
|
|
async def test_an_unlinked_verdict_is_refused_too(tmp_path) -> None:
|
|
"""LOAD-BEARING (5): a verdict file navigation never reached is refused as well.
|
|
|
|
"However the path was found" is the order's own wording and the reason the rule reads the
|
|
RESOLVED DOCUMENT's frontmatter rather than looking the path up among the navigated files. An
|
|
implementation gated on ``Bundle.verdicts`` passes arms (1), (2) and (4) and still serves this
|
|
one — the defect wearing the fix's clothes.
|
|
|
|
Detach point: gate on the walk instead of the document → RED here and nowhere else."""
|
|
bundle_dir = _base_with_an_unlinked_verdict(tmp_path)
|
|
bundle_id = okf.reconcile_bundle_id(bundle_dir).id
|
|
|
|
assert not any(f.name == "verdict-orphan.md" for f in okf.navigate_bundle(bundle_dir).files), (
|
|
"precondition: the orphan must be OUTSIDE the walk, or this arm proves nothing"
|
|
)
|
|
answer = await _invoke(
|
|
_tools(bundle_dir)["read_file"], bundle_id=bundle_id, path="verdict-orphan.md"
|
|
)
|
|
assert answer.startswith(f"REFUSED ({VerdictLayerRefused.__name__})")
|
|
assert _VERDICT_SENTINEL not in answer
|
|
|
|
|
|
# ------------------------------------------------------------------------ (6) ladder intact
|
|
|
|
|
|
async def test_the_index_still_reads_whole(tmp_path) -> None:
|
|
"""The ladder's top rung is untouched: ``index.md`` is navigation, never a judgement, and
|
|
``read_file(id, 'index.md')`` is the disclosure level ``list_bundles``' excerpt points at.
|
|
|
|
Detach point: gate on the complement of ``context_files`` (which drops index files too) → RED."""
|
|
bundle_dir = _base_with_a_marked_verdict(tmp_path)
|
|
bundle_id = okf.reconcile_bundle_id(bundle_dir).id
|
|
|
|
whole = await _invoke(_tools(bundle_dir)["read_file"], bundle_id=bundle_id, path="index.md")
|
|
assert "progressiv disclosure" in whole.lower()
|