feat(validator): S6 — deterministic backbone: typed IR, golden-frozen validator, provenance stamp
TDD from method-spec alone (§3 Step 4, §7, §9), golden.json as the only ground truth: ir.py (construction invariants, fail-fast bundle loader), validator.py (closed-form feasibility bound 0.30·Σ + Monte Carlo seed 20260624/512 samples/inclusive quantiles — reproduces every frozen golden field; Rejection as a distinct unconsumable type), provenance.py (stamp mirroring ONLY the deterministic validator). Mutation controls + seed-detach proof (§11); 45/45 green without an API key; ruff + mypy --strict clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QdSfQdND84oeq2mbjueLTS
This commit is contained in:
parent
4efb72943c
commit
1e1b7e4506
7 changed files with 529 additions and 0 deletions
58
src/portfolio_optimiser_claude/ir.py
Normal file
58
src/portfolio_optimiser_claude/ir.py
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
"""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). 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 Any
|
||||
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
_VALIDATOR_INPUT_FILENAME = "validator-input.json"
|
||||
|
||||
|
||||
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)
|
||||
unit_cost: float = Field(gt=0)
|
||||
|
||||
|
||||
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)
|
||||
assumptions: dict[str, tuple[float, float]] = 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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue