portfolio-optimiser-claude/src/portfolio_optimiser_claude/validator.py
Kjell Tore Guttormsen 1e1b7e4506 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
2026-07-03 06:27:40 +02:00

83 lines
3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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,
)