feat(s54): value_report text + deterministic JSON formatters
This commit is contained in:
parent
878c989f8c
commit
dd566025a4
2 changed files with 128 additions and 0 deletions
|
|
@ -13,8 +13,11 @@ saving realized under two dimensions (the exact bug the ledger's dimension-free
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from portfolio_optimiser.costsim import _ore_to_kr_str
|
||||||
from portfolio_optimiser.ledger import SavingsLedger
|
from portfolio_optimiser.ledger import SavingsLedger
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -82,3 +85,38 @@ def build_value_report(ledger: SavingsLedger) -> ValueReport:
|
||||||
overlaps=ledger.overlaps(),
|
overlaps=ledger.overlaps(),
|
||||||
provenance=provenance,
|
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"
|
||||||
|
|
|
||||||
|
|
@ -8,10 +8,14 @@ risk on cross-dimension overlaps).
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
from portfolio_optimiser.ledger import LedgerEntry, SavingsLedger, stamp
|
from portfolio_optimiser.ledger import LedgerEntry, SavingsLedger, stamp
|
||||||
from portfolio_optimiser.value_report import (
|
from portfolio_optimiser.value_report import (
|
||||||
ProvenanceLine,
|
ProvenanceLine,
|
||||||
build_value_report,
|
build_value_report,
|
||||||
|
dump_report_json,
|
||||||
|
format_report_text,
|
||||||
)
|
)
|
||||||
|
|
||||||
_TS = "2026-07-06T00:00:00Z"
|
_TS = "2026-07-06T00:00:00Z"
|
||||||
|
|
@ -82,3 +86,89 @@ def test_provenance_line_per_entry_verbatim_and_deterministic() -> None:
|
||||||
# deterministically ordered: same entries in a different insertion order -> identical provenance
|
# deterministically ordered: same entries in a different insertion order -> identical provenance
|
||||||
shuffled = SavingsLedger(entries=list(reversed(led.entries)))
|
shuffled = SavingsLedger(entries=list(reversed(led.entries)))
|
||||||
assert build_value_report(shuffled).provenance == report.provenance
|
assert build_value_report(shuffled).provenance == report.provenance
|
||||||
|
|
||||||
|
|
||||||
|
# --- Step 2: text + JSON formatters (exact NOK bytes SC2, byte-determinism SC6) -------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_report_text_exact_nok_bytes() -> None:
|
||||||
|
"""SC2: a portfolio total of ``1234567`` øre renders the EXACT Norwegian NOK bytes
|
||||||
|
``"12\xa0345,67\xa0kr"`` (non-breaking-space thousands + before ``kr``, mirroring
|
||||||
|
``test_costsim.py:95``), and the empty ledger's ``0`` total renders ``"0,00\xa0kr"``."""
|
||||||
|
led = SavingsLedger(entries=[_entry("P1", "c-a", 1234567)])
|
||||||
|
text = format_report_text(build_value_report(led))
|
||||||
|
assert "12\xa0345,67\xa0kr" in text
|
||||||
|
|
||||||
|
empty = format_report_text(build_value_report(SavingsLedger()))
|
||||||
|
assert "0,00\xa0kr" in empty # valid rc-0 zero-savings output, not an error
|
||||||
|
|
||||||
|
|
||||||
|
def _sc6_entries() -> list[LedgerEntry]:
|
||||||
|
"""A determinism fixture that INCLUDES a full-``_storage_key``-duplicate pair (``c-dup`` under
|
||||||
|
``(P1, energi)`` twice, differing only in ``verdict_id``/``provenance``) — constructible ONLY by
|
||||||
|
direct ``SavingsLedger(entries=[...])`` (``add_realized`` would dedup the second). This exercises
|
||||||
|
the TOTAL provenance sort from Step 1: a triple-only sort key would TIE the pair and let
|
||||||
|
insertion order flip the bytes. Also carries a genuine cross-dimension overlap (``c-ov`` under
|
||||||
|
both ``energi`` and ``asfalt``) so ``overlaps`` is non-empty."""
|
||||||
|
return [
|
||||||
|
LedgerEntry(
|
||||||
|
project_id="P1",
|
||||||
|
dimension="energi",
|
||||||
|
candidate_identity="c-dup",
|
||||||
|
amount_ore=1000,
|
||||||
|
verdict_id="v-a",
|
||||||
|
provenance="prov-a",
|
||||||
|
),
|
||||||
|
LedgerEntry(
|
||||||
|
project_id="P1",
|
||||||
|
dimension="energi",
|
||||||
|
candidate_identity="c-dup",
|
||||||
|
amount_ore=1000,
|
||||||
|
verdict_id="v-b",
|
||||||
|
provenance="prov-b", # full-key dup of the above
|
||||||
|
),
|
||||||
|
LedgerEntry(
|
||||||
|
project_id="P1",
|
||||||
|
dimension="energi",
|
||||||
|
candidate_identity="c-ov",
|
||||||
|
amount_ore=3000,
|
||||||
|
verdict_id="v-c",
|
||||||
|
provenance="prov-c",
|
||||||
|
),
|
||||||
|
LedgerEntry(
|
||||||
|
project_id="P1",
|
||||||
|
dimension="asfalt",
|
||||||
|
candidate_identity="c-ov",
|
||||||
|
amount_ore=3000,
|
||||||
|
verdict_id="v-d",
|
||||||
|
provenance="prov-d", # cross-dimension overlap
|
||||||
|
),
|
||||||
|
LedgerEntry(
|
||||||
|
project_id="P2",
|
||||||
|
dimension="energi",
|
||||||
|
candidate_identity="c-c",
|
||||||
|
amount_ore=4000,
|
||||||
|
verdict_id="v-e",
|
||||||
|
provenance="prov-e",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_output_is_byte_deterministic_regardless_of_order() -> None:
|
||||||
|
"""SC6: the same entries inserted in a different order -> byte-identical ``format_report_text``
|
||||||
|
AND ``dump_report_json`` output. The full-key duplicate pair exercises the total provenance
|
||||||
|
sort; ``overlaps`` tuples serialize to JSON lists (asserted on the parse-back)."""
|
||||||
|
entries = _sc6_entries()
|
||||||
|
a = build_value_report(SavingsLedger(entries=list(entries)))
|
||||||
|
b = build_value_report(SavingsLedger(entries=list(reversed(entries))))
|
||||||
|
|
||||||
|
assert format_report_text(a).encode() == format_report_text(b).encode()
|
||||||
|
assert dump_report_json(a).encode() == dump_report_json(b).encode()
|
||||||
|
|
||||||
|
payload = json.loads(dump_report_json(a))
|
||||||
|
assert payload["portfolio_total_ore"] == 8000 # c-dup 1000 + c-ov 3000 (once) + P2 4000
|
||||||
|
assert payload["per_project"] == {"P1": 4000, "P2": 4000}
|
||||||
|
assert isinstance(payload["overlaps"], list) and payload["overlaps"] # non-empty
|
||||||
|
for ov in payload["overlaps"]:
|
||||||
|
assert isinstance(ov, list) # tuple serialized as a JSON array
|
||||||
|
assert ["P1", "c-ov"] in payload["overlaps"]
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue