#!/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 by (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()) + ".")