"""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)