Three engine defects, found while building the multi-coalition study §6.9 flags
as open. The first is the serious one.
1. COALITION SIZING (engine._adversary_mask, `random` selection — the default).
The coalition was the smallest random prefix whose stake reached the target.
Under a Pareto tail a whale straddling the cut carries it far past its label:
over 60 replicates, a nominal adversary_frac of 0.4 realised a MAJORITY in
~10% of them and reached 0.97, and 0.2 reached 0.90. The median was always
on-label, which is why it hid — it distorts the tail, not the centre.
Both other places in the code that size a set by stake had already rejected
this rule: the `whale` arm uses fit-then-close, and _churn_inactive_mask
documents the identical failure ("a 30% label realising up to ~53%"). The
`random` arm kept it. Now fit-then-close in random order, and a draw where
the tail leaves no subset near the label warns instead of silently running a
different attacker. Realised stake is now within 0.1% of its label.
Re-ran the load-bearing studies. §8.4 capstone (2 of 8 replicates
contaminated, one a 61% majority): spec rule 0.994 -> 0.995, p_ref 0.936 ->
0.937. The parent-anchored variant is far more sensitive — 0.974 -> 0.990,
p_ref 0.875 -> 0.923 — because a tighter window and a larger suppressing
coalition compound, so §8.4's argument for the W = 12 pairing rested on
0.021 of cost that is really 0.006. The pairing itself survives re-measurement
and is now better supported: p_ref reaches parity at W = 12 too, not at 15.
§6.8's uncle-margin sweep and §6.5's random-arm variants are flagged as
needing re-measurement (§8.3 item 20), not silently carried.
2. SM1 NEVER TERMINATED under a forking honest network. Textbook SM1 waits while
it leads, assuming the lead returns to zero. But honest blocks fork against
each other, so the public chain's HEIGHT grows at ~(1-a)*f*(1-fork) while a
coalition sharing one view extends privately at the full a*f; past a fork rate
of ~1 - a/(1-a) the private chain outruns the public one and `wait` never
fires. The lead ran to thousands and every block was stranded at the epoch
boundary — 98% of adversarial blocks at alpha=0.4, delta_max=8 — scoring an
attacker that WON the race as having earned nothing. selfish_lead_cap
(default: the finality depth k) publishes a lead that can no longer be caught.
Inert unless `wait` stops terminating; pinned paired.
3. RIVAL COALITIONS (adversary_coalitions = K) for the §6.9 study: K private
chains, each invisible to the others by the same arrival sentinel that hides
them from honest nodes, so they orphan each other as well as the honest chain.
Stake-balanced partition (LPT), K=1 bit-identical to the single-coalition path.
Also corrects §8.4's closing paragraph, which still quoted a pre-countable
D-hat/D of 1.001 and fork rates that contradicted its own table.
Co-Authored-By: Claude Opus 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). - Adversaries (
adversary_strategy, over a coalition holdingadversary_fracof stake, selected at random oradversary_selection: whalefor the largest holders at matched stake):suppress— produces normally but references no uncles, starving the recovered density.withhold— never gossips its blocks; abstention, a dead loss to the attacker.selfish— mines a private chain and releases it under Eyal–Sirer SM1 rules, orphaning honest work. Only visibility is modelled: a coalition member's fork choice builds on the private tip whenever it leads, so the chain forms and is abandoned emergently. Forces the exact full scan, since a hidden block breaks the windowed horizon's premise.
- Metrics: per-node
D_estspread (range,IQR), canonical-chain agreement (window prefix vs current tip), mean accuracy, fork structure (fork_rate, reorg depth,p_refandp_ref_honest, anddeep_orphan_share— the share of orphans below their fork's first block, i.e. unreferenceable by construction), 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)
uncle-selection.yaml (the spec's oldest-first rule vs a deviating proposer)
spec-point-{n5000,window,jitter}.yaml (the DEPLOYED operating point: delta_max=4
from the spec's Blend profile — size, window and per-recipient-variance arms)
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)
deflation_frontier.py (how far a PAID adversary can deflate D_est; fig37)
selfish_uncle_margin.py (does the uncle cap need margin under a private chain?)
spec_point.py (the deployed operating point; the three f-precision arms)
spec_jitter.py (per-recipient delay variance — the transport diagnostic)
Modelling the deployed chain rather than the mechanism
Two defaults are deliberately not spec-faithful, because the report's job is to isolate mechanisms. Flip both for any run meant to answer "what would the deployed chain read":
| knob | default (design) | spec-faithful | why it matters |
|---|---|---|---|
fixed_point / f_precision |
False / 1e6 — exact f |
True / 1000 |
the spec quantises the target rate at 1e3, which reads +1.0 % high; this is the largest error in the deployed estimator |
uncle_model |
countable — the spec's rules |
(same) | --old is an unreachable ceiling, not an alternative: the spec rejects a block carrying a reference that fails the counting rules |
scripts/spec_point.py runs both arms side by side.