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
83 lines
3 KiB
Python
83 lines
3 KiB
Python
"""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,
|
||
)
|