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:
224
tests/correlation/attribution/test_multi_actor_correlator.py
Normal file
224
tests/correlation/attribution/test_multi_actor_correlator.py
Normal 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()
|
||||
Reference in New Issue
Block a user