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

View file

@ -0,0 +1,96 @@
"""Typed savings ledger (Fase 1, F1): realized cost savings — deterministic, fail-fast.
MAF-repo-local: imports ``Verdict`` / ``ProposalFeatures`` from ``verdicts`` (which is MAF-bound),
so it CANNOT live in the framework-neutral ``shared/`` subtree (contrast ``dimension.py``, which is
neutral). All amounts are integer *øre* (1 NOK = 100 øre): integer sums are exact and
order-independent, so the goal boundary (Step 8) is deterministic and the on-disk form is
byte-stable a float NOK amount would make both non-deterministic (float associativity).
The ledger is the accumulated record of realized savings a portfolio run stops against (Step 8).
Accumulation dedups on the DIMENSION-FREE key ``(project_id, candidate_identity)`` (C1), so the same
underlying saving counted under two dimensions contributes ONCE. Step 5 formalizes the two-part key
(the full ``(project_id, dimension, candidate_identity)`` is kept only for storage + cross-dimension
overlap flagging) and the ``_candidate_identity`` helper.
"""
from __future__ import annotations
import json
from pathlib import Path
from pydantic import BaseModel, Field
class LedgerEntry(BaseModel):
"""One realized saving, linked to the approving verdict."""
project_id: str
dimension: str
candidate_identity: str
amount_ore: int = Field(ge=0) # integer øre — exact, order-independent sums
verdict_id: str # link to the approving verdict
provenance: str # lightweight who/experiment/when string
def stamp(*, approver: str, experiment: str, timestamp: str) -> str:
"""A lightweight who/experiment/when provenance string for a ledger entry. ``timestamp`` is a
required keyword no wall-clock default so a stamped entry is deterministic and its provenance
reproducible (mirrors ``promote_verdict``). Deliberately NOT ``ProvenanceStamp``, which is
MAF-bound and requires ``citations >= 1`` a ledger entry has no text span to cite."""
return f"godkjent av {approver}; eksperiment {experiment}; {timestamp}"
class SavingsLedger(BaseModel):
"""A typed store of realized ``LedgerEntry`` records.
``add_realized`` gates accumulation on the dimension-free key ``(project_id, candidate_identity)``
(C1): the same underlying candidate, realized under two dimensions, is counted ONCE. Totals sum
integer øre and are therefore order-independent; the sorted iteration in ``save`` is only for
byte-deterministic serialization, not for sum correctness.
"""
entries: list[LedgerEntry] = Field(default_factory=list)
@staticmethod
def _dedup_key(entry: LedgerEntry) -> tuple[str, str]:
return (entry.project_id, entry.candidate_identity)
def add_realized(self, entry: LedgerEntry) -> bool:
"""Append ``entry`` unless its dimension-free key is already present. Returns ``True`` if
added, ``False`` if it was a duplicate (already counted never double-summed)."""
seen = {self._dedup_key(e) for e in self.entries}
if self._dedup_key(entry) in seen:
return False
self.entries.append(entry)
return True
def per_project_total(self, project_id: str) -> int:
"""Total realized øre for one project (order-independent integer sum)."""
return sum(e.amount_ore for e in self.entries if e.project_id == project_id)
def portfolio_total(self) -> int:
"""Total realized øre across the whole portfolio (order-independent integer sum)."""
return sum(e.amount_ore for e in self.entries)
def save(self, path: str) -> None:
"""Serialize deterministically: entries sorted by their full key, then JSON with
``sort_keys=True, indent=2`` (mirrors ``verdicts.py``'s deterministic on-disk form). Same
entries, any insertion order -> byte-identical output."""
ordered = sorted(
self.entries,
key=lambda e: (e.project_id, e.candidate_identity, e.dimension),
)
payload = [e.model_dump() for e in ordered]
Path(path).write_text(json.dumps(payload, sort_keys=True, indent=2), encoding="utf-8")
@classmethod
def load(cls, path: str) -> SavingsLedger:
"""Fail-fast load (mirrors ``okf.load_ir_projection``'s required-input semantics): a missing
file raises ``FileNotFoundError``; a malformed row raises ``pydantic.ValidationError``.
Contrast the tolerant verdict inbox (``load_verdicts_from_dir``), which SKIPS bad files
the ledger is authoritative input, not an out-of-band drop folder."""
p = Path(path)
if not p.is_file():
raise FileNotFoundError(f"savings ledger not found: {path!r}")
rows = json.loads(p.read_text(encoding="utf-8"))
return cls(entries=[LedgerEntry(**row) for row in rows])

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