Files
hack-house/tests/test_sor_analysis.py
T
leetcrypt 92da24dfe2 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>
2026-07-19 16:47:56 -07:00

113 lines
4.6 KiB
Python

"""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)