Add linkability, messaging redundancy and churn percolation to pd; report
Extends the pd Blend simulator along two axes the deanonymization model opened up, adds the reports/blend/pd report of record, and fixes three correctness defects found while reviewing the result. Linkability over time (pd.linkability): - time to link an emitter ~ 30s*ln(1/(1-alpha))/(stake*q): inversely proportional to stake, so a 5% staker is linked in ~2 days and a 0.001% staker only after ~27 years; - time to certify a node's stake >= theta from the count of attributable observations (relative precision ~1/sqrt(N)): sizing a node costs 100-400x more than identifying it, and sub-0.1% stake is practically unlearnable. Both are closed forms over the exact deanonymization rates and a stake-proportional 30 s emission cadence, checked against a Monte-Carlo of the emission process in verify. Messaging redundancy (R independent cascades per emission, R = 1..4): - `redundancy` knob threaded through config/rng/propagation/engine/metrics/ sweep; a node receives from whichever cascade reaches it first, so arrival times combine element-wise. Delivery and capture both follow 1-(1-x)^R, so redundancy trades reliability against anonymity and divides time-to-link by ~R. Measured: delivery 0.34 -> 0.81 at 30% churn for R = 1 -> 4, while a 1%-staker's time to link falls 10 d -> 2.5 d. - Redundancy buys NO coverage: a cascade only delivers if the sender could already route to its relay, so every delivered cascade floods the sender's own component. Coverage is flat in R to four decimals at every degree. - Near the percolation threshold the cascades fail together rather than independently, so redundancy under-delivers against 1-(1-p1)^R there. Churn percolation (configs/percolation.yaml, verify check 7): - the flood only crosses responsive nodes, so it lives on the responsive sub-graph -- site percolation on a d-regular graph. A network survives churn only up to u_c = 1 - 1/(degree-1); measured collapse lands on the predicted threshold for every degree (3 -> 0.50, 6 -> 0.80, 16 -> 0.93), which inverts into the sizing rule degree > 1 + 1/(1-u). Correctness fixes: - redundancy delay used the fastest cascade's own full delay, which over-states it (min-max vs max-min); now the element-wise earliest arrival, reducing exactly to the single-cascade model at R = 1 (test); - the "redundancy improves coverage" claim was false in both the report and the simulator README -- removed and replaced with the measured result; - per-hop latency is degree-dependent (1.5 s at degree 16 to 2.7 s at degree 3), not a flat 1.6 s; and the worst-case observation figure was averaged over degrees -- at degree 8 and f_adv = 0.2 it is 0.83 -> 1.000. Statistics: round counts raised for resolution rather than speed -- 8000 rounds per cell in the main sweep, 9600 in the redundancy study, 6400 in the percolation study, giving SEM <= 0.009 on every delivery rate and <= 0.04 s on every delay mean. The previous redundancy grid (144 rounds/cell) produced a non-monotonic delivery curve; it is now monotonic and within 0.015 of theory. Adversary and deanonymization metrics remain closed-form and exact. reports/blend/pd: the report of record -- peering-degree trade-offs across speed, observation, eclipse, deanonymization and reliability, plus the time-to-link, stake-inference, redundancy and churn-threshold sections, with 21 figures of record and an explicit sampling-error statement. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
301
reports/blend/pd/README.md
Normal file
@ -0,0 +1,301 @@
|
||||
# Peering degree in the Blend network — a Monte-Carlo study
|
||||
|
||||
*Static-graph network simulation of the Blend message cascade. Simulator: [`pd`](../../../tools/simulators/blend/pd). All delays in **milliseconds**; the free-running mix clock's maximum interval (`max_blend_delay`) is in **whole seconds**. Adversary and deanonymization metrics are exact at every network size; propagation is Monte-Carlo over random senders.*
|
||||
|
||||
This report quantifies how a node's **peering degree** — the number of symmetric peers it keeps — trades off four properties of the Blend network at once: how fast a message propagates, how much of the network an adversary observes, how often a message is deanonymized, and how reliably messages are delivered when a fraction of nodes go dark. The headline is a single tension: **raising the peering degree improves propagation speed, eclipse resistance, and churn resilience, but *worsens* observation and sender deanonymization.** The anonymity axis has its own, degree-independent control — the **blend-path length** — so the two knobs separate cleanly: set the degree for the transport goals, set the path length for the anonymity goal. Two further questions follow from the deanonymization rates: *how long* an adversary needs to link an emitter to its messages and to learn its stake — which scales inversely with the node's own stake — and how **messaging redundancy** (sending each message over several independent cascades) trades reliability against anonymity, amplifying both by the very same factor.
|
||||
|
||||
## Headline
|
||||
|
||||
A peering degree of **6–8** is the operating sweet spot for the sizes tested (10³–10⁶ nodes). Below 6, propagation is slow, eclipse is non-negligible at high adversary fractions, and — the sharpest failure — the responsive sub-network *shatters* under heavy churn: a degree-`d` network survives churn only up to `u_c = 1 − 1/(d − 1)`, which is 50 % at degree 3 but 80 % at degree 6 and 86 % at degree 8. Above ~8 the speed, eclipse, and churn gains flatten while observation and full-deanonymization exposure keep climbing, so there is no reason to go higher for transport alone. Anonymity is bought separately, with the number of blend hops: the whole-path capture rate is exactly `f_adv^blend_hops` and does not depend on degree.
|
||||
|
||||
| knob | recommended | why |
|
||||
|---|---|---|
|
||||
| peering degree | **6–8** | speed, eclipse resistance, and churn resilience saturate here; observation/deanon exposure rises past it (§3.1, §3.3, §3.5) |
|
||||
| blend-path length | **set from the anonymity target**: `blend_hops ≥ ln ε / ln f_adv` to hold whole-path capture ≤ ε | the degree-independent anonymity lever; costs ~1.5–2.7 s latency (degree-dependent) and a `(1−u)` reliability factor per hop (§3.4, §3.2, §3.5) |
|
||||
| operating churn | pick the degree from the churn to be survived: **`u_c = 1 − 1/(degree − 1)`** (degree 6 → 80 %, degree 8 → 86 %) | site percolation of the responsive sub-graph (§3.5) |
|
||||
| messaging redundancy | **R = 1 unless reliability demands more** — each extra copy multiplies *both* delivery and capture by `1−(1−x)^R` | redundancy is reliability and exposure in one dial; it cuts time-to-link ≈ R× (§3.8) |
|
||||
|
||||
A node's exposure is not only *whether* it is deanonymized but *how soon*: if each 30 s slot one node emits with probability equal to its stake, the time to link an emitter is `≈ 30 s · ln(1/(1−α)) / (stake · f_adv^blend_hops)` — **inversely proportional to its stake** — so high-stake nodes are identified in days and the smallest holders effectively never (§3.6). The same event stream lets an adversary *estimate* a node's stake, but pinning it below ~0.1 % takes over a decade and below ~0.01 % centuries (§3.7).
|
||||
|
||||
---
|
||||
|
||||
<a id="s1"></a>
|
||||
## 1. Executive summary
|
||||
|
||||
**The setting.** In the Blend network a node forwards a message along a short **blend path** of relay nodes — each a free-running timed-release mix — and the last relay floods it to the whole network. Every node keeps a fixed number of symmetric peers (its *peering degree*), and that single number is a design choice with consequences that pull in opposite directions. This study builds a seeded, exactly *d*-regular peer graph, drives many random-sender cascades across it, and places adversarial and unresponsive nodes on it, at network sizes from a thousand to a million.
|
||||
|
||||
**What the axes show**
|
||||
|
||||
1. **Propagation speed improves with degree, with strong diminishing returns.** The full delay of a 3-hop cascade at 100 000 nodes falls from 10.3 s at degree 3 to 5.0 s at degree 16, but most of the gain is spent by degree 6–8 (6.3 s → 5.8 s); the last doublings barely move it. The multi-second total is dominated by the blend path's *mixing* waits, not by the network flood: at degree 8 the final flood reaches 99 % of a 100 000-node network in 0.62 s, and the total delay grows only 18 % across a hundred-fold increase in network size (§3.1, §3.2).
|
||||
|
||||
2. **Observation rises with degree; eclipse falls with it.** With a fraction `f_adv` of adversarial peers placed at random, the share of honest nodes with at least one adversarial peer (**observed**) is `1 − (1 − f_adv)^degree` — it *increases* with degree (at `f_adv = 0.2`: 0.49 at degree 3, 0.83 at degree 8, 0.97 at degree 16). The share whose *every* peer is adversarial (**eclipsed**) is `f_adv^degree` — it *vanishes* with degree (at `f_adv = 0.5`: 0.125 at degree 3, 0.004 at degree 8, ~0 by degree 12). A worst-case (greedy-coverage) placement raises observation sharply above random — at degree 8 and `f_adv = 0.2` it takes it from 0.83 to **1.000**, meaning *every* honest node ends up with an adversarial peer (§3.3).
|
||||
|
||||
3. **Deanonymization is governed by path length, not degree.** A message is *deanonymized* when its whole blend path is adversarial; because relays are chosen blind to who is adversarial, this rate is the exact hypergeometric `≈ f_adv^blend_hops` — independent of peering degree, and driven down exponentially by lengthening the path. It is *fully* deanonymized when, in addition, the honest sender is directly peered with an adversary; that adds the `observed` factor, so full deanonymization — unlike whole-path capture — **rises with degree** and is amplified by a worst-case placement (§3.4).
|
||||
|
||||
4. **Reliability degrades with churn; degree buys back coverage.** With a fraction `u` of unresponsive nodes that relay nothing, a message survives its cascade only if every relay forwards, so the delivery rate is `≈ (1 − u)^blend_hops` — longer paths are far more fragile. Unresponsive nodes are routing holes that can strand pockets during the final flood; a higher degree supplies redundant paths that keep coverage near-total. The effect is a genuine percolation threshold with a closed form: the flood travels only over the responsive sub-graph, which is site percolation on a `d`-regular graph and keeps a giant component only up to `u_c = 1 − 1/(degree − 1)`. Degree 3 therefore dies at exactly 50 % churn, degree 6 survives to 80 % and degree 8 to 86 % — each measured collapse landing on its predicted threshold (§3.5).
|
||||
|
||||
5. **How fast an emitter is linked scales inversely with its stake.** Only a *linkable* node — one with ≥ 1 adversarial peer, a fraction `1 − (1 − f_adv)^degree` of nodes; the rest are structurally unlinkable — can be tied to a message, and it is, the first time one of its emissions travels a wholly-adversarial cascade (per-emission probability `q = f_adv^blend_hops`). Since a node emits at a rate equal to its stake, the time to link it with confidence α is `≈ 30 s · ln(1/(1−α)) / (stake · q)`. At `f_adv = 0.2`, 3 hops, degree 8, a 5 %-stake node is linked within ~2 days (α = 0.9), a 0.1 % node within ~100 days, and a 0.001 % node only after ~27 years (§3.6). Counting the same events *estimates* the node's stake, but pinning it to ±10 % below ~0.1 % stake takes over a decade and below ~0.01 % centuries — fine-grained stake is practically unlearnable (§3.7).
|
||||
|
||||
6. **Messaging redundancy is reliability and exposure in one dial.** Sending each message over `R` independent cascades delivers it if *any* cascade survives — `delivery = 1 − (1 − (1 − u)^blend_hops)^R` — but captures it if *any* cascade is adversarial — `deanon = 1 − (1 − f_adv^blend_hops)^R`. Both obey the same law, so redundancy recovers delivery *and* speeds linking by ≈ `R×` together; it cannot buy reliability without spending anonymity (§3.8).
|
||||
|
||||
**The tension, in one line.** Axes 1, 2-eclipse, and 4 all want *more* degree; axis 2-observation and axis 3-full-deanonymization want *less*. Because whole-path deanonymization (axis 3) depends only on the blend-path length, the resolution is to raise the degree to where speed, eclipse, and churn saturate (6–8) and to control anonymity independently through the number of blend hops. Messaging redundancy (finding 6) does not escape the trade — it moves reliability and anonymity together, never apart — and time itself is an axis: exposure is a rate, and a high-stake node accumulates it fastest (finding 5).
|
||||
|
||||
*Method note: the peer graph is a deterministic, exactly d-regular matching-union reconstructible from one seed; the adversary observation/eclipse counts and both deanonymization rates are computed in closed form and are exact at every N (including 10⁶); propagation delays are Monte-Carlo over random senders (200 rounds × 8 topologies per cell). Delays fold a geographic link base (15–200 ms), an exponential transport jitter, and a per-node processing lag ({10, 50, 100} ms); mixing is the residual wait to a relay's next free-running release on a Uniform{0…3}-second clock.*
|
||||
|
||||
---
|
||||
|
||||
<a id="s2"></a>
|
||||
## 2. Model
|
||||
|
||||
All delays are in milliseconds; the mix clock's maximum interval is in whole seconds.
|
||||
|
||||
**Peer graph.** A seeded random **d-regular** graph over a globally known node list: every node has exactly `degree` symmetric peers, and the whole graph is reconstructed identically by everyone from one global seed. It is realized as a vectorized union of `degree` random perfect matchings (simple, low-diameter, scalable to 10⁶). The graph is a pure function of the topology seed and is identical across every adversary and propagation setting measured on it.
|
||||
|
||||
**Blend cascade (propagation).** Each round, a sender routes a message along a path of `blend_hops` relays — each a free-running timed-release mix — and the last relay floods it to the whole network. A transport leg between two nodes is the **directed shortest path** with edge weight `base(u,v) + Exp(jitter) + processing(u)`: a geographic base latency (metro 15 → antipodal 200 ms), an exponential transport jitter (mean 5 ms), and the relaying node's fixed processing lag (drawn once per node from {10, 50, 100} ms at {0.5, 0.4, 0.1}). At each relay the message waits the **residual** to that relay's next release on a free-running clock whose intervals are Uniform{0…`max_blend_delay`} seconds; the final flood is plain transport. The **full delay** is the sum of the transport legs, the per-hop mixing waits, and the broadcast to the last node.
|
||||
|
||||
**Unresponsive nodes.** A random fraction `unresponsive_frac` of the population relays nothing — their outgoing edges are removed, so nothing routes *through* them, though they can still *receive* as a leaf. Relays are drawn from the whole node list **blind to responsiveness** (a sender cannot know who is up), so a message dies if any relay on its path is unresponsive. This axis affects propagation only.
|
||||
|
||||
**Adversary.** A fraction `f_adv` of nodes are adversarial, placed either at random (average case) or by a greedy worst-case strategy (the security *envelope*, characterized at N ≤ 10⁵). An honest node is **observed** if it has ≥ 1 adversarial peer and **eclipsed** if *all* its peers are adversarial; both are counted exactly by one sparse reduction over the graph.
|
||||
|
||||
**Deanonymization.** Tying propagation to the adversary. Relays are chosen blind to who is adversarial, so the probability that a message's *whole* blend path is adversarial — **deanonymization**, the adversary owning the cascade end-to-end — is the exact hypergeometric `C(n_adv, blend_hops) / C(N−1, blend_hops) ≈ f_adv^blend_hops`, depending only on the adversary *count*, not the placement or the degree. Multiplying by the fraction of honest nodes with ≥ 1 adversarial peer (`observed_frac`) gives **full deanonymization** — the honest sender is additionally exposed, tying the message to its originator. Both are exact at every N.
|
||||
|
||||
---
|
||||
|
||||
<a id="s3"></a>
|
||||
## 3. Findings
|
||||
|
||||
Unless noted, propagation figures are at N = 100 000 with `max_blend_delay = 3` s and no churn; adversary and deanonymization figures span N up to 10⁵ with the worst-case envelope (random-placement metrics are additionally exact at 10⁶).
|
||||
|
||||
<a id="s3-1"></a>
|
||||
### 3.1 Propagation speed vs peering degree
|
||||
|
||||
Full cascade delay falls steeply from low degree and then flattens. At N = 100 000, `max_blend_delay = 3` s:
|
||||
|
||||
| blend_hops | d = 3 | 4 | 6 | 8 | 12 | 16 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 1 | 4.8 s | 3.5 | 2.7 | 2.4 | 2.1 | 1.9 |
|
||||
| 2 | 7.5 | 5.6 | 4.5 | 4.0 | 3.7 | 3.5 |
|
||||
| 3 | 10.3 | 7.8 | 6.3 | 5.8 | 5.2 | 5.0 |
|
||||
| 5 | 15.7 | 12.0 | 9.9 | 9.1 | 8.4 | 8.0 |
|
||||
|
||||
Going from degree 3 to 6 removes ~40 % of the 3-hop delay; from 8 to 16 removes ~13 %. Degree 6–8 captures nearly all of the achievable speed-up (**Fig 1**). Delay grows close to linearly in the number of blend hops, and the per-hop cost is remarkably constant *within* a degree while falling *across* degrees — 2.7 s at degree 3, 2.1 s at 4, 1.8 s at 6, 1.7 s at 8, and 1.5 s at 12–16, each holding to within 0.1 s over every hop added (**Fig 2**). About 1.2 s of that is the mixing wait — the mean residual to the next release of a Uniform{0…3}-second free-running clock, which is the same at every degree — and the remainder is one more transport leg, which is exactly what a higher degree shortens. The floor is therefore the mixing, not the network: even at infinite degree a hop could not cost less than ~1.2 s.
|
||||
|
||||

