diff --git a/reports/blend/README.md b/reports/blend/README.md
index 97d3ad3..a8502ba 100644
--- a/reports/blend/README.md
+++ b/reports/blend/README.md
@@ -165,7 +165,7 @@ So **the `full_deanon` figures above overstate confident origination by up to fi
**`confidence = 1 / (1 + (1 − f_adv)^L)`** for `L` upstream hops — the local model being exactly `L = 1`.
-`L` is not a free parameter; the graph fixes it. A forwarder sits about halfway along a route, and the peer graph is low-diameter: mean hop distance is **5.15 at degree 8** (3.87 at degree 16), so `L ≈ 2.6` — measured by `blend.adversary.mean_upstream_hops`, which the deanon table now records per run as `upstream_hops` alongside the resulting `neighbourhood_conf`. That gives:
+`L` is not a free parameter; the graph fixes it. A forwarder sits about halfway along a route, and the peer graph is low-diameter: mean hop distance is **5.15 at degree 8** (3.87 at degree 16), so `L ≈ 2.58` — recorded per run as `upstream_hops` alongside the resulting `neighbourhood_conf` (evidence in [`data/attribution/`](data/attribution)). That gives:
| `f_adv` | local (`L=1`) | neighbourhood (`L≈2.6`) | `L` needed for 0.9 |
|---|---|---|---|
@@ -173,6 +173,8 @@ So **the `full_deanon` figures above overstate confident origination by up to fi
| 0.20 | 0.56 | **0.64** | 9.8 |
| 0.33 | 0.60 | 0.74 | 5.5 |
+**Degree cuts both ways here.** A sparser graph has longer routes, so it offers the adversary more upstream places to see the message: `L` is 4.18 at degree 4 against 2.58 at degree 8 and 1.93 at degree 16, lifting neighbourhood confidence at `f_adv = 0.2` to 0.72, 0.64 and 0.61 respectively. The low diameter that makes propagation fast (§3.1) also starves the adversary of observation points — one of the few places in this report where raising the degree helps anonymity rather than hurting it.
+
So the neighbourhood does help the adversary — 0.56 → 0.64 at `f_adv = 0.2` — but **reaching 0.9 would need ~10 upstream hops where the graph supplies 2.6**, and the low diameter that makes propagation fast (§3.1) is exactly what starves the adversary of upstream observation points. The bracket therefore closes **near the local model, not near certainty**: confident attribution of the originator stays out of reach at realistic parameters, and the binary `full_deanon` treatment is not rescued by neighbourhood effects.
One caveat remains: confidence is a *threshold* choice. An adversary content with 0.64 attributes most senders; one demanding 0.9 attributes almost none. The report gives both ends rather than picking for the reader.
@@ -417,10 +419,13 @@ The measure is the **effective anonymity set** of a release: the perplexity of t
**At the specified cover rate, timing linkage is essentially total under either design** — the adversary's best guess is right 98–99 % of the time. That is the direct consequence of §3.10's result that a relay holds 0.0014 messages: with one message per relay there is nothing to confuse it with, and no amount of delay manufactures ambiguity out of a single message. **Traffic buys timing protection; delay does not.**
-Between the two designs, **the independent draw wins at equal cost**, contrary to the intuition that batching at a tick should be safer. The clock's tick is a *hard boundary*: the observer can exclude everything before the previous tick, so the candidate set is exactly one inter-tick batch. An exponential has unbounded support and never excludes anything outright. But the margin is much smaller than the set size suggests — at 64 msg/s jitter's effective set is **67 %** larger while its advantage in defeating the best guess is only **19 %**. The heavy tail keeps old arrivals nominally possible while contributing almost nothing, so **perplexity alone would have overstated the case for Poisson-style mixing by roughly a factor of three.**
+Between the two designs, **the independent draw wins at equal cost**, contrary to the intuition that batching at a tick should be safer. The clock's tick is a *hard boundary*: the observer can exclude everything before the previous tick, so the candidate set is exactly one inter-tick batch. An exponential has unbounded support and never excludes anything outright. But the margin is much smaller than the set size suggests — at 64 msg/s jitter's effective set is **67 %** larger while its advantage in defeating the best guess is only **19 %**. The heavy tail keeps old arrivals nominally possible while contributing almost nothing, so **perplexity alone would have overstated the case for Poisson-style mixing by roughly a factor of three** (**Fig 25**).
One objection needs disposing of: the clock figures above hand the observer the *tick schedule*, which is the conservative assumption but also the harsher one, since a real observer sees only ticks that released something. Measured both ways, it makes **no difference at all** — and provably so. A silent tick implies nothing was pending at it, and any arrival older than the previous release has already left, so the window bounded by the true previous tick and the window bounded by the last observed release contain the same arrivals. The clock design is not being handicapped by a generous adversary.
+
+*Fig 25 — Matching a relay's output to its input, at a matched delay budget. Solid = MAP success (↓ good), dashed = effective anonymity set on the right axis (↑ good). The dashed lines separate far faster than the solid ones: the set size credits jitter with an advantage the best-guess attack largely takes back.*
+
**A minimum release interval changes nothing.** Forbidding zero-length intervals (`min_blend_delay = 1`) leaves the mean hold at 10.14 s against 10.22 s, and MAP success at 0.858 against 0.860 — identical within noise. This is structural rather than a null measurement: a zero-length gap is instantaneous, so it can never cover a message arrival and is never drawn by the residual or by the size-biased interval. Excluding it removes probability mass that was never sampled. What it *does* change is `E[S]`, the spacing of release opportunities — so a minimum would matter under per-message jitter, where a zero draw is a genuinely unmixed forward, but under a free-running clock it buys nothing.
---
@@ -458,7 +463,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)`; 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` (`make cover-traffic`), 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; the attribution bracket (§3.4) from `configs/attribution.yaml` (`make attribution`), which records both bounds and the graph's hop distance at the reported scale; 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` (`make cover-traffic`), 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 +471,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; 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.
+All twenty-five 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). `25` the timing attack (§3.11). Evidence for each study is in [`data/`](data). Nineteen of the twenty-five 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 cf94dd6..acfd7b3 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` |
+| `attribution/` | `configs/attribution.yaml` | closed-form + 4 seeds | §3.4 — the attribution bracket at the report's scale: local confidence, attributable fractions, upstream hops, neighbourhood confidence |
| `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
diff --git a/reports/blend/data/attribution/adversary.parquet b/reports/blend/data/attribution/adversary.parquet
new file mode 100644
index 0000000..7304ecd
Binary files /dev/null and b/reports/blend/data/attribution/adversary.parquet differ
diff --git a/reports/blend/data/attribution/deanon.parquet b/reports/blend/data/attribution/deanon.parquet
new file mode 100644
index 0000000..e637310
Binary files /dev/null and b/reports/blend/data/attribution/deanon.parquet differ
diff --git a/reports/blend/data/attribution/propagation.parquet b/reports/blend/data/attribution/propagation.parquet
new file mode 100644
index 0000000..ed0f67c
Binary files /dev/null and b/reports/blend/data/attribution/propagation.parquet differ
diff --git a/reports/blend/data/report_numbers.py b/reports/blend/data/report_numbers.py
index b968a6a..b507534 100644
--- a/reports/blend/data/report_numbers.py
+++ b/reports/blend/data/report_numbers.py
@@ -178,8 +178,11 @@ m = TMT[TMT.release_mode == "clock"].groupby("min_blend_delay").agg(
print(m.to_string(index=False, float_format=lambda v: f"{v:.3f}"))
# --- 3.4 attribution bracket ---------------------------------------------------------------------
-print("\n### 3.4 attribution: local confidence vs the neighbourhood bound")
-TD = pd.read_parquet(TM + "/deanon.parquet")
-cols = ["attribution_conf_mean", "attributable_frac_50", "attributable_frac_90",
- "upstream_hops", "neighbourhood_conf"]
-print(TD[cols].mean().round(4).to_string())
+AT = os.path.join(_here, "attribution")
+print("\n### 3.4 attribution bracket (N=20k, random placement)")
+AD = pd.read_parquet(AT + "/deanon.parquet")
+a = AD[AD.adversary_mode == "random"].groupby(["degree", "f_adv"]).agg(
+ observed=("observed_frac", "mean"), local_mean=("attribution_conf_mean", "mean"),
+ attributable_90=("attributable_frac_90", "mean"), upstream=("upstream_hops", "mean"),
+ neighbourhood=("neighbourhood_conf", "mean")).reset_index()
+print(a.to_string(index=False, float_format=lambda v: f"{v:.5f}"))
diff --git a/reports/blend/report-figures/25_timing_linkability.png b/reports/blend/report-figures/25_timing_linkability.png
new file mode 100644
index 0000000..b40bc86
Binary files /dev/null and b/reports/blend/report-figures/25_timing_linkability.png differ
diff --git a/tools/simulators/blend/Makefile b/tools/simulators/blend/Makefile
index 3e70cd3..8d74098 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 cover-traffic timing figures verify test lint clean
+.PHONY: install smoke sweep sweep-fullscale redundancy percolation correlated-churn cover-traffic timing attribution 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
+attribution: $(STAMP) ## attribution confidence: the local bound and the neighbourhood bound
+ $(PY) -m blend.sweep --config configs/attribution.yaml
+
cover-traffic: $(STAMP) ## cover traffic: blending, mixing, and the emission-quota ceiling
$(PY) -m blend.sweep --config configs/cover-traffic.yaml
diff --git a/tools/simulators/blend/configs/attribution.yaml b/tools/simulators/blend/configs/attribution.yaml
new file mode 100644
index 0000000..ab36e75
--- /dev/null
+++ b/tools/simulators/blend/configs/attribution.yaml
@@ -0,0 +1,23 @@
+# Attribution confidence at the scale the report quotes (section 3.4).
+#
+# Capturing a cascade identifies the MESSAGE, not the originator: an honest node seen transmitting
+# may simply have been passing one along. This run measures both ends of the resulting bracket.
+#
+# local -- confidence d/(2d-a) from the sender's own a adversarial peers, and the share of
+# honest nodes attributable at 0.5 / 0.9 / 0.99.
+# neighbourhood -- 1/(1+(1-f)^L), where the adversary also sees the message anywhere upstream.
+# L is fixed by the graph, so upstream_hops is recorded per run.
+#
+# Propagation is not the subject, so n_rounds is minimal: the adversary and deanonymization metrics
+# are closed-form and exact, and the hop distance is a property of the topology.
+n_nodes: [20000]
+degree: [4, 8, 16]
+blend_hops: [3]
+max_blend_delay: [3]
+unresponsive_frac: [0.0]
+f_adv: [0.1, 0.2, 0.33, 0.5]
+adversary_mode: [random, worstcase_coverage]
+seeds: 4
+base:
+ n_rounds: 10
+ n_placements: 4
diff --git a/tools/simulators/blend/src/blend/plotting/figures.py b/tools/simulators/blend/src/blend/plotting/figures.py
index 4e06e9c..ac3981f 100644
--- a/tools/simulators/blend/src/blend/plotting/figures.py
+++ b/tools/simulators/blend/src/blend/plotting/figures.py
@@ -709,3 +709,42 @@ def quota_stake_ceiling(traffic: pd.DataFrame):
ax.set_title("Emission quota: the most stake a node can hold and stay uniform")
ax.legend(fontsize=7)
return fig
+
+
+def timing_linkability_vs_rate(traffic: pd.DataFrame):
+ """Can an observer match a relay's outgoing message to the incoming one? (§3.11)
+
+ Solid = MAP success, how often the observer's single best guess is right; dashed = the
+ effective anonymity set on the right axis. Both release designs cost the same mean delay, so
+ they differ only in how they delay. The two lines together make the point that the set size
+ alone would miss: a heavy-tailed delay inflates the set while leaving the best guess nearly as
+ good, so the jitter design's real advantage is a fraction of its apparent one.
+ """
+ if (traffic is None or not len(traffic) or "map_success" not in traffic
+ or traffic["release_mode"].nunique() < 2):
+ return None
+ import matplotlib.pyplot as plt
+ style.apply_style()
+ d = traffic[traffic.min_blend_delay == traffic.min_blend_delay.min()]
+ fig, ax = plt.subplots()
+ ax2 = ax.twinx()
+ ax2.grid(False)
+ for i, mode in enumerate(sorted(d.release_mode.unique())):
+ c = style.color_for(i)
+ s = d[d.release_mode == mode].groupby("cover_rate_mult").agg(
+ mp=("map_success", "mean"), es=("timing_set_mean", "mean")).reset_index()
+ ax.plot(s.cover_rate_mult, s.mp, "-o", ms=5, color=c, label=f"{mode}: MAP success ↓ good")
+ ax2.plot(s.cover_rate_mult, s.es, "--s", ms=4, color=c, alpha=0.65,
+ label=f"{mode}: effective set ↑ good")
+ ax.axhline(1.0, ls=":", lw=0.8, color="0.6")
+ ax.set_xscale("log")
+ ax.set_xlabel("cover-traffic rate (messages/second, network-wide)")
+ ax.set_ylabel("MAP success: best guess is correct")
+ ax.set_ylim(0.0, 1.05)
+ ax2.set_ylabel("effective anonymity set of a release")
+ h1, l1 = ax.get_legend_handles_labels()
+ h2, l2 = ax2.get_legend_handles_labels()
+ ax.legend(h1 + h2, l1 + l2, fontsize=7, loc="center left")
+ ax.set_title("Timing: matching a relay's output to its input\n"
+ "(matched delay budget; at the baseline rate both designs fail)")
+ return fig
diff --git a/tools/simulators/blend/src/blend/plotting/make_figures.py b/tools/simulators/blend/src/blend/plotting/make_figures.py
index 1973b8d..2450dcc 100644
--- a/tools/simulators/blend/src/blend/plotting/make_figures.py
+++ b/tools/simulators/blend/src/blend/plotting/make_figures.py
@@ -44,6 +44,7 @@ _DEANON_BUILDERS = [
_TRAFFIC_BUILDERS = [
("23_blending_vs_rate_and_delay", figures.blending_vs_rate_and_delay),
("24_quota_stake_ceiling", figures.quota_stake_ceiling),
+ ("25_timing_linkability", figures.timing_linkability_vs_rate),
]