feat(fase2a): wire run_project(outbox_dir, run_id) → outbox-skriving, load-bearing (S2.1)

This commit is contained in:
Kjell Tore Guttormsen 2026-07-15 07:22:41 +02:00
commit a706184bdd
2 changed files with 137 additions and 3 deletions

View file

@ -48,7 +48,7 @@ from portfolio_optimiser.ir import SavingsProposal
from portfolio_optimiser.provenance import ProvenanceStamp from portfolio_optimiser.provenance import ProvenanceStamp
from portfolio_optimiser.reference_domain import Project, load_reference_projects from portfolio_optimiser.reference_domain import Project, load_reference_projects
from portfolio_optimiser.validator import Rejection, ValidatedProposal from portfolio_optimiser.validator import Rejection, ValidatedProposal
from portfolio_optimiser import okf from portfolio_optimiser import okf, outbox
from portfolio_optimiser.verdicts import ( from portfolio_optimiser.verdicts import (
ExpeLContextProvider, ExpeLContextProvider,
ProposalFeatures, ProposalFeatures,
@ -221,6 +221,8 @@ async def run_project(
dimension: Dimension | None = None, dimension: Dimension | None = None,
store: VerdictStore | None = None, store: VerdictStore | None = None,
verdict_dir: str | None = None, verdict_dir: str | None = None,
outbox_dir: str | None = None,
run_id: str | None = None,
client_factory: Callable[[str], BaseChatClient] | None = None, client_factory: Callable[[str], BaseChatClient] | None = None,
max_rounds: int = 3, max_rounds: int = 3,
max_tokens: int = 100_000, max_tokens: int = 100_000,
@ -238,8 +240,21 @@ async def run_project(
files (plain JSON, R2 raw layer) MERGED into the store BEFORE the Step-1 fold, so a verdict files (plain JSON, R2 raw layer) MERGED into the store BEFORE the Step-1 fold, so a verdict
dropped after an earlier run is consumed by this separate, later run the long feedback loop, dropped after an earlier run is consumed by this separate, later run the long feedback loop,
fully resumable across runs separated in time. The system READS this folder; it does not write fully resumable across runs separated in time. The system READS this folder; it does not write
to it (the expert/persona writes, målbilde §3). Raises ``pydantic.ValidationError`` on a bad to it (the expert/persona writes, målbilde §3). ``outbox_dir`` (Fase 2a, Steg 7 output layer,
contract and ``BudgetExceeded`` when the token/round cap is crossed.""" målbilde §3, R2) is the RAW OUTBOX: when set, the run's proposal + outcome artefacts are written
there via ``outbox.write_outbox`` (``run_id`` is then REQUIRED no wall-clock/uuid default, for
byte-determinism). The outbox dir should be DISTINCT from any ``verdict_dir`` inbox: writing the
outbox into a folder later read as an inbox would re-ingest raw agent output and bypass the
Step-8 promotion gate (self-contamination) documented here, not enforced. Raises
``pydantic.ValidationError`` on a bad contract and ``BudgetExceeded`` when the token/round cap is
crossed, and ``ValueError`` when ``outbox_dir`` is set without a ``run_id``."""
# 0. Fail-fast: an outbox write is byte-deterministic and keyed on run_id — no wall-clock default.
if outbox_dir is not None and run_id is None:
raise ValueError(
"run_id is required when outbox_dir is set (no wall-clock/uuid default — the outbox "
"artefacts are byte-deterministic and keyed on run_id)"
)
# 1. Fail-fast: validate ALL contracts (incl. the verdict-feedback shape) before any client. # 1. Fail-fast: validate ALL contracts (incl. the verdict-feedback shape) before any client.
load_contracts( load_contracts(
{"docs_dir": docs_dir, "top_k": top_k}, {"docs_dir": docs_dir, "top_k": top_k},
@ -382,6 +397,20 @@ async def run_project(
if notify is not None: if notify is not None:
notify(verdict) notify(verdict)
# S2.1 outbox (RAW output layer, målbilde §3): persist the run's proposal + outcome artefacts
# when configured. Wired ONLY here — no new consumer (S5.1/S5.2 are Non-Goals this bolk). run_id
# is guaranteed non-None by the fail-fast guard at the top.
if outbox_dir is not None:
assert run_id is not None # narrowed by the step-0 guard; keeps the type checker honest
outbox.write_outbox(
outbox_dir,
run_id,
outcome=outcome,
provenance=stamp,
checker_verdict=checker_decision,
verdict_id=verdict.id,
)
return RunResult( return RunResult(
outcome=outcome, outcome=outcome,
provenance=stamp, provenance=stamp,

View file

@ -10,14 +10,43 @@ both-arms (T-2.1a) checks below.
from __future__ import annotations from __future__ import annotations
from collections.abc import Callable
from pathlib import Path from pathlib import Path
import pytest
from agent_framework import BaseChatClient
from conftest import SyntheticUsageChatClient
from portfolio_optimiser.ir import AffectedItem, SavingsProposal from portfolio_optimiser.ir import AffectedItem, SavingsProposal
from portfolio_optimiser.outbox import write_outbox from portfolio_optimiser.outbox import write_outbox
from portfolio_optimiser.provenance import Citation, ProvenanceStamp from portfolio_optimiser.provenance import Citation, ProvenanceStamp
from portfolio_optimiser.retrieval import TextSpan from portfolio_optimiser.retrieval import TextSpan
from portfolio_optimiser.run import run_project
from portfolio_optimiser.validator import Rejection, ValidatedProposal from portfolio_optimiser.validator import Rejection, ValidatedProposal
# --- Step 6 (run_project wiring) fixtures: the shared bundle + a role-aware scripted factory ---
BUNDLE_DIR = Path(__file__).resolve().parents[1] / "shared" / "examples" / "bygg-energi-mikro"
# A VALIDATOR-VALID BYGG-KONTOR-NORD proposal (degenerate Monte Carlo P90 = 0.30 x 300000 = 90000
# >= claimed 30000 -> validates), so the only possible rejecter is the checker.
_VALID_PROPOSER_REPLY = (
'{"measure":"LED-retrofit av kontorbelysning","affected_items":'
'[{"code":"ENERGI-TOTAL-EL","quantity":300000,"unit_cost":1.0}],"claimed_saving_nok":30000}'
)
_VERDICT_INPUT = {"decision": "approved", "rationale": "expert reviewed (sim)"}
def _role_factory(proposer_reply: str, checker_reply: str) -> Callable[[str], BaseChatClient]:
"""A role-aware synthetic factory: the checker speaks its verdict, the proposer its proposal."""
def factory(role: str) -> BaseChatClient:
return SyntheticUsageChatClient(
default_reply=checker_reply if role == "checker" else proposer_reply
)
return factory
_PROPOSAL = SavingsProposal( _PROPOSAL = SavingsProposal(
project_id="P1", project_id="P1",
measure="LED-retrofit av kontorbelysning", measure="LED-retrofit av kontorbelysning",
@ -110,3 +139,79 @@ def test_outbox_registered_maf_free() -> None:
from tests.test_okf import _MAF_FREE_MODULES from tests.test_okf import _MAF_FREE_MODULES
assert "outbox.py" in _MAF_FREE_MODULES assert "outbox.py" in _MAF_FREE_MODULES
async def test_run_project_writes_outbox_validated_arm(tmp_path) -> None:
"""T-2.1a (via run_project, validated): a run with an ``outbox_dir`` + ``run_id`` writes both
artefacts; the outcome file records ``validated``. Detach the ``write_outbox`` call in
``run_project`` the files are absent RED."""
factory = _role_factory(_VALID_PROPOSER_REPLY, "VERDICT: APPROVE")
result = await run_project(
"BYGG-KONTOR-NORD",
"local",
docs_dir=str(BUNDLE_DIR),
bundle_dir=str(BUNDLE_DIR),
verdict_input=_VERDICT_INPUT,
client_factory=factory,
outbox_dir=str(tmp_path),
run_id="run-validated",
)
assert isinstance(result.outcome, ValidatedProposal)
proposal_file = tmp_path / "run-validated-proposal.json"
outcome_file = tmp_path / "run-validated-outcome.json"
assert proposal_file.is_file()
assert outcome_file.is_file()
assert '"outcome_type": "validated"' in outcome_file.read_text(encoding="utf-8")
async def test_run_project_writes_outbox_rejected_arm(tmp_path) -> None:
"""T-2.1a (via run_project, rejected): a checker ``VERDICT: REJECT`` flips an otherwise-validated
proposal to a Rejection, and the outbox outcome file records ``rejected``."""
factory = _role_factory(
_VALID_PROPOSER_REPLY, "VERDICT: REJECT - payback exceeds horizon (sim)"
)
result = await run_project(
"BYGG-KONTOR-NORD",
"local",
docs_dir=str(BUNDLE_DIR),
bundle_dir=str(BUNDLE_DIR),
verdict_input=_VERDICT_INPUT,
client_factory=factory,
outbox_dir=str(tmp_path),
run_id="run-rejected",
)
assert isinstance(result.outcome, Rejection)
outcome_file = tmp_path / "run-rejected-outcome.json"
assert '"outcome_type": "rejected"' in outcome_file.read_text(encoding="utf-8")
async def test_run_project_without_outbox_dir_writes_nothing(tmp_path) -> None:
"""T-2.1c (control): a run WITHOUT ``outbox_dir`` writes no artefacts — proving the writes above
are caused by the seam, not incidental."""
factory = _role_factory(_VALID_PROPOSER_REPLY, "VERDICT: APPROVE")
await run_project(
"BYGG-KONTOR-NORD",
"local",
docs_dir=str(BUNDLE_DIR),
bundle_dir=str(BUNDLE_DIR),
verdict_input=_VERDICT_INPUT,
client_factory=factory,
)
assert list(tmp_path.iterdir()) == []
async def test_run_project_outbox_dir_requires_run_id(tmp_path) -> None:
"""T-2.1e: an ``outbox_dir`` with ``run_id=None`` raises (no wall-clock/uuid default — the
artefacts are byte-deterministic and keyed on run_id)."""
factory = _role_factory(_VALID_PROPOSER_REPLY, "VERDICT: APPROVE")
with pytest.raises(ValueError):
await run_project(
"BYGG-KONTOR-NORD",
"local",
docs_dir=str(BUNDLE_DIR),
bundle_dir=str(BUNDLE_DIR),
verdict_input=_VERDICT_INPUT,
client_factory=factory,
outbox_dir=str(tmp_path),
run_id=None,
)