mirror of
https://github.com/logos-blockchain/research.git
synced 2026-08-07 11:43:20 +00:00
The unpaired comparison could not answer the question it was asked. The two uncle models draw independent RNG streams -- uncle_model is in the config key, which is what makes --old bit-reproduce earlier runs -- so the arms differed in stake draw, peering graph and every lottery outcome, each comparison paid the between-run variance twice, and the per-cell floor (+-0.0015) sat an order of magnitude above the effect. Only delta_max = 5 resolved, and only after pooling. Adds `paired_streams`: the RNG root is derived from the model- independent part of the key, so a countable cell and its --old twin get the SAME stake, graph and lottery draws and the uncle rule is the only difference. Each replicate is then a matched pair and the shared variance cancels. Trajectories still diverge after epoch 0 through the genuine feedback (a different counted density changes the next epoch's difficulty), which is the signal. The flag is deliberately NOT in key(): it selects which key the seed is derived from, so including it would perturb every historical seed. Re-verified that --old still bit-reproduces the committed 2026-07-27 rho-boundary parquet, max |delta| = 0. Results (configs/fine-delay-paired.yaml, 40 replicates per arm): - Negative control becomes an IDENTITY check. With U = 0 no reference is taken, so shared streams must give bit-identical trajectories. All 200 replicate pairs differ by exactly 0.0. Unpaired, the same control only had to agree within +-0.025 and drifted by 0.016. - Per-cell SE shrinks by a median 1.6x (1.2-2.1x); widest 95% CI goes +-0.0015 -> +-0.0010. 5/15 cells resolve at |t| >= 2 (0.75 expected by chance); the largest, U=2 at delta_max=4, is t = 4.32 and clears Bonferroni for 15 tests. - The cost is a STEP, not the ramp the unpaired data suggested: delta_max 1-3 unresolved (t = 1.1, 1.8, 1.4), then delta_max 4 AND 5 both resolve at -0.0011 (t = 4.7) and -0.0009 (t = 3.7). Whole-band pooled -0.00060 +- 0.00021, t = 5.7 -- where the unpaired estimate of the same quantity (t = 2.8) had failed correction. So the first-fork restriction costs nothing measurable up to delta_max = 3 and about 0.1% at 4-5 -- an order of magnitude below the +-0.9% per-epoch sampling noise. Two bugs found while building this, both of which would have silently produced a wrong answer: - paired_streams was missing from metrics._CONFIG_FIELDS, so it never reached the parquet; plot_fine_delay.py falls back to the unpaired test when it cannot confirm pairing, so the sweep would have completed and quietly reported the old result. Caught before the run finished; the sweep was restarted and a test now pins the field. - The U=0 control check reported FAILS on a PERFECT control: paired, the gap is exactly 0 so its SE is 0 and t is 0/0. It now checks the gap itself when the streams are shared, and falls back to the t-test only when there is real spread. §3.2a is rewritten around the paired measurement; the unpaired sweep is retained in §9 as the power comparison that motivated it. Figures 34-35 regenerated, with the control annotation and provenance reflecting the design actually used. Tests: 214 passed (was 209). ruff clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
298 lines
15 KiB
Python
298 lines
15 KiB
Python
"""High-precision figures for the LOW mixing-delay band (the design regime).
|
||
|
||
countable-vs-old.yaml samples delay at 4/8/16/32 with 5 replicates. That resolves the
|
||
overload regime but leaves the design regime under-measured: every countable-vs-unrestricted
|
||
gap at delay <= 8 sits inside the replicate noise there, so the only honest statement is
|
||
"no difference detected" — with no bound on how large an undetected difference could be.
|
||
|
||
fine-delay.yaml spends replicates instead of range (delay 1..5, 40 replicates) to turn that
|
||
into a real bound. Consumes:
|
||
|
||
tsi-sweep --config configs/fine-delay.yaml --label fine-countable
|
||
tsi-sweep --config configs/fine-delay.yaml --old --label fine-old
|
||
|
||
and renders (into --out):
|
||
|
||
fine_accuracy_vs_delay equilibrium D/D_true vs delay 1..5, countable (solid) vs
|
||
unrestricted (dashed) per U, replicate-SEM bars.
|
||
fine_gap_vs_delay THE precision figure: the countable - unrestricted gap with 95%
|
||
CIs, against the U=0 negative-control band. A CI straddling zero
|
||
means no difference at this power; the band shows the floor.
|
||
|
||
Usage:
|
||
python scripts/plot_fine_delay.py --countable RUNDIR --old RUNDIR \
|
||
--out figures/fine-delay
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
from pathlib import Path
|
||
|
||
import matplotlib.pyplot as plt
|
||
import numpy as np
|
||
import pandas as pd
|
||
|
||
from tsi_sim.plotting import style
|
||
from tsi_sim.plotting.figures_pernode import equilibrium, rho_for, sem
|
||
|
||
DELAY = "blend_delay_max"
|
||
# Normal approximation: with 40 replicates per arm the t-quantile is within ~2% of 1.96,
|
||
# and the replicate spread itself is the dominant uncertainty, so 1.96 is precise enough.
|
||
Z95 = 1.96
|
||
|
||
|
||
def _load(run_dir: str | Path) -> pd.DataFrame:
|
||
return pd.read_parquet(Path(run_dir) / "results.parquet")
|
||
|
||
|
||
def paired_gaps(cnt_raw: pd.DataFrame, old_raw: pd.DataFrame) -> pd.DataFrame | None:
|
||
"""Per-replicate differences, when both arms were run with ``paired_streams``.
|
||
|
||
Under common random numbers replicate *i* of each arm shares the stake draw, the peering
|
||
graph and the lottery outcomes, so ``d_i = countable_i - unrestricted_i`` is a PAIRED
|
||
observation and the shared variance cancels. The test is then a one-sample t on the d_i,
|
||
which is what makes a sub-0.1 % effect reachable per cell instead of only after pooling.
|
||
|
||
Returns None when the runs are not paired, so the caller falls back to the unpaired test.
|
||
"""
|
||
if not (cnt_raw.get("paired_streams", pd.Series([False])).all()
|
||
and old_raw.get("paired_streams", pd.Series([False])).all()):
|
||
return None
|
||
c, o = equilibrium(cnt_raw), equilibrium(old_raw)
|
||
keys = [DELAY, "max_uncles", "replicate"]
|
||
m = c[[*keys, "mean_ratio"]].merge(o[[*keys, "mean_ratio"]], on=keys,
|
||
suffixes=("_c", "_o"))
|
||
m["d"] = m.mean_ratio_c - m.mean_ratio_o
|
||
rows = []
|
||
for (dl, u), s in m.groupby([DELAY, "max_uncles"]):
|
||
d = s.d.to_numpy()
|
||
se = sem(d)
|
||
rows.append({DELAY: dl, "max_uncles": u, "gap": float(d.mean()), "se": se,
|
||
"ci95": Z95 * se, "t": abs(d.mean()) / se if se > 0 else np.inf,
|
||
"n_pair": len(d), "n_zero": int((d == 0.0).sum())})
|
||
return pd.DataFrame(rows).sort_values(["max_uncles", DELAY])
|
||
|
||
|
||
def _cells(df: pd.DataFrame) -> pd.DataFrame:
|
||
"""Per (delay, U): replicate mean, SEM and count of the equilibrium accuracy."""
|
||
return equilibrium(df).groupby([DELAY, "max_uncles"], as_index=False).agg(
|
||
mean_ratio=("mean_ratio", "mean"),
|
||
sem_ratio=("mean_ratio", sem),
|
||
n_rep=("mean_ratio", "size"),
|
||
mean_q=("mean_q", "mean"),
|
||
mean_q_eff=("mean_q_eff", "mean"))
|
||
|
||
|
||
def vs_one(cells: pd.DataFrame) -> pd.DataFrame:
|
||
"""Test each U >= 1 cell against the exact target 1.0.
|
||
|
||
An independent read on the same question the gap test asks: the report's claim is that
|
||
uncle recovery restores the equilibrium to EXACTLY the true stake, so a systematic
|
||
shortfall across uncle caps is the first-fork cost seen from the absolute side rather
|
||
than differentially. 40 replicates give ~0.0005 resolution, enough to see 0.1%.
|
||
"""
|
||
u = cells[cells.max_uncles > 0].copy()
|
||
u["dev"] = u.mean_ratio - 1.0
|
||
u["t"] = u.dev / u.sem_ratio.replace(0, np.nan)
|
||
return u.sort_values(["max_uncles", DELAY])
|
||
|
||
|
||
def gaps(cnt: pd.DataFrame, old: pd.DataFrame) -> pd.DataFrame:
|
||
"""countable - unrestricted per cell, with the unpaired SE and 95% CI half-width."""
|
||
m = cnt.merge(old, on=[DELAY, "max_uncles"], suffixes=("_c", "_o"))
|
||
m["gap"] = m.mean_ratio_c - m.mean_ratio_o
|
||
m["se"] = np.hypot(m.sem_ratio_c, m.sem_ratio_o)
|
||
m["ci95"] = Z95 * m.se
|
||
m["t"] = np.where(m.se > 0, np.abs(m.gap) / m.se.replace(0, np.nan), np.inf)
|
||
return m.sort_values(["max_uncles", DELAY])
|
||
|
||
|
||
def fig_accuracy(cnt: pd.DataFrame, old: pd.DataFrame) -> plt.Figure:
|
||
fig, ax = plt.subplots(figsize=(6.4, 4.2))
|
||
for i, u in enumerate(sorted(cnt["max_uncles"].unique())):
|
||
c = style.color_for(i)
|
||
a = cnt[cnt.max_uncles == u].sort_values(DELAY)
|
||
b = old[old.max_uncles == u].sort_values(DELAY)
|
||
ctl = " (control)" if u == 0 else ""
|
||
ax.errorbar(a[DELAY], a.mean_ratio, yerr=a.sem_ratio, fmt="-o", color=c,
|
||
label=f"U={u} countable{ctl}", ms=4, capsize=2, lw=1.2)
|
||
ax.errorbar(b[DELAY], b.mean_ratio, yerr=b.sem_ratio, fmt="--s", color=c,
|
||
label=f"U={u} unrestricted{ctl}", ms=4, capsize=2, alpha=0.75, lw=1.2)
|
||
ax.axhline(1.0, color="0.4", lw=0.8, ls=":")
|
||
ax.set_xlabel("max per-relay mixing delay (slots)")
|
||
ax.set_ylabel(r"equilibrium $\hat{D}/D_{true}$")
|
||
ax.set_title("Design-regime accuracy: countable vs unrestricted referencing")
|
||
ax.legend(ncol=2, fontsize="x-small")
|
||
return fig
|
||
|
||
|
||
def pooled_by_delay(g: pd.DataFrame) -> pd.DataFrame:
|
||
"""Inverse-variance pooled gap across the U >= 1 arms, per delay.
|
||
|
||
Individual cells are underpowered against a sub-0.1 % effect even at 40 replicates, but
|
||
the three uncle caps are independent measurements of the same underlying difference, so
|
||
pooling them buys back a factor of ~sqrt(3) and is what actually resolves the trend.
|
||
"""
|
||
u = g[g.max_uncles > 0]
|
||
rows = []
|
||
for d, s in u.groupby(DELAY):
|
||
w = 1.0 / s.se.to_numpy() ** 2
|
||
p = float((s.gap.to_numpy() * w).sum() / w.sum())
|
||
e = float(np.sqrt(1.0 / w.sum()))
|
||
rows.append({DELAY: d, "gap": p, "se": e, "ci95": Z95 * e,
|
||
"t": abs(p) / e if e > 0 else np.inf})
|
||
return pd.DataFrame(rows).sort_values(DELAY)
|
||
|
||
|
||
def fig_gap(g: pd.DataFrame) -> plt.Figure:
|
||
"""The countable − unrestricted gap with 95% CIs, zoomed to the U >= 1 scale.
|
||
|
||
The U=0 negative control is NOT plotted as a band here: with no uncles the models are
|
||
identical by construction, but the unrecovered regime is so noisy that its CI (±0.025)
|
||
is ~17x the entire range of the U >= 1 gaps and would fill the axes. Its magnitude is
|
||
annotated instead — the point being that the control's noise floor lives far outside
|
||
anything the uncle arms show, so those arms are measuring signal, not spread.
|
||
"""
|
||
fig, ax = plt.subplots(figsize=(6.6, 4.2))
|
||
pooled = pooled_by_delay(g)
|
||
for i, u in enumerate(sorted(g["max_uncles"].unique())):
|
||
if u == 0:
|
||
continue
|
||
a = g[g.max_uncles == u].sort_values(DELAY)
|
||
ax.errorbar(a[DELAY], a.gap, yerr=a.ci95, fmt="-o", color=style.color_for(i),
|
||
label=f"U={u}", ms=4, capsize=3, lw=1.0, alpha=0.75)
|
||
ax.errorbar(pooled[DELAY], pooled.gap, yerr=pooled.ci95, fmt="-D", color="0.15",
|
||
label="pooled over U≥1", ms=5, capsize=4, lw=1.8, zorder=5)
|
||
ax.axhline(0.0, color="0.3", lw=0.9, ls=":")
|
||
ax.set_xlabel("max per-relay mixing delay (slots)")
|
||
ax.set_ylabel(r"$\hat{D}/D$ gap: countable $-$ unrestricted")
|
||
ax.set_title("The first-fork cost across the design band (95% CI)")
|
||
ctl = g[g.max_uncles == 0]
|
||
if len(ctl):
|
||
band = float(ctl.ci95.max())
|
||
span = float(np.abs(np.r_[g[g.max_uncles > 0].gap + g[g.max_uncles > 0].ci95,
|
||
g[g.max_uncles > 0].gap - g[g.max_uncles > 0].ci95]).max())
|
||
ax.set_ylim(-1.35 * span, 1.35 * span)
|
||
# Under pairing the control is exactly 0 in every replicate pair (shared streams), so
|
||
# quoting a CI for it is meaningless; unpaired, the width of that CI is the point.
|
||
note = ("U=0 negative control: exactly 0.0 in all 200 replicate pairs "
|
||
"(shared streams — an identity check, not a noise check)"
|
||
if band == 0.0 else
|
||
f"U=0 negative control (true gap = 0): 95% CI ±{band:.4f}, "
|
||
f"{band / span:.0f}× outside this range")
|
||
ax.text(0.015, 0.03, note, transform=ax.transAxes, fontsize=6.5, alpha=0.75)
|
||
ax.legend(fontsize="x-small", ncol=2)
|
||
return fig
|
||
|
||
|
||
def main() -> None:
|
||
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||
ap.add_argument("--countable", required=True, help="run dir of fine-countable")
|
||
ap.add_argument("--old", required=True, help="run dir of fine-old")
|
||
ap.add_argument("--out", default="figures/fine-delay")
|
||
args = ap.parse_args()
|
||
style.apply_style()
|
||
out = Path(args.out)
|
||
out.mkdir(parents=True, exist_ok=True)
|
||
|
||
cnt_raw, old_raw = _load(args.countable), _load(args.old)
|
||
cnt, old = _cells(cnt_raw), _cells(old_raw)
|
||
unpaired = gaps(cnt, old)
|
||
pg = paired_gaps(cnt_raw, old_raw)
|
||
# The paired test supersedes the unpaired one when both arms share their streams: same
|
||
# estimand, far smaller standard error. Keep the unpaired numbers for the variance-
|
||
# reduction report below.
|
||
g = unpaired if pg is None else unpaired.drop(columns=["gap", "se", "ci95", "t"]).merge(
|
||
pg[[DELAY, "max_uncles", "gap", "se", "ci95", "t"]], on=[DELAY, "max_uncles"])
|
||
prov = ("tsi-sim-pernode fine-delay%s.yaml (+--old)"
|
||
% ("-paired" if pg is not None else ""))
|
||
|
||
# rho per delay, derived (never hand-substituted) — these are the report's axis labels.
|
||
delays = sorted(cnt[DELAY].unique())
|
||
print("load rho = f*D_vis per delay (measured ell_mean, see figures_pernode.rho_for):")
|
||
print(" " + " ".join(f"delay={d:g}: rho={r:.3f}"
|
||
for d, r in zip(delays, rho_for(cnt_raw, delays), strict=True)))
|
||
print()
|
||
|
||
written = []
|
||
written += style.save(fig_accuracy(cnt, old), out / "fine_accuracy_vs_delay", prov)
|
||
written += style.save(fig_gap(g), out / "fine_gap_vs_delay", prov)
|
||
|
||
print(f"{'cell':<16} {'countable':>17} {'unrestricted':>17} "
|
||
f"{'gap':>9} {'95% CI':>9} {'t':>6} verdict")
|
||
for _, r in g.iterrows():
|
||
verdict = ("CONTROL (true gap = 0)" if r.max_uncles == 0 else
|
||
"resolved" if r.t >= 2 else "no difference resolved")
|
||
print(f"U={int(r.max_uncles)} delay={r[DELAY]:>5g} "
|
||
f"{r.mean_ratio_c:.4f}+-{r.sem_ratio_c:.4f} "
|
||
f"{r.mean_ratio_o:.4f}+-{r.sem_ratio_o:.4f} "
|
||
f"{r.gap:+.4f} +-{r.ci95:.4f} {r.t:6.2f} {verdict}")
|
||
worst = g[g.max_uncles > 0]
|
||
print(f"\nn_rep = {int(g.n_rep_c.min())}/{int(g.n_rep_o.min())} per arm")
|
||
print(f"widest 95% CI half-width at U>=1: +-{worst.ci95.max():.4f} "
|
||
f"({100 * worst.ci95.max():.2f} pp)")
|
||
# Per-cell significance must be read against the number of cells tested: with 15 cells,
|
||
# ~0.75 are expected to clear t=2 by chance alone, so quote the Bonferroni threshold.
|
||
bonf = 2.935 if len(worst) == 15 else float("nan")
|
||
print(f"per-cell: {int((worst.t >= 2).sum())}/{len(worst)} cells with t>=2 "
|
||
f"(expected by chance {0.05 * len(worst):.2f}); max t = {worst.t.max():.2f} "
|
||
f"vs Bonferroni threshold {bonf:.3f}")
|
||
|
||
print("\npooled over U>=1 (the three caps measure the same difference):")
|
||
pooled = pooled_by_delay(g)
|
||
for _, r in pooled.iterrows():
|
||
mark = " <-- resolved" if r.t >= 2 else ""
|
||
print(f" delay={r[DELAY]:>4g}: {r.gap:+.5f} +-{r.ci95:.5f} t={r.t:5.2f}{mark}")
|
||
w = 1.0 / worst.se.to_numpy() ** 2
|
||
allp = float((worst.gap.to_numpy() * w).sum() / w.sum())
|
||
alle = float(np.sqrt(1.0 / w.sum()))
|
||
print(f" whole band: {allp:+.5f} +-{Z95 * alle:.5f} t={abs(allp) / alle:.2f}")
|
||
|
||
if pg is not None:
|
||
print("\n=== PAIRED design (common random numbers) ===")
|
||
# Variance reduction actually achieved, per cell, vs the unpaired standard error.
|
||
cmp = unpaired[[DELAY, "max_uncles", "se"]].merge(
|
||
pg[[DELAY, "max_uncles", "se", "n_pair"]], on=[DELAY, "max_uncles"],
|
||
suffixes=("_unpaired", "_paired"))
|
||
u = cmp[cmp.max_uncles > 0]
|
||
ratio = (u.se_unpaired / u.se_paired.replace(0, np.nan))
|
||
print(f" SE shrink at U>=1: median {ratio.median():.1f}x, range "
|
||
f"{ratio.min():.1f}-{ratio.max():.1f}x ({int(u.n_pair.min())} pairs/cell)")
|
||
pctl = pg[pg.max_uncles == 0]
|
||
exact, tot = int(pctl.n_zero.sum()), int(pctl.n_pair.sum())
|
||
print(f" U=0 control under pairing must be EXACTLY zero: {exact}/{tot} pairs are 0.0"
|
||
f" -> {'PASSES' if exact == tot else 'FAILS — streams are not shared'}")
|
||
res = pg[(pg.max_uncles > 0) & (pg.t >= 2)]
|
||
print(f" per-cell resolved at |t|>=2: {len(res)}/{len(pg[pg.max_uncles>0])}"
|
||
f" (unpaired: {int((unpaired[unpaired.max_uncles>0].t>=2).sum())}/15)")
|
||
|
||
ctl = g[g.max_uncles == 0]
|
||
if len(ctl):
|
||
# Under pairing the control gap is EXACTLY 0, so its se is 0 and t is 0/0. That is the
|
||
# ideal outcome, not a failure — check the gap itself, and only fall back to the
|
||
# t-based check when there is real spread to test (the unpaired case).
|
||
worst = float(ctl.gap.abs().max())
|
||
exact = bool((ctl.se == 0).all()) if "se" in ctl else False
|
||
ok = (worst == 0.0) if exact else (float(ctl.t.max()) < 2)
|
||
how = "identical by construction" if exact else "within noise"
|
||
print(f"\nU=0 negative control (true gap = 0): |gap| up to {worst:.4g} ({how})"
|
||
f" -> control {'PASSES' if ok else 'FAILS'}")
|
||
|
||
# Absolute test: does uncle recovery actually land on 1.0? Same question as the gap
|
||
# test, asked without reference to the other model.
|
||
for lbl, cells in (("countable", cnt), ("unrestricted", old)):
|
||
v = vs_one(cells)
|
||
lo = v[v.t <= -2]
|
||
print(f"\nvs exact 1.0, {lbl}: {len(lo)}/{len(v)} cells significantly BELOW 1")
|
||
if len(lo):
|
||
for d, s in lo.groupby(DELAY):
|
||
caps = "/".join(f"U={int(x)}" for x in sorted(s.max_uncles))
|
||
print(f" delay={d:>4g}: {caps} dev {s.dev.min():+.5f}..{s.dev.max():+.5f} "
|
||
f"t {s.t.min():.2f}..{s.t.max():.2f}")
|
||
print(f"wrote {len(written)} files -> {out}")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|