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:
parent
d0107e7f8c
commit
4dcdd8017a
18 changed files with 1477 additions and 6 deletions
13
tests/data/valuereport/inbox/82d6e4e60ae87e14.json
Normal file
13
tests/data/valuereport/inbox/82d6e4e60ae87e14.json
Normal 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)"
|
||||
}
|
||||
13
tests/data/valuereport/inbox/94a98883515d1267.json
Normal file
13
tests/data/valuereport/inbox/94a98883515d1267.json
Normal 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)"
|
||||
}
|
||||
13
tests/data/valuereport/inbox/fe354d69ef8a47b1.json
Normal file
13
tests/data/valuereport/inbox/fe354d69ef8a47b1.json
Normal 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)"
|
||||
}
|
||||
16
tests/data/valuereport/ledger.json
Normal file
16
tests/data/valuereport/ledger.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
29
tests/data/valuereport/outbox/run-001-outcome.json
Normal file
29
tests/data/valuereport/outbox/run-001-outcome.json
Normal 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"
|
||||
}
|
||||
13
tests/data/valuereport/outbox/run-001-proposal.json
Normal file
13
tests/data/valuereport/outbox/run-001-proposal.json
Normal 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"
|
||||
}
|
||||
29
tests/data/valuereport/outbox/run-002-outcome.json
Normal file
29
tests/data/valuereport/outbox/run-002-outcome.json
Normal 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"
|
||||
}
|
||||
13
tests/data/valuereport/outbox/run-002-proposal.json
Normal file
13
tests/data/valuereport/outbox/run-002-proposal.json
Normal 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"
|
||||
}
|
||||
29
tests/data/valuereport/outbox/run-003-outcome.json
Normal file
29
tests/data/valuereport/outbox/run-003-outcome.json
Normal 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"
|
||||
}
|
||||
13
tests/data/valuereport/outbox/run-003-proposal.json
Normal file
13
tests/data/valuereport/outbox/run-003-proposal.json
Normal 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"
|
||||
}
|
||||
29
tests/data/valuereport/outbox/run-004-outcome.json
Normal file
29
tests/data/valuereport/outbox/run-004-outcome.json
Normal 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"
|
||||
}
|
||||
13
tests/data/valuereport/outbox/run-004-proposal.json
Normal file
13
tests/data/valuereport/outbox/run-004-proposal.json
Normal 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"
|
||||
}
|
||||
376
tests/test_valuereport_loadbearing.py
Normal file
376
tests/test_valuereport_loadbearing.py
Normal 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
|
||||
164
tests/test_valuereport_seam_loadbearing.py
Normal file
164
tests/test_valuereport_seam_loadbearing.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue