feat(s53): load_dimension fail-fast loader (MAF-free, mirrors load_goal_config)
New load_dimension(str | Path) in dimension.py: is_file() -> FileNotFoundError, then Dimension.model_validate_json -> pydantic.ValidationError on malformed shape. Fail-fast because dimension config is authoritative startup input (contrast the tolerant verdict-inbox RAW layer). Stdlib + pydantic only — stays MAF-free (test_okf_is_maf_free AST guard green). Exported from __init__.py in both the import block and __all__. RED-first: 3 loader tests failed on the missing import, green after. tests/test_dimension.py + test_okf.py: 29 passed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KNNiJRk1sSwxgVLS5AobT1
This commit is contained in:
parent
2ed0b5991c
commit
124b7aedde
3 changed files with 59 additions and 2 deletions
|
|
@ -1,7 +1,7 @@
|
||||||
"""portfolio-optimiser — generic MAF framework for per-project cost-savings optimization."""
|
"""portfolio-optimiser — generic MAF framework for per-project cost-savings optimization."""
|
||||||
|
|
||||||
from portfolio_optimiser.contracts import GoalConfig, GoalContract, load_goal_config
|
from portfolio_optimiser.contracts import GoalConfig, GoalContract, load_goal_config
|
||||||
from portfolio_optimiser.dimension import Dimension, admits
|
from portfolio_optimiser.dimension import Dimension, admits, load_dimension
|
||||||
from portfolio_optimiser.ledger import (
|
from portfolio_optimiser.ledger import (
|
||||||
LedgerEntry,
|
LedgerEntry,
|
||||||
RealizationRefused,
|
RealizationRefused,
|
||||||
|
|
@ -37,6 +37,7 @@ __all__ = [
|
||||||
# Fase 1 domain model
|
# Fase 1 domain model
|
||||||
"Dimension",
|
"Dimension",
|
||||||
"admits",
|
"admits",
|
||||||
|
"load_dimension",
|
||||||
"SavingsLedger",
|
"SavingsLedger",
|
||||||
"LedgerEntry",
|
"LedgerEntry",
|
||||||
"realize",
|
"realize",
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@ at least one affected code must match an allowed prefix.
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -42,3 +44,25 @@ def admits(*, measure_type: str, codes: frozenset[str], dimension: Dimension) ->
|
||||||
return any(
|
return any(
|
||||||
code.startswith(prefix) for code in codes for prefix in dimension.allowed_code_prefixes
|
code.startswith(prefix) for code in codes for prefix in dimension.allowed_code_prefixes
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def load_dimension(path: str | Path) -> Dimension:
|
||||||
|
"""Fail-fast standalone loader for a dimension scope config (mirrors ``load_goal_config``).
|
||||||
|
|
||||||
|
A dimension config is *authoritative startup input*, so loading is fail-fast: a
|
||||||
|
missing file raises ``FileNotFoundError`` and malformed/invalid content raises
|
||||||
|
``pydantic.ValidationError``. This is the deliberate contrast to the tolerant
|
||||||
|
verdict-inbox RAW layer (``load_verdicts_from_dir``), which skips bad files rather
|
||||||
|
than raising — startup scope must never be silently degraded.
|
||||||
|
|
||||||
|
Stdlib + ``pydantic`` only: ``dimension.py`` is in ``_MAF_FREE_MODULES`` and the AST
|
||||||
|
guard (``tests/test_okf.py::test_okf_is_maf_free``) fails on any ``agent_framework``/
|
||||||
|
``mcp`` import here.
|
||||||
|
|
||||||
|
:raises FileNotFoundError: ``path`` does not point at an existing file.
|
||||||
|
:raises pydantic.ValidationError: the JSON is malformed or violates the ``Dimension`` schema.
|
||||||
|
"""
|
||||||
|
p = Path(path)
|
||||||
|
if not p.is_file():
|
||||||
|
raise FileNotFoundError(f"dimension config not found: {str(path)!r}")
|
||||||
|
return Dimension.model_validate_json(p.read_text(encoding="utf-8"))
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ from __future__ import annotations
|
||||||
import pytest
|
import pytest
|
||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
|
|
||||||
from portfolio_optimiser.dimension import Dimension, admits
|
from portfolio_optimiser.dimension import Dimension, admits, load_dimension
|
||||||
|
|
||||||
|
|
||||||
def _energy_dim(*, prefixes: frozenset[str] = frozenset()) -> Dimension:
|
def _energy_dim(*, prefixes: frozenset[str] = frozenset()) -> Dimension:
|
||||||
|
|
@ -74,3 +74,35 @@ def test_dimension_empty_label_raises() -> None:
|
||||||
label="",
|
label="",
|
||||||
allowed_measure_types=frozenset({"energy_efficiency"}),
|
allowed_measure_types=frozenset({"energy_efficiency"}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Step 1 (S5.3): fail-fast load_dimension loader (mirrors load_goal_config) -------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_dimension_round_trip(tmp_path) -> None:
|
||||||
|
"""A valid dimension JSON round-trips through ``load_dimension`` (accepts str | Path)."""
|
||||||
|
dim = Dimension(
|
||||||
|
id="energi",
|
||||||
|
label="Energi",
|
||||||
|
allowed_measure_types=frozenset({"energy_efficiency"}),
|
||||||
|
allowed_code_prefixes=frozenset({"07"}),
|
||||||
|
)
|
||||||
|
p = tmp_path / "dim.json"
|
||||||
|
p.write_text(dim.model_dump_json(), encoding="utf-8")
|
||||||
|
assert load_dimension(p) == dim
|
||||||
|
assert load_dimension(str(p)) == dim # str path also accepted
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_dimension_missing_file_raises(tmp_path) -> None:
|
||||||
|
"""A missing file fails fast with ``FileNotFoundError`` — authoritative startup config,
|
||||||
|
NOT a tolerant RAW inbox layer (contrast the verdict inbox)."""
|
||||||
|
with pytest.raises(FileNotFoundError):
|
||||||
|
load_dimension(tmp_path / "does-not-exist.json")
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_dimension_malformed_shape_raises(tmp_path) -> None:
|
||||||
|
"""Malformed content (missing required fields) fails fast with ``ValidationError``."""
|
||||||
|
bad = tmp_path / "dim.json"
|
||||||
|
bad.write_text('{"id": "energi"}', encoding="utf-8") # missing label + allowed_measure_types
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
load_dimension(bad)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue