diff --git a/reports/blend/README.md b/reports/blend/README.md index cf7ea7c..04f1493 100644 --- a/reports/blend/README.md +++ b/reports/blend/README.md @@ -458,7 +458,7 @@ One objection needs disposing of: the clock figures above hand the observer the ## 6. Reproducibility -The simulator, configs, and analytic checks live in [`tools/simulators/blend`](../../tools/simulators/blend). From that directory: `make install`, then `make sweep` runs the main grid (`configs/default.yaml`: N up to 10⁵, degree 3–16, 1–5 blend hops, `f_adv` up to 0.5, unresponsive fractions to 0.5, all three placement modes, 8 topology seeds) into `runs/_default/`, writing three tables — `propagation.parquet`, `adversary.parquet`, and `deanon.parquet` — and rendering the figures. `make sweep-fullscale` extends the exact metrics to 10⁶ nodes. The messaging-redundancy study (§3.8) and the linkability figures come from `configs/redundancy.yaml` (`python -m blend.sweep --config configs/redundancy.yaml`), which sweeps `redundancy` ∈ {1, 2, 3, 4} alongside the churn and adversary grids; the churn-threshold study (§3.5, Fig 20) comes from `configs/percolation.yaml`, which walks the unresponsive fraction to 0.9 so each degree's collapse can be located against `u_c = 1 − 1/(degree − 1)`; and the correlated-outage study (§3.9, Fig 22) from `configs/correlated-churn.yaml` (`make correlated-churn`), which partitions the network into failure domains and runs both churn modes on the same topologies. `make sweep-fullscale` produces the 10⁶ scaling check described in §5; and the cover-traffic study (§3.10, Figs 23–24) comes from `configs/cover-traffic.yaml`, which sweeps the emission rate over three decades against three release delays and pairs each timeline with the epoch-scale emission budget. Round counts in all three configs are set for statistical resolution, not speed — see the sampling-error note in §5. `make verify` runs the analytic anchors (d-regularity; `observed ≈ 1 − (1 − f)^degree`; `eclipsed ≈ f^degree`; delivery `≈ (1 − u)^blend_hops`; both deanonymization rates against a direct Monte-Carlo of the same draw; and — check 6 — `deanon_R` / `delivery_R = 1 − (1 − x)^R` for R independent cascades and the time-to-link geometric law), and `make test` the unit suite (`test_linkability.py` covers the time-to-link and stake formulae). The time-to-link and stake-inference curves are computed by `blend.linkability` from these exact rates. +The simulator, configs, and analytic checks live in [`tools/simulators/blend`](../../tools/simulators/blend). From that directory: `make install`, then `make sweep` runs the main grid (`configs/default.yaml`: N up to 10⁵, degree 3–16, 1–5 blend hops, `f_adv` up to 0.5, unresponsive fractions to 0.5, all three placement modes, 8 topology seeds) into `runs/_default/`, writing three tables — `propagation.parquet`, `adversary.parquet`, and `deanon.parquet` — and rendering the figures. `make sweep-fullscale` extends the exact metrics to 10⁶ nodes. The messaging-redundancy study (§3.8) and the linkability figures come from `configs/redundancy.yaml` (`python -m blend.sweep --config configs/redundancy.yaml`), which sweeps `redundancy` ∈ {1, 2, 3, 4} alongside the churn and adversary grids; the churn-threshold study (§3.5, Fig 20) comes from `configs/percolation.yaml`, which walks the unresponsive fraction to 0.9 so each degree's collapse can be located against `u_c = 1 − 1/(degree − 1)`; the release-design study (§3.11) from `configs/timing.yaml` (`make timing`), which sweeps the two designs and the minimum interval against the cover rate; and the correlated-outage study (§3.9, Fig 22) from `configs/correlated-churn.yaml` (`make correlated-churn`), which partitions the network into failure domains and runs both churn modes on the same topologies. `make sweep-fullscale` produces the 10⁶ scaling check described in §5; and the cover-traffic study (§3.10, Figs 23–24) comes from `configs/cover-traffic.yaml`, which sweeps the emission rate over three decades against three release delays and pairs each timeline with the epoch-scale emission budget. Round counts in all three configs are set for statistical resolution, not speed — see the sampling-error note in §5. `make verify` runs the analytic anchors (d-regularity; `observed ≈ 1 − (1 − f)^degree`; `eclipsed ≈ f^degree`; delivery `≈ (1 − u)^blend_hops`; both deanonymization rates against a direct Monte-Carlo of the same draw; and — check 6 — `deanon_R` / `delivery_R = 1 − (1 − x)^R` for R independent cascades and the time-to-link geometric law), and `make test` the unit suite (`test_linkability.py` covers the time-to-link and stake formulae). The time-to-link and stake-inference curves are computed by `blend.linkability` from these exact rates. The figures of record for this report are the copies checked in under [`report-figures/`](report-figures); the simulator does not commit its own generated figures. To regenerate: run the sweeps above, then copy `runs/<…>/figures/*.png` into `report-figures/`. @@ -466,4 +466,4 @@ The **evidence** is checked in too: [`data/`](data) holds the sweep outputs behi ## Figures -All twenty-four rendered figures are versioned in [`report-figures/`](report-figures): `01`–`03` propagation delay (vs degree, vs path length, vs N); `04`–`09` adversary observation and eclipse (vs `f_adv`, vs degree, and heatmaps); `10`–`11` reliability under churn (delivery and coverage); `12`–`15` deanonymization (whole-path and full, vs path length, `f_adv`, and degree); `16`–`18` linkability over time (time to link vs stake, with redundancy, and time to learn stake vs threshold); `19` the redundancy reliability-vs-anonymity trade-off in probability and `21` the same trade in delivery-vs-time-to-link; `20` the churn-percolation threshold; `22` correlated versus uniform outages; `23`–`24` cover traffic (the anonymity set against rate and delay, and the emission-quota stake ceiling). §3.11's timing results are tabulated rather than plotted. Eighteen of the twenty-four are embedded above; the other six (`04`–`06`, `09`, `11`, `13`) are alternative cuts of data already shown — for instance 11 and 20 both plot coverage against churn, and 20 supersedes 11 by walking the churn past every degree's threshold. +All twenty-four rendered figures are versioned in [`report-figures/`](report-figures): `01`–`03` propagation delay (vs degree, vs path length, vs N); `04`–`09` adversary observation and eclipse (vs `f_adv`, vs degree, and heatmaps); `10`–`11` reliability under churn (delivery and coverage); `12`–`15` deanonymization (whole-path and full, vs path length, `f_adv`, and degree); `16`–`18` linkability over time (time to link vs stake, with redundancy, and time to learn stake vs threshold); `19` the redundancy reliability-vs-anonymity trade-off in probability and `21` the same trade in delivery-vs-time-to-link; `20` the churn-percolation threshold; `22` correlated versus uniform outages; `23`–`24` cover traffic (the anonymity set against rate and delay, and the emission-quota stake ceiling). §3.11's timing results are tabulated rather than plotted; its evidence is in [`data/timing/`](data/timing). Eighteen of the twenty-four are embedded above; the other six (`04`–`06`, `09`, `11`, `13`) are alternative cuts of data already shown — for instance 11 and 20 both plot coverage against churn, and 20 supersedes 11 by walking the churn past every degree's threshold. diff --git a/reports/blend/data/README.md b/reports/blend/data/README.md index eb52f24..b7230ac 100644 --- a/reports/blend/data/README.md +++ b/reports/blend/data/README.md @@ -15,6 +15,7 @@ Each run directory holds the three tables the simulator writes: `propagation.par | `correlated-churn/` | `configs/correlated-churn.yaml` | 800 × 8 = **6 400/cell** | §3.9 — correlated AS/region outages vs uniform churn | | `fullscale/` | `configs/fullscale.yaml` | 64 × 3 = **192/cell** | §5 — the 10⁶ scaling check (deliberately lighter; not a source of headline numbers) | | `cover-traffic/` | `configs/cover-traffic.yaml` | 900 s timeline × 4 seeds | §3.10 — blending, mixing, and the emission-quota stake ceiling. Carries a fourth table, `traffic.parquet` | +| `timing/` | `configs/timing.yaml` | 120 s timeline × 3 seeds | §3.11 — the two release designs under a timing attack, and the minimum-interval control | The linkability results (§3.6–§3.7) and both deanonymization rates are closed forms over these tables rather than separate measurements, so they have no run of their own — `blend.linkability` diff --git a/reports/blend/data/timing/adversary.parquet b/reports/blend/data/timing/adversary.parquet new file mode 100644 index 0000000..ba024a2 Binary files /dev/null and b/reports/blend/data/timing/adversary.parquet differ diff --git a/reports/blend/data/timing/deanon.parquet b/reports/blend/data/timing/deanon.parquet new file mode 100644 index 0000000..af0bd14 Binary files /dev/null and b/reports/blend/data/timing/deanon.parquet differ diff --git a/reports/blend/data/timing/propagation.parquet b/reports/blend/data/timing/propagation.parquet new file mode 100644 index 0000000..dd4c439 Binary files /dev/null and b/reports/blend/data/timing/propagation.parquet differ diff --git a/reports/blend/data/timing/traffic.parquet b/reports/blend/data/timing/traffic.parquet new file mode 100644 index 0000000..cc9ab13 Binary files /dev/null and b/reports/blend/data/timing/traffic.parquet differ diff --git a/tools/simulators/blend/Makefile b/tools/simulators/blend/Makefile index f9fe822..2e597a3 100644 --- a/tools/simulators/blend/Makefile +++ b/tools/simulators/blend/Makefile @@ -8,7 +8,7 @@ export OPENBLAS_NUM_THREADS := 1 export MKL_NUM_THREADS := 1 export NUMEXPR_NUM_THREADS := 1 -.PHONY: install smoke sweep sweep-fullscale redundancy percolation correlated-churn figures verify test lint clean +.PHONY: install smoke sweep sweep-fullscale redundancy percolation correlated-churn timing 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. @@ -35,6 +35,9 @@ redundancy: $(STAMP) ## messaging redundancy R=1..4 (delivery vs deanonymization percolation: $(STAMP) ## churn threshold: coverage collapse at u_c = 1 - 1/(degree-1) $(PY) -m blend.sweep --config configs/percolation.yaml +timing: $(STAMP) ## release designs under a timing attack (jitter vs clock tick) + $(PY) -m blend.sweep --config configs/timing.yaml + correlated-churn: $(STAMP) ## correlated AS/region outages vs uniform churn, matched fractions $(PY) -m blend.sweep --config configs/correlated-churn.yaml diff --git a/tools/simulators/blend/configs/timing.yaml b/tools/simulators/blend/configs/timing.yaml new file mode 100644 index 0000000..1b16f01 --- /dev/null +++ b/tools/simulators/blend/configs/timing.yaml @@ -0,0 +1,30 @@ +# Release designs under a timing attack (report section 3.11). +# +# A blend relay holds a message and re-emits it; a node merely forwarding passes it straight +# through. If that delay is visible so is the relay's role, and matching an outgoing message to the +# incoming one links both ends of a hop. This sweeps the two ways of delaying at a MATCHED latency +# budget, so they differ only in HOW they delay: +# +# clock -- the free-running tick: everything held leaves together at the next tick. +# jitter -- each message waits its own independent draw, same mean as the clock's residual. +# +# min_blend_delay 0 vs 1 tests whether forbidding instant re-release buys anything. It should not: +# a zero-length gap is instantaneous, so it never covers an arrival and is never sampled. +# +# The rate is swept because section 3.10 predicts the answer is dominated by traffic, not delay -- +# a relay holding ~one message has nothing to be confused with under either design. +n_nodes: [2000] +degree: [8] +blend_hops: [3] +max_blend_delay: [30] +min_blend_delay: [0, 1] +release_mode: [clock, jitter] +cover_rate_mult: [1.0, 16.0, 64.0] +unresponsive_frac: [0.0] +f_adv: [0.2] +adversary_mode: [random] +seeds: 3 +base: + traffic_window_slots: 120 + n_rounds: 10 # propagation is not the subject here + n_placements: 1 diff --git a/tools/simulators/blend/src/blend/config.py b/tools/simulators/blend/src/blend/config.py index 5d77489..06d95d8 100644 --- a/tools/simulators/blend/src/blend/config.py +++ b/tools/simulators/blend/src/blend/config.py @@ -199,6 +199,10 @@ class SweepConfig: """(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 release_designs(self) -> list[tuple[int, str]]: + """(min_blend_delay, release_mode) pairs the cover-traffic timeline is measured over.""" + return [(lo, mode) for lo in self.min_blend_delay for mode in self.release_mode] + 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]] = [] diff --git a/tools/simulators/blend/src/blend/engine.py b/tools/simulators/blend/src/blend/engine.py index b6c2466..9463930 100644 --- a/tools/simulators/blend/src/blend/engine.py +++ b/tools/simulators/blend/src/blend/engine.py @@ -15,6 +15,8 @@ from .adversary import ( adversary_metrics, attribution_metrics, deanon_metrics, + mean_upstream_hops, + neighbourhood_confidence, place_adversary, ) from .config import WORSTCASE_MODES, SimConfig @@ -29,7 +31,7 @@ from .rng import ( stake_seedseq, traffic_seedseq, ) -from .traffic import simulate_window, traffic_metrics +from .traffic import simulate_window, timing_linkability, traffic_metrics def run_graph_cell(base: SimConfig, prop_grid: list[tuple[int, int]], @@ -37,6 +39,7 @@ def run_graph_cell(base: SimConfig, prop_grid: list[tuple[int, int]], adv_grid: list[tuple[float, str]], churn_modes: list[str] | None = None, cover_rates: list[float] | None = None, + release_designs: list[tuple[int, str]] | None = None, ) -> tuple[list[dict], list[dict], list[dict], list[dict]]: """Build ``base``'s topology once; return (propagation, adversary, deanon, traffic rows). @@ -50,6 +53,7 @@ def run_graph_cell(base: SimConfig, prop_grid: list[tuple[int, int]], graph-free and therefore computed separately. """ graph = build_graph(base) + upstream = mean_upstream_hops(graph, np.random.default_rng(base.root_seed)) blend_hops_set = sorted({bh for bh, _ in prop_grid}) modes = churn_modes or [base.churn_mode] @@ -79,6 +83,9 @@ def run_graph_cell(base: SimConfig, prop_grid: list[tuple[int, int]], adv_mask = place_adversary(graph, f_adv, mode, rng, base.worstcase_max_n) adv = adversary_metrics(graph, adv_mask) att = attribution_metrics(graph, adv_mask) + # upper end of the attribution bracket: the adversary also sees the message upstream + att = dict(att, upstream_hops=upstream, + neighbourhood_conf=neighbourhood_confidence(f_adv, upstream)) adv_rows.append(adversary_row(base, f_adv, mode, rep, adv)) for bh in blend_hops_set: for R in redundancies: @@ -95,13 +102,15 @@ def run_graph_cell(base: SimConfig, prop_grid: list[tuple[int, int]], quota = quota_summary(stake, f, base.n_nodes, base.slots_per_epoch, srng, base.stake_inference_ratio, rate) for blend_hops, max_blend_delay in prop_grid: - trng = np.random.default_rng( - traffic_seedseq(base, blend_hops, max_blend_delay, rate)) - win = simulate_window(graph, cfg, trng, base.traffic_window_slots, - max_blend_delay, blend_hops) - tm = traffic_metrics(win, cfg, max_blend_delay) - traffic_rows.append( - traffic_row(base, blend_hops, max_blend_delay, rate, tm, quota)) + for lo, mode in (release_designs or [(base.min_blend_delay, base.release_mode)]): + trng = np.random.default_rng( + traffic_seedseq(base, blend_hops, max_blend_delay, rate)) + win = simulate_window(graph, cfg, trng, base.traffic_window_slots, + max_blend_delay, blend_hops, mode, lo) + tm = traffic_metrics(win, cfg, max_blend_delay) + tl = timing_linkability(win, cfg, max_blend_delay, lo, mode) + traffic_rows.append( + traffic_row(base, blend_hops, max_blend_delay, rate, tm, quota, lo, mode, tl)) return prop_rows, adv_rows, deanon_rows, traffic_rows diff --git a/tools/simulators/blend/src/blend/metrics.py b/tools/simulators/blend/src/blend/metrics.py index 0015e56..df4d5a8 100644 --- a/tools/simulators/blend/src/blend/metrics.py +++ b/tools/simulators/blend/src/blend/metrics.py @@ -28,7 +28,9 @@ def propagation_row(config: SimConfig, blend_hops: int, max_blend_delay: int, def traffic_row(config: SimConfig, blend_hops: int, max_blend_delay: int, - cover_rate_mult: float, traffic: dict, quota: dict) -> dict: + cover_rate_mult: float, traffic: dict, quota: dict, + min_blend_delay: int | None = None, release_mode: str | None = None, + timing: dict | None = None) -> dict: """One cover-traffic cell: what the timeline measured, plus the epoch emission budget. ``traffic`` comes from the windowed simulation (blending, mixing, counts) and ``quota`` from @@ -39,6 +41,8 @@ def traffic_row(config: SimConfig, blend_hops: int, max_blend_delay: int, "degree": config.degree, "blend_hops": blend_hops, "max_blend_delay": max_blend_delay, + "min_blend_delay": config.min_blend_delay if min_blend_delay is None else min_blend_delay, + "release_mode": release_mode or config.release_mode, "cover_rate_mult": cover_rate_mult, "block_interval_slots": config.block_interval_slots, "slots_per_epoch": config.slots_per_epoch, @@ -48,6 +52,7 @@ def traffic_row(config: SimConfig, blend_hops: int, max_blend_delay: int, "traffic_window_slots": config.traffic_window_slots, **traffic, **quota, + **(timing or {}), } diff --git a/tools/simulators/blend/src/blend/propagation.py b/tools/simulators/blend/src/blend/propagation.py index 65a9d1a..ef4cc68 100644 --- a/tools/simulators/blend/src/blend/propagation.py +++ b/tools/simulators/blend/src/blend/propagation.py @@ -71,7 +71,7 @@ def assign_responsive(n: int, unresponsive_frac: float, rng: np.random.Generator def blend_round(graph: Graph, sender: int, relays: np.ndarray, jitter_mean_ms: float, max_blend_delay: int, rng: np.random.Generator, coverage_pcts: tuple[float, ...], responsive: np.ndarray | None = None, - stats: bool = True) -> dict: + stats: bool = True, min_blend_delay: int = 0) -> dict: """One Blend cascade. ``delivered`` is True iff every relay forwards and the final relay (which must be responsive) floods; delay fields are NaN on a dropped message. @@ -101,7 +101,7 @@ def blend_round(graph: Graph, sender: int, relays: np.ndarray, jitter_mean_ms: f legs_ok = False break legs += float(d) - mix_total = float(mix_wait(rng, max_blend_delay, k).sum()) + mix_total = float(mix_wait(rng, max_blend_delay, k, min_blend_delay).sum()) final_relay = int(sources[k]) final_ok = responsive is None or bool(responsive[final_relay]) @@ -177,7 +177,8 @@ def propagation_metrics(graph: Graph, blend_hops: int, max_blend_delay: int, relays = rng.choice(n - 1, size=blend_hops, replace=False) relays[relays >= sender] += 1 # blend_hops distinct nodes, all != sender rc = blend_round(graph, sender, relays, config.transport_jitter_mean_ms, - max_blend_delay, rng, pcts, responsive, stats=False) + max_blend_delay, rng, pcts, responsive, stats=False, + min_blend_delay=config.min_blend_delay) if not rc["delivered"]: continue a = rc["arrival"] diff --git a/tools/simulators/blend/src/blend/sweep.py b/tools/simulators/blend/src/blend/sweep.py index 131d00c..943ad13 100644 --- a/tools/simulators/blend/src/blend/sweep.py +++ b/tools/simulators/blend/src/blend/sweep.py @@ -38,9 +38,9 @@ def new_run_dir(outdir: Path, label: str) -> Path: def _cell_worker(base: SimConfig, prop_grid, unresponsive_fracs, redundancies, adv_grid, - churn_modes, cover_rates): + churn_modes, cover_rates, release_designs): return run_graph_cell(base, prop_grid, unresponsive_fracs, redundancies, adv_grid, - churn_modes, cover_rates) + churn_modes, cover_rates, release_designs) def run_sweep(sweep: SweepConfig, n_jobs: int = -1) -> tuple[pd.DataFrame, ...]: @@ -50,11 +50,12 @@ def run_sweep(sweep: SweepConfig, n_jobs: int = -1) -> tuple[pd.DataFrame, ...]: redundancies = list(sweep.redundancy) churn_modes = list(sweep.churn_mode) cover_rates = list(sweep.cover_rate_mult) + release_designs = sweep.release_designs() adv_grid = sweep.adv_grid() bases = [sweep.base_config(n, d, g) for (n, d, g) in cells] results = Parallel(n_jobs=n_jobs, prefer="processes")( delayed(_cell_worker)(base, prop_grid, unresponsive_fracs, redundancies, adv_grid, - churn_modes, cover_rates) + churn_modes, cover_rates, release_designs) for base in tqdm(bases, desc="topologies") ) prop_rows = [r for pr, _, _, _ in results for r in pr] diff --git a/tools/simulators/blend/tests/test_timing.py b/tools/simulators/blend/tests/test_timing.py index 15ace32..1cb58e4 100644 --- a/tools/simulators/blend/tests/test_timing.py +++ b/tools/simulators/blend/tests/test_timing.py @@ -117,3 +117,40 @@ def test_jitter_beats_the_clock_at_a_matched_delay_budget(): _, j = _run("jitter", rate=64.0, slots=60) assert j["map_success"] < c["map_success"] assert j["timing_set_mean"] > c["timing_set_mean"] + + +def test_release_design_is_a_real_sweep_axis(): + """Regression: min_blend_delay and release_mode were declared on SweepConfig, validated and + keyed, but never read by the sweep -- a YAML setting them was silently ignored.""" + from blend.config import SweepConfig + sw = SweepConfig(min_blend_delay=[0, 1], release_mode=["clock", "jitter"]) + assert sorted(sw.release_designs()) == [(0, "clock"), (0, "jitter"), + (1, "clock"), (1, "jitter")] + + +def test_the_engine_emits_a_timing_row_per_release_design(): + """The timing measures must land in a result table, not only in ad-hoc analysis.""" + from blend.config import SimConfig + from blend.engine import run_graph_cell + base = SimConfig(n_nodes=600, degree=8, traffic_window_slots=40, n_rounds=5, n_placements=1) + _, _, deanon_rows, traffic_rows = run_graph_cell( + base, [(3, 30)], [0.0], [1], [(0.2, "random")], + cover_rates=[1.0], release_designs=[(0, "clock"), (0, "jitter")]) + assert len(traffic_rows) == 2 + for row in traffic_rows: + for col in ("release_mode", "min_blend_delay", "timing_set_mean", "map_success"): + assert col in row, col + assert {r["release_mode"] for r in traffic_rows} == {"clock", "jitter"} + # and the attribution bracket reaches the deanon table + for col in ("attribution_conf_mean", "upstream_hops", "neighbourhood_conf"): + assert col in deanon_rows[0], col + + +def test_propagation_honours_the_minimum_interval(): + """Regression: the non-timeline path called mix_wait without the minimum, so the knob was + silently inert on the delay tables of 3.1-3.2.""" + import inspect + + from blend import propagation + src = inspect.getsource(propagation.propagation_metrics) + assert "min_blend_delay=config.min_blend_delay" in src