feat(ttp): E.3.9 BehavioralLifter (R0031-R0040)

Reads pre-shaped session aggregates from TaggerEvent.payload and emits
techniques per Appendix A behavior tables. Per-rule predicates dispatch
on match.kind (lifter:behavioral_<name>); the lifter holds its own
RuleIndex watching the same RuleStore as the engine, so disable / clip /
TTL state reaches lifter-bound rules through the same atomic-swap path.

R0032/R0036/R0037/R0040 YAMLs had over-escaped regex strings (\\
instead of \\) — fixed in place.

Factory wired so default get_tagger() returns CompositeTagger with
BehavioralLifter shipped; remaining three lifters (E.3.10-E.3.12) land
in subsequent commits.

E.2.6 contract preserved via TolerantTagger: empty payload steady-state
yields [] with zero ERROR records. Disabled / clipped / expired state
verified.
This commit is contained in:
2026-05-01 20:17:59 -04:00
parent 321ea7a2a6
commit eff3e4bce7
14 changed files with 759 additions and 52 deletions

View File

@@ -106,13 +106,18 @@ class CompositeTagger(Tagger):
def get_tagger() -> Tagger:
"""Return the configured tagger instance.
Lazy package layout: the composite is constructed with an empty
lifter list during the contract phase. E.1.6 will replace this
with explicit lifter wiring; callers don't change.
Synchronous construction: each shipped lifter takes the shared
:class:`RuleStore` reference, but the per-lifter watch loops are
started by the worker (E.3.14), not by this factory. Tests that
instantiate via this path get an idle composite — exercising the
watch loop is the worker's contract.
"""
name = os.environ.get("DECNET_TTP_TAGGER_TYPE", _DEFAULT).strip().lower()
if name == "composite":
return CompositeTagger(lifters=[])
from decnet.ttp.impl.behavioral_lifter import BehavioralLifter
from decnet.ttp.store.factory import get_rule_store
store = get_rule_store()
return CompositeTagger(lifters=[BehavioralLifter(store)])
raise ValueError(
f"Unknown tagger: {name!r}. Known: {_KNOWN}"
)