"""S5.4 — verdirapport (målbilde §7): read-only savings roll-up over SavingsLedger. MAF-bound via ``ledger`` (which imports the MAF-bound ``verdicts``), so this module CANNOT live in the framework-neutral ``shared/`` subtree and is deliberately NOT registered in ``tests/test_okf.py``'s ``_MAF_FREE_MODULES``. It is a pure OFFLINE read: it turns the ledger's already-deduped integer-øre totals into a legible statement of value (per-project + portfolio totals, flagged cross-dimension overlaps, per-entry provenance). Zero model calls, zero writes. Correctness rests on CALLING the ledger's roll-up accessors (``per_project_total`` / ``portfolio_total`` / ``overlaps``) — never re-summing ``.entries``, which would double-count a saving realized under two dimensions (the exact bug the ledger's dimension-free dedup prevents). """ from __future__ import annotations import json from pydantic import BaseModel from portfolio_optimiser.costsim import _ore_to_kr_str from portfolio_optimiser.ledger import SavingsLedger class ProvenanceLine(BaseModel): """One realized ledger entry surfaced at the report's ENTRY level: verbatim ``verdict_id`` + ``provenance`` string kept in association, so provenance is legible per-entry in the JSON output. Reads ONLY the entry's own fields — never joins the MAF verdict store (Non-Goal).""" project_id: str dimension: str candidate_identity: str verdict_id: str amount_ore: int provenance: str class ValueReport(BaseModel): """The read-only value roll-up over a ``SavingsLedger``: per-project + portfolio totals (integer øre, dimension-free-deduped by the ledger accessors), the flagged cross-dimension ``overlaps``, and one ``ProvenanceLine`` per ledger entry (deterministically ordered).""" per_project: dict[str, int] portfolio_total_ore: int overlaps: list[tuple[str, str]] provenance: list[ProvenanceLine] def build_value_report(ledger: SavingsLedger) -> ValueReport: """Build the value report by CALLING the ledger's deduped roll-up accessors — never summing ``.entries`` directly (double-count risk on cross-dimension overlaps). The provenance sort key is TOTAL: the leading ``(project_id, dimension, candidate_identity)`` triple is the ledger's ``_storage_key``, unique for in-process-built ledgers but NOT for a ``load()``-ed / hand-authored file (load does no dedup), so two rows sharing the triple would tie and their order would follow file insertion order (byte-instability). Appending ``verdict_id, provenance, amount_ore`` makes the order total and byte-stable (SC6). """ project_ids = sorted({e.project_id for e in ledger.entries}) per_project = {pid: ledger.per_project_total(pid) for pid in project_ids} ordered_entries = sorted( ledger.entries, key=lambda e: ( e.project_id, e.dimension, e.candidate_identity, e.verdict_id, e.provenance, e.amount_ore, ), ) provenance = [ ProvenanceLine( project_id=e.project_id, dimension=e.dimension, candidate_identity=e.candidate_identity, verdict_id=e.verdict_id, amount_ore=e.amount_ore, provenance=e.provenance, ) for e in ordered_entries ] return ValueReport( per_project=per_project, portfolio_total_ore=ledger.portfolio_total(), overlaps=ledger.overlaps(), provenance=provenance, ) def format_report_text(report: ValueReport) -> str: """Render the value report as a column-aligned human table: one per-project row (id + realized NOK), a portfolio-total footer, and an overlaps section listing the flagged ``(project_id, candidate_identity)`` keys (or a ``ingen overlapp`` line when empty). Every øre value is formatted through the single byte-pinned money edge ``costsim._ore_to_kr_str`` (float-free integer ``divmod``), and ``per_project`` is iterated in sorted key order for byte-stability (SC6).""" lines = [ "Verdirapport — realiserte besparelser (kun-lesing, ingen modellkall)", "", f"{'prosjekt':<24} {'realisert':>16}", ] for pid in sorted(report.per_project): lines.append(f"{pid:<24} {_ore_to_kr_str(report.per_project[pid]):>16}") lines += [ "", f"{'portefølje totalt':<24} {_ore_to_kr_str(report.portfolio_total_ore):>16}", "", "Kryss-dimensjon overlapp (talt én gang, flagget):", ] if report.overlaps: for project_id, candidate_identity in report.overlaps: lines.append(f" {project_id} / {candidate_identity}") else: lines.append(" ingen overlapp") return "\n".join(lines) def dump_report_json(report: ValueReport) -> str: """Byte-deterministic JSON serialization of the value report (the repo-wide idiom ``sort_keys=True, indent=2`` + trailing LF; mirrors ``costsim.dump_estimate_table`` / ``outbox._dump``). ``overlaps`` tuples serialize to JSON arrays.""" return json.dumps(report.model_dump(), sort_keys=True, indent=2) + "\n"