feat(fase1): dimension IR + admits, MAF-free guard extended (F1)
This commit is contained in:
parent
254e3da1d8
commit
a44256a994
3 changed files with 135 additions and 8 deletions
44
src/portfolio_optimiser/dimension.py
Normal file
44
src/portfolio_optimiser/dimension.py
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
"""Typed IR for a cost-reduction *dimension* + the ``admits`` scoping gate (Fase 1, F1).
|
||||||
|
|
||||||
|
Pure module — imports **only** ``pydantic`` + stdlib. No ``agent_framework`` and no
|
||||||
|
``verdicts`` (so ``ProposalFeatures`` is never referenced here); ``admits`` takes
|
||||||
|
primitives. This keeps the module D7-portable and MAF-free, enforced by
|
||||||
|
``tests/test_okf.py::test_okf_is_maf_free`` which AST-scans this file alongside
|
||||||
|
``okf.py``.
|
||||||
|
|
||||||
|
A dimension is one cost axis a project is reduced along (energy, paving, ...).
|
||||||
|
``admits`` decides whether a candidate measure belongs to a dimension: its
|
||||||
|
``measure_type`` must be allowed, and — when the dimension constrains cost codes —
|
||||||
|
at least one affected code must match an allowed prefix.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class Dimension(BaseModel):
|
||||||
|
"""One cost-reduction axis a project's candidate measures are scoped to."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
label: str = Field(min_length=1)
|
||||||
|
allowed_measure_types: frozenset[str]
|
||||||
|
allowed_code_prefixes: frozenset[str] = frozenset()
|
||||||
|
|
||||||
|
|
||||||
|
def admits(*, measure_type: str, codes: frozenset[str], dimension: Dimension) -> bool:
|
||||||
|
"""True iff a candidate measure belongs to ``dimension``.
|
||||||
|
|
||||||
|
Two conjoined conditions:
|
||||||
|
|
||||||
|
- ``measure_type`` is in ``dimension.allowed_measure_types``; **and**
|
||||||
|
- the dimension imposes no code constraint (``allowed_code_prefixes`` empty),
|
||||||
|
**or** at least one of ``codes`` starts with one of the allowed prefixes.
|
||||||
|
"""
|
||||||
|
if measure_type not in dimension.allowed_measure_types:
|
||||||
|
return False
|
||||||
|
if not dimension.allowed_code_prefixes:
|
||||||
|
return True
|
||||||
|
return any(
|
||||||
|
code.startswith(prefix) for code in codes for prefix in dimension.allowed_code_prefixes
|
||||||
|
)
|
||||||
76
tests/test_dimension.py
Normal file
76
tests/test_dimension.py
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
"""Unit tests for the dimension IR + ``admits`` scoping gate (Fase 1, Step 1, SC1).
|
||||||
|
|
||||||
|
``admits`` must accept an in-dimension candidate (``measure_type`` in the allowed
|
||||||
|
set) and reject an out-of-dimension one, exercising BOTH branches of the
|
||||||
|
code-prefix logic — empty prefixes (measure_type-only) and non-empty prefixes
|
||||||
|
where a matching vs non-matching code decides the outcome. A ``Dimension`` with an
|
||||||
|
empty ``label`` must fail construction (Pydantic ``ValidationError``).
|
||||||
|
|
||||||
|
Model test: ``tests/test_validator.py`` (rejection assertions).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from portfolio_optimiser.dimension import Dimension, admits
|
||||||
|
|
||||||
|
|
||||||
|
def _energy_dim(*, prefixes: frozenset[str] = frozenset()) -> Dimension:
|
||||||
|
return Dimension(
|
||||||
|
id="energi",
|
||||||
|
label="Energi",
|
||||||
|
allowed_measure_types=frozenset({"energy_efficiency"}),
|
||||||
|
allowed_code_prefixes=prefixes,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_admits_in_dimension_measure_type_only() -> None:
|
||||||
|
"""Empty ``allowed_code_prefixes``: only the measure_type gate applies."""
|
||||||
|
dim = _energy_dim()
|
||||||
|
assert (
|
||||||
|
admits(measure_type="energy_efficiency", codes=frozenset({"07.1"}), dimension=dim) is True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_admits_rejects_out_of_dimension_measure_type() -> None:
|
||||||
|
"""Out-of-dimension measure_type is rejected regardless of codes."""
|
||||||
|
dim = _energy_dim()
|
||||||
|
assert admits(measure_type="paving", codes=frozenset({"07.1"}), dimension=dim) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_admits_prefix_branch_matching_code() -> None:
|
||||||
|
"""Non-empty prefixes: a code matching a prefix is admitted."""
|
||||||
|
dim = _energy_dim(prefixes=frozenset({"07"}))
|
||||||
|
assert (
|
||||||
|
admits(
|
||||||
|
measure_type="energy_efficiency",
|
||||||
|
codes=frozenset({"07.1", "99.9"}),
|
||||||
|
dimension=dim,
|
||||||
|
)
|
||||||
|
is True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_admits_prefix_branch_no_matching_code() -> None:
|
||||||
|
"""Non-empty prefixes: no code matches any prefix -> rejected even when measure_type is allowed."""
|
||||||
|
dim = _energy_dim(prefixes=frozenset({"07"}))
|
||||||
|
assert (
|
||||||
|
admits(
|
||||||
|
measure_type="energy_efficiency",
|
||||||
|
codes=frozenset({"99.9"}),
|
||||||
|
dimension=dim,
|
||||||
|
)
|
||||||
|
is False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_dimension_empty_label_raises() -> None:
|
||||||
|
"""An empty ``label`` violates ``Field(min_length=1)`` -> ValidationError at construction."""
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
Dimension(
|
||||||
|
id="energi",
|
||||||
|
label="",
|
||||||
|
allowed_measure_types=frozenset({"energy_efficiency"}),
|
||||||
|
)
|
||||||
|
|
@ -9,10 +9,17 @@ No ``agent_framework``/``mcp`` import is allowed in ``okf`` — guarded by ``tes
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ast
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
from portfolio_optimiser import okf
|
from portfolio_optimiser import okf
|
||||||
|
|
||||||
|
# Framework-neutral, D7-portable modules that must never import MAF/mcp (C2:
|
||||||
|
# the guard previously scanned only okf.py; dimension.py is now covered too).
|
||||||
|
_MAF_FREE_MODULES = ["okf.py", "dimension.py"]
|
||||||
|
|
||||||
BUNDLE_DIR = Path(__file__).resolve().parents[1] / "shared" / "examples" / "bygg-energi-mikro"
|
BUNDLE_DIR = Path(__file__).resolve().parents[1] / "shared" / "examples" / "bygg-energi-mikro"
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -162,14 +169,14 @@ def test_link_in_index_is_idempotent(tmp_path) -> None:
|
||||||
assert body.count("(promoted-verdict-x.md)") == 1
|
assert body.count("(promoted-verdict-x.md)") == 1
|
||||||
|
|
||||||
|
|
||||||
def test_okf_is_maf_free() -> None:
|
@pytest.mark.parametrize("module_name", _MAF_FREE_MODULES)
|
||||||
"""D7 portability: ``okf.py`` IMPORTS no ``agent_framework`` / ``mcp`` (the docstring may name
|
def test_okf_is_maf_free(module_name: str) -> None:
|
||||||
them to document the constraint, exactly as ``retrieval.py`` does) — checked via the AST, not a
|
"""D7 portability: each framework-neutral module IMPORTS no ``agent_framework`` / ``mcp`` (a
|
||||||
raw substring, so the prose claim doesn't trip the guard."""
|
docstring may name them to document the constraint, exactly as ``retrieval.py`` does) — checked
|
||||||
import ast
|
via the AST, not a raw substring, so the prose claim doesn't trip the guard. Parametrized over
|
||||||
|
``_MAF_FREE_MODULES`` so ``dimension.py`` is guarded alongside ``okf.py`` (C2)."""
|
||||||
src = (
|
src = (
|
||||||
Path(__file__).resolve().parents[1] / "src" / "portfolio_optimiser" / "okf.py"
|
Path(__file__).resolve().parents[1] / "src" / "portfolio_optimiser" / module_name
|
||||||
).read_text(encoding="utf-8")
|
).read_text(encoding="utf-8")
|
||||||
imported: list[str] = []
|
imported: list[str] = []
|
||||||
for node in ast.walk(ast.parse(src)):
|
for node in ast.walk(ast.parse(src)):
|
||||||
|
|
@ -178,4 +185,4 @@ def test_okf_is_maf_free() -> None:
|
||||||
elif isinstance(node, ast.ImportFrom):
|
elif isinstance(node, ast.ImportFrom):
|
||||||
imported.append(node.module or "")
|
imported.append(node.module or "")
|
||||||
forbidden = [m for m in imported if m.split(".")[0] in {"agent_framework", "mcp"}]
|
forbidden = [m for m in imported if m.split(".")[0] in {"agent_framework", "mcp"}]
|
||||||
assert forbidden == [], f"okf.py must not import MAF/mcp, found: {forbidden}"
|
assert forbidden == [], f"{module_name} must not import MAF/mcp, found: {forbidden}"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue