Every stage of validate_proposal reasoned only about numbers the proposal itself supplied, so an internally-consistent hallucination cleared the whole gate (F3). A new stage 0 reconciles each affected_item against the project's CostBaseline before the CBC solve: an unknown cost code is rejected, and a real code carrying a quantity/unit_cost outside the configured tolerance (5% default, relative to the baseline value) is rejected. Validation, never repair. The baseline argument is OPTIONAL (None = pre-S4.0 behaviour), but both run paths set it: the road path projects project.cost_items, the bundle path loads cost-baseline.json when the bundle ships one. Bundles written before the amendment stay un-anchored, so the commons-owned goldens run byte-identically; a baseline that exists but is malformed still raises on both loaders. F8: the method-specific cap now comes from the METHOD_CAPS registry (measure type -> fraction, injectable) instead of an energy_efficiency string comparison. The baseline format and tolerance semantics were decided locally — the commons amendment (D-A pt. 2) never arrived, exactly as in S3.2. D7 mirroring stays open. Three portfolio fixtures quoted cost codes belonging to OTHER projects; the new gate caught them. They now quote each project's own lines, and the two copied REPLIES tables import the single source instead of drifting from it. Load-bearing measured (tests/test_s40_cost_baseline_loadbearing.py), six mutations all red: detach the reconciliation stage; detach the magnitude tolerance; detach the road wiring; detach the bundle wiring; ignore the injected cap registry; make the optional loader tolerant of malformed content. Control: with the road wiring detached the repaired portfolio fixtures still pass, so they are not masking the seam. 597 -> 612 tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JdwK7bQ4BZkWH4t8MRDKb4
91 lines
3.8 KiB
Python
91 lines
3.8 KiB
Python
"""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
|