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
86
tests/test_ir.py
Normal file
86
tests/test_ir.py
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
"""Typed cost-IR tests (method-spec §7.1).
|
||||
|
||||
Schema invariants are enforced at IR construction, so a malformed proposal can never
|
||||
exist as a value (§3 Step 2). Loading the IR projection from a bundle is FAIL-FAST:
|
||||
a missing file raises (required input — contrast the tolerant inbox, §5). Pure
|
||||
config/file-layer — no model client, no API key, no network.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from portfolio_optimiser_claude.ir import SavingsProposal, load_validator_input
|
||||
|
||||
BUNDLE = Path(__file__).resolve().parents[1] / "shared" / "examples" / "bygg-energi-mikro"
|
||||
|
||||
VALID: dict[str, Any] = {
|
||||
"project_id": "P1",
|
||||
"measure": "LED retrofit",
|
||||
"affected_items": [{"code": "EL", "quantity": 1000, "unit_cost": 1.0}],
|
||||
"claimed_saving_nok": 200,
|
||||
"assumptions": {"EL": [0.8, 1.2]},
|
||||
}
|
||||
|
||||
|
||||
def build(**overrides: Any) -> SavingsProposal:
|
||||
return SavingsProposal(**{**VALID, **overrides})
|
||||
|
||||
|
||||
class TestSchemaInvariants:
|
||||
"""§7.1: invariants hold at construction — a schema error, never a validator call."""
|
||||
|
||||
def test_valid_proposal_constructs(self) -> None:
|
||||
proposal = build()
|
||||
assert proposal.project_id == "P1"
|
||||
assert proposal.assumptions["EL"] == (0.8, 1.2)
|
||||
|
||||
def test_empty_affected_items_rejected(self) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
build(affected_items=[])
|
||||
|
||||
def test_negative_quantity_rejected(self) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
build(affected_items=[{"code": "EL", "quantity": -1, "unit_cost": 1.0}])
|
||||
|
||||
@pytest.mark.parametrize("bad", [0, -0.5])
|
||||
def test_nonpositive_unit_cost_rejected(self, bad: float) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
build(affected_items=[{"code": "EL", "quantity": 1000, "unit_cost": bad}])
|
||||
|
||||
@pytest.mark.parametrize("bad", [0, -100])
|
||||
def test_nonpositive_claimed_saving_rejected(self, bad: float) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
build(claimed_saving_nok=bad)
|
||||
|
||||
def test_claim_above_affected_total_is_schema_error(self) -> None:
|
||||
# The claimed saving MUST NOT exceed the affected items' own total
|
||||
# (Σ quantity·unit_cost = 1000) — a schema error, not a validator rejection.
|
||||
with pytest.raises(ValidationError):
|
||||
build(claimed_saving_nok=1001)
|
||||
|
||||
def test_claim_equal_to_affected_total_allowed(self) -> None:
|
||||
assert build(claimed_saving_nok=1000).claimed_saving_nok == 1000
|
||||
|
||||
def test_assumptions_default_to_empty(self) -> None:
|
||||
# Empty assumptions = degenerate band, no spread (§7.1).
|
||||
proposal = build(assumptions={})
|
||||
assert proposal.assumptions == {}
|
||||
|
||||
|
||||
class TestFailFastLoader:
|
||||
"""§7.1: the IR projection is required input — a missing file raises."""
|
||||
|
||||
def test_loads_shared_bundle_projection_unchanged(self) -> None:
|
||||
proposal = load_validator_input(BUNDLE)
|
||||
assert proposal.project_id == "BYGG-KONTOR-NORD"
|
||||
assert [item.code for item in proposal.affected_items] == ["ENERGI-TOTAL-EL"]
|
||||
assert proposal.assumptions["ENERGI-TOTAL-EL"] == (0.70, 1.40)
|
||||
|
||||
def test_missing_projection_raises(self, tmp_path: Path) -> None:
|
||||
with pytest.raises(FileNotFoundError):
|
||||
load_validator_input(tmp_path)
|
||||
Loading…
Add table
Add a link
Reference in a new issue