feat(major2): run_project owns the review sink, keys it per approach and writes it from a finally [skip-docs]

Ordre 20260904T173146Z-8102814273-from-portfolio-optimiser, steg 4 av 10.

run_project faar proposal_reviewer (keyword-only, None => byte-identisk kjoering), eier
sinken expert_reviews ved siden av parse_failures, og skriver
{run_id}-proposal-reviews.json fra den EKSISTERENDE genererings-finally-en.

Skriveregelen er IFF en reviewer ble gitt, OGSAA naar lista er tom (D4). Begge halvdeler
er baerende og trekker hver sin vei: write_debate_tools skriver ubetinget fordi DER er det
tomme tilfellet regresjonen; her maa en reviewer-LOES kjoering la utboksen staa byte-identisk
(to eksisterende tester pinner et EKSAKT fire-navns-listing), mens en reviewer som ble tilbudt
og aldri konsultert er et faktum artefaktet maa kunne SI.

Noeklingen: med mandat er hver post noeklet - kjoeringens eget forslag paa OWN_PROPOSAL_ID -
og None betyr kun EN ting: det fantes intet mandat. RunResult.expert_revisions bygges FRA
sinken, aldri ved siden av (kø-(p)).

RODT foer impl: 8 armer.

REGRESJON FANGET AV FULL SUITE OG RETTET HER: steg 3s _FeedbackAwareChatClient kopierte
_inner_get_response-kroppen og gjorde test_scripted_client_consolidation
::test_inner_get_response_collapsed_to_two_sites roed. Doblen overstyrer naa _next_reply i
stedet - basen har alt lagt DENNE kallets prompt i received_texts naar den ber om et svar,
saa sommen holder uten en tredje kopi av kroppen, og registeret i vakten trenger ingen ny
oppfoering. Aa registrere fila som foreign lineage var ikke mulig og heller ikke riktig:
Group B bruker ScriptedChatClient, som den vakten nekter for nettopp den lista.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-09-05 06:58:18 +02:00
commit 59f3fce20a
3 changed files with 410 additions and 27 deletions

View file

