19 Commits

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 18:00:01 +02:00
Marcin Pawlowski
e4d3d3a0f7
blend: close the attribution bracket with a neighbourhood-observability model
The a/d estimator credits the adversary only with the sender own links, which
understates it: to rule out that X was forwarding it need not hold X incoming
link, only have seen the message anywhere upstream. That gives

    confidence = 1 / (1 + (1-f_adv)^L)

for L upstream hops, with the local model as the L=1 case.

L is not free -- the graph fixes it. A forwarder sits about halfway along a route
and the peer graph is low-diameter: mean hop distance 5.15 at degree 8, so L ~ 2.6.
Confidence rises from 0.56 to 0.64 at f_adv = 0.2, but 0.9 would need ~10 upstream
hops. The low diameter that makes propagation fast is exactly what starves the
adversary of observation points.

So the bracket closes near the local model rather than near certainty, and the
binary full_deanon treatment is NOT rescued by neighbourhood effects. Both ends
are reported rather than one being chosen, since confident attribution is a
threshold question: an adversary content with 0.64 attributes most senders, one
demanding 0.9 attributes almost none.

What remains unmodelled is an adversary combining this structural evidence with
the timing evidence of 3.11.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 18:00:01 +02:00
Marcin Pawlowski
0c72a39e38
blend: show the clock design is not handicapped by the adversary assumption
The jitter-vs-clock verdict rested on handing the clock adversary the tick
schedule, which is the conservative choice but also the harsher one -- a real
observer sees only ticks that released something. Rather than assume, both are
now measurable, and they are provably equivalent.

A silent tick implies nothing was pending at it, and any arrival older than the
previous release has demonstrably already left. So the candidate window bounded by
the true previous tick and the one bounded by the previous observed release hold
the same arrivals. Confirmed identical to 1e-9 in both the sparse regime (one hold
per relay) and the dense one (56 holds per relay), where the two coincide for
different reasons.

That removes the objection to the comparison: jitter beats the clock at a matched
delay budget on merit, not because the clock was given a stronger opponent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 18:00:00 +02:00
Marcin Pawlowski
4915444c3e
blend: cover the two new release fields in the key() test
Caught by the test immediately: key() enumerates every SimConfig field, so
min_blend_delay and release_mode needed alternative values.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 18:00:00 +02:00
Marcin Pawlowski
57be929af7
blend: minimum release interval, and jitter vs clock under a timing attack
Two release designs at a MATCHED delay budget, so they differ only in how they
delay: clock (batch at free-running ticks, the existing model) and jitter (each
message waits its own exponential draw, mean set equal to the clock residual).
Plus min_blend_delay, which forbids intervals shorter than it.

Minimum interval -- a negative result, and provably so. A zero-length gap is
instantaneous, so it never covers an arrival and is never sampled by the residual
or by the size-biased interval. Excluding it therefore leaves the mean hold
exactly unchanged, and with it blending and linkability; what it does change is
E[S], the gap between release opportunities. Confirmed analytically and in
simulation: 1.168s vs 1.167s at M=3.

Timing attack -- the effective anonymity set of a release (perplexity of the
observer posterior over which arrival produced it), plus MAP success, the chance
its single best guess is right. The second matters because perplexity flatters a
heavy tail: an exponential never fully excludes an old arrival, so it can look
unlinkable while still being guessed correctly.

At the baseline rate BOTH designs fail almost completely -- MAP success 0.98-0.99,
effective set ~1. A relay handles so little traffic that in->out matching is
trivial, which follows directly from the mixing~0 result. Traffic, not delay, is
what buys timing protection.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 18:00:00 +02:00
Marcin Pawlowski
3c7fef64d0
blend: attribution confidence -- one adversarial peer is not an identification
full_deanon counted any sender with an adversarial peer as identified. Capturing a
cascade tells the adversary WHICH message it is following, not WHO started it:
seeing an honest X transmit is equally consistent with X having received it from a
peer the adversary cannot watch. Separating the two gives

    confidence = 1/(2 - a/d) = d/(2d - a)

for a adversarial peers of degree d. The path length does not enter -- the
conditioning event already fixes the relays as adversarial, so an honest X is not
one of them for this message.

The consequence is large. One peer of eight is worth 0.53, barely above the 0.5
prior, and 90% confidence needs a >= 8: every peer, which is the ECLIPSE condition
rather than the observation condition. Measured, attributable_frac_90 equals
eclipsed_frac exactly. At f_adv = 0.2, degree 8 that is 2.6e-6 against an
observed_frac of 0.83 -- the published figure overstates confident origination by
five orders of magnitude.

