#!/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()