IR schema now refuses non-finite numbers (allow_inf_nan=False on quantity/ unit_cost/claimed_saving_nok) and non-finite or negative assumption-band endpoints; json.loads accepts the bare Infinity literal, so the bundle seam is tested directly. ModelMapContract rejects empty-string model ids (min_length=1). check_turn_safety_net documented as a deliberately unreachable belt under the range-bound debate loop. 18 new tests; detach-proven (re-allow inf/nan -> 5 red, drop min_length -> 2 red). Full gate: 365 passed, ruff/format/mypy clean; golden untouched. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
82 lines
3.3 KiB
Python
82 lines
3.3 KiB
Python
"""Structural-block tests (method-spec §3 Step 4).
|
||
|
||
A claim above the optimistic feasible bound (p90) yields a ``Rejection`` that is a
|
||
DISTINCT type from ``ValidatedProposal`` — carrying the claimed and feasible figures
|
||
in its reason, and NO percentiles — so it can never be consumed as validated.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import pytest
|
||
from pydantic import ValidationError
|
||
|
||
from portfolio_optimiser_claude.ir import SavingsProposal
|
||
from portfolio_optimiser_claude.validator import (
|
||
Rejection,
|
||
ValidatedProposal,
|
||
validate_proposal,
|
||
)
|
||
|
||
|
||
def proposal_claiming(claimed: float) -> SavingsProposal:
|
||
# Degenerate band (no assumptions): every Monte Carlo sample equals the fixed
|
||
# cost, so the feasible bound is exactly 0.30 × 1000 = 300 at every percentile.
|
||
return SavingsProposal(
|
||
project_id="P1",
|
||
measure="test measure",
|
||
affected_items=[{"code": "EL", "quantity": 1000, "unit_cost": 1.0}],
|
||
claimed_saving_nok=claimed,
|
||
)
|
||
|
||
|
||
class TestStructuralBlock:
|
||
"""§3 Step 4: the deterministic validator gates the numbers — blocking."""
|
||
|
||
def test_claim_above_p90_yields_rejection(self) -> None:
|
||
outcome = validate_proposal(proposal_claiming(500))
|
||
assert isinstance(outcome, Rejection)
|
||
|
||
def test_claim_within_p90_yields_validated(self) -> None:
|
||
outcome = validate_proposal(proposal_claiming(200))
|
||
assert isinstance(outcome, ValidatedProposal)
|
||
assert outcome.validates is True
|
||
# Degenerate band: no spread, all percentiles collapse to the fixed bound.
|
||
assert outcome.p10 == outcome.p50 == outcome.p90 == pytest.approx(300.0)
|
||
|
||
def test_rejection_reason_carries_claimed_and_feasible_figures(self) -> None:
|
||
outcome = validate_proposal(proposal_claiming(500))
|
||
assert isinstance(outcome, Rejection)
|
||
assert "500" in outcome.reason
|
||
assert "300" in outcome.reason
|
||
|
||
|
||
class TestRejectionIsUnconsumable:
|
||
"""§3 Step 4: a distinct type with no percentiles — never consumable as validated."""
|
||
|
||
def test_rejection_is_not_a_validated_proposal(self) -> None:
|
||
outcome = validate_proposal(proposal_claiming(500))
|
||
assert not isinstance(outcome, ValidatedProposal)
|
||
assert not issubclass(Rejection, ValidatedProposal)
|
||
assert not issubclass(ValidatedProposal, Rejection)
|
||
|
||
def test_rejection_has_no_percentiles(self) -> None:
|
||
outcome = validate_proposal(proposal_claiming(500))
|
||
assert isinstance(outcome, Rejection)
|
||
for field in ("p10", "p50", "p90", "validates"):
|
||
assert not hasattr(outcome, field)
|
||
|
||
|
||
class TestValidatorCannotBeVacuouslyCleared:
|
||
"""R-2: the review's run proof — ``unit_cost: Infinity`` used to reach the
|
||
validator and clear it with ``ValidatedProposal(validates=True, p90=inf)``.
|
||
The IR schema now refuses non-finite numbers, so no proposal the validator
|
||
can receive carries them (§3 Step 4 stays blocking, never vacuous)."""
|
||
|
||
def test_r2_infinity_proposal_cannot_exist(self) -> None:
|
||
with pytest.raises(ValidationError):
|
||
SavingsProposal(
|
||
project_id="P1",
|
||
measure="m",
|
||
affected_items=[{"code": "EL", "quantity": 1.0, "unit_cost": float("inf")}],
|
||
claimed_saving_nok=1e12,
|
||
)
|