Stated in the report as a bracket rather than a replacement: full_deanon is the
upper bound on adversary capability, this is the lower bound, and the truth lies
between because the adversary also learns from the sender neighbourhood. Closing
that gap needs a k-hop observability model and is recorded as open in section 5.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 17:59:59 +02:00
Marcin Pawlowski
f51630e509
Rename the simulator and report from pd to blend
The study started as a peering-degree question and grew well past it: propagation,
adversary exposure, deanonymization and time-to-link, reliability under uniform
and correlated churn, messaging redundancy, and cover traffic. The pd name no
longer describes it.

tools/simulators/blend/pd/ -> tools/simulators/blend/, package src/pd -> src/blend,
and reports/blend/pd/ -> reports/blend/. Moved with git mv so history follows.

The text substitutions are deliberately narrow. pd is also the conventional pandas
alias, and pandas genuinely has a pd.plotting submodule, so a blanket pd. -> blend.
rewrite would have corrupted four files. Only package-unambiguous forms were
changed: from pd.X, -m pd.X, pd.<our module>, PD_BYTES_BUDGET, src/pd, and the
pyproject name. All four import pandas as pd lines are untouched and verified.

Both READMEs reframed: peering degree is now presented as the primary axis that
ties the others together rather than as the subject, and the relative links, which
lost a directory level in the move, are corrected.

Verified after the move: ruff clean, 101 tests, 45 verify anchors, make targets,
the script shims, an end-to-end smoke run, and data/report_numbers.py still
reproducing the report tables from the checked-in evidence.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 17:59:59 +02:00
Marcin Pawlowski
8a3a43b366
pd: fix a Poisson underflow that broke the quota ceiling at higher cover rates
Found by the cover-traffic sweep: the 99%-safe stake ceiling FELL as the cover
rate rose, while the mean bind rose to 0.38 -- backwards.

quota_exceedance_prob summed the Poisson CDF by hand starting from exp(-lam).
That underflows to zero past lam ~ 745, so the CDF collapsed to 0 and the function
reported every node as exceeding its quota, which drove the bisection in
max_alpha_for_confidence to a meaningless answer. The default rate is unaffected
(lam ~ 32), but raising the cover rate reaches the broken regime immediately,
because the quota and the tolerable block count grow in proportion.

Replaced with scipy poisson.sf. Exceedance at the mean bind is now ~0.5 at every
rate, as it must be, and the safe/mean ratio rises 0.65 -> 0.81 -> 0.90 -> 0.95 ->
0.98 across the swept rates: Poisson noise shrinks relative to a growing quota, so
less headroom is needed for the same confidence. Two regression tests pin both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 17:59:58 +02:00
Marcin Pawlowski
1865b4110d
pd: verify anchors for the blending law and the emission ceiling
Check 9 -- cover traffic on a timeline. Blending follows rate*(2M+1)/3 (7.39 vs
7.41 predicted), the mean hold is the renewal residual (2M+1)/6, and mixing is
nil at the baseline rate: 0.007 concurrent holds, max 2. That last one is the
substantive finding rather than a sanity check -- at one message per second a
relay has nothing to mix, so the anonymity comes entirely from what it has seen.

Check 10 -- the emission quota. The measured breakpoint brackets the closed form
(band [0.127%, 0.159%] against a predicted 0.1475%), and deflating D_hat/D to
0.64 pushes more nodes over their quota, as the (D_hat/D)*alpha_max form requires.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 17:59:58 +02:00
Marcin Pawlowski
61d72bb5f5
pd: wire cover traffic through the sweep as a fourth table
engine gains a cover_rates axis: each rate plays a timeline through the same
graph and pairs it with the epoch emission budget, which needs no graph and so is
computed alongside rather than inside the window. Seeds are separate streams
(traffic_seedseq for the timeline and clocks, stake_seedseq for the stake draw
and budget), so the stake distribution is independent of the topology and of the
message schedule.

sweep writes traffic.parquet only when a cover-traffic study actually ran, so
every existing config keeps producing exactly three tables. quota_summary reports
the measured ceiling beside the predicted one in the same row, so a run can be
checked against the closed form instead of asked to be believed.

Two figures: blending against cover rate and release delay with the
rate*(2M+1)/3 law overlaid, and the quota ceiling with the measured transition
band against the prediction.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 17:59:58 +02:00
Marcin Pawlowski
f718f5f954
pd: stake distribution, so the emission ceiling is measured not asserted
The quota ceiling was closed-form only. This adds per-node stake so a run can
show nodes actually breaking it.

