"""Step 2 tests — the promoted blocking validator + IR (B1). Crafted IR, deterministic. Determinism is asserted over FIXED inputs (seed 20260624), never through an LLM. The out-of-range path returns a ``Rejection`` that carries no percentiles, so it can never be consumed as validated. Pattern: tests/spikes/test_c_validator.py. """ import pytest from pydantic import ValidationError from portfolio_optimiser.ir import AffectedItem, SavingsProposal from portfolio_optimiser.reference_domain import load_reference_projects from portfolio_optimiser.validator import ( Rejection, ValidatedProposal, _monte_carlo, proposal_for, validate_proposal, ) _ASSUMPTIONS = {"05.2": (200.0, 230.0), "03.1": (290.0, 330.0)} @pytest.fixture(scope="module") def project(): return load_reference_projects()[0] # FV42-GSV-E1 def _valid(project) -> SavingsProposal: # Affected total ~1.48M NOK; 30% feasible cap ~0.44M. Claim 200k is comfortably feasible. return proposal_for( project, ["05.2", "03.1"], claimed_saving_nok=200_000, assumptions=_ASSUMPTIONS ) def _out_of_range(project) -> SavingsProposal: # Constructs fine (800k <= 1.48M total) but far exceeds the ~0.44M feasible cap. return proposal_for( project, ["05.2", "03.1"], claimed_saving_nok=800_000, assumptions=_ASSUMPTIONS ) def test_valid_proposal_yields_ordered_percentiles(project) -> None: result = validate_proposal(_valid(project)) assert isinstance(result, ValidatedProposal) assert result.p10 <= result.p50 <= result.p90 # P10 <= P50 <= P90 assert result.nominal_feasible > 0 # the CBC solve produced a feasible bound def test_out_of_range_is_structurally_blocked(project) -> None: result = validate_proposal(_out_of_range(project)) assert isinstance(result, Rejection) assert result.reason # carries a human-readable reason # A Rejection carries no percentiles -> it can never be consumed as validated. with pytest.raises(AttributeError): _ = result.p50 # type: ignore[attr-defined] def test_monte_carlo_is_reproducible(project) -> None: a = validate_proposal(_valid(project)) b = validate_proposal(_valid(project)) assert isinstance(a, ValidatedProposal) and isinstance(b, ValidatedProposal) assert (a.p10, a.p50, a.p90) == (b.p10, b.p50, b.p90) def test_pydantic_blocks_negative_quantity() -> None: with pytest.raises(ValidationError): SavingsProposal( project_id="X", measure="bad", affected_items=[{"code": "A", "quantity": -1, "unit_cost": 10}], claimed_saving_nok=1, ) def test_pydantic_blocks_claim_above_affected_total(project) -> None: with pytest.raises(ValidationError): proposal_for(project, ["05.2"], claimed_saving_nok=99_000_000) def _above_nominal_within_p90() -> SavingsProposal: """Review counterexample #1: affected total 300000 -> nominal feasible 0.30 x 300000 = 90000, but the band [0.70, 1.40] is skewed UPWARD around unit_cost 1.0, so the Monte Carlo P90 sits above it. A claim of 100000 therefore clears the P90 stage while exceeding the deterministic bound the CBC solve actually established.""" return SavingsProposal( project_id="BYGG-KONTOR-NORD", measure="LED-retrofit av 200 lysrorarmaturer", affected_items=[AffectedItem(code="ENERGI-TOTAL-EL", quantity=300_000, unit_cost=1.0)], claimed_saving_nok=100_000, assumptions={"ENERGI-TOTAL-EL": (0.70, 1.40)}, ) def test_claim_above_nominal_feasible_is_blocked_though_p90_would_pass() -> None: """S2.7 (1): the validator enforces its OWN stage-2 boundary. The nominal block is an INDEPENDENT gate — neither stage dominates the other, because an upward-skewed band lifts P90 above nominal while a downward-skewed one pushes it below.""" proposal = _above_nominal_within_p90() # Control: without the nominal block this proposal VALIDATES — the P90 stage lets it # through. If this assert ever fails, the test below has stopped gating the new stage. _, _, p90 = _monte_carlo(proposal) assert proposal.claimed_saving_nok <= p90 result = validate_proposal(proposal) assert isinstance(result, Rejection) assert "nominal" in result.reason def test_pydantic_blocks_assumption_band_that_excludes_unit_cost() -> None: """S2.7 (2): an assumption band is uncertainty AROUND the item's own unit_cost, so it must enclose it. A band of [1.8, 2.2] around unit_cost 1.0 states a different price, not an uncertainty — every Monte Carlo sample would then exceed the item's own cost.""" with pytest.raises(ValidationError): SavingsProposal( project_id="X", measure="band around the wrong centre", affected_items=[AffectedItem(code="A", quantity=1000, unit_cost=1.0)], claimed_saving_nok=100, assumptions={"A": (1.8, 2.2)}, ) def test_assumption_band_bounds_are_inclusive() -> None: """The enclosure is ``low <= unit_cost <= high``: a band that touches the unit_cost at either end is a legitimate one-sided uncertainty, not a violation.""" for band in ((1.0, 1.4), (0.7, 1.0)): SavingsProposal( project_id="X", measure="one-sided uncertainty", affected_items=[AffectedItem(code="A", quantity=1000, unit_cost=1.0)], claimed_saving_nok=100, assumptions={"A": band}, )