igneum/sim/daa_trace.py
igneum-labs 7dc6e349c0 Ledger close round 1 (sims): M14 and F14, the 50x pulse against a lagging retarget inside finality_v2.py, both W2 forms; X1 status restored
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>
2026-10-05 19:19:30 +00:00

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()