mirror of
https://github.com/logos-blockchain/research.git
synced 2026-08-09 12:43:31 +00:00
The spec bounds an uncle's own slot (0 < sl_A - sl_U <= w_u) but leaves its PARENT unconstrained beyond lying on the referencing chain. So a block minted NOW, built on a chain block from arbitrarily far back, is a legal first-fork uncle: recent by its own slot, ancient by its parent's. Verifying it means deriving the epoch state and ledger root as of that ancient parent, per reference, and those are precisely the inputs the counting rules require -- so the work cannot be amortised. It costs the adversary nothing beyond lottery wins it already has; it just builds them somewhere useless. Measured with a deep_parent coalition. At the deployed operating point a 30% adversary moves the MEDIAN counted reference's reach from 54 slots back to 20,144, and the worst case to 76,778 -- the epoch boundary, ~21 hours of history, ~256x the nominal window. It is not a tail effect. The fix is a SUBSTITUTION, not an additional rule. A block strictly postdates its parent and a referenced uncle strictly precedes its referencer, so sl_A - sl_U < sl_A - sl_parent(U) <= w_u: bounding the parent bounds the uncle for free, and a both-windows variant would be identical to the parent one. Both invariants are pinned in a new test_slot_ordering.py rather than argued -- the user asked to confirm sl_A > sl_U explicitly, and it turns out to be load-bearing for the whole implication, so it is tested at three geometries plus a hand-built counting case. Under the parent anchor the same coalition reaches 292/300/300 slots at delta_max 4/8/16 -- capped by construction. Honest recovery is unaffected: 0.9993 -> 0.9999, 0.9969 -> 0.9986, 0.9791 -> 0.9858, no loss anywhere within one to two SEM, because a latency orphan's parent is recent by construction. One finding that sharpens the case: at delta_max = 16 the HONEST uncle-anchored arm already reaches 315 slots, past its own w_u = 300. Under the current rule w_u is not a bound on validation reach even with no adversary present. It only becomes a state-retention bound once anchored to the parent. Recorded as sec 6.12 with fig38, a new row in the sec 8.5 spec deltas, both new knobs in sec 7, and the study in sec 9. uncle_window_anchor and the deep_parent strategy are appended to the RNG key only when non-default, so no committed run is reseeded. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
219 lines
10 KiB
Python
219 lines
10 KiB
Python
import dataclasses
|
|
|
|
import pytest
|
|
|
|
from tsi_sim.config import SimConfig, SweepConfig
|
|
|
|
|
|
def test_expand_cardinality_and_u0_strategy_dedup():
|
|
# full_mesh, where `latency` (L) IS a live axis, so the x2 latency factor applies.
|
|
sweep = SweepConfig(
|
|
n_nodes=[1000, 2000],
|
|
stake_dist=["uniform"],
|
|
topology=["full_mesh"],
|
|
latency=[0, 4],
|
|
max_uncles=[0, 1, 2],
|
|
uncle_strategy=["oldest", "random"],
|
|
replicates=3,
|
|
base={"k": 16, "epochs": 5},
|
|
)
|
|
configs = sweep.expand()
|
|
# U=0 keeps only the first strategy; U>0 keeps both.
|
|
# per (n,dist,lat): U0 x1 strat + U1 x2 + U2 x2 = 5 strat-U combos, x3 reps = 15
|
|
# x 2 n_nodes x 1 dist x 2 lat = 60
|
|
assert len(configs) == 60
|
|
u0 = [c for c in configs if c.max_uncles == 0]
|
|
assert all(c.uncle_strategy == "oldest" for c in u0)
|
|
assert {c.replicate for c in configs} == {0, 1, 2}
|
|
|
|
|
|
def test_latency_collapsed_for_graph_topologies():
|
|
# `latency` is the full_mesh-only uniform-L knob; regular/blend ignore it, so sweeping it
|
|
# must NOT emit duplicate (seed-shifted) graph cells.
|
|
for topo in ("regular", "blend"):
|
|
sweep = SweepConfig(
|
|
n_nodes=[100], topology=[topo], degree=[4], latency=[0, 2, 4], max_uncles=[0],
|
|
uncle_strategy=["oldest"], replicates=1, base={"k": 8, "epochs": 3},
|
|
)
|
|
configs = sweep.expand()
|
|
assert len(configs) == 1, topo # 3 latency values collapse to 1
|
|
assert configs[0].latency == 0
|
|
|
|
|
|
def test_base_propagation():
|
|
sweep = SweepConfig(n_nodes=[500], stake_dist=["pareto"], latency=[2], max_uncles=[0],
|
|
uncle_strategy=["oldest"], replicates=1,
|
|
base={"k": 32, "epochs": 7, "fixed_point": True})
|
|
(c,) = sweep.expand()
|
|
assert c.k == 32 and c.epochs == 7 and c.fixed_point is True and c.stake_dist == "pareto"
|
|
|
|
|
|
@pytest.mark.parametrize("kwargs", [
|
|
{"k": 0}, {"epochs": 0}, {"n_nodes": 0}, {"latency": -1}, {"max_uncles": -1},
|
|
{"uncle_window": 0}, {"lottery_chunks": 0}, {"uncle_random_p": 1.5}, {"f": 0.0},
|
|
{"f": 1.0}, {"beta": 0.0}, {"genesis_d_factor": 0.0}, {"pareto_shape": 0.0},
|
|
{"stake_dist": "zipf"}, {"uncle_strategy": "newest"}, {"topology": "star"},
|
|
{"blend_hops": 0}, {"blend_delay_max": -1.0},
|
|
{"adversary_period": -1}, {"adversary_withhold_epochs": -1},
|
|
# a schedule may not withhold for more epochs than its own period
|
|
{"adversary_period": 2, "adversary_withhold_epochs": 3},
|
|
# blend needs `blend_hops` distinct relays from the non-producer pool (n-1 of them)
|
|
{"topology": "blend", "n_nodes": 4, "degree": 2, "blend_hops": 4},
|
|
])
|
|
def test_validation_rejects_bad_fields(kwargs):
|
|
with pytest.raises(ValueError):
|
|
SimConfig(**kwargs)
|
|
|
|
|
|
def test_blend_dedup_does_not_multiply_non_blend_configs():
|
|
# blend_hops / blend_delay_max only affect blend runs; sweeping them must not duplicate
|
|
# the regular / full_mesh cells.
|
|
sweep = SweepConfig(
|
|
n_nodes=[100], stake_dist=["uniform"], topology=["regular", "blend"],
|
|
degree=[4], link_latency_mean=[1.0], link_latency_dist=["fixed"],
|
|
blend_hops=[2, 3], blend_delay_max=[1.0, 3.0], max_uncles=[0],
|
|
uncle_strategy=["oldest"], init_dest=["common"], replicates=1,
|
|
base={"k": 8, "epochs": 3},
|
|
)
|
|
configs = sweep.expand()
|
|
regular = [c for c in configs if c.topology == "regular"]
|
|
blend = [c for c in configs if c.topology == "blend"]
|
|
assert len(regular) == 1 # blend knobs collapsed for non-blend
|
|
assert len(blend) == 4 # 2 hops x 2 delay_max
|
|
assert {(c.blend_hops, c.blend_delay_max) for c in blend} == {
|
|
(2, 1.0), (2, 3.0), (3, 1.0), (3, 3.0)}
|
|
|
|
|
|
def test_uncle_window_sweeps_and_collapses_for_u0():
|
|
# OLD model: uncle_window is a live axis for U>0, but U=0 references no uncles so it
|
|
# must collapse. (The countable model ignores uncle_window entirely — see the twin
|
|
# test below.)
|
|
sweep = SweepConfig(
|
|
n_nodes=[100], stake_dist=["uniform"], topology=["blend"], degree=[6],
|
|
link_latency_mean=[0.5], link_latency_dist=["geo"], blend_hops=[3],
|
|
blend_delay_max=[4.0], uncle_window=[10, 100], max_uncles=[0, 1],
|
|
uncle_strategy=["oldest"], init_dest=["common"], replicates=1,
|
|
base={"k": 8, "epochs": 3, "uncle_model": "old"},
|
|
)
|
|
configs = sweep.expand()
|
|
u0 = [c for c in configs if c.max_uncles == 0]
|
|
u1 = [c for c in configs if c.max_uncles == 1]
|
|
assert len(u0) == 1 # W collapsed for U=0
|
|
assert {c.uncle_window for c in u1} == {10, 100} # both W kept for U=1
|
|
|
|
|
|
def test_window_absorption_sweeps_and_ignored_axis_collapses():
|
|
# COUNTABLE model: window_absorption is the live window axis; uncle_window is ignored
|
|
# and must collapse. And vice versa for the old model (guarded above).
|
|
sweep = SweepConfig(
|
|
n_nodes=[100], stake_dist=["uniform"], topology=["blend"], degree=[6],
|
|
link_latency_mean=[0.5], link_latency_dist=["geo"], blend_hops=[3],
|
|
blend_delay_max=[4.0], uncle_window=[10, 100], window_absorption=[2.0, 4.0],
|
|
max_uncles=[0, 1], uncle_strategy=["oldest"], init_dest=["common"], replicates=1,
|
|
base={"k": 8, "epochs": 3},
|
|
)
|
|
configs = sweep.expand()
|
|
u0 = [c for c in configs if c.max_uncles == 0]
|
|
u1 = [c for c in configs if c.max_uncles == 1]
|
|
assert len(u0) == 1 # all window knobs collapse
|
|
assert {c.window_absorption for c in u1} == {2.0, 4.0} # live axis kept for U=1
|
|
assert {c.uncle_window for c in u1} == {10} # ignored axis collapsed
|
|
|
|
|
|
def test_unknown_sweep_key_rejected():
|
|
with pytest.raises(ValueError, match="unknown sweep keys"):
|
|
SweepConfig.from_dict({"latencies": [0, 1], "base": {}}) # typo: latencies vs latency
|
|
|
|
|
|
def test_from_dict_roundtrip_ok():
|
|
sw = SweepConfig.from_dict({"latency": [0, 3], "max_uncles": [0, 2], "replicates": 2,
|
|
"base": {"k": 8, "epochs": 4}})
|
|
assert sw.latency == [0, 3] and sw.replicates == 2 and sw.base["k"] == 8
|
|
|
|
|
|
def test_key_covers_every_field():
|
|
# Guard against the silent shared-RNG bug: key() must reflect all run-affecting fields.
|
|
# root_seed enters _entropy separately; windowed_fork_choice is a pure compute
|
|
# optimisation (no RNG, identical results) so it is intentionally not in key().
|
|
# early_stop is truncation-only (per-epoch RNG streams are pre-spawned, so the epochs
|
|
# that DO run are bit-identical to a full run's prefix) — intentionally excluded from key().
|
|
# uncle_window is read ONLY by the old model; under the (default) countable model it is
|
|
# an ignored field, deliberately left in the base tuple at its old position so that an
|
|
# --old run's key stays byte-identical to historical keys.
|
|
# paired_streams must NOT be in key(): it selects WHICH key the RNG root is derived from
|
|
# (see seed_key), so putting it in key() would perturb every historical seed and break
|
|
# --old bit-reproduction. Its own behaviour is pinned in test_rng.py.
|
|
ignored = {"root_seed", "windowed_fork_choice", "prune_arrival", "early_stop",
|
|
"uncle_window", "paired_streams"}
|
|
names = {f.name for f in dataclasses.fields(SimConfig)} - ignored
|
|
a = SimConfig()
|
|
for name in names:
|
|
cur = getattr(a, name)
|
|
alt = _perturb(cur)
|
|
b = dataclasses.replace(a, **{name: alt})
|
|
assert a.key() != b.key(), f"key() does not distinguish field {name!r}"
|
|
# ... and uncle_window IS distinguished under the old model, where it is live.
|
|
old = SimConfig(uncle_model="old")
|
|
assert old.key() != dataclasses.replace(old, uncle_window=old.uncle_window + 1).key()
|
|
# paired_streams leaves key() untouched but DOES change the seed derived from it.
|
|
a_paired = dataclasses.replace(a, paired_streams=True)
|
|
assert a.key() == a_paired.key()
|
|
assert a.seed_key() != a_paired.seed_key()
|
|
|
|
|
|
def test_old_model_key_is_historical():
|
|
# --old must bit-reproduce historical runs: its key is exactly the pre-uncle_model
|
|
# tuple (no uncle_model / window_absorption entries), and the countable key extends it.
|
|
old = SimConfig(uncle_model="old")
|
|
new = SimConfig()
|
|
assert new.key()[: len(old.key())] == old.key()
|
|
assert new.key()[len(old.key()):] == ("countable", new.window_absorption)
|
|
# window_absorption is ignored (and absent from key) under the old model...
|
|
assert dataclasses.replace(old, window_absorption=2.0).key() == old.key()
|
|
# ...and live under the countable model.
|
|
assert dataclasses.replace(new, window_absorption=2.0).key() != new.key()
|
|
|
|
|
|
def test_effective_uncle_window():
|
|
# countable: derived w_u = round(W / f); old: uncle_window taken directly.
|
|
assert SimConfig(k=2160).effective_uncle_window == 300 # W=10, f=1/30
|
|
assert SimConfig(k=2160, window_absorption=5.0).effective_uncle_window == 150
|
|
assert SimConfig(uncle_model="old", uncle_window=42).effective_uncle_window == 42
|
|
|
|
|
|
def test_window_absorption_bound():
|
|
import warnings
|
|
|
|
with pytest.raises(ValueError):
|
|
SimConfig(window_absorption=0.5) # W < 1 rejected
|
|
with pytest.warns(RuntimeWarning, match="exceeds the spec bound"):
|
|
SimConfig(k=8, window_absorption=10.0) # W > 0.6*k warns
|
|
with warnings.catch_warnings():
|
|
warnings.simplefilter("error")
|
|
SimConfig(k=2160, window_absorption=10.0) # full scale: silent
|
|
SimConfig(k=8, uncle_model="old", uncle_window=300) # old model: no bound
|
|
|
|
|
|
def _perturb(v):
|
|
if isinstance(v, bool):
|
|
return not v
|
|
if isinstance(v, int):
|
|
return v + 1
|
|
if isinstance(v, float):
|
|
# stay inside [0, 1]-capped fields (e.g. jitter_frac defaults to 1.0)
|
|
return v - 0.001 if v >= 1.0 else v + 0.001
|
|
flips = {
|
|
"uniform": "pareto", "oldest": "random", "full_mesh": "regular",
|
|
"fixed": "exp", "common": "heterogeneous", "suppress": "withhold",
|
|
"exp": "poisson", # jitter_dist
|
|
"sine": "ramp", # churn_mode
|
|
"countable": "old", # uncle_model
|
|
# adversary_selection. Keyed by VALUE, so this only fires on fields that are currently
|
|
# "random" — uncle_strategy defaults to "oldest" and keeps its own flip above.
|
|
"random": "whale",
|
|
"uncle": "parent", # uncle_window_anchor
|
|
}
|
|
if isinstance(v, str) and v in flips:
|
|
return flips[v]
|
|
return v
|