Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -94,5 +94,8 @@ flamegraph = ["pprof2/flamegraph", "pprof2/criterion"]
experimental = []
evm = []
io = []
# Use the original inverse-logup memory-check in ppSNARK instead of Logup-GKR.
# Default (feature off) uses the Logup-GKR memory-check.
logup-no-gkr = []
# Enables insecure setup methods that generate random tau. Only for testing!
test-utils = []
2 changes: 1 addition & 1 deletion benches/sumcheckeq.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
use criterion::*;
use nova_snark::{
provider::Bn256EngineKZG,
spartan::{ppsnark::MemorySumcheckInstance, SumcheckEngine},
spartan::{mem_check_logup::MemorySumcheckInstance, SumcheckEngine},
traits::Engine,
};
use rayon::iter::{IntoParallelIterator, ParallelIterator};
Expand Down
2 changes: 1 addition & 1 deletion src/frontend/gadgets/num.rs
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,7 @@ impl<Scalar: PrimeField> AllocatedNum<Scalar> {
cs.namespace(|| format!("run ending at {i}")),
&current_run,
)?);
current_run.truncate(0);
current_run.clear();
}

// If `last_run` is true, `a` must be false, or it would
Expand Down
6 changes: 3 additions & 3 deletions src/frontend/test_shape_cs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ where
let mut s = String::new();

for input in &self.inputs {
writeln!(s, "INPUT {}", &input).unwrap()
writeln!(s, "INPUT {}", input).unwrap()
}

let negone = -<E::Scalar>::ONE;
Expand Down Expand Up @@ -174,10 +174,10 @@ where

match var.0.get_unchecked() {
Index::Input(i) => {
write!(s, "`I{}`", &self.inputs[i]).unwrap();
write!(s, "`I{}`", self.inputs[i]).unwrap();
}
Index::Aux(i) => {
write!(s, "`A{}`", &self.aux[i]).unwrap();
write!(s, "`A{}`", self.aux[i]).unwrap();
}
}
}
Expand Down
95 changes: 95 additions & 0 deletions src/spartan/logup_gkr/fraction.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
//! Projective fraction arithmetic for the Logup-GKR fractional-sum tree.
//!
//! A fraction `num/den` is kept in projective form so the GKR circuit never
//! performs a field inversion: the fraction-add gate combines two children
//! `(n_l, d_l)` and `(n_r, d_r)` into `(n_l·d_r + n_r·d_l, d_l·d_r)`. This is the
//! root of why Logup-GKR avoids the inverse-polynomial commitments that the
//! current ppSNARK memory-check pays for.

use crate::traits::evm_serde::{CustomSerdeTrait, EvmCompatSerde};
use core::iter::Sum;
use core::ops::Add;
use ff::Field;
use serde::{Deserialize, Serialize};
use serde_with::serde_as;

/// A projective fraction `numerator / denominator` over the engine scalar field.
///
/// Equality of the represented rationals is cross-multiplicative
/// (`a/b == c/d` iff `a·d == c·b`); this type does not normalize.
#[serde_as]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(bound = "F: CustomSerdeTrait")]
pub struct Fraction<F: Field> {
/// Numerator.
#[serde_as(as = "EvmCompatSerde")]
pub num: F,
/// Denominator.
#[serde_as(as = "EvmCompatSerde")]
pub den: F,
}

impl<F: Field> Fraction<F> {
/// Creates a new projective fraction `num/den`.
pub fn new(num: F, den: F) -> Self {
Self { num, den }
}

/// The additive identity `0/1` for projective fraction addition.
pub fn zero() -> Self {
Self {
num: F::ZERO,
den: F::ONE,
}
}
}

/// Projective fraction addition (the 2-to-1 gate): `a/b + c/d = (a·d + c·b)/(b·d)`.
///
/// `Fraction` is `Copy`, so `+` takes values with no cost.
impl<F: Field> Add for Fraction<F> {
type Output = Self;

fn add(self, rhs: Self) -> Self {
Self {
num: self.num * rhs.den + rhs.num * self.den,
den: self.den * rhs.den,
}
}
}

