feat(portfolio): K11 — per-run value report, pure projection over the three layers (parity row 25) [skip-docs]

The S5.4 analog: every run/portfolio pass can produce a deterministic value
report — modelled → expert-corrected → realized, goal progress, a quantified
learning effect, cost against value — with no model call, no clock and no new
state. It is a PURE PROJECTION over what is already persisted (K5 outbox pairs,
§4.2 inbox verdicts, K1 ledger), joined on the verdict_id K5 mints.

The honesty rule (§1) sets the shape, not the layout:
- approved      -> the claim stands        (quantified)
- rejected      -> the claim is void, 0    (quantified — an earned zero)
- approved_with_adjustment -> §4.2 carries NO adjusted amount, so the corrected
  value is UNQUANTIFIED, never back-filled with the claim
- no verdict    -> realized is UNMARKED, never zero-that-reads-as-judged and
  never the modelled figure
Partial quantification is counted in the output (2 of 4 …, 2 UNMARKED) rather
than summed into a full-looking total. Learning is measured, not asserted: a
rising approval share is reported only alongside the modelled→corrected gap that
shrank behind it, over cohorts split by run_id order. Cost (USD, a K6 upper
bound) and value (NOK) sit side by side and are never divided — no sourced FX
rate exists here, and a ratio would invent one.

Surfaces: standalone CLI (valuereport) and an opt-in --value-report on run.py,
which requires --outbox and is refused BEFORE any spend without one; the report
is written on both run outcomes and never rewrites the run's exit code (a budget
stop stays a budget stop).

Six seams detach-proven RED: honesty boundary (corrected + realized), gap
arithmetic, projection purity, run-seam wiring, pre-spend fail-fast, both-outcome
reporting. Fixtures are COMMITTED and generated with the real primitives, so the
id-join under test is genuine.

Note on the purity test: it was green-but-dead in its first form. Snapshotting
the committed fixture tree in place let an earlier test's stray write pre-seed
the 'before' snapshot, so the detached write reproduced it byte-for-byte. Every
test now projects from a per-test copy, and a pinned file-set test guards the
committed tree. Found by running the detach proof — which is what §11 is for.

portfolio.py is deliberately NOT wired: run_portfolio persists nothing, so there
is nothing for a projection to read. Its docstring now says that instead of
promising the wiring it did not get.

562 -> 584 tests green; ruff + mypy --strict clean over 27 src files.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MQu2xxwedckjU56byu1aUG
This commit is contained in:
Kjell Tore Guttormsen 2026-07-25 06:25:02 +02:00
commit 4dcdd8017a
18 changed files with 1477 additions and 6 deletions

View file

