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>
64 lines
2.7 KiB
Python
64 lines
2.7 KiB
Python
"""The typed cost-IR of a candidate measure (method-spec §7.1).
|
|
|
|
Schema invariants are enforced at construction, so a malformed proposal can never
|
|
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). 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).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
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, allow_inf_nan=False)
|
|
unit_cost: float = Field(gt=0, allow_inf_nan=False)
|
|
|
|
|
|
class SavingsProposal(BaseModel):
|
|
"""The candidate measure projected into the typed cost-IR (§7.1)."""
|
|
|
|
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, 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:
|
|
# §7.1: a claim above the items' own total is a schema error, not a
|
|
# validator rejection — the value must never exist.
|
|
total = sum(item.quantity * item.unit_cost for item in self.affected_items)
|
|
if self.claimed_saving_nok > total:
|
|
raise ValueError(
|
|
f"claimed_saving_nok ({self.claimed_saving_nok}) exceeds the affected "
|
|
f"items' own total ({total})"
|
|
)
|
|
return self
|
|
|
|
|
|
def load_validator_input(bundle_dir: Path) -> SavingsProposal:
|
|
"""Load a bundle's IR projection — FAIL-FAST: a missing file raises (§7.1)."""
|
|
raw: dict[str, Any] = json.loads(
|
|
(bundle_dir / _VALIDATOR_INPUT_FILENAME).read_text(encoding="utf-8")
|
|
)
|
|
# Shared fasit files carry an informative "_note"; extra keys are ignored.
|
|
return SavingsProposal.model_validate(raw)
|