@ -248,6 +248,34 @@ def write_plan_review(
return path
def write_proposal_reviews(
outbox_dir: str,
run_id: str,
*,
payload: Mapping[str, Any],
) -> Path:
"""Write ``{run_id}-proposal-reviews.json`` — what a human answered about the proposals this
run put on the table (MAJOR-2) and return its path.
Takes an already-rendered plain mapping (``proposal_review.proposal_reviews_payload``) for the
reason ``write_plan_review`` does: the renderer lives in the module that owns the type, and
this layer stays MAF-free. The writer prepends ``run_id`` exactly as its sibling does, so the
on-disk object is ``{"reviews": [...], "run_id": ...}`` under ``_dump``'s sorted keys.
**The write rule is: iff a reviewer was given, INCLUDING when the list is empty** (D4).
Both halves are load-bearing and they pull in opposite directions. ``write_debate_tools``
writes unconditionally because THERE the empty case is the regression; here a reviewer-LESS
run must leave the outbox byte-identical, and two existing tests pin an exact four-name
listing on such a run. But a reviewer that was offered and never consulted no candidate ever
validated is a fact this artefact must be able to STATE, not something an operator has to
infer from a missing file. "Iff a reviewer was given" is the only rule that keeps both."""
directory = Path(outbox_dir)
directory.mkdir(parents=True, exist_ok=True)
path = directory / f"{run_id}-proposal-reviews.json"
path.write_text(_dump({"run_id": run_id, **dict(payload)}), encoding="utf-8")
return path
def write_run_config(
config_dir: str,
run_id: str,

View file

@ -101,6 +101,11 @@ from portfolio_optimiser.mcp_tools import (
service_labels,
tool_server_index,
)
from portfolio_optimiser.proposal_review import (
ProposalReview,
ProposalReviewer,
proposal_reviews_payload,
)
from portfolio_optimiser.provenance import ProvenanceStamp
from portfolio_optimiser.reference_domain import Project, load_reference_projects
from portfolio_optimiser.tracing import TracingConfigError, configure_tracing, tracing_notice
@ -213,6 +218,19 @@ class RunResult:
#: The RESULT of each call is deliberately absent — that is the base's content, i.e. the very
#: thing too big to ride along (``ToolCall``'s own rule, MAJOR-1).
debate_tool_calls: tuple[ToolCall, ...] = ()
#: What a human answered about each candidate the validator accepted, in order (MAJOR-2).
#: Built FROM the caller-owned sink ``run_project`` hands to ``generate_via_llm``, never
#: accumulated beside it: two containers holding one fact drift (kø-(p)), and a drifted pair
#: would let this result and the written artefact describe different runs.
#:
#: KEYED per approach, unlike ``refinements`` above — a human wrote these words about ONE
#: specific candidate, and an artefact that cannot say which is one nobody can act on. EMPTY
#: is an honest POSITIVE statement ("nobody was asked, or nothing validated"), so it defaults,
#: exactly as ``skipped_links`` does.
#:
#: Carried HERE and on neither other carrier: ``ProvenanceStamp`` describes the gate that
#: judged ONE candidate, and ``DryRunReport`` returns above generation entirely.
expert_revisions: tuple[ProposalReview, ...] = ()
@property
def verdict_key(self) -> str:
@ -832,6 +850,12 @@ async def run_project(
embedder: Embedder | None = None,
mandate: Mandate | None = None,
mcp_servers: tuple[McpServerConfig, ...] = (),
#: MAJOR-2: the synchronous HITL door onto a candidate the deterministic validator has just
#: ACCEPTED. ``None`` (the default) is byte-identical to a pre-MAJOR-2 run — same prompts, same
#: outbox files, same golden transcript. Given one, it is called once per validated attempt of
#: every commissioned approach, and a ``revise`` buys ONE more attempt out of the budget the
#: loop already has. It mints no verdict and gates nothing (F2).
proposal_reviewer: ProposalReviewer | None = None,
) -> RunResult | DryRunReport:
"""Run the vertical slice for ONE project. ``client_factory`` is the test-injection seam
(defaults to the real backend). ``verdict_input`` carries the expert decision/rationale
@ -1110,8 +1134,24 @@ async def run_project(
# 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] = []
# MAJOR-2: what a human answered about each validated candidate. A caller-owned sink for the
# same measured reason ``parse_failures`` is one, one notch sharper: the round ledger can fire
# on the very attempt a revise bought, and on that path ``generate_via_llm`` returns nothing —
# so the run whose record matters most is exactly the one a return value cannot reach.
expert_reviews: list[ProposalReview] = []
async def _evaluate(approach: Approach | None) -> ValidatedProposal | Rejection:
# Which candidate the expert is being asked about. With a mandate every entry is keyed —
# the run's own proposal by ``OWN_PROPOSAL_ID``, the first-class row ``_evaluate_mandate``
# already uses — and ``None`` means only one thing: there was no mandate at all. Recording
# ``None`` for the own proposal would make it indistinguishable from a non-mandate run's
# entry, which is the property keying exists for.
if approach is not None:
review_key: tuple[str | None, str | None] = (approach.id, approach.label)
elif mandate is not None:
review_key = (OWN_PROPOSAL_ID, "the system's own proposal")
else:
review_key = (None, project.id)
generated = await generate_via_llm(
proposer_client,
project,
@ -1120,6 +1160,13 @@ async def run_project(
baseline=baseline,
approach=approach,
parse_failures=parse_failures,
reviewer=proposal_reviewer,
reviews=expert_reviews,
review_key=review_key,
# D3: the reasoning gate's own answer, READ-ONLY. An expert deciding whether to spend
# an attempt is helped by knowing it; it never enters the record, because the two
# falsifiers are never blended.
checker_verdict=checker_decision,
)
refinements.extend(generated.refinements)
return generated.outcome
@ -1143,6 +1190,19 @@ async def run_project(
run_id,
failures=[{"text": f.text, "error": f.error} for f in parse_failures],
)
# Same ``finally``, different write rule: IFF a reviewer was given, including when the
# list is empty (D4). A reviewer-less run must leave the outbox byte-identical, while a
# reviewer that was offered and never consulted is a fact the artefact must be able to
# state rather than one an operator infers from an absent file.
if outbox_dir is not None and proposal_reviewer is not None:
assert run_id is not None # narrowed by the step-0 guard (no wall-clock default)
outbox.write_proposal_reviews(
outbox_dir,
run_id,
payload=proposal_reviews_payload(
expert_reviews, key_of=lambda p: verdict_key(_features_of(p))
),
)
proposal = validator_outcome.proposal
# 6. First-class provenance stamp (authoritative; independent of MAF Annotation).
@ -1299,6 +1359,7 @@ async def run_project(
skipped_links=skipped_links,
unkeyed_verdicts=unkeyed_verdicts,
debate_tool_calls=tuple(debate_tool_calls),
expert_revisions=tuple(expert_reviews),
)