1595 lines
82 KiB
Python
1595 lines
82 KiB
Python
#!/usr/bin/env python3
|
|
"""Igneum finality rule V2: checkpoint-level simulation with latency, partitions and eclipses.
|
|
|
|
Rule under test (CLAUDE.md, FINALITY RULE V2, review round 2, 3 October 2026):
|
|
* Vote weight of a key = its blue blocks over a flat trailing 30-day window (DAA time,
|
|
86,400 blocks a day). No damping. Dust threshold DUST blocks: a key below it is not a
|
|
voter and is not counted in any denominator.
|
|
* A checkpoint forms every 30 blocks (blue score 30i). Every voter signs every checkpoint
|
|
it sees while online. Aggregators collect votes; a certificate (a lock) exists once the
|
|
collected signatures reach QUORUM (2/3) of the denominator.
|
|
* ACTIVE denominator: sum over eligible keys of weight x participation, where participation
|
|
= min(1, certified votes of that key over the last PRESENCE (240) checkpoint indices / 240).
|
|
A key whose first block is less than 240 checkpoints old counts as participation 1.
|
|
* TOTAL denominator (the alternative): sum of weight over every eligible key, no factor.
|
|
* Equivocation (one key signing two different checkpoint blocks at one index) zeroes the
|
|
key's weight for the rest of the window once the evidence is seen.
|
|
|
|
Participation bookkeeping has three readings, selected with --pmode. The difference only shows
|
|
when a checkpoint index gets no certificate:
|
|
cert (the brief, literal) an uncertified index contributes 0 certified votes to EVERY key,
|
|
so a stall decays everyone's participation and the denominator shrinks until the
|
|
present signers reach 2/3 of it. Self-healing, and the partition hazard.
|
|
seen an uncertified index credits the keys whose votes the node observed on the wire.
|
|
Silent keys decay, present keys do not. Not objective: each node counts its own view.
|
|
frozen the window is over the last 240 CERTIFIED indices, so a stall freezes participation.
|
|
Fails safe and never recovers liveness within the window.
|
|
|
|
Model (all assumptions, repeated in results_v2.md):
|
|
* Time step = one 30-s slot. The network mines Poisson(30) blocks per slot, split per key by
|
|
hashrate share (perfect difficulty retarget). Every block is blue (GHOSTDAG abstracted).
|
|
* Weight window = 720 hourly buckets of blocks per key (30 days), rolled every hour.
|
|
* A checkpoint index forms on a side each time that side's blue score passes a multiple of
|
|
30, so a partition side mining a fraction s of the hashrate forms checkpoints at s per slot.
|
|
Checkpoint blocks on different sides are different blocks at the same index.
|
|
* Regions: a one-way delay matrix, DELAY between regions, INTRA inside one, lognormal jitter
|
|
per hop. A vote for checkpoint i issued by a key in region r reaches the aggregator in
|
|
region a at t0 + d(src, r) + d(r, a) (+ a per-key extra delay for an eclipsed key).
|
|
One aggregator per region on the side; the certificate forms at the first one to reach
|
|
quorum; its signer set is every vote that arrived there by max(threshold time, t0 + GRACE).
|
|
* Honest keys follow a two-state uptime chain: availability UPTIME (UPTIME_BIG for keys at or
|
|
above 1% of hashrate, a pool with redundant infrastructure), mean outage OUTAGE slots.
|
|
* A partition splits regions into sides, each with its own view (certificates, participation
|
|
ring, blue score). At the heal the views merge: certificates are unioned, two certificates
|
|
at one index with different blocks count as a CONFLICTING LOCK, participation rings are
|
|
OR-merged by index, and any key that signed on two sides at a common index is stripped.
|
|
* Weights are global (a side does not see the other side's blocks for the partition's
|
|
duration; at most 150 minutes of a 30-day window, ignored).
|
|
|
|
Standard library plus numpy. Deterministic for a given --seed.
|
|
|
|
Usage:
|
|
python3 finality_v2.py # every scenario, markdown on stdout
|
|
python3 finality_v2.py --scenarios A,E --delay 5
|
|
python3 finality_v2.py --quick # shortened runs for development
|
|
"""
|
|
|
|
import argparse
|
|
import sys
|
|
import time
|
|
|
|
import numpy as np
|
|
|
|
SLOT_S = 30.0
|
|
BLOCKS_PER_CP = 30
|
|
SLOTS_PER_HOUR = 120
|
|
SLOTS_PER_DAY = 2_880
|
|
WINDOW_HOURS = 720
|
|
WINDOW_BLOCKS = 86_400 * 30
|
|
N_HONEST = 1_000
|
|
PARETO_SHAPE = 1.0
|
|
GEOGRAPHY = (0.45, 0.35, 0.20) # honest hashrate per region, model assumption
|
|
TWO_THIRDS = 2.0 / 3.0
|
|
|
|
|
|
class P:
|
|
"""Parameters. Keyword overrides."""
|
|
|
|
def __init__(self, **kw):
|
|
self.delay = 2.0 # one-way inter-region delay, seconds
|
|
self.intra = 0.1 # one-way intra-region delay, seconds
|
|
self.jitter = 0.25 # lognormal sigma per hop
|
|
self.grace = 15.0 # seconds after t0 during which late votes still enter the certificate
|
|
self.uptime = 0.97 # availability of an honest key under 1% of hashrate
|
|
self.uptime_big = 0.995 # availability of a key at or above 1% of hashrate (redundant pool infrastructure)
|
|
self.big_share = 0.01
|
|
self.outage = 20 # mean outage length, slots (10 minutes)
|
|
self.denom = "active" # "active" or "total"
|
|
self.pmode = "cert" # "cert", "seen", "frozen"
|
|
self.presence = 240 # checkpoints in the participation window
|
|
self.dust = 100 # blocks
|
|
self.quorum = TWO_THIRDS
|
|
self.floor = 0.0 # hybrid: active denominator never below floor x total weight (0 = off; 1.0 = the rule of 4 Oct 2026, a lock needs 2/3 of total)
|
|
self.daa = "none" # "none": a partition side mines at its hashrate share; "full": each side retargets to 30 blocks/slot at once
|
|
self.local = False # True: a partition side's weight table counts only the blocks it has seen (its own after the split), as a real node's window does
|
|
self.frozen = False # True: rule v3 (4 Oct 2026, ledger F21): a lock also needs 2/3 of the weight table FROZEN at the view's last
|
|
# certified checkpoint, signers counted at their frozen weights; the frozen table expires one window (30 days)
|
|
# after its checkpoint, after which the sliding table alone applies (as today)
|
|
self.__dict__.update(kw)
|
|
|
|
def label(self):
|
|
if self.denom == "total":
|
|
return "total"
|
|
s = "active/" + self.pmode
|
|
if self.floor > 0:
|
|
s += "+floor%.2f" % self.floor
|
|
if self.daa != "none":
|
|
s += "+daa"
|
|
if self.local:
|
|
s += "+local"
|
|
if self.frozen:
|
|
s += "+frozen"
|
|
return s
|
|
|
|
|
|
class View:
|
|
"""One side's view: participation ring, checkpoint counter, certificates."""
|
|
|
|
def __init__(self, sid, regions, n, presence, next_idx):
|
|
self.sid = sid
|
|
self.regions = list(regions)
|
|
self.ring = np.zeros((presence, n), dtype=np.int8)
|
|
self.ring_idx = np.full(presence, -1, dtype=np.int64)
|
|
self.pcount = np.zeros(n, dtype=np.int32)
|
|
self.next_idx = int(next_idx)
|
|
self.blue = 0.0
|
|
self.certs = {} # idx -> (sid, slot, latency_s)
|
|
self.stalls = [] # (idx, slot)
|
|
self.key_mask = None # keys whose region is on this side
|
|
self.mine_mask = None
|
|
self.excl = np.zeros(n) # blocks mined off this side since the split, unseen by it (only used with P.local)
|
|
self.frozen_wt = None # rule v3: the weight table at this view's last certified checkpoint (None before the first)
|
|
self.frozen_slot = -1
|
|
|
|
def clone_ring_from(self, other):
|
|
self.ring[:] = other.ring
|
|
self.ring_idx[:] = other.ring_idx
|
|
self.pcount[:] = other.pcount
|
|
self.frozen_wt = None if other.frozen_wt is None else other.frozen_wt.copy()
|
|
self.frozen_slot = other.frozen_slot
|
|
|
|
|
|
class Sim:
|
|
def __init__(self, p, rng, n_regions=3):
|
|
self.p = p
|
|
self.rng = rng
|
|
self.R = int(n_regions)
|
|
self.D = np.full((self.R, self.R), p.delay)
|
|
np.fill_diagonal(self.D, p.intra)
|
|
self.N = 0
|
|
self.hash = np.zeros(0)
|
|
self.region = np.zeros(0, dtype=np.int64)
|
|
self.online = np.zeros(0, dtype=bool)
|
|
self.flaky = np.zeros(0, dtype=bool)
|
|
self.signs = np.zeros(0, dtype=bool)
|
|
self.equiv = np.zeros(0, dtype=bool)
|
|
self.stripped = np.zeros(0, dtype=bool)
|
|
self.extra_delay = np.zeros(0)
|
|
self.first_idx = np.zeros(0, dtype=np.int64)
|
|
self.buckets = np.zeros((WINDOW_HOURS, 0))
|
|
self.weight = np.zeros(0)
|
|
self.slot = 0
|
|
self.views = []
|
|
self.next_sid = 1
|
|
self.records = [] # (slot, idx, sid, latency_s or -1, signed/denom, denom/total)
|
|
self.conflicts = [] # (idx, slot the second certificate existed)
|
|
self.q_on = 1.0 / p.outage
|
|
self.q_off = np.zeros(0)
|
|
|
|
# ------------------------------------------------------------- keys
|
|
def add_keys(self, hashes, regions, flaky=True, equiv=False, signs=True):
|
|
hashes = np.asarray(hashes, dtype=float)
|
|
regions = np.asarray(regions, dtype=np.int64)
|
|
n = hashes.size
|
|
self.hash = np.concatenate([self.hash, hashes])
|
|
self.region = np.concatenate([self.region, regions])
|
|
self.online = np.concatenate([self.online, np.ones(n, dtype=bool)])
|
|
self.flaky = np.concatenate([self.flaky, np.full(n, flaky, dtype=bool)])
|
|
self.signs = np.concatenate([self.signs, np.full(n, signs, dtype=bool)])
|
|
self.equiv = np.concatenate([self.equiv, np.full(n, equiv, dtype=bool)])
|
|
self.stripped = np.concatenate([self.stripped, np.zeros(n, dtype=bool)])
|
|
self.extra_delay = np.concatenate([self.extra_delay, np.zeros(n)])
|
|
self.first_idx = np.concatenate([self.first_idx, np.full(n, -1, dtype=np.int64)])
|
|
self.buckets = np.concatenate([self.buckets, np.zeros((WINDOW_HOURS, n))], axis=1)
|
|
self.weight = np.concatenate([self.weight, np.zeros(n)])
|
|
first = self.N
|
|
self.N += n
|
|
self.q_off = np.concatenate([self.q_off, np.zeros(n)])
|
|
self.set_uptime()
|
|
return np.arange(first, self.N)
|
|
|
|
def set_uptime(self):
|
|
"""Per-key off-switch probability per slot from the size-dependent availability. Call after hashrate changes."""
|
|
tot = self.hash.sum()
|
|
share = self.hash / tot if tot > 0 else np.zeros(self.N)
|
|
u = np.where(share >= self.p.big_share, self.p.uptime_big, self.p.uptime)
|
|
self.q_off = self.q_on * (1.0 - u) / u
|
|
|
|
def warm_start(self):
|
|
"""Fill the 30-day window as if the mining keys had been mining at their share for 30 days."""
|
|
share = self.hash / self.hash.sum()
|
|
lam = 3_600.0 * share
|
|
for h in range(WINDOW_HOURS):
|
|
self.buckets[h] = self.rng.poisson(lam)
|
|
self.weight = self.buckets.sum(axis=0)
|
|
self.first_idx[self.hash > 0] = -10 ** 9
|
|
self.slot = 0
|
|
|
|
def init_views(self, warm):
|
|
v = View(0, range(self.R), self.N, self.p.presence, next_idx=(WINDOW_BLOCKS // BLOCKS_PER_CP if warm else 0))
|
|
self._set_masks(v)
|
|
if warm:
|
|
elig = (self.weight >= self.p.dust) & self.signs
|
|
v.ring[:] = elig.astype(np.int8)[None, :]
|
|
v.ring_idx[:] = np.arange(v.next_idx - self.p.presence, v.next_idx)
|
|
v.pcount[:] = v.ring.sum(axis=0)
|
|
self.views = [v]
|
|
self.next_sid = 1
|
|
|
|
def _set_masks(self, v):
|
|
v.key_mask = np.isin(self.region, v.regions)
|
|
v.mine_mask = v.key_mask.copy()
|
|
|
|
# ------------------------------------------------------------- partitions
|
|
def split(self, groups):
|
|
base = self.views[0]
|
|
new = []
|
|
for g in groups:
|
|
v = View(self.next_sid, g, self.N, self.p.presence, next_idx=base.next_idx)
|
|
self.next_sid += 1
|
|
v.clone_ring_from(base)
|
|
self._set_masks(v)
|
|
new.append(v)
|
|
self.split_idx = base.next_idx
|
|
self.views = new
|
|
|
|
def heal(self):
|
|
views = self.views
|
|
P_ = self.p.presence
|
|
nxt = max(v.next_idx for v in views)
|
|
m = View(self.next_sid, range(self.R), self.N, P_, next_idx=nxt)
|
|
self.next_sid += 1
|
|
self._set_masks(m)
|
|
for i in range(max(0, nxt - P_), nxt):
|
|
row = i % P_
|
|
acc = np.zeros(self.N, dtype=np.int8)
|
|
hit = False
|
|
for v in views:
|
|
if v.ring_idx[row] == i:
|
|
acc |= v.ring[row]
|
|
hit = True
|
|
if hit:
|
|
m.ring[row] = acc
|
|
m.ring_idx[row] = i
|
|
m.pcount[:] = m.ring.sum(axis=0)
|
|
newest = max(views, key=lambda v: v.frozen_slot)
|
|
m.frozen_wt = None if newest.frozen_wt is None else newest.frozen_wt.copy()
|
|
m.frozen_slot = newest.frozen_slot
|
|
# certificates and conflicts
|
|
for v in views:
|
|
for idx, (sid, slot, lat) in v.certs.items():
|
|
if idx in m.certs and m.certs[idx][0] != sid:
|
|
self.conflicts.append((idx, max(slot, m.certs[idx][1])))
|
|
else:
|
|
m.certs.setdefault(idx, (sid, slot, lat))
|
|
m.stalls.extend(v.stalls)
|
|
# equivocation evidence: an equivocating key signed every side's checkpoint at every common index
|
|
common = min(v.next_idx for v in views) > self.split_idx
|
|
if common and self.equiv.any():
|
|
self.stripped |= self.equiv
|
|
self.strip_slot = self.slot
|
|
self.views = [m]
|
|
|
|
# ------------------------------------------------------------- stepping
|
|
def run(self, n_slots, snap=None):
|
|
"""snap = (every_n_slots, fn(sim)) called before the step on matching slots."""
|
|
for _ in range(int(n_slots)):
|
|
if snap is not None and self.slot % snap[0] == 0:
|
|
snap[1](self)
|
|
self.step()
|
|
|
|
def step(self):
|
|
p = self.p
|
|
slot = self.slot
|
|
tot = self.hash.sum()
|
|
if p.daa == "full" and len(self.views) > 1:
|
|
blocks = np.zeros(self.N)
|
|
for v in self.views:
|
|
side_tot = self.hash[v.mine_mask].sum()
|
|
if side_tot > 0:
|
|
blocks[v.mine_mask] = self.rng.poisson(BLOCKS_PER_CP * self.hash[v.mine_mask] / side_tot)
|
|
else:
|
|
blocks = self.rng.poisson(BLOCKS_PER_CP * self.hash / tot) if tot > 0 else np.zeros(self.N)
|
|
hp = (slot // SLOTS_PER_HOUR) % WINDOW_HOURS
|
|
if slot % SLOTS_PER_HOUR == 0:
|
|
self.weight -= self.buckets[hp]
|
|
self.buckets[hp] = 0.0
|
|
self.buckets[hp] += blocks
|
|
self.weight += blocks
|
|
if p.local and len(self.views) > 1:
|
|
# a side's window holds only the blocks it has seen: the other sides' post-split blocks are unseen
|
|
for v in self.views:
|
|
v.excl += blocks * ~v.mine_mask
|
|
gidx = max(v.next_idx for v in self.views)
|
|
newly = (blocks > 0) & (self.first_idx == -1)
|
|
if newly.any():
|
|
self.first_idx[newly] = gidx
|
|
r = self.rng.random(self.N)
|
|
flip = np.where(self.online, r < self.q_off, r < self.q_on) & self.flaky
|
|
self.online ^= flip
|
|
for v in self.views:
|
|
v.blue += blocks[v.mine_mask].sum()
|
|
while v.blue >= BLOCKS_PER_CP:
|
|
v.blue -= BLOCKS_PER_CP
|
|
self.checkpoint(v, blocks)
|
|
self.slot += 1
|
|
|
|
def participation(self, v, idx):
|
|
part = np.minimum(1.0, v.pcount / float(self.p.presence))
|
|
new = (self.first_idx > idx - self.p.presence) & (self.first_idx >= 0)
|
|
part[new] = 1.0
|
|
return part
|
|
|
|
def checkpoint(self, v, blocks):
|
|
p = self.p
|
|
idx = v.next_idx
|
|
v.next_idx += 1
|
|
t0 = self.slot * SLOT_S
|
|
# miner of the checkpoint block: a key on this side weighted by its blocks this slot
|
|
bm = blocks * v.mine_mask
|
|
cum = np.cumsum(bm)
|
|
if cum[-1] > 0:
|
|
j = int(np.searchsorted(cum, self.rng.random() * cum[-1], side="right"))
|
|
src = int(self.region[min(j, self.N - 1)])
|
|
else:
|
|
src = v.regions[0]
|
|
jit1 = np.exp(self.rng.normal(0.0, p.jitter, self.R))
|
|
jit2 = np.exp(self.rng.normal(0.0, p.jitter, (self.R, self.R)))
|
|
# the weight table this side computes: global, or (P.local) less the blocks it has not seen since the split
|
|
wt = np.maximum(self.weight - v.excl, 0.0) if p.local else self.weight
|
|
elig = (wt >= p.dust) & ~self.stripped
|
|
voters = elig & self.online & self.signs & (v.key_mask | self.equiv)
|
|
if p.denom == "active":
|
|
denom = float((wt * self.participation(v, idx))[elig].sum())
|
|
else:
|
|
denom = float(wt[elig].sum())
|
|
total = float(wt[elig].sum())
|
|
if p.denom == "active" and p.floor > 0:
|
|
denom = max(denom, p.floor * total)
|
|
need = p.quorum * denom
|
|
vi = np.flatnonzero(voters)
|
|
signed_w = float(wt[vi].sum())
|
|
ratio = signed_w / denom if denom > 0 else 0.0
|
|
# rule v3 (frozen): signers also need 2/3 of the table frozen at the view's last certified checkpoint, counted at
|
|
# their frozen weights, while that checkpoint is less than one window old
|
|
wf, need_f = None, 0.0
|
|
if p.frozen and v.frozen_wt is not None and (self.slot - v.frozen_slot) < WINDOW_HOURS * SLOTS_PER_HOUR:
|
|
felig = (v.frozen_wt >= p.dust) & ~self.stripped
|
|
total_f = float(v.frozen_wt[felig].sum())
|
|
if total_f > 0:
|
|
wf = np.where(felig, v.frozen_wt, 0.0)[vi]
|
|
need_f = p.quorum * total_f
|
|
best = None
|
|
if denom > 0 and vi.size > 0:
|
|
w = wt[vi]
|
|
reg = self.region[vi]
|
|
hop1 = np.where(self.equiv[vi], p.intra, self.D[src, reg] * jit1[reg])
|
|
issue = t0 + hop1 + self.extra_delay[vi]
|
|
for a in v.regions:
|
|
hop2 = np.where(self.equiv[vi], p.intra, self.D[reg, a] * jit2[reg, a])
|
|
arr = issue + hop2
|
|
order = np.argsort(arr, kind="stable")
|
|
cw = np.cumsum(w[order])
|
|
k = int(np.searchsorted(cw, need))
|
|
if wf is not None:
|
|
k = max(k, int(np.searchsorted(np.cumsum(wf[order]), need_f)))
|
|
if k < cw.size:
|
|
thr = float(arr[order[k]])
|
|
if best is None or thr < best[0]:
|
|
best = (thr, arr)
|
|
if best is not None:
|
|
thr, arr = best
|
|
lat = thr - t0
|
|
seal = max(thr, t0 + p.grace)
|
|
mask = np.zeros(self.N, dtype=np.int8)
|
|
mask[vi[arr <= seal]] = 1
|
|
v.certs[idx] = (v.sid, self.slot, lat)
|
|
if p.frozen:
|
|
v.frozen_wt = wt.copy()
|
|
v.frozen_slot = self.slot
|
|
self._push(v, idx, mask)
|
|
self.records.append((self.slot, idx, v.sid, lat, ratio, denom / total if total > 0 else 0.0))
|
|
else:
|
|
v.stalls.append((idx, self.slot))
|
|
if p.pmode == "cert":
|
|
self._push(v, idx, np.zeros(self.N, dtype=np.int8))
|
|
elif p.pmode == "seen":
|
|
self._push(v, idx, voters.astype(np.int8))
|
|
# frozen: no ring update
|
|
self.records.append((self.slot, idx, v.sid, -1.0, ratio, denom / total if total > 0 else 0.0))
|
|
|
|
def _push(self, v, idx, mask):
|
|
row = idx % self.p.presence
|
|
v.pcount -= v.ring[row]
|
|
v.ring[row] = mask
|
|
v.pcount += mask
|
|
v.ring_idx[row] = idx
|
|
|
|
# ------------------------------------------------------------- queries
|
|
def recs(self):
|
|
return np.array(self.records, dtype=float).reshape(-1, 6)
|
|
|
|
def elig_mask(self):
|
|
return (self.weight >= self.p.dust) & ~self.stripped
|
|
|
|
def share(self, keys):
|
|
e = self.elig_mask()
|
|
tot = self.weight[e].sum()
|
|
if tot <= 0:
|
|
return 0.0
|
|
m = np.zeros(self.N, dtype=bool)
|
|
m[keys] = True
|
|
return float(self.weight[m & e].sum() / tot)
|
|
|
|
|
|
# ---------------------------------------------------------------- helpers
|
|
|
|
def pareto_hashrates(rng, n, total=1.0):
|
|
h = rng.pareto(PARETO_SHAPE, n) + 1.0
|
|
return h * (total / h.sum())
|
|
|
|
|
|
def assign_regions(hashes, targets):
|
|
"""Largest key first, each to the region with the largest remaining hashrate deficit."""
|
|
targets = np.asarray(targets, dtype=float)
|
|
tot = hashes.sum()
|
|
filled = np.zeros(targets.size)
|
|
reg = np.zeros(hashes.size, dtype=np.int64)
|
|
for i in np.argsort(-hashes):
|
|
r = int(np.argmax(targets * tot - filled))
|
|
reg[i] = r
|
|
filled[r] += hashes[i]
|
|
return reg
|
|
|
|
|
|
def pick_weight_subset(rng, weights, frac):
|
|
"""Random keys whose weight sums to about frac of total, never over."""
|
|
target = frac * weights.sum()
|
|
mask = np.zeros(weights.size, dtype=bool)
|
|
acc = 0.0
|
|
for i in rng.permutation(weights.size):
|
|
if acc + weights[i] <= target:
|
|
mask[i] = True
|
|
acc += weights[i]
|
|
return mask, acc / weights.sum()
|
|
|
|
|
|
def gini(x):
|
|
x = np.sort(np.asarray(x, dtype=float))
|
|
n = x.size
|
|
if n == 0 or x.sum() == 0:
|
|
return 0.0
|
|
cum = np.cumsum(x)
|
|
return (n + 1 - 2.0 * cum.sum() / cum[-1]) / n
|
|
|
|
|
|
def pct(x, d=1):
|
|
return ("%%.%df%%%%" % d) % (100.0 * x)
|
|
|
|
|
|
def md_table(headers, rows):
|
|
out = ["| " + " | ".join(str(h) for h in headers) + " |", "|" + "---|" * len(headers)]
|
|
for r in rows:
|
|
out.append("| " + " | ".join(str(c) for c in r) + " |")
|
|
return "\n".join(out)
|
|
|
|
|
|
def lat_stats(recs, s_from=0, s_to=None):
|
|
if s_to is None:
|
|
s_to = np.inf
|
|
sel = recs[(recs[:, 0] >= s_from) & (recs[:, 0] < s_to)]
|
|
lat = sel[:, 3]
|
|
ok = lat >= 0
|
|
n = int(sel.shape[0])
|
|
st = int((~ok).sum())
|
|
cps = np.floor(lat[ok] / SLOT_S)
|
|
d = dict(n=n, stalls=st, c0=int((cps == 0).sum()), c1=int((cps == 1).sum()), c2=int((cps >= 2).sum()))
|
|
if ok.any():
|
|
d.update(med=float(np.median(lat[ok])), p99=float(np.percentile(lat[ok], 99)), mx=float(lat[ok].max()),
|
|
ratio_min=float(sel[ok, 4].min()), ratio_med=float(np.median(sel[ok, 4])))
|
|
else:
|
|
d.update(med=float("nan"), p99=float("nan"), mx=float("nan"), ratio_min=float("nan"), ratio_med=float("nan"))
|
|
return d
|
|
|
|
|
|
def lat_row(label, d):
|
|
return [label, d["n"], d["c0"], d["c1"], d["c2"], d["stalls"], "%.1f" % d["med"], "%.1f" % d["p99"], "%.1f" % d["mx"],
|
|
"%.3f" % d["ratio_min"]]
|
|
|
|
|
|
LAT_HEADERS = ["run", "checkpoints", "locked in slot 0", "slot 1", "slot 2+", "stalled", "median s", "p99 s", "max s",
|
|
"min signed/denominator"]
|
|
|
|
|
|
def first_lock_after(recs, slot, sid=None):
|
|
sel = recs[(recs[:, 0] >= slot) & (recs[:, 3] >= 0)]
|
|
if sid is not None:
|
|
sel = sel[sel[:, 2] == sid]
|
|
if sel.shape[0] == 0:
|
|
return None
|
|
return int(sel[0, 0])
|
|
|
|
|
|
def stalls_between(recs, s_from, s_to, sid=None):
|
|
sel = recs[(recs[:, 0] >= s_from) & (recs[:, 0] < s_to) & (recs[:, 3] < 0)]
|
|
if sid is not None:
|
|
sel = sel[sel[:, 2] == sid]
|
|
return int(sel.shape[0])
|
|
|
|
|
|
def fmt_min(slots):
|
|
if slots is None:
|
|
return "never"
|
|
m = slots * SLOT_S / 60.0
|
|
if m < 120:
|
|
return "%.0f min" % m
|
|
if m < 48 * 60:
|
|
return "%.1f h" % (m / 60.0)
|
|
return "%.1f d" % (m / 1440.0)
|
|
|
|
|
|
def fmt_days(slots):
|
|
return "never" if slots is None else "%.1f" % (slots / SLOTS_PER_DAY)
|
|
|
|
|
|
def build_honest(p, seed, n_regions=3, geography=GEOGRAPHY, big_pool=None):
|
|
rng = np.random.default_rng(seed)
|
|
sim = Sim(p, rng, n_regions=n_regions)
|
|
if big_pool is None:
|
|
h = pareto_hashrates(rng, N_HONEST)
|
|
reg = assign_regions(h, geography)
|
|
sim.add_keys(h, reg, flaky=True)
|
|
pool = None
|
|
else:
|
|
h = pareto_hashrates(rng, N_HONEST - 1, total=1.0 - big_pool)
|
|
reg = assign_regions(h, geography)
|
|
sim.add_keys(h, reg, flaky=True)
|
|
pool = int(sim.add_keys([big_pool], [3], flaky=False)[0])
|
|
return sim, rng, pool
|
|
|
|
|
|
# ---------------------------------------------------------------- scenarios
|
|
|
|
def scenario_a(args):
|
|
out = ["### A. Steady state from zero history, 1,000 honest keys, 3 regions %s, %d days" % (
|
|
"/".join(pct(g, 0) for g in GEOGRAPHY), args.days_a), ""]
|
|
days = args.days_a
|
|
snaps = {}
|
|
lat_rows = []
|
|
prop_rows = []
|
|
for denom in ("active", "total"):
|
|
p = P(denom=denom, delay=args.delay)
|
|
sim, rng, _ = build_honest(p, args.seed)
|
|
sim.init_views(warm=False)
|
|
series = []
|
|
|
|
def snap(s, series=series):
|
|
e = s.elig_mask()
|
|
series.append((s.slot // SLOTS_PER_DAY, s.weight.sum() / WINDOW_BLOCKS, int((~e).sum())))
|
|
|
|
sim.run(days * SLOTS_PER_DAY, snap=(SLOTS_PER_DAY, snap))
|
|
snap(sim)
|
|
recs = sim.recs()
|
|
snaps[denom] = series
|
|
lat_rows.append(lat_row("%s, days 0 to 1" % denom, lat_stats(recs, 0, SLOTS_PER_DAY)))
|
|
lat_rows.append(lat_row("%s, days 1 to 30" % denom, lat_stats(recs, SLOTS_PER_DAY, 30 * SLOTS_PER_DAY)))
|
|
lat_rows.append(lat_row("%s, days 30 to %d" % (denom, days), lat_stats(recs, 30 * SLOTS_PER_DAY, None)))
|
|
w = sim.weight
|
|
ws = w / w.sum()
|
|
hs = sim.hash / sim.hash.sum()
|
|
order = np.argsort(-hs)
|
|
rel = ws / hs
|
|
prop_rows.append([denom, "%.5f" % np.corrcoef(hs, ws)[0, 1], "%.3f / %.3f" % (gini(hs), gini(ws)),
|
|
pct(hs[order[0]]) + " / " + pct(ws[order[0]]),
|
|
pct(hs[order[:10]].sum()) + " / " + pct(ws[order[:10]].sum()),
|
|
pct(hs[order[500:]].sum()) + " / " + pct(ws[order[500:]].sum()),
|
|
"%.3f / %.3f" % (rel.min(), rel.max()), int((rel < 0.9).sum()), int((~sim.elig_mask()).sum())])
|
|
# genesis stall run
|
|
first = first_lock_after(recs, 0)
|
|
snaps[denom + "_first"] = first
|
|
s = snaps["active"]
|
|
ramp = []
|
|
for d in (1, 2, 5, 10, 20, 30, 31, 45, days):
|
|
row = [x for x in s if x[0] == d]
|
|
if row:
|
|
ramp.append([d, pct(row[0][1]), row[0][2]])
|
|
out.append("Weight ramp (active run; the total run mines the same blocks):")
|
|
out.append("")
|
|
out.append(md_table(["day", "total weight / full window", "keys under dust (100 blocks)"], ramp))
|
|
out.append("")
|
|
out.append("Weight against hashrate at day %d:" % days)
|
|
out.append("")
|
|
out.append(md_table(["denominator", "corr(hash, weight)", "Gini hash / weight", "top-1 hash / weight",
|
|
"top-10 hash / weight", "bottom-500 hash / weight", "min / max weight:hash", "keys under 0.9x",
|
|
"dust keys"], prop_rows))
|
|
out.append("")
|
|
out.append("Lock latency (seconds from checkpoint block to quorum; slot = 30 s), inter-region delay %.1f s:" % args.delay)
|
|
out.append("")
|
|
out.append(md_table(LAT_HEADERS, lat_rows))
|
|
out.append("")
|
|
out.append("First lock from genesis: active at slot %s, total at slot %s (the first checkpoints have no key above dust)." % (
|
|
snaps["active_first"], snaps["total_first"]))
|
|
out.append("")
|
|
# delay comparison, short warm-started runs
|
|
rows = []
|
|
for delay in (0.5, 2.0, 5.0):
|
|
for grace in (args.grace,):
|
|
p = P(denom="active", delay=delay, grace=grace)
|
|
sim, rng, _ = build_honest(p, args.seed)
|
|
sim.warm_start()
|
|
sim.init_views(warm=True)
|
|
sim.run(args.days_delay * SLOTS_PER_DAY)
|
|
recs = sim.recs()
|
|
d = lat_stats(recs)
|
|
# participation of the slowest region
|
|
v = sim.views[0]
|
|
part = sim.participation(v, v.next_idx)
|
|
by_region = ["%.3f" % np.average(part[sim.region == r], weights=sim.weight[sim.region == r]) for r in range(3)]
|
|
rows.append(lat_row("delay %.1f s, grace %.0f s, %d days warm" % (delay, grace, args.days_delay), d) + ["/".join(by_region)])
|
|
out.append("Delay sweep, warm-started (steady-state weights), active denominator. Last column: weight-averaged participation per region.")
|
|
out.append("")
|
|
out.append(md_table(LAT_HEADERS + ["participation r0/r1/r2"], rows))
|
|
return "\n".join(out)
|
|
|
|
|
|
def scenario_b(args):
|
|
out = ["### B. Rental burst at day 60, one public key with a x honest hashrate, signs every checkpoint", ""]
|
|
mults = (1, 2, 4, 9)
|
|
series = {}
|
|
cross = {}
|
|
for a in mults:
|
|
p = P(denom="active", delay=args.delay)
|
|
sim, rng, _ = build_honest(p, args.seed, n_regions=4)
|
|
att = int(sim.add_keys([0.0], [3], flaky=False)[0])
|
|
sim.warm_start()
|
|
sim.init_views(warm=True)
|
|
sim.run(SLOTS_PER_HOUR) # one hour of baseline
|
|
t_event = sim.slot
|
|
sim.hash[att] = float(a)
|
|
s = []
|
|
c13 = c23 = None
|
|
for day in range(1, args.days_b + 1):
|
|
for _ in range(SLOTS_PER_DAY // 24):
|
|
sim.run(24)
|
|
sh = sim.share([att])
|
|
if c13 is None and sh >= 1.0 / 3.0:
|
|
c13 = sim.slot - t_event
|
|
if c23 is None and sh >= TWO_THIRDS:
|
|
c23 = sim.slot - t_event
|
|
s.append((day, sim.share([att])))
|
|
recs = sim.recs()
|
|
series[a] = s
|
|
cross[a] = (c13, c23, lat_stats(recs, t_event, None))
|
|
pick = [d for d in (1, 5, 10, 15, 20, 25, 30, 35) if d <= args.days_b]
|
|
rows = []
|
|
for d in pick:
|
|
row = ["+%d" % d]
|
|
for a in mults:
|
|
sim_v = dict(series[a])[d]
|
|
formula = min(d / 30.0, 1.0) * a / (1.0 + a)
|
|
row.append("%s / %s" % (pct(sim_v), pct(formula)))
|
|
rows.append(row)
|
|
out.append("Attacker weight share, simulated / formula (t/30) x a/(1+a):")
|
|
out.append("")
|
|
out.append(md_table(["day after burst"] + ["a=%d (%s of hashrate)" % (a, pct(a / (1.0 + a), 0)) for a in mults], rows))
|
|
out.append("")
|
|
ev = [
|
|
["crosses 1/3 (honest alone can no longer lock), sim day"] + [fmt_days(cross[a][0]) for a in mults],
|
|
["crosses 1/3, formula 10(1+a)/a"] + ["%.1f" % (10.0 * (1 + a) / a) for a in mults],
|
|
["crosses 2/3 (locks alone), sim day"] + [fmt_days(cross[a][1]) for a in mults],
|
|
["crosses 2/3, formula 20(1+a)/a"] + ["%.1f" % (20.0 * (1 + a) / a) if 20.0 * (1 + a) / a <= 30 else "never (ceiling %s)" % pct(a / (1 + a), 0) for a in mults],
|
|
["max abs deviation sim vs formula, days 1 to 30, points"] + [
|
|
"%.2f" % (100 * max(abs(v - min(d / 30.0, 1.0) * a / (1.0 + a)) for d, v in series[a] if d <= 30)) for a in mults],
|
|
["stalled checkpoints after the burst"] + [cross[a][2]["stalls"] for a in mults],
|
|
]
|
|
out.append(md_table(["event"] + ["a=%d" % a for a in mults], ev))
|
|
return "\n".join(out)
|
|
|
|
|
|
def scenario_c(args):
|
|
out = ["### C. Silent set: a random set holding x of weight stops signing at day 60 (hour 3 of the run) and keeps mining", ""]
|
|
fracs = (0.34, 0.40, 0.45, 0.50, 0.55)
|
|
configs = [("active", "cert", args.hours_c, 0.0, 240), ("active", "seen", args.hours_c, 0.0, 240),
|
|
("active", "frozen", args.hours_c, 0.0, 240), ("active", "cert", args.hours_c_total, 0.0, 2880),
|
|
("active", "cert", args.hours_c_total, 0.8, 240), ("active", "cert", args.hours_c_total, 0.85, 240),
|
|
("active", "cert", args.hours_c_total, 1.0, 240), ("total", "cert", args.hours_c_total, 0.0, 240)]
|
|
labels = []
|
|
for denom, pmode, hours, floor, presence in configs:
|
|
lab = P(denom=denom, pmode=pmode, floor=floor, presence=presence).label()
|
|
if presence != 240:
|
|
lab += ", presence %d" % presence
|
|
labels.append(lab)
|
|
rows = []
|
|
detail = []
|
|
for frac in fracs:
|
|
row = [pct(frac, 0)]
|
|
for denom, pmode, hours, floor, presence in configs:
|
|
p = P(denom=denom, pmode=pmode, delay=args.delay, floor=floor, presence=presence)
|
|
sim, rng, _ = build_honest(p, args.seed)
|
|
sim.warm_start()
|
|
sim.init_views(warm=True)
|
|
sim.run(3 * SLOTS_PER_HOUR)
|
|
silent, got = pick_weight_subset(rng, sim.weight, frac)
|
|
sim.signs[silent] = False
|
|
t_event = sim.slot
|
|
v0 = sim.views[0]
|
|
e0 = sim.elig_mask()
|
|
s_on = float(sim.weight[e0 & sim.online & ~silent].sum() / sim.weight[e0].sum())
|
|
p0 = float((sim.weight * sim.participation(v0, v0.next_idx))[e0].sum() / sim.weight[e0].sum())
|
|
predicted = max(0, int(round(p.presence * (p0 - 1.5 * s_on))))
|
|
sim.run(int(hours * SLOTS_PER_HOUR))
|
|
recs = sim.recs()
|
|
fl = first_lock_after(recs, t_event)
|
|
st = stalls_between(recs, t_event, sim.slot)
|
|
v = sim.views[0]
|
|
part = sim.participation(v, v.next_idx)
|
|
sil_part = float(np.average(part[silent], weights=sim.weight[silent]))
|
|
# stalls after the first lock (does it stay locked?)
|
|
later = stalls_between(recs, fl, sim.slot) if fl is not None else st
|
|
fl_txt = "never (%s)" % fmt_min(sim.slot - t_event) if fl is None else fmt_min(fl - t_event)
|
|
row.append("%s, %d stalled" % (fl_txt, st))
|
|
if denom == "active" and pmode == "cert" and floor == 0 and presence == 240:
|
|
tail = recs[(recs[:, 0] >= sim.slot - SLOTS_PER_HOUR) & (recs[:, 3] >= 0)]
|
|
detail.append([pct(frac, 0), pct(got), int(silent.sum()), pct(s_on), "%.3f" % p0, predicted, fl_txt, st, later, "%.3f" % sil_part,
|
|
"%.3f" % (np.median(tail[:, 4]) if tail.shape[0] else float("nan")),
|
|
"%.3f" % (np.median(tail[:, 5]) if tail.shape[0] else float("nan"))])
|
|
rows.append(row)
|
|
out.append("Time from the event to the first lock, and checkpoints stalled in the run (%d h for the first three columns, %d h for the rest):" % (
|
|
args.hours_c, args.hours_c_total))
|
|
out.append("")
|
|
out.append(md_table(["silent weight"] + labels, rows))
|
|
out.append("")
|
|
out.append("Active/cert detail. Predicted stalls = presence x (p0 - 1.5 x online signing share), where p0 is the weight-averaged participation at the event:")
|
|
out.append("")
|
|
out.append(md_table(["silent weight", "picked", "keys", "online signing share at event", "p0", "predicted stalls", "first lock", "stalled", "stalled after first lock",
|
|
"silent participation at end", "signed/denominator at end (median, last hour)",
|
|
"active/total at end"], detail))
|
|
return "\n".join(out)
|
|
|
|
|
|
def scenario_d(args):
|
|
out = ["### D. Churn: a random set holding x of weight stops mining and signing at day 60 (hour 3 of the run)", ""]
|
|
fracs = (0.35, 0.50)
|
|
rows = []
|
|
dconfigs = [P(denom="active", delay=args.delay), P(denom="active", presence=2880, delay=args.delay),
|
|
P(denom="active", floor=0.8, delay=args.delay), P(denom="active", floor=0.85, delay=args.delay),
|
|
P(denom="active", floor=1.0, delay=args.delay), P(denom="total", delay=args.delay)]
|
|
for frac in fracs:
|
|
for p in dconfigs:
|
|
days = args.days_d35 if frac < 0.4 else args.days_d50
|
|
denom = p.label() + (", presence 2880" if p.presence == 2880 else "")
|
|
sim, rng, _ = build_honest(p, args.seed)
|
|
sim.warm_start()
|
|
sim.init_views(warm=True)
|
|
sim.run(3 * SLOTS_PER_HOUR)
|
|
gone, got = pick_weight_subset(rng, sim.weight, frac)
|
|
sim.signs[gone] = False
|
|
sim.online[gone] = False
|
|
sim.flaky[gone] = False
|
|
sim.hash[gone] = 0.0
|
|
t_event = sim.slot
|
|
live = ~gone
|
|
sim.run(int(days * SLOTS_PER_DAY))
|
|
recs = sim.recs()
|
|
fl = first_lock_after(recs, t_event)
|
|
st = stalls_between(recs, t_event, sim.slot)
|
|
later = stalls_between(recs, fl, sim.slot) if fl is not None else 0
|
|
live_share_at = None
|
|
rows.append([pct(frac, 0), pct(got), int(gone.sum()), denom,
|
|
"never in %s" % fmt_min(sim.slot - t_event) if fl is None else fmt_min(fl - t_event),
|
|
st, later, pct(sim.share(np.flatnonzero(live)))])
|
|
out.append(md_table(["churn weight", "picked", "keys", "denominator", "first lock after the event", "stalled checkpoints",
|
|
"stalled after first lock", "live share of total weight at end of run"], rows))
|
|
out.append("")
|
|
out.append("Analytic (perfect retarget): live share of total weight on day t = 1 - x(30 - t)/30, so the total "
|
|
"denominator recovers at t = 30(1 - 1/(3x)): never for x <= 1/3, day 1.4 at 35%, day 10 at 50%.")
|
|
return "\n".join(out)
|
|
|
|
|
|
def run_partition(seed, fracs, att_share, dur_min, p, pre_min=60, post_min=180):
|
|
rng = np.random.default_rng(seed)
|
|
nR = max(3, len(fracs) + 1)
|
|
sim = Sim(p, rng, n_regions=nR)
|
|
h = pareto_hashrates(rng, N_HONEST)
|
|
reg = assign_regions(h, fracs)
|
|
sim.add_keys(h, reg, flaky=True)
|
|
groups = [[i] for i in range(len(fracs))]
|
|
att = None
|
|
if att_share > 0:
|
|
att = int(sim.add_keys([att_share / (1.0 - att_share)], [len(fracs)], flaky=False, equiv=True)[0])
|
|
groups[0].append(len(fracs))
|
|
sim.warm_start()
|
|
sim.init_views(warm=True)
|
|
sim.run(pre_min * 2)
|
|
t_split = sim.slot
|
|
sim.split(groups)
|
|
sides = [v.sid for v in sim.views]
|
|
sim.run(dur_min * 2)
|
|
t_heal = sim.slot
|
|
sim.heal()
|
|
sim.run(post_min * 2)
|
|
recs = sim.recs()
|
|
res = dict(conflicts=len(sim.conflicts), t_split=t_split, t_heal=t_heal)
|
|
res["first_conflict_min"] = (min(c[1] for c in sim.conflicts) - t_split) / 2.0 if sim.conflicts else None
|
|
res["side_first_lock"] = []
|
|
res["side_locks"] = []
|
|
res["side_stalls"] = []
|
|
for sid in sides:
|
|
fl = first_lock_after(recs[recs[:, 0] < t_heal], t_split, sid=sid)
|
|
res["side_first_lock"].append(None if fl is None else (fl - t_split) / 2.0)
|
|
sel = recs[(recs[:, 0] >= t_split) & (recs[:, 0] < t_heal) & (recs[:, 2] == sid)]
|
|
res["side_locks"].append(int((sel[:, 3] >= 0).sum()))
|
|
res["side_stalls"].append(int((sel[:, 3] < 0).sum()))
|
|
res["post_stalls"] = stalls_between(recs, t_heal, sim.slot)
|
|
fl = first_lock_after(recs, t_heal)
|
|
res["post_first_lock_min"] = None if fl is None else (fl - t_heal) / 2.0
|
|
res["att_share"] = sim.share([att]) if att is not None else 0.0
|
|
return res
|
|
|
|
|
|
def fm(m):
|
|
return "never" if m is None else "%.0f" % m
|
|
|
|
|
|
def scenario_e(args):
|
|
out = ["### E. Partition: honest weight split across sides for a set time, then healed", ""]
|
|
configs = [P(denom="active", pmode="cert", delay=args.delay), P(denom="active", pmode="seen", delay=args.delay),
|
|
P(denom="total", delay=args.delay)]
|
|
q = getattr(args, "quick", False)
|
|
splits = [("50/50", [0.5, 0.5])] if q else [("50/50", [0.5, 0.5]), ("33/33/34", [0.33, 0.33, 0.34])]
|
|
rows_conf = []
|
|
rows_first = []
|
|
for name, fr in splits:
|
|
for att in ((0.0,) if q else (0.0, 0.34)):
|
|
for dur in ((30,) if q else (30, 90, 150)):
|
|
rc = [name, pct(att, 0), dur]
|
|
rf = [name, pct(att, 0), dur]
|
|
for p in configs:
|
|
r = run_partition(args.seed, fr, att, dur, p, **({"pre_min": 15, "post_min": 30} if q else {}))
|
|
rc.append("%d%s" % (r["conflicts"], "" if r["conflicts"] == 0 else " (first at %s min)" % fm(r["first_conflict_min"])))
|
|
rf.append(" / ".join(fm(x) for x in r["side_first_lock"]) + " ; post-heal stalls %d" % r["post_stalls"])
|
|
rows_conf.append(rc)
|
|
rows_first.append(rf)
|
|
labels = [p.label() for p in configs]
|
|
out.append("Conflicting locks (two certificates at one index, different blocks). Attacker = equivocating key holding the stated share of total weight, honest weight split as stated. Pass needs 0 at 0% attacker for 30, 90 and 150 min.")
|
|
out.append("")
|
|
out.append(md_table(["honest split", "attacker", "partition min"] + labels, rows_conf))
|
|
out.append("")
|
|
out.append("Minutes after the split until each side's first lock (side order as in the split; attacker mines on the first side) and stalls in the 3 hours after the heal:")
|
|
out.append("")
|
|
out.append(md_table(["honest split", "attacker", "partition min"] + labels, rows_first))
|
|
out.append("")
|
|
# supplementary: more splits, 0% attacker, plus the DAA-retarget and floor variants
|
|
configs2 = configs + [P(denom="active", pmode="cert", daa="full", delay=args.delay),
|
|
P(denom="active", pmode="seen", daa="full", delay=args.delay),
|
|
P(denom="active", pmode="cert", floor=0.8, daa="full", delay=args.delay),
|
|
P(denom="active", pmode="cert", floor=0.85, daa="full", delay=args.delay),
|
|
P(denom="active", pmode="cert", floor=1.0, daa="full", delay=args.delay)]
|
|
labels2 = [p.label() for p in configs2]
|
|
rows = []
|
|
for name, fr in (("50/50", [0.5, 0.5]), ("60/40", [0.6, 0.4]), ("67/33", [0.67, 0.33]), ("80/20", [0.8, 0.2]),
|
|
("33/33/34", [0.33, 0.33, 0.34])):
|
|
for dur in (150, 360):
|
|
rc = [name, dur]
|
|
for p in configs2:
|
|
r = run_partition(args.seed, fr, 0.0, dur, p, post_min=120)
|
|
rc.append("%d; %s" % (r["conflicts"], " / ".join(fm(x) for x in r["side_first_lock"])))
|
|
rows.append(rc)
|
|
out.append("Supplementary, 0% attacker, 150 and 360 min. Cell = conflicting locks; minutes to each side's first lock. "
|
|
"'+daa' = each side retargets to 1 block/s at once (worst case for the presence clock); "
|
|
"'+floor0.80' = active denominator never below 80% of total weight (a lock needs at least 53.3% of total), "
|
|
"'+floor0.85' needs 56.7%, '+floor1.00' needs 66.7% (the rule of 4 October 2026; arithmetically the total column).")
|
|
out.append("")
|
|
out.append(md_table(["honest split", "partition min"] + labels2, rows))
|
|
out.append("")
|
|
# presence window sweep, active/cert, 0% attacker
|
|
rows = []
|
|
for presence in (240, 720, 2880):
|
|
for name, fr in (("50/50", [0.5, 0.5]), ("60/40", [0.6, 0.4]), ("33/33/34", [0.33, 0.33, 0.34])):
|
|
dur = {240: 360, 720: 720, 2880: 1500}[presence]
|
|
p = P(denom="active", pmode="cert", presence=presence, delay=args.delay)
|
|
r = run_partition(args.seed, fr, 0.0, dur, p, post_min=120)
|
|
s = min(fr)
|
|
pred = presence * (1.0 - 1.5 * s) / s / 2.0 if s < TWO_THIRDS else None
|
|
rows.append([presence, "%.1f h" % (presence / 120.0), name, dur, r["conflicts"],
|
|
" / ".join(fm(x) for x in r["side_first_lock"]), "%.0f" % pred if pred is not None else "no"])
|
|
out.append("Presence window sweep, active/cert, 0% attacker. Prediction for the smallest side (share s): "
|
|
"first lock after presence x (1 - 1.5 s) / s slots, in minutes = that / 2.")
|
|
out.append("")
|
|
out.append(md_table(["presence (checkpoints)", "presence (hours)", "honest split", "partition min", "conflicts",
|
|
"first lock per side, min", "predicted smallest-side lock, min"], rows))
|
|
return "\n".join(out)
|
|
|
|
|
|
def run_eclipse(seed, dur_h, poisoned, p, pre_min=60, post_h=5):
|
|
rng = np.random.default_rng(seed)
|
|
sim = Sim(p, rng, n_regions=5)
|
|
# shares of TOTAL weight: pool 20%, attacker 34% when poisoned, the rest honest
|
|
others = 0.8 if not poisoned else 0.46
|
|
h = pareto_hashrates(rng, N_HONEST - 1, total=others)
|
|
reg = assign_regions(h, GEOGRAPHY)
|
|
sim.add_keys(h, reg, flaky=True)
|
|
K = int(sim.add_keys([0.2], [3], flaky=False)[0])
|
|
att = None
|
|
if poisoned:
|
|
att = int(sim.add_keys([0.34], [4], flaky=False, equiv=True)[0])
|
|
sim.warm_start()
|
|
sim.init_views(warm=True)
|
|
track = []
|
|
|
|
def snap(s):
|
|
v = [x for x in s.views if 0 in x.regions][0]
|
|
track.append((s.slot, float(min(1.0, v.pcount[K] / p.presence))))
|
|
|
|
sim.run(pre_min * 2, snap=(10, snap))
|
|
t0 = sim.slot
|
|
if poisoned:
|
|
sim.split([[0, 1, 2], [3, 4]])
|
|
else:
|
|
sim.extra_delay[K] = dur_h * 3600.0
|
|
sim.run(int(dur_h * SLOTS_PER_HOUR), snap=(10, snap))
|
|
t_end = sim.slot
|
|
if poisoned:
|
|
sim.heal()
|
|
else:
|
|
sim.extra_delay[K] = 0.0
|
|
sim.run(post_h * SLOTS_PER_HOUR, snap=(10, snap))
|
|
snap(sim)
|
|
recs = sim.recs()
|
|
tr = np.array(track)
|
|
during = tr[(tr[:, 0] >= t0) & (tr[:, 0] <= t_end)]
|
|
after = tr[tr[:, 0] >= t_end]
|
|
res = dict(min_part=float(tr[:, 1].min()), part_at_end=float(during[-1, 1]) if during.shape[0] else 1.0)
|
|
below = tr[(tr[:, 0] >= t0) & (tr[:, 1] < 0.999)]
|
|
res["drop_min"] = None if below.shape[0] == 0 else (below[0, 0] - t0) / 2.0
|
|
rec = after[after[:, 1] >= 0.999]
|
|
res["recover_min"] = None if rec.shape[0] == 0 else (rec[0, 0] - t_end) / 2.0
|
|
res["conflicts"] = len(sim.conflicts)
|
|
res["first_conflict_min"] = (min(c[1] for c in sim.conflicts) - t0) / 2.0 if sim.conflicts else None
|
|
honest_sid = [v for v in [1]] if poisoned else [0]
|
|
res["honest_stalls"] = stalls_between(recs, t0, t_end, sid=(1 if poisoned else 0))
|
|
res["ecl_locks"] = int(((recs[:, 2] == 2) & (recs[:, 3] >= 0) & (recs[:, 0] >= t0) & (recs[:, 0] < t_end)).sum()) if poisoned else 0
|
|
res["post_stalls"] = stalls_between(recs, t_end, sim.slot)
|
|
return res
|
|
|
|
|
|
def scenario_f(args):
|
|
out = ["### F. Eclipse of one pool holding 20% of weight", ""]
|
|
configs = [P(denom="active", pmode="cert", delay=args.delay), P(denom="active", pmode="seen", delay=args.delay),
|
|
P(denom="total", delay=args.delay)]
|
|
q = getattr(args, "quick", False)
|
|
rows = []
|
|
for dur in ((1,) if q else (1, 2, 4)):
|
|
for p in configs:
|
|
r = run_eclipse(args.seed, dur, False, p)
|
|
rows.append([dur, p.label(), "%.3f" % r["min_part"], fm(r["drop_min"]), fm(r["recover_min"]), r["conflicts"],
|
|
r["honest_stalls"], r["post_stalls"]])
|
|
out.append("F1. Delayed view: the pool receives every block and vote %s late, votes for the right blocks, late. Participation as the rest of the network computes it." % "D hours")
|
|
out.append("")
|
|
out.append(md_table(["eclipse h", "denominator", "pool participation, minimum", "first drop below 1, min after start",
|
|
"back to 1, min after end", "conflicting locks", "stalls during", "stalls after"], rows))
|
|
out.append("")
|
|
rows = []
|
|
configs2 = configs + [P(denom="active", pmode="cert", floor=0.8, delay=args.delay),
|
|
P(denom="active", pmode="cert", floor=0.85, delay=args.delay),
|
|
P(denom="active", pmode="cert", floor=1.0, delay=args.delay)]
|
|
for dur in ((1,) if q else (1, 2, 4)):
|
|
for p in configs2:
|
|
r = run_eclipse(args.seed, dur, True, p)
|
|
rows.append([dur, p.label(), "%.3f" % r["min_part"], fm(r["recover_min"]), r["conflicts"], fm(r["first_conflict_min"]),
|
|
r["ecl_locks"], r["honest_stalls"], r["post_stalls"]])
|
|
out.append("F2. Poisoned view: an attacker holding 34% of weight feeds the pool a private fork for the eclipse, signs both forks, and is stripped at the heal. The pool votes for the attacker's checkpoints. Honest side = the other 46%.")
|
|
out.append("")
|
|
out.append(md_table(["eclipse h", "denominator", "pool participation, minimum (honest view)", "back to 1, min after end",
|
|
"conflicting locks", "first conflict, min after start", "locks on the eclipsed side",
|
|
"honest-side stalls during", "stalls after heal"], rows))
|
|
return "\n".join(out)
|
|
|
|
|
|
def scenario_g(args):
|
|
out = ["### G. Honest doubling overnight at day 60: 1,000 new keys, fresh Pareto draw, same total hashrate as the old 1,000", ""]
|
|
rows_share = []
|
|
rows_lat = []
|
|
ev = []
|
|
for denom in ("active", "total"):
|
|
p = P(denom=denom, delay=args.delay)
|
|
sim, rng, _ = build_honest(p, args.seed)
|
|
h_new = pareto_hashrates(rng, N_HONEST, total=1.0)
|
|
new = sim.add_keys(np.zeros(N_HONEST), assign_regions(h_new, GEOGRAPHY), flaky=True)
|
|
old = np.arange(N_HONEST)
|
|
sim.warm_start()
|
|
sim.init_views(warm=True)
|
|
sim.run(SLOTS_PER_HOUR)
|
|
t_event = sim.slot
|
|
sim.hash[new] = h_new
|
|
sim.set_uptime()
|
|
series = []
|
|
last_23 = None
|
|
for day in range(1, args.days_g + 1):
|
|
sim.run(SLOTS_PER_DAY)
|
|
so = sim.share(old)
|
|
sn = sim.share(new)
|
|
dust_new = int((sim.weight[new] < p.dust).sum())
|
|
series.append((day, so, sn, dust_new))
|
|
if so >= TWO_THIRDS:
|
|
last_23 = day
|
|
recs = sim.recs()
|
|
if denom == "active":
|
|
for d in (1, 5, 10, 15, 20, 21, 25):
|
|
r = [x for x in series if x[0] == d]
|
|
if r:
|
|
rows_share.append(["+%d" % d, pct(r[0][1]), pct(r[0][2]), pct(min(d / 60.0, 0.5)), r[0][3]])
|
|
rows_lat.append(lat_row("%s, day before" % denom, lat_stats(recs, 0, t_event)))
|
|
rows_lat.append(lat_row("%s, days 1 to 5 after" % denom, lat_stats(recs, t_event, t_event + 5 * SLOTS_PER_DAY)))
|
|
rows_lat.append(lat_row("%s, days 5 to %d after" % (denom, args.days_g), lat_stats(recs, t_event + 5 * SLOTS_PER_DAY, None)))
|
|
r45 = next((d for d, so, sn, _ in series if sn >= 0.45), None)
|
|
r49 = next((d for d, so, sn, _ in series if sn >= 0.49), None)
|
|
ev.append([denom, last_23, "not in run" if r45 is None else r45, "not in run" if r49 is None else r49,
|
|
stalls_between(recs, t_event, sim.slot)])
|
|
out.append("Weight shares (active run):")
|
|
out.append("")
|
|
out.append(md_table(["day after doubling", "old cohort", "new cohort", "new cohort formula t/60", "new keys under dust"], rows_share))
|
|
out.append("")
|
|
out.append(md_table(["denominator", "last day old cohort holds 2/3 (locks alone)", "new cohort reaches 45%", "new cohort reaches 49%",
|
|
"stalled checkpoints"], ev))
|
|
out.append("")
|
|
out.append(md_table(LAT_HEADERS, rows_lat))
|
|
return "\n".join(out)
|
|
|
|
|
|
# ---------------------------------------------------------------- additions, 3 October 2026, for section 3.11 Guarantees
|
|
# Scenarios H to K run the rule exactly as Q3 specifies it (active denominator, cert reading, the floor at FLOOR_F x 2/3 of
|
|
# total) over several seeds. Floor factor FLOOR_F: 0.85 until 4 October 2026 (a lock needed 2/3 of active and 17/30 of
|
|
# total); 1.0 since (decision of 4 October 2026, O-3.15: a lock needs 2/3 of total, which implies the active test).
|
|
# `--floor 0.85` reproduces the 3 October tables. The floor-1.0 rule is arithmetically the "total" denominator of A to G.
|
|
|
|
NEW_SEEDS = (7, 11, 13, 17, 19)
|
|
FLOOR_F = 1.0
|
|
P_FLOOR = FLOOR_F * TWO_THIRDS
|
|
|
|
|
|
def set_floor(f):
|
|
"""Set the floor factor for rule_p and the predictions of H to L (called from main with --floor)."""
|
|
global FLOOR_F, P_FLOOR
|
|
FLOOR_F = float(f)
|
|
P_FLOOR = FLOOR_F * TWO_THIRDS
|
|
|
|
|
|
def rule_p(delay, **kw):
|
|
"""Q3 as specified: active/cert with the floor at FLOOR_F x 2/3 of total."""
|
|
base = dict(denom="active", pmode="cert", floor=FLOOR_F, delay=delay)
|
|
base.update(kw)
|
|
return P(**base)
|
|
|
|
|
|
def rule_name():
|
|
return "active/cert + floor %.2f (a lock needs %s of total)" % (FLOOR_F, pct(P_FLOOR))
|
|
|
|
|
|
def seeds_of(args):
|
|
s = getattr(args, "seeds", "") or ""
|
|
out = tuple(int(x) for x in s.split(",") if x.strip())
|
|
return out or NEW_SEEDS
|
|
|
|
|
|
def span(vals, fmt="%d"):
|
|
"""'a' when every seed agrees, else 'a to b'."""
|
|
vals = list(vals)
|
|
lo, hi = min(vals), max(vals)
|
|
return (fmt % lo) if lo == hi else (fmt % lo) + " to " + (fmt % hi)
|
|
|
|
|
|
def span_min(vals):
|
|
"""Minutes over seeds, 'never' when no seed produced the event, 'never in k of n' when some did."""
|
|
vals = list(vals)
|
|
got = [v for v in vals if v is not None]
|
|
if not got:
|
|
return "never"
|
|
s = span(got, "%.0f")
|
|
if len(got) < len(vals):
|
|
s += " (never in %d of %d)" % (len(vals) - len(got), len(vals))
|
|
return s
|
|
|
|
|
|
def lock_gaps_min(recs, s_from, s_to):
|
|
"""Longest run of consecutive slots without a lock inside [s_from, s_to), in minutes; the 'finality unavailable' interval."""
|
|
locks = recs[(recs[:, 0] >= s_from) & (recs[:, 0] < s_to) & (recs[:, 3] >= 0), 0]
|
|
edges = np.concatenate([[s_from], np.unique(locks), [s_to]])
|
|
return float(np.max(np.diff(edges)) * SLOT_S / 60.0)
|
|
|
|
|
|
def run_partition2(seed, fracs, att_share, dur_min, p, pre_min=60, post_min=180):
|
|
"""run_partition with the heal check of section 3.11 item 4: every certificate any side held before the heal
|
|
is in the merged view afterwards (a lock is never reversed), plus per-side lock counts."""
|
|
rng = np.random.default_rng(seed)
|
|
nR = max(3, len(fracs) + 1)
|
|
sim = Sim(p, rng, n_regions=nR)
|
|
h = pareto_hashrates(rng, N_HONEST)
|
|
reg = assign_regions(h, fracs)
|
|
sim.add_keys(h, reg, flaky=True)
|
|
groups = [[i] for i in range(len(fracs))]
|
|
att = None
|
|
if att_share > 0:
|
|
att = int(sim.add_keys([att_share / (1.0 - att_share)], [len(fracs)], flaky=False, equiv=True)[0])
|
|
groups[0].append(len(fracs))
|
|
sim.warm_start()
|
|
sim.init_views(warm=True)
|
|
sim.run(pre_min * 2)
|
|
t_split = sim.slot
|
|
sim.split(groups)
|
|
sides = [v.sid for v in sim.views]
|
|
sim.run(dur_min * 2)
|
|
t_heal = sim.slot
|
|
before = {}
|
|
for v in sim.views:
|
|
for idx, c in v.certs.items():
|
|
before.setdefault(idx, set()).add(c[0])
|
|
sim.heal()
|
|
merged = sim.views[0].certs
|
|
kept = all(idx in merged for idx in before)
|
|
sim.run(post_min * 2)
|
|
recs = sim.recs()
|
|
res = dict(conflicts=len(sim.conflicts), kept=kept, pre_locks=len(before),
|
|
att_share=sim.share([att]) if att is not None else 0.0)
|
|
res["first_conflict_min"] = (min(c[1] for c in sim.conflicts) - t_split) / 2.0 if sim.conflicts else None
|
|
res["side_first_lock"] = []
|
|
res["side_locks"] = []
|
|
for sid in sides:
|
|
fl = first_lock_after(recs[recs[:, 0] < t_heal], t_split, sid=sid)
|
|
res["side_first_lock"].append(None if fl is None else (fl - t_split) / 2.0)
|
|
sel = recs[(recs[:, 0] >= t_split) & (recs[:, 0] < t_heal) & (recs[:, 2] == sid)]
|
|
res["side_locks"].append(int((sel[:, 3] >= 0).sum()))
|
|
fl = first_lock_after(recs, t_heal)
|
|
res["post_first_lock_min"] = None if fl is None else (fl - t_heal) / 2.0
|
|
res["post_stalls"] = stalls_between(recs, t_heal, sim.slot)
|
|
return res
|
|
|
|
|
|
def scenario_h(args):
|
|
"""Partition with an equivocator under the rule as specified: the 4/30 bound of section 3.11."""
|
|
seeds = seeds_of(args)
|
|
q = getattr(args, "quick", False)
|
|
durs = (60,) if q else (150, 360)
|
|
atts = (0.0, 0.10, 0.13, 0.14, 0.20, 0.30, 0.33, 0.34)
|
|
out = ["### H. Partition of a 50/50 honest network with an equivocating attacker, rule as specified (%s), "
|
|
"each side retargets at once (+daa, the median-time clock of Q1), seeds %s" % (rule_name(), ",".join(str(s) for s in seeds)), ""]
|
|
out.append("Attacker = one key holding the stated share of TOTAL weight, mining on the first side, voting on both. Each side holds "
|
|
"(1 - a)/2 + a of total. Prediction (section 3.11 item 2): a side holding s of total locks alone once s >= the floor (%s) and its "
|
|
"view's active weight has decayed to 1.5 s, which under the cert reading is P x (1 - 1.5 s) slots after the split "
|
|
"(P = 240, so 2 h x (1 - 1.5 s), 0 at s >= 2/3); two sides over the floor need a >= %s." % (pct(P_FLOOR), pct(2 * P_FLOOR - 1)))
|
|
out.append("")
|
|
rows = []
|
|
for a in atts:
|
|
s = (1.0 - a) / 2.0 + a
|
|
pred = "no (side holds %s < %s)" % (pct(s), pct(P_FLOOR)) if s < P_FLOOR else "%.0f min" % (120.0 * max(0.0, 1.0 - 1.5 * s))
|
|
for dur in durs:
|
|
rs = [run_partition2(sd, [0.5, 0.5], a, dur, rule_p(args.delay, daa="full")) for sd in seeds]
|
|
rows.append([pct(a, 0), pct(s), dur, pred, span(r["conflicts"] for r in rs), span_min(r["first_conflict_min"] for r in rs),
|
|
" / ".join(span_min([r["side_first_lock"][i] for r in rs]) for i in range(2)),
|
|
"yes" if all(r["kept"] for r in rs) else "NO", span(r["post_stalls"] for r in rs)])
|
|
out.append(md_table(["attacker (of total)", "each side holds", "partition min", "predicted first conflict", "conflicting locks",
|
|
"first conflict, min", "first lock per side, min", "every pre-heal lock kept at the heal", "stalls in 3 h after heal"], rows))
|
|
return "\n".join(out)
|
|
|
|
|
|
def scenario_i(args):
|
|
"""The 40/40/20 split, three readings."""
|
|
seeds = seeds_of(args)
|
|
q = getattr(args, "quick", False)
|
|
durs = (60,) if q else (150, 360)
|
|
out = ["### I. The 40/40/20 split, rule as specified (%s), +daa, seeds %s" % (rule_name(), ",".join(str(s) for s in seeds)), ""]
|
|
cases = [("honest 40/40/20, no attacker", [0.4, 0.4, 0.2], 0.0),
|
|
("honest 40/40/20 plus a 10% equivocator (sides 36+10 / 36+10 / 18+10)", [0.4, 0.4, 0.2], 0.10),
|
|
("honest 40/40/20 plus a 20% equivocator (sides 32+20 / 32+20 / 16+20)", [0.4, 0.4, 0.2], 0.20),
|
|
("honest 40/40 plus a 20% equivocator reaching both (sides 40+20 / 40+20)", [0.5, 0.5], 0.20),
|
|
("honest 40/40 plus a 34% equivocator reaching both (sides 33+34 / 33+34)", [0.5, 0.5], 0.34)]
|
|
rows = []
|
|
for name, fr, a in cases:
|
|
for dur in durs:
|
|
rs = [run_partition2(sd, fr, a, dur, rule_p(args.delay, daa="full")) for sd in seeds]
|
|
n = len(fr)
|
|
rows.append([name, dur, span(r["conflicts"] for r in rs), span_min(r["first_conflict_min"] for r in rs),
|
|
" / ".join(span([r["side_locks"][i] for r in rs]) for i in range(n)),
|
|
"yes" if all(r["kept"] for r in rs) else "NO",
|
|
span_min(r["post_first_lock_min"] for r in rs), span(r["post_stalls"] for r in rs)])
|
|
out.append(md_table(["case", "partition min", "conflicting locks", "first conflict, min", "locks per side during",
|
|
"every pre-heal lock kept", "first lock after heal, min", "stalls in 3 h after heal"], rows))
|
|
return "\n".join(out)
|
|
|
|
|
|
def run_silent_resume(seed, frac, hours, p, pre_h=3, post_h=3):
|
|
sim, rng, _ = build_honest(p, seed)
|
|
sim.warm_start()
|
|
sim.init_views(warm=True)
|
|
sim.run(pre_h * SLOTS_PER_HOUR)
|
|
silent, got = pick_weight_subset(rng, sim.weight, frac)
|
|
sim.signs[silent] = False
|
|
t0 = sim.slot
|
|
sim.run(int(hours * SLOTS_PER_HOUR))
|
|
t1 = sim.slot
|
|
sim.signs[silent] = True
|
|
sim.run(post_h * SLOTS_PER_HOUR)
|
|
recs = sim.recs()
|
|
fl = first_lock_after(recs, t0)
|
|
fr = first_lock_after(recs, t1)
|
|
return dict(got=got, stalls=stalls_between(recs, t0, t1), first_lock=None if fl is None or fl >= t1 else (fl - t0) / 2.0,
|
|
gap=lock_gaps_min(recs, t0, t1), resume=None if fr is None else (fr - t1) / 2.0,
|
|
post_stalls=stalls_between(recs, t1, sim.slot), conflicts=len(sim.conflicts),
|
|
locked_share=float(((recs[:, 0] >= t0) & (recs[:, 0] < t1) & (recs[:, 3] >= 0)).sum()) / max(1, int(((recs[:, 0] >= t0) & (recs[:, 0] < t1)).sum())))
|
|
|
|
|
|
def scenario_j(args):
|
|
"""Signing stops while mining continues, for 1, 6 and 24 hours, then resumes."""
|
|
seeds = seeds_of(args)
|
|
q = getattr(args, "quick", False)
|
|
hours = (1,) if q else (1, 6, 24)
|
|
fracs = (0.34, 0.40, 0.45)
|
|
out = ["### J. Signing stops while mining continues for 1, 6 and 24 hours, then resumes; rule as specified, seeds %s" % ",".join(str(s) for s in seeds), ""]
|
|
out.append("A random set holding x of weight stops signing at hour 3 and resumes after the stated time. Its weight never ages out because it keeps mining. "
|
|
"'Longest gap' is the longest interval without a lock while they are silent: the time the node reports finality unavailable.")
|
|
out.append("")
|
|
rows = []
|
|
for frac in fracs:
|
|
for h in hours:
|
|
rs = [run_silent_resume(sd, frac, h, rule_p(args.delay)) for sd in seeds]
|
|
rows.append([pct(frac, 0), h, span_min(r["first_lock"] for r in rs), span(r["stalls"] for r in rs),
|
|
span(int(round(100 * r["locked_share"])) for r in rs) + "%",
|
|
span((r["gap"] for r in rs), "%.0f"), span_min(r["resume"] for r in rs), span(r["post_stalls"] for r in rs),
|
|
span(r["conflicts"] for r in rs)])
|
|
out.append(md_table(["silent weight", "silent hours", "first lock after the stop, min", "stalled checkpoints while silent",
|
|
"checkpoints locked while silent", "longest gap without a lock, min", "first lock after resume, min",
|
|
"stalls in 3 h after resume", "conflicting locks"], rows))
|
|
return "\n".join(out)
|
|
|
|
|
|
def run_acquired(seed, bought, att_hash, signs, days, p):
|
|
"""An attacker buys keys holding `bought` of window weight and starts mining at `att_hash` of network hashrate.
|
|
The sellers keep their rigs and mine on under fresh keys."""
|
|
sim, rng, _ = build_honest(p, seed)
|
|
# the bought set is picked by hashrate, which the warm start turns into weight at the same share
|
|
mask, got = pick_weight_subset(rng, sim.hash, bought)
|
|
bidx = np.flatnonzero(mask)
|
|
seller_hash = sim.hash[bidx].copy()
|
|
fresh = sim.add_keys(np.zeros(bidx.size), sim.region[bidx].copy(), flaky=True)
|
|
att = int(sim.add_keys([0.0], [0], flaky=False, signs=signs)[0])
|
|
sim.warm_start()
|
|
sim.init_views(warm=True)
|
|
sim.run(SLOTS_PER_HOUR)
|
|
honest = sim.hash.sum()
|
|
sim.hash[fresh] = seller_hash
|
|
sim.hash[bidx] = 0.0
|
|
sim.flaky[bidx] = False
|
|
sim.online[bidx] = True
|
|
sim.signs[bidx] = signs
|
|
sim.hash[att] = honest * att_hash / (1.0 - att_hash)
|
|
sim.set_uptime()
|
|
got = float(sim.share(bidx))
|
|
owned = np.concatenate([bidx, [att]])
|
|
t0 = sim.slot
|
|
series = []
|
|
above13 = None
|
|
last13 = None
|
|
for day in range(1, days + 1):
|
|
s0 = sim.slot
|
|
sim.run(SLOTS_PER_DAY)
|
|
sh = sim.share(owned)
|
|
recs = sim.recs()
|
|
series.append((day, sh, stalls_between(recs, s0, sim.slot)))
|
|
if sh >= 1.0 / 3.0:
|
|
last13 = day
|
|
if above13 is None:
|
|
above13 = day
|
|
recs = sim.recs()
|
|
return dict(got=got, series=series, above13=above13, last13=last13, stalls=stalls_between(recs, t0, sim.slot),
|
|
max_share=max(s for _, s, _ in series), end_share=series[-1][1], conflicts=len(sim.conflicts))
|
|
|
|
|
|
def scenario_k(args):
|
|
"""Acquired old keys against fresh hashrate."""
|
|
seeds = seeds_of(args)
|
|
q = getattr(args, "quick", False)
|
|
days = 3 if q else 30
|
|
att_hash = 0.30
|
|
out = ["### K. Acquired keys: an attacker buys keys holding 20%% or 40%% of window weight and mines at 30%% of network hashrate from day 0; "
|
|
"rule as specified, %d days, seeds %s" % (days, ",".join(str(s) for s in seeds)), ""]
|
|
out.append("The sellers keep their rigs and mine on under fresh keys (so honest hashrate is unchanged and the fresh keys start under dust). "
|
|
"Formula (section 3.11 item 5): share(t) = b (1 - t/30) + 0.30 t/30, the bought blocks age out of the window as the attacker's own blocks enter it. "
|
|
"'Fresh hash only' is b = 0: share(t) = 0.30 t/30.")
|
|
out.append("")
|
|
configs = [(0.0, True), (0.20, True), (0.40, True), (0.20, False), (0.40, False)]
|
|
results = {}
|
|
for b, signs in configs:
|
|
results[(b, signs)] = [run_acquired(sd, b, att_hash, signs, days, rule_p(args.delay)) for sd in seeds]
|
|
pick = [d for d in (1, 5, 10, 15, 20, 25, 30) if d <= days]
|
|
rows = []
|
|
for d in pick:
|
|
row = ["+%d" % d]
|
|
for b, signs in configs:
|
|
if not signs:
|
|
continue
|
|
vals = [dict((x[0], x[1]) for x in r["series"])[d] for r in results[(b, signs)]]
|
|
formula = b * (1.0 - d / 30.0) + att_hash * d / 30.0
|
|
row.append("%s / %s" % (span((100 * v for v in vals), "%.1f") + "%", pct(formula)))
|
|
rows.append(row)
|
|
out.append("Attacker weight share, simulated over the seeds / formula (attacker signs every checkpoint):")
|
|
out.append("")
|
|
out.append(md_table(["day after purchase"] + ["bought %s" % pct(b, 0) for b, s in configs if s], rows))
|
|
out.append("")
|
|
ev = []
|
|
for b, signs in configs:
|
|
rs = results[(b, signs)]
|
|
ev.append([pct(b, 0), "signs" if signs else "silent", span((100 * r["got"] for r in rs), "%.1f") + "%",
|
|
span((100 * r["max_share"] for r in rs), "%.1f") + "%", span((100 * r["end_share"] for r in rs), "%.1f") + "%",
|
|
"never" if all(r["above13"] is None for r in rs) else "from day %s until day %s" % (span(r["above13"] for r in rs), span(r["last13"] for r in rs)),
|
|
span(r["stalls"] for r in rs), span(r["conflicts"] for r in rs)])
|
|
out.append(md_table(["bought weight", "attacker", "bought, as picked", "peak share", "share at day %d" % days,
|
|
"holds at least 1/3 (can veto)", "stalled checkpoints in %d days" % days, "conflicting locks"], ev))
|
|
out.append("")
|
|
srows = []
|
|
for b, signs in configs:
|
|
if signs:
|
|
continue
|
|
rs = results[(b, signs)]
|
|
for d in pick:
|
|
vals = [dict((x[0], x[2]) for x in r["series"])[d] for r in rs]
|
|
srows.append([pct(b, 0), "+%d" % d, span(vals)])
|
|
out.append("Stalled checkpoints per day while the attacker withholds its votes (2,880 checkpoints a day):")
|
|
out.append("")
|
|
out.append(md_table(["bought weight", "day", "stalled that day"], srows))
|
|
return "\n".join(out)
|
|
|
|
|
|
def run_churn(seed, frac, days, p):
|
|
"""Scenario D as a function: a set holding `frac` of weight stops mining and signing at hour 3; survivors inherit the block supply."""
|
|
sim, rng, _ = build_honest(p, seed)
|
|
sim.warm_start()
|
|
sim.init_views(warm=True)
|
|
sim.run(3 * SLOTS_PER_HOUR)
|
|
gone, got = pick_weight_subset(rng, sim.weight, frac)
|
|
sim.signs[gone] = False
|
|
sim.online[gone] = False
|
|
sim.flaky[gone] = False
|
|
sim.hash[gone] = 0.0
|
|
t_event = sim.slot
|
|
sim.run(int(days * SLOTS_PER_DAY))
|
|
recs = sim.recs()
|
|
fl = first_lock_after(recs, t_event)
|
|
st = stalls_between(recs, t_event, sim.slot)
|
|
later = stalls_between(recs, fl, sim.slot) if fl is not None else 0
|
|
return dict(got=got, first_lock_days=None if fl is None else (fl - t_event) / float(SLOTS_PER_DAY), stalls=st, later=later,
|
|
live_share=float(sim.share(np.flatnonzero(~gone))), conflicts=len(sim.conflicts))
|
|
|
|
|
|
def scenario_l(args):
|
|
"""The floor at 2/3 of total (4 October 2026): the cases the decision turns on, over seeds. Rule as rule_p (FLOOR_F)."""
|
|
seeds = seeds_of(args)
|
|
few = seeds[:3]
|
|
q = getattr(args, "quick", False)
|
|
out = ["### L. The floor at %s of total (floor factor %.2f): silent weight, churn, the eclipse, and long partitions with view-local "
|
|
"weight; seeds %s (churn and long partitions: %s)" % (pct(P_FLOOR), FLOOR_F, ",".join(str(s) for s in seeds), ",".join(str(s) for s in few)), ""]
|
|
# (a) silent weight, fine resolution around one third
|
|
hours = (1,) if q else (1, 6)
|
|
fracs = (0.25, 0.30, 0.32, 0.33, 0.34, 0.40, 0.45)
|
|
out.append("L1. Signing stops while mining continues (as J), finer around one third. The floor needs the signing weight at or above %s of total; "
|
|
"the model's resting participation is 0.978, so the online signing share is about 0.978 x (1 - silent)." % pct(P_FLOOR))
|
|
out.append("")
|
|
rows = []
|
|
for frac in fracs:
|
|
for h in hours:
|
|
rs = [run_silent_resume(sd, frac, h, rule_p(args.delay)) for sd in seeds]
|
|
rows.append([pct(frac, 0), pct(0.978 * (1.0 - frac)), h, span_min(r["first_lock"] for r in rs), span(r["stalls"] for r in rs),
|
|
span(int(round(100 * r["locked_share"])) for r in rs) + "%",
|
|
span((r["gap"] for r in rs), "%.0f"), span_min(r["resume"] for r in rs), span(r["post_stalls"] for r in rs),
|
|
span(r["conflicts"] for r in rs)])
|
|
out.append(md_table(["silent weight", "online signing share, about", "silent hours", "first lock after the stop, min", "stalled checkpoints while silent",
|
|
"checkpoints locked while silent", "longest gap without a lock, min", "first lock after resume, min",
|
|
"stalls in 3 h after resume", "conflicting locks"], rows))
|
|
out.append("")
|
|
# (b) churn
|
|
d35 = 1 if q else 3
|
|
d50 = 1 if q else 12
|
|
out.append("L2. Churn (as D): a set stops mining and signing; survivors inherit the block supply (perfect retarget). Analytic first lock at day "
|
|
"30 (1 - (1 - %s) / x) for a set holding x: %s." % (pct(P_FLOOR), ", ".join("%s: %s" % (pct(x, 0), "never" if 1 - (1 - P_FLOOR) / x <= 0 else "day %.1f" % (30 * (1 - (1 - P_FLOOR) / x))) for x in (0.35, 0.50))))
|
|
out.append("")
|
|
rows = []
|
|
for frac, days in ((0.35, d35), (0.50, d50)):
|
|
rs = [run_churn(sd, frac, days, rule_p(args.delay)) for sd in few]
|
|
rows.append([pct(frac, 0), days, span_min(None if r["first_lock_days"] is None else r["first_lock_days"] * 1440.0 for r in rs) if days <= 1
|
|
else ("never in %d days" % days if all(r["first_lock_days"] is None for r in rs) else span((r["first_lock_days"] for r in rs if r["first_lock_days"] is not None), "%.1f") + " days"),
|
|
span(r["stalls"] for r in rs), span(r["later"] for r in rs), span((100 * r["live_share"] for r in rs), "%.1f") + "%", span(r["conflicts"] for r in rs)])
|
|
out.append(md_table(["churn weight", "days run", "first lock after the event", "stalled checkpoints", "stalled after the first lock",
|
|
"live share of total weight at the end", "conflicting locks"], rows))
|
|
out.append("")
|
|
# (c) the poisoned eclipse of F2
|
|
out.append("L3. The poisoned eclipse (as F2): a 34% attacker feeds a 20% pool a private fork and signs both; the eclipsed side holds 54% of total.")
|
|
out.append("")
|
|
rows = []
|
|
for dur in ((1,) if q else (1, 2, 4)):
|
|
rs = [run_eclipse(sd, dur, True, rule_p(args.delay)) for sd in seeds]
|
|
rows.append([dur, span(r["conflicts"] for r in rs), span_min(r["first_conflict_min"] for r in rs), span(r["ecl_locks"] for r in rs),
|
|
span(r["honest_stalls"] for r in rs), span(r["post_stalls"] for r in rs), span(("%.3f" % r["min_part"] for r in rs), "%s")])
|
|
out.append(md_table(["eclipse h", "conflicting locks", "first conflict, min", "locks on the eclipsed side", "honest-side stalls during",
|
|
"stalls after heal", "pool participation, minimum"], rows))
|
|
out.append("")
|
|
# (d) long honest partitions with view-local weight tables: the window bound of attack scenario 6A
|
|
days = 1 if q else 12
|
|
out.append("L4. Long honest partitions with view-local weight ('+local'): after the split a side's window holds only the blocks it has seen, so its own "
|
|
"share of its own table rises as s + (1 - s) T / 30 on day T (each side retargets, +daa). Prediction: a side with pre-split share s locks "
|
|
"alone from day 30 (floor - s) / (1 - s), 0 if s is already at the floor; %d days, no attacker, seeds %s. The 3 October tables kept weights "
|
|
"global (results_v2.md, 'Weights in a partition'), which hid this bound; attack scenario 6A found it on the devnet." % (days, ",".join(str(s) for s in few)))
|
|
out.append("")
|
|
rows = []
|
|
for name, fr in (("50/50", [0.5, 0.5]), ("60/40", [0.6, 0.4]), ("55/45", [0.55, 0.45])):
|
|
for f in sorted({FLOOR_F, 0.85}, reverse=True):
|
|
pf = f * TWO_THIRDS
|
|
preds = []
|
|
for s in fr:
|
|
preds.append("0" if s >= pf else ("%.1f" % (30.0 * (pf - s) / (1.0 - s)) if 30.0 * (pf - s) / (1.0 - s) <= days else "> %d" % days))
|
|
p = P(denom="active", pmode="cert", floor=f, daa="full", local=True, delay=args.delay)
|
|
rs = [run_partition2(sd, fr, 0.0, days * 1440, p, pre_min=60, post_min=180) for sd in few]
|
|
fl = [[None if r["side_first_lock"][i] is None else r["side_first_lock"][i] / 1440.0 for r in rs] for i in range(len(fr))]
|
|
rows.append([name, "%.2f (%s)" % (f, pct(pf)), days, " / ".join(preds),
|
|
" / ".join(("never" if all(x is None for x in col) else span((x for x in col if x is not None), "%.1f") + ("" if all(x is not None for x in col) else " (never in %d of %d)" % (sum(x is None for x in col), len(col)))) for col in fl),
|
|
span(r["conflicts"] for r in rs),
|
|
("never" if all(r["first_conflict_min"] is None for r in rs) else span((r["first_conflict_min"] / 1440.0 for r in rs if r["first_conflict_min"] is not None), "%.1f") + " days"),
|
|
"yes" if all(r["kept"] for r in rs) else "NO", span(r["post_stalls"] for r in rs)])
|
|
out.append(md_table(["honest split", "floor factor (lock needs, of the side's own table)", "partition days", "predicted first lock per side, day",
|
|
"first lock per side, day", "conflicting locks", "first conflict", "every pre-heal lock kept", "stalls in 3 h after heal"], rows))
|
|
return "\n".join(out)
|
|
|
|
|
|
# ---------------------------------------------------------------- addition, 4 October 2026 (evening): rule v3, ledger F21
|
|
# The frozen-table rule ('+frozen', P.frozen): a lock needs two thirds of the sliding table at C_i (Q3 as today) AND two
|
|
# thirds of the table frozen at the view's last certified checkpoint, signers counted at their frozen weights. The frozen
|
|
# table rolls forward only when a checkpoint certifies and expires one window (30 days) after its checkpoint, after which
|
|
# the sliding table alone applies. Scenario M measures what that does to the window bound of 3.7 item 9 (L4, 6A), to 6B,
|
|
# to the heal, to churn (L2) and to the equivocator bound (H).
|
|
|
|
WINDOW_SLOTS = WINDOW_HOURS * SLOTS_PER_HOUR
|
|
|
|
|
|
def rule_v3(delay, **kw):
|
|
base = dict(denom="active", pmode="cert", floor=FLOOR_F, delay=delay, daa="full", local=True, frozen=True)
|
|
base.update(kw)
|
|
return P(**base)
|
|
|
|
|
|
def rule_v2_local(delay, **kw):
|
|
base = dict(denom="active", pmode="cert", floor=FLOOR_F, delay=delay, daa="full", local=True)
|
|
base.update(kw)
|
|
return P(**base)
|
|
|
|
|
|
def scenario_m(args):
|
|
seeds = seeds_of(args)[:2]
|
|
q = getattr(args, "quick", False)
|
|
out = ["### M. Rule v3, the frozen weight table (ledger F21): partitions, the heal, churn and the equivocator bound; "
|
|
"v2 = the rule as specified today with view-local weights (+local), v3 = v2 plus the frozen table (+frozen); seeds %s" % ",".join(str(s) for s in seeds), ""]
|
|
out.append("Prediction for v3: a side of an honest partition holds its pre-split share s of the frozen table for as long as the table stands, so "
|
|
"no side under two thirds locks until the frozen checkpoint is one window old (day 30 after the last lock, whatever s), after which the "
|
|
"sliding table applies and the v2 bound (already crossed) locks both sides. A side at or above two thirds (6B) locks at once under both. "
|
|
"A departed set stalls the survivors until day 30 under v3 (v2: 30 (1 - 1/(3x)) days). The equivocator bound of 3.11.2 is unchanged: an "
|
|
"equivocator at a of total gives each side (1 - a)/2 + a of the frozen table, two thirds at a = 1/3.")
|
|
out.append("")
|
|
# M1: 6A and 6B at devnet lengths (minutes), v2 against v3
|
|
rows = []
|
|
cases = [("50/50 (6A)", [0.5, 0.5]), ("60/40", [0.6, 0.4]), ("67/33 (6B, the 4/2 split)", [0.667, 0.333]), ("70/30", [0.7, 0.3])]
|
|
durs = (60,) if q else (150, 360)
|
|
for name, fr in cases:
|
|
for dur in durs:
|
|
for lab, mk in (("v2", rule_v2_local), ("v3", rule_v3)):
|
|
rs = [run_partition2(sd, fr, 0.0, dur, mk(args.delay), pre_min=60, post_min=180) for sd in seeds]
|
|
rows.append([name, dur, lab, span(r["conflicts"] for r in rs), " / ".join(span([r["side_locks"][i] for r in rs]) for i in range(2)),
|
|
" / ".join(span_min([r["side_first_lock"][i] for r in rs]) for i in range(2)),
|
|
"yes" if all(r["kept"] for r in rs) else "NO", span_min(r["post_first_lock_min"] for r in rs), span(r["post_stalls"] for r in rs)])
|
|
out.append("M1. Honest partitions at devnet lengths, no attacker, each side retargets and counts only its own blocks:")
|
|
out.append("")
|
|
out.append(md_table(["honest split", "partition min", "rule", "conflicting locks", "locks per side during", "first lock per side, min",
|
|
"every pre-heal lock kept", "first lock after heal, min", "stalls in 3 h after heal"], rows))
|
|
out.append("")
|
|
# M2: L4 rerun, 12 days
|
|
days = 1 if q else 12
|
|
rows = []
|
|
for name, fr in (("50/50", [0.5, 0.5]), ("60/40", [0.6, 0.4]), ("55/45", [0.55, 0.45])):
|
|
for lab, mk in (("v2", rule_v2_local), ("v3", rule_v3)):
|
|
rs = [run_partition2(sd, fr, 0.0, days * 1440, mk(args.delay), pre_min=60, post_min=180) for sd in seeds]
|
|
fl = [[None if r["side_first_lock"][i] is None else r["side_first_lock"][i] / 1440.0 for r in rs] for i in range(len(fr))]
|
|
rows.append([name, lab, days, " / ".join(("never" if all(x is None for x in col) else span((x for x in col if x is not None), "%.1f")) for col in fl),
|
|
span(r["conflicts"] for r in rs),
|
|
"yes" if all(r["kept"] for r in rs) else "NO", span_min(r["post_first_lock_min"] for r in rs), span(r["post_stalls"] for r in rs)])
|
|
out.append("M2. L4 again (long honest partitions, %d days): v2 locks alone from day 30 (2/3 - s) / (1 - s), v3 not before day 30:" % days)
|
|
out.append("")
|
|
out.append(md_table(["honest split", "rule", "partition days", "first lock per side, day", "conflicting locks", "every pre-heal lock kept",
|
|
"first lock after heal, min", "stalls in 3 h after heal"], rows))
|
|
out.append("")
|
|
# M3: the expiry of the frozen table: 50/50 for 31 days under v3
|
|
days3 = 2 if q else 31
|
|
rows = []
|
|
for name, fr in (("50/50", [0.5, 0.5]), ("60/40", [0.6, 0.4])):
|
|
rs = [run_partition2(sd, fr, 0.0, days3 * 1440, rule_v3(args.delay), pre_min=60, post_min=180) for sd in seeds]
|
|
fl = [[None if r["side_first_lock"][i] is None else r["side_first_lock"][i] / 1440.0 for r in rs] for i in range(len(fr))]
|
|
rows.append([name, days3, " / ".join(("never" if all(x is None for x in col) else span((x for x in col if x is not None), "%.2f")) for col in fl),
|
|
span(r["conflicts"] for r in rs),
|
|
("never" if all(r["first_conflict_min"] is None for r in rs) else span((r["first_conflict_min"] / 1440.0 for r in rs if r["first_conflict_min"] is not None), "%.2f") + " days")])
|
|
out.append("M3. The frozen table expires one window after its checkpoint (%d-day partitions, v3): the bound moves to day 30 for every split under two thirds:" % days3)
|
|
out.append("")
|
|
out.append(md_table(["honest split", "partition days", "first lock per side, day", "conflicting locks", "first conflict"], rows))
|
|
out.append("")
|
|
# M4: churn under v3 against v2
|
|
rows = []
|
|
for frac in (0.35, 0.50):
|
|
for lab, mk in (("v2", rule_p), ("v3", lambda d: rule_p(d, frozen=True))):
|
|
rs = [run_churn(sd, frac, days3, mk(args.delay)) for sd in seeds]
|
|
rows.append([pct(frac, 0), lab, days3,
|
|
"never in %d days" % days3 if all(r["first_lock_days"] is None for r in rs) else span((r["first_lock_days"] for r in rs if r["first_lock_days"] is not None), "%.2f") + " days",
|
|
span(r["stalls"] for r in rs), span(r["later"] for r in rs), span(r["conflicts"] for r in rs)])
|
|
out.append("M4. Churn (as L2): a set holding x of weight stops mining and signing at once; v2 recovers at 30 (1 - 1/(3x)) days, v3 when the frozen table expires:")
|
|
out.append("")
|
|
out.append(md_table(["churn weight", "rule", "days run", "first lock after the event", "stalled checkpoints", "stalled after the first lock", "conflicting locks"], rows))
|
|
out.append("")
|
|
# M5: the equivocator bound (H) under v3
|
|
rows = []
|
|
for a in (0.30, 0.33, 0.34):
|
|
s_ = (1.0 - a) / 2.0 + a
|
|
rs = [run_partition2(sd, [0.5, 0.5], a, 60 if q else 150, rule_v3(args.delay)) for sd in seeds]
|
|
rows.append([pct(a, 0), pct(s_), span(r["conflicts"] for r in rs), span_min(r["first_conflict_min"] for r in rs),
|
|
" / ".join(span_min([r["side_first_lock"][i] for r in rs]) for i in range(2))])
|
|
out.append("M5. The equivocator across a 50/50 split (as H), v3: each side holds (1 - a)/2 + a of the frozen table, so the one-third bound stands:")
|
|
out.append("")
|
|
out.append(md_table(["attacker (of total)", "each side holds", "conflicting locks", "first conflict, min", "first lock per side, min"], rows))
|
|
return "\n".join(out)
|
|
|
|
|
|
SCENARIOS = {"A": scenario_a, "B": scenario_b, "C": scenario_c, "D": scenario_d, "E": scenario_e, "F": scenario_f, "G": scenario_g,
|
|
"H": scenario_h, "I": scenario_i, "J": scenario_j, "K": scenario_k, "L": scenario_l, "M": scenario_m}
|
|
|
|
|
|
def main(argv=None):
|
|
ap = argparse.ArgumentParser(description="Igneum finality rule V2 simulation")
|
|
ap.add_argument("--seed", type=int, default=7)
|
|
ap.add_argument("--scenarios", default="A,B,C,D,E,F,G")
|
|
ap.add_argument("--delay", type=float, default=2.0, help="one-way inter-region delay in seconds for the main runs")
|
|
ap.add_argument("--grace", type=float, default=15.0)
|
|
ap.add_argument("--quick", action="store_true", help="shortened runs for development")
|
|
ap.add_argument("--seeds", default="", help="comma-separated seeds for scenarios H to L (default 7,11,13,17,19)")
|
|
ap.add_argument("--floor", type=float, default=FLOOR_F,
|
|
help="floor factor f for the rule of H to L and the floor columns of C to F: a lock needs f x 2/3 of total (1.0 = the rule of 4 Oct 2026; 0.85 = the 3 Oct tables)")
|
|
args = ap.parse_args(argv)
|
|
set_floor(args.floor)
|
|
q = args.quick
|
|
# --quick is the smoke run: every scenario end to end at a length that finishes in under two minutes on a
|
|
# 2-vCPU CI runner (the full set takes about an hour). The numbers it prints are not the results of
|
|
# results_v2.md; only the full run is.
|
|
args.days_a = 1 if q else 60
|
|
args.days_delay = 1 if q else 3
|
|
args.days_b = 1 if q else 35
|
|
args.hours_c = 1 if q else 6
|
|
args.hours_c_total = 1 if q else 72
|
|
args.days_d35 = 1 if q else 3
|
|
args.days_d50 = 1 if q else 12
|
|
args.days_g = 1 if q else 25
|
|
print("# finality_v2 output")
|
|
print()
|
|
p = P()
|
|
print("seed %d, slot %.0f s, %d blocks per checkpoint, window %d h, presence %d checkpoints, dust %d, quorum 2/3, "
|
|
"floor factor %.2f (a lock needs %s of total under the rule as specified), "
|
|
"inter-region delay %.1f s (intra %.1f s, jitter sigma %.2f), grace %.0f s, uptime %.2f (mean outage %d slots), "
|
|
"uptime %.3f for keys at or above %s of hashrate, honest keys %d Pareto %.1f, geography %s%s" % (
|
|
args.seed, SLOT_S, BLOCKS_PER_CP, WINDOW_HOURS, p.presence, p.dust, FLOOR_F, pct(P_FLOOR), args.delay, p.intra, p.jitter,
|
|
args.grace, p.uptime, p.outage, p.uptime_big, pct(p.big_share, 0), N_HONEST, PARETO_SHAPE, "/".join(pct(g, 0) for g in GEOGRAPHY),
|
|
", QUICK" if q else ""))
|
|
print()
|
|
for s in args.scenarios.split(","):
|
|
s = s.strip().upper()
|
|
if s not in SCENARIOS:
|
|
print("unknown scenario %s" % s, file=sys.stderr)
|
|
return 2
|
|
t = time.time()
|
|
print(SCENARIOS[s](args))
|
|
print()
|
|
print("(%s took %.0f s)" % (s, time.time() - t), file=sys.stderr)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|