2026-07-30 18:57:10 +02:00
|
|
|
"""Multi-epoch per-node trajectory driver for a single config."""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
from typing import Any
|
|
|
|
|
|
|
|
|
|
import numpy as np
|
|
|
|
|
|
|
|
|
|
from . import topology
|
|
|
|
|
from .config import SimConfig
|
|
|
|
|
from .epoch import simulate_epoch
|
|
|
|
|
from .metrics import divergence_row
|
|
|
|
|
from .rng import seedseq_for
|
|
|
|
|
from .stake import make_stake
|
|
|
|
|
|
|
|
|
|
# Early-stop (config.early_stop): detector + measurement budget. The detector uses a short
|
|
|
|
|
# 2-epoch delta window (convergence at beta=1 is abrupt, ~2-5 epochs); ES_MIN_EPOCH keeps it
|
|
|
|
|
# out of the genesis transient, and ES_MEASURE post-detection epochs form the equilibrium
|
|
|
|
|
# sample, so a slightly eager detection still averages over converging epochs. Thresholds are
|
|
|
|
|
# in units of the per-epoch sampling noise sigma_th = sqrt((1-f)/(f*T)); regimes noisier than
|
|
|
|
|
# that (e.g. Blend U=0 fork-race noise) never trigger and simply run their full budget.
|
|
|
|
|
ES_MIN_EPOCH = 6 # first epoch at which the detector may fire
|
|
|
|
|
ES_MEASURE = 10 # measurement epochs run after detection
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _adversary_mask(config: SimConfig, stake: np.ndarray) -> np.ndarray | None:
|
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
|
|
|
"""Nodes controlled by the uncle-suppressing adversary — a coalition whose stake sums to
|
|
|
|
|
``adversary_frac`` of the total, giving smooth control of the adversary's block share.
|
|
|
|
|
``None`` if honest.
|
|
|
|
|
|
|
|
|
|
``adversary_selection`` picks *which* nodes, at that same total stake:
|
|
|
|
|
|
|
|
|
|
* ``"random"`` (default) — a uniformly random set. For uncle suppression the deflation depends
|
|
|
|
|
only on the summed block share, not on whether the coalition is one whale or many small
|
|
|
|
|
nodes, so this is the neutral choice and concentration does not enter.
|
|
|
|
|
* ``"whale"`` — the largest holders first. Same stake in far fewer nodes, so the coalition's
|
|
|
|
|
block production is lumpier: it is the concentration case report §6.5 flags as untested, and
|
|
|
|
|
the reason to run it is the *variance* of the coalition's share, not its mean.
|
2026-07-30 18:57:10 +02:00
|
|
|
|
|
|
|
|
Seeded from a standalone ``SeedSequence([root_seed, replicate, 0xADEADBEEF])`` (independent of
|
|
|
|
|
the main spawn hierarchy), and drawn only after the ``adversary_frac <= 0`` early return, so an
|
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_frac == 0`` run is bit-identical to the honest baseline. The whale order is
|
|
|
|
|
deterministic given the stake vector and consumes no randomness, but the seed is still drawn
|
|
|
|
|
first so that switching selection never perturbs the rest of the stream.
|
2026-07-30 18:57:10 +02:00
|
|
|
"""
|
|
|
|
|
if config.adversary_frac <= 0.0:
|
|
|
|
|
return None
|
|
|
|
|
adv_seed = np.random.SeedSequence([config.root_seed, config.replicate, 0xADEADBEEF])
|
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
|
|
|
rand_order = np.random.default_rng(adv_seed).permutation(config.n_nodes)
|
2026-07-30 18:57:10 +02:00
|
|
|
target = config.adversary_frac * float(stake.sum())
|
|
|
|
|
mask = np.zeros(config.n_nodes, dtype=bool)
|
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 config.adversary_selection == "whale":
|
|
|
|
|
# Largest stake first, ties broken by the random order so equal-stake runs stay unbiased.
|
|
|
|
|
# Taking whales until the cumulative sum first EXCEEDS the target would overshoot badly
|
|
|
|
|
# under a heavy tail (the top holder alone can be a sixth of the stake), and a coalition
|
|
|
|
|
# holding visibly more than adversary_frac would confound concentration with stake. So:
|
|
|
|
|
# walk descending and take every node that still FITS under the target, then close any
|
|
|
|
|
# remaining gap with the smallest node that can. The coalition is therefore dominated by
|
|
|
|
|
# the top holders, with small nodes only topping it up onto the target — a handful of
|
|
|
|
|
# members against the random arm's hundreds, at the same stake.
|
|
|
|
|
order = rand_order[np.argsort(-stake[rand_order], kind="stable")]
|
|
|
|
|
st = stake[order]
|
|
|
|
|
chosen = np.zeros(order.size, dtype=bool)
|
|
|
|
|
cum = 0.0
|
|
|
|
|
for j, s in enumerate(st):
|
|
|
|
|
if cum + s <= target:
|
|
|
|
|
chosen[j] = True
|
|
|
|
|
cum += s
|
|
|
|
|
if cum < target:
|
|
|
|
|
# `st` is descending, so among the nodes that can close the gap the LAST is the
|
|
|
|
|
# smallest. Every skipped node exceeds the current gap (the gap only shrinks), so a
|
|
|
|
|
# candidate always exists while any node remains; the fallback is defensive only.
|
|
|
|
|
cand = np.nonzero(~chosen & (st >= target - cum))[0]
|
|
|
|
|
chosen[cand[-1] if cand.size else np.nonzero(~chosen)[0][0]] = True
|
|
|
|
|
mask[order[chosen]] = True
|
|
|
|
|
return mask
|
|
|
|
|
|
|
|
|
|
cum_r = np.cumsum(stake[rand_order])
|
|
|
|
|
take = int(np.searchsorted(cum_r, target, side="left")) + 1 # smallest coalition >= target
|
|
|
|
|
mask[rand_order[:take]] = True
|
2026-07-30 18:57:10 +02:00
|
|
|
return mask
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _initial_d_est(config: SimConfig, d_true: float, rng: np.random.Generator) -> np.ndarray:
|
|
|
|
|
"""Per-node initial estimate: common genesis, or heterogeneous around it."""
|
|
|
|
|
base = config.genesis_d_factor * d_true
|
|
|
|
|
n = config.n_nodes
|
|
|
|
|
if config.init_dest == "common" or config.init_spread <= 0.0:
|
|
|
|
|
return np.full(n, base, dtype=float)
|
|
|
|
|
# heterogeneous: uniform in base*(1 ± init_spread), clamped positive
|
|
|
|
|
lo = max(base * (1.0 - config.init_spread), 1.0)
|
|
|
|
|
hi = base * (1.0 + config.init_spread)
|
|
|
|
|
return rng.uniform(lo, hi, size=n)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _churn_active_fraction(config: SimConfig, epoch: int) -> float:
|
|
|
|
|
"""Active honest-stake fraction this epoch, per the churn schedule (1.0 if no churn)."""
|
|
|
|
|
if config.churn_amp <= 0.0:
|
|
|
|
|
return 1.0
|
|
|
|
|
a, per = config.churn_amp, config.churn_period
|
|
|
|
|
if config.churn_mode == "sine":
|
|
|
|
|
return 1.0 - a * (1.0 - np.cos(2.0 * np.pi * epoch / per)) / 2.0
|
|
|
|
|
if config.churn_mode == "ramp":
|
|
|
|
|
return 1.0 - a * min(epoch / per, 1.0)
|
|
|
|
|
# step: drop to 1-a at epoch `per`, hold
|
|
|
|
|
return 1.0 - a if epoch >= per else 1.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _churn_inactive_mask(stake: np.ndarray, active_frac: float,
|
|
|
|
|
rng: np.random.Generator) -> np.ndarray:
|
|
|
|
|
"""A random node subset whose stake sums to ~(1-active_frac) of the total, marked inactive.
|
|
|
|
|
|
|
|
|
|
Greedy nearest-fill in random order: while the running inactive stake is short of the
|
|
|
|
|
target, a node is deactivated only if it lands the total closer to the target than stopping
|
|
|
|
|
short would (the gap is at least half the node's stake). A whale that would overshoot is
|
|
|
|
|
skipped and the fill continues with smaller nodes. Under a heavy-tailed (Pareto) stake
|
|
|
|
|
distribution a plain cumulative-prefix cut lets a single whale straddling the cutoff
|
|
|
|
|
overshoot the amplitude badly (a 30 % label realising up to ~53 %); nearest-fill keeps the
|
|
|
|
|
realised amplitude on-label.
|
|
|
|
|
"""
|
|
|
|
|
n = stake.shape[0]
|
|
|
|
|
if active_frac >= 1.0:
|
|
|
|
|
return np.zeros(n, dtype=bool)
|
|
|
|
|
target = (1.0 - active_frac) * float(stake.sum())
|
|
|
|
|
mask = np.zeros(n, dtype=bool)
|
|
|
|
|
acc = 0.0
|
|
|
|
|
for i in rng.permutation(n):
|
|
|
|
|
if acc >= target:
|
|
|
|
|
break
|
|
|
|
|
s = float(stake[i])
|
|
|
|
|
if (target - acc) >= 0.5 * s: # including node i lands closer than stopping short
|
|
|
|
|
mask[i] = True
|
|
|
|
|
acc += s
|
|
|
|
|
return mask
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def run_trajectory(config: SimConfig) -> list[dict[str, Any]]:
|
|
|
|
|
"""Run ``config.epochs`` per-node epochs, one divergence-summary row per epoch.
|
|
|
|
|
|
|
|
|
|
Each of the ``N`` nodes carries its OWN ``d_est`` and self-updates from its own view.
|
|
|
|
|
The topology (``path_latency``) is built once (invariant across epochs). RNG is a spawn
|
|
|
|
|
hierarchy off the config's root SeedSequence: child 0 = stake, 1 = graph, 2 = init,
|
|
|
|
|
3+e = epoch e — so results are deterministic and order-independent.
|
|
|
|
|
"""
|
|
|
|
|
root = seedseq_for(config)
|
|
|
|
|
children = root.spawn(config.epochs + 3)
|
|
|
|
|
stake = make_stake(config, np.random.default_rng(children[0]))
|
|
|
|
|
d_true = float(stake.sum())
|
|
|
|
|
path_latency = topology.build_path_latency(config, np.random.default_rng(children[1]))
|
|
|
|
|
d_est = _initial_d_est(config, d_true, np.random.default_rng(children[2]))
|
|
|
|
|
adv_mask = _adversary_mask(config, stake)
|
|
|
|
|
# exact stake fraction of the (integer-rounded) coalition, for the active-stake bookkeeping
|
|
|
|
|
coalition_frac = float(stake[adv_mask].sum() / d_true) if adv_mask is not None else 0.0
|
|
|
|
|
withholding = adv_mask is not None and config.adversary_strategy == "withhold"
|
|
|
|
|
# churn RNG is standalone (drawn only when churn_amp>0) so churn=0 stays bit-identical
|
|
|
|
|
churn_rng = (np.random.default_rng(np.random.SeedSequence([config.root_seed,
|
|
|
|
|
config.replicate, 0xC4084])) if config.churn_amp > 0.0 else None)
|
|
|
|
|
|
|
|
|
|
def _sigma_th() -> float:
|
|
|
|
|
t_win = config.period_T
|
|
|
|
|
return float(np.sqrt((1.0 - config.f) / (config.f * t_win)))
|
|
|
|
|
|
|
|
|
|
def _converged(series: list[float]) -> bool:
|
|
|
|
|
"""2-epoch delta window: last step within sigma_th, 2-step drift within 1.5x."""
|
|
|
|
|
if len(series) < 3:
|
|
|
|
|
return False
|
|
|
|
|
sig = _sigma_th()
|
|
|
|
|
return (abs(series[-1] - series[-2]) <= sig
|
|
|
|
|
and abs(series[-1] - series[-3]) <= 1.5 * sig)
|
|
|
|
|
|
|
|
|
|
# sawtooth schedules must run their full budget; the detector would misread a rejoin ramp
|
|
|
|
|
allow_early = config.early_stop and config.adversary_period == 0
|
|
|
|
|
|
|
|
|
|
rows: list[dict[str, Any]] = []
|
|
|
|
|
stop_after: int | None = None
|
|
|
|
|
for epoch in range(config.epochs):
|
|
|
|
|
# The coalition is fixed; the schedule only gates whether it withholds THIS epoch. On a
|
|
|
|
|
# rejoin epoch it behaves fully honestly (behaviour mask None == honest baseline). A
|
|
|
|
|
# suppressing coalition (or the static default) attacks every epoch.
|
|
|
|
|
attacks = config.adversary_withholds(epoch) if withholding else adv_mask is not None
|
|
|
|
|
behaviour_mask = adv_mask if attacks else None
|
|
|
|
|
active_stake_frac = 1.0 - coalition_frac if (withholding and attacks) else 1.0
|
|
|
|
|
# organic honest churn: deactivate a scheduled stake fraction this epoch
|
|
|
|
|
inactive_mask = None
|
|
|
|
|
if churn_rng is not None:
|
|
|
|
|
active_frac = _churn_active_fraction(config, epoch)
|
|
|
|
|
inactive_mask = _churn_inactive_mask(stake, active_frac, churn_rng)
|
|
|
|
|
active_stake_frac = float(stake[~inactive_mask].sum() / d_true) * active_stake_frac
|
|
|
|
|
er = simulate_epoch(config, stake, d_est, path_latency, children[epoch + 3],
|
|
|
|
|
adversary_mask=behaviour_mask, coalition_mask=adv_mask,
|
|
|
|
|
inactive_mask=inactive_mask)
|
|
|
|
|
row = divergence_row(config, epoch, d_est, er, d_true)
|
|
|
|
|
row["adversary_withholding"] = bool(withholding and attacks)
|
|
|
|
|
row["active_stake_frac"] = active_stake_frac
|
|
|
|
|
rows.append(row)
|
|
|
|
|
d_est = er.d_next
|
|
|
|
|
if allow_early and stop_after is None and epoch >= ES_MIN_EPOCH:
|
|
|
|
|
if _converged([r["mean_ratio"] for r in rows]):
|
|
|
|
|
stop_after = epoch + ES_MEASURE
|
|
|
|
|
if stop_after is not None and epoch >= stop_after:
|
|
|
|
|
break
|
|
|
|
|
return rows
|