84 lines
3.4 KiB
Python
84 lines
3.4 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
|
|
|
|
from pydantic import BaseModel
|
|
|
|
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,
|
|
)
|