205 lines
10 KiB
Python
Raw Normal View History

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