feat(fase1): hard/soft goal-stop in run_portfolio on accumulated ledger (F1)
This commit is contained in:
parent
c6f62d41db
commit
16b6d80b82
4 changed files with 270 additions and 6 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ from __future__ import annotations
|
|||
|
||||
import pytest
|
||||
|
||||
from portfolio_optimiser.contracts import GoalConfig, GoalContract
|
||||
from portfolio_optimiser.ledger import (
|
||||
LedgerEntry,
|
||||
RealizationRefused,
|
||||
|
|
@ -25,6 +26,7 @@ from portfolio_optimiser.ledger import (
|
|||
realize,
|
||||
stamp,
|
||||
)
|
||||
from portfolio_optimiser.run import run_portfolio
|
||||
from portfolio_optimiser.verdicts import ProposalFeatures, Verdict
|
||||
|
||||
_TS = "2026-07-06T00:00:00Z"
|
||||
|
|
@ -180,3 +182,53 @@ def test_realize_ore_conversion_is_exact() -> None:
|
|||
timestamp=_TS,
|
||||
)
|
||||
assert entry.amount_ore == 1234567
|
||||
|
||||
|
||||
# --- Step 8: goal-stop is load-bearing (SC6) -----------------------------------------------------
|
||||
|
||||
_PORTFOLIO_IDS = ["FV42-GSV-E1", "RV13-RAS-TP", "BRU-LAKS-REHAB"]
|
||||
|
||||
|
||||
def _portfolio_ledger(amount_ore: int) -> SavingsLedger:
|
||||
led = SavingsLedger()
|
||||
led.add_realized(
|
||||
LedgerEntry(
|
||||
project_id="FV42-GSV-E1",
|
||||
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_goal_stop_is_load_bearing(make_portfolio_client_factory) -> None:
|
||||
"""LOAD-BEARING (SC6): a reached HARD portfolio goal stops the pass (no project runs); a
|
||||
below-goal ledger runs the FULL pass. RED if the goal-stop check is detached in run_portfolio
|
||||
(a reached goal then runs past the target). The control (below goal -> full pass) proves the
|
||||
stop is CAUSED by the goal being reached, not by the fixture."""
|
||||
goals = GoalConfig(portfolio=GoalContract(absolute_ore=1000))
|
||||
|
||||
stopped = await run_portfolio(
|
||||
_PORTFOLIO_IDS,
|
||||
"local",
|
||||
ledger=_portfolio_ledger(1000), # == goal (>=)
|
||||
goals=goals,
|
||||
client_factory=make_portfolio_client_factory({}),
|
||||
max_rounds=1,
|
||||
)
|
||||
assert stopped.stopped_early is True
|
||||
assert stopped.runs == () # detach -> this becomes 3 (runs past the reached goal)
|
||||
|
||||
below = await run_portfolio(
|
||||
_PORTFOLIO_IDS,
|
||||
"local",
|
||||
ledger=_portfolio_ledger(999), # below the goal
|
||||
goals=goals,
|
||||
client_factory=make_portfolio_client_factory({}),
|
||||
max_rounds=1,
|
||||
)
|
||||
assert below.stopped_early is False # control: goal not reached -> full pass
|
||||
assert len(below.runs) == 3
|
||||
|
|
|
|||
|
|
@ -74,6 +74,23 @@ async def test_a_fanout_returns_one_runresult_per_project(
|
|||
assert all(r.provenance.token_usage > 0 for r in result.runs)
|
||||
|
||||
|
||||
async def test_a1_default_no_goal_leaves_stop_fields_unset(
|
||||
make_portfolio_client_factory, fresh_store
|
||||
) -> None:
|
||||
"""Step 8 regression: with no ``goals``/``ledger`` the pass is unchanged — ``stopped_early`` is
|
||||
False and ``stop_reason`` is None, and every project still runs (the new frozen fields default,
|
||||
so the default path is byte-for-byte the prior behavior)."""
|
||||
result = await run_portfolio(
|
||||
_PORTFOLIO_IDS,
|
||||
"local",
|
||||
store=fresh_store,
|
||||
client_factory=make_portfolio_client_factory(REPLIES),
|
||||
)
|
||||
assert result.stopped_early is False
|
||||
assert result.stop_reason is None
|
||||
assert len(result.runs) == 3
|
||||
|
||||
|
||||
async def test_a2_unknown_project_id_raises(make_portfolio_client_factory, fresh_store) -> None:
|
||||
"""The unknown-id error path: an id absent from the loaded portfolio raises ValueError."""
|
||||
with pytest.raises(ValueError):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue