mirror of
https://github.com/logos-blockchain/research.git
synced 2026-08-07 19:53:10 +00:00
194 lines
8.5 KiB
Python
194 lines
8.5 KiB
Python
|
|
"""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()
|