use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
pub const SCALE: f64 = 1_000_000.0;
const DOMAIN_LEAF: &[u8] = b"scemadex.zkbackend.v1.leaf";
const DOMAIN_NODE: &[u8] = b"scemadex.zkbackend.v1.node";
const DOMAIN_FS: &[u8] = b"scemadex.zkbackend.v1.fiat-shamir";
fn q(x: f64) -> i64 {
(x * SCALE).round() as i64
}
fn dq(i: i64) -> f64 {
i as f64 / SCALE
}
fn hash(parts: &[&[u8]]) -> [u8; 32] {
let mut h = Sha256::new();
for p in parts {
h.update(p);
}
h.finalize().into()
}
fn leaf_hash(index: u64, value: i64) -> [u8; 32] {
hash(&[DOMAIN_LEAF, &index.to_le_bytes(), &value.to_le_bytes()])
}
#[derive(Clone, Debug)]
pub struct DenseLayer {
pub weights: Vec<Vec<f64>>,
pub biases: Vec<f64>,
pub relu: bool,
}
impl DenseLayer {
pub fn new(weights: Vec<Vec<f64>>, biases: Vec<f64>, relu: bool) -> Self {
Self { weights, biases, relu }
}
fn out_dim(&self) -> usize {
self.biases.len()
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct LayerShape {
pub out_dim: usize,
pub relu: bool,
}
#[derive(Clone, Debug)]
pub struct TracedMlp {
pub layers: Vec<DenseLayer>,
pub input_dim: usize,
}
impl TracedMlp {
pub fn new(input_dim: usize, layers: Vec<DenseLayer>) -> Self {
Self { layers, input_dim }
}
fn shape(&self) -> Vec<LayerShape> {
self.layers
.iter()
.map(|l| LayerShape { out_dim: l.out_dim(), relu: l.relu })
.collect()
}
}
#[derive(Clone, Copy, Debug)]
pub struct SpotCheckConfig {
pub num_queries: usize,
}
impl Default for SpotCheckConfig {
fn default() -> Self {
Self { num_queries: 16 }
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct OpenedCell {
pub index: u64,
pub value: i64,
pub path: Vec<[u8; 32]>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct SpotCheckProof {
pub root: [u8; 32],
pub num_leaves: u64,
pub input_dim: usize,
pub shape: Vec<LayerShape>,
pub num_queries: usize,
pub output: Vec<f64>,
pub cells: Vec<OpenedCell>,
}
struct Layout {
input_dim: usize,
fan_in: Vec<usize>,
out: Vec<usize>,
weight_off: Vec<usize>,
bias_off: Vec<usize>,
act_off: Vec<usize>,
total: usize,
}
impl Layout {
fn new(input_dim: usize, shape: &[LayerShape]) -> Self {
let mut fan_in = Vec::with_capacity(shape.len());
let mut out = Vec::with_capacity(shape.len());
let mut weight_off = Vec::with_capacity(shape.len());
let mut bias_off = Vec::with_capacity(shape.len());
let mut act_off = Vec::with_capacity(shape.len());
let mut cursor = input_dim; let mut prev_width = input_dim;
for s in shape {
let fi = prev_width;
fan_in.push(fi);
out.push(s.out_dim);
weight_off.push(cursor);
cursor += s.out_dim * fi;
bias_off.push(cursor);
cursor += s.out_dim;
act_off.push(cursor);
cursor += s.out_dim;
prev_width = s.out_dim;
}
Self {
input_dim,
fan_in,
out,
weight_off,
bias_off,
act_off,
total: cursor,
}
}
fn input_cell(&self, i: usize) -> usize {
i
}
fn weight_cell(&self, l: usize, o: usize, i: usize) -> usize {
self.weight_off[l] + o * self.fan_in[l] + i
}
fn bias_cell(&self, l: usize, o: usize) -> usize {
self.bias_off[l] + o
}
fn act_cell(&self, l: usize, o: usize) -> usize {
self.act_off[l] + o
}
fn prev_act_cell(&self, l: usize, i: usize) -> usize {
if l == 0 {
self.input_cell(i)
} else {
self.act_cell(l - 1, i)
}
}
fn computed_neurons(&self) -> Vec<(usize, usize)> {
let mut v = Vec::new();
for (l, &o) in self.out.iter().enumerate() {
for j in 0..o {
v.push((l, j));
}
}
v
}
}
struct MerkleTree {
levels: Vec<Vec<[u8; 32]>>,
}
impl MerkleTree {
fn build(cells: &[i64]) -> Self {
let leaves: Vec<[u8; 32]> = cells
.iter()
.enumerate()
.map(|(i, &v)| leaf_hash(i as u64, v))
.collect();
let mut levels = vec![leaves];
while levels.last().unwrap().len() > 1 {
let cur = levels.last().unwrap();
let mut next = Vec::with_capacity(cur.len().div_ceil(2));
let mut i = 0;
while i < cur.len() {
let left = cur[i];
let right = if i + 1 < cur.len() { cur[i + 1] } else { cur[i] };
next.push(hash(&[DOMAIN_NODE, &left, &right]));
i += 2;
}
levels.push(next);
}
Self { levels }
}
fn root(&self) -> [u8; 32] {
*self.levels.last().unwrap().last().unwrap()
}
fn open(&self, index: usize) -> Vec<[u8; 32]> {
let mut path = Vec::with_capacity(self.levels.len());
let mut idx = index;
for level in &self.levels[..self.levels.len() - 1] {
let sib = if idx.is_multiple_of(2) {
if idx + 1 < level.len() { idx + 1 } else { idx } } else {
idx - 1
};
path.push(level[sib]);
idx /= 2;
}
path
}
}
fn merkle_root_from(index: u64, value: i64, path: &[[u8; 32]], num_leaves: u64) -> [u8; 32] {
let mut node = leaf_hash(index, value);
let mut idx = index;
let mut level_len = num_leaves;
for sib in path {
let (left, right) = if idx.is_multiple_of(2) {
let is_last_dup = idx + 1 == level_len;
(node, if is_last_dup { node } else { *sib })
} else {
(*sib, node)
};
node = hash(&[DOMAIN_NODE, &left, &right]);
idx /= 2;
level_len = level_len.div_ceil(2);
}
node
}
fn fiat_shamir(root: &[u8; 32], num_leaves: u64, num_queries: usize, domain: usize) -> Vec<usize> {
let mut out = Vec::new();
if domain == 0 {
return out;
}
let want = num_queries.min(domain);
let mut seen = std::collections::HashSet::new();
let mut counter: u64 = 0;
while out.len() < want {
let h = hash(&[DOMAIN_FS, root, &num_leaves.to_le_bytes(), &counter.to_le_bytes()]);
let mut word = [0u8; 8];
word.copy_from_slice(&h[..8]);
let idx = (u64::from_le_bytes(word) % domain as u64) as usize;
if seen.insert(idx) {
out.push(idx);
}
counter += 1;
}
out.sort_unstable();
out
}
pub fn prove(model: &TracedMlp, input: &[f64], config: SpotCheckConfig) -> SpotCheckProof {
assert_eq!(input.len(), model.input_dim, "input width must match model");
let shape = model.shape();
let layout = Layout::new(model.input_dim, &shape);
let mut cells = vec![0i64; layout.total];
let mut prev: Vec<f64> = Vec::with_capacity(input.len());
for (i, &x) in input.iter().enumerate() {
let qi = q(x);
cells[layout.input_cell(i)] = qi;
prev.push(dq(qi));
}
for (l, layer) in model.layers.iter().enumerate() {
assert_eq!(layer.weights.len(), layout.out[l], "layer {l} out width");
let mut acts = Vec::with_capacity(layout.out[l]);
for (o, row) in layer.weights.iter().enumerate() {
assert_eq!(row.len(), layout.fan_in[l], "layer {l} neuron {o} fan-in");
let mut acc = 0.0;
for (i, &w) in row.iter().enumerate() {
let wq = dq(q(w));
cells[layout.weight_cell(l, o, i)] = q(w);
acc += wq * prev[i];
}
let bq = dq(q(layer.biases[o]));
cells[layout.bias_cell(l, o)] = q(layer.biases[o]);
acc += bq;
if layer.relu && acc < 0.0 {
acc = 0.0;
}
let aq = q(acc);
cells[layout.act_cell(l, o)] = aq;
acts.push(dq(aq));
}
prev = acts;
}
let tree = MerkleTree::build(&cells);
let root = tree.root();
let num_leaves = cells.len() as u64;
let output = prev.clone();
let mut needed: std::collections::BTreeSet<usize> = std::collections::BTreeSet::new();
for i in 0..layout.input_dim {
needed.insert(layout.input_cell(i));
}
let last = shape.len() - 1;
for o in 0..layout.out[last] {
needed.insert(layout.act_cell(last, o));
}
let neurons = layout.computed_neurons();
let num_queries = config.num_queries.min(neurons.len());
for &nid in &fiat_shamir(&root, num_leaves, num_queries, neurons.len()) {
let (l, o) = neurons[nid];
needed.insert(layout.act_cell(l, o));
needed.insert(layout.bias_cell(l, o));
for i in 0..layout.fan_in[l] {
needed.insert(layout.weight_cell(l, o, i));
needed.insert(layout.prev_act_cell(l, i));
}
}
let opened = needed
.into_iter()
.map(|idx| OpenedCell {
index: idx as u64,
value: cells[idx],
path: tree.open(idx),
})
.collect();
SpotCheckProof {
root,
num_leaves,
input_dim: model.input_dim,
shape,
num_queries,
output,
cells: opened,
}
}
impl SpotCheckProof {
pub fn verify(&self, input: &[f64]) -> bool {
self.verify_with_min(input, 0)
}
pub fn verify_with_min(&self, input: &[f64], min_queries: usize) -> bool {
if input.len() != self.input_dim || self.shape.is_empty() {
return false;
}
if self.num_queries < min_queries {
return false;
}
let layout = Layout::new(self.input_dim, &self.shape);
if layout.total as u64 != self.num_leaves {
return false;
}
let mut map = std::collections::HashMap::with_capacity(self.cells.len());
for c in &self.cells {
if c.index >= self.num_leaves {
return false;
}
if merkle_root_from(c.index, c.value, &c.path, self.num_leaves) != self.root {
return false;
}
map.insert(c.index as usize, c.value);
}
let get = |idx: usize| map.get(&idx).copied();
for (i, &x) in input.iter().enumerate() {
if get(layout.input_cell(i)) != Some(q(x)) {
return false;
}
}
let last = self.shape.len() - 1;
if self.output.len() != layout.out[last] {
return false;
}
for (o, &y) in self.output.iter().enumerate() {
if get(layout.act_cell(last, o)) != Some(q(y)) {
return false;
}
}
let neurons = layout.computed_neurons();
for &nid in &fiat_shamir(&self.root, self.num_leaves, self.num_queries, neurons.len()) {
let (l, o) = neurons[nid];
let bias = match get(layout.bias_cell(l, o)) {
Some(b) => dq(b),
None => return false,
};
let mut acc = bias;
for i in 0..layout.fan_in[l] {
let (w, a) = match (get(layout.weight_cell(l, o, i)), get(layout.prev_act_cell(l, i))) {
(Some(w), Some(a)) => (dq(w), dq(a)),
_ => return false,
};
acc += w * a;
}
if self.shape[l].relu && acc < 0.0 {
acc = 0.0;
}
if get(layout.act_cell(l, o)) != Some(q(acc)) {
return false;
}
}
true
}
pub fn queries(&self) -> usize {
self.num_queries
}
}
#[cfg(test)]
mod tests {
use super::*;
fn net() -> TracedMlp {
TracedMlp::new(
3,
vec![
DenseLayer::new(
vec![
vec![0.5, -0.2, 0.1],
vec![-0.3, 0.4, 0.2],
vec![0.1, 0.1, -0.5],
vec![0.2, -0.1, 0.3],
],
vec![0.05, -0.05, 0.0, 0.1],
true,
),
DenseLayer::new(
vec![vec![0.3, -0.2, 0.5, 0.1], vec![-0.1, 0.4, -0.3, 0.2]],
vec![0.0, 0.01],
false,
),
],
)
}
fn cfg() -> SpotCheckConfig {
SpotCheckConfig { num_queries: 64 }
}
#[test]
fn honest_proof_verifies() {
let m = net();
let input = [1.0, -2.0, 0.5];
let proof = prove(&m, &input, cfg());
assert!(proof.verify(&input));
}
#[test]
fn proof_output_matches_a_direct_forward_pass() {
let m = net();
let input = [0.7, 0.3, -0.4];
let proof = prove(&m, &input, cfg());
let mut prev: Vec<f64> = input.iter().map(|&x| dq(q(x))).collect();
for layer in &m.layers {
let mut acts = Vec::new();
for (o, row) in layer.weights.iter().enumerate() {
let mut acc = dq(q(layer.biases[o]));
for (i, &w) in row.iter().enumerate() {
acc += dq(q(w)) * prev[i];
}
if layer.relu && acc < 0.0 {
acc = 0.0;
}
acts.push(dq(q(acc)));
}
prev = acts;
}
assert_eq!(proof.output, prev);
}
#[test]
fn verify_fails_on_a_different_input() {
let m = net();
let proof = prove(&m, &[1.0, -2.0, 0.5], cfg());
assert!(!proof.verify(&[1.0, -2.0, 0.6]));
}
#[test]
fn tampering_the_claimed_output_is_rejected() {
let m = net();
let input = [1.0, -2.0, 0.5];
let mut proof = prove(&m, &input, cfg());
proof.output[0] += 1.0; assert!(!proof.verify(&input));
}
#[test]
fn tampering_a_committed_cell_breaks_the_merkle_path() {
let m = net();
let input = [1.0, -2.0, 0.5];
let mut proof = prove(&m, &input, cfg());
if let Some(c) = proof.cells.iter_mut().find(|c| c.value != 0) {
c.value = c.value.wrapping_add(1);
}
assert!(!proof.verify(&input));
}
#[test]
fn forging_a_neuron_consistently_is_caught_by_a_spot_check() {
let m = net();
let input = [1.0, -2.0, 0.5];
let proof = prove(&m, &input, cfg());
let layout = Layout::new(proof.input_dim, &proof.shape);
let target = layout.act_cell(0, 0); let mut cells = vec![0i64; layout.total];
for c in &proof.cells {
cells[c.index as usize] = c.value;
}
cells[target] += 500_000; let tree = MerkleTree::build(&cells);
let mut forged = proof.clone();
forged.root = tree.root();
for c in forged.cells.iter_mut() {
c.value = cells[c.index as usize];
c.path = tree.open(c.index as usize);
}
assert!(!forged.verify(&input));
}
#[test]
fn merkle_open_and_verify_round_trip_including_odd_levels() {
let cells: Vec<i64> = vec![10, 20, 30, 40, 50];
let tree = MerkleTree::build(&cells);
let root = tree.root();
for (i, &v) in cells.iter().enumerate() {
let path = tree.open(i);
assert_eq!(merkle_root_from(i as u64, v, &path, cells.len() as u64), root);
}
}
}