feat(s51): fail-fast self-contained routing config loader
Gate: pytest tests/test_hitl.py -k routing_config → 6 passed.
This commit is contained in:
parent
b50e3fdff2
commit
e9179271e6
2 changed files with 121 additions and 0 deletions
|
|
@ -35,6 +35,8 @@ from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field, model_validator
|
||||||
|
|
||||||
# Mirrored INLINE from verdicts.py (NOT imported — verdicts.py:29 pulls agent_framework). The inbox
|
# Mirrored INLINE from verdicts.py (NOT imported — verdicts.py:29 pulls agent_framework). The inbox
|
||||||
# predicate must match load_verdicts_from_dir EXACTLY, else pending would count as judged a file the
|
# predicate must match load_verdicts_from_dir EXACTLY, else pending would count as judged a file the
|
||||||
# real loader drops. Kept in lockstep with verdicts._REQUIRED_VERDICT_KEYS / _INBOX_DECISION_VOCABULARY
|
# real loader drops. Kept in lockstep with verdicts._REQUIRED_VERDICT_KEYS / _INBOX_DECISION_VOCABULARY
|
||||||
|
|
@ -135,6 +137,50 @@ def pending(outbox_dir: str, verdict_dir: str) -> list[PendingProposal]:
|
||||||
return sorted(unjudged, key=lambda p: (p.run_id, p.verdict_id))
|
return sorted(unjudged, key=lambda p: (p.run_id, p.verdict_id))
|
||||||
|
|
||||||
|
|
||||||
|
# --- Routing config: self-contained dimension→expert table (fail-fast) ----------------------------
|
||||||
|
# A minimal MVP stand-in for the S3.5 dimension catalog (kept DISTINCT — see the plan's Non-Goals).
|
||||||
|
# Field names mirror ``dimension.Dimension`` so the two reconcile cleanly when S3.5 lands. No ``label``
|
||||||
|
# field: ``_matches`` never builds a ``Dimension``, so a label would be dead single-use surface.
|
||||||
|
|
||||||
|
|
||||||
|
class RoutingEntry(BaseModel):
|
||||||
|
"""One ``dimension → expert`` routing rule. ``allowed_measure_types`` EMPTY means "any measure"
|
||||||
|
(route by code prefix alone — see ``_matches`` + the plan's Risk #1); a non-empty set restores a
|
||||||
|
measure gate for deployments that want one."""
|
||||||
|
|
||||||
|
id: str = Field(min_length=1)
|
||||||
|
allowed_measure_types: frozenset[str] = frozenset()
|
||||||
|
allowed_code_prefixes: frozenset[str] = frozenset()
|
||||||
|
expert: str = Field(min_length=1)
|
||||||
|
|
||||||
|
|
||||||
|
class RoutingConfig(BaseModel):
|
||||||
|
"""The routing table. Entry ``id``s must be unique — a duplicate would make the sorted-first
|
||||||
|
tie-break ambiguous."""
|
||||||
|
|
||||||
|
entries: list[RoutingEntry]
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _unique_entry_ids(self) -> RoutingConfig:
|
||||||
|
ids = [e.id for e in self.entries]
|
||||||
|
if len(ids) != len(set(ids)):
|
||||||
|
raise ValueError("routing config has duplicate entry ids")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
def load_routing_config(path: str | Path) -> RoutingConfig:
|
||||||
|
"""Load + validate the routing config, fail-fast (mirrors ``costsim.load_pricing``): a missing
|
||||||
|
file raises ``FileNotFoundError``; malformed data raises ``pydantic.ValidationError``; a duplicate
|
||||||
|
entry id raises ``ValueError`` (via the after-validator). Top-level ``_``-prefixed keys are
|
||||||
|
ignored (doc/comment convention)."""
|
||||||
|
p = Path(path)
|
||||||
|
if not p.is_file():
|
||||||
|
raise FileNotFoundError(f"routing config not found: {str(p)!r}")
|
||||||
|
raw = json.loads(p.read_text(encoding="utf-8"))
|
||||||
|
data = {k: v for k, v in raw.items() if not k.startswith("_")}
|
||||||
|
return RoutingConfig(**data)
|
||||||
|
|
||||||
|
|
||||||
def _load_json_dict(file: Path) -> dict[str, Any] | None:
|
def _load_json_dict(file: Path) -> dict[str, Any] | None:
|
||||||
"""Tolerant read: parse ``file`` as JSON and return it only if it is a dict, else ``None`` (an
|
"""Tolerant read: parse ``file`` as JSON and return it only if it is a dict, else ``None`` (an
|
||||||
unreadable / non-JSON / non-object file is skipped by every reader here)."""
|
unreadable / non-JSON / non-object file is skipped by every reader here)."""
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,9 @@ from __future__ import annotations
|
||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
from portfolio_optimiser.ir import AffectedItem, SavingsProposal
|
from portfolio_optimiser.ir import AffectedItem, SavingsProposal
|
||||||
from portfolio_optimiser.outbox import write_outbox
|
from portfolio_optimiser.outbox import write_outbox
|
||||||
from portfolio_optimiser.provenance import Citation, ProvenanceStamp
|
from portfolio_optimiser.provenance import Citation, ProvenanceStamp
|
||||||
|
|
@ -138,3 +141,75 @@ def test_hitl_registered_maf_free() -> None:
|
||||||
from tests.test_okf import _MAF_FREE_MODULES
|
from tests.test_okf import _MAF_FREE_MODULES
|
||||||
|
|
||||||
assert "hitl.py" in _MAF_FREE_MODULES
|
assert "hitl.py" in _MAF_FREE_MODULES
|
||||||
|
|
||||||
|
|
||||||
|
# --- load_routing_config() fail-fast ---------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _write_config(tmp_path: Path, data: dict) -> Path:
|
||||||
|
p = tmp_path / "routing-config.json"
|
||||||
|
p.write_text(json.dumps(data), encoding="utf-8")
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
_WELLFORMED_CONFIG = {
|
||||||
|
"_note": "ignored underscore key",
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"id": "energi",
|
||||||
|
"allowed_measure_types": ["scope_reduction"],
|
||||||
|
"allowed_code_prefixes": ["05"],
|
||||||
|
"expert": "Ola Energi",
|
||||||
|
},
|
||||||
|
{"id": "vei", "allowed_code_prefixes": ["03"], "expert": "Kari Vei"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_routing_config_loads_wellformed(tmp_path: Path) -> None:
|
||||||
|
"""A well-formed config loads: entries + experts present, ``_``-prefixed top-level keys stripped."""
|
||||||
|
config = hitl.load_routing_config(_write_config(tmp_path, _WELLFORMED_CONFIG))
|
||||||
|
assert [e.id for e in config.entries] == ["energi", "vei"]
|
||||||
|
assert {e.expert for e in config.entries} == {"Ola Energi", "Kari Vei"}
|
||||||
|
assert config.entries[1].allowed_measure_types == frozenset() # empty default = "any measure"
|
||||||
|
|
||||||
|
|
||||||
|
def test_routing_config_missing_expert_raises(tmp_path: Path) -> None:
|
||||||
|
"""An entry missing the required ``expert`` field fails validation."""
|
||||||
|
bad = {"entries": [{"id": "energi", "allowed_code_prefixes": ["05"]}]}
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
hitl.load_routing_config(_write_config(tmp_path, bad))
|
||||||
|
|
||||||
|
|
||||||
|
def test_routing_config_duplicate_id_raises(tmp_path: Path) -> None:
|
||||||
|
"""Two entries with the same ``id`` raise (ValueError via the after-validator)."""
|
||||||
|
dup = {
|
||||||
|
"entries": [
|
||||||
|
{"id": "energi", "expert": "A"},
|
||||||
|
{"id": "energi", "expert": "B"},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
hitl.load_routing_config(_write_config(tmp_path, dup))
|
||||||
|
|
||||||
|
|
||||||
|
def test_routing_config_wrong_types_raise(tmp_path: Path) -> None:
|
||||||
|
"""A non-list ``entries`` (wrong shape) raises."""
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
hitl.load_routing_config(_write_config(tmp_path, {"entries": {"id": "x", "expert": "y"}}))
|
||||||
|
|
||||||
|
|
||||||
|
def test_routing_config_missing_file_raises(tmp_path: Path) -> None:
|
||||||
|
"""A missing config file fails fast with ``FileNotFoundError`` (mirrors ``load_pricing``)."""
|
||||||
|
with pytest.raises(FileNotFoundError):
|
||||||
|
hitl.load_routing_config(tmp_path / "no-such-routing-config.json")
|
||||||
|
|
||||||
|
|
||||||
|
def test_routing_config_valid_variant_loads(tmp_path: Path) -> None:
|
||||||
|
"""Control: a minimal valid config (single entry, no code/measure constraints) loads — proving
|
||||||
|
the raises above fire only on the bad case, not always."""
|
||||||
|
config = hitl.load_routing_config(
|
||||||
|
_write_config(tmp_path, {"entries": [{"id": "generalist", "expert": "Per"}]})
|
||||||
|
)
|
||||||
|
assert config.entries[0].expert == "Per"
|
||||||
|
assert config.entries[0].allowed_code_prefixes == frozenset()
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue