SavingsLedger.load unpacked the payload with `**`, so a valid-JSON but non-object book ([], "x", 3, null) escaped as a raw TypeError — a failure mode no caller catching ValueError would see. The run path was already covered: valuereport.load_ledger caught the TypeError and re-raised it as ValueError, and `run.py --goals` goes through that function. The leak reached only callers outside that one path, which is why the suite stayed green. The fix moves the normalization DOWN into ledger.py, where the public boundary is, and deletes the now-dead patch in valuereport.load_ledger. One except clause now covers the whole boundary: unparsable bytes (JSONDecodeError), non-object top level (explicit check), wrong-shaped object (ValidationError). Load-bearing (§11): the new TestLoadHasOneFailureType went RED before the fix with exactly the TypeError it exists to forbid — pytest.raises(ValueError) does not swallow it. Detach point named in the class docstring: drop the isinstance check and the array/string cases raise TypeError again. Found by cross-checking MAF's 7dab2df; queued in STATE as post 2b, approved by the operator this session. 604 -> 612 passed, ruff + mypy --strict clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MQu2xxwedckjU56byu1aUG
211 lines
8.7 KiB
Python
211 lines
8.7 KiB
Python
"""Savings ledger — LOAD-BEARING (parity rows 2-3; §6-style determinism, §11).
|
|
|
|
The seam this file keeps alive: realized savings enter the book ONLY through
|
|
the fail-closed expert gate (an explicit approved verdict + expert identity +
|
|
explicit timestamp), and the sum key is DIMENSION-FREE — the same realized
|
|
candidate surfaced via two dimensions lands in ONE slot, never double-counted.
|
|
RED when an unapproved verdict's numbers enter the book (detach point 1: the
|
|
decision gate in ``realize``), or when the mint key starts carrying the
|
|
dimension (detach point 2: dimension exclusion in the sum key).
|
|
|
|
Ledger semantics are a STACK-LOCAL contract mirrored from the MAF plan's
|
|
capability description (typed store, fail-closed realize, dimension-free sum
|
|
key) — never from MAF code.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from pydantic import ValidationError
|
|
|
|
from portfolio_optimiser_claude.contracts import FeedbackContract
|
|
from portfolio_optimiser_claude.ledger import LedgerGateError, SavingsLedger
|
|
|
|
APPROVED = FeedbackContract(decision="approved", rationale="Verified on-site by expert.")
|
|
REJECTED = FeedbackContract(decision="rejected", rationale="Numbers did not hold up.")
|
|
TIMESTAMP = "2026-07-17T03:00:00Z"
|
|
EXPERT = "persona:expert-reviewer"
|
|
|
|
|
|
def _realize(
|
|
ledger: SavingsLedger,
|
|
*,
|
|
project: str = "bygg-energi-mikro",
|
|
measure_type: str = "led-retrofit",
|
|
affected_codes: frozenset[str] = frozenset({"E01"}),
|
|
amount_nok: float = 25000.0,
|
|
verdict: FeedbackContract = APPROVED,
|
|
expert: str = EXPERT,
|
|
timestamp: str = TIMESTAMP,
|
|
dimension: str | None = None,
|
|
) -> object:
|
|
return ledger.realize(
|
|
project=project,
|
|
measure_type=measure_type,
|
|
affected_codes=affected_codes,
|
|
amount_nok=amount_nok,
|
|
verdict=verdict,
|
|
expert=expert,
|
|
timestamp=timestamp,
|
|
dimension=dimension,
|
|
)
|
|
|
|
|
|
class TestExpertGate:
|
|
"""LOAD-BEARING (§11): only expert-APPROVED savings are realized — fail-closed."""
|
|
|
|
def test_rejected_verdict_is_refused_entering_nothing(self) -> None:
|
|
ledger = SavingsLedger()
|
|
with pytest.raises(LedgerGateError):
|
|
_realize(ledger, verdict=REJECTED)
|
|
assert len(ledger) == 0
|
|
assert ledger.total_realized_nok() == 0.0
|
|
|
|
def test_blank_expert_identity_is_refused(self) -> None:
|
|
ledger = SavingsLedger()
|
|
with pytest.raises(LedgerGateError):
|
|
_realize(ledger, expert=" ")
|
|
assert len(ledger) == 0
|
|
|
|
def test_expert_is_an_explicit_required_argument(self) -> None:
|
|
# No implicit expert — realization without a named expert is a call error.
|
|
with pytest.raises(TypeError):
|
|
SavingsLedger().realize( # type: ignore[call-arg]
|
|
project="p",
|
|
measure_type="m",
|
|
affected_codes=frozenset({"E01"}),
|
|
amount_nok=1.0,
|
|
verdict=APPROVED,
|
|
timestamp=TIMESTAMP,
|
|
)
|
|
|
|
def test_timestamp_is_an_explicit_required_argument(self) -> None:
|
|
# No wall-clock default — realization is deterministic and reproducible (§6-style).
|
|
with pytest.raises(TypeError):
|
|
SavingsLedger().realize( # type: ignore[call-arg]
|
|
project="p",
|
|
measure_type="m",
|
|
affected_codes=frozenset({"E01"}),
|
|
amount_nok=1.0,
|
|
verdict=APPROVED,
|
|
expert=EXPERT,
|
|
)
|
|
|
|
@pytest.mark.parametrize("amount", [0.0, -25000.0, math.inf, math.nan])
|
|
def test_non_positive_or_non_finite_amounts_are_refused(self, amount: float) -> None:
|
|
ledger = SavingsLedger()
|
|
with pytest.raises(ValidationError):
|
|
_realize(ledger, amount_nok=amount)
|
|
assert len(ledger) == 0
|
|
|
|
|
|
class TestDimensionFreeSumKey:
|
|
"""LOAD-BEARING (§11): the sum key excludes the dimension — no double counting."""
|
|
|
|
def test_same_candidate_under_two_dimensions_is_one_slot(self) -> None:
|
|
# Key assumption (K1 plan): the same realized saving surfaced via two
|
|
# dimensions in the same project must NOT be counted twice.
|
|
ledger = SavingsLedger()
|
|
first = _realize(ledger, dimension="energi")
|
|
second = _realize(ledger, dimension="vedlikehold")
|
|
assert len(ledger) == 1
|
|
assert ledger.total_realized_nok() == 25000.0
|
|
assert second == first # first-write-wins, §4.2-style idempotence
|
|
|
|
def test_distinct_candidates_in_the_same_project_both_count(self) -> None:
|
|
ledger = SavingsLedger()
|
|
_realize(ledger, measure_type="led-retrofit", amount_nok=25000.0)
|
|
_realize(ledger, measure_type="heat-recovery", amount_nok=40000.0)
|
|
assert len(ledger) == 2
|
|
assert ledger.total_realized_nok() == 65000.0
|
|
|
|
def test_same_candidate_across_projects_both_count(self) -> None:
|
|
ledger = SavingsLedger()
|
|
_realize(ledger, project="prosjekt-a")
|
|
_realize(ledger, project="prosjekt-b")
|
|
assert len(ledger) == 2
|
|
assert ledger.total_realized_nok() == 50000.0
|
|
|
|
|
|
class TestDeterministicPersistence:
|
|
"""Deterministic JSON persistence: sort_keys, indent 2, LF, trailing newline."""
|
|
|
|
def _populated(self) -> SavingsLedger:
|
|
ledger = SavingsLedger()
|
|
_realize(ledger, project="prosjekt-b", amount_nok=40000.0)
|
|
_realize(ledger, project="prosjekt-a", amount_nok=25000.0)
|
|
return ledger
|
|
|
|
def test_identical_sequences_persist_byte_identically(self, tmp_path: Path) -> None:
|
|
path_a = tmp_path / "a.json"
|
|
path_b = tmp_path / "b.json"
|
|
self._populated().save(path_a)
|
|
self._populated().save(path_b)
|
|
assert path_a.read_bytes() == path_b.read_bytes()
|
|
|
|
def test_file_is_lf_only_with_trailing_newline(self, tmp_path: Path) -> None:
|
|
path = tmp_path / "ledger.json"
|
|
self._populated().save(path)
|
|
data = path.read_bytes()
|
|
assert b"\r" not in data
|
|
assert data.endswith(b"\n")
|
|
|
|
def test_round_trip_is_byte_identical_and_sum_preserving(self, tmp_path: Path) -> None:
|
|
original = tmp_path / "original.json"
|
|
rewritten = tmp_path / "rewritten.json"
|
|
ledger = self._populated()
|
|
ledger.save(original)
|
|
loaded = SavingsLedger.load(original)
|
|
loaded.save(rewritten)
|
|
assert rewritten.read_bytes() == original.read_bytes()
|
|
assert loaded.total_realized_nok() == ledger.total_realized_nok()
|
|
|
|
def test_load_fails_fast_on_a_malformed_entry(self, tmp_path: Path) -> None:
|
|
path = tmp_path / "ledger.json"
|
|
text = self._populated().to_json().replace("25000.0", "-25000.0")
|
|
path.write_text(text, encoding="utf-8")
|
|
with pytest.raises(ValidationError):
|
|
SavingsLedger.load(path)
|
|
|
|
|
|
class TestLoadHasOneFailureType:
|
|
"""LOAD-BEARING (§10-style, §11): every rejected ledger file raises ``ValueError``.
|
|
|
|
``load`` is a PUBLIC boundary, and its rejections must be catchable as one
|
|
type. A non-object top level (an array, a bare string, a number) reached
|
|
``_LedgerFile(**payload)`` and escaped as a raw ``TypeError`` — a failure
|
|
mode no caller catching ``ValueError`` would see. The run path patched
|
|
around it one level up (``valuereport.load_ledger``), so only callers
|
|
outside that single path met the leak.
|
|
|
|
Detach point: remove the top-level object check in ``load`` → the array and
|
|
string cases raise ``TypeError`` again → RED (``pytest.raises(ValueError)``
|
|
does not swallow a ``TypeError``).
|
|
"""
|
|
|
|
@pytest.mark.parametrize("payload", ["[]", '["a"]', '"x"', "3", "true", "null"])
|
|
def test_a_non_object_top_level_is_a_value_error(self, tmp_path: Path, payload: str) -> None:
|
|
path = tmp_path / "ledger.json"
|
|
path.write_text(payload, encoding="utf-8")
|
|
with pytest.raises(ValueError) as exc:
|
|
SavingsLedger.load(path)
|
|
assert str(path) in str(exc.value) # the refusal names the offending file
|
|
|
|
def test_malformed_json_is_the_same_failure_type(self, tmp_path: Path) -> None:
|
|
# json.JSONDecodeError IS a ValueError — asserted so the one-type
|
|
# contract covers unparsable bytes too, not just wrong shapes.
|
|
path = tmp_path / "ledger.json"
|
|
path.write_text("{not json", encoding="utf-8")
|
|
with pytest.raises(ValueError):
|
|
SavingsLedger.load(path)
|
|
|
|
def test_a_wrong_shaped_object_is_the_same_failure_type(self, tmp_path: Path) -> None:
|
|
# pydantic's ValidationError is a ValueError subclass; the caller needs
|
|
# ONE except clause for the whole boundary.
|
|
path = tmp_path / "ledger.json"
|
|
path.write_text('{"entries": {}}', encoding="utf-8")
|
|
with pytest.raises(ValueError):
|
|
SavingsLedger.load(path)
|