feat(fase1): typed savings ledger with deterministic provenance (F1)
This commit is contained in:
parent
d2029964cc
commit
13905193f7
2 changed files with 197 additions and 0 deletions
96
src/portfolio_optimiser/ledger.py
Normal file
96
src/portfolio_optimiser/ledger.py
Normal 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])
|
||||
Loading…
Add table
Add a link
Reference in a new issue