mirror of
https://github.com/logos-blockchain/research.git
synced 2026-08-10 21:23:12 +00:00
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>
263 lines
12 KiB
Python
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())
|