|
||||
*Fig 1 — Blend full delay vs peering degree, one line per blend-path length (N = 100 000, `max_blend_delay = 3` s). The curve is convex: most of the gain is realised by degree 6–8.*
|
||||
|
||||
<a id="s3-2"></a>
|
||||
### 3.2 Delay composition and network-size scaling
|
||||
|
||||
The multi-second total is spent in the blend path, not the flood. At degree 8, 3 hops, N = 100 000: the path (transport legs + mixing) is 5.05 s while the final flood to the whole network is only 0.70 s — seven eighths of the delay is the cascade, one eighth is reaching everybody. The flood reaches 50 / 90 / 99 % of nodes in 0.52 / 0.58 / 0.62 s, so the last percent of the network costs only ~100 ms more than the first half. The flood is also what a higher degree accelerates most — 99 %-coverage time falls from 1.90 s at degree 3 to 0.41 s at degree 16. And the total is nearly flat in network size: at degree 8, 3 hops it is 4.87 s at 1 000 nodes, 5.27 s at 10 000, and 5.75 s at 100 000 — an 18 % rise for a hundred-fold size increase, because the flood grows only logarithmically in N while the fixed mixing waits dominate (**Fig 3**).
|
||||
|
||||

|
||||
*Fig 3 — Blend full delay vs network size N (log-x, degree lines; 3 hops, `max_blend_delay = 3` s). Near-flat scaling: the seconds-scale mixing waits dominate and the flood grows only ~log N.*
|
||||
|
||||
<a id="s3-3"></a>
|
||||
### 3.3 Adversary observation and eclipse
|
||||
|
||||
With random placement the two structural metrics follow their closed forms exactly. **Observed** — an honest node with ≥ 1 adversarial peer — is `1 − (1 − f_adv)^degree` and rises with degree (N = 100 000):
|
||||
|
||||
| f_adv | d = 3 | 4 | 6 | 8 | 12 | 16 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 0.05 | 0.14 | 0.19 | 0.27 | 0.34 | 0.46 | 0.56 |
|
||||
| 0.10 | 0.27 | 0.34 | 0.47 | 0.57 | 0.72 | 0.82 |
|
||||
| 0.20 | 0.49 | 0.59 | 0.74 | 0.83 | 0.93 | 0.97 |
|
||||
| 0.33 | 0.70 | 0.80 | 0.91 | 0.96 | 0.99 | 1.00 |
|
||||
|
||||
**Eclipse** — every peer adversarial — is `f_adv^degree` and *falls* with degree, negligible beyond low degree: at `f_adv = 0.5` it is 0.125 at degree 3, 0.004 at degree 8, and ~0 by degree 12; at `f_adv = 0.33` it is already 0.036 at degree 3 and ~0 by degree 8. **A degree of 6–8 makes eclipse practically impossible even against a large adversary** (**Fig 7**). A worst-case greedy-coverage placement pushes observation far above random, and at a useful degree it saturates. At degree 8: `f_adv = 0.1` takes 0.570 → 0.766, and `f_adv = 0.2` takes 0.832 → **1.000** — one adversarial node in five, placed well, observes *every* honest node. At degree 4 the same placement gives 0.590 → 0.840 at `f_adv = 0.2` and reaches 1.000 by `f_adv = 0.33`. So observation must be planned against the worst-case envelope, where it is effectively total for any adversary worth worrying about, while eclipse can be planned against the (already tiny) random rate. This is also why full deanonymization equals the whole-path rate under a worst-case placement (§3.4): the `observed_frac` factor is simply 1.
|
||||
|
||||

|
||||
*Fig 7 — Honest eclipse fraction vs peering degree (random placement, N = 100 000). Eclipse collapses with degree; by degree 6–8 it is negligible even at `f_adv = 0.5`.*
|
||||
|
||||

|
||||
*Fig 8 — Observed fraction over degree × adversary fraction. Observation grows in **both** directions — the cost of a higher degree.*
|
||||
|
||||
<a id="s3-4"></a>
|
||||
### 3.4 Deanonymization
|
||||
|
||||
Because relays are drawn blind to who is adversarial, the whole-path-adversarial rate is exactly `f_adv^blend_hops` — degree- and placement-independent — and drops by a factor of `f_adv` per hop:
|
||||
|
||||
| f_adv | 1 hop | 2 | 3 | 5 |
|
||||
|---|---|---|---|---|
|
||||
| 0.10 | 0.100 | 0.010 | 0.0010 | 0.00001 |
|
||||
| 0.20 | 0.200 | 0.040 | 0.0080 | 0.00032 |
|
||||
| 0.33 | 0.330 | 0.109 | 0.0359 | 0.0039 |
|
||||
| 0.50 | 0.500 | 0.250 | 0.125 | 0.031 |
|
||||
|
||||
So the blend-path length is the anonymity lever, and the number of hops needed to hold whole-path capture below a target ε is `blend_hops ≥ ln ε / ln f_adv`: to reach ε = 0.01 takes 3 hops at `f_adv = 0.2`, 5 hops at `f_adv = 0.33`, and 7 hops at `f_adv = 0.5` (**Fig 12**). Each hop, though, costs 1.5–2.7 s of latency depending on the degree (§3.1) and a `(1 − u)` reliability factor (§3.5) — the price of anonymity.
|
||||
|
||||
**Full** deanonymization adds the requirement that the honest sender is itself peered with an adversary, i.e. multiplies by `observed_frac`. Unlike whole-path capture, this **rises with degree** (2 hops, random placement): at `f_adv = 0.33` it climbs from 0.076 at degree 3 to 0.109 at degree 16 (approaching the whole-path ceiling of `0.33² = 0.109` as the sender becomes almost surely observed); at `f_adv = 0.5` it climbs from 0.219 to 0.250. A worst-case-coverage placement drives the `observed` factor up toward 1, pushing full deanonymization to the whole-path ceiling (**Fig 14**, **Fig 15**). This is the sharp form of the tension: a higher degree that speeds propagation and defeats eclipse *also* makes the sender almost certainly exposed whenever the path is captured.
|
||||
|
||||

|
||||
*Fig 12 — Whole-path deanonymization rate vs blend-path length, one line per `f_adv` (log-y). Solid = simulated, dashed = the analytic `f_adv^blend_hops`; path length drives it down exponentially, independent of degree.*
|
||||
|
||||

|
||||
*Fig 15 — Full deanonymization rate vs peering degree, per `f_adv` (2 hops, log-y). Full deanonymization **rises** with degree (dashed = the degree-flat whole-path rate): more peers make the sender almost surely touch the adversary.*
|
||||
|
||||
<a id="s3-5"></a>
|
||||
### 3.5 Reliability under churn
|
||||
|
||||
With a fraction `u` of unresponsive nodes, a message survives only if every relay on its (responsiveness-blind) path forwards, so the delivery rate tracks `(1 − u)^blend_hops` — and longer paths are much more fragile (N = 100 000, degree 8):
|
||||
|
||||
| blend_hops | u = 0.05 | 0.10 | 0.20 | 0.30 | 0.50 |
|
||||
|---|---|---|---|---|---|
|
||||
| 1 | 0.947 | 0.895 | 0.798 | 0.701 | 0.494 |
|
||||
| 2 | 0.895 | 0.815 | 0.642 | 0.488 | 0.245 |
|
||||
| 3 | 0.862 | 0.729 | 0.510 | 0.339 | 0.121 |
|
||||
| 5 | 0.774 | 0.591 | 0.331 | 0.166 | 0.030 |
|
||||
|
||||
Every entry sits within 0.007 of `(1 − u)^blend_hops` (SEM ≤ 0.009), so the law is exact to measurement precision in this regime. This compounds with the anonymity lever of §3.4: a 5-hop path that holds deanonymization to a few per mille also loses 41 % of messages at 10 % churn and 97 % at 50 % churn (**Fig 10**). The two are directly opposed — every hop bought for anonymity is paid for in delivery — which caps how long a usable path can be at a given churn level.
|
||||
|
||||
The second churn effect is on **coverage**: unresponsive nodes still receive but do not relay, so they are routing holes that can strand pockets during the final flood. Here a higher degree is decisive, and not gradually — **the collapse has a threshold, and it is exactly predictable.** Because unresponsive nodes relay nothing, the network that actually carries a flood is the sub-graph induced on the responsive nodes, and that is textbook *site percolation* on a random `d`-regular graph, whose giant component survives only while the responsive fraction exceeds `1/(degree − 1)`. The network therefore tolerates churn up to
|
||||
|
||||
**`u_c = 1 − 1/(degree − 1)`**
|
||||
|
||||
and shatters above it. Walking the churn to 90 % (N = 100 000, single hop, 6 400 rounds per cell) the measured flood coverage collapses at exactly that point for every degree:
|
||||
|
||||
| degree | `u_c` | u = 0.3 | 0.5 | 0.6 | 0.7 | 0.8 | 0.9 |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| 3 | **0.50** | 0.92 | **0.01** | 0.00 | 0.00 | 0.00 | 0.00 |
|
||||
| 4 | **0.67** | 0.99 | 0.85 | 0.54 | **0.00** | 0.00 | 0.00 |
|
||||
| 6 | **0.80** | 1.00 | 0.98 | 0.93 | 0.75 | **0.03** | 0.00 |
|
||||
| 8 | **0.86** | 1.00 | 1.00 | 0.98 | 0.92 | 0.62 | **0.00** |
|
||||
| 12 | **0.91** | 1.00 | 1.00 | 1.00 | 0.99 | 0.90 | **0.20** |
|
||||
| 16 | **0.93** | 1.00 | 1.00 | 1.00 | 1.00 | 0.97 | **0.64** |
|
||||
|
||||
Every degree holds essentially total coverage until its own threshold and then falls off a cliff at it (bold; **Fig 20**) — the last grid point below `u_c` still delivers to most of the network, the first point at or above it delivers to almost none. The collapse is sharp, not gradual, so a network does not degrade gracefully into heavy churn: it works, and then it does not. (This study is an independent run from the one behind the delivery table above; where the two overlap they agree to ≤ 0.01 — degree 4 at `u = 0.5` gives 0.85 in both — which is a useful cross-check on the whole propagation path.)
|
||||
|
||||
This also explains the one badly-behaved cell in the study. Degree 3 at `u = 0.5` sits *exactly on its critical point*, where the giant component is famously bimodal — across eight topologies, five delivered to no one at all and three to 0.3–8.6 % of the network. No amount of extra sampling converges that mean, because it is a critical point rather than a noisy measurement (§5). Hence the design rule is stated as a threshold, not a coverage number: **degree 3 dies at 50 % churn, degree 6 survives to 80 %, degree 8 to 86 %.**
|
||||
|
||||
The threshold also bounds the delivery law of the previous table. `(1 − u)^blend_hops` assumes the legs are routable, which holds while `u` is comfortably below `u_c`: at degree 8 measured single-hop delivery is 0.494 against a predicted 0.5 at `u = 0.5`, but only 0.078 against 0.2 at `u = 0.8` as the threshold approaches, and 0 beyond it. **Below the percolation threshold the delivery law holds; above it, delivery does not merely decay — it stops.**
|
||||
|
||||

|
||||
*Fig 10 — Message delivery rate vs unresponsive fraction, one line per blend-path length (N = 100 000, degree 8); dashed = `(1 − u)^blend_hops`. Longer paths are much more fragile to churn.*
|
||||
|
||||

