feat: add live statistics with per-trial snapshots and von Neumann option (FPGA v5)

This commit is contained in:
randogoth 2026-10-25 14:27:45 +02:00
parent 67bd7eec58
commit 5305d66102
8 changed files with 427 additions and 17 deletions

View file

@ -7,6 +7,7 @@ Subcommands:
sweep capture sources at increasing decimation k and fit the accumulated jitter
output run the onod3000 test suite on every generator's processed output (256 KB each)
health show the health monitors (NIST repetition count / adaptive proportion, dead rings)
stats follow the live statistics trial by trial, with rolling and cumulative windows
pairs capture the same ring index in two generators simultaneously and test for correlation
Entropy per bit is estimated from conditional block entropies and converted to a
@ -43,6 +44,8 @@ REG_RING_SELECT, REG_FREQ_COUNT3 = 0x11, 0x12
REG_GEN_ENABLE, REG_TEMPORAL, REG_FREQ_GEN = 0x16, 0x17, 0x18
REG_HEALTHY, REG_HEALTH_CTRL, REG_RCT_CUTOFF, REG_APT_HIGH, REG_APT_LOW, REG_MAX_DEAD = 0x20, 0x21, 0x22, 0x23, 0x24, 0x25
REG_GEN_HEALTH = 0x30 # + 4g: flags, RCT events, APT events, dead rings
REG_TRIAL_HIGH, REG_TRIAL_LOW, REG_STATS_CTRL = 0x26, 0x27, 0x28
CHANNEL_NAMES = ["g0", "g1", "g2", "g3", "combined"]
CONTROL_START_FREQ = 0x04
STATUS_FREQ_VALID = 0x08
@ -76,8 +79,8 @@ class Board:
while self.serial.readline():
pass
self.serial.timeout = 200
if not identity.startswith("STRX v4"):
sys.exit(f"unexpected board identity: {identity!r} (needs the v4 FPGA image)")
if not identity.startswith("STRX v5"):
sys.exit(f"unexpected board identity: {identity!r} (needs the v5 FPGA image)")
self.generators = self.read(REG_GENERATORS)
self.rings = self.read(REG_RINGS)
print(f"board: {identity}, {self.generators} generators x {self.rings} rings")
@ -123,6 +126,17 @@ class Board:
return count * 256 / gate_seconds
def read_snapshot(board: "Board") -> dict | None:
"""Latest trial snapshot (layout in fpga/rtl/stats_block.v), or None if the read was torn."""
raw = bytes.fromhex(board.command("stats"))
word = lambda i: int.from_bytes(raw[i:i + 4], "big")
if word(0) != word(len(raw) - 4):
return None
channels = [(word(8 + 12 * c), word(12 + 12 * c), word(16 + 12 * c)) for c in range((len(raw) - 12) // 12)]
return {"sequence": word(0), "healthy": raw[4], "von_neumann": bool(raw[5] & 1),
"trial_ms": raw[6] << 8 | raw[7], "channels": channels}
def cached_capture(board: Board | None, path: Path, *args) -> bytes | None:
if board:
path.write_bytes(board.capture(*args))
@ -317,6 +331,75 @@ def run_health(board: Board | None, args) -> None:
f"{rct:>4} {apt_events:>4} {dead:>4}")
def z_scores(bits: int, ones: int, agreements: int) -> tuple[float, float]:
"""Bias z = (2*ones - n)/sqrt(n); lag-1 autocorrelation z = (2*agreements - n)/sqrt(n)."""
if bits == 0:
return float("nan"), float("nan")
root = math.sqrt(bits)
return (2 * ones - bits) / root, (2 * agreements - bits) / root
def run_stats(board: Board | None, args) -> None:
if board is None:
sys.exit("stats needs the board")
board.write(REG_TRIAL_HIGH, args.trial_ms >> 8)
board.write(REG_TRIAL_LOW, args.trial_ms & 0xFF)
board.write(REG_STATS_CTRL, (1 if args.von_neumann else 0) | 2) # bit 1 restarts trials
# The snapshot still holds the last trial from before the restart until the first new
# trial ends; wait for the sequence to start over.
while (snapshot := read_snapshot(board)) is None or snapshot["sequence"] > 1:
time.sleep(args.trial_ms / 4000)
count = len(CHANNEL_NAMES)
totals = np.zeros((count, 3), dtype=np.int64)
recent: list[np.ndarray] = []
trial_z = [[] for _ in range(count)]
trial_ac = [[] for _ in range(count)]
last_sequence, missed, torn = -1, 0, 0
print(f"trials of {args.trial_ms} ms, von Neumann {'on' if args.von_neumann else 'off'}, "
f"rolling window {args.rolling} trials")
print(f"{'trial':>5} {'healthy':>7} " + " ".join(f"{n + ' z':>11}" for n in CHANNEL_NAMES)
+ f" {'rolling':>8} {'walk':>9}")
while len(trial_z[0]) < args.trials:
snapshot = read_snapshot(board)
if snapshot is None:
torn += 1
continue
sequence = snapshot["sequence"]
if sequence == last_sequence:
time.sleep(args.trial_ms / 4000)
continue
if last_sequence >= 0 and sequence != last_sequence + 1:
missed += sequence - last_sequence - 1
last_sequence = sequence
counts = np.array(snapshot["channels"], dtype=np.int64)
totals += counts
recent = (recent + [counts])[-args.rolling:]
rolling = np.sum(recent, axis=0)
zs = []
for c in range(count):
z, ac = z_scores(*counts[c])
trial_z[c].append(z)
trial_ac[c].append(ac)
zs.append(z)
rolling_z, _ = z_scores(*rolling[count - 1])
walk = 2 * totals[count - 1][1] - totals[count - 1][0]
print(f"{sequence:>5} {snapshot['healthy']:>7x} " + " ".join(f"{z:>11.2f}" for z in zs)
+ f" {rolling_z:>8.2f} {walk:>9d}")
print(f"\n{len(trial_z[0])} trials, {missed} missed, {torn} torn reads (retried)")
print("baseline: trial z-scores should have mean about 0 and standard deviation about 1\n")
print(f"{'channel':<9} {'bits':>12} {'cum. z':>7} {'trial z mean':>12} {'sd':>5} {'|z|>3':>5}"
f" {'autocorr z mean':>15} {'sd':>5}")
for c in range(count):
z_total, _ = z_scores(*totals[c])
z, ac = np.array(trial_z[c]), np.array(trial_ac[c])
print(f"{CHANNEL_NAMES[c]:<9} {totals[c][0]:>12d} {z_total:>7.2f} {z.mean():>12.3f} {z.std():>5.2f}"
f" {int(np.sum(np.abs(z) > 3)):>5d} {ac.mean():>15.3f} {ac.std():>5.2f}")
def run_pairs(board: Board | None, args) -> None:
generators = board.generators if board else 4
rng = np.random.default_rng(0)
@ -366,6 +449,11 @@ def main() -> None:
output.add_argument("--bursts", type=int, default=8, help="32 KB captures per generator")
output.add_argument("--onod", type=Path, default=Path(".build/onod3000/release/onod3000"))
output.add_argument("--verbose", action="store_true", help="print the full onod3000 report")
stats = sub.add_parser("stats")
stats.add_argument("--trials", type=int, default=100)
stats.add_argument("--trial-ms", type=int, default=200)
stats.add_argument("--rolling", type=int, default=10, help="trials in the rolling window")
stats.add_argument("--von-neumann", action="store_true")
health = sub.add_parser("health")
health.add_argument("--clear", action="store_true", help="clear failures and event counters first")
pairs = sub.add_parser("pairs")
@ -376,7 +464,7 @@ def main() -> None:
args.out.mkdir(parents=True, exist_ok=True)
board = None if args.analyze_only else Board(args.port)
handlers = {"freq": run_freq, "rings": run_rings, "sweep": run_sweep,
"output": run_output, "health": run_health, "pairs": run_pairs}
"output": run_output, "health": run_health, "stats": run_stats, "pairs": run_pairs}
handlers[args.command](board, args)