feat(fase1): hard/soft goal-stop in run_portfolio on accumulated ledger (F1)

This commit is contained in:
Kjell Tore Guttormsen 2026-07-07 08:11:43 +02:00
commit 16b6d80b82
4 changed files with 270 additions and 6 deletions

View file

@ -12,7 +12,9 @@ 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"
@ -99,3 +101,115 @@ def test_save_is_byte_identical_regardless_of_order(tmp_path) -> None:
# 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