- assign_stake: uniform, or heavy-tailed zipf (s ~ 1/rank^a), which is what makes
  the ceiling bite -- the head sits orders of magnitude above it, the tail far below;
- inferred_alpha: converts true relative stake to the sigma/D_hat the lottery
  actually weighs, so a low estimate inflates every node alpha;
- simulate_epoch_emissions: measures the budget over a full epoch. Overrun happens
  at epoch scale and needs no graph, so this is cheap: proposals are Binomial over
  the epoch slots, a proposal cancels the next cover, and a node stays at exactly
  its quota until its wins no longer fit -- at which point it emits more often than
  everyone else, which is the signal cover traffic exists to suppress.

Measured against the closed form at N=20,000, zipf stake, over an epoch: the
predicted ceiling falls inside the transition band every time, and at D_hat/D = 1
the smallest overrunning node sits at 0.1468% against a predicted 0.1475%. The
D_hat/D normalisation is confirmed empirically -- deflating the estimate to 0.64
pulls the measured ceiling down with it, as the (D_hat/D)*alpha_max form requires.
With heavy-tailed stake 99.7% of nodes comply and only the head breaks; the
largest holder at 9.5% stake is some 65x over its allowance.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 17:59:58 +02:00
Marcin Pawlowski
2248a048d4
pd: cover the new cover-traffic config fields in the key() test
The key() coverage test enumerates every SimConfig field, so the five cover-
traffic knobs had to be given alternative values. Caught by the test itself
immediately after the previous commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 17:59:58 +02:00
Marcin Pawlowski
e35804f29d
pd: cover traffic -- emission quota and the blending timeline
First half of the cover-traffic work: the two new modules and their tests.

quota.py -- the emission budget. Cover traffic gives every node the same number
of emissions per epoch, which only holds while a node block proposals fit inside
its quota. The bind is exact: alpha_max = ln(1-q)/ln(1-f), where alpha is stake
relative to the INFERRED total D_hat, since that is the denominator the lottery
threshold is derived from. In true stake the ceiling carries the estimator ratio,
s_max = (D_hat/D)*alpha_max, with D_hat/D an input rather than an assumption. The
familiar q/f is a small-q approximation that runs 1.7% high and so overstates the
tolerable stake. Sitting on the mean bind overruns the quota half the time, so
max_alpha_for_confidence gives the ceiling that holds with stated probability.

traffic.py -- the timeline. The rest of the simulator samples independent rounds
and draws each hold from the stationary residual, which has no notion of time and
so can never let two messages meet at a relay. Here every node owns one
free-running clock shared by all messages through it, extended lazily so only the
relays actually visited grow one. A clock sampled once still reproduces
mixclock.mix_wait, so single-message statistics are unchanged.

It separates two quantities that are easy to conflate: mixing (messages a relay
holds at once) and blending (messages it has SEEN between consecutive releases).
Blending is the anonymity set -- every broadcast reaches every node, so an
observer cannot tell which of them the relay forwarded. Gaps sampled at a release
are size-biased, so blending is rate*(2M+1)/3, twice the mean hold, not
rate*M/2 as a naive reading gives. Measured within 1-4% of that at M = 3, 10, 30
and linear in the cover rate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 17:59:57 +02:00
Marcin Pawlowski
c42d030f0d
pd review: document the new columns, pin the coverage notion, drop dead code
Third review pass over the blend material.

Completeness:
- the simulator README documented neither frac_reached_live nor the three
  correlated-churn columns (churn_mode, n_regions, region_locality) that every
  run now writes, and its model section never described correlated outages at all;
- the knowledge graph had no pd nodes -- graphify update had never been run since
  the simulator was added (2643 -> 2968 nodes).

Correctness/coherence:
- section 3.5 quotes coverage without saying which coverage, now that 3.9
  distinguishes all-node from live-network. It is all-node; under uniform churn
  the two agree to 0.001, so nothing in 3.5 turns on it. Said so explicitly;
- 3.9 named its groups AS/region without noting that link latency ignores them.
  Regions are failure and peering domains, not latency domains -- real co-located
  nodes would also be faster, so the clustered delays are if anything pessimistic.

Redundancy:
- style.band_plot was dead: never called by any figure. Removed, with the two
  imports it alone needed;
- the units sentence appeared verbatim in the header note and again opening the
  model section. Dropped the second.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 17:59:57 +02:00
