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:
parent
873f5fa272
commit
e8cec2e2c0
3 changed files with 94 additions and 1 deletions
|
|
@ -42,3 +42,25 @@ class SavingsProposal(BaseModel):
|
||||||
f"claimed saving {self.claimed_saving_nok} exceeds affected items' total {total}"
|
f"claimed saving {self.claimed_saving_nok} exceeds affected items' total {total}"
|
||||||
)
|
)
|
||||||
return self
|
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
|
||||||
|
|
|
||||||
|
|
@ -135,6 +135,20 @@ def validate_proposal(proposal: SavingsProposal) -> ValidatedProposal | Rejectio
|
||||||
proposal=proposal,
|
proposal=proposal,
|
||||||
reason=f"claimed saving {proposal.claimed_saving_nok:.0f} exceeds P90 feasible {p90:.0f}",
|
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
|
# 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
|
# 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.
|
# gate that can reject a proposal the P90 stage passed. Same ``Rejection`` type, not a new gate.
|
||||||
|
|
|
||||||
|
|
@ -8,11 +8,12 @@ consumed as validated. Pattern: tests/spikes/test_c_validator.py.
|
||||||
import pytest
|
import pytest
|
||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
|
|
||||||
from portfolio_optimiser.ir import SavingsProposal
|
from portfolio_optimiser.ir import AffectedItem, SavingsProposal
|
||||||
from portfolio_optimiser.reference_domain import load_reference_projects
|
from portfolio_optimiser.reference_domain import load_reference_projects
|
||||||
from portfolio_optimiser.validator import (
|
from portfolio_optimiser.validator import (
|
||||||
Rejection,
|
Rejection,
|
||||||
ValidatedProposal,
|
ValidatedProposal,
|
||||||
|
_monte_carlo,
|
||||||
proposal_for,
|
proposal_for,
|
||||||
validate_proposal,
|
validate_proposal,
|
||||||
)
|
)
|
||||||
|
|
@ -75,3 +76,59 @@ def test_pydantic_blocks_negative_quantity() -> None:
|
||||||
def test_pydantic_blocks_claim_above_affected_total(project) -> None:
|
def test_pydantic_blocks_claim_above_affected_total(project) -> None:
|
||||||
with pytest.raises(ValidationError):
|
with pytest.raises(ValidationError):
|
||||||
proposal_for(project, ["05.2"], claimed_saving_nok=99_000_000)
|
proposal_for(project, ["05.2"], claimed_saving_nok=99_000_000)
|
||||||
|
|
||||||
|
|
||||||
|
def _above_nominal_within_p90() -> SavingsProposal:
|
||||||
|
"""Review counterexample #1: affected total 300000 -> nominal feasible 0.30 x 300000 =
|
||||||
|
90000, but the band [0.70, 1.40] is skewed UPWARD around unit_cost 1.0, so the Monte
|
||||||
|
Carlo P90 sits above it. A claim of 100000 therefore clears the P90 stage while
|
||||||
|
exceeding the deterministic bound the CBC solve actually established."""
|
||||||
|
return SavingsProposal(
|
||||||
|
project_id="BYGG-KONTOR-NORD",
|
||||||
|
measure="LED-retrofit av 200 lysrorarmaturer",
|
||||||
|
affected_items=[AffectedItem(code="ENERGI-TOTAL-EL", quantity=300_000, unit_cost=1.0)],
|
||||||
|
claimed_saving_nok=100_000,
|
||||||
|
assumptions={"ENERGI-TOTAL-EL": (0.70, 1.40)},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_claim_above_nominal_feasible_is_blocked_though_p90_would_pass() -> None:
|
||||||
|
"""S2.7 (1): the validator enforces its OWN stage-2 boundary. The nominal block is an
|
||||||
|
INDEPENDENT gate — neither stage dominates the other, because an upward-skewed band
|
||||||
|
lifts P90 above nominal while a downward-skewed one pushes it below."""
|
||||||
|
proposal = _above_nominal_within_p90()
|
||||||
|
# Control: without the nominal block this proposal VALIDATES — the P90 stage lets it
|
||||||
|
# through. If this assert ever fails, the test below has stopped gating the new stage.
|
||||||
|
_, _, p90 = _monte_carlo(proposal)
|
||||||
|
assert proposal.claimed_saving_nok <= p90
|
||||||
|
|
||||||
|
result = validate_proposal(proposal)
|
||||||
|
assert isinstance(result, Rejection)
|
||||||
|
assert "nominal" in result.reason
|
||||||
|
|
||||||
|
|
||||||
|
def test_pydantic_blocks_assumption_band_that_excludes_unit_cost() -> None:
|
||||||
|
"""S2.7 (2): an assumption band is uncertainty AROUND the item's own unit_cost, so it
|
||||||
|
must enclose it. A band of [1.8, 2.2] around unit_cost 1.0 states a different price,
|
||||||
|
not an uncertainty — every Monte Carlo sample would then exceed the item's own cost."""
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
SavingsProposal(
|
||||||
|
project_id="X",
|
||||||
|
measure="band around the wrong centre",
|
||||||
|
affected_items=[AffectedItem(code="A", quantity=1000, unit_cost=1.0)],
|
||||||
|
claimed_saving_nok=100,
|
||||||
|
assumptions={"A": (1.8, 2.2)},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_assumption_band_bounds_are_inclusive() -> None:
|
||||||
|
"""The enclosure is ``low <= unit_cost <= high``: a band that touches the unit_cost at
|
||||||
|
either end is a legitimate one-sided uncertainty, not a violation."""
|
||||||
|
for band in ((1.0, 1.4), (0.7, 1.0)):
|
||||||
|
SavingsProposal(
|
||||||
|
project_id="X",
|
||||||
|
measure="one-sided uncertainty",
|
||||||
|
affected_items=[AffectedItem(code="A", quantity=1000, unit_cost=1.0)],
|
||||||
|
claimed_saving_nok=100,
|
||||||
|
assumptions={"A": band},
|
||||||
|
)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue