Marcin Pawlowski 0510b20684
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

198 lines
9.1 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.

"""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()