feat(validator): enforce the deterministic stage-2 bound and band enclosure (S2.7)

Two tightenings, each measured by a detached-mutation run:

(1) The validator now blocks a claim above the CBC nominal feasible, in ADDITION
to the P90 stage. Neither dominates the other: an upward-skewed assumption band
lifts P90 ABOVE nominal -- so P90 alone passed review counterexample #1 (claim
100k, nominal 90k, band [0.70, 1.40], measured P90 121057) -- while a
downward-skewed band pushes P90 below it. Independent gate, same Rejection type,
existing rejections keep their existing reason.

(2) An assumption band must enclose its item's unit_cost (low <= unit_cost <=
high, inclusive). A band that misses it states a different price rather than an
uncertainty, and every Monte Carlo draw would then sample away from the item's
stated cost. Checked exactly where the Monte Carlo looks bands up -- per affected
item, by code; a band keyed to no affected item is never sampled and so has no
unit_cost to enclose.

The premise was re-verified against ground truth before building on it, not
taken from STATE: 05.2 unit_cost 215 in (200,230), 03.1 310 in (290,330),
ENERGI-TOTAL-EL 1.0 in [0.70,1.40] and (0.8,1.2). No fixture violates it.
The LLM path already catches ValidationError as a meter-bounded retry
(generate.py:138), so the new invariant cannot crash a run.

Mutations, all RED: detach the nominal block; drop the model_validator
decorator; make the enclosure strict. tests/test_bygg_energi_mikro.py and the
commons golden are UNCHANGED and green -- the regression proof.

586 -> 589 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017DDXwUqHVAQQeYE7X1TXy5
This commit is contained in:
Kjell Tore Guttormsen 2026-08-03 16:22:54 +02:00
commit e8cec2e2c0
3 changed files with 94 additions and 1 deletions

View file

@ -42,3 +42,25 @@ class SavingsProposal(BaseModel):
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

View file

@ -135,6 +135,20 @@ def validate_proposal(proposal: SavingsProposal) -> ValidatedProposal | Rejectio
proposal=proposal,
reason=f"claimed saving {proposal.claimed_saving_nok:.0f} exceeds P90 feasible {p90:.0f}",
)
# Stage 4b (S2.7): the validator enforces its OWN stage-2 boundary. The CBC solve already
# established the nominal feasible saving at the items' stated unit-costs; a claim above it
# is out of range no matter how the uncertainty bands fall. This is an INDEPENDENT gate, not
# a restatement of the P90 stage: an upward-skewed band lifts P90 ABOVE nominal (so P90 alone
# would pass a claim the deterministic bound rejects), while a downward-skewed one pushes P90
# below it. Neither stage dominates, so both are kept.
if proposal.claimed_saving_nok > nominal:
return Rejection(
proposal=proposal,
reason=(
f"claimed saving {proposal.claimed_saving_nok:.0f} exceeds the nominal feasible "
f"{nominal:.0f} at the items' stated unit-costs"
),
)
# Stage 5 (Step 9, SC7-B): a method-specific rule STRICTER than the generic cap. A proposal in
# the energy method (IPMVP Option A) must clear a lower, method-scoped feasible — an INDEPENDENT
# gate that can reject a proposal the P90 stage passed. Same ``Rejection`` type, not a new gate.