491 lines
30 KiB
Python
Raw Normal View History

2026-07-30 18:57:10 +02:00
"""Configuration dataclasses for single runs and parameter sweeps."""
from __future__ import annotations
import itertools
from dataclasses import dataclass, field, replace
from typing import Any, Literal
from . import constants
StakeDist = Literal["uniform", "pareto"]
UncleStrategy = Literal["oldest", "random"]
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 counting/selection model:
# "countable" (default) — the spec's counting-only model (cryptarchia-v1-protocol.md):
# only the FIRST block of a fork is referenceable/countable (its parent lies on the
# referencing chain), the window is derived as w_u = window_absorption / f slots,
# selection excludes slots already occupied on the producer's chain and picks at most
# one uncle per slot, and counting re-checks every rule per reference.
# "old" — the pre-redesign model (run with --old): window = uncle_window slots directly,
# any orphan in view is referenceable regardless of fork depth, no occupied-slot or
# per-slot exclusion, and every baked reference counts.
UncleModel = Literal["countable", "old"]
2026-07-30 18:57:10 +02:00
Topology = Literal["full_mesh", "regular", "blend"]
LinkLatencyDist = Literal["fixed", "uniform", "exp", "geo"]
JitterDist = Literal["exp", "poisson"]
ChurnMode = Literal["sine", "ramp", "step"]
InitDest = Literal["common", "heterogeneous"]
# How the adversary_frac coalition attacks the TSI density count:
# "suppress" — produces normally but references NO uncles (starves the recovered density; weak);
# "withhold" — never gossips its blocks (they are orphaned, its won slots become gaps in the
# canonical chain), so the counted density drops ~adversary_frac and TSI deflates D_est toward
# the reduced ACTIVE stake. Stronger, but the withheld blocks earn nothing (griefing/grinding).
Private-chain (SM1) adversary in the per-node engine 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>
2026-08-06 12:46:57 +02:00
# "selfish" — mines a PRIVATE chain and releases it to orphan honest blocks (Eyal-Sirer SM1).
# Unlike "withhold" (which discards its blocks — abstention, a dead loss), this recovers the
# forfeit by displacing honest work, and is the one profitable lever (report §6.6). Its
# estimator damage is what the countable uncle rule can only partly repair, because an
# override discards a CHAIN of honest blocks and only the first is referenceable (§2.1).
AdversaryStrategy = Literal["suppress", "withhold", "selfish"]
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
# WHICH nodes make up that coalition, at the same total stake:
# "random" — a uniformly random set grown until its stake reaches adversary_frac (the default; the
# block share is then smooth in adversary_frac, which is all the density levers depend on);
# "whale" — the LARGEST holders first. Same stake, far fewer nodes, so the coalition's block
# production is lumpier — the untested concentration case flagged in report §6.5's scope.
AdversarySelection = Literal["random", "whale"]
2026-07-30 18:57:10 +02:00
@dataclass(frozen=True)
class SimConfig:
"""A single fully-specified simulation run (one grid cell, one replicate)."""
# --- network / stake ---
n_nodes: int = 1000
stake_dist: StakeDist = "uniform"
pareto_shape: float = 1.16 # Pareto (Lomax) tail index; ~80/20 by default
uniform_random: bool = False # if True, draw i.i.d. uniform stakes; else equal
total_stake: float = 1.0e9 # FIXED across distributions for comparability
# --- network latency (slots) ---
latency: int = 0 # L: full-mesh uniform link latency (block seen at t+L)
latency_stochastic: bool = False # if True, L is the mean of a stochastic model
# --- network topology (per-node model) ---
# "full_mesh": every node one hop away, uniform latency = `latency` (reproduces the
# reduced model). "regular": random d-regular peering graph with per-link latency;
# a block reaches a node after the shortest WEIGHTED path from its producer.
# "blend": same d-regular graph, but a block is first relayed through `blend_hops` random
# nodes (a mix cascade, each adding a Uniform(0, blend_delay_max) mixing delay) before a
# final network-wide gossip makes it visible — models routing over the Blend mixnet.
topology: Topology = "full_mesh"
degree: int = 8 # peering degree (regular / blend graph)
# One entropy contributor to the per-trajectory RNG (via key()), NOT an independent topology
# knob: the graph is seeded from the config's full-key spawn hierarchy (engine.run_trajectory),
# so it is fixed per trajectory but is re-rolled by ANY key() field (stake_dist, f,
# adversary_frac, replicate, ...). Consequently two configs that differ only in a non-topology
# field draw different graphs; adversary-vs-honest comparisons are therefore unpaired in the
# graph sample (a variance source averaged out over replicates, not a bias — the main deflation
# effects are topology-independent, §6.4). Making it a paired/independent knob would require
# seeding the graph from topology-only entropy and re-running every sweep.
graph_seed: int = 0
# Blend mixnet cascade (topology == "blend"): the producer picks `blend_hops` distinct
# relay nodes uniformly at random; the block hops producer -> r1 -> ... -> r_hops over the
# graph, each relay waiting Uniform(0, blend_delay_max) slots before forwarding; the last
# relay's forward is the final network-wide gossip. Ignored by full_mesh / regular.
blend_hops: int = 3 # number of random relay hops in the mix cascade
adversary_strategy: AdversaryStrategy = "suppress" # how adversary_frac attacks (see above)
blend_delay_max: float = 3.0 # max per-relay mixing delay (slots); delay ~ U(0, this)
# Mean one-way per-link latency in SLOTS (1 slot = 1 s). Realistic direct-gossip links are
# sub-slot (~0.04-0.15 slot = 40-150 ms); whole-slot values (1, 2, ...) model routing over
# the Blend mixnet, where each hop costs seconds. Arrivals are kept sub-slot (float).
link_latency_mean: float = 1.0
# Per-link latency distribution (all have mean = link_latency_mean): "fixed" (all equal),
# "uniform" (0..2*mean), "exp" (long tail), "geo" (real-world geographic band mixture:
# short intra-region links, long inter-continental ones — see constants.GEO_LATENCY_*).
link_latency_dist: LinkLatencyDist = "fixed"
jitter_mean: float = 0.0 # extra per-(block,node) jitter (slots); 0 = none.
# Jitter model (active when jitter_mean > 0):
# "exp" — EVERY delivery gets +Exp(jitter_mean); the §6.1 robustness model.
# "poisson" — a random fraction `jitter_frac` of deliveries gets +Poisson(jitter_mean)
# whole slots; the rest arrive on time. A LONG-TAIL model: most deliveries are
# unaffected, a few straggle by multiple slots (case (b) of the N-scaling study).
jitter_dist: JitterDist = "exp"
jitter_frac: float = 1.0 # fraction of deliveries hit (poisson model; exp uses all)
# --- uncle references ---
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_model: UncleModel = "countable" # countable (spec, default) | old (--old)
# Countable model: window absorption parameter W; the uncle reference window is DERIVED
# as w_u = W / f slots (W expected block-intervals), bounded 1 <= W <= 0.6*k
# (constants.W_ABS_MAX_FACTOR). Ignored by the old model.
window_absorption: float = constants.W_ABS_DEFAULT
# Old model only (--old): the uncle reference window w_u in slots, set directly.
# Ignored by the countable model, which derives the window from window_absorption.
uncle_window: int = constants.W_DEFAULT
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
# COMMON RANDOM NUMBERS for countable-vs-old comparisons. Off by default, and deliberately
# NOT part of key() — with it off every seed is byte-identical to before, so historical runs
# and --old bit-reproduction are untouched.
#
# The two uncle models normally draw independent streams (uncle_model is in the key), so a
# comparison pays the full between-run variance TWICE and the arms differ in stake draw,
# peering graph and every lottery outcome. With paired_streams=True the RNG root is derived
# from the model-independent part of the key instead, so both arms get the SAME stake, the
# SAME graph and the SAME lottery draws; the only difference is the uncle rule, and the
# per-replicate difference becomes a paired observation with the shared variance cancelled.
# Trajectories still diverge legitimately after epoch 0 — a different counted density feeds
# back into the next epoch's difficulty — which is the effect being measured, not noise.
paired_streams: bool = False
2026-07-30 18:57:10 +02:00
max_uncles: int = 0 # U (0 = baseline, no uncles)
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
# "oldest" IS the spec rule: cryptarchia-v1-protocol.md (Uncle Selection) has the proposer
# take the oldest candidates first, deterministically, because an uncle expires w_u slots
# after its own slot so the oldest are the closest to expiring. Every headline result uses it.
2026-07-30 18:57:10 +02:00
uncle_strategy: UncleStrategy = "oldest"
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
# "random" is NOT a spec variant — it is the deviation probe: walk the same oldest-first
# candidate order but include each candidate with probability uncle_random_p, so a lone
# candidate is dropped half the time. Uncle selection is proposer-local and unvalidated, so a
# proposer CAN deviate; this measures what that costs the estimate (report §3.4).
2026-07-30 18:57:10 +02:00
uncle_random_p: float = 0.5
# --- adversary (grinding via D_est deflation) ---
# Fraction of TOTAL STAKE controlled by an adversary that suppresses uncle references in its
# own blocks (references no uncles), starving the TSI density count so honest nodes under-count
# blocks and infer a LOW D_est -> everyone's win probability phi(f, w/D_est) rises, which is the
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
# grinding payoff. 0.0 = fully honest (the studied baseline). The coalition is by default a
# RANDOM node set whose stake sums to adversary_frac (see engine._adversary_mask); block
# production is stake-proportional, so the deflation depends only on that summed share, not on
# whether the coalition is one whale or many small nodes. Withholding is a separate, stronger
# lever, and adversary_selection controls WHICH nodes are taken at that fixed stake.
2026-07-30 18:57:10 +02:00
adversary_frac: float = 0.0
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: AdversarySelection = "random"
2026-07-30 18:57:10 +02:00
# Dynamic (withhold-then-rejoin) schedule for the withholding lever (§6.5). The coalition is
# FIXED (identity from adversary_frac); this only gates whether it withholds in a given epoch.
# adversary_period == 0 -> STATIC: the coalition attacks (withholds) every epoch (the §6.4
# model; backward-compatible default).
# adversary_period > 0 -> PERIODIC: withhold for the first `adversary_withhold_epochs` of
# every `adversary_period`-epoch cycle, then behave honestly (produce + gossip) for the
# rest — an abstain-then-rejoin grinder. A single downward pulse (does D_est recover, or
# tip into the §6.2 collapsed branch?) is period == epochs, withhold_epochs == pulse length.
# Only affects adversary_strategy == "withhold"; suppression stays static.
adversary_period: int = 0
adversary_withhold_epochs: int = 0
# --- consensus / TSI ---
f: float = constants.F # slot activation coefficient (configurable; sweepable)
beta: float = constants.BETA_DEFAULT
k: int = 64 # scaled by default; full scale = 2160
genesis_d_factor: float = 0.5 # genesis D = factor * true total stake
epochs: int = 40
Round-4 TSI report review: apply findings, editorial pass, code + figure fixes Applied the reconstructed round-4 review to the TSI parameter-selection report set (reports/tsi) and executed the follow-ups. Report (reports/tsi): - Applied the must+should findings across README + parts 1-4: cross-part numeric corrections, figure-caption fixes, spec reconciliation, and cross-file companions (hops-degradation and notch/reward numbers, tip-agreement ordering, density-window timing, VRF -> ZK Proof-of-Leadership, w_u window/reward gloss). - Editorial pass for timeless voice (no "now adopted / merged / coin" narration) and a gentle spec-safety framing (recommendations are thresholds; the protocol's MAX_UNCLES=4 sits safely above them). - Added the fork-rate-vs-scale table (6.10), defined "grinding gain", promoted the clock-skew study to its own paragraph, added the correlated-latency caveat, and moved fig27/fig28 beside their discussion. - Documented the Blend cascade in 2: hops propagate over the shared gossip graph (not direct links), the final broadcast comes from the last relay, relays are blind forwarders. Simulator (tools/simulators/tsi/tsi-sim-pernode): - Docstring/dead-code fixes: theory.block_count_ceiling (legacy framing), measure, reorg (catch-up reading), metrics (removed two dead helpers), config (fixed_point 10^-6; clock_skew_max/lottery_chunks documented inert), stake_vs_delay. - Generator correctness + regenerated figures: figures_pernode.CONFIG_COLS now exhaustive (f no longer pooled); rho_boundary_analysis SEM across replicates + hollow floored markers + de-hardcoded ell_mean (measured from the run's graph); appendix_fluct per-N sigma + ~18x title (figB2); bootstrap_dynamics driving estimate so fig1 epoch-0 matches genesis. - pytest: 186 passed; report links 528/0 dangling. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-31 13:13:03 +02:00
# If True, quantise the target rate the way an on-chain integer estimator does:
# f_p = int(f*tsi.PRECISION)/tsi.PRECISION. With tsi.PRECISION = 1_000_000 (the report's
# recommended 10^-6 f-precision, §8) this gives f_p = 0.033333 and a negligible residual
# f/f_p < 1e-5 — not the ~1% overestimate the old 10^-3 truncation produced.
2026-07-30 18:57:10 +02:00
# Default False keeps the analysis-faithful exact-f behaviour.
fixed_point: bool = False
# If True, count uncle references per BLOCK ID (the pre-fix behaviour, which double-counts
# same-slot co-winners and inflates the equilibrium by c(f)). The correct default counts
# per SLOT (one count per slot, matching the pre-uncle design invariant). Kept as a flag
# for reproducing historical runs only; no study uses it.
legacy_block_count: bool = False
# Early stop: when the per-epoch estimate has converged (trailing epochs statistically
# flat), run ES_MEASURE more epochs as the equilibrium sample and stop. Truncation-only:
# per-epoch RNG streams are pre-spawned, so the epochs that DO run are bit-identical to a
# full run's prefix (hence excluded from key()). Auto-disabled for periodic-adversary
# schedules (sawtooths must run their full budget).
early_stop: bool = False
# Organic (non-adversarial) participation churn: each epoch a `churn_amp` fraction of honest
# stake goes inactive following a schedule, so the ACTIVE stake oscillates/ramps and TSI must
# track it. churn_amp = peak inactive fraction; churn_period = epochs per cycle; churn_mode:
# "sine" — active fraction = 1 - churn_amp*(1-cos(2π·epoch/period))/2 (smooth weekly cycle)
# "ramp" — active fraction declines linearly to 1-churn_amp over churn_period, then holds
# "step" — one-time drop to (1-churn_amp) at churn_period (mass leave)
churn_amp: float = 0.0
churn_period: int = 4
churn_mode: ChurnMode = "sine"
Round-4 TSI report review: apply findings, editorial pass, code + figure fixes Applied the reconstructed round-4 review to the TSI parameter-selection report set (reports/tsi) and executed the follow-ups. Report (reports/tsi): - Applied the must+should findings across README + parts 1-4: cross-part numeric corrections, figure-caption fixes, spec reconciliation, and cross-file companions (hops-degradation and notch/reward numbers, tip-agreement ordering, density-window timing, VRF -> ZK Proof-of-Leadership, w_u window/reward gloss). - Editorial pass for timeless voice (no "now adopted / merged / coin" narration) and a gentle spec-safety framing (recommendations are thresholds; the protocol's MAX_UNCLES=4 sits safely above them). - Added the fork-rate-vs-scale table (6.10), defined "grinding gain", promoted the clock-skew study to its own paragraph, added the correlated-latency caveat, and moved fig27/fig28 beside their discussion. - Documented the Blend cascade in 2: hops propagate over the shared gossip graph (not direct links), the final broadcast comes from the last relay, relays are blind forwarders. Simulator (tools/simulators/tsi/tsi-sim-pernode): - Docstring/dead-code fixes: theory.block_count_ceiling (legacy framing), measure, reorg (catch-up reading), metrics (removed two dead helpers), config (fixed_point 10^-6; clock_skew_max/lottery_chunks documented inert), stake_vs_delay. - Generator correctness + regenerated figures: figures_pernode.CONFIG_COLS now exhaustive (f no longer pooled); rho_boundary_analysis SEM across replicates + hollow floored markers + de-hardcoded ell_mean (measured from the run's graph); appendix_fluct per-N sigma + ~18x title (figB2); bootstrap_dynamics driving estimate so fig1 epoch-0 matches genesis. - pytest: 186 passed; report links 528/0 dangling. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-31 13:13:03 +02:00
# INERT: nothing reads this. The §6.1 clock-skew study is run stand-alone by
# scripts/clock_skew.py, which applies its own per-node offsets — not through this field.
# Retained only as a key() seed contributor for run-hash compatibility (like `per_node_dest`);
# leave at 0.
2026-07-30 18:57:10 +02:00
clock_skew_max: int = 0
# Each node updates its OWN D_est from its OWN view — the point of this simulator, and the ONLY
# mode implemented here (always True). The global-consensus-D_est baseline (per_node_dest=False)
# is not built in this package; it lives in the sibling reduced model (tsi-sim). Retained as a
# key() seed contributor for compatibility; do not set False (no code path reads it).
per_node_dest: bool = True
# "common": all nodes start at genesis_d_factor*D_true (studies convergence FROM
# agreement). "heterogeneous": per-node initial D_est drawn with relative spread
# `init_spread` around genesis (studies transient re-convergence from disagreement).
init_dest: InitDest = "common"
init_spread: float = 0.0 # relative spread of heterogeneous initial D_est
# --- performance ---
Round-4 TSI report review: apply findings, editorial pass, code + figure fixes Applied the reconstructed round-4 review to the TSI parameter-selection report set (reports/tsi) and executed the follow-ups. Report (reports/tsi): - Applied the must+should findings across README + parts 1-4: cross-part numeric corrections, figure-caption fixes, spec reconciliation, and cross-file companions (hops-degradation and notch/reward numbers, tip-agreement ordering, density-window timing, VRF -> ZK Proof-of-Leadership, w_u window/reward gloss). - Editorial pass for timeless voice (no "now adopted / merged / coin" narration) and a gentle spec-safety framing (recommendations are thresholds; the protocol's MAX_UNCLES=4 sits safely above them). - Added the fork-rate-vs-scale table (6.10), defined "grinding gain", promoted the clock-skew study to its own paragraph, added the correlated-latency caveat, and moved fig27/fig28 beside their discussion. - Documented the Blend cascade in 2: hops propagate over the shared gossip graph (not direct links), the final broadcast comes from the last relay, relays are blind forwarders. Simulator (tools/simulators/tsi/tsi-sim-pernode): - Docstring/dead-code fixes: theory.block_count_ceiling (legacy framing), measure, reorg (catch-up reading), metrics (removed two dead helpers), config (fixed_point 10^-6; clock_skew_max/lottery_chunks documented inert), stake_vs_delay. - Generator correctness + regenerated figures: figures_pernode.CONFIG_COLS now exhaustive (f no longer pooled); rho_boundary_analysis SEM across replicates + hollow floored markers + de-hardcoded ell_mean (measured from the run's graph); appendix_fluct per-N sigma + ~18x title (figB2); bootstrap_dynamics driving estimate so fig1 epoch-0 matches genesis. - pytest: 186 passed; report links 528/0 dangling. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-31 13:13:03 +02:00
# INERT: nothing reads this — `simulate_epoch` never calls `lottery.sample_wins_chunked`,
# so it has no modelled effect. Retained as a key() seed contributor for run-hash
# compatibility (like `per_node_dest` above); leave at 1.
2026-07-30 18:57:10 +02:00
lottery_chunks: int = 1
# Windowed fork choice bounds the per-slot candidate scan to a horizon of the max path
# latency (plus the fully-propagated best tip), turning O(n_blocks^2) into O(n_blocks*H).
# EXACT when link latency is deterministic (jitter_mean == 0). With jitter_mean > 0 it is
# a (usually tiny) approximation and emits a warning — see blocktree.build_tree_pernode.
# Set False for a guaranteed-exact full scan.
windowed_fork_choice: bool = True
# Sliding-window pruning of the (N x n_blocks) arrival matrix: keep per-node arrival columns
# only for blocks still inside the keep-span max(horizon, uncle_window); blocks past that are
# finalized (arrived at every node under the deterministic horizon), so their columns are
# dropped. Turns O(N * n_blocks) memory into O(N * keep-span-blocks) — the fix for the
# collapsed-D_est block explosion. EXACT vs the full matrix when jitter_mean == 0 (needs the
# horizon, so it only applies when windowed_fork_choice is on); set False to store the whole
# matrix (the parity oracle, and required for a guaranteed-exact jitter>0 run).
prune_arrival: bool = True
# --- bookkeeping ---
replicate: int = 0
root_seed: int = 12345
def __post_init__(self) -> None:
# frozen dataclass: validation only (no attribute assignment)
if self.stake_dist not in ("uniform", "pareto"):
raise ValueError(f"stake_dist must be uniform|pareto, got {self.stake_dist!r}")
if self.uncle_strategy not in ("oldest", "random"):
raise ValueError(f"uncle_strategy must be oldest|random, got {self.uncle_strategy!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
if self.uncle_model not in ("countable", "old"):
raise ValueError(f"uncle_model must be countable|old, got {self.uncle_model!r}")
if self.uncle_model == "countable":
if self.window_absorption < 1.0:
raise ValueError(
f"window_absorption W={self.window_absorption} must be >= 1")
if self.window_absorption > constants.W_ABS_MAX_FACTOR * self.k:
# The spec bounds W <= 0.6*k (w_u <= 0.6*k/f, inside the finalization
# window). Scaled-down research geometries (small k) may violate it on
# purpose — warn loudly rather than refuse, but full-scale runs should
# never see this.
import warnings
warnings.warn(
f"window_absorption W={self.window_absorption} exceeds the spec bound "
f"{constants.W_ABS_MAX_FACTOR}*k = "
f"{constants.W_ABS_MAX_FACTOR * self.k:g} (k={self.k}); the derived "
f"window is outside the finalization window at this geometry",
RuntimeWarning, stacklevel=2)
2026-07-30 18:57:10 +02:00
if self.topology not in ("full_mesh", "regular", "blend"):
raise ValueError(f"topology must be full_mesh|regular|blend, got {self.topology!r}")
if self.link_latency_dist not in ("fixed", "uniform", "exp", "geo"):
raise ValueError(f"link_latency_dist must be fixed|uniform|exp|geo, got "
f"{self.link_latency_dist!r}")
if self.jitter_dist not in ("exp", "poisson"):
raise ValueError(f"jitter_dist must be exp|poisson, got {self.jitter_dist!r}")
if not 0.0 <= self.jitter_frac <= 1.0:
raise ValueError(f"jitter_frac must be in [0, 1], got {self.jitter_frac}")
if self.init_dest not in ("common", "heterogeneous"):
raise ValueError(f"init_dest must be common|heterogeneous, got {self.init_dest!r}")
if self.churn_mode not in ("sine", "ramp", "step"):
raise ValueError(f"churn_mode must be sine|ramp|step, got {self.churn_mode!r}")
if not 0.0 <= self.churn_amp < 1.0:
raise ValueError(f"churn_amp must be in [0, 1), got {self.churn_amp}")
if self.churn_period < 1:
raise ValueError(f"churn_period must be >= 1, got {self.churn_period}")
if self.clock_skew_max < 0:
raise ValueError(f"clock_skew_max must be >= 0, got {self.clock_skew_max}")
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
if self.adversary_selection not in ("random", "whale"):
raise ValueError(f"adversary_selection must be random|whale, got "
f"{self.adversary_selection!r}")
Private-chain (SM1) adversary in the per-node engine 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>
2026-08-06 12:46:57 +02:00
if self.adversary_strategy not in ("suppress", "withhold", "selfish"):
raise ValueError(f"adversary_strategy must be suppress|withhold|selfish, got "
2026-07-30 18:57:10 +02:00
f"{self.adversary_strategy!r}")
checks = {
"n_nodes": self.n_nodes >= 1,
"k": self.k >= 1,
"epochs": self.epochs >= 1,
"latency": self.latency >= 0,
"max_uncles": self.max_uncles >= 0,
"uncle_window": self.uncle_window >= 1,
"lottery_chunks": self.lottery_chunks >= 1,
"uncle_random_p": 0.0 <= self.uncle_random_p <= 1.0,
"f": 0.0 < self.f < 1.0,
"beta": self.beta > 0.0,
"genesis_d_factor": self.genesis_d_factor > 0.0,
"pareto_shape": self.pareto_shape > 0.0,
"total_stake": self.total_stake > 0.0,
"degree": self.degree >= 1,
"link_latency_mean": self.link_latency_mean >= 0.0,
"jitter_mean": self.jitter_mean >= 0.0,
"init_spread": self.init_spread >= 0.0,
"blend_hops": self.blend_hops >= 1,
"blend_delay_max": self.blend_delay_max >= 0.0,
"adversary_frac": 0.0 <= self.adversary_frac < 1.0,
"adversary_period": self.adversary_period >= 0,
"adversary_withhold_epochs": self.adversary_withhold_epochs >= 0,
}
bad = [name for name, ok in checks.items() if not ok]
if bad:
raise ValueError(f"invalid SimConfig field(s): {bad}")
if self.adversary_period > 0 and self.adversary_withhold_epochs > self.adversary_period:
raise ValueError(
f"adversary_withhold_epochs ({self.adversary_withhold_epochs}) must be "
f"<= adversary_period ({self.adversary_period})")
if self.topology in ("regular", "blend"):
# a d-regular graph on n nodes needs degree < n and n*degree even
if self.degree >= self.n_nodes:
raise ValueError(f"degree ({self.degree}) must be < n_nodes ({self.n_nodes})")
if (self.n_nodes * self.degree) % 2 != 0:
raise ValueError("regular graph requires n_nodes*degree to be even")
if self.topology == "blend":
# need `blend_hops` DISTINCT relay nodes drawn from the non-producer pool
if self.blend_hops > self.n_nodes - 1:
raise ValueError(
f"blend_hops ({self.blend_hops}) must be <= n_nodes-1 ({self.n_nodes - 1})")
def adversary_withholds(self, epoch: int) -> bool:
"""Whether the (fixed) coalition withholds this epoch under its schedule.
Static (``adversary_period == 0``) attacks every epoch; periodic attacks the first
``adversary_withhold_epochs`` epochs of each ``adversary_period``-epoch cycle. Meaningful
only for ``adversary_strategy == "withhold"`` with ``adversary_frac > 0``.
"""
if self.adversary_period <= 0:
return True
return (epoch % self.adversary_period) < self.adversary_withhold_epochs
# derived geometry -------------------------------------------------------
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
@property
def effective_uncle_window(self) -> int:
"""The uncle reference window ``w_u`` in slots actually used by this run.
Countable model (default): derived, ``w_u = round(window_absorption / f)``.
Old model (``--old``): ``uncle_window`` taken directly.
"""
if self.uncle_model == "old":
return self.uncle_window
return constants.uncle_window_slots(self.window_absorption, self.f)
2026-07-30 18:57:10 +02:00
@property
def epoch_len(self) -> int:
return constants.epoch_len(self.k, self.f)
@property
def period_T(self) -> int:
return constants.period_T(self.k, self.f)
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
def _base_key(self) -> tuple:
"""Identity fields shared by both uncle models — see ``key`` and ``seed_key``."""
return (
2026-07-30 18:57:10 +02:00
self.n_nodes, self.stake_dist, self.pareto_shape, self.uniform_random,
self.total_stake, self.latency, self.latency_stochastic, self.uncle_window,
self.max_uncles, self.uncle_strategy, self.uncle_random_p, self.f, self.beta,
self.k, self.genesis_d_factor, self.epochs, self.fixed_point,
self.legacy_block_count, self.churn_amp, self.churn_period, self.churn_mode,
self.clock_skew_max, self.per_node_dest,
self.lottery_chunks, self.topology, self.degree, self.graph_seed,
self.link_latency_mean, self.link_latency_dist, self.jitter_mean,
self.jitter_dist, self.jitter_frac,
self.blend_hops, self.blend_delay_max, self.adversary_frac, self.adversary_strategy,
self.adversary_period, self.adversary_withhold_epochs,
self.init_dest, self.init_spread, self.replicate,
)
# NOTE: windowed_fork_choice and prune_arrival are deliberately excluded — they are pure
# compute/memory optimisations that consume no RNG and (at jitter_mean == 0) change no
# result, so pruned and full-matrix runs must share a seed (see test_pernode parity).
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
def key(self) -> tuple:
"""Hashable identity used to seed the RNG deterministically.
Must include EVERY field that affects the run (guarded by test_rng), otherwise two
distinct configs would share an RNG stream. ``uncle_model`` /
``window_absorption`` are appended ONLY for the countable model: an ``--old`` run's
key is then byte-identical to the pre-redesign key, so ``--old`` bit-reproduces
historical runs (the two models still get distinct streams from the marker).
"""
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
# uncle_model == "old" keeps the historical tuple exactly (--old bit-compat).
base = (self._base_key() if self.uncle_model == "old"
else self._base_key() + (self.uncle_model, self.window_absorption))
# Appended ONLY when non-default, for the same reason the uncle_model marker is: a
# "random"-coalition run's key must stay byte-identical to every historical run's.
return base if self.adversary_selection == "random" else base + (self.adversary_selection,)
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
def seed_key(self) -> tuple:
"""The identity the RNG root is actually derived from (see ``rng.seedseq_for``).
Identical to ``key`` except under ``paired_streams``, where it deliberately drops the
uncle-model marker so that a countable run and an ``--old`` run of the SAME cell draw
the SAME root seed common random numbers, which is what makes the two arms a
*paired* sample (see ``paired_streams``).
"""
return self._base_key() if self.paired_streams else self.key()
2026-07-30 18:57:10 +02:00
# Axes that can be swept; every SimConfig field is legal here.
_SWEEP_AXES = (
"n_nodes", "stake_dist", "latency", "max_uncles", "uncle_strategy", "uncle_window",
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
"window_absorption",
2026-07-30 18:57:10 +02:00
"topology", "degree", "link_latency_mean", "link_latency_dist",
"blend_hops", "blend_delay_max", "init_dest", "f",
)
@dataclass
class SweepConfig:
"""A cartesian grid of runs plus replicates, all sharing ``base`` settings."""
n_nodes: list[int] = field(default_factory=lambda: [1000])
stake_dist: list[StakeDist] = field(default_factory=lambda: ["uniform"])
latency: list[int] = field(default_factory=lambda: [0])
max_uncles: list[int] = field(default_factory=lambda: [0, 1, 2, 4])
uncle_strategy: list[UncleStrategy] = field(default_factory=lambda: ["oldest"])
uncle_window: list[int] = field(default_factory=lambda: [constants.W_DEFAULT])
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
window_absorption: list[float] = field(default_factory=lambda: [constants.W_ABS_DEFAULT])
2026-07-30 18:57:10 +02:00
topology: list[Topology] = field(default_factory=lambda: ["regular"])
degree: list[int] = field(default_factory=lambda: [8])
link_latency_mean: list[float] = field(default_factory=lambda: [1.0])
link_latency_dist: list[LinkLatencyDist] = field(default_factory=lambda: ["fixed"])
blend_hops: list[int] = field(default_factory=lambda: [3])
blend_delay_max: list[float] = field(default_factory=lambda: [3.0])
init_dest: list[InitDest] = field(default_factory=lambda: ["common"])
f: list[float] = field(default_factory=lambda: [constants.F])
replicates: int = 8
base: dict[str, Any] = field(default_factory=dict)
def expand(self) -> list[SimConfig]:
"""Materialise every ``SimConfig`` in the grid × replicates."""
base = SimConfig(**self.base)
cells: list[SimConfig] = []
axis_values = [getattr(self, ax) for ax in _SWEEP_AXES]
for combo in itertools.product(*axis_values):
overrides = dict(zip(_SWEEP_AXES, combo, strict=True))
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
# U=0 references no uncles, so it is independent of uncle_strategy AND the window
# knobs; keep only the first of each to avoid duplicate (identical) work.
2026-07-30 18:57:10 +02:00
if overrides["max_uncles"] == 0 and (
overrides["uncle_strategy"] != self.uncle_strategy[0]
or overrides["uncle_window"] != self.uncle_window[0]
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
or overrides["window_absorption"] != self.window_absorption[0]
):
continue
# each uncle model reads exactly one window knob — collapse the other axis so a
# sweep never emits duplicate cells that differ only in an ignored field.
if base.uncle_model == "countable" and (
overrides["uncle_window"] != self.uncle_window[0]
):
continue
if base.uncle_model == "old" and (
overrides["window_absorption"] != self.window_absorption[0]
2026-07-30 18:57:10 +02:00
):
continue
# full mesh ignores degree / link-latency model; keep only the first to avoid dupes.
if overrides["topology"] == "full_mesh" and (
overrides["degree"] != self.degree[0]
or overrides["link_latency_mean"] != self.link_latency_mean[0]
or overrides["link_latency_dist"] != self.link_latency_dist[0]
):
continue
# only blend uses the mix-cascade knobs; collapse them elsewhere to avoid dupes.
if overrides["topology"] != "blend" and (
overrides["blend_hops"] != self.blend_hops[0]
or overrides["blend_delay_max"] != self.blend_delay_max[0]
):
continue
# `latency` is the full_mesh uniform-L knob; regular/blend ignore it — collapse it
# for them so sweeping latency doesn't emit duplicate (seed-shifted) graph cells.
if overrides["topology"] != "full_mesh" and overrides["latency"] != self.latency[0]:
continue
for rep in range(self.replicates):
cells.append(replace(base, **overrides, replicate=rep))
return cells
@classmethod
def from_dict(cls, d: dict[str, Any]) -> SweepConfig:
d = dict(d)
base = d.pop("base", {})
known = {*_SWEEP_AXES, "replicates"}
unknown = set(d) - known
if unknown:
raise ValueError(
f"unknown sweep keys: {sorted(unknown)} (valid: {sorted(known)}; "
"per-run settings belong under 'base:')"
)
return cls(base=base, **d)