From a7c69dbd5598849282bfbb007d5c9f0e36907670 Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Fri, 26 Jun 2026 15:05:41 +0200 Subject: [PATCH] test(fase3): portfolio rejection arm + provenance-seam assertions (F2 + F1 follow-up) Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_019any9zfGNNwWJPX5Zq2QRz --- tests/test_portfolio.py | 33 ++++++++++++++++++++++++++++++--- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/tests/test_portfolio.py b/tests/test_portfolio.py index 544cc45..c5e3585 100644 --- a/tests/test_portfolio.py +++ b/tests/test_portfolio.py @@ -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(