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>
126 lines
7.4 KiB
Python
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()) + ".")
|