research/tools/simulators/tsi/tsi-sim-pernode/tests/test_countable_selfish.py
Marcin Pawlowski 8cc682aa49
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

150 lines
7.2 KiB
Python

"""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
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
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
@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