|
||||
*Fig 20 — Coverage across the percolation threshold, churn walked to 90 % (N = 100 000, single hop). Each degree's dotted vertical is its predicted `u_c = 1 − 1/(degree − 1)`; every curve falls off its own mark.*
|
||||
|
||||
<a id="s3-6"></a>
|
||||
### 3.6 Time to link — how fast an emitter is deanonymized
|
||||
|
||||
Exposure is a *rate*, not a one-off. Model traffic as: every 30 s one node network-wide emits a message, chosen with probability proportional to its stake, so a node of stake fraction `s` emits with probability `s` per slot. Only a node with at least one adversarial peer — the **linkable set**, a fraction `observed_frac = 1 − (1 − f_adv)^degree` of the network (0.83 at `f_adv = 0.2`, degree 8) — can ever be tied to a message this way; a node with no adversarial peer is structurally unlinkable. A linkable node is *linked* the first time one of its emissions traverses a wholly-adversarial cascade, which happens per emission with probability `q = f_adv^blend_hops` (the whole-path rate of §3.4; the sender-peered condition is already met for a linkable node). Attributable observations therefore arrive as a Bernoulli(`s·q`)-per-slot process, and the time to have linked the node with probability α is
|
||||
|
||||
`T_link(α) = 30 s · ⌈ln(1−α) / ln(1 − s·q)⌉ ≈ 30 s · ln(1/(1−α)) / (s·q)` — **inversely proportional to the node's stake.**
|
||||
|
||||
At `f_adv = 0.2`, `blend_hops = 3`, degree 8 (`q = 0.2³ = 0.008`):
|
||||
|
||||
| stake `s` | α = 0.5 | α = 0.9 | α = 0.99 |
|
||||
|---|---|---|---|
|
||||
| 5 % | 0.6 d | 2.0 d | 4.0 d |
|
||||
| 1 % | 3.0 d | 10.0 d | 20.0 d |
|
||||
| 0.5 % | 6.0 d | 20.0 d | 40.0 d |
|
||||
| 0.1 % | 30.1 d | 99.9 d | 199.9 d |
|
||||
| 0.05 % | 60.2 d | 199.9 d | 399.8 d |
|
||||
| 0.01 % | 300.8 d | 999 d (2.7 yr) | 1 999 d (5.5 yr) |
|
||||
| 0.005 % | 602 d (1.6 yr) | 1 999 d (5.5 yr) | 3 998 d (11.0 yr) |
|
||||
| 0.001 % | 3 008 d (8.2 yr) | 9 994 d (27.4 yr) | 19 988 d (54.8 yr) |
|
||||
|
||||
Every 10× drop in stake multiplies the time by 10, and raising the confidence from α = 0.5 to 0.99 costs a fixed factor `ln(1/0.01)/ln 2 ≈ 6.6` at any stake. A large staker is deanonymized within days; the smallest holders are, for practical purposes, never linked — and lengthening the blend path multiplies `q` down by `f_adv` per hop, stretching every entry in the table by `1/f_adv` (25× per two hops at `f_adv = 0.2`). This is the anonymity value of both a small stake and a long path, expressed as time (**Fig 16**).
|
||||
|
||||

|
||||
*Fig 16 — Time to link an emitter vs its stake fraction, one line per confidence α (log-log; `f_adv = 0.2`, `blend_hops = 3`, degree 8; linkable fraction 0.83). The parallel lines are the `T ∝ 1/stake` law; only the 83 % of nodes with an adversarial peer are on it at all.*
|
||||
|
||||
<a id="s3-7"></a>
|
||||
### 3.7 Learning a node's stake
|
||||
|
||||
The same attributable-observation stream measures *how much* stake a node holds: its events arrive at rate `s·q`, so counting them estimates `s`. After `N` attributable observations the stake estimate has relative precision `≈ 1/√N` (a count of `N` has standard error `√N`), and the expected time for a node sitting at a threshold `θ` to accumulate them is `T = 30 s · N / (θ·q)`. Identity linking (§3.6) is the `N = 1` case; certifying a node holds *at least* `θ` to ±10 % needs `N = 100`, to ±5 % needs `N = 400`. At `f_adv = 0.2`, 3 hops, degree 8 (`q = 0.008`), in **days**:
|
||||
|
||||
| threshold `θ` | identity (N=1) | stake ±10 % (N=100) | stake ±5 % (N=400) |
|
||||
|---|---|---|---|
|
||||
| 5 % | 0.9 d | 86.8 d | 347 d |
|
||||
| 1 % | 4.3 d | 434 d (1.2 yr) | 1 736 d (4.8 yr) |
|
||||
| 0.5 % | 8.7 d | 868 d (2.4 yr) | 3 472 d (9.5 yr) |
|
||||
| 0.1 % | 43.4 d | 4 340 d (11.9 yr) | 17 361 d (47.6 yr) |
|
||||
| 0.05 % | 86.8 d | 8 681 d (23.8 yr) | 34 722 d (95.1 yr) |
|
||||
| 0.01 % | 434 d (1.2 yr) | 43 403 d (118.9 yr) | 173 611 d (475.6 yr) |
|
||||
| 0.005 % | 868 d (2.4 yr) | 86 806 d (237.8 yr) | 347 222 d (951.3 yr) |
|
||||
| 0.001 % | 4 340 d (11.9 yr) | 1 189 yr | 4 756 yr |
|
||||
|
||||
So an adversary can tell within a year *whether* a node is a large staker (≥ ~1 %), but resolving stake below ~0.1 % to a useful precision takes more than a decade and below ~0.01 % runs to centuries — a hard floor on stake inference set by the deanonymization rate and the emission cadence (**Fig 18**). Note the two columns answer different questions: the `N = 1` column is just §3.6's linking time re-expressed (it identifies the node without sizing it), while the ±10 %/±5 % columns are what it costs to *measure* the stake. Sizing is 100× to 400× more expensive than identifying, so on any realistic horizon an adversary learns *who* a node is long before it learns *how much* it holds.
|
||||
|
||||

|
||||
*Fig 18 — Time to certify a node holds at least a stake threshold θ, vs θ, for identity (N=1) and stake estimates to ±10 % / ±5 % (dotted lines mark 1 and 10 years). Sub-0.1 % stake is effectively unlearnable.*
|
||||
|
||||
<a id="s3-8"></a>
|
||||
### 3.8 Messaging redundancy — reliability against anonymity
|
||||
|
||||
Sending each message over `R` **independent** blend cascades means it succeeds if *any* cascade delivers, and is captured if *any* cascade is adversarial — the same "at least one of R" structure, so both quantities follow `1 − (1 − x)^R`. Redundancy is thus a single dial that moves reliability and exposure **in the same direction**. Delivery (measured; degree 8, 3 hops, `p₁ = (1−u)^3`) and whole-path/full deanonymization (exact; `f_adv = 0.2`) as `R` rises:
|
||||
|
||||
| R | delivery `u=0.1` | delivery `u=0.3` | delivery `u=0.5` | deanon | full deanon |
|
||||
|---|---|---|---|---|---|
|
||||
| 1 | 0.735 | 0.342 | 0.117 | 0.0080 | 0.0067 |
|
||||
| 2 | 0.930 | 0.581 | 0.228 | 0.0159 | 0.0133 |
|
||||
| 3 | 0.980 | 0.713 | 0.319 | 0.0238 | 0.0198 |
|
||||
| 4 | 0.995 | 0.810 | 0.406 | 0.0316 | 0.0263 |
|
||||
|
||||
(Delivery is measured over 9 600 rounds per cell, SEM ≤ 0.006; the deanonymization columns are closed-form. Every delivery figure sits within 0.015 of its `1 − (1 − p₁)^R` prediction — e.g. 0.810 measured against 0.813 predicted at `u = 0.3, R = 4`.)
|
||||
|
||||
`R = 3` turns a one-in-three delivery at 30 % churn into better than seven-in-ten, but simultaneously triples the per-emission capture probability (0.008 → 0.024). Because the capture rate rises ≈ `R×`, so does the linking rate — `q_R ≈ R·q₁` — and every time in §3.6–§3.7 shrinks by ≈ `R`:
|
||||
|
||||
| stake | R = 1 | R = 2 | R = 3 | R = 4 |
|
||||
|---|---|---|---|---|
|
||||
| 5 % | 2.0 d | 1.0 d | 0.7 d | 0.5 d |
|
||||
| 1 % | 10.0 d | 5.0 d | 3.4 d | 2.5 d |
|
||||
| 0.1 % | 99.9 d | 50.2 d | 33.6 d | 25.3 d |
|
||||
|
||||
*Time to link at α = 0.9, `f_adv = 0.2`, 3 hops (**Fig 17**).* So four copies of every message buy back delivery from 0.34 to 0.81 at 30 % churn and, in exchange, cut a 1 %-staker's anonymity lifetime from ten days to two and a half.
|
||||
|
||||
Expressing the cost as *time* rather than as a per-emission probability is what makes the trade legible, because the two sides then carry the same units of consequence — messages that arrive versus days of anonymity remaining. **Fig 21** puts them on one plot: the delivery curve rises through `R` while every stake's time-to-link falls through it, and the two cross. The probability view (**Fig 19**) says redundancy multiplies capture by `R`; the time view says the same thing in the currency an operator actually cares about — a 5 %-staker linked in 2.0 days at `R = 1` is linked in 0.5 days at `R = 4`, and no choice of `R` improves both axes at once.
|
||||
|
||||

|
||||
*Fig 21 — The redundancy trade in both units: measured delivery rate (left axis, solid, ↑ good) against time to link (right axis, log days, dashed, ↓ bad) for three stakes, at `u = 0.3`, `f_adv = 0.2`, 3 hops. Buying delivery with copies spends anonymity time in proportion.*
|
||||
|
||||
**The trade gets worse, not better, near the churn threshold.** The `1 − (1 − p₁)^R` law assumes the `R` cascades fail *independently*, which holds while relay responsiveness is the binding constraint. Close to the percolation threshold of §3.5 the binding constraint becomes the *shared* responsive graph — if the sender sits in a poorly-connected pocket, every cascade fails together — so redundancy under-delivers against the independent prediction: at degree 3, single hop, `u = 0.3`, `R = 4` measures 0.902 where independence predicts 0.970, and at degree 3 with `u = 0.5` four copies still deliver only 1.4 % of messages. Redundancy cannot repair a network that is falling apart structurally; correlated failure defeats it exactly where reliability is most wanted.
|
||||
|
||||
**Redundancy buys delivery only — never coverage.** It is tempting to expect a second benefit, that independent cascades flooding from different final relays would between them reach pockets a single flood strands. They do not, and the reason is structural: a cascade is *delivered* only if the sender can route the message to its relay in the first place, so every delivered cascade's final relay already lies in the sender's own reachable component and floods (a subset of) the very same set. The union over `R` cascades therefore cannot exceed what one delivered cascade already reaches — measured directly, in the fragmented regime the union was identical to the best single flood in every round where two or more cascades delivered, and coverage is flat in `R` to four decimal places at every degree and churn level tested (degree 8, `u = 0.5`: 0.9959 at every `R`). At the critical point it even drifts slightly *down* with `R` (degree 3, `u = 0.5`: 0.053 → 0.045), because extra copies mostly add deliveries out of small pockets, which lowers the average coverage of a delivered message. Redundancy is a pure reliability-for-anonymity trade with no coverage dividend; the way to protect coverage is peering degree (§3.5), not repetition.
|
||||
|
||||

|
||||
*Fig 19 — Reliability gain vs anonymity cost of redundancy R (log-y, N = 20 000, degree 8, 3 hops). Delivery (↑ good) and whole-path / full deanonymization (↓ good) all climb as `1 − (1 − x)^R`: the same dial.*
|
||||
|
||||

