feat(correlation/attribution): cross-primitive multi-actor detection (Phase 5)

Add tick_multi_actor() — periodic walk of attribution_state firing
attribution.profile.multi_actor_suspected when an identity carries
>= MULTI_ACTOR_MIN_PRIMITIVES rows in multi_actor state.

* Repo's list_multi_actor_identities() already filters to >= 2
  primitives; the correlator just dispatches.
* In-memory dedup keyed on identity_uuid -> frozenset(primitives):
  same set as last fire -> no re-emit. Set grows -> re-emit.
  Set shrinks below threshold -> evict so a future re-flap re-fires.
  Restart-resets are honest because attribution_state persists; a
  v1 multi_actor_suspect_log table can replace this if needed.
* run_attribution_loop() now supervises three concurrent tasks:
  observation handler, multi_actor tick loop, health/control. Tick
  interval comes from _thresholds.MULTI_ACTOR_TICK_SECS (60s) with
  test override.

Tests: 6 scenarios — single-primitive doesn't fire, two-primitive
co-flag fires, dedup blocks unchanged set, set growth re-fires,
threshold drop re-arms, multiple identities fire independently.
This commit is contained in:
2026-05-09 02:18:42 -04:00
parent dd265d7520
commit e2c7e16793
2 changed files with 340 additions and 3 deletions

View File

