portfolio-optimiser-claude/tests/test_validator.py
Kjell Tore Guttormsen e7ce6b0a31 fix(validator): C2.6 — finiteness hardening, Infinity can no longer vacuously clear the gate (closes R-2)
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>
2026-07-16 20:10:12 +02:00

82 lines
3.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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,
)