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
27
README.md
27
README.md
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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__":
|
||||
|
|
|
|||
615
src/portfolio_optimiser_claude/valuereport.py
Normal file
615
src/portfolio_optimiser_claude/valuereport.py
Normal 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 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 <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())
|
||||
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