2026-07-30 18:57:10 +02:00
|
|
|
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():
|
Countable uncle model: spec counting rules, sweeps, figures
Implement the countable uncle model from the Cryptarchia spec's
counting-only reference rules, and make it the simulator default.
Counting rules (uncles.py, measure.py):
- Only the first block of a fork (parent on the producer's chain) is
referenceable and countable, which makes every reference verifiable
from chain data alone.
- The reference window is derived from a window-absorption parameter,
w_u = W_abs/f slots (W_abs in expected block-intervals, default 10,
bounded W_abs <= 0.6*k), replacing the free-standing uncle_window.
- Selection skips slots already occupied on the producer's chain and
takes at most one uncle per slot.
- The measurement pass re-checks every rule per reference and tallies
rejections as deep_ref_share.
The pre-redesign model is preserved behind --old on tsi-sweep and
tsi-verify. Its RNG key is byte-identical to the pre-uncle_model key,
so --old bit-reproduces the historical runs.
Supporting changes: uncle_model and window_absorption config surface
with validation (config.py, constants.py); accuracy closed form over
the effective q_u (theory.py); plumbing through tsi.py, epoch.py,
sweep.py, blocktree.py, metrics.py, verify.py, figures_pernode.py.
Studies and figures:
- configs/countable-vs-old.yaml -- delay x U grid, run under both
models on the same grid.
- configs/absorption-window.yaml -- accuracy vs W_abs at U=1.
- scripts/plot_countable_vs_old.py renders fig30-fig33 into
reports/tsi/report-figures/.
Tests: tests/test_countable_counting.py (7 cases) covering first-fork
eligibility, derived-window bounds, occupied-slot exclusion, and
per-reference re-checking; extensions to test_uncles.py,
test_config.py, test_slot_counting.py. Full fast suite: 202 passed.
Also adds CLAUDE.md (graphify project instructions) and ignores
editor/local-agent state plus the vendored Equi-X benchmark clone.
The reports/tsi/ prose describing this model is held back for a
separate editorial pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 18:48:46 +02:00
|
|
|
# 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.)
|
2026-07-30 18:57:10 +02:00
|
|
|
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,
|
Countable uncle model: spec counting rules, sweeps, figures
Implement the countable uncle model from the Cryptarchia spec's
counting-only reference rules, and make it the simulator default.
Counting rules (uncles.py, measure.py):
- Only the first block of a fork (parent on the producer's chain) is
referenceable and countable, which makes every reference verifiable
from chain data alone.
- The reference window is derived from a window-absorption parameter,
w_u = W_abs/f slots (W_abs in expected block-intervals, default 10,
bounded W_abs <= 0.6*k), replacing the free-standing uncle_window.
- Selection skips slots already occupied on the producer's chain and
takes at most one uncle per slot.
- The measurement pass re-checks every rule per reference and tallies
rejections as deep_ref_share.
The pre-redesign model is preserved behind --old on tsi-sweep and
tsi-verify. Its RNG key is byte-identical to the pre-uncle_model key,
so --old bit-reproduces the historical runs.
Supporting changes: uncle_model and window_absorption config surface
with validation (config.py, constants.py); accuracy closed form over
the effective q_u (theory.py); plumbing through tsi.py, epoch.py,
sweep.py, blocktree.py, metrics.py, verify.py, figures_pernode.py.
Studies and figures:
- configs/countable-vs-old.yaml -- delay x U grid, run under both
models on the same grid.
- configs/absorption-window.yaml -- accuracy vs W_abs at U=1.
- scripts/plot_countable_vs_old.py renders fig30-fig33 into
reports/tsi/report-figures/.
Tests: tests/test_countable_counting.py (7 cases) covering first-fork
eligibility, derived-window bounds, occupied-slot exclusion, and
per-reference re-checking; extensions to test_uncles.py,
test_config.py, test_slot_counting.py. Full fast suite: 202 passed.
Also adds CLAUDE.md (graphify project instructions) and ignores
editor/local-agent state plus the vendored Equi-X benchmark clone.
The reports/tsi/ prose describing this model is held back for a
separate editorial pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 18:48:46 +02:00
|
|
|
base={"k": 8, "epochs": 3, "uncle_model": "old"},
|
2026-07-30 18:57:10 +02:00
|
|
|
)
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
Countable uncle model: spec counting rules, sweeps, figures
Implement the countable uncle model from the Cryptarchia spec's
counting-only reference rules, and make it the simulator default.
Counting rules (uncles.py, measure.py):
- Only the first block of a fork (parent on the producer's chain) is
referenceable and countable, which makes every reference verifiable
from chain data alone.
- The reference window is derived from a window-absorption parameter,
w_u = W_abs/f slots (W_abs in expected block-intervals, default 10,
bounded W_abs <= 0.6*k), replacing the free-standing uncle_window.
- Selection skips slots already occupied on the producer's chain and
takes at most one uncle per slot.
- The measurement pass re-checks every rule per reference and tallies
rejections as deep_ref_share.
The pre-redesign model is preserved behind --old on tsi-sweep and
tsi-verify. Its RNG key is byte-identical to the pre-uncle_model key,
so --old bit-reproduces the historical runs.
Supporting changes: uncle_model and window_absorption config surface
with validation (config.py, constants.py); accuracy closed form over
the effective q_u (theory.py); plumbing through tsi.py, epoch.py,
sweep.py, blocktree.py, metrics.py, verify.py, figures_pernode.py.
Studies and figures:
- configs/countable-vs-old.yaml -- delay x U grid, run under both
models on the same grid.
- configs/absorption-window.yaml -- accuracy vs W_abs at U=1.
- scripts/plot_countable_vs_old.py renders fig30-fig33 into
reports/tsi/report-figures/.
Tests: tests/test_countable_counting.py (7 cases) covering first-fork
eligibility, derived-window bounds, occupied-slot exclusion, and
per-reference re-checking; extensions to test_uncles.py,
test_config.py, test_slot_counting.py. Full fast suite: 202 passed.
Also adds CLAUDE.md (graphify project instructions) and ignores
editor/local-agent state plus the vendored Equi-X benchmark clone.
The reports/tsi/ prose describing this model is held back for a
separate editorial pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 18:48:46 +02:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-07-30 18:57:10 +02:00
|
|
|
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().
|
Countable uncle model: spec counting rules, sweeps, figures
Implement the countable uncle model from the Cryptarchia spec's
counting-only reference rules, and make it the simulator default.
Counting rules (uncles.py, measure.py):
- Only the first block of a fork (parent on the producer's chain) is
referenceable and countable, which makes every reference verifiable
from chain data alone.
- The reference window is derived from a window-absorption parameter,
w_u = W_abs/f slots (W_abs in expected block-intervals, default 10,
bounded W_abs <= 0.6*k), replacing the free-standing uncle_window.
- Selection skips slots already occupied on the producer's chain and
takes at most one uncle per slot.
- The measurement pass re-checks every rule per reference and tallies
rejections as deep_ref_share.
The pre-redesign model is preserved behind --old on tsi-sweep and
tsi-verify. Its RNG key is byte-identical to the pre-uncle_model key,
so --old bit-reproduces the historical runs.
Supporting changes: uncle_model and window_absorption config surface
with validation (config.py, constants.py); accuracy closed form over
the effective q_u (theory.py); plumbing through tsi.py, epoch.py,
sweep.py, blocktree.py, metrics.py, verify.py, figures_pernode.py.
Studies and figures:
- configs/countable-vs-old.yaml -- delay x U grid, run under both
models on the same grid.
- configs/absorption-window.yaml -- accuracy vs W_abs at U=1.
- scripts/plot_countable_vs_old.py renders fig30-fig33 into
reports/tsi/report-figures/.
Tests: tests/test_countable_counting.py (7 cases) covering first-fork
eligibility, derived-window bounds, occupied-slot exclusion, and
per-reference re-checking; extensions to test_uncles.py,
test_config.py, test_slot_counting.py. Full fast suite: 202 passed.
Also adds CLAUDE.md (graphify project instructions) and ignores
editor/local-agent state plus the vendored Equi-X benchmark clone.
The reports/tsi/ prose describing this model is held back for a
separate editorial pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 18:48:46 +02:00
|
|
|
# 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 design: resolve the design band with common random numbers
The unpaired comparison could not answer the question it was asked. The
two uncle models draw independent RNG streams -- uncle_model is in the
config key, which is what makes --old bit-reproduce earlier runs -- so
the arms differed in stake draw, peering graph and every lottery
outcome, each comparison paid the between-run variance twice, and the
per-cell floor (+-0.0015) sat an order of magnitude above the effect.
Only delta_max = 5 resolved, and only after pooling.
Adds `paired_streams`: the RNG root is derived from the model-
independent part of the key, so a countable cell and its --old twin get
the SAME stake, graph and lottery draws and the uncle rule is the only
difference. Each replicate is then a matched pair and the shared
variance cancels. Trajectories still diverge after epoch 0 through the
genuine feedback (a different counted density changes the next epoch's
difficulty), which is the signal.
The flag is deliberately NOT in key(): it selects which key the seed is
derived from, so including it would perturb every historical seed.
Re-verified that --old still bit-reproduces the committed 2026-07-27
rho-boundary parquet, max |delta| = 0.
Results (configs/fine-delay-paired.yaml, 40 replicates per arm):
- Negative control becomes an IDENTITY check. With U = 0 no reference is
taken, so shared streams must give bit-identical trajectories. All 200
replicate pairs differ by exactly 0.0. Unpaired, the same control only
had to agree within +-0.025 and drifted by 0.016.
- Per-cell SE shrinks by a median 1.6x (1.2-2.1x); widest 95% CI goes
+-0.0015 -> +-0.0010. 5/15 cells resolve at |t| >= 2 (0.75 expected by
chance); the largest, U=2 at delta_max=4, is t = 4.32 and clears
Bonferroni for 15 tests.
- The cost is a STEP, not the ramp the unpaired data suggested:
delta_max 1-3 unresolved (t = 1.1, 1.8, 1.4), then delta_max 4 AND 5
both resolve at -0.0011 (t = 4.7) and -0.0009 (t = 3.7). Whole-band
pooled -0.00060 +- 0.00021, t = 5.7 -- where the unpaired estimate of
the same quantity (t = 2.8) had failed correction.
So the first-fork restriction costs nothing measurable up to
delta_max = 3 and about 0.1% at 4-5 -- an order of magnitude below the
+-0.9% per-epoch sampling noise.
Two bugs found while building this, both of which would have silently
produced a wrong answer:
- paired_streams was missing from metrics._CONFIG_FIELDS, so it never
reached the parquet; plot_fine_delay.py falls back to the unpaired
test when it cannot confirm pairing, so the sweep would have completed
and quietly reported the old result. Caught before the run finished;
the sweep was restarted and a test now pins the field.
- The U=0 control check reported FAILS on a PERFECT control: paired, the
gap is exactly 0 so its SE is 0 and t is 0/0. It now checks the gap
itself when the streams are shared, and falls back to the t-test only
when there is real spread.
§3.2a is rewritten around the paired measurement; the unpaired sweep is
retained in §9 as the power comparison that motivated it. Figures 34-35
regenerated, with the control annotation and provenance reflecting the
design actually used.
Tests: 214 passed (was 209). ruff clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 11:58:26 +02:00
|
|
|
# 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.
|
Countable uncle model: spec counting rules, sweeps, figures
Implement the countable uncle model from the Cryptarchia spec's
counting-only reference rules, and make it the simulator default.
Counting rules (uncles.py, measure.py):
- Only the first block of a fork (parent on the producer's chain) is
referenceable and countable, which makes every reference verifiable
from chain data alone.
- The reference window is derived from a window-absorption parameter,
w_u = W_abs/f slots (W_abs in expected block-intervals, default 10,
bounded W_abs <= 0.6*k), replacing the free-standing uncle_window.
- Selection skips slots already occupied on the producer's chain and
takes at most one uncle per slot.
- The measurement pass re-checks every rule per reference and tallies
rejections as deep_ref_share.
The pre-redesign model is preserved behind --old on tsi-sweep and
tsi-verify. Its RNG key is byte-identical to the pre-uncle_model key,
so --old bit-reproduces the historical runs.
Supporting changes: uncle_model and window_absorption config surface
with validation (config.py, constants.py); accuracy closed form over
the effective q_u (theory.py); plumbing through tsi.py, epoch.py,
sweep.py, blocktree.py, metrics.py, verify.py, figures_pernode.py.
Studies and figures:
- configs/countable-vs-old.yaml -- delay x U grid, run under both
models on the same grid.
- configs/absorption-window.yaml -- accuracy vs W_abs at U=1.
- scripts/plot_countable_vs_old.py renders fig30-fig33 into
reports/tsi/report-figures/.
Tests: tests/test_countable_counting.py (7 cases) covering first-fork
eligibility, derived-window bounds, occupied-slot exclusion, and
per-reference re-checking; extensions to test_uncles.py,
test_config.py, test_slot_counting.py. Full fast suite: 202 passed.
Also adds CLAUDE.md (graphify project instructions) and ignores
editor/local-agent state plus the vendored Equi-X benchmark clone.
The reports/tsi/ prose describing this model is held back for a
separate editorial pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 18:48:46 +02:00
|
|
|
ignored = {"root_seed", "windowed_fork_choice", "prune_arrival", "early_stop",
|
Paired design: resolve the design band with common random numbers
The unpaired comparison could not answer the question it was asked. The
two uncle models draw independent RNG streams -- uncle_model is in the
config key, which is what makes --old bit-reproduce earlier runs -- so
the arms differed in stake draw, peering graph and every lottery
outcome, each comparison paid the between-run variance twice, and the
per-cell floor (+-0.0015) sat an order of magnitude above the effect.
Only delta_max = 5 resolved, and only after pooling.
Adds `paired_streams`: the RNG root is derived from the model-
independent part of the key, so a countable cell and its --old twin get
the SAME stake, graph and lottery draws and the uncle rule is the only
difference. Each replicate is then a matched pair and the shared
variance cancels. Trajectories still diverge after epoch 0 through the
genuine feedback (a different counted density changes the next epoch's
difficulty), which is the signal.
The flag is deliberately NOT in key(): it selects which key the seed is
derived from, so including it would perturb every historical seed.
Re-verified that --old still bit-reproduces the committed 2026-07-27
rho-boundary parquet, max |delta| = 0.
Results (configs/fine-delay-paired.yaml, 40 replicates per arm):
- Negative control becomes an IDENTITY check. With U = 0 no reference is
taken, so shared streams must give bit-identical trajectories. All 200
replicate pairs differ by exactly 0.0. Unpaired, the same control only
had to agree within +-0.025 and drifted by 0.016.
- Per-cell SE shrinks by a median 1.6x (1.2-2.1x); widest 95% CI goes
+-0.0015 -> +-0.0010. 5/15 cells resolve at |t| >= 2 (0.75 expected by
chance); the largest, U=2 at delta_max=4, is t = 4.32 and clears
Bonferroni for 15 tests.
- The cost is a STEP, not the ramp the unpaired data suggested:
delta_max 1-3 unresolved (t = 1.1, 1.8, 1.4), then delta_max 4 AND 5
both resolve at -0.0011 (t = 4.7) and -0.0009 (t = 3.7). Whole-band
pooled -0.00060 +- 0.00021, t = 5.7 -- where the unpaired estimate of
the same quantity (t = 2.8) had failed correction.
So the first-fork restriction costs nothing measurable up to
delta_max = 3 and about 0.1% at 4-5 -- an order of magnitude below the
+-0.9% per-epoch sampling noise.
Two bugs found while building this, both of which would have silently
produced a wrong answer:
- paired_streams was missing from metrics._CONFIG_FIELDS, so it never
reached the parquet; plot_fine_delay.py falls back to the unpaired
test when it cannot confirm pairing, so the sweep would have completed
and quietly reported the old result. Caught before the run finished;
the sweep was restarted and a test now pins the field.
- The U=0 control check reported FAILS on a PERFECT control: paired, the
gap is exactly 0 so its SE is 0 and t is 0/0. It now checks the gap
itself when the streams are shared, and falls back to the t-test only
when there is real spread.
§3.2a is rewritten around the paired measurement; the unpaired sweep is
retained in §9 as the power comparison that motivated it. Figures 34-35
regenerated, with the control annotation and provenance reflecting the
design actually used.
Tests: 214 passed (was 209). ruff clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 11:58:26 +02:00
|
|
|
"uncle_window", "paired_streams"}
|
2026-07-30 18:57:10 +02:00
|
|
|
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}"
|
Countable uncle model: spec counting rules, sweeps, figures
Implement the countable uncle model from the Cryptarchia spec's
counting-only reference rules, and make it the simulator default.
Counting rules (uncles.py, measure.py):
- Only the first block of a fork (parent on the producer's chain) is
referenceable and countable, which makes every reference verifiable
from chain data alone.
- The reference window is derived from a window-absorption parameter,
w_u = W_abs/f slots (W_abs in expected block-intervals, default 10,
bounded W_abs <= 0.6*k), replacing the free-standing uncle_window.
- Selection skips slots already occupied on the producer's chain and
takes at most one uncle per slot.
- The measurement pass re-checks every rule per reference and tallies
rejections as deep_ref_share.
The pre-redesign model is preserved behind --old on tsi-sweep and
tsi-verify. Its RNG key is byte-identical to the pre-uncle_model key,
so --old bit-reproduces the historical runs.
Supporting changes: uncle_model and window_absorption config surface
with validation (config.py, constants.py); accuracy closed form over
the effective q_u (theory.py); plumbing through tsi.py, epoch.py,
sweep.py, blocktree.py, metrics.py, verify.py, figures_pernode.py.
Studies and figures:
- configs/countable-vs-old.yaml -- delay x U grid, run under both
models on the same grid.
- configs/absorption-window.yaml -- accuracy vs W_abs at U=1.
- scripts/plot_countable_vs_old.py renders fig30-fig33 into
reports/tsi/report-figures/.
Tests: tests/test_countable_counting.py (7 cases) covering first-fork
eligibility, derived-window bounds, occupied-slot exclusion, and
per-reference re-checking; extensions to test_uncles.py,
test_config.py, test_slot_counting.py. Full fast suite: 202 passed.
Also adds CLAUDE.md (graphify project instructions) and ignores
editor/local-agent state plus the vendored Equi-X benchmark clone.
The reports/tsi/ prose describing this model is held back for a
separate editorial pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 18:48:46 +02:00
|
|
|
# ... 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 design: resolve the design band with common random numbers
The unpaired comparison could not answer the question it was asked. The
two uncle models draw independent RNG streams -- uncle_model is in the
config key, which is what makes --old bit-reproduce earlier runs -- so
the arms differed in stake draw, peering graph and every lottery
outcome, each comparison paid the between-run variance twice, and the
per-cell floor (+-0.0015) sat an order of magnitude above the effect.
Only delta_max = 5 resolved, and only after pooling.
Adds `paired_streams`: the RNG root is derived from the model-
independent part of the key, so a countable cell and its --old twin get
the SAME stake, graph and lottery draws and the uncle rule is the only
difference. Each replicate is then a matched pair and the shared
variance cancels. Trajectories still diverge after epoch 0 through the
genuine feedback (a different counted density changes the next epoch's
difficulty), which is the signal.
The flag is deliberately NOT in key(): it selects which key the seed is
derived from, so including it would perturb every historical seed.
Re-verified that --old still bit-reproduces the committed 2026-07-27
rho-boundary parquet, max |delta| = 0.
Results (configs/fine-delay-paired.yaml, 40 replicates per arm):
- Negative control becomes an IDENTITY check. With U = 0 no reference is
taken, so shared streams must give bit-identical trajectories. All 200
replicate pairs differ by exactly 0.0. Unpaired, the same control only
had to agree within +-0.025 and drifted by 0.016.
- Per-cell SE shrinks by a median 1.6x (1.2-2.1x); widest 95% CI goes
+-0.0015 -> +-0.0010. 5/15 cells resolve at |t| >= 2 (0.75 expected by
chance); the largest, U=2 at delta_max=4, is t = 4.32 and clears
Bonferroni for 15 tests.
- The cost is a STEP, not the ramp the unpaired data suggested:
delta_max 1-3 unresolved (t = 1.1, 1.8, 1.4), then delta_max 4 AND 5
both resolve at -0.0011 (t = 4.7) and -0.0009 (t = 3.7). Whole-band
pooled -0.00060 +- 0.00021, t = 5.7 -- where the unpaired estimate of
the same quantity (t = 2.8) had failed correction.
So the first-fork restriction costs nothing measurable up to
delta_max = 3 and about 0.1% at 4-5 -- an order of magnitude below the
+-0.9% per-epoch sampling noise.
Two bugs found while building this, both of which would have silently
produced a wrong answer:
- paired_streams was missing from metrics._CONFIG_FIELDS, so it never
reached the parquet; plot_fine_delay.py falls back to the unpaired
test when it cannot confirm pairing, so the sweep would have completed
and quietly reported the old result. Caught before the run finished;
the sweep was restarted and a test now pins the field.
- The U=0 control check reported FAILS on a PERFECT control: paired, the
gap is exactly 0 so its SE is 0 and t is 0/0. It now checks the gap
itself when the streams are shared, and falls back to the t-test only
when there is real spread.
§3.2a is rewritten around the paired measurement; the unpaired sweep is
retained in §9 as the power comparison that motivated it. Figures 34-35
regenerated, with the control annotation and provenance reflecting the
design actually used.
Tests: 214 passed (was 209). ruff clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 11:58:26 +02:00
|
|
|
# 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()
|
Countable uncle model: spec counting rules, sweeps, figures
Implement the countable uncle model from the Cryptarchia spec's
counting-only reference rules, and make it the simulator default.
Counting rules (uncles.py, measure.py):
- Only the first block of a fork (parent on the producer's chain) is
referenceable and countable, which makes every reference verifiable
from chain data alone.
- The reference window is derived from a window-absorption parameter,
w_u = W_abs/f slots (W_abs in expected block-intervals, default 10,
bounded W_abs <= 0.6*k), replacing the free-standing uncle_window.
- Selection skips slots already occupied on the producer's chain and
takes at most one uncle per slot.
- The measurement pass re-checks every rule per reference and tallies
rejections as deep_ref_share.
The pre-redesign model is preserved behind --old on tsi-sweep and
tsi-verify. Its RNG key is byte-identical to the pre-uncle_model key,
so --old bit-reproduces the historical runs.
Supporting changes: uncle_model and window_absorption config surface
with validation (config.py, constants.py); accuracy closed form over
the effective q_u (theory.py); plumbing through tsi.py, epoch.py,
sweep.py, blocktree.py, metrics.py, verify.py, figures_pernode.py.
Studies and figures:
- configs/countable-vs-old.yaml -- delay x U grid, run under both
models on the same grid.
- configs/absorption-window.yaml -- accuracy vs W_abs at U=1.
- scripts/plot_countable_vs_old.py renders fig30-fig33 into
reports/tsi/report-figures/.
Tests: tests/test_countable_counting.py (7 cases) covering first-fork
eligibility, derived-window bounds, occupied-slot exclusion, and
per-reference re-checking; extensions to test_uncles.py,
test_config.py, test_slot_counting.py. Full fast suite: 202 passed.
Also adds CLAUDE.md (graphify project instructions) and ignores
editor/local-agent state plus the vendored Equi-X benchmark clone.
The reports/tsi/ prose describing this model is held back for a
separate editorial pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 18:48:46 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
2026-07-30 18:57:10 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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 uncle model: spec counting rules, sweeps, figures
Implement the countable uncle model from the Cryptarchia spec's
counting-only reference rules, and make it the simulator default.
Counting rules (uncles.py, measure.py):
- Only the first block of a fork (parent on the producer's chain) is
referenceable and countable, which makes every reference verifiable
from chain data alone.
- The reference window is derived from a window-absorption parameter,
w_u = W_abs/f slots (W_abs in expected block-intervals, default 10,
bounded W_abs <= 0.6*k), replacing the free-standing uncle_window.
- Selection skips slots already occupied on the producer's chain and
takes at most one uncle per slot.
- The measurement pass re-checks every rule per reference and tallies
rejections as deep_ref_share.
The pre-redesign model is preserved behind --old on tsi-sweep and
tsi-verify. Its RNG key is byte-identical to the pre-uncle_model key,
so --old bit-reproduces the historical runs.
Supporting changes: uncle_model and window_absorption config surface
with validation (config.py, constants.py); accuracy closed form over
the effective q_u (theory.py); plumbing through tsi.py, epoch.py,
sweep.py, blocktree.py, metrics.py, verify.py, figures_pernode.py.
Studies and figures:
- configs/countable-vs-old.yaml -- delay x U grid, run under both
models on the same grid.
- configs/absorption-window.yaml -- accuracy vs W_abs at U=1.
- scripts/plot_countable_vs_old.py renders fig30-fig33 into
reports/tsi/report-figures/.
Tests: tests/test_countable_counting.py (7 cases) covering first-fork
eligibility, derived-window bounds, occupied-slot exclusion, and
per-reference re-checking; extensions to test_uncles.py,
test_config.py, test_slot_counting.py. Full fast suite: 202 passed.
Also adds CLAUDE.md (graphify project instructions) and ignores
editor/local-agent state plus the vendored Equi-X benchmark clone.
The reports/tsi/ prose describing this model is held back for a
separate editorial pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 18:48:46 +02:00
|
|
|
"countable": "old", # uncle_model
|
Uncle selection: the spec fixes oldest-first, so measure deviation from it
Open item 11 listed "a random (rather than oldest-first) uncle-selection draw"
as an untested spec sensitivity. The spec does not leave it open: Uncle
Selection in cryptarchia-v1-protocol.md has the proposer take the oldest
candidates first, deterministically, because an uncle expires w_u slots after
its own slot. That is exactly what every result in the report already uses, so
the item is a conformance match, not a gap -- and the simulator comment calling
uncle_random_p "the spec's unbiased coin" cites text the spec no longer has.
What is genuinely open is deviation FROM that rule: selection is proposer-local
and the uncles field is never validated. configs/uncle-selection.yaml measures
the cost. A proposer that includes each candidate on a fair coin instead loses
up to 0.10 in D-hat/D, and 0.063 at the recommended W = 10 once rho ~ 1
(0.902 vs 0.965, t = -8.6). At the design point the margin survives but is
spent: 0.980 vs 0.997 against a 0.98 bar. The loss does not close as W grows,
because a coin wastes opportunities rather than queue capacity and a well-sized
window is precisely what keeps the queue short enough for that to bite.
This matters for the sec 8.5 reward recommendation: the spec argues a proposer
has no incentive to deviate BECAUSE uncles grant no reward, and paying them
removes that argument.
Also adds adversary_selection=whale (the largest holders at matched stake, for
the untested concentration case). The marker is appended to key() only when
non-default so every historical run's seed stays byte-identical, guarded by a
test alongside the paired_streams one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 15:42:16 +02:00
|
|
|
# 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",
|
Anchor the uncle reference window to the parent, not the uncle
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>
2026-08-07 12:22:50 +02:00
|
|
|
"uncle": "parent", # uncle_window_anchor
|
2026-07-30 18:57:10 +02:00
|
|
|
}
|
|
|
|
|
if isinstance(v, str) and v in flips:
|
|
|
|
|
return flips[v]
|
|
|
|
|
return v
|