From 6bee626b7f023e6960de7c98e02bbdc0af918e4a Mon Sep 17 00:00:00 2001 From: randogoth Date: Sun, 25 Oct 2026 18:23:18 +0200 Subject: [PATCH] feat: add per-second GCP-format trials with history, watch and HTTP access (FPGA v10) --- README.md | 1 + .../libraries/StrixFPGA/src/StrixRegisters.h | 6 +- arduino/strix/API.md | 26 ++- arduino/strix/strix.ino | 184 ++++++++++++++++-- devbox.json | 2 +- fpga/rtl/gcp_block.v | 131 +++++++++++++ fpga/rtl/strix_core.v | 30 ++- fpga/sim/tb_strix_core.v | 89 ++++++++- fpga/src/strix.qsf | 1 + tools/api_check.py | 19 ++ tools/ble_check.py | 11 +- tools/jitter.py | 4 +- 12 files changed, 475 insertions(+), 29 deletions(-) create mode 100644 fpga/rtl/gcp_block.v diff --git a/README.md b/README.md index dc94d62..36a3cfa 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ Entropy comes from FPGA ring oscillators whose sampled timing jitter is XOR-comb - Live statistics per trial: bias, z-score, random walk and lag-1 autocorrelation, with rolling and cumulative windows - Configurable random-walk / majority-vote bias amplifier on a bias path and an autocorrelation path, with sub-trial voting - Von Neumann extractor that can be toggled on the stream, statistics and amplifier paths +- Per-second trials in Global Consciousness Project format (200-bit XOR-masked sums per generator, plus full-rate sums), with Stouffer Z and network variance - Raw ring captures and frequency measurement for characterization The USB, HTTP and BLE API is documented in [arduino/strix/API.md](arduino/strix/API.md). diff --git a/arduino/libraries/StrixFPGA/src/StrixRegisters.h b/arduino/libraries/StrixFPGA/src/StrixRegisters.h index 3a795bb..5690a84 100644 --- a/arduino/libraries/StrixFPGA/src/StrixRegisters.h +++ b/arduino/libraries/StrixFPGA/src/StrixRegisters.h @@ -1,4 +1,4 @@ -// STRIX FPGA register map (FPGA version 9). Mirrors the definitions in fpga/rtl/strix_core.v, +// STRIX FPGA register map (FPGA version 10). Mirrors the definitions in fpga/rtl/strix_core.v, // which also documents the SPI protocol and snapshot layouts. #pragma once @@ -6,7 +6,7 @@ namespace strix { -const uint8_t FPGA_VERSION = 9; +const uint8_t FPGA_VERSION = 10; const uint8_t READ = 0x80; // command bit 7: read enum Register : uint8_t { @@ -47,6 +47,7 @@ enum Register : uint8_t { REG_STATS = 0x40, REG_AMP = 0x42, REG_STREAM = 0x44, + REG_GCP = 0x46, REG_AMP_CTRL = 0x50, REG_AMP_SOURCES = 0x51, REG_AMP_SUB_MS = 0x52, @@ -71,6 +72,7 @@ const uint8_t GENERATORS = 4; const uint8_t STATS_CHANNELS = GENERATORS + 1; // generators, then the combined channel const uint8_t STATS_SNAPSHOT_BYTES = 72; const uint8_t AMP_SNAPSHOT_BYTES = 44; +const uint8_t GCP_SNAPSHOT_BYTES = 36; const size_t CAPTURE_BYTES = 32768; } // namespace strix diff --git a/arduino/strix/API.md b/arduino/strix/API.md index 99a610d..224eee6 100644 --- a/arduino/strix/API.md +++ b/arduino/strix/API.md @@ -22,9 +22,11 @@ The firmware speaks a line protocol over the board's USB serial port (115200 bau | `stats` | Latest statistics trial: per channel (`g0`–`g3`, `combined`) bits, ones, bias z and lag-1 autocorrelation z for the trial, the rolling window and the cumulative total (with random walk position). Also trials counted and trials missed. | | `stats reset` | Restarts trials and clears the rolling and cumulative windows | | `amp` | Latest bias amplifier trial: per path (`bias`, `autocorr`) amplified bits, ones, z, sub-trial votes (`1`, `0`, `.` for no vote) and the trial result (`null` on a tie) | +| `gcp` | Latest per-second trial (see below) | +| `gcp ` | `{"records":[...]}`: all kept trials after second `n`, oldest first (the last 60 seconds are kept) | | `get` | All settings | | `set ` | Changes a setting (see below) | -| `watch stats` / `watch amp` / `watch off` | Pushes the `stats` or `amp` reply after every new trial | +| `watch stats` / `watch amp` / `watch gcp` / `watch off` | Pushes the `stats` or `amp` reply after every new trial, or every new per-second trial as one line, to the transport that sent the command (USB, or BLE reply notifications). A BLE watch ends when the connection closes. | ## Settings @@ -47,6 +49,23 @@ The firmware speaks a line protocol over the board's USB serial port (115200 bau Changing an `amp_*` setting restarts the amplifier. +## Per-second trials (GCP format) + +Independent of all settings, the FPGA produces one trial per generator every second, in the form used by Global Consciousness Project style network analyses. Bit i of each second is inverted when i is odd (an XOR mask that cancels a constant bias), and: + +- `sample`: the sum of the first 200 masked bits of the second, the GCP trial (expected 100, standard deviation 7.071); +- `full`: the sum of all masked bits of the second, about 24 million at the default `temporal`. + +``` +{"second":812,"healthy":15, + "sample":{"bits":200,"sums":[103,96,99,108],"z":[0.424,-0.566,-0.141,1.131],"stouffer":0.424,"netvar":0.180}, + "full":{"bits":24000000,"sums":[...],"z":[...],"stouffer":...,"netvar":...}} +``` + +`second` counts seconds since the FPGA started; a gap means seconds were lost (the firmware also records seconds during long entropy transfers). `z` is (2 · sum − bits) / √bits, `stouffer` is Σz / √n over the generators that were healthy for the whole second (`healthy`, bit g), and `netvar` is its square, χ² with one degree of freedom. Unhealthy generators show `null`. The device has no clock, so clients timestamp the trials; the cumulative sum of `netvar` − 1 and its probability envelopes are left to the client. + +The four generators share one chip and power supply, unlike the separate devices of a GCP network. While a radio runs, only the generators in `radio_generators` (default 2) take part. + ## WiFi Without a USB host and on external power (wall charger), the device starts WiFi by itself: an access point named `Strix` (WPA2, password `owl12345678`) at `192.168.4.1`, with an HTTP server on port 80. With a computer attached, WiFi stays off until `wifi start`. On weak USB power the radio's current bursts plus the ring oscillators can reset the board. @@ -58,10 +77,11 @@ Until WiFi is started, the FPGA holds the WiFi module in reset. | `/entropy?bytes=[&format=hex\|bin]` | `entropy [hex\|bin]`. `bin` is sent as `application/octet-stream`, without the `BIN` line. | | `/?length=` | `entropy hex` (compatible with the earlier STRIX sketch) | | `/id`, `/health`, `/stats`, `/amp`, `/wifi` | same command | +| `/gcp[?since=]` | `gcp []`. Polling with `since` set to the last second received loses no seconds. | | `/config` | `get` | | `/set?key=&value=` | `set ` | -Replies have status 200 (errors are JSON `{"error":...}`), or 404 for unknown paths, and allow cross-origin requests. `watch` is USB-only. +Replies have status 200 (errors are JSON `{"error":...}`), or 404 for unknown paths, and allow cross-origin requests. `watch` works over USB and BLE only. ## Bluetooth LE @@ -72,7 +92,7 @@ Service `53545249-5800-4000-8000-000000000001` with three characteristics: | Characteristic | UUID suffix | Properties | Use | |---|---|---|---| | command | `…0002` | write | One USB command per write (text, up to 128 bytes) | -| reply | `…0003` | notify | The command's reply in chunks of up to 20 bytes. Every reply ends with a newline, so concatenate notifications until `\n`. | +| reply | `…0003` | notify | The command's reply in chunks of up to 20 bytes. Every reply ends with a newline, so concatenate notifications until `\n`. Also carries `watch` output, such as one line per second after `watch gcp`. | | entropy | `…0004` | read, notify | Each read returns 64 fresh bytes from the entropy stream. Subscribers receive new bytes about every 50 ms (each notification carries up to the connection's ATT payload, 20 bytes by default). An empty value means the stream had no fresh bytes; bytes are never sent twice. | `tools/ble_check.py` exercises all three from a computer with Bluetooth. diff --git a/arduino/strix/strix.ino b/arduino/strix/strix.ino index 1ad6b0d..d884efb 100644 --- a/arduino/strix/strix.ino +++ b/arduino/strix/strix.ino @@ -1,6 +1,6 @@ // STRIX firmware for the Arduino MKR Vidor 4000: boots the STRIX FPGA image and serves -// entropy, health, live statistics and bias amplifier trials over USB serial, over HTTP on its -// own WiFi access point, and over Bluetooth LE. +// entropy, health, live statistics, bias amplifier trials and per-second GCP-style trials over +// USB serial, over HTTP on its own WiFi access point, and over Bluetooth LE. // // USB protocol: one command per line; replies are single JSON lines, except `entropy`, which // replies with hex text or a "BIN " header followed by n raw bytes. HTTP and BLE carry the @@ -13,7 +13,7 @@ using namespace strix; -const char FIRMWARE_VERSION[] = "0.3.0"; +const char FIRMWARE_VERSION[] = "0.4.0"; const unsigned long POLL_MS = 10; const unsigned long ENTROPY_TIMEOUT_MS = 2000; const unsigned long USB_HOST_WAIT_MS = 1500; @@ -21,13 +21,16 @@ const unsigned long ENTROPY_NOTIFY_MS = 50; // BLE entropy stream interval const size_t MAX_ENTROPY_BYTES = 65536; const size_t CHUNK_BYTES = 256; const uint8_t MAX_ROLLING = 50; +const uint8_t GCP_HISTORY = 60; // seconds kept for clients that poll const char *CHANNEL_NAMES[STATS_CHANNELS] = {"g0", "g1", "g2", "g3", "combined"}; const char *PATH_NAMES[2] = {"bias", "autocorr"}; const char WIFI_SSID[] = "Strix"; const char WIFI_PASSWORD[] = "owl12345678"; // WPA2, at least 8 characters -// Replies go to *out: USB serial, or the HTTP response while one is being served. +// Replies go to *out: USB serial, or the HTTP response or BLE reply while one is being served. Print *out = &Serial; +enum Transport { TRANSPORT_USB, TRANSPORT_HTTP, TRANSPORT_BLE }; +Transport transport = TRANSPORT_USB; // where the command being handled came from // USB binary entropy is announced with a "BIN " line; HTTP sends the bytes alone. bool binaryHeader = true; @@ -40,8 +43,6 @@ bool externalPower = true; // charger or USB power present at boot (from the ch // more than the board's supply delivers. Two generators still far exceed what WiFi or BLE carry. uint8_t radioGenerators = 0x03; -bool radioActive() { return wifiReady || bleReady; } - // BLE: commands are written to `command` as text lines; replies arrive as notifications on `reply` // in chunks, each reply ending with a newline. `entropy` holds fresh bytes for every read and // streams them to subscribers. @@ -99,11 +100,25 @@ struct AmpTrial { AmpTrial latestAmp; bool haveAmp = false; -enum Watch { WATCH_OFF, WATCH_STATS, WATCH_AMP }; +// Per-second GCP-style trials, oldest first from gcpNext - gcpCount. +struct GcpSecond { + uint32_t second, bits; + uint8_t healthy, sampleBits; + uint32_t sampleSums[GENERATORS], ones[GENERATORS]; +}; + +GcpSecond gcpHistory[GCP_HISTORY]; +uint8_t gcpCount = 0, gcpNext = 0; + +enum Watch { WATCH_OFF, WATCH_STATS, WATCH_AMP, WATCH_GCP }; Watch watching = WATCH_OFF; +Transport watchTransport = TRANSPORT_USB; +uint32_t watchedSecond = 0; // last second pushed by `watch gcp` // Helpers +bool radioActive() { return wifiReady || bleReady; } + uint32_t word32(const uint8_t *bytes) { return (uint32_t)bytes[0] << 24 | (uint32_t)bytes[1] << 16 | (uint32_t)bytes[2] << 8 | bytes[3]; } @@ -196,6 +211,7 @@ void printStats(); void printAmp(); void printWifi(); void printBle(); +void emitWatched(void (*print)()); void pollStats() { uint8_t raw[STATS_SNAPSHOT_BYTES]; @@ -226,7 +242,7 @@ void pollStats() { if (historyCount < MAX_ROLLING) historyCount++; trialsCounted++; haveStats = true; - if (watching == WATCH_STATS) printStats(); + if (watching == WATCH_STATS) emitWatched(printStats); } void pollAmp() { @@ -253,7 +269,34 @@ void pollAmp() { path.valid = b[13] & 2; } haveAmp = true; - if (watching == WATCH_AMP) printAmp(); + if (watching == WATCH_AMP) emitWatched(printAmp); +} + +const GcpSecond *gcpAt(uint8_t age) { return &gcpHistory[(gcpNext + GCP_HISTORY - 1 - age) % GCP_HISTORY]; } + +// Records each completed second. The FPGA keeps only the latest, so this also runs during long +// entropy transfers. +void pollGcp() { + static unsigned long lastPoll = 0; + if (millis() - lastPoll < POLL_MS) return; + lastPoll = millis(); + uint8_t raw[GCP_SNAPSHOT_BYTES]; + fpga.readBurst(REG_GCP, raw, GCP_SNAPSHOT_BYTES); + uint32_t second = word32(raw); + if (second == 0 || second != word32(raw + GCP_SNAPSHOT_BYTES - 4)) return; // none yet, or torn + if (gcpCount && gcpAt(0)->second == second) return; + + GcpSecond &record = gcpHistory[gcpNext]; + record.second = second; + record.healthy = raw[4]; + record.sampleBits = raw[5]; + record.bits = word32(raw + 8); + for (uint8_t g = 0; g < GENERATORS; g++) { + record.sampleSums[g] = raw[12 + 5 * g]; + record.ones[g] = word32(raw + 13 + 5 * g); + } + gcpNext = (gcpNext + 1) % GCP_HISTORY; + if (gcpCount < GCP_HISTORY) gcpCount++; } // Commands @@ -397,6 +440,93 @@ void printAmp() { out->println("]}"); } +// One set of per-generator trial sums of `bits` bits each, with z-scores, the Stouffer Z of the +// healthy generators and the network variance Z^2. Unhealthy generators show null. +void printTrialSet(uint32_t bits, const uint32_t *sums, uint8_t healthy) { + double zSum = 0; + uint8_t used = 0; + out->print("{\"bits\":"); + out->print(bits); + out->print(",\"sums\":["); + for (uint8_t g = 0; g < GENERATORS; g++) { + if (g) out->print(','); + if (healthy >> g & 1) { + out->print(sums[g]); + } else { + out->print("null"); + } + } + out->print("],\"z\":["); + for (uint8_t g = 0; g < GENERATORS; g++) { + if (g) out->print(','); + if (healthy >> g & 1 && bits) { + double z = (2.0 * sums[g] - bits) / sqrt(bits); + zSum += z; + used++; + out->print(z, 3); + } else { + out->print("null"); + } + } + out->print("],\"stouffer\":"); + if (used) { + double stouffer = zSum / sqrt(used); + out->print(stouffer, 3); + out->print(",\"netvar\":"); + out->print(stouffer * stouffer, 3); + } else { + out->print("null,\"netvar\":null"); + } + out->print('}'); +} + +void printGcpSecond(const GcpSecond &record) { + out->print("{\"second\":"); + out->print(record.second); + out->print(",\"healthy\":"); + out->print(record.healthy); + out->print(",\"sample\":"); + printTrialSet(record.sampleBits, record.sampleSums, record.healthy); + out->print(",\"full\":"); + printTrialSet(record.bits, record.ones, record.healthy); + out->print('}'); +} + +// `gcp`: the latest second. `gcp `: all kept seconds after second n, oldest first. +void printGcp(const String &since) { + if (!gcpCount) { + printError("no second completed yet"); + return; + } + if (!since.length()) { + printGcpSecond(*gcpAt(0)); + out->println(); + return; + } + uint32_t after = strtoul(since.c_str(), nullptr, 10); + bool first = true; + out->print("{\"records\":["); + for (int age = gcpCount - 1; age >= 0; age--) { + const GcpSecond *record = gcpAt(age); + if (record->second <= after) continue; + if (!first) out->print(','); + first = false; + printGcpSecond(*record); + } + out->println("]}"); +} + +// Pushes the seconds completed since the last push, one line each. +void printNewGcp() { + for (int age = gcpCount - 1; age >= 0; age--) { + const GcpSecond *record = gcpAt(age); + if (record->second <= watchedSecond) continue; + printGcpSecond(*record); + out->println(); + watchedSecond = record->second; + } +} + // Streams `count` entropy bytes as hex or binary. Stops early (binary: short output, hex: an // error line) if the selected source stops delivering. void sendEntropy(size_t count, bool binary) { @@ -425,8 +555,10 @@ void sendEntropy(size_t count, bool binary) { } return; } + pollGcp(); continue; } + pollGcp(); size_t length = count - sent; if (length > level) length = level; if (length > CHUNK_BYTES) length = CHUNK_BYTES; @@ -606,12 +738,20 @@ void handleCommand(String line) { } } else if (command == "amp") { printAmp(); + } else if (command == "gcp") { + printGcp(first); } else if (command == "get") { printConfig(); } else if (command == "set" && first.length() && rest.length()) { setConfig(first, rest); - } else if (command == "watch" && (first == "stats" || first == "amp" || first == "off")) { - watching = first == "stats" ? WATCH_STATS : first == "amp" ? WATCH_AMP : WATCH_OFF; + } else if (command == "watch" && (first == "stats" || first == "amp" || first == "gcp" || first == "off")) { + if (transport == TRANSPORT_HTTP) { + printError("watch needs USB or BLE"); + return; + } + watching = first == "stats" ? WATCH_STATS : first == "amp" ? WATCH_AMP : first == "gcp" ? WATCH_GCP : WATCH_OFF; + watchTransport = transport; + watchedSecond = gcpCount ? gcpAt(0)->second : 0; printOk(); } else { printError("unknown command"); @@ -711,6 +851,7 @@ String httpCommand(const String &path, const String &query, const char *&type) { if (path == "/id" || path == "/health" || path == "/stats" || path == "/amp" || path == "/wifi") { return path.substring(1); } + if (path == "/gcp") return "gcp " + queryParam(query, "since"); if (path == "/config") return "get"; if (path == "/set") return "set " + queryParam(query, "key") + " " + queryParam(query, "value"); return ""; @@ -742,6 +883,7 @@ void serveHttp() { response.print("\r\nAccess-Control-Allow-Origin: *\r\nConnection: close\r\n\r\n"); out = &response; + transport = TRANSPORT_HTTP; binaryHeader = false; if (command.length()) { handleCommand(command); @@ -749,6 +891,7 @@ void serveHttp() { printError("not found"); } out = &Serial; + transport = TRANSPORT_USB; binaryHeader = true; response.flush(); client.stop(); @@ -838,6 +981,21 @@ void startBle() { bleReady = true; } +// Sends watch output to the transport that started the watch; a BLE watch ends with the connection. +void emitWatched(void (*print)()) { + BleReply reply; + if (watchTransport == TRANSPORT_BLE) { + if (!bleReady || !BLE.connected()) { + watching = WATCH_OFF; + return; + } + out = &reply; + } + print(); + out = &Serial; + reply.flush(); +} + void printBle() { out->print("{\"ble\":"); printBool(bleReady); @@ -856,9 +1014,11 @@ void serveBle() { String command((const char *)bleCommand.value(), bleCommand.valueLength()); BleReply reply; out = &reply; + transport = TRANSPORT_BLE; binaryHeader = true; handleCommand(command); out = &Serial; + transport = TRANSPORT_USB; reply.flush(); } // Stream entropy to subscribers, one notification per interval. @@ -944,6 +1104,8 @@ void loop() { lastPoll = millis(); pollStats(); pollAmp(); + pollGcp(); + if (watching == WATCH_GCP) emitWatched(printNewGcp); // LED on while every enabled generator is healthy uint8_t enabled = fpga.read(REG_GEN_ENABLE); digitalWrite(LED_BUILTIN, enabled && (fpga.read(REG_HEALTHY) & enabled) == enabled); diff --git a/devbox.json b/devbox.json index 30ab708..4f89441 100644 --- a/devbox.json +++ b/devbox.json @@ -22,7 +22,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/stats_block.v fpga/rtl/entropy_stream.v fpga/rtl/bias_amplifier.v fpga/rtl/amp_path.v fpga/rtl/amp_stage.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/entropy_stream.v fpga/rtl/bias_amplifier.v fpga/rtl/amp_path.v fpga/rtl/amp_stage.v fpga/rtl/gcp_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", "iverilog -g2005 -o /tmp/strix-sim/tb_amp_stage fpga/sim/tb_amp_stage.v fpga/rtl/amp_stage.v", "vvp -n /tmp/strix-sim/tb_amp_stage" diff --git a/fpga/rtl/gcp_block.v b/fpga/rtl/gcp_block.v new file mode 100644 index 0000000..645a585 --- /dev/null +++ b/fpga/rtl/gcp_block.v @@ -0,0 +1,131 @@ +// Per-second trials in the format of Global Consciousness Project (GCP) style network analyses, +// running alongside the other outputs. Every TRIAL_MS milliseconds (one second) each generator +// yields: +// - the sum of the first SAMPLE_BITS bits of the second, the GCP trial (expected 100, sd 7.07); +// - the sum of all its bits during the second, for a full-rate z-score. +// Both use XOR-masked bits: bit i of the second is inverted when i is odd, so a constant bias +// cancels. All generators deliver in lockstep, so one bit count serves them all; generators that +// were not healthy for the whole second are flagged in the snapshot, not excluded from counting. +// +// Snapshot layout (big-endian), latched at the end of each second: +// 0-3 sequence number, starting at 1 (0: no second completed yet) +// 4 generators healthy during the whole second (bit g) +// 5 SAMPLE_BITS +// 6-7 0 +// 8-11 bits per generator during the second +// 12+5g generator g: sample sum (1), masked ones during the second (4) +// 32-35 sequence number again; a mismatch with bytes 0-3 means a torn read +module gcp_block #( + parameter GENERATORS = 4, + parameter SAMPLE_BITS = 200, + parameter TRIAL_MS = 1000, + parameter TICK_CYCLES = 120000 // clk cycles per millisecond +) ( + input wire clk, + input wire reset, + input wire [GENERATORS-1:0] gen_bit, + input wire gen_valid, + input wire [GENERATORS-1: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 + 5 * GENERATORS + 4; + localparam [7:0] SAMPLE_SIZE = SAMPLE_BITS; + + // Trial timing + + reg [16:0] tick_count; + reg [15:0] ms_count; + wire tick = (tick_count == TICK_CYCLES - 1); + + always @(posedge clk) begin + trial_end <= 1'b0; + if (reset) begin + tick_count <= 17'd0; + ms_count <= 16'd0; + end else begin + tick_count <= tick ? 17'd0 : tick_count + 17'd1; + if (tick && ms_count == TRIAL_MS - 1) begin + ms_count <= 16'd0; + trial_end <= 1'b1; + end else if (tick) begin + ms_count <= ms_count + 16'd1; + end + end + end + + // Counters. As in stats_block, the bit arriving in the trial_end cycle opens the next second. + + reg [31:0] bit_count; + reg [7:0] sampled; // bits taken into the sample sums this second + reg odd; // the next bit has an odd index in the second + reg [7:0] sample_sum [0:GENERATORS-1]; + reg [31:0] masked_ones [0:GENERATORS-1]; + reg [31:0] sequence_number; + reg [GENERATORS-1:0] healthy_during; + + wire mask = trial_end ? 1'b0 : odd; + wire in_sample = trial_end || sampled < SAMPLE_BITS; + wire [GENERATORS-1:0] masked = gen_bit ^ {GENERATORS{mask}}; + + reg [8*SNAPSHOT_BYTES-1:0] snapshot_next; + reg [7:0] snapshot [0:SNAPSHOT_BYTES-1]; + + integer k; + always @(*) begin + snapshot_next = {SNAPSHOT_BYTES{8'h00}}; + snapshot_next[8*SNAPSHOT_BYTES-1 -: 96] = {sequence_number, + {8-GENERATORS{1'b0}}, healthy_during & healthy, + SAMPLE_SIZE, 16'd0, bit_count}; + for (k = 0; k < GENERATORS; k = k + 1) + snapshot_next[8*SNAPSHOT_BYTES-1-96-40*k -: 40] = {sample_sum[k], masked_ones[k]}; + snapshot_next[31:0] = sequence_number; + end + + integer g, b; + always @(posedge clk) begin + if (reset) begin + bit_count <= 32'd0; + sampled <= 8'd0; + odd <= 1'b0; + sequence_number <= 32'd1; + healthy_during <= {GENERATORS{1'b1}}; + for (g = 0; g < GENERATORS; g = g + 1) begin + sample_sum[g] <= 8'd0; + masked_ones[g] <= 32'd0; + end + end else begin + if (gen_valid) begin + bit_count <= (trial_end ? 32'd0 : bit_count) + 32'd1; + sampled <= in_sample ? (trial_end ? 8'd0 : sampled) + 8'd1 : sampled; + odd <= ~mask; + for (g = 0; g < GENERATORS; g = g + 1) begin + sample_sum[g] <= (trial_end ? 8'd0 : sample_sum[g]) + (in_sample && masked[g]); + masked_ones[g] <= (trial_end ? 32'd0 : masked_ones[g]) + masked[g]; + end + end else if (trial_end) begin + bit_count <= 32'd0; + sampled <= 8'd0; + odd <= 1'b0; + for (g = 0; g < GENERATORS; g = g + 1) begin + sample_sum[g] <= 8'd0; + masked_ones[g] <= 32'd0; + end + end + if (trial_end) begin + for (b = 0; b < SNAPSHOT_BYTES; b = b + 1) + snapshot[b] <= snapshot_next[8*(SNAPSHOT_BYTES-1-b) +: 8]; + sequence_number <= sequence_number + 32'd1; + healthy_during <= healthy; + end else begin + healthy_during <= healthy_during & healthy; + end + end + end + + always @(posedge clk) + read_byte <= (read_index < SNAPSHOT_BYTES) ? snapshot[read_index] : 8'h00; + +endmodule diff --git a/fpga/rtl/strix_core.v b/fpga/rtl/strix_core.v index aff31f4..28e69ad 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, entropy byte -// stream, live statistics, bias amplifier, source selection, raw capture, frequency counter +// stream, live statistics, bias amplifier, per-second GCP-style trials, 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, @@ -20,7 +20,8 @@ // 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. AMP does the same for -// the bias amplifier's trial snapshot (layout in bias_amplifier.v). Reading STREAM returns +// the bias amplifier's trial snapshot (layout in bias_amplifier.v), and GCP for the per-second +// trial snapshot (layout in gcp_block.v). Reading STREAM returns // entropy bytes from the stream FIFO, removing each byte once it has been clocked out; read // at most STREAM_LEVEL bytes (an empty FIFO returns 0x00). module strix_core #( @@ -28,7 +29,8 @@ module strix_core #( parameter FREQ_GATE_BITS = 24, parameter RINGS_PER_GEN = 90, parameter DEAD_WINDOW_BITS = 16, - parameter TICK_CYCLES = 120000 // clk cycles per millisecond + parameter TICK_CYCLES = 120000, // clk cycles per millisecond + parameter GCP_TRIAL_MS = 1000 ) ( input wire clk, input wire reset, @@ -41,7 +43,7 @@ module strix_core #( output reg nina_gpio0_in // GPIO0 is driven by the module (to the SAMD21), not to it ); - localparam [7:0] VERSION = 8'h09; + localparam [7:0] VERSION = 8'h0A; localparam GENERATORS = 4; localparam [6:0] REG_ID0 = 7'h00, // "STRX" @@ -93,6 +95,7 @@ module strix_core #( REG_STATS = 7'h40, // burst read of the statistics trial snapshot REG_STREAM = 7'h44, // burst read of entropy bytes REG_AMP = 7'h42, // burst read of the amplifier trial snapshot + REG_GCP = 7'h46, // burst read of the per-second trial snapshot REG_AMP_CTRL = 7'h50, // bit 0 von Neumann on the amplifier path; // write bit 1: restart the amplifier, sequence 0 REG_AMP_SOURCES = 7'h51, // generators feeding the amplifier (healthy only) @@ -220,6 +223,18 @@ module strix_core #( .trial_end(trial_end) ); + // Per-second GCP-style trials, fed from the same registered generator outputs + + reg [7:0] gcp_index; + wire [7:0] gcp_byte; + wire gcp_trial_end; + + gcp_block #(.GENERATORS(GENERATORS), .TRIAL_MS(GCP_TRIAL_MS), .TICK_CYCLES(TICK_CYCLES)) gcp ( + .clk(clk), .reset(reset), .gen_bit(stats_bits[GENERATORS-1:0]), .gen_valid(stats_valid), + .healthy(stats_healthy), .read_index(gcp_index), .read_byte(gcp_byte), + .trial_end(gcp_trial_end) + ); + // Source selection reg [3:0] source_a, source_b; @@ -367,6 +382,7 @@ module strix_core #( REG_DATA: burst_byte = capture_byte; REG_STATS: burst_byte = stats_byte; REG_AMP: burst_byte = amp_byte; + REG_GCP: burst_byte = gcp_byte; REG_STREAM: burst_byte = stream_available ? stream_head : 8'h00; default: begin burst_byte = 8'h00; @@ -411,6 +427,7 @@ module strix_core #( amp_stage_mode <= 3'b000; amp_stage_param <= {16'd4, 16'd8, 16'd8}; // random walk 8 x 8 x 4 = bound 256 amp_index <= 8'd0; + gcp_index <= 8'd0; stream_source <= 3'd4; nina_enable <= 1'b0; nina_gpio0_in <= 1'b0; @@ -425,6 +442,11 @@ module strix_core #( else if (tx_loaded && is_read && reg_addr == REG_AMP) amp_index <= amp_index + 8'd1; + if (!selected) + gcp_index <= 8'd0; + else if (tx_loaded && is_read && reg_addr == REG_GCP) + gcp_index <= gcp_index + 8'd1; + if (!selected) stats_index <= 8'd0; else if (tx_loaded && is_read && reg_addr == REG_STATS) diff --git a/fpga/sim/tb_strix_core.v b/fpga/sim/tb_strix_core.v index dd1bde7..30e4fab 100644 --- a/fpga/sim/tb_strix_core.v +++ b/fpga/sim/tb_strix_core.v @@ -1,8 +1,8 @@ // 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, the health monitors with injected faults, the statistics snapshots against a -// reference model, the bias amplifier's bit counts in several configurations, and the entropy -// byte stream against the FIFO contents. Rings are replaced by the SIM model in ring_osc.v. +// reference model, the bias amplifier's bit counts in several configurations, the entropy +// byte stream against the FIFO contents, and the per-second GCP trials against a reference model. Rings are replaced by the SIM model in ring_osc.v. `timescale 1ns / 1ps module tb_strix_core; @@ -21,14 +21,18 @@ module tb_strix_core; REG_STATS_CTRL = 7'h28, REG_STATS = 7'h40, REG_AMP = 7'h42, REG_AMP_CTRL = 7'h50, REG_AMP_SOURCES = 7'h51, REG_AMP_SUB_MS = 7'h52, REG_AMP_SUBS = 7'h53, REG_AMP_STAGE = 7'h54, REG_STREAM_CTRL = 7'h29, - REG_LEVEL_HIGH = 7'h2A, REG_LEVEL_LOW = 7'h2B, REG_STREAM = 7'h44; + REG_LEVEL_HIGH = 7'h2A, REG_LEVEL_LOW = 7'h2B, REG_STREAM = 7'h44, + REG_GCP = 7'h46; localparam AMP_SNAPSHOT_BYTES = 44; + localparam GCP_SNAPSHOT_BYTES = 36; 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 // Amplifier trials in the tests: 3 sub-trials of 2 "ms" -> 9000 clk cycles -> 1800 output // bits per generator at temporal 5 localparam AMP_GEN_BITS = 3 * 2 * TICK_CYCLES / 5; + localparam GCP_TRIAL_MS = 10; // shortened "second": GCP_TRIAL_BITS bits per generator + localparam GCP_TRIAL_BITS = GCP_TRIAL_MS * TICK_CYCLES / 5; // Per-generator health registers function [6:0] gen_reg(input integer generator, input integer index); @@ -44,7 +48,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), .TICK_CYCLES(TICK_CYCLES)) dut ( + .DEAD_WINDOW_BITS(6), .TICK_CYCLES(TICK_CYCLES), .GCP_TRIAL_MS(GCP_TRIAL_MS)) dut ( .clk(clk), .reset(reset), .spi_sck(sck), .spi_mosi(mosi), .spi_ss_n(ss_n), .spi_miso(miso), .capture_done(done) @@ -121,6 +125,48 @@ module tb_strix_core; end end + // Reference model of the GCP trials: bit i of a second is inverted when i is odd, and the + // first 200 masked bits form the sample sum. + reg [31:0] gcp_model_bits = 0; + reg [7:0] gcp_model_sum [0:3]; + reg [31:0] gcp_model_ones [0:3]; + reg [31:0] gcp_latched_bits; + reg [7:0] gcp_latched_sum [0:3]; + reg [31:0] gcp_latched_ones [0:3]; + integer gm; + + initial for (gm = 0; gm < 4; gm = gm + 1) begin + gcp_model_sum[gm] = 0; + gcp_model_ones[gm] = 0; + end + + always @(posedge clk) if (!reset) begin : gcp_model + reg [31:0] index; + reg masked; + if (dut.gcp_trial_end) begin + gcp_latched_bits <= gcp_model_bits; + for (gm = 0; gm < 4; gm = gm + 1) begin + gcp_latched_sum[gm] <= gcp_model_sum[gm]; + gcp_latched_ones[gm] <= gcp_model_ones[gm]; + end + end + if (dut.gcp.gen_valid) begin + index = dut.gcp_trial_end ? 0 : gcp_model_bits; + gcp_model_bits <= index + 1; + for (gm = 0; gm < 4; gm = gm + 1) begin + masked = dut.gcp.gen_bit[gm] ^ index[0]; + gcp_model_ones[gm] <= (dut.gcp_trial_end ? 0 : gcp_model_ones[gm]) + masked; + gcp_model_sum[gm] <= (dut.gcp_trial_end ? 0 : gcp_model_sum[gm]) + (index < 200 && masked); + end + end else if (dut.gcp_trial_end) begin + gcp_model_bits <= 0; + for (gm = 0; gm < 4; gm = gm + 1) begin + gcp_model_sum[gm] <= 0; + gcp_model_ones[gm] <= 0; + end + end + end + reg [7:0] snapshot [0:SNAPSHOT_BYTES-1]; function [31:0] snapshot_word(input integer index); @@ -282,7 +328,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'h09, "VERSION"); + read_reg(REG_VERSION, v); expect(v, 8'h0A, "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"); @@ -459,6 +505,39 @@ module tb_strix_core; read_reg(REG_LEVEL_LOW, v); expect_range(v, 0, 15, "stream level after flush"); + // GCP trials: a full second after the earlier tests, checked against the model + @(posedge dut.gcp_trial_end); #200; + begin : gcp_read + reg [7:0] gcp [0:GCP_SNAPSHOT_BYTES-1]; + reg [7:0] dummy; + integer i; + ss_n = 1'b0; #SCK_HALF; + spi_byte({1'b1, REG_GCP}, dummy); + for (i = 0; i < GCP_SNAPSHOT_BYTES; i = i + 1) spi_byte(8'h00, gcp[i]); + #SCK_HALF ss_n = 1'b1; #(4 * SCK_HALF); + if ({gcp[0], gcp[1], gcp[2], gcp[3]} !== {gcp[32], gcp[33], gcp[34], gcp[35]} + || {gcp[0], gcp[1], gcp[2], gcp[3]} < 32'd2) begin + $display("FAIL GCP sequence %0d", {gcp[0], gcp[1], gcp[2], gcp[3]}); errors = errors + 1; + end + expect(gcp[4], 8'h0F, "GCP healthy"); + expect(gcp[5], 8'd200, "GCP sample bits"); + if ({gcp[8], gcp[9], gcp[10], gcp[11]} !== gcp_latched_bits) begin + $display("FAIL GCP bits %0d, model %0d", {gcp[8], gcp[9], gcp[10], gcp[11]}, gcp_latched_bits); + errors = errors + 1; + end + expect_range({gcp[8], gcp[9], gcp[10], gcp[11]}, GCP_TRIAL_BITS - 1, GCP_TRIAL_BITS + 1, "GCP bits"); + for (i = 0; i < 4; i = i + 1) begin + if (gcp[12 + 5 * i] !== gcp_latched_sum[i] + || {gcp[13 + 5 * i], gcp[14 + 5 * i], gcp[15 + 5 * i], gcp[16 + 5 * i]} !== gcp_latched_ones[i]) begin + $display("FAIL GCP generator %0d: sum %0d ones %0d, model %0d / %0d", i, gcp[12 + 5 * i], + {gcp[13 + 5 * i], gcp[14 + 5 * i], gcp[15 + 5 * i], gcp[16 + 5 * i]}, + gcp_latched_sum[i], gcp_latched_ones[i]); + errors = errors + 1; + end + expect_range(gcp[12 + 5 * i], 1, 199, "GCP sample sum"); + end + end + 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 6840487..873072a 100644 --- a/fpga/src/strix.qsf +++ b/fpga/src/strix.qsf @@ -67,6 +67,7 @@ set_global_assignment -name VERILOG_FILE ../rtl/entropy_stream.v set_global_assignment -name VERILOG_FILE ../rtl/bias_amplifier.v set_global_assignment -name VERILOG_FILE ../rtl/amp_path.v set_global_assignment -name VERILOG_FILE ../rtl/amp_stage.v +set_global_assignment -name VERILOG_FILE ../rtl/gcp_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/api_check.py b/tools/api_check.py index 8dba37c..b8ce7b5 100644 --- a/tools/api_check.py +++ b/tools/api_check.py @@ -99,6 +99,25 @@ def main() -> None: device.serial.reset_input_buffer() check(trials == sorted(trials) and len(set(trials)) == 3, f"watch stats pushed trials {trials}") + check(device.json("watch gcp") == {"ok": True}, "watch gcp") + seconds = [json.loads(device.serial.readline()) for _ in range(3)] + device.json("watch off") + device.serial.reset_input_buffer() + numbers = [r.get("second") for r in seconds] + last = seconds[-1] + check(numbers == list(range(numbers[0], numbers[0] + 3)), f"watch gcp pushed consecutive seconds {numbers}") + check(last["healthy"] == 15 and last["sample"]["bits"] == 200 + and all(0 <= s <= 200 for s in last["sample"]["sums"]), + f"gcp sample: sums {last['sample']['sums']}, Stouffer {last['sample']['stouffer']}") + check(abs(last["full"]["bits"] - 24_000_000) <= 1, + f"gcp full: {last['full']['bits']} bits, z {last['full']['z']}, Stouffer {last['full']['stouffer']}") + device.entropy_bin(65536) + time.sleep(1.1) + records = device.json(f"gcp {numbers[-1]}").get("records", []) + after = [r["second"] for r in records] + check(len(after) >= 1 and after == list(range(numbers[-1] + 1, numbers[-1] + 1 + len(after))), + f"gcp {numbers[-1]}: no gap across an entropy transfer, seconds {after}") + if args.onod.exists(): sample = Path("captures/api_entropy.bin") sample.parent.mkdir(exist_ok=True) diff --git a/tools/ble_check.py b/tools/ble_check.py index 342fbcf..dd2cc91 100644 --- a/tools/ble_check.py +++ b/tools/ble_check.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """Checks the STRIX firmware's Bluetooth LE API (arduino/strix/API.md) from this computer: finds the device, sends commands over the command characteristic, reads the replies from the -reply notifications, and reads and streams the entropy characteristic.""" +reply notifications (including `watch gcp` output), and reads and streams the entropy characteristic.""" import argparse import asyncio @@ -57,6 +57,15 @@ async def main() -> None: print(f"{'stats':<12} -> trial {stats.get('trial')}, combined total z " f"{stats['channels'][-1]['total']['z'] if 'channels' in stats else None}") + print(f"{'watch gcp':<12} -> {await command(client, replies, 'watch gcp')}") + records = [json.loads(await asyncio.wait_for(replies.lines.get(), timeout=3)) for _ in range(2)] + await client.write_gatt_char(COMMAND, b"watch off", response=True) + await asyncio.sleep(1.5) + while not replies.lines.empty(): + replies.lines.get_nowait() # the watch reply and any second pushed before it + print(f"{'gcp seconds':<12} -> {[r['second'] for r in records]}, healthy {records[-1]['healthy']}, " + f"sample sums {records[-1]['sample']['sums']}") + first = await client.read_gatt_char(ENTROPY) second = await client.read_gatt_char(ENTROPY) print(f"entropy read -> {len(first)} bytes, then {len(second)} bytes, " diff --git a/tools/jitter.py b/tools/jitter.py index f0ee04e..ff84876 100644 --- a/tools/jitter.py +++ b/tools/jitter.py @@ -82,8 +82,8 @@ class Board: while self.serial.readline(): pass self.serial.timeout = 200 - if not identity.startswith("STRX v9"): - sys.exit(f"unexpected board identity: {identity!r} (needs the v9 FPGA image)") + if not identity.startswith("STRX v10"): + sys.exit(f"unexpected board identity: {identity!r} (needs the v10 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")