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>
143 lines
5.9 KiB
Python
143 lines
5.9 KiB
Python
"""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 TestFiniteness:
|
|
"""R-2 hardening: non-finite numbers are schema errors — the mandatory validator
|
|
can never be vacuously cleared by ``Infinity`` (today's defect: ``validates=True``
|
|
with ``p90=inf``). Detach point: re-allow inf/nan on any field → these go red."""
|
|
|
|
@pytest.mark.parametrize("bad", [float("inf"), float("-inf"), float("nan")])
|
|
def test_nonfinite_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", [float("inf"), float("nan")])
|
|
def test_nonfinite_quantity_rejected(self, bad: float) -> None:
|
|
with pytest.raises(ValidationError):
|
|
build(affected_items=[{"code": "EL", "quantity": bad, "unit_cost": 1.0}])
|
|
|
|
@pytest.mark.parametrize("bad", [float("inf"), float("nan")])
|
|
def test_nonfinite_claimed_saving_rejected(self, bad: float) -> None:
|
|
with pytest.raises(ValidationError):
|
|
build(claimed_saving_nok=bad)
|
|
|
|
@pytest.mark.parametrize(
|
|
"band",
|
|
[
|
|
(0.8, float("inf")),
|
|
(float("-inf"), 1.2),
|
|
(float("nan"), 1.2),
|
|
(0.8, float("nan")),
|
|
],
|
|
)
|
|
def test_nonfinite_assumption_band_endpoint_rejected(self, band: tuple[float, float]) -> None:
|
|
with pytest.raises(ValidationError):
|
|
build(assumptions={"EL": band})
|
|
|
|
@pytest.mark.parametrize("band", [(-0.1, 1.2), (0.8, -1.2)])
|
|
def test_negative_assumption_band_endpoint_rejected(self, band: tuple[float, float]) -> None:
|
|
# A band endpoint is a sampled unit cost — a negative cost is a schema error.
|
|
with pytest.raises(ValidationError):
|
|
build(assumptions={"EL": band})
|
|
|
|
def test_finite_band_still_constructs(self) -> None:
|
|
# Regression control: the golden bundle's finite numbers are untouched.
|
|
assert build(assumptions={"EL": (0.70, 1.40)}).assumptions["EL"] == (0.70, 1.40)
|
|
|
|
|
|
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)
|
|
|
|
def test_infinity_in_bundle_projection_rejected(self, tmp_path: Path) -> None:
|
|
# R-2's actual entry seam: ``json.loads`` accepts the bare ``Infinity``
|
|
# literal, so a bundle file can carry a non-finite number into the IR —
|
|
# construction must refuse it before the validator ever sees it.
|
|
(tmp_path / "validator-input.json").write_text(
|
|
'{"project_id": "P1", "measure": "m", '
|
|
'"affected_items": [{"code": "EL", "quantity": 1.0, "unit_cost": Infinity}], '
|
|
'"claimed_saving_nok": 100}',
|
|
encoding="utf-8",
|
|
)
|
|
with pytest.raises(ValidationError):
|
|
load_validator_input(tmp_path)
|