portfolio-optimiser-claude/tests/test_validator.py
Kjell Tore Guttormsen 1e1b7e4506 feat(validator): S6 — deterministic backbone: typed IR, golden-frozen validator, provenance stamp
TDD from method-spec alone (§3 Step 4, §7, §9), golden.json as the only
ground truth: ir.py (construction invariants, fail-fast bundle loader),
validator.py (closed-form feasibility bound 0.30·Σ + Monte Carlo seed
20260624/512 samples/inclusive quantiles — reproduces every frozen golden
field; Rejection as a distinct unconsumable type), provenance.py (stamp
mirroring ONLY the deterministic validator). Mutation controls + seed-detach
proof (§11); 45/45 green without an API key; ruff + mypy --strict clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QdSfQdND84oeq2mbjueLTS
2026-07-03 06:27:40 +02:00

65 lines
2.5 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 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)