Check in the evidence behind the pd report
The simulator gitignores its runs/ directory, so every table and figure in
reports/blend/pd rested on data that existed only on one machine. This adds the
sweep outputs of record under reports/blend/pd/data -- one directory per study,
1 MB total -- so any number can be checked against its source, or challenged,
without re-running hours of compute.
report_numbers.py comes with them: run it and it prints every value the report
quotes together with its across-topology standard error, straight from these
parquets. It reproduces the report tables exactly.
Kept: default (8000 rounds/cell), redundancy (9600), percolation (6400),
correlated-churn (6400), fullscale (192, the deliberately lighter 1e6 check).
Omitted: the smoke runs, and an earlier 144-rounds/cell redundancy grid whose
sampling error produced a non-monotonic delivery curve -- superseded, and the
reason the kept grid samples 9600.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 14:50:41 +02:00
|
|
|
"""Regenerate every number the report quotes, with its across-topology standard error.
|
|
|
|
|
|
|
|
|
|
Run from this directory: python report_numbers.py
|
|
|
|
|
Each printed value is mean +- SEM over the independent topology seeds, computed from the
|
|
|
|
|
parquets checked in beside this script (see README.md for what each run is).
|
|
|
|
|
"""
|
blend: emission-model fidelity, and evidence tooling that covers every section
Second review pass, three findings.
Pending cancellations were a set, so a node that proposed twice before its next
cover emission forfeited only one and then over-emitted relative to its quota --
the precise uniformity cover traffic exists to preserve. Now a multiset.
The timeline drew the block proposer uniformly while quota.py used a stake-
weighted lottery, so the two halves of the cover-traffic model disagreed. The
timeline now takes the stake array. Concentration is visible in the bookkeeping: a
dominant proposer wins most proposals but rarely draws a cover slot to forfeit, so
cancellations redeemed fall from 107 to 28 -- the unredeemed backlog being exactly
the over-emission the stake ceiling describes.
data/report_numbers.py claimed to print every quoted value but covered only
sections 3.1-3.5 and 3.8. Extended to 3.9 correlated churn, 3.10 blending, mixing
and the quota ceiling, 3.11 the release designs, and the 3.4 attribution bracket;
the claim in data/README is corrected to say what it actually does.
Neither model fix moves a published number: the proposer identity does not enter
blending, mixing or timing, and repeat proposals are rare at the reported rates.
Two regression tests pin both.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 14:31:33 +02:00
|
|
|
import itertools
|
Check in the evidence behind the pd report
The simulator gitignores its runs/ directory, so every table and figure in
reports/blend/pd rested on data that existed only on one machine. This adds the
sweep outputs of record under reports/blend/pd/data -- one directory per study,
1 MB total -- so any number can be checked against its source, or challenged,
without re-running hours of compute.
report_numbers.py comes with them: run it and it prints every value the report
quotes together with its across-topology standard error, straight from these
parquets. It reproduces the report tables exactly.
Kept: default (8000 rounds/cell), redundancy (9600), percolation (6400),
correlated-churn (6400), fullscale (192, the deliberately lighter 1e6 check).
Omitted: the smoke runs, and an earlier 144-rounds/cell redundancy grid whose
sampling error produced a non-monotonic delivery curve -- superseded, and the
reason the kept grid samples 9600.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 14:50:41 +02:00
|
|
|
import os
|
|
|
|
|
import sys
|
|
|
|
|
|
|
|
|
|
import numpy as np
|
|
|
|
|
import pandas as pd
|
|
|
|
|
|
|
|
|
|
_here = os.path.dirname(os.path.abspath(__file__))
|
|
|
|
|
D = sys.argv[1] if len(sys.argv) > 1 else os.path.join(_here, "default")
|
|
|
|
|
R = sys.argv[2] if len(sys.argv) > 2 else os.path.join(_here, "redundancy")
|
|
|
|
|
PC = sys.argv[3] if len(sys.argv) > 3 else os.path.join(_here, "percolation")
|
blend: emission-model fidelity, and evidence tooling that covers every section
Second review pass, three findings.
Pending cancellations were a set, so a node that proposed twice before its next
cover emission forfeited only one and then over-emitted relative to its quota --
the precise uniformity cover traffic exists to preserve. Now a multiset.
The timeline drew the block proposer uniformly while quota.py used a stake-
weighted lottery, so the two halves of the cover-traffic model disagreed. The
timeline now takes the stake array. Concentration is visible in the bookkeeping: a
dominant proposer wins most proposals but rarely draws a cover slot to forfeit, so
cancellations redeemed fall from 107 to 28 -- the unredeemed backlog being exactly
the over-emission the stake ceiling describes.
data/report_numbers.py claimed to print every quoted value but covered only
sections 3.1-3.5 and 3.8. Extended to 3.9 correlated churn, 3.10 blending, mixing
and the quota ceiling, 3.11 the release designs, and the 3.4 attribution bracket;
the claim in data/README is corrected to say what it actually does.
Neither model fix moves a published number: the proposer identity does not enter
blending, mixing or timing, and repeat proposals are rare at the reported rates.
Two regression tests pin both.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 14:31:33 +02:00
|
|
|
CC = os.path.join(_here, "correlated-churn")
|
|
|
|
|
CT = os.path.join(_here, "cover-traffic")
|
|
|
|
|
TM = os.path.join(_here, "timing")
|
Check in the evidence behind the pd report
The simulator gitignores its runs/ directory, so every table and figure in
reports/blend/pd rested on data that existed only on one machine. This adds the
sweep outputs of record under reports/blend/pd/data -- one directory per study,
1 MB total -- so any number can be checked against its source, or challenged,
without re-running hours of compute.
report_numbers.py comes with them: run it and it prints every value the report
quotes together with its across-topology standard error, straight from these
parquets. It reproduces the report tables exactly.
Kept: default (8000 rounds/cell), redundancy (9600), percolation (6400),
correlated-churn (6400), fullscale (192, the deliberately lighter 1e6 check).
Omitted: the smoke runs, and an earlier 144-rounds/cell redundancy grid whose
sampling error produced a non-monotonic delivery curve -- superseded, and the
reason the kept grid samples 9600.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 14:50:41 +02:00
|
|
|
P = pd.read_parquet(D + "/propagation.parquet")
|
|
|
|
|
A = pd.read_parquet(D + "/adversary.parquet")
|
|
|
|
|
Z = pd.read_parquet(D + "/deanon.parquet")
|
|
|
|
|
PR = pd.read_parquet(R + "/propagation.parquet")
|
|
|
|
|
ZR = pd.read_parquet(R + "/deanon.parquet")
|
|
|
|
|
PP = pd.read_parquet(PC + "/propagation.parquet")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def sem(s):
|
|
|
|
|
return s.std(ddof=1) / np.sqrt(s.count()) if s.count() > 1 else np.nan
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def cell(df, col):
|
|
|
|
|
return df[col].mean(), sem(df[col])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
print(f"rounds/cell: default {P.n_rounds.iloc[0]}x{P.graph_seed.nunique()}"
|
|
|
|
|
f" | redundancy {PR.n_rounds.iloc[0]}x{PR.graph_seed.nunique()}"
|
|
|
|
|
f" | percolation {PP.n_rounds.iloc[0]}x{PP.graph_seed.nunique()}")
|
|
|
|
|
|
|
|
|
|
print("\n### 3.1 full delay (s), N=1e5 mbd=3 u=0 : mean+-SEM")
|
|
|
|
|
b = P[(P.n_nodes == 100000) & (P.max_blend_delay == 3) & (P.unresponsive_frac == 0.0)]
|
|
|
|
|
for bh in sorted(b.blend_hops.unique()):
|
|
|
|
|
out = []
|
|
|
|
|
for d in sorted(b.degree.unique()):
|
|
|
|
|
m, e = cell(b[(b.blend_hops == bh) & (b.degree == d)], "full_delay_ms_mean")
|
|
|
|
|
out.append(f"d{d}:{m/1000:.2f}+-{e/1000:.3f}")
|
|
|
|
|
print(f" bh={bh} " + " ".join(out))
|
|
|
|
|
print(" per-hop cost (s) by degree:")
|
|
|
|
|
for d in sorted(b.degree.unique()):
|
|
|
|
|
g = b[b.degree == d].groupby("blend_hops").full_delay_ms_mean.mean() / 1000
|
|
|
|
|
print(f" d={d:<3} 1->2 {g[2]-g[1]:.2f} 2->3 {g[3]-g[2]:.2f} 3->5 {(g[5]-g[3])/2:.2f}")
|
|
|
|
|
|
|
|
|
|
print("\n### 3.2 composition N=1e5 deg8 bh3 ; and N-scaling")
|
|
|
|
|
g = b[(b.degree == 8) & (b.blend_hops == 3)]
|
|
|
|
|
for c in ("path_delay_ms_mean", "broadcast_delay_ms_mean", "full_delay_ms_mean",
|
|
|
|
|
"cover50_ms", "cover90_ms", "cover99_ms"):
|
|
|
|
|
m, e = cell(g, c)
|
|
|
|
|
print(f" {c:<24} {m:8.0f} +- {e:.0f} ms")
|
|
|
|
|
for n in sorted(P.n_nodes.unique()):
|
|
|
|
|
m, e = cell(P[(P.n_nodes == n) & (P.degree == 8) & (P.blend_hops == 3)
|
|
|
|
|
& (P.max_blend_delay == 3) & (P.unresponsive_frac == 0)], "full_delay_ms_mean")
|
|
|
|
|
print(f" N={n:<8} full {m/1000:.2f}+-{e/1000:.3f} s")
|
|
|
|
|
print(" cover99 by degree (N=1e5,bh3):",
|
|
|
|
|
{int(d): round(b[(b.degree == d) & (b.blend_hops == 3)].cover99_ms.mean())
|
|
|
|
|
for d in sorted(b.degree.unique())})
|
|
|
|
|
|
|
|
|
|
print("\n### 3.3 observed / eclipsed (exact) N=1e5 random")
|
|
|
|
|
ab = A[(A.n_nodes == 100000) & (A.adversary_mode == "random")]
|
|
|
|
|
print(ab.groupby(["f_adv", "degree"]).observed_frac.mean().unstack().round(3).to_string())
|
|
|
|
|
print(ab.groupby(["f_adv", "degree"]).eclipsed_frac.mean().unstack().round(4).to_string())
|
|
|
|
|
print(" random vs worstcase_coverage observed, AT DEGREE 8 (not averaged over degrees):")
|
|
|
|
|
wc = A[(A.n_nodes == 100000) & (A.degree == 8)
|
|
|
|
|
& A.adversary_mode.isin(["random", "worstcase_coverage"])]
|
|
|
|
|
print(wc.groupby(["f_adv", "adversary_mode"]).observed_frac.mean().unstack().round(3).to_string())
|
|
|
|
|
print(" ...and eclipse random vs worstcase_eclipse at degree 4:")
|
|
|
|
|
we = A[(A.n_nodes == 100000) & (A.degree == 4)
|
|
|
|
|
& A.adversary_mode.isin(["random", "worstcase_eclipse"])]
|
|
|
|
|
print(we.groupby(["f_adv", "adversary_mode"]).eclipsed_frac.mean().unstack().round(4).to_string())
|
|
|
|
|
|
|
|
|
|
print("\n### 3.4 deanon (exact) N=1e5 random deg8 : deanon_rate by f_adv x hops")
|
|
|
|
|
zz = Z[(Z.n_nodes == 100000) & (Z.adversary_mode == "random") & (Z.degree == 8)]
|
|
|
|
|
print(zz.groupby(["f_adv", "blend_hops"]).deanon_rate.mean().unstack().round(5).to_string())
|
|
|
|
|
print(" full_deanon vs degree (bh=2):")
|
|
|
|
|
print(Z[(Z.n_nodes == 100000) & (Z.adversary_mode == "random") & (Z.blend_hops == 2)]
|
|
|
|
|
.groupby(["f_adv", "degree"]).full_deanon_rate.mean().unstack().round(4).to_string())
|
|
|
|
|
|
|
|
|
|
print("\n### 3.5 delivery (N=1e5 deg8 mbd3): mean+-SEM [theory (1-u)^bh]")
|
|
|
|
|
dv = P[(P.n_nodes == 100000) & (P.degree == 8) & (P.max_blend_delay == 3)]
|
|
|
|
|
for bh in sorted(dv.blend_hops.unique()):
|
|
|
|
|
out = []
|
|
|
|
|
for u in sorted(dv.unresponsive_frac.unique()):
|
|
|
|
|
if u == 0:
|
|
|
|
|
continue
|
|
|
|
|
m, e = cell(dv[(dv.blend_hops == bh) & (dv.unresponsive_frac == u)], "delivery_rate")
|
|
|
|
|
out.append(f"u{u}:{m:.3f}+-{e:.3f}[{(1-u)**bh:.3f}]")
|
|
|
|
|
print(f" bh={bh} " + " ".join(out))
|
|
|
|
|
print("\n### 3.5 coverage (N=1e5 bh1 mbd3): mean+-SEM")
|
|
|
|
|
cc = P[(P.n_nodes == 100000) & (P.blend_hops == 1) & (P.max_blend_delay == 3)]
|
|
|
|
|
for d in sorted(cc.degree.unique()):
|
|
|
|
|
out = []
|
|
|
|
|
for u in (0.2, 0.3, 0.5):
|
|
|
|
|
m, e = cell(cc[(cc.degree == d) & (cc.unresponsive_frac == u)], "frac_reached")
|
|
|
|
|
out.append(f"u{u}:{m:.4f}+-{e:.4f}")
|
|
|
|
|
print(f" deg={d:<3} " + " ".join(out))
|
|
|
|
|
|
|
|
|
|
print("\n### 3.5 PERCOLATION run: coverage vs u (N=1e5, bh=1); u_c = 1-1/(d-1)")
|
|
|
|
|
for d in sorted(PP.degree.unique()):
|
|
|
|
|
uc = 1 - 1 / (d - 1)
|
|
|
|
|
g = PP[PP.degree == d].groupby("unresponsive_frac").frac_reached.mean()
|
|
|
|
|
print(f" deg={d:<3} u_c={uc:.2f} " + " ".join(f"{u:.1f}:{v:.3f}" for u, v in g.items()))
|
|
|
|
|
|
|
|
|
|
print("\n### 3.8 REDUNDANCY: delivery vs R (N=20k deg8 bh3): mean+-SEM [1-(1-p1)^R]")
|
|
|
|
|
pr = PR[(PR.degree == 8) & (PR.blend_hops == 3)]
|
|
|
|
|
for u in sorted(pr.unresponsive_frac.unique()):
|
|
|
|
|
if u == 0:
|
|
|
|
|
continue
|
|
|
|
|
p1 = pr[(pr.unresponsive_frac == u) & (pr.redundancy == 1)].delivery_rate.mean()
|
|
|
|
|
out, vals = [], []
|
|
|
|
|
for Rn in (1, 2, 3, 4):
|
|
|
|
|
m, e = cell(pr[(pr.unresponsive_frac == u) & (pr.redundancy == Rn)], "delivery_rate")
|
|
|
|
|
vals.append(m)
|
|
|
|
|
out.append(f"R{Rn}:{m:.3f}+-{e:.3f}[{1-(1-p1)**Rn:.3f}]")
|
blend: emission-model fidelity, and evidence tooling that covers every section
Second review pass, three findings.
Pending cancellations were a set, so a node that proposed twice before its next
cover emission forfeited only one and then over-emitted relative to its quota --
the precise uniformity cover traffic exists to preserve. Now a multiset.
The timeline drew the block proposer uniformly while quota.py used a stake-
weighted lottery, so the two halves of the cover-traffic model disagreed. The
timeline now takes the stake array. Concentration is visible in the bookkeeping: a
dominant proposer wins most proposals but rarely draws a cover slot to forfeit, so
cancellations redeemed fall from 107 to 28 -- the unredeemed backlog being exactly
the over-emission the stake ceiling describes.
data/report_numbers.py claimed to print every quoted value but covered only
sections 3.1-3.5 and 3.8. Extended to 3.9 correlated churn, 3.10 blending, mixing
and the quota ceiling, 3.11 the release designs, and the 3.4 attribution bracket;
the claim in data/README is corrected to say what it actually does.
Neither model fix moves a published number: the proposer identity does not enter
blending, mixing or timing, and repeat proposals are rare at the reported rates.
Two regression tests pin both.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 14:31:33 +02:00
|
|
|
mono = all(y >= x - 1e-9 for x, y in itertools.pairwise(vals))
|
Check in the evidence behind the pd report
The simulator gitignores its runs/ directory, so every table and figure in
reports/blend/pd rested on data that existed only on one machine. This adds the
sweep outputs of record under reports/blend/pd/data -- one directory per study,
1 MB total -- so any number can be checked against its source, or challenged,
without re-running hours of compute.
report_numbers.py comes with them: run it and it prints every value the report
quotes together with its across-topology standard error, straight from these
parquets. It reproduces the report tables exactly.
Kept: default (8000 rounds/cell), redundancy (9600), percolation (6400),
correlated-churn (6400), fullscale (192, the deliberately lighter 1e6 check).
Omitted: the smoke runs, and an earlier 144-rounds/cell redundancy grid whose
sampling error produced a non-monotonic delivery curve -- superseded, and the
reason the kept grid samples 9600.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 14:50:41 +02:00
|
|
|
print(f" u={u} " + " ".join(out) + ("" if mono else " <<< NON-MONOTONIC"))
|
|
|
|
|
print(" coverage vs R (should be flat -- no union bonus):")
|
|
|
|
|
for d in sorted(PR.degree.unique()):
|
|
|
|
|
for u in (0.3, 0.5):
|
|
|
|
|
g = PR[(PR.degree == d) & (PR.blend_hops == 1) & (PR.unresponsive_frac == u)]
|
|
|
|
|
print(f" deg={d} u={u}: " +
|
|
|
|
|
" ".join(f"R{Rn}:{g[g.redundancy==Rn].frac_reached.mean():.4f}" for Rn in (1, 2, 3, 4)))
|
|
|
|
|
print(" deanon vs R (exact, N=20k deg8 bh3 f=0.2 random):")
|
|
|
|
|
zr = ZR[(ZR.degree == 8) & (ZR.blend_hops == 3) & (ZR.f_adv == 0.2)
|
|
|
|
|
& (ZR.adversary_mode == "random")]
|
|
|
|
|
print(zr.groupby("redundancy")[["deanon_rate", "full_deanon_rate"]].mean().round(4).to_string())
|
blend: emission-model fidelity, and evidence tooling that covers every section
Second review pass, three findings.
Pending cancellations were a set, so a node that proposed twice before its next
cover emission forfeited only one and then over-emitted relative to its quota --
the precise uniformity cover traffic exists to preserve. Now a multiset.
The timeline drew the block proposer uniformly while quota.py used a stake-
weighted lottery, so the two halves of the cover-traffic model disagreed. The
timeline now takes the stake array. Concentration is visible in the bookkeeping: a
dominant proposer wins most proposals but rarely draws a cover slot to forfeit, so
cancellations redeemed fall from 107 to 28 -- the unredeemed backlog being exactly
the over-emission the stake ceiling describes.
data/report_numbers.py claimed to print every quoted value but covered only
sections 3.1-3.5 and 3.8. Extended to 3.9 correlated churn, 3.10 blending, mixing
and the quota ceiling, 3.11 the release designs, and the 3.4 attribution bracket;
the claim in data/README is corrected to say what it actually does.
Neither model fix moves a published number: the proposer identity does not enter
blending, mixing or timing, and repeat proposals are rare at the reported rates.
Two regression tests pin both.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 14:31:33 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- 3.9 correlated outages ----------------------------------------------------------------------
|
|
|
|
|
CCP = pd.read_parquet(CC + "/propagation.parquet")
|
|
|
|
|
print("\n### 3.9 correlated vs uniform churn (N=20k, 1 hop): live / all-node coverage, delivery")
|
|
|
|
|
for deg in sorted(CCP.degree.unique()):
|
|
|
|
|
d = CCP[(CCP.degree == deg) & (CCP.blend_hops == 1)]
|
|
|
|
|
for u in sorted(d.unresponsive_frac.unique()):
|
|
|
|
|
if u == 0:
|
|
|
|
|
continue
|
|
|
|
|
r = {m: d[(d.unresponsive_frac == u) & (d.churn_mode == m)] for m in ("uniform", "regional")}
|
|
|
|
|
print(f" deg={deg:<3} u={u:.1f} live {r['uniform'].frac_reached_live.mean():.3f} ->"
|
|
|
|
|
f" {r['regional'].frac_reached_live.mean():.3f} | all"
|
|
|
|
|
f" {r['uniform'].frac_reached.mean():.3f} -> {r['regional'].frac_reached.mean():.3f}"
|
|
|
|
|
f" | delivery {r['uniform'].delivery_rate.mean():.3f} ->"
|
|
|
|
|
f" {r['regional'].delivery_rate.mean():.3f}")
|
|
|
|
|
|
|
|
|
|
# --- 3.10 cover traffic --------------------------------------------------------------------------
|
|
|
|
|
CTT = pd.read_parquet(CT + "/traffic.parquet")
|
|
|
|
|
print("\n### 3.10 blending / mixing vs cover rate and release delay")
|
|
|
|
|
print(CTT.pivot_table(index="cover_rate_mult", columns="max_blend_delay",
|
|
|
|
|
values="blending_mean").round(1).to_string())
|
|
|
|
|
print(" mixing (mean concurrent holds):")
|
|
|
|
|
print(CTT.pivot_table(index="cover_rate_mult", columns="max_blend_delay",
|
|
|
|
|
values="queue_mean").round(4).to_string())
|
|
|
|
|
print("\n### 3.10 emission-quota stake ceiling vs cover rate")
|
|
|
|
|
q = CTT.groupby("cover_rate_mult").agg(
|
|
|
|
|
quota=("quota_per_epoch", "mean"), pred=("s_max_predicted", "mean"),
|
|
|
|
|
safe=("alpha_max_99", "mean"), hi=("max_compliant_stake", "mean"),
|
|
|
|
|
lo=("min_overrun_stake", "mean"), comp=("compliant_frac", "mean")).reset_index()
|
|
|
|
|
print(q.to_string(index=False, float_format=lambda v: f"{v:.5f}"))
|
|
|
|
|
|
|
|
|
|
# --- 3.11 timing ---------------------------------------------------------------------------------
|
|
|
|
|
TMT = pd.read_parquet(TM + "/traffic.parquet")
|
|
|
|
|
print("\n### 3.11 timing: release designs at a matched delay budget (min_blend_delay = 0)")
|
|
|
|
|
t = TMT[TMT.min_blend_delay == 0].groupby(["cover_rate_mult", "release_mode"]).agg(
|
|
|
|
|
hold=("hold_seconds_mean", "mean"), eff_set=("timing_set_mean", "mean"),
|
|
|
|
|
linked=("timing_linked_frac", "mean"), map_success=("map_success", "mean")).reset_index()
|
|
|
|
|
print(t.to_string(index=False, float_format=lambda v: f"{v:.3f}"))
|
|
|
|
|
print(" minimum-interval control (clock):")
|
|
|
|
|
m = TMT[TMT.release_mode == "clock"].groupby("min_blend_delay").agg(
|
|
|
|
|
hold=("hold_seconds_mean", "mean"), map_success=("map_success", "mean")).reset_index()
|
|
|
|
|
print(m.to_string(index=False, float_format=lambda v: f"{v:.3f}"))
|
|
|
|
|
|
|
|
|
|
# --- 3.4 attribution bracket ---------------------------------------------------------------------
|
blend: attribution evidence at the reported scale, and a figure for the timing study
Two gaps left by the previous review.
Section 3.4 quoted the attribution bracket at N=20,000 while the only committed
evidence carrying those columns was the timing run at N=2,000, so a reader diffing
report against data saw different numbers for the same quantity. Added
configs/attribution.yaml and a make target: it records both bounds and the graph
hop distance at the reported scale, cheaply, since the adversary and
deanonymization metrics are closed-form and the hop distance is a property of the
topology. It reproduces the section exactly -- L = 2.58 and neighbourhood
confidence 0.640 at degree 8, f_adv 0.2.
It also surfaces a result the smaller run could not: degree cuts both ways. A
sparser graph has longer routes, so it offers the adversary more upstream places
to see the message -- L is 4.18 at degree 4 against 1.93 at degree 16, lifting
neighbourhood confidence from 0.61 to 0.72. The low diameter that makes
propagation fast also starves the adversary, one of the few places where raising
the degree helps anonymity rather than hurting it.
Section 3.11 was the only section without a figure. Fig 25 plots MAP success
against the effective anonymity set for both release designs: the dashed sets
separate far faster than the solid best-guess curves, which is the whole argument
for not trusting perplexity alone.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 17:12:33 +02:00
|
|
|
AT = os.path.join(_here, "attribution")
|
|
|
|
|
print("\n### 3.4 attribution bracket (N=20k, random placement)")
|
|
|
|
|
AD = pd.read_parquet(AT + "/deanon.parquet")
|
|
|
|
|
a = AD[AD.adversary_mode == "random"].groupby(["degree", "f_adv"]).agg(
|
|
|
|
|
observed=("observed_frac", "mean"), local_mean=("attribution_conf_mean", "mean"),
|
|
|
|
|
attributable_90=("attributable_frac_90", "mean"), upstream=("upstream_hops", "mean"),
|
|
|
|
|
neighbourhood=("neighbourhood_conf", "mean")).reset_index()
|
|
|
|
|
print(a.to_string(index=False, float_format=lambda v: f"{v:.5f}"))
|