feat(s54): value_report roll-up builder over SavingsLedger accessors
This commit is contained in:
parent
d44305cda9
commit
878c989f8c
2 changed files with 168 additions and 0 deletions
84
src/portfolio_optimiser/value_report.py
Normal file
84
src/portfolio_optimiser/value_report.py
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
"""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,
|
||||
)
|
||||
84
tests/test_value_report.py
Normal file
84
tests/test_value_report.py
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
"""S5.4 — verdirapport unit tests: roll-up correctness (SC1), exact NOK (SC2), determinism (SC6).
|
||||
|
||||
In-process build over a hand-constructed ``SavingsLedger``; local ``_entry(...)`` factory (copied
|
||||
from ``tests/test_ledger.py:22-37``). The roll-up must call the ledger accessors
|
||||
(``per_project_total`` / ``portfolio_total`` / ``overlaps``), never re-sum ``.entries`` (double-count
|
||||
risk on cross-dimension overlaps).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from portfolio_optimiser.ledger import LedgerEntry, SavingsLedger, stamp
|
||||
from portfolio_optimiser.value_report import (
|
||||
ProvenanceLine,
|
||||
build_value_report,
|
||||
)
|
||||
|
||||
_TS = "2026-07-06T00:00:00Z"
|
||||
|
||||
|
||||
def _entry(
|
||||
project_id: str,
|
||||
candidate_identity: str,
|
||||
amount_ore: int,
|
||||
*,
|
||||
dimension: str = "energi",
|
||||
verdict_id: str = "v1",
|
||||
) -> LedgerEntry:
|
||||
return LedgerEntry(
|
||||
project_id=project_id,
|
||||
dimension=dimension,
|
||||
candidate_identity=candidate_identity,
|
||||
amount_ore=amount_ore,
|
||||
verdict_id=verdict_id,
|
||||
provenance=stamp(approver="ekspert", experiment="fase1-sim", timestamp=_TS),
|
||||
)
|
||||
|
||||
|
||||
def _mixed_ledger() -> SavingsLedger:
|
||||
""">=2 projects, >=2 dimensions, one cross-dimension overlap (the same candidate ``c-a`` realized
|
||||
under both ``energi`` and ``asfalt`` in P1 -> counted ONCE by the deduped total, FLAGGED)."""
|
||||
led = SavingsLedger()
|
||||
led.add_realized(_entry("P1", "c-a", 1000, dimension="energi"))
|
||||
led.add_realized(_entry("P1", "c-a", 1000, dimension="asfalt")) # overlap: same candidate id
|
||||
led.add_realized(_entry("P1", "c-b", 2500, dimension="energi"))
|
||||
led.add_realized(_entry("P2", "c-c", 4000, dimension="energi"))
|
||||
return led
|
||||
|
||||
|
||||
def test_build_value_report_matches_ledger_accessors() -> None:
|
||||
"""SC1: per-project + portfolio totals equal the ledger accessors; ``overlaps`` equals
|
||||
``ledger.overlaps()`` (the cross-dimension overlap counted ONCE, flagged)."""
|
||||
led = _mixed_ledger()
|
||||
report = build_value_report(led)
|
||||
|
||||
for pid in {e.project_id for e in led.entries}:
|
||||
assert report.per_project[pid] == led.per_project_total(pid)
|
||||
assert report.portfolio_total_ore == led.portfolio_total()
|
||||
assert report.overlaps == led.overlaps()
|
||||
# overlap counted once: P1 = c-a (1000, once across 2 dims) + c-b (2500) = 3500
|
||||
assert report.per_project["P1"] == 3500
|
||||
assert report.per_project["P2"] == 4000
|
||||
assert report.overlaps == [("P1", "c-a")]
|
||||
|
||||
|
||||
def test_provenance_line_per_entry_verbatim_and_deterministic() -> None:
|
||||
"""SC1 (Revision #9): ``.provenance`` carries one ``ProvenanceLine`` per ledger entry with
|
||||
verbatim ``verdict_id`` + ``provenance`` string, deterministically ordered."""
|
||||
led = _mixed_ledger()
|
||||
report = build_value_report(led)
|
||||
|
||||
assert len(report.provenance) == len(led.entries)
|
||||
for line in report.provenance:
|
||||
assert isinstance(line, ProvenanceLine)
|
||||
|
||||
by_key = {(m.project_id, m.dimension, m.candidate_identity): m for m in report.provenance}
|
||||
for e in led.entries:
|
||||
line = by_key[(e.project_id, e.dimension, e.candidate_identity)]
|
||||
assert line.verdict_id == e.verdict_id # verbatim, never re-minted
|
||||
assert line.provenance == e.provenance # entry's own string, no verdict-store join
|
||||
assert line.amount_ore == e.amount_ore
|
||||
|
||||
# deterministically ordered: same entries in a different insertion order -> identical provenance
|
||||
shuffled = SavingsLedger(entries=list(reversed(led.entries)))
|
||||
assert build_value_report(shuffled).provenance == report.provenance
|
||||
Loading…
Add table
Add a link
Reference in a new issue