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])
|
||||
101
tests/test_ledger.py
Normal file
101
tests/test_ledger.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue