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]
|
|
|
|
|
for i, b in enumerate(BETAS):
|
|
|
|
|
g = spl[spl.beta == b].groupby("K")[["stake_share", "canonical_share"]].mean()
|
|
|
|
|
ax.plot(g.index, g.canonical_share / g.stake_share, marker="o", ms=4,
|
|
|
|
|
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$")
|
|
|
|
|
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()
|