"""R7 (partial) — offline detector calibration checks (gate items 3 & 4). Acceptance predicate (roadmap R7, the offline-verifiable half): - entropy metric returns log2(N) for N equiprobable senders (gate item 4); - the bridge-correlation scorer gives AUC ≈ 1 on a known-linked control pair and ≈ 0.5 on a known-unlinked pair (gate item 3). Calibrated on synthetic fixtures only (CLAUDE.md: detectors are never fit to confirmatory-cell data). These detectors read no pcaps, spawn no engine, and move no traffic — fully offline. The traffic-moving R7 pieces (churn VM spin/kill, live selector rebuild, metrics.json emission) are HELD for R4/R6 + a live grid and are intentionally absent. """ import math import statistics import pytest from cmd_chat.sor.analysis.detectors import ( auc, bridge_correlation_auc, pearson, shannon_entropy_bits, synthetic_bridge_fixture, ) # --------------------------------------------------------------------------- # # Gate item 4 — anonymity-set entropy. # --------------------------------------------------------------------------- # @pytest.mark.parametrize("n", [1, 2, 3, 4, 5, 8, 16, 17]) def test_entropy_equiprobable_is_log2n(n): # N equiprobable senders -> exactly log2(N) bits (the gate item 4 predicate). h = shannon_entropy_bits({f"sender{i}": 1 for i in range(n)}) assert h == pytest.approx(math.log2(n), abs=1e-12) def test_entropy_is_maximized_by_the_uniform_distribution(): n = 8 uniform = shannon_entropy_bits([1] * n) skewed = shannon_entropy_bits([10, 1, 1, 1, 1, 1, 1, 1]) assert skewed < uniform == pytest.approx(3.0) def test_entropy_degenerate_cases(): assert shannon_entropy_bits({"only": 5}) == 0.0 # one sender -> 0 bits assert shannon_entropy_bits([]) == 0.0 assert shannon_entropy_bits([0, 0]) == 0.0 def test_entropy_known_skewed_value(): # 3:1 split -> H = 0.75*log2(4/3) + 0.25*log2(4) = 0.811278... assert shannon_entropy_bits([3, 1]) == pytest.approx(0.8112781, abs=1e-6) # --------------------------------------------------------------------------- # # Correlation + AUC primitives. # --------------------------------------------------------------------------- # def test_pearson_identities(): assert pearson([1, 2, 3, 4], [1, 2, 3, 4]) == pytest.approx(1.0) assert pearson([1, 2, 3, 4], [4, 3, 2, 1]) == pytest.approx(-1.0) assert pearson([1, 2, 3], [5, 5, 5]) == 0.0 # zero variance -> no signal assert pearson([], []) == 0.0 assert pearson([1, 2], [1, 2, 3]) == 0.0 # length mismatch def test_auc_basics(): assert auc([3, 4, 5], [0, 1, 2]) == 1.0 # perfectly separated assert auc([0, 1], [2, 3]) == 0.0 # perfectly inverted assert auc([1, 1], [1, 1]) == 0.5 # all ties -> chance assert auc([], [1]) == 0.5 # --------------------------------------------------------------------------- # # Gate item 3 — bridge-correlation AUC calibration. # --------------------------------------------------------------------------- # def test_known_linked_pair_scores_auc_near_one(): # A linked bridge (egress = ingress + small jitter): the diagonal dominates. ingress, egress = synthetic_bridge_fixture(42, linked=True) assert bridge_correlation_auc(ingress, egress) == pytest.approx(1.0) # Robust across seeds — never below near-perfect. for seed in (1, 123456789, 777, 2024): ig, eg = synthetic_bridge_fixture(seed, linked=True) assert bridge_correlation_auc(ig, eg) >= 0.99 def test_known_unlinked_pair_scores_auc_near_half(): # A single unlinked pair is noisy (finite flows); the estimator is *unbiased*, # so the ensemble mean over many seeds sits at chance (0.5). aucs = [ bridge_correlation_auc(*synthetic_bridge_fixture(seed, n_flows=24, linked=False)) for seed in range(40) ] assert statistics.mean(aucs) == pytest.approx(0.5, abs=0.05) # A representative single pair is within a chance band (not linkable). ig, eg = synthetic_bridge_fixture(42, n_flows=24, linked=False) assert 0.35 <= bridge_correlation_auc(ig, eg) <= 0.65 def test_linked_separates_from_unlinked(): linked = bridge_correlation_auc(*synthetic_bridge_fixture(7, n_flows=24, linked=True)) unlinked = bridge_correlation_auc(*synthetic_bridge_fixture(7, n_flows=24, linked=False)) assert linked > unlinked def test_fixture_is_seed_deterministic(): a = synthetic_bridge_fixture(99, linked=True) b = synthetic_bridge_fixture(99, linked=True) assert a == b # Different ground truth from the same seed is actually different data. assert synthetic_bridge_fixture(99, linked=True) != synthetic_bridge_fixture(99, linked=False)