"""StrategyRegistry — registration + DAG cycle detection. Mutated at startup via :meth:`register` / :meth:`validate`, and at runtime via :meth:`replace` (config hot-reload). Cycle detection is a Kahn-style topological pass on the event-flow DAG implied by ``trigger.on`` (incoming) and ``emits`` (outgoing). """ from __future__ import annotations from collections import defaultdict, deque from collections.abc import Callable from typing import Any from everos.infra.ome.decorator import StrategyMeta from everos.infra.ome.events import BaseEvent, CronTick, IdleTick from everos.infra.ome.exceptions import StartupValidationError from everos.infra.ome.triggers import Cron, Idle, Immediate, Trigger class StrategyRegistry: """Startup-time registry for offline strategies with cycle detection.""" def __init__(self) -> None: self._strategies: dict[str, StrategyMeta] = {} def register(self, func: Callable[..., Any]) -> None: """Register a strategy function (reads ``_ome_strategy_meta``). Raises ``StartupValidationError`` if ``func`` is not decorated with ``@offline_strategy`` or if its name is already registered. """ meta = getattr(func, "_ome_strategy_meta", None) if not isinstance(meta, StrategyMeta): fn_name = getattr(func, "__name__", repr(func)) raise StartupValidationError( f"register: {fn_name} is not decorated with @offline_strategy" ) if meta.name in self._strategies: raise StartupValidationError( f"register: duplicate strategy name {meta.name!r}" ) self._strategies[meta.name] = meta def replace(self, name: str, new_meta: StrategyMeta) -> None: """Swap an already-registered strategy's meta in place (hot-reload entry). Cycle / gate validation is **not** re-run; callers (currently :func:`apply_overrides`) must only feed metas where the DAG-shaping fields (``trigger.on``, ``emits``, trigger type) match the original. Raises ``KeyError`` if ``name`` is not yet registered. """ if name not in self._strategies: raise KeyError(name) self._strategies[name] = new_meta def get(self, name: str) -> StrategyMeta: """Return meta by name (raises ``KeyError`` if absent).""" return self._strategies[name] def all(self) -> list[StrategyMeta]: """Return a snapshot list of every registered strategy.""" return list(self._strategies.values()) def lookup_by_event(self, event_cls: type[BaseEvent]) -> list[StrategyMeta]: """Return strategies that may receive an event of ``event_cls``. Resolution: * ``Immediate`` strategy listening on the class → match * ``CronTick`` → all Cron strategies (narrowed later by name) * ``IdleTick`` → all Idle strategies (narrowed later by name) Engine-emitted ticks carry a ``strategy_name`` field; dispatcher narrows the returned set to the single target via ``_routes_to``. """ out: list[StrategyMeta] = [] for m in self._strategies.values(): if ( (isinstance(m.trigger, Immediate) and event_cls in m.trigger.on) or (isinstance(m.trigger, Cron) and event_cls is CronTick) or (isinstance(m.trigger, Idle) and event_cls is IdleTick) ): out.append(m) return out def validate(self) -> None: """Validate the strategy DAG for cycles and gate field existence.""" self._validate_no_cycles() self._validate_gate_event_fields() def _validate_no_cycles(self) -> None: """Kahn topological sort over the event-flow DAG. Edge ``s_a → s_b`` exists iff ``s_a.emits`` intersects ``s_b.trigger.on``. """ adj: dict[str, set[str]] = defaultdict(set) indeg: dict[str, int] = dict.fromkeys(self._strategies, 0) for src in self._strategies.values(): for ev in src.emits: for dst in self._strategies.values(): if ( isinstance(dst.trigger, Immediate) and ev in dst.trigger.on and dst.name not in adj[src.name] ): adj[src.name].add(dst.name) indeg[dst.name] += 1 queue = deque(n for n, d in indeg.items() if d == 0) visited = 0 while queue: n = queue.popleft() visited += 1 for nbr in adj[n]: indeg[nbr] -= 1 if indeg[nbr] == 0: queue.append(nbr) if visited < len(self._strategies): raise StartupValidationError("cycle detected in strategy DAG") def _validate_gate_event_fields(self) -> None: """Reject any ``gate.event_field`` missing from a receivable event class. Without this check a typo silently collapses every event into one shared bucket and the rate gate stops segmenting. """ for meta in self._strategies.values(): if meta.gate is None or meta.gate.event_field is None: continue field = meta.gate.event_field for ev_cls in _event_classes_for_trigger(meta.trigger): if field not in ev_cls.model_fields: # type: ignore[operator] # Pydantic model_fields → dict via @deprecated_instance_property (pydantic/main.py:277) raise StartupValidationError( f"strategy {meta.name!r}: gate.event_field {field!r} " f"not found in {ev_cls.__name__} fields " f"(available: {list(ev_cls.model_fields)})" # type: ignore[arg-type] # same as above ) def _event_classes_for_trigger(trigger: Trigger) -> list[type[BaseEvent]]: """Enumerate event classes a strategy with the given trigger receives.""" if isinstance(trigger, Immediate): return list(trigger.on) if isinstance(trigger, Cron): return [CronTick] if isinstance(trigger, Idle): return [IdleTick] raise NotImplementedError(f"unknown trigger type: {type(trigger).__name__}")