From 6ad63ce2f3d48ca61408877b07138734898992ae Mon Sep 17 00:00:00 2001 From: Marcin Pawlowski Date: Mon, 3 Aug 2026 16:47:46 +0200 Subject: [PATCH] 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) --- tools/simulators/blend/pd/.gitignore | 17 + tools/simulators/blend/pd/Makefile | 45 +++ tools/simulators/blend/pd/README.md | 68 ++++ .../simulators/blend/pd/configs/default.yaml | 13 + .../blend/pd/configs/fullscale.yaml | 14 + tools/simulators/blend/pd/configs/smoke.yaml | 12 + tools/simulators/blend/pd/pyproject.toml | 43 ++ .../simulators/blend/pd/requirements-dev.txt | 1 + tools/simulators/blend/pd/requirements.txt | 1 + .../blend/pd/scripts/make_figures.py | 11 + .../simulators/blend/pd/scripts/run_sweep.py | 11 + tools/simulators/blend/pd/scripts/verify.py | 11 + tools/simulators/blend/pd/src/pd/__init__.py | 3 + tools/simulators/blend/pd/src/pd/adversary.py | 162 ++++++++ tools/simulators/blend/pd/src/pd/config.py | 151 +++++++ tools/simulators/blend/pd/src/pd/constants.py | 24 ++ tools/simulators/blend/pd/src/pd/engine.py | 81 ++++ tools/simulators/blend/pd/src/pd/graph.py | 179 ++++++++ tools/simulators/blend/pd/src/pd/latency.py | 39 ++ tools/simulators/blend/pd/src/pd/memguard.py | 55 +++ tools/simulators/blend/pd/src/pd/metrics.py | 57 +++ tools/simulators/blend/pd/src/pd/mixclock.py | 36 ++ .../blend/pd/src/pd/plotting/__init__.py | 1 + .../blend/pd/src/pd/plotting/figures.py | 382 ++++++++++++++++++ .../blend/pd/src/pd/plotting/make_figures.py | 71 ++++ .../blend/pd/src/pd/plotting/style.py | 90 +++++ .../simulators/blend/pd/src/pd/propagation.py | 163 ++++++++ tools/simulators/blend/pd/src/pd/rng.py | 68 ++++ tools/simulators/blend/pd/src/pd/sweep.py | 93 +++++ tools/simulators/blend/pd/src/pd/verify.py | 119 ++++++ .../blend/pd/tests/test_adversary.py | 62 +++ .../simulators/blend/pd/tests/test_config.py | 59 +++ .../simulators/blend/pd/tests/test_deanon.py | 100 +++++ tools/simulators/blend/pd/tests/test_graph.py | 57 +++ .../blend/pd/tests/test_mixclock.py | 24 ++ .../blend/pd/tests/test_propagation.py | 95 +++++ tools/simulators/blend/pd/tests/test_rng.py | 39 ++ 37 files changed, 2457 insertions(+) create mode 100644 tools/simulators/blend/pd/.gitignore create mode 100644 tools/simulators/blend/pd/Makefile create mode 100644 tools/simulators/blend/pd/README.md create mode 100644 tools/simulators/blend/pd/configs/default.yaml create mode 100644 tools/simulators/blend/pd/configs/fullscale.yaml create mode 100644 tools/simulators/blend/pd/configs/smoke.yaml create mode 100644 tools/simulators/blend/pd/pyproject.toml create mode 100644 tools/simulators/blend/pd/requirements-dev.txt create mode 100644 tools/simulators/blend/pd/requirements.txt create mode 100644 tools/simulators/blend/pd/scripts/make_figures.py create mode 100644 tools/simulators/blend/pd/scripts/run_sweep.py create mode 100644 tools/simulators/blend/pd/scripts/verify.py create mode 100644 tools/simulators/blend/pd/src/pd/__init__.py create mode 100644 tools/simulators/blend/pd/src/pd/adversary.py create mode 100644 tools/simulators/blend/pd/src/pd/config.py create mode 100644 tools/simulators/blend/pd/src/pd/constants.py create mode 100644 tools/simulators/blend/pd/src/pd/engine.py create mode 100644 tools/simulators/blend/pd/src/pd/graph.py create mode 100644 tools/simulators/blend/pd/src/pd/latency.py create mode 100644 tools/simulators/blend/pd/src/pd/memguard.py create mode 100644 tools/simulators/blend/pd/src/pd/metrics.py create mode 100644 tools/simulators/blend/pd/src/pd/mixclock.py create mode 100644 tools/simulators/blend/pd/src/pd/plotting/__init__.py create mode 100644 tools/simulators/blend/pd/src/pd/plotting/figures.py create mode 100644 tools/simulators/blend/pd/src/pd/plotting/make_figures.py create mode 100644 tools/simulators/blend/pd/src/pd/plotting/style.py create mode 100644 tools/simulators/blend/pd/src/pd/propagation.py create mode 100644 tools/simulators/blend/pd/src/pd/rng.py create mode 100644 tools/simulators/blend/pd/src/pd/sweep.py create mode 100644 tools/simulators/blend/pd/src/pd/verify.py create mode 100644 tools/simulators/blend/pd/tests/test_adversary.py create mode 100644 tools/simulators/blend/pd/tests/test_config.py create mode 100644 tools/simulators/blend/pd/tests/test_deanon.py create mode 100644 tools/simulators/blend/pd/tests/test_graph.py create mode 100644 tools/simulators/blend/pd/tests/test_mixclock.py create mode 100644 tools/simulators/blend/pd/tests/test_propagation.py create mode 100644 tools/simulators/blend/pd/tests/test_rng.py diff --git a/tools/simulators/blend/pd/.gitignore b/tools/simulators/blend/pd/.gitignore new file mode 100644 index 0000000..8a18509 --- /dev/null +++ b/tools/simulators/blend/pd/.gitignore @@ -0,0 +1,17 @@ +# Generated artifacts +runs/ +results/ +figures/ +!results/.gitkeep + +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +.venv/ +venv/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +*.parquet +*.csv diff --git a/tools/simulators/blend/pd/Makefile b/tools/simulators/blend/pd/Makefile new file mode 100644 index 0000000..4e8ee44 --- /dev/null +++ b/tools/simulators/blend/pd/Makefile @@ -0,0 +1,45 @@ +VENV ?= .venv +PY := $(VENV)/bin/python +STAMP := $(VENV)/.installed + +# Keep numpy/scipy BLAS single-threaded so joblib process parallelism doesn't oversubscribe. +export OMP_NUM_THREADS := 1 +export OPENBLAS_NUM_THREADS := 1 +export MKL_NUM_THREADS := 1 +export NUMEXPR_NUM_THREADS := 1 + +.PHONY: install smoke sweep sweep-fullscale figures verify test lint clean + +# The stamp is the real install; targets below depend on it so `make sweep` (etc.) auto-installs +# on a fresh checkout and re-installs whenever pyproject.toml changes. +$(STAMP): pyproject.toml + python3 -m venv $(VENV) + $(PY) -m pip install -U pip + $(PY) -m pip install -e ".[dev]" + @touch $(STAMP) + +install: $(STAMP) + +smoke: $(STAMP) ## fast end-to-end (seconds): tiny N, few rounds/seeds + $(PY) -m pd.sweep --config configs/smoke.yaml + +sweep: $(STAMP) + $(PY) -m pd.sweep --config configs/default.yaml + +sweep-fullscale: $(STAMP) + $(PY) -m pd.sweep --config configs/fullscale.yaml + +figures: $(STAMP) ## make figures RUN=runs/ + $(PY) -m pd.plotting.make_figures --run $(RUN) + +verify: $(STAMP) + $(PY) -m pd.verify + +test: $(STAMP) + $(PY) -m pytest + +lint: $(STAMP) + $(VENV)/bin/ruff check src scripts tests + +clean: + rm -rf runs/* figures/* .pytest_cache .ruff_cache .mypy_cache diff --git a/tools/simulators/blend/pd/README.md b/tools/simulators/blend/pd/README.md new file mode 100644 index 0000000..282ab24 --- /dev/null +++ b/tools/simulators/blend/pd/README.md @@ -0,0 +1,68 @@ +# pd — peering-degree Monte-Carlo graph simulator + +Quantifies how a node's **peering degree** trades off, in the Blend network: + +- **propagation speed** — the full delay (ms) of a message: a random sender routes it along a + `blend_hops`-relay Blend path (each relay a free-running timed-release mix node) and the last + relay floods the whole network; +- **adversary exposure** — with a fraction `f_adv` of adversarial nodes, how many honest nodes are + peered with ≥1 adversary (**observed**) and how many are fully surrounded (**eclipsed**); +- **deanonymization** — tying propagation to the adversary: how often a message's *whole* blend path + is adversarial (**deanonymization** — the adversary owns the cascade end-to-end) and how often the + honest sender is *additionally* peered with an adversary (**full deanonymization** — the message is + tied back to its originator); and +- **reliability under churn** — with a fraction `unresponsive_frac` of nodes that relay nothing, the + **message success-delivery-rate** (fraction of messages that survive the whole blend cascade to a + responsive final relay) and the flood **coverage** of those that do. + +The peer graph is a seeded random **d-regular** graph (exactly `degree` symmetric peers, identical +for everyone from one global seed). This is static-graph analysis — no consensus — so it is far +lighter than the TSI simulators and scales to **10⁶ nodes** (sparse CSR + sampled Dijkstra; the +adversary metrics are exact at every N). + +## Model (all delays in ms) +- **Link delay:** geographic base (metro 15 → antipodal 200 ms) + exponential transport jitter. +- **Processing lag:** each node draws a fixed lag from a categorical distribution (default + {10, 50, 100} ms at {0.5, 0.4, 0.1}), incurred every time it relays. +- **Blend mixing:** each relay releases on a free-running clock whose successive intervals are + Uniform{0…`max_blend_delay`} whole seconds; a held message waits for the relay's next release + (the renewal residual). Mixing happens only at the `blend_hops` relays; the final flood is plain. +- **Unresponsive nodes:** a random `unresponsive_frac` of the population relays nothing (its outgoing + edges are removed). Relays are drawn from the whole node list *blind to responsiveness*, so a + message dies if any relay on its path is unresponsive — the delivery-rate then tracks + `(1−unresponsive_frac)^blend_hops`. Unresponsive nodes still *receive*, but they are routing holes, + so a delivered flood can strand pockets; a higher peering degree supplies redundant paths that keep + coverage high. This axis affects propagation only, not the adversary metrics. +- **Deanonymization:** relays are picked *blind to who is adversarial*, so P(the whole blend path is + adversarial) is the exact hypergeometric `C(n_adv, blend_hops) / C(N−1, blend_hops)` ≈ + `f_adv^blend_hops` (**deanon_rate**) — placement-independent, driven by path length, not degree. + Multiplying by the fraction of honest nodes with ≥1 adversary peer (`observed_frac`, which the + worst-case-coverage placement maximizes) gives **full_deanon_rate** — the honest sender is *also* + directly exposed, so the message is tied to its originator. Lengthening the blend path is the + dominant defence; a higher degree speeds propagation but *raises* the chance a sender directly + touches the adversary. Both are exact at every N (no Monte-Carlo), like the other adversary metrics. + +## Quick start +``` +make install # or reuse a sibling venv: PYTHONPATH=src -m pd.sweep ... +make smoke # fast end-to-end -> runs/_smoke/{propagation,adversary}.parquet + figures/ +make verify # analytic checks (closed forms + graph invariants) +make test # unit tests +make sweep # configs/default.yaml (N up to 1e5, both adversary modes) +make sweep-fullscale # configs/fullscale.yaml (N up to 1e6, random-mode exact) +make figures RUN=runs/ +``` + +## Outputs +Three parquets per run: `propagation.parquet` (`full_delay_ms_*`, `delivery_rate`, `frac_reached`, +`coverN_ms` vs degree / blend_hops / N / unresponsive_frac), `adversary.parquet` (`observed_frac` / +`eclipsed_frac` vs degree / f_adv / mode, random + worst-case envelope), and `deanon.parquet` +(`deanon_rate` / `full_deanon_rate` vs degree / blend_hops / f_adv / mode — propagation paths crossed +with the adversary set). Figures render all three, including delivery-rate and flood-coverage vs the +unresponsive fraction and the deanonymization rates vs blend-path length, f_adv, and degree. + +## Layout +`src/pd/`: `graph` (matching-union CSR d-regular), `propagation` (Blend cascade), `mixclock` +(release-clock residual), `adversary` (exact observation/eclipse + deanonymization + placement), +`config`/`engine`/`sweep`/`metrics`, `plotting`. `configs/` sweeps, `tests/`, `scripts/` shims. +Reports of record live outside the sim at `reports/blend/pd/`. diff --git a/tools/simulators/blend/pd/configs/default.yaml b/tools/simulators/blend/pd/configs/default.yaml new file mode 100644 index 0000000..850ef76 --- /dev/null +++ b/tools/simulators/blend/pd/configs/default.yaml @@ -0,0 +1,13 @@ +# Main study grid (N up to 1e5; both adversary modes; degree is the primary axis). +n_nodes: [1000, 10000, 100000] +degree: [3, 4, 6, 8, 12, 16] +blend_hops: [1, 2, 3, 5] +max_blend_delay: [3] +unresponsive_frac: [0.0, 0.05, 0.1, 0.2, 0.3, 0.5] +f_adv: [0.05, 0.1, 0.2, 0.33, 0.5] +adversary_mode: [random, worstcase_coverage, worstcase_eclipse] +seeds: 8 +base: + n_rounds: 200 + n_placements: 8 + worstcase_max_n: 100000 diff --git a/tools/simulators/blend/pd/configs/fullscale.yaml b/tools/simulators/blend/pd/configs/fullscale.yaml new file mode 100644 index 0000000..92e2f23 --- /dev/null +++ b/tools/simulators/blend/pd/configs/fullscale.yaml @@ -0,0 +1,14 @@ +# Internet-scale: N up to 1e6. Propagation is sampled (n_rounds); adversary metrics are exact. +# Worst-case placement is capped (worstcase_max_n) so only `random` runs at 1e6. +n_nodes: [100000, 1000000] +degree: [4, 6, 8, 12, 16] +blend_hops: [3] +max_blend_delay: [3] +unresponsive_frac: [0.0, 0.1, 0.2, 0.5] +f_adv: [0.1, 0.2, 0.33] +adversary_mode: [random] +seeds: 3 +base: + n_rounds: 64 + n_placements: 3 + worstcase_max_n: 100000 diff --git a/tools/simulators/blend/pd/configs/smoke.yaml b/tools/simulators/blend/pd/configs/smoke.yaml new file mode 100644 index 0000000..0a3ebca --- /dev/null +++ b/tools/simulators/blend/pd/configs/smoke.yaml @@ -0,0 +1,12 @@ +# Fast end-to-end smoke run (seconds): tiny N, few rounds/seeds, both adversary modes. +n_nodes: [1000] +degree: [4, 8] +blend_hops: [2, 3] +max_blend_delay: [0, 3] +unresponsive_frac: [0.0, 0.2, 0.5] +f_adv: [0.1, 0.33] +adversary_mode: [random, worstcase_coverage] +seeds: 2 +base: + n_rounds: 20 + n_placements: 2 diff --git a/tools/simulators/blend/pd/pyproject.toml b/tools/simulators/blend/pd/pyproject.toml new file mode 100644 index 0000000..458a292 --- /dev/null +++ b/tools/simulators/blend/pd/pyproject.toml @@ -0,0 +1,43 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "pd" +version = "0.1.0" +description = "Peering-degree Monte-Carlo graph simulator for the Blend network" +requires-python = ">=3.11" +dependencies = [ + "numpy", + "pandas", + "pyarrow", + "matplotlib", + "scipy", + "pyyaml", + "tqdm", + "joblib", +] + +[project.optional-dependencies] +dev = ["pytest", "pytest-xdist", "ruff", "mypy"] + +[project.scripts] +pd-sweep = "pd.sweep:main" +pd-verify = "pd.verify:main" +pd-figures = "pd.plotting.make_figures:main" + +[tool.hatch.build.targets.wheel] +packages = ["src/pd"] + +[tool.ruff] +line-length = 100 +target-version = "py311" + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B", "NPY"] +ignore = ["E741"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-q" +markers = ["slow: marks slow tests (deselect with -m 'not slow')"] diff --git a/tools/simulators/blend/pd/requirements-dev.txt b/tools/simulators/blend/pd/requirements-dev.txt new file mode 100644 index 0000000..aefbcb6 --- /dev/null +++ b/tools/simulators/blend/pd/requirements-dev.txt @@ -0,0 +1 @@ +-e .[dev] diff --git a/tools/simulators/blend/pd/requirements.txt b/tools/simulators/blend/pd/requirements.txt new file mode 100644 index 0000000..d6e1198 --- /dev/null +++ b/tools/simulators/blend/pd/requirements.txt @@ -0,0 +1 @@ +-e . diff --git a/tools/simulators/blend/pd/scripts/make_figures.py b/tools/simulators/blend/pd/scripts/make_figures.py new file mode 100644 index 0000000..be5fbe5 --- /dev/null +++ b/tools/simulators/blend/pd/scripts/make_figures.py @@ -0,0 +1,11 @@ +#!/usr/bin/env python3 +"""Shim: add src/ to sys.path, then run pd.plotting.make_figures:main.""" +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + +from pd.plotting.make_figures import main # noqa: E402 + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/simulators/blend/pd/scripts/run_sweep.py b/tools/simulators/blend/pd/scripts/run_sweep.py new file mode 100644 index 0000000..9e18f86 --- /dev/null +++ b/tools/simulators/blend/pd/scripts/run_sweep.py @@ -0,0 +1,11 @@ +#!/usr/bin/env python3 +"""Shim: add src/ to sys.path, then run pd.sweep:main.""" +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + +from pd.sweep import main # noqa: E402 + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/simulators/blend/pd/scripts/verify.py b/tools/simulators/blend/pd/scripts/verify.py new file mode 100644 index 0000000..7863933 --- /dev/null +++ b/tools/simulators/blend/pd/scripts/verify.py @@ -0,0 +1,11 @@ +#!/usr/bin/env python3 +"""Shim: add src/ to sys.path, then run pd.verify:main.""" +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + +from pd.verify import main # noqa: E402 + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/simulators/blend/pd/src/pd/__init__.py b/tools/simulators/blend/pd/src/pd/__init__.py new file mode 100644 index 0000000..8214a75 --- /dev/null +++ b/tools/simulators/blend/pd/src/pd/__init__.py @@ -0,0 +1,3 @@ +"""pd — peering-degree Monte-Carlo graph simulator for the Blend network.""" + +__version__ = "0.1.0" diff --git a/tools/simulators/blend/pd/src/pd/adversary.py b/tools/simulators/blend/pd/src/pd/adversary.py new file mode 100644 index 0000000..8a37766 --- /dev/null +++ b/tools/simulators/blend/pd/src/pd/adversary.py @@ -0,0 +1,162 @@ +"""Adversary observation + eclipse metrics (exact, O(N*degree)) and placement strategies. + +- ``adversary_metrics``: given a boolean adversary mask, count the honest nodes peered with >=1 + adversary (**observed**) and the honest nodes whose EVERY peer is adversarial (**eclipsed**), + via one sparse reduction over the CSR. Exact at every N (incl. 1e6). +- ``place_adversary``: pick the adversary set. ``random`` (average case) scales to 1e6; the + worst-case greedy strategies (``worstcase_coverage``/``worstcase_eclipse``) characterize the + security *envelope* and are meant for N <= ``worstcase_max_n``. ``worstcase_degree`` is + degenerate on a strict d-regular graph (all degrees equal) and coincides with ``random``. +""" + +from __future__ import annotations + +import heapq + +import numpy as np + +from .config import WORSTCASE_MODES +from .graph import Graph + + +def adversary_metrics(graph: Graph, adv_mask: np.ndarray) -> dict: + """Exact observation/eclipse counts for the given adversary mask.""" + adv = adv_mask + honest = ~adv + counts = np.add.reduceat(adv[graph.indices].astype(np.int32), graph.indptr[:-1]) + observed = honest & (counts >= 1) + eclipsed = honest & (counts == graph.degree) + n_adv = int(adv.sum()) + n_honest = int(honest.sum()) + obs = int(observed.sum()) + ecl = int(eclipsed.sum()) + return { + "n_adv": n_adv, + "n_honest": n_honest, + "observed_count": obs, + "observed_frac": float(obs / n_honest) if n_honest else 0.0, + "eclipsed_count": ecl, + "eclipsed_frac": float(ecl / n_honest) if n_honest else 0.0, + "mean_adv_peers_honest": float(counts[honest].mean()) if n_honest else 0.0, + "max_adv_peers": int(counts.max()) if graph.n else 0, + } + + +def deanon_metrics(n: int, n_adv: int, observed_frac: float, blend_hops: int) -> dict: + """Exact deanonymization rates for an honest sender whose message traverses ``blend_hops`` + relays chosen uniformly *blind to who is adversarial* (the sender cannot know who is honest). + + * ``deanon_rate`` — P(**every** relay on the path is adversarial): the adversary then controls + the whole blend cascade and links the message from entry to exit. Because the relays are a + uniform draw, this is the hypergeometric ``C(n_adv, k) / C(n-1, k)`` (an honest sender leaves + all ``n_adv`` adversaries in the ``n-1``-node relay pool). It therefore depends only on the + adversary *count*, not the placement, and ~ ``f_adv**blend_hops``. + * ``full_deanon_rate`` — additionally the honest sender is directly peered with >=1 adversary, + so the adversary also ties the message to its originator. The sender is uniform over honest + nodes, so this is ``deanon_rate * observed_frac``; ``observed_frac`` (the honest-node fraction + with an adversary peer) is placement-dependent, so full deanonymization carries the + placement's fingerprint (worst-case coverage drives it up). + + Both are exact at every N -- no Monte-Carlo -- matching the other adversary metrics. + """ + k = int(blend_hops) + deanon = 0.0 + if 1 <= k <= n_adv and k <= n - 1: + deanon = 1.0 + for i in range(k): + deanon *= (n_adv - i) / (n - 1 - i) # C(n_adv,k)/C(n-1,k), stable for small k + return {"deanon_rate": float(deanon), "full_deanon_rate": float(deanon * observed_frac)} + + +def _peers(graph: Graph, v: int) -> np.ndarray: + return graph.indices[graph.indptr[v]:graph.indptr[v + 1]] + + +def _greedy_coverage(graph: Graph, n_adv: int, rng: np.random.Generator) -> np.ndarray: + """Lazy-greedy (CELF) maximum-coverage: pick adversaries covering the most honest neighbours.""" + n, degree = graph.n, graph.degree + adv = np.zeros(n, dtype=bool) + covered = np.zeros(n, dtype=bool) + + def gain(v: int) -> int: + pu = _peers(graph, v) + return int(np.count_nonzero(~adv[pu] & ~covered[pu])) + + heap = [(-degree, int(v)) for v in range(n)] + heapq.heapify(heap) + chosen: list[int] = [] + while len(chosen) < n_adv and heap: + neg, v = heapq.heappop(heap) + if adv[v]: + continue + g = gain(v) + if -neg != g: + heapq.heappush(heap, (-g, v)) + continue + adv[v] = True + chosen.append(v) + pu = _peers(graph, v) + covered[pu[~adv[pu] & ~covered[pu]]] = True + return np.asarray(chosen, dtype=np.int64) + + +def _greedy_eclipse(graph: Graph, n_adv: int, rng: np.random.Generator) -> np.ndarray: + """Greedy fully-surround: repeatedly finish the honest node closest to eclipse. + + Heuristic (eclipse maximization is NP-hard) — an upper-envelope, not exact. + """ + n, degree = graph.n, graph.degree + adv = np.zeros(n, dtype=bool) + remaining = np.full(n, degree, dtype=np.int64) # honest node's peers not yet adversary + budget = int(n_adv) + heap = [(degree, int(u)) for u in range(n)] + heapq.heapify(heap) + while budget > 0 and heap: + rem_u, u = heapq.heappop(heap) + if adv[u]: + continue + cur = int(remaining[u]) + if cur != rem_u: + heapq.heappush(heap, (cur, u)) + continue + if cur == 0: + continue + pu = _peers(graph, u) + tozap = pu[~adv[pu]] + if tozap.shape[0] > budget: + tozap = tozap[:budget] + for z in tozap.tolist(): + if adv[z]: + continue + adv[z] = True + budget -= 1 + remaining[_peers(graph, z)] -= 1 + return np.where(adv)[0] + + +def place_adversary(graph: Graph, f_adv: float, mode: str, rng: np.random.Generator, + worstcase_max_n: int) -> np.ndarray: + """Return a boolean adversary mask of size ``graph.n``.""" + n = graph.n + n_adv = int(round(f_adv * n)) + mask = np.zeros(n, dtype=bool) + if n_adv <= 0: + return mask + if n_adv >= n: + mask[:] = True + return mask + if mode in ("random", "worstcase_degree"): + # d-regular: all degrees equal, so degree-ranked == uniform-random. + mask[rng.choice(n, size=n_adv, replace=False)] = True + return mask + if mode not in WORSTCASE_MODES: + raise ValueError(f"unknown adversary_mode {mode!r}") + if n > worstcase_max_n: + raise ValueError( + f"worst-case mode {mode!r} at N={n} exceeds worstcase_max_n={worstcase_max_n}; " + f"run worst-case only at smaller N (the engine skips it above the cap).") + if mode == "worstcase_coverage": + mask[_greedy_coverage(graph, n_adv, rng)] = True + else: # worstcase_eclipse + mask[_greedy_eclipse(graph, n_adv, rng)] = True + return mask diff --git a/tools/simulators/blend/pd/src/pd/config.py b/tools/simulators/blend/pd/src/pd/config.py new file mode 100644 index 0000000..7f75376 --- /dev/null +++ b/tools/simulators/blend/pd/src/pd/config.py @@ -0,0 +1,151 @@ +"""Configuration dataclasses for single runs and parameter sweeps.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Literal + +from . import constants + +LatencyDist = Literal["geo", "fixed", "uniform", "exp"] +AdversaryMode = Literal[ + "random", "worstcase_coverage", "worstcase_eclipse", "worstcase_degree" +] +_DISTS = ("geo", "fixed", "uniform", "exp") +_MODES = ("random", "worstcase_coverage", "worstcase_eclipse", "worstcase_degree") +WORSTCASE_MODES = ("worstcase_coverage", "worstcase_eclipse", "worstcase_degree") + + +@dataclass(frozen=True) +class SimConfig: + """A single fully-specified graph cell (one topology + one propagation/adversary setting).""" + + # --- network --- + n_nodes: int = 1000 # must be even (matching-union construction) + degree: int = 8 # peering degree — the primary study axis + + # --- propagation (Blend cascade, delays in ms) --- + blend_hops: int = 3 # relay-path length (swept) + max_blend_delay: int = 3 # free-running release-clock max interval, whole SECONDS + unresponsive_frac: float = 0.0 # ratio of nodes that do NOT relay any messages (swept) + n_rounds: int = 200 # random-sender rounds per topology + transport_jitter_mean_ms: float = 5.0 + processing_lags_ms: tuple[float, ...] = (10.0, 50.0, 100.0) + processing_lag_probs: tuple[float, ...] = (0.5, 0.4, 0.1) + link_latency_dist: LatencyDist = "geo" + link_latency_mean_ms: float = constants.GEO_LATENCY_MEAN_MS # only used by non-geo dists + coverage_pcts: tuple[float, ...] = (50.0, 90.0, 99.0) + + # --- adversary --- + f_adv: float = 0.0 + adversary_mode: AdversaryMode = "random" + n_placements: int = 4 # random-placement sub-replicates (worstcase -> 1) + worstcase_max_n: int = 100_000 # cap greedy worst-case strategies above this N + + # --- bookkeeping --- + graph_seed: int = 0 # the "global seed" / topology-ensemble index + replicate: int = 0 + root_seed: int = 12345 + + def __post_init__(self) -> None: + if self.n_nodes % 2 != 0: + raise ValueError(f"n_nodes must be even, got {self.n_nodes}") + if not (1 <= self.degree < self.n_nodes): + raise ValueError(f"need 1 <= degree < n_nodes ({self.degree} vs {self.n_nodes})") + if not (1 <= self.blend_hops < self.n_nodes): + raise ValueError(f"need 1 <= blend_hops < n_nodes, got {self.blend_hops}") + if self.max_blend_delay < 0: + raise ValueError("max_blend_delay must be >= 0 (whole seconds)") + if not (0.0 <= self.unresponsive_frac < 1.0): + raise ValueError(f"need 0 <= unresponsive_frac < 1, got {self.unresponsive_frac}") + if not (0.0 <= self.f_adv < 1.0): + raise ValueError(f"need 0 <= f_adv < 1, got {self.f_adv}") + if self.n_rounds < 1 or self.n_placements < 1: + raise ValueError("n_rounds and n_placements must be >= 1") + if len(self.processing_lags_ms) != len(self.processing_lag_probs): + raise ValueError("processing_lags_ms and processing_lag_probs must have equal length") + if abs(sum(self.processing_lag_probs) - 1.0) > 1e-9: + raise ValueError( + f"processing_lag_probs must sum to 1, got {sum(self.processing_lag_probs)}") + if any(p < 0 for p in self.processing_lag_probs): + raise ValueError("processing_lag_probs must be non-negative") + if self.link_latency_dist not in _DISTS: + raise ValueError(f"link_latency_dist must be one of {_DISTS}") + if self.adversary_mode not in _MODES: + raise ValueError(f"adversary_mode must be one of {_MODES}") + + @property + def n_adv(self) -> int: + return int(round(self.f_adv * self.n_nodes)) + + @property + def n_honest(self) -> int: + return self.n_nodes - self.n_adv + + def key(self) -> tuple: + """Hashable identity used to seed RNGs deterministically.""" + return ( + self.n_nodes, self.degree, self.blend_hops, self.max_blend_delay, + self.unresponsive_frac, self.n_rounds, + self.transport_jitter_mean_ms, self.processing_lags_ms, self.processing_lag_probs, + self.link_latency_dist, self.link_latency_mean_ms, self.coverage_pcts, + self.f_adv, self.adversary_mode, self.n_placements, self.worstcase_max_n, + self.graph_seed, self.replicate, self.root_seed, + ) + + +_TUPLE_FIELDS = ("processing_lags_ms", "processing_lag_probs", "coverage_pcts") + + +@dataclass +class SweepConfig: + """Grids for a sweep: topologies (n x degree x graph_seed) plus the propagation and + adversary sub-grids that every topology is measured over.""" + + n_nodes: list[int] = field(default_factory=lambda: [1000]) + degree: list[int] = field(default_factory=lambda: [3, 4, 6, 8, 12, 16]) + blend_hops: list[int] = field(default_factory=lambda: [3]) + max_blend_delay: list[int] = field(default_factory=lambda: [3]) + unresponsive_frac: list[float] = field(default_factory=lambda: [0.0]) + f_adv: list[float] = field(default_factory=lambda: [0.1, 0.2, 0.33, 0.5]) + adversary_mode: list[str] = field(default_factory=lambda: ["random"]) + seeds: int = 8 # number of graph_seed values (topology ensemble) + base: dict[str, Any] = field(default_factory=dict) + + def base_config(self, n_nodes: int, degree: int, graph_seed: int) -> SimConfig: + b = dict(self.base) + for k in _TUPLE_FIELDS: + if k in b and b[k] is not None: + b[k] = tuple(b[k]) + b.pop("n_nodes", None) + b.pop("degree", None) + b.pop("graph_seed", None) + return SimConfig(n_nodes=n_nodes, degree=degree, graph_seed=graph_seed, **b) + + def graph_cells(self) -> list[tuple[int, int, int]]: + """Distinct topologies to build: (n_nodes, degree, graph_seed).""" + return [(n, d, g) for n in self.n_nodes for d in self.degree for g in range(self.seeds)] + + def prop_grid(self) -> list[tuple[int, int]]: + """(blend_hops, max_blend_delay) settings each topology is measured over.""" + return [(bh, md) for bh in self.blend_hops for md in self.max_blend_delay] + + def adv_grid(self) -> list[tuple[float, str]]: + """(f_adv, adversary_mode) settings; f_adv==0 keeps only one (mode-irrelevant) row.""" + out: list[tuple[float, str]] = [] + for f in self.f_adv: + modes = self.adversary_mode if f > 0 else self.adversary_mode[:1] + for m in modes: + out.append((f, m)) + return out + + @classmethod + def from_dict(cls, d: dict[str, Any]) -> SweepConfig: + d = dict(d) + base = d.pop("base", {}) + known = {"n_nodes", "degree", "blend_hops", "max_blend_delay", "unresponsive_frac", + "f_adv", "adversary_mode", "seeds"} + unknown = set(d) - known + if unknown: + raise ValueError(f"unknown sweep keys: {sorted(unknown)}") + return cls(base=base, **{k: v for k, v in d.items() if k in known}) diff --git a/tools/simulators/blend/pd/src/pd/constants.py b/tools/simulators/blend/pd/src/pd/constants.py new file mode 100644 index 0000000..acb0034 --- /dev/null +++ b/tools/simulators/blend/pd/src/pd/constants.py @@ -0,0 +1,24 @@ +"""Network-latency constants for pd. All delays are in **milliseconds**. + +One-way, application-level latency between two directly-peered nodes, bucketed by the +geographic relationship of the peers (~ RTT/2 from public latency measurements plus a little +gossip processing overhead). In a globally distributed node set a random peer is usually on +another continent, so most peer links fall in the long-latency bands. A message gossip-floods +over the peering graph, so its delay to a far node is the sum of a few such per-link latencies +along the fastest (Dijkstra) path — see graph.py / propagation.py. +""" + +from __future__ import annotations + +GEO_LATENCY_BANDS_MS = ( + 15.0, # metro / same country (~15 ms one-way) + 40.0, # same continent, e.g. EU<->EU (~40 ms) + 90.0, # transatlantic, e.g. EU<->US-East (~90 ms) + 200.0, # antipodal, e.g. EU<->AU / EU<->JP (~200 ms) +) +# Share of random peer links falling in each band for a globally distributed node set. +GEO_LATENCY_WEIGHTS = (0.15, 0.35, 0.35, 0.15) +# Mean one-way latency of a random global peer link under the mixture above (~78.75 ms). +GEO_LATENCY_MEAN_MS = sum( + b * w for b, w in zip(GEO_LATENCY_BANDS_MS, GEO_LATENCY_WEIGHTS, strict=True) +) diff --git a/tools/simulators/blend/pd/src/pd/engine.py b/tools/simulators/blend/pd/src/pd/engine.py new file mode 100644 index 0000000..26417b6 --- /dev/null +++ b/tools/simulators/blend/pd/src/pd/engine.py @@ -0,0 +1,81 @@ +"""Build-once engine: one topology -> propagation rows (per blend setting) + adversary rows. + +The graph build and propagation depend only on ``(n_nodes, degree, graph_seed)``; the adversary +metrics are cheap and exact. So a topology is built once and measured across the whole +propagation and adversary sub-grids. +""" + +from __future__ import annotations + +import numpy as np + +from .adversary import adversary_metrics, deanon_metrics, place_adversary +from .config import WORSTCASE_MODES, SimConfig +from .graph import build_graph +from .metrics import adversary_row, deanon_row, propagation_row +from .propagation import assign_responsive, propagation_metrics +from .rng import placement_seedseq, responsive_seedseq, round_seedseq + + +def run_graph_cell(base: SimConfig, prop_grid: list[tuple[int, int]], + unresponsive_fracs: list[float], + adv_grid: list[tuple[float, str]], + ) -> tuple[list[dict], list[dict], list[dict]]: + """Build ``base``'s topology once; return (propagation, adversary, deanonymization rows). + + ``base`` carries the topology (n_nodes, degree, graph_seed) and all shared knobs; + ``prop_grid`` = [(blend_hops, max_blend_delay)], ``unresponsive_fracs`` = the relay-dropout + axis (propagation-only), ``adv_grid`` = [(f_adv, mode)]. Deanonymization crosses each adversary + placement with the propagation grid's blend-path lengths, so it is emitted alongside the + adversary rows. + """ + graph = build_graph(base) + blend_hops_set = sorted({bh for bh, _ in prop_grid}) + + prop_rows: list[dict] = [] + for uf in unresponsive_fracs: + responsive = assign_responsive( + base.n_nodes, uf, np.random.default_rng(responsive_seedseq(base, uf))) + for blend_hops, max_blend_delay in prop_grid: + rng = np.random.default_rng(round_seedseq(base, blend_hops, max_blend_delay, uf)) + prop = propagation_metrics( + graph, blend_hops, max_blend_delay, uf, responsive, base, rng) + prop_rows.append(propagation_row(base, blend_hops, max_blend_delay, uf, prop)) + + adv_rows: list[dict] = [] + deanon_rows: list[dict] = [] + for f_adv, mode in adv_grid: + if mode in WORSTCASE_MODES and base.n_nodes > base.worstcase_max_n: + continue # worst-case is an envelope characterized at N <= worstcase_max_n + n_placements = base.n_placements if mode == "random" else 1 + for rep in range(n_placements): + rng = np.random.default_rng(placement_seedseq(base, f_adv, mode, rep)) + adv_mask = place_adversary(graph, f_adv, mode, rng, base.worstcase_max_n) + adv = adversary_metrics(graph, adv_mask) + adv_rows.append(adversary_row(base, f_adv, mode, rep, adv)) + for bh in blend_hops_set: + dz = deanon_metrics(graph.n, adv["n_adv"], adv["observed_frac"], bh) + deanon_rows.append(deanon_row(base, bh, f_adv, mode, rep, adv, dz)) + + return prop_rows, adv_rows, deanon_rows + + +def run_trajectory(config: SimConfig) -> dict: + """Single-cell convenience for tests/verify: build the graph, run one propagation cell + (``config.blend_hops``/``config.max_blend_delay``) and one adversary cell.""" + graph = build_graph(config) + uf = config.unresponsive_frac + responsive = assign_responsive( + config.n_nodes, uf, np.random.default_rng(responsive_seedseq(config, uf))) + prng = np.random.default_rng( + round_seedseq(config, config.blend_hops, config.max_blend_delay, uf)) + prop = propagation_metrics( + graph, config.blend_hops, config.max_blend_delay, uf, responsive, config, prng) + arng = np.random.default_rng( + placement_seedseq(config, config.f_adv, config.adversary_mode, config.replicate)) + adv_mask = place_adversary( + graph, config.f_adv, config.adversary_mode, arng, config.worstcase_max_n) + adv = adversary_metrics(graph, adv_mask) + deanon = deanon_metrics(graph.n, adv["n_adv"], adv["observed_frac"], config.blend_hops) + return {"graph": graph, "propagation": prop, "adversary": adv, "adv_mask": adv_mask, + "deanon": deanon} diff --git a/tools/simulators/blend/pd/src/pd/graph.py b/tools/simulators/blend/pd/src/pd/graph.py new file mode 100644 index 0000000..9f0b2bb --- /dev/null +++ b/tools/simulators/blend/pd/src/pd/graph.py @@ -0,0 +1,179 @@ +"""Deterministic random d-regular peer graph as a sparse CSR, scalable to 1e6 nodes. + +Construction: a **union of `degree` random perfect matchings** (fully vectorized), repaired to +drop the few parallel edges (expected count ~ C(degree,2), independent of N) so the result is +EXACTLY d-regular, simple, undirected — and reconstructible from one global seed. Preferred over +the tsi ring-lattice + Maslov-Sneppen generator: O(N*degree) (~seconds at 1e6 vs minutes of +Python swaps) and genuinely low-diameter immediately. + +The ``Graph`` holds the undirected CSR adjacency (``indptr``/``indices`` — drives the exact +adversary reductions) plus the directed per-edge base latency (``base``) and source node +(``src``), and the per-node processing lags (``p``), which drive the per-round directed Dijkstra +in ``propagation.py``. +""" + +from __future__ import annotations + +from collections import defaultdict +from dataclasses import dataclass + +import numpy as np +from scipy.sparse import csr_matrix + +from . import latency +from .config import SimConfig +from .memguard import check_alloc +from .rng import graph_seedseq + + +@dataclass(frozen=True) +class Graph: + n: int + degree: int + indptr: np.ndarray # (n+1,) CSR row pointers (contiguous degree-length blocks) + indices: np.ndarray # (2E,) peers of each node, grouped by source; 2E = n*degree + base: np.ndarray # (2E,) geo base latency (ms) per directed edge, CSR-aligned + src: np.ndarray # (2E,) source node per directed edge, CSR-aligned + p: np.ndarray # (n,) per-node processing lag (ms) + + @property + def n_edges(self) -> int: + return int(self.indices.shape[0] // 2) + + def weighted_csr(self, data: np.ndarray) -> csr_matrix: + """A directed CSR sharing this graph's sparsity, with the given per-edge ``data``.""" + return csr_matrix((data, self.indices, self.indptr), shape=(self.n, self.n)) + + +def build_regular_edges(n: int, degree: int, rng: np.random.Generator) -> np.ndarray: + """Exactly d-regular simple undirected edge list, shape (E, 2) with u < v. + + Requires ``n`` even and ``1 <= degree < n``. Deterministic in ``rng``. + """ + if n % 2 != 0: + raise ValueError("n must be even") + if not (1 <= degree < n): + raise ValueError("need 1 <= degree < n") + h = n // 2 + lo = np.empty(degree * h, dtype=np.int64) + hi = np.empty(degree * h, dtype=np.int64) + for m in range(degree): + perm = rng.permutation(n) + a, b = perm[0::2], perm[1::2] + lo[m * h:(m + 1) * h] = np.minimum(a, b) + hi[m * h:(m + 1) * h] = np.maximum(a, b) + # drop parallel edges (keep first occurrence of each undirected pair) + key = lo * n + hi + order = np.argsort(key, kind="stable") + ks = key[order] + first = np.ones(ks.shape[0], dtype=bool) + first[1:] = ks[1:] != ks[:-1] + edges = np.stack([lo[order][first], hi[order][first]], axis=1) + deg = np.bincount(edges.ravel(), minlength=n) + if np.all(deg == degree): + return edges + return _repair_to_regular(edges, deg, n, degree, rng) + + +def _repair_to_regular(edges_arr: np.ndarray, deg: np.ndarray, n: int, degree: int, + rng: np.random.Generator, max_passes: int = 100_000) -> np.ndarray: + """Restore exact d-regularity after dedup dropped a few parallel edges. + + Re-pairs the freed stubs (a tiny set, size ~ 2*C(degree,2)) into new simple edges, using a + single Maslov-Sneppen swap against a full-degree edge whenever a pass makes no progress. + """ + deficient = np.where(deg < degree)[0] + defset = set(int(x) for x in deficient) + nbr: dict[int, set[int]] = defaultdict(set) + mask = np.isin(edges_arr[:, 0], deficient) | np.isin(edges_arr[:, 1], deficient) + for a, b in edges_arr[mask].tolist(): + if a in defset: + nbr[a].add(b) + if b in defset: + nbr[b].add(a) + edges: list[list[int]] = edges_arr.tolist() + deg = deg.astype(np.int64).copy() + + def _swap_connect(x: int, y: int) -> bool: + for _ in range(500): + idx = int(rng.integers(0, len(edges))) + a, b = edges[idx] + if deg[a] != degree or deg[b] != degree: # only rewire full-degree edges + continue + if a in (x, y) or b in (x, y): + continue + if a in nbr[x] or b in nbr[y]: + continue + # remove (a,b); add (x,a) and (y,b) -> x,y gain a peer; a,b unchanged + if a in defset: + nbr[a].discard(b) + nbr[a].add(x) + if b in defset: + nbr[b].discard(a) + nbr[b].add(y) + edges[idx] = [min(x, a), max(x, a)] + edges.append([min(y, b), max(y, b)]) + nbr[x].add(a) + nbr[y].add(b) + deg[x] += 1 + deg[y] += 1 + return True + return False + + for _ in range(max_passes): + stubs: list[int] = [] + for node in defset: + stubs.extend([node] * int(degree - deg[node])) + if not stubs: + break + rng.shuffle(stubs) + progressed = False + leftover: list[int] = [] + i = 0 + while i < len(stubs) - 1: + x, y = int(stubs[i]), int(stubs[i + 1]) + i += 2 + if x != y and y not in nbr[x]: + edges.append([min(x, y), max(x, y)]) + nbr[x].add(y) + nbr[y].add(x) + deg[x] += 1 + deg[y] += 1 + progressed = True + else: + leftover.extend([x, y]) + if i == len(stubs) - 1: + leftover.append(int(stubs[-1])) + if not progressed and leftover: + x = leftover[0] + y = leftover[1] if len(leftover) > 1 else leftover[0] + if not _swap_connect(x, y): + raise RuntimeError("graph repair swap failed") + + out = np.asarray(edges, dtype=np.int64) + if not np.all(np.bincount(out.ravel(), minlength=n) == degree): + raise RuntimeError("graph repair failed to reach exact d-regularity") + return out + + +def build_graph(config: SimConfig) -> Graph: + """Build the exact d-regular peer graph + directed base/src arrays + per-node lags. + + Everything is a pure function of ``graph_seedseq(config)`` (topology fields only). + """ + n, degree = config.n_nodes, config.degree + check_alloc(int(n * degree * 24), "d-regular CSR (indices+base+src)", + f"N={n}, degree={degree}") + rng = np.random.default_rng(graph_seedseq(config)) + edges = build_regular_edges(n, degree, rng) + base_u = latency.sample_link_latencies(edges.shape[0], config, rng) + p = latency.assign_processing_lags(n, config, rng) + rows = np.concatenate([edges[:, 0], edges[:, 1]]) + cols = np.concatenate([edges[:, 1], edges[:, 0]]) + data = np.concatenate([base_u, base_u]) + csr = csr_matrix((data, (rows, cols)), shape=(n, n)) + csr.sort_indices() + indptr = csr.indptr.astype(np.int64) + src = np.repeat(np.arange(n, dtype=np.int64), np.diff(indptr)) + return Graph(n=n, degree=degree, indptr=indptr, indices=csr.indices, + base=csr.data, src=src, p=p) diff --git a/tools/simulators/blend/pd/src/pd/latency.py b/tools/simulators/blend/pd/src/pd/latency.py new file mode 100644 index 0000000..eeb3846 --- /dev/null +++ b/tools/simulators/blend/pd/src/pd/latency.py @@ -0,0 +1,39 @@ +"""Per-link base latencies (ms) and per-node processing lags (ms).""" + +from __future__ import annotations + +import numpy as np + +from . import constants +from .config import SimConfig + + +def sample_link_latencies(n_edges: int, config: SimConfig, rng: np.random.Generator) -> np.ndarray: + """Per-(undirected-)edge one-way base latency in ms. + + ``geo`` draws each link from the real-world geographic band mixture (used as-is, in ms); + ``fixed``/``uniform``/``exp`` have mean ``link_latency_mean_ms``. + """ + dist = config.link_latency_dist + if dist == "geo": + bands = np.asarray(constants.GEO_LATENCY_BANDS_MS, dtype=float) + weights = np.asarray(constants.GEO_LATENCY_WEIGHTS, dtype=float) + idx = rng.choice(bands.shape[0], size=n_edges, p=weights) + return bands[idx] + mean = config.link_latency_mean_ms + if dist == "fixed": + return np.full(n_edges, mean, dtype=float) + if dist == "uniform": + return rng.uniform(0.0, 2.0 * mean, size=n_edges) + if dist == "exp": + return rng.exponential(mean, size=n_edges) + raise ValueError(f"unknown link_latency_dist {dist!r}") + + +def assign_processing_lags(n_nodes: int, config: SimConfig, rng: np.random.Generator) -> np.ndarray: + """Per-node fixed processing lag (ms): a categorical draw over ``processing_lags_ms`` with + weights ``processing_lag_probs`` (e.g. {10,50,100} ms at {0.5,0.4,0.1}).""" + lags = np.asarray(config.processing_lags_ms, dtype=float) + probs = np.asarray(config.processing_lag_probs, dtype=float) + idx = rng.choice(lags.shape[0], size=n_nodes, p=probs) + return lags[idx] diff --git a/tools/simulators/blend/pd/src/pd/memguard.py b/tools/simulators/blend/pd/src/pd/memguard.py new file mode 100644 index 0000000..5a4bddf --- /dev/null +++ b/tools/simulators/blend/pd/src/pd/memguard.py @@ -0,0 +1,55 @@ +"""Fail-loud memory guard for large allocations (copied from tsi-sim-pernode). + +The dominant arrays here are the sparse CSR adjacency (``2E = N*degree`` entries) and the +sampled single-source distance matrices (``S x N`` or ``(blend_hops+1) x N``), which grow with +``N``. Every worker checks the size *before* allocating and raises ``AllocationTooLarge`` if it +would exceed its budget, so an under-sized config fails with a clear message instead of freezing +the machine. + +Budget (``budget_bytes``): ``PD_BYTES_BUDGET`` > 0 -> that many bytes (the sweep sets this to +each worker's RAM share); otherwise ``DEFAULT_BUDGET_FRAC`` of physical RAM. +""" + +from __future__ import annotations + +import os +import subprocess + +DEFAULT_BUDGET_FRAC = 0.9 + + +class AllocationTooLarge(MemoryError): + """A pd array would exceed the memory budget; raised before allocating.""" + + +def total_ram_bytes() -> int: + """Best-effort physical RAM in bytes (POSIX sysconf, then Darwin sysctl, then 8 GB).""" + try: + return os.sysconf("SC_PAGE_SIZE") * os.sysconf("SC_PHYS_PAGES") + except (ValueError, OSError, AttributeError): + pass + try: # macOS lacks SC_PHYS_PAGES + out = subprocess.run(["sysctl", "-n", "hw.memsize"], capture_output=True, text=True) + return int(out.stdout.strip()) + except (OSError, ValueError): + return 8 * 1024**3 + + +def budget_bytes() -> int: + """Per-process byte budget for a single big array (see module docstring).""" + try: + explicit = int(os.environ.get("PD_BYTES_BUDGET", "0")) + except ValueError: + explicit = 0 + if explicit > 0: + return explicit + return int(DEFAULT_BUDGET_FRAC * total_ram_bytes()) + + +def check_alloc(nbytes: int, label: str, detail: str = "") -> None: + """Raise ``AllocationTooLarge`` if allocating ``nbytes`` would exceed the budget.""" + budget = budget_bytes() + if nbytes > budget: + raise AllocationTooLarge( + f"{label} needs {nbytes / 1024**3:.1f} GB > per-process budget " + f"{budget / 1024**3:.1f} GB.{(' ' + detail) if detail else ''}") diff --git a/tools/simulators/blend/pd/src/pd/metrics.py b/tools/simulators/blend/pd/src/pd/metrics.py new file mode 100644 index 0000000..65b3a20 --- /dev/null +++ b/tools/simulators/blend/pd/src/pd/metrics.py @@ -0,0 +1,57 @@ +"""Flat parquet-row builders for the two result tables.""" + +from __future__ import annotations + +from .config import SimConfig + + +def propagation_row(config: SimConfig, blend_hops: int, max_blend_delay: int, + unresponsive_frac: float, prop: dict) -> dict: + return { + "n_nodes": config.n_nodes, + "degree": config.degree, + "blend_hops": blend_hops, + "max_blend_delay": max_blend_delay, + "unresponsive_frac": unresponsive_frac, + "graph_seed": config.graph_seed, + "n_rounds": config.n_rounds, + "transport_jitter_mean_ms": config.transport_jitter_mean_ms, + "processing_lags_ms": str(tuple(config.processing_lags_ms)), + "processing_lag_probs": str(tuple(config.processing_lag_probs)), + **prop, + } + + +def adversary_row(config: SimConfig, f_adv: float, mode: str, placement_rep: int, + adv: dict) -> dict: + return { + "n_nodes": config.n_nodes, + "degree": config.degree, + "f_adv": f_adv, + "adversary_mode": mode, + "graph_seed": config.graph_seed, + "placement_rep": placement_rep, + **adv, + } + + +def deanon_row(config: SimConfig, blend_hops: int, f_adv: float, mode: str, + placement_rep: int, adv: dict, deanon: dict) -> dict: + """One row of the deanonymization table: a (placement x blend-path-length) cell. + + ``blend_hops`` comes from the propagation grid, the rest from the adversary placement; the two + are crossed here because deanonymization is where propagation paths meet the adversary set. + """ + return { + "n_nodes": config.n_nodes, + "degree": config.degree, + "blend_hops": blend_hops, + "f_adv": f_adv, + "adversary_mode": mode, + "graph_seed": config.graph_seed, + "placement_rep": placement_rep, + "n_adv": adv["n_adv"], + "n_honest": adv["n_honest"], + "observed_frac": adv["observed_frac"], + **deanon, + } diff --git a/tools/simulators/blend/pd/src/pd/mixclock.py b/tools/simulators/blend/pd/src/pd/mixclock.py new file mode 100644 index 0000000..d480af1 --- /dev/null +++ b/tools/simulators/blend/pd/src/pd/mixclock.py @@ -0,0 +1,36 @@ +"""Free-running release-clock mixing delay for a blend relay. + +Each relay releases on its own ongoing schedule whose successive intervals are +``S ~ Uniform{0,1,...,max_blend_delay}`` whole seconds (0 = release now), re-drawn after each +release. A message arrives at a stationary random phase relative to this clock, so the mixing +delay it experiences is the renewal-process **residual life** to the next release: + + covering interval S* is size-biased: P(S* = s) proportional to s (s in 1..M); + phase within it is uniform, so residual R = Uniform(0, S*) seconds. + +Mean residual = (2M+1)/6 seconds (= E[S^2]/(2 E[S]) for S ~ Uniform{0..M}). Returned in ms. +""" + +from __future__ import annotations + +import numpy as np + + +def mix_wait(rng: np.random.Generator, max_blend_delay: int, size: int) -> np.ndarray: + """``size`` i.i.d. mixing-delay residuals (ms) for a Uniform{0..max_blend_delay}-sec clock.""" + m = int(max_blend_delay) + if m <= 0 or size <= 0: + return np.zeros(max(size, 0), dtype=float) + s = np.arange(1, m + 1, dtype=float) + probs = s / s.sum() # size-biased over positive intervals + covering = rng.choice(s, size=size, p=probs) + residual_seconds = rng.uniform(0.0, covering) # uniform phase within the covering interval + return residual_seconds * 1000.0 # -> milliseconds + + +def mean_residual_ms(max_blend_delay: int) -> float: + """Analytic mean mixing delay (ms): (2M+1)/6 seconds.""" + m = int(max_blend_delay) + if m <= 0: + return 0.0 + return (2.0 * m + 1.0) / 6.0 * 1000.0 diff --git a/tools/simulators/blend/pd/src/pd/plotting/__init__.py b/tools/simulators/blend/pd/src/pd/plotting/__init__.py new file mode 100644 index 0000000..2d0f432 --- /dev/null +++ b/tools/simulators/blend/pd/src/pd/plotting/__init__.py @@ -0,0 +1 @@ +"""Plotting: shared style + figure builders for pd.""" diff --git a/tools/simulators/blend/pd/src/pd/plotting/figures.py b/tools/simulators/blend/pd/src/pd/plotting/figures.py new file mode 100644 index 0000000..0d176e9 --- /dev/null +++ b/tools/simulators/blend/pd/src/pd/plotting/figures.py @@ -0,0 +1,382 @@ +"""Figure builders for pd. Each takes (prop_df, adv_df) and returns a Figure or None. + +Propagation full delay (ms) vs peering degree / blend-path length / network size, and adversary +observation + eclipse fractions vs adversary fraction / degree (with the worst-case envelope) plus +heatmaps. Slices default to the largest N and a representative setting. +""" + +from __future__ import annotations + +import numpy as np +import pandas as pd + +from . import style + +_MS = "full propagation delay (ms)" + + +def _prov(*dfs: pd.DataFrame) -> str: + ns, seeds = set(), set() + for df in dfs: + if df is not None and len(df): + ns |= set(df["n_nodes"].unique()) + seeds |= set(df["graph_seed"].unique()) + return f"pd | N={sorted(int(x) for x in ns)} seeds={len(seeds)}" + + +def _largest_n(df: pd.DataFrame) -> int: + return int(sorted(df["n_nodes"].unique())[-1]) + + +def delay_vs_degree(prop: pd.DataFrame, adv: pd.DataFrame): + if prop is None or not len(prop): + return None + import matplotlib.pyplot as plt + style.apply_style() + n = _largest_n(prop) + mbd = int(sorted(prop["max_blend_delay"].unique())[0]) + d = prop[(prop.n_nodes == n) & (prop.max_blend_delay == mbd)] + fig, ax = plt.subplots() + for i, bh in enumerate(sorted(d.blend_hops.unique())): + s = d[d.blend_hops == bh].groupby("degree").full_delay_ms_mean.mean().reset_index() + ax.plot(s.degree, s.full_delay_ms_mean, "-o", ms=4, color=style.color_for(i), + label=f"blend_hops={bh}") + ax.set_xlabel("peering degree") + ax.set_ylabel(_MS) + ax.set_title(f"Blend full delay vs peering degree (N={n:,}, max_blend_delay={mbd}s)") + ax.legend() + return fig + + +def _median_degree(d: pd.DataFrame) -> int: + degs = sorted(d.degree.unique()) + return int(degs[len(degs) // 2]) + + +def delivery_vs_unresponsive(prop: pd.DataFrame, adv: pd.DataFrame): + """Message success-delivery-rate vs the unresponsive fraction, one line per blend-path length. + + A message is delivered only if every relay on its (responsiveness-blind) path forwards, so the + rate tracks the analytic ``(1-u)^blend_hops`` cascade-survival law (dashed) and is dominated by + path length, not degree. + """ + if prop is None or not len(prop) or prop["unresponsive_frac"].nunique() < 2: + return None + import matplotlib.pyplot as plt + style.apply_style() + n = _largest_n(prop) + mbd = int(sorted(prop["max_blend_delay"].unique())[0]) + d = prop[(prop.n_nodes == n) & (prop.max_blend_delay == mbd)] + deg = _median_degree(d) + d = d[d.degree == deg] + fig, ax = plt.subplots() + for i, bh in enumerate(sorted(d.blend_hops.unique())): + s = d[d.blend_hops == bh].groupby("unresponsive_frac").delivery_rate.mean().reset_index() + c = style.color_for(i) + ax.plot(s.unresponsive_frac, s.delivery_rate, "-o", ms=4, color=c, + label=f"blend_hops={bh}") + u = np.linspace(0.0, float(s.unresponsive_frac.max()), 50) + ax.plot(u, (1.0 - u) ** bh, "--", lw=0.8, color=c, alpha=0.6) + ax.set_xlabel("unresponsive fraction u") + ax.set_ylabel("message delivery rate") + ax.set_ylim(0.0, 1.02) + ax.set_title(f"Cascade delivery vs unresponsive nodes " + f"(N={n:,}, degree={deg}); dashed = $(1-u)^{{hops}}$") + ax.legend() + return fig + + +def coverage_vs_unresponsive(prop: pd.DataFrame, adv: pd.DataFrame): + """Flood coverage of *delivered* messages vs the unresponsive fraction, one line per degree. + + Unresponsive nodes still receive but do not forward, so they strand pockets of the network; a + higher peering degree supplies redundant paths that keep coverage high as ``u`` rises. + """ + if prop is None or not len(prop) or prop["unresponsive_frac"].nunique() < 2: + return None + import matplotlib.pyplot as plt + style.apply_style() + n = _largest_n(prop) + mbd = int(sorted(prop["max_blend_delay"].unique())[0]) + bh = int(sorted(prop["blend_hops"].unique())[0]) + d = prop[(prop.n_nodes == n) & (prop.max_blend_delay == mbd) & (prop.blend_hops == bh)] + fig, ax = plt.subplots() + for i, deg in enumerate(sorted(d.degree.unique())): + s = d[d.degree == deg].groupby("unresponsive_frac").frac_reached.mean().reset_index() + ax.plot(s.unresponsive_frac, s.frac_reached, "-o", ms=4, color=style.color_for(i), + label=f"degree={deg}") + ax.set_xlabel("unresponsive fraction u") + ax.set_ylabel("flood coverage (fraction reached | delivered)") + ax.set_title(f"Flood coverage vs unresponsive nodes (N={n:,}, blend_hops={bh})") + ax.legend() + return fig + + +def delay_vs_blendhops(prop: pd.DataFrame, adv: pd.DataFrame): + if prop is None or not len(prop) or prop["blend_hops"].nunique() < 2: + return None + import matplotlib.pyplot as plt + style.apply_style() + n = _largest_n(prop) + mbd = int(sorted(prop["max_blend_delay"].unique())[0]) + d = prop[(prop.n_nodes == n) & (prop.max_blend_delay == mbd)] + fig, ax = plt.subplots() + for i, deg in enumerate(sorted(d.degree.unique())): + s = d[d.degree == deg].groupby("blend_hops").full_delay_ms_mean.mean().reset_index() + ax.plot(s.blend_hops, s.full_delay_ms_mean, "-o", ms=4, color=style.color_for(i), + label=f"degree={deg}") + ax.set_xlabel("blend-path hops") + ax.set_ylabel(_MS) + ax.set_title(f"Blend full delay vs path length (N={n:,}, max_blend_delay={mbd}s)") + ax.legend() + return fig + + +def delay_vs_N(prop: pd.DataFrame, adv: pd.DataFrame): + if prop is None or not len(prop) or prop["n_nodes"].nunique() < 2: + return None + import matplotlib.pyplot as plt + style.apply_style() + mbd = int(sorted(prop["max_blend_delay"].unique())[0]) + bh = int(sorted(prop["blend_hops"].unique())[0]) + d = prop[(prop.max_blend_delay == mbd) & (prop.blend_hops == bh)] + fig, ax = plt.subplots() + for i, deg in enumerate(sorted(d.degree.unique())): + s = d[d.degree == deg].groupby("n_nodes").full_delay_ms_mean.mean().reset_index() + ax.plot(s.n_nodes, s.full_delay_ms_mean, "-o", ms=4, color=style.color_for(i), + label=f"degree={deg}") + ax.set_xscale("log") + ax.set_xlabel("network size N") + ax.set_ylabel(_MS) + ax.set_title(f"Blend full delay vs network size (blend_hops={bh}, max_blend_delay={mbd}s)") + ax.legend() + return fig + + +def _adv_vs_fadv(adv: pd.DataFrame, col: str, ylabel: str, title: str): + if adv is None or not len(adv): + return None + import matplotlib.pyplot as plt + style.apply_style() + n = _largest_n(adv) + degs = sorted(adv[adv.n_nodes == n].degree.unique()) + deg = int(degs[len(degs) // 2]) + d = adv[(adv.n_nodes == n) & (adv.degree == deg)] + fig, ax = plt.subplots() + for i, mode in enumerate(sorted(d.adversary_mode.unique())): + s = d[d.adversary_mode == mode].groupby("f_adv")[col].mean().reset_index() + ax.plot(s.f_adv, s[col], "-o", ms=4, color=style.color_for(i), label=mode) + ax.set_xlabel("adversary fraction f_adv") + ax.set_ylabel(ylabel) + ax.set_title(f"{title} (N={n:,}, degree={deg})") + ax.legend() + return fig + + +def observed_vs_fadv(prop, adv): + return _adv_vs_fadv(adv, "observed_frac", "honest observed (fraction)", + "Adversary observation vs f_adv") + + +def eclipse_vs_fadv(prop, adv): + return _adv_vs_fadv(adv, "eclipsed_frac", "honest eclipsed (fraction)", + "Honest eclipse vs f_adv") + + +def _adv_vs_degree(adv: pd.DataFrame, col: str, ylabel: str, title: str): + if adv is None or not len(adv) or adv["degree"].nunique() < 2: + return None + import matplotlib.pyplot as plt + style.apply_style() + n = _largest_n(adv) + d = adv[adv.n_nodes == n] + favs = sorted(d.f_adv.unique()) + f = favs[len(favs) // 2] + d = d[d.f_adv == f] + fig, ax = plt.subplots() + for i, mode in enumerate(sorted(d.adversary_mode.unique())): + s = d[d.adversary_mode == mode].groupby("degree")[col].mean().reset_index() + ax.plot(s.degree, s[col], "-o", ms=4, color=style.color_for(i), label=mode) + ax.set_xlabel("peering degree") + ax.set_ylabel(ylabel) + ax.set_title(f"{title} (N={n:,}, f_adv={f})") + ax.legend() + return fig + + +def observed_vs_degree(prop, adv): + return _adv_vs_degree(adv, "observed_frac", "honest observed (fraction)", + "Adversary observation vs degree") + + +def eclipse_vs_degree(prop, adv): + return _adv_vs_degree(adv, "eclipsed_frac", "honest eclipsed (fraction)", + "Honest eclipse vs degree") + + +def _heatmap(adv: pd.DataFrame, col: str, title: str): + if adv is None or not len(adv) or adv["degree"].nunique() < 2 or adv["f_adv"].nunique() < 2: + return None + import matplotlib.pyplot as plt + style.apply_style() + n = _largest_n(adv) + d = adv[(adv.n_nodes == n) & (adv.adversary_mode == "random")] + if not len(d): + d = adv[adv.n_nodes == n] + piv = d.groupby(["degree", "f_adv"])[col].mean().unstack("f_adv") + fig, ax = plt.subplots() + im = ax.imshow(piv.values, origin="lower", aspect="auto", cmap=style.SEQUENTIAL_CMAP) + ax.set_xticks(range(len(piv.columns)), [f"{c:g}" for c in piv.columns]) + ax.set_yticks(range(len(piv.index)), [str(int(i)) for i in piv.index]) + ax.set_xlabel("adversary fraction f_adv") + ax.set_ylabel("peering degree") + ax.set_title(f"{title} (N={n:,})") + fig.colorbar(im, ax=ax, shrink=0.85) + return fig + + +def heatmap_observed(prop, adv): + return _heatmap(adv, "observed_frac", "Honest observed fraction") + + +def heatmap_eclipse(prop, adv): + return _heatmap(adv, "eclipsed_frac", "Honest eclipsed fraction") + + +# --- deanonymization: propagation paths x the adversary set (3-arg builders) ------------------- + +def _pos(s: pd.Series) -> pd.Series: + """Blank out non-positive rates so they vanish on a log axis instead of erroring.""" + return s.where(s > 0) + + +def deanon_vs_blendhops(prop, adv, deanon): + """P(whole blend path adversarial) vs path length, one line per f_adv (log-y). + + Relays are drawn blind to who is adversarial, so the rate is the exact hypergeometric ~ + ``f_adv**blend_hops`` (dashed) -- lengthening the blend path is the dominant defence, and it is + independent of peering degree. + """ + if deanon is None or not len(deanon) or deanon["blend_hops"].nunique() < 2: + return None + import matplotlib.pyplot as plt + style.apply_style() + n = _largest_n(deanon) + d = deanon[(deanon.n_nodes == n) & (deanon.adversary_mode == "random") & (deanon.f_adv > 0)] + if not len(d): + return None + deg = _median_degree(d) + d = d[d.degree == deg] + fig, ax = plt.subplots() + for i, f in enumerate(sorted(d.f_adv.unique())): + s = d[d.f_adv == f].groupby("blend_hops").deanon_rate.mean().reset_index() + c = style.color_for(i) + ax.plot(s.blend_hops, _pos(s.deanon_rate), "-o", ms=4, color=c, label=f"f_adv={f:g}") + ax.plot(s.blend_hops, f ** s.blend_hops, "--", lw=0.8, color=c, alpha=0.6) + ax.set_yscale("log") + ax.set_xlabel("blend-path hops") + ax.set_ylabel("deanonymization rate\nP(whole path adversarial)") + ax.set_title(f"Deanonymization vs path length (N={n:,}, degree={deg}); " + f"dashed = $f_{{adv}}^{{hops}}$") + ax.legend() + return fig + + +def full_deanon_vs_blendhops(prop, adv, deanon): + """P(whole path adversarial AND sender peered with an adversary) vs path length, per f_adv. + + Dashed = the closed form ``f_adv**hops * (1-(1-f_adv)**degree)`` at the plotted degree. + """ + if deanon is None or not len(deanon) or deanon["blend_hops"].nunique() < 2: + return None + import matplotlib.pyplot as plt + style.apply_style() + n = _largest_n(deanon) + d = deanon[(deanon.n_nodes == n) & (deanon.adversary_mode == "random") & (deanon.f_adv > 0)] + if not len(d): + return None + deg = _median_degree(d) + d = d[d.degree == deg] + fig, ax = plt.subplots() + for i, f in enumerate(sorted(d.f_adv.unique())): + s = d[d.f_adv == f].groupby("blend_hops").full_deanon_rate.mean().reset_index() + c = style.color_for(i) + ax.plot(s.blend_hops, _pos(s.full_deanon_rate), "-o", ms=4, color=c, label=f"f_adv={f:g}") + ax.plot(s.blend_hops, f ** s.blend_hops * (1.0 - (1.0 - f) ** deg), "--", + lw=0.8, color=c, alpha=0.6) + ax.set_yscale("log") + ax.set_xlabel("blend-path hops") + ax.set_ylabel("full-deanonymization rate\nP(path adversarial & sender exposed)") + ax.set_title(f"Full deanonymization vs path length (N={n:,}, degree={deg})") + ax.legend() + return fig + + +def full_deanon_vs_fadv(prop, adv, deanon): + """Full deanonymization vs adversary fraction, random vs worst-case placement (log-y). + + The whole-path-adversarial rate is placement-independent (thin grey ceiling); full + deanonymization adds the sender-peer factor ``observed_frac``, which the worst-case-coverage + adversary maximizes -- so the placements fan out below that ceiling. + """ + if deanon is None or not len(deanon) or deanon["f_adv"].nunique() < 2: + return None + import matplotlib.pyplot as plt + style.apply_style() + n = _largest_n(deanon) + bhs = sorted(deanon.blend_hops.unique()) + bh = int(bhs[len(bhs) // 2]) + d = deanon[(deanon.n_nodes == n) & (deanon.blend_hops == bh) & (deanon.f_adv > 0)] + if not len(d): + return None + deg = _median_degree(d) + d = d[d.degree == deg] + fig, ax = plt.subplots() + for i, mode in enumerate(sorted(d.adversary_mode.unique())): + s = d[d.adversary_mode == mode].groupby("f_adv").full_deanon_rate.mean().reset_index() + ax.plot(s.f_adv, _pos(s.full_deanon_rate), "-o", ms=4, color=style.color_for(i), + label=f"full ({mode})") + ceil = d[d.adversary_mode == "random"].groupby("f_adv").deanon_rate.mean().reset_index() + if len(ceil): + ax.plot(ceil.f_adv, _pos(ceil.deanon_rate), ":", lw=1.0, color="0.5", + label="whole path (any placement)") + ax.set_yscale("log") + ax.set_xlabel("adversary fraction f_adv") + ax.set_ylabel("deanonymization rate") + ax.set_title(f"Full deanonymization vs f_adv (N={n:,}, degree={deg}, blend_hops={bh})") + ax.legend() + return fig + + +def full_deanon_vs_degree(prop, adv, deanon): + """Full deanonymization vs peering degree, per f_adv (fixed path length, log-y). + + ``deanon_rate`` (dashed) is degree-flat -- relays are uniform -- but the sender-peer factor + ``1-(1-f_adv)**degree`` rises with degree, so *full* deanonymization worsens as degree grows + even though a higher degree speeds propagation. The peering-degree tension, in one plot. + """ + if deanon is None or not len(deanon) or deanon["degree"].nunique() < 2: + return None + import matplotlib.pyplot as plt + style.apply_style() + n = _largest_n(deanon) + bhs = sorted(deanon.blend_hops.unique()) + bh = int(bhs[len(bhs) // 2]) + d = deanon[(deanon.n_nodes == n) & (deanon.blend_hops == bh) + & (deanon.adversary_mode == "random") & (deanon.f_adv > 0)] + if not len(d): + return None + fig, ax = plt.subplots() + for i, f in enumerate(sorted(d.f_adv.unique())): + c = style.color_for(i) + s = d[d.f_adv == f].groupby("degree").agg( + full=("full_deanon_rate", "mean"), whole=("deanon_rate", "mean")).reset_index() + ax.plot(s.degree, _pos(s.full), "-o", ms=4, color=c, label=f"full, f_adv={f:g}") + ax.plot(s.degree, _pos(s.whole), "--", lw=0.8, color=c, alpha=0.6) + ax.set_yscale("log") + ax.set_xlabel("peering degree") + ax.set_ylabel("deanonymization rate") + ax.set_title(f"Full deanonymization vs degree (N={n:,}, blend_hops={bh}); " + f"dashed = whole-path (degree-flat)") + ax.legend() + return fig diff --git a/tools/simulators/blend/pd/src/pd/plotting/make_figures.py b/tools/simulators/blend/pd/src/pd/plotting/make_figures.py new file mode 100644 index 0000000..9b4506a --- /dev/null +++ b/tools/simulators/blend/pd/src/pd/plotting/make_figures.py @@ -0,0 +1,71 @@ +"""Render pd figures from the propagation + adversary parquets (pd-figures).""" + +from __future__ import annotations + +import argparse +from pathlib import Path + +import pandas as pd + +from . import figures, style + +# (prop, adv) builders. +_BUILDERS = [ + ("01_delay_vs_degree", figures.delay_vs_degree), + ("02_delay_vs_blendhops", figures.delay_vs_blendhops), + ("03_delay_vs_N", figures.delay_vs_N), + ("04_observed_vs_fadv", figures.observed_vs_fadv), + ("05_eclipse_vs_fadv", figures.eclipse_vs_fadv), + ("06_observed_vs_degree", figures.observed_vs_degree), + ("07_eclipse_vs_degree", figures.eclipse_vs_degree), + ("08_heatmap_observed", figures.heatmap_observed), + ("09_heatmap_eclipse", figures.heatmap_eclipse), + ("10_delivery_vs_unresponsive", figures.delivery_vs_unresponsive), + ("11_coverage_vs_unresponsive", figures.coverage_vs_unresponsive), +] + +# (prop, adv, deanon) builders — deanonymization crosses propagation paths with the adversary set. +_DEANON_BUILDERS = [ + ("12_deanon_vs_blendhops", figures.deanon_vs_blendhops), + ("13_full_deanon_vs_blendhops", figures.full_deanon_vs_blendhops), + ("14_full_deanon_vs_fadv", figures.full_deanon_vs_fadv), + ("15_full_deanon_vs_degree", figures.full_deanon_vs_degree), +] + + +def render(prop_df: pd.DataFrame, adv_df: pd.DataFrame, deanon_df: pd.DataFrame, + out_dir: Path) -> list[Path]: + out_dir = Path(out_dir) + prov = figures._prov(prop_df, adv_df, deanon_df) + jobs = ([(name, fn, (prop_df, adv_df)) for name, fn in _BUILDERS] + + [(name, fn, (prop_df, adv_df, deanon_df)) for name, fn in _DEANON_BUILDERS]) + written: list[Path] = [] + for name, fn, fn_args in jobs: + try: + fig = fn(*fn_args) + except Exception as e: # noqa: BLE001 — a bad slice shouldn't kill the whole render + print(f"skip {name}: {e}") + continue + if fig is not None: + written += style.save(fig, out_dir / name, provenance=prov) + return written + + +def main(argv: list[str] | None = None) -> int: + ap = argparse.ArgumentParser(description="Render pd figures from a run directory") + ap.add_argument("--run", required=True, help="runs/_