research/tools/simulators/tsi/tsi-sim-pernode/tests/test_multi_coalition.py
Marcin Pawlowski 097543a5f9
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

263 lines
12 KiB
Python

"""Rival selfish coalitions: the partition, the isolation invariant, and K == 1 compatibility."""
from __future__ import annotations
import dataclasses
import numpy as np
import pandas as pd
import pytest
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.rng import seedseq_for
from tsi_sim.stake import stake_for
KW = dict(n_nodes=250, 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,
window_absorption=10.0, k=64, epochs=3, genesis_d_factor=0.5, early_stop=False,
adversary_frac=0.4, adversary_strategy="selfish", replicate=0,
prune_arrival=False, windowed_fork_choice=False)
def _tree(cfg):
"""Rebuild one epoch's tree with the coalition split applied."""
kids = seedseq_for(cfg).spawn(cfg.epochs + 3)
stake = stake_for(cfg)
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, cfg.genesis_d_factor * 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)
return tree, A, stake, mask, ids
def test_k1_is_bit_identical_to_the_single_coalition_path():
"""Adding the knob must not disturb any committed selfish result.
Both the key (so the RNG stream is untouched) and the trajectory itself.
"""
a = SimConfig(**KW)
b = SimConfig(**KW, adversary_coalitions=1)
assert a.key() == b.key()
assert pd.DataFrame(run_trajectory(a)).equals(pd.DataFrame(run_trajectory(b)))
# ...and K > 1 DOES get its own stream, or the two would silently share one.
assert SimConfig(**KW, adversary_coalitions=2).key() != a.key()
def test_partition_splits_stake_and_is_as_even_as_the_tail_allows():
"""Equal-*stake* rivals is the question §6.9 asks; equal-member-count would not be it.
Perfect evenness is not achievable and not claimed: under a Pareto tail a single adversarial
whale can exceed beta/K on its own, and then no partition is even. What LPT guarantees is that
the spread is at most one member's stake — if bin A ends heaviest, the last item x placed in it
went there because A was then the lightest, so load(A) - x <= load(B) for every B at that time
and loads only grow. That bound is what this pins, together with the partition being exact.
"""
K = 3
cfg = SimConfig(**{**KW, "adversary_coalitions": K})
stake = stake_for(cfg)
mask = _adversary_mask(cfg, stake)
ids = _coalition_ids(cfg, stake, mask)
assert ids is not None
shares = np.array([stake[ids == g].sum() for g in range(K)]) / stake.sum()
beta = stake[mask].sum() / stake.sum()
biggest = stake[mask].max() / stake.sum()
assert shares.max() - shares.min() <= biggest + 1e-12 # the LPT bound
# an exact partition of the coalition, and nothing else
assert shares.sum() == pytest.approx(beta)
assert set(np.nonzero(ids >= 0)[0]) == set(np.nonzero(mask)[0])
assert (ids[~mask] == -1).all()
def test_k1_returns_no_labels():
"""K == 1 must return None, not an all-zero vector: that is what keeps the old path exact."""
cfg = SimConfig(**KW)
stake = stake_for(cfg)
assert _coalition_ids(cfg, stake, _adversary_mask(cfg, stake)) is None
assert _coalition_ids(SimConfig(**{**KW, "adversary_frac": 0.0, "adversary_coalitions": 3}),
stake, None) is None
def test_rivals_cannot_see_each_others_private_blocks():
"""THE isolation invariant the whole study rests on, checked on the mechanism itself.
A coalition's unreleased block must be visible to its own members and to nobody else — not
honest nodes, and (the part K > 1 adds) not a rival coalition either. If it leaked, the rivals
would effectively be one coalition sharing a view and the experiment would measure nothing.
This cannot be read off the FINAL arrival matrix: by the end every private block has either
been released (public arrivals) or stranded (hidden from its own coalition too), so the private
state is gone. So drive the two coalition objects directly, which is where the rule lives.
"""
from tsi_sim.blocktree import _SelfishCoalition
nb, E = 8, 1000
left = _SelfishCoalition(np.array([0, 1]), nb, E)
right = _SelfishCoalition(np.array([2, 3]), nb, E)
height = np.zeros(nb, dtype=np.int64)
# a public block both sides see
height[1] = 1
left.note_block(1, 5.0)
right.note_block(1, 5.0)
# `left` mines two private blocks on top of it; `right` mines one
height[2], height[3] = 2, 3
left.add_private(2, 10, 1)
left.add_private(3, 11, 2)
height[4] = 2
right.add_private(4, 12, 1)
# neither side's private chain enters the other's view of the PUBLIC chain
assert left.public_height(20, height, 5) == 1, "a rival's hidden block raised left's public h"
assert right.public_height(20, height, 5) == 1, "left's hidden blocks raised right's public h"
# ...and each side does see its own
assert left.unreleased[2] and left.unreleased[3] and not left.unreleased[4]
assert right.unreleased[4] and not right.unreleased[2]
# a rival's block is invisible in time, not just excluded by the unreleased flag: that is what
# makes the isolation hold for blocks the engine has not flagged on this side at all.
assert left.coal_arr[4] > E and right.coal_arr[2] > E
# once `right` releases, `left` learns of it (the engine notes releases to every rival) and it
# counts toward left's public height — the channel by which one override buries another's chain
left.note_block(4, 13.0)
assert left.public_height(20, height, 5) == 2
def test_splitting_changes_the_fork_structure():
"""K > 1 must actually behave differently, or the knob is inert.
Rivals cut each other's leads short, so the private chains are far shallower than the single
coalition's. This pins the direction, not a value.
"""
def max_fork_depth(cfg):
tree, A, *_ = _tree(cfg)
seen = A.min(axis=0) <= cfg.epoch_len
h = np.where(seen, tree.height, -1)
chain, c = set(), int(np.argmax(h))
while c > 0:
chain.add(c)
c = int(tree.parent[c])
best = 0
for b in range(1, tree.n_blocks):
if b in chain:
continue
d, x = 0, b
while x > 0 and x not in chain:
x = int(tree.parent[x])
d += 1
best = max(best, d)
return best
deep_one = max_fork_depth(SimConfig(**KW))
deep_many = max_fork_depth(SimConfig(**{**KW, "adversary_coalitions": 4}))
assert deep_many < deep_one, (
f"4 rivals held a deeper private chain ({deep_many}) than one coalition ({deep_one})")
def test_partition_is_ignored_where_it_provably_cannot_matter():
"""Under suppression the deflation depends on summed stake alone (§6.9, exact by construction),
so the partition must leave that path untouched.
Compared at the TREE, holding every RNG input fixed and varying only ``coalition_ids``. A
trajectory comparison would not show this: K enters ``key()``, so two trajectories draw
different stake, graph and lottery, and at test scale that difference swamps the effect.
"""
cfg = SimConfig(**{**KW, "adversary_strategy": "suppress", "adversary_coalitions": 4})
kids = seedseq_for(cfg).spawn(cfg.epochs + 3)
stake = stake_for(cfg)
mask = _adversary_mask(cfg, stake)
ids = _coalition_ids(cfg, stake, mask)
assert ids is not None and (ids >= 0).any()
pl = topology.build_path_latency(cfg, np.random.default_rng(kids[1]))
d = np.full(cfg.n_nodes, cfg.genesis_d_factor * 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)
def build(coalition_ids):
tree, A = build_tree_pernode(slots, groups, pl, cfg, np.random.default_rng(kids[4]),
adversary_mask=mask, coalition_ids=coalition_ids)
return tree, A
t0, a0 = build(None)
t1, a1 = build(ids)
assert np.array_equal(t0.parent, t1.parent) and np.array_equal(t0.leader, t1.leader)
assert np.array_equal(t0.slot, t1.slot) and t0.uncles == t1.uncles
assert np.array_equal(a0, a1)
def test_realised_adversary_stake_stays_on_its_label():
"""The coalition must hold the stake the knob names — including in the tail.
A plain cumulative-prefix cut let one whale straddling the cutoff carry the coalition far past
its label: ~10% of Pareto replicates at adversary_frac = 0.4 realised a MAJORITY, up to 0.97.
The median was always on-label, so only a tail check catches it. This is the regression guard.
"""
import warnings as _w
for nominal in (0.2, 0.3, 0.4):
got, unreachable = [], 0
for rep in range(40):
cfg = SimConfig(n_nodes=800, stake_dist="pareto",
adversary_frac=nominal, replicate=rep)
stake = stake_for(cfg)
with _w.catch_warnings(record=True) as caught:
_w.simplefilter("always")
mask = _adversary_mask(cfg, stake)
unreachable += any("not reachable" in str(c.message) for c in caught)
got.append(float(stake[mask].sum() / stake.sum()))
got = np.array(got)
# THE regression guard: the defect was OVERSHOOT, so no draw may exceed its label by more
# than a rounding hair. Undershoot is a different thing and is allowed — a lumpy tail can
# leave no subset that reaches the target without blowing past it, and stopping short is
# then the nearer answer. It biases that replicate's adversary weak, never strong.
assert got.max() <= nominal * 1.01, f"{nominal} overshot to {got.max():.4f}"
assert (got > 0.5).sum() == 0, f"{nominal} produced a majority coalition: {got.max()}"
# most draws land essentially exactly on the label
assert np.median(np.abs(got - nominal)) < 0.01 * nominal
# and any draw that misses it by more than 20 % is loud about it, not silent
assert unreachable == int((np.abs(got - nominal) > 0.2 * nominal).sum())
def test_lead_cap_only_changes_runaway_private_chains():
"""The cap must be inert unless `wait` has stopped terminating.
Held paired (same RNG, cap the only difference) so this is the mechanism and not a reseed.
"""
cfg_kw = {**KW, "n_nodes": 400, "adversary_frac": 0.3}
ref = SimConfig(**cfg_kw)
kids = seedseq_for(ref).spawn(ref.epochs + 3)
stake = stake_for(ref)
mask = _adversary_mask(ref, stake)
pl = topology.build_path_latency(ref, np.random.default_rng(kids[1]))
d = np.full(ref.n_nodes, ref.genesis_d_factor * float(stake.sum()))
ws, wn = lottery.sample_wins(lottery.win_probs(stake, d, ref.f), ref.epoch_len,
np.random.default_rng(kids[3]))
slots, groups = lottery.group_by_slot(ws, wn)
def build(cap):
cfg = SimConfig(**{**cfg_kw, "selfish_lead_cap": cap})
return build_tree_pernode(slots, groups, pl, cfg, np.random.default_rng(kids[4]),
adversary_mask=mask)
t_unc, a_unc = build(-1) # textbook SM1, no cap
t_cap, a_cap = build(0) # default: cap at k
stranded = (a_unc > ref.epoch_len).all(axis=0)[1:]
adv = np.array([mask[int(t_unc.leader[b])] for b in range(1, t_unc.n_blocks)])
frac_stranded = int((adv & stranded).sum()) / max(int(adv.sum()), 1)
if frac_stranded < 0.5:
# `wait` terminated normally, so the cap never fired and the trees must be identical
assert np.array_equal(a_unc, a_cap) and np.array_equal(t_unc.parent, t_cap.parent)
else:
# it ran away: the cap must have rescued the chain from the epoch boundary
cap_stranded = (a_cap > ref.epoch_len).all(axis=0)[1:]
assert int((adv & cap_stranded).sum()) < int((adv & stranded).sum())