feat(fase1): assessment-method encoding — persisted dimension-scoped example + validator rule (F1)

This commit is contained in:
Kjell Tore Guttormsen 2026-07-07 08:15:44 +02:00
commit 69ca508677
4 changed files with 171 additions and 0 deletions

View file

@ -0,0 +1,99 @@
"""Step 9 load-bearing seam (SC7): assessment-method encoding — a PERSISTED dimension-scoped
example (Arm A) + an INDEPENDENT deterministic validator rule (Arm B).
Arm A (navigable, dimension-scoped): the persisted, MAF-repo-local method bundle
(``data/method_examples/energi/``) carries a ``type: methodology`` file with ``dimension: energi``.
Its sentinel reaches ``bundle_context(..., dimension="energi")`` but is ABSENT at
``dimension="asfalt"`` the detach is the Step-3 dimension filter (H4: "text is rendered" is not a
detachable seam on its own; the dimension SCOPING is).
Arm B (independent validator rule): a proposal the generic P90 stage PASSES but the stricter energy-
method cap REJECTS proving the method rule is an INDEPENDENT gate (not redundant with P90). Detach
the rule (or flip the measure) and the same proposal validates. The rule returns the validator's own
``Rejection`` type, so the validator stays the sole blocking gate and provenance stays honest (the
numbers only).
Patterns: ``test_okf.py:141/51`` (navigable + bundle_context), ``test_validator.py:49`` (rule).
"""
from __future__ import annotations
from pathlib import Path
from portfolio_optimiser import okf
from portfolio_optimiser.ir import AffectedItem, SavingsProposal
from portfolio_optimiser.validator import (
Rejection,
ValidatedProposal,
validate_proposal,
)
METHOD_BUNDLE = (
Path(__file__).resolve().parents[1]
/ "src"
/ "portfolio_optimiser"
/ "data"
/ "method_examples"
/ "energi"
)
_METHOD_SENTINEL = "ENERGI-METODE-IPMVP-A-SENTINEL"
# --- Arm A: navigable, dimension-scoped persisted example ----------------------------------------
def test_persisted_method_navigable_when_dimension_matches() -> None:
"""The persisted energi method (``dimension: energi``) is navigable + rendered when the run is
scoped to energi an actually-delivered, persisted '>=1 encoded method' (not an ephemeral copy)."""
bundle = okf.navigate_bundle(str(METHOD_BUNDLE))
scoped = okf.bundle_context(bundle, dimension="energi")
assert _METHOD_SENTINEL in scoped
def test_persisted_method_absent_for_other_dimension() -> None:
"""LOAD-BEARING (SC7-A): the energi method is ABSENT when the run is scoped to another dimension.
RED if the Step-3 dimension filter is detached (the energi method then leaks into an asfalt run)."""
bundle = okf.navigate_bundle(str(METHOD_BUNDLE))
other = okf.bundle_context(bundle, dimension="asfalt")
assert _METHOD_SENTINEL not in other
# --- Arm B: independent deterministic validator rule ---------------------------------------------
def _proposal(measure: str, claimed: float) -> SavingsProposal:
"""Affected total 100000 (degenerate MC, empty assumptions) -> generic P90 = 0.30 x 100000 =
30000; the energy-method cap = 0.15 x 100000 = 15000."""
return SavingsProposal(
project_id="P-ENERGI",
measure=measure,
affected_items=[AffectedItem(code="ENERGI-TOTAL-EL", quantity=100000, unit_cost=1.0)],
claimed_saving_nok=claimed,
assumptions={},
)
def test_method_rule_is_independent_of_generic_p90() -> None:
"""LOAD-BEARING (SC7-B): claimed 20000 is <= generic P90 (30000) but > the energy-method cap
(15000). A NON-energy proposal with the same numbers validates (P90 passes); the energy-method
proposal is REJECTED by the stricter method rule an independent flip. RED if the method rule is
detached (the energy proposal then validates too)."""
# Control: same numbers, non-energy measure -> the generic P90 stage validates it.
control = validate_proposal(_proposal("scope_reduction", 20000))
assert isinstance(control, ValidatedProposal), (
"the generic P90 stage should pass 20000 <= 30000"
)
# Energy method: the stricter method cap flips the SAME numbers to a Rejection.
result = validate_proposal(_proposal("energy_efficiency", 20000))
assert isinstance(result, Rejection), "the energy-method cap must reject a P90-valid over-claim"
assert "method cap" in result.reason
# The rule returns the validator's OWN Rejection type -> the validator stays the sole blocking
# gate (numbers only); it never introduces a new gate type.
def test_method_rule_admits_within_the_stricter_cap() -> None:
"""Causality control: an energy proposal WITHIN the method cap (claimed 15000 == 0.15 x 100000)
validates proving the rejection above is caused by exceeding the cap, not the measure string."""
result = validate_proposal(_proposal("energy_efficiency", 15000))
assert isinstance(result, ValidatedProposal)