portfolio-optimiser/src/portfolio_optimiser/value_report.py

122 lines
5.1 KiB
Python

"""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"