@@ -0,0 +1,224 @@
"""Phase 5 — cross-primitive multi_actor correlator.
Periodic tick over attribution_state rows; fires
``attribution.profile.multi_actor_suspected`` when ≥ 2 primitives flag
the same identity. Dedup keeps it from spamming on every tick.
"""
from __future__ import annotations
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
import pytest
from decnet.bus.fake import FakeBus
from decnet.correlation import attribution_worker as _aw
from decnet.web.db.factory import get_repository
@pytest.fixture
async def repo(tmp_path: Path):
r = get_repository(db_path=str(tmp_path / "ma.db"))
await r.initialize()
return r
async def _seed_identity(repo, ip: str = "10.0.0.42") -> str:
now = datetime.now(timezone.utc)
auid = await repo.upsert_attacker({
"ip": ip, "first_seen": now, "last_seen": now,
})
iuid = await repo.ensure_stub_identity_for_attacker(auid)
assert iuid is not None
return iuid
async def _set_state(
repo, identity_uuid: str, primitive: str, state: str,
) -> None:
await repo.upsert_attribution_state({
"identity_uuid": identity_uuid,
"primitive": primitive,
"current_value": "x",
"state": state,
"confidence": 0.55,
"observation_count": 10,
"last_change_ts": 1714000000.0,
"last_observation_ts": 1714000000.0,
})
@pytest.mark.anyio
async def test_no_event_for_single_primitive_multi_actor(
repo, monkeypatch: pytest.MonkeyPatch,
) -> None:
"""One primitive flagged multi_actor on its own is too noisy
(flapping primitive, flaky network). The correlator must not
fire."""
bus = FakeBus(); await bus.connect()
captured: list[dict[str, Any]] = []
async def cap(_b, t, p, *, event_type=""):
captured.append({"topic": t, "payload": p})
monkeypatch.setattr(_aw, "publish_safely", cap)
iuid = await _seed_identity(repo)
await _set_state(repo, iuid, "motor.input_modality", "multi_actor")
fired = await _aw.tick_multi_actor(bus, repo, {})
assert fired == 0
assert captured == []
await bus.close()
@pytest.mark.anyio
async def test_event_fires_when_two_primitives_co_flag(
repo, monkeypatch: pytest.MonkeyPatch,
) -> None:
bus = FakeBus(); await bus.connect()
captured: list[dict[str, Any]] = []
async def cap(_b, t, p, *, event_type=""):
captured.append({"topic": t, "payload": p})
monkeypatch.setattr(_aw, "publish_safely", cap)
iuid = await _seed_identity(repo)
await _set_state(repo, iuid, "motor.input_modality", "multi_actor")
await _set_state(repo, iuid, "cognitive.feedback_loop_engagement", "multi_actor")
fired = await _aw.tick_multi_actor(bus, repo, {})
assert fired == 1
assert len(captured) == 1
payload = captured[0]["payload"]
assert payload["identity_uuid"] == iuid
assert sorted(payload["primitives"]) == [
"cognitive.feedback_loop_engagement",
"motor.input_modality",
]
assert payload["confidence"] <= 0.6
await bus.close()
@pytest.mark.anyio
async def test_dedup_no_refire_on_unchanged_primitive_set(
repo, monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Same identity, same primitive set across two ticks → fire
once. The correlator must dedup so the SIEM channel doesn't
drown in repeats."""
bus = FakeBus(); await bus.connect()
captured: list[dict[str, Any]] = []
async def cap(_b, t, p, *, event_type=""):
captured.append({"topic": t, "payload": p})
monkeypatch.setattr(_aw, "publish_safely", cap)
iuid = await _seed_identity(repo)
await _set_state(repo, iuid, "motor.input_modality", "multi_actor")
await _set_state(repo, iuid, "cognitive.feedback_loop_engagement", "multi_actor")
last_fired: dict[str, frozenset[str]] = {}
fired1 = await _aw.tick_multi_actor(bus, repo, last_fired)
fired2 = await _aw.tick_multi_actor(bus, repo, last_fired)
fired3 = await _aw.tick_multi_actor(bus, repo, last_fired)
assert fired1 == 1
assert fired2 == 0
assert fired3 == 0
assert len(captured) == 1
await bus.close()
@pytest.mark.anyio
async def test_refires_when_primitive_set_grows(
repo, monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A third primitive joining the multi_actor set is new
information — re-emit so subscribers see the expanded
evidence."""
bus = FakeBus(); await bus.connect()
captured: list[dict[str, Any]] = []
async def cap(_b, t, p, *, event_type=""):
captured.append({"topic": t, "payload": p})
monkeypatch.setattr(_aw, "publish_safely", cap)
iuid = await _seed_identity(repo)
await _set_state(repo, iuid, "motor.input_modality", "multi_actor")
await _set_state(repo, iuid, "cognitive.feedback_loop_engagement", "multi_actor")
last_fired: dict[str, frozenset[str]] = {}
await _aw.tick_multi_actor(bus, repo, last_fired)
assert len(captured) == 1
# Add a third primitive.
await _set_state(repo, iuid, "temporal.weekend_cadence", "multi_actor")
await _aw.tick_multi_actor(bus, repo, last_fired)
assert len(captured) == 2
# Latest payload carries all three.
assert len(captured[1]["payload"]["primitives"]) == 3
await bus.close()
@pytest.mark.anyio
async def test_rearms_when_primitives_drop_below_threshold(
repo, monkeypatch: pytest.MonkeyPatch,
) -> None:
"""If an identity's multi_actor count falls below 2, the
correlator should evict it from the dedup map so a future
re-flap re-fires."""
bus = FakeBus(); await bus.connect()
captured: list[dict[str, Any]] = []
async def cap(_b, t, p, *, event_type=""):
captured.append({"topic": t, "payload": p})
monkeypatch.setattr(_aw, "publish_safely", cap)
iuid = await _seed_identity(repo)
await _set_state(repo, iuid, "motor.input_modality", "multi_actor")
await _set_state(repo, iuid, "cognitive.feedback_loop_engagement", "multi_actor")
last_fired: dict[str, frozenset[str]] = {}
await _aw.tick_multi_actor(bus, repo, last_fired)
assert len(captured) == 1
assert iuid in last_fired
# One primitive recovers to stable; identity drops below threshold.
await _set_state(repo, iuid, "motor.input_modality", "stable")
await _aw.tick_multi_actor(bus, repo, last_fired)
assert iuid not in last_fired
# Re-flap: same primitives flag again. Dedup should NOT block.
await _set_state(repo, iuid, "motor.input_modality", "multi_actor")
await _aw.tick_multi_actor(bus, repo, last_fired)
assert len(captured) == 2
await bus.close()
@pytest.mark.anyio
async def test_independent_dedup_per_identity(
repo, monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Two identities, both co-flagged → both fire on the same tick."""
bus = FakeBus(); await bus.connect()
captured: list[dict[str, Any]] = []
async def cap(_b, t, p, *, event_type=""):
captured.append({"topic": t, "payload": p})
monkeypatch.setattr(_aw, "publish_safely", cap)
iuid_a = await _seed_identity(repo, ip="10.0.0.1")
iuid_b = await _seed_identity(repo, ip="10.0.0.2")
for iuid in (iuid_a, iuid_b):
await _set_state(repo, iuid, "motor.input_modality", "multi_actor")
await _set_state(
repo, iuid, "cognitive.feedback_loop_engagement", "multi_actor",
)
fired = await _aw.tick_multi_actor(bus, repo, {})
assert fired == 2
seen = {c["payload"]["identity_uuid"] for c in captured}
assert seen == {iuid_a, iuid_b}
await bus.close()