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 @@
// w <register> <value> write a register, prints "OK"
// cap start a capture with the current settings and send it as
// "DATA <n>\n" followed by n raw bytes
// stats print the latest statistics trial snapshot as hex
#include <SPI.h>
#include <VidorBoot.h>
@ -23,6 +24,7 @@ enum : uint8_t {
REG_CONTROL = 0x0B,
REG_STATUS = 0x0C,
REG_DATA = 0x10,
REG_STATS = 0x40,
};
const uint8_t READ = 0x80;
@ -30,6 +32,7 @@ const uint8_t CONTROL_START_CAPTURE = 0x01;
const uint8_t STATUS_CAPTURE_DONE = 0x02;
const size_t CAPTURE_BYTES = 32768;
const size_t CHUNK_BYTES = 512;
const size_t SNAPSHOT_BYTES = 72;
VidorUtils fpga;
@ -89,6 +92,17 @@ void capture() {
}
}
void printSnapshot() {
beginTransaction(READ | REG_STATS);
for (size_t i = 0; i < SNAPSHOT_BYTES; i++) {
uint8_t value = strixSpi.transfer(0);
if (value < 0x10) Serial.print('0');
Serial.print(value, HEX);
}
endTransaction();
Serial.println();
}
// Parses "<a>" or "<a> <b>" after the command word; returns the number of values found.
int parseArgs(const String &args, long &a, long &b) {
int space = args.indexOf(' ');
@ -116,6 +130,8 @@ void handleCommand(String line) {
Serial.println("OK");
} else if (command == "cap") {
capture();
} else if (command == "stats") {
printSnapshot();
} else if (line.length() > 0) {
Serial.println("ERR unknown or malformed command");
}

View file

@ -25,7 +25,7 @@
],
"sim": [
"mkdir -p /tmp/strix-sim",
"iverilog -g2005 -DSIM -o /tmp/strix-sim/tb_strix_core fpga/sim/tb_strix_core.v fpga/rtl/strix_core.v fpga/rtl/entropy_generator.v fpga/rtl/health_monitor.v fpga/rtl/freq_counter.v fpga/rtl/ring_osc.v fpga/rtl/raw_capture.v fpga/rtl/spi_slave.v",
"iverilog -g2005 -DSIM -o /tmp/strix-sim/tb_strix_core fpga/sim/tb_strix_core.v fpga/rtl/strix_core.v fpga/rtl/entropy_generator.v fpga/rtl/health_monitor.v fpga/rtl/stats_block.v fpga/rtl/freq_counter.v fpga/rtl/ring_osc.v fpga/rtl/raw_capture.v fpga/rtl/spi_slave.v",
"vvp -n /tmp/strix-sim/tb_strix_core"
],
"fpga": [

View file

@ -15,6 +15,7 @@ module entropy_generator #(
input wire [3:0] temporal, // consecutive bits XORed per output bit, 1-8
input wire [7:0] ring_select, // ring exposed on the characterization outputs
output reg out_bit, // processed output, held for `temporal` clocks
output reg out_valid, // pulse: out_bit has just been updated
output wire spatial_bit, // XOR of all rings, one per clock
output wire [RINGS-1:0] ring_samples, // synchronized enhanced outputs, for health tests
output reg ring_enhanced,
@ -72,6 +73,7 @@ module entropy_generator #(
wire last = (phase >= temporal - 4'd1) || (temporal == 4'd0);
always @(posedge clk) begin
out_valid <= last;
if (last) begin
out_bit <= accumulator ^ spatial_bit;
accumulator <= 1'b0;

148
fpga/rtl/stats_block.v Normal file
View file

@ -0,0 +1,148 @@
// Live statistics: back-to-back trials of `trial_ms` milliseconds. During each trial every
// channel counts its bits, ones, and agreements (a bit equal to the bit before it, which may
// be the last bit of the previous trial, so a trial has as many comparisons as bits). At the
// end of a trial the counts are latched into a snapshot and the counters restart in the same
// cycle, so no bit is lost between trials. Bias, z-scores, random walk and lag-1
// autocorrelation all follow from the three counts; rolling and cumulative windows are built
// from consecutive snapshots outside the FPGA.
//
// With von Neumann enabled, each channel's bits pass through a von Neumann extractor (pairs
// 01 -> 0, 10 -> 1, 00 and 11 dropped) before counting.
//
// Snapshot layout (big-endian), read one byte at a time by index:
// 0-3 trial sequence number
// 4 healthy generators during the whole trial (AND over the trial)
// 5 bit 0: von Neumann was enabled for the whole trial
// 6-7 trial length in ms
// 8+12c channel c: bits (4), ones (4), agreements (4)
// 8+12C trial sequence number again; a mismatch with bytes 0-3 means a torn read
module stats_block #(
parameter CHANNELS = 5,
parameter TICK_CYCLES = 120000 // clk cycles per millisecond
) (
input wire clk,
input wire reset,
input wire [CHANNELS-1:0] bits,
input wire valid, // new bits on all channels this cycle
input wire von_neumann,
input wire [15:0] trial_ms,
input wire restart, // start a new trial now, sequence back to 0
input wire [3:0] healthy,
input wire [7:0] read_index,
output reg [7:0] read_byte, // one cycle after read_index
output reg trial_end // pulse: a new snapshot is available
);
localparam SNAPSHOT_BYTES = 12 + 12 * CHANNELS;
// Trial timing
reg [16:0] tick_count;
reg [15:0] ms_count;
wire tick = (tick_count == TICK_CYCLES - 1);
wire trial_due = tick && (ms_count + 16'd1 >= trial_ms);
always @(posedge clk) begin
trial_end <= 1'b0;
if (reset || restart) begin
tick_count <= 17'd0;
ms_count <= 16'd0;
end else begin
tick_count <= tick ? 17'd0 : tick_count + 17'd1;
if (trial_due) begin
ms_count <= 16'd0;
trial_end <= 1'b1;
end else if (tick) begin
ms_count <= ms_count + 16'd1;
end
end
end
// Von Neumann extraction per channel
reg [CHANNELS-1:0] pair_first, have_first;
wire [CHANNELS-1:0] vn_valid = have_first & (bits ^ pair_first);
wire [CHANNELS-1:0] count_valid = von_neumann ? (valid ? vn_valid : {CHANNELS{1'b0}})
: {CHANNELS{valid}};
wire [CHANNELS-1:0] count_bit = von_neumann ? pair_first : bits;
always @(posedge clk) begin
if (reset || !von_neumann) begin
have_first <= {CHANNELS{1'b0}};
end else if (valid) begin
have_first <= ~have_first;
pair_first <= bits;
end
end
// Counters
reg [31:0] bit_count [0:CHANNELS-1];
reg [31:0] one_count [0:CHANNELS-1];
reg [31:0] agree_count [0:CHANNELS-1];
reg [CHANNELS-1:0] previous_bit, has_previous;
reg [31:0] sequence_number;
reg [3:0] healthy_during;
reg von_neumann_during;
// The snapshot is latched as one flat byte string (byte 0 in the top bits), so reading it
// is a plain byte multiplexer.
reg [8*SNAPSHOT_BYTES-1:0] snapshot, snapshot_next;
integer k;
always @(*) begin
snapshot_next = {SNAPSHOT_BYTES{8'h00}};
snapshot_next[8*SNAPSHOT_BYTES-1 -: 64] = {sequence_number, 4'd0, healthy_during & healthy,
7'd0, von_neumann_during & von_neumann, trial_ms};
for (k = 0; k < CHANNELS; k = k + 1)
snapshot_next[8*SNAPSHOT_BYTES-1-64-96*k -: 96] = {bit_count[k], one_count[k], agree_count[k]};
snapshot_next[31:0] = sequence_number;
end
integer c;
always @(posedge clk) begin
if (reset || restart) begin
sequence_number <= 32'd0;
healthy_during <= 4'hF;
von_neumann_during <= von_neumann;
for (c = 0; c < CHANNELS; c = c + 1) begin
bit_count[c] <= 32'd0;
one_count[c] <= 32'd0;
agree_count[c] <= 32'd0;
end
has_previous <= {CHANNELS{1'b0}};
end else begin
for (c = 0; c < CHANNELS; c = c + 1) begin
// The bit arriving in the trial's last cycle belongs to the next trial.
if (count_valid[c]) begin
bit_count[c] <= (trial_end ? 32'd0 : bit_count[c]) + 32'd1;
one_count[c] <= (trial_end ? 32'd0 : one_count[c]) + count_bit[c];
agree_count[c] <= (trial_end ? 32'd0 : agree_count[c])
+ (has_previous[c] && count_bit[c] == previous_bit[c]);
previous_bit[c] <= count_bit[c];
has_previous[c] <= 1'b1;
end else if (trial_end) begin
bit_count[c] <= 32'd0;
one_count[c] <= 32'd0;
agree_count[c] <= 32'd0;
end
end
if (trial_end) begin
snapshot <= snapshot_next;
sequence_number <= sequence_number + 32'd1;
healthy_during <= healthy;
von_neumann_during <= von_neumann;
end else begin
healthy_during <= healthy_during & healthy;
von_neumann_during <= von_neumann_during & von_neumann;
end
end
end
// Snapshot read-out, registered: the byte for read_index appears one cycle later.
always @(posedge clk)
read_byte <= (read_index < SNAPSHOT_BYTES) ? snapshot[8*(SNAPSHOT_BYTES-1-read_index) +: 8] : 8'h00;
endmodule

View file

@ -1,5 +1,5 @@
// Board-independent STRIX logic: entropy generators with health monitors, source selection,
// raw capture, frequency counter and the SPI register interface to the SAMD21.
// Board-independent STRIX logic: entropy generators with health monitors, live statistics,
// source selection, raw capture, frequency counter and the SPI register interface to the SAMD21.
//
// SPI protocol (mode 0, one transaction per ss_n low period): the first byte is a command,
// bit 7 = read (1) or write (0), bits 6:0 = register address. A write stores the following
@ -15,11 +15,16 @@
// Health: a generator is healthy when it is enabled, its monitor has completed a dead-ring
// sweep, neither NIST test has failed since the last clear, and at most MAX_DEAD_RINGS rings
// are dead. Per-generator registers at 0x30 + 4g: FLAGS, RCT_EVENTS, APT_EVENTS, DEAD_RINGS.
//
// Statistics channels 0-3 are the generators' processed outputs, channel 4 is the XOR of the
// healthy generators' outputs. Reading STATS returns the latest trial snapshot (layout in
// stats_block.v) byte by byte, starting at byte 0 in every transaction.
module strix_core #(
parameter CAPTURE_ADDR_WIDTH = 15,
parameter FREQ_GATE_BITS = 24,
parameter RINGS_PER_GEN = 90,
parameter DEAD_WINDOW_BITS = 16
parameter DEAD_WINDOW_BITS = 16,
parameter TICK_CYCLES = 120000 // clk cycles per millisecond
) (
input wire clk,
input wire reset,
@ -30,7 +35,7 @@ module strix_core #(
output wire capture_done
);
localparam [7:0] VERSION = 8'h04;
localparam [7:0] VERSION = 8'h05;
localparam GENERATORS = 4;
localparam [6:0] REG_ID0 = 7'h00, // "STRX"
@ -64,7 +69,12 @@ module strix_core #(
REG_RCT_CUTOFF = 7'h22, // repetition count test cutoff (run length)
REG_APT_HIGH = 7'h23, // adaptive proportion test cutoff (per 1024 bits)
REG_APT_LOW = 7'h24,
REG_MAX_DEAD = 7'h25; // dead rings tolerated per generator
REG_MAX_DEAD = 7'h25, // dead rings tolerated per generator
REG_TRIAL_HIGH = 7'h26, // trial length in ms
REG_TRIAL_LOW = 7'h27,
REG_STATS_CTRL = 7'h28, // bit 0 von Neumann on the statistics path;
// write bit 1: restart trials, sequence 0
REG_STATS = 7'h40; // burst read of the trial snapshot
// Per-generator health registers at 0x30 + 4g + index
localparam [1:0] GEN_FLAGS = 2'd0, // bit 0 RCT failed, 1 APT failed, 2 too many dead rings,
@ -78,7 +88,7 @@ module strix_core #(
reg [GENERATORS-1:0] gen_enable;
reg [3:0] temporal;
reg [7:0] ring_select;
wire [GENERATORS-1:0] out_bit, spatial_bit, ring_enhanced, ring_tap, ring_raw_tap;
wire [GENERATORS-1:0] out_bit, out_valid, spatial_bit, ring_enhanced, ring_tap, ring_raw_tap;
reg health_clear;
reg [7:0] rct_cutoff, max_dead;
@ -96,7 +106,7 @@ module strix_core #(
entropy_generator #(.RINGS(RINGS_PER_GEN)) generator (
.clk(clk), .enable(gen_enable[g]), .temporal(temporal), .ring_select(ring_select),
.out_bit(out_bit[g]), .spatial_bit(spatial_bit[g]),
.out_bit(out_bit[g]), .out_valid(out_valid[g]), .spatial_bit(spatial_bit[g]),
.ring_enhanced(ring_enhanced[g]), .ring_tap(ring_tap[g]),
.ring_raw_tap(ring_raw_tap[g]), .ring_samples(ring_samples)
);
@ -116,6 +126,33 @@ module strix_core #(
end
endgenerate
// Statistics. All generators share the temporal setting and run in lockstep, so
// generator 0's valid strobe times every channel.
reg [15:0] trial_ms;
reg stats_von_neumann, stats_restart;
reg [7:0] stats_index;
wire [7:0] stats_byte;
wire trial_end;
// Registered inputs keep the health logic and the combining XOR off the counter paths;
// every channel is delayed by the same cycle, so they stay aligned.
reg [GENERATORS:0] stats_bits;
reg stats_valid;
reg [GENERATORS-1:0] stats_healthy;
always @(posedge clk) begin
stats_healthy <= healthy;
stats_bits <= {^(out_bit & healthy), out_bit};
stats_valid <= out_valid[0];
end
stats_block #(.CHANNELS(GENERATORS + 1), .TICK_CYCLES(TICK_CYCLES)) stats (
.clk(clk), .reset(reset), .bits(stats_bits), .valid(stats_valid),
.von_neumann(stats_von_neumann), .trial_ms(trial_ms), .restart(stats_restart),
.healthy(stats_healthy), .read_index(stats_index), .read_byte(stats_byte),
.trial_end(trial_end)
);
// Source selection
reg [3:0] source_a, source_b;
@ -224,6 +261,10 @@ module strix_core #(
REG_APT_HIGH: tx_byte = {5'd0, apt_cutoff[10:8]};
REG_APT_LOW: tx_byte = apt_cutoff[7:0];
REG_MAX_DEAD: tx_byte = max_dead;
REG_TRIAL_HIGH: tx_byte = trial_ms[15:8];
REG_TRIAL_LOW: tx_byte = trial_ms[7:0];
REG_STATS_CTRL: tx_byte = {7'd0, stats_von_neumann};
REG_STATS: tx_byte = stats_byte;
default: tx_byte = gen_register(reg_addr);
endcase
end
@ -232,6 +273,7 @@ module strix_core #(
capture_start <= 1'b0;
freq_start <= 1'b0;
health_clear <= 1'b0;
stats_restart <= 1'b0;
if (reset) begin
gen_enable <= {GENERATORS{1'b1}};
temporal <= 4'd5;
@ -245,11 +287,19 @@ module strix_core #(
apt_cutoff <= 11'd824; // same, 1024-bit window
max_dead <= 8'd8; // measured sizing tolerates about 17
health_clear <= 1'b1;
trial_ms <= 16'd200;
stats_von_neumann <= 1'b0;
stats_index <= 8'd0;
is_read <= 1'b0;
reg_addr <= 7'd0;
bytes_read <= {CAPTURE_ADDR_WIDTH{1'b0}};
prefetch_addr <= {CAPTURE_ADDR_WIDTH{1'b0}};
end else begin
if (!selected)
stats_index <= 8'd0;
else if (tx_loaded && is_read && reg_addr == REG_STATS)
stats_index <= stats_index + 8'd1;
if (!selected)
prefetch_addr <= bytes_read;
else if (tx_loaded && reading_data)
@ -277,6 +327,12 @@ module strix_core #(
REG_APT_HIGH: apt_cutoff[10:8] <= rx_byte[2:0];
REG_APT_LOW: apt_cutoff[7:0] <= rx_byte;
REG_MAX_DEAD: max_dead <= rx_byte;
REG_TRIAL_HIGH: trial_ms[15:8] <= rx_byte;
REG_TRIAL_LOW: trial_ms[7:0] <= rx_byte;
REG_STATS_CTRL: begin
stats_von_neumann <= rx_byte[0];
stats_restart <= rx_byte[1];
end
REG_CONTROL: begin
if (rx_byte[0]) capture_start <= 1'b1;
if (rx_byte[0] || rx_byte[1]) bytes_read <= {CAPTURE_ADDR_WIDTH{1'b0}};

View file

@ -1,6 +1,7 @@
// Testbench for strix_core: register access, generator XOR logic checked against the sampled
// ring outputs, captures read back across transactions, generator enable, the frequency
// counter, and the health monitors with injected faults. Rings are replaced by the SIM model in ring_osc.v.
// counter, the health monitors with injected faults, and the statistics snapshots against a
// reference model. Rings are replaced by the SIM model in ring_osc.v.
`timescale 1ns / 1ps
module tb_strix_core;
@ -15,7 +16,11 @@ module tb_strix_core;
REG_DECIM_LOW = 7'h0A, REG_CONTROL = 7'h0B, REG_STATUS = 7'h0C,
REG_DATA = 7'h10, REG_RING_SELECT = 7'h11, REG_FREQ_COUNT0 = 7'h15,
REG_GEN_ENABLE = 7'h16, REG_TEMPORAL = 7'h17, REG_FREQ_GEN = 7'h18,
REG_HEALTHY = 7'h20, REG_HEALTH_CTRL = 7'h21;
REG_HEALTHY = 7'h20, REG_HEALTH_CTRL = 7'h21, REG_TRIAL_LOW = 7'h27,
REG_STATS_CTRL = 7'h28, REG_STATS = 7'h40;
localparam SNAPSHOT_BYTES = 72;
localparam TICK_CYCLES = 1500; // 1 "ms" = 12.5 us: a 10 "ms" trial outlasts a 72-byte read
localparam TRIAL_BITS = 10 * TICK_CYCLES / 5; // 10 "ms" trials at temporal 5
// Per-generator health registers
function [6:0] gen_reg(input integer generator, input integer index);
@ -31,7 +36,7 @@ module tb_strix_core;
wire miso, done;
strix_core #(.CAPTURE_ADDR_WIDTH(CAPTURE_ADDR_WIDTH), .FREQ_GATE_BITS(14), .RINGS_PER_GEN(RINGS),
.DEAD_WINDOW_BITS(6)) dut (
.DEAD_WINDOW_BITS(6), .TICK_CYCLES(TICK_CYCLES)) dut (
.clk(clk), .reset(reset),
.spi_sck(sck), .spi_mosi(mosi), .spi_ss_n(ss_n), .spi_miso(miso),
.capture_done(done)
@ -80,6 +85,40 @@ module tb_strix_core;
end
end
// Reference model of statistics channel 0, mirroring stats_block without von Neumann:
// counts restart at each trial end, and the bit arriving in that cycle opens the new trial.
reg [31:0] model_bits = 0, model_ones = 0, model_agree = 0;
reg [31:0] model_latched_bits, model_latched_ones, model_latched_agree;
reg model_previous, model_has_previous = 1'b0;
always @(posedge clk) begin
if (dut.stats_restart) begin
model_bits <= 0; model_ones <= 0; model_agree <= 0; model_has_previous <= 1'b0;
end else begin
if (dut.trial_end) begin
model_latched_bits <= model_bits;
model_latched_ones <= model_ones;
model_latched_agree <= model_agree;
end
if (dut.stats.valid) begin
model_bits <= (dut.trial_end ? 0 : model_bits) + 1;
model_ones <= (dut.trial_end ? 0 : model_ones) + dut.stats.bits[0];
model_agree <= (dut.trial_end ? 0 : model_agree)
+ (model_has_previous && dut.stats.bits[0] == model_previous);
model_previous <= dut.stats.bits[0];
model_has_previous <= 1'b1;
end else if (dut.trial_end) begin
model_bits <= 0; model_ones <= 0; model_agree <= 0;
end
end
end
reg [7:0] snapshot [0:SNAPSHOT_BYTES-1];
function [31:0] snapshot_word(input integer index);
snapshot_word = {snapshot[index], snapshot[index+1], snapshot[index+2], snapshot[index+3]};
endfunction
task spi_byte(input [7:0] out, output [7:0] in);
integer i;
begin
@ -169,8 +208,27 @@ module tb_strix_core;
end
endtask
reg [7:0] v;
integer changes, polls;
task read_snapshot;
reg [7:0] dummy;
integer i;
begin
ss_n = 1'b0; #SCK_HALF;
spi_byte({1'b1, REG_STATS}, dummy);
for (i = 0; i < SNAPSHOT_BYTES; i = i + 1) spi_byte(8'h00, snapshot[i]);
#SCK_HALF ss_n = 1'b1; #(4 * SCK_HALF);
end
endtask
task wait_trial_end;
begin
@(posedge dut.trial_end);
#200;
end
endtask
reg [7:0] v;
integer changes, polls;
reg [31:0] sequence_seen;
initial begin
#100 reset = 1'b0;
@ -179,7 +237,7 @@ module tb_strix_core;
// Identification
read_reg(REG_ID0, v); expect(v, "S", "ID0");
read_reg(REG_ID3, v); expect(v, "X", "ID3");
read_reg(REG_VERSION, v); expect(v, 8'h04, "VERSION");
read_reg(REG_VERSION, v); expect(v, 8'h05, "VERSION");
read_reg(REG_GENERATORS, v); expect(v, 8'd4, "GENERATORS");
read_reg(REG_RINGS, v); expect(v, RINGS, "RINGS");
read_reg(REG_TEMPORAL, v); expect(v, 8'd5, "TEMPORAL");
@ -259,6 +317,47 @@ module tb_strix_core;
read_reg(gen_reg(3, FLAGS), v); expect(v, 8'h00, "FLAGS g3 disabled");
write_reg(REG_GEN_ENABLE, 8'h0F);
// Statistics: 10 "ms" trials, temporal 5 -> TRIAL_BITS bits per channel per trial
write_reg(REG_TEMPORAL, 8'd5);
write_reg(REG_TRIAL_LOW, 8'd10);
write_reg(REG_STATS_CTRL, 8'h02); // restart
wait_trial_end;
wait_trial_end;
read_snapshot;
if (snapshot_word(0) !== snapshot_word(SNAPSHOT_BYTES - 4)) begin
$display("FAIL torn snapshot"); errors = errors + 1;
end
expect(snapshot_word(0), 8'd1, "trial sequence");
expect(snapshot[4], 8'h0F, "trial healthy");
expect(snapshot[7], 8'd10, "trial ms");
if (snapshot_word(8) !== model_latched_bits || snapshot_word(12) !== model_latched_ones
|| snapshot_word(16) !== model_latched_agree) begin
$display("FAIL channel 0: %0d/%0d/%0d, model %0d/%0d/%0d", snapshot_word(8), snapshot_word(12),
snapshot_word(16), model_latched_bits, model_latched_ones, model_latched_agree);
errors = errors + 1;
end
if (snapshot_word(8) < TRIAL_BITS - 1 || snapshot_word(8) > TRIAL_BITS + 1) begin
$display("FAIL channel 0 bit count %0d, expected about %0d", snapshot_word(8), TRIAL_BITS);
errors = errors + 1;
end
if (snapshot_word(8 + 48) !== snapshot_word(8)) begin
$display("FAIL combined channel bit count differs"); errors = errors + 1;
end
if (snapshot_word(12) == 0 || snapshot_word(12) == snapshot_word(8)) begin
$display("FAIL channel 0 ones count %0d is degenerate", snapshot_word(12)); errors = errors + 1;
end
// Von Neumann keeps about a quarter of the bits
write_reg(REG_STATS_CTRL, 8'h03);
wait_trial_end;
wait_trial_end;
read_snapshot;
expect(snapshot[5], 8'h01, "trial von Neumann");
if (snapshot_word(8) < TRIAL_BITS / 10 || snapshot_word(8) > TRIAL_BITS * 45 / 100) begin
$display("FAIL von Neumann bit count %0d", snapshot_word(8)); errors = errors + 1;
end
write_reg(REG_STATS_CTRL, 8'h00);
if (xor_checks < 100) begin
$display("FAIL only %0d temporal XOR checks ran", xor_checks);
errors = errors + 1;

View file

@ -62,6 +62,7 @@ set_global_assignment -name VERILOG_FILE ../rtl/sys_pll.v
set_global_assignment -name VERILOG_FILE ../rtl/strix_core.v
set_global_assignment -name VERILOG_FILE ../rtl/entropy_generator.v
set_global_assignment -name VERILOG_FILE ../rtl/health_monitor.v
set_global_assignment -name VERILOG_FILE ../rtl/stats_block.v
set_global_assignment -name VERILOG_FILE ../rtl/freq_counter.v
set_global_assignment -name VERILOG_FILE ../rtl/ring_osc.v
set_global_assignment -name VERILOG_FILE ../rtl/raw_capture.v

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)