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