S5.4 review MINOR (SC5 fail-fast hole, run.py:740). A valid-JSON but
wrong-shape savings ledger escaped the --report fail-fast refusal:
- top-level {} iterated zero keys -> entries=[] -> rc 0 "0,00 kr"
(a malformed file masquerading as a real zero-savings result)
- a bare scalar / object-with-keys / list-of-non-objects raised an
uncaught TypeError -> traceback (violates SC5 "rc 1, no traceback")
Fix at the fail-fast boundary, not the run.py except tuple: the review's
first option (add TypeError to run.py:740) leaves the {} masquerade
because {} is an empty iteration, not a TypeError. SavingsLedger.load now
raises ValueError for a non-array top-level and a non-object row, caught
by run.py:740's existing ValueError arm. Hardens both callers
(run.py:740 report + run.py:785 portfolio).
RED-first: 6 unit cases (test_ledger) + 2 CLI rc-1 cases (test_run_cli).
452 passed; ruff + mypy clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KNNiJRk1sSwxgVLS5AobT1
230 lines
11 KiB
Python
230 lines
11 KiB
Python
"""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 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 happens HERE and only here, via ``Decimal`` to avoid binary-float error:
|
|
``12345.67`` NOK -> ``1234567`` øre exactly (a raw ``float * 100`` would drift to ...66.9999).
|
|
``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 = int(
|
|
(Decimal(str(features.claimed_saving_nok)) * 100).quantize(
|
|
Decimal("1"), rounding=ROUND_HALF_UP
|
|
)
|
|
)
|
|
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
|