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

@ -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: