portfolio-optimiser/tests/test_validator.py
Kjell Tore Guttormsen e8cec2e2c0 feat(validator): enforce the deterministic stage-2 bound and band enclosure (S2.7)
Two tightenings, each measured by a detached-mutation run:

(1) The validator now blocks a claim above the CBC nominal feasible, in ADDITION
to the P90 stage. Neither dominates the other: an upward-skewed assumption band
lifts P90 ABOVE nominal -- so P90 alone passed review counterexample #1 (claim
100k, nominal 90k, band [0.70, 1.40], measured P90 121057) -- while a
downward-skewed band pushes P90 below it. Independent gate, same Rejection type,
existing rejections keep their existing reason.

(2) An assumption band must enclose its item's unit_cost (low <= unit_cost <=
high, inclusive). A band that misses it states a different price rather than an
uncertainty, and every Monte Carlo draw would then sample away from the item's
stated cost. Checked exactly where the Monte Carlo looks bands up -- per affected
item, by code; a band keyed to no affected item is never sampled and so has no
unit_cost to enclose.

The premise was re-verified against ground truth before building on it, not
taken from STATE: 05.2 unit_cost 215 in (200,230), 03.1 310 in (290,330),
ENERGI-TOTAL-EL 1.0 in [0.70,1.40] and (0.8,1.2). No fixture violates it.
The LLM path already catches ValidationError as a meter-bounded retry
(generate.py:138), so the new invariant cannot crash a run.

Mutations, all RED: detach the nominal block; drop the model_validator
decorator; make the enclosure strict. tests/test_bygg_energi_mikro.py and the
commons golden are UNCHANGED and green -- the regression proof.

586 -> 589 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017DDXwUqHVAQQeYE7X1TXy5
2026-08-03 16:22:54 +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] # 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},
)