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
376 lines
14 KiB
Python
376 lines
14 KiB
Python
"""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
|