102 lines
3.4 KiB
Python
102 lines
3.4 KiB
Python
"""Spike C tests — blocking hybrid validator (B1). Crafted IR, deterministic.
|
|
|
|
Checks: structural block of an out-of-range proposal (P10/P50/P90 only exist on the
|
|
validated path); a valid proposal yields P10 <= P50 <= P90; Monte Carlo reproducibility;
|
|
self-repair capped. Pattern: tests/test_reference_domain.py + pytest.raises.
|
|
"""
|
|
|
|
import pytest
|
|
from pydantic import ValidationError
|
|
|
|
from portfolio_optimiser.reference_domain import load_reference_projects
|
|
from spikes.c_validator import (
|
|
Rejection,
|
|
SavingsProposal,
|
|
ValidatedProposal,
|
|
proposal_for,
|
|
self_repair,
|
|
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_out_of_range_is_structurally_blocked(project) -> None:
|
|
result = validate_proposal(_out_of_range(project))
|
|
assert isinstance(result, Rejection)
|
|
# A Rejection carries no percentiles -> it can never be consumed as validated.
|
|
with pytest.raises(AttributeError):
|
|
_ = result.p50 # type: ignore[attr-defined]
|
|
|
|
|
|
def test_valid_proposal_yields_ordered_percentiles(project) -> None:
|
|
result = validate_proposal(_valid(project))
|
|
assert isinstance(result, ValidatedProposal)
|
|
# P10 <= P50 <= P90
|
|
assert result.p10 <= result.p50 <= result.p90
|
|
assert result.nominal_feasible > 0 # the CBC solve produced a feasible bound
|
|
|
|
|
|
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_claim_above_affected_total(project) -> None:
|
|
with pytest.raises(ValidationError):
|
|
proposal_for(project, ["05.2"], claimed_saving_nok=99_000_000)
|
|
|
|
|
|
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_self_repair_stops_at_max_attempts(project) -> None:
|
|
calls = {"n": 0}
|
|
|
|
def always_bad(_attempt: int) -> SavingsProposal:
|
|
calls["n"] += 1
|
|
return _out_of_range(project)
|
|
|
|
result = self_repair(always_bad, max_attempts=3)
|
|
assert isinstance(result, Rejection)
|
|
assert calls["n"] == 3 # bounded — never exceeds max_attempts
|
|
|
|
|
|
def test_self_repair_returns_on_first_valid(project) -> None:
|
|
calls = {"n": 0}
|
|
|
|
def valid_on_second(attempt: int) -> SavingsProposal:
|
|
calls["n"] += 1
|
|
return _valid(project) if attempt >= 2 else _out_of_range(project)
|
|
|
|
result = self_repair(valid_on_second, max_attempts=3)
|
|
assert isinstance(result, ValidatedProposal)
|
|
assert calls["n"] == 2
|