Files
DECNET/decnet/ttp/impl/_rule_index.py
anti e7531ee756 refactor(ttp): extract RuleIndex from RuleEngine
E.3.9.0 prerequisite for the per-source lifters (E.3.9-E.3.13). The
dispatch index, install/evict/apply_change atomic-swap protocol, and
state-modulation helpers (is_active / apply_ceiling) move out of
rule_engine.py into _rule_index.py and _state.py. RuleEngine wraps a
RuleIndex; back-compat shims preserve _by_kind / _by_rule / _install
attribute access for tests poking at the dispatch internals.

Lifters in E.3.9-E.3.12 will each hold their own RuleIndex, watching
the same RuleStore via subscribe_changes() fan-out. Hot-reload
semantics (disable / clip / TTL via set_state API) now reach
lifter-bound rules through the same atomic-swap path the engine uses,
not a future composite-rebuild compromise.
2026-05-01 20:09:18 -04:00

181 lines
7.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Hot-swappable rule registry shared by RuleEngine and per-source lifters.
The dispatch index originally lived inline on
:class:`~decnet.ttp.impl.rule_engine.RuleEngine`. E.3.9 adds four
per-source lifters that need the same install / evict / state-restamp
atomic-swap protocol; pulling it into one helper keeps the contract
single-sourced.
Atomicity invariant (TTP_TAGGING.md §"Atomic swap" / E.2.14b): a rule
sitting in the index must never be torn mid-evaluate. Mutations
replace dict entries with fresh lists / fresh
:class:`~decnet.ttp.impl.rule_engine.CompiledRule` tuples — never
in-place edits. Single dict assignments are GIL-atomic to readers.
"""
from __future__ import annotations
from collections.abc import Callable, Iterable
from typing import TYPE_CHECKING
from decnet.logging import get_logger
if TYPE_CHECKING:
from decnet.ttp.impl.rule_engine import CompiledRule
from decnet.ttp.store.base import RuleChange, RuleStore
_log = get_logger("ttp.index")
class RuleIndex:
"""Owns ``rule_id -> CompiledRule`` plus a ``source_kind -> [rules]`` index.
Consumers:
* :class:`RuleEngine` — uses :meth:`by_kind` to dispatch evaluate().
* Per-source lifters (E.3.9E.3.13) — use :meth:`get` and
:meth:`values` to consume rules they own (filtered via the
``predicate`` passed to :meth:`watch`).
"""
def __init__(self) -> None:
# source_kind -> list of compiled rules that claim it.
self._by_kind: dict[str, list["CompiledRule"]] = {}
# rule_id -> compiled rule (mirror; used for state restamp).
self._by_rule: dict[str, "CompiledRule"] = {}
# ── Read API ────────────────────────────────────────────────────
def by_kind(self, source_kind: str) -> list["CompiledRule"]:
return self._by_kind.get(source_kind, [])
def get(self, rule_id: str) -> "CompiledRule | None":
return self._by_rule.get(rule_id)
def values(self) -> Iterable["CompiledRule"]:
return self._by_rule.values()
# ── Mutation API (atomic-swap) ──────────────────────────────────
def install(self, rule: "CompiledRule") -> None:
"""Atomic-swap install of one compiled rule.
Empty ``applies_to`` AND empty ``emits`` is the deletion sentinel
used by both store backends — drop the rule from the index
instead of registering a no-op entry.
"""
if not rule.applies_to and not rule.emits:
self.evict(rule.rule_id)
return
self._by_rule[rule.rule_id] = rule
for kind in rule.applies_to:
current = self._by_kind.get(kind, [])
replaced = [r for r in current if r.rule_id != rule.rule_id]
replaced.append(rule)
# Single dict assignment — GIL-atomic to readers.
self._by_kind[kind] = replaced
def evict(self, rule_id: str) -> None:
existing = self._by_rule.pop(rule_id, None)
if existing is None:
return
for kind in existing.applies_to:
current = self._by_kind.get(kind, [])
replaced = [r for r in current if r.rule_id != rule_id]
self._by_kind[kind] = replaced
def apply_change(
self, change: "RuleChange", state_cls: type
) -> None:
"""Apply one :class:`RuleChange` to the index.
``state_cls`` is :class:`RuleState`; passed in to avoid a
runtime-circular import — the store package imports from this
one transitively.
"""
from decnet.ttp.impl.rule_engine import CompiledRule # noqa: PLC0415
if change.change_kind == "definition":
value = change.new_value
if isinstance(value, CompiledRule):
self.install(value)
return
# state change
existing = self._by_rule.get(change.rule_id)
if existing is None or not isinstance(change.new_value, state_cls):
return
new_state = change.new_value
# NamedTuple._replace returns a fresh frozen tuple — single
# dict assignment swaps it in atomically.
restamped = existing._replace(state=new_state) # type: ignore[arg-type]
self._by_rule[change.rule_id] = restamped
for kind in restamped.applies_to:
current = self._by_kind.get(kind, [])
replaced = [r for r in current if r.rule_id != change.rule_id]
replaced.append(restamped)
self._by_kind[kind] = replaced
# ── Lifecycle ───────────────────────────────────────────────────
async def hydrate_from(
self,
store: "RuleStore",
predicate: Callable[["CompiledRule"], bool] | None = None,
) -> None:
"""Load every compiled rule from *store* and install matching ones.
``predicate`` filters; engine omits it (installs everything),
lifters pass a ``match.kind`` prefix check.
"""
compiled = await store.load_compiled()
for rule in compiled:
if predicate is not None and not predicate(rule):
continue
self.install(rule)
async def watch(
self,
store: "RuleStore",
predicate: Callable[["CompiledRule"], bool] | None = None,
) -> None:
"""Hydrate once + drain ``subscribe_changes`` forever.
Cancellation-safe: an :class:`asyncio.CancelledError` from the
outer task propagates cleanly. Per-change application errors
log and continue — one bad rule edit must not stall the stream.
"""
from decnet.ttp.store.base import RuleState # noqa: PLC0415
await self.hydrate_from(store, predicate=predicate)
async for change in store.subscribe_changes():
if predicate is not None:
# For state changes the value is a RuleState (no
# match_spec to inspect); always apply when the rule
# is already in the index, otherwise skip.
if change.change_kind == "state":
if change.rule_id not in self._by_rule:
continue
else:
value = change.new_value
# Definition changes carry a CompiledRule; skip
# ones the predicate doesn't claim. A previously-
# owned rule whose YAML moved out of our ownership
# gets evicted explicitly.
from decnet.ttp.impl.rule_engine import ( # noqa: PLC0415
CompiledRule,
)
if isinstance(value, CompiledRule) and not predicate(value):
if change.rule_id in self._by_rule:
self.evict(change.rule_id)
continue
try:
self.apply_change(change, RuleState)
except Exception: # noqa: BLE001
_log.exception(
"ttp.index: rule change apply failed rule_id=%s",
change.rule_id,
)
__all__ = ["RuleIndex"]