blend: make sections 3.11 and the attribution bracket reproducible

Review finding: the timing study and the neighbourhood-confidence numbers were
produced by ad-hoc analysis, not by the simulator. timing_linkability,
neighbourhood_confidence and mean_upstream_hops had no callers outside their own
modules; min_blend_delay and release_mode were declared on SweepConfig, validated
and keyed, but never read by sweep.py, so a YAML setting them was silently
ignored; and propagation.py called mix_wait without the minimum, leaving the knob
inert on the delay tables of 3.1-3.2. Section 6 promised every number was
reproducible and data/README claimed to hold the evidence behind every number --
both were false for 3.11.

Now wired end to end: release_designs() is a real sweep axis, the engine measures
the timing attack per design and records it in traffic.parquet, and the deanon
table carries the full attribution bracket (local confidence, attributable
fractions, upstream hops, neighbourhood confidence). Added configs/timing.yaml
and a make target.

The committed sweep reproduces 3.11: MAP success 0.993/0.905/0.683 for clock and
0.989/0.832/0.550 for jitter across the swept rates, and the minimum interval
changes nothing (0.993 vs 0.993). Evidence checked in under data/timing.

Three regression tests pin the wiring so a measure cannot go back to living only
in analysis.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marcin Pawlowski 2026-08-06 14:12:44 +02:00 committed by Marcin Pawlowski
parent e4d3d3a0f7
commit fb49a790fc
No known key found for this signature in database
14 changed files with 109 additions and 18 deletions

View File

@ -458,7 +458,7 @@ One objection needs disposing of: the clock figures above hand the observer the
<a id="s6"></a>
## 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 316, 15 blend hops, `f_adv` up to 0.5, unresponsive fractions to 0.5, all three placement modes, 8 topology seeds) into `runs/<timestamp>_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 2324) 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 316, 15 blend hops, `f_adv` up to 0.5, unresponsive fractions to 0.5, all three placement modes, 8 topology seeds) into `runs/<timestamp>_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 2324) 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.

View File

@ -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`

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -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

View File

@ -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

View File

@ -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]] = []

View File

@ -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

View File

@ -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 {}),
}

View File

@ -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"]

View File

@ -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]

View File

@ -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