portfolio-optimiser/tests/test_ledger.py
Kjell Tore Guttormsen 7dab2dfb78 fix(s54): reject non-array/non-object ledger JSON in SavingsLedger.load
S5.4 review MINOR (SC5 fail-fast hole, run.py:740). A valid-JSON but
wrong-shape savings ledger escaped the --report fail-fast refusal:
  - top-level {} iterated zero keys -> entries=[] -> rc 0 "0,00 kr"
    (a malformed file masquerading as a real zero-savings result)
  - a bare scalar / object-with-keys / list-of-non-objects raised an
    uncaught TypeError -> traceback (violates SC5 "rc 1, no traceback")

Fix at the fail-fast boundary, not the run.py except tuple: the review's
first option (add TypeError to run.py:740) leaves the {} masquerade
because {} is an empty iteration, not a TypeError. SavingsLedger.load now
raises ValueError for a non-array top-level and a non-object row, caught
by run.py:740's existing ValueError arm. Hardens both callers
(run.py:740 report + run.py:785 portfolio).

RED-first: 6 unit cases (test_ledger) + 2 CLI rc-1 cases (test_run_cli).
452 passed; ruff + mypy clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KNNiJRk1sSwxgVLS5AobT1
2026-07-24 19:52:52 +02:00

236 lines
9 KiB
Python

