fix(explore): a plan review never shows an expert the word None and calls it progress

[skip-docs]

PM addendum 4 to order 20260902T151931Z-250257273.

PlanReviewRequest.current_progress and ParkedExploration.current_progress were
both built with str(review.current_progress). The value is a
MagenticProgressLedger | None and is None in every run measured so far, so
str() produced the four characters None, the renderer's truthiness guard found
them non-empty, and the terminal printed a "progress so far" section whose only
content was None. The same four characters went into
{run_id}-plan-review.json -- the one thing that crosses the process boundary in
the asynchronous door, where nobody can ask what it meant.

_progress_text is the ONE conversion, beside _plan_text: absent becomes the
empty string, never "None". The data layer states absence by being absent, as
Bundle.skipped's empty tuple does. The TERMINAL states it in words -- this is
the one surface where omission is wrong, because silence at a gate somebody
signs is exactly what PlanReviewInputError already refuses for EOF.

Measured, and it changed the test: reverting both sites left ONLY a
source-inspection arm red, which is a lint and not a gate, because the first
arms construct a PlanReviewRequest themselves and never enter either site. Each
site now has a behavioural witness that drives the real door -- the terminal for
the synchronous one, the parked question FILE for the asynchronous one.

Load-bearing measured, five mutations, all red against the WHOLE suite, each
with its own signature: both sites reverted (3 red) - synchronous site alone
(2) - parked site alone (2) - the terminal goes silent again (2) - the
placeholder always fires, swallowing a real ledger (1, the control alone).
Green control 1202 passed / 5 skipped; golden demo-transcript.stdout unchanged
(ea8c534773acdbe41ae68f2c55724d69aaf8be4f).

The recorder wiring in resume_exploration is untouched, per the order.
This commit is contained in:
Kjell Tore Guttormsen 2026-09-03 01:06:27 +02:00
commit 7552c0ef73
2 changed files with 290 additions and 5 deletions

View file

