igneum/sim/finality_v2.py
igneum-labs 42d780818a Chain scene: back to four states with a bold locked ring
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-03 16:05:19 +00:00

1043 lines
48 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)
self.daa = "none" # "none": a partition side mines at its hashrate share; "full": each side retargets to 30 blocks/slot at once
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"
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
def clone_ring_from(self, other):
self.ring[:] = other.ring
self.ring_idx[:] = other.ring_idx
self.pcount[:] = other.pcount
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)
# 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
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)))
elig = (self.weight >= p.dust) & ~self.stripped
voters = elig & self.online & self.signs & (v.key_mask | self.equiv)
if p.denom == "active":
denom = float((self.weight * self.participation(v, idx))[elig].sum())
else:
denom = float(self.weight[elig].sum())
total = float(self.weight[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(self.weight[vi].sum())
ratio = signed_w / denom if denom > 0 else 0.0
best = None
if denom > 0 and vi.size > 0:
w = self.weight[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 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)
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),
("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="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)]
splits = [("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, 0.34):
for dur in (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)
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)]
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%.")
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)]
rows = []
for dur in (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)]
for dur in (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)
SCENARIOS = {"A": scenario_a, "B": scenario_b, "C": scenario_c, "D": scenario_d, "E": scenario_e, "F": scenario_f, "G": scenario_g}
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")
args = ap.parse_args(argv)
q = args.quick
args.days_a = 3 if q else 60
args.days_delay = 1 if q else 3
args.days_b = 4 if q else 35
args.hours_c = 3 if q else 6
args.hours_c_total = 3 if q else 72
args.days_d35 = 1 if q else 3
args.days_d50 = 1 if q else 12
args.days_g = 3 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, "
"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, 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())