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>
134 lines
5.4 KiB
Python
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},
|
|
)
|