"""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 modelled→corrected 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
\\
--inbox [--ledger ] [--goal-nok N] \\
[--estimated-cost-usd X] [--json ]
"""
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"
# Shares are rounded so the JSON bytes never carry the 1-ULP float tail a cohort
# subtraction leaves behind (0.1 - 0.3 is -0.19999999999999998). MEASURED band,
# bound by the two value proofs in test_valuereport_loadbearing.py: 17 leaks that
# tail, 2 moves the rendered percent, and everything in [3, 16] is identical to
# every consumer this system has. 6 is a convention inside the band — NOT a
# figure any layer derives, and the tests say only what was measured.
_SHARE_DIGITS = 6
@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); anything else is opened
by ``SavingsLedger.load``, which refuses malformed bytes, a non-object top
level and a wrong-shaped object alike as ``ValueError``. Reading any of them
as an empty book would silently report every realized saving as unmarked.
This function adds ONLY the None case — the failure-type normalization lives
at the ledger's own entrance, so every caller gets it, not just this path.
"""
if ledger_path is None:
return SavingsLedger()
return SavingsLedger.load(ledger_path)
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())