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
|
||||
|
||||
import ast
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
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"
|
||||
|
||||
|
||||
|
|
@ -162,14 +169,14 @@ def test_link_in_index_is_idempotent(tmp_path) -> None:
|
|||
assert body.count("(promoted-verdict-x.md)") == 1
|
||||
|
||||
|
||||
def test_okf_is_maf_free() -> None:
|
||||
"""D7 portability: ``okf.py`` IMPORTS no ``agent_framework`` / ``mcp`` (the docstring may name
|
||||
them to document the constraint, exactly as ``retrieval.py`` does) — checked via the AST, not a
|
||||
raw substring, so the prose claim doesn't trip the guard."""
|
||||
import ast
|
||||
|
||||
@pytest.mark.parametrize("module_name", _MAF_FREE_MODULES)
|
||||
def test_okf_is_maf_free(module_name: str) -> None:
|
||||
"""D7 portability: each framework-neutral module IMPORTS no ``agent_framework`` / ``mcp`` (a
|
||||
docstring may name them to document the constraint, exactly as ``retrieval.py`` does) — checked
|
||||
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 = (
|
||||
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")
|
||||
imported: list[str] = []
|
||||
for node in ast.walk(ast.parse(src)):
|
||||
|
|
@ -178,4 +185,4 @@ def test_okf_is_maf_free() -> None:
|
|||
elif isinstance(node, ast.ImportFrom):
|
||||
imported.append(node.module or "")
|
||||
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