portfolio-optimiser/src/portfolio_optimiser/ir.py
Kjell Tore Guttormsen 126807aee7 feat(validator): anchor the deterministic gate to the project's real cost baseline (S4.0)
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
2026-08-03 17:19:31 +02:00

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