198 lines
9.1 KiB
Python
Raw Normal View History

Countable recovery under a selfish adversary: SM1 hides the first-fork cost The countable model can reference only the first block of a fork, so a discarded chain of h honest blocks yields one countable uncle, not h. Sec 6.6 reads the estimator repair off a free knob eta and quotes it at eta = 1 -- attainable under SM1, which acts the moment the honest branch reaches length 1 and so never buries a second block. The optimal SSZ policy waits and does bury them, and there the deployed counting rules cap eta at 0.44 (alpha = 0.4, gamma = 0), landing D-hat at 0.81 rather than the 0.94 an unrestricted count gives -- and the ceiling degrades with alpha while the unrestricted value improves. So SM1 is a faithful proxy for selfish-mining revenue (0.484 vs 0.489) but not for TSI's estimator damage. selfish_mdp: carry per-branch orphan counts on the transition table so the accounting cannot drift from the race logic; optimal_policy_stats solves the policy's stationary distribution for per-event canonical/orphan rates. The per-event rates sum to 1 (every block is canonical or orphaned), which the tests assert as an independent check on the whole derivation. reorg: the same ceiling for the depth-maximising adversary -- 0.52 at alpha = 0.30 with the measured honest fork rate -- reached from the other direction. Neither adversary optimises deflation directly, so both ceilings are upper bounds on eta; that gap is logged as open item 16. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 15:29:57 +02:00
"""What the first-fork restriction costs against a selfish adversary — REPORT §6.6 (fig36).
§6.6 reads the estimator repair off a free knob: the uncle-recovery fraction ``eta``, quoted at
``eta = 1`` ("honest-orphan recovery"). The countable uncle model (§2.1) can reference only the
**first block of a fork**, so ``eta`` is not free it is capped by how the adversary *shapes* the
blocks it orphans:
* **SM1** acts the moment the honest branch reaches length 1 (match at a 1-lead, override at a
2-lead, publish-one above it), so it never buries a second block behind the first. Every orphan
it makes is the first block of its fork and the cap is exactly 1 the restriction is free.
* The **optimal** (SapirshteinSompolinskyZohar) policy *waits*, then overrides a run of ``h``
honest blocks at once. That run is one chain, so the deployed rules recover **one** uncle from
it, not ``h``, and the cap falls to ~0.44 at ``alpha = 0.4``.
So SM1 is a faithful proxy for selfish-mining *revenue* (§6.6 quotes 0.484 vs the optimum's 0.488
at gamma = 0) but **not** for TSI's estimator damage: the two differ by a factor of ~2 in
recoverable orphans. The panel on the right is the consequence the repair §6.6 credits to uncle
counting is roughly half of what the unrestricted model shows, and it *degrades* with alpha where
the unrestricted model improves.
Two panels (fig36):
LEFT the recovery ceiling ``eta_countable`` vs stake, per gamma, against SM1's flat 1.0.
RIGHT the resulting ``D_hat/D*`` at gamma = 0: no uncles, countable (p_ref = 1 and 0.85), and
the unrestricted baseline §6.6 reports.
Run: python scripts/countable_selfish.py (writes runs/countable_selfish.parquet + fig36)
"""
from __future__ import annotations
from pathlib import Path
import numpy as np
import pandas as pd
from tsi_sim.plotting import style
from tsi_sim.reorg import (
alpha_effective,
countable_recovery_from_depths,
simulate_deepest_reorg,
)
from tsi_sim.selfish import race_from_alpha, selfish_threshold, tsi_dhat_ratio
from tsi_sim.selfish_mdp import optimal_policy_stats
HERE = Path(__file__).resolve().parent.parent
RUNS = HERE / "runs"
FIGS = HERE / "report-figures"
RUNS.mkdir(exist_ok=True)
FIGS.mkdir(exist_ok=True)
# cap 64 keeps the orphan *shape* converged (it settles more slowly than the revenue: the drift
# from cap 48 to 64 is ~3e-4 in eta at alpha = 0.4, ~5e-3 at alpha = 0.45).
CAP = 64
GAMMAS = [0.0, 0.5]
ALPHAS = [0.26, 0.28, 0.30, 0.34, 0.36, 0.38, 0.40, 0.42, 0.44, 0.46]
P_REF_REALISTIC = 0.85 # the §6.8 stand-in for the emergent honest-referencer rate
N_EVENTS = 4_000_000 # SM1 comparison arm
def sweep() -> pd.DataFrame:
rng = np.random.default_rng(20260805)
rows = []
for gamma in GAMMAS:
for alpha in ALPHAS:
s = optimal_policy_stats(alpha, gamma, cap=CAP)
sm1 = race_from_alpha(alpha, N_EVENTS, gamma, rng)
rows.append(dict(
alpha=alpha, gamma=gamma, cap=CAP,
above_threshold=alpha > selfish_threshold(gamma),
deviates=s.deviates,
revenue_opt=s.revenue,
revenue_sm1=sm1.revenue_share,
density_fraction=s.density_fraction,
orphan_hon_blocks=s.orphan_hon_blocks,
orphan_hon_runs=s.orphan_hon_runs,
eta_countable=s.countable_recovery if s.deviates else np.nan,
eta_countable_adv=s.countable_recovery_adv if s.deviates else np.nan,
# the estimator, as §6.6 reports it (unrestricted) and as deployed (countable)
dhat_u0=s.dhat_ratio(p_ref=0.0),
dhat_unrestricted=s.dhat_ratio(p_ref=1.0, countable=False),
dhat_countable=s.dhat_ratio(p_ref=1.0, countable=True),
dhat_countable_pref=s.dhat_ratio(p_ref=P_REF_REALISTIC, countable=True),
# SM1's own repair, for contrast: eta = 1 is attainable there
dhat_sm1_eta1=tsi_dhat_ratio(sm1, 1.0),
eta_sm1=sm1.countable_recovery,
))
out = pd.DataFrame(rows)
out.to_parquet(RUNS / "countable_selfish.parquet")
return out
def report(df: pd.DataFrame) -> None:
print(f"{'gamma':>5} {'alpha':>6} {'rev_opt':>8} {'rev_SM1':>8} {'eta_cnt':>8} {'eta_SM1':>8} "
f"{'no unc':>7} {'unrestr':>8} {'count':>7} {'cnt@.85':>8}")
for _, r in df[df.deviates].iterrows():
print(f"{r.gamma:5.1f} {r.alpha:6.2f} {r.revenue_opt:8.4f} {r.revenue_sm1:8.4f} "
f"{r.eta_countable:8.4f} {r.eta_sm1:8.4f} {r.dhat_u0:7.4f} "
f"{r.dhat_unrestricted:8.4f} {r.dhat_countable:7.4f} {r.dhat_countable_pref:8.4f}")
hit = df[(df.gamma == 0.0) & np.isclose(df.alpha, 0.40)]
if not hit.empty:
r = hit.iloc[0]
print(f"\nHeadline (alpha=0.40, gamma=0): revenue {r.revenue_sm1:.3f} (SM1) vs "
f"{r.revenue_opt:.3f} (optimal) — a faithful proxy;")
print(f" but eta 1.000 (SM1) vs {r.eta_countable:.3f} (optimal) — not a faithful proxy, "
f"and D_hat {r.dhat_unrestricted:.3f} -> {r.dhat_countable:.3f}.")
def fig36(df: pd.DataFrame) -> None:
import matplotlib.pyplot as plt
style.apply_style()
fig, axes = plt.subplots(1, 2, figsize=(9.6, 3.8))
# LEFT: the recovery ceiling, optimal policy vs SM1
ax = axes[0]
ax.axhline(1.0, color="0.5", lw=1.1, ls="--",
label="SM1 — every orphan countable")
for i, gamma in enumerate(GAMMAS):
g = df[(df.gamma == gamma) & df.deviates].sort_values("alpha")
ax.plot(g.alpha, g.eta_countable, "-o", ms=4, color=style.OKABE_ITO[i + 1],
label=rf"optimal policy, $\gamma={gamma}$")
thr = selfish_threshold(gamma)
ax.axvline(thr, color=style.OKABE_ITO[i + 1], lw=0.7, ls=":")
ax.set_ylim(0, 1.08)
ax.set_xlabel(r"adversary stake $\alpha$")
ax.set_ylabel(r"countable recovery ceiling $\eta$")
ax.set_title("The optimum buries orphans SM1 leaves reachable")
ax.legend(fontsize=7, loc="lower left")
# RIGHT: the estimator consequence at gamma = 0
ax = axes[1]
g0 = df[(df.gamma == 0.0) & df.deviates].sort_values("alpha")
ax.axhline(1.0, color="0.5", lw=0.9, ls="--", label=r"honest $D^*$")
ax.plot(g0.alpha, g0.dhat_unrestricted, "-s", ms=4, color=style.OKABE_ITO[2],
label=r"unrestricted count, $p_{ref}=1$ (fig13's $\eta=1$)")
ax.plot(g0.alpha, g0.dhat_countable, "-o", ms=4, color=style.OKABE_ITO[3],
label=r"countable, $p_{ref}=1$ (deployed rule)")
ax.plot(g0.alpha, g0.dhat_countable_pref, "-^", ms=4, color=style.OKABE_ITO[5],
label=rf"countable, $p_{{ref}}={P_REF_REALISTIC}$")
ax.plot(g0.alpha, g0.dhat_u0, "-v", ms=4, color=style.OKABE_ITO[1],
label=r"no uncles ($\eta=0$)")
ax.set_xlabel(r"adversary stake $\alpha$")
ax.set_ylabel(r"$\hat D / D^*$ (estimator deflation)")
ax.set_title(r"About half the repair fig13's $\eta=1$ implies")
ax.legend(fontsize=7, loc="lower left")
style.save(fig, FIGS / "fig36_countable_selfish", provenance="scripts/countable_selfish.py")
plt.close(fig)
def reorg_ceilings() -> pd.DataFrame:
"""The same ceiling for the *depth*-maximising adversary of §6.10, for cross-reference.
A depth-``d`` reorg discards ``d`` consecutive public blocks one chain, one countable uncle.
Reported at the honest fork rate ``o = 0`` and at the measured Blend value ``o = 0.35``, which
inflates the adversary's effective share and so its reorg depths.
"""
rows = []
for o in (0.0, 0.35):
for alpha in (0.10, 0.20, 0.30):
ae = alpha_effective(alpha, o)
depths = simulate_deepest_reorg(ae, 4_000_000, np.random.default_rng(5))
rows.append(dict(alpha=alpha, orphan_rate=o, alpha_eff=ae, attacks=int(depths.size),
mean_depth=float(depths.mean()),
eta_countable=countable_recovery_from_depths(depths)))
out = pd.DataFrame(rows)
out.to_parquet(RUNS / "countable_selfish_reorg.parquet")
print("\n=== depth-maximising adversary (§6.10) — same first-fork ceiling ===")
print(f"{'alpha':>6} {'o':>5} {'a_eff':>7} {'E[d]':>6} {'eta_cnt':>8}")
for _, r in out.iterrows():
print(f"{r.alpha:6.2f} {r.orphan_rate:5.2f} {r.alpha_eff:7.4f} {r.mean_depth:6.3f} "
f"{r.eta_countable:8.4f}")
return out
def main() -> None:
import argparse
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--reuse", action="store_true",
help="re-render from runs/countable_selfish.parquet instead of re-solving the "
"MDP (the solve is ~15 min; the figure is not)")
args = ap.parse_args()
cached = RUNS / "countable_selfish.parquet"
if args.reuse and cached.exists():
print(f"=== re-rendering from {cached.name} ===")
df = pd.read_parquet(cached)
else:
print(f"=== countable recovery under a selfish adversary (MDP cap={CAP}) ===")
df = sweep()
reorg_ceilings()
report(df)
fig36(df)
print("wrote fig36_countable_selfish")
if __name__ == "__main__":
main()