/// Sum of a sequence of fractions, folding from the identity `0/1`. Lets a whole
/// tree level be reduced with `.sum()`.
impl<F: Field> Sum for Fraction<F> {
fn sum<I: Iterator<Item = Self>>(iter: I) -> Self {
iter.fold(Self::zero(), |a, b| a + b)
}
}

#[cfg(all(test, feature = "evm"))]
mod evm_serde_tests {
use super::Fraction;
use crate::traits::Engine;

type E = crate::provider::Bn256EngineKZG;
type Fr = <E as Engine>::Scalar;

#[test]
fn fraction_has_big_endian_scalar_golden_encoding() {
let fraction = Fraction::new(Fr::from(1), Fr::from(2));
let config = bincode::config::legacy()
.with_big_endian()
.with_fixed_int_encoding();
let bytes = bincode::serde::encode_to_vec(fraction, config).expect("serialize fraction");

let mut expected = [0u8; 64];
expected[31] = 1;
expected[63] = 2;
assert_eq!(bytes, expected);

let (decoded, consumed): (Fraction<Fr>, usize) =
bincode::serde::decode_from_slice(&bytes, config).expect("deserialize fraction");
assert_eq!(decoded, fraction);
assert_eq!(consumed, expected.len());
}
}
215 changes: 215 additions & 0 deletions src/spartan/logup_gkr/layer.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
//! Layer type for the Logup-GKR fractional-sum tree.
//!
//! One [`Layer`] is a single level of a tree: two multilinear polynomials
//! (numerator, denominator). The same type serves the input layer, every
//! internal layer, and the output layer — they differ only in height (the
//! coefficient length halves each level, `N → N/2 → … → 1`). The same `Layer`
//! type serves all levels, with no separate input/output variants.
//!
//! ppSNARK constructs separate table and access input layers for each of its row
//! and column relations. Their leaves are `(num, den) = (ts, T+r)` on a table
//! side and `(-1, W+r)` on an access side.
//!
//! # Endianness (critical): MSB-first, pairs are `i` and `i+n`
//! Nova's MLEs are **MSB-first** (`bind_poly_var_top` folds the top variable by
//! `split_at(len/2)`). So a level's two children are the halves `x[i]` (top bit
//! 0) and `x[i+n]` (top bit 1), `n = len/2` — NOT `x[2i]` / `x[2i+1]`. Folding
//! this way keeps the tree consistent with Nova's sumcheck round order
//! (`eval_point` challenges bind MSB→LSB), so the GKR point matches the sumcheck
//! point.

use crate::spartan::logup_gkr::fraction::Fraction;
use crate::spartan::polys::multilinear::MultilinearPolynomial;
use crate::spartan::polys::multilinear::MultilinearPolynomial as MLE;
use crate::traits::Engine;
use rayon::prelude::*;

/// Parallelize `fold_up` only above this many output cells. A fold cell is a
/// few field multiplications, so rayon's scheduling overhead dominates on
/// smaller layers. This threshold is intentionally higher than the crate's
/// `PARALLEL_THRESHOLD`, which is tuned for more expensive per-element curve
/// operations such as MSMs.
const FOLD_PARALLEL_THRESHOLD: usize = 1 << 16;

/// One level of a fractional-sum tree: parallel numerator and denominator
/// multilinear polynomials over `{0,1}^{log len}`.
///
/// Storage is **two** MLEs. The "left/right" children the fraction gate consumes
/// are the two halves of these MLEs (`x[i]` and `x[i+n]`, `n = len/2`; MSB-first,
/// see module docs), read during the fold. So a layer holds 2 polynomials; a
/// per-layer *claim* exposes 4 values (`nL, nR, dL, dR`; see
/// `proof::LayerFinalClaim`). Do not confuse the two.
///
/// Invariant: `num.len() == den.len()` and both lengths are a power of two.
///
/// # Numerator/denominator convention (read before constructing)
/// `num` is the **signed weight** side (`ts` on a table side, `-1` on an access
/// side); `den` is the **fingerprint** side (`T+r` / `W+r`, never
/// inverted). To make an accidental swap impossible, this type has **no
/// positional constructor**: build it with field-init syntax so `num`/`den` are
/// named explicitly, e.g. `Layer { num: ts, den: t_plus_r }`.
pub struct Layer<E: Engine> {
/// Numerator = signed weight (`ts` on a table side, `-1` on an access side).
pub num: MultilinearPolynomial<E::Scalar>,
/// Denominator = fingerprint (`T+r` / `W+r`), never inverted.
pub den: MultilinearPolynomial<E::Scalar>,
}

