portfolio-optimiser/tests/test_ledger.py
Kjell Tore Guttormsen 37547fe292
refactor(examples): replace sector-specific example material with generic, fictitious examples
The context sets, the packaged knowledge bases and the example bundles are
replaced by one fictitious example set about IT operations in an invented
organisation: three context sets (serverrom-2027, driftsavtale-2027 and the
two-base drift-og-avtale-2027), two synthetic knowledge bases under
src/portfolio_optimiser/data/kunnskapsbaser and two example bundles under
src/portfolio_optimiser/data/bundles. Numbers, codes and structural values in
tests and fixtures are kept; names, ids and wording change. Dated measurement
documents that only recorded runs on the replaced material are deleted.

Gate figures measured on the new set are not comparable with earlier ones.
The exclusion gate from the previous commit is green: 0 tracked files hit
outside the shared/ subtree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 15:04:21 +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 = ["KONTOR-IT-E1", "NETT-SIKR-TP", "ARKIV-LAGR-MIGR"]
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("KONTOR-IT-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("KONTOR-IT-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("KONTOR-IT-E1", 1000)
goals = GoalConfig(per_project={"KONTOR-IT-E1": GoalContract(absolute_ore=1000)})
result = await run_portfolio(
["KONTOR-IT-E1", "NETT-SIKR-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 "KONTOR-IT-E1" not in ran # its goal is reached -> skipped
assert ran == ["NETT-SIKR-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("KONTOR-IT-E1", 100)
goals = GoalConfig(portfolio=GoalContract(absolute_ore=100, mode="soft"))
result = await run_portfolio(
["KONTOR-IT-E1", "NETT-SIKR-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("KONTOR-IT-E1", 100),
goals=goals,
client_factory=make_portfolio_client_factory({}),
max_rounds=1,
)
r2 = await run_portfolio(
_PORTFOLIO_IDS,
"local",
ledger=_prefilled("KONTOR-IT-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