portfolio-optimiser/tests/test_validator.py

77 lines
2.7 KiB
Python

"""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 SavingsProposal
from portfolio_optimiser.reference_domain import load_reference_projects
from portfolio_optimiser.validator import (
Rejection,
ValidatedProposal,
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)