diff --git a/Cargo.lock b/Cargo.lock index 7dea3cc57..3bedaeef4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4656,6 +4656,7 @@ dependencies = [ "provekit-backend-goldilocks", "provekit-common", "provekit-prover", + "provekit-spark", "provekit-verifier", "tracing", "tracing-flame", @@ -7138,7 +7139,7 @@ dependencies = [ [[package]] name = "whir" version = "0.1.0" -source = "git+https://github.com/worldfnd/whir.git?rev=afec1fac27c1c8cc7331aa5668bc9800f104d92c#afec1fac27c1c8cc7331aa5668bc9800f104d92c" +source = "git+https://github.com/worldfnd/whir.git?rev=d2b16529d0a4fad4f53d088281e9062898d35f5b#d2b16529d0a4fad4f53d088281e9062898d35f5b" dependencies = [ "ark-ff", "ark-serialize", diff --git a/Cargo.toml b/Cargo.toml index 01ff19afe..b420fb154 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -214,4 +214,4 @@ spongefish = { package = "provekit-spongefish", version = "=0.2.0", features = [ "sha2", ] } spongefish-pow = { package = "provekit-spongefish-pow", version = "=0.2.0" } -whir = { git = "https://github.com/worldfnd/whir.git", rev = "afec1fac27c1c8cc7331aa5668bc9800f104d92c", features = ["tracing", "rs_in_order"] } +whir = { git = "https://github.com/worldfnd/whir.git", rev = "d2b16529d0a4fad4f53d088281e9062898d35f5b", features = ["tracing", "rs_in_order"] } diff --git a/provekit/common/src/file/binary_format.rs b/provekit/common/src/file/binary_format.rs index 3e36b1906..a14bcf788 100644 --- a/provekit/common/src/file/binary_format.rs +++ b/provekit/common/src/file/binary_format.rs @@ -18,16 +18,16 @@ pub const XZ_MAGIC: [u8; 6] = [0xfd, 0x37, 0x7a, 0x58, 0x5a, 0x00]; // --------------------------------------------------------------------------- pub const PROVER_FORMAT: [u8; 8] = *b"PrvKitPr"; -pub const PROVER_VERSION: (u16, u16) = (2, 0); +pub const PROVER_VERSION: (u16, u16) = (3, 0); pub const VERIFIER_FORMAT: [u8; 8] = *b"PrvKitVr"; -pub const VERIFIER_VERSION: (u16, u16) = (2, 1); +pub const VERIFIER_VERSION: (u16, u16) = (3, 0); pub const NOIR_PROOF_SCHEME_FORMAT: [u8; 8] = *b"NrProScm"; -pub const NOIR_PROOF_SCHEME_VERSION: (u16, u16) = (2, 0); +pub const NOIR_PROOF_SCHEME_VERSION: (u16, u16) = (3, 0); pub const NOIR_PROOF_FORMAT: [u8; 8] = *b"NPSProof"; -pub const NOIR_PROOF_VERSION: (u16, u16) = (2, 0); +pub const NOIR_PROOF_VERSION: (u16, u16) = (3, 0); pub const SPARK_PROOF_FORMAT: [u8; 8] = *b"SparkPrf"; pub const SPARK_PROOF_VERSION: (u16, u16) = (1, 0); diff --git a/provekit/common/src/joint_claims.rs b/provekit/common/src/joint_claims.rs new file mode 100644 index 000000000..33133f330 --- /dev/null +++ b/provekit/common/src/joint_claims.rs @@ -0,0 +1,325 @@ +//! Linear claims for the virtual two-stage witness `[w1, w2]`. +//! +//! Each block has size `2^m`. Matrix columns split at `w1_size` before padding. +//! Claim order is A, B, C, public inputs, then challenges. + +use { + crate::{ + prefix_covector::{build_prefix_covectors, make_challenge_weight, make_public_weight}, + JointLinearForm, + }, + anyhow::{ensure, Result}, + ark_ff::Field, + whir::{ + algebra::{embedding::Embedding, mixed_dot}, + buffer::{Buffer, BufferOps}, + }, +}; + +/// Compute matrix claims from both source buffers without concatenating the +/// witnesses. +pub fn compute_joint_matrix_evals( + embedding: &M, + alphas: &[Vec; 3], + w1_size: usize, + witnesses: [&Buffer; 2], +) -> Result<[M::Target; 3]> { + ensure!( + alphas.iter().all(|alpha| alpha.len() == alphas[0].len()), + "Joint matrix covectors must have equal lengths" + ); + ensure!( + w1_size <= witnesses[0].len(), + "First witness buffer is too short" + ); + for alpha in alphas { + ensure!( + alpha.len() >= w1_size, + "Witness split exceeds matrix columns" + ); + ensure!( + alpha.len() - w1_size <= witnesses[1].len(), + "Second witness buffer is too short" + ); + } + Ok(alphas.each_ref().map(|alpha| { + let (left, right) = alpha.split_at(w1_size); + mixed_dot(embedding, left, &witnesses[0].to_slice()[..left.len()]) + + mixed_dot(embedding, right, &witnesses[1].to_slice()[..right.len()]) + })) +} + +/// Build combined matrix forms and optional binding forms. +/// +/// Public inputs belong to the first block. Challenge offsets refer to the +/// second block. The R1CS constant at index zero accompanies each public-input +/// form. +pub fn build_joint_forms( + m: usize, + w1_size: usize, + alphas: [Vec; 3], + binding_challenge: F, + num_public_inputs: usize, + challenge_offsets: &[usize], +) -> Result>> { + ensure!( + m > 0 && m < usize::BITS as usize - 1, + "Invalid joint block dimension" + ); + let block_size = 1usize << m; + let total_size = alphas[0].len(); + ensure!( + alphas.iter().all(|alpha| alpha.len() == total_size), + "Joint matrix covectors must have equal lengths" + ); + ensure!( + w1_size <= total_size, + "Witness split exceeds matrix columns" + ); + ensure!( + w1_size <= block_size && total_size - w1_size <= block_size, + "Witness block exceeds the joint block size" + ); + if num_public_inputs > 0 { + ensure!( + num_public_inputs < w1_size, + "Public inputs exceed the first witness block" + ); + } + ensure!( + challenge_offsets + .iter() + .all(|&offset| offset < total_size - w1_size), + "Challenge offset exceeds the second witness block" + ); + + let mut forms = Vec::with_capacity(5); + for mut alpha in alphas { + let right = alpha.split_off(w1_size); + let mut blocks = build_prefix_covectors(m, [alpha, right]).into_iter(); + let left = blocks.next().expect("first matrix block"); + let right = blocks.next().expect("second matrix block"); + forms.push(JointLinearForm::new(left, right)); + } + if num_public_inputs > 0 { + forms.push(JointLinearForm::left_only(make_public_weight( + binding_challenge, + num_public_inputs, + m, + ))); + } + if !challenge_offsets.is_empty() { + forms.push(JointLinearForm::right_only(make_challenge_weight( + binding_challenge, + challenge_offsets, + m, + ))); + } + Ok(forms) +} + +#[cfg(test)] +mod tests { + use { + super::*, + whir::algebra::{fields::Field64 as F, linear_form::LinearForm, multilinear_extend}, + }; + + fn fields(values: &[u64]) -> Vec { + values.iter().copied().map(F::from).collect() + } + + fn dense(form: &JointLinearForm) -> Vec { + let mut weights = vec![F::from(0); form.size()]; + form.accumulate(&mut weights, F::from(1)); + weights + } + + fn dot(weights: &[F], witness: &[F]) -> F { + weights.iter().zip(witness).map(|(a, b)| *a * b).sum() + } + + #[test] + fn joint_claims_prove_three_times_four_equals_twelve() { + // Original columns: w1 = [1, 3, 12], w2 = [4, 9]. + // A selects 3. B selects 4. C selects 12. + let forms = build_joint_forms( + 2, + 3, + [ + fields(&[0, 1, 0, 0, 0]), + fields(&[0, 0, 0, 1, 0]), + fields(&[0, 0, 1, 0, 0]), + ], + F::from(2), + 0, + &[], + ) + .unwrap(); + let witness = fields(&[1, 3, 12, 0, 4, 9, 0, 0]); + let values: Vec = forms + .iter() + .map(|form| dot(&dense(form), &witness)) + .collect(); + assert_eq!(values, fields(&[3, 4, 12])); + assert_eq!(values[0] * values[1], values[2]); + + // Changing either selected witness breaks the same constraint. + for changed_index in [1, 4] { + let mut changed = witness.clone(); + changed[changed_index] += F::from(1); + let values: Vec = forms + .iter() + .map(|form| dot(&dense(form), &changed)) + .collect(); + assert_ne!(values[0] * values[1], values[2]); + } + } + + #[test] + fn joint_claims_add_stage_contributions_without_selector_weights() { + let forms = build_joint_forms( + 2, + 3, + [ + fields(&[2, 3, 4, 11, 13]), + fields(&[0, 1, 0, 0, 1]), + fields(&[0, 0, 1, 1, 0]), + ], + F::from(2), + 0, + &[], + ) + .unwrap(); + let witness = fields(&[1, 2, 3, 0, 5, 7, 0, 0]); + // A = (2 + 6 + 12) + (55 + 91) = 166. + let values: Vec = forms + .iter() + .map(|form| dot(&dense(form), &witness)) + .collect(); + assert_eq!(values, fields(&[166, 9, 8])); + } + + #[test] + fn joint_matrix_evaluations_use_both_buffers_without_concatenation() { + use whir::algebra::embedding::Identity; + + let alphas = [ + fields(&[2, 3, 4, 11, 13]), + fields(&[0, 1, 0, 0, 1]), + fields(&[0, 0, 1, 1, 0]), + ]; + let left = Buffer::from(fields(&[1, 2, 3, 0])); + let right = Buffer::from(fields(&[5, 7, 0, 0])); + let values = + compute_joint_matrix_evals(&Identity::new(), &alphas, 3, [&left, &right]).unwrap(); + assert_eq!(values, [F::from(166), F::from(9), F::from(8)]); + assert!(compute_joint_matrix_evals(&Identity::new(), &alphas, 3, [ + &Buffer::from(fields(&[1, 2])), + &right, + ]) + .is_err()); + assert!(compute_joint_matrix_evals(&Identity::new(), &alphas, 3, [ + &left, + &Buffer::from(fields(&[5])), + ]) + .is_err()); + } + + #[test] + fn joint_matrix_evaluations_preserve_extension_field_coefficients() { + use whir::algebra::{embedding::Basefield, fields::Field64_3 as E}; + + let coefficient = + E::from_base_prime_field_elems([F::from(2), F::from(3), F::from(5)]).unwrap(); + let alphas = std::array::from_fn(|_| vec![coefficient, coefficient * E::from(2)]); + let values = compute_joint_matrix_evals(&Basefield::::new(), &alphas, 1, [ + &Buffer::from(fields(&[3])), + &Buffer::from(fields(&[7])), + ]) + .unwrap(); + // Each result is 3*c + 7*(2*c) = 17*c, including non-base coefficients. + assert_eq!(values, [coefficient * E::from(17); 3]); + } + + #[test] + fn joint_claims_keep_public_inputs_left_and_scattered_challenges_right() { + let forms = build_joint_forms( + 2, + 3, + [fields(&[0; 6]), fields(&[0; 6]), fields(&[0; 6])], + F::from(2), + 1, + &[2, 0], + ) + .unwrap(); + assert_eq!(forms.len(), 5); + assert_eq!(dense(&forms[3]), fields(&[1, 2, 0, 0, 0, 0, 0, 0])); + assert_eq!(dense(&forms[4]), fields(&[0, 0, 0, 0, 2, 0, 1, 0])); + + let witness = fields(&[1, 3, 12, 0, 4, 9, 5, 0]); + assert_eq!(dot(&dense(&forms[3]), &witness), F::from(7)); // 1 + 2*3 + assert_eq!(dot(&dense(&forms[4]), &witness), F::from(13)); // 5 + 2*4 + } + + #[test] + fn joint_claims_final_matrix_evaluations_match_the_combined_dense_matrix() { + let forms = build_joint_forms( + 2, + 3, + [ + fields(&[2, 3, 4, 11, 13]), + fields(&[0, 1, 0, 0, 1]), + fields(&[0, 0, 1, 1, 0]), + ], + F::from(2), + 0, + &[], + ) + .unwrap(); + let point = fields(&[2, 3, 5]); + for form in &forms { + assert_eq!( + form.mle_evaluate(&point), + multilinear_extend(&dense(form), &point) + ); + } + } + + #[test] + fn joint_claims_support_a_larger_second_stage_and_empty_stage() { + for split in [0, 1, 3, 4] { + let forms = build_joint_forms( + 2, + split, + std::array::from_fn(|_| fields(&[1, 2, 3, 4])), + F::from(2), + 0, + &[], + ) + .unwrap(); + let mut expected = vec![F::from(0); 8]; + expected[..split].copy_from_slice(&fields(&[1, 2, 3, 4])[..split]); + expected[4..4 + 4 - split].copy_from_slice(&fields(&[1, 2, 3, 4])[split..]); + assert_eq!(dense(&forms[0]), expected); + } + } + + #[test] + fn joint_claims_reject_invalid_layouts_and_binding_positions() { + let alphas = || std::array::from_fn(|_| fields(&[1, 2, 3, 4, 5])); + assert!(build_joint_forms(2, 6, alphas(), F::from(2), 0, &[]).is_err()); + assert!(build_joint_forms(2, 0, alphas(), F::from(2), 0, &[]).is_err()); + assert!(build_joint_forms(2, 3, alphas(), F::from(2), 3, &[]).is_err()); + assert!(build_joint_forms(2, 3, alphas(), F::from(2), 0, &[2]).is_err()); + assert!(build_joint_forms( + 2, + 3, + [fields(&[1; 5]), fields(&[1; 4]), fields(&[1; 5])], + F::from(2), + 0, + &[] + ) + .is_err()); + } +} diff --git a/provekit/common/src/joint_covector.rs b/provekit/common/src/joint_covector.rs new file mode 100644 index 000000000..47a4d935b --- /dev/null +++ b/provekit/common/src/joint_covector.rs @@ -0,0 +1,340 @@ +use {ark_ff::Field, whir::algebra::linear_form::LinearForm}; + +/// A linear form over two equal witness blocks, without a combined allocation. +/// +/// The first coordinate selects a block: `L(s, x) = (1 - s) left(x) + s +/// right(x)`. Each missing branch represents the zero linear form. +pub struct JointLinearForm { + left: Option>>, + right: Option>>, + domain_size: usize, +} + +impl JointLinearForm { + /// Combine two linear forms with equal domain sizes. + /// + /// # Panics + /// + /// Panics if sizes differ, a size is not a nonzero power of two, or their + /// sum overflows. + #[must_use] + pub fn new, R: LinearForm>(left: L, right: R) -> Self { + assert_eq!( + left.size(), + right.size(), + "JointLinearForm: branch sizes must match" + ); + let domain_size = Self::checked_domain_size(left.size()); + Self { + left: Some(Box::new(left)), + right: Some(Box::new(right)), + domain_size, + } + } + + /// Place a linear form in the first block, with a zero second block. + /// + /// # Panics + /// + /// Panics if the size is not a nonzero power of two or doubling it + /// overflows. + #[must_use] + pub fn left_only>(left: L) -> Self { + let domain_size = Self::checked_domain_size(left.size()); + Self { + left: Some(Box::new(left)), + right: None, + domain_size, + } + } + + /// Place a linear form in the second block, with a zero first block. + /// + /// # Panics + /// + /// Panics if the size is not a nonzero power of two or doubling it + /// overflows. + #[must_use] + pub fn right_only>(right: R) -> Self { + let domain_size = Self::checked_domain_size(right.size()); + Self { + left: None, + right: Some(Box::new(right)), + domain_size, + } + } + + fn checked_domain_size(block_size: usize) -> usize { + assert!( + block_size.is_power_of_two(), + "JointLinearForm: block size must be a nonzero power of two" + ); + block_size + .checked_mul(2) + .expect("JointLinearForm: combined domain size overflows") + } +} + +impl LinearForm for JointLinearForm { + fn size(&self) -> usize { + self.domain_size + } + + fn mle_evaluate(&self, point: &[F]) -> F { + assert_eq!( + point.len(), + self.domain_size.trailing_zeros() as usize, + "JointLinearForm: point dimension must match the combined domain" + ); + let (selector, rest) = point + .split_first() + .expect("the combined domain has two blocks"); + let left = self + .left + .as_ref() + .map_or(F::ZERO, |form| form.mle_evaluate(rest)); + let right = self + .right + .as_ref() + .map_or(F::ZERO, |form| form.mle_evaluate(rest)); + (F::ONE - selector) * left + *selector * right + } + + fn accumulate(&self, accumulator: &mut [F], scalar: F) { + assert_eq!( + accumulator.len(), + self.domain_size, + "JointLinearForm: accumulator size must match the combined domain" + ); + let (left, right) = accumulator.split_at_mut(self.domain_size / 2); + if let Some(form) = &self.left { + form.accumulate(left, scalar); + } + if let Some(form) = &self.right { + form.accumulate(right, scalar); + } + } +} + +#[cfg(test)] +mod tests { + use { + super::*, + crate::PrefixCovector, + ark_ff::AdditiveGroup, + whir::algebra::{fields::Field64, multilinear_extend}, + }; + + fn field(value: u64) -> Field64 { + Field64::from(value) + } + + fn prefix(values: &[u64], block_size: usize) -> PrefixCovector { + PrefixCovector::new(values.iter().copied().map(field).collect(), block_size) + } + + fn example() -> JointLinearForm { + JointLinearForm::new(prefix(&[2, 4], 2), prefix(&[10, 14], 2)) + } + + #[test] + fn selector_zero_reads_the_first_block_and_selector_one_reads_the_second_block() { + let form = example(); + + // At x = 3, the first block evaluates to 8, and the second evaluates to 22. + assert_eq!(form.size(), 4); + assert_eq!(form.mle_evaluate(&[field(0), field(3)]), field(8)); + assert_eq!(form.mle_evaluate(&[field(1), field(3)]), field(22)); + } + + #[test] + fn non_boolean_selector_uses_the_same_linear_combination() { + let form = example(); + + // At s = 2 and x = 3, the result is (1 - 2) * 8 + 2 * 22 = 36. + assert_eq!(form.mle_evaluate(&[field(2), field(3)]), field(36)); + } + + #[test] + fn joint_evaluation_matches_the_dense_vector_at_every_boolean_point() { + let form = JointLinearForm::new(prefix(&[2, 3, 5, 7], 4), prefix(&[11, 13, 17, 19], 4)); + let dense: Vec<_> = [2, 3, 5, 7, 11, 13, 17, 19].map(field).into(); + + for index in 0..8 { + let point = [ + field((index >> 2) & 1), + field((index >> 1) & 1), + field(index & 1), + ]; + assert_eq!( + form.mle_evaluate(&point), + dense[index as usize], + "index {index}" + ); + assert_eq!( + form.mle_evaluate(&point), + multilinear_extend(&dense, &point) + ); + } + } + + #[test] + fn joint_evaluation_matches_the_dense_vector_at_non_boolean_points() { + let form = JointLinearForm::new(prefix(&[2, 3, 5, 7], 4), prefix(&[11, 13, 17, 19], 4)); + let dense: Vec<_> = [2, 3, 5, 7, 11, 13, 17, 19].map(field).into(); + + for point in [[2, 3, 5], [7, 11, 13], [17, 19, 23]] { + let point = point.map(field); + assert_eq!( + form.mle_evaluate(&point), + multilinear_extend(&dense, &point) + ); + } + } + + #[test] + fn selector_and_weights_preserve_non_base_extension_components() { + use whir::algebra::fields::Field64_3 as E; + + // u has a nonzero extension component. It does not belong to Field64. + let u = E::from_base_prime_field_elems([field(0), field(1), field(0)]).unwrap(); + let form = JointLinearForm::new( + PrefixCovector::new(vec![E::from(2), u], 2), + PrefixCovector::new(vec![E::from(3) * u, E::from(7)], 2), + ); + let point = [u + E::from(1), u + E::from(2)]; + let dense = [E::from(2), u, E::from(3) * u, E::from(7)]; + + assert_eq!( + form.mle_evaluate(&point), + multilinear_extend(&dense, &point) + ); + let mut actual = [E::ZERO; 4]; + form.accumulate(&mut actual, u); + assert_eq!(actual, dense.map(|weight| u * weight)); + } + + #[test] + fn unequal_prefix_lengths_have_separate_zero_padding() { + let form = JointLinearForm::new(prefix(&[2, 3, 5, 7], 4), prefix(&[11, 13], 4)); + let dense: Vec<_> = [2, 3, 5, 7, 11, 13, 0, 0].map(field).into(); + let mut actual = vec![Field64::ZERO; 8]; + form.accumulate(&mut actual, Field64::ONE); + + assert_eq!(actual, dense); + for point in [[0, 1, 1], [1, 1, 1], [2, 3, 5]] { + let point = point.map(field); + assert_eq!( + form.mle_evaluate(&point), + multilinear_extend(&dense, &point) + ); + } + + // Swapping the blocks preserves each block's padding. + let swapped = JointLinearForm::new(prefix(&[11, 13], 4), prefix(&[2, 3, 5, 7], 4)); + let mut actual = vec![Field64::ZERO; 8]; + swapped.accumulate(&mut actual, Field64::ONE); + assert_eq!(actual, [11, 13, 0, 0, 2, 3, 5, 7].map(field)); + } + + #[test] + fn accumulation_adds_scaled_weights_to_existing_values() { + let form = example(); + let mut accumulator = [100, 200, 300, 400].map(field); + + form.accumulate(&mut accumulator, field(3)); + + assert_eq!(accumulator, [106, 212, 330, 442].map(field)); + form.accumulate(&mut accumulator, Field64::ZERO); + assert_eq!(accumulator, [106, 212, 330, 442].map(field)); + } + + #[test] + fn one_branch_forms_leave_the_other_block_unchanged() { + let left = JointLinearForm::left_only(prefix(&[2, 4], 2)); + let right = JointLinearForm::right_only(prefix(&[10, 14], 2)); + let point = [field(2), field(3)]; + + assert_eq!(left.mle_evaluate(&point), -field(8)); + assert_eq!(right.mle_evaluate(&point), field(44)); + + let mut left_accumulator = [100, 200, 300, 400].map(field); + left.accumulate(&mut left_accumulator, field(3)); + assert_eq!(left_accumulator, [106, 212, 300, 400].map(field)); + + let mut right_accumulator = [100, 200, 300, 400].map(field); + right.accumulate(&mut right_accumulator, field(3)); + assert_eq!(right_accumulator, [100, 200, 330, 442].map(field)); + } + + #[test] + fn one_entry_blocks_need_only_the_selector_coordinate() { + let form = JointLinearForm::new(prefix(&[2], 1), prefix(&[10], 1)); + assert_eq!(form.mle_evaluate(&[field(3)]), field(26)); + } + + // This form checks sizes without allocating a witness or weight vector. + struct SizeOnly(usize); + + impl LinearForm for SizeOnly { + fn size(&self) -> usize { + self.0 + } + + fn mle_evaluate(&self, _point: &[Field64]) -> Field64 { + unreachable!() + } + + fn accumulate(&self, _accumulator: &mut [Field64], _scalar: Field64) { + unreachable!() + } + } + + #[test] + #[should_panic(expected = "branch sizes must match")] + fn construction_rejects_different_block_sizes() { + let _ = JointLinearForm::new(SizeOnly(2), SizeOnly(4)); + } + + #[test] + #[should_panic(expected = "block size must be a nonzero power of two")] + fn construction_rejects_zero_block_size() { + let _ = JointLinearForm::left_only(SizeOnly(0)); + } + + #[test] + #[should_panic(expected = "block size must be a nonzero power of two")] + fn construction_rejects_non_power_of_two_block_size() { + let _ = JointLinearForm::right_only(SizeOnly(3)); + } + + #[test] + #[should_panic(expected = "combined domain size overflows")] + fn construction_rejects_combined_size_overflow() { + let _ = JointLinearForm::left_only(SizeOnly(1 << (usize::BITS - 1))); + } + + #[test] + #[should_panic(expected = "point dimension must match the combined domain")] + fn evaluation_rejects_a_missing_selector_coordinate() { + example().mle_evaluate(&[field(3)]); + } + + #[test] + #[should_panic(expected = "point dimension must match the combined domain")] + fn evaluation_rejects_extra_coordinates() { + example().mle_evaluate(&[field(2), field(3), field(5)]); + } + + #[test] + #[should_panic(expected = "accumulator size must match the combined domain")] + fn accumulation_rejects_a_short_accumulator() { + example().accumulate(&mut [Field64::ZERO; 3], Field64::ONE); + } + + #[test] + #[should_panic(expected = "accumulator size must match the combined domain")] + fn accumulation_rejects_a_long_accumulator() { + example().accumulate(&mut [Field64::ZERO; 5], Field64::ONE); + } +} diff --git a/provekit/common/src/lib.rs b/provekit/common/src/lib.rs index 26a738079..56eed920e 100644 --- a/provekit/common/src/lib.rs +++ b/provekit/common/src/lib.rs @@ -6,6 +6,8 @@ pub use file::binary_format; pub mod field; pub mod hash_config; mod interner; +pub mod joint_claims; +pub mod joint_covector; mod logging; pub mod prefix_covector; pub mod public_inputs; @@ -24,6 +26,7 @@ pub use { compress::CompressedR1CS, field::{Base, Ext, FieldHash, ProofField}, hash_config::{HashConfig, POSEIDON2, SKYSCRAPER}, + joint_covector::JointLinearForm, logging::log_commit_input, prefix_covector::{OffsetCovector, PrefixCovector, SparseCovector}, public_inputs::{PublicInputs, PublicInputsHash}, diff --git a/provekit/common/src/prefix_covector.rs b/provekit/common/src/prefix_covector.rs index cfdb79cd3..313955f02 100644 --- a/provekit/common/src/prefix_covector.rs +++ b/provekit/common/src/prefix_covector.rs @@ -1,7 +1,7 @@ use { ark_ff::{Field, One, Zero}, whir::{ - algebra::{embedding::Embedding, linear_form::LinearForm, mixed_dot, multilinear_extend}, + algebra::{embedding::Embedding, linear_form::LinearForm, multilinear_extend}, buffer::{Buffer, BufferOps}, }, }; @@ -212,22 +212,6 @@ pub fn build_prefix_covectors( .collect() } -/// Compute dot products of alpha vectors against a polynomial without -/// allocating [`PrefixCovector`] weights. Used to write transcript hints -/// before deferring weight construction (saves memory in dual-commit). -#[must_use] -pub fn compute_alpha_evals( - embedding: &M, - polynomial: &Buffer, - alphas: &[Vec; N], -) -> Vec { - let polynomial = polynomial.to_slice(); - alphas - .iter() - .map(|w| mixed_dot(embedding, w, &polynomial[..w.len()])) - .collect() -} - /// Compute the public weight evaluation `⟨[1, x, x², …, x^N], poly[0..=N]⟩` /// without allocating a [`PrefixCovector`]. Covers the R1CS constant at /// position 0 and `num_public_inputs` public input positions. diff --git a/provekit/common/src/whir_r1cs.rs b/provekit/common/src/whir_r1cs.rs index 985feac3c..60975a9e1 100644 --- a/provekit/common/src/whir_r1cs.rs +++ b/provekit/common/src/whir_r1cs.rs @@ -157,7 +157,8 @@ pub struct WhirR1CSScheme { pub num_challenges: usize, pub challenge_offsets: Vec, pub has_public_inputs: bool, - /// Base-field witness commitment, via zook. + /// Base-field witness commitment, via zook. Two-stage witnesses open + /// jointly. pub whir_witness: ZookConfig, /// Separate ext-field commitment to the Spartan blinding polynomial `g`; /// masking the ext-valued sumcheck rounds needs ext randomness, so `g` @@ -181,6 +182,17 @@ impl WhirR1CSScheme

{ 1usize << self.whir_blinding.initial_num_variables() } + /// Reject a missing or extra witness commitment before indexing + /// commitments. + pub fn validate_witness_commitment_count(&self, count: usize) -> anyhow::Result<()> { + let expected = if self.num_challenges > 0 { 2 } else { 1 }; + anyhow::ensure!( + count == expected, + "Expected {expected} witness commitments, received {count}" + ); + Ok(()) + } + /// Create a domain separator for the provekit outer protocol. /// /// The domain separator serializes the entire scheme (including @@ -197,6 +209,37 @@ impl WhirR1CSScheme

{ } impl WhirR1CSScheme

{ + /// Validate joint opening parameters before proving or verifying. + pub fn validate_joint_opening(&self) -> anyhow::Result<()> { + if self.num_challenges > 0 { + anyhow::ensure!( + self.num_challenges == self.challenge_offsets.len(), + "Challenge count does not match challenge offsets" + ); + let block_size = 1usize + .checked_shl(u32::try_from(self.m)?) + .ok_or_else(|| anyhow::anyhow!("Invalid witness block dimension"))?; + anyhow::ensure!( + self.w1_size <= block_size, + "First witness size exceeds its commitment block" + ); + anyhow::ensure!( + self.challenge_offsets + .iter() + .all(|offset| *offset < block_size), + "Challenge offset exceeds the second commitment block" + ); + anyhow::ensure!( + self.whir_witness.tuning().vector_size == block_size, + "Joint opening source size does not match the witness block" + ); + self.whir_witness + .validate_pair() + .map_err(|_| anyhow::anyhow!("Invalid joint opening parameters"))?; + } + Ok(()) + } + /// Build a scheme for a concrete R1CS instance, recording its hash so the /// transcript can later be bound to this instance (in /// [`Self::create_domain_separator`]). @@ -256,7 +299,7 @@ impl WhirR1CSScheme

{ let m = m1_raw.max(m2_raw).max(MIN_WHIR_NUM_VARIABLES); let m_0 = m0_raw.max(MIN_SUMCHECK_NUM_VARIABLES); - Ok(Self { + let scheme = Self { m, m_0, a_num_terms: next_power_of_two(a_num_entries), @@ -272,7 +315,9 @@ impl WhirR1CSScheme

{ has_public_inputs, r1cs_hash: R1csHash::UNSET, hash_config, - }) + }; + scheme.validate_joint_opening()?; + Ok(scheme) } /// Build the zook witness config: commits in `P`'s base field, opens at diff --git a/provekit/prover/src/whir_r1cs.rs b/provekit/prover/src/whir_r1cs.rs index bab35462f..3be15554f 100644 --- a/provekit/prover/src/whir_r1cs.rs +++ b/provekit/prover/src/whir_r1cs.rs @@ -7,9 +7,9 @@ use { Zero, }, provekit_common::{ + joint_claims::{build_joint_forms, compute_joint_matrix_evals}, prefix_covector::{ - build_prefix_covectors, compute_alpha_evals, compute_challenge_eval, - compute_public_eval, expand_powers, linear_form_refs, make_challenge_weight, + compute_challenge_eval, compute_public_eval, expand_powers, linear_form_refs, make_public_weight, OffsetCovector, }, utils::{ @@ -64,9 +64,9 @@ pub struct SparkColQueryData { } /// SPARK query material produced as a side output of proving: the Spartan -/// sumcheck point (shared row axis) plus one column query per witness -/// commitment. The concrete backend converts this into its serialized SPARK -/// query batch type. +/// sumcheck point (shared row axis) plus the final matrix queries. +/// Joint openings produce one query over the combined column domain. The +/// concrete backend converts this into its serialized SPARK query batch type. pub struct SparkQueryData { pub row: Vec>, pub queries: Vec>, @@ -236,7 +236,8 @@ fn prove_noir_inner( where Standard: Distribution> + Distribution>, { - ensure!(!commitments.is_empty(), "Need at least one commitment"); + scheme.validate_witness_commitment_count(commitments.len())?; + scheme.validate_joint_opening()?; let (a, b, c) = calculate_witness_bounds(&r1cs, &full_witness); drop(full_witness); @@ -329,6 +330,8 @@ pub fn prove_from_alphas_ctx( where Standard: Distribution> + Distribution>, { + scheme.validate_witness_commitment_count(ctx.commitments.len())?; + scheme.validate_joint_opening()?; let ProveFromAlphasCtx { alphas, blinding_eval, @@ -417,153 +420,63 @@ where } }) } else { - // Dual commitment path - let mut commitments = commitments.into_iter(); - let c1 = commitments - .next() - .expect("dual-commitment path requires first commitment"); - let c2 = commitments - .next() - .expect("dual-commitment path requires second commitment"); - - let (alphas_1, alphas_2): (Vec<_>, Vec<_>) = alphas - .into_iter() - .map(|mut v| { - let v2 = v.split_off(scheme.w1_size); - (v, v2) - }) - .unzip(); - - let alphas_1: [Vec>; 3] = alphas_1 - .try_into() - .expect("alphas_1 must have exactly 3 elements"); - let alphas_2: [Vec>; 3] = alphas_2 + let [c1, c2]: [WhirR1CSCommitment

; 2] = commitments .try_into() - .expect("alphas_2 must have exactly 3 elements"); - - let evals_1 = compute_alpha_evals(&embedding, &c1.polynomial, &alphas_1); - let evals_2 = compute_alpha_evals(&embedding, &c2.polynomial, &alphas_2); - for eval in &evals_1 { - merlin.prover_message(eval); + .map_err(|_| anyhow::anyhow!("Joint opening requires two commitments"))?; + let matrix_evals = compute_joint_matrix_evals(&embedding, &alphas, scheme.w1_size, [ + &c1.polynomial, + &c2.polynomial, + ])?; + let forms = build_joint_forms( + scheme.m, + scheme.w1_size, + alphas, + x, + public_inputs_len, + &scheme.challenge_offsets, + )?; + let mut evaluations = matrix_evals.to_vec(); + if public_inputs_len > 0 { + evaluations.push(compute_public_eval( + &embedding, + x, + public_inputs_len, + &c1.polynomial, + )); } - for eval in &evals_2 { - merlin.prover_message(eval); + if !scheme.challenge_offsets.is_empty() { + evaluations.push(compute_challenge_eval( + &embedding, + x, + &scheme.challenge_offsets, + &c2.polynomial, + )); } - - let public_1 = if public_inputs_len > 0 { - let p1 = compute_public_eval(&embedding, x, public_inputs_len, &c1.polynomial); - merlin.prover_message(&p1); - Some(p1) - } else { - None - }; - - // Challenge binding: prove that w2 contains the correct Fiat-Shamir - // challenge values at the expected positions. - let challenge_eval = if !scheme.challenge_offsets.is_empty() { - let ce = - compute_challenge_eval(&embedding, x, &scheme.challenge_offsets, &c2.polynomial); - merlin.prover_message(&ce); - Some(ce) - } else { - None - }; - - let WhirR1CSCommitment { witness: w1, .. } = c1; - let WhirR1CSCommitment { witness: w2, .. } = c2; - - let (final_claim_1, claimed_1) = { - let mut weights = build_prefix_covectors(scheme.m, alphas_1); - - // Snapshot the three alpha covectors (A, B, C) before the public - // weight is inserted; they are re-evaluated at the final WHIR - // point to form the SPARK query. - let spark_weights: Option>, usize)>> = spark_row.as_ref().map(|_| { - weights - .iter() - .map(|w| (w.vector().to_vec(), w.size())) - .collect() - }); - - let mut evaluations: Vec> = Vec::new(); - if let Some(pe) = public_1 { - weights.insert(0, make_public_weight(x, public_inputs_len, scheme.m)); - evaluations.push(pe); - } - evaluations.extend_from_slice(&evals_1); - - let form_refs = linear_form_refs(&weights); - - let final_claim = scheme - .whir_witness - .prove(&mut merlin, w1, &form_refs, &evaluations); - - let claimed = - spark_weights.map(|sw| evaluate_spark_weights(sw, &final_claim.evaluation_point)); - (final_claim, claimed) - }; - - let (final_claim_2, claimed_2) = { - let weights = build_prefix_covectors(scheme.m, alphas_2); - - // The first three weights are the alpha covectors (A, B, C); the - // challenge weight, if any, is appended after them. - let spark_weights: Option>, usize)>> = spark_row.as_ref().map(|_| { - weights - .iter() - .map(|w| (w.vector().to_vec(), w.size())) - .collect() - }); - - let mut evaluations: Vec> = evals_2; - - let challenge_covector = challenge_eval.map(|ce| { - evaluations.push(ce); - make_challenge_weight(x, &scheme.challenge_offsets, scheme.m) - }); - - let mut form_refs = linear_form_refs(&weights); - if let Some(ref cw) = challenge_covector { - form_refs.push(cw as &dyn LinearForm>); + let form_refs = linear_form_refs(&forms); + for evaluation in &evaluations { + merlin.prover_message(evaluation); + } + let final_claim = scheme + .whir_witness + .prove_pair( + &mut merlin, + [c1.witness, c2.witness], + &form_refs, + &evaluations, + ) + .map_err(|_| anyhow::anyhow!("Joint WHIR opening failed"))?; + spark_row.map(|row| { + let col = final_claim.evaluation_point; + SparkQueryData { + row, + queries: vec![SparkColQueryData { + claimed_a: forms[0].mle_evaluate(&col), + claimed_b: forms[1].mle_evaluate(&col), + claimed_c: forms[2].mle_evaluate(&col), + col, + }], } - - let final_claim = scheme - .whir_witness - .prove(&mut merlin, w2, &form_refs, &evaluations); - - let claimed = - spark_weights.map(|sw| evaluate_spark_weights(sw, &final_claim.evaluation_point)); - (final_claim, claimed) - }; - - // The SPARK column axis spans w1 | w2: prefix the per-commitment WHIR - // points with a selector coordinate (0 selects w1, 1 selects w2). - spark_row - .zip(claimed_1.zip(claimed_2)) - .map(|(row, ([a1, b1, c1], [a2, b2, c2]))| { - let mut col_1 = final_claim_1.evaluation_point; - col_1.insert(0, >::zero()); - let query_1 = SparkColQueryData { - col: col_1, - claimed_a: a1, - claimed_b: b1, - claimed_c: c1, - }; - - let mut col_2 = final_claim_2.evaluation_point; - col_2.insert(0, >::one()); - let query_2 = SparkColQueryData { - col: col_2, - claimed_a: a2, - claimed_b: b2, - claimed_c: c2, - }; - - SparkQueryData { - row, - queries: vec![query_1, query_2], - } - }) + }) }; // Open the ext blinding commitment: prove `blinding_eval` is the evaluation diff --git a/provekit/verifier/src/whir_r1cs.rs b/provekit/verifier/src/whir_r1cs.rs index 0ff7b8e6a..6040b7e47 100644 --- a/provekit/verifier/src/whir_r1cs.rs +++ b/provekit/verifier/src/whir_r1cs.rs @@ -6,9 +6,10 @@ use { One, }, provekit_common::{ + joint_claims::build_joint_forms, prefix_covector::{ - build_prefix_covectors, expand_powers, linear_form_refs, make_challenge_weight, - make_public_weight, OffsetCovector, + build_prefix_covectors, expand_powers, linear_form_refs, make_public_weight, + OffsetCovector, }, utils::sumcheck::{ calculate_eq, eval_cubic_poly, multiply_transposed_by_eq_alpha, transpose_r1cs_matrices, @@ -50,6 +51,7 @@ where public_inputs: &PublicInputs>, r1cs: &R1CS>, ) -> Result<()> { + self.validate_joint_opening()?; let actual_r1cs_hash = r1cs.hash(); anyhow::ensure!( self.r1cs_hash == actual_r1cs_hash, @@ -124,97 +126,54 @@ where let blinding_weights = expand_powers::<4, _>(&data_from_sumcheck_verifier.alpha); let (az_at_alpha, bz_at_alpha, cz_at_alpha) = if let Some(commitment_2) = commitment_2 { - let (alphas_1, alphas_2): (Vec<_>, Vec<_>) = alphas - .into_iter() - .map(|mut v| { - let v2 = v.split_off(self.w1_size); - (v, v2) - }) - .unzip(); - let alphas_1: [Vec>; 3] = alphas_1 - .try_into() - .map_err(|_| anyhow::anyhow!("Expected 3 alpha vectors for commitment 1"))?; - let alphas_2: [Vec>; 3] = alphas_2 - .try_into() - .map_err(|_| anyhow::anyhow!("Expected 3 alpha vectors for commitment 2"))?; - - let evals_1: [Ext

; 3] = [ + let forms = build_joint_forms( + self.m, + self.w1_size, + alphas, + x, + public_inputs.len(), + &self.challenge_offsets, + )?; + let matrix_evals: [Ext

; 3] = [ arthur .prover_message() - .map_err(|_| anyhow::anyhow!("Failed to read evals_1[0]"))?, + .map_err(|_| anyhow::anyhow!("Failed to read joint A evaluation"))?, arthur .prover_message() - .map_err(|_| anyhow::anyhow!("Failed to read evals_1[1]"))?, + .map_err(|_| anyhow::anyhow!("Failed to read joint B evaluation"))?, arthur .prover_message() - .map_err(|_| anyhow::anyhow!("Failed to read evals_1[2]"))?, + .map_err(|_| anyhow::anyhow!("Failed to read joint C evaluation"))?, ]; - let evals_2: [Ext

; 3] = [ - arthur - .prover_message() - .map_err(|_| anyhow::anyhow!("Failed to read evals_2[0]"))?, - arthur - .prover_message() - .map_err(|_| anyhow::anyhow!("Failed to read evals_2[1]"))?, - arthur + let mut evaluations = matrix_evals.to_vec(); + if !public_inputs.is_empty() { + let public_eval = arthur .prover_message() - .map_err(|_| anyhow::anyhow!("Failed to read evals_2[2]"))?, - ]; - - let mut weights_1 = build_prefix_covectors(self.m, alphas_1); - let weights_2 = build_prefix_covectors(self.m, alphas_2); - - let evaluations_1 = if !public_inputs.is_empty() { - let public_1: Ext

= arthur - .prover_message() - .map_err(|_| anyhow::anyhow!("Failed to read public_1"))?; - verify_public_input_binding(&embedding, public_1, x, public_inputs)?; - weights_1.insert(0, make_public_weight(x, public_inputs.len(), self.m)); - vec![public_1, evals_1[0], evals_1[1], evals_1[2]] - } else { - evals_1.to_vec() - }; - let mut evaluations_2 = evals_2.to_vec(); - - // Challenge binding: verify that w2 contains the correct - // Fiat-Shamir challenge values at the expected positions. - let challenge_covector = if let Some(ref challenges) = logup_challenges { - let challenge_eval: Ext

= arthur + .map_err(|_| anyhow::anyhow!("Failed to read joint public evaluation"))?; + verify_public_input_binding(&embedding, public_eval, x, public_inputs)?; + evaluations.push(public_eval); + } + if let Some(ref challenges) = logup_challenges { + let challenge_eval = arthur .prover_message() - .map_err(|_| anyhow::anyhow!("Failed to read challenge_eval"))?; + .map_err(|_| anyhow::anyhow!("Failed to read joint challenge evaluation"))?; verify_challenge_binding(challenge_eval, x, challenges)?; - let cw = make_challenge_weight(x, &self.challenge_offsets, self.m); - evaluations_2.push(challenge_eval); - Some(cw) - } else { - None - }; - - let weight_refs_1 = linear_form_refs(&weights_1); - - let fc_1 = self - .whir_witness - .verify(&mut arthur, commitment_1, &weight_refs_1, &evaluations_1) - .map_err(|_| anyhow::anyhow!("WHIR verification failed for c1"))?; - fc_1.verify(&weight_refs_1) - .map_err(|_| anyhow::anyhow!("WHIR final-claim check failed for c1"))?; - - let mut weight_refs_2 = linear_form_refs(&weights_2); - if let Some(ref cw) = challenge_covector { - weight_refs_2.push(cw as &dyn LinearForm>); + evaluations.push(challenge_eval); } - let fc_2 = self + let form_refs = linear_form_refs(&forms); + let final_claim = self .whir_witness - .verify(&mut arthur, commitment_2, &weight_refs_2, &evaluations_2) - .map_err(|_| anyhow::anyhow!("WHIR verification failed for c2"))?; - fc_2.verify(&weight_refs_2) - .map_err(|_| anyhow::anyhow!("WHIR final-claim check failed for c2"))?; - - ( - evals_1[0] + evals_2[0], - evals_1[1] + evals_2[1], - evals_1[2] + evals_2[2], - ) + .verify_pair( + &mut arthur, + [commitment_1, commitment_2], + &form_refs, + &evaluations, + ) + .map_err(|_| anyhow::anyhow!("Joint WHIR verification failed"))?; + final_claim + .verify(&form_refs) + .map_err(|_| anyhow::anyhow!("Joint WHIR final-claim check failed"))?; + (matrix_evals[0], matrix_evals[1], matrix_evals[2]) } else { let evals: [Ext

; 3] = [ arthur diff --git a/tooling/provekit-fixtures/Cargo.toml b/tooling/provekit-fixtures/Cargo.toml index fd6add37d..6d3002366 100644 --- a/tooling/provekit-fixtures/Cargo.toml +++ b/tooling/provekit-fixtures/Cargo.toml @@ -24,6 +24,7 @@ whir.workspace = true # Concrete proof fields to instantiate the generic harness. provekit-backend-bn254.workspace = true provekit-backend-goldilocks.workspace = true +provekit-spark.workspace = true inferno.workspace = true tracing.workspace = true tracing-flame.workspace = true diff --git a/tooling/provekit-fixtures/tests/joint_opening.rs b/tooling/provekit-fixtures/tests/joint_opening.rs new file mode 100644 index 000000000..969202a42 --- /dev/null +++ b/tooling/provekit-fixtures/tests/joint_opening.rs @@ -0,0 +1,353 @@ +//! Joint opening acceptance tests through the production prover and verifier. + +use { + ark_ff::{AdditiveGroup, Field}, + ark_std::rand::distributions::{Distribution, Standard}, + provekit_backend_bn254::{spark::SparkQueryBatch, Bn254Field, FieldElement}, + provekit_backend_goldilocks::{GoldilocksEfField, GoldilocksField}, + provekit_common::{ + Base, Ext, FieldHash, HashConfig, JointLinearForm, PrefixCovector, PublicInputs, + PublicInputsHash, WhirR1CSScheme, WitnessCommitmentMode, + }, + provekit_fixtures::builders::{ + challenge_with_public_input, multi_challenge_inverses_w2, two_public_inputs, + }, + provekit_prover::{SparkQueryData, WhirR1CSProver}, + provekit_spark::{ + preprocess_spark, SparkMatrix, SparkProverContext, SparkProverScheme, SparkVerifierScheme, + }, + provekit_verifier::WhirR1CSVerifier, + whir::{ + algebra::{ + embedding::Embedding, + fields::{Field64, Field64_3}, + linear_form::LinearForm, + }, + buffer::Buffer, + protocols::{ + params::{FoldingFactor, Mode, PowBudget, RateSchedule, SecuritySpec, TuningSpec}, + zook::ProtocolConfig, + }, + transcript::{ + codecs::Empty, DomainSeparator, Proof, ProverState, VerificationResult, + VerifierMessage, VerifierState, + }, + }, +}; + +const HASH: HashConfig = HashConfig::Sha256; + +#[derive(Clone, Copy)] +enum Change { + None, + PublicWitness, + StageChallenge, +} + +fn check_full_r1cs

(mode: WitnessCommitmentMode, change: Change) -> SparkQueryData

+where + P: FieldHash, + P::Embedding: Embedding>, + Standard: Distribution>, +{ + P::register(); + // The witness is [1, 3 | c, 1/c]. The constraints are 3*1=3 and c*(1/c)=1. + let (mut r1cs, mut left, offsets) = challenge_with_public_input::>(3); + // Eight constraints also meet SPARK's minimum matrix dimensions. + let one = Base::

::ONE; + for _ in 1..4 { + r1cs.add_constraint(&[(one, 1)], &[(one, 0)], &[(one, 1)]); + r1cs.add_constraint(&[(one, 2)], &[(one, 3)], &[(one, 0)]); + } + if matches!(change, Change::PublicWitness) { + left[1] += Base::

::ONE; + } + let public = PublicInputs::from_vec(vec![Base::

::from(3)]); + let scheme = + WhirR1CSScheme::

::new_for_r1cs(&r1cs, left.len(), 1, offsets, true, HASH, mode).unwrap(); + scheme.validate_joint_opening().unwrap(); + assert_eq!(scheme.whir_witness.security().target_security_bits, 128); + let instance = public.hash_bytes::

(HASH); + let ds = scheme.create_domain_separator().instance(&instance); + let mut prover = ProverState::new(&ds, P::transcript_sponge(HASH)); + let first = scheme + .commit(&mut prover, 4, r1cs.num_constraints(), left.clone(), true) + .unwrap(); + let mut challenges: Vec> = prover.verifier_message_vec(1); + if matches!(change, Change::StageChallenge) { + challenges[0] += Base::

::ONE; + } + let right = multi_challenge_inverses_w2(&challenges, &left).unwrap(); + assert_eq!(right[0] * right[1], Base::

::ONE); + let second = scheme + .commit(&mut prover, 4, r1cs.num_constraints(), right.clone(), false) + .unwrap(); + let mut full_witness = left; + full_witness.extend(right); + let (proof, queries) = scheme + .prove_noir_with_spark( + prover, + r1cs.clone(), + vec![first, second], + full_witness, + &public, + ) + .unwrap(); + + // One matrix query keeps the selector and every source coordinate. + assert_eq!(queries.queries.len(), 1); + assert_eq!(queries.queries[0].col.len(), scheme.m + 1); + let result = scheme.verify(&proof, &public, &r1cs); + match change { + Change::None => result.unwrap(), + Change::PublicWitness | Change::StageChallenge => { + let expected = match change { + Change::PublicWitness => "public", + _ => "challenge", + }; + let error = result.unwrap_err(); + assert!( + format!("{error:#}").to_lowercase().contains(expected), + "{error:#}" + ); + return queries; + } + } + + let wrong_public = PublicInputs::from_vec(vec![Base::

::from(4)]); + assert!(scheme.verify(&proof, &wrong_public, &r1cs).is_err()); + let mut truncated = proof; + truncated.narg_string.pop(); + assert!(scheme.verify(&truncated, &public, &r1cs).is_err()); + queries +} + +#[test] +fn bn254_joint_r1cs_proof_binds_public_inputs_and_stage_challenges() { + for mode in [ + WitnessCommitmentMode::Standard, + WitnessCommitmentMode::ZeroKnowledge, + ] { + check_spark(check_full_r1cs::(mode, Change::None)); + } +} + +fn check_spark(queries: SparkQueryData) { + let n = 1usize << (queries.queries[0].col.len() - 1); + let columns = 2 * n; + let (mut rows, mut cols) = (Vec::new(), Vec::new()); + // SPARK uses the block matrix [A B; 0 C] over the two padded witnesses. + for i in 0..4 { + let a = 2 * i; + let b = a + 1; + for (row, col) in [ + (a, 1), + (b, n), + (a, columns), + (b, columns + n + 1), + (8 + a, columns + 1), + (8 + b, columns), + ] { + rows.push(row); + cols.push(col); + } + } + let mut values = vec![FieldElement::ONE; rows.len()]; + rows.resize(32, 0); + cols.resize(32, 0); + values.resize(32, FieldElement::ZERO); + let matrix = SparkMatrix::new(rows, cols, values, 16, 2 * columns); + let (setup, witnesses) = preprocess_spark(&matrix, HASH); + let context = SparkProverContext { + matrix, + witnesses, + setup, + }; + let scheme = SparkProverScheme { + whir_configs: context.setup.whir_configs.clone(), + matrix_dimensions: context.setup.matrix_dimensions.clone(), + }; + let batch = SparkQueryBatch::from(queries); + let proof = scheme.prove(&context, &batch).unwrap(); + SparkVerifierScheme + .verify(proof, &context.setup, &batch) + .unwrap(); +} + +#[test] +fn extension_leaf_joint_r1cs_proof_binds_public_inputs_and_stage_challenges() { + // Existing R1CS challenge slots store one field element, so this path uses + // Identity. The next test exercises Basefield directly. + for mode in [ + WitnessCommitmentMode::Standard, + WitnessCommitmentMode::ZeroKnowledge, + ] { + check_full_r1cs::(mode, Change::None); + } +} + +#[test] +fn base_field_witnesses_open_genuine_extension_claims_with_provekit_forms() { + for mode in [Mode::Standard, Mode::ZeroKnowledge] { + check_base_field_pair(mode); + } +} + +fn check_base_field_pair(mode: Mode) { + type P = GoldilocksField; + type F = Field64_3; + P::register(); + let n = 256; + let source = ProtocolConfig::<

::Embedding>::derive( + SecuritySpec::new(128) + .with_mode(mode) + .with_pow_budget(PowBudget::per_slot(10)) + .with_hash(HASH.engine_id()), + TuningSpec { + vector_size: n, + starting_log_inv_rate: 2, + folding_factor: FoldingFactor::Constant(2), + rate_schedule: RateSchedule::Stepping, + }, + ) + .unwrap(); + let e = F::new(Field64::ZERO, Field64::ONE, Field64::ZERO); + let forms = [ + JointLinearForm::new( + PrefixCovector::new([2, 3, 4, 0].map(F::from).to_vec(), n), + PrefixCovector::new([11, 13].map(F::from).to_vec(), n), + ), + JointLinearForm::new( + PrefixCovector::new(vec![F::ZERO, e + F::ONE], n), + PrefixCovector::new(vec![e], n), + ), + ]; + let refs: Vec<&dyn LinearForm> = forms.iter().map(|form| form as _).collect(); + // Left [1,2,3] gives 20. Right [5,7] gives 146. The total is 166. + // The second claim is (e+1)*2 + e*5 = 7e+2, with e outside the base field. + let values = [F::from(166), e * F::from(7) + F::from(2)]; + assert_ne!(values[1].c1, Field64::ZERO); + let mut left = vec![Field64::ZERO; n]; + left[..3].copy_from_slice(&[1, 2, 3].map(Field64::from)); + let mut right = vec![Field64::ZERO; n]; + right[..2].copy_from_slice(&[5, 7].map(Field64::from)); + let ds = DomainSeparator::protocol(&"provekit-joint-forms").instance(&Empty); + let mut prover = ProverState::new(&ds, P::transcript_sponge(HASH)); + let first = source.commit(&mut prover, Buffer::from(left)); + let challenge: F = prover.verifier_message(); + let second = source.commit(&mut prover, Buffer::from(right)); + let result = source + .prove_pair(&mut prover, [first, second], &refs, &values) + .unwrap(); + prover.prover_message(&F::from(987)); + let proof = prover.proof(); + + let verify = |proof: &Proof, values: &[F]| -> VerificationResult<()> { + let mut verifier = VerifierState::new(&ds, proof, P::transcript_sponge(HASH)); + let first = source.receive_commitment(&mut verifier)?; + let received: F = verifier.verifier_message(); + assert_eq!(received, challenge); + let second = source.receive_commitment(&mut verifier)?; + let final_claim = source.verify_pair(&mut verifier, [first, second], &refs, values)?; + assert_eq!(final_claim.evaluation_point, result.evaluation_point); + final_claim.verify(&refs)?; + let trailing: F = verifier.prover_message()?; + assert_eq!(trailing, F::from(987)); + verifier.check_eof() + }; + verify(&proof, &values).unwrap(); + let mut false_values = values; + false_values[1] += F::ONE; + assert!(verify(&proof, &false_values).is_err()); +} + +#[test] +fn joint_proofs_reject_incorrect_public_witnesses_and_stage_challenges() { + for mode in [ + WitnessCommitmentMode::Standard, + WitnessCommitmentMode::ZeroKnowledge, + ] { + for change in [Change::PublicWitness, Change::StageChallenge] { + check_full_r1cs::(mode, change); + } + } +} + +fn bn254_scheme() -> WhirR1CSScheme { + type P = Bn254Field; + P::register(); + let (r1cs, left, offsets) = challenge_with_public_input::>(3); + WhirR1CSScheme::

::new_for_r1cs( + &r1cs, + left.len(), + 1, + offsets, + true, + HASH, + WitnessCommitmentMode::Standard, + ) + .unwrap() +} + +#[test] +fn joint_preflight_checks_source_size_and_challenge_and_commitment_counts() { + let scheme = bn254_scheme(); + scheme.validate_joint_opening().unwrap(); + scheme.validate_witness_commitment_count(2).unwrap(); + for count in [0, 1, 3] { + assert!(scheme.validate_witness_commitment_count(count).is_err()); + } + let mut wrong_size = scheme.clone(); + wrong_size.m += 1; + assert!(wrong_size + .validate_joint_opening() + .unwrap_err() + .to_string() + .contains("source size")); + let mut wrong_count = scheme; + wrong_count.num_challenges += 1; + assert!(wrong_count + .validate_joint_opening() + .unwrap_err() + .to_string() + .contains("Challenge count")); +} + +#[test] +fn single_stage_proofs_open_one_witness() { + type P = Bn254Field; + P::register(); + let (r1cs, witness) = two_public_inputs::>(6, 7); + let public = PublicInputs::from_vec(vec![witness[1], witness[2]]); + for mode in [ + WitnessCommitmentMode::Standard, + WitnessCommitmentMode::ZeroKnowledge, + ] { + let scheme = + WhirR1CSScheme::

::new_for_r1cs(&r1cs, witness.len(), 0, vec![], true, HASH, mode) + .unwrap(); + scheme.validate_joint_opening().unwrap(); + let instance = public.hash_bytes::

(HASH); + let ds = scheme.create_domain_separator().instance(&instance); + let mut prover = ProverState::new(&ds, P::transcript_sponge(HASH)); + let committed = scheme + .commit( + &mut prover, + r1cs.num_witnesses(), + r1cs.num_constraints(), + witness.clone(), + true, + ) + .unwrap(); + let proof = scheme + .prove_noir( + prover, + r1cs.clone(), + vec![committed], + witness.clone(), + &public, + ) + .unwrap(); + scheme.verify(&proof, &public, &r1cs).unwrap(); + } +}