sim/daa_trace.py: Kaspa's sampled DAA and the Igneum rule v2 inside a block-level block supply with bounded histories, the two W2 forms from the aggregate series. sim/finality_v2.py: scenario N, P.wform (hour, daa, median), Sim.supply. Result: weight share over hash share under 1.0 in every cell; Kaspa's DAA plus the DAA-second window hands the renter a third on day 12; rule v2 keeps it under 20%; the median-time form caps it at 17% under either controller. Also: the X12 status edit of the previous commit had landed on X1's identical line; X1 is back to Conceded, fix now and X12 carries it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
295 lines
13 KiB
Python
295 lines
13 KiB
Python
#!/usr/bin/env python3
|
|
"""Lagging-retarget block supply for finality_v2.py scenario N (ledger M14 and F14, O-3.14; 5 October 2026, night).
|
|
|
|
finality_v2.py draws Poisson(30) blocks per 30-s slot, a perfect retarget. This module puts a difficulty controller in
|
|
the loop: the controllers of sim/difficulty/sim.py (Kaspa's sampled DAA, `kaspa`; the Igneum rule v2 of 4 October
|
|
2026, `igneum-v2`) drive a lean block-level loop modelled on sim/difficulty/attacks/attacks.py `run` (two miners,
|
|
honest timestamps above the sampled past median, every solve drawn at the hash rate live at that moment, the finder
|
|
drawn by hash share). The per-height histories the controllers keep are bounded (`Tail`) so a 30-day chain fits in
|
|
memory; the lanes look back at most one 3,600-block epoch plus Kaspa's 661 x 4 samples.
|
|
|
|
Miners: the base (the always-on honest network, hash 1 in the unit where 1 block/s holds at the genesis difficulty)
|
|
and the renter (MULT x the base for BURST_S of every PERIOD_S, from hour SETTLE_H). Output per 30-s slot: blocks and
|
|
hashes of each. From those series the renter's weight share is computed under both W2 forms (spec 3.1, ledger F14):
|
|
daa blocks over the trailing 2,592,000 blocks (a DAA-second window is a block-count window)
|
|
median the renter's share of each 60-s wall-clock bucket summed over the trailing 30 days (past-median time
|
|
tracks the wall clock to within a few blocks here, since every stamp is honest), scaled by 60 so that a
|
|
steady share s weighs 2,592,000 s in both forms (an empty bucket weighs nothing for anyone)
|
|
|
|
python3 daa_trace.py --rules igneum-v2,kaspa --seeds 7,11,13 --days 30 # the traces and the share tables
|
|
"""
|
|
import argparse
|
|
import math
|
|
import os
|
|
import random
|
|
import sys
|
|
import time
|
|
from multiprocessing import Pool
|
|
|
|
import numpy as np
|
|
|
|
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "difficulty"))
|
|
import sim as S # noqa: E402
|
|
|
|
SLOT_MS = 30_000
|
|
WINDOW_BLOCKS = 86_400 * 30
|
|
PMT_SAMPLES, PMT_INTERVAL, PMT_CENTRE = 27, 10, 11
|
|
CACHE_DIR = os.environ.get("DAA_TRACE_CACHE", "/tmp/igneum-daa-trace")
|
|
|
|
|
|
class Tail:
|
|
"""A list with absolute indices that keeps only the newest `keep` entries."""
|
|
|
|
__slots__ = ("d", "base", "keep")
|
|
|
|
def __init__(self, init=(), keep=16_000):
|
|
self.d = list(init)
|
|
self.base = 0
|
|
self.keep = keep
|
|
|
|
def append(self, x):
|
|
self.d.append(x)
|
|
if len(self.d) > 2 * self.keep:
|
|
cut = len(self.d) - self.keep
|
|
del self.d[:cut]
|
|
self.base += cut
|
|
|
|
def __len__(self):
|
|
return self.base + len(self.d)
|
|
|
|
def __getitem__(self, i):
|
|
if isinstance(i, slice):
|
|
start, stop, step = i.indices(len(self))
|
|
return [self[j] for j in range(start, stop, step)]
|
|
if i < 0:
|
|
return self.d[i]
|
|
j = i - self.base
|
|
if j < 0:
|
|
raise IndexError("history trimmed at %d, asked %d" % (self.base, i))
|
|
return self.d[j]
|
|
|
|
|
|
def bounded(ctrl):
|
|
for name in ("ts", "tg", "tss", "sts", "pref_d", "pref_t", "pref_st", "lane"):
|
|
if hasattr(ctrl, name):
|
|
setattr(ctrl, name, Tail(getattr(ctrl, name)))
|
|
return ctrl
|
|
|
|
|
|
def past_median_time(ts, h):
|
|
"""27 samples at 10-block intervals ending at the parent, sorted, mean of the central 11 (as attacks.py, bounded)."""
|
|
samp = [ts[j] for j in range(h - 1, max(-1, h - 1 - PMT_INTERVAL * PMT_SAMPLES), -PMT_INTERVAL)]
|
|
samp.sort()
|
|
n = len(samp)
|
|
if n <= PMT_CENTRE:
|
|
return sum(samp) // n
|
|
lo = (n - PMT_CENTRE) // 2
|
|
return sum(samp[lo:lo + PMT_CENTRE]) // PMT_CENTRE
|
|
|
|
|
|
def pulse_trace(rule, seed, days, mult=50.0, burst_s=600, period_s=3600, settle_h=2, genesis_d=S.SCALE):
|
|
"""Mine settle_h hours plus `days` days. Returns dict(blocks=(2, nslots), hashes=(2, nslots), settle_slots, ...);
|
|
row 0 = base, row 1 = renter. Slots are 30 s of wall time from genesis."""
|
|
t_start = time.time()
|
|
rng = random.Random(seed)
|
|
ctrl = bounded(S.make_controller(rule, S.ONE / genesis_d))
|
|
ONE = S.ONE
|
|
base_h, rent_h = 1.0 * S.H1, mult * S.H1
|
|
s0, P, B = settle_h * 3600 * 1000, period_s * 1000, burst_s * 1000
|
|
dur = s0 + days * 86_400 * 1000
|
|
nslots = int(math.ceil(dur / SLOT_MS))
|
|
blocks = np.zeros((2, nslots))
|
|
hashes = np.zeros((2, nslots))
|
|
peak_min = {}
|
|
worst_gap = 0.0
|
|
|
|
def rent_on(tt):
|
|
return tt >= s0 and ((tt - s0) % P) < B
|
|
|
|
def next_change(tt):
|
|
if tt < s0:
|
|
return s0
|
|
k = (tt - s0) // P
|
|
off = (tt - s0) - k * P
|
|
return s0 + k * P + (B if off < B else P)
|
|
|
|
def credit(tt, dt, rh):
|
|
# hashes spent over [tt, tt + dt) at base_h and rh, split over the slots the interval covers; the last solve
|
|
# may run past the end of the trace, and that tail is dropped (the first draft clamped the slot instead and
|
|
# spun on the last slot forever)
|
|
end = min(tt + dt, nslots * SLOT_MS)
|
|
a = tt
|
|
while a < end:
|
|
s = int(a // SLOT_MS)
|
|
b = min(end, (s + 1) * SLOT_MS)
|
|
d = (b - a) / 1000.0
|
|
hashes[0, s] += base_h * d
|
|
hashes[1, s] += rh * d
|
|
a = b
|
|
|
|
t = 0.0
|
|
ctrl.push(0, ONE / genesis_d)
|
|
h = 1
|
|
while t < dur:
|
|
target = ctrl.next_target()
|
|
D = ONE / target
|
|
work = rng.expovariate(1.0) * D
|
|
tt = t
|
|
while True:
|
|
on = rent_on(tt)
|
|
rh = rent_h if on else 0.0
|
|
hr = base_h + rh
|
|
nxt = next_change(tt)
|
|
if work <= hr * (nxt - tt):
|
|
dt = work / hr
|
|
credit(tt, dt, rh)
|
|
tt += dt
|
|
break
|
|
dt = nxt - tt
|
|
credit(tt, dt, rh)
|
|
work -= hr * dt
|
|
tt = nxt
|
|
if tt > dur:
|
|
break
|
|
gap = max(tt - t, 1.0)
|
|
if t >= s0 and gap > worst_gap:
|
|
worst_gap = gap
|
|
t += gap
|
|
on = rent_on(t)
|
|
tot = base_h + (rent_h if on else 0.0)
|
|
finder = 1 if (on and rng.random() * tot > base_h) else 0
|
|
ts = max(int(t), past_median_time(ctrl.ts, h) + 1)
|
|
ctrl.push(ts, target)
|
|
blocks[finder, min(int(t // SLOT_MS), nslots - 1)] += 1
|
|
if t >= s0:
|
|
m = int(t // 60_000)
|
|
peak_min[m] = peak_min.get(m, 0) + 1
|
|
h += 1
|
|
settle_slots = s0 // SLOT_MS
|
|
return dict(rule=rule, seed=seed, days=days, mult=mult, burst_s=burst_s, period_s=period_s, settle_slots=settle_slots,
|
|
blocks=blocks, hashes=hashes, height=h - 1, peak_blocks_per_min=max(peak_min.values()) if peak_min else 0,
|
|
worst_gap_s=worst_gap / 1000.0, wall_s=time.time() - t_start)
|
|
|
|
|
|
def cached_trace(rule, seed, days, **kw):
|
|
os.makedirs(CACHE_DIR, exist_ok=True)
|
|
path = os.path.join(CACHE_DIR, "pulse-%s-s%d-d%d.npz" % (rule, seed, days))
|
|
if os.path.exists(path):
|
|
z = np.load(path, allow_pickle=True)
|
|
d = dict(z["meta"].item())
|
|
d["blocks"], d["hashes"] = z["blocks"], z["hashes"]
|
|
return d
|
|
d = pulse_trace(rule, seed, days, **kw)
|
|
meta = {k: v for k, v in d.items() if k not in ("blocks", "hashes")}
|
|
np.savez(path, blocks=d["blocks"], hashes=d["hashes"], meta=np.array(meta, dtype=object))
|
|
return d
|
|
|
|
|
|
# ---------------------------------------------------------------- the two W2 forms from the aggregate series
|
|
|
|
def share_daa(blocks, settle_slots):
|
|
"""Renter's share of the trailing WINDOW_BLOCKS blocks at the end of every slot after the settle (the window
|
|
before the pulse is filled with base blocks at 1 block/s, as a warm start)."""
|
|
base_pre = np.full(WINDOW_BLOCKS // (SLOT_MS // 1000), SLOT_MS / 1000.0) # 30 blocks a slot of history
|
|
b = np.concatenate([base_pre, blocks[0, settle_slots:]])
|
|
r = np.concatenate([np.zeros(base_pre.size), blocks[1, settle_slots:]])
|
|
tot = np.cumsum(b + r)
|
|
rc = np.cumsum(r)
|
|
# window start: the first slot index whose cumulative total exceeds tot[i] - WINDOW_BLOCKS
|
|
starts = np.searchsorted(tot, tot - WINDOW_BLOCKS, side="right")
|
|
rc0 = np.where(starts > 0, rc[np.maximum(starts - 1, 0)], 0.0)
|
|
tot0 = np.where(starts > 0, tot[np.maximum(starts - 1, 0)], 0.0)
|
|
share = (rc - rc0) / np.maximum(tot - tot0, 1.0)
|
|
return share[base_pre.size:]
|
|
|
|
|
|
def share_median(blocks, settle_slots):
|
|
"""Renter's share of each 60-s bucket (two slots) summed over the trailing 30 days (43,200 buckets), as a share of
|
|
the bucket count the window could hold; the pre-pulse window is full of base buckets."""
|
|
b = blocks[0, settle_slots:]
|
|
r = blocks[1, settle_slots:]
|
|
n = (b.size // 2) * 2
|
|
bb = b[:n].reshape(-1, 2).sum(axis=1)
|
|
rr = r[:n].reshape(-1, 2).sum(axis=1)
|
|
tot = bb + rr
|
|
rs = np.where(tot > 0, rr / np.maximum(tot, 1.0), 0.0)
|
|
bs = np.where(tot > 0, bb / np.maximum(tot, 1.0), 0.0)
|
|
W = 43_200
|
|
pre = np.ones(W)
|
|
rsum = np.cumsum(np.concatenate([np.zeros(W), rs]))
|
|
bsum = np.cumsum(np.concatenate([pre, bs]))
|
|
idx = np.arange(W, W + rs.size)
|
|
rw = rsum[idx] - rsum[idx - W]
|
|
bw = bsum[idx] - bsum[idx - W]
|
|
share = rw / np.maximum(rw + bw, 1e-9)
|
|
return np.repeat(share, 2)[:b.size] if b.size == n else np.concatenate([np.repeat(share, 2), [share[-1]]])
|
|
|
|
|
|
def daily(series, settle_slots_removed=True):
|
|
per_day = 86_400 * 1000 // SLOT_MS
|
|
n = series.size // per_day
|
|
return [float(series[(d + 1) * per_day - 1]) for d in range(n)]
|
|
|
|
|
|
def summarise(d):
|
|
s = d["settle_slots"]
|
|
b, r = d["blocks"][0, s:], d["blocks"][1, s:]
|
|
hb, hr = d["hashes"][0, s:], d["hashes"][1, s:]
|
|
per_day = 86_400 * 1000 // SLOT_MS
|
|
sd = share_daa(d["blocks"], s)
|
|
sm = share_median(d["blocks"], s)
|
|
out = dict(rule=d["rule"], seed=d["seed"], days=d["days"], blocks_base=float(b.sum()), blocks_renter=float(r.sum()),
|
|
hash_share=float(hr.sum() / (hr.sum() + hb.sum())), block_share=float(r.sum() / (r.sum() + b.sum())),
|
|
blocks_per_day=float((b.sum() + r.sum()) / d["days"]), peak_blocks_per_min=d["peak_blocks_per_min"], worst_gap_s=d["worst_gap_s"],
|
|
daily_daa=daily(sd), daily_median=daily(sm), wall_s=d.get("wall_s", float("nan")))
|
|
out["bph_ratio"] = (r.sum() / hr.sum()) / (b.sum() / hb.sum()) if hr.sum() > 0 else float("nan")
|
|
for name, ser in (("daa", out["daily_daa"]), ("median", out["daily_median"])):
|
|
cross = next((i + 1 for i, v in enumerate(ser) if v >= 1.0 / 3.0), None)
|
|
out["cross_" + name] = cross
|
|
out["peak_" + name] = max(ser)
|
|
out["end_" + name] = ser[-1]
|
|
# hash share over the trailing 30 days at the end (the pulse's hashes over everyone's)
|
|
out["hash_share_30d"] = float(hr[-30 * per_day:].sum() / (hr[-30 * per_day:].sum() + hb[-30 * per_day:].sum()))
|
|
return out
|
|
|
|
|
|
def job(args):
|
|
rule, seed, days = args
|
|
return summarise(cached_trace(rule, seed, days))
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--rules", default="igneum-v2,kaspa")
|
|
ap.add_argument("--seeds", default="7,11,13")
|
|
ap.add_argument("--days", type=int, default=30)
|
|
ap.add_argument("--jobs", type=int, default=4)
|
|
a = ap.parse_args()
|
|
rules = a.rules.split(",")
|
|
seeds = [int(x) for x in a.seeds.split(",")]
|
|
jobs = [(r, s, a.days) for r in rules for s in seeds]
|
|
t0 = time.time()
|
|
with Pool(min(a.jobs, len(jobs))) as pool:
|
|
res = pool.map(job, jobs)
|
|
print("# daa_trace: 50x pulse, %d s of every %d s, %d days, rules %s, seeds %s (%.0f s wall)" % (600, 3600, a.days, a.rules, a.seeds, time.time() - t0))
|
|
print()
|
|
print("| rule | seed | renter hash share (30 d) | renter block share | blocks per hash, renter over base | chain blocks per day | peak blocks per minute | worst gap (s) | DAA form: day it crosses 1/3 | peak | at day %d | median form: day | peak | at day %d | trace wall (s) |" % (a.days, a.days))
|
|
print("|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|")
|
|
for r in res:
|
|
print("| %s | %d | %.1f%% | %.1f%% | %.3f | %.0f | %d | %.0f | %s | %.1f%% | %.1f%% | %s | %.1f%% | %.1f%% | %.0f |" % (
|
|
r["rule"], r["seed"], 100 * r["hash_share_30d"], 100 * r["block_share"], r["bph_ratio"], r["blocks_per_day"], r["peak_blocks_per_min"], r["worst_gap_s"],
|
|
r["cross_daa"] or "never", 100 * r["peak_daa"], 100 * r["end_daa"], r["cross_median"] or "never", 100 * r["peak_median"], 100 * r["end_median"], r["wall_s"]))
|
|
print()
|
|
print("Renter weight share by day (DAA form / median form), seed %d:" % seeds[0])
|
|
print()
|
|
pick = [d for d in (1, 2, 3, 5, 7, 10, 12, 15, 20, 25, 30) if d <= a.days]
|
|
print("| rule | " + " | ".join("+%d" % d for d in pick) + " |")
|
|
print("|---|" + "---|" * len(pick))
|
|
for r in res:
|
|
if r["seed"] != seeds[0]:
|
|
continue
|
|
print("| %s | " % r["rule"] + " | ".join("%.1f / %.1f" % (100 * r["daily_daa"][d - 1], 100 * r["daily_median"][d - 1]) for d in pick) + " |")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|