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

194 lines
8.5 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.

"""Fork depth and private-chain reorg depth vs parameters and adversary stake (fig27, fig28).
Honest fork depth (adversary 0 %) is measured by the engine (runs/fork_rate_vs_delay.parquet:
fork_rate and max_reorg_depth vs Blend delay and uncle cap). The adversarial deepest-reorg tail
comes from src/tsi_sim/reorg.py, coupled to the measured honest orphan rate via alpha_eff.
fig27 — P(reorg depth >= d) vs d, per adversary stake {0, 10, 20, 30 %}, at the recommended
operating point; closed-form tail with Monte-Carlo validation markers.
fig28 — reorg depth vs Blend delay: the honest max depth (engine, U=0 vs U=2) and the
adversarial 99.9-percentile depth per stake — both fall steeply as delay drops
(fewer forks) and as uncles keep the block rate at f.
Run: python scripts/reorg_depth.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
from tsi_sim.reorg import alpha_effective, reorg_depth_tail # noqa: E402
HERE = Path(__file__).resolve().parent.parent
RUNS = HERE / "runs"
FIGS = HERE / "report-figures"
F = 1.0 / 30.0
STAKES = [0.0, 0.1, 0.2, 0.3]
COL = {0.0: "0.5", 0.1: style.OKABE_ITO[0], 0.2: style.OKABE_ITO[1], 0.3: style.OKABE_ITO[2]}
def depth_for_prob(alpha_eff: float, p: float = 1e-3) -> float:
"""Smallest depth d with P(reorg >= d) < p (a practical worst-case reorg to defend against)."""
if alpha_eff <= 0.0:
return 0.0
if alpha_eff >= 0.5:
return float("inf")
r = alpha_eff / (1.0 - alpha_eff)
return float(np.ceil(np.log(p) / np.log(r)))
def fig27(o_ref: float) -> None:
import matplotlib.pyplot as plt
style.apply_style()
rng = np.random.default_rng(20260723)
fig, ax = plt.subplots(figsize=(7.6, 4.4))
ds = np.arange(1, 13)
for alpha in STAKES:
ae = alpha_effective(alpha, o_ref)
if alpha == 0.0:
ax.plot(ds, [0] * len(ds), "-", color=COL[alpha], lw=1.4,
label="0 % (no private chain)")
continue
tail = [reorg_depth_tail(ae, int(d)) for d in ds]
ax.plot(ds, tail, "-", color=COL[alpha], lw=1.6,
label=f"{alpha:.0%} (α_eff={ae:.2f})")
# Monte-Carlo of the stationary catch-up tail: the fraction of time a reflected
# random walk (adversary lead over the public chain, down-drift since ae<1/2) sits
# at least d ahead converges to the closed form r^d — the quantity the lines plot.
walk = rng.random(4_000_000) < ae
lead = 0
occ = np.zeros(len(ds) + 1, dtype=np.int64)
for up in walk:
lead = lead + 1 if up else max(lead - 1, 0)
if lead:
occ[1:min(lead, len(ds)) + 1] += 1
pts = [occ[int(d)] / walk.size for d in ds[:6]]
ax.plot(ds[:6], pts, "s", ms=4, color=COL[alpha], alpha=0.5)
ax.set_yscale("log")
ax.set_ylim(1e-6, 1.5)
ax.set_xlabel("reorg depth d (confirmations reversed)")
ax.set_ylabel(r"$P(\mathrm{reorg\ depth} \geq d)$")
ax.set_title(f"Deepest-reorg tail vs adversary stake (Blend, operating point o≈{o_ref:.2f})\n"
"lines: closed form; squares: Monte-Carlo")
ax.legend(fontsize=8, title="adversary stake")
style.save(fig, FIGS / "fig27_reorg_tail", provenance="scripts/reorg_depth.py")
plt.close(fig)
def fig28(fr: pd.DataFrame) -> None:
import matplotlib.pyplot as plt
style.apply_style()
fig, ax = plt.subplots(figsize=(7.8, 4.4))
# honest measured max reorg depth (engine), U=0 vs U=2
for U, ls, lbl in ((0, ":", "honest, U=0 (overproduces)"), (2, "-", "honest, U=2")):
s = fr[fr.U == U].sort_values("delta")
ax.plot(s.delta, s.max_depth, ls, color="0.4", lw=1.4, marker="o", ms=4, label=lbl)
# adversarial 99.9-pct depth vs delay, per stake, using U=2 honest orphan rate.
# inf (alpha_eff >= 1/2: unbounded) is drawn as an off-top marker with a "∞" callout.
base = fr[fr.U == 2].sort_values("delta")
ax.set_ylim(0, 40)
for alpha in (0.1, 0.2, 0.3):
raw = [depth_for_prob(alpha_effective(alpha, o)) for o in base.fork_rate]
depths = [min(d, 39) for d in raw]
ax.plot(base.delta, depths, "-", color=COL[alpha], lw=1.6, marker="s", ms=4,
label=f"adversary {alpha:.0%} (99.9-pct)")
for x, r in zip(base.delta, raw, strict=True):
if np.isinf(r):
ax.annotate("∞ (unbounded)", (x, 39), color=COL[alpha], fontsize=7,
ha="center", va="top")
ax.axvline(16.8, color="0.8", lw=0.8, ls="--")
ax.text(15.8, 37, "ρ≈1 (δ≈17 s)", fontsize=7, color="0.5", ha="right")
ax.set_xlabel("Blend per-hop blending budget δ (s) [more delay → more forks]")
ax.set_ylabel("reorg depth (blocks)")
ax.set_title("Reorg depth grows with delay and adversary stake — "
"shrunk by uncles (block rate at f) and by ρ<1")
ax.legend(fontsize=8, ncols=2)
style.save(fig, FIGS / "fig28_reorg_depth_vs_delay", provenance="scripts/reorg_depth.py")
plt.close(fig)
def measure_fork_rate() -> pd.DataFrame:
"""Honest fork rate + max reorg depth vs Blend delay and uncle cap (engine, adversary 0 %)."""
from tsi_sim.config import SimConfig
from tsi_sim.engine import run_trajectory
rows = []
for delta in (2.0, 4.0, 8.0, 16.0, 32.0):
for u in (0, 2):
frs, mds = [], []
for rep in range(3):
df = pd.DataFrame(run_trajectory(SimConfig(
n_nodes=1000, stake_dist="pareto", topology="blend", degree=6,
link_latency_mean=0.5, link_latency_dist="geo", blend_hops=3,
blend_delay_max=delta, max_uncles=u, uncle_window=300, k=256,
epochs=16, genesis_d_factor=0.5, early_stop=True, replicate=rep)))
t = df[df.epoch >= 6]
frs.append(t.fork_rate.mean())
mds.append(t.max_reorg_depth.max())
rows.append(dict(delta=delta, U=u, fork_rate=float(np.mean(frs)),
max_depth=int(np.max(mds))))
out = pd.DataFrame(rows)
out.to_parquet(RUNS / "fork_rate_vs_delay.parquet")
return out
def measure_fork_rate_scale() -> pd.DataFrame:
"""Honest fork rate vs N and peering degree at the recommended budget (U=2, δ=8 s).
Widens the reorg study (§6.10): since a bigger/sparser network raises the honest fork rate,
and the adversary's effective share depends on it, reorg depth should be shown vs N/degree.
"""
from tsi_sim.config import SimConfig
from tsi_sim.engine import run_trajectory
rows = []
for n in (1000, 4000, 16000):
for deg in (4, 6, 8):
frs = []
for rep in range(3):
df = pd.DataFrame(run_trajectory(SimConfig(
n_nodes=n, stake_dist="pareto", topology="blend", degree=deg,
link_latency_mean=0.5, link_latency_dist="geo", blend_hops=3,
blend_delay_max=8.0, max_uncles=2, uncle_window=300, k=256,
epochs=16, genesis_d_factor=0.5, early_stop=True, replicate=rep)))
frs.append(df[df.epoch >= 6].fork_rate.mean())
o = float(np.mean(frs))
row = dict(n=n, degree=deg, fork_rate=o)
for alpha in (0.1, 0.2, 0.3):
row[f"d999_{int(alpha * 100)}"] = depth_for_prob(alpha_effective(alpha, o))
rows.append(row)
out = pd.DataFrame(rows)
out.to_parquet(RUNS / "fork_rate_vs_scale.parquet")
print(out.round(3).to_string(index=False))
return out
def main() -> None:
if "--measure" in sys.argv:
print(measure_fork_rate().to_string(index=False))
return
if "--measure-scale" in sys.argv:
measure_fork_rate_scale()
return
fr = pd.read_parquet(RUNS / "fork_rate_vs_delay.parquet")
o_ref = float(fr[(fr.U == 2) & (fr.delta == 8.0)].fork_rate.iloc[0])
fig27(o_ref)
fig28(fr)
print("=== reorg-depth summary (U=2 operating points) ===")
for _, row in fr[fr.U == 2].sort_values("delta").iterrows():
line = f"δ={row.delta:4.0f}s honest o={row.fork_rate:.2f} max_depth={row.max_depth}"
for alpha in (0.1, 0.2, 0.3):
dd = depth_for_prob(alpha_effective(alpha, row.fork_rate))
line += f" | {alpha:.0%}: d99.9={dd:.0f}"
print(line)
print("wrote fig27_reorg_tail, fig28_reorg_depth_vs_delay")
if __name__ == "__main__":
main()