Files
hack-house/cmd_chat/sor/analysis/detectors.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

133 lines
5.2 KiB
Python

"""R7 (partial) — offline detector primitives + seeded synthetic fixtures.
Pure, stdlib-only calibration instruments (gate items 3 and 4). No I/O, no
engine, no pcaps: they operate on in-memory numeric series/distributions so they
can be validated entirely offline against synthetic ground truth. Determinism is
inherited from the R1 ``SorRng`` so a calibration fixture is reproducible from
its seed alone.
"""
from __future__ import annotations
import math
from typing import Dict, List, Sequence, Union
from cmd_chat.sor.config import Domain, SorRng
Number = Union[int, float]
Series = Sequence[Number]
# --------------------------------------------------------------------------- #
# RQ2 — anonymity-set entropy (gate item 4).
# --------------------------------------------------------------------------- #
def shannon_entropy_bits(weights: Union[Dict[object, Number], Sequence[Number]]) -> float:
"""Shannon entropy in **bits** of an observed sender distribution.
``weights`` is either a mapping ``sender -> count`` or a sequence of
non-negative weights. For ``N`` equiprobable senders this returns exactly
``log2(N)`` (the maximum for an N-set), which is the gate item 4 predicate.
An empty or all-zero distribution has entropy 0."""
vals = list(weights.values()) if isinstance(weights, dict) else list(weights)
total = float(sum(vals))
if total <= 0:
return 0.0
h = 0.0
for c in vals:
if c > 0:
p = c / total
h -= p * math.log2(p)
return h
# --------------------------------------------------------------------------- #
# RQ1 — bridge-linkability correlation scorer (gate item 3).
# --------------------------------------------------------------------------- #
def pearson(xs: Series, ys: Series) -> float:
"""Pearson correlation coefficient of two equal-length numeric series.
Returns 0.0 for empty, mismatched-length, or zero-variance inputs (a flat
series carries no linkage signal)."""
n = len(xs)
if n == 0 or n != len(ys):
return 0.0
mx = sum(xs) / n
my = sum(ys) / n
num = sum((x - mx) * (y - my) for x, y in zip(xs, ys))
dx = math.sqrt(sum((x - mx) ** 2 for x in xs))
dy = math.sqrt(sum((y - my) ** 2 for y in ys))
if dx == 0.0 or dy == 0.0:
return 0.0
return num / (dx * dy)
def score_matrix(ingress: Sequence[Series], egress: Sequence[Series]) -> List[List[float]]:
"""Full pairwise linkage-score matrix ``S[i][j] = pearson(ingress[i],
egress[j])``. The candidate true pairing is the diagonal (``i == j``)."""
return [[pearson(a, b) for b in egress] for a in ingress]
def auc(pos: Series, neg: Series) -> float:
"""Rank AUC = P(pos ranked above neg) with ties counted as 0.5 (Mann-Whitney
U normalized). AUC 1.0 = perfect separation, 0.5 = chance. Empty side -> 0.5."""
if not pos or not neg:
return 0.5
wins = 0.0
for p in pos:
for q in neg:
if p > q:
wins += 1.0
elif p == q:
wins += 0.5
return wins / (len(pos) * len(neg))
def linkage_auc(scores: Sequence[Sequence[float]]) -> float:
"""AUC of the diagonal (linked) scores against the off-diagonal (unlinked)
scores of a square score matrix — the RQ1 bridge-correlation AUC."""
n = len(scores)
pos = [scores[i][i] for i in range(n)]
neg = [scores[i][j] for i in range(n) for j in range(n) if i != j]
return auc(pos, neg)
def bridge_correlation_auc(ingress: Sequence[Series], egress: Sequence[Series]) -> float:
"""RQ1 scorer: how well the correlator links each ingress flow to its true
egress flow, as AUC over all candidate pairings. ≈1 for a linked bridge,
≈0.5 for an unlinked one (gate item 3)."""
return linkage_auc(score_matrix(ingress, egress))
# --------------------------------------------------------------------------- #
# Seeded synthetic calibration fixtures (ground truth known by construction).
# --------------------------------------------------------------------------- #
def synthetic_bridge_fixture(
seed: int,
n_flows: int = 8,
bins: int = 64,
jitter: int = 5,
linked: bool = True,
) -> tuple[List[List[int]], List[List[int]]]:
"""Deterministically build ``(ingress, egress)`` flow sets of per-bin byte
counts from ``seed`` alone (reuses the R1 ``SorRng`` streams).
- ``linked=True``: each egress flow is its ingress flow plus small independent
padding/latency ``jitter`` — the *known-linked* control (diagonal
correlation dominates -> AUC ≈ 1).
- ``linked=False``: egress flows are drawn from an independent domain stream,
uncorrelated with ingress — the *known-unlinked* control (no diagonal
advantage -> AUC ≈ 0.5).
"""
rng = SorRng(seed)
sig = rng.stream(Domain.PATH) # ingress signal
ingress = [[sig.next_below(1000) for _ in range(bins)] for _ in range(n_flows)]
if linked:
noise = rng.stream(Domain.PADDING)
span = 2 * jitter + 1
egress = [
[v + (noise.next_below(span) - jitter) for v in flow] for flow in ingress
]
else:
alt = rng.stream(Domain.CHURN) # independent of the PATH signal
egress = [[alt.next_below(1000) for _ in range(bins)] for _ in range(n_flows)]
return ingress, egress