"""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 hashlib import json from decimal import ROUND_HALF_UP, Decimal from pathlib import Path from pydantic import BaseModel, Field from portfolio_optimiser.verdicts import _APPROVED_DECISIONS, ProposalFeatures, Verdict class RealizationRefused(RuntimeError): """Fail-closed gate (SC5, mirrors ``verdicts.PromotionRefused``): a non-approved verdict was offered for realization. Nothing is written — only human/persona-approved savings enter the ledger, never raw agent output (self-contamination).""" 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 to_ore(nok: float) -> int: """The framework's ONE NOK -> integer-øre conversion (Kø-(p)). Via ``Decimal`` to avoid binary-float error: ``12345.67`` NOK -> ``1234567`` øre exactly (a raw ``float * 100`` would drift to ``...66.9999``). Half øre round HALF UP. **Apply this PER money amount, then sum the integers — never sum floats and convert the total.** The two orders disagree (three ``60000.005`` NOK lines are ``18000003`` øre quantized first, ``18000001`` summed first), and each cost line is itself a real amount, so the per-line value is the one that exists. Integer addition is also associative, which keeps every total order-independent under the D-D wave model. ``run.py``'s goal baselines import THIS function rather than re-implementing it: two copies of a money conversion drift, and a drifted copy would put the two sides of a goal comparison on different scales.""" return int((Decimal(str(nok)) * 100).quantize(Decimal("1"), rounding=ROUND_HALF_UP)) 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}" def _candidate_identity( *, affected_codes: frozenset[str], measure_type: str, amount_ore: int ) -> str: """Stable content-hash identity for a realized candidate — a canonical-JSON sha256 over ``sorted(affected_codes) + measure_type + amount_ore`` (mirrors ``verdicts._mint_id``'s canonical form). Integer *øre* is IN the identity, so it is (a) deterministic — no ``30000`` vs ``30000.0`` divergence after a JSON round-trip — AND (b) collision-free: two genuinely distinct realizations with the same codes+measure but a different amount stay SEPARATE (a magnitude-free identity would under-report by merging them). Named module-level so Step-5 tests and Step-6 ``realize`` construct entries with the same identity.""" canonical = json.dumps( { "affected_codes": sorted(affected_codes), "measure_type": measure_type, "amount_ore": amount_ore, }, sort_keys=True, separators=(",", ":"), ) return hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:16] class SavingsLedger(BaseModel): """A typed store of realized ``LedgerEntry`` records — the two-part key (C1). ``add_realized`` stores on the FULL key ``(project_id, dimension, candidate_identity)`` (so the same candidate under two dimensions is kept for overlap flagging); the totals dedup on the dimension-free key ``(project_id, candidate_identity)`` so that underlying saving 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 _sum_key(entry: LedgerEntry) -> tuple[str, str]: """The dimension-FREE key: sum/dedup on this so the same candidate realized under two dimensions is counted ONCE (C1). ``candidate_identity`` embeds the amount, so two entries sharing this key share the amount — summing one representative is well-defined.""" return (entry.project_id, entry.candidate_identity) @staticmethod def _storage_key(entry: LedgerEntry) -> tuple[str, str, str]: """The FULL key: storage + overlap-flagging ONLY, never the sum. The same candidate under a different dimension is a distinct full key, so both are stored (the overlap can be flagged).""" return (entry.project_id, entry.dimension, entry.candidate_identity) def add_realized(self, entry: LedgerEntry) -> bool: """Store ``entry`` unless its FULL key is already present. The same candidate under a DIFFERENT dimension is a distinct full key -> both are stored (so ``overlaps`` can flag it), while the totals still count the underlying saving ONCE (dimension-free key). Returns ``True`` if stored, ``False`` if it was an exact (full-key) duplicate.""" seen = {self._storage_key(e) for e in self.entries} if self._storage_key(entry) in seen: return False self.entries.append(entry) return True def _dedup_amount(self, entries: list[LedgerEntry]) -> int: """Sum ``amount_ore`` over UNIQUE dimension-free keys — each underlying candidate counted once, so a cross-dimension overlap is never double-summed (SC4).""" seen: set[tuple[str, str]] = set() total = 0 for e in entries: key = self._sum_key(e) if key in seen: continue seen.add(key) total += e.amount_ore return total def per_project_total(self, project_id: str) -> int: """Total realized øre for one project — dimension-free-deduped, order-independent.""" return self._dedup_amount([e for e in self.entries if e.project_id == project_id]) def portfolio_total(self) -> int: """Total realized øre across the portfolio — dimension-free-deduped, order-independent.""" return self._dedup_amount(self.entries) def overlaps(self) -> list[tuple[str, str]]: """The dimension-free keys registered under MORE THAN ONE dimension — flagged, never double-summed. Returns the sorted ``(project_id, candidate_identity)`` keys whose stored entries span >1 dimension.""" dims: dict[tuple[str, str], set[str]] = {} for e in self.entries: dims.setdefault(self._sum_key(e), set()).add(e.dimension) return sorted(key for key, ds in dims.items() if len(ds) > 1) 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 non-array top-level or non-object row raises ``ValueError`` (a valid-JSON ``{}`` must NOT masquerade as an empty zero-savings ledger, and a bare scalar / non-object row must NOT leak an uncaught ``TypeError``); a malformed object 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")) if not isinstance(rows, list): raise ValueError( f"savings ledger must be a JSON array of entries, got {type(rows).__name__}: {path!r}" ) entries = [] for row in rows: if not isinstance(row, dict): raise ValueError( f"savings ledger entry must be a JSON object, got {type(row).__name__}: {path!r}" ) entries.append(LedgerEntry(**row)) return cls(entries=entries) def realize( ledger: SavingsLedger, features: ProposalFeatures, verdict: Verdict, *, project_id: str, dimension: str, approver: str, experiment: str, timestamp: str, ) -> LedgerEntry: """Realize an APPROVED candidate into ``ledger`` and return the entry (SC5). FAIL-CLOSED: a verdict whose ``decision`` is not an approval raises ``RealizationRefused`` and writes NOTHING — only human/persona-approved savings enter the ledger (mirrors ``promote_verdict``). The approval set is the PROMOTION set ``{approved, approved_with_adjustment}``, NOT the run-path binary ``FeedbackContract`` (H6). Deliberately NOT wired into ``run_project`` (role split C3): the system READS context; the expert/persona realizes out of band — mirroring how ``promote_verdict`` is never called in the run path (self-contamination guard). ``project_id`` and ``dimension`` are required keywords: a ``LedgerEntry`` is scoped to a project and a dimension, and neither ``features`` nor ``verdict`` carries them. NOK->øre conversion goes through ``to_ore``, the framework's ONE conversion (Kø-(p)). ``timestamp`` is a required keyword (no wall-clock default), so the entry is deterministic.""" if verdict.decision not in _APPROVED_DECISIONS: raise RealizationRefused( f"refusing to realize a non-approved verdict (decision={verdict.decision!r}); " "only human/persona-approved savings enter the ledger (SC5)" ) amount_ore = to_ore(features.claimed_saving_nok) entry = LedgerEntry( project_id=project_id, dimension=dimension, candidate_identity=_candidate_identity( affected_codes=features.affected_codes, measure_type=features.measure_type, amount_ore=amount_ore, ), amount_ore=amount_ore, verdict_id=verdict.id, provenance=stamp(approver=approver, experiment=experiment, timestamp=timestamp), ) ledger.add_realized(entry) return entry