feat(fase1): typed savings ledger with deterministic provenance (F1)

This commit is contained in:
Kjell Tore Guttormsen 2026-07-07 07:53:31 +02:00
commit 13905193f7
2 changed files with 197 additions and 0 deletions

101
tests/test_ledger.py Normal file
View file

@ -0,0 +1,101 @@
"""Behavioral tests for the typed savings ledger (Fase 1, Step 4 — SC3 + SC8-identical).
Multiple realized entries (distinct candidates) total correctly per-project and per-portfolio; each
entry carries provenance + a ``verdict_id`` link; a malformed entry raises ``ValidationError``
(fail-fast, contrast the tolerant verdict inbox); the same input serialized twice is byte-identical
(SC8 determinism). Model tests: ``tests/test_portfolio.py:49`` (aggregate), ``tests/test_contracts.py``
(fail-fast).
"""
from __future__ import annotations
import pytest
from pydantic import ValidationError
from portfolio_optimiser.ledger import LedgerEntry, SavingsLedger, stamp
_TS = "2026-07-06T00:00:00Z"
def _entry(
project_id: str,
candidate_identity: str,
amount_ore: int,
*,
dimension: str = "energi",
verdict_id: str = "v1",
) -> LedgerEntry:
return LedgerEntry(
project_id=project_id,
dimension=dimension,
candidate_identity=candidate_identity,
amount_ore=amount_ore,
verdict_id=verdict_id,
provenance=stamp(approver="ekspert", experiment="fase1-sim", timestamp=_TS),
)
def test_totals_per_project_and_portfolio() -> None:
ledger = SavingsLedger()
assert ledger.add_realized(_entry("P1", "c-a", 1000)) is True
assert ledger.add_realized(_entry("P1", "c-b", 2500)) is True
assert ledger.add_realized(_entry("P2", "c-c", 4000)) is True
assert ledger.per_project_total("P1") == 3500
assert ledger.per_project_total("P2") == 4000
assert ledger.portfolio_total() == 7500
def test_entry_carries_provenance_and_verdict_link() -> None:
e = _entry("P1", "c-a", 1000, verdict_id="verdict-xyz")
assert e.verdict_id == "verdict-xyz"
assert "godkjent av ekspert" in e.provenance
assert _TS in e.provenance # timestamp is baked into the stamp (deterministic, no wall-clock)
def test_malformed_entry_raises_validation_error() -> None:
"""Fail-fast (contrast the tolerant inbox): a negative ``amount_ore`` violates ``ge=0``."""
with pytest.raises(ValidationError):
LedgerEntry(
project_id="P1",
dimension="energi",
candidate_identity="c-a",
amount_ore=-1,
verdict_id="v1",
provenance="x",
)
def test_load_rejects_malformed_row(tmp_path) -> None:
"""A malformed on-disk row raises ``ValidationError`` on load (fail-fast, never skipped)."""
bad = tmp_path / "ledger.json"
bad.write_text('[{"project_id": "P1"}]', encoding="utf-8") # missing required fields
with pytest.raises(ValidationError):
SavingsLedger.load(str(bad))
def test_load_missing_file_raises(tmp_path) -> None:
with pytest.raises(FileNotFoundError):
SavingsLedger.load(str(tmp_path / "does-not-exist.json"))
def test_save_is_byte_identical_regardless_of_order(tmp_path) -> None:
"""SC8 determinism: the same entries inserted in different order serialize byte-identically
(sorted keys + integer øre => the on-disk form is order-independent)."""
a = SavingsLedger()
a.add_realized(_entry("P2", "c-c", 4000))
a.add_realized(_entry("P1", "c-a", 1000))
a.add_realized(_entry("P1", "c-b", 2500))
b = SavingsLedger()
b.add_realized(_entry("P1", "c-b", 2500))
b.add_realized(_entry("P1", "c-a", 1000))
b.add_realized(_entry("P2", "c-c", 4000))
pa, pb = tmp_path / "a.json", tmp_path / "b.json"
a.save(str(pa))
b.save(str(pb))
assert pa.read_text(encoding="utf-8") == pb.read_text(encoding="utf-8")
# Round-trips: load() reconstructs an equivalent ledger with the same totals.
reloaded = SavingsLedger.load(str(pa))
assert reloaded.portfolio_total() == 7500