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:
Kjell Tore Guttormsen 2026-07-03 06:27:40 +02:00
commit 1e1b7e4506
7 changed files with 529 additions and 0 deletions

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

View file

@ -0,0 +1,42 @@
"""The first-class provenance stamp (method-spec §9) — authoritative data.
At least one citation into the source documents; the producing ``model`` and
``role`` (an injected test client's real model id when available, the neutral
``unknown`` as fallback never a fabricated name); the run's token usage; and
``validator_decision``, which mirrors the DETERMINISTIC VALIDATOR only stamped
from the validator's outcome BEFORE any checker override, so a checker-gated
proposal whose numbers passed is never mislabelled as validator-rejected (§9).
"""
from __future__ import annotations
from typing import Literal
from pydantic import BaseModel, Field
from portfolio_optimiser_claude.validator import Rejection, ValidatedProposal
class Citation(BaseModel):
"""One citation into the source documents: file + exact text span + snippet (§9)."""
file: str = Field(min_length=1)
span: str = Field(min_length=1)
snippet: str = Field(min_length=1)
class Provenance(BaseModel):
"""The proposal's provenance stamp (§9) — schema-validated, fail-fast."""
citations: list[Citation] = Field(min_length=1)
model: str = Field(default="unknown", min_length=1)
role: str = Field(min_length=1)
validator_decision: Literal["validated", "rejected"]
tokens_used: int = Field(ge=0)
def stamp_validator_decision(
outcome: ValidatedProposal | Rejection,
) -> Literal["validated", "rejected"]:
"""Mirror ONLY the deterministic validator's outcome (§9) — never the checker's."""
return "validated" if isinstance(outcome, ValidatedProposal) else "rejected"

View file

@ -0,0 +1,83 @@
"""The deterministic validator (method-spec §3 Step 4, frozen by the golden suite §7.2).
The one endpoint-free judge that anchors the loop against swarm self-confirmation
mandatory, blocking, never an optional plugin. Implements the spec's reference
procedure: the feasibility bound is the closed form ``0.30 × Σ quantity·unit_cost``;
the risk simulation is a Mersenne-Twister Monte Carlo (seed 20260624, 512 samples,
uniform draws from each item's assumptions band, fixed cost when no band) whose
``p10``/``p50``/``p90`` are the 1st/5th/9th cut points of the 10-quantiles (inclusive
method). ``shared/examples/bygg-energi-mikro/golden.json`` is the ONLY ground truth
(§7); ``test_bygg_energi_mikro.py`` freezes every decided field.
"""
from __future__ import annotations
import random
import statistics
from pydantic import BaseModel
from portfolio_optimiser_claude.ir import SavingsProposal
# Policy cap (§3 Step 4): max feasible saving as a fraction of the affected total.
_FEASIBLE_FRACTION = 0.30
# Frozen by the golden suite (§7.2) — changing either detaches from the fasit.
_MC_SEED = 20260624
_MC_SAMPLES = 512
class ValidatedProposal(BaseModel):
"""The validated outcome: the claim sits within the feasible range (§7.2)."""
validates: bool
claimed_saving_nok: float
nominal_feasible: float
p10: float
p50: float
p90: float
class Rejection(BaseModel):
"""A structural block — a DISTINCT type from ``ValidatedProposal`` (§3 Step 4).
Carries the claimed and feasible figures in its ``reason`` and NO percentiles,
so it can never be consumed as validated.
"""
reason: str
def validate_proposal(proposal: SavingsProposal) -> ValidatedProposal | Rejection:
"""Gate the numbers deterministically (§3 Step 4): validated outcome or rejection."""
affected_total = sum(item.quantity * item.unit_cost for item in proposal.affected_items)
nominal_feasible = _FEASIBLE_FRACTION * affected_total
rng = random.Random(_MC_SEED)
feasible_samples: list[float] = []
for _ in range(_MC_SAMPLES):
sampled_total = 0.0
for item in proposal.affected_items:
band = proposal.assumptions.get(item.code)
unit_cost = item.unit_cost if band is None else rng.uniform(band[0], band[1])
sampled_total += item.quantity * unit_cost
feasible_samples.append(_FEASIBLE_FRACTION * sampled_total)
cut_points = statistics.quantiles(feasible_samples, n=10, method="inclusive")
p10, p50, p90 = cut_points[0], cut_points[4], cut_points[8]
if proposal.claimed_saving_nok > p90:
return Rejection(
reason=(
f"claimed saving {proposal.claimed_saving_nok:.2f} NOK exceeds the "
f"optimistic feasible bound {p90:.2f} NOK "
f"(nominal feasible {nominal_feasible:.2f} NOK)"
)
)
return ValidatedProposal(
validates=True,
claimed_saving_nok=proposal.claimed_saving_nok,
nominal_feasible=nominal_feasible,
p10=p10,
p50=p50,
p90=p90,
)