"""Appendix B: the U=0 estimate fluctuates around 1 — sampling noise, not bias (figB1, figB2). Data: (1) clean zero-delay series (full_mesh, L=0, U=0, uniform stakes) at k in {256, 1024, 2160} -> runs/fluctuation_u0.parquet (this script, --run) (2) the committed full-scale N=1000 run (regular sub-slot links and blend, U=0, k=2160) -> per-epoch tails read directly. Figures: figB1 — high-precision per-epoch trace of (D_hat/D - 1) in per-mil at k=2160: the clean zero-delay series and the realistic 0.1-slot direct-gossip series, with the +-sigma_th = sqrt((1-f)/(f T)) band. figB2 — left: per-epoch deviation distributions vs k with the 1/sqrt(T) law; right: the delay progression (0.1 -> 1.0-slot links, blend): mean drops below 1 and P(D_hat/D > 1) -> 0 as orphan loss takes over. Run: python scripts/appendix_fluct.py --run (simulate series (1), ~30-60 min) python scripts/appendix_fluct.py (render figures + print stats) """ 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 joblib import Parallel, delayed # noqa: E402 from tsi_sim.config import SimConfig # noqa: E402 from tsi_sim.engine import run_trajectory # noqa: E402 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 KS = (256, 1024, 2160) REPS = 4 EPOCHS = 120 def sigma_theory(k: int) -> float: t_win = 6 * int(k / F) return float(np.sqrt((1 - F) / (F * t_win))) def _one(k: int, rep: int) -> pd.DataFrame: cfg = SimConfig(n_nodes=400, stake_dist="uniform", topology="full_mesh", latency=0, max_uncles=0, uncle_window=300, k=k, epochs=EPOCHS, genesis_d_factor=1.0, replicate=rep) df = pd.DataFrame(run_trajectory(cfg)) df["k_run"] = k return df[["k_run", "replicate", "epoch", "mean_ratio", "range_ratio"]] def run() -> None: jobs = [(k, r) for k in KS for r in range(REPS)] parts = Parallel(n_jobs=3, prefer="processes")(delayed(_one)(k, r) for k, r in jobs) out = pd.concat(parts, ignore_index=True) out.to_parquet(RUNS / "fluctuation_u0.parquet") print(f"wrote {len(out)} rows -> runs/fluctuation_u0.parquet") def figs() -> None: import matplotlib.pyplot as plt style.apply_style() clean = pd.read_parquet(RUNS / "fluctuation_u0.parquet") full = pd.read_parquet(sorted(RUNS.glob("2026-07-23_*_fullscale-small/results.parquet"))[-1]) u0 = full[full.max_uncles == 0] # ---- figB1: high-precision traces at k=2160 ---- fig, ax = plt.subplots(figsize=(8.6, 4.0)) s = clean[(clean.k_run == 2160) & (clean.replicate == 0) & (clean.epoch >= 4)] ax.plot(s.epoch, (s.mean_ratio - 1) * 1e3, "-o", ms=3, color=style.OKABE_ITO[0], label="zero delay (full mesh), U = 0") r = (u0[(u0.topology == "regular") & (u0.link_latency_mean == 0.1) & (u0.degree == 6) & (u0.replicate == 0) & (u0.epoch >= 4)]) ax.plot(r.epoch, (r.mean_ratio - 1) * 1e3, "-s", ms=3, color=style.OKABE_ITO[1], label="direct gossip, 0.1-slot links, U = 0") sg = sigma_theory(2160) * 1e3 ax.axhspan(-sg, sg, color="0.9", zorder=0) ax.axhline(0.0, color="0.5", lw=0.8) ax.text(119, -sg * 1.45, r"$\pm\sigma_{th} = \sqrt{(1-f)/(fT)}$", fontsize=8, color="0.4", ha="right") ax.set_xlabel("epoch") ax.set_ylabel(r"$(\hat D / D - 1) \times 10^{3}$ (per-mil)") ax.set_title("U = 0, k = 2160: per-epoch sampling noise around the ≤1 equilibrium") ax.legend(fontsize=8) style.save(fig, FIGS / "figB1_fluctuation_trace", provenance="scripts/appendix_fluct.py") plt.close(fig) # ---- figB2: sigma vs k (left), delay progression (middle), sigma vs delay/U (right) ---- fig, (ax1, ax2, ax3) = plt.subplots(1, 3, figsize=(13.6, 4.0)) for i, k in enumerate(KS): s = clean[(clean.k_run == k) & (clean.epoch >= 8)] dev = (s.mean_ratio - 1) * 1e3 ax1.hist(dev, bins=31, density=True, histtype="step", lw=1.4, color=style.OKABE_ITO[i], label=f"k={k}: sd {dev.std()/1e3:.4f} (th {sigma_theory(k):.4f})") ax1.axvline(0, color="0.5", lw=0.8) ax1.set_xlabel(r"$(\hat D / D - 1) \times 10^{3}$") ax1.set_ylabel("density") ax1.set_title(r"noise shrinks as $1/\sqrt{T}$ (window size)") ax1.legend(fontsize=7) rows = [] for lat in (0.1, 0.2, 0.5, 1.0): s = u0[(u0.topology == "regular") & (u0.link_latency_mean == lat) & (u0.epoch >= 15)].mean_ratio rows.append(dict(case=f"gossip {lat}", mean=s.mean(), p_gt1=(s > 1).mean(), lo=s.quantile(0.05), hi=s.quantile(0.95))) b = u0[(u0.topology == "blend") & (u0.epoch >= 15)].mean_ratio rows.append(dict(case="Blend", mean=b.mean(), p_gt1=(b > 1).mean(), lo=b.quantile(0.05), hi=b.quantile(0.95))) dd = pd.DataFrame(rows) x = np.arange(len(dd)) ax2.errorbar(x, dd["mean"], yerr=[dd["mean"] - dd.lo, dd.hi - dd["mean"]], fmt="o", ms=5, capsize=3, color=style.OKABE_ITO[0]) for xi, (_, row) in zip(x, dd.iterrows(), strict=True): ax2.annotate(f"P(>1)={row.p_gt1:.0%}", (xi, row.hi), textcoords="offset points", xytext=(0, 6), ha="center", fontsize=7, color="0.35") ax2.axhline(1.0, color="0.5", lw=0.8, ls=":") ax2.set_xticks(x, dd.case, rotation=20, ha="right", fontsize=8) ax2.set_ylabel(r"$\hat D / D$ (U = 0, k = 2160)") ax2.set_title("orphan loss pulls the mean below 1;\nexcursions above 1 vanish with delay") # right: per-epoch sigma (within a trajectory) vs case, U=0 vs U=1 def per_epoch_sigma(s: pd.DataFrame) -> float: return float(s.groupby(["degree", "replicate"]).mean_ratio.std().mean()) cases: list[tuple[str, pd.DataFrame]] = [] for lat in (0.1, 0.2, 0.5, 1.0): cases.append((f"gossip {lat}", full[(full.topology == "regular") & (full.link_latency_mean == lat) & (full.epoch >= 15)])) for dl in (1.0, 2.0, 3.0): cases.append((f"blend δ={dl:g}", full[(full.topology == "blend") & (full.blend_delay_max == dl) & (full.epoch >= 15)])) x3 = np.arange(len(cases)) for u, marker, lbl in ((0, "o", "U = 0"), (1, "s", "U = 1")): sig = [per_epoch_sigma(s[s.max_uncles == u]) for _, s in cases] ax3.plot(x3, sig, marker, ms=6, ls="-", lw=1.0, color=style.OKABE_ITO[0 if u else 1], label=lbl) ax3.axhline(sigma_theory(2160), color="0.5", lw=0.9, ls="--") ax3.text(0.05, sigma_theory(2160) * 1.15, r"sampling floor $\sigma_{th}$", fontsize=7, color="0.4") ax3.set_yscale("log") ax3.set_xticks(x3, [c for c, _ in cases], rotation=20, ha="right", fontsize=8) ax3.set_ylabel(r"per-epoch $\sigma$ of $\hat D / D$") ax3.set_title("Blend delay amplifies U = 0 noise ~17×;\none uncle restores the floor") ax3.legend(fontsize=8) style.save(fig, FIGS / "figB2_fluctuation_stats", provenance="scripts/appendix_fluct.py") plt.close(fig) # ---- stats for the appendix text ---- print("=== clean zero-delay series ===") for k in KS: s = clean[(clean.k_run == k) & (clean.epoch >= 8)].mean_ratio print(f"k={k}: mean={s.mean():.5f} sd={s.std():.5f} (th {sigma_theory(k):.5f}) " f"P(>1)={(s > 1).mean():.2f} min={s.min():.4f} max={s.max():.4f}") if __name__ == "__main__": if "--run" in sys.argv: run() else: figs()