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])