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
236 lines
9 KiB
Python
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
|