Sec 6.8 recorded that "the per-node engine has no private-chain strategy", which is why every selfish result came from the global race model with uncle recovery as a free knob eta -- and why open item 5 (does the uncle cap need margin under attack-inflated orphaning?) could not be sized: a knob has no queue to overflow. adversary_strategy="selfish" adds it. The coalition mines one shared private chain and releases under the classic SM1 rules in (a, h) form: adopt when the public chain wins, match at equal length, override at a one-block lead, else wait. Only VISIBILITY is modelled -- the coalition's mining needs no special case, because a member's fork choice already builds on the private tip whenever it leads (that tip has the greatest height among blocks the member can see) and falls back to the public chain exactly when the public chain overtakes, which is the adopt branch. So the private chain forms, extends and is abandoned emergently, and the code that had to be written is the arrival matrix. Design notes worth keeping: - Private blocks reuse the sentinel `withhold` already had (never-arrives), so the existing exclusions from canonical-tip selection apply unchanged; release flips it back and gossips DIRECTLY from the producer, bypassing Blend, since an adversary has no privacy budget to respect and wants the race won. - A private chain breaks the windowed horizon's premise (a hidden block is old enough to look fully-propagated while no honest node has it, and it becomes visible LATER, which the one-way frontier pointer cannot revisit), so selfish forces the exact full scan and full matrix. - Blocks still hidden at epoch end are abandoned and hidden from the coalition too, or the canonical-tip search would crown a chain no honest node saw. Validated against Eyal-Sirer at sub-slot latency: revenue share 0.0356 vs an exact 0.0356 at alpha = 0.1, and above the closed form at higher alpha by just the margin the alpha_eff fork-amplification correction predicts (0.498 vs 0.484 at alpha = 0.4, with fork rate 0.38). Adds p_ref_honest: the reference rate over orphans produced OUTSIDE the coalition. Under a private-chain attack this diverges sharply from p_ref, and only the honest one measures the repair the report credits to uncle counting -- an attacker's own discarded blocks are its loss to bear. test_fork unpacks fork_stats positionally, so its three call sites take the new fifth value. 247 tests pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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 scalarD_estper epoch. This package removes that collapse: every one of theNnodes runs TSI individually with its ownD_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
iwins a slot withφ_f(w_i / D_est_i)—D_estis a length-Nvector, 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 latencyL— reproduces the reduced model exactly (validation baseline).regular: a random d-regular peering graph (configurabledegree) with per-link latency (link_latency_dist ∈ {fixed, uniform, exp, geo}, all with meanlink_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 picksblend_hopsdistinct relay nodes uniformly at random, the block hopsproducer → r₁ → … → r_hopsover the graph, each relay waiting aUniform(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 areUniform-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 (tens–hundreds of ms) are fractions of a slot; arrivals are therefore
kept sub-slot (float), not rounded to whole slots.
link_latency_dist=geodraws each link from a geographic band mixture (~15 msmetro →~200 msantipodal, EU↔EU ≪ EU↔AU), rescaled solink_latency_meanstays the mean-latency knob. Soregularruns the realistic sub-slot direct-gossip regime (~0.05–0.2slot), where forks are rare, andblendruns 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 matrixA; 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). - Uncle model (
uncle_model, CLI--old): the default countable model implements the spec's counting-only rules (cryptarchia-v1-protocol.md): only the first block of a fork (parent on the producer's chain) is referenceable/countable, the window is derived asw_u = window_absorption / fslots (Wexpected block-intervals, defaultW = 10→ 300 slots, boundedW ≤ 0.6·k), selection skips slots already occupied on the producer's chain and picks one uncle per slot, and the measurement pass re-checks every rule per reference (rejections tallied asdeep_ref_share). Passing--oldtotsi-sweep/tsi-verifyruns the pre-redesign model unchanged — window =uncle_windowslots, any-depth orphans referenceable, every baked reference counted — and bit-reproduces historical runs (the old model's RNG key is byte-identical to the pre-uncle_modelkey). - Metrics: per-node
D_estspread (range,IQR), canonical-chain agreement (window prefix vs current tip), mean accuracy, and — withinit_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 m → identical 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)float64arrival matrixA(sub-slot arrivals); topologypath_latency[N,N](per-node Dijkstra, once per trajectory). n_blocksis NOT~10·kin general — it tracks block production.n_blocksis the number of lottery wins in an epoch,≈ E·Σᵢφ(wᵢ/D_est). At equilibrium that is~10·k(≈ 22k at k=2160), but whenD_estis far below the true stake — the collapsed-estimate regime, e.g. a smallgenesis_d_factor—Σ(stake)/D_est = 1/genesis_d_factoris large and block production explodes proportionally. Atgenesis_d_factor=0.01, genesis epoch-0 produces ~2.0M blocks (100× equilibrium) →A ≈ 15 GBfor a single worker;D_estself-corrects to equilibrium within ~2 epochs, so only the earliest epoch(s) are heavy. Raisinggenesis_d_factortoward 0.1–0.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 withslot ≤ t − Hhas reached every node, so its column is finalized and dropped. We keep columns only for blocks insidemax(horizon, uncle_window)slots in a base-offset buffer, turning theO(N·n_blocks)matrix intoO(N · keep-span-blocks). This is what makes the collapsed regime affordable: at N=1000/k=2160/gdf=0.01the 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 atjitter_mean == 0(proven bytest_prune_matches_full_matrixacross topologies/uncles/gdf); with jitter it falls back to the full matrix (whose safety clamp keeps the tree valid). Setprune_arrival: falseto 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) andpath_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_factorexplosion 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 orN > 2000(--calibrate {auto,always,never}, defaultauto; 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(inbuild_tree_pernode) and the(N × N)path_latency(inbuild_path_latency, built first) — and raisesArrivalMatrixTooLargeif it would exceed the budgetTSI_ARRIVAL_BYTES_BUDGET. The sweep sets that to each worker's RAM share; unset or0is not "unlimited" — it resolves toDEFAULT_BUDGET_FRAC(0.9) of physical RAM, so a barerun_trajectory,tsi-verify, the probe, or a--mem-frac 0run all keep an absolute per-process ceiling. So a mis-estimated block explosion (or a hugeN) 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 collapsesNto 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 theaccelextra (pip install -e ".[dev,accel]", done bymake 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, turningO(n_blocks^2)fork choice intoO(n_blocks*H). Exact when link latency is deterministic (jitter_mean == 0) — bit-identical to a full scan (parity test). Withjitter_mean > 0it becomes a tiny approximation and warns; a safety clamp still keeps the tree valid, andwindowed_fork_choice=Falseforces a guaranteed-exact full scan. - Reproducibility: every draw spawns off
SeedSequence(hash(config))— child 0 stake, 1 graph, 2 init, 3+e epoche.graph_seed/degree/link_latency_*are part of the config identity.- numpy-version caveat: the
accelextra (numba) requiresnumpy<2.5, so installing it pins numpy to 2.4.x. numpy'sGenerator.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.
- numpy-version caveat: the
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
countable-vs-old.yaml absorption-window.yaml (countable-model studies)
fine-delay.yaml (delay 1-5 at 40 replicates: the design band, high precision)
tests/ test_{pernode,config,rng,lottery,blocktree,uncles,tsi_counting,stake,
theory,latency,theory_convergence,countable_counting,
countable_selfish,...}.py
scripts/ plot_countable_vs_old.py (countable-vs-unrestricted comparison figures)
plot_fine_delay.py (design-band accuracy + model gap with 95% CIs)
countable_selfish.py (first-fork ceiling under the selfish MDP; fig36)
adversary_variants.py (whale/jitter/slow-beta variants + the withhold-load sweep)