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

@ -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__":