2026-07-30 18:57:10 +02:00

168 lines
8.2 KiB
Python

"""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:
"""Nodes controlled by the uncle-suppressing adversary — a random coalition whose stake sums to
``adversary_frac`` of the total, giving smooth control of the adversary's block share. (For
uncle suppression the deflation depends only on that block share, not on whether the coalition
is one whale or many small nodes, so concentration is not modelled here.) ``None`` if honest.
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
``adversary_frac == 0`` run is bit-identical to the honest baseline.
"""
if config.adversary_frac <= 0.0:
return None
adv_seed = np.random.SeedSequence([config.root_seed, config.replicate, 0xADEADBEEF])
order = np.random.default_rng(adv_seed).permutation(config.n_nodes)
target = config.adversary_frac * float(stake.sum())
cum = np.cumsum(stake[order])
take = int(np.searchsorted(cum, target, side="left")) + 1 # smallest coalition >= target
mask = np.zeros(config.n_nodes, dtype=bool)
mask[order[:take]] = True
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