R7 (partial): offline detector calibration — entropy + bridge-correlation AUC
Land only the offline-validatable half of R7 (instrument-validation gate items 3 and 4): the detectors the gate calibrates on synthetic fixtures, never on confirmatory-cell data. Pure stdlib functions over in-memory series — no pcaps, no engine, no traffic, no VM fabric. - analysis/detectors.py: shannon_entropy_bits (RQ2 anonymity-set entropy), pearson/score_matrix/auc/linkage_auc, bridge_correlation_auc (RQ1 linkability scorer), and synthetic_bridge_fixture (seed-deterministic known-linked / known-unlinked ground truth via the R1 SorRng). Calibration green: entropy returns exactly log2(N) for N equiprobable senders (gate item 4); a known-linked control pair scores AUC=1.0 and the unlinked estimator is unbiased at chance (ensemble mean over 40 seeds = 0.498 ~ 0.5, gate item 3). Detectors are calibrated on synthetic fixtures only — no fitting. The traffic-moving R7 pieces (churn.py VM spin/kill, live selector rebuild loop, metrics.json emission) are HELD for R4/R6 + a live grid and are absent here. Python SOR suite 66 passed. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user