impl<E: Engine> Layer<E> {
/// Number of hypercube variables of this layer (`log2(len)`).
pub fn num_vars(&self) -> usize {
self.num.get_num_vars()
}

/// True when the layer has collapsed to a single cell (the output/root).
pub fn is_output(&self) -> bool {
self.num.len() == 1
}

/// Folds this layer one level up via the fraction-add gate, MSB-first: the two
/// children of output cell `i` are the halves `i` (top bit 0) and `i+n` (top
/// bit 1), `n = len/2`. Returns the parent layer of half the height.
///
/// `(num[i]/den[i]) + (num[i+n]/den[i+n]) =
/// ((num[i]·den[i+n] + num[i+n]·den[i]) / (den[i]·den[i+n]))`
pub fn fold_up(&self) -> Self {
let len = self.num.len();
debug_assert_eq!(len, self.den.len());
debug_assert!(len >= 2 && len.is_power_of_two());
let n = len / 2;

// Each output cell i is the fraction-add of the two MSB-halves (child cells
// i and i+n) — independent across i, so parallelize above the threshold.
let fold = |i: usize| -> (E::Scalar, E::Scalar) {
let child = Fraction::new(self.num.Z[i], self.den.Z[i])
+ Fraction::new(self.num.Z[i + n], self.den.Z[i + n]);
(child.num, child.den)
};

let (next_num, next_den): (Vec<_>, Vec<_>) = if n < FOLD_PARALLEL_THRESHOLD {
(0..n).map(fold).unzip()
} else {
(0..n).into_par_iter().map(fold).unzip()
};

Self {
num: MLE::new(next_num),
den: MLE::new(next_den),
}
}

/// Builds the full tree from this input layer, leaf→root. Result has
/// `log2(len) + 1` entries; `[0]` is the input layer, the last is the single
/// output cell.
pub fn build_tree(self) -> Vec<Self> {
let depth = self.num.get_num_vars() + 1;
let mut layers = Vec::with_capacity(depth);
let mut cur = self;
loop {
if cur.is_output() {
layers.push(cur);
break;
}
let next = cur.fold_up();
layers.push(cur);
cur = next;
}
layers
}

/// The output cell's fraction `(num, den)` as a two-element claim vector,
/// used to seed the verifier's fold-down (`initial_claims`).
pub fn output_fraction(&self) -> (E::Scalar, E::Scalar) {
debug_assert!(self.is_output());
(self.num.Z[0], self.den.Z[0])
}
}

