From 5305d6610218b9e18b724c907d7c20f057d62901 Mon Sep 17 00:00:00 2001 From: randogoth Date: Sun, 25 Oct 2026 14:27:45 +0200 Subject: [PATCH] feat: add live statistics with per-trial snapshots and von Neumann option (FPGA v5) --- arduino/strix_capture/strix_capture.ino | 16 +++ devbox.json | 2 +- fpga/rtl/entropy_generator.v | 2 + fpga/rtl/stats_block.v | 148 ++++++++++++++++++++++++ fpga/rtl/strix_core.v | 70 +++++++++-- fpga/sim/tb_strix_core.v | 111 +++++++++++++++++- fpga/src/strix.qsf | 1 + tools/jitter.py | 94 ++++++++++++++- 8 files changed, 427 insertions(+), 17 deletions(-) create mode 100644 fpga/rtl/stats_block.v diff --git a/arduino/strix_capture/strix_capture.ino b/arduino/strix_capture/strix_capture.ino index e621c29..173b579 100644 --- a/arduino/strix_capture/strix_capture.ino +++ b/arduino/strix_capture/strix_capture.ino @@ -7,6 +7,7 @@ // w write a register, prints "OK" // cap start a capture with the current settings and send it as // "DATA \n" followed by n raw bytes +// stats print the latest statistics trial snapshot as hex #include #include @@ -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 "" or " " 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"); } diff --git a/devbox.json b/devbox.json index a9d3c5a..8309783 100644 --- a/devbox.json +++ b/devbox.json @@ -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": [ diff --git a/fpga/rtl/entropy_generator.v b/fpga/rtl/entropy_generator.v index d39bc33..43c3475 100644 --- a/fpga/rtl/entropy_generator.v +++ b/fpga/rtl/entropy_generator.v @@ -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; diff --git a/fpga/rtl/stats_block.v b/fpga/rtl/stats_block.v new file mode 100644 index 0000000..605d5a3 --- /dev/null +++ b/fpga/rtl/stats_block.v @@ -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 diff --git a/fpga/rtl/strix_core.v b/fpga/rtl/strix_core.v index 240e366..3cedc92 100644 --- a/fpga/rtl/strix_core.v +++ b/fpga/rtl/strix_core.v @@ -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}}; diff --git a/fpga/sim/tb_strix_core.v b/fpga/sim/tb_strix_core.v index 9f5240a..3d7691b 100644 --- a/fpga/sim/tb_strix_core.v +++ b/fpga/sim/tb_strix_core.v @@ -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; diff --git a/fpga/src/strix.qsf b/fpga/src/strix.qsf index aeba2b1..de0a037 100644 --- a/fpga/src/strix.qsf +++ b/fpga/src/strix.qsf @@ -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 diff --git a/tools/jitter.py b/tools/jitter.py index 99db88a..aeddeba 100644 --- a/tools/jitter.py +++ b/tools/jitter.py @@ -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)