New ledger.py: typed SavingsLedger; realize is fail-closed on an APPROVED FeedbackContract + a named expert + an explicit timestamp (the §6 determinism rule — no wall-clock default). The sum key is DIMENSION-FREE (the dimension label is annotation only and never participates in the mint), so the same realized saving surfaced via two dimensions lands in one first-write-wins slot and is never double-counted. Deterministic JSON persistence (sort_keys, indent 2, LF, trailing newline), schema-validated on load. New goals.py: GoalContract (absolute target, hard/soft, fail-fast §10). A hard goal reached raises GoalReached, a structured stop event carrying target + observed — never a silent stop; soft flags without stopping. The percent-goal baseline is D-E-gated: the field is reserved and construction refuses with an explicit NotImplementedError. Semantics are marked STACK-LOCAL in the docstrings — mirrored from the MAF plan's capability description, never from MAF code; format shareability stays a proposed decision point in the brief. Two detach proofs delivered (decision gate removed -> red; dimension into the key mint -> the double-counting test red). 400 -> 426 tests; README synced (test count + a Value layer module block). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
171 lines
6.7 KiB
Python
171 lines
6.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)
|