portfolio-optimiser/tests/test_validator.py
Kjell Tore Guttormsen 37547fe292
refactor(examples): replace sector-specific example material with generic, fictitious examples
The context sets, the packaged knowledge bases and the example bundles are
replaced by one fictitious example set about IT operations in an invented
organisation: three context sets (serverrom-2027, driftsavtale-2027 and the
two-base drift-og-avtale-2027), two synthetic knowledge bases under
src/portfolio_optimiser/data/kunnskapsbaser and two example bundles under
src/portfolio_optimiser/data/bundles. Numbers, codes and structural values in
tests and fixtures are kept; names, ids and wording change. Dated measurement
documents that only recorded runs on the replaced material are deleted.

Gate figures measured on the new set are not comparable with earlier ones.
The exclusion gate from the previous commit is green: 0 tracked files hit
outside the shared/ subtree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 15:04:21 +02:00

134 lines
5.4 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 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] # KONTOR-IT-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},
)