#[cfg(test)]
mod tests {
use super::*;
use crate::provider::Bn256EngineKZG;
use ff::Field;

type E = Bn256EngineKZG;
type Fr = <E as Engine>::Scalar;

// Reference: the rational sum Σ num[i]/den[i] as a single reduced fraction,
// computed with real field inversion (only for the test oracle).
fn ref_sum(num: &[Fr], den: &[Fr]) -> (Fr, Fr) {
// accumulate as projective fraction a/b + c/d = (ad+cb, bd)
let mut acc = (Fr::ZERO, Fr::ONE);
for i in 0..num.len() {
acc = (acc.0 * den[i] + num[i] * acc.1, acc.1 * den[i]);
}
acc
}

// Cross-multiplication equality for projective fractions.
fn frac_eq((n0, d0): (Fr, Fr), (n1, d1): (Fr, Fr)) -> bool {
n0 * d1 == n1 * d0
}

fn layer_from(num: Vec<u64>, den: Vec<u64>) -> Layer<E> {
Layer {
num: MultilinearPolynomial::new(num.into_iter().map(Fr::from).collect()),
den: MultilinearPolynomial::new(den.into_iter().map(Fr::from).collect()),
}
}

#[test]
fn fold_up_matches_reference_n4() {
let num = vec![1u64, 2, 3, 4];
let den = vec![5u64, 6, 7, 8];
let l = layer_from(num.clone(), den.clone());
let tree = l.build_tree();
assert_eq!(tree.len(), 3); // 2 vars → depth 3
let root = tree.last().unwrap().output_fraction();
let expected = ref_sum(
&num.iter().map(|&x| Fr::from(x)).collect::<Vec<_>>(),
&den.iter().map(|&x| Fr::from(x)).collect::<Vec<_>>(),
);
assert!(
frac_eq(root, expected),
"tree root fraction must equal the reference rational sum"
);
}

#[test]
fn fold_up_matches_reference_n8() {
let num: Vec<u64> = vec![3, 1, 4, 1, 5, 9, 2, 6];
let den: Vec<u64> = vec![2, 7, 1, 8, 2, 8, 1, 8];
let l = layer_from(num.clone(), den.clone());
let tree = l.build_tree();
assert_eq!(tree.len(), 4);
let root = tree.last().unwrap().output_fraction();
let expected = ref_sum(
&num.iter().map(|&x| Fr::from(x)).collect::<Vec<_>>(),
&den.iter().map(|&x| Fr::from(x)).collect::<Vec<_>>(),
);
assert!(frac_eq(root, expected));
}

#[test]
fn logup_balance_gives_zero_numerator() {
// A balanced multiset: table {a,b} with multiplicities {1,1}, lookups {a,b}.
// Σ 1/(α-a) + 1/(α-b) - 1/(α-a) - 1/(α-b) = 0. Encode as one instance's
// input layer with num = [+1,+1,-1,-1], den = [α-a, α-b, α-a, α-b].
let alpha = Fr::from(100);
let a = Fr::from(7);
let b = Fr::from(9);
let num = vec![Fr::ONE, Fr::ONE, -Fr::ONE, -Fr::ONE];
let den = vec![alpha - a, alpha - b, alpha - a, alpha - b];
let l = Layer::<E> {
num: MultilinearPolynomial::new(num),
den: MultilinearPolynomial::new(den),
};
let root = l.build_tree().last().unwrap().output_fraction();
assert_eq!(
root.0,
Fr::ZERO,
"balanced logup must have zero root numerator"
);
assert_ne!(root.1, Fr::ZERO, "root denominator must be non-zero");
}
}
37 changes: 37 additions & 0 deletions src/spartan/logup_gkr/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
//! Logup-GKR fractional-sum memory-check argument.
//!
//! Replaces the inverse-logup memory-check in `ppsnark.rs` (the
//! `MemorySumcheckInstance` 6-route sumcheck + 4 inverse-polynomial
//! commitments) with four equal-height fractional-sum GKR trees: the table and
//! access sides of the row and column relations. Projective fractions keep the
//! circuit inversion-free and eliminate the four inverse-polynomial
//! commitments.
//!
//! ## Module map
//! - [`fraction`]: projective fraction + 2-to-1 gate (pure).
//! - [`layer`]: the `Layer` type (one tree level: num/den MLEs).
//! - [`proof`]: proof/claim interface.
//! - [`prover`]: fold-up + batched sumcheck prover.
//! - [`verifier`]: fold-down + root check, emits the shared opening claim.
//!
//! ## Boundary with ppSNARK (host reconcile contract)
//! The argument owns no commitment scheme. Its verifier returns a
//! [`proof::LogupGkrOpeningClaim`]: a single shared `eval_point` plus the
//! four input-layer fractions `openings`, ordered `[row_table, row_access,
//! col_table, col_access]`. The **host** then:
//! 1. rerandomizes the seven claimed columns `[L_row, L_col, addr_row, addr_col,
//! ts_row, ts_col, mem_col]` from `eval_point` into the inner sumcheck and
//! binds them at `r_inner_batched` through the batched PCS opening;
//! 2. recomputes the four fractions from those claims (`num = ts` on the table
//! sides, `num = -1` on the access sides) and checks them against `openings`;
//! 3. runs the `0/den` zero-sum balance check.
//!
//! Steps 2-3 are the host's job, never the GKR verifier's.

pub mod fraction;
pub mod layer;
pub mod proof;
pub mod prover;
pub mod verifier;

pub use proof::{LogupGkrOpeningClaim, LogupGkrProof};
Loading
Loading