import numpy as np import pandas as pd import pytest from tsi_sim import constants, lottery, topology, tsi from tsi_sim.blocktree import GENESIS, build_tree_pernode from tsi_sim.config import SimConfig from tsi_sim.engine import run_trajectory def _build(cfg, replicate=0): """Build one epoch's (tree, A, path_latency) for a config.""" root = np.random.SeedSequence(abs(hash((cfg.key(), replicate))) % (2**63)) ch = root.spawn(4) stake = np.ones(cfg.n_nodes) * (cfg.total_stake / cfg.n_nodes) pl = topology.build_path_latency(cfg, np.random.default_rng(ch[1])) d = np.full(cfg.n_nodes, cfg.genesis_d_factor * cfg.total_stake) p = lottery.win_probs(stake, d, cfg.f) ws, wn = lottery.sample_wins(p, cfg.epoch_len, np.random.default_rng(ch[2])) active, groups = lottery.group_by_slot(ws, wn) tree, A = build_tree_pernode(active, groups, pl, cfg, np.random.default_rng(ch[3])) return tree, A, pl # --- topology --------------------------------------------------------------- def test_full_mesh_path_latency(): cfg = SimConfig(n_nodes=50, topology="full_mesh", latency=5) pl = topology.build_path_latency(cfg, np.random.default_rng(0)) assert pl.shape == (50, 50) assert np.all(np.diag(pl) == 0) off = pl[~np.eye(50, dtype=bool)] assert np.all(off == 5) def test_geo_link_latency_mean_preserved_and_banded(): # "geo" draws real-world geographic latency bands but rescales them so the configured # link_latency_mean stays the single mean-latency knob. cfg = SimConfig(n_nodes=300, topology="regular", degree=8, link_latency_mean=0.08, link_latency_dist="geo") w = topology._sample_link_latencies(50_000, cfg, np.random.default_rng(3)) assert abs(w.mean() - cfg.link_latency_mean) < 0.02 * cfg.link_latency_mean # E[w] == mean scale = cfg.link_latency_mean / constants.GEO_LATENCY_MEAN_SLOTS expected = {round(b * scale, 9) for b in constants.GEO_LATENCY_BANDS_SLOTS} assert {round(x, 9) for x in np.unique(w)} == expected # exact bands # realistic direct-gossip: end-to-end (multi-hop) propagation stays a fraction of a slot pl = topology.build_path_latency(cfg, np.random.default_rng(4)) assert float(pl[~np.eye(cfg.n_nodes, dtype=bool)].mean()) < 1.0 def test_regular_graph_connected_symmetric_and_degree(): cfg = SimConfig(n_nodes=100, topology="regular", degree=6, link_latency_mean=1.0) pl = topology.build_path_latency(cfg, np.random.default_rng(1)) assert np.all(np.diag(pl) == 0) assert np.allclose(pl, pl.T) # undirected assert np.all(np.isfinite(pl)) and pl.max() < cfg.epoch_len # connected # direct neighbours (latency == link mean 1) — each node has exactly `degree` of them neigh = (pl == 1).sum(axis=1) assert np.all(neigh == 6) # --- blend mixnet topology -------------------------------------------------- def test_blend_reuses_the_regular_graph(): # blend transport runs over the SAME weighted d-regular graph as `regular`. common = dict(n_nodes=120, degree=6, link_latency_mean=1.0, link_latency_dist="fixed", graph_seed=7) plr = topology.build_path_latency(SimConfig(topology="regular", **common), np.random.default_rng(0)) plb = topology.build_path_latency(SimConfig(topology="blend", **common), np.random.default_rng(0)) np.testing.assert_array_equal(plr, plb) def test_blend_arrival_bounded_and_producer_instant(): # inspects the raw (N x n_blocks) arrival matrix, so keep it (prune stores a sliding buffer) cfg = SimConfig(n_nodes=120, topology="blend", degree=6, link_latency_mean=1.0, blend_hops=3, blend_delay_max=3.0, k=8, max_uncles=0, prune_arrival=False) tree, A, pl = _build(cfg) # hard cascade bound used by the windowed horizon: (hops+1) transport legs + hops mix delays H = (cfg.blend_hops + 1) * float(pl.max()) + cfg.blend_hops * cfg.blend_delay_max assert np.all(A[:, GENESIS] == 0) for b in range(1, tree.n_blocks): s = float(tree.slot[b]) v = int(tree.leader[b]) assert A[v, b] == max(s, float(A[v, int(tree.parent[b])])) # producer sees own instantly assert np.all(A[:, b] - s <= H + 1e-9) # nobody exceeds the bound assert np.all(A[:, b] >= A[:, int(tree.parent[b])]) # never before parent def test_blend_mixing_delay_increases_arrival(): def mean_rel_arrival(delay_max): cfg = SimConfig(n_nodes=120, topology="blend", degree=6, link_latency_mean=1.0, blend_hops=3, blend_delay_max=delay_max, k=8, max_uncles=0, prune_arrival=False) tree, A, _ = _build(cfg) rel = [float((np.delete(A[:, b], int(tree.leader[b])) - float(tree.slot[b])).mean()) for b in range(1, tree.n_blocks)] return float(np.mean(rel)) # more mixing delay per hop => strictly later visibility on average assert mean_rel_arrival(0.0) < mean_rel_arrival(6.0) # --- arrival matrix invariants ---------------------------------------------- def test_arrival_clamp_and_genesis(): cfg = SimConfig(n_nodes=60, topology="regular", degree=6, link_latency_mean=2.0, k=8, max_uncles=0, prune_arrival=False) # inspects the raw arrival matrix tree, A, _ = _build(cfg) assert np.all(A[:, GENESIS] == 0) # genesis seen by all at slot 0 for b in range(1, tree.n_blocks): p = int(tree.parent[b]) assert np.all(A[:, b] >= A[:, p]) # never arrive before parent v = int(tree.leader[b]) # producer sees its own block at its slot (sub-slot/float arrivals) assert A[v, b] == max(float(tree.slot[b]), float(A[v, p])) def test_full_mesh_zero_divergence_parity(): # Full mesh L=0: identical views -> identical per-node D_est -> range 0, agreement 1. df = pd.DataFrame(run_trajectory(SimConfig( n_nodes=200, topology="full_mesh", latency=0, max_uncles=0, k=16, epochs=16))) assert (df["range_ratio"] == 0.0).all() assert (df["agreement_window"] == 1.0).all() assert abs(df["mean_ratio"].iloc[-4:].mean() - 1.0) < 0.1 def test_regular_graph_window_agreement_holds(): # Even under a graph, the settled window agrees -> zero D_est spread. df = pd.DataFrame(run_trajectory(SimConfig( n_nodes=200, topology="regular", degree=6, link_latency_mean=3.0, max_uncles=2, k=12, epochs=10))) assert (df["range_ratio"] < 1e-9).all() assert (df["agreement_window"] > 0.999).all() def test_topology_shifts_mean_accuracy(): def mean_ratio(**kw): vals = [pd.DataFrame(run_trajectory(SimConfig( n_nodes=200, topology="regular", max_uncles=0, k=12, epochs=12, replicate=r, **kw) ))["mean_ratio"].iloc[6:].mean() for r in range(4)] return float(np.mean(vals)) sparse = mean_ratio(degree=2, link_latency_mean=6.0) dense = mean_ratio(degree=16, link_latency_mean=1.0) assert sparse < dense # more forks -> lower estimate # --- windowed fork choice --------------------------------------------------- @pytest.mark.parametrize("kw", [ dict(topology="regular", degree=6, link_latency_mean=3.0, max_uncles=4), dict(topology="regular", degree=2, link_latency_mean=8.0, max_uncles=2), # sparse: big H dict(topology="full_mesh", latency=5, max_uncles=2), dict(topology="full_mesh", latency=0, max_uncles=0), # blend: mixing delays are Uniform-bounded, so the windowed horizon stays exact dict(topology="blend", degree=6, link_latency_mean=1.0, blend_hops=3, blend_delay_max=3.0, max_uncles=4), dict(topology="blend", degree=4, link_latency_mean=2.0, blend_hops=2, blend_delay_max=5.0, max_uncles=2), ]) def test_windowed_fork_choice_matches_full_scan(kw): # Deterministic latency (jitter=0): the windowed horizon must be BIT-IDENTICAL to a full scan. # windowed_fork_choice is not in key(), so both share the same seed/inputs. prune_arrival is # disabled here so both keep a full matrix A (the pruned==full parity is test_prune_* below). tw, Aw, _ = _build(SimConfig(n_nodes=150, k=10, windowed_fork_choice=True, prune_arrival=False, **kw)) tf, Af, _ = _build(SimConfig(n_nodes=150, k=10, windowed_fork_choice=False, prune_arrival=False, **kw)) np.testing.assert_array_equal(tw.parent, tf.parent) np.testing.assert_array_equal(tw.height, tf.height) np.testing.assert_array_equal(Aw, Af) assert tw.uncles == tf.uncles def test_jitter_warns_under_windowed(): cfg = SimConfig(n_nodes=80, topology="regular", degree=6, link_latency_mean=2.0, jitter_mean=1.0, k=8) with pytest.warns(RuntimeWarning, match="windowed_fork_choice"): _build(cfg) def test_windowed_never_builds_on_unreceived_block_under_jitter(): # jitter > 0 disables pruning (falls back to the full matrix), whose safety clamp keeps the # tree valid even under the windowed approximation. cfg = SimConfig(n_nodes=80, topology="regular", degree=6, link_latency_mean=2.0, jitter_mean=2.0, k=8, max_uncles=2) with pytest.warns(RuntimeWarning): tree, A, _ = _build(cfg) for b in range(1, tree.n_blocks): v = int(tree.leader[b]) assert A[v, int(tree.parent[b])] <= int(tree.slot[b]) # producer had the parent # --- sliding-window prune --------------------------------------------------- @pytest.mark.parametrize("kw", [ dict(topology="full_mesh", latency=3, max_uncles=2), dict(topology="regular", degree=8, link_latency_mean=1.0, max_uncles=3), dict(topology="regular", degree=4, link_latency_mean=0.1, max_uncles=2, uncle_strategy="random"), # blend deg4 (small graph horizon, W-dominated keepspan -> heavy compaction) caught an # off-by-one in the finalize boundary; low gdf forces many compactions. dict(topology="blend", degree=4, link_latency_mean=0.1, blend_hops=3, blend_delay_max=2.0, max_uncles=2, genesis_d_factor=0.05, uncle_strategy="oldest"), dict(topology="blend", degree=4, link_latency_mean=0.1, blend_hops=3, blend_delay_max=2.0, max_uncles=3, genesis_d_factor=0.05, uncle_strategy="random", init_dest="heterogeneous"), ]) def test_prune_matches_full_matrix(kw): # prune_arrival is a pure memory optimisation: at jitter=0 the pruned build must reproduce the # full matrix's per-epoch rows BIT-FOR-BIT (it is excluded from key(), so seeds match). common = dict(n_nodes=120, k=64, epochs=6, stake_dist="pareto", link_latency_dist="geo", init_spread=0.5) df_prune = pd.DataFrame(run_trajectory(SimConfig(**common, **kw, prune_arrival=True))) df_full = pd.DataFrame(run_trajectory(SimConfig(**common, **kw, prune_arrival=False))) pd.testing.assert_frame_equal(df_prune, df_full) def test_prune_returns_small_sliding_buffer(): # The collapsed regime that OOM-froze the full matrix: pruning must keep only a keep-span # window, not all n_blocks. from tsi_sim.blocktree import SlidingArrival cfg = SimConfig(n_nodes=100, k=64, stake_dist="pareto", genesis_d_factor=0.02, topology="regular", degree=8, link_latency_mean=0.1) tree, arr, _ = _build(cfg) assert isinstance(arr, SlidingArrival) assert arr.buf.shape[1] < tree.n_blocks # buffer far narrower than the block count # --- update_D_vec ----------------------------------------------------------- def test_update_D_vec_matches_scalar(): f, T = 1 / 30, 3000 d = np.array([1000.0, 500.0, 2000.0]) m = np.array([90, 100, 110]) got = tsi.update_D_vec(d, m, T, f, beta=1.0) exp = np.array([tsi.update_D(float(d[i]), int(m[i]), T, f, 1.0) for i in range(3)]) np.testing.assert_allclose(got, exp) # --- adversarial grinding (uncle suppression) ------------------------------- def test_adversary_frac_zero_is_noop(): # adversary_frac=0 -> mask None -> identical to a plain honest run (bit-for-bit rows). kw = dict(n_nodes=150, k=16, epochs=6, stake_dist="pareto", topology="blend", degree=6, link_latency_dist="geo", link_latency_mean=0.5, blend_hops=3, blend_delay_max=8.0, max_uncles=2, genesis_d_factor=0.5) a = pd.DataFrame(run_trajectory(SimConfig(**kw))) b = pd.DataFrame(run_trajectory(SimConfig(**kw, adversary_frac=0.0))) pd.testing.assert_frame_equal(a, b) def test_adversary_uncle_suppression_deflates_and_is_deterministic(): kw = dict(n_nodes=200, k=32, epochs=8, stake_dist="pareto", topology="blend", degree=6, link_latency_dist="geo", link_latency_mean=0.5, blend_hops=3, blend_delay_max=24.0, max_uncles=2, uncle_window=300, genesis_d_factor=0.5) honest = pd.DataFrame(run_trajectory(SimConfig(**kw))) adv = pd.DataFrame(run_trajectory(SimConfig(**kw, adversary_frac=0.4))) def tail(df): return df[df.epoch >= df.epochs * 0.5].mean_ratio.mean() assert tail(adv) < tail(honest) - 0.02 # grinding deflates D_hat (raises win rate) adv2 = pd.DataFrame(run_trajectory(SimConfig(**kw, adversary_frac=0.4))) pd.testing.assert_frame_equal(adv, adv2) # deterministic def test_adversary_withhold_deflates_far_more_than_suppress(): # Withholding orphans the adversary's own blocks (won slots wasted), so counted density drops # ~beta and D_hat deflates toward the active stake (1-beta)*D — much stronger than suppression. kw = dict(n_nodes=300, k=64, epochs=12, stake_dist="pareto", topology="regular", degree=8, link_latency_mean=0.3, link_latency_dist="geo", max_uncles=2, uncle_window=300, genesis_d_factor=0.5, windowed_fork_choice=False) def settled(**extra): rows = pd.DataFrame(run_trajectory(SimConfig(**kw, **extra))) return rows[lambda d: d.epoch >= 6].mean_ratio.mean() honest = settled() supp = settled(adversary_frac=0.3, adversary_strategy="suppress") wh = settled(adversary_frac=0.3, adversary_strategy="withhold") assert supp > 0.95 * honest # suppression barely moves it on sub-slot direct gossip assert wh < 0.8 * honest # withholding deflates toward (1-0.3) = 0.7 def test_reward_attribution_backward_compat(): # adversary_frac == 0 leaves the sim bit-identical and credits no adversary blocks. kw = dict(n_nodes=200, k=32, epochs=6, stake_dist="uniform", topology="regular", degree=6, link_latency_mean=0.3, link_latency_dist="geo", max_uncles=1) rows = pd.DataFrame(run_trajectory(SimConfig(**kw))) assert (rows.adv_blocks == 0).all() and (rows.adv_block_share == 0.0).all() assert (rows.honest_blocks > 0).all() def test_dynamic_withhold_schedule_gates_and_is_unprofitable(): # Equal stakes -> coalition_frac == beta exactly. A withhold-then-rejoin grinder deflates D_hat # only on its withhold epochs and earns canonical blocks only on its rejoin epochs; because it # forfeits whole epochs to depress a difficulty that helps everyone, it earns strictly LESS # than its stake share (reward/stake < 1) — dynamic withholding is self-punishing like static. beta_adv = 0.3 kw = dict(n_nodes=1000, k=64, epochs=20, stake_dist="uniform", topology="regular", degree=8, link_latency_mean=0.3, link_latency_dist="geo", max_uncles=2, uncle_window=300, genesis_d_factor=0.5, windowed_fork_choice=False, adversary_frac=beta_adv, adversary_strategy="withhold") dyn = pd.DataFrame(run_trajectory( SimConfig(**kw, adversary_period=2, adversary_withhold_epochs=1))) tail = dyn[dyn.epoch >= 10] wh = tail[tail.adversary_withholding] rj = tail[~tail.adversary_withholding] # schedule alternates; withhold epochs deflate toward active stake, rejoin epochs recover assert wh.mean_ratio.mean() < 0.85 and rj.mean_ratio.mean() > 0.90 # coalition earns ~0 while withholding, ~beta while rejoined assert wh.adv_block_share.mean() < 0.02 assert abs(rj.adv_block_share.mean() - beta_adv) < 0.05 # profitability: realized share of ALL canonical blocks over the run, vs stake share beta total_blocks = (tail.adv_blocks + tail.honest_blocks).sum() reward_over_stake = tail.adv_blocks.sum() / (beta_adv * total_blocks) assert reward_over_stake < 1.0 # strictly unprofitable for a minority coalition