test(fase3): portfolio rejection arm + provenance-seam assertions (F2 + F1 follow-up)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019any9zfGNNwWJPX5Zq2QRz
This commit is contained in:
Kjell Tore Guttormsen 2026-06-26 15:05:41 +02:00
commit a7c69dbd55

View file

@ -13,6 +13,7 @@ scanning the prompt for its id, so one production-shaped factory serves the whol
import pytest
from portfolio_optimiser.run import PortfolioResult, RunResult, run_portfolio, run_project
from portfolio_optimiser.validator import Rejection
# The synthetic reply IS the proposal: generate._parse_ir builds affected_items (each with its
# own quantity/unit_cost) straight from this JSON, and the validator's P90 = 0.30 x Σ(qty·unit_cost)
@ -84,6 +85,32 @@ async def test_a2_unknown_project_id_raises(make_portfolio_client_factory, fresh
)
async def test_a3_rejected_proposal_excluded_from_aggregate(
make_portfolio_client_factory, fresh_store
) -> None:
"""F2 (rejection arm, previously unexercised): one project's claim exceeds its P90, so the
validator REJECTS it (FV42 800000 > P90 444750, <= Σ 1,482,500), while RV13+BRU validate.
rejected_count counts it and the validated-only sum (run.py:241) EXCLUDES its claim. Also the
F1 portfolio seam: provenance.model mirrors the injected client across all N records."""
replies = {**REPLIES, "FV42-GSV-E1": REPLIES["FV42-GSV-E1"].replace("200000", "800000")}
result = await run_portfolio(
_PORTFOLIO_IDS,
"local",
store=fresh_store,
client_factory=make_portfolio_client_factory(replies),
)
assert result.rejected_count == 1
assert result.validated_count == 2
# Validated-only sum EXCLUDES the rejected 800000 (run.py:241): 130000 + 210000.
assert result.sum_claimed_saving_nok == 340000
rejected = [r for r in result.runs if isinstance(r.outcome, Rejection)]
assert len(rejected) == 1
assert rejected[0].outcome.proposal.claimed_saving_nok == 800000 # carried, not summed
assert rejected[0].provenance.validator_decision == "rejected"
# F1 portfolio seam: the injected client's model is stamped across ALL N records.
assert all(r.provenance.model == "synthetic" for r in result.runs)
async def test_b_shared_store_accumulates_and_surfaces_prior_verdict(
make_portfolio_client_factory, fresh_store
) -> None:
@ -161,9 +188,9 @@ async def test_d_both_profiles_run_offline(
is a profile-dependent seam reachable WITHOUT egress each profile resolves its own
configured model from the bundled model_map.json, and local != azure (deleting the azure
entry reddens this). Honest limitation: under an injected ``client_factory``, ``profile`` is
otherwise inert inside ``run_project`` (run.py:152,173 ``model="fake-model"``, the F9 standing
item); the end-to-end run proves the path executes under both, and ``resolve_model`` is the
only profile-dependent seam provable offline."""
otherwise inert inside ``run_project`` (run.py:197 stamps the injected client's own model, not
the profile's ``resolve_model``); the end-to-end run proves the path executes under both, and
``resolve_model`` is the only profile-dependent seam provable offline."""
from portfolio_optimiser.backends import resolve_model
result = await run_portfolio(