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>
This commit is contained in:
Kjell Tore Guttormsen 2026-07-16 20:10:12 +02:00
commit e7ce6b0a31
6 changed files with 109 additions and 9 deletions

View file

@ -99,6 +99,18 @@ class TestModelMap:
with pytest.raises(ValidationError):
load(model_map={"profiles": {}})
def test_empty_string_model_id_rejected(self) -> None:
# C2.6: an empty-string model id satisfied dict[str, str] and slipped
# through to the client layer — it is a startup schema error.
with pytest.raises(ValidationError):
load(model_map={"profiles": {"anthropic": {"default": ""}}})
def test_empty_string_role_model_id_rejected(self) -> None:
with pytest.raises(ValidationError):
load(
model_map={"profiles": {"anthropic": {"default": "some-model-id", "proposer": ""}}}
)
def test_bundled_model_map_is_valid(self) -> None:
# model_map=None falls back to the bundled data/model_map.json, which must
# itself satisfy the contract (fail-fast on the shipped config too).

View file

@ -72,6 +72,50 @@ class TestSchemaInvariants:
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."""
@ -84,3 +128,16 @@ class TestFailFastLoader:
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)

View file

@ -8,6 +8,7 @@ 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 (
@ -63,3 +64,19 @@ class TestRejectionIsUnconsumable:
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,
)