84 lines
4 KiB
Python
84 lines
4 KiB
Python
"""Fase 3 — sequential portfolio fan-out over N independent projects.
|
|
|
|
Covers the success criteria: SC2 (fan-out + aggregate sums), SC4 (shared store accumulates +
|
|
load-bearing cross-project retrieval), SC3 (load-bearing meter-isolation detach), SC7 (both
|
|
profiles offline + resolve_model teeth), SC1 (new project via config only + no-hardcoded-id src
|
|
guard), SC6 (extension doc exists + references the seams).
|
|
|
|
Pattern: tests/test_vertical_slice_e2e.py:28 (run_project call shape). The project-aware
|
|
synthetic client (conftest ``make_portfolio_client_factory``) selects each project's reply by
|
|
scanning the prompt for its id, so one production-shaped factory serves the whole portfolio.
|
|
"""
|
|
|
|
import pytest
|
|
|
|
from portfolio_optimiser.run import PortfolioResult, RunResult, run_portfolio
|
|
|
|
# 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)
|
|
# ONLY when ``assumptions`` is empty (degenerate Monte Carlo, validator.py:108-113). All three
|
|
# replies therefore OMIT ``assumptions`` and carry explicit magnitudes so each
|
|
# ``claimed_saving_nok`` <= P90. Verified against validator + ir:
|
|
# FV42-GSV-E1 Σ=1,482,500 P90=444,750 claimed 200,000 -> validates
|
|
# RV13-RAS-TP Σ= 756,000 P90=226,800 claimed 130,000 -> validates (decoy)
|
|
# BRU-LAKS-REHAB Σ=2,580,500 P90=774,150 claimed 210,000 -> validates
|
|
# ``measure`` is byte-identical "Reduce scope" for FV42+BRU (measure-match is exact string
|
|
# equality, verdicts.py:68) and "Material substitution" for the decoy, so the BRU<->FV42 pair
|
|
# overlaps (shared code 05.2 + measure + magnitude bucket) while the decoy does not.
|
|
REPLIES = {
|
|
"FV42-GSV-E1": (
|
|
'{"measure":"Reduce scope","affected_items":['
|
|
'{"code":"05.2","quantity":4300,"unit_cost":215},'
|
|
'{"code":"03.1","quantity":1800,"unit_cost":310}],"claimed_saving_nok":200000}'
|
|
),
|
|
"RV13-RAS-TP": (
|
|
'{"measure":"Material substitution","affected_items":['
|
|
'{"code":"88.2","quantity":180,"unit_cost":4200}],"claimed_saving_nok":130000}'
|
|
),
|
|
"BRU-LAKS-REHAB": (
|
|
'{"measure":"Reduce scope","affected_items":['
|
|
'{"code":"05.2","quantity":4300,"unit_cost":215},'
|
|
'{"code":"07.4","quantity":2400,"unit_cost":690}],"claimed_saving_nok":210000}'
|
|
),
|
|
}
|
|
|
|
_PORTFOLIO_IDS = ["FV42-GSV-E1", "RV13-RAS-TP", "BRU-LAKS-REHAB"]
|
|
|
|
|
|
async def test_a_fanout_returns_one_runresult_per_project(
|
|
make_portfolio_client_factory, fresh_store
|
|
) -> None:
|
|
"""SC2: run_portfolio fans out sequentially, one RunResult per project, and aggregates the
|
|
validated/rejected counts + claimed-saving + token sums."""
|
|
result = await run_portfolio(
|
|
_PORTFOLIO_IDS,
|
|
"local",
|
|
store=fresh_store,
|
|
client_factory=make_portfolio_client_factory(REPLIES),
|
|
)
|
|
assert isinstance(result, PortfolioResult)
|
|
assert len(result.runs) == 3
|
|
assert all(isinstance(r, RunResult) for r in result.runs)
|
|
# Aggregate pinned to the fixture constants above (all three validate).
|
|
assert result.validated_count == 3
|
|
assert result.rejected_count == 0
|
|
assert result.sum_claimed_saving_nok == 540000
|
|
# Explicit element-wise wiring check (not the field's own sum() definition).
|
|
expected_tokens = (
|
|
result.runs[0].provenance.token_usage
|
|
+ result.runs[1].provenance.token_usage
|
|
+ result.runs[2].provenance.token_usage
|
|
)
|
|
assert result.sum_token_usage == expected_tokens
|
|
assert all(r.provenance.token_usage > 0 for r in result.runs)
|
|
|
|
|
|
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):
|
|
await run_portfolio(
|
|
["NOPE-DOES-NOT-EXIST"],
|
|
"local",
|
|
store=fresh_store,
|
|
client_factory=make_portfolio_client_factory(REPLIES),
|
|
)
|