233 lines
12 KiB
Python
Raw Normal View History

Fix adversary coalition sizing; add rival coalitions and a lead cap Three engine defects, found while building the multi-coalition study §6.9 flags as open. The first is the serious one. 1. COALITION SIZING (engine._adversary_mask, `random` selection — the default). The coalition was the smallest random prefix whose stake reached the target. Under a Pareto tail a whale straddling the cut carries it far past its label: over 60 replicates, a nominal adversary_frac of 0.4 realised a MAJORITY in ~10% of them and reached 0.97, and 0.2 reached 0.90. The median was always on-label, which is why it hid — it distorts the tail, not the centre. Both other places in the code that size a set by stake had already rejected this rule: the `whale` arm uses fit-then-close, and _churn_inactive_mask documents the identical failure ("a 30% label realising up to ~53%"). The `random` arm kept it. Now fit-then-close in random order, and a draw where the tail leaves no subset near the label warns instead of silently running a different attacker. Realised stake is now within 0.1% of its label. Re-ran the load-bearing studies. §8.4 capstone (2 of 8 replicates contaminated, one a 61% majority): spec rule 0.994 -> 0.995, p_ref 0.936 -> 0.937. The parent-anchored variant is far more sensitive — 0.974 -> 0.990, p_ref 0.875 -> 0.923 — because a tighter window and a larger suppressing coalition compound, so §8.4's argument for the W = 12 pairing rested on 0.021 of cost that is really 0.006. The pairing itself survives re-measurement and is now better supported: p_ref reaches parity at W = 12 too, not at 15. §6.8's uncle-margin sweep and §6.5's random-arm variants are flagged as needing re-measurement (§8.3 item 20), not silently carried. 2. SM1 NEVER TERMINATED under a forking honest network. Textbook SM1 waits while it leads, assuming the lead returns to zero. But honest blocks fork against each other, so the public chain's HEIGHT grows at ~(1-a)*f*(1-fork) while a coalition sharing one view extends privately at the full a*f; past a fork rate of ~1 - a/(1-a) the private chain outruns the public one and `wait` never fires. The lead ran to thousands and every block was stranded at the epoch boundary — 98% of adversarial blocks at alpha=0.4, delta_max=8 — scoring an attacker that WON the race as having earned nothing. selfish_lead_cap (default: the finality depth k) publishes a lead that can no longer be caught. Inert unless `wait` stops terminating; pinned paired. 3. RIVAL COALITIONS (adversary_coalitions = K) for the §6.9 study: K private chains, each invisible to the others by the same arrival sentinel that hides them from honest nodes, so they orphan each other as well as the honest chain. Stake-balanced partition (LPT), K=1 bit-identical to the single-coalition path. Also corrects §8.4's closing paragraph, which still quoted a pre-countable D-hat/D of 1.001 and fork rates that contradicted its own table. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 12:21:00 +02:00
"""Do K rival selfish coalitions deflate D-hat further than one coalition of the same size? (fig39)
§6.9 settles the *withholding* commons exactly deflation depends on the total abstaining stake
and not on how it is partitioned and then flags the selfish case as conjectural, on two counts
taken from the literature:
(a) total orphaning, hence raw D-hat deflation, *can exceed* the single-coalition value, so the
§6.6 figure at alpha = 0.4 is not a multi-coalition upper bound; and
(b) several individually sub-threshold coalitions may be *jointly* profitable, i.e. the 1/3
threshold is not a per-coalition safety argument.
Both are now testable. The per-node engine runs one private chain per coalition, and a rival's
unreleased blocks are invisible to every other coalition by the same arrival sentinel that hides
them from honest nodes so the chains race each other as well as the public chain, which is the
whole mechanism the conjecture rests on. `adversary_coalitions = K` splits a fixed adversarial
stake into K near-equal rivals (`engine._coalition_ids`), holding beta constant so K is the only
thing that moves.
Two arms:
* **accuracy** D-hat/D, fork rate and p_ref against (beta, K), which answers (a) directly;
* **profitability** each coalition's share of the canonical chain against its OWN stake, which
answers (b). A coalition profits when its canonical share exceeds its stake share; the joint
question is whether that holds for every one of K rivals that are each below 1/3.
Re-measure the W pairing paired, and correct my own severity numbers The W = 12 pairing is now measured the way a ~0.001 claim has to be: every integer W from 8 to 15, 32 replicates, and paired_streams so the whole grid runs on common random numbers (_base_key excludes both uncle_window_anchor and window_absorption, so a replicate draws one stake vector, one graph and one lottery for every cell). The earlier unpaired sweep reported +0.0008 against a standard error of 0.0009 — it could not resolve its own headline. Paired against today's recipe (uncle-anchored, W = 10): parent W=10 -0.0056 +- 0.0005 t = -10.4 parent W=11 -0.0024 +- 0.0005 t = -4.5 parent W=12 +0.00004 +- 0.00050 t = 0.1 <- parity parent W=14 +0.0016 +- 0.0004 t = 3.5 W = 12 is the smallest window reaching parity, and the parity is exact rather than marginal: W = 11, one interval short, is still resolvably worse. p_ref agrees at the same window (0.938 vs 0.939) instead of lagging to W = 15 as the unpaired edition had it. Also states what the sweep makes visible: widening today's uncle-anchored rule buys +0.0018 on its own, so W = 12 makes the swap cost-neutral against the CURRENT recipe rather than optimal in absolute terms. CORRECTIONS to the previous commit, which measured contamination on the wrong RNG stream. The engine draws stake from seedseq_for(config).spawn(...)[0]; I used rng_for(config), the root. Both are valid stake draws, neither is the same vector. Redone properly: - The capstone draw was NOT contaminated: 0 of 8 replicates over 1.25x its label, worst 0.369 against 0.30, no majority. My "2 of 8, one a 61% majority" was wrong and is withdrawn from §8.4 and §9. - The finding that survives is sharper: on that same mild overshoot the spec's rule moved 0.001 and the parent-anchored variant moved 0.016. A rule leaning harder on the reference window is far more sensitive to an oversized suppressing coalition. - Genuinely contaminated: §6.12's 12-replicate W sweep (2 majorities, worst 0.720) and §6.8's selfish margin at a=0.3 and a=0.4 (2 and 1 majorities). §6.5's variants and §6.8's a=0.2 arm are clean; §8.3 item 20 narrowed to the one sweep that still needs re-running. - The general severity is worse than first stated, not better: at the report's geometry a nominal 0.3 realised a majority in 12% of replicates. Two more defects found on the way: - stake_for(config) added, because scripts used rng_for and the engine uses the spawned child — so every script that rebuilt a tree was analysing a different network than the trajectory it was compared against. All scripts and tests now use it. - A coalition member could receive a private block BEFORE its parent: the arrival was clamped against the PRODUCER's view of the parent and applied to the whole coalition, so a member still awaiting a public parent got the child first. Now clamped per member. Caught by the existing arrival-order test once the stake derivation was corrected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 12:37:21 +02:00
Both arms record the REALISED stake shares rather than trusting the knob. That is no longer a
correction for the sizing defect §9 describes (fixed: the coalition now lands within 0.1 % of its
label), but it stays because a Pareto draw can still leave `beta` unreachable one holder above
the target and the split into K rivals is only as even as the tail permits.
Fix adversary coalition sizing; add rival coalitions and a lead cap Three engine defects, found while building the multi-coalition study §6.9 flags as open. The first is the serious one. 1. COALITION SIZING (engine._adversary_mask, `random` selection — the default). The coalition was the smallest random prefix whose stake reached the target. Under a Pareto tail a whale straddling the cut carries it far past its label: over 60 replicates, a nominal adversary_frac of 0.4 realised a MAJORITY in ~10% of them and reached 0.97, and 0.2 reached 0.90. The median was always on-label, which is why it hid — it distorts the tail, not the centre. Both other places in the code that size a set by stake had already rejected this rule: the `whale` arm uses fit-then-close, and _churn_inactive_mask documents the identical failure ("a 30% label realising up to ~53%"). The `random` arm kept it. Now fit-then-close in random order, and a draw where the tail leaves no subset near the label warns instead of silently running a different attacker. Realised stake is now within 0.1% of its label. Re-ran the load-bearing studies. §8.4 capstone (2 of 8 replicates contaminated, one a 61% majority): spec rule 0.994 -> 0.995, p_ref 0.936 -> 0.937. The parent-anchored variant is far more sensitive — 0.974 -> 0.990, p_ref 0.875 -> 0.923 — because a tighter window and a larger suppressing coalition compound, so §8.4's argument for the W = 12 pairing rested on 0.021 of cost that is really 0.006. The pairing itself survives re-measurement and is now better supported: p_ref reaches parity at W = 12 too, not at 15. §6.8's uncle-margin sweep and §6.5's random-arm variants are flagged as needing re-measurement (§8.3 item 20), not silently carried. 2. SM1 NEVER TERMINATED under a forking honest network. Textbook SM1 waits while it leads, assuming the lead returns to zero. But honest blocks fork against each other, so the public chain's HEIGHT grows at ~(1-a)*f*(1-fork) while a coalition sharing one view extends privately at the full a*f; past a fork rate of ~1 - a/(1-a) the private chain outruns the public one and `wait` never fires. The lead ran to thousands and every block was stranded at the epoch boundary — 98% of adversarial blocks at alpha=0.4, delta_max=8 — scoring an attacker that WON the race as having earned nothing. selfish_lead_cap (default: the finality depth k) publishes a lead that can no longer be caught. Inert unless `wait` stops terminating; pinned paired. 3. RIVAL COALITIONS (adversary_coalitions = K) for the §6.9 study: K private chains, each invisible to the others by the same arrival sentinel that hides them from honest nodes, so they orphan each other as well as the honest chain. Stake-balanced partition (LPT), K=1 bit-identical to the single-coalition path. Also corrects §8.4's closing paragraph, which still quoted a pre-countable D-hat/D of 1.001 and fork rates that contradicted its own table. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 12:21:00 +02:00
Run: python scripts/multi_coalition.py (writes runs/multi_coalition{,_split}.parquet + fig39)
"""
from __future__ import annotations
from pathlib import Path
import numpy as np
import pandas as pd
from joblib import Parallel, delayed
from tsi_sim import lottery, topology
from tsi_sim.blocktree import build_tree_pernode
from tsi_sim.config import SimConfig
from tsi_sim.engine import _adversary_mask, _coalition_ids, run_trajectory
from tsi_sim.epoch import _canonical_producer_split
from tsi_sim.plotting import style
Re-measure the W pairing paired, and correct my own severity numbers The W = 12 pairing is now measured the way a ~0.001 claim has to be: every integer W from 8 to 15, 32 replicates, and paired_streams so the whole grid runs on common random numbers (_base_key excludes both uncle_window_anchor and window_absorption, so a replicate draws one stake vector, one graph and one lottery for every cell). The earlier unpaired sweep reported +0.0008 against a standard error of 0.0009 — it could not resolve its own headline. Paired against today's recipe (uncle-anchored, W = 10): parent W=10 -0.0056 +- 0.0005 t = -10.4 parent W=11 -0.0024 +- 0.0005 t = -4.5 parent W=12 +0.00004 +- 0.00050 t = 0.1 <- parity parent W=14 +0.0016 +- 0.0004 t = 3.5 W = 12 is the smallest window reaching parity, and the parity is exact rather than marginal: W = 11, one interval short, is still resolvably worse. p_ref agrees at the same window (0.938 vs 0.939) instead of lagging to W = 15 as the unpaired edition had it. Also states what the sweep makes visible: widening today's uncle-anchored rule buys +0.0018 on its own, so W = 12 makes the swap cost-neutral against the CURRENT recipe rather than optimal in absolute terms. CORRECTIONS to the previous commit, which measured contamination on the wrong RNG stream. The engine draws stake from seedseq_for(config).spawn(...)[0]; I used rng_for(config), the root. Both are valid stake draws, neither is the same vector. Redone properly: - The capstone draw was NOT contaminated: 0 of 8 replicates over 1.25x its label, worst 0.369 against 0.30, no majority. My "2 of 8, one a 61% majority" was wrong and is withdrawn from §8.4 and §9. - The finding that survives is sharper: on that same mild overshoot the spec's rule moved 0.001 and the parent-anchored variant moved 0.016. A rule leaning harder on the reference window is far more sensitive to an oversized suppressing coalition. - Genuinely contaminated: §6.12's 12-replicate W sweep (2 majorities, worst 0.720) and §6.8's selfish margin at a=0.3 and a=0.4 (2 and 1 majorities). §6.5's variants and §6.8's a=0.2 arm are clean; §8.3 item 20 narrowed to the one sweep that still needs re-running. - The general severity is worse than first stated, not better: at the report's geometry a nominal 0.3 realised a majority in 12% of replicates. Two more defects found on the way: - stake_for(config) added, because scripts used rng_for and the engine uses the spawned child — so every script that rebuilt a tree was analysing a different network than the trajectory it was compared against. All scripts and tests now use it. - A coalition member could receive a private block BEFORE its parent: the arrival was clamped against the PRODUCER's view of the parent and applied to the whole coalition, so a member still awaiting a public parent got the child first. Now clamped per member. Caught by the existing arrival-order test once the stake derivation was corrected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 12:37:21 +02:00
from tsi_sim.rng import seedseq_for
from tsi_sim.stake import stake_for
Fix adversary coalition sizing; add rival coalitions and a lead cap Three engine defects, found while building the multi-coalition study §6.9 flags as open. The first is the serious one. 1. COALITION SIZING (engine._adversary_mask, `random` selection — the default). The coalition was the smallest random prefix whose stake reached the target. Under a Pareto tail a whale straddling the cut carries it far past its label: over 60 replicates, a nominal adversary_frac of 0.4 realised a MAJORITY in ~10% of them and reached 0.97, and 0.2 reached 0.90. The median was always on-label, which is why it hid — it distorts the tail, not the centre. Both other places in the code that size a set by stake had already rejected this rule: the `whale` arm uses fit-then-close, and _churn_inactive_mask documents the identical failure ("a 30% label realising up to ~53%"). The `random` arm kept it. Now fit-then-close in random order, and a draw where the tail leaves no subset near the label warns instead of silently running a different attacker. Realised stake is now within 0.1% of its label. Re-ran the load-bearing studies. §8.4 capstone (2 of 8 replicates contaminated, one a 61% majority): spec rule 0.994 -> 0.995, p_ref 0.936 -> 0.937. The parent-anchored variant is far more sensitive — 0.974 -> 0.990, p_ref 0.875 -> 0.923 — because a tighter window and a larger suppressing coalition compound, so §8.4's argument for the W = 12 pairing rested on 0.021 of cost that is really 0.006. The pairing itself survives re-measurement and is now better supported: p_ref reaches parity at W = 12 too, not at 15. §6.8's uncle-margin sweep and §6.5's random-arm variants are flagged as needing re-measurement (§8.3 item 20), not silently carried. 2. SM1 NEVER TERMINATED under a forking honest network. Textbook SM1 waits while it leads, assuming the lead returns to zero. But honest blocks fork against each other, so the public chain's HEIGHT grows at ~(1-a)*f*(1-fork) while a coalition sharing one view extends privately at the full a*f; past a fork rate of ~1 - a/(1-a) the private chain outruns the public one and `wait` never fires. The lead ran to thousands and every block was stranded at the epoch boundary — 98% of adversarial blocks at alpha=0.4, delta_max=8 — scoring an attacker that WON the race as having earned nothing. selfish_lead_cap (default: the finality depth k) publishes a lead that can no longer be caught. Inert unless `wait` stops terminating; pinned paired. 3. RIVAL COALITIONS (adversary_coalitions = K) for the §6.9 study: K private chains, each invisible to the others by the same arrival sentinel that hides them from honest nodes, so they orphan each other as well as the honest chain. Stake-balanced partition (LPT), K=1 bit-identical to the single-coalition path. Also corrects §8.4's closing paragraph, which still quoted a pre-countable D-hat/D of 1.001 and fork rates that contradicted its own table. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 12:21:00 +02:00
HERE = Path(__file__).resolve().parent.parent
RUNS = HERE / "runs"
FIGS = HERE / "report-figures"
RUNS.mkdir(exist_ok=True)
FIGS.mkdir(exist_ok=True)
REPS = 10
N_JOBS = 10
BETAS = [0.2, 0.3, 0.4]
KS = [1, 2, 3, 4]
# Full scan / no prune: the selfish path forces both anyway (a private chain's release reorders
# the fork-choice frontier), stated here so the geometry is explicit rather than implied.
BASE = dict(n_nodes=600, stake_dist="pareto", topology="blend", degree=6,
link_latency_mean=0.5, link_latency_dist="geo", blend_hops=3, blend_delay_max=8.0,
max_uncles=2, uncle_strategy="oldest", window_absorption=10.0,
k=256, epochs=10, genesis_d_factor=0.5, early_stop=False,
adversary_strategy="selfish", prune_arrival=False, windowed_fork_choice=False)
def _accuracy(beta: float, K: int, rep: int) -> dict:
"""What K rivals at a fixed total stake do to the estimator."""
cfg = SimConfig(**BASE, adversary_frac=beta, adversary_coalitions=K, replicate=rep)
t = pd.DataFrame(run_trajectory(cfg))
t = t[t.epoch >= t.epoch.max() // 2]
adv, hon = t.adv_blocks.sum(), t.honest_blocks.sum()
return dict(beta=beta, K=K, rep=rep, mean_ratio=float(t.mean_ratio.mean()),
fork_rate=float(t.fork_rate.mean()), p_ref=float(t.p_ref.mean()),
mean_orphan_rate=float(t.mean_orphan_rate.mean()),
joint_share=float(adv / (adv + hon)) if adv + hon else 0.0)
def _profit(beta: float, K: int, rep: int, ratio: float) -> list[dict]:
"""Each coalition's canonical share against its own stake, on one rebuilt epoch.
``ratio`` is the accuracy arm's measured ``D-hat/D`` for this cell, and it matters: the
lottery is driven by the estimate, so rebuilding at the GENESIS d_est would produce blocks at
roughly twice the equilibrium rate and measure profitability in a regime the chain never
occupies. Seeding at ``ratio * D_true`` puts the rebuild at the operating point the
trajectory actually converges to under this attack.
"""
cfg = SimConfig(**{**BASE, "epochs": 2}, adversary_frac=beta,
adversary_coalitions=K, replicate=rep)
kids = seedseq_for(cfg).spawn(cfg.epochs + 3)
Re-measure the W pairing paired, and correct my own severity numbers The W = 12 pairing is now measured the way a ~0.001 claim has to be: every integer W from 8 to 15, 32 replicates, and paired_streams so the whole grid runs on common random numbers (_base_key excludes both uncle_window_anchor and window_absorption, so a replicate draws one stake vector, one graph and one lottery for every cell). The earlier unpaired sweep reported +0.0008 against a standard error of 0.0009 — it could not resolve its own headline. Paired against today's recipe (uncle-anchored, W = 10): parent W=10 -0.0056 +- 0.0005 t = -10.4 parent W=11 -0.0024 +- 0.0005 t = -4.5 parent W=12 +0.00004 +- 0.00050 t = 0.1 <- parity parent W=14 +0.0016 +- 0.0004 t = 3.5 W = 12 is the smallest window reaching parity, and the parity is exact rather than marginal: W = 11, one interval short, is still resolvably worse. p_ref agrees at the same window (0.938 vs 0.939) instead of lagging to W = 15 as the unpaired edition had it. Also states what the sweep makes visible: widening today's uncle-anchored rule buys +0.0018 on its own, so W = 12 makes the swap cost-neutral against the CURRENT recipe rather than optimal in absolute terms. CORRECTIONS to the previous commit, which measured contamination on the wrong RNG stream. The engine draws stake from seedseq_for(config).spawn(...)[0]; I used rng_for(config), the root. Both are valid stake draws, neither is the same vector. Redone properly: - The capstone draw was NOT contaminated: 0 of 8 replicates over 1.25x its label, worst 0.369 against 0.30, no majority. My "2 of 8, one a 61% majority" was wrong and is withdrawn from §8.4 and §9. - The finding that survives is sharper: on that same mild overshoot the spec's rule moved 0.001 and the parent-anchored variant moved 0.016. A rule leaning harder on the reference window is far more sensitive to an oversized suppressing coalition. - Genuinely contaminated: §6.12's 12-replicate W sweep (2 majorities, worst 0.720) and §6.8's selfish margin at a=0.3 and a=0.4 (2 and 1 majorities). §6.5's variants and §6.8's a=0.2 arm are clean; §8.3 item 20 narrowed to the one sweep that still needs re-running. - The general severity is worse than first stated, not better: at the report's geometry a nominal 0.3 realised a majority in 12% of replicates. Two more defects found on the way: - stake_for(config) added, because scripts used rng_for and the engine uses the spawned child — so every script that rebuilt a tree was analysing a different network than the trajectory it was compared against. All scripts and tests now use it. - A coalition member could receive a private block BEFORE its parent: the arrival was clamped against the PRODUCER's view of the parent and applied to the whole coalition, so a member still awaiting a public parent got the child first. Now clamped per member. Caught by the existing arrival-order test once the stake derivation was corrected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 12:37:21 +02:00
stake = stake_for(cfg)
Fix adversary coalition sizing; add rival coalitions and a lead cap Three engine defects, found while building the multi-coalition study §6.9 flags as open. The first is the serious one. 1. COALITION SIZING (engine._adversary_mask, `random` selection — the default). The coalition was the smallest random prefix whose stake reached the target. Under a Pareto tail a whale straddling the cut carries it far past its label: over 60 replicates, a nominal adversary_frac of 0.4 realised a MAJORITY in ~10% of them and reached 0.97, and 0.2 reached 0.90. The median was always on-label, which is why it hid — it distorts the tail, not the centre. Both other places in the code that size a set by stake had already rejected this rule: the `whale` arm uses fit-then-close, and _churn_inactive_mask documents the identical failure ("a 30% label realising up to ~53%"). The `random` arm kept it. Now fit-then-close in random order, and a draw where the tail leaves no subset near the label warns instead of silently running a different attacker. Realised stake is now within 0.1% of its label. Re-ran the load-bearing studies. §8.4 capstone (2 of 8 replicates contaminated, one a 61% majority): spec rule 0.994 -> 0.995, p_ref 0.936 -> 0.937. The parent-anchored variant is far more sensitive — 0.974 -> 0.990, p_ref 0.875 -> 0.923 — because a tighter window and a larger suppressing coalition compound, so §8.4's argument for the W = 12 pairing rested on 0.021 of cost that is really 0.006. The pairing itself survives re-measurement and is now better supported: p_ref reaches parity at W = 12 too, not at 15. §6.8's uncle-margin sweep and §6.5's random-arm variants are flagged as needing re-measurement (§8.3 item 20), not silently carried. 2. SM1 NEVER TERMINATED under a forking honest network. Textbook SM1 waits while it leads, assuming the lead returns to zero. But honest blocks fork against each other, so the public chain's HEIGHT grows at ~(1-a)*f*(1-fork) while a coalition sharing one view extends privately at the full a*f; past a fork rate of ~1 - a/(1-a) the private chain outruns the public one and `wait` never fires. The lead ran to thousands and every block was stranded at the epoch boundary — 98% of adversarial blocks at alpha=0.4, delta_max=8 — scoring an attacker that WON the race as having earned nothing. selfish_lead_cap (default: the finality depth k) publishes a lead that can no longer be caught. Inert unless `wait` stops terminating; pinned paired. 3. RIVAL COALITIONS (adversary_coalitions = K) for the §6.9 study: K private chains, each invisible to the others by the same arrival sentinel that hides them from honest nodes, so they orphan each other as well as the honest chain. Stake-balanced partition (LPT), K=1 bit-identical to the single-coalition path. Also corrects §8.4's closing paragraph, which still quoted a pre-countable D-hat/D of 1.001 and fork rates that contradicted its own table. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 12:21:00 +02:00
mask = _adversary_mask(cfg, stake)
ids = _coalition_ids(cfg, stake, mask)
pl = topology.build_path_latency(cfg, np.random.default_rng(kids[1]))
d = np.full(cfg.n_nodes, max(ratio, 0.05) * float(stake.sum()))
ws, wn = lottery.sample_wins(lottery.win_probs(stake, d, cfg.f), cfg.epoch_len,
np.random.default_rng(kids[3]))
slots, groups = lottery.group_by_slot(ws, wn)
tree, A = build_tree_pernode(slots, groups, pl, cfg, np.random.default_rng(kids[4]),
adversary_mask=mask, coalition_ids=ids)
total = float(stake.sum())
# Blocks that reached nobody: private chains still hidden when the epoch ended. The lead cap
# should keep this near zero — a large value means `wait` stopped terminating and the arm is
# measuring the epoch boundary rather than the attack, so it is reported, not assumed away.
adv_blk = np.array([bool(mask[int(tree.leader[b])]) for b in range(1, tree.n_blocks)])
never = (A[:, 1:] > cfg.epoch_len).all(axis=0)
stranded = float((adv_blk & never).sum() / max(int(adv_blk.sum()), 1))
# K == 1 has no id vector (the single-coalition path is left untouched), so synthesise one.
groups_of = [mask] if ids is None else [ids == g for g in range(K)]
out = []
for g, gmask in enumerate(groups_of):
won, hon = _canonical_producer_split(tree, A, gmask, cfg.period_T, cfg.epoch_len)
out.append(dict(beta=beta, K=K, rep=rep, coalition=g, stranded=stranded,
stake_share=float(stake[gmask].sum() / total),
canonical_share=float(won / (won + hon)) if won + hon else 0.0))
return out
def sweep() -> tuple[pd.DataFrame, pd.DataFrame]:
jobs = [(b, k, r) for b in BETAS for k in KS for r in range(REPS)]
par = Parallel(n_jobs=N_JOBS, backend="loky", inner_max_num_threads=1)
acc = pd.DataFrame(par(delayed(_accuracy)(*j) for j in jobs))
# the profit arm rebuilds at each cell's MEASURED operating point, so accuracy runs first
at = {(r.beta, r.K, r.rep): r.mean_ratio for r in acc.itertuples()}
spl = pd.DataFrame([r for rows in par(delayed(_profit)(b, k, rp, at[(b, k, rp)])
for b, k, rp in jobs) for r in rows])
acc.to_parquet(RUNS / "multi_coalition.parquet", index=False)
spl.to_parquet(RUNS / "multi_coalition_split.parquet", index=False)
return acc, spl
def report(acc: pd.DataFrame, spl: pd.DataFrame) -> None:
print("\n=== (a) does splitting the SAME stake deflate D-hat further? ===")
# MEDIAN, not mean: the deflation feedback of §6.2 is bistable, so a cell that drops a
# replicate onto the collapsed branch has a bimodal sample and its mean sits between two
# branches, describing neither. `low` counts those replicates so the tail stays visible.
print(f"{'beta':>6} {'K':>3} | {'median D_hat/D':>15} {'IQR':>15} {'low':>4} "
f"{'fork':>6} {'p_ref':>7} {'joint sh':>9}")
for b in BETAS:
for k in KS:
g = acc[(acc.beta == b) & (acc.K == k)]
med = g.mean_ratio.median()
lo, hi = g.mean_ratio.quantile(0.25), g.mean_ratio.quantile(0.75)
n_low = int((g.mean_ratio < med - 0.15).sum())
print(f"{b:6.2f} {k:3d} | {med:15.4f} {f'[{lo:.3f}, {hi:.3f}]':>15} {n_low:4d} "
f"{g.fork_rate.median():6.3f} {g.p_ref.median():7.3f} "
f"{g.joint_share.median():9.4f}")
one = acc[(acc.beta == b) & (acc.K == 1)].mean_ratio.median()
by_k = acc[acc.beta == b].groupby("K").mean_ratio.median()
worst, got = by_k.idxmin(), by_k.min()
verdict = ("SPLITTING DEFLATES FURTHER" if got < one - 0.005
else "the single coalition bounds it")
print(f" -> worst K = {worst} at {got:.4f} vs K=1 {one:.4f}: {verdict}\n")
print("=== (b) are individually sub-threshold coalitions each profitable? ===")
print(f"{'beta':>6} {'K':>3} | {'own stake':>10} {'canonical':>10} {'ratio':>8} "
f"{'stranded':>9} verdict")
for b in BETAS:
for k in KS:
g = spl[(spl.beta == b) & (spl.K == k)]
st, cs = g.stake_share.median(), g.canonical_share.median()
strand = g.stranded.median()
sub = "sub-1/3" if st < 1 / 3 else "over-1/3"
pays = "PAYS" if cs > st * 1.005 else "does not pay"
# A stranded fraction above ~0.2 means private chains were still hidden at the epoch
# boundary, so the cell measures the boundary and not the attack — flag, do not hide.
flag = " <-- BOUNDARY-DOMINATED" if strand > 0.2 else ""
print(f"{b:6.2f} {k:3d} | {st:10.4f} {cs:10.4f} {cs / st:8.3f} {strand:9.3f} "
f"{sub}, {pays}{flag}")
print()
def fig39(acc: pd.DataFrame, spl: pd.DataFrame) -> None:
import matplotlib.pyplot as plt
style.apply_style()
fig, axes = plt.subplots(1, 2, figsize=(9.6, 3.8))
ax = axes[0]
for i, b in enumerate(BETAS):
g = (acc[acc.beta == b].groupby("K").mean_ratio.agg(["mean", "sem"]).reset_index())
ax.errorbar(g.K, g["mean"], yerr=g["sem"], marker="o", ms=4, capsize=2,
color=style.OKABE_ITO[i + 1], label=rf"$\beta$ = {b:.1f}")
ax.axhline(1.0, color="0.5", lw=0.9, ls="--")
ax.set_xticks(KS)
ax.set_xlabel("number of rival coalitions $K$ (total stake held fixed)")
ax.set_ylabel(r"$\hat D / D^*$")
ax.set_title("Accuracy vs how the same stake is split")
ax.legend(fontsize=7)
ax = axes[1]
Settle §6.9's multi-coalition conjecture: refuted for deflation, confirmed for the threshold §6.9 flagged multi-coalition selfish mining as conjectural on two counts taken from the literature — that splitting a coalition can deflate D-hat FURTHER than one coalition of the same size, and that individually sub-threshold coalitions can be jointly profitable. With rival private chains in the engine, both are now measured (scripts/multi_coalition.py, fig39), and they point opposite ways. DEFLATION — REFUTED. Splitting a fixed stake into K rivals reduces estimator damage, monotonically, at every beta and K tested (t = 4.3 to 14.3): beta K=1 K=2 K=3 K=4 0.20 0.908 0.928 0.922 0.928 0.30 0.778 0.858 0.880 0.880 0.40 0.586* 0.764 0.807 0.823 The mechanism shows in the fork structure: a lone coalition holds one private chain ~2000 blocks deep, while two rivals cut each other to depth ~2. Rivals spend their advantage burying each other instead of honest work. So D-hat ~ 0.70 at alpha = 0.4 IS an upper bound on multi-coalition deflation, not the under-estimate §6.9 warned it might be. (* boundary-affected: 66% of that cell's adversarial blocks were still private at epoch end even with the lead cap. The 0.2 and 0.3 rows have <1% stranding and carry the result alone.) THRESHOLD — CONFIRMED, and 1/3 does not hold at this load: one coalition at 0.200 0.875x t = -2.5 does not pay one coalition at 0.295 1.232x t = 2.7 PAYS two rivals at 0.200 each 1.045x t = 2.5 each PAYS three rivals at 0.133 each 0.852x t = -5.1 does not pay Two results against the folklore. A single coalition already profits at alpha ~ 0.295, because the honest network forks at ~0.48 here so the public chain's HEIGHT advances at only (1-a)*f*(1-fork) while a coalition sharing one view extends privately at the full a*f — the threshold falls with the fork rate. And a 0.20 coalition that does NOT pay alone DOES pay against a second 0.20 rival: the rival displaces honest blocks too and both collect on the disruption. That is the "individually sub-threshold, jointly profitable" case, confirmed at K = 2 and not extending to K >= 3. The practical split: coalition fragmentation is good for TSI's estimator and bad for the incentive argument. "No coalition holds 1/3" is not a safety property at a load where the honest network forks appreciably, which makes rho < 1 an incentive constraint and not only an accuracy one. §8.3 item 2 narrowed accordingly; fig39 marks boundary-affected cells hollow. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 12:42:54 +02:00
flagged = False
Fix adversary coalition sizing; add rival coalitions and a lead cap Three engine defects, found while building the multi-coalition study §6.9 flags as open. The first is the serious one. 1. COALITION SIZING (engine._adversary_mask, `random` selection — the default). The coalition was the smallest random prefix whose stake reached the target. Under a Pareto tail a whale straddling the cut carries it far past its label: over 60 replicates, a nominal adversary_frac of 0.4 realised a MAJORITY in ~10% of them and reached 0.97, and 0.2 reached 0.90. The median was always on-label, which is why it hid — it distorts the tail, not the centre. Both other places in the code that size a set by stake had already rejected this rule: the `whale` arm uses fit-then-close, and _churn_inactive_mask documents the identical failure ("a 30% label realising up to ~53%"). The `random` arm kept it. Now fit-then-close in random order, and a draw where the tail leaves no subset near the label warns instead of silently running a different attacker. Realised stake is now within 0.1% of its label. Re-ran the load-bearing studies. §8.4 capstone (2 of 8 replicates contaminated, one a 61% majority): spec rule 0.994 -> 0.995, p_ref 0.936 -> 0.937. The parent-anchored variant is far more sensitive — 0.974 -> 0.990, p_ref 0.875 -> 0.923 — because a tighter window and a larger suppressing coalition compound, so §8.4's argument for the W = 12 pairing rested on 0.021 of cost that is really 0.006. The pairing itself survives re-measurement and is now better supported: p_ref reaches parity at W = 12 too, not at 15. §6.8's uncle-margin sweep and §6.5's random-arm variants are flagged as needing re-measurement (§8.3 item 20), not silently carried. 2. SM1 NEVER TERMINATED under a forking honest network. Textbook SM1 waits while it leads, assuming the lead returns to zero. But honest blocks fork against each other, so the public chain's HEIGHT grows at ~(1-a)*f*(1-fork) while a coalition sharing one view extends privately at the full a*f; past a fork rate of ~1 - a/(1-a) the private chain outruns the public one and `wait` never fires. The lead ran to thousands and every block was stranded at the epoch boundary — 98% of adversarial blocks at alpha=0.4, delta_max=8 — scoring an attacker that WON the race as having earned nothing. selfish_lead_cap (default: the finality depth k) publishes a lead that can no longer be caught. Inert unless `wait` stops terminating; pinned paired. 3. RIVAL COALITIONS (adversary_coalitions = K) for the §6.9 study: K private chains, each invisible to the others by the same arrival sentinel that hides them from honest nodes, so they orphan each other as well as the honest chain. Stake-balanced partition (LPT), K=1 bit-identical to the single-coalition path. Also corrects §8.4's closing paragraph, which still quoted a pre-countable D-hat/D of 1.001 and fork rates that contradicted its own table. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 12:21:00 +02:00
for i, b in enumerate(BETAS):
g = spl[spl.beta == b].groupby("K")[["stake_share", "canonical_share"]].mean()
Settle §6.9's multi-coalition conjecture: refuted for deflation, confirmed for the threshold §6.9 flagged multi-coalition selfish mining as conjectural on two counts taken from the literature — that splitting a coalition can deflate D-hat FURTHER than one coalition of the same size, and that individually sub-threshold coalitions can be jointly profitable. With rival private chains in the engine, both are now measured (scripts/multi_coalition.py, fig39), and they point opposite ways. DEFLATION — REFUTED. Splitting a fixed stake into K rivals reduces estimator damage, monotonically, at every beta and K tested (t = 4.3 to 14.3): beta K=1 K=2 K=3 K=4 0.20 0.908 0.928 0.922 0.928 0.30 0.778 0.858 0.880 0.880 0.40 0.586* 0.764 0.807 0.823 The mechanism shows in the fork structure: a lone coalition holds one private chain ~2000 blocks deep, while two rivals cut each other to depth ~2. Rivals spend their advantage burying each other instead of honest work. So D-hat ~ 0.70 at alpha = 0.4 IS an upper bound on multi-coalition deflation, not the under-estimate §6.9 warned it might be. (* boundary-affected: 66% of that cell's adversarial blocks were still private at epoch end even with the lead cap. The 0.2 and 0.3 rows have <1% stranding and carry the result alone.) THRESHOLD — CONFIRMED, and 1/3 does not hold at this load: one coalition at 0.200 0.875x t = -2.5 does not pay one coalition at 0.295 1.232x t = 2.7 PAYS two rivals at 0.200 each 1.045x t = 2.5 each PAYS three rivals at 0.133 each 0.852x t = -5.1 does not pay Two results against the folklore. A single coalition already profits at alpha ~ 0.295, because the honest network forks at ~0.48 here so the public chain's HEIGHT advances at only (1-a)*f*(1-fork) while a coalition sharing one view extends privately at the full a*f — the threshold falls with the fork rate. And a 0.20 coalition that does NOT pay alone DOES pay against a second 0.20 rival: the rival displaces honest blocks too and both collect on the disruption. That is the "individually sub-threshold, jointly profitable" case, confirmed at K = 2 and not extending to K >= 3. The practical split: coalition fragmentation is good for TSI's estimator and bad for the incentive argument. "No coalition holds 1/3" is not a safety property at a load where the honest network forks appreciably, which makes rho < 1 an incentive constraint and not only an accuracy one. §8.3 item 2 narrowed accordingly; fig39 marks boundary-affected cells hollow. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 12:42:54 +02:00
strand = spl[spl.beta == b].groupby("K").stranded.mean()
ratio = g.canonical_share / g.stake_share
ax.plot(g.index, ratio, marker="o", ms=4, color=style.OKABE_ITO[i + 1],
label=rf"$\beta$ = {b:.1f}")
# A cell whose private chains were still hidden at the epoch boundary is measuring the
# boundary, not the attack. Draw it hollow so the curve cannot be read as if every point
# carried the same weight — the §6.9 text says the same thing in words.
bad = strand > 0.2
if bad.any():
flagged = True
ax.plot(ratio.index[bad], ratio[bad], "o", ms=9, mfc="none", mew=1.4,
color=style.OKABE_ITO[i + 1], zorder=5)
Fix adversary coalition sizing; add rival coalitions and a lead cap Three engine defects, found while building the multi-coalition study §6.9 flags as open. The first is the serious one. 1. COALITION SIZING (engine._adversary_mask, `random` selection — the default). The coalition was the smallest random prefix whose stake reached the target. Under a Pareto tail a whale straddling the cut carries it far past its label: over 60 replicates, a nominal adversary_frac of 0.4 realised a MAJORITY in ~10% of them and reached 0.97, and 0.2 reached 0.90. The median was always on-label, which is why it hid — it distorts the tail, not the centre. Both other places in the code that size a set by stake had already rejected this rule: the `whale` arm uses fit-then-close, and _churn_inactive_mask documents the identical failure ("a 30% label realising up to ~53%"). The `random` arm kept it. Now fit-then-close in random order, and a draw where the tail leaves no subset near the label warns instead of silently running a different attacker. Realised stake is now within 0.1% of its label. Re-ran the load-bearing studies. §8.4 capstone (2 of 8 replicates contaminated, one a 61% majority): spec rule 0.994 -> 0.995, p_ref 0.936 -> 0.937. The parent-anchored variant is far more sensitive — 0.974 -> 0.990, p_ref 0.875 -> 0.923 — because a tighter window and a larger suppressing coalition compound, so §8.4's argument for the W = 12 pairing rested on 0.021 of cost that is really 0.006. The pairing itself survives re-measurement and is now better supported: p_ref reaches parity at W = 12 too, not at 15. §6.8's uncle-margin sweep and §6.5's random-arm variants are flagged as needing re-measurement (§8.3 item 20), not silently carried. 2. SM1 NEVER TERMINATED under a forking honest network. Textbook SM1 waits while it leads, assuming the lead returns to zero. But honest blocks fork against each other, so the public chain's HEIGHT grows at ~(1-a)*f*(1-fork) while a coalition sharing one view extends privately at the full a*f; past a fork rate of ~1 - a/(1-a) the private chain outruns the public one and `wait` never fires. The lead ran to thousands and every block was stranded at the epoch boundary — 98% of adversarial blocks at alpha=0.4, delta_max=8 — scoring an attacker that WON the race as having earned nothing. selfish_lead_cap (default: the finality depth k) publishes a lead that can no longer be caught. Inert unless `wait` stops terminating; pinned paired. 3. RIVAL COALITIONS (adversary_coalitions = K) for the §6.9 study: K private chains, each invisible to the others by the same arrival sentinel that hides them from honest nodes, so they orphan each other as well as the honest chain. Stake-balanced partition (LPT), K=1 bit-identical to the single-coalition path. Also corrects §8.4's closing paragraph, which still quoted a pre-countable D-hat/D of 1.001 and fork rates that contradicted its own table. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 12:21:00 +02:00
ax.axhline(1.0, color="0.5", lw=0.9, ls="--")
Settle §6.9's multi-coalition conjecture: refuted for deflation, confirmed for the threshold §6.9 flagged multi-coalition selfish mining as conjectural on two counts taken from the literature — that splitting a coalition can deflate D-hat FURTHER than one coalition of the same size, and that individually sub-threshold coalitions can be jointly profitable. With rival private chains in the engine, both are now measured (scripts/multi_coalition.py, fig39), and they point opposite ways. DEFLATION — REFUTED. Splitting a fixed stake into K rivals reduces estimator damage, monotonically, at every beta and K tested (t = 4.3 to 14.3): beta K=1 K=2 K=3 K=4 0.20 0.908 0.928 0.922 0.928 0.30 0.778 0.858 0.880 0.880 0.40 0.586* 0.764 0.807 0.823 The mechanism shows in the fork structure: a lone coalition holds one private chain ~2000 blocks deep, while two rivals cut each other to depth ~2. Rivals spend their advantage burying each other instead of honest work. So D-hat ~ 0.70 at alpha = 0.4 IS an upper bound on multi-coalition deflation, not the under-estimate §6.9 warned it might be. (* boundary-affected: 66% of that cell's adversarial blocks were still private at epoch end even with the lead cap. The 0.2 and 0.3 rows have <1% stranding and carry the result alone.) THRESHOLD — CONFIRMED, and 1/3 does not hold at this load: one coalition at 0.200 0.875x t = -2.5 does not pay one coalition at 0.295 1.232x t = 2.7 PAYS two rivals at 0.200 each 1.045x t = 2.5 each PAYS three rivals at 0.133 each 0.852x t = -5.1 does not pay Two results against the folklore. A single coalition already profits at alpha ~ 0.295, because the honest network forks at ~0.48 here so the public chain's HEIGHT advances at only (1-a)*f*(1-fork) while a coalition sharing one view extends privately at the full a*f — the threshold falls with the fork rate. And a 0.20 coalition that does NOT pay alone DOES pay against a second 0.20 rival: the rival displaces honest blocks too and both collect on the disruption. That is the "individually sub-threshold, jointly profitable" case, confirmed at K = 2 and not extending to K >= 3. The practical split: coalition fragmentation is good for TSI's estimator and bad for the incentive argument. "No coalition holds 1/3" is not a safety property at a load where the honest network forks appreciably, which makes rho < 1 an incentive constraint and not only an accuracy one. §8.3 item 2 narrowed accordingly; fig39 marks boundary-affected cells hollow. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 12:42:54 +02:00
if flagged:
ax.annotate("hollow: private chains\nstranded at epoch end\n(read as directional)",
xy=(0.30, 0.04), xycoords="axes fraction", fontsize=6, color="0.35")
Fix adversary coalition sizing; add rival coalitions and a lead cap Three engine defects, found while building the multi-coalition study §6.9 flags as open. The first is the serious one. 1. COALITION SIZING (engine._adversary_mask, `random` selection — the default). The coalition was the smallest random prefix whose stake reached the target. Under a Pareto tail a whale straddling the cut carries it far past its label: over 60 replicates, a nominal adversary_frac of 0.4 realised a MAJORITY in ~10% of them and reached 0.97, and 0.2 reached 0.90. The median was always on-label, which is why it hid — it distorts the tail, not the centre. Both other places in the code that size a set by stake had already rejected this rule: the `whale` arm uses fit-then-close, and _churn_inactive_mask documents the identical failure ("a 30% label realising up to ~53%"). The `random` arm kept it. Now fit-then-close in random order, and a draw where the tail leaves no subset near the label warns instead of silently running a different attacker. Realised stake is now within 0.1% of its label. Re-ran the load-bearing studies. §8.4 capstone (2 of 8 replicates contaminated, one a 61% majority): spec rule 0.994 -> 0.995, p_ref 0.936 -> 0.937. The parent-anchored variant is far more sensitive — 0.974 -> 0.990, p_ref 0.875 -> 0.923 — because a tighter window and a larger suppressing coalition compound, so §8.4's argument for the W = 12 pairing rested on 0.021 of cost that is really 0.006. The pairing itself survives re-measurement and is now better supported: p_ref reaches parity at W = 12 too, not at 15. §6.8's uncle-margin sweep and §6.5's random-arm variants are flagged as needing re-measurement (§8.3 item 20), not silently carried. 2. SM1 NEVER TERMINATED under a forking honest network. Textbook SM1 waits while it leads, assuming the lead returns to zero. But honest blocks fork against each other, so the public chain's HEIGHT grows at ~(1-a)*f*(1-fork) while a coalition sharing one view extends privately at the full a*f; past a fork rate of ~1 - a/(1-a) the private chain outruns the public one and `wait` never fires. The lead ran to thousands and every block was stranded at the epoch boundary — 98% of adversarial blocks at alpha=0.4, delta_max=8 — scoring an attacker that WON the race as having earned nothing. selfish_lead_cap (default: the finality depth k) publishes a lead that can no longer be caught. Inert unless `wait` stops terminating; pinned paired. 3. RIVAL COALITIONS (adversary_coalitions = K) for the §6.9 study: K private chains, each invisible to the others by the same arrival sentinel that hides them from honest nodes, so they orphan each other as well as the honest chain. Stake-balanced partition (LPT), K=1 bit-identical to the single-coalition path. Also corrects §8.4's closing paragraph, which still quoted a pre-countable D-hat/D of 1.001 and fork rates that contradicted its own table. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 12:21:00 +02:00
ax.set_xticks(KS)
ax.set_xlabel("number of rival coalitions $K$")
ax.set_ylabel("canonical share / own stake\n(per coalition; > 1 = selfish mining pays)")
ax.set_title("Profitability of each rival")
ax.legend(fontsize=7)
style.save(fig, FIGS / "fig39_multi_coalition", provenance="scripts/multi_coalition.py")
plt.close(fig)
def main() -> None:
print("=== K rival selfish coalitions at fixed total stake (§6.9) ===")
acc, spl = sweep()
report(acc, spl)
fig39(acc, spl)
print(f"wrote {RUNS}/multi_coalition{{,_split}}.parquet + fig39")
if __name__ == "__main__":
main()