research/tools/simulators/blend/pd/tests/test_propagation.py
Marcin Pawlowski 6ad63ce2f3
Add pd: peering-degree Blend Monte-Carlo graph simulator
Static-graph simulator quantifying how a node's peering degree trades off
propagation speed, adversary exposure, deanonymization, and reliability in the
Blend network. Scales to 1e6 nodes (sparse CSR + sampled Dijkstra); the
adversary and deanonymization metrics are exact at every N.

Model (ms): seeded d-regular peer graph (matching-union), Blend cascade
(sender -> blend_hops timed-release mix relays -> final flood), geographic link
base + exponential transport jitter, per-node processing lag, free-running
release-clock mixing.

Metrics:
- propagation: full-delay mean/p50/p90/p99, path/broadcast split, coverage times
- reliability: message success-delivery-rate ~ (1-unresponsive_frac)^blend_hops
  and flood coverage, with unresponsive nodes modelled as routing holes
- adversary (exact): observed/eclipsed fractions, random + worst-case placement
- deanonymization (exact): P(whole blend path adversarial) ~ f_adv^blend_hops,
  and full deanonymization (path adversarial AND honest sender peered with an
  adversary) = deanon_rate * observed_frac

Deterministic blake2b seed streams, three parquet tables, joblib parallelism,
memguard, an analytic verify harness, 50 unit tests, and an auto-installing
Makefile.

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

96 lines
4.2 KiB
Python

import numpy as np
from pd.graph import Graph
from pd.propagation import assign_responsive, blend_round
def _k4(p):
"""Complete graph on 4 nodes (degree 3), base latency 10 ms on every link, node lags `p`."""
indptr = np.array([0, 3, 6, 9, 12], dtype=np.int64)
indices = np.array([1, 2, 3, 0, 2, 3, 0, 1, 3, 0, 1, 2], dtype=np.int64)
base = np.full(12, 10.0)
src = np.array([0, 0, 0, 1, 1, 1, 2, 2, 2, 3, 3, 3], dtype=np.int64)
return Graph(n=4, degree=3, indptr=indptr, indices=indices, base=base, src=src,
p=np.asarray(p, dtype=float))
def test_single_relay_delay_with_node_lags():
# jitter=0, max_blend_delay=0. Directed edge (u->v) = base(10) + p(u).
g = _k4([1.0, 2.0, 3.0, 4.0])
rng = np.random.default_rng(0)
r = blend_round(g, sender=0, relays=np.array([1]), jitter_mean_ms=0.0,
max_blend_delay=0, rng=rng, coverage_pcts=(50.0, 90.0, 99.0))
# leg 0->1 = 10 + p(0) = 11 ; broadcast from 1 to farthest = 10 + p(1) = 12
assert r["path"] == 11.0
assert r["broadcast"] == 12.0
assert r["full"] == 23.0
assert r["frac_reached"] == 1.0
def test_two_relay_path_sums_legs():
g = _k4([1.0, 2.0, 3.0, 4.0])
rng = np.random.default_rng(0)
r = blend_round(g, sender=0, relays=np.array([1, 2]), jitter_mean_ms=0.0,
max_blend_delay=0, rng=rng, coverage_pcts=(50.0,))
# legs: 0->1 = 11, 1->2 = 10 + p(1) = 12 => path 23 ; broadcast from 2 = 10 + p(2) = 13
assert r["path"] == 23.0
assert r["broadcast"] == 13.0
assert r["full"] == 36.0
def test_mixing_adds_positive_delay():
g = _k4([0.0, 0.0, 0.0, 0.0])
rng = np.random.default_rng(1)
no_mix = blend_round(g, 0, np.array([1]), 0.0, 0, rng, (50.0,))["full"]
mixed = np.mean([blend_round(g, 0, np.array([1]), 0.0, 5, rng, (50.0,))["full"]
for _ in range(500)])
assert mixed > no_mix # the free-running clock adds a positive mixing residual
def _path4():
"""Line graph 0-1-2-3 (base 10 ms each way, no node lags)."""
indptr = np.array([0, 1, 3, 5, 6], dtype=np.int64)
indices = np.array([1, 0, 2, 1, 3, 2], dtype=np.int64)
base = np.full(6, 10.0)
src = np.array([0, 1, 1, 2, 2, 3], dtype=np.int64)
return Graph(n=4, degree=2, indptr=indptr, indices=indices, base=base, src=src,
p=np.zeros(4))
def test_assign_responsive_count_and_edges():
rng = np.random.default_rng(0)
mask = assign_responsive(1000, 0.3, rng)
assert mask.dtype == bool
assert int(mask.sum()) == 700 # exactly 30% dropped
assert assign_responsive(1000, 0.0, rng).all() # frac 0 -> everyone responsive
def test_unresponsive_final_relay_drops_message():
# final relay (node 1) unresponsive -> it receives but cannot flood: not delivered.
g = _k4([0.0, 0.0, 0.0, 0.0])
responsive = np.array([True, False, True, True])
r = blend_round(g, sender=0, relays=np.array([1]), jitter_mean_ms=0.0, max_blend_delay=0,
rng=np.random.default_rng(0), coverage_pcts=(50.0,), responsive=responsive)
assert r["delivered"] is False
assert np.isnan(r["full"])
def test_unresponsive_intermediate_relay_drops_message():
# first relay (node 1) unresponsive -> the second leg 1->2 is inf: not delivered.
g = _k4([0.0, 0.0, 0.0, 0.0])
responsive = np.array([True, False, True, True])
r = blend_round(g, sender=0, relays=np.array([1, 2]), jitter_mean_ms=0.0, max_blend_delay=0,
rng=np.random.default_rng(0), coverage_pcts=(50.0,), responsive=responsive)
assert r["delivered"] is False
def test_unresponsive_node_strands_flood_pocket():
# path 0-1-2-3; relay 1 is responsive so the message is delivered, but node 2 is a routing hole
# so node 3 (only reachable through 2) never receives the flood.
g = _path4()
responsive = np.array([True, True, False, True])
r = blend_round(g, sender=0, relays=np.array([1]), jitter_mean_ms=0.0, max_blend_delay=0,
rng=np.random.default_rng(0), coverage_pcts=(50.0,), responsive=responsive)
assert r["delivered"] is True
assert r["frac_reached"] == 0.75 # node 3 stranded behind unresponsive node 2