|
||||
*Fig 17 — Time to link vs stake, one line per redundancy R (α = 0.9). Each extra cascade multiplies the capture rate by ≈ R, cutting the time to link proportionally.*
|
||||
|
||||
---
|
||||
|
||||
<a id="s4"></a>
|
||||
## 4. Design guidance
|
||||
|
||||
**Choose the peering degree for transport, and set it at 6–8.** Speed (§3.1), eclipse resistance (§3.3), and churn resilience (§3.5) all improve with degree and all saturate by 6–8; the percolation threshold of §3.5 is the binding constraint, ruling out degree 3–4 for any deployment expecting substantial churn. That constraint inverts into a sizing rule: to keep the network connected through a churn fraction `u`, the degree must satisfy **`degree > 1 + 1/(1 − u)`** — degree 4 to survive 50 % churn, 6 to approach 80 %, 8 for 86 %, with a margin above the threshold rather than at it (the critical point itself is where behaviour becomes erratic). Going above ~8 buys little transport and steadily worsens observation and full deanonymization (§3.3, §3.4), so a higher degree is justified only where worst-case eclipse at very low degree, or churn beyond ~86 %, would otherwise be a concern.
|
||||
|
||||
**Choose the blend-path length for anonymity, independently.** Whole-path deanonymization is `f_adv^blend_hops` and does not depend on degree, so the number of hops is a free anonymity control: `blend_hops ≥ ln ε / ln f_adv` for a whole-path-capture target ε against an assumed adversary fraction `f_adv` (§3.4). The cost is paid in latency (1.5–2.7 s/hop by degree, §3.1) and reliability (a `(1 − u)` factor per hop, §3.5), and those costs — not the anonymity benefit — are what bound the usable path length at a given churn level.
|
||||
|
||||
**Plan the adversary against the right case.** Observation and full deanonymization should be planned against the *worst-case* placement envelope (greedy coverage raises observation by up to ~0.15 absolute and pushes full deanonymization to the whole-path ceiling); eclipse can be planned against the random rate, since it is already negligible at degree ≥ 6–8 even in the worst case examined.
|
||||
|
||||
**Judge anonymity in time, and weigh it by stake.** A per-emission rate that looks small is not safety for an active, high-stake node: exposure accumulates at `stake · f_adv^blend_hops` per slot, so a large staker is linked in days while a small one is effectively never (§3.6). If high-stake participants must stay unlinkable over a long horizon, the lever is the blend-path length — each hop multiplies the time-to-link by `1/f_adv` — since their stake (hence emission rate) is fixed. The corresponding protection against *stake* inference is automatic below ~0.1 % but weak for large stakers, who are both linked and sized quickly (§3.7).
|
||||
|
||||
**Add messaging redundancy only for reliability, and price the anonymity it costs.** Redundancy `R` multiplies delivery and capture by the same `1−(1−x)^R`, so it should be raised only when churn makes single-cascade delivery inadequate (e.g. `R = 3` lifts delivery from 0.34 to 0.72 at 30 % churn), accepting that it shortens every time-to-link by ≈ `R` (§3.8). It is strictly the wrong tool for *coverage*: redundancy cannot reach nodes a single delivered cascade misses, because every cascade floods the sender's own component — coverage is bought with peering degree, not repetition. Note also that path length and redundancy pull against each other: each hop divides the capture rate by `f_adv` but multiplies the loss rate, while each extra copy restores delivery at a proportional anonymity cost. The efficient combination is the *shortest* path that still meets the anonymity target at `R = 1`, raising `R` only if the resulting delivery rate is unacceptable.
|
||||
|
||||
---
|
||||
|
||||
<a id="s5"></a>
|
||||
## 5. Validity and caveats
|
||||
|
||||
- **Structural adversary.** The adversary is modelled as controlling *nodes* and their peerings; it observes messages that traverse relays it owns (deanonymization) and honest nodes it peers (observation). Timing/traffic-analysis correlation across honest relays, and an adversary that adaptively targets the transport path of a *specific* known sender, are outside the model.
|
||||
- **Uniform, uncorrelated churn.** Unresponsive nodes are an independent uniform-random sample. Correlated outages (by region/AS) or adversarially placed churn would degrade coverage faster than the uniform percolation of §3.5; the results here are the average, not the worst, case for reliability.
|
||||
- **Exactly d-regular topology.** Every node has the same degree. A realistic degree *distribution* (hubs and leaves) would shift both the flood dynamics and the per-node observation exposure; the regular graph is the clean baseline.
|
||||
- **Sampled propagation, exact structure — and what each is worth.** Only the propagation quantities are sampled: they are Monte-Carlo over **1 000 rounds × 8 independent topologies = 8 000 rounds per cell**, which puts the standard error at **≤ 0.009 on every delivery rate**, **≤ 0.001 on every coverage figure** (bar the critical cell below), and **≤ 0.04 s on every full-delay mean** (the redundancy study uses 1 200 × 8 = 9 600 rounds per cell, SEM ≤ 0.006, and the churn-threshold study 800 × 8 = 6 400). That is a digit finer than the tables quote, so the reported two-decimal rates and 0.1-second delays are resolved rather than sampling noise; error bars were computed across topologies, which captures graph-to-graph variation as well as round-to-round. Everything else — the graph invariants, the observation and eclipse counts, both deanonymization rates, and therefore all of §3.6–§3.8's derived times — is closed-form and carries **no sampling error at all** at any N. The worst-case adversary placement is a greedy envelope characterized at N ≤ 10⁵.
|
||||
- **One cell is intrinsically unstable, by physics rather than sampling.** Coverage at degree 3 with `u = 0.5` sits exactly on that degree's percolation threshold, where the giant component is bimodal: five of eight topologies delivered to no one, three to 0.3–8.6 % of the network. Five times the rounds moved its mean only from 0.019 to 0.024 and left the spread untouched (SEM 0.009), because the variation is across *topologies*, not rounds — it is the critical point. §3.5 therefore states the threshold law rather than a mean there.
|
||||
- **Single mixing setting in the headline sweep.** The multi-second totals assume a Uniform{0…3}-second free-running mix clock; the per-hop mixing cost scales with `max_blend_delay`, but the *shape* of every finding (degree convexity, `f_adv^hops` deanonymization, `(1 − u)^hops` delivery, the coverage percolation) is independent of it.
|
||||
- **Idealised emission and linking model (§3.6–§3.8).** The time-to-link and stake-inference results assume one emission per 30 s slot with the emitter drawn exactly proportional to stake, independent emissions, and that a single wholly-adversarial cascade is a definitive, permanent link. A real adversary doing statistical disclosure could link *faster* by correlating partial observations; conversely, cover traffic, non-stake-proportional sending, or key rotation would slow it. The redundancy cascades are treated as independent given the responsive mask (a shared-relay correlation trims delivery by < 1.5 %, §6); attribution uses the whole-path capture rate, not once-linked cheaper observation, so these times are conservative upper bounds within the structural model.
|
||||
|
||||
---
|
||||
|
||||
<a id="s6"></a>
|
||||
## 6. Reproducibility
|
||||
|
||||
The simulator, configs, and analytic checks live in [`tools/simulators/blend/pd`](../../../tools/simulators/blend/pd). 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/<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 pd.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)`. 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 `pd.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/`.
|
||||
|
||||
## Figures
|
||||
|
||||
All twenty-one 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. Figures 11 and 20 both plot coverage against churn — 20 supersedes 11 by walking the churn past every degree's threshold, so only 20 is embedded above.
|
||||
BIN
reports/blend/pd/report-figures/01_delay_vs_degree.png
Normal file
|
After Width: | Height: | Size: 144 KiB |
BIN
reports/blend/pd/report-figures/02_delay_vs_blendhops.png
Normal file
|
After Width: | Height: | Size: 173 KiB |
BIN
reports/blend/pd/report-figures/03_delay_vs_N.png
Normal file
|
After Width: | Height: | Size: 126 KiB |
BIN
reports/blend/pd/report-figures/04_observed_vs_fadv.png
Normal file
|
After Width: | Height: | Size: 138 KiB |
BIN
reports/blend/pd/report-figures/05_eclipse_vs_fadv.png
Normal file
|
After Width: | Height: | Size: 112 KiB |
BIN
reports/blend/pd/report-figures/06_observed_vs_degree.png
Normal file
|
After Width: | Height: | Size: 128 KiB |
BIN
reports/blend/pd/report-figures/07_eclipse_vs_degree.png
Normal file
|
After Width: | Height: | Size: 104 KiB |
BIN
reports/blend/pd/report-figures/08_heatmap_observed.png
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
reports/blend/pd/report-figures/09_heatmap_eclipse.png
Normal file
|
After Width: | Height: | Size: 69 KiB |
BIN
reports/blend/pd/report-figures/10_delivery_vs_unresponsive.png
Normal file
|
After Width: | Height: | Size: 221 KiB |
BIN
reports/blend/pd/report-figures/11_coverage_vs_unresponsive.png
Normal file
|
After Width: | Height: | Size: 125 KiB |
BIN
reports/blend/pd/report-figures/12_deanon_vs_blendhops.png
Normal file
|
After Width: | Height: | Size: 208 KiB |
BIN
reports/blend/pd/report-figures/13_full_deanon_vs_blendhops.png
Normal file
|
After Width: | Height: | Size: 203 KiB |
BIN
reports/blend/pd/report-figures/14_full_deanon_vs_fadv.png
Normal file
|
After Width: | Height: | Size: 149 KiB |
BIN
reports/blend/pd/report-figures/15_full_deanon_vs_degree.png
Normal file
|
After Width: | Height: | Size: 117 KiB |
BIN
reports/blend/pd/report-figures/16_time_to_link_vs_stake.png
Normal file
|
After Width: | Height: | Size: 157 KiB |
|
After Width: | Height: | Size: 170 KiB |
|
After Width: | Height: | Size: 164 KiB |
BIN
reports/blend/pd/report-figures/19_redundancy_tradeoff.png
Normal file
|
After Width: | Height: | Size: 108 KiB |
BIN
reports/blend/pd/report-figures/20_coverage_percolation.png
Normal file
|
After Width: | Height: | Size: 222 KiB |
BIN
reports/blend/pd/report-figures/21_redundancy_time_to_link.png
Normal file
|
After Width: | Height: | Size: 166 KiB |
@ -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 figures verify test lint clean
|
||||
.PHONY: install smoke sweep sweep-fullscale redundancy percolation 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.
|
||||
@ -29,6 +29,12 @@ sweep: $(STAMP)
|
||||
sweep-fullscale: $(STAMP)
|
||||
$(PY) -m pd.sweep --config configs/fullscale.yaml
|
||||
|
||||
redundancy: $(STAMP) ## messaging redundancy R=1..4 (delivery vs deanonymization, time-to-link)
|
||||
$(PY) -m pd.sweep --config configs/redundancy.yaml
|
||||
|
||||
percolation: $(STAMP) ## churn threshold: coverage collapse at u_c = 1 - 1/(degree-1)
|
||||
$(PY) -m pd.sweep --config configs/percolation.yaml
|
||||
|
||||
figures: $(STAMP) ## make figures RUN=runs/<dir>
|
||||
$(PY) -m pd.plotting.make_figures --run $(RUN)
|
||||
|
||||
|
||||
@ -41,6 +41,23 @@ adversary metrics are exact at every N).
|
||||
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.
|
||||
- **Messaging redundancy:** `redundancy` R sends each emission over R *independent* blend cascades.
|
||||
A node receives the message from whichever cascade reaches it first (arrival times are combined
|
||||
element-wise), so it is delivered if any cascade delivers (`delivery = 1−(1−(1−u)^blend_hops)^R`)
|
||||
and captured if any cascade is whole-path-adversarial (`deanon = 1−(1−f_adv^blend_hops)^R`) — the
|
||||
same `1−(1−x)^R` law, so redundancy trades reliability against anonymity. It buys **no** extra
|
||||
coverage: a cascade only delivers if the sender could route to its relay, so every delivered
|
||||
cascade floods the sender's own component. R = 1 is the plain single-cascade model (default), to
|
||||
which the whole aggregation reduces exactly.
|
||||
- **Churn percolation:** the flood only crosses responsive nodes, so it lives on the responsive
|
||||
sub-graph — site percolation on a d-regular graph, whose giant component survives only while the
|
||||
responsive fraction exceeds `1/(degree−1)`. A network tolerates churn up to
|
||||
`u_c = 1 − 1/(degree−1)` (degree 3 → 0.5, degree 6 → 0.8, degree 16 → 0.93) and shatters above it;
|
||||
`configs/percolation.yaml` walks u across the threshold and `make verify` checks it.
|
||||
- **Linkability over time** (`pd.linkability`): given the rates above and an emission cadence (one
|
||||
node emits per 30 s slot, chosen ∝ stake), the module derives the *time to link* an emitter
|
||||
(`≈ 30 s·ln(1/(1−α))/(stake·q)`, inverse in stake) and the *time to learn its stake* to a threshold
|
||||
from the count of attributable observations. See `configs/redundancy.yaml` and the report.
|
||||
|
||||
## Quick start
|
||||
```
|
||||
@ -50,6 +67,8 @@ 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 redundancy # configs/redundancy.yaml (R=1..4: delivery vs deanonymization)
|
||||
make percolation # configs/percolation.yaml (churn threshold u_c = 1-1/(degree-1))
|
||||
make figures RUN=runs/<dir>
|
||||
```
|
||||
|
||||
|
||||
@ -8,6 +8,10 @@ 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
|
||||
# 1000 rounds x 8 topology seeds = 8000 rounds per cell: measured SEM <= 0.009 on the delivery
|
||||
# rates and <= 0.04 s on the full-delay means, so the report's rate tables and 0.1-second delay
|
||||
# tables are resolved rather than sampling noise. (Coverage at degree 3 / u=0.5 stays noisy at
|
||||
# any round count -- it sits on the percolation threshold, where topologies genuinely differ.)
|
||||
n_rounds: 1000
|
||||
n_placements: 8
|
||||
worstcase_max_n: 100000
|
||||
|
||||
24
tools/simulators/blend/pd/configs/percolation.yaml
Normal file
@ -0,0 +1,24 @@
|
||||
# Churn-percolation study: where does the responsive sub-graph shatter?
|
||||
#
|
||||
# Unresponsive nodes relay nothing, so the network that actually carries a flood is the sub-graph
|
||||
# induced on the responsive nodes. That is site percolation on a random d-regular graph, which has
|
||||
# a giant component only while the responsive fraction exceeds 1/(degree-1) -- i.e. the network
|
||||
# survives churn up to u_c = 1 - 1/(degree-1). This config walks u across that threshold for every
|
||||
# degree to locate the collapse (degree 3 breaks at 0.5, degree 6 at 0.8, degree 16 at 0.93).
|
||||
#
|
||||
# blend_hops=1 isolates the flood (one relay, no extra path fragility) and keeps the cost low;
|
||||
# 800 rounds x 8 seeds = 6400 rounds per cell. Coverage right AT the threshold is genuinely
|
||||
# bimodal across topologies (critical behaviour), so seed spread there is physics, not noise.
|
||||
n_nodes: [100000]
|
||||
degree: [3, 4, 6, 8, 12, 16]
|
||||
blend_hops: [1]
|
||||
max_blend_delay: [0]
|
||||
unresponsive_frac: [0.0, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9]
|
||||
redundancy: [1]
|
||||
f_adv: [0.1]
|
||||
adversary_mode: [random]
|
||||
seeds: 8
|
||||
base:
|
||||
n_rounds: 800
|
||||
n_placements: 1
|
||||
worstcase_max_n: 100000
|
||||
23
tools/simulators/blend/pd/configs/redundancy.yaml
Normal file
@ -0,0 +1,23 @@
|
||||
# Messaging-redundancy study: R independent blend cascades per emission (R in 1..4).
|
||||
# Measures delivery_R (reliability) and deanon_R / full_deanon_R (anonymity cost), and feeds the
|
||||
# time-to-link / stake-inference figures.
|
||||
#
|
||||
# Statistics: 1200 rounds x 8 topology seeds = 9600 rounds per cell -> binomial SEM <= 0.005 on
|
||||
# every delivery rate, enough to resolve the 1-(1-p)^R law (a coarser grid was non-monotonic in R).
|
||||
# degree 3 is included because it is the one setting where the responsive sub-graph fragments, so
|
||||
# it tests whether redundancy can buy back coverage (it cannot -- see the report).
|
||||
# max_blend_delay=0 keeps it affordable: redundancy multiplies propagation cost by R, and mixing
|
||||
# delays are not the subject here (the headline delay study is configs/default.yaml).
|
||||
n_nodes: [20000]
|
||||
degree: [3, 8]
|
||||
blend_hops: [1, 3]
|
||||
max_blend_delay: [0]
|
||||
unresponsive_frac: [0.0, 0.1, 0.2, 0.3, 0.5]
|
||||
redundancy: [1, 2, 3, 4]
|
||||
f_adv: [0.1, 0.2, 0.33]
|
||||
adversary_mode: [random, worstcase_coverage]
|
||||
seeds: 8
|
||||
base:
|
||||
n_rounds: 1200
|
||||
n_placements: 4
|
||||
worstcase_max_n: 100000
|
||||
@ -42,29 +42,34 @@ def adversary_metrics(graph: Graph, adv_mask: np.ndarray) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def deanon_metrics(n: int, n_adv: int, observed_frac: float, blend_hops: int) -> dict:
|
||||
def deanon_metrics(n: int, n_adv: int, observed_frac: float, blend_hops: int,
|
||||
redundancy: int = 1) -> 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``.
|
||||
With messaging **redundancy** ``R``, each emission is sent over ``R`` independent blend cascades
|
||||
and is captured if **any** cascade's whole path is adversarial, so the single-cascade
|
||||
hypergeometric ``d1`` becomes ``1 - (1 - d1)**R``. ``R = 1`` is the plain single-cascade case.
|
||||
|
||||
* ``deanon_rate`` — P(some cascade has **every** relay adversarial): the adversary then controls
|
||||
a whole cascade and links the message from entry to exit. The single-cascade term is the
|
||||
hypergeometric ``d1 = C(n_adv, k) / C(n-1, k)`` (an honest sender leaves all ``n_adv``
|
||||
adversaries in the ``n-1``-node relay pool) — it depends only on the adversary *count*, not
|
||||
the placement, and ``d1 ~ 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).
|
||||
so the adversary also ties the message to its originator: ``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
|
||||
k, R = int(blend_hops), int(redundancy)
|
||||
d1 = 0.0
|
||||
if 1 <= k <= n_adv and k <= n - 1:
|
||||
deanon = 1.0
|
||||
d1 = 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
|
||||
d1 *= (n_adv - i) / (n - 1 - i) # C(n_adv,k)/C(n-1,k), stable for small k
|
||||
deanon = 1.0 - (1.0 - d1) ** R # any of R independent cascades whole-path-adversarial
|
||||
return {"deanon_rate": float(deanon), "full_deanon_rate": float(deanon * observed_frac)}
|
||||
|
||||
|
||||
|
||||
@ -28,6 +28,7 @@ class SimConfig:
|
||||
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)
|
||||
redundancy: int = 1 # copies per emission via R independent cascades (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)
|
||||
@ -58,6 +59,8 @@ class SimConfig:
|
||||
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 self.redundancy < 1:
|
||||
raise ValueError(f"redundancy must be >= 1, got {self.redundancy}")
|
||||
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:
|
||||
@ -86,7 +89,7 @@ class SimConfig:
|
||||
"""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.unresponsive_frac, self.redundancy, 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,
|
||||
@ -107,6 +110,7 @@ class SweepConfig:
|
||||
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])
|
||||
redundancy: list[int] = field(default_factory=lambda: [1])
|
||||
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)
|
||||
@ -144,7 +148,7 @@ class SweepConfig:
|
||||
d = dict(d)
|
||||
base = d.pop("base", {})
|
||||
known = {"n_nodes", "degree", "blend_hops", "max_blend_delay", "unresponsive_frac",
|
||||
"f_adv", "adversary_mode", "seeds"}
|
||||
"redundancy", "f_adv", "adversary_mode", "seeds"}
|
||||
unknown = set(d) - known
|
||||
if unknown:
|
||||
raise ValueError(f"unknown sweep keys: {sorted(unknown)}")
|
||||
|
||||
@ -18,15 +18,16 @@ 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],
|
||||
unresponsive_fracs: list[float], redundancies: list[int],
|
||||
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
|
||||
axis, ``redundancies`` = the messaging-redundancy axis (R independent cascades per emission),
|
||||
``adv_grid`` = [(f_adv, mode)]. Deanonymization crosses each adversary placement with the
|
||||
propagation grid's blend-path lengths and redundancies, so it is emitted alongside the
|
||||
adversary rows.
|
||||
"""
|
||||
graph = build_graph(base)
|
||||
@ -37,10 +38,12 @@ def run_graph_cell(base: SimConfig, prop_grid: list[tuple[int, int]],
|
||||
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))
|
||||
for R in redundancies:
|
||||
rng = np.random.default_rng(
|
||||
round_seedseq(base, blend_hops, max_blend_delay, uf, R))
|
||||
prop = propagation_metrics(
|
||||
graph, blend_hops, max_blend_delay, uf, R, responsive, base, rng)
|
||||
prop_rows.append(propagation_row(base, blend_hops, max_blend_delay, uf, R, prop))
|
||||
|
||||
adv_rows: list[dict] = []
|
||||
deanon_rows: list[dict] = []
|
||||
@ -54,8 +57,9 @@ def run_graph_cell(base: SimConfig, prop_grid: list[tuple[int, int]],
|
||||
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))
|
||||
for R in redundancies:
|
||||
dz = deanon_metrics(graph.n, adv["n_adv"], adv["observed_frac"], bh, R)
|
||||
deanon_rows.append(deanon_row(base, bh, f_adv, mode, rep, R, adv, dz))
|
||||
|
||||
return prop_rows, adv_rows, deanon_rows
|
||||
|
||||
@ -67,15 +71,16 @@ def run_trajectory(config: SimConfig) -> dict:
|
||||
uf = config.unresponsive_frac
|
||||
responsive = assign_responsive(
|
||||
config.n_nodes, uf, np.random.default_rng(responsive_seedseq(config, uf)))
|
||||
R = config.redundancy
|
||||
prng = np.random.default_rng(
|
||||
round_seedseq(config, config.blend_hops, config.max_blend_delay, uf))
|
||||
round_seedseq(config, config.blend_hops, config.max_blend_delay, uf, R))
|
||||
prop = propagation_metrics(
|
||||
graph, config.blend_hops, config.max_blend_delay, uf, responsive, config, prng)
|
||||
graph, config.blend_hops, config.max_blend_delay, uf, R, 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)
|
||||
deanon = deanon_metrics(graph.n, adv["n_adv"], adv["observed_frac"], config.blend_hops, R)
|
||||
return {"graph": graph, "propagation": prop, "adversary": adv, "adv_mask": adv_mask,
|
||||
"deanon": deanon}
|
||||
|
||||
80
tools/simulators/blend/pd/src/pd/linkability.py
Normal file
@ -0,0 +1,80 @@
|
||||
"""Linkability over time: how long an adversary needs to link a node to a message (identity), and
|
||||
how long to certify the node's *stake*, given the structural deanonymization rates.
|
||||
|
||||
**Emission model.** Every ``slot_seconds`` (default 30 s) a single node network-wide emits, chosen
|
||||
with probability proportional to stake, so a node of stake fraction ``s`` emits with probability
|
||||
``s`` per slot. A node that has >=1 adversarial peer -- the *linkable* set, a fraction
|
||||
``observed_frac = 1-(1-f_adv)^degree`` of nodes -- has each emission captured **and attributed**
|
||||
(full deanonymization) with probability ``q = 1-(1-f_adv**blend_hops)**R`` per emission, where ``R``
|
||||
is the messaging redundancy (R independent cascades, captured if *any* is whole-path-adversarial).
|
||||
Nodes with no adversarial peer are structurally unlinkable via this mechanism.
|
||||
|
||||
Attributable observations therefore arrive as a Bernoulli(``s*q``)-per-slot process:
|
||||
|
||||
* **time to link** -- the first attributable observation -- is ``Geometric(s*q)``;
|
||||
* **time to learn stake** -- accumulate ``N`` attributable observations to estimate the emission
|
||||
rate (hence the stake) to relative precision ``~1/sqrt(N)`` -- is the sum of ``N`` such waits,
|
||||
mean ``N/(s*q)`` slots.
|
||||
|
||||
Linking (identity) is the ``N = 1`` special case of stake learning. Redundancy ``R`` multiplies the
|
||||
per-emission capture probability from ``d1`` to ``1-(1-d1)**R`` (``~ R*d1`` when ``d1`` is small),
|
||||
so it cuts every one of these times by roughly ``R``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
SLOT_SECONDS = 30.0
|
||||
|
||||
|
||||
def redundant(x: float, R: int) -> float:
|
||||
"""Probability at least one of ``R`` independent trials succeeds: ``1-(1-x)**R``."""
|
||||
return 1.0 - (1.0 - x) ** int(R)
|
||||
|
||||
|
||||
def capture_prob(deanon_single: float, observed_frac: float = 1.0, redundancy: int = 1) -> float:
|
||||
"""Per-emission probability an emission is captured **and** attributed to its sender.
|
||||
|
||||
``deanon_single`` is the single-cascade whole-path-adversarial prob ``f_adv**blend_hops``.
|
||||
For a *linkable* node (already known to have an adversary peer) pass ``observed_frac = 1``; pass
|
||||
the population ``observed_frac`` to average over the (unlinkable) nodes with no adversary peer.
|
||||
"""
|
||||
return observed_frac * redundant(deanon_single, redundancy)
|
||||
|
||||
|
||||
def time_to_link_seconds(stake: float, q: float, alpha: float,
|
||||
slot_seconds: float = SLOT_SECONDS) -> float:
|
||||
"""Time until the node is linked with probability ``alpha`` -- the first attributable
|
||||
observation. Exact geometric: ``P(linked by n slots) = 1-(1-s*q)**n``, so
|
||||
``n = ceil(ln(1-alpha)/ln(1-s*q))``. Returns ``inf`` for an unlinkable node (``s*q <= 0``)."""
|
||||
p = stake * q
|
||||
if p <= 0.0:
|
||||
return math.inf
|
||||
if p >= 1.0:
|
||||
return slot_seconds
|
||||
n = math.ceil(math.log(1.0 - alpha) / math.log(1.0 - p))
|
||||
return n * slot_seconds
|
||||
|
||||
|
||||
def time_to_stake_seconds(threshold_stake: float, q: float, n_obs: int,
|
||||
slot_seconds: float = SLOT_SECONDS) -> float:
|
||||
"""Expected time to accumulate ``n_obs`` attributable observations for a node whose stake sits
|
||||
at ``threshold_stake`` -- enough to estimate its stake to relative precision ``~1/sqrt(n_obs)``
|
||||
and so certify it holds at least that much. Mean of ``n_obs`` Geometric(``theta*q``) waits,
|
||||
``n_obs/(theta*q)`` slots. Returns ``inf`` when the node is unlinkable."""
|
||||
p = threshold_stake * q
|
||||
if p <= 0.0:
|
||||
return math.inf
|
||||
return n_obs / p * slot_seconds
|
||||
|
||||
|
||||
def obs_for_precision(rel_precision: float) -> int:
|
||||
"""Attributable observations needed to estimate the stake to relative std error ``eps``: a count
|
||||
of ``N`` events has relative error ``1/sqrt(N)``, so ``N = ceil(1/eps**2)``."""
|
||||
return max(1, math.ceil(1.0 / (rel_precision * rel_precision)))
|
||||
|
||||
|
||||
def stake_rel_precision(n_obs: int) -> float:
|
||||
"""Relative std error of the stake estimate after ``n_obs`` attributable observations."""
|
||||
return 1.0 / math.sqrt(n_obs) if n_obs > 0 else math.inf
|
||||
@ -6,13 +6,14 @@ from .config import SimConfig
|
||||
|
||||
|
||||
def propagation_row(config: SimConfig, blend_hops: int, max_blend_delay: int,
|
||||
unresponsive_frac: float, prop: dict) -> dict:
|
||||
unresponsive_frac: float, redundancy: int, 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,
|
||||
"redundancy": redundancy,
|
||||
"graph_seed": config.graph_seed,
|
||||
"n_rounds": config.n_rounds,
|
||||
"transport_jitter_mean_ms": config.transport_jitter_mean_ms,
|
||||
@ -36,16 +37,18 @@ def adversary_row(config: SimConfig, f_adv: float, mode: str, placement_rep: int
|
||||
|
||||
|
||||
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.
|
||||
placement_rep: int, redundancy: int, adv: dict, deanon: dict) -> dict:
|
||||
"""One row of the deanonymization table: a (placement x blend-path-length x redundancy) 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.
|
||||
``blend_hops`` and ``redundancy`` come from the propagation grid, the rest from the adversary
|
||||
placement; they 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,
|
||||
"redundancy": redundancy,
|
||||
"f_adv": f_adv,
|
||||
"adversary_mode": mode,
|
||||
"graph_seed": config.graph_seed,
|
||||
|
||||
@ -10,9 +10,11 @@ from __future__ import annotations
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from ..linkability import capture_prob, time_to_link_seconds, time_to_stake_seconds
|
||||
from . import style
|
||||
|
||||
_MS = "full propagation delay (ms)"
|
||||
_DAY = 86400.0
|
||||
|
||||
|
||||
def _prov(*dfs: pd.DataFrame) -> str:
|
||||
@ -112,6 +114,39 @@ def coverage_vs_unresponsive(prop: pd.DataFrame, adv: pd.DataFrame):
|
||||
return fig
|
||||
|
||||
|
||||
def coverage_percolation(prop: pd.DataFrame, adv: pd.DataFrame):
|
||||
"""Flood coverage vs churn across the percolation threshold, one line per degree.
|
||||
|
||||
Unresponsive nodes relay nothing, so the network that carries a flood is the sub-graph induced
|
||||
on the responsive nodes -- site percolation on a random d-regular graph, whose giant component
|
||||
survives only while the responsive fraction exceeds ``1/(degree-1)``. The dotted verticals mark
|
||||
the predicted collapse ``u_c = 1 - 1/(degree-1)``; each degree's curve falls off its own mark.
|
||||
Needs a churn grid that reaches past 0.5 (see configs/percolation.yaml).
|
||||
"""
|
||||
if prop is None or not len(prop) or float(prop["unresponsive_frac"].max()) <= 0.5:
|
||||
return None
|
||||
import matplotlib.pyplot as plt
|
||||
style.apply_style()
|
||||
n = _largest_n(prop)
|
||||
bh = int(sorted(prop["blend_hops"].unique())[0])
|
||||
d = prop[(prop.n_nodes == n) & (prop.blend_hops == bh)]
|
||||
if "redundancy" in d:
|
||||
d = d[d.redundancy == d.redundancy.min()]
|
||||
fig, ax = plt.subplots()
|
||||
for i, deg in enumerate(sorted(d.degree.unique())):
|
||||
c = style.color_for(i)
|
||||
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=c, label=f"degree={deg}")
|
||||
if deg > 2:
|
||||
ax.axvline(1.0 - 1.0 / (deg - 1), ls=":", lw=0.8, color=c, alpha=0.7)
|
||||
ax.set_xlabel("unresponsive fraction u")
|
||||
ax.set_ylabel("flood coverage (fraction reached | delivered)")
|
||||
ax.set_ylim(-0.02, 1.02)
|
||||
ax.set_title(f"Churn percolation: coverage collapses at $u_c=1-1/(d-1)$ (N={n:,})")
|
||||
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
|
||||
@ -380,3 +415,192 @@ def full_deanon_vs_degree(prop, adv, deanon):
|
||||
f"dashed = whole-path (degree-flat)")
|
||||
ax.legend()
|
||||
return fig
|
||||
|
||||
|
||||
# --- linkability over time: how fast an emitter is linked / its stake learned (3-arg builders) ---
|
||||
|
||||
def _repr_linkability(deanon) -> tuple[float, int, int, float]:
|
||||
"""Representative (f_adv, blend_hops, degree, observed_frac) for the linkability figures, taken
|
||||
from the middle of the swept grid (defaults when no deanon table is present)."""
|
||||
if deanon is None or not len(deanon):
|
||||
return 0.2, 3, 8, 1.0 - 0.8 ** 8
|
||||
d = deanon[deanon.f_adv > 0] if (deanon.f_adv > 0).any() else deanon
|
||||
fs = sorted(d.f_adv.unique())
|
||||
f = float(fs[len(fs) // 2])
|
||||
bhs = sorted(d.blend_hops.unique())
|
||||
bh = int(bhs[len(bhs) // 2])
|
||||
degs = sorted(d.degree.unique())
|
||||
deg = int(degs[len(degs) // 2])
|
||||
return f, bh, deg, float(1.0 - (1.0 - f) ** deg)
|
||||
|
||||
|
||||
def time_to_link_vs_stake(prop, adv, deanon):
|
||||
"""Time to link an emitter vs its stake, one line per accuracy alpha (log-log).
|
||||
|
||||
A node emits with probability = stake per 30 s slot; a linkable node (>=1 adversary peer) is
|
||||
linked the first time a whole cascade is adversarial (prob ``q = f_adv^blend_hops`` per
|
||||
emission), so ``T_link ~ 30s*ln(1/(1-alpha))/(stake*q)`` -- inversely proportional to stake."""
|
||||
import matplotlib.pyplot as plt
|
||||
style.apply_style()
|
||||
f, bh, deg, obs = _repr_linkability(deanon)
|
||||
q = f ** bh
|
||||
stakes = np.logspace(-5, -1, 60)
|
||||
fig, ax = plt.subplots()
|
||||
for i, alpha in enumerate((0.5, 0.9, 0.99)):
|
||||
days = [time_to_link_seconds(s, q, alpha) / _DAY for s in stakes]
|
||||
ax.plot(stakes, days, color=style.color_for(i), label=f"$\\alpha$ = {alpha}")
|
||||
ax.set_xscale("log")
|
||||
ax.set_yscale("log")
|
||||
ax.set_xlabel("node stake fraction s")
|
||||
ax.set_ylabel("time to link (days)")
|
||||
ax.set_title(f"Time to link vs stake (f_adv={f:g}, blend_hops={bh}, degree={deg}; "
|
||||
f"linkable fraction {obs:.2f})")
|
||||
ax.legend()
|
||||
return fig
|
||||
|
||||
|
||||
def time_to_link_vs_stake_redundancy(prop, adv, deanon):
|
||||
"""Time to link vs stake, one line per messaging redundancy R (alpha=0.9, log-log).
|
||||
|
||||
With R independent cascades the per-emission capture rate rises to ``1-(1-f_adv^hops)^R ~ R*``,
|
||||
so redundancy cuts the time to link by roughly R -- reliability bought at anonymity's cost."""
|
||||
import matplotlib.pyplot as plt
|
||||
style.apply_style()
|
||||
f, bh, deg, _ = _repr_linkability(deanon)
|
||||
d1 = f ** bh
|
||||
stakes = np.logspace(-5, -1, 60)
|
||||
fig, ax = plt.subplots()
|
||||
for i, R in enumerate((1, 2, 3, 4)):
|
||||
q = capture_prob(d1, 1.0, R)
|
||||
days = [time_to_link_seconds(s, q, 0.9) / _DAY for s in stakes]
|
||||
ax.plot(stakes, days, color=style.color_for(i), label=f"R = {R}")
|
||||
ax.set_xscale("log")
|
||||
ax.set_yscale("log")
|
||||
ax.set_xlabel("node stake fraction s")
|
||||
ax.set_ylabel("time to link (days)")
|
||||
ax.set_title(f"Redundancy speeds linking (f_adv={f:g}, blend_hops={bh}, $\\alpha$=0.9)")
|
||||
ax.legend()
|
||||
return fig
|
||||
|
||||
|
||||
def time_to_stake_vs_threshold(prop, adv, deanon):
|
||||
"""Time to certify a node holds at least a stake threshold, vs the threshold (log-log).
|
||||
|
||||
Attributable observations arrive at rate ``stake*q``; ``N`` of them pin the stake to relative
|
||||
precision ``~1/sqrt(N)``. Lines: identity (N=1), stake to +-10% (N=100), +-5% (N=400). Small
|
||||
thresholds take astronomically long -- a hard floor on how finely stake can be learned."""
|
||||
import matplotlib.pyplot as plt
|
||||
style.apply_style()
|
||||
f, bh, deg, _ = _repr_linkability(deanon)
|
||||
q = f ** bh
|
||||
thresholds = np.array([0.05, 0.01, 0.005, 0.001, 5e-4, 1e-4, 5e-5, 1e-5])
|
||||
fig, ax = plt.subplots()
|
||||
for i, (nobs, lab) in enumerate([(1, "identity link (N=1)"),
|
||||
(100, "stake $\\pm$10% (N=100)"),
|
||||
(400, "stake $\\pm$5% (N=400)")]):
|
||||
days = [time_to_stake_seconds(th, q, nobs) / _DAY for th in thresholds]
|
||||
ax.plot(thresholds * 100, days, "-o", ms=4, color=style.color_for(i), label=lab)
|
||||
for yr, txt in [(365.0, "1 yr"), (3650.0, "10 yr")]:
|
||||
ax.axhline(yr, ls=":", lw=0.8, color="0.6")
|
||||
ax.text(ax.get_xlim()[0], yr, f" {txt}", va="bottom", ha="left", fontsize=7, color="0.5")
|
||||
ax.set_xscale("log")
|
||||
ax.set_yscale("log")
|
||||
ax.set_xlabel("stake threshold $\\theta$ (%)")
|
||||
ax.set_ylabel("time to certify stake $\\geq \\theta$ (days)")
|
||||
ax.set_title(f"Time to learn stake vs threshold (f_adv={f:g}, blend_hops={bh}, degree={deg})")
|
||||
ax.legend()
|
||||
return fig
|
||||
|
||||
|
||||
def redundancy_time_to_link(prop, adv, deanon):
|
||||
"""The redundancy trade in its two natural units at once: the delivery rate R buys (left axis,
|
||||
measured) against the time to link the emitter that it spends (right axis, log days, per stake).
|
||||
|
||||
Redundancy multiplies the per-emission capture probability by ~R, so it divides the time to link
|
||||
by ~R -- four copies of every message roughly quarter a node's anonymity lifetime.
|
||||
"""
|
||||
if (prop is None or not len(prop) or "redundancy" not in prop
|
||||
or prop["redundancy"].nunique() < 2):
|
||||
return None
|
||||
import matplotlib.pyplot as plt
|
||||
style.apply_style()
|
||||
n = _largest_n(prop)
|
||||
deg = _median_degree(prop[prop.n_nodes == n])
|
||||
bhs = sorted(prop.blend_hops.unique())
|
||||
bh = int(bhs[len(bhs) // 2])
|
||||
ufs = [u for u in sorted(prop.unresponsive_frac.unique()) if u > 0]
|
||||
u = ufs[len(ufs) // 2] if ufs else 0.0
|
||||
pr = prop[(prop.n_nodes == n) & (prop.degree == deg) & (prop.blend_hops == bh)
|
||||
& (prop.unresponsive_frac == u)]
|
||||
deliv = pr.groupby("redundancy").delivery_rate.mean().reset_index()
|
||||
if not len(deliv):
|
||||
return None
|
||||
f, _, _, _ = _repr_linkability(deanon)
|
||||
d1 = f ** bh
|
||||
rs = [int(r) for r in deliv.redundancy]
|
||||
|
||||
fig, ax = plt.subplots()
|
||||
ax.plot(rs, deliv.delivery_rate, "-o", ms=6, color=style.color_for(0),
|
||||
label=f"delivery rate (u={u:g}) ↑ good")
|
||||
ax.set_xlabel("messaging redundancy R (independent cascades)")
|
||||
ax.set_ylabel("message delivery rate")
|
||||
ax.set_ylim(0.0, 1.02)
|
||||
ax.set_xticks(rs)
|
||||
ax2 = ax.twinx()
|
||||
ax2.grid(False)
|
||||
for i, (s, lab) in enumerate([(0.05, "5%"), (0.01, "1%"), (0.001, "0.1%")]):
|
||||
days = [time_to_link_seconds(s, capture_prob(d1, 1.0, r), 0.9) / _DAY for r in rs]
|
||||
ax2.plot(rs, days, "--s", ms=4, color=style.color_for(i + 1),
|
||||
label=f"time to link, stake {lab} ↓ bad")
|
||||
ax2.set_yscale("log")
|
||||
ax2.set_ylabel("time to link (days), $\\alpha$ = 0.9")
|
||||
h1, l1 = ax.get_legend_handles_labels()
|
||||
h2, l2 = ax2.get_legend_handles_labels()
|
||||
ax.legend(h1 + h2, l1 + l2, fontsize=7, loc="center right")
|
||||
ax.set_title(f"Redundancy buys delivery, spends anonymity time\n"
|
||||
f"(N={n:,}, degree={deg}, {bh} hops, f_adv={f:g})")
|
||||
return fig
|
||||
|
||||
|
||||
def redundancy_tradeoff(prop, adv, deanon):
|
||||
"""Reliability gain vs anonymity cost of messaging redundancy R (log-y).
|
||||
|
||||
Delivery (from the churn sweep) and whole-path / full deanonymization (exact) both rise as
|
||||
``1-(1-x)^R`` -- redundancy amplifies the wanted (delivery) and the unwanted (capture) together.
|
||||
"""
|
||||
if (prop is None or not len(prop) or "redundancy" not in prop
|
||||
or prop["redundancy"].nunique() < 2):
|
||||
return None
|
||||
import matplotlib.pyplot as plt
|
||||
style.apply_style()
|
||||
n = _largest_n(prop)
|
||||
deg = _median_degree(prop[prop.n_nodes == n])
|
||||
bhs = sorted(prop.blend_hops.unique())
|
||||
bh = int(bhs[len(bhs) // 2])
|
||||
ufs = [u for u in sorted(prop.unresponsive_frac.unique()) if u > 0]
|
||||
u = ufs[len(ufs) // 2] if ufs else 0.0
|
||||
pr = prop[(prop.n_nodes == n) & (prop.degree == deg) & (prop.blend_hops == bh)
|
||||
& (prop.unresponsive_frac == u)]
|
||||
deliv = pr.groupby("redundancy").delivery_rate.mean().reset_index()
|
||||
fig, ax = plt.subplots()
|
||||
ax.plot(deliv.redundancy, _pos(deliv.delivery_rate), "-o", ms=5, color=style.color_for(0),
|
||||
label=f"delivery (u={u:g}) ↑ good")
|
||||
if deanon is not None and len(deanon) and "redundancy" in deanon:
|
||||
f, _, _, _ = _repr_linkability(deanon)
|
||||
dz = deanon[(deanon.n_nodes == deanon.n_nodes.max()) & (deanon.degree == deg)
|
||||
& (deanon.blend_hops == bh) & (deanon.f_adv == f)
|
||||
& (deanon.adversary_mode == "random")]
|
||||
if len(dz):
|
||||
g = dz.groupby("redundancy").agg(dr=("deanon_rate", "mean"),
|
||||
fd=("full_deanon_rate", "mean")).reset_index()
|
||||
ax.plot(g.redundancy, _pos(g.dr), "-s", ms=5, color=style.color_for(1),
|
||||
label=f"whole-path capture (f_adv={f:g}) ↓ good")
|
||||
ax.plot(g.redundancy, _pos(g.fd), "-^", ms=5, color=style.color_for(2),
|
||||
label="full deanonymization ↓ good")
|
||||
ax.set_yscale("log")
|
||||
ax.set_xticks([1, 2, 3, 4])
|
||||
ax.set_xlabel("messaging redundancy R (independent cascades)")
|
||||
ax.set_ylabel("per-emission probability")
|
||||
ax.set_title(f"Redundancy: reliability vs anonymity (N={n:,}, degree={deg}, blend_hops={bh})")
|
||||
ax.legend()
|
||||
return fig
|
||||
|
||||
@ -22,14 +22,21 @@ _BUILDERS = [
|
||||
("09_heatmap_eclipse", figures.heatmap_eclipse),
|
||||
("10_delivery_vs_unresponsive", figures.delivery_vs_unresponsive),
|
||||
("11_coverage_vs_unresponsive", figures.coverage_vs_unresponsive),
|
||||
("20_coverage_percolation", figures.coverage_percolation),
|
||||
]
|
||||
|
||||
# (prop, adv, deanon) builders — deanonymization crosses propagation paths with the adversary set.
|
||||
# (prop, adv, deanon) builders — deanonymization crosses propagation paths with the adversary set,
|
||||
# and the linkability-over-time figures (time to link / learn stake, and the redundancy trade-off).
|
||||
_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),
|
||||
("16_time_to_link_vs_stake", figures.time_to_link_vs_stake),
|
||||
("17_time_to_link_vs_stake_redundancy", figures.time_to_link_vs_stake_redundancy),
|
||||
("18_time_to_stake_vs_threshold", figures.time_to_stake_vs_threshold),
|
||||
("19_redundancy_tradeoff", figures.redundancy_tradeoff),
|
||||
("21_redundancy_time_to_link", figures.redundancy_time_to_link),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@ -44,9 +44,15 @@ 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) -> dict:
|
||||
coverage_pcts: tuple[float, ...], responsive: np.ndarray | None = None,
|
||||
stats: bool = True) -> 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."""
|
||||
must be responsive) floods; delay fields are NaN on a dropped message.
|
||||
|
||||
Always returns ``arrival``: the per-node absolute arrival time (path + flood distance, ``inf``
|
||||
where unreachable, ``None`` if undelivered) -- the quantity a caller combines across redundant
|
||||
cascades. ``stats=False`` skips the per-cascade scalar/percentile summary (the caller derives it
|
||||
from the combined ``arrival`` instead), which is what makes R cascades cost O(R) sorts fewer."""
|
||||
data = (graph.base
|
||||
+ rng.exponential(jitter_mean_ms, size=graph.base.shape[0])
|
||||
+ graph.p[graph.src])
|
||||
@ -77,31 +83,41 @@ def blend_round(graph: Graph, sender: int, relays: np.ndarray, jitter_mean_ms: f
|
||||
flood = dist[k]
|
||||
finite = np.isfinite(flood)
|
||||
delivered = legs_ok and final_ok and bool(finite.any())
|
||||
path = legs + mix_total if delivered else float("nan")
|
||||
# absolute arrival time per node; inf where the flood never reaches it
|
||||
arrival = flood + path if delivered else None
|
||||
out: dict = {"delivered": delivered, "path": path, "arrival": arrival}
|
||||
if not stats:
|
||||
return out
|
||||
if delivered:
|
||||
reached = flood[finite]
|
||||
broadcast = float(reached.max())
|
||||
covers = [float(np.percentile(reached, pc)) for pc in coverage_pcts]
|
||||
frac_reached = float(finite.mean())
|
||||
path = legs + mix_total
|
||||
full = path + broadcast
|
||||
out["broadcast"] = float(reached.max())
|
||||
out["covers"] = [float(np.percentile(reached, pc)) for pc in coverage_pcts]
|
||||
out["frac_reached"] = float(finite.mean())
|
||||
out["full"] = path + out["broadcast"]
|
||||
else:
|
||||
broadcast = float("nan")
|
||||
covers = [float("nan")] * len(coverage_pcts)
|
||||
frac_reached = 0.0
|
||||
path = float("nan")
|
||||
full = float("nan")
|
||||
return {"full": full, "path": path, "broadcast": broadcast, "covers": covers,
|
||||
"frac_reached": frac_reached, "delivered": delivered}
|
||||
out["broadcast"] = float("nan")
|
||||
out["covers"] = [float("nan")] * len(coverage_pcts)
|
||||
out["frac_reached"] = 0.0
|
||||
out["full"] = float("nan")
|
||||
return out
|
||||
|
||||
|
||||
def propagation_metrics(graph: Graph, blend_hops: int, max_blend_delay: int,
|
||||
unresponsive_frac: float, responsive: np.ndarray,
|
||||
unresponsive_frac: float, redundancy: int, responsive: np.ndarray,
|
||||
config: SimConfig, rng: np.random.Generator) -> dict:
|
||||
"""Aggregate the Blend cascade over ``config.n_rounds`` rounds.
|
||||
|
||||
Each round a responsive node injects a message and the ``blend_hops`` relays are drawn from the
|
||||
whole node list (blind to responsiveness). ``delivery_rate`` is the fraction that complete the
|
||||
cascade; delay/coverage statistics are conditioned on those delivered rounds.
|
||||
Each round a responsive node injects a message that is sent over ``redundancy`` (``R``)
|
||||
independent blend cascades, each with its own ``blend_hops`` relays drawn from the whole node
|
||||
list (blind to responsiveness). A node receives the message from whichever cascade reaches it
|
||||
**first**, so the round's arrival times are the element-wise minimum over the delivered
|
||||
cascades: the message is **delivered** if any cascade completes, its **coverage** is the union
|
||||
of their reached sets, and its **full delay** is the last node's earliest arrival (not the
|
||||
fastest cascade's own full delay, which would over-state it). ``path`` is the quickest
|
||||
cascade's path delay and the coverage times are measured from it. ``delivery_rate`` is the
|
||||
fraction of rounds delivered; delay/coverage stats are conditioned on those. All of this
|
||||
reduces exactly to the plain single-cascade model at ``R = 1``.
|
||||
"""
|
||||
n = graph.n
|
||||
check_alloc(int((blend_hops + 1) * n * 8), "sampled (blend_hops+1) x N distance matrix",
|
||||
@ -122,24 +138,37 @@ def propagation_metrics(graph: Graph, blend_hops: int, max_blend_delay: int,
|
||||
if resp_ids.shape[0] < 1 or n < blend_hops + 1:
|
||||
return _empty() # no responsive sender, or too few nodes to draw a distinct path
|
||||
|
||||
R = int(redundancy)
|
||||
fulls, paths, bcasts, fracs = [], [], [], []
|
||||
covers = [[] for _ in pcts]
|
||||
delivered = 0
|
||||
for _ in range(config.n_rounds):
|
||||
sender = int(rng.choice(resp_ids))
|
||||
relays = rng.choice(n - 1, size=blend_hops, replace=False)
|
||||
relays[relays >= sender] += 1 # blend_hops distinct nodes, all != sender
|
||||
r = blend_round(graph, sender, relays, config.transport_jitter_mean_ms,
|
||||
max_blend_delay, rng, pcts, responsive)
|
||||
if not r["delivered"]:
|
||||
arr = None # element-wise earliest arrival over the R cascades
|
||||
path_min = float("inf")
|
||||
for _c in range(R):
|
||||
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)
|
||||
if not rc["delivered"]:
|
||||
continue
|
||||
a = rc["arrival"]
|
||||
arr = a if arr is None else np.minimum(arr, a)
|
||||
path_min = min(path_min, rc["path"])
|
||||
if arr is None: # no cascade delivered this round
|
||||
continue
|
||||
delivered += 1
|
||||
fulls.append(r["full"])
|
||||
paths.append(r["path"])
|
||||
bcasts.append(r["broadcast"])
|
||||
fracs.append(r["frac_reached"])
|
||||
for j, c in enumerate(r["covers"]):
|
||||
covers[j].append(c)
|
||||
finite = np.isfinite(arr)
|
||||
reached = arr[finite]
|
||||
full = float(reached.max())
|
||||
fulls.append(full)
|
||||
paths.append(path_min)
|
||||
bcasts.append(full - path_min)
|
||||
fracs.append(float(finite.mean()))
|
||||
rel = reached - path_min # coverage measured from the quickest path's release
|
||||
for j, pc in enumerate(pcts):
|
||||
covers[j].append(float(np.percentile(rel, pc)))
|
||||
|
||||
delivery_rate = delivered / config.n_rounds
|
||||
if delivered == 0:
|
||||
|
||||
@ -50,11 +50,11 @@ def responsive_seedseq(config: SimConfig, unresponsive_frac: float) -> np.random
|
||||
|
||||
|
||||
def round_seedseq(config: SimConfig, blend_hops: int, max_blend_delay: int,
|
||||
unresponsive_frac: float) -> np.random.SeedSequence:
|
||||
unresponsive_frac: float, redundancy: int = 1) -> np.random.SeedSequence:
|
||||
"""Per-cell propagation seed (senders/relays/jitter/mix over n_rounds)."""
|
||||
return np.random.SeedSequence(_digest(
|
||||
config.root_seed, "rounds", config.n_nodes, config.degree, config.graph_seed,
|
||||
blend_hops, max_blend_delay, unresponsive_frac, config.n_rounds,
|
||||
blend_hops, max_blend_delay, unresponsive_frac, redundancy, config.n_rounds,
|
||||
config.transport_jitter_mean_ms,
|
||||
))
|
||||
|
||||
|
||||
@ -37,8 +37,8 @@ def new_run_dir(outdir: Path, label: str) -> Path:
|
||||
return run_dir
|
||||
|
||||
|
||||
def _cell_worker(base: SimConfig, prop_grid, unresponsive_fracs, adv_grid):
|
||||
return run_graph_cell(base, prop_grid, unresponsive_fracs, adv_grid)
|
||||
def _cell_worker(base: SimConfig, prop_grid, unresponsive_fracs, redundancies, adv_grid):
|
||||
return run_graph_cell(base, prop_grid, unresponsive_fracs, redundancies, adv_grid)
|
||||
|
||||
|
||||
def run_sweep(sweep: SweepConfig,
|
||||
@ -46,10 +46,11 @@ def run_sweep(sweep: SweepConfig,
|
||||
cells = sweep.graph_cells()
|
||||
prop_grid = sweep.prop_grid()
|
||||
unresponsive_fracs = list(sweep.unresponsive_frac)
|
||||
redundancies = list(sweep.redundancy)
|
||||
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, adv_grid)
|
||||
delayed(_cell_worker)(base, prop_grid, unresponsive_fracs, redundancies, adv_grid)
|
||||
for base in tqdm(bases, desc="topologies")
|
||||
)
|
||||
prop_rows = [r for pr, _, _ in results for r in pr]
|
||||
|
||||
@ -111,6 +111,83 @@ def main(argv: list[str] | None = None) -> int:
|
||||
abs(dz["full_deanon_rate"] - fd_emp) < max(0.006, 0.15 * fd_emp),
|
||||
f"closed {dz['full_deanon_rate']:.4f} vs MC {fd_emp:.4f}")
|
||||
|
||||
# 6. messaging redundancy R and the linkability laws.
|
||||
# deanon_R = 1-(1-d1)^R (any of R independent cascades whole-path-adversarial); delivery_R =
|
||||
# 1-(1-p1)^R (any of R cascades delivers); T_link ~ 30s*ln(1/(1-alpha))/(s*q).
|
||||
from .linkability import time_to_link_seconds
|
||||
from .propagation import assign_responsive, propagation_metrics
|
||||
from .rng import responsive_seedseq, round_seedseq
|
||||
f, k, degree = 0.33, 2, 8 # (a) deanon_R vs direct R-cascade Monte-Carlo
|
||||
cfg = SimConfig(n_nodes=3000, degree=degree, graph_seed=1, f_adv=f, blend_hops=k)
|
||||
g = build_graph(cfg)
|
||||
mask = place_adversary(g, f, "random",
|
||||
np.random.default_rng(placement_seedseq(cfg, f, "random", 0)),
|
||||
cfg.worstcase_max_n)
|
||||
adv = adversary_metrics(g, mask)
|
||||
honest, n = np.where(~mask)[0], g.n
|
||||
for R in (2, 3):
|
||||
dzR = deanon_metrics(n, adv["n_adv"], adv["observed_frac"], k, R)
|
||||
srng = np.random.default_rng(1234 + R)
|
||||
trials, hit = 40_000, 0
|
||||
for _ in range(trials):
|
||||
s = int(srng.choice(honest))
|
||||
captured = False
|
||||
for _c in range(R):
|
||||
r = srng.choice(n - 1, size=k, replace=False)
|
||||
r[r >= s] += 1
|
||||
if mask[r].all():
|
||||
captured = True
|
||||
break
|
||||
hit += int(captured)
|
||||
emp = hit / trials
|
||||
ok &= _check(f"deanon_rate R={R}", abs(dzR["deanon_rate"] - emp) < max(0.006, 0.1 * emp),
|
||||
f"closed {dzR['deanon_rate']:.4f} vs MC {emp:.4f}")
|
||||
|
||||
u = 0.3 # (b) simulated delivery_R vs 1-(1-p1)^R
|
||||
dc = SimConfig(n_nodes=5000, degree=16, blend_hops=3, max_blend_delay=0,
|
||||
transport_jitter_mean_ms=0.0, unresponsive_frac=u, n_rounds=1500)
|
||||
gg = build_graph(dc)
|
||||
resp = assign_responsive(dc.n_nodes, u, np.random.default_rng(responsive_seedseq(dc, u)))
|
||||
deliveries = {}
|
||||
for R in (1, 2, 3):
|
||||
rng = np.random.default_rng(round_seedseq(dc, dc.blend_hops, dc.max_blend_delay, u, R))
|
||||
deliveries[R] = propagation_metrics(
|
||||
gg, dc.blend_hops, dc.max_blend_delay, u, R, resp, dc, rng)["delivery_rate"]
|
||||
for R in (2, 3):
|
||||
th = 1 - (1 - deliveries[1]) ** R # cascades ~independent given the responsive mask
|
||||
ok &= _check(f"delivery_rate R={R}", abs(deliveries[R] - th) < 0.04,
|
||||
f"sim {deliveries[R]:.3f} vs 1-(1-p1)^R {th:.3f}")
|
||||
|
||||
s_stake, q = 0.02, 0.05 # (c) time-to-link geometric law vs emission MC
|
||||
first = np.random.default_rng(99).geometric(s_stake * q, size=200_000)
|
||||
for alpha in (0.5, 0.9):
|
||||
emp = 30.0 * float(np.quantile(first, alpha))
|
||||
closed = time_to_link_seconds(s_stake, q, alpha)
|
||||
ok &= _check(f"time_to_link alpha={alpha}", abs(emp - closed) / closed < 0.03,
|
||||
f"MC {emp:.0f}s vs closed {closed:.0f}s")
|
||||
|
||||
# 7. churn percolation: the responsive sub-graph is site percolation on a d-regular graph, so
|
||||
# its giant component survives only while the responsive fraction exceeds 1/(degree-1) --
|
||||
# i.e. up to churn u_c = 1 - 1/(degree-1). Check it is giant below u_c and gone above.
|
||||
from scipy.sparse import csr_matrix
|
||||
from scipy.sparse.csgraph import connected_components
|
||||
n_p = 20_000
|
||||
for degree in (3, 6):
|
||||
u_c = 1.0 - 1.0 / (degree - 1)
|
||||
cfg = SimConfig(n_nodes=n_p, degree=degree, graph_seed=0)
|
||||
g = build_graph(cfg)
|
||||
giant = {}
|
||||
for u in (u_c - 0.15, min(u_c + 0.15, 0.99)):
|
||||
resp = assign_responsive(n_p, u, np.random.default_rng(responsive_seedseq(cfg, u)))
|
||||
keep = resp[g.src] & resp[g.indices]
|
||||
m = csr_matrix((np.ones(int(keep.sum())), (g.src[keep], g.indices[keep])),
|
||||
shape=(n_p, n_p))
|
||||
_, lab = connected_components(m, directed=False)
|
||||
giant[round(u, 3)] = float(np.bincount(lab[resp]).max() / n_p)
|
||||
below, above = giant[round(u_c - 0.15, 3)], giant[round(min(u_c + 0.15, 0.99), 3)]
|
||||
ok &= _check(f"percolation d={degree} (u_c={u_c:.2f})", below > 0.1 and above < 0.02,
|
||||
f"giant {below:.3f} at u_c-0.15 -> {above:.4f} at u_c+0.15")
|
||||
|
||||
print("OK" if ok else "FAILURES PRESENT")
|
||||
return 0 if ok else 1
|
||||
|
||||
|
||||
@ -12,7 +12,8 @@ def test_key_covers_every_field():
|
||||
for name in fields:
|
||||
cur = getattr(base, name)
|
||||
alt = {"n_nodes": 2000, "degree": 4, "blend_hops": 2, "max_blend_delay": 5,
|
||||
"unresponsive_frac": 0.2, "n_rounds": 10, "transport_jitter_mean_ms": 1.0,
|
||||
"unresponsive_frac": 0.2, "redundancy": 2, "n_rounds": 10,
|
||||
"transport_jitter_mean_ms": 1.0,
|
||||
"processing_lags_ms": (11.0, 51.0, 101.0), "processing_lag_probs": (0.6, 0.3, 0.1),
|
||||
"link_latency_dist": "fixed", "link_latency_mean_ms": 1.0,
|
||||
"coverage_pcts": (25.0,), "f_adv": 0.1, "adversary_mode": "worstcase_coverage",
|
||||
|
||||
@ -85,12 +85,12 @@ def test_engine_emits_deanon_rows():
|
||||
base = SimConfig(n_nodes=1000, degree=8, graph_seed=0, n_placements=2)
|
||||
prop_grid = [(2, 0), (3, 0)] # distinct blend_hops = {2, 3}
|
||||
adv_grid = [(0.2, "random"), (0.0, "random")]
|
||||
prop_rows, adv_rows, deanon_rows = run_graph_cell(base, prop_grid, [0.0], adv_grid)
|
||||
prop_rows, adv_rows, deanon_rows = run_graph_cell(base, prop_grid, [0.0], [1], adv_grid)
|
||||
|
||||
# one deanon row per (placement, distinct blend_hops)
|
||||
# one deanon row per (placement, distinct blend_hops, redundancy)
|
||||
assert len(deanon_rows) == len(adv_rows) * 2
|
||||
cols = {"n_nodes", "degree", "blend_hops", "f_adv", "adversary_mode", "graph_seed",
|
||||
"placement_rep", "n_adv", "n_honest", "observed_frac",
|
||||
cols = {"n_nodes", "degree", "blend_hops", "redundancy", "f_adv", "adversary_mode",
|
||||
"graph_seed", "placement_rep", "n_adv", "n_honest", "observed_frac",
|
||||
"deanon_rate", "full_deanon_rate"}
|
||||
assert cols <= set(deanon_rows[0])
|
||||
assert {row["blend_hops"] for row in deanon_rows} == {2, 3}
|
||||
|
||||
85
tools/simulators/blend/pd/tests/test_linkability.py
Normal file
@ -0,0 +1,85 @@
|
||||
"""Time-to-link and stake-inference laws, and a Monte-Carlo check of the emission process."""
|
||||
|
||||
import math
|
||||
|
||||
import numpy as np
|
||||
|
||||
from pd.linkability import (
|
||||
capture_prob,
|
||||
obs_for_precision,
|
||||
redundant,
|
||||
stake_rel_precision,
|
||||
time_to_link_seconds,
|
||||
time_to_stake_seconds,
|
||||
)
|
||||
|
||||
|
||||
def test_redundant_values_and_bounds():
|
||||
assert abs(redundant(0.1, 1) - 0.1) < 1e-12
|
||||
assert abs(redundant(0.1, 2) - 0.19) < 1e-12
|
||||
assert redundant(0.0, 4) == 0.0
|
||||
assert redundant(1.0, 3) == 1.0
|
||||
# strictly increasing in R for 0 < x < 1
|
||||
vals = [redundant(0.2, R) for R in (1, 2, 3, 4)]
|
||||
assert all(b > a for a, b in zip(vals, vals[1:], strict=False))
|
||||
|
||||
|
||||
def test_capture_prob_linkable_vs_population():
|
||||
d1 = 0.2 ** 3
|
||||
assert abs(capture_prob(d1, 1.0, 1) - d1) < 1e-12 # linkable, single cascade
|
||||
assert abs(capture_prob(d1, 0.5, 1) - 0.5 * d1) < 1e-12
|
||||
assert abs(capture_prob(d1, 1.0, 2) - (1 - (1 - d1) ** 2)) < 1e-12
|
||||
|
||||
|
||||
def test_time_to_link_matches_geometric_definition():
|
||||
p, alpha, slot = 0.02, 0.9, 30.0
|
||||
q = p / 0.01 # stake=0.01 -> s*q = p
|
||||
t = time_to_link_seconds(0.01, q, alpha, slot)
|
||||
n = round(t / slot)
|
||||
assert 1 - (1 - p) ** n >= alpha - 1e-12
|
||||
assert 1 - (1 - p) ** (n - 1) < alpha
|
||||
|
||||
|
||||
def test_time_to_link_scales_inverse_stake():
|
||||
q, alpha = 0.01, 0.9
|
||||
t1 = time_to_link_seconds(0.01, q, alpha)
|
||||
t2 = time_to_link_seconds(0.005, q, alpha)
|
||||
assert abs(t2 / t1 - 2.0) < 0.02 # halving stake ~doubles the time
|
||||
|
||||
|
||||
def test_time_to_link_unlinkable_is_infinite():
|
||||
assert time_to_link_seconds(0.05, 0.0, 0.9) == math.inf
|
||||
assert time_to_stake_seconds(0.01, 0.0, 100) == math.inf
|
||||
|
||||
|
||||
def test_redundancy_cuts_time_by_about_R():
|
||||
d1, s, alpha = 0.2 ** 3, 0.01, 0.9 # small d1 -> q_R ~ R*d1
|
||||
t1 = time_to_link_seconds(s, capture_prob(d1, 1.0, 1), alpha)
|
||||
t4 = time_to_link_seconds(s, capture_prob(d1, 1.0, 4), alpha)
|
||||
assert 3.5 < t1 / t4 < 4.0 # ~4x faster with R=4
|
||||
|
||||
|
||||
def test_time_to_stake_scaling():
|
||||
q = 0.008
|
||||
lin = time_to_stake_seconds(0.01, q, 200) / time_to_stake_seconds(0.01, q, 100)
|
||||
assert abs(lin - 2) < 1e-9 # linear in n_obs
|
||||
inv = time_to_stake_seconds(0.001, q, 100) / time_to_stake_seconds(0.01, q, 100)
|
||||
assert abs(inv - 10) < 1e-9 # inverse in threshold
|
||||
assert abs(time_to_stake_seconds(0.05, q, 100) - 100 / (0.05 * q) * 30) < 1e-6
|
||||
|
||||
|
||||
def test_obs_for_precision_and_precision():
|
||||
assert obs_for_precision(0.1) == 100
|
||||
assert obs_for_precision(0.05) == 400
|
||||
assert obs_for_precision(0.5) == 4
|
||||
assert abs(stake_rel_precision(100) - 0.1) < 1e-12
|
||||
|
||||
|
||||
def test_time_to_link_matches_simulation():
|
||||
"""Empirical alpha-quantile of the first-observation slot matches the closed form."""
|
||||
s, q, alpha = 0.02, 0.05, 0.9 # p = s*q = 1e-3
|
||||
rng = np.random.default_rng(7)
|
||||
first = rng.geometric(s * q, size=300_000) # slots until first success, support {1,2,...}
|
||||
emp_slots = float(np.quantile(first, alpha))
|
||||
closed_slots = time_to_link_seconds(s, q, alpha) / 30.0
|
||||
assert abs(emp_slots - closed_slots) / closed_slots < 0.02
|
||||
@ -1,7 +1,9 @@
|
||||
import numpy as np
|
||||
|
||||
from pd.graph import Graph
|
||||
from pd.propagation import assign_responsive, blend_round
|
||||
from pd.config import SimConfig
|
||||
from pd.graph import Graph, build_graph
|
||||
from pd.propagation import assign_responsive, blend_round, propagation_metrics
|
||||
from pd.rng import responsive_seedseq, round_seedseq
|
||||
|
||||
|
||||
def _k4(p):
|
||||
@ -93,3 +95,106 @@ def test_unresponsive_node_strands_flood_pocket():
|
||||
rng=np.random.default_rng(0), coverage_pcts=(50.0,), responsive=responsive)
|
||||
assert r["delivered"] is True
|
||||
assert r["frac_reached"] == 0.75 # node 3 stranded behind unresponsive node 2
|
||||
|
||||
|
||||
# --- arrival times and messaging redundancy -----------------------------------------------------
|
||||
|
||||
def test_arrival_is_path_plus_flood_distance():
|
||||
"""``arrival`` is the absolute per-node arrival time -- what is combined across cascades."""
|
||||
g = _k4([1.0, 2.0, 3.0, 4.0])
|
||||
r = blend_round(g, sender=0, relays=np.array([1]), jitter_mean_ms=0.0, max_blend_delay=0,
|
||||
rng=np.random.default_rng(0), coverage_pcts=(50.0,))
|
||||
arr = r["arrival"]
|
||||
assert arr[1] == r["path"] # the flooding relay itself, at t = path
|
||||
assert float(np.nanmax(arr[np.isfinite(arr)])) == r["full"] # last arrival == full delay
|
||||
assert np.all(arr[[0, 2, 3]] == r["path"] + 12.0) # 10 ms link + p(1)=2 from the relay
|
||||
|
||||
|
||||
def test_arrival_is_none_when_undelivered():
|
||||
g = _k4([0.0, 0.0, 0.0, 0.0])
|
||||
responsive = np.array([True, False, True, True])
|
||||
r = blend_round(g, 0, np.array([1]), 0.0, 0, np.random.default_rng(0), (50.0,), responsive)
|
||||
assert r["delivered"] is False and r["arrival"] is None
|
||||
|
||||
|
||||
def test_stats_false_skips_summary_but_keeps_arrival():
|
||||
g = _k4([1.0, 2.0, 3.0, 4.0])
|
||||
kw = dict(jitter_mean_ms=0.0, max_blend_delay=0, coverage_pcts=(50.0, 90.0))
|
||||
full = blend_round(g, 0, np.array([1]), rng=np.random.default_rng(0), **kw)
|
||||
lean = blend_round(g, 0, np.array([1]), rng=np.random.default_rng(0), stats=False, **kw)
|
||||
assert "full" in full and "full" not in lean
|
||||
assert lean["path"] == full["path"]
|
||||
assert np.array_equal(lean["arrival"], full["arrival"])
|
||||
|
||||
|
||||
def _prop(n_nodes, degree, u, blend_hops, R, n_rounds, seed=0):
|
||||
cfg = SimConfig(n_nodes=n_nodes, degree=degree, blend_hops=blend_hops, max_blend_delay=0,
|
||||
transport_jitter_mean_ms=0.0, unresponsive_frac=u, redundancy=R,
|
||||
n_rounds=n_rounds, graph_seed=seed)
|
||||
g = build_graph(cfg)
|
||||
resp = assign_responsive(n_nodes, u, np.random.default_rng(responsive_seedseq(cfg, u)))
|
||||
rng = np.random.default_rng(round_seedseq(cfg, blend_hops, 0, u, R))
|
||||
return propagation_metrics(g, blend_hops, 0, u, R, resp, cfg, rng)
|
||||
|
||||
|
||||
def test_single_cascade_reduces_to_blend_round_stats():
|
||||
"""R=1 aggregation over ``arrival`` must reproduce the per-cascade scalar summary exactly."""
|
||||
g = _k4([1.0, 2.0, 3.0, 4.0])
|
||||
pcts = (50.0, 90.0, 99.0)
|
||||
r = blend_round(g, 0, np.array([1]), 0.0, 0, np.random.default_rng(0), pcts)
|
||||
arr = r["arrival"]
|
||||
finite = np.isfinite(arr)
|
||||
reached = arr[finite]
|
||||
assert float(reached.max()) == r["full"] # full delay
|
||||
assert float(reached.max()) - r["path"] == r["broadcast"] # broadcast phase
|
||||
rel = reached - r["path"]
|
||||
for pc, c in zip(pcts, r["covers"], strict=True):
|
||||
assert abs(float(np.percentile(rel, pc)) - c) < 1e-9 # coverage times
|
||||
assert float(finite.mean()) == r["frac_reached"]
|
||||
|
||||
|
||||
def test_redundancy_raises_delivery_monotonically():
|
||||
rates = [_prop(2000, 4, 0.3, 3, R, 300)["delivery_rate"] for R in (1, 2, 3)]
|
||||
assert all(b >= a for a, b in zip(rates, rates[1:], strict=False))
|
||||
assert rates[2] > rates[0] + 0.1 # a real gain, not noise
|
||||
|
||||
|
||||
def test_redundancy_buys_no_coverage_even_when_fragmented():
|
||||
"""Redundancy raises *delivery*, never *coverage* -- including in the fragmented regime.
|
||||
|
||||
A cascade is delivered only if the sender can route to its relay, so every delivered cascade's
|
||||
relay already lies in the sender's reachable set and floods (a subset of) the same component.
|
||||
The union over R cascades therefore cannot exceed what one delivered cascade already reaches.
|
||||
"""
|
||||
for degree, u in ((3, 0.5), (8, 0.3)): # fragmented, then connected
|
||||
single = _prop(4000, degree, u, 1, 1, 300)["frac_reached"]
|
||||
quad = _prop(4000, degree, u, 1, 4, 300)["frac_reached"]
|
||||
assert quad <= single + 0.01, (degree, u, single, quad)
|
||||
|
||||
|
||||
def test_redundant_cascades_flood_the_same_component():
|
||||
"""Direct check of the mechanism: with several cascades delivered in one round, the union of
|
||||
their reached sets equals the largest single one."""
|
||||
n, u = 4000, 0.5
|
||||
cfg = SimConfig(n_nodes=n, degree=3, blend_hops=1, max_blend_delay=0,
|
||||
transport_jitter_mean_ms=0.0, unresponsive_frac=u, graph_seed=0)
|
||||
g = build_graph(cfg)
|
||||
resp = assign_responsive(n, u, np.random.default_rng(responsive_seedseq(cfg, u)))
|
||||
rng = np.random.default_rng(5)
|
||||
resp_ids = np.where(resp)[0]
|
||||
checked = 0
|
||||
for _ in range(400):
|
||||
s = int(rng.choice(resp_ids))
|
||||
masks = []
|
||||
for _c in range(4):
|
||||
rel = rng.choice(n - 1, size=1, replace=False)
|
||||
rel[rel >= s] += 1
|
||||
rc = blend_round(g, s, rel, 0.0, 0, rng, (50.0,), resp, stats=False)
|
||||
if rc["delivered"]:
|
||||
masks.append(np.isfinite(rc["arrival"]))
|
||||
if len(masks) < 2:
|
||||
continue
|
||||
checked += 1
|
||||
union = np.logical_or.reduce(masks)
|
||||
assert int(union.sum()) == max(int(m.sum()) for m in masks)
|
||||
assert checked > 0 # the multi-delivery case did occur
|
||||
|
||||