@ -152,6 +152,24 @@ description, never from its code)
outcomes — a budget stop notifies too) and `hitl.py` (read-only preserved) share the same
opt-in-gated CLI seam, refusing a webhook-without-opt-in *before* any spend. The payload
shape is stack-local (no shared notification spec across the siblings).
- `valuereport.py` — what the loop actually delivered (**offline, read-only**): a
deterministic projection of the outbox, the inbox and the ledger into three distinct
columns — *modelled* (what the system claimed), *expert-corrected* (what the §4.2 verdict
makes of that claim), *realized* (what passed the expert gate into the book) — plus goal
progress, a quantified learning effect and cost against value. No model call, no clock, no
new state. The honesty rule (§1) sets its shape: a figure the layers do not carry is
reported `UNMARKED`, never back-filled from the stage before it. An `approved_with_adjustment`
verdict changes the amount but carries none in the §4.2 shape, so its corrected value is
unquantified — and a project nobody has judged has no realized figure at all, rather than a
zero that reads as a judgment. Learning is measured, not asserted: settled proposals split
by `run_id` order into an earlier and a later cohort, and a rising approval share is only
reported alongside the modelled→corrected gap that shrank behind it. Cost (USD, itself a K6
upper bound) and value (NOK) are printed side by side and never divided — this repo carries
no sourced exchange rate, and a ratio would invent one. Available standalone
(`uv run python -m portfolio_optimiser_claude.valuereport --outbox <dir> --inbox <dir>
[--ledger <file>]`) and as an opt-in side product of a run (`run.py --value-report <file>`,
which requires `--outbox` and is refused before any spend without one; the report is written
on both run outcomes and never rewrites the run's own exit code).
### Load-bearing tests (§11)
@ -180,8 +198,13 @@ and hitl never writes any layer, proven by a before/after byte snapshot),
per-run opt-in flag refuses fail-fast and its transport never fires — red the moment the gate
is detached — the canned transport receives the structured payload, an AST grep-guard proves
no network path lives outside the one injectable seam function, and the run/hitl entrances
emit on their outcomes while hitl stays read-only), and
`test_sdk_isolation.py` (local config cannot capture the checker).
emit on their outcomes while hitl stays read-only),
`test_valuereport_loadbearing.py` and `test_valuereport_seam_loadbearing.py` (an unjudged
project's realized value stays unmarked and never mirrors the modelled claim — red the moment
that boundary is detached — a rising approval share is not reported as learning without the
gap arithmetic behind it, the projection writes no byte into the three layers it reads, and
the run entrance produces the report on both outcomes while leaving the run's verdict alone),
and `test_sdk_isolation.py` (local config cannot capture the checker).
## The ingest layer — CSV and SQL, in front of the loop

View file

@ -17,9 +17,17 @@ project still composes its OWN context inside the loop, never a hoisted, shared
Failure policy is a STACK-LOCAL choice until D-D: the default RAISES (today
everything is thrown a budget stop or any run error propagates and the portfolio
stops). K18 flips this to a collect-and-continue wave model when the D-D fasit
lands. Goals/ledger are out of scope here beyond being accepted as optional
arguments in a later session (K11); this module wires the run path and the
portfolio learning store, nothing more.
lands. This module wires the run path and the portfolio learning store, nothing
more.
Goals/ledger are still NOT wired here, and K11 (the value report) deliberately
did not change that: the report is a projection over PERSISTED state, and this
path persists nothing it returns typed results and leaves filing to the caller.
Wiring ``--value-report`` here would first require portfolio-level outbox
persistence, which is its own decision (the outbox names pairs by ``run_id``, and
a portfolio pass has no run id of its own). Until that lands, the report is
produced per run (``run.py --value-report``) or standalone over an outbox that
already holds the pairs.
"""
from __future__ import annotations

View file

@ -58,6 +58,7 @@ from portfolio_optimiser_claude.notify import (
notify_config_from_args,
)
from portfolio_optimiser_claude.validator import Rejection
from portfolio_optimiser_claude.valuereport import build_value_report, report_to_json
_PROPOSER_ROLE = "proposer"
_CHECKER_ROLE = "checker"
@ -240,6 +241,35 @@ def execute_run(
return 0
def write_value_report(
*,
outbox_dir: Path,
inbox_dir: Path | None,
ledger_path: Path | None,
destination: Path,
) -> bool:
"""K11 opt-in: project the persisted layers into a value report AFTER the run.
The report is a projection over what is already on disk including the pair
this run just filed so it runs after the run, never before, and it can
never change what the run itself decided. A malformed layer is reported here
and makes the COMMAND non-zero (the operator asked for a report and did not
get one), but a budget stop stays a budget stop: reporting never rewrites a
run's own verdict. Returns whether the report was written.
"""
try:
report = build_value_report(
outbox_dir=outbox_dir, inbox_dir=inbox_dir, ledger_path=ledger_path
)
except (OSError, TypeError, ValueError) as exc:
print(f"VALUE REPORT FAILED — refusing to project a malformed layer (§10): {exc}")
return False
destination.parent.mkdir(parents=True, exist_ok=True)
destination.write_text(report_to_json(report), encoding="utf-8", newline="\n")
print(f"value-report: {destination}")
return True
def build_dry_run_config(
contracts: Contracts,
*,
@ -357,6 +387,19 @@ def main(
parser.add_argument("--max-debate-rounds", type=int, default=3)
parser.add_argument("--max-attempts", type=int, default=3)
parser.add_argument("--top-k", type=int, default=3)
parser.add_argument(
"--value-report",
type=Path,
default=None,
help="after the run, project the outbox (+ --inbox, + --ledger) into a "
"deterministic value report written as JSON to this path (K11; no model call).",
)
parser.add_argument(
"--ledger",
type=Path,
default=None,
help="realized-savings ledger read by --value-report; absent = an empty book.",
)
parser.add_argument(
"--live-dry-run",
action="store_true",
@ -378,6 +421,11 @@ def main(
)
elif args.outbox is not None and not (args.run_id or "").strip():
parser.error("--outbox requires --run-id (no wall-clock default)")
# K11: the value report PROJECTS the outbox — without one there is nothing to
# project. Rejected here, before any spend, so a run never completes only to
# find it cannot produce the report the operator asked for.
if args.value_report is not None and args.outbox is None:
parser.error("--value-report requires --outbox (the report projects the outbox pairs)")
# K10 (§8): build the notify sinks BEFORE any spend — a --notify-webhook
# without --allow-webhook-egress is refused fail-fast here, so no run rides
@ -442,7 +490,7 @@ def main(
refusals=refusals,
)
return execute_run(
code = execute_run(
client,
composed,
contracts=contracts,
@ -453,6 +501,20 @@ def main(
run_id=args.run_id,
notifiers=notifiers,
)
# K11: the value report is produced on BOTH outcomes — a budget-stopped run
# still has a value picture worth reporting (the accumulated outbox, inbox
# and ledger are what it projects, not this one run's success).
if args.value_report is not None:
assert args.outbox is not None # narrowed by the fail-fast above
written = write_value_report(
outbox_dir=args.outbox,
inbox_dir=args.inbox,
ledger_path=args.ledger,
destination=args.value_report,
)
if not written and code == 0:
code = 1
return code
if __name__ == "__main__":

View file

@ -0,0 +1,615 @@
"""Per-run value report (method-spec §1/§4/§5 analog; S5.4; paritetsrad 25; K11).
What did the loop actually deliver? This module answers that as a PURE
PROJECTION over state that already exists the K5 outbox pairs, the §4.2 inbox
verdicts, and the K1 ledger with no model call, no clock, and no new state.
Three stages, each measured from its OWN layer and never copied from the one
before it:
* **modelled** what the system claimed (``claimed_saving_nok`` in the outbox
proposal). The cheapest figure to produce and the easiest to over-trust.
* **expert-corrected** what the expert's §4.2 verdict makes of that claim:
``approved`` leaves it standing, ``rejected`` writes it to zero. The third
vocabulary member, ``approved_with_adjustment``, says the amount changes but
the §4.2 shape carries NO adjusted figure so the corrected value is
UNQUANTIFIED, reported as such and never silently back-filled with the claim.
* **realized** what passed the expert gate into the K1 book. A project with no
ledger entry and no settled verdict is UNMARKED (``None``), never zero-that-
looks-judged and never the modelled figure (§1: the report may not claim more
than the layers carry).
The learning effect is quantified rather than asserted: settled proposals are
split by ``run_id`` order into an earlier and a later cohort, and BOTH the
approval share and the modelledcorrected gap are compared across them. A rising
approval share alone is not evidence of learning the report pairs it with the
gap that shrank, or it reports neither.
Cost against value is reported side by side WITHOUT a ratio: the cost estimate is
USD (K6, itself an upper bound) and the realized value is NOK, and this repo has
no sourced exchange rate. Dividing them would manufacture a number no source
backs (§1).
Run: uv run python -m portfolio_optimiser_claude.valuereport --outbox <dir> \\
--inbox <dir> [--ledger <file>] [--goal-nok N] \\
[--estimated-cost-usd X] [--json <file>]
"""
from __future__ import annotations
import argparse
import json
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Sequence
from pydantic import ValidationError
from portfolio_optimiser_claude.goals import GoalContract
from portfolio_optimiser_claude.hitl import load_outbox_proposals
from portfolio_optimiser_claude.inbox import load_inbox
from portfolio_optimiser_claude.ledger import SavingsLedger
# §4.2 decisions that keep the measure alive (the expert did not throw it out).
_ACCEPTING = frozenset({"approved", "approved_with_adjustment"})
# The decision that voids the claim outright — a QUANTIFIED correction to zero.
_REJECTED = "rejected"
# The decision that changes the amount without carrying one (§4.2 has no field
# for an adjusted figure) — quantifying it here would be invention, not reading.
_ADJUSTED = "approved_with_adjustment"
_UNMARKED = "UNMARKED"
_SHARE_DIGITS = 6 # shares are rounded so the JSON bytes never carry float noise
@dataclass(frozen=True)
class ProposalValue:
"""One outbox proposal projected through the expert stage.
``expert_corrected_nok`` is ``None`` for exactly two reasons, both honest:
no verdict has arrived, or the verdict adjusted the amount without stating
it. ``status`` names which.
"""
run_id: str
verdict_id: str
project_id: str
measure: str
modelled_nok: float
decision: str | None
expert_corrected_nok: float | None
status: str
@dataclass(frozen=True)
class ProjectValue:
"""One project's three stages, plus how much of it is quantified at all."""
project_id: str
modelled_nok: float
expert_corrected_nok: float | None
realized_nok: float | None
quantified_proposals: int
unquantified_proposals: int
proposals: list[ProposalValue]
@dataclass(frozen=True)
class Cohort:
"""One half of the run history: how it was judged, and how big its gap was.
``gap_share`` is computed over the QUANTIFIED proposals only an
unquantified adjustment cannot contribute to a gap measurement and
``quantified`` says how many that was, so a thin cohort is visible.
"""
settled: int
accepted: int
approval_share: float | None
quantified: int
modelled_nok: float
expert_corrected_nok: float | None
gap_share: float | None
@dataclass(frozen=True)
class LearningEffect:
"""The quantified trend across cohorts — approval share AND the shrinking gap.
``comparable`` is False whenever there are fewer than two settled proposals
to split: one judgment is a data point, not a trend, and the report says so
rather than rendering a confident-looking zero.
"""
comparable: bool
earlier: Cohort | None
later: Cohort | None
earlier_approval_share: float | None
later_approval_share: float | None
approval_share_delta: float | None
earlier_gap_share: float | None
later_gap_share: float | None
gap_share_delta: float | None
gap_shrinking: bool | None
@dataclass(frozen=True)
class GoalProgress:
"""Progress toward the §8-adjacent savings goal, measured on REALIZED value only."""
target_nok: float
realized_nok: float
share: float
reached: bool
@dataclass(frozen=True)
class CostVersusValue:
"""Run cost against realized value — two currencies, deliberately NOT divided."""
estimated_cost_usd: float
realized_value_nok: float
note: str
@dataclass(frozen=True)
class ValueReport:
"""The whole projection: portfolio roll-up, per project, trend, goal, cost."""
modelled_nok: float
expert_corrected_nok: float | None
realized_nok: float
gap_nok: float
gap_share: float | None
n_proposals: int
n_settled: int
n_pending: int
quantified_proposals: int
unquantified_proposals: int
approval_share: float | None
projects: list[ProjectValue]
learning: LearningEffect
goal: GoalProgress | None
cost: CostVersusValue | None
def _share(numerator: float, denominator: float) -> float | None:
"""A share, or ``None`` when there is nothing to divide by (never a fake zero)."""
if denominator == 0:
return None
return round(numerator / denominator, _SHARE_DIGITS)
def _corrected(decision: str | None, modelled_nok: float) -> tuple[float | None, str]:
"""Project one verdict onto the claim — the §1 honesty boundary, in one place.
Detaching this (returning ``modelled_nok`` for the pending or adjusted case)
is precisely the flattering lie the load-bearing tests exist to catch.
"""
if decision is None:
return None, "pending"
if decision == _REJECTED:
return 0.0, "rejected"
if decision == _ADJUSTED:
return None, "adjusted_unquantified"
return modelled_nok, "approved"
def _cohort(proposals: Sequence[ProposalValue]) -> Cohort:
"""Roll one cohort up: approval share over settled, gap share over quantified."""
settled = [p for p in proposals if p.decision is not None]
accepted = [p for p in settled if p.decision in _ACCEPTING]
quantified = [p for p in settled if p.expert_corrected_nok is not None]
modelled = sum(p.modelled_nok for p in quantified)
corrected = sum(p.expert_corrected_nok or 0.0 for p in quantified) if quantified else None
return Cohort(
settled=len(settled),
accepted=len(accepted),
approval_share=_share(len(accepted), len(settled)),
quantified=len(quantified),
modelled_nok=modelled,
expert_corrected_nok=corrected,
gap_share=None if corrected is None else _share(modelled - corrected, modelled),
)
def _learning_effect(proposals: Sequence[ProposalValue]) -> LearningEffect:
"""Split the settled history in two by ``run_id`` order and compare the halves.
The split point is the midpoint of the SETTLED proposals (pending ones carry
no judgment to compare). Fewer than two settled not comparable.
"""
settled = sorted((p for p in proposals if p.decision is not None), key=lambda p: p.run_id)
if len(settled) < 2:
return LearningEffect(
comparable=False,
earlier=None,
later=None,
earlier_approval_share=None,
later_approval_share=None,
approval_share_delta=None,
earlier_gap_share=None,
later_gap_share=None,
gap_share_delta=None,
gap_shrinking=None,
)
midpoint = len(settled) // 2
earlier, later = _cohort(settled[:midpoint]), _cohort(settled[midpoint:])
approval_delta = (
None
if earlier.approval_share is None or later.approval_share is None
else round(later.approval_share - earlier.approval_share, _SHARE_DIGITS)
)
# The gap arithmetic is load-bearing: without it a rising approval share
# would be reported as "learning" with nothing measured behind it.
gap_delta = (
None
if earlier.gap_share is None or later.gap_share is None
else round(later.gap_share - earlier.gap_share, _SHARE_DIGITS)
)
return LearningEffect(
comparable=True,
earlier=earlier,
later=later,
earlier_approval_share=earlier.approval_share,
later_approval_share=later.approval_share,
approval_share_delta=approval_delta,
earlier_gap_share=earlier.gap_share,
later_gap_share=later.gap_share,
gap_share_delta=gap_delta,
gap_shrinking=None if gap_delta is None else gap_delta < 0,
)
def _project_value(
project_id: str, proposals: Sequence[ProposalValue], realized_by_project: dict[str, float]
) -> ProjectValue:
"""Roll one project up — realized stays UNMARKED until something passed the gate.
A project earns a realized ZERO only when every one of its proposals came
back rejected: that is a judged outcome. Anything else without a ledger entry
is ``None`` unjudged is not the same as worth nothing, and neither is the
same as the modelled claim.
"""
quantified = [p for p in proposals if p.expert_corrected_nok is not None]
corrected = sum(p.expert_corrected_nok or 0.0 for p in quantified) if quantified else None
if project_id in realized_by_project:
realized: float | None = realized_by_project[project_id]
elif proposals and all(p.decision == _REJECTED for p in proposals):
realized = 0.0 # judged, and judged worthless — an earned zero
else:
realized = None
return ProjectValue(
project_id=project_id,
modelled_nok=sum(p.modelled_nok for p in proposals),
expert_corrected_nok=corrected,
realized_nok=realized,
quantified_proposals=len(quantified),
unquantified_proposals=len(proposals) - len(quantified),
proposals=list(proposals),
)
def load_ledger(ledger_path: Path | None) -> SavingsLedger:
"""Load the book fail-fast (§10) — a wrong-SHAPE ledger never masquerades as empty.
``None`` means no book was supplied (an empty one). A path that exists but
holds valid JSON of the wrong shape (an array, a string, ``{"entries": {}}``)
raises: reading it as an empty book would silently report every realized
saving as unmarked. ``SavingsLedger.load`` unpacks the payload, so a non-object
top level surfaces as ``TypeError`` normalized here to ``ValueError`` so the
caller has ONE failure type to catch.
"""
if ledger_path is None:
return SavingsLedger()
try:
return SavingsLedger.load(ledger_path)
except TypeError as exc: # non-object JSON: `**` needs a mapping
raise ValueError(f"ledger {ledger_path} is not a JSON object: {exc}") from exc
def build_value_report(
*,
outbox_dir: Path,
inbox_dir: Path | None = None,
ledger_path: Path | None = None,
goal: GoalContract | None = None,
estimated_cost_usd: float | None = None,
) -> ValueReport:
"""Project the three layers into one deterministic report — reads only, writes nothing.
The id-join is K5's: each outbox pair carries the ``verdict_id`` minted over
the candidate features, and an inbox verdict for that id is the expert stage
for that proposal. Ordering everywhere is ``run_id`` / ``project_id`` sorted,
so the same inputs always yield the same report (no clock, no set iteration).
``inbox_dir`` and ``ledger_path`` are optional because a run may legitimately
have neither yet the result is a report where every expert-stage figure is
UNMARKED, which is the honest picture of a loop whose experts have not spoken.
"""
decisions = (
{}
if inbox_dir is None
else {document.id: document.decision for document in load_inbox(inbox_dir)}
)
ledger = load_ledger(ledger_path)
realized_by_project: dict[str, float] = {}
for entry in ledger.entries():
realized_by_project[entry.project] = realized_by_project.get(entry.project, 0.0) + (
entry.amount_nok
)
proposals: list[ProposalValue] = []
for outbox_proposal in load_outbox_proposals(outbox_dir):
decision = decisions.get(outbox_proposal.verdict_id)
corrected, status = _corrected(decision, outbox_proposal.claimed_saving_nok)
proposals.append(
ProposalValue(
run_id=outbox_proposal.run_id,
verdict_id=outbox_proposal.verdict_id,
project_id=outbox_proposal.project_id,
measure=outbox_proposal.measure,
modelled_nok=outbox_proposal.claimed_saving_nok,
decision=decision,
expert_corrected_nok=corrected,
status=status,
)
)
by_project: dict[str, list[ProposalValue]] = {}
for proposal in proposals:
by_project.setdefault(proposal.project_id, []).append(proposal)
projects = [
_project_value(project_id, by_project[project_id], realized_by_project)
for project_id in sorted(by_project)
]
modelled = sum(p.modelled_nok for p in proposals)
realized = ledger.total_realized_nok()
quantified = [p for p in proposals if p.expert_corrected_nok is not None]
settled = [p for p in proposals if p.decision is not None]
accepted = [p for p in settled if p.decision in _ACCEPTING]
return ValueReport(
modelled_nok=modelled,
expert_corrected_nok=(
sum(p.expert_corrected_nok or 0.0 for p in quantified) if quantified else None
),
realized_nok=realized,
gap_nok=modelled - realized,
gap_share=_share(modelled - realized, modelled),
n_proposals=len(proposals),
n_settled=len(settled),
n_pending=len(proposals) - len(settled),
quantified_proposals=len(quantified),
unquantified_proposals=len(proposals) - len(quantified),
approval_share=_share(len(accepted), len(settled)),
projects=projects,
learning=_learning_effect(proposals),
goal=(
None
if goal is None
else GoalProgress(
target_nok=goal.target_nok,
realized_nok=realized,
share=round(realized / goal.target_nok, _SHARE_DIGITS),
reached=realized >= goal.target_nok,
)
),
cost=(
None
if estimated_cost_usd is None
else CostVersusValue(
estimated_cost_usd=estimated_cost_usd,
realized_value_nok=realized,
note=(
"ESTIMAT (K6 upper bound) in USD against realized value in NOK — "
"reported side by side, NOT divided: this repo carries no sourced "
"NOK/USD rate, and a ratio would invent one (§1)."
),
)
),
)
def _cohort_payload(cohort: Cohort | None) -> dict[str, Any] | None:
if cohort is None:
return None
return {
"accepted": cohort.accepted,
"approval_share": cohort.approval_share,
"expert_corrected_nok": cohort.expert_corrected_nok,
"gap_share": cohort.gap_share,
"modelled_nok": cohort.modelled_nok,
"quantified": cohort.quantified,
"settled": cohort.settled,
}
def report_payload(report: ValueReport) -> dict[str, Any]:
"""The report as plain data — the one place the JSON shape is defined."""
return {
"approval_share": report.approval_share,
"cost": (
None
if report.cost is None
else {
"estimated_cost_usd": report.cost.estimated_cost_usd,
"note": report.cost.note,
"realized_value_nok": report.cost.realized_value_nok,
}
),
"expert_corrected_nok": report.expert_corrected_nok,
"gap_nok": report.gap_nok,
"gap_share": report.gap_share,
"goal": (
None
if report.goal is None
else {
"reached": report.goal.reached,
"realized_nok": report.goal.realized_nok,
"share": report.goal.share,
"target_nok": report.goal.target_nok,
}
),
"learning": {
"approval_share_delta": report.learning.approval_share_delta,
"comparable": report.learning.comparable,
"earlier": _cohort_payload(report.learning.earlier),
"earlier_approval_share": report.learning.earlier_approval_share,
"earlier_gap_share": report.learning.earlier_gap_share,
"gap_share_delta": report.learning.gap_share_delta,
"gap_shrinking": report.learning.gap_shrinking,
"later": _cohort_payload(report.learning.later),
"later_approval_share": report.learning.later_approval_share,
"later_gap_share": report.learning.later_gap_share,
},
"modelled_nok": report.modelled_nok,
"n_pending": report.n_pending,
"n_proposals": report.n_proposals,
"n_settled": report.n_settled,
"projects": [
{
"expert_corrected_nok": project.expert_corrected_nok,
"modelled_nok": project.modelled_nok,
"project_id": project.project_id,
"proposals": [
{
"decision": proposal.decision,
"expert_corrected_nok": proposal.expert_corrected_nok,
"measure": proposal.measure,
"modelled_nok": proposal.modelled_nok,
"run_id": proposal.run_id,
"status": proposal.status,
"verdict_id": proposal.verdict_id,
}
for proposal in project.proposals
],
"quantified_proposals": project.quantified_proposals,
"realized_nok": project.realized_nok,
"unquantified_proposals": project.unquantified_proposals,
}
for project in report.projects
],
"quantified_proposals": report.quantified_proposals,
"realized_nok": report.realized_nok,
"unquantified_proposals": report.unquantified_proposals,
}
def report_to_json(report: ValueReport) -> str:
"""Deterministic house JSON: sorted keys, 2-space indent, trailing LF."""
return json.dumps(report_payload(report), sort_keys=True, indent=2, ensure_ascii=False) + "\n"
def _nok(value: float | None) -> str:
"""Render a figure, or the UNMARKED label — never a blank that reads as zero."""
return _UNMARKED if value is None else f"{value:,.0f} NOK".replace(",", " ")
def _pct(share: float | None) -> str:
return _UNMARKED if share is None else f"{share * 100:.1f}%"
def render_report(report: ValueReport) -> str:
"""Render the report as text — every missing figure is LABELLED, never blank."""
lines = [
"VERDIRAPPORT — modellert → ekspert-korrigert → realisert",
(
f" modellert {_nok(report.modelled_nok)}\n"
f" ekspert-korrigert {_nok(report.expert_corrected_nok)} "
f"({report.quantified_proposals} of {report.n_proposals} proposals quantified, "
f"{report.unquantified_proposals} {_UNMARKED})\n"
f" realisert {_nok(report.realized_nok)} "
f"(gap vs modellert: {_nok(report.gap_nok)}, {_pct(report.gap_share)})"
),
(
f" {report.n_proposals} proposal(s): {report.n_settled} settled, "
f"{report.n_pending} awaiting a verdict, approval share "
f"{_pct(report.approval_share)}"
),
"",
"PER PROSJEKT",
]
for project in report.projects:
lines.append(
f" {project.project_id:<12} modellert {_nok(project.modelled_nok):>15} "
f"korrigert {_nok(project.expert_corrected_nok):>15} "
f"realisert {_nok(project.realized_nok):>15}"
)
lines.append("")
lines.append("LÆRINGSEFFEKT (tidligere → senere kjøringer, delt på run_id-rekkefølge)")
learning = report.learning
if not learning.comparable:
lines.append(
" not comparable — fewer than two settled proposals; one judgment is a "
"data point, not a trend"
)
else:
lines.append(
f" godkjenningsandel {_pct(learning.earlier_approval_share)}"
f"{_pct(learning.later_approval_share)} "
f"(delta {_pct(learning.approval_share_delta)})"
)
lines.append(
f" gap modellert→korrigert {_pct(learning.earlier_gap_share)}"
f"{_pct(learning.later_gap_share)} "
f"(delta {_pct(learning.gap_share_delta)}, "
f"shrinking={learning.gap_shrinking})"
)
if report.goal is not None:
lines.append("")
lines.append(
f"MÅLPROGRESJON {_nok(report.goal.realized_nok)} of "
f"{_nok(report.goal.target_nok)} ({_pct(report.goal.share)}), "
f"reached={report.goal.reached}"
)
if report.cost is not None:
lines.append("")
lines.append(
f"KOST MOT VERDI ~${report.cost.estimated_cost_usd:.6f} (ESTIMAT, K6 upper bound) "
f"vs realisert {_nok(report.cost.realized_value_nok)}"
)
lines.append(f" {report.cost.note}")
return "\n".join(lines)
def main(argv: list[str] | None = None) -> int:
"""The thin CLI: load the three layers fail-fast (§10) → project → print → optionally write."""
parser = argparse.ArgumentParser(
description=(
"Project the outbox, the inbox and the ledger into one deterministic "
"value report — modelled → expert-corrected → realized, goal progress, "
"learning effect, cost against value. Reads only; no model call."
)
)
parser.add_argument("--outbox", required=True, help="outbox dir (run_id-named pairs, K5)")
parser.add_argument("--inbox", required=True, help="inbox dir (expert verdict files, §4.2)")
parser.add_argument("--ledger", default=None, help="ledger file (K1); absent = empty book")
parser.add_argument("--goal-nok", type=float, default=None, help="absolute savings target")
parser.add_argument(
"--estimated-cost-usd", type=float, default=None, help="K6 upper-bound run cost estimate"
)
parser.add_argument("--json", default=None, help="also write the report as JSON to this path")
args = parser.parse_args(argv)
try:
report = build_value_report(
outbox_dir=Path(args.outbox),
inbox_dir=Path(args.inbox),
ledger_path=None if args.ledger is None else Path(args.ledger),
goal=None
if args.goal_nok is None
else GoalContract(target_nok=args.goal_nok, mode="soft"),
estimated_cost_usd=args.estimated_cost_usd,
)
except (OSError, ValueError, ValidationError) as exc:
print(f"VALUE REPORT FAILED — refusing to project a malformed layer (§10): {exc}")
return 1
print(render_report(report))
if args.json is not None:
Path(args.json).write_text(report_to_json(report), encoding="utf-8", newline="\n")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,13 @@
{
"decision": "approved_with_adjustment",
"id": "82d6e4e60ae87e14",
"proposal_features": {
"affected_codes": [
"BY-11"
],
"claimed_saving_nok": 60000.0,
"description": "Etterisolering in FV42-P3",
"measure_type": "Etterisolering"
},
"rationale": "fixture verdict for run-004 (approved_with_adjustment)"
}

View file

@ -0,0 +1,13 @@
{
"decision": "approved",
"id": "94a98883515d1267",
"proposal_features": {
"affected_codes": [
"VT-07"
],
"claimed_saving_nok": 80000.0,
"description": "VFD-pumpe in FV42-P1",
"measure_type": "VFD-pumpe"
},
"rationale": "fixture verdict for run-002 (approved)"
}

View file

@ -0,0 +1,13 @@
{
"decision": "rejected",
"id": "fe354d69ef8a47b1",
"proposal_features": {
"affected_codes": [
"EL-01"
],
"claimed_saving_nok": 100000.0,
"description": "LED-retrofit in FV42-P1",
"measure_type": "LED-retrofit"
},
"rationale": "fixture verdict for run-001 (rejected)"
}

View file

@ -0,0 +1,16 @@
{
"entries": [
{
"affected_codes": [
"VT-07"
],
"amount_nok": 80000.0,
"dimension": null,
"expert": "ke.fixture",
"key": "3826f2b927f7cc7f",
"measure_type": "VFD-pumpe",
"project": "FV42-P1",
"timestamp": "2026-07-24T00:00:00Z"
}
]
}

View file

@ -0,0 +1,29 @@
{
"attempts": 1,
"checker_decision": "approve",
"outcome": {
"claimed_saving_nok": 100000.0,
"nominal_feasible": 100000.0,
"p10": 80000.0,
"p50": 100000.0,
"p90": 120000.0,
"type": "validated",
"validates": true
},
"provenance": {
"citations": [
{
"file": "tiltak-vfd.md",
"snippet": "VFD paa pumpe P-07",
"span": "L1-L4"
}
],
"model": "unknown",
"role": "proposer",
"tokens_used": 0,
"validator_decision": "validated"
},
"run_id": "run-001",
"validator_decision": "validated",
"verdict_id": "fe354d69ef8a47b1"
}

View file

@ -0,0 +1,13 @@
{
"affected_items": [
{
"code": "EL-01",
"quantity": 1.0,
"unit_cost": 100000.0
}
],
"assumptions": {},
"claimed_saving_nok": 100000.0,
"measure": "LED-retrofit",
"project_id": "FV42-P1"
}

View file

@ -0,0 +1,29 @@
{
"attempts": 1,
"checker_decision": "approve",
"outcome": {
"claimed_saving_nok": 80000.0,
"nominal_feasible": 80000.0,
"p10": 64000.0,
"p50": 80000.0,
"p90": 96000.0,
"type": "validated",
"validates": true
},
"provenance": {
"citations": [
{
"file": "tiltak-vfd.md",
"snippet": "VFD paa pumpe P-07",
"span": "L1-L4"
}
],
"model": "unknown",
"role": "proposer",
"tokens_used": 0,
"validator_decision": "validated"
},
"run_id": "run-002",
"validator_decision": "validated",
"verdict_id": "94a98883515d1267"
}

View file

@ -0,0 +1,13 @@
{
"affected_items": [
{
"code": "VT-07",
"quantity": 1.0,
"unit_cost": 80000.0
}
],
"assumptions": {},
"claimed_saving_nok": 80000.0,
"measure": "VFD-pumpe",
"project_id": "FV42-P1"
}

View file

@ -0,0 +1,29 @@
{
"attempts": 1,
"checker_decision": "approve",
"outcome": {
"claimed_saving_nok": 50000.0,
"nominal_feasible": 50000.0,
"p10": 40000.0,
"p50": 50000.0,
"p90": 60000.0,
"type": "validated",
"validates": true
},
"provenance": {
"citations": [
{
"file": "tiltak-vfd.md",
"snippet": "VFD paa pumpe P-07",
"span": "L1-L4"
}
],
"model": "unknown",
"role": "proposer",
"tokens_used": 0,
"validator_decision": "validated"
},
"run_id": "run-003",
"validator_decision": "validated",
"verdict_id": "8ec27d38b3e5a8e2"
}

View file

@ -0,0 +1,13 @@
{
"affected_items": [
{
"code": "AU-03",
"quantity": 1.0,
"unit_cost": 50000.0
}
],
"assumptions": {},
"claimed_saving_nok": 50000.0,
"measure": "SD-anlegg",
"project_id": "FV42-P2"
}

View file

@ -0,0 +1,29 @@
{
"attempts": 1,
"checker_decision": "approve",
"outcome": {
"claimed_saving_nok": 60000.0,
"nominal_feasible": 60000.0,
"p10": 48000.0,
"p50": 60000.0,
"p90": 72000.0,
"type": "validated",
"validates": true
},
"provenance": {
"citations": [
{
"file": "tiltak-vfd.md",
"snippet": "VFD paa pumpe P-07",
"span": "L1-L4"
}
],
"model": "unknown",
"role": "proposer",
"tokens_used": 0,
"validator_decision": "validated"
},
"run_id": "run-004",
"validator_decision": "validated",
"verdict_id": "82d6e4e60ae87e14"
}

View file

@ -0,0 +1,13 @@
{
"affected_items": [
{
"code": "BY-11",
"quantity": 1.0,
"unit_cost": 60000.0
}
],
"assumptions": {},
"claimed_saving_nok": 60000.0,
"measure": "Etterisolering",
"project_id": "FV42-P3"
}

View file

@ -0,0 +1,376 @@
"""Per-run value report — LOAD-BEARING (S5.4-analog; §1, §11; paritetsrad 25; K11).
The seam this file keeps alive: every run/portfolio pass can produce a
deterministic value report modelled expert-corrected realized, goal
progress, a QUANTIFIED learning effect, and cost-against-value as a PURE
PROJECTION over state that already exists (the outbox pairs from K5, the §4.2
inbox verdicts, the K1 ledger) with not one model call and not one byte written.
The honesty rule (§1) is the spine of this module, so it is the spine of this
file: a figure the layers do not carry is reported as UNMARKED, never filled in
from the stage before it. An expert who has not judged is not an expert who
approved; an ``approved_with_adjustment`` verdict carries no adjusted amount in
the §4.2 shape, so the corrected value is unquantified NOT the modelled claim.
A report that silently promoted modelled figures into the realized column would
be the single most flattering lie this system could tell about itself.
Three detached seams proven RED here:
* Detach proof (the honesty boundary): let the corrected/realized figure fall
back to the modelled claim for a project with no verdict
``test_project_without_verdict_has_unmarked_realized`` and
``test_unquantified_adjustment_is_never_the_claim`` go red.
* Detach proof (the gap computation): drop the gap arithmetic from the cohort
roll-up so ``gap_share`` stays flat/None ``test_learning_effect_rises_with_
approval_share`` goes red (a rising approval share alone must NOT be reported
as learning without the shrinking gap that backs it).
* Detach proof (the projection purity): let any build path write a byte into the
three read layers ``test_report_never_writes_the_read_layers`` goes red.
EVERY test projects from a per-test COPY of the committed fixtures, never the
committed tree in place. That is not tidiness the purity test was green-but-
dead when tests read the tree directly: an earlier test's stray write landed in
the committed tree, so it was already present in the "before" snapshot and the
detached write reproduced it byte-for-byte. Reading only copies removes the
channel through which one test can pre-seed another's evidence.
Key assumption (tested, not asserted in prose): the outbox + inbox + ledger carry
everything the report needs it is a pure projection with NO new state. Proven
by building the whole report from the COMMITTED fixtures under
``tests/data/valuereport/`` without executing a single run.
"""
from __future__ import annotations
import json
import shutil
from dataclasses import dataclass
from pathlib import Path
import pytest
from portfolio_optimiser_claude.goals import GoalContract
from portfolio_optimiser_claude.valuereport import (
ProjectValue,
ValueReport,
build_value_report,
main,
render_report,
report_to_json,
)
FIXTURES = Path(__file__).parent / "data" / "valuereport"
# The committed fixture scenario, stated once so every expectation below is
# readable against it (generated with the REAL primitives, so the verdict_id
# join is genuine — never hand-typed ids):
#
# run-001 FV42-P1 LED-retrofit 100 000 -> verdict: rejected
# run-002 FV42-P1 VFD-pumpe 80 000 -> verdict: approved -> ledger
# run-003 FV42-P2 SD-anlegg 50 000 -> NO verdict (pending)
# run-004 FV42-P3 Etterisolering 60 000 -> verdict: approved_with_adjustment
#
# Ledger total: 80 000 (only the plain approval passed the expert gate).
MODELLED_TOTAL = 290_000.0
REALIZED_TOTAL = 80_000.0
# The committed tree, pinned: a stray file written by a run under test would
# otherwise be committed as if it belonged here.
EXPECTED_FIXTURE_FILES = {
"inbox/82d6e4e60ae87e14.json",
"inbox/94a98883515d1267.json",
"inbox/fe354d69ef8a47b1.json",
"ledger.json",
"outbox/run-001-outcome.json",
"outbox/run-001-proposal.json",
"outbox/run-002-outcome.json",
"outbox/run-002-proposal.json",
"outbox/run-003-outcome.json",
"outbox/run-003-proposal.json",
"outbox/run-004-outcome.json",
"outbox/run-004-proposal.json",
}
@dataclass(frozen=True)
class Layers:
"""The three read layers, as a private copy of the committed fixtures."""
root: Path
outbox: Path
inbox: Path
ledger: Path
@pytest.fixture
def layers(tmp_path: Path) -> Layers:
"""A fresh copy of the committed fixtures — the tree under test is never the real one."""
root = tmp_path / "layers"
shutil.copytree(FIXTURES, root)
return Layers(
root=root, outbox=root / "outbox", inbox=root / "inbox", ledger=root / "ledger.json"
)
def _build(layers: Layers, **kwargs: object) -> ValueReport:
return build_value_report(
outbox_dir=layers.outbox,
inbox_dir=layers.inbox,
ledger_path=layers.ledger,
**kwargs, # type: ignore[arg-type]
)
def _project(report: ValueReport, project_id: str) -> ProjectValue:
return next(project for project in report.projects if project.project_id == project_id)
# --- the three stages: modelled -> expert-corrected -> realized ------------------------------
def test_committed_fixture_tree_is_exactly_what_it_claims() -> None:
"""No stray file has crept into the committed layers (a run under test writing home)."""
present = {
str(path.relative_to(FIXTURES))
for path in FIXTURES.rglob("*")
if path.is_file() and "__pycache__" not in path.parts
}
assert present == EXPECTED_FIXTURE_FILES
def test_report_is_a_pure_projection_of_the_committed_fixtures(layers: Layers) -> None:
"""The key assumption: outbox + inbox + ledger carry everything (no run needed)."""
report = _build(layers)
assert report.modelled_nok == MODELLED_TOTAL
assert report.realized_nok == REALIZED_TOTAL
assert report.n_proposals == 4
assert report.n_settled == 3
assert report.n_pending == 1
def test_three_stages_are_distinct_columns_per_project(layers: Layers) -> None:
"""Each stage is measured from its OWN layer — never copied from the one before."""
p1 = _project(_build(layers), "FV42-P1") # one rejected (->0), one approved (->80k)
assert p1.modelled_nok == 180_000.0
assert p1.expert_corrected_nok == 80_000.0
assert p1.realized_nok == 80_000.0
def test_project_without_verdict_has_unmarked_realized(layers: Layers) -> None:
"""§1 honesty: no verdict → realized is UNMARKED, and NEVER equal to modelled.
Detach the honesty boundary (the figure falls back to the modelled claim) and
this test goes red which is the whole point of it existing.
"""
p2 = _project(_build(layers), "FV42-P2") # run-003, no verdict at all
assert p2.realized_nok is None
assert p2.expert_corrected_nok is None
assert p2.modelled_nok == 50_000.0
assert p2.realized_nok != p2.modelled_nok
def test_unquantified_adjustment_is_never_the_claim(layers: Layers) -> None:
"""``approved_with_adjustment`` carries NO amount in §4.2 → unquantified, not the claim."""
p3 = _project(_build(layers), "FV42-P3") # run-004, adjusted without a figure
assert p3.expert_corrected_nok is None
assert p3.realized_nok is None
assert p3.modelled_nok == 60_000.0
def test_partial_quantification_is_counted_not_hidden(layers: Layers) -> None:
"""A partly-quantified portfolio says so in numbers — never a silent full-looking sum."""
report = _build(layers)
assert report.expert_corrected_nok == 80_000.0
assert report.quantified_proposals == 2
assert report.unquantified_proposals == 2
# --- quantified learning effect (the plan's first RED) ---------------------------------------
def test_learning_effect_rises_with_approval_share(layers: Layers) -> None:
"""Two cohorts of runs with a rising approval share → the metric rises AND the gap shrinks.
Detach the gap computation (cohort gap_share left flat/None) and this test
goes red: a rising approval share on its own is NOT evidence of learning, so
the report must never claim it without the gap that backs it.
"""
learning = _build(layers).learning
assert learning.comparable is True
# earlier cohort = run-001 (rejected), later = run-002 + run-004 (both accepted)
assert learning.earlier_approval_share == 0.0
assert learning.later_approval_share == 1.0
assert learning.approval_share_delta == 1.0
# ...and the modelled→corrected gap shrank from "the whole claim" to nothing.
assert learning.earlier_gap_share == 1.0
assert learning.later_gap_share == 0.0
assert learning.gap_share_delta == -1.0
assert learning.gap_shrinking is True
def test_learning_needs_two_cohorts_before_it_claims_anything(layers: Layers) -> None:
"""One settled run cannot evidence a trend — the report says so instead of inventing one."""
for verdict_file in layers.inbox.glob("*.json"):
verdict_file.unlink() # no verdicts at all → nothing settled to compare
learning = _build(layers).learning
assert learning.comparable is False
assert learning.approval_share_delta is None
assert learning.gap_share_delta is None
assert learning.gap_shrinking is None
# --- goal progress + cost against value ------------------------------------------------------
def test_goal_progress_measures_realized_against_the_target(layers: Layers) -> None:
"""Goal progress is measured on REALIZED value — the only figure that survived the gate."""
report = _build(layers, goal=GoalContract(target_nok=200_000.0, mode="soft"))
assert report.goal is not None
assert report.goal.target_nok == 200_000.0
assert report.goal.realized_nok == REALIZED_TOTAL
assert report.goal.share == 0.4
assert report.goal.reached is False
def test_cost_against_value_never_invents_an_exchange_rate(layers: Layers) -> None:
"""USD cost and NOK value sit side by side — no ratio, because no sourced FX rate exists."""
report = _build(layers, estimated_cost_usd=0.127514)
assert report.cost is not None
assert report.cost.estimated_cost_usd == 0.127514
assert report.cost.realized_value_nok == REALIZED_TOTAL
assert "ESTIMAT" in report.cost.note
# The honest omission: the two currencies are NOT divided into a fake ratio.
assert not hasattr(report.cost, "ratio")
# --- byte determinism (the plan's third RED) --------------------------------------------------
def test_report_bytes_are_deterministic(layers: Layers, tmp_path: Path) -> None:
"""Same ledger + outbox + inbox → byte-identical report (the ``diff`` the plan asks for)."""
first = tmp_path / "first.json"
second = tmp_path / "second.json"
first.write_text(report_to_json(_build(layers)), encoding="utf-8", newline="\n")
second.write_text(report_to_json(_build(layers)), encoding="utf-8", newline="\n")
assert first.read_bytes() == second.read_bytes()
def test_report_json_is_house_style_bytes(layers: Layers) -> None:
"""Sorted keys, 2-space indent, LF only, trailing newline — the house JSON contract."""
payload = report_to_json(_build(layers))
assert payload.endswith("\n")
assert "\r" not in payload
parsed = json.loads(payload)
assert payload == json.dumps(parsed, sort_keys=True, indent=2, ensure_ascii=False) + "\n"
def test_markdown_render_marks_every_unmarked_figure(layers: Layers) -> None:
"""The rendered report never shows a blank where a number is missing — it says so."""
rendered = render_report(_build(layers))
assert "FV42-P2" in rendered
assert "UNMARKED" in rendered # the pending/unquantified projects are labelled
assert "ESTIMAT" not in rendered # no cost estimate was passed in → no cost section
# --- projection purity: the report writes NOTHING ---------------------------------------------
def _snapshot(root: Path) -> dict[str, bytes]:
return {
str(p.relative_to(root)): p.read_bytes() for p in sorted(root.rglob("*")) if p.is_file()
}
def test_report_never_writes_the_read_layers(layers: Layers) -> None:
"""Read-only over all three layers (§3 Step 7) — byte snapshot before/after.
Honest limit: a write that reproduces a file's exact existing bytes is
invisible to a content snapshot. The failure this guards is state leaking
into the read layers, which changes bytes or adds files.
"""
before = _snapshot(layers.root)
_build(
layers,
goal=GoalContract(target_nok=200_000.0, mode="soft"),
estimated_cost_usd=0.5,
)
assert _snapshot(layers.root) == before
# --- the CLI seam -----------------------------------------------------------------------------
def test_cli_writes_the_report_and_exits_zero(
layers: Layers, tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
"""The thin CLI: project the three layers, print the render, write the JSON."""
out = tmp_path / "value-report.json"
code = main(
[
"--outbox",
str(layers.outbox),
"--inbox",
str(layers.inbox),
"--ledger",
str(layers.ledger),
"--json",
str(out),
]
)
assert code == 0
assert out.read_text(encoding="utf-8") == report_to_json(_build(layers))
assert "VERDIRAPPORT" in capsys.readouterr().out
def test_cli_refuses_a_malformed_ledger_fail_fast(layers: Layers) -> None:
"""§10 (SC5): a valid-JSON-but-wrong-shape ledger is refused, never read as an empty book."""
layers.ledger.write_text('{"entries": {}}', encoding="utf-8")
code = main(
[
"--outbox",
str(layers.outbox),
"--inbox",
str(layers.inbox),
"--ledger",
str(layers.ledger),
]
)
assert code == 1
def test_cli_refuses_a_non_object_ledger_fail_fast(layers: Layers) -> None:
"""A JSON array is not a book either — the `**` unpack must not surface as a crash."""
layers.ledger.write_text("[]", encoding="utf-8")
code = main(
[
"--outbox",
str(layers.outbox),
"--inbox",
str(layers.inbox),
"--ledger",
str(layers.ledger),
]
)
assert code == 1

View file

@ -0,0 +1,164 @@
"""Value-report seam in run.py — LOAD-BEARING (S5.4-analog; §11; paritetsrad 25; K11).
The seam this file keeps alive: the deliverable entrance can produce the value
report as an OPT-IN side product of a run, projecting the layers that are on
disk when the run finishes including the outbox pair the run itself just
filed. The report is a projection, never a second source of truth, so it can
neither change the run's verdict nor be produced without an outbox to project.
Detach proofs:
* Report wired: an opted-in run writes the JSON, and it carries THIS run's
proposal as modelled value. Detach point: drop the ``write_value_report`` call
in ``main`` no file ``test_run_writes_the_value_report`` RED.
* Opt-in requires an outbox, refused BEFORE any spend: ``--value-report`` without
``--outbox`` exits via ``parser.error`` and the scripted client is never
constructed. Detach point: remove the fail-fast the run spends and only then
discovers it has nothing to project ``test_value_report_without_outbox_
refuses_before_any_spend`` RED.
* Both outcomes: a budget-stopped run still writes the report AND still exits 3.
Detach point: produce the report only on success, or let a report failure
overwrite the run's exit code → ``test_budget_stop_still_reports_and_keeps_
exit_code`` RED.
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from _scripted import ScriptedClient, reply
from portfolio_optimiser_claude.contracts import Contracts
from portfolio_optimiser_claude.ir import load_validator_input
from portfolio_optimiser_claude.loop import ModelClient
from portfolio_optimiser_claude.run import main as run_main
BUNDLE = Path(__file__).resolve().parents[1] / "shared" / "examples" / "bygg-energi-mikro"
def _scripted_factory(replies: list[object]) -> tuple[object, list[ScriptedClient]]:
created: list[ScriptedClient] = []
def factory(contracts: Contracts, max_budget_usd_per_call: float) -> ModelClient:
client = ScriptedClient(replies=list(replies)) # type: ignore[arg-type]
created.append(client)
return client
return factory, created
def _happy_replies() -> list[object]:
return [
reply("debate reasoning"),
reply("VERDICT: APPROVE"),
reply(json.dumps(load_validator_input(BUNDLE).model_dump())),
]
def _run_args(tmp_path: Path, *extra: str) -> list[str]:
return [
"--bundle",
str(BUNDLE),
"--out",
str(tmp_path / "out"),
"--outbox",
str(tmp_path / "outbox"),
"--run-id",
"r-001",
*extra,
]
def test_run_writes_the_value_report(tmp_path: Path) -> None:
"""LOAD-BEARING (report wired): the run's own pair shows up as modelled value."""
destination = tmp_path / "reports" / "value.json"
factory, _ = _scripted_factory(_happy_replies())
code = run_main(_run_args(tmp_path, "--value-report", str(destination)), client_factory=factory)
assert code == 0
payload = json.loads(destination.read_text(encoding="utf-8"))
assert payload["n_proposals"] == 1
assert payload["modelled_nok"] == load_validator_input(BUNDLE).claimed_saving_nok
# No inbox, no ledger → the expert stages are UNMARKED, never mirrored from modelled.
assert payload["expert_corrected_nok"] is None
assert payload["n_pending"] == 1
assert payload["projects"][0]["realized_nok"] is None
def test_value_report_without_outbox_refuses_before_any_spend(tmp_path: Path) -> None:
"""LOAD-BEARING (fail-fast): nothing to project → refuse BEFORE constructing a client."""
factory, created = _scripted_factory(_happy_replies())
with pytest.raises(SystemExit):
run_main(
[
"--bundle",
str(BUNDLE),
"--out",
str(tmp_path / "out"),
"--value-report",
str(tmp_path / "value.json"),
],
client_factory=factory,
)
assert created == [] # refused before the client existed → no spend
assert not (tmp_path / "value.json").exists()
def test_budget_stop_still_reports_and_keeps_exit_code(tmp_path: Path) -> None:
"""LOAD-BEARING (both outcomes): a stop is still reported, and stays exit 3."""
destination = tmp_path / "value.json"
factory, _ = _scripted_factory([reply("debate reasoning", usage_tokens=10)])
code = run_main(
_run_args(tmp_path, "--max-tokens", "5", "--value-report", str(destination)),
client_factory=factory,
)
assert code == 3 # the run's own verdict is untouched by reporting
payload = json.loads(destination.read_text(encoding="utf-8"))
assert payload["n_proposals"] == 0 # a stop files no pair — an honest empty picture
def test_report_failure_never_rewrites_a_budget_stop(tmp_path: Path) -> None:
"""A malformed ledger fails the report, but a budget stop still reports as a stop."""
broken_ledger = tmp_path / "ledger.json"
broken_ledger.write_text('{"entries": {}}', encoding="utf-8")
factory, _ = _scripted_factory([reply("debate reasoning", usage_tokens=10)])
code = run_main(
_run_args(
tmp_path,
"--max-tokens",
"5",
"--value-report",
str(tmp_path / "value.json"),
"--ledger",
str(broken_ledger),
),
client_factory=factory,
)
assert code == 3 # NOT rewritten to 1 — the budget stop is the bigger news
assert not (tmp_path / "value.json").exists()
def test_report_failure_fails_an_otherwise_clean_run(tmp_path: Path) -> None:
"""The operator asked for a report and did not get one → the command is non-zero."""
broken_ledger = tmp_path / "ledger.json"
broken_ledger.write_text("[]", encoding="utf-8")
factory, _ = _scripted_factory(_happy_replies())
code = run_main(
_run_args(
tmp_path, "--value-report", str(tmp_path / "value.json"), "--ledger", str(broken_ledger)
),
client_factory=factory,
)
assert code == 1
assert (tmp_path / "outbox" / "r-001-proposal.json").exists() # the run itself still landed