//! The hardware backend: lower a sampling graph to a fixed-point p-bit fabric and emit
//! synthesizable Verilog for it — the same design for every deployment target in [`crate::targets`],
//! from an Alchitry board to an Alveo card to an AWS F2 instance.
//!
//! The contract that makes this trustworthy: [`FixedFabric`] is a CYCLE-EXACT Rust emulator of
//! the emitted hardware — same quantization (Q.8 weights), same sigmoid ROM, same per-node
//! xorshift32 RNG, same two-phase chromatic schedule. The Verilog testbench replays the emulator's
//! per-sweep state trace and must match BIT-EXACTLY in simulation (icarus-verilog gate in the
//! tests). Software model == emulator == RTL, or the build fails.
//!
//! v1 scope: bipartite pairwise spin graphs (the lattice and Z1-topology classes), free-running
//! (no clamp ports yet), fully parallel per color — one full sweep per two clock cycles.
use crate::graph::Graph;
pub const FRAC: u32 = 8; // Q.8 fixed point
const FMAX: i32 = 2047; // clamp field to [-8.0, +8.0) in Q.8
const FMIN: i32 = -2048;
pub const LUT_BITS: u32 = 10; // 1024-entry sigmoid ROM
fn splitmix(mut z: u64) -> u64 {
z = z.wrapping_add(0x9E3779B97F4A7C15);
let mut x = z;
x = (x ^ (x >> 30)).wrapping_mul(0xBF58476D1CE4E5B9);
x = (x ^ (x >> 27)).wrapping_mul(0x94D049BB133111EB);
x ^ (x >> 31)
}
#[inline]
fn xorshift32(mut x: u32) -> u32 {
x ^= x << 13;
x ^= x >> 17;
x ^= x << 5;
x
}
pub struct FixedFabric {
pub n: usize,
/// per node: (neighbor index, quantized weight)
pub adj: Vec<Vec<(u32, i32)>>,
pub bias_q: Vec<i32>,
/// the two chromatic classes (v1 requires a bipartite graph)
pub classes: [Vec<u32>; 2],
pub lut: Vec<u16>,
pub seeds: Vec<u32>,
pub init_s: Vec<bool>,
/// current emulator state (true = +1)
pub s: Vec<bool>,
pub rng: Vec<u32>,
}
impl FixedFabric {
/// Quantize a graph at inverse temperature `beta` into the fabric. Panics unless the
/// coloring is exactly two classes (the lattice / device-topology case v1 targets).
pub fn new(g: &Graph, beta: f64, seed: u64) -> FixedFabric {
assert_eq!(g.classes.len(), 2, "v1 fabric requires a bipartite (2-colorable) graph");
let scale = (1u32 << FRAC) as f64;
let mut adj = vec![Vec::new(); g.n];
for i in 0..g.n {
for k in g.offset[i]..g.offset[i + 1] {
adj[i].push((g.nbr[k], (g.w[k] * scale).round() as i32));
}
}
let bias_q: Vec<i32> = g.h.iter().map(|&h| (h * scale).round() as i32).collect();
let lut: Vec<u16> = (0..(1usize << LUT_BITS))
.map(|a| {
let arg = ((a as f64 + 0.5) * 4.0 - 2048.0) / scale;
let p = 1.0 / (1.0 + (-2.0 * beta * arg).exp());
(p * 65535.0).round().min(65535.0) as u16
})
.collect();
let seeds: Vec<u32> = (0..g.n)
.map(|i| {
let s = splitmix(seed ^ (i as u64).wrapping_mul(0xD6E8FEB86659FD93)) as u32;
if s == 0 { 1 } else { s }
})
.collect();
let init_s: Vec<bool> = (0..g.n).map(|i| splitmix(seed ^ 0xA5A5 ^ i as u64) & 1 == 1).collect();
FixedFabric {
n: g.n,
adj,
bias_q,
classes: [g.classes[0].clone(), g.classes[1].clone()],
lut,
seeds: seeds.clone(),
init_s: init_s.clone(),
s: init_s,
rng: seeds,
}
}
#[inline]
fn update_node(&mut self, i: usize) {
let mut field = self.bias_q[i];
for &(j, w) in &self.adj[i] {
field += if self.s[j as usize] { w } else { -w };
}
let fc = field.clamp(FMIN, FMAX);
let addr = ((fc + 2048) >> 2) as usize;
let p16 = self.lut[addr];
let nx = xorshift32(self.rng[i]);
self.rng[i] = nx;
let rand16 = (nx >> 16) as u16;
self.s[i] = rand16 < p16;
}
/// One full sweep: phase 0 updates class 0 (reading registered class-1 states), then phase 1.
pub fn sweep(&mut self) {
for phase in 0..2 {
let class = self.classes[phase].clone();
for &iu in &class {
self.update_node(iu as usize);
}
}
}
pub fn magnetization(&self) -> f64 {
let up = self.s.iter().filter(|&&b| b).count() as f64;
(2.0 * up - self.n as f64) / self.n as f64
}
/// Reset the emulator to the exact power-on state of the emitted hardware.
pub fn reset(&mut self) {
self.s.copy_from_slice(&self.init_s);
self.rng.copy_from_slice(&self.seeds);
}
/// Emit the synthesizable fabric module.
pub fn emit_verilog(&self, module: &str) -> String {
let n = self.n;
let mut v = String::new();
v.push_str(&format!(
"// generated by ferrotherm::hdl — fixed-point chromatic-Gibbs p-bit fabric\n\
// {n} p-bits, Q.{FRAC} weights, {}-entry sigmoid ROM, xorshift32 per node\n\
module {module} (\n input wire clk,\n input wire rst,\n output reg [{top}:0] state,\n output reg phase\n);\n",
1usize << LUT_BITS,
top = n - 1
));
v.push_str(" function [15:0] fsig; input [9:0] a; begin\n case (a)\n");
for (a, p) in self.lut.iter().enumerate() {
v.push_str(&format!(" 10'd{a}: fsig = 16'd{p};\n"));
}
v.push_str(" default: fsig = 16'd0;\n endcase\n end endfunction\n\n");
v.push_str(
" function [31:0] xs32; input [31:0] x; reg [31:0] a, b; begin\n a = x ^ (x << 13); b = a ^ (a >> 17); xs32 = b ^ (b << 5);\n end endfunction\n\n",
);
v.push_str(&format!(" reg [31:0] rng [0:{}];\n", n - 1));
for i in 0..n {
let mut terms = vec![format!("32'sd{}", self.bias_q[i])];
for &(j, w) in &self.adj[i] {
let wa = w.abs();
if w >= 0 {
terms.push(format!("(state[{j}] ? 32'sd{wa} : -32'sd{wa})"));
} else {
terms.push(format!("(state[{j}] ? -32'sd{wa} : 32'sd{wa})"));
}
}
v.push_str(&format!(" wire signed [31:0] f{i} = {};\n", terms.join(" + ")));
v.push_str(&format!(
" wire signed [31:0] fc{i} = f{i} > 32'sd{FMAX} ? 32'sd{FMAX} : (f{i} < -32'sd2048 ? -32'sd2048 : f{i});\n"
));
v.push_str(&format!(" wire [9:0] ad{i} = (fc{i} + 32'sd2048) >>> 2;\n"));
v.push_str(&format!(" wire [31:0] nr{i} = xs32(rng[{i}]);\n"));
v.push_str(&format!(" wire up{i} = nr{i}[31:16] < fsig(ad{i});\n"));
}
v.push_str("\n always @(posedge clk) begin\n if (rst) begin\n phase <= 1'b0;\n");
for i in 0..n {
v.push_str(&format!(" rng[{i}] <= 32'd{};\n", self.seeds[i]));
v.push_str(&format!(" state[{i}] <= 1'b{};\n", self.init_s[i] as u8));
}
v.push_str(" end else begin\n phase <= ~phase;\n");
for (ci, class) in self.classes.iter().enumerate() {
v.push_str(&format!(" if (phase == 1'b{ci}) begin\n"));
for &iu in class {
let i = iu as usize;
v.push_str(&format!(" state[{i}] <= up{i}; rng[{i}] <= nr{i};\n"));
}
v.push_str(" end\n");
}
v.push_str(" end\n end\nendmodule\n");
v
}
/// Emit a self-checking testbench plus the expected per-sweep state trace (hex lines) from
/// the emulator. The testbench prints FERROTHERM_PASS only on a bit-exact match.
pub fn emit_testbench(&mut self, module: &str, sweeps: usize) -> (String, String) {
self.reset();
let n = self.n;
let hexw = n.div_ceil(4);
let mut expected = String::new();
for _ in 0..sweeps {
self.sweep();
let mut val = vec![0u8; hexw];
for (i, &b) in self.s.iter().enumerate() {
if b {
val[hexw - 1 - i / 4] |= 1 << (i % 4);
}
}
for b in &val {
expected.push_str(&format!("{b:x}"));
}
expected.push('\n');
}
self.reset();
let tb = format!(
"`timescale 1ns/1ps\nmodule tb;\n reg clk = 0, rst = 1;\n wire [{top}:0] state;\n wire phase;\n {module} dut(.clk(clk), .rst(rst), .state(state), .phase(phase));\n reg [{top}:0] expected [0:{last}];\n integer sw, errors = 0;\n always #5 clk = ~clk;\n initial begin\n $readmemh(\"expected.hex\", expected);\n @(posedge clk); @(posedge clk); rst = 0;\n for (sw = 0; sw < {sweeps}; sw = sw + 1) begin\n @(posedge clk); @(posedge clk); #1;\n if (state !== expected[sw]) begin\n errors = errors + 1;\n $display(\"MISMATCH sweep %0d: got %h want %h\", sw, state, expected[sw]);\n end\n end\n if (errors == 0) $display(\"FERROTHERM_PASS\");\n else $display(\"FERROTHERM_FAIL %0d\", errors);\n $finish;\n end\nendmodule\n",
top = n - 1,
last = sweeps - 1,
);
(tb, expected)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ising::{lattice2d, onsager_m};
/// The quantized fabric must still pass the physics gate: Onsager within quantization tolerance.
#[test]
fn fixed_point_physics() {
let g = lattice2d(24, 1.0);
let beta = 0.5;
let mut fab = FixedFabric::new(&g, beta, 0xFAB);
for s in fab.s.iter_mut() {
*s = true;
}
for _ in 0..1500 {
fab.sweep();
}
let mut acc = 0.0;
let reads = 3000;
for _ in 0..reads {
fab.sweep();
acc += fab.magnetization().abs();
}
let m = acc / reads as f64;
let exact = onsager_m(beta);
assert!((m - exact).abs() < 0.03, "fixed-point |M| {m:.4} vs Onsager {exact:.4}");
}
/// THE HARDWARE GATE: the emitted Verilog, simulated with icarus-verilog, must reproduce the
/// emulator's state trace bit-exactly for every sweep. Skips (with a notice) if iverilog is
/// not installed; CI installs it.
#[test]
fn verilog_matches_emulator_bit_exact() {
if std::process::Command::new("iverilog").arg("-V").output().is_err() {
eprintln!("SKIP: iverilog not installed; the RTL bit-exactness gate did not run");
return;
}
let g = lattice2d(6, 0.9);
let mut fab = FixedFabric::new(&g, 0.7, 0x1234);
let rtl = fab.emit_verilog("fabric");
let (tb, expected) = fab.emit_testbench("fabric", 40);
let dir = std::env::temp_dir().join(format!("ferrotherm_hdl_{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("fabric.v"), rtl).unwrap();
std::fs::write(dir.join("tb.v"), tb).unwrap();
std::fs::write(dir.join("expected.hex"), expected).unwrap();
let out = std::process::Command::new("iverilog")
.current_dir(&dir)
.args(["-g2012", "-o", "sim", "fabric.v", "tb.v"])
.output()
.unwrap();
assert!(out.status.success(), "iverilog: {}", String::from_utf8_lossy(&out.stderr));
let run = std::process::Command::new("vvp").current_dir(&dir).arg("sim").output().unwrap();
let stdout = String::from_utf8_lossy(&run.stdout);
assert!(stdout.contains("FERROTHERM_PASS"), "RTL/emulator divergence:\n{stdout}");
let _ = std::fs::remove_dir_all(&dir);
}
}