Marcin Pawlowski 9b03a68a84
Add linkability, messaging redundancy and churn percolation to pd; report
Extends the pd Blend simulator along two axes the deanonymization model
opened up, adds the reports/blend/pd report of record, and fixes three
correctness defects found while reviewing the result.

Linkability over time (pd.linkability):
- time to link an emitter ~ 30s*ln(1/(1-alpha))/(stake*q): inversely
  proportional to stake, so a 5% staker is linked in ~2 days and a 0.001%
  staker only after ~27 years;
- time to certify a node's stake >= theta from the count of attributable
  observations (relative precision ~1/sqrt(N)): sizing a node costs 100-400x
  more than identifying it, and sub-0.1% stake is practically unlearnable.
Both are closed forms over the exact deanonymization rates and a
stake-proportional 30 s emission cadence, checked against a Monte-Carlo of
the emission process in verify.

Messaging redundancy (R independent cascades per emission, R = 1..4):
- `redundancy` knob threaded through config/rng/propagation/engine/metrics/
  sweep; a node receives from whichever cascade reaches it first, so arrival
  times combine element-wise. Delivery and capture both follow 1-(1-x)^R, so
  redundancy trades reliability against anonymity and divides time-to-link
  by ~R. Measured: delivery 0.34 -> 0.81 at 30% churn for R = 1 -> 4, while a
  1%-staker's time to link falls 10 d -> 2.5 d.
- Redundancy buys NO coverage: a cascade only delivers if the sender could
  already route to its relay, so every delivered cascade floods the sender's
  own component. Coverage is flat in R to four decimals at every degree.
- Near the percolation threshold the cascades fail together rather than
  independently, so redundancy under-delivers against 1-(1-p1)^R there.

Churn percolation (configs/percolation.yaml, verify check 7):
- the flood only crosses responsive nodes, so it lives on the responsive
  sub-graph -- site percolation on a d-regular graph. A network survives churn
  only up to u_c = 1 - 1/(degree-1); measured collapse lands on the predicted
  threshold for every degree (3 -> 0.50, 6 -> 0.80, 16 -> 0.93), which inverts
  into the sizing rule degree > 1 + 1/(1-u).

Correctness fixes:
- redundancy delay used the fastest cascade's own full delay, which
  over-states it (min-max vs max-min); now the element-wise earliest arrival,
  reducing exactly to the single-cascade model at R = 1 (test);
- the "redundancy improves coverage" claim was false in both the report and
  the simulator README -- removed and replaced with the measured result;
- per-hop latency is degree-dependent (1.5 s at degree 16 to 2.7 s at degree
  3), not a flat 1.6 s; and the worst-case observation figure was averaged
  over degrees -- at degree 8 and f_adv = 0.2 it is 0.83 -> 1.000.

Statistics: round counts raised for resolution rather than speed -- 8000
rounds per cell in the main sweep, 9600 in the redundancy study, 6400 in the
percolation study, giving SEM <= 0.009 on every delivery rate and <= 0.04 s
on every delay mean. The previous redundancy grid (144 rounds/cell) produced a
non-monotonic delivery curve; it is now monotonic and within 0.015 of theory.
Adversary and deanonymization metrics remain closed-form and exact.

reports/blend/pd: the report of record -- peering-degree trade-offs across
speed, observation, eclipse, deanonymization and reliability, plus the
time-to-link, stake-inference, redundancy and churn-threshold sections, with
21 figures of record and an explicit sampling-error statement.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 17:59:55 +02:00

101 lines
4.9 KiB
Python

