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
This commit is contained in:
parent
4efb72943c
commit
1e1b7e4506
7 changed files with 529 additions and 0 deletions
65
tests/test_validator.py
Normal file
65
tests/test_validator.py
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
"""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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue