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