Marcin Pawlowski 15e876e90a
Correctness pass: derive rho in code, and the absolute vs-1.0 test
Two findings from re-reviewing the fine-delay section.

1. The rho values I put in s3.2a were wrong. The report derives
   rho = f*D_vis with D_vis = hops*delta_max/2 + (hops+1)*ell_mean from
   a MEASURED ell_mean (1.211 slots at N=1000/degree=6), not from the
   link_latency_mean parameter (0.5). Hand-substituting a guessed 1.5
   inflated every value by ~0.04: the band is rho 0.21-0.41, not
   0.25-0.45.

   To stop that recurring, graph_ell_mean moves out of
   rho_boundary_analysis.py into figures_pernode.py, joined by a new
   rho_for() that both scripts and any future quotation go through;
   plot_fine_delay.py now prints the derived rho per delay.

   This also exposed an inconsistency in the existing s3.2 table, which
   rounded delta_max=4 to "rho ~ 0.4" while s3.2a called the same cell
   0.36 and prose elsewhere already used 0.56 for delta_max=8. The s3.2
   column now carries the derived values (0.36/0.56/0.96/1.76).

2. Testing each cell against the exact target 1.0 -- the same question
   the gap test asks, without reference to the other model --
   corroborates the first-fork onset independently. Unrestricted: 1/15
   cells below 1 (t=-2.09, chance). Countable: 4/15, and not scattered
   -- delta_max=4 at U=1, and ALL THREE caps at delta_max=5 (-0.0012 to
   -0.0019, t=-2.5..-3.7). A shortfall appearing at every cap at once,
   only at the top of the band, only under the restricted model, is the
   first-fork cost seen absolutely.

   That makes "one uncle slot is sufficient -- not approximately,
   exactly" too strong as I had written it. s3.2a now states the
   residual (0.1-0.2% at the top of the band, zero below delta_max=3),
   reconciles it with the s1 headline, and notes that since all three
   caps show the same shortfall the residual is not a capacity limit.
   The bound quoted in s1 moves from "below 0.15%" to "<= 0.2%".

Also adds the new run directories to s9's canonical list, which covered
every other study but not these.

Tests: 209 passed. ruff clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 10:34:33 +02:00

235 lines
11 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.

"""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 _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)
ax.text(0.015, 0.03,
f"U=0 negative control (true gap = 0): 95% CI ±{band:.4f}, "
f"{band / span:.0f}× outside this range",
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 = _load(args.countable)
cnt, old = _cells(cnt_raw), _cells(_load(args.old))
g = gaps(cnt, old)
prov = "tsi-sim-pernode fine-delay.yaml (+--old)"
# 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}")
ctl = g[g.max_uncles == 0]
if len(ctl):
print(f"\nU=0 negative control (true gap = 0): |gap| up to {ctl.gap.abs().max():.4f}, "
f"max t = {ctl.t.max():.2f}, 95% CI +-{ctl.ci95.max():.4f} "
f"-> control {'PASSES' if ctl.t.max() < 2 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()