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-03 16:47:46 +02:00
|
|
|
import dataclasses
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
Rename the simulator and report from pd to blend
The study started as a peering-degree question and grew well past it: propagation,
adversary exposure, deanonymization and time-to-link, reliability under uniform
and correlated churn, messaging redundancy, and cover traffic. The pd name no
longer describes it.
tools/simulators/blend/pd/ -> tools/simulators/blend/, package src/pd -> src/blend,
and reports/blend/pd/ -> reports/blend/. Moved with git mv so history follows.
The text substitutions are deliberately narrow. pd is also the conventional pandas
alias, and pandas genuinely has a pd.plotting submodule, so a blanket pd. -> blend.
rewrite would have corrupted four files. Only package-unambiguous forms were
changed: from pd.X, -m pd.X, pd.<our module>, PD_BYTES_BUDGET, src/pd, and the
pyproject name. All four import pandas as pd lines are untouched and verified.
Both READMEs reframed: peering degree is now presented as the primary axis that
ties the others together rather than as the subject, and the relative links, which
lost a directory level in the move, are corrected.
Verified after the move: ruff clean, 101 tests, 45 verify anchors, make targets,
the script shims, an end-to-end smoke run, and data/report_numbers.py still
reproducing the report tables from the checked-in evidence.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 12:20:07 +02:00
|
|
|
from blend.config import SimConfig, SweepConfig
|
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-03 16:47:46 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_key_covers_every_field():
|
|
|
|
|
fields = [f.name for f in dataclasses.fields(SimConfig)]
|
|
|
|
|
assert len(SimConfig().key()) == len(fields)
|
pd: correlated AS/region churn, and two report caveats corrected
Uncorrelated churn alone was incomplete: real outages take out a datacentre, AS
or region as a unit. Adds failure domains and a correlated churn mode, plus the
metric needed to tell the two apart.
- n_regions / region_locality: nodes belong to equal-sized failure domains, and
a configurable share of each node peers inside its own domain. Locality is what
makes a failure domain a connectivity domain -- with region-blind peering,
dropping whole regions removes a uniformly random set of nodes and is
indistinguishable from uniform churn. The locality matchings keep the graph
exactly d-regular (they change where peers are, never how many).
- churn_mode = uniform | regional, swept per topology so both modes are compared
on the same graph at an identical dead-node count.
- frac_reached_live: coverage of the *responsive* network, alongside coverage of
all nodes. The two move in opposite directions under correlated failure, so one
number could not express the result.
Measured (degree 4, 20 domains, 75% locality, half the network dead): clustered
failure leaves the survivors fully connected -- live coverage 1.000 and delivery
equal to the live-relay rate, i.e. nothing lost to routing -- where the same
number of scattered failures gives 0.857 live coverage and loses delivery to
broken routes. Correlated outages are gentler on the survivors than uniform
churn, while stranding the dead domains. Verify check 8 anchors this.
Also, per review of the caveats: exact d-regularity is a protocol requirement
rather than a modelling simplification, and the timing-correlation adversary is
deferred because it is only meaningful once the network emits cover traffic,
which this simulator does not yet do.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 11:37:56 +02:00
|
|
|
# n_regions=2 in the base so the region/churn fields can each be varied on their own
|
|
|
|
|
# (region_locality and churn_mode="regional" both require n_regions >= 2)
|
|
|
|
|
base = SimConfig(n_regions=2)
|
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-03 16:47:46 +02:00
|
|
|
for name in fields:
|
|
|
|
|
cur = getattr(base, name)
|
pd: correlated AS/region churn, and two report caveats corrected
Uncorrelated churn alone was incomplete: real outages take out a datacentre, AS
or region as a unit. Adds failure domains and a correlated churn mode, plus the
metric needed to tell the two apart.
- n_regions / region_locality: nodes belong to equal-sized failure domains, and
a configurable share of each node peers inside its own domain. Locality is what
makes a failure domain a connectivity domain -- with region-blind peering,
dropping whole regions removes a uniformly random set of nodes and is
indistinguishable from uniform churn. The locality matchings keep the graph
exactly d-regular (they change where peers are, never how many).
- churn_mode = uniform | regional, swept per topology so both modes are compared
on the same graph at an identical dead-node count.
- frac_reached_live: coverage of the *responsive* network, alongside coverage of
all nodes. The two move in opposite directions under correlated failure, so one
number could not express the result.
Measured (degree 4, 20 domains, 75% locality, half the network dead): clustered
failure leaves the survivors fully connected -- live coverage 1.000 and delivery
equal to the live-relay rate, i.e. nothing lost to routing -- where the same
number of scattered failures gives 0.857 live coverage and loses delivery to
broken routes. Correlated outages are gentler on the survivors than uniform
churn, while stranding the dead domains. Verify check 8 anchors this.
Also, per review of the caveats: exact d-regularity is a protocol requirement
rather than a modelling simplification, and the timing-correlation adversary is
deferred because it is only meaningful once the network emits cover traffic,
which this simulator does not yet do.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 11:37:56 +02:00
|
|
|
alt = {"n_nodes": 2000, "degree": 4, "n_regions": 4, "region_locality": 0.5,
|
|
|
|
|
"blend_hops": 2, "max_blend_delay": 5,
|
|
|
|
|
"unresponsive_frac": 0.2, "churn_mode": "regional", "redundancy": 2,
|
2026-08-05 16:57:24 +02:00
|
|
|
"cover_rate_mult": 2.0, "block_interval_slots": 60, "slots_per_epoch": 1000,
|
|
|
|
|
"stake_inference_ratio": 0.7, "traffic_window_slots": 100,
|
pd: stake distribution, so the emission ceiling is measured not asserted
The quota ceiling was closed-form only. This adds per-node stake so a run can
show nodes actually breaking it.
- assign_stake: uniform, or heavy-tailed zipf (s ~ 1/rank^a), which is what makes
the ceiling bite -- the head sits orders of magnitude above it, the tail far below;
- inferred_alpha: converts true relative stake to the sigma/D_hat the lottery
actually weighs, so a low estimate inflates every node alpha;
- simulate_epoch_emissions: measures the budget over a full epoch. Overrun happens
at epoch scale and needs no graph, so this is cheap: proposals are Binomial over
the epoch slots, a proposal cancels the next cover, and a node stays at exactly
its quota until its wins no longer fit -- at which point it emits more often than
everyone else, which is the signal cover traffic exists to suppress.
Measured against the closed form at N=20,000, zipf stake, over an epoch: the
predicted ceiling falls inside the transition band every time, and at D_hat/D = 1
the smallest overrunning node sits at 0.1468% against a predicted 0.1475%. The
D_hat/D normalisation is confirmed empirically -- deflating the estimate to 0.64
pulls the measured ceiling down with it, as the (D_hat/D)*alpha_max form requires.
With heavy-tailed stake 99.7% of nodes comply and only the head breaks; the
largest holder at 9.5% stake is some 65x over its allowance.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 17:21:36 +02:00
|
|
|
"stake_dist": "zipf", "stake_zipf_a": 1.5,
|
pd: correlated AS/region churn, and two report caveats corrected
Uncorrelated churn alone was incomplete: real outages take out a datacentre, AS
or region as a unit. Adds failure domains and a correlated churn mode, plus the
metric needed to tell the two apart.
- n_regions / region_locality: nodes belong to equal-sized failure domains, and
a configurable share of each node peers inside its own domain. Locality is what
makes a failure domain a connectivity domain -- with region-blind peering,
dropping whole regions removes a uniformly random set of nodes and is
indistinguishable from uniform churn. The locality matchings keep the graph
exactly d-regular (they change where peers are, never how many).
- churn_mode = uniform | regional, swept per topology so both modes are compared
on the same graph at an identical dead-node count.
- frac_reached_live: coverage of the *responsive* network, alongside coverage of
all nodes. The two move in opposite directions under correlated failure, so one
number could not express the result.
Measured (degree 4, 20 domains, 75% locality, half the network dead): clustered
failure leaves the survivors fully connected -- live coverage 1.000 and delivery
equal to the live-relay rate, i.e. nothing lost to routing -- where the same
number of scattered failures gives 0.857 live coverage and loses delivery to
broken routes. Correlated outages are gentler on the survivors than uniform
churn, while stranding the dead domains. Verify check 8 anchors this.
Also, per review of the caveats: exact d-regularity is a protocol requirement
rather than a modelling simplification, and the timing-correlation adversary is
deferred because it is only meaningful once the network emits cover traffic,
which this simulator does not yet do.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 11:37:56 +02:00
|
|
|
"n_rounds": 10, "transport_jitter_mean_ms": 1.0,
|
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-03 16:47:46 +02:00
|
|
|
"processing_lags_ms": (11.0, 51.0, 101.0), "processing_lag_probs": (0.6, 0.3, 0.1),
|
|
|
|
|
"link_latency_dist": "fixed", "link_latency_mean_ms": 1.0,
|
|
|
|
|
"coverage_pcts": (25.0,), "f_adv": 0.1, "adversary_mode": "worstcase_coverage",
|
|
|
|
|
"n_placements": 1, "worstcase_max_n": 5, "graph_seed": 99, "replicate": 1,
|
|
|
|
|
"root_seed": 7}[name]
|
|
|
|
|
assert alt != cur
|
|
|
|
|
assert dataclasses.replace(base, **{name: alt}).key() != base.key(), name
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize("kw", [
|
|
|
|
|
{"n_nodes": 999}, # odd
|
|
|
|
|
{"degree": 1000}, # >= n
|
|
|
|
|
{"blend_hops": 0}, # < 1
|
|
|
|
|
{"f_adv": 1.0}, # >= 1
|
|
|
|
|
{"max_blend_delay": -1},
|
|
|
|
|
{"processing_lag_probs": (0.5, 0.4)}, # doesn't sum to 1 (with default 3 lags -> len mismatch)
|
|
|
|
|
{"link_latency_dist": "bogus"},
|
|
|
|
|
{"adversary_mode": "bogus"},
|
|
|
|
|
])
|
|
|
|
|
def test_validation_rejects(kw):
|
|
|
|
|
with pytest.raises(ValueError):
|
|
|
|
|
SimConfig(**kw)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_sweep_grids_and_collapse():
|
|
|
|
|
sw = SweepConfig(n_nodes=[1000, 10000], degree=[4, 8], blend_hops=[2, 3],
|
|
|
|
|
max_blend_delay=[0, 3], f_adv=[0.0, 0.2],
|
|
|
|
|
adversary_mode=["random", "worstcase_coverage"], seeds=3)
|
|
|
|
|
assert len(sw.graph_cells()) == 2 * 2 * 3
|
|
|
|
|
assert len(sw.prop_grid()) == 2 * 2
|
|
|
|
|
# f_adv=0 collapses to a single (mode-irrelevant) row; f_adv=0.2 keeps both modes
|
|
|
|
|
assert sw.adv_grid() == [(0.0, "random"), (0.2, "random"), (0.2, "worstcase_coverage")]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_from_dict_rejects_unknown():
|
|
|
|
|
with pytest.raises(ValueError):
|
|
|
|
|
SweepConfig.from_dict({"nonsense": [1]})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_base_config_coerces_tuples():
|
|
|
|
|
sw = SweepConfig(base={"processing_lags_ms": [10.0, 90.0], "processing_lag_probs": [0.3, 0.7]})
|
|
|
|
|
cfg = sw.base_config(1000, 8, 0)
|
|
|
|
|
assert cfg.processing_lags_ms == (10.0, 90.0)
|
|
|
|
|
assert isinstance(cfg.key(), tuple)
|