feat(s51): route pending proposals to experts by code-prefix
Gate: pytest tests/test_hitl.py tests/test_hitl_loadbearing.py -k route → 5 passed.
This commit is contained in:
parent
e9179271e6
commit
62d6b40eae
3 changed files with 162 additions and 0 deletions
|
|
@ -181,6 +181,56 @@ def load_routing_config(path: str | Path) -> RoutingConfig:
|
|||
return RoutingConfig(**data)
|
||||
|
||||
|
||||
# --- Routing: classify each pending proposal to an expert (Step 3) --------------------------------
|
||||
|
||||
|
||||
def _matches(entry: RoutingEntry, *, measure: str, codes: frozenset[str]) -> bool:
|
||||
"""Mirror ``dimension.admits`` BUT with an OPTIONAL measure gate: an empty
|
||||
``allowed_measure_types`` means "any measure" (route by code prefix alone). ``measure`` is
|
||||
open-vocabulary prose (verified non-discriminating — plan Risk #1), so code prefixes are the
|
||||
reliable domain key; the measure gate is a strict opt-in filter for deployments that set one."""
|
||||
if entry.allowed_measure_types and measure not in entry.allowed_measure_types:
|
||||
return False
|
||||
if not entry.allowed_code_prefixes:
|
||||
return True
|
||||
return any(code.startswith(prefix) for code in codes for prefix in entry.allowed_code_prefixes)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RoutedProposal:
|
||||
"""A pending proposal classified to an expert. ``expert``/``dimension_id`` are ``None`` when no
|
||||
entry admits it (unroutable, still emitted); ``ambiguous`` flags a >1-entry match resolved by the
|
||||
sorted-first ``entry.id`` tie-break."""
|
||||
|
||||
pending: PendingProposal
|
||||
expert: str | None
|
||||
dimension_id: str | None
|
||||
ambiguous: bool
|
||||
|
||||
|
||||
def route(outbox_dir: str, verdict_dir: str, config: RoutingConfig) -> list[RoutedProposal]:
|
||||
"""Classify each pending proposal to the expert who owns its dimension. 0 matching entries →
|
||||
unroutable (emitted with ``expert=None``); 1 → that entry; >1 → the sorted-first ``entry.id``
|
||||
(deterministic) flagged ``ambiguous``. Order follows ``pending`` (sorted, deterministic)."""
|
||||
routed: list[RoutedProposal] = []
|
||||
for proposal in pending(outbox_dir, verdict_dir):
|
||||
matches = [
|
||||
entry
|
||||
for entry in config.entries
|
||||
if _matches(entry, measure=proposal.measure, codes=proposal.codes)
|
||||
]
|
||||
if not matches:
|
||||
routed.append(RoutedProposal(proposal, expert=None, dimension_id=None, ambiguous=False))
|
||||
continue
|
||||
winner = min(matches, key=lambda entry: entry.id)
|
||||
routed.append(
|
||||
RoutedProposal(
|
||||
proposal, expert=winner.expert, dimension_id=winner.id, ambiguous=len(matches) > 1
|
||||
)
|
||||
)
|
||||
return routed
|
||||
|
||||
|
||||
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
|
||||
unreadable / non-JSON / non-object file is skipped by every reader here)."""
|
||||
|
|
|
|||
|
|
@ -213,3 +213,89 @@ def test_routing_config_valid_variant_loads(tmp_path: Path) -> None:
|
|||
)
|
||||
assert config.entries[0].expert == "Per"
|
||||
assert config.entries[0].allowed_code_prefixes == frozenset()
|
||||
|
||||
|
||||
# --- route() classification -----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_route_by_code_prefix_with_measure_optional(tmp_path: Path) -> None:
|
||||
"""A proposal with a REAL prose measure routes by code prefix when the entry sets no measure gate
|
||||
(``allowed_measure_types`` empty = "any") — proving measure-optional works on real prose, not a
|
||||
fixture-tuned token."""
|
||||
outbox = tmp_path / "outbox"
|
||||
inbox = tmp_path / "inbox"
|
||||
_write_proposal(
|
||||
outbox, "run-1", verdict_id="v1", measure="LED-retrofit av kontorbelysning", codes=["05.2"]
|
||||
)
|
||||
config = hitl.RoutingConfig(
|
||||
entries=[
|
||||
hitl.RoutingEntry(id="energi", allowed_code_prefixes=frozenset({"05"}), expert="Ola")
|
||||
]
|
||||
)
|
||||
routed = hitl.route(str(outbox), str(inbox), config)
|
||||
assert len(routed) == 1
|
||||
r = routed[0]
|
||||
assert r.expert == "Ola"
|
||||
assert r.dimension_id == "energi"
|
||||
assert r.ambiguous is False
|
||||
assert r.pending.measure == "LED-retrofit av kontorbelysning"
|
||||
|
||||
|
||||
def test_route_unroutable_when_no_entry_matches(tmp_path: Path) -> None:
|
||||
"""A proposal whose codes match no entry is emitted UNROUTABLE (expert/dimension None), never
|
||||
silently dropped."""
|
||||
outbox = tmp_path / "outbox"
|
||||
inbox = tmp_path / "inbox"
|
||||
_write_proposal(outbox, "run-1", verdict_id="v1", codes=["99.9"])
|
||||
config = hitl.RoutingConfig(
|
||||
entries=[
|
||||
hitl.RoutingEntry(id="energi", allowed_code_prefixes=frozenset({"05"}), expert="Ola")
|
||||
]
|
||||
)
|
||||
routed = hitl.route(str(outbox), str(inbox), config)
|
||||
assert len(routed) == 1
|
||||
assert routed[0].expert is None
|
||||
assert routed[0].dimension_id is None
|
||||
assert routed[0].ambiguous is False
|
||||
|
||||
|
||||
def test_route_ambiguous_uses_sorted_first_tie_break(tmp_path: Path) -> None:
|
||||
"""A proposal admitted by two entries routes to the sorted-first ``entry.id`` (deterministic) and
|
||||
is flagged ``ambiguous``."""
|
||||
outbox = tmp_path / "outbox"
|
||||
inbox = tmp_path / "inbox"
|
||||
_write_proposal(outbox, "run-1", verdict_id="v1", codes=["05.2"])
|
||||
config = hitl.RoutingConfig(
|
||||
entries=[
|
||||
hitl.RoutingEntry(id="b-energi", allowed_code_prefixes=frozenset({"05"}), expert="Beta"),
|
||||
hitl.RoutingEntry(id="a-energi", allowed_code_prefixes=frozenset({"05"}), expert="Alpha"),
|
||||
]
|
||||
)
|
||||
r = hitl.route(str(outbox), str(inbox), config)[0]
|
||||
assert r.ambiguous is True
|
||||
assert r.dimension_id == "a-energi"
|
||||
assert r.expert == "Alpha"
|
||||
|
||||
|
||||
def test_route_measure_constrained_entry_filters(tmp_path: Path) -> None:
|
||||
"""A measure-constrained entry filters out a proposal whose prose measure is not in
|
||||
``allowed_measure_types`` (unroutable); the same entry routes a matching measure — proving the
|
||||
gate fires only on the mismatch."""
|
||||
inbox = tmp_path / "inbox"
|
||||
config = hitl.RoutingConfig(
|
||||
entries=[
|
||||
hitl.RoutingEntry(
|
||||
id="energi",
|
||||
allowed_measure_types=frozenset({"scope_reduction"}),
|
||||
allowed_code_prefixes=frozenset({"05"}),
|
||||
expert="Ola",
|
||||
)
|
||||
]
|
||||
)
|
||||
miss = tmp_path / "miss"
|
||||
_write_proposal(miss, "run-1", verdict_id="v1", measure="rate_renegotiation", codes=["05.2"])
|
||||
assert hitl.route(str(miss), str(inbox), config)[0].expert is None
|
||||
|
||||
hit = tmp_path / "hit"
|
||||
_write_proposal(hit, "run-1", verdict_id="v1", measure="scope_reduction", codes=["05.2"])
|
||||
assert hitl.route(str(hit), str(inbox), config)[0].expert == "Ola"
|
||||
|
|
|
|||
|
|
@ -141,6 +141,32 @@ def test_inbox_idset_skips_wrong_decision(tmp_path: Path) -> None:
|
|||
assert [p.run_id for p in hitl.pending(str(outbox), str(inbox))] == ["run-1"]
|
||||
|
||||
|
||||
# --- route classification (Step 3) ----------------------------------------------------------------
|
||||
|
||||
|
||||
def test_route_classification_is_load_bearing(tmp_path: Path) -> None:
|
||||
"""LOAD-BEARING: ``_matches`` genuinely classifies. Detach it (route everything to ``entries[0]``)
|
||||
→ both assertions flip RED: an unmatched proposal would stop being UNROUTABLE, and a two-entry
|
||||
match would tie-break to ``entries[0]`` instead of the sorted-first id. ``entries[0]`` is
|
||||
deliberately NOT the sorted-first id, so the tie-break assertion is sensitive to the detach."""
|
||||
outbox = tmp_path / "outbox"
|
||||
inbox = tmp_path / "inbox"
|
||||
config = hitl.RoutingConfig(
|
||||
entries=[
|
||||
hitl.RoutingEntry(id="z-vei", allowed_code_prefixes=frozenset({"07"}), expert="Zeta"),
|
||||
hitl.RoutingEntry(id="a-energi", allowed_code_prefixes=frozenset({"05"}), expert="Alpha"),
|
||||
]
|
||||
)
|
||||
_write_proposal(outbox, "run-A", verdict_id="vA", codes=["99.9"]) # matches nothing
|
||||
_write_proposal(outbox, "run-C", verdict_id="vC", codes=["05.1", "07.1"]) # matches BOTH
|
||||
|
||||
routed = {r.pending.run_id: r for r in hitl.route(str(outbox), str(inbox), config)}
|
||||
assert routed["run-A"].expert is None # detach → routed to Zeta → RED
|
||||
assert routed["run-C"].ambiguous is True
|
||||
assert routed["run-C"].dimension_id == "a-energi" # sorted-first, NOT entries[0] z-vei
|
||||
assert routed["run-C"].expert == "Alpha"
|
||||
|
||||
|
||||
def test_inbox_idset_skips_malformed_features(tmp_path: Path) -> None:
|
||||
"""An inbox verdict with all four top-level keys but ``proposal_features`` MISSING ``measure_type``
|
||||
(which ``verdict_from_dict`` reads → would raise) is skipped, so it does NOT clear pending —
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue