igneum/sim/difficulty/sim.py
igneum-labs e17d3816ba Difficulty: the live oscillation of 4 October, its cause, the DAG replay and rule v2 (reference window 600 behind a height switch)
Live devnet v4: a second RTX 5090 joining 7 minutes into an epoch left the whole-epoch reference lane polluted for the hour; the short lane read 11 to 25% above it and the 25% trigger flipped between the two for 40 minutes (102M to 164M, 54 to 81 blocks a minute). Record and hash-rate truth under sim/difficulty/records/. sim.py gains a DAG model (miners on nodes with igneum-miner's template staleness, GHOSTDAG, the rule as the node runs it) and --live replay: std of log difficulty 0.115 against the record's 0.134, 4.3 peaks of 1.31x against 4 of 1.37x. The brief's candidates (short lane 240/360, ease clamp 3%, clamp once per DAA second, hysteresis, median of three) leave 0.09 to 0.13; capping the reference lane at the newest 600 blocks of the epoch gives 0.026 with no flips. Rule v2 = that cap, epoch lane only, behind difficulty_v2_activation_daa (devnet-v4 fork). Attack suite and synthetic set before and after, 3-node test network of the switch (testnet_v2.py), analysis document, spec 2.3, bench-log entry, ledger M24, fast-time file carries the new field.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-04 13:26:23 +00:00

1364 lines
63 KiB
Python

#!/usr/bin/env python3
"""Igneum difficulty controller simulator.
Replays block production from a hash-rate profile through candidate difficulty controllers and
reports how each one tracks the target rate. Chain model: one selected chain, DAA score = height,
1 block per second target, block times exponential with mean (expected hashes per block) / (hash rate).
Timestamps are the true block times in milliseconds (option --ts-noise adds miner clock jitter).
Controllers
kaspa Kaspa's sampled DAA (KIP-4) as the fork runs it: samples every 4th block, 661 samples,
average target of the window without its earliest block, times measured over expected,
fixed genesis bits for the first 600 blocks (150 samples).
monero Monero style: last 720 blocks, timestamps sorted, 60 cut from each end, lag 15.
lwma60 Zawy LWMA-1, N = 60, solvetimes clamped to [-FTL, 6T], FTL = 132 s.
lwma120 the same with N = 120.
igneum the Igneum rule (two lanes, epoch windows, 3% harden and 10% ease clamps, 20 T solvetime cap on a
sanitised running clock, warm-up from block 8, target floor 2^128).
igneum-literal
the brief's literal trigger (fast lane engaged while the short-window RATE is more than
25% off target) kept for the chatter measurement.
igneum-v2 rule v2 (4 October 2026): the reference lane is the newest 600 blocks of the epoch (epoch lane only).
igneum:key=value+key=value
any variant of the Igneum class (ns, trig, trig_exit, soft=a/b, median3, clamp_window, ref_window,
long_min, clamp_pct, ease_pct, cap, k0, ...).
Profiles
real the devnet record (--record CSV) turned into a step profile of estimated hash rate.
up50, down50, epoch30, walk10, hop3, hop10, warmup-hard, warmup-easy
Usage
python3 sim.py every synthetic profile, every controller, markdown out
python3 sim.py --record devnet-2026-10-03.csv adds the real profile and the exact-bits replay check
python3 sim.py --tune the parameter sweep for the Igneum candidate
python3 sim.py --live records/live-2026-10-04.csv --hashrate records/live-2026-10-04-hashrate.csv \
--controllers igneum,igneum-v2 DAG replay of the live devnet against the record (4 October 2026)
"""
import argparse
import csv
import math
import random
import sys
from collections import deque
T = 1000 # target time per block, ms
EPOCH = 3600 # blocks per program epoch
FTL = 132 * 1000 # Kaspa's future timestamp tolerance, ms
MAX_TARGET = (1 << 255) - 1
ONE = float(1 << 255) # genesis-independent scale: target = ONE / D, D = expected hashes
MIN_TARGET = ONE / float(1 << 128) # the Igneum rule's floor: 2^128 expected hashes per block (spec 2.3 rule 4)
# ---------------------------------------------------------------- controllers
class Controller:
name = "base"
def __init__(self, genesis_target):
self.g = genesis_target
self.ts = [] # timestamps ms, index = height
self.tg = [] # targets (float), index = height
def push(self, ts, target):
self.ts.append(ts)
self.tg.append(target)
def next_target(self):
raise NotImplementedError
def clamp(x, lo, hi):
return lo if x < lo else hi if x > hi else x
class Kaspa(Controller):
"""KIP-4 sampled DAA as in rusty-kaspa `SampledDifficultyManager::calculate_difficulty_bits`.
Sampling: block at height h is a sample when (h_parent_daa + 1) % rate == 0, i.e. h % 4 == 3 on a chain;
genesis is never a sample. Window keeps the 661 highest samples. Retarget needs 150 samples."""
name = "kaspa"
def __init__(self, g, window=661, rate=4, min_samples=150):
super().__init__(g)
self.window = window
self.rate = rate
self.min_samples = min_samples
self.samples = deque() # (ts, target) of sampled blocks, oldest first
def push(self, ts, target):
h = len(self.ts)
super().push(ts, target)
if h > 0 and h % self.rate == self.rate - 1:
self.samples.append((ts, target))
if len(self.samples) > self.window:
self.samples.popleft()
def next_target(self):
n = len(self.samples)
if n < self.min_samples:
return self.tg[-1] if self.tg else self.g
min_i = min(range(n), key=lambda i: self.samples[i][0])
max_i = max(range(n), key=lambda i: self.samples[i][0])
min_ts = self.samples[min_i][0]
max_ts = self.samples[max_i][0]
tsum = sum(t for i, (_, t) in enumerate(self.samples) if i != min_i)
avg = tsum / (n - 1)
measured = max(max_ts - min_ts, 1)
expected = T * self.rate * (n - 1)
return min(avg * measured / expected, ONE)
class Monero(Controller):
"""Monero next_difficulty: N 720, cut 60 each side on sorted timestamps, lag 15.
Difficulty units, converted to target at the end."""
name = "monero"
def __init__(self, g, n=720, cut=60, lag=15):
super().__init__(g)
self.n, self.cut, self.lag = n, cut, lag
def next_target(self):
h = len(self.ts)
end = h - self.lag
if end < 2:
return self.g
start = max(0, end - self.n)
ts = sorted(self.ts[start:end])
length = len(ts)
if length <= 2 * self.cut + 2:
cb, ce = 0, length
else:
cb = (length - (self.n - 2 * self.cut) + 1) // 2 if length >= self.n else self.cut
cb = clamp(cb, 0, self.cut)
ce = length - cb
if ce - cb < 2:
cb, ce = 0, length
span = max(ts[ce - 1] - ts[cb], 1)
work = sum(ONE / t for t in self.tg[start + cb:start + ce])
d = work * T / span
return min(ONE / d, ONE)
class Lwma(Controller):
"""Zawy LWMA-1 (2018 reference): linear weights, solvetime in [-FTL, 6T], average target over N.
Grows from 4 blocks instead of waiting for N."""
name = "lwma"
def __init__(self, g, n=120, lo=-FTL, hi=6 * T, min_blocks=4):
super().__init__(g)
self.n, self.lo, self.hi, self.min_blocks = n, lo, hi, min_blocks
self.name = "lwma%d" % n
def next_target(self):
h = len(self.ts)
if h < self.min_blocks + 1:
return self.g
n = min(self.n, h - 1)
ts = self.ts
wsum = 0.0
tsum = 0.0
for i in range(1, n + 1):
j = h - n - 1 + i
st = clamp(ts[j] - ts[j - 1], self.lo, self.hi)
wsum += st * i
tsum += self.tg[j]
wsum = max(wsum, n * n * T / 20)
avg = tsum / n
return min(avg * wsum / (T * n * (n + 1) / 2), ONE)
class Igneum(Controller):
"""Igneum dual-lane controller (docs/spec/02-consensus.md section 2.3).
Every lane estimates the hash rate as work over time (what Kaspa's estimateNetworkHashesPerSecond does),
never as average target times average solvetime, which is biased while targets ramp.
k = blocks of the current epoch already in the past (epoch = height // 3600).
Every chain step is measured on a sanitised running clock (4 October 2026, after the timestamp-stretching
attack of sim/difficulty/attacks/): c(genesis) = t(genesis); c(b) = max(c(p) + clamp(t(b) - c(p), -CAP, +CAP),
t(b) - LAG_CAPS x CAP); step(b) = min(c(b) - c(p), CAP). The steps of a lane sum to the clock's span over it, so
a forged stamp is paid back by the honest blocks after it instead of being cancelled to zero measured time.
S (short): linear-weighted work over the clock steps of the last min(NS, k) chain blocks.
E (epoch): work over the clock steps of the whole epoch so far, shrunk toward the parent's implied rate by
k / (k + K0); used as the reference while k < LONG_MIN.
L (long): blue work between the earliest and latest sample of Kaspa's sampled window restricted to the
epoch, over their time span; the reference once k >= LONG_MIN.
Candidate = S when S and the reference disagree by more than TRIG, else the reference. The output may
fall (harder) by at most CLAMP and rise (easier) by at most EASE per block, then is bounded to
[MIN_TARGET, ONE]. The parent's target is held for the first K_MIN blocks of an epoch. Genesis bits only for
blocks 1 to K_MIN."""
name = "igneum"
def __init__(self, g, ns=120, k_min=8, k0=16, long_min=600, trig=0.25, clamp_pct=0.03, ease_pct=0.10, cap=20 * T,
window=661, rate=4, literal=False, epoch_aware=True, kaspa_formula=False, lag_caps=3,
trig_exit=None, soft=None, median3=False, clamp_window=None, ref_window=None):
"""Candidates of 4 October 2026 (live oscillation, docs/analysis/difficulty-2026-10-04-oscillation.md), all off by
default so the rule of spec 2.3 is unchanged:
trig_exit hysteresis: the short lane, once engaged, stays engaged until it is within this fraction of the reference
soft (a, b): blend instead of switch; weight on the short lane rises linearly from 0 at |S/R - 1| = a to 1 at b
median3 the short lane's rate is the median of its estimates at the block, its parent and its grandparent
clamp_window ms: the clamps also bind relative to the target of the newest chain block at least this far back on
the sanitised clock, so a burst of chain blocks inside one window moves the target once
ref_window blocks: the reference lane sees at most this many newest blocks of the epoch (the long lane's samples
and the epoch lane's steps alike), so a hash-rate step inside an epoch washes out of it in that many
blocks instead of lasting the whole epoch"""
super().__init__(g)
self.ns, self.k_min, self.k0, self.long_min = ns, k_min, k0, long_min
self.trig, self.clamp_pct, self.cap = trig, clamp_pct, cap
self.lag_caps = lag_caps
self.trig_exit, self.soft, self.median3 = trig_exit, soft, median3
self.clamp_window, self.ref_window = clamp_window, ref_window
self.lane = [False] # per height: the short lane ruled the block's target (index 0 = genesis)
self.flips = 0
self.tss = [] # sanitised clock per height
self.sts = [] # clock step per height (the solvetime the lanes see)
self.ease_pct = clamp_pct if ease_pct is None else ease_pct
self.window, self.rate = window, rate
self.literal = literal
self.epoch_aware = epoch_aware
self.kaspa_formula = kaspa_formula
self.samples = deque() # (height, ts, blue_work)
self.pref_d = [0.0] # prefix sums of D (work), index h+1 = work through block h = blue work
self.pref_t = [0.0] # prefix sums of targets (only for the kaspa_formula variant)
self.pref_st = [0.0] # prefix sums of capped solvetimes
self.engaged = 0
def push(self, ts, target):
h = len(self.ts)
if h == 0:
st, tss = 0, ts
else:
# the sanitised clock: one capped step from the parent's clock, never more than LAG_CAPS caps behind
# the raw stamp (an honest idle gap is not paid back as cap-sized steps for many blocks)
tss = self.tss[-1] + clamp(ts - self.tss[-1], -self.cap, self.cap)
tss = max(tss, ts - self.lag_caps * self.cap)
st = min(tss - self.tss[-1], self.cap)
self.tss.append(tss)
self.sts.append(st)
super().push(ts, target)
self.pref_d.append(self.pref_d[-1] + ONE / target)
self.pref_t.append(self.pref_t[-1] + target)
self.pref_st.append(self.pref_st[-1] + st)
if h > 0 and h % self.rate == self.rate - 1:
self.samples.append((h, ts, self.pref_d[-1])) # the long lane keeps the raw sampled span
if len(self.samples) > self.window:
self.samples.popleft()
def rate_s(self, lo, hi):
"""Linear-weighted hash rate over chain steps lo+1..hi-1 (work of block j over the clock step of block j)."""
n = hi - lo - 1
if n < 1:
return None
wsum = 0.0
dsum = 0.0
for i in range(1, n + 1):
j = lo + i
wsum += self.sts[j] * i
dsum += (self.pref_d[j + 1] - self.pref_d[j]) * i
wsum = max(wsum, n * n * T / 20)
return dsum / wsum
def rate_e(self, lo, hi, k):
n = hi - lo - 1
if n < 1:
return None
work = self.pref_d[hi] - self.pref_d[lo + 1]
measured = max(self.pref_st[hi] - self.pref_st[lo + 1], 1)
r = work / measured
prior = (self.pref_d[hi] - self.pref_d[hi - 1]) / T # the parent's implied rate
return (k * r + self.k0 * prior) / (k + self.k0)
def rate_l(self, lo):
samp = [s for s in self.samples if s[0] >= lo]
n = len(samp)
if n < self.long_min // self.rate:
return None
mn = min(samp, key=lambda s: s[1])
mx = max(samp, key=lambda s: s[1])
measured = max(mx[1] - mn[1], 1)
if self.kaspa_formula:
tsum = sum(self.tg[s[0]] for s in samp if s is not mn)
return 1.0 / ((tsum / (n - 1)) * measured / (T * self.rate * (n - 1))) / ONE * ONE * (1.0 / T) * T if False else None
return (mx[2] - mn[2]) / measured
def next_target(self):
h = len(self.ts)
if h == 0:
return self.g
parent = self.tg[-1]
epoch_start = (h // EPOCH) * EPOCH if self.epoch_aware else 0
k = h - epoch_start
eng = False
if k < self.k_min:
cand = parent
else:
ref_start = max(epoch_start, h - self.ref_window) if self.ref_window else epoch_start
s_lo = max(ref_start, h - self.ns)
r_s = self.rate_s(s_lo, h)
if self.median3 and h - s_lo > 4:
r_s = sorted([r_s, self.rate_s(s_lo, h - 1), self.rate_s(s_lo, h - 2)])[1]
r_ref = None
if k >= self.long_min:
r_ref = self.rate_l(max(ref_start, h - self.window * self.rate))
if r_ref is None:
r_ref = self.rate_e(ref_start, h, h - ref_start)
if self.literal:
n = h - s_lo
measured = max(self.pref_st[h] - self.pref_st[s_lo + 1], 1)
eng = abs(T * (n - 1) / measured - 1) > self.trig
r = r_s if eng else r_ref
else:
r, eng = choose_rate(r_s, r_ref, self.lane[-1], self.trig, self.trig_exit, self.soft)
self.engaged += 1 if eng else 0
cand = ONE / (r * T)
base = None
if self.clamp_window:
j = h - 1
while j > 0 and self.tss[h - 1] - self.tss[j] < self.clamp_window:
j -= 1
base = self.tg[j]
if eng != self.lane[-1]:
self.flips += 1
self.lane.append(eng)
return clamp_target(cand, parent, base, self.clamp_pct, self.ease_pct)
def choose_rate(r_s, r_ref, prev_engaged, trig, trig_exit=None, soft=None):
"""The trigger between the lanes. Returns (rate, short lane engaged).
Spec 2.3 rule 3: the short lane rules while it differs from the reference by more than `trig`.
trig_exit: hysteresis, an engaged short lane rules until it is within trig_exit of the reference.
soft (a, b): the output is the reference plus a share of the difference that rises linearly from 0 at a to 1 at b."""
if r_s is None:
return r_ref, False
if r_ref is None:
return r_s, True
dev = abs(r_s / r_ref - 1)
if soft is not None:
a, b = soft
w = clamp((dev - a) / (b - a), 0.0, 1.0)
return r_ref + w * (r_s - r_ref), w > 0
thr = trig_exit if (prev_engaged and trig_exit is not None) else trig
use_s = dev > thr
return (r_s if use_s else r_ref), use_s
def clamp_target(cand, parent, base, clamp_pct, ease_pct):
"""Spec 2.3 rule 4: harden by at most clamp_pct and ease by at most ease_pct against the parent, then the floor and
ceiling. `base` (the clamp-window candidate): the same bounds against the newest chain block at least one window back."""
out = clamp(cand, parent / (1 + clamp_pct), parent * (1 + ease_pct))
if base is not None:
out = clamp(out, base / (1 + clamp_pct), base * (1 + ease_pct))
return clamp(out, MIN_TARGET, ONE)
def make_controller(name, g, **kw):
if name == "kaspa":
return Kaspa(g)
if name == "monero":
return Monero(g)
if name == "lwma60":
return Lwma(g, n=60)
if name == "lwma120":
return Lwma(g, n=120)
if name == "igneum":
return Igneum(g, **kw)
if name == "igneum-literal":
return Igneum(g, literal=True, **kw)
if name == "igneum-noepoch":
return Igneum(g, epoch_aware=False, **kw)
if name.startswith("igneum:"):
# igneum:key=value+key=value with the Igneum constructor's names; soft=a/b; v2 expands to the adopted candidate
opts = dict(kw)
opts.update(parse_variant(name[len("igneum:"):]))
return Igneum(g, **opts)
if name == "igneum-v2":
return Igneum(g, **dict(V2, **kw))
raise ValueError(name)
# Rule v2, adopted 4 October 2026 (docs/analysis/difficulty-2026-10-04-oscillation.md): the reference lane sees the
# newest 600 blocks of the epoch through the epoch lane only (the sampled long lane is not consulted). `igneum-v2` runs it.
V2 = {"ref_window": 600, "long_min": 1 << 62}
def parse_variant(spec):
out = {}
for part in [p for p in spec.replace(",", "+").split("+") if p]:
key, val = part.split("=")
if key == "v2" or key == "soft" and "/" in val:
if key == "v2":
out.update(V2)
else:
a, b = val.split("/")
out["soft"] = (float(a), float(b))
continue
if key in ("ns", "k_min", "k0", "long_min", "window", "rate", "lag_caps", "ref_window"):
out[key] = int(val)
elif key in ("cap", "clamp_window"):
out[key] = float(val) * T if float(val) < 1000 else float(val)
elif key in ("median3", "literal", "epoch_aware"):
out[key] = val in ("1", "true", "yes")
elif key == "trig_exit" and val in ("none", "None"):
out[key] = None
else:
out[key] = float(val)
return out
# ---------------------------------------------------------------- profiles
class Profile:
"""Hash rate in hashes per ms as a function of (time ms, height). `steps` lists (t_ms, factor) events
for the recovery metrics; `epoch_steps` are height-keyed factors applied at epoch boundaries."""
def __init__(self, name, base, duration_ms, genesis_d=None, seed=7):
self.name = name
self.base = base
self.duration = duration_ms
self.time_steps = [] # (t_ms, factor)
self.epoch_steps = {} # height -> factor
self.walk = None # (sigma per minute)
self.genesis_d = genesis_d if genesis_d is not None else base * T
self.rng = random.Random(seed)
self._walk_cache = {}
def rate(self, t, h):
f = 1.0
for ts, fac in self.time_steps:
if t >= ts:
f *= fac
for hh, fac in self.epoch_steps.items():
if h >= hh:
f *= fac
if self.walk:
m = int(t // 60000)
if m not in self._walk_cache:
prev = self._walk_cache.get(m - 1, 0.0) if m > 0 else 0.0
self._walk_cache[m] = prev + self.rng.gauss(0, self.walk)
f *= math.exp(self._walk_cache[m])
return self.base * f
def events(self):
"""Step events as (t_ms or None, height or None, factor)."""
out = [(t, None, f) for t, f in self.time_steps]
out += [(None, h, f) for h, f in self.epoch_steps.items()]
return out
SCALE = float(1 << 28) # synthetic difficulty unit: 2^28 hashes per block, the devnet genesis
H1 = SCALE / T # hash rate that gives one block per second at D = SCALE
def synthetic_profiles(seed):
P = []
hour = 3600 * 1000
p = Profile("up50", H1, 5 * hour, seed=seed); p.time_steps = [(3 * hour, 50.0)]; P.append(p)
p = Profile("down50", 50 * H1, 7 * hour, seed=seed); p.time_steps = [(3 * hour, 1 / 50.0)]; P.append(p)
p = Profile("epoch30", H1, 8 * hour, seed=seed)
rng = random.Random(seed)
fac = 1.0
for e in range(2, 8):
s = rng.choice([1.3, 1 / 1.3])
p.epoch_steps[e * EPOCH] = s
P.append(p)
p = Profile("walk10", H1, 6 * hour, seed=seed); p.walk = 0.10 / math.sqrt(60); P.append(p)
p = Profile("hop3", H1, 6 * hour, seed=seed)
p.time_steps = [(3 * hour + i * 15 * 60000, 3.0 if i % 2 == 0 else 1 / 3.0) for i in range(12)]
P.append(p)
p = Profile("hop10", H1, 6 * hour, seed=seed)
p.time_steps = [(3 * hour + i * 15 * 60000, 10.0 if i % 2 == 0 else 1 / 10.0) for i in range(12)]
P.append(p)
# the devnet of 3 Oct 2026: genesis sized for 0.6 blocks/s of GPU, a 0.11 blocks/s Metal worker for 21 min,
# 13 min of nothing, then the PC; Kaspa's first retarget at block 600 sees the whole slow history
p = Profile("polluted", 0.6 * H1, 5 * hour, genesis_d=1.0 * SCALE, seed=seed)
p.time_steps = [(0, 0.11 / 0.6), (21 * 60000, 1e-9), (34 * 60000, 0.6 / 0.11 / 1e-9)]
P.append(p)
p = Profile("warmup-hard", H1, 2 * hour, genesis_d=10.0 * SCALE, seed=seed); p.time_steps = [(0, 1.0)]; P.append(p)
p = Profile("warmup-easy", H1, 2 * hour, genesis_d=0.1 * SCALE, seed=seed); p.time_steps = [(0, 1.0)]; P.append(p)
return P
# ---------------------------------------------------------------- record (real devnet)
def load_record(path):
"""Returns (chain, all): chain blocks (one per blue score, for the exact replay) and every block
(for the hash-rate profile), as (blue_score, daa_score, timestamp_ms, bits)."""
rows = list(csv.DictReader(open(path)))
rows.sort(key=lambda r: (int(r["blue_score"]), int(r["daa_score"]), int(r["timestamp_ms"])))
every = [(int(r["blue_score"]), int(r["daa_score"]), int(r["timestamp_ms"]), int(r["bits"], 16)) for r in rows]
chain = []
seen = set()
for r in rows:
if r["is_chain_block"] != "1" and r["blue_score"] != "0":
continue
bs = int(r["blue_score"])
if bs in seen:
continue
seen.add(bs)
chain.append((bs, int(r["daa_score"]), int(r["timestamp_ms"]), int(r["bits"], 16)))
return chain, every
def bits_to_target(bits):
exp = bits >> 24
mant = bits & 0x7FFFFF
if exp <= 3:
return mant >> (8 * (3 - exp))
return mant << (8 * (exp - 3))
def target_to_bits(t):
"""Uint256::compact_target_bits: size in bytes, mantissa top 3 bytes, sign-bit carry."""
if t == 0:
return 0
size = (t.bit_length() + 7) // 8
if size <= 3:
mant = t << (8 * (3 - size))
else:
mant = t >> (8 * (size - 3))
if mant & 0x800000:
mant >>= 8
size += 1
return (size << 24) | mant
def kaspa_bits_exact(record, window=661, rate=4, min_samples=150):
"""Replay the record's own timestamps and bits through rusty-kaspa's integer arithmetic; return
(checked, mismatches). Sample rule as in `sampled_mergeset_iterator` on a chain."""
samples = deque()
checked = mism = 0
first_bad = None
exact_before_first = 0
for i, (bs, daa, ts, bits) in enumerate(record):
if i > 0:
n = len(samples)
if n >= min_samples:
min_i = min(range(n), key=lambda j: samples[j][0])
max_i = max(range(n), key=lambda j: samples[j][0])
tsum = sum(t for j, (_, t) in enumerate(samples) if j != min_i)
avg = tsum // (n - 1)
measured = max(samples[max_i][0] - samples[min_i][0], 1)
expected = T * rate * (n - 1)
new = min(avg * measured // expected, MAX_TARGET)
want = target_to_bits(new)
checked += 1
if want != bits:
mism += 1
if first_bad is None:
first_bad = (daa, hex(bits), hex(want))
elif first_bad is None:
exact_before_first += 1
parent_daa = record[i - 1][1] if i > 0 else None
if i > 0 and (parent_daa + 1) % rate == 0:
samples.append((ts, bits_to_target(bits)))
if len(samples) > window:
samples.popleft()
return checked, mism, first_bad, exact_before_first
def record_profile(record, every, events, seed):
"""Piecewise-constant hash rate estimated from the record between the given event times.
events: sorted list of (t_ms, label). Rate in a segment = sum of expected hashes / duration."""
t0 = record[1][2]
tend = record[-1][2]
bounds = [t0] + [t for t, _ in events if t0 < t < tend] + [tend]
segs = []
for a, b in zip(bounds, bounds[1:]):
hashes = sum(2 * ONE / bits_to_target(bits) for (_, _, ts, bits) in every[1:] if a < ts <= b)
dur = max(b - a, 1)
segs.append((a, b, hashes / dur))
p = Profile("real", segs[0][2], tend - t0, genesis_d=ONE / bits_to_target(record[0][3]), seed=seed)
for (a, b, r), (a2, b2, r2) in zip(segs, segs[1:]):
p.time_steps.append((a2 - t0, r2 / r if r > 0 else 1.0))
p.segments = [(a - t0, b - t0, r) for a, b, r in segs]
p.min_rate = 1e-12 * H1
return p
# ---------------------------------------------------------------- simulation
def simulate(profile, ctrl, rng, ts_noise=0, max_blocks=400000):
"""Returns list of (t_ms, height, D, hashrate, r) per block, r = expected block rate / target."""
t = 0
out = []
target = ONE / profile.genesis_d
ctrl.push(0, target) # genesis at t = 0
h = 1
while t < profile.duration and h < max_blocks:
target = ctrl.next_target()
D = ONE / target
work = rng.expovariate(1.0) * D # hashes needed for this block
t_start = t
tt = t
while True:
hr = profile.rate(tt, h)
nxt = min((st for st, _ in profile.time_steps if st > tt), default=None)
if hr > 0 and (nxt is None or work <= hr * (nxt - tt)):
tt += work / hr
break
if nxt is None: # zero hash rate for ever: end the run
tt = profile.duration + 1
break
work -= hr * (nxt - tt)
tt = nxt
gap = tt - t_start
hr = profile.rate(tt, h)
gap = max(gap, 1.0)
t += gap
ts = int(t) + (rng.randint(-ts_noise, ts_noise) if ts_noise else 0)
ctrl.push(ts, target)
r = hr * T / D
out.append((t, h, D, hr, r))
h += 1
return out
def smoothed(xs, half):
"""Centred moving mean over 2*half+1 values (shorter at the ends)."""
n = len(xs)
pref = [0.0]
for x in xs:
pref.append(pref[-1] + x)
out = []
for i in range(n):
lo = max(0, i - half)
hi = min(n, i + half + 1)
out.append((pref[hi] - pref[lo]) / (hi - lo))
return out
def metrics(blocks, profile, t_step=None, h_step=None, settle_blocks=100, t_end=None):
"""Recovery after one step, overshoot, steady state and gap statistics."""
res = {}
if t_step is not None or h_step is not None:
if t_step is None:
t_step = next((b[0] for b in blocks if b[1] >= h_step), None)
post = [b for b in blocks if b[0] >= t_step and (t_end is None or b[0] < t_end)] if t_step is not None else []
if not post:
res["recover_s"] = None
res["overshoot"] = None
else:
rec = None
sm = smoothed([b[4] for b in post], 60)
first = next((i for i, x in enumerate(sm) if abs(x - 1) <= 0.10), None)
res["first10_s"] = None if first is None else (post[first][0] - t_step) / 1000
for i in range(len(post)):
seg = sm[i:i + settle_blocks]
if len(seg) == settle_blocks and all(abs(x - 1) <= 0.10 for x in seg):
rec = post[i][0] - t_step
break
res["recover_s"] = None if rec is None else rec / 1000
res["recover_blocks"] = None if rec is None else sum(1 for b in post if b[0] - t_step <= rec)
r0 = post[0][4]
side = 1 if r0 > 1 else -1
cross = next((i for i, x in enumerate(sm) if (x - 1) * side <= 0), None)
if cross is None:
res["overshoot"] = 0.0
else:
tail = sm[cross:cross + 3600]
res["overshoot"] = max(0.0, max((1 - x) * side for x in tail))
res["above2x"] = sum(1 for b in post if b[4] > 2)
res["below05"] = sum(1 for b in post if b[4] < 0.5)
res["worst_gap_s"] = max((post[i][0] - post[i - 1][0]) for i in range(1, len(post))) / 1000 if len(post) > 1 else None
return res
def steady(blocks, t_from, t_to):
seg = [b for b in blocks if t_from <= b[0] < t_to]
if len(seg) < 10:
return None, None, None
rs = [b[4] for b in seg]
mean = sum(rs) / len(rs)
std = math.sqrt(sum((x - mean) ** 2 for x in rs) / len(rs))
# blocks per minute
mins = {}
for b in seg:
mins[int(b[0] // 60000)] = mins.get(int(b[0] // 60000), 0) + 1
counts = list(mins.values())[1:-1]
cm = sum(counts) / len(counts) if counts else 0
cs = math.sqrt(sum((c - cm) ** 2 for c in counts) / len(counts)) if counts else 0
gaps = [seg[i][0] - seg[i - 1][0] for i in range(1, len(seg))]
return std, (cs / cm if cm else None), max(gaps) / 1000
# ---------------------------------------------------------------- DAG model (4 October 2026, the live oscillation)
# Several miners on several nodes. A miner fetches a template from its node (the node stamps it with its clock at the
# fetch and the miner never touches the stamp, as igneum-miner does), the template reaches the card `delay` later (the
# miner's job queue), and the miner refetches every `period` and right after its own block. A block carries the parents
# and the stamp of the template it was mined from, so blocks found inside one another's staleness are parallel and the
# next block merges them. GHOSTDAG with every merged block blue (k = 18 is far above the width seen here): selected
# parent = the parent with the most blue work, blue work = the selected parent's plus the work of its mergeset blues
# (the selected parent included), DAA score = the selected parent's plus the mergeset size. The rule runs exactly as
# `SampledDifficultyManager::igneum_difficulty_bits`: the chain walk from the virtual's selected parent with blue-work
# increments over sanitised-clock steps, and the sampled window (every 4th block of each mergeset, selected parent
# first then descending blue work, 661 samples) restricted to the epoch for the long lane.
class DagBlock:
__slots__ = ("id", "parents", "sp", "ms", "work", "bw", "daa", "bs", "ts", "clock", "target", "past", "lane",
"found", "miner", "samples", "children", "arrival", "engaged_frac")
def __init__(self, bid):
self.id = bid
self.parents = []
self.sp = None
self.ms = [] # mergeset without the selected parent, descending blue work
self.children = []
self.samples = [] # (ts, bw, daa) of the sampled blocks of this block's mergeset
self.lane = False
self.past = set() # ids of recent ancestors (PAST_DEPTH ids back)
PAST_DEPTH = 400
class DagRule:
"""The Igneum rule on a DAG, parameters taken from an `Igneum` controller instance (so `igneum:...` variants apply)."""
def __init__(self, ctrl):
self.c = ctrl
self.cache = {}
def mergeset(self, parents, sp):
out = []
seen = set()
stack = [p for p in parents if p is not sp]
while stack:
x = stack.pop()
if x.id in seen or x is sp or x.id in sp.past or x.id < sp.id - PAST_DEPTH:
continue
seen.add(x.id)
out.append(x)
stack.extend(x.parents)
out.sort(key=lambda b: (-b.bw, b.id))
return out
def virtual(self, tips):
sp = max(tips, key=lambda b: (b.bw, -b.id))
ms = self.mergeset(tips, sp)
daa = sp.daa + 1 + len(ms)
return sp, ms, daa
@staticmethod
def samples_of(sp, ms):
"""Kaspa's sampled_mergeset_iterator for a block whose selected parent is sp: index 1 = sp, then the mergeset in
descending blue work; sampled where (daa(sp) + index) % rate == 0."""
out = []
for i, b in enumerate([sp] + ms, start=1):
if (sp.daa + i) % 4 == 0:
out.append((b.ts, b.bw, b.daa))
return out
def target_for(self, tips):
"""Target, DAA score and short-lane flag of the template built on `tips`."""
key = tuple(sorted(b.id for b in tips))
hit = self.cache.get(key)
if hit is not None:
return hit
c = self.c
sp, ms, daa = self.virtual(tips)
parent = sp.target
epoch_start = (daa // EPOCH) * EPOCH
k = daa - epoch_start
eng = False
if k < c.k_min:
cand = parent
else:
ref_start = max(epoch_start, daa - c.ref_window) if c.ref_window else epoch_start
# long lane: the virtual's own sampled mergeset, then the chain's, newest first, 661 samples
r_ref = None
if k >= c.long_min:
samples = self.samples_of(sp, ms)
cur = sp
while cur.id != 0 and len(samples) < c.window and cur.daa >= ref_start:
samples.extend(cur.samples)
cur = cur.sp
samples = [s for s in samples[:c.window] if s[2] >= ref_start]
if len(samples) >= c.long_min // c.rate:
mn = min(samples, key=lambda s: s[0])
mx = max(samples, key=lambda s: s[0])
if mx[1] > mn[1] and mx[0] > mn[0]:
r_ref = (mx[1] - mn[1]) / (mx[0] - mn[0])
# chain walk: blue-work increments over sanitised-clock steps, oldest first
limit = c.ns if r_ref is not None else max(c.long_min, c.ns)
steps = []
cur = sp
while cur.id != 0 and cur.daa >= ref_start and len(steps) < limit:
p = cur.sp
steps.append((cur.bw - p.bw, min(cur.clock - p.clock, c.cap)))
cur = p
steps.reverse()
r_s = self.short(steps, c.ns)
if c.median3 and len(steps) > 4:
r_s = sorted([r_s, self.short(steps[:-1], c.ns), self.short(steps[:-2], c.ns)])[1]
if r_ref is None:
work = sum(w for w, _ in steps)
time = max(sum(s for _, s in steps), 1)
kk = daa - ref_start
r_ref = (kk * work / time + c.k0 * (ONE / parent) / T) / (kk + c.k0)
r, eng = choose_rate(r_s, r_ref, sp.lane, c.trig, c.trig_exit, c.soft)
cand = ONE / (r * T)
base = None
if c.clamp_window:
b = sp
while b.id != 0 and sp.clock - b.clock < c.clamp_window:
b = b.sp
base = b.target
out = (clamp_target(cand, parent, base, c.clamp_pct, c.ease_pct), daa, eng)
self.cache[key] = out
return out
@staticmethod
def short(steps, ns):
n = min(ns, len(steps))
if n == 0:
return None
wsum = 0.0
dsum = 0.0
for i, (w, s) in enumerate(steps[len(steps) - n:], start=1):
wsum += s * i
dsum += w * i
wsum = max(wsum, n * n * T / 20)
return dsum / wsum
class DagMiner:
def __init__(self, name, node, period_ms, delay_ms, rpc_ms=30):
self.name, self.node, self.period, self.delay, self.rpc = name, node, period_ms, delay_ms, rpc_ms
self.hr = 0.0 # hashes per ms
self.template = None # (tips, ts, target, daa, eng)
self.pending = [] # (apply time, template) fetched but still in the job queue
self.t_apply = None
self.t_fetch = 0
self.t_solve = None
def simulate_dag(miners, schedule, ctrl, t0, t_end, rng, genesis_d, genesis_ts, nodes=2, prop_ms=100, verify_ms=30):
"""Blocks of a DAG mined by `miners` under the hash-rate `schedule` (sorted (t_ms, miner name, hashes per ms)).
Returns (blocks, chain ids, lane flips)."""
rule = DagRule(ctrl)
c = ctrl
genesis = DagBlock(0)
genesis.ts = genesis_ts
genesis.clock = genesis_ts
genesis.target = ONE / genesis_d
genesis.work = genesis_d
genesis.bw = 0.0
genesis.daa = 0
genesis.bs = 0
genesis.sp = genesis
genesis.found = t0
genesis.arrival = [t0] * nodes
genesis.miner = "genesis"
blocks = [genesis]
views = [[(t0, genesis)] for _ in range(nodes)] # per node, (arrival, block) in arrival order
by_name = {m.name: m for m in miners}
for m in miners:
m.t_fetch = t0 + rng.random() * m.period
si = 0
t = t0
flips = 0
last_lane = False
def tips_at(node, tau):
v = views[node]
out = []
for i in range(len(v) - 1, -1, -1):
arr, b = v[i]
if arr > tau:
continue
if all(ch.arrival[node] > tau for ch in b.children):
out.append(b)
if len(v) - i > 60 and out:
break
return out
def redraw(m, now):
if m.hr > 0 and m.template is not None:
D = ONE / m.template[2]
m.t_solve = now + rng.expovariate(1.0) * D / m.hr
else:
m.t_solve = None
while True:
# next event
t_sched = schedule[si][0] if si < len(schedule) else None
ev, tm, who = None, None, None
for m in miners:
for kind, tt in (("solve", m.t_solve), ("fetch", m.t_fetch), ("apply", m.t_apply)):
if tt is not None and (tm is None or tt < tm):
ev, tm, who = kind, tt, m
if t_sched is not None and (tm is None or t_sched <= tm):
ev, tm, who = "sched", t_sched, None
if tm is None or tm > t_end:
break
t = tm
if ev == "sched":
_, name, hr = schedule[si]
si += 1
m = by_name[name]
m.hr = hr
redraw(m, t)
elif ev == "fetch":
m = who
tips = tips_at(m.node, t)
target, daa, eng = rule.target_for(tips)
m.pending.append((t + m.delay, (tips, t, target, daa, eng)))
m.t_apply = m.pending[0][0]
m.t_fetch = t + m.period
elif ev == "apply":
m = who
m.template = m.pending.pop(0)[1]
m.t_apply = m.pending[0][0] if m.pending else None
redraw(m, t)
elif ev == "solve":
m = who
tips, ts, target, daa, eng = m.template
b = DagBlock(len(blocks))
b.parents = tips
b.sp = max(tips, key=lambda x: (x.bw, -x.id))
b.ms = rule.mergeset(tips, b.sp)
b.ts = ts
b.target = target
b.work = ONE / target
b.bw = b.sp.bw + b.sp.work + sum(x.work for x in b.ms)
b.daa = b.sp.daa + 1 + len(b.ms)
b.bs = b.sp.bs + 1 + len(b.ms)
b.clock = max(b.sp.clock + clamp(ts - b.sp.clock, -c.cap, c.cap), ts - c.lag_caps * c.cap)
b.lane = eng
b.found = t
b.miner = m.name
b.samples = rule.samples_of(b.sp, b.ms)
past = set()
for p in tips:
past.add(p.id)
past.update(p.past)
b.past = {x for x in past if x >= b.id - PAST_DEPTH}
b.arrival = [t + (verify_ms if n == m.node else prop_ms) for n in range(nodes)]
for p in tips:
p.children.append(b)
blocks.append(b)
for n in range(nodes):
v = views[n]
arr = b.arrival[n]
i = len(v)
while i > 0 and v[i - 1][0] > arr:
i -= 1
v.insert(i, (arr, b))
if eng != last_lane:
flips += 1
last_lane = eng
m.t_fetch = min(m.t_fetch, t + m.rpc)
redraw(m, t)
# the selected chain of the final virtual
tips = tips_at(0, t_end + 10 * prop_ms)
chain = set()
cur = max(tips, key=lambda x: (x.bw, -x.id)) if tips else genesis
while True:
chain.add(cur.id)
if cur.id == 0:
break
cur = cur.sp
return blocks, chain, flips
def window_stats(items, secs):
"""items: (ts_ms, is_chain, mergeset_blues, parents, D, step_ms or None), D in the node's convention (2^255 / target)
i.e. half the expected hashes. Chain items in chain order."""
ch = [x for x in items if x[1]]
if len(ch) < 10:
return {"blocks": len(items)}
D = [x[4] for x in ch]
ld = [math.log(x) for x in D]
mean_ld = sum(ld) / len(ld)
std_ld = math.sqrt(sum((x - mean_ld) ** 2 for x in ld) / len(ld))
peaks = []
trough = D[0]
up = False
peak = pi = None
for i, x in enumerate(D):
if not up:
trough = min(trough, x)
if x >= trough * 1.15:
up, peak, pi = True, x, i
else:
if x > peak:
peak, pi = x, i
if x <= peak / 1.15:
peaks.append((pi, peak / trough))
up, trough = False, x
mins = {}
for x in items:
mins[int(x[0] // 60000)] = mins.get(int(x[0] // 60000), 0) + 1
counts = sorted(mins.values())[1:-1] if len(mins) > 2 else list(mins.values())
steps = sorted(x[5] for x in ch if x[5] is not None)
return {"blocks": len(items), "blocks_s": len(items) / secs, "chain_s": len(ch) / secs, "blues_per_chain": len(items) / len(ch),
"merge2_pct": 100.0 * sum(1 for x in ch if x[2] >= 2) / len(ch), "parents2_pct": 100.0 * sum(1 for x in items if x[3] >= 2) / len(items),
"step_median_ms": steps[len(steps) // 2] if steps else None,
"D_mean_M": sum(D) / len(D) / 1e6, "D_min_M": min(D) / 1e6, "D_max_M": max(D) / 1e6, "std_logD": std_ld,
"peaks15": len(peaks), "peak_ratio": (sum(p[1] for p in peaks) / len(peaks)) if peaks else None,
"peak_spacing_blocks": ((peaks[-1][0] - peaks[0][0]) / (len(peaks) - 1)) if len(peaks) > 1 else None,
"bpm_min": min(counts) if counts else None, "bpm_max": max(counts) if counts else None}
LIVE_COLS = [("blocks", "blocks"), ("blocks_s", "blocks/s"), ("chain_s", "chain/s"), ("blues_per_chain", "blues per chain block"),
("merge2_pct", "chain blocks merging 2+ %"), ("parents2_pct", "blocks with 2+ parents %"), ("step_median_ms", "chain step median ms"),
("D_mean_M", "difficulty mean M"), ("D_min_M", "min M"), ("D_max_M", "max M"), ("std_logD", "std log D"),
("peaks15", "peaks over 15%"), ("peak_ratio", "mean peak/trough"), ("peak_spacing_blocks", "peak spacing chain blocks"),
("bpm_min", "blocks/min min"), ("bpm_max", "blocks/min max"), ("flips", "lane flips"), ("engaged_pct", "short lane %")]
def record_items(rows, t_lo, t_hi):
by = {r["hash"]: r for r in rows}
out = []
for r in sorted(rows, key=lambda r: (int(r["blue_score"]), int(r["daa_score"]))):
ts = int(r["timestamp_ms"])
if int(r["daa_score"]) == 0 or not (t_lo <= ts < t_hi):
continue
p = by.get(r.get("selected_parent"))
step = (ts - int(p["timestamp_ms"])) if (p and r["is_chain_block"] == "1") else None
out.append((ts, r["is_chain_block"] == "1", int(r.get("mergeset_blues") or 1), int(r["parents"]), float(r["difficulty"]), step))
return out
def dag_items(blocks, chain, t_lo, t_hi):
out = []
for b in blocks[1:]:
if not (t_lo <= b.ts < t_hi):
continue
is_chain = b.id in chain
out.append((b.ts, is_chain, 1 + len(b.ms), len(b.parents), b.work / 2.0, (b.ts - b.sp.ts) if is_chain else None, b.lane))
return out
def live_schedule(path, t0, metal_mhs):
"""Hash-rate events from the records CSV (STATUS lines: the last 30 s of every worker): (t_ms, worker, hashes/ms).
A worker is off from 5 s after its last report when the next one is more than 45 s away. The Mac Metal miner has
no upload; it mines `metal_mhs` from t0."""
rows = list(csv.DictReader(open(path)))
byw = {}
for r in rows:
byw.setdefault(r["worker"], []).append((float(r["t_unix"]) * 1000, float(r["mh_s_last_30s"]) * 1e3))
ev = [(t0, "mac-metal", metal_mhs * 1e3)]
nodes = {"mac-metal": 1}
for w, pts in byw.items():
nodes[w] = 0 if rows[[r["worker"] for r in rows].index(w)]["node"] == "pc-node" else 1
pts.sort()
prev = None
for t, hr in pts:
if prev is None or t - prev > 45000:
if prev is not None:
ev.append((prev + 5000, w, 0.0))
ev.append((t - 30000, w, hr))
elif abs(hr - prev_hr) > 0.03 * max(prev_hr, 1):
ev.append((t - 30000, w, hr))
prev, prev_hr = t, hr
ev.append((prev + 5000, w, 0.0))
ev.sort()
return ev, nodes
def run_live(args, names):
import datetime
rows = list(csv.DictReader(open(args.live)))
rows.sort(key=lambda r: (int(r["blue_score"]), int(r["daa_score"])))
genesis = rows[0]
first = next(r for r in rows if int(r["daa_score"]) > 0 or int(r["blue_score"]) == 1)
t0 = int(first["timestamp_ms"]) - 1000
day = datetime.datetime.utcfromtimestamp(t0 / 1000).strftime("%Y-%m-%d")
def at(hhmm):
return int(datetime.datetime.fromisoformat("%sT%s:00+00:00" % (day, hhmm)).timestamp() * 1000)
windows = []
for w in args.live_windows.split(","):
name, span = w.split("=")
a, b = span.split("-")
windows.append((name, at(a), at(b)))
t_end = at(args.live_end) if args.live_end else max(int(r["timestamp_ms"]) for r in rows)
genesis_d = ONE / bits_to_target(int(genesis["bits"], 16))
schedule, node_of = live_schedule(args.hashrate, t0, args.metal_mhs)
print("# Live replay of %s: %d headers, DAG model, seed %d, delay scale %.2f\n" % (args.live, len(rows), args.seed, args.dag_delay))
print("Hash-rate schedule (from the log intake and the Mac's bench value), MH/s:\n")
print("| time UTC | worker | node | MH/s |")
print("|---|---|---|---|")
for t, w, hr in schedule:
print("| %s | %s | %s | %.1f |" % (datetime.datetime.utcfromtimestamp(t / 1000).strftime("%H:%M:%S"), w, "pc" if node_of[w] == 0 else "mac", hr / 1e3))
results = {}
for name, lo, hi in windows:
results.setdefault("record", {})[name] = window_stats(record_items(rows, lo, hi), (hi - lo) / 1000)
for cn in names:
ctrl = make_controller(cn, ONE / genesis_d)
if not isinstance(ctrl, Igneum):
continue
rng = random.Random(args.seed)
miners = []
for w, n in node_of.items():
if w == "mac-metal":
miners.append(DagMiner(w, n, 630, int(900 * args.dag_delay)))
else:
miners.append(DagMiner(w, n, 150, int(350 * args.dag_delay)))
blocks, chain, flips = simulate_dag(miners, schedule, ctrl, t0, t_end, rng, genesis_d, int(genesis["timestamp_ms"]), prop_ms=int(100 * args.dag_delay) + 20)
for name, lo, hi in windows:
items = dag_items(blocks, chain, lo, hi)
st = window_stats([x[:6] for x in items], (hi - lo) / 1000)
seg = [b for b in blocks[1:] if lo <= b.ts < hi and b.id in chain]
st["flips"] = sum(1 for a, b in zip(seg, seg[1:]) if a.lane != b.lane)
st["engaged_pct"] = 100.0 * sum(1 for b in seg if b.lane) / len(seg) if seg else None
results.setdefault(cn, {})[name] = st
if args.live_dump:
with open(args.live_dump % cn.replace(":", "_").replace("/", "_") if "%s" in args.live_dump else args.live_dump, "w") as f:
f.write("id,found_ms,ts_ms,daa,blue_score,chain,parents,mergeset_blues,difficulty,miner,lane\n")
for b in blocks[1:]:
f.write("%d,%d,%d,%d,%d,%d,%d,%d,%.0f,%s,%d\n" % (b.id, b.found, b.ts, b.daa, b.bs, b.id in chain, len(b.parents), 1 + len(b.ms), b.work / 2, b.miner, b.lane))
for name, lo, hi in windows:
print("\n### Window %s (%s to %s UTC)\n" % (name, datetime.datetime.utcfromtimestamp(lo / 1000).strftime("%H:%M"), datetime.datetime.utcfromtimestamp(hi / 1000).strftime("%H:%M")))
cols = [(k, h) for k, h in LIVE_COLS if any(k in results[r].get(name, {}) for r in results)]
print("| source | " + " | ".join(h for _, h in cols) + " |")
print("|---|" + "---|" * len(cols))
for r in results:
st = results[r].get(name, {})
print("| %s | " % r + " | ".join(fmt(st.get(k), 3 if k in ("std_logD", "blues_per_chain") else (2 if k in ("blocks_s", "chain_s", "peak_ratio") else 1)) for k, _ in cols) + " |")
def fmt(x, nd=1):
if x is None:
return "none"
if isinstance(x, float):
return ("%." + str(nd) + "f") % x
return str(x)
def run_profile(profile, names, seed, ts_noise, kw=None):
rows = []
hour = 3600 * 1000
for name in names:
rng = random.Random(seed)
ctrl = make_controller(name, ONE / profile.genesis_d, **(kw or {}))
blocks = simulate(profile, ctrl, rng, ts_noise=ts_noise)
row = {"ctrl": name, "blocks": len(blocks)}
ev = profile.events()
if profile.name.startswith("warmup"):
m = metrics(blocks, profile, t_step=0)
row.update(m)
s = steady(blocks, 1 * hour, 2 * hour)
elif profile.name == "epoch30":
recs, ovs, a2, b5, wg = [], [], 0, 0, 0
firsts = []
for h, f in sorted(profile.epoch_steps.items()):
t_end = next((b[0] for b in blocks if b[1] >= h + EPOCH), None)
m = metrics(blocks, profile, h_step=h, t_end=t_end)
recs.append(m.get("recover_s"))
firsts.append(m.get("first10_s"))
ovs.append(m.get("overshoot") or 0)
a2 += m.get("above2x", 0)
b5 += m.get("below05", 0)
m = metrics(blocks, profile, h_step=2 * EPOCH)
good = [r for r in recs if r is not None]
gf = [r for r in firsts if r is not None]
row["first10_s"] = (sum(gf) / len(gf)) if gf else None
row["recover_s"] = (sum(good) / len(good)) if good else None
row["recover_max_s"] = max(good) if good else None
row["recover_fail"] = len(recs) - len(good)
row["overshoot"] = max(ovs)
row["above2x"] = sum(1 for b in blocks if b[4] > 2 and b[1] >= 2 * EPOCH)
row["below05"] = sum(1 for b in blocks if b[4] < 0.5 and b[1] >= 2 * EPOCH)
row["worst_gap_s"] = max(blocks[i][0] - blocks[i - 1][0] for i in range(1, len(blocks))) / 1000
s = steady(blocks, 1 * hour, 2 * hour)
elif profile.name.startswith("hop"):
recs = []
ovs = []
firsts = []
for i, (t, f) in enumerate(profile.time_steps):
t_end = profile.time_steps[i + 1][0] if i + 1 < len(profile.time_steps) else None
m = metrics(blocks, profile, t_step=t, t_end=t_end)
recs.append(m.get("recover_s"))
firsts.append(m.get("first10_s"))
ovs.append(m.get("overshoot") or 0)
good = [r for r in recs if r is not None]
gf = [r for r in firsts if r is not None]
row["first10_s"] = (sum(gf) / len(gf)) if gf else None
row["recover_s"] = (sum(good) / len(good)) if good else None
row["recover_max_s"] = max(good) if good else None
row["recover_fail"] = len(recs) - len(good)
row["overshoot"] = max(ovs)
t0 = profile.time_steps[0][0]
row["above2x"] = sum(1 for b in blocks if b[4] > 2 and b[0] >= t0)
row["below05"] = sum(1 for b in blocks if b[4] < 0.5 and b[0] >= t0)
row["worst_gap_s"] = max(blocks[i][0] - blocks[i - 1][0] for i in range(1, len(blocks)) if blocks[i][0] >= t0) / 1000
s = steady(blocks, 1 * hour, 3 * hour)
elif profile.name == "walk10":
row["above2x"] = sum(1 for b in blocks if b[4] > 2 and b[0] >= hour)
row["below05"] = sum(1 for b in blocks if b[4] < 0.5 and b[0] >= hour)
row["worst_gap_s"] = max(blocks[i][0] - blocks[i - 1][0] for i in range(1, len(blocks)) if blocks[i][0] >= hour) / 1000
s = steady(blocks, 1 * hour, 6 * hour)
elif profile.name == "polluted":
t_pc = profile.time_steps[2][0]
m = metrics(blocks, profile, t_step=t_pc)
row.update(m)
post = [b for b in blocks if b[0] >= t_pc]
row["peak_rate"] = max(b[4] for b in post)
row["trough_rate"] = min(b[4] for b in post if b[0] >= t_pc + 600000) if any(b[0] >= t_pc + 600000 for b in post) else None
s = steady(blocks, 3 * hour, 5 * hour)
elif getattr(profile, "custom", False):
recs, firsts, ovs = [], [], []
for i, (t, f) in enumerate(profile.time_steps):
t_end = profile.time_steps[i + 1][0] if i + 1 < len(profile.time_steps) else None
m = metrics(blocks, profile, t_step=t, t_end=t_end)
row["step%d_first" % (i + 1)] = m.get("first10_s")
row["step%d_settled" % (i + 1)] = m.get("recover_s")
row["step%d_gap" % (i + 1)] = m.get("worst_gap_s")
row["blocks_within10"] = sum(1 for b in blocks if abs(b[4] - 1) <= 0.1)
s = (None, None, None)
elif profile.name == "real":
segs = profile.segments
# the biggest step in the record
big = max(profile.time_steps, key=lambda e: abs(math.log(e[1])))
m = metrics(blocks, profile, t_step=big[0])
row.update(m)
row["step"] = big[1]
s = steady(blocks, segs[-1][0] + 600000, segs[-1][1]) if segs[-1][1] - segs[-1][0] > 1200000 else (None, None, None)
row["blocks_within10"] = sum(1 for b in blocks if abs(b[4] - 1) <= 0.1)
else:
t, f = profile.time_steps[0]
m = metrics(blocks, profile, t_step=t)
row.update(m)
s = steady(blocks, 1 * hour, 3 * hour)
row["ss_std"], row["ss_bpm_cv"], row["ss_worst_gap_s"] = s
if isinstance(ctrl, Igneum):
row["engaged"] = ctrl.engaged
rows.append(row)
return rows
COLS = [("ctrl", "controller"), ("first10_s", "first within 10% s"), ("recover_s", "settled s"), ("recover_blocks", "recovery blocks"),
("recover_max_s", "worst recovery s"), ("recover_fail", "not recovered"), ("overshoot", "overshoot"),
("ss_std", "steady std of rate"), ("ss_bpm_cv", "steady blocks/min CV"), ("worst_gap_s", "worst gap s"),
("above2x", "blocks above 2x"), ("below05", "blocks below 0.5x"), ("blocks", "blocks"), ("engaged", "fast-lane blocks"),
("blocks_within10", "blocks within 10%"), ("step", "step factor"), ("peak_rate", "peak rate"), ("trough_rate", "trough rate after 10 min")]
def print_table(title, rows):
cols = [(k, h) for k, h in COLS if any(k in r for r in rows)]
print("\n### " + title + "\n")
print("| " + " | ".join(h for _, h in cols) + " |")
print("|" + "---|" * len(cols))
for r in rows:
print("| " + " | ".join(fmt(r.get(k), 3 if k in ("ss_std", "ss_bpm_cv", "overshoot") else 1) for k, _ in cols) + " |")
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--record")
ap.add_argument("--seed", type=int, default=7)
ap.add_argument("--ts-noise", type=int, default=0, help="uniform timestamp jitter, ms")
ap.add_argument("--profiles", default="all")
ap.add_argument("--controllers", default="kaspa,monero,lwma60,lwma120,igneum,igneum-literal")
ap.add_argument("--tune", action="store_true")
ap.add_argument("--events", default="", help="real record: comma list of ISO times of miner events")
ap.add_argument("--dump", default="", help="profile:controller, write per-block trajectory to stdout as CSV and exit")
ap.add_argument("--custom", default="", help="extra profile: name=genesis_bits_hex,base_MHs,duration_s,t_s:factor,t_s:factor,...")
ap.add_argument("--live", default="", help="DAG replay of a live record (records/live-2026-10-04.csv) against the controllers")
ap.add_argument("--hashrate", default="", help="live replay: the hash-rate CSV from the log intake (records/live-2026-10-04-hashrate.csv)")
ap.add_argument("--live-windows", default="solo=09:25-10:04,both=10:17-10:32,late=10:37-10:54", help="live replay: name=HH:MM-HH:MM UTC windows to compare")
ap.add_argument("--live-end", default="", help="live replay: stop at HH:MM UTC (default: the record's last stamp)")
ap.add_argument("--live-dump", default="", help="live replay: write every simulated block to this CSV (%s = controller name)")
ap.add_argument("--dag-delay", type=float, default=1.0, help="live replay: scale of the miners' template staleness and propagation (1 = fitted)")
ap.add_argument("--metal-mhs", type=float, default=26.7, help="live replay: the Mac Metal miner's hash rate (no upload; bench-log value)")
ap.add_argument("--quick", action="store_true",
help="smoke run for CI: profiles up50 and warmup-hard, controllers kaspa and igneum (about a minute); --profiles and --controllers still override")
args = ap.parse_args()
if args.quick:
if args.profiles == "all":
args.profiles = "up50,warmup-hard"
if args.controllers == "kaspa,monero,lwma60,lwma120,igneum,igneum-literal":
args.controllers = "kaspa,igneum"
names = args.controllers.split(",")
if args.live:
run_live(args, names)
return
profiles = synthetic_profiles(args.seed)
if args.profiles != "all":
want = args.profiles.split(",")
profiles = [p for p in profiles if p.name in want]
if args.custom:
name, rest = args.custom.split("=")
parts = rest.split(",")
gbits, mhs, dur = int(parts[0], 16), float(parts[1]), float(parts[2])
p = Profile(name, mhs * 1e6 / 1000, dur * 1000, genesis_d=ONE / bits_to_target(gbits), seed=args.seed)
p.time_steps = [(float(x.split(":")[0]) * 1000, float(x.split(":")[1])) for x in parts[3:]]
p.custom = True
profiles.append(p)
if args.dump:
pn, cn = args.dump.split(":")
p = next(q for q in synthetic_profiles(args.seed) if q.name == pn)
ctrl = make_controller(cn, ONE / p.genesis_d)
for t, h, D, hr, r in simulate(p, ctrl, random.Random(args.seed)):
print("%d,%d,%.6g,%.6g,%.4f" % (t, h, D, hr, r))
return
print("# Difficulty controller simulation, seed %d, ts noise %d ms\n" % (args.seed, args.ts_noise))
if args.tune:
p_names = ["up50", "down50", "epoch30", "hop10", "walk10", "polluted"]
base = {}
grid = [("ns", [60, 120, 180]), ("trig", [0.15, 0.25, 0.35]), ("clamp_pct", [0.02, 0.03, 0.05]),
("ease_pct", [0.03, 0.06, 0.10, 0.15]),
("k0", [8, 16, 32]), ("cap", [6 * T, 20 * T, FTL])]
for key, vals in grid:
rows = []
for v in vals:
kw = dict(base)
kw[key] = v
agg = {"ctrl": "%s=%s" % (key, v if key != "cap" else v // 1000)}
for p in [q for q in synthetic_profiles(args.seed) if q.name in p_names]:
r = run_profile(p, ["igneum"], args.seed, args.ts_noise, kw)[0]
agg[p.name + " rec"] = r.get("recover_s")
agg[p.name + " first"] = r.get("first10_s")
agg[p.name + " std"] = r.get("ss_std")
if p.name == "down50":
agg["down50 gap"] = r.get("worst_gap_s")
if p.name == "polluted":
agg["polluted peak"] = r.get("peak_rate")
if p.name == "walk10":
agg["walk10 >2x/<0.5x"] = "%d/%d" % (r.get("above2x", 0), r.get("below05", 0))
rows.append(agg)
keys = list(rows[0].keys())
print("\n### Tune %s\n" % key)
print("| " + " | ".join(keys) + " |")
print("|" + "---|" * len(keys))
for r in rows:
print("| " + " | ".join(fmt(r[k], 3 if k.endswith("std") else 1) for k in keys) + " |")
return
for p in profiles:
rows = run_profile(p, names, args.seed, args.ts_noise)
if getattr(p, "custom", False):
continue
desc = {"up50": "hash rate x50 at 3 h", "down50": "hash rate /50 at 3 h",
"epoch30": "x1.3 or /1.3 at each epoch boundary from epoch 2", "walk10": "10% per hour log random walk",
"hop3": "3x pool hops in and out every 15 min from 3 h", "hop10": "10x pool hops in and out every 15 min from 3 h",
"polluted": "window polluted by a pre-step slow period (21 min at 0.11 blocks/s, 13 min idle, then 0.6 blocks/s at genesis difficulty)", "warmup-hard": "genesis difficulty 10x too hard", "warmup-easy": "genesis difficulty 10x too easy"}[p.name]
print_table("%s: %s" % (p.name, desc), rows)
for p in profiles:
if getattr(p, "custom", False):
rows = run_profile(p, names, args.seed, args.ts_noise)
keys = [k for k in rows[0].keys() if k != "ss_std" and k != "ss_bpm_cv" and k != "ss_worst_gap_s"]
print("\n### %s: custom profile\n" % p.name)
print("| " + " | ".join(keys) + " |")
print("|" + "---|" * len(keys))
for r in rows:
print("| " + " | ".join(fmt(r.get(k)) for k in keys) + " |")
if args.record:
rec, every = load_record(args.record)
checked, mism, first_bad, exact = kaspa_bits_exact(rec)
print("\n### Exact replay of Kaspa's rule on the record\n")
print("Blocks %d, chain blocks %d, retargets checked %d, exact before the first mismatch %d, mismatches %d%s" % (
len(every), len(rec), checked, exact, mism, "" if first_bad is None else ", first at daa %d: have %s want %s" % first_bad))
print("(A chain-only replay cannot see the merged blocks that Kaspa's sampled window includes once the DAG widens.)")
import datetime
ev = []
for s in [x for x in args.events.split(",") if x]:
ev.append((int(datetime.datetime.fromisoformat(s).timestamp() * 1000), s))
p = record_profile(rec, every, sorted(ev), args.seed)
print("\nReal profile segments (hash rate estimated as expected hashes over wall time):\n")
print("| from s | to s | blocks/s at genesis difficulty | MH/s |")
print("|---|---|---|---|")
g = p.genesis_d
for a, b, r in p.segments:
print("| %d | %d | %.3f | %.1f |" % (a / 1000, b / 1000, r * T / (2 * g), r * 1000 / 1e6))
rows = run_profile(p, names, args.seed, args.ts_noise)
print_table("real: the devnet record replayed as a hash-rate profile", rows)
if __name__ == "__main__":
main()