"""Deanonymization metrics: exact closed forms + a Monte-Carlo tie to the actual draw.
A *deanonymization* event is a round whose whole blend path is adversarial; *full* deanonymization
additionally requires the honest sender to be directly peered with an adversary. Relays are drawn
uniformly blind to who is adversarial, so both rates are exact (no sampling in production)."""
import numpy as np
from pd.adversary import adversary_metrics, deanon_metrics, place_adversary
from pd.config import SimConfig
from pd.engine import run_graph_cell
from pd.graph import build_graph
def test_deanon_rate_hand_computed():
# n=4, 2 adversaries, honest sender leaves 3 nodes (2 adversarial) in the relay pool;
# k=2 distinct relays both adversarial: C(2,2)/C(3,2) = 1/3.
dz = deanon_metrics(n=4, n_adv=2, observed_frac=0.5, blend_hops=2)
assert abs(dz["deanon_rate"] - 1.0 / 3.0) < 1e-12
assert abs(dz["full_deanon_rate"] - (1.0 / 3.0) * 0.5) < 1e-12
def test_deanon_rate_zero_when_too_few_adversaries():
assert deanon_metrics(n=100, n_adv=1, observed_frac=0.9, blend_hops=2)["deanon_rate"] == 0.0
assert deanon_metrics(n=100, n_adv=0, observed_frac=0.0, blend_hops=1)["deanon_rate"] == 0.0
# too few adversaries -> no full deanonymization either
too_few = deanon_metrics(n=100, n_adv=1, observed_frac=0.9, blend_hops=2)
assert too_few["full_deanon_rate"] == 0.0
def test_full_deanon_is_deanon_times_observed():
dz = deanon_metrics(n=5000, n_adv=1000, observed_frac=0.73, blend_hops=3)
assert abs(dz["full_deanon_rate"] - dz["deanon_rate"] * 0.73) < 1e-12
assert dz["full_deanon_rate"] <= dz["deanon_rate"] + 1e-12
def test_deanon_rate_is_placement_independent_but_full_is_not():
"""The whole-path-adversarial rate depends only on the adversary COUNT; the full rate also
tracks how many honest nodes are peered with an adversary, which the worst case maximizes."""
g = build_graph(SimConfig(n_nodes=2000, degree=6, graph_seed=0))
rng = np.random.default_rng(0)
rand = adversary_metrics(g, place_adversary(g, 0.2, "random", rng, 10**9))
wc = adversary_metrics(g, place_adversary(g, 0.2, "worstcase_coverage", rng, 10**9))
assert rand["n_adv"] == wc["n_adv"] # same budget
dz_rand = deanon_metrics(g.n, rand["n_adv"], rand["observed_frac"], 3)
dz_wc = deanon_metrics(g.n, wc["n_adv"], wc["observed_frac"], 3)
assert abs(dz_rand["deanon_rate"] - dz_wc["deanon_rate"]) < 1e-12 # placement-independent
assert dz_wc["full_deanon_rate"] >= dz_rand["full_deanon_rate"] - 1e-12 # worst case >= random
def test_deanon_asymptotic_fadv_power():
# C(A,k)/C(n-1,k) -> f_adv^k for large n.
f, k, n = 0.3, 3, 20000
dz = deanon_metrics(n=n, n_adv=int(round(f * n)), observed_frac=0.5, blend_hops=k)
assert abs(dz["deanon_rate"] - f ** k) < 0.002
def test_deanon_matches_direct_sampling():
"""Closed form == empirical rate of the exact honest-sender/blind-relay draw the sim uses."""
f, k = 0.33, 2
cfg = SimConfig(n_nodes=1500, degree=8, graph_seed=3, f_adv=f, blend_hops=k)
g = build_graph(cfg)
mask = place_adversary(g, f, "random", np.random.default_rng(1), cfg.worstcase_max_n)
adv = adversary_metrics(g, mask)
dz = deanon_metrics(g.n, adv["n_adv"], adv["observed_frac"], k)
counts = np.add.reduceat(mask[g.indices].astype(np.int32), g.indptr[:-1])
observed_node = counts >= 1
honest = np.where(~mask)[0]
n = g.n
rng = np.random.default_rng(42)
trials, d_hit, fd_hit = 40_000, 0, 0
for _ in range(trials):
s = int(rng.choice(honest))
r = rng.choice(n - 1, size=k, replace=False)
r[r >= s] += 1
if mask[r].all():
d_hit += 1
fd_hit += int(observed_node[s])
assert abs(dz["deanon_rate"] - d_hit / trials) < max(0.006, 0.1 * dz["deanon_rate"])
assert abs(dz["full_deanon_rate"] - fd_hit / trials) < max(0.006, 0.12 * dz["full_deanon_rate"])
def test_engine_emits_deanon_rows():
base = SimConfig(n_nodes=1000, degree=8, graph_seed=0, n_placements=2)
prop_grid = [(2, 0), (3, 0)] # distinct blend_hops = {2, 3}
adv_grid = [(0.2, "random"), (0.0, "random")]
prop_rows, adv_rows, deanon_rows = run_graph_cell(base, prop_grid, [0.0], [1], adv_grid)
# one deanon row per (placement, distinct blend_hops, redundancy)
assert len(deanon_rows) == len(adv_rows) * 2
cols = {"n_nodes", "degree", "blend_hops", "redundancy", "f_adv", "adversary_mode",
"graph_seed", "placement_rep", "n_adv", "n_honest", "observed_frac",
"deanon_rate", "full_deanon_rate"}
assert cols <= set(deanon_rows[0])
assert {row["blend_hops"] for row in deanon_rows} == {2, 3}
for row in deanon_rows:
assert 0.0 <= row["full_deanon_rate"] <= row["deanon_rate"] + 1e-12
if row["f_adv"] == 0.0:
assert row["deanon_rate"] == 0.0 # no adversary -> no deanonymization