test(clustering): fixture 4 paused_campaign + active_days/time_window

Adds the actor.active_days primitive to the campaign factory so a
DSL actor can be bound to specific day indexes. Falls back to the
non-paused day pool when absent (existing fixtures unchanged).
Intersects with pause_windows so the campaign-wide silence still
wins if both are set.

Adds time_window_clusterer reference to fixture_harness — union-find
over attackers, edge if their session time-ranges are within
gap_days of each other. Deliberately-bad reference for fixture 4:
multi-day silent stretches fragment a single campaign because the
clusterer has no signal that bridges the gap.

Fixture 4 (paused_campaign): one campaign modeled as two DSL actors
representing the operator's two operational windows (active days
1-2 and 6-7), separated by a silent stretch (days 3-5). Both share
JA3 + HASSH + payload + C2 callback; only their active_days differ.

Five tests: corpus shape (rows in their windows, shared signals),
pipeline pass via fingerprint_clusterer at level=campaign,
adversarial fragmentation via time_window_clusterer (1-day union
threshold cannot bridge the 4-day silence → completeness collapses),
huge-gap sanity (gap_days=10 unions both halves), silent-stretch
invariant (no session leaks into the configured pause window).

Identity-level scoring is fixture 2's job; this fixture is
campaign-level only — modeling caveat documented in the YAML.
This commit is contained in:
2026-04-26 07:39:46 -04:00
parent 0def6f7e37
commit 304592abfe
5 changed files with 334 additions and 11 deletions

View File

@@ -331,21 +331,30 @@ def _emit_campaign(
decky_choices = decky_pool
# Schedule sessions across the campaign window, respecting the
# actor's hours_active_utc and pause_windows.
# actor's hours_active_utc, pause_windows, and (if specified)
# the actor's active_days. ``active_days`` (per-actor list of
# day indexes) lets a fixture bind an actor to specific days
# without affecting siblings — used by fixture 4 to model an
# operator who pauses operations between sprints.
active_hours = actor_spec.get("hours_active_utc", list(range(24)))
jitter = int(actor_spec.get("jitter_seconds", 60))
non_paused = [
d for d in range(duration_days)
if not any(s <= d <= e for s, e in pause_windows)
]
actor_active_days = actor_spec.get("active_days")
if actor_active_days is not None:
# Intersect with non-paused so pause_windows still wins
# globally if the fixture sets both (defensive).
day_pool = [d for d in actor_active_days if d in non_paused]
else:
day_pool = non_paused
for s_idx in range(n_sessions):
day = rng.randint(0, max(0, duration_days - 1))
if any(start <= day <= end for start, end in pause_windows):
# Skip into post-pause day.
later_days = [
d for d in range(duration_days)
if not any(s <= d <= e for s, e in pause_windows)
]
if not later_days:
continue
day = rng.choice(later_days)
if not day_pool:
continue
day = rng.choice(day_pool)
hour = rng.choice(active_hours)
day_start = epoch + timedelta(days=day)
started_at = _hour_to_offset(rng, day_start, hour, jitter)