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

@ -23,12 +23,15 @@ from __future__ import annotations
import json import json
from importlib.resources import files from importlib.resources import files
from typing import Any, Literal from typing import Annotated, Any, Literal
from pydantic import BaseModel, Field, model_validator from pydantic import BaseModel, Field, model_validator
_MODEL_MAP_RESOURCE = "data/model_map.json" _MODEL_MAP_RESOURCE = "data/model_map.json"
# C2.6: an empty-string model id is a startup schema error, never a client-layer one.
_ModelId = Annotated[str, Field(min_length=1)]
class DataSourceContract(BaseModel): class DataSourceContract(BaseModel):
"""The local-folder data source config (schema-validated, fail-fast).""" """The local-folder data source config (schema-validated, fail-fast)."""
@ -40,7 +43,7 @@ class DataSourceContract(BaseModel):
class ModelMapContract(BaseModel): class ModelMapContract(BaseModel):
"""Role -> model id per backend profile (validates data/model_map.json).""" """Role -> model id per backend profile (validates data/model_map.json)."""
profiles: dict[str, dict[str, str]] = Field(min_length=1) profiles: dict[str, dict[str, _ModelId]] = Field(min_length=1)
@model_validator(mode="after") @model_validator(mode="after")
def _each_profile_has_default(self) -> ModelMapContract: def _each_profile_has_default(self) -> ModelMapContract:

View file

@ -4,7 +4,10 @@ Schema invariants are enforced at construction, so a malformed proposal can neve
exist as a value (§3 Step 2): ``affected_items`` non-empty with ``quantity >= 0`` and exist as a value (§3 Step 2): ``affected_items`` non-empty with ``quantity >= 0`` and
``unit_cost > 0``, ``claimed_saving_nok > 0`` and never above the affected items' own ``unit_cost > 0``, ``claimed_saving_nok > 0`` and never above the affected items' own
total, ``assumptions`` an uncertainty band per cost code (empty = degenerate, no total, ``assumptions`` an uncertainty band per cost code (empty = degenerate, no
spread). Loading the IR projection from a bundle is FAIL-FAST: a missing file raises spread). ALL numbers are finite and band endpoints non-negative (R-2 hardening: a
non-finite number would clear the mandatory validator vacuously ``p90=inf``
validates everything; note ``json.loads`` accepts the bare ``Infinity`` literal).
Loading the IR projection from a bundle is FAIL-FAST: a missing file raises
(required input contrast the tolerant inbox, §5). (required input contrast the tolerant inbox, §5).
""" """
@ -12,19 +15,22 @@ from __future__ import annotations
import json import json
from pathlib import Path from pathlib import Path
from typing import Any from typing import Annotated, Any
from pydantic import BaseModel, Field, model_validator from pydantic import BaseModel, Field, model_validator
_VALIDATOR_INPUT_FILENAME = "validator-input.json" _VALIDATOR_INPUT_FILENAME = "validator-input.json"
# A band endpoint is a sampled unit cost (§7.1) — finite, never negative.
_BandEndpoint = Annotated[float, Field(ge=0, allow_inf_nan=False)]
class AffectedItem(BaseModel): class AffectedItem(BaseModel):
"""One affected cost item: ``{code, quantity >= 0, unit_cost > 0}`` (§7.1).""" """One affected cost item: ``{code, quantity >= 0, unit_cost > 0}`` (§7.1)."""
code: str = Field(min_length=1) code: str = Field(min_length=1)
quantity: float = Field(ge=0) quantity: float = Field(ge=0, allow_inf_nan=False)
unit_cost: float = Field(gt=0) unit_cost: float = Field(gt=0, allow_inf_nan=False)
class SavingsProposal(BaseModel): class SavingsProposal(BaseModel):
@ -33,8 +39,8 @@ class SavingsProposal(BaseModel):
project_id: str = Field(min_length=1) project_id: str = Field(min_length=1)
measure: str = Field(min_length=1) measure: str = Field(min_length=1)
affected_items: list[AffectedItem] = Field(min_length=1) affected_items: list[AffectedItem] = Field(min_length=1)
claimed_saving_nok: float = Field(gt=0) claimed_saving_nok: float = Field(gt=0, allow_inf_nan=False)
assumptions: dict[str, tuple[float, float]] = Field(default_factory=dict) assumptions: dict[str, tuple[_BandEndpoint, _BandEndpoint]] = Field(default_factory=dict)
@model_validator(mode="after") @model_validator(mode="after")
def _claim_within_affected_total(self) -> SavingsProposal: def _claim_within_affected_total(self) -> SavingsProposal:

View file

@ -109,7 +109,12 @@ class DebateResult:
def check_turn_safety_net(turns: int, max_rounds: int) -> None: def check_turn_safety_net(turns: int, max_rounds: int) -> None:
"""The turn-count termination safety net ABOVE the round cap (§3 Step 3, §8).""" """The turn-count termination safety net ABOVE the round cap (§3 Step 3, §8).
Under the ``range(max_rounds)``-bounded debate loop, ``turns`` never exceeds
``2 * max_rounds``, so this net is structurally unreachable it is a
deliberate belt that fires only if a refactor breaks the loop's own bound.
"""
if turns > 2 * max_rounds + 2: if turns > 2 * max_rounds + 2:
raise RuntimeError( raise RuntimeError(
f"debate turn-count safety net tripped: {turns} turns with max_rounds={max_rounds}" f"debate turn-count safety net tripped: {turns} turns with max_rounds={max_rounds}"

View file

@ -99,6 +99,18 @@ class TestModelMap:
with pytest.raises(ValidationError): with pytest.raises(ValidationError):
load(model_map={"profiles": {}}) 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: def test_bundled_model_map_is_valid(self) -> None:
# model_map=None falls back to the bundled data/model_map.json, which must # 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). # itself satisfy the contract (fail-fast on the shipped config too).

View file

@ -72,6 +72,50 @@ class TestSchemaInvariants:
assert proposal.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: class TestFailFastLoader:
"""§7.1: the IR projection is required input — a missing file raises.""" """§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: def test_missing_projection_raises(self, tmp_path: Path) -> None:
with pytest.raises(FileNotFoundError): with pytest.raises(FileNotFoundError):
load_validator_input(tmp_path) 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 from __future__ import annotations
import pytest import pytest
from pydantic import ValidationError
from portfolio_optimiser_claude.ir import SavingsProposal from portfolio_optimiser_claude.ir import SavingsProposal
from portfolio_optimiser_claude.validator import ( from portfolio_optimiser_claude.validator import (
@ -63,3 +64,19 @@ class TestRejectionIsUnconsumable:
assert isinstance(outcome, Rejection) assert isinstance(outcome, Rejection)
for field in ("p10", "p50", "p90", "validates"): for field in ("p10", "p50", "p90", "validates"):
assert not hasattr(outcome, field) 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,
)