2026-07-30 18:57:10 +02:00
|
|
|
import numpy as np
|
|
|
|
|
|
|
|
|
|
from tsi_sim.config import SimConfig
|
|
|
|
|
from tsi_sim.engine import run_trajectory
|
|
|
|
|
from tsi_sim.rng import rng_for, seedseq_for
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_seedseq_and_rng_deterministic():
|
|
|
|
|
cfg = SimConfig(k=8, epochs=3)
|
|
|
|
|
a = np.random.default_rng(seedseq_for(cfg)).random(5)
|
|
|
|
|
b = rng_for(cfg).random(5)
|
|
|
|
|
np.testing.assert_array_equal(a, b)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_distinct_configs_get_distinct_streams():
|
|
|
|
|
c0 = SimConfig(k=8, epochs=3, latency=0)
|
|
|
|
|
c1 = SimConfig(k=8, epochs=3, latency=1)
|
|
|
|
|
assert not np.array_equal(rng_for(c0).random(4), rng_for(c1).random(4))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_trajectory_is_order_independent_and_reproducible():
|
|
|
|
|
cfg = SimConfig(n_nodes=300, topology="regular", degree=8, k=8, epochs=6,
|
|
|
|
|
link_latency_mean=2.0, max_uncles=2)
|
|
|
|
|
r1 = run_trajectory(cfg)
|
|
|
|
|
r2 = run_trajectory(cfg)
|
|
|
|
|
assert [row["mean_ratio"] for row in r1] == [row["mean_ratio"] for row in r2]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_replicates_differ():
|
|
|
|
|
a = run_trajectory(SimConfig(n_nodes=300, topology="regular", k=8, epochs=6,
|
|
|
|
|
link_latency_mean=2.0, replicate=0))
|
|
|
|
|
b = run_trajectory(SimConfig(n_nodes=300, topology="regular", k=8, epochs=6,
|
|
|
|
|
link_latency_mean=2.0, replicate=1))
|
|
|
|
|
assert a[-1]["mean_ratio"] != b[-1]["mean_ratio"]
|
Paired design: resolve the design band with common random numbers
The unpaired comparison could not answer the question it was asked. The
two uncle models draw independent RNG streams -- uncle_model is in the
config key, which is what makes --old bit-reproduce earlier runs -- so
the arms differed in stake draw, peering graph and every lottery
outcome, each comparison paid the between-run variance twice, and the
per-cell floor (+-0.0015) sat an order of magnitude above the effect.
Only delta_max = 5 resolved, and only after pooling.
Adds `paired_streams`: the RNG root is derived from the model-
independent part of the key, so a countable cell and its --old twin get
the SAME stake, graph and lottery draws and the uncle rule is the only
difference. Each replicate is then a matched pair and the shared
variance cancels. Trajectories still diverge after epoch 0 through the
genuine feedback (a different counted density changes the next epoch's
difficulty), which is the signal.
The flag is deliberately NOT in key(): it selects which key the seed is
derived from, so including it would perturb every historical seed.
Re-verified that --old still bit-reproduces the committed 2026-07-27
rho-boundary parquet, max |delta| = 0.
Results (configs/fine-delay-paired.yaml, 40 replicates per arm):
- Negative control becomes an IDENTITY check. With U = 0 no reference is
taken, so shared streams must give bit-identical trajectories. All 200
replicate pairs differ by exactly 0.0. Unpaired, the same control only
had to agree within +-0.025 and drifted by 0.016.
- Per-cell SE shrinks by a median 1.6x (1.2-2.1x); widest 95% CI goes
+-0.0015 -> +-0.0010. 5/15 cells resolve at |t| >= 2 (0.75 expected by
chance); the largest, U=2 at delta_max=4, is t = 4.32 and clears
Bonferroni for 15 tests.
- The cost is a STEP, not the ramp the unpaired data suggested:
delta_max 1-3 unresolved (t = 1.1, 1.8, 1.4), then delta_max 4 AND 5
both resolve at -0.0011 (t = 4.7) and -0.0009 (t = 3.7). Whole-band
pooled -0.00060 +- 0.00021, t = 5.7 -- where the unpaired estimate of
the same quantity (t = 2.8) had failed correction.
So the first-fork restriction costs nothing measurable up to
delta_max = 3 and about 0.1% at 4-5 -- an order of magnitude below the
+-0.9% per-epoch sampling noise.
Two bugs found while building this, both of which would have silently
produced a wrong answer:
- paired_streams was missing from metrics._CONFIG_FIELDS, so it never
reached the parquet; plot_fine_delay.py falls back to the unpaired
test when it cannot confirm pairing, so the sweep would have completed
and quietly reported the old result. Caught before the run finished;
the sweep was restarted and a test now pins the field.
- The U=0 control check reported FAILS on a PERFECT control: paired, the
gap is exactly 0 so its SE is 0 and t is 0/0. It now checks the gap
itself when the streams are shared, and falls back to the t-test only
when there is real spread.
§3.2a is rewritten around the paired measurement; the unpaired sweep is
retained in §9 as the power comparison that motivated it. Figures 34-35
regenerated, with the control annotation and provenance reflecting the
design actually used.
Tests: 214 passed (was 209). ruff clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 11:58:26 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_paired_streams_shares_the_root_across_uncle_models():
|
|
|
|
|
"""Common random numbers: with paired_streams the two arms draw the SAME root seed."""
|
|
|
|
|
from tsi_sim.rng import seedseq_for
|
|
|
|
|
|
|
|
|
|
kw = dict(n_nodes=50, max_uncles=2, blend_delay_max=5.0, topology="blend",
|
|
|
|
|
k=32, epochs=2, replicate=3, paired_streams=True)
|
|
|
|
|
c = SimConfig(uncle_model="countable", **kw)
|
|
|
|
|
o = SimConfig(uncle_model="old", **kw)
|
|
|
|
|
assert c.seed_key() == o.seed_key() # the marker is dropped
|
|
|
|
|
assert c.key() != o.key() # ...but identity still distinguishes them
|
|
|
|
|
assert seedseq_for(c).entropy == seedseq_for(o).entropy
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_unpaired_is_the_default_and_separates_the_models():
|
|
|
|
|
kw = dict(n_nodes=50, max_uncles=2, blend_delay_max=5.0, topology="blend",
|
|
|
|
|
k=32, epochs=2, replicate=3)
|
|
|
|
|
c, o = SimConfig(uncle_model="countable", **kw), SimConfig(uncle_model="old", **kw)
|
|
|
|
|
assert c.paired_streams is False and o.paired_streams is False
|
|
|
|
|
assert c.seed_key() == c.key() and o.seed_key() == o.key()
|
|
|
|
|
assert seedseq_for(c).entropy != seedseq_for(o).entropy
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_paired_streams_does_not_perturb_unpaired_seeds():
|
|
|
|
|
"""The flag must not enter key(): every historical seed stays byte-identical.
|
|
|
|
|
|
|
|
|
|
This is what protects --old bit-reproduction of the pre-redesign runs (report §9).
|
|
|
|
|
"""
|
|
|
|
|
from tsi_sim.rng import seedseq_for
|
|
|
|
|
|
|
|
|
|
for model in ("countable", "old"):
|
|
|
|
|
base = SimConfig(uncle_model=model, n_nodes=50, max_uncles=2, k=32, epochs=2)
|
|
|
|
|
flagged = SimConfig(uncle_model=model, n_nodes=50, max_uncles=2, k=32, epochs=2,
|
|
|
|
|
paired_streams=False)
|
|
|
|
|
assert base.key() == flagged.key()
|
|
|
|
|
assert seedseq_for(base).entropy == seedseq_for(flagged).entropy
|
|
|
|
|
# and the old model's key is still exactly the base tuple (no marker appended)
|
|
|
|
|
o = SimConfig(uncle_model="old", n_nodes=50, k=32, epochs=2)
|
|
|
|
|
assert o.key() == o._base_key()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_paired_streams_gives_both_arms_the_same_stake_and_graph():
|
|
|
|
|
"""Pairing must reach the actual shared inputs, not just the root seed."""
|
|
|
|
|
import numpy as np
|
|
|
|
|
|
|
|
|
|
from tsi_sim import topology
|
|
|
|
|
from tsi_sim.rng import seedseq_for
|
|
|
|
|
from tsi_sim.stake import make_stake
|
|
|
|
|
|
|
|
|
|
kw = dict(n_nodes=60, degree=4, topology="blend", max_uncles=2, k=32, epochs=2,
|
|
|
|
|
blend_delay_max=5.0, replicate=1)
|
|
|
|
|
c = SimConfig(uncle_model="countable", paired_streams=True, **kw)
|
|
|
|
|
o = SimConfig(uncle_model="old", paired_streams=True, **kw)
|
|
|
|
|
kids = {n: seedseq_for(cfg).spawn(cfg.epochs + 3)
|
|
|
|
|
for n, cfg in (("c", c), ("o", o))}
|
|
|
|
|
s_c = make_stake(c, np.random.default_rng(kids["c"][0]))
|
|
|
|
|
s_o = make_stake(o, np.random.default_rng(kids["o"][0]))
|
|
|
|
|
np.testing.assert_array_equal(s_c, s_o) # same stake draw
|
|
|
|
|
g_c = topology.build_path_latency(c, np.random.default_rng(kids["c"][1]))
|
|
|
|
|
g_o = topology.build_path_latency(o, np.random.default_rng(kids["o"][1]))
|
|
|
|
|
np.testing.assert_array_equal(g_c, g_o) # same peering graph
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_paired_streams_is_recorded_in_the_output_row():
|
|
|
|
|
"""A paired run must be identifiable from its parquet alone.
|
|
|
|
|
|
|
|
|
|
scripts/plot_fine_delay.py picks the paired test only when both arms report
|
|
|
|
|
paired_streams; if the flag were missing from the recorded config it would silently fall
|
|
|
|
|
back to the unpaired test and quietly discard the whole point of the paired sweep.
|
|
|
|
|
"""
|
|
|
|
|
from tsi_sim.metrics import _CONFIG_FIELDS
|
|
|
|
|
|
|
|
|
|
assert "paired_streams" in _CONFIG_FIELDS
|
Uncle selection: the spec fixes oldest-first, so measure deviation from it
Open item 11 listed "a random (rather than oldest-first) uncle-selection draw"
as an untested spec sensitivity. The spec does not leave it open: Uncle
Selection in cryptarchia-v1-protocol.md has the proposer take the oldest
candidates first, deterministically, because an uncle expires w_u slots after
its own slot. That is exactly what every result in the report already uses, so
the item is a conformance match, not a gap -- and the simulator comment calling
uncle_random_p "the spec's unbiased coin" cites text the spec no longer has.
What is genuinely open is deviation FROM that rule: selection is proposer-local
and the uncles field is never validated. configs/uncle-selection.yaml measures
the cost. A proposer that includes each candidate on a fair coin instead loses
up to 0.10 in D-hat/D, and 0.063 at the recommended W = 10 once rho ~ 1
(0.902 vs 0.965, t = -8.6). At the design point the margin survives but is
spent: 0.980 vs 0.997 against a 0.98 bar. The loss does not close as W grows,
because a coin wastes opportunities rather than queue capacity and a well-sized
window is precisely what keeps the queue short enough for that to bite.
This matters for the sec 8.5 reward recommendation: the spec argues a proposer
has no incentive to deviate BECAUSE uncles grant no reward, and paying them
removes that argument.
Also adds adversary_selection=whale (the largest holders at matched stake, for
the untested concentration case). The marker is appended to key() only when
non-default so every historical run's seed stays byte-identical, guarded by a
test alongside the paired_streams one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 15:42:16 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_whale_selection_does_not_perturb_random_coalition_seeds():
|
|
|
|
|
"""A default ("random") coalition's key must stay byte-identical to every historical run's.
|
|
|
|
|
|
|
|
|
|
Same protection as test_paired_streams_does_not_perturb_unpaired_seeds gives the uncle model:
|
|
|
|
|
the marker is appended only when non-default, so adding the knob rewrites no existing seed.
|
|
|
|
|
"""
|
|
|
|
|
from tsi_sim.rng import seedseq_for
|
|
|
|
|
|
|
|
|
|
for model in ("countable", "old"):
|
|
|
|
|
base = SimConfig(uncle_model=model, n_nodes=50, k=32, epochs=2, adversary_frac=0.3)
|
|
|
|
|
explicit = SimConfig(uncle_model=model, n_nodes=50, k=32, epochs=2, adversary_frac=0.3,
|
|
|
|
|
adversary_selection="random")
|
|
|
|
|
assert base.key() == explicit.key()
|
|
|
|
|
assert seedseq_for(base).entropy == seedseq_for(explicit).entropy
|
|
|
|
|
o = SimConfig(uncle_model="old", n_nodes=50, k=32, epochs=2, adversary_frac=0.3)
|
|
|
|
|
assert o.key() == o._base_key() # still exactly the historical tuple
|
|
|
|
|
whale = SimConfig(uncle_model="old", n_nodes=50, k=32, epochs=2, adversary_frac=0.3,
|
|
|
|
|
adversary_selection="whale")
|
|
|
|
|
assert whale.key() != o.key() # ...but the whale arm is its own stream
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_whale_coalition_takes_the_largest_holders_at_the_same_stake():
|
|
|
|
|
from tsi_sim.engine import _adversary_mask
|
|
|
|
|
from tsi_sim.stake import make_stake
|
|
|
|
|
|
|
|
|
|
cfg_r = SimConfig(n_nodes=400, stake_dist="pareto", k=32, epochs=2, adversary_frac=0.3)
|
|
|
|
|
cfg_w = SimConfig(n_nodes=400, stake_dist="pareto", k=32, epochs=2, adversary_frac=0.3,
|
|
|
|
|
adversary_selection="whale")
|
|
|
|
|
stake = make_stake(cfg_r, rng_for(cfg_r))
|
|
|
|
|
m_r, m_w = _adversary_mask(cfg_r, stake), _adversary_mask(cfg_w, stake)
|
|
|
|
|
total = stake.sum()
|
|
|
|
|
# The point of the knob is concentration at MATCHED stake, so the realised shares must agree
|
|
|
|
|
# closely — a whale arm holding visibly more stake would confound the two.
|
|
|
|
|
assert abs(stake[m_r].sum() / total - 0.3) < 0.01
|
|
|
|
|
assert abs(stake[m_w].sum() / total - 0.3) < 0.01
|
|
|
|
|
# ...and the whales get there with far fewer nodes: that is the variable under test.
|
|
|
|
|
assert m_w.sum() * 5 < m_r.sum()
|
|
|
|
|
# The coalition is drawn from the top of the distribution and dominated by it: the biggest
|
|
|
|
|
# holders are all in, and the small top-up nodes contribute almost none of its stake.
|
|
|
|
|
top3 = np.argsort(-stake)[:3]
|
|
|
|
|
assert m_w[top3].all()
|
|
|
|
|
assert stake[top3].sum() / stake[m_w].sum() > 0.9
|