2026-07-30 18:57:10 +02:00

11 KiB
Raw Blame History

tsi-sim-pernode — Cryptarchia TSI per-node network simulator (Phase 2)

The reduced-model simulators (../tsi-sim/, ../tsi-sim-mc/) collapse the network to one global canonical chain and one scalar D_est per epoch. This package removes that collapse: every one of the N nodes runs TSI individually with its own D_est, from its own partial view of the block tree under explicit message propagation over a peering graph. Its job is to test the reduced model's assumption that all honest nodes agree.

What it models

  • Per-node lottery: node i wins a slot with φ_f(w_i / D_est_i)D_est is a length-N vector, each node self-updating from its own view (the reduced model's key reuse: the sparse sampler already takes a per-node probability vector).
  • Topology (topology): three propagation models over the network.
    • full_mesh: every node one hop away with uniform latency L — reproduces the reduced model exactly (validation baseline).
    • regular: a random d-regular peering graph (configurable degree) with per-link latency (link_latency_dist ∈ {fixed, uniform, exp, geo}, all with mean link_latency_mean). A block reaches a node after the shortest weighted path from its producer (gossip flooding). Models direct block gossip.
    • blend: the same d-regular graph, but a block is first routed through the Blend mixnet before it is public — the producer picks blend_hops distinct relay nodes uniformly at random, the block hops producer → r₁ → … → r_hops over the graph, each relay waiting a Uniform(0, blend_delay_max) mixing delay before forwarding, and the last relay's forward is the final network-wide gossip that makes the block visible. Relays are blind forwarders (they learn the block only from that final gossip). The dominant latency is the per-hop mixing, not the graph transport — this is the multi-slot regime where forks and the stake underestimate appear and uncle references matter. Because the mixing delays are Uniform-bounded, the windowed fork choice stays exact (horizon (blend_hops+1)·max_path_latency + blend_hops·blend_delay_max).
  • Real-world latency (units). Latency is in slots and a slot is 1 s, so measured internet latencies (tenshundreds of ms) are fractions of a slot; arrivals are therefore kept sub-slot (float), not rounded to whole slots. link_latency_dist=geo draws each link from a geographic band mixture (~15 ms metro → ~200 ms antipodal, EU↔EU ≪ EU↔AU), rescaled so link_latency_mean stays the mean-latency knob. So regular runs the realistic sub-slot direct-gossip regime (~0.050.2 slot), where forks are rare, and blend runs the multi-slot Blend-mixnet regime, where per-hop mixing delays dominate.
  • Per-node views: one global block tree plus an (N × n_blocks) arrival matrix A; each node builds on / measures density over the blocks that have arrived at it. Uncle refs are baked at production from the producer's view (faithful — immutable once adopted).
  • Metrics: per-node D_est spread (range, IQR), canonical-chain agreement (window prefix vs current tip), mean accuracy, and — with init_dest=heterogeneous — transient re-convergence.

Headline result

Per-node D_est disagreement collapses to zero. Because TSI reads density from a window buried far past k-finality, and all nodes seed the recursion from a common hardcoded genesis D, every node computes the same measured density midentical D_est (range ≈ 0, agreement_window = 1) — even under a sparse graph with high latency and heavy tip-level forking (agreement_tip can drop well below 1). This validates the reduced model. Topology/latency instead shift the shared mean accuracy (via fork rate → q), which uncle references recover just as in the reduced model. (Injected heterogeneous disagreement, which the real protocol never creates, is preserved by the common multiplicative update — a cautionary note, not protocol behaviour.)

Quick start

make install         # venv + editable install
make test            # unit + fast per-node checks
make verify          # per-node validation (parity, spread→0, agreement, topology effect)
# Run any configs/<name>.yaml by its stem (auto-discovered); each writes a dated runs/ folder:
make smoke           # tiny end-to-end grid + figures  (configs/smoke.yaml)
make default         # scaled-k divergence/topology sweep + figures  (configs/default.yaml)
make fullscale       # full-scale (true k) confirmation  (configs/fullscale.yaml)
make figures RESULTS=runs/<dir>/results.parquet          # re-render figures from a run
# Extra sweep flags: make fullscale SWEEP_ARGS="--batch-size 1 --mem-frac 0.6"

Scale & performance

  • Representation: one global block tree + (N × n_blocks) float64 arrival matrix A (sub-slot arrivals); topology path_latency[N,N] (per-node Dijkstra, once per trajectory).
  • n_blocks is NOT ~10·k in general — it tracks block production. n_blocks is the number of lottery wins in an epoch, ≈ E·Σᵢφ(wᵢ/D_est). At equilibrium that is ~10·k (≈ 22k at k=2160), but when D_est is far below the true stake — the collapsed-estimate regime, e.g. a small genesis_d_factorΣ(stake)/D_est = 1/genesis_d_factor is large and block production explodes proportionally. At genesis_d_factor=0.01, genesis epoch-0 produces ~2.0M blocks (100× equilibrium) → A ≈ 15 GB for a single worker; D_est self-corrects to equilibrium within ~2 epochs, so only the earliest epoch(s) are heavy. Raising genesis_d_factor toward 0.10.5 collapses this cost (0.1 → ~0.22M blocks → ~1.6 GB; 0.5 → ~44k → ~0.4 GB) and does not change the equilibrium result, which is measured after burn-in.
  • Sliding-window prune (prune_arrival, default on): the arrival matrix never needs per-node columns for blocks past the horizon — under deterministic latency a block with slot ≤ t H has reached every node, so its column is finalized and dropped. We keep columns only for blocks inside max(horizon, uncle_window) slots in a base-offset buffer, turning the O(N·n_blocks) matrix into O(N · keep-span-blocks). This is what makes the collapsed regime affordable: at N=1000/k=2160/gdf=0.01 the buffer is ~tens of MB instead of the ~15 GB full matrix (fork choice, the parent clamp, uncle selection, and per-node tips all reconstruct exactly from it). It is bit-identical to the full matrix at jitter_mean == 0 (proven by test_prune_matches_full_matrix across topologies/uncles/gdf); with jitter it falls back to the full matrix (whose safety clamp keeps the tree valid). Set prune_arrival: false to force the full matrix (the parity oracle). The measurement pass also argmaxes in node-row bands so it adds only a small temporary. Divergence sweeps run at scaled k=256 (configs/default.yaml); full-scale k=2160 is validated to N ≤ 2000.
  • Worker sizing (auto, RAM-safe): both A (~N·n_blocks, incl. the block explosion above) and path_latency (~N²) grow, so the sweep runner sizes the loky pool to fit a RAM budget (--mem-frac, default 0.7 of physical RAM) instead of blindly using every core. The per-worker estimate realises the seeded stake to compute the genesis-epoch block count (expected_peak_blocks), so it reflects a low-genesis_d_factor explosion rather than assuming ~10·k. A calibration probe measures a real worker's peak RSS (one genesis epoch of the heaviest config in a spawned process) whenever the estimate is heavy or N > 2000 (--calibrate {auto,always,never}, default auto; the probe bounds itself to physical RAM so it fails loud rather than freezing).
  • Fail-loud memory guard (memguard.py): every worker checks size before allocating both big arrays — the (N × n_blocks) A (in build_tree_pernode) and the (N × N) path_latency (in build_path_latency, built first) — and raises ArrivalMatrixTooLarge if it would exceed the budget TSI_ARRIVAL_BYTES_BUDGET. The sweep sets that to each worker's RAM share; unset or 0 is not "unlimited" — it resolves to DEFAULT_BUDGET_FRAC (0.9) of physical RAM, so a bare run_trajectory, tsi-verify, the probe, or a --mem-frac 0 run all keep an absolute per-process ceiling. So a mis-estimated block explosion (or a huge N) fails with a clear message instead of freezing the machine.
  • Cost: dominated by the per-node fork choice (batched per slot) and the arrival-matrix fill; the sparse lottery is negligible. Across-config joblib loky parallelism reused.
  • Measurement optimisation (measure.py): the per-node canonical/density/agreement pass was ~95% of an epoch. It is now deduped by tip (nodes sharing a tip share every derived quantity — high agreement collapses N to a handful of computations) and the per-tip chain walk runs as a cached numba kernel (pure-Python fallback if numba is absent). Exact — bit-identical to the naive loop (test_measure). Measured ~9× end-to-end (heavy config 11.3 s → 1.2 s) and ~14× on measurement-bound configs. numba comes via the accel extra (pip install -e ".[dev,accel]", done by make install).
  • Windowed fork choice (windowed_fork_choice, default on): bounds the block-tree build's per-slot candidate scan to a horizon of the max path latency plus the fully-propagated best tip, turning O(n_blocks^2) fork choice into O(n_blocks*H). Exact when link latency is deterministic (jitter_mean == 0) — bit-identical to a full scan (parity test). With jitter_mean > 0 it becomes a tiny approximation and warns; a safety clamp still keeps the tree valid, and windowed_fork_choice=False forces a guaranteed-exact full scan.
  • Reproducibility: every draw spawns off SeedSequence(hash(config)) — child 0 stake, 1 graph, 2 init, 3+e epoch e. graph_seed/degree/link_latency_* are part of the config identity.
    • numpy-version caveat: the accel extra (numba) requires numpy<2.5, so installing it pins numpy to 2.4.x. numpy's Generator.choice(replace=False) is not stream-stable across the 2.4↔2.5 boundary, and the sparse lottery uses it heavily only in the degenerate collapsed-estimate regime (D_est → 0 ⇒ win-prob → 1 ⇒ count ≈ n_slots). So a run on numpy 2.5 and a run on numpy 2.4 give identical results for all normal configs but can diverge chaotically in that one extreme regime (e.g. degree=4, link_latency=8, where the estimate has already collapsed to ~0.13 — off the safe chart). The differences are tiny (max |Δ mean_ratio| ≈ 3e-3) and change no conclusion; pin numpy if bit-reproducibility across environments is required.

Layout

src/tsi_sim/   constants config rng stake lottery topology blocktree(+build_tree_pernode)
               uncles(+select_uncles_at_production) tsi(+update_D_vec) epoch engine metrics
               theory verify  plotting/{style, figures_pernode, make_figures}
configs/       smoke.yaml  default.yaml  fullscale.yaml
tests/         test_{pernode,config,rng,lottery,blocktree,uncles,tsi_counting,stake,
                     theory,latency,theory_convergence}.py