igneum/sim/difficulty/record_report.py
igneum-labs def6ef137d Ledger close round 1 (sims): F20, M21, X14, E16 code half, P17, X12 run and logged
F20: tools/finality-attacks/f20.mjs, a 47.5% silent set through four fast-time epoch boundaries (0 locks in the pause, program on schedule, one seed per index, 12 pre-pause locks held, 0 conflicts).
M21: tools/finality-attacks/m21.mjs, 490 KB coinbase bodies on 100-ms proxied links, k re-derived with the fork's calculate_ghostdag_k (p99 812 ms, k 5).
X14 and E16: tools/finality-attacks/x14-concentration.mjs, read-only concentration from the observer node (signing not exposed by any RPC) and one live block's burn output beside its escrow payout.
P17: report only, igneum_getTransactionStatus per state, finalized resolves to the tip.
X12: sim/difficulty/record_report.py, the 3 October record's step profile, retarget trajectory, 2-minute buckets and epoch gap in the bench-log.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-05 19:15:59 +00:00

126 lines
7.4 KiB
Python

#!/usr/bin/env python3
"""Ledger X12 (5 October 2026, night): the 3 October devnet run's numbers from its record, for docs/bench-log.md.
python3 record_report.py [devnet-2026-10-03.csv] [/tmp/igneum-devnet/node1.log]
Prints, from the CSV (every header the observer saw, read-only, see README.md): the step profile (blocks per second
in 2-minute wall buckets with the DAA range of each), the retarget trajectory (expected hashes per block by DAA at
every change of `bits` on the selected chain, with the first retarget and the trough), and the epoch-boundary gap
(the wall time between the last block of epoch 0 and the first block of epoch 1). From the node log, when present:
accepted-block lines per 2-minute bucket over the same span, and the longest silence around the boundary.
"""
import csv
import datetime as dt
import re
import sys
from collections import Counter, defaultdict
CSV = sys.argv[1] if len(sys.argv) > 1 else "devnet-2026-10-03.csv"
LOG = sys.argv[2] if len(sys.argv) > 2 else "/tmp/igneum-devnet/node1.log"
def utc(ms):
return dt.datetime.utcfromtimestamp(ms / 1000).strftime("%H:%M:%S")
rows = list(csv.DictReader(open(CSV)))
for r in rows:
r["daa"] = int(r["daa_score"]); r["t"] = int(r["timestamp_ms"]); r["d"] = int(r["difficulty"]); r["chain"] = r["is_chain_block"] == "1"
r["epoch"] = int(r["epoch"]); r["bs"] = int(r["blue_score"])
rows.sort(key=lambda r: (r["t"], r["daa"]))
genesis = rows[0]
body = [r for r in rows if r["daa"] > 0 or r["bs"] > 0]
t0 = body[0]["t"]
print("# devnet-2026-10-03.csv: %d headers (genesis stamped %s UTC; first mined block %s, last %s UTC, DAA %d to %d, blue score to %d, %d chain blocks, %d epochs)" % (
len(rows), utc(genesis["t"]), utc(body[0]["t"]), utc(body[-1]["t"]), body[0]["daa"], body[-1]["daa"], body[-1]["bs"], sum(1 for r in body if r["chain"]), len(set(r["epoch"] for r in body))))
print()
# 2-minute buckets
B = 120_000
buckets = defaultdict(list)
for r in body:
buckets[(r["t"] - t0) // B].append(r)
print("## Block rate in 2-minute wall buckets (all headers, then chain blocks only)")
print()
print("| bucket (UTC) | blocks | blocks/s | chain blocks | DAA at end | difficulty at end (expected hashes) | epoch |")
print("|---|---|---|---|---|---|---|")
last_daa = 0
for k in range(0, max(buckets) + 1):
bs = buckets.get(k, [])
if not bs:
print("| %s to %s | 0 | 0.00 | 0 | %d | | |" % (utc(t0 + k * B), utc(t0 + (k + 1) * B), last_daa)); continue
last = max(bs, key=lambda r: r["daa"]); last_daa = last["daa"]
print("| %s to %s | %d | %.2f | %d | %d | %s | %d |" % (utc(t0 + k * B), utc(t0 + (k + 1) * B), len(bs), len(bs) / 120.0, sum(1 for r in bs if r["chain"]), last["daa"], format(last["d"], ","), last["epoch"]))
print()
# retarget trajectory on the selected chain
print("## Retarget trajectory (selected chain, every change of bits)")
print()
print("| DAA | UTC | bits | expected hashes per block | change |")
print("|---|---|---|---|---|")
chain = [r for r in body if r["chain"]]
chain.sort(key=lambda r: r["daa"])
prev = None
changes = 0
trough = min(chain, key=lambda r: r["d"])
for r in chain:
if prev is None or r["bits"] != prev["bits"]:
changes += 1
if changes <= 12 or r is trough or r["daa"] in (715, 812) or (prev and r["d"] > prev["d"] and changes % 25 == 0):
print("| %d | %s | %s | %s | %s |" % (r["daa"], utc(r["t"]), r["bits"], format(r["d"], ","), ("x%.2f easier" % (prev["d"] / r["d"]) if prev and r["d"] < prev["d"] else ("x%.2f harder" % (r["d"] / prev["d"]) if prev else "genesis"))))
prev = r
by_daa = {r["daa"]: r for r in chain}
first = next((r for r in chain if r["bits"] != genesis["bits"]), None)
print()
print("Retargets on the chain: %d changes of bits over %d chain blocks. First retarget: DAA %s at %s UTC, %s to %s expected hashes (x%.2f easier). Trough: DAA %d at %s UTC, %s expected hashes (x%.1f easier than genesis). At DAA 715: %s; at DAA 812: %s; at the last chain block (DAA %d): %s." % (
changes, len(chain), first["daa"] if first else "none", utc(first["t"]) if first else "", format(genesis["d"], ","), format(first["d"], ",") if first else "", genesis["d"] / first["d"] if first else 0,
trough["daa"], utc(trough["t"]), format(trough["d"], ","), genesis["d"] / trough["d"], format(by_daa[715]["d"], ",") if 715 in by_daa else "n/a", format(by_daa[812]["d"], ",") if 812 in by_daa else "n/a", chain[-1]["daa"], format(chain[-1]["d"], ",")))
print()
# epoch boundary gap
e0 = [r for r in body if r["epoch"] == 0]; e1 = [r for r in body if r["epoch"] == 1]
if e0 and e1:
a = max(e0, key=lambda r: r["t"]); b = min(e1, key=lambda r: r["t"])
print("Epoch boundary: last epoch-0 header at DAA %d, %s UTC; first epoch-1 header at DAA %d, %s UTC; gap %.1f s (header timestamps)." % (a["daa"], utc(a["t"]), b["daa"], utc(b["t"]), (b["t"] - a["t"]) / 1000.0))
# longest gaps
gaps = sorted(((body[i]["t"] - body[i - 1]["t"]) / 1000.0, body[i - 1]["daa"], utc(body[i - 1]["t"])) for i in range(1, len(body)))[-5:]
print("Longest header-timestamp gaps: " + "; ".join("%.1f s after DAA %d (%s UTC)" % g for g in reversed(gaps)) + ".")
# peak minute
mins = Counter((r["t"] - t0) // 60_000 for r in body)
pk, pn = max(mins.items(), key=lambda x: x[1])
print("Peak minute: %d headers in the minute from %s UTC. Headers above 2 blocks/s (per minute): %d." % (pn, utc(t0 + pk * 60_000), sum(n for n in mins.values() if n > 120)))
print()
# node log
try:
lines = open(LOG, errors="replace").read().splitlines()
except OSError:
print("Node log %s not present." % LOG); sys.exit(0)
# "PoW accepted <hash> by <engine> (daa N, epoch seed S, ...)": one line per block whose proof of work node 1 verified
acc = []
pat = re.compile(r"^(\d{4}-\d\d-\d\d \d\d:\d\d:\d\d\.\d+)([+-]\d\d:\d\d) \[INFO \] PoW accepted ([0-9a-f]+) by (\S+) \(daa (\d+), epoch seed ([0-9a-f]+)")
t_end = body[-1]["t"] + 120_000
for ln in lines:
m = pat.match(ln)
if m:
t = dt.datetime.fromisoformat(m.group(1) + m.group(2)).timestamp() * 1000
acc.append((t, int(m.group(5)), m.group(6), m.group(4)))
span = [a for a in acc if t0 <= a[0] <= t_end]
print("## Node log %s: %d lines, %d 'PoW accepted' lines in all (to %s UTC), %d inside the record's span to %s UTC" % (LOG, len(lines), len(acc), utc(max(a[0] for a in acc)) if acc else "", len(span), utc(t_end)))
print()
if span:
print("| bucket (UTC) | PoW accepted lines | per s | highest daa in the bucket |")
print("|---|---|---|---|")
cnt = Counter(int((a[0] - t0) // B) for a in span)
hi = defaultdict(int)
for a in span:
k = int((a[0] - t0) // B); hi[k] = max(hi[k], a[1])
for k in range(0, max(cnt) + 1):
print("| %s to %s | %d | %.2f | %d |" % (utc(t0 + k * B), utc(t0 + (k + 1) * B), cnt.get(k, 0), cnt.get(k, 0) / 120.0, hi.get(k, 0)))
span.sort()
g = sorted(((span[i][0] - span[i - 1][0]) / 1000.0, span[i - 1][1], utc(span[i - 1][0])) for i in range(1, len(span)))[-4:]
print()
print("Longest silences between PoW-accepted lines inside the span: " + "; ".join("%.1f s after daa %d (%s UTC)" % x for x in reversed(g)) + ".")
seeds = []
for a in span:
if not seeds or seeds[-1][1] != a[2]:
seeds.append((a[0], a[2], a[1]))
print("Epoch seeds in the span: " + "; ".join("%s... from %s UTC (daa %d)" % (s[1][:12], utc(s[0]), s[2]) for s in seeds) + ".")
engines = Counter(a[3] for a in span)
print("Engines: " + ", ".join("%s %d" % kv for kv in engines.items()) + ".")