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:
Kjell Tore Guttormsen 2026-07-15 19:38:00 +02:00
commit 62d6b40eae
3 changed files with 162 additions and 0 deletions

View file

@ -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)."""