Marcin Pawlowski
0f125b40c6
pd: correlated AS/region churn, and two report caveats corrected
Uncorrelated churn alone was incomplete: real outages take out a datacentre, AS
or region as a unit. Adds failure domains and a correlated churn mode, plus the
metric needed to tell the two apart.

- n_regions / region_locality: nodes belong to equal-sized failure domains, and
  a configurable share of each node peers inside its own domain. Locality is what
  makes a failure domain a connectivity domain -- with region-blind peering,
  dropping whole regions removes a uniformly random set of nodes and is
  indistinguishable from uniform churn. The locality matchings keep the graph
  exactly d-regular (they change where peers are, never how many).
- churn_mode = uniform | regional, swept per topology so both modes are compared
  on the same graph at an identical dead-node count.
- frac_reached_live: coverage of the *responsive* network, alongside coverage of
  all nodes. The two move in opposite directions under correlated failure, so one
  number could not express the result.

Measured (degree 4, 20 domains, 75% locality, half the network dead): clustered
failure leaves the survivors fully connected -- live coverage 1.000 and delivery
equal to the live-relay rate, i.e. nothing lost to routing -- where the same
number of scattered failures gives 0.857 live coverage and loses delivery to
broken routes. Correlated outages are gentler on the survivors than uniform
churn, while stranding the dead domains. Verify check 8 anchors this.

Also, per review of the caveats: exact d-regularity is a protocol requirement
rather than a modelling simplification, and the timing-correlation adversary is
deferred because it is only meaningful once the network emits cover traffic,
which this simulator does not yet do.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 17:59:56 +02:00
Marcin Pawlowski
81c48a38ab
pd review: model completeness, stale numbers, figure coherence
Correctness/completeness pass over the blend material only (TSI untouched).

- report Model section (2) was missing two of the six axes: messaging
  redundancy (R cascades, first-arrival combination) and the emission/linking
  model (30 s stake-proportional cadence, what counts as linked) were defined
  only inline in the findings;
- method note still claimed 200 rounds x 8 topologies, contradicting the 1000
  x 8 the tables now come from;
- design guidance carried two superseded numbers: worst-case observation as
  "+0.15 absolute" (it saturates at 1.000 at degree 8, f_adv 0.2) and the
  redundancy example (0.34 -> 0.72, measured 0.342 -> 0.713);
- figure references were incoherent: Figs 2 and 14 were cited in the text but
  never shown, and Fig 8 was shown but never cited. All 15 embedded figures are
  now cited and all citations resolve;
- simulator README listed two parquets for smoke (there are three) and omitted
  redundancy from the propagation/deanon column lists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 17:59:56 +02:00
Marcin Pawlowski
b5782619d8
pd smoke: exercise the multi-cascade path end-to-end
smoke.yaml never set redundancy > 1, so the R-cascade aggregation and the two
redundancy figures were only covered by unit tests, never by the end-to-end run.
Adding redundancy: [1, 2] takes smoke from 17 to 19 of the 21 figure builders
(only delay_vs_N and the churn-percolation figure need grids smoke does not have).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 17:59:56 +02:00
Marcin Pawlowski
9b03a68a84
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>
2026-08-06 17:59:55 +02:00
Marcin Pawlowski
6ad63ce2f3
Add pd: peering-degree Blend Monte-Carlo graph simulator
Static-graph simulator quantifying how a node's peering degree trades off
propagation speed, adversary exposure, deanonymization, and reliability in the
Blend network. Scales to 1e6 nodes (sparse CSR + sampled Dijkstra); the
adversary and deanonymization metrics are exact at every N.

Model (ms): seeded d-regular peer graph (matching-union), Blend cascade
(sender -> blend_hops timed-release mix relays -> final flood), geographic link
base + exponential transport jitter, per-node processing lag, free-running
release-clock mixing.

Metrics:
- propagation: full-delay mean/p50/p90/p99, path/broadcast split, coverage times
- reliability: message success-delivery-rate ~ (1-unresponsive_frac)^blend_hops
  and flood coverage, with unresponsive nodes modelled as routing holes
- adversary (exact): observed/eclipsed fractions, random + worst-case placement
- deanonymization (exact): P(whole blend path adversarial) ~ f_adv^blend_hops,
  and full deanonymization (path adversarial AND honest sender peered with an
  adversary) = deanon_rate * observed_frac

Deterministic blake2b seed streams, three parquet tables, joblib parallelism,
memguard, an analytic verify harness, 50 unit tests, and an auto-installing
Makefile.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-06 17:59:55 +02:00