"""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