"""Typed Pydantic IR for a candidate cost-saving measure (B1). Pure module — **no** ``agent_framework`` and no solver. The two structural invariants (non-negative quantities; a claimed saving may not exceed the affected items' own total) are enforced at construction by Pydantic, so a malformed proposal can never be built. This IR is the D7-portable contract both the deterministic validator and the LLM->IR generator speak. """ from __future__ import annotations from pydantic import BaseModel, Field, model_validator class AffectedItem(BaseModel): """One project cost line a proposal claims to save against.""" code: str quantity: float = Field(ge=0) # quantities must be >= 0 unit_cost: float = Field(gt=0) @property def total(self) -> float: return self.quantity * self.unit_cost class CostBaselineLine(BaseModel): """One line of a project's ACTUAL cost baseline: the quantity and unit cost a proposal's ``AffectedItem`` for that code must reconcile against (S4.0, F3).""" quantity: float = Field(ge=0) unit_cost: float = Field(gt=0) class CostBaseline(BaseModel): """A project's cost baseline, keyed by cost code — the ground truth the deterministic validator anchors ``affected_items`` to, so the gate cannot be fed hallucinated cost lines. Deliberately a typed IR contract (not a loader-private shape): both sources project INTO it — an OKF bundle's ``cost-baseline.json`` (``okf.load_cost_baseline``) and the road reference domain's ``cost_items`` (``validator.baseline_from_project``) — so the validator sees ONE representation regardless of path, and the Claude-SDK sibling can mirror it (D7). The projection/tolerance semantics were decided HERE: the commons amendment specifying ``cost-baseline.json`` never arrived, exactly as in S3.2. D7 mirroring stays OPEN. """ project_id: str items: dict[str, CostBaselineLine] class SavingsProposal(BaseModel): """Typed IR for a candidate cost-saving measure (B1).""" project_id: str measure: str affected_items: list[AffectedItem] = Field(min_length=1) claimed_saving_nok: float = Field(gt=0) # code -> (low_unit_cost, high_unit_cost) for the Monte Carlo step; empty = degenerate. assumptions: dict[str, tuple[float, float]] = Field(default_factory=dict) @model_validator(mode="after") def _claim_within_affected_total(self) -> SavingsProposal: total = sum(item.total for item in self.affected_items) if self.claimed_saving_nok > total: raise ValueError( f"claimed saving {self.claimed_saving_nok} exceeds affected items' total {total}" ) return self @model_validator(mode="after") def _assumption_bands_enclose_unit_cost(self) -> SavingsProposal: """A band states the UNCERTAINTY around an item's own ``unit_cost``, so it must enclose it (``low <= unit_cost <= high``, inclusive — a one-sided band that touches the unit_cost is legitimate). A band that misses it states a *different* price, and the Monte Carlo would then sample every draw away from the item's stated cost. Checked exactly where the Monte Carlo looks bands up — per affected item, by code (``validator._monte_carlo``). A band keyed to no affected item is never sampled, so it has no ``unit_cost`` to enclose and is not this invariant's business.""" for item in self.affected_items: band = self.assumptions.get(item.code) if band is None: continue low, high = band if not (low <= item.unit_cost <= high): raise ValueError( f"assumption band {band} for {item.code!r} does not enclose its " f"unit_cost {item.unit_cost}" ) return self