2026-07-30 18:57:10 +02:00

162 lines
7.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""N-scaling of the one-uncle boundary: figures fig23/fig24 + §3.8 numbers.
Combines the direct ladder (nscaling-a/b + 32k tiers, N = 1k..32k) with the exact topology
probe (l_mean(N, degree) to N = 10^6) and the load law rho = f*D_vis:
fig23 — U=1 accuracy vs N per degree, case (a) vs (b), at the 8-s blending budget.
fig24 — the ladder collapsed onto rho (validating that N enters only via l_mean), with the
probe's rho(N) curves extrapolating each degree to 10^6 and the U=1 boundary marked.
Run: python scripts/nscaling_analysis.py
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pandas as pd
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from tsi_sim.plotting import style # noqa: E402
HERE = Path(__file__).resolve().parent.parent
RUNS = HERE / "runs"
FIGS = HERE / "report-figures"
F = 1.0 / 30.0
HOPS = 3
def load_ladder() -> pd.DataFrame:
parts = []
for stem in ("nscaling-a", "nscaling-b", "nscaling32-a", "nscaling32-b"):
cands = sorted(RUNS.glob(f"*_{stem}/results.parquet"))
if not cands:
raise SystemExit(f"missing run for {stem}")
d = pd.read_parquet(cands[-1])
d["case"] = "b" if stem.endswith("-b") else "a"
parts.append(d)
df = pd.concat(parts, ignore_index=True)
# Per-trajectory 50% burn-in (matches the report's line-55 convention). A global
# epoch.max()//2 threshold discards the early-stopping large-N tiers entirely, which
# dropped deg6/deg8's N=32000 points and left deg4's on a single replicate.
_keys = ["case", "n_nodes", "degree", "blend_delay_max", "max_uncles", "replicate"]
tail = df[df.epoch >= df.groupby(_keys).epoch.transform("max") // 2]
eq = tail.groupby(["case", "n_nodes", "degree", "blend_delay_max", "max_uncles",
"replicate"], as_index=False).mean_ratio.mean()
return eq
def lmean_fits(probe: pd.DataFrame) -> dict[int, tuple[float, float]]:
"""Per-degree log-fit l_mean ~ a*ln(N) + b from the exact probe."""
lm = probe.groupby(["n", "degree"], as_index=False).l_mean.mean()
fits = {}
for deg in sorted(lm.degree.unique()):
s = lm[lm.degree == deg]
a, b = np.polyfit(np.log(s.n), s.l_mean, 1)
fits[int(deg)] = (float(a), float(b))
return fits
def rho_of(eq: pd.DataFrame, probe: pd.DataFrame) -> pd.DataFrame:
# fitted l_mean covers every ladder N (the probe grid is log-spaced, not the ladder's)
fits = lmean_fits(probe)
m = eq.copy()
m["l_mean"] = [fits[int(d)][0] * np.log(n) + fits[int(d)][1]
for d, n in zip(m.degree, m.n_nodes, strict=True)]
m["d_vis"] = HOPS * m.blend_delay_max / 2.0 + (HOPS + 1) * m.l_mean
m["rho"] = F * m.d_vis
return m
def fig23(eq: pd.DataFrame) -> None:
import matplotlib.pyplot as plt
style.apply_style()
fig, axes = plt.subplots(1, 2, figsize=(9.6, 4.2), sharey=True)
for ax, case, title in ((axes[0], "a", "case (a): geo delays"),
(axes[1], "b", "case (b): + 10% Poisson(3) stragglers")):
s = eq[(eq.case == case) & (eq.blend_delay_max == 8.0) & (eq.max_uncles == 1)]
for i, deg in enumerate((4, 6, 8)):
g = s[s.degree == deg].groupby("n_nodes").mean_ratio.agg(["mean", "sem"])
ax.errorbar(g.index, g["mean"], yerr=g["sem"], fmt="-o", ms=4, capsize=2,
color=style.OKABE_ITO[i], label=f"degree {deg}")
u2 = eq[(eq.case == case) & (eq.blend_delay_max == 8.0) & (eq.max_uncles == 2)]
g2 = u2.groupby("n_nodes").mean_ratio.mean()
ax.plot(g2.index, g2.values, ":", color="0.5", lw=1.2, label="U = 2 (all degrees)")
ax.axhline(0.98, color="0.75", lw=0.8, ls="--")
ax.text(1100, 0.982, "0.98 recovery bar", fontsize=7, color="0.5")
ax.set_xscale("log")
ax.set_xlabel("network size N")
ax.set_title(title)
ax.legend(fontsize=8, loc="lower left")
axes[0].set_ylim(top=1.01) # bounded by 1: cap at the exact-recovery bound (above-1 is noise)
axes[0].set_ylabel(r"stake estimate accuracy $\hat D / D$")
fig.suptitle(r"U = 1 erodes with network size (blend, $\delta_{max}$ = 8 s, f = 1/30)",
y=1.02)
style.save(fig, FIGS / "fig23_nscaling_u1", provenance="scripts/nscaling_analysis.py")
plt.close(fig)
def fig24(m: pd.DataFrame, probe: pd.DataFrame) -> None:
import matplotlib.pyplot as plt
style.apply_style()
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(9.6, 4.2))
# left: ladder accuracy vs rho, all (N, degree, delay) cells collapse onto one curve
for case, marker, lbl in (("a", "o", "case (a)"), ("b", "s", "case (b)")):
s = m[(m.case == case) & (m.max_uncles == 1)]
g = s.groupby(["n_nodes", "degree", "blend_delay_max"]).agg(
rho=("rho", "mean"), acc=("mean_ratio", "mean")).reset_index()
ax1.scatter(g.rho, g.acc, s=18, marker=marker, alpha=0.75, label=lbl)
ax1.axvline(1.0, color="0.6", lw=0.8, ls=":")
ax1.text(0.98, 0.05, r"$\rho=1$", rotation=90, fontsize=7, color="0.4",
va="bottom", ha="right", transform=ax1.get_xaxis_transform())
ax1.set_xlabel(r"load $\rho = f\,D_{vis}(N, d, \delta)$")
ax1.set_ylabel(r"$\hat D / D$ at U = 1")
ax1.set_ylim(top=1.01) # bounded by 1: cap at the exact-recovery bound
ax1.set_title("ladder collapses onto the load law")
ax1.legend(fontsize=8)
# right: probe rho(N) per degree to 1M, delta=8
lm = probe.groupby(["n", "degree"], as_index=False).l_mean.mean()
for i, deg in enumerate((4, 6, 8)):
s = lm[lm.degree == deg].sort_values("n")
rho = F * (HOPS * 8.0 / 2.0 + (HOPS + 1) * s.l_mean)
ax2.plot(s.n, rho, "-o", ms=4, color=style.OKABE_ITO[i], label=f"degree {deg}")
ax2.axhline(1.0, color="0.6", lw=0.8, ls=":")
ax2.axhline(0.96, color="tab:red", lw=0.8, ls="--")
ax2.text(1.0e6, 0.955, "measured U=1 failure (ρ=0.96, §3.6)", fontsize=7, color="tab:red",
ha="right", va="top")
ax2.set_xscale("log")
ax2.set_xlabel("network size N")
ax2.set_ylabel(r"load $\rho$ at $\delta_{max}$ = 8 s")
ax2.set_title(r"exact probe: $\rho(N)$ to $10^6$ nodes")
ax2.legend(fontsize=8, loc="upper left")
style.save(fig, FIGS / "fig24_nscaling_probe", provenance="scripts/nscaling_analysis.py")
plt.close(fig)
def main() -> None:
probe = pd.read_parquet(RUNS / "topology_probe.parquet")
eq = load_ladder()
m = rho_of(eq, probe)
fig23(eq)
fig24(m, probe)
# §3.8 numbers
print("=== U=1 accuracy vs N (delta=8) ===")
t = eq[(eq.blend_delay_max == 8.0) & (eq.max_uncles == 1)]
print(t.groupby(["case", "degree", "n_nodes"]).mean_ratio.mean().round(3).to_string())
print("\n=== probe rho(delta=8) at 1M ===")
lm = probe.groupby(["n", "degree"]).l_mean.mean().reset_index()
one = lm[lm.n == 1_000_000]
for _, r in one.iterrows():
rho = F * (HOPS * 4.0 + 4 * r.l_mean)
print(f"degree {int(r.degree)}: l_mean={r.l_mean:.2f} rho={rho:.3f}")
for d, a, b in ((4, 0.326, -0.23), (6, 0.192, -0.06), (8, 0.143, 0.00)):
nstar = np.exp(((0.96 / F - HOPS * 4.0) / 4 - b) / a)
print(f"degree {d}: U=1 failure crossing at N* ≈ {nstar:.2e}")
if __name__ == "__main__":
main()