"""Behavioral tests for the typed savings ledger (Fase 1, Step 4 — SC3 + SC8-identical).
Multiple realized entries (distinct candidates) total correctly per-project and per-portfolio; each
entry carries provenance + a ``verdict_id`` link; a malformed entry raises ``ValidationError``
(fail-fast, contrast the tolerant verdict inbox); the same input serialized twice is byte-identical
(SC8 determinism). Model tests: ``tests/test_portfolio.py:49`` (aggregate), ``tests/test_contracts.py``
(fail-fast).
"""
from __future__ import annotations
import pytest
from pydantic import ValidationError
from portfolio_optimiser.contracts import GoalConfig, GoalContract
from portfolio_optimiser.ledger import LedgerEntry, SavingsLedger, stamp
from portfolio_optimiser.run import run_portfolio
_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 test_totals_per_project_and_portfolio() -> None:
ledger = SavingsLedger()
assert ledger.add_realized(_entry("P1", "c-a", 1000)) is True
assert ledger.add_realized(_entry("P1", "c-b", 2500)) is True
assert ledger.add_realized(_entry("P2", "c-c", 4000)) is True
assert ledger.per_project_total("P1") == 3500
assert ledger.per_project_total("P2") == 4000
assert ledger.portfolio_total() == 7500
def test_entry_carries_provenance_and_verdict_link() -> None:
e = _entry("P1", "c-a", 1000, verdict_id="verdict-xyz")
assert e.verdict_id == "verdict-xyz"
assert "godkjent av ekspert" in e.provenance
assert _TS in e.provenance # timestamp is baked into the stamp (deterministic, no wall-clock)
def test_malformed_entry_raises_validation_error() -> None:
"""Fail-fast (contrast the tolerant inbox): a negative ``amount_ore`` violates ``ge=0``."""
with pytest.raises(ValidationError):
LedgerEntry(
project_id="P1",
dimension="energi",
candidate_identity="c-a",
amount_ore=-1,
verdict_id="v1",
provenance="x",
)
def test_load_rejects_malformed_row(tmp_path) -> None:
"""A malformed on-disk row raises ``ValidationError`` on load (fail-fast, never skipped)."""
bad = tmp_path / "ledger.json"
bad.write_text('[{"project_id": "P1"}]', encoding="utf-8") # missing required fields
with pytest.raises(ValidationError):
SavingsLedger.load(str(bad))
def test_load_missing_file_raises(tmp_path) -> None:
with pytest.raises(FileNotFoundError):
SavingsLedger.load(str(tmp_path / "does-not-exist.json"))
@pytest.mark.parametrize("payload", ["{}", "42", '"hello"', "null"])
def test_load_rejects_non_list_toplevel(tmp_path, payload) -> None:
"""A valid-JSON but non-array top-level payload is fail-fast rejected with ``ValueError`` — NOT
silently loaded as an empty ledger (``{}`` iterates zero keys -> entries=[] -> a malformed file
masquerading as a real zero-savings result) and NOT an uncaught ``TypeError``. Mirrors the
documented fail-fast contract: the ledger is authoritative input, so a wrong-shape file refuses."""
bad = tmp_path / "ledger.json"
bad.write_text(payload, encoding="utf-8")
with pytest.raises(ValueError):
SavingsLedger.load(str(bad))
def test_load_rejects_non_dict_row(tmp_path) -> None:
"""A top-level array whose elements are not JSON objects is fail-fast rejected with ``ValueError``
(``LedgerEntry(**1)`` would otherwise raise an uncaught ``TypeError``)."""
bad = tmp_path / "ledger.json"
bad.write_text("[1, 2]", encoding="utf-8")
with pytest.raises(ValueError):
SavingsLedger.load(str(bad))
def test_save_is_byte_identical_regardless_of_order(tmp_path) -> None:
"""SC8 determinism: the same entries inserted in different order serialize byte-identically
(sorted keys + integer øre => the on-disk form is order-independent)."""
a = SavingsLedger()
a.add_realized(_entry("P2", "c-c", 4000))
a.add_realized(_entry("P1", "c-a", 1000))
a.add_realized(_entry("P1", "c-b", 2500))
b = SavingsLedger()
b.add_realized(_entry("P1", "c-b", 2500))
b.add_realized(_entry("P1", "c-a", 1000))
b.add_realized(_entry("P2", "c-c", 4000))
pa, pb = tmp_path / "a.json", tmp_path / "b.json"
a.save(str(pa))
b.save(str(pb))
assert pa.read_text(encoding="utf-8") == pb.read_text(encoding="utf-8")
# Round-trips: load() reconstructs an equivalent ledger with the same totals.
reloaded = SavingsLedger.load(str(pa))
assert reloaded.portfolio_total() == 7500
# --- Step 8: hard/soft goal-stop in run_portfolio on the accumulated ledger (SC6/SC8) ------------
_PORTFOLIO_IDS = ["FV42-GSV-E1", "RV13-RAS-TP", "BRU-LAKS-REHAB"]
def _prefilled(project_id: str, amount_ore: int) -> SavingsLedger:
"""A ledger prefilled with ONE realized entry (representing an EARLIER, out-of-band HITL
realization — the accumulated sum the goal-stop reads before this pass)."""
led = SavingsLedger()
led.add_realized(
LedgerEntry(
project_id=project_id,
dimension="energi",
candidate_identity="prior-hitl",
amount_ore=amount_ore,
verdict_id="v-prior",
provenance=stamp(approver="ekspert", experiment="earlier", timestamp=_TS),
)
)
return led
async def test_portfolio_hard_goal_stops_the_whole_pass(make_portfolio_client_factory) -> None:
ledger = _prefilled("FV42-GSV-E1", 100) # portfolio_total == 100
goals = GoalConfig(portfolio=GoalContract(absolute_ore=100)) # met at 100 (>=)
result = await run_portfolio(
_PORTFOLIO_IDS,
"local",
ledger=ledger,
goals=goals,
client_factory=make_portfolio_client_factory({}),
max_rounds=1,
)
assert result.stopped_early is True
assert result.runs == () # goal already reached before pid 0 -> nothing runs
assert result.stop_reason is not None
assert result.stop_reason.scope == "portfolio"
assert result.stop_reason.observed_ore == 100
assert result.stop_reason.limit_ore == 100
async def test_boundary_exact_equal_stops(make_portfolio_client_factory) -> None:
""">= boundary: accumulated EXACTLY equal to the goal stops (reached, not strictly exceeded)."""
ledger = _prefilled("FV42-GSV-E1", 500)
goals = GoalConfig(portfolio=GoalContract(absolute_ore=500))
result = await run_portfolio(
_PORTFOLIO_IDS,
"local",
ledger=ledger,
goals=goals,
client_factory=make_portfolio_client_factory({}),
max_rounds=1,
)
assert result.stopped_early is True
async def test_per_project_hard_goal_skips_only_that_pid(make_portfolio_client_factory) -> None:
ledger = _prefilled("FV42-GSV-E1", 1000)
goals = GoalConfig(per_project={"FV42-GSV-E1": GoalContract(absolute_ore=1000)})
result = await run_portfolio(
["FV42-GSV-E1", "RV13-RAS-TP"],
"local",
ledger=ledger,
goals=goals,
client_factory=make_portfolio_client_factory({}),
max_rounds=1,
)
ran = [r.outcome.proposal.project_id for r in result.runs]
assert "FV42-GSV-E1" not in ran # its goal is reached -> skipped
assert ran == ["RV13-RAS-TP"] # the rest of the pass proceeds
assert result.stopped_early is False # a per-project skip is NOT a pass-stop
async def test_soft_goal_flags_but_continues(make_portfolio_client_factory) -> None:
ledger = _prefilled("FV42-GSV-E1", 100)
goals = GoalConfig(portfolio=GoalContract(absolute_ore=100, mode="soft"))
result = await run_portfolio(
["FV42-GSV-E1", "RV13-RAS-TP"],
"local",
ledger=ledger,
goals=goals,
client_factory=make_portfolio_client_factory({}),
max_rounds=1,
)
assert result.stopped_early is False # soft: does not stop
assert len(result.runs) == 2 # all ran
assert result.stop_reason is not None # but the goal-reached flag IS surfaced
async def test_stop_decision_is_deterministic(make_portfolio_client_factory) -> None:
"""SC8: the same ledger + goals twice -> IDENTICAL stop decision (stopped_early + stop_reason)."""
goals = GoalConfig(portfolio=GoalContract(absolute_ore=100))
r1 = await run_portfolio(
_PORTFOLIO_IDS,
"local",
ledger=_prefilled("FV42-GSV-E1", 100),
goals=goals,
client_factory=make_portfolio_client_factory({}),
max_rounds=1,
)
r2 = await run_portfolio(
_PORTFOLIO_IDS,
"local",
ledger=_prefilled("FV42-GSV-E1", 100),
goals=goals,
client_factory=make_portfolio_client_factory({}),
max_rounds=1,
)
assert r1.stopped_early == r2.stopped_early
assert r1.stop_reason == r2.stop_reason # frozen-dataclass equality: identical decision