research/tools/simulators/tsi/tsi-sim-pernode/tests/test_countable_selfish.py

150 lines
7.2 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
"""Countable (first-fork) uncle recovery under a selfish adversary (§6.6).
The countable model can reference only the first block of a fork, so a discarded *chain* of
honest blocks yields one countable uncle however long it is. These tests pin the two ends of
that: SM1 never buries a second block (so the restriction costs nothing), while the optimal
policy waits and does (so it costs a factor of ~2 in recoverable orphans).
"""
import numpy as np
import pytest
from tsi_sim.selfish import race_from_alpha, selfish_threshold
from tsi_sim.selfish_mdp import optimal_policy_stats
FAST = dict(cap=16, iters=1500)
@pytest.mark.parametrize("gamma", [0.0, 0.5, 1.0])
@pytest.mark.parametrize("alpha", [0.2, 1 / 3, 0.4, 0.45])
def test_sm1_orphans_are_all_countable(alpha, gamma):
# SM1 acts as soon as the honest branch reaches length 1, so every orphan it makes is the
# first block of its fork: the first-fork restriction costs SM1 exactly nothing.
r = race_from_alpha(alpha, 200_000, gamma, np.random.default_rng(3))
assert r.orphan_hon_runs == r.orphan_hon
assert r.countable_recovery == 1.0
@pytest.mark.parametrize("gamma", [0.0, 0.5])
def test_optimal_policy_block_conservation(gamma):
# Every block-finding event yields exactly one block, which ends up canonical or orphaned.
# Per-event rates must therefore sum to 1 — the same invariant test_selfish asserts for SM1.
s = optimal_policy_stats(0.4, gamma, **FAST)
total = s.density_fraction + s.orphan_hon_blocks + s.orphan_adv_blocks
assert abs(total - 1.0) < 1e-9
@pytest.mark.parametrize("gamma", [0.0, 0.5])
def test_optimal_policy_buries_orphans(gamma):
# Above the profitability threshold the optimum waits before overriding, so it discards
# multi-block honest chains that the first-fork rule cannot recover.
s = optimal_policy_stats(0.4, gamma, **FAST)
assert s.deviates
assert s.orphan_hon_runs < s.orphan_hon_blocks
assert s.countable_recovery < 0.7 # measured ~0.44 (gamma=0) / ~0.55 (gamma=0.5)
def test_below_threshold_does_not_deviate():
# Below the threshold the optimum is honest mining; the MDP is indifferent across policies
# there, so the orphan structure of an arbitrary greedy tie-break must not be reported.
alpha = 0.25
assert alpha < selfish_threshold(0.0)
s = optimal_policy_stats(alpha, 0.0, **FAST)
assert not s.deviates
assert s.orphan_hon_blocks == 0.0
assert s.density_fraction == 1.0
def test_countable_dhat_is_below_unrestricted():
s = optimal_policy_stats(0.4, 0.0, **FAST)
# With no references the two models agree; with them, countable recovers strictly less.
assert s.dhat_ratio(p_ref=0.0, countable=True) == s.dhat_ratio(p_ref=0.0, countable=False)
assert s.dhat_ratio(p_ref=1.0, countable=True) < s.dhat_ratio(p_ref=1.0, countable=False)
# and both are bounded by the no-attack value
assert s.dhat_ratio(p_ref=1.0, countable=False) <= 1.0
# monotone in the reference rate
assert (s.dhat_ratio(p_ref=0.0, countable=True)
< s.dhat_ratio(p_ref=0.5, countable=True)
< s.dhat_ratio(p_ref=1.0, countable=True))
def test_attacker_self_uncle_is_capped_too():
# The attacker's abandoned secret chain is also one chain, so it can self-uncle only its
# first block — the §6.7(a) farming channel is narrower than the block count suggests.
s = optimal_policy_stats(0.4, 0.0, **FAST)
assert s.orphan_adv_runs < s.orphan_adv_blocks
assert 0.5 < s.countable_recovery_adv < 1.0
Item 16: the revenue-optimal adversary is not the estimator's worst case Both eta ceilings in sec 6.6 come from adversaries optimising something else (revenue, reorg depth), so they bound eta from above without bounding the damage from below. Optimising the estimate directly needs no ratio transform: each transition consumes exactly one block-finding event, so minimising D-hat = (canonical + p_ref * countable uncles)/events is a plain average-reward MDP over the transition table that already carries the orphan counts. One value-iteration pass, no bisection. Unconstrained, the answer degenerates -- and usefully. The optimum is pure abstention: publish nothing, adopt when overtaken, D-hat = 1 - alpha exactly, revenue zero. That is sec 6.4's withholding, which the report already shows is CORRECT measurement rather than mis-measurement, so the unconstrained objective asks the wrong question. The constrained one bites. Sweeping lam * (adversary blocks) - (contribution to D-hat) enumerates policies; the line of interest is where revenue SHARE reaches alpha, i.e. where attacking costs nothing versus mining honestly. At alpha=0.4 such a policy drives D-hat to 0.642 where the revenue-maximiser reaches 0.811 -- 17 points of extra deflation bought with the selfish premium alone. At 0.36 and 0.45 the gaps are 0.082 and 0.103. Below the 1/3 threshold nothing profitable deflates, so the exposure starts exactly where selfish mining does. This revises two claims that were about revenue but read as though they were about the adversary in general: sec 6.7's "the adversary frontier is exactly optimal selfish mining; no compounding lever remains" and sec 8.2's echo of it. Both now say the PROFIT frontier is bounded and the estimator frontier is not the same policy. Note the sweep parameter is deliberately non-monotone in revenue -- selfish mining takes a bigger share of a smaller pie, so raw block rate is maximised by honesty and large lam returns there; it enumerates policies rather than tracing a path. Also closes a fairness loop these findings opened. Sec 6.7(1) credits uncle rewards with compensating orphaned honest producers, computed on the SM1 race where every orphan is a first-fork block. Under a private chain 20-40% of the honest blocks destroyed are unreferenceable by construction, so those producers are uncompensatable at ANY w_u -- not underpaid because p_ref is low, but unreachable because no valid block may name them. The fairness guarantee inherits the same first-fork ceiling as the density repair. Logged as item 19, flagged as a protocol-design question rather than something a schedule fixes. _solve_mdp is refactored into _solve_reward/_greedy_policy/_stationary/ _policy_rates so both objectives share one implementation; optimal_policy_stats reproduces its committed figures exactly (eta 0.4413, D-hat 0.9447/0.8111 at alpha=0.4). 251 tests pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 14:54:52 +02:00
def test_unconstrained_deflation_optimum_is_abstention():
# Minimising the estimate with no constraint degenerates: publish nothing, and D-hat lands on
# exactly 1 - alpha with zero revenue. §6.4 already covers that case and shows it is CORRECT
# measurement rather than mis-measurement, which is why item 16 needs the paid frontier.
from tsi_sim.selfish_mdp import deflation_optimal_stats
for alpha in (0.2, 0.4):
s = deflation_optimal_stats(alpha, 0.0, cap=16)
assert abs(s.dhat_ratio(1.0, True) - (1.0 - alpha)) < 1e-6
assert s.revenue < 1e-9
assert s.orphan_hon_blocks < 1e-9 # it orphans no honest work at all
def test_deflation_solver_gain_matches_its_stationary_accounting():
# deflation_optimal_stats raises if the MDP's average gain disagrees with the estimate
# recomputed from the stationary distribution -- an independent check that the solver and the
# orphan accounting describe the same policy. Exercise it across a spread of inputs.
from tsi_sim.selfish_mdp import deflation_optimal_stats
for alpha in (0.25, 0.35, 0.45):
for p_ref in (0.0, 0.85, 1.0):
deflation_optimal_stats(alpha, 0.0, p_ref=p_ref, cap=16) # no AssertionError
def test_frontier_endpoints_bracket_the_two_pure_objectives():
from tsi_sim.selfish_mdp import deflation_frontier, deflation_optimal_stats
alpha = 0.4
zero = deflation_frontier(alpha, 0.0, 0.0, cap=16)
pure = deflation_optimal_stats(alpha, 0.0, cap=16)
assert abs(zero["dhat_countable"] - pure.dhat_ratio(1.0, True)) < 1e-6 # lam=0 is that optimum
# Selfish mining takes a bigger share of a SMALLER pie, so maximising raw adversary block
# rate returns to honest mining -- the frontier is not monotone in revenue, by construction.
far = deflation_frontier(alpha, 0.0, 50.0, cap=16)
assert abs(far["revenue"] - alpha) < 1e-3
assert abs(far["dhat_countable"] - 1.0) < 1e-3
def test_a_paid_policy_deflates_further_than_the_revenue_optimum():
# Item 16's answer: the revenue-optimal adversary is not the estimator's worst case. At
# alpha = 0.4 a policy exists that pays at least as well as honest mining yet deflates
# substantially further than the revenue optimum does.
from tsi_sim.selfish_mdp import deflation_frontier, optimal_policy_stats
alpha, cap = 0.4, 32
ro = optimal_policy_stats(alpha, 0.0, cap=cap)
paid = [deflation_frontier(alpha, 0.0, lam, cap=cap) for lam in (0.4, 0.6, 0.8, 1.0)]
paid = [p for p in paid if p["reward_per_stake"] >= 1.0 - 1e-9]
assert paid, "expected at least one break-even-or-better frontier point"
assert min(p["dhat_countable"] for p in paid) < ro.dhat_ratio(1.0, True) - 0.05
Review pass: reproduce every number from its data of record, fix what did not Correctness/completeness review of the report and simulator. Verified against the committed parquets: the sec 6.6 countable-ceiling table (cap-64 MDP sweep), sec 6.10 Result 4's depth ceilings, the sec 3.4 uncle-selection table, all adversary-variant numbers, the rho-boundary row-4 quotes (0.976 at rho=0.91, 4-sigma shortfall at 0.96, max cell 1.0024), and the sec 8.4 capstone table. Three defects found, all fixed: 1. The collapse event was not reproducible from the committed script. Study D swept only the default (random) coalition, but the one observed collapse is a whale cell; the "once in 144 runs" count came from an ad-hoc probe. The committed sweep now carries the selection axis (96 runs) and reproduces the event: 1/12 in the whale 50% cell at delta_max = 8, never at 4. All six fold-related passages now quote the committed sweep, which also retires the stale "the full dynamics never reach it" wording in the sec 6 arc, the sec 6.2 intro, row 6 and item 1 -- text that contradicted item 18 since yesterday's finding. 2. capstone.py's printout could not reproduce the report's sec 8.4 table. The report's numbers are a per-replicate-tail aggregation (each replicate burns in against its own early-stop length); the script cut the tail at the ARM's max epoch, silently dropping any replicate that stopped earlier (7 of 8 in the adversary arm) and landing one rounding step off on three cells. The script now aggregates per replicate and prints the SEM; against the existing parquet it reproduces the table exactly (1.001/0.998, 0.342+-0.009 / 0.343+-0.005, p_ref 1.000/0.990, 8 reps both arms). The report table was right all along; sec 6.8's p_ref quote (0.989, the per-arm value) is aligned to 0.990. 3. Small report fixes: slow-beta deflation rounded 0.765 -> "0.77" (now 0.76); fig13's caption now points at the fig36 ceiling instead of implying free recovery; row 5 cites the measured slow-beta standing deflation; the canonical-data paragraph lists the new studies' artifacts; the simulator README's layout block lists the new tests and scripts. Adds a unit test for reorg.countable_recovery_from_depths (the one new function that had none). 236 tests pass; the new-study parquets are copied to the main checkout's runs/, where every other study's data of record lives. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 12:29:13 +02:00
def test_reorg_countable_recovery_from_depths():
# A depth-d reorg discards one chain of d blocks -> 1 countable uncle: runs / blocks.
from tsi_sim.reorg import countable_recovery_from_depths
assert countable_recovery_from_depths(np.array([], dtype=np.int64)) == 1.0
assert countable_recovery_from_depths(np.array([1, 1, 1])) == 1.0 # SM1-like: all depth-1
assert countable_recovery_from_depths(np.array([3, 1, 2])) == 0.5 # 3 runs / 6 blocks
# and it is the depth-weighted harmonic sense of "share": deeper reorgs drag it down
assert countable_recovery_from_depths(np.array([10])) == 0.1
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
@pytest.mark.slow
def test_cap_convergence():
# The orphan shape converges more slowly in cap than the revenue does; check the drift is
# small where the report quotes numbers.
a = optimal_policy_stats(0.4, 0.0, cap=48)
b = optimal_policy_stats(0.4, 0.0, cap=64)
assert abs(a.countable_recovery - b.countable_recovery) < 2e-3
assert abs(a.revenue - b.revenue) < 1e-3