@ -514,9 +514,15 @@ def terminal_plan_reviewer(
print(f"\nPLAN REVIEW #{request.index + 1}{stalled}", file=sink)
print("--- the plan the exploration would run ---", file=sink)
print(request.plan, file=sink)
if request.current_progress.strip():
print("--- progress so far ---", file=sink)
print(request.current_progress, file=sink)
print("--- progress so far ---", file=sink)
# Stated, never omitted, and never a repr of absence. An expert about to sign has to be
# able to tell "the loop has not reported progress yet" from "this section was dropped".
print(
request.current_progress
if request.current_progress.strip()
else "(no progress ledger yet)",
file=sink,
)
while True:
print(
f'Answer "{_APPROVE_ANSWER}" to sign it off, '
@ -1087,6 +1093,19 @@ def _plan_text(content: Any) -> str:
return str(getattr(content, "text", "") or "")
def _progress_text(content: Any) -> str:
"""The progress ledger as text — and ABSENCE as the empty string, never ``"None"``.
``MagenticProgressLedger | None`` went through a bare ``str()`` at both construction sites, so
in every run measured so far (the value is ``None`` until the manager has emitted a ledger) the
expert was shown, and the parked question file stored, the four characters ``None``. That is
worse than saying nothing: it looks like content. The data layer states absence by being
absent ``Bundle.skipped``'s empty-tuple rule — and the TERMINAL is where it is put in words,
because silence at a gate somebody signs is what ``PlanReviewInputError`` already refuses.
"""
return "" if content is None else str(content)
def _absorb(
result: Any,
*,
@ -1512,7 +1531,7 @@ async def _drive(
PlanReviewRequest(
index=len(plan_reviews),
plan=_plan_text(review.plan),
current_progress=str(review.current_progress),
current_progress=_progress_text(review.current_progress),
is_stalled=_truthy(review.is_stalled),
)
)
@ -1604,7 +1623,7 @@ async def _park(
checkpoint_id=str(latest.checkpoint_id),
index=len(trace.plan_reviews),
plan=_plan_text(review.plan),
current_progress=str(review.current_progress),
current_progress=_progress_text(review.current_progress),
is_stalled=_truthy(review.is_stalled),
bundle_dirs=tuple(bundle_dirs),
contract=contract,

View file

@ -0,0 +1,266 @@
"""The plan review never shows an expert the word ``None`` and calls it progress.
``PlanReviewRequest.current_progress`` and ``ParkedExploration.current_progress`` were both built
with ``str(review.current_progress)``. The value is a ``MagenticProgressLedger | None``, and in
every run measured so far it is ``None`` (``docs/2026-09-02-misjonsreview-v2.md``, PM addendum 4)
so ``str()`` produced the four characters ``None``, the renderer's ``if ... .strip()`` guard found
them truthy, and the terminal printed:
--- progress so far ---
None
That is worse than saying nothing. An expert being asked to SIGN a plan was shown a section that
looks like content and is not, and the same four characters were written into
``{run_id}-plan-review.json`` the one thing that crosses the process boundary in the U12
asynchronous door, where nobody can ask what it meant.
Two halves, and each is asserted so the other cannot carry it:
(a) ``_progress_text`` is the ONE place a possibly-absent ledger becomes text: ``None`` becomes the
EMPTY string, never ``"None"``. It sits beside ``_plan_text`` and is applied at BOTH
construction sites the synchronous request (``explore.py``, F4's terminal door) and the
parked question (U12's file). The data layer says "absent" by being absent, which is what
``Bundle.skipped``'s empty-tuple rule and ``cost_baseline_notice``'s omission both do;
(b) the TERMINAL says it in words. This is the one place the order asks for a line rather than an
omission, and the reason is the surface's own job: silence at a signing gate is exactly what
this repo refuses when it makes EOF an error rather than an approval. An expert must be able
to tell "there is no progress yet" from "the section was dropped for some reason".
The control arm is what keeps (b) from being a blanket replacement: with a REAL ledger, the real
ledger is shown, not the placeholder.
"""
from __future__ import annotations
import io
import json
from pathlib import Path
from typing import Any
from portfolio_optimiser import explore as ex
class _Ledger:
"""Stands in for ``MagenticProgressLedger``: something with a readable ``str``."""
def __str__(self) -> str: # pragma: no cover - exercised through the renderer
return "LEDGER-SENTINEL: two documents read"
def _render(current_progress: str) -> str:
out = io.StringIO()
reviewer = ex.terminal_plan_reviewer(stream_in=io.StringIO("approve\n"), stream_out=out)
reviewer(
ex.PlanReviewRequest(
index=0, plan="a plan", current_progress=current_progress, is_stalled=False
)
)
return out.getvalue()
def test_an_absent_ledger_becomes_the_empty_string_not_the_word_none() -> None:
"""(a) The ONE conversion. ``str(None)`` is a lie the data layer must never carry."""
assert ex._progress_text(None) == "", (
"an absent progress ledger must be absent, not the four characters 'None'"
)
assert ex._progress_text(_Ledger()) == "LEDGER-SENTINEL: two documents read", (
"a ledger that EXISTS must still be rendered — this is not a blanket blank"
)
def test_the_terminal_says_there_is_no_ledger_yet_in_words() -> None:
"""(b) An expert asked to sign must be able to tell 'none yet' from 'section dropped'."""
out = _render("")
assert "None" not in out, f"the reviewer must never be shown a repr of absence: {out!r}"
assert "progress so far" in out
assert "no progress ledger yet" in out, (
"silence at a signing gate is what this repo refuses elsewhere (EOF is an error, never an "
f"approval); the absence must be stated: {out!r}"
)
def test_a_real_ledger_is_shown_verbatim_and_not_replaced() -> None:
"""The CONTROL for (b): the placeholder must not swallow a ledger that exists."""
out = _render("LEDGER-SENTINEL: two documents read")
assert "LEDGER-SENTINEL: two documents read" in out
assert "no progress ledger yet" not in out, (
"a run WITH progress must show it — a placeholder that always fires proves nothing"
)
def test_both_construction_sites_go_through_the_one_conversion() -> None:
"""(a), at the sites. ``str(...)`` at either place puts 'None' back — in the terminal on one
side, and in the parked question file on the other, which is the only thing U12's asynchronous
door carries across the process boundary.
"""
source = Path(ex.__file__).read_text(encoding="utf-8")
assert "str(review.current_progress)" not in source, (
"str() on a MagenticProgressLedger | None is the defect; both sites must use _progress_text"
)
assert source.count("current_progress=_progress_text(review.current_progress)") == 2, (
"both the synchronous request and the parked question must use the one conversion"
)
def test_the_parked_question_file_carries_absence_as_absence(tmp_path: Path) -> None:
"""The data layer, end to end: what crosses the process boundary must not say 'None'."""
parked = ex.ParkedExploration(
prompt="p",
request_id="r",
checkpoint_id="c",
index=0,
plan="a plan",
current_progress=ex._progress_text(None),
is_stalled=False,
bundle_dirs=(),
contract=ex.ExplorationContract(
max_rounds=1,
max_tokens=1,
max_stall_count=0,
max_reset_count=1,
max_plan_revisions=0,
enable_plan_review=True,
),
ledger=(),
plan_reviews=(),
hypotheses=(),
tokens_spent=0,
replans=0,
)
payload: dict[str, Any] = ex.parked_payload(parked)
assert payload["current_progress"] == ""
assert "None" not in json.dumps(payload["current_progress"])
# ---------------------------------------------------------------------------------------------
# The BEHAVIOURAL witnesses, one per construction site.
#
# Measured while gating this: reverting BOTH sites to the bare ``str()`` left only the
# source-inspection arm above red — a lint, not a gate, because the arms before it construct a
# ``PlanReviewRequest`` themselves and so never enter either site. Each site therefore gets a
# witness that drives the REAL door and reads the surface an expert reads: the terminal for F4's
# synchronous door, the parked question FILE for U12's asynchronous one, which is the only thing
# that crosses the process boundary.
# ---------------------------------------------------------------------------------------------
_PID = "BYGG-KONTOR-NORD"
_BUNDLE_DIR = Path(__file__).resolve().parents[1] / "shared" / "examples" / "bygg-energi-mikro"
_RUN_ID = "progress-line"
_REPLIES = {
"proposer": json.dumps(
{
"measure": "LED-retrofit",
"affected_items": [{"code": "ENERGI-TOTAL-EL", "quantity": 300000, "unit_cost": 1.0}],
"claimed_saving_nok": 30000,
}
),
"checker": "VERDICT: APPROVE",
"manager": json.dumps(
{
"is_request_satisfied": {"reason": "r", "answer": True},
"is_in_loop": {"reason": "r", "answer": False},
"is_progress_being_made": {"reason": "r", "answer": True},
"next_speaker": {"reason": "r", "answer": "hypothesiser"},
"instruction_or_question": {"reason": "r", "answer": "go"},
}
),
"navigator": "NAVIGATOR: read the index.",
"hypothesiser": "HYPOTHESIS: " + json.dumps({"label": "Night setback", "rationale": "y"}),
}
def _files(tmp_path: Path) -> tuple[str, str]:
config = tmp_path / "exploration.json"
config.write_text(
json.dumps(
{
"max_rounds": 4,
"max_tokens": 200_000,
"max_stall_count": 2,
"max_reset_count": 1,
"max_plan_revisions": 2,
"enable_plan_review": True,
}
),
encoding="utf-8",
)
replies = tmp_path / "replies.json"
replies.write_text(json.dumps(_REPLIES), encoding="utf-8")
return str(config), str(replies)
def _argv(tmp_path: Path, *extra: str) -> list[str]:
config, replies = _files(tmp_path)
return [
_PID,
"--docs-dir",
str(_BUNDLE_DIR),
"--bundle-dir",
str(_BUNDLE_DIR),
"--explore",
"Find the cheapest saving.",
"--explore-config",
config,
"--scripted-replies",
replies,
"--outbox-dir",
str(tmp_path / "outbox"),
"--run-id",
_RUN_ID,
*extra,
]
def _progress_section(text: str) -> str:
"""The line the surface printed UNDER the progress header — the one the defect corrupted."""
lines = text.splitlines()
assert "--- progress so far ---" in lines, f"the section must be shown at all: {text!r}"
return lines[lines.index("--- progress so far ---") + 1]
def test_the_synchronous_door_shows_absence_in_words_end_to_end(
tmp_path, capsys, monkeypatch
) -> None:
"""Site 1, driven for real: F4's terminal door, with a run whose ledger is genuinely ``None``.
Detach point: ``current_progress=str(review.current_progress)`` at the request site the line
under the header is the word ``None`` (RED).
"""
import sys as _sys
from portfolio_optimiser import run
monkeypatch.setattr(_sys, "stdin", io.StringIO("approve\napprove\napprove\n"))
run.main(_argv(tmp_path, "--plan-review"))
line = _progress_section(capsys.readouterr().out)
assert line == "(no progress ledger yet)", (
f"an expert signing a plan must be told there is no ledger yet, not shown 'None': {line!r}"
)
def test_the_parked_question_file_shows_absence_as_absence_end_to_end(tmp_path) -> None:
"""Site 2, driven for real: U12's asynchronous door. The file is the ONLY thing that crosses
the process boundary, so 'None' written here is a question nobody can interrogate.
Detach point: ``current_progress=str(review.current_progress)`` at the park site the file
carries the four characters ``None`` (RED).
"""
from portfolio_optimiser import run
run.main(_argv(tmp_path, "--checkpoint-dir", str(tmp_path / "checkpoints")))
question = json.loads(
(tmp_path / "outbox" / f"{_RUN_ID}-plan-review.json").read_text(encoding="utf-8")
)
assert question["current_progress"] == "", (
"the parked question must carry absence as absence, never as the string 'None': "
f"{question['current_progress']!r}"
)