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:
parent
d8f4e13bfa
commit
e7ce6b0a31
6 changed files with 109 additions and 9 deletions
|
|
@ -23,12 +23,15 @@ from __future__ import annotations
|
|||
|
||||
import json
|
||||
from importlib.resources import files
|
||||
from typing import Any, Literal
|
||||
from typing import Annotated, Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
_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):
|
||||
"""The local-folder data source config (schema-validated, fail-fast)."""
|
||||
|
|
@ -40,7 +43,7 @@ class DataSourceContract(BaseModel):
|
|||
class ModelMapContract(BaseModel):
|
||||
"""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")
|
||||
def _each_profile_has_default(self) -> ModelMapContract:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
``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
|
||||
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).
|
||||
"""
|
||||
|
||||
|
|
@ -12,19 +15,22 @@ from __future__ import annotations
|
|||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import Annotated, Any
|
||||
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
_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):
|
||||
"""One affected cost item: ``{code, quantity >= 0, unit_cost > 0}`` (§7.1)."""
|
||||
|
||||
code: str = Field(min_length=1)
|
||||
quantity: float = Field(ge=0)
|
||||
unit_cost: float = Field(gt=0)
|
||||
quantity: float = Field(ge=0, allow_inf_nan=False)
|
||||
unit_cost: float = Field(gt=0, allow_inf_nan=False)
|
||||
|
||||
|
||||
class SavingsProposal(BaseModel):
|
||||
|
|
@ -33,8 +39,8 @@ class SavingsProposal(BaseModel):
|
|||
project_id: str = Field(min_length=1)
|
||||
measure: str = Field(min_length=1)
|
||||
affected_items: list[AffectedItem] = Field(min_length=1)
|
||||
claimed_saving_nok: float = Field(gt=0)
|
||||
assumptions: dict[str, tuple[float, float]] = Field(default_factory=dict)
|
||||
claimed_saving_nok: float = Field(gt=0, allow_inf_nan=False)
|
||||
assumptions: dict[str, tuple[_BandEndpoint, _BandEndpoint]] = Field(default_factory=dict)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _claim_within_affected_total(self) -> SavingsProposal:
|
||||
|
|
|
|||
|
|
@ -109,7 +109,12 @@ class DebateResult:
|
|||
|
||||
|
||||
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:
|
||||
raise RuntimeError(
|
||||
f"debate turn-count safety net tripped: {turns} turns with max_rounds={max_rounds}"
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue