diff --git a/Cargo.lock b/Cargo.lock index acc6ca37..62e5a0d4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1360,6 +1360,26 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "thread_local" version = "1.1.9" @@ -1556,6 +1576,7 @@ dependencies = [ "sha3", "spongefish", "static_assertions", + "thiserror", "tracing", "tracing-subscriber", "zerocopy", diff --git a/Cargo.toml b/Cargo.toml index 515ee2b8..52fc40e5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -55,6 +55,7 @@ const-oid = "0.9.6" arrayvec = "0.7.6" derive-where = { version = "1.6.0", features = ["safe"] } ordered-float = { version = "5.1.0", features = ["serde"] } +thiserror = "2.0" [dev-dependencies] proptest = "1.0" diff --git a/benches/expand_from_coeff.rs b/benches/expand_from_coeff.rs index c244514e..bdcfde9a 100644 --- a/benches/expand_from_coeff.rs +++ b/benches/expand_from_coeff.rs @@ -1,5 +1,11 @@ use divan::{black_box, AllocProfiler, Bencher}; -use whir::algebra::{fields::Field64, ntt, random_vector}; +use whir::{ + algebra::{ + fields::Field64, + ntt::{Messages, NttEngine, ReedSolomon}, + }, + buffer::{ActiveBuffer, Buffer, BufferOps}, +}; #[global_allocator] static ALLOC: AllocProfiler = AllocProfiler::system(); @@ -28,18 +34,17 @@ fn interleaved_rs_encode(bencher: Bencher, case: &(usize, usize, usize)) { let message_length = 1 << (exp - coset_sz); let num_messages = 1 << coset_sz; let mut rng = ark_std::rand::thread_rng(); - let coeffs: Vec> = (0..num_messages) - .map(|_| random_vector(&mut rng, message_length)) + let coeffs: Vec> = (0..num_messages) + .map(|_| ActiveBuffer::random(&mut rng, message_length)) .collect(); - (coeffs, expansion, coset_sz) + let engine = NttEngine::::new_from_fftfield(); + (engine, coeffs, expansion) }) - .bench_values(|(coeffs, expansion, _coset_sz)| { - let coeffs_refs = coeffs.iter().map(|v| v.as_slice()).collect::>(); - black_box(ntt::interleaved_rs_encode( - &coeffs_refs, - &[], - coeffs[0].len() * expansion, - )) + .bench_values(|(engine, coeffs, expansion)| { + let coeffs_refs = coeffs.iter().collect::>(); + let messages = Messages::new(&coeffs_refs, coeffs[0].len(), 1); + let masks = ActiveBuffer::from_slice(&[]); + black_box(engine.interleaved_encode(messages, &masks, coeffs[0].len() * expansion)) }); } diff --git a/proptest-regressions/protocols/code_switch.txt b/proptest-regressions/protocols/code_switch.txt index 67bf0158..fdc02672 100644 --- a/proptest-regressions/protocols/code_switch.txt +++ b/proptest-regressions/protocols/code_switch.txt @@ -7,3 +7,6 @@ cc d1b11d6a89f5f0e74e62779bf54948e664f6780433d440ac3d977744776ed358 # shrinks to seed = 0, source_size = 3, src_lir = 1, tgt_lir = 1 cc e5c94e6ed6e6d91a8725206056b46ea1c13efd83e0c206e32f20a01929ddf654 # shrinks to seed = 0, source_size = 12, src_lir = 1, tgt_lir = 1 cc c5cdfb9b93481bf4819a4207587a6bf93c85cce8b7e2e999a9478cfc0f78cc0d # shrinks to seed = 0, source_size = 8, src_lir = 2, tgt_lir = 1 +cc ba9d37bf3442e60c7741c3593749c9413bb460cc51999b5200e54c9985794559 # shrinks to seed = 0, msg_len = 1, mask_length = 2, ood_samples = 1 +cc 582495d0662a9c5083a7bad07e789df2e433d36d9afeb27f21bf593fb2d6a9e0 # shrinks to seed = 0, config = Config { source: Config { embedding: Identity { field: FieldInfo { characteristic: [255, 255, 255, 255, 0, 0, 0, 1], extension_degree: 1 } }, num_vectors: 1, vector_size: 4, mask_length: 3, codeword_length: 8, interleaving_depth: 2, matrix_commit: Config { element_type: FieldInfo { characteristic: [255, 255, 255, 255, 0, 0, 0, 1], extension_degree: 1 }, num_cols: 2, leaf_hash_id: 018eaa247cb8957ab1e9fdac885450403c5e7bda1acaaa504e4cc8f2f76bb076, merkle_tree: Config { num_leaves: 8, layers: [LayerConfig { hash_id: 018eaa247cb8957ab1e9fdac885450403c5e7bda1acaaa504e4cc8f2f76bb076 }, LayerConfig { hash_id: ddd248964e320312a66775aee8e16c88c927734be59aca09b7af6deb0ad00e8c }, LayerConfig { hash_id: ddd248964e320312a66775aee8e16c88c927734be59aca09b7af6deb0ad00e8c }, LayerConfig { hash_id: aa802dcdabad8ea8a1430919893b96c30e31ff5734b385999108aa202d27dc12 }, LayerConfig { hash_id: ddd248964e320312a66775aee8e16c88c927734be59aca09b7af6deb0ad00e8c }, LayerConfig { hash_id: aa802dcdabad8ea8a1430919893b96c30e31ff5734b385999108aa202d27dc12 }] } }, johnson_slack: 0.0, in_domain_samples: 1, out_domain_samples: 7, deduplicate_in_domain: true }, target: Config { embedding: Identity { field: FieldInfo { characteristic: [255, 255, 255, 255, 0, 0, 0, 1], extension_degree: 1 } }, num_vectors: 1, vector_size: 2, mask_length: 1, codeword_length: 4, interleaving_depth: 1, matrix_commit: Config { element_type: FieldInfo { characteristic: [255, 255, 255, 255, 0, 0, 0, 1], extension_degree: 1 }, num_cols: 1, leaf_hash_id: 09459020f451874a1b399819d079632cc0f9263b1486c423173c6e15d8e2d61d, merkle_tree: Config { num_leaves: 4, layers: [LayerConfig { hash_id: 018eaa247cb8957ab1e9fdac885450403c5e7bda1acaaa504e4cc8f2f76bb076 }, LayerConfig { hash_id: 018eaa247cb8957ab1e9fdac885450403c5e7bda1acaaa504e4cc8f2f76bb076 }] } }, johnson_slack: 0.0, in_domain_samples: 0, out_domain_samples: 1, deduplicate_in_domain: true }, message_mask_length: 3, out_domain_samples: 4 } +cc a0ce555f621161cc430096035f127c5098eebb8670480a5fa39ec36a9b10d4e2 # shrinks to seed = 0, config = Config { source: Config { embedding: Identity { field: FieldInfo { characteristic: [255, 255, 255, 255, 0, 0, 0, 1], extension_degree: 1 } }, num_vectors: 1, vector_size: 64, mask_length: 3, codeword_length: 32, interleaving_depth: 4, matrix_commit: Config { element_type: FieldInfo { characteristic: [255, 255, 255, 255, 0, 0, 0, 1], extension_degree: 1 }, num_cols: 4, leaf_hash_id: 09459020f451874a1b399819d079632cc0f9263b1486c423173c6e15d8e2d61d, merkle_tree: Config { num_leaves: 32, layers: [LayerConfig { hash_id: 018eaa247cb8957ab1e9fdac885450403c5e7bda1acaaa504e4cc8f2f76bb076 }, LayerConfig { hash_id: 018eaa247cb8957ab1e9fdac885450403c5e7bda1acaaa504e4cc8f2f76bb076 }, LayerConfig { hash_id: 018eaa247cb8957ab1e9fdac885450403c5e7bda1acaaa504e4cc8f2f76bb076 }, LayerConfig { hash_id: 018eaa247cb8957ab1e9fdac885450403c5e7bda1acaaa504e4cc8f2f76bb076 }, LayerConfig { hash_id: 018eaa247cb8957ab1e9fdac885450403c5e7bda1acaaa504e4cc8f2f76bb076 }] } }, johnson_slack: 0.0, in_domain_samples: 1, out_domain_samples: 10, deduplicate_in_domain: false }, target: Config { embedding: Identity { field: FieldInfo { characteristic: [255, 255, 255, 255, 0, 0, 0, 1], extension_degree: 1 } }, num_vectors: 1, vector_size: 16, mask_length: 1, codeword_length: 64, interleaving_depth: 1, matrix_commit: Config { element_type: FieldInfo { characteristic: [255, 255, 255, 255, 0, 0, 0, 1], extension_degree: 1 }, num_cols: 1, leaf_hash_id: 09459020f451874a1b399819d079632cc0f9263b1486c423173c6e15d8e2d61d, merkle_tree: Config { num_leaves: 64, layers: [LayerConfig { hash_id: 018eaa247cb8957ab1e9fdac885450403c5e7bda1acaaa504e4cc8f2f76bb076 }, LayerConfig { hash_id: 018eaa247cb8957ab1e9fdac885450403c5e7bda1acaaa504e4cc8f2f76bb076 }, LayerConfig { hash_id: 018eaa247cb8957ab1e9fdac885450403c5e7bda1acaaa504e4cc8f2f76bb076 }, LayerConfig { hash_id: 018eaa247cb8957ab1e9fdac885450403c5e7bda1acaaa504e4cc8f2f76bb076 }, LayerConfig { hash_id: 03e01749ebcc0477924254eb482066b864a8dd4d77252464ca6f5b6f5cc05b4c }, LayerConfig { hash_id: 018eaa247cb8957ab1e9fdac885450403c5e7bda1acaaa504e4cc8f2f76bb076 }] } }, johnson_slack: 0.0, in_domain_samples: 3, out_domain_samples: 8, deduplicate_in_domain: true }, message_mask_length: 3, out_domain_samples: 3 } diff --git a/proptest-regressions/protocols/mask_proximity.txt b/proptest-regressions/protocols/mask_proximity.txt new file mode 100644 index 00000000..71771e33 --- /dev/null +++ b/proptest-regressions/protocols/mask_proximity.txt @@ -0,0 +1,7 @@ +# Seeds for failure cases proptest has generated in the past. It is +# automatically read and these particular cases re-run before any +# novel cases are generated. +# +# It is recommended to check this file in to source control so that +# everyone who runs the test benefits from these saved cases. +cc 6a96626efcf88c8458e112fceda942463479dfd2637ca89ecaa4c89e49bf8f8e # shrinks to seed = 0, config = Config { c_zk: Config { embedding: Identity { field: FieldInfo { characteristic: [255, 255, 255, 255, 0, 0, 0, 1], extension_degree: 1 } }, num_vectors: 2, vector_size: 2, mask_length: 3, codeword_length: 4, interleaving_depth: 1, matrix_commit: Config { element_type: FieldInfo { characteristic: [255, 255, 255, 255, 0, 0, 0, 1], extension_degree: 1 }, num_cols: 2, leaf_hash_id: 03e01749ebcc0477924254eb482066b864a8dd4d77252464ca6f5b6f5cc05b4c, merkle_tree: Config { num_leaves: 4, layers: [LayerConfig { hash_id: 03e01749ebcc0477924254eb482066b864a8dd4d77252464ca6f5b6f5cc05b4c }, LayerConfig { hash_id: 03e01749ebcc0477924254eb482066b864a8dd4d77252464ca6f5b6f5cc05b4c }] } }, johnson_slack: 0.05590169943749475, in_domain_samples: 0, out_domain_samples: 1, deduplicate_in_domain: false }, num_masks: 1 } diff --git a/proptest-regressions/protocols/params/basecase.txt b/proptest-regressions/protocols/params/basecase.txt new file mode 100644 index 00000000..c3b26074 --- /dev/null +++ b/proptest-regressions/protocols/params/basecase.txt @@ -0,0 +1,10 @@ +# Seeds for failure cases proptest has generated in the past. It is +# automatically read and these particular cases re-run before any +# novel cases are generated. +# +# It is recommended to check this file in to source control so that +# everyone who runs the test benefits from these saved cases. +cc b6e8ae0b3e6a9769901e0e0e489da34965bf0a8df7dd049aef66e0541bf10baf # shrinks to spec = SecuritySpec { mode: ZeroKnowledge, decoding_regime: Johnson, target_security_bits: 30, pow_budget: Forbidden, hash_id: 03e01749ebcc0477924254eb482066b864a8dd4d77252464ca6f5b6f5cc05b4c }, (log_size, log_inv_rate) = (1, 1) +cc a2f771fc5031440200810b95ea2d347da895f8eb2e1a87f53fd69ad224287e84 # shrinks to spec = SecuritySpec { mode: Standard, decoding_regime: Johnson, target_security_bits: 30, pow_budget: Forbidden, hash_id: 03e01749ebcc0477924254eb482066b864a8dd4d77252464ca6f5b6f5cc05b4c }, (log_size, log_inv_rate) = (2, 2) +cc f66c89bc700c79bca5f4b7234f1345129962c78e5a2036a6430564f615f19b30 # shrinks to spec = SecuritySpec { mode: ZeroKnowledge, decoding_regime: Johnson, target_security_bits: 30, pow_budget: Forbidden, hash_id: 03e01749ebcc0477924254eb482066b864a8dd4d77252464ca6f5b6f5cc05b4c }, (log_size, log_inv_rate) = (1, 1) +cc 312fec8a96f6f55f5d3c0346bdb85690f23150aadf88888d453041e99b05d414 # shrinks to spec = SecuritySpec { mode: Standard, decoding_regime: Johnson, target_security_bits: 39, pow_budget: Forbidden, hash_id: 03e01749ebcc0477924254eb482066b864a8dd4d77252464ca6f5b6f5cc05b4c }, (log_size, log_inv_rate) = (1, 1) diff --git a/proptest-regressions/protocols/params/code_switch.txt b/proptest-regressions/protocols/params/code_switch.txt new file mode 100644 index 00000000..de6c40cf --- /dev/null +++ b/proptest-regressions/protocols/params/code_switch.txt @@ -0,0 +1,13 @@ +# Seeds for failure cases proptest has generated in the past. It is +# automatically read and these particular cases re-run before any +# novel cases are generated. +# +# It is recommended to check this file in to source control so that +# everyone who runs the test benefits from these saved cases. +cc 7a7df094ea650db7a295d162b75dd9da9b52d1fc36947d2b07df8150cd9d906f # shrinks to spec = SecuritySpec { mode: Standard { unique_decoding: false }, target_security_bits: 80, vector_size: 256, starting_log_inv_rate: 1, initial_folding_factor: 4, folding_factor: 4, max_pow_bits: None, hash_id: 03e01749ebcc0477924254eb482066b864a8dd4d77252464ca6f5b6f5cc05b4c, _embedding: PhantomData, 1>>> }, log_inv_rate = 1, folding_factor = 3, num_vars = 4 +cc b42c982074a04c7110df07cf00f45156607be547e176b1ddd5f9d994ad491ddb # shrinks to spec = SecuritySpec { mode: ZeroKnowledge, target_security_bits: 80, vector_size: 256, starting_log_inv_rate: 1, initial_folding_factor: 4, folding_factor: 4, max_pow_bits: None, hash_id: 03e01749ebcc0477924254eb482066b864a8dd4d77252464ca6f5b6f5cc05b4c, _embedding: PhantomData, 1>>> }, log_inv_rate = 1, folding_factor = 3, num_vars = 4 +cc eaf09a2b6bdffa86026264679f008326498ca800260dd2f17d4370df9fb3f801 # shrinks to spec = SecuritySpec { mode: ZeroKnowledge, target_security_bits: 80, vector_size: 256, starting_log_inv_rate: 1, initial_folding_factor: 4, folding_factor: 4, max_pow_bits: None, hash_id: 03e01749ebcc0477924254eb482066b864a8dd4d77252464ca6f5b6f5cc05b4c, _embedding: PhantomData, 1>>> }, log_inv_rate = 1, folding_factor = 3, num_vars = 4 +cc 3887a5fa698c99109e8262e843dbd24ea94b9c9d420791e4520b5c9211a3eca0 # shrinks to spec = SecuritySpec { mode: ZeroKnowledge, target_security_bits: 100, vector_size: 256, starting_log_inv_rate: 1, initial_folding_factor: 4, folding_factor: 4, max_pow_bits: None, hash_id: 03e01749ebcc0477924254eb482066b864a8dd4d77252464ca6f5b6f5cc05b4c, _embedding: PhantomData, 1>>> }, (log_inv_rate, folding_factor, num_vars) = (3, 2, 7) +cc b3e128084f721e6f43e263e05acf2e2de6fcd05dccf3811f063eeb0b63d78f8e # shrinks to spec = SecuritySpec { mode: ZeroKnowledge, target_security_bits: 47, max_pow_bits: Some(15), hash_id: 03e01749ebcc0477924254eb482066b864a8dd4d77252464ca6f5b6f5cc05b4c }, (log_inv_rate, folding_factor, num_vars) = (3, 2, 4) +cc b71da9002ceac9e4a74af097a7b087557a5b916fe8da47e39c4682375d749f88 # shrinks to spec = SecuritySpec { mode: Standard, decoding_regime: Johnson, target_security_bits: 50, pow_budget: Forbidden, hash_id: 03e01749ebcc0477924254eb482066b864a8dd4d77252464ca6f5b6f5cc05b4c }, (log_inv_rate, folding_factor, num_vars) = (1, 2, 4) +cc 1981509d857e56772dd4a79f8692619e968891aa3d84576ea1857f6d9a484a2d # shrinks to spec = SecuritySpec { mode: Standard, decoding_regime: Johnson, target_security_bits: 50, pow_budget: Forbidden, hash_id: 03e01749ebcc0477924254eb482066b864a8dd4d77252464ca6f5b6f5cc05b4c }, (log_inv_rate, folding_factor, num_vars) = (1, 2, 4) diff --git a/proptest-regressions/protocols/params/derive.txt b/proptest-regressions/protocols/params/derive.txt new file mode 100644 index 00000000..ca83c1b3 --- /dev/null +++ b/proptest-regressions/protocols/params/derive.txt @@ -0,0 +1,7 @@ +# Seeds for failure cases proptest has generated in the past. It is +# automatically read and these particular cases re-run before any +# novel cases are generated. +# +# It is recommended to check this file in to source control so that +# everyone who runs the test benefits from these saved cases. +cc 104921a4117ed8255308c1ea5d3e12c72356ef72ef0d93fc0f24ed29f93fdd3a # shrinks to tuning = TuningSpec { vector_size: 32, starting_log_inv_rate: 3, folding_factor: Constant(1) } diff --git a/proptest-regressions/protocols/params/irs_commit.txt b/proptest-regressions/protocols/params/irs_commit.txt new file mode 100644 index 00000000..4d35f3df --- /dev/null +++ b/proptest-regressions/protocols/params/irs_commit.txt @@ -0,0 +1,8 @@ +# Seeds for failure cases proptest has generated in the past. It is +# automatically read and these particular cases re-run before any +# novel cases are generated. +# +# It is recommended to check this file in to source control so that +# everyone who runs the test benefits from these saved cases. +cc 0b6dd03179c9a4e38b29b34b241b88fba69348a2c8938af7253314b7035bea82 # shrinks to spec = SecuritySpec { mode: ZeroKnowledge, target_security_bits: 80, vector_size: 256, starting_log_inv_rate: 1, initial_folding_factor: 4, folding_factor: 4, max_pow_bits: None, hash_id: 03e01749ebcc0477924254eb482066b864a8dd4d77252464ca6f5b6f5cc05b4c, _embedding: PhantomData, 1>>> }, ctx = RoundContext { round_index: 0, vector_size: 16, log_inv_rate: 1, folding_factor: 1, prev_round_in_domain_samples: 0, prev_round_query_error: 0.0 }, out_domain = 0, seed = 0 +cc 7e49f7a2d53f55cfa2f09114d17ab4123678b45ddf69e0cfbc646b246de2f042 # shrinks to spec = SecuritySpec { mode: ZeroKnowledge, target_security_bits: 80, max_pow_bits: None, hash_id: 03e01749ebcc0477924254eb482066b864a8dd4d77252464ca6f5b6f5cc05b4c }, ctx = RoundContext { vector_size: 128, log_inv_rate: 2, folding_factor: 2 }, out_domain = 11 diff --git a/proptest-regressions/protocols/params/sumcheck.txt b/proptest-regressions/protocols/params/sumcheck.txt new file mode 100644 index 00000000..d6f6e6ed --- /dev/null +++ b/proptest-regressions/protocols/params/sumcheck.txt @@ -0,0 +1,12 @@ +# Seeds for failure cases proptest has generated in the past. It is +# automatically read and these particular cases re-run before any +# novel cases are generated. +# +# It is recommended to check this file in to source control so that +# everyone who runs the test benefits from these saved cases. +cc 0ffdc71948ed0315f4cf55fb8f2dd25bf71f7e41f53cd4fe35ee9da6fb125a20 # shrinks to spec = SecuritySpec { mode: Standard { unique_decoding: false }, target_security_bits: 86, vector_size: 256, starting_log_inv_rate: 1, initial_folding_factor: 4, folding_factor: 3, max_pow_bits: None, hash_id: 03e01749ebcc0477924254eb482066b864a8dd4d77252464ca6f5b6f5cc05b4c, _embedding: PhantomData, 1>>> }, ctx = RoundContext { round_index: 0, vector_size: 32, log_inv_rate: 2, folding_factor: 1, prev_round_in_domain_samples: 0, prev_round_query_error: 0.0 } +cc e8ab6549772cf6bf4c3af116ebcba3dbf295ffbe2aee4a94be7df4b9f45d61ec # shrinks to spec = SecuritySpec { mode: ZeroKnowledge, target_security_bits: 91, vector_size: 256, starting_log_inv_rate: 1, initial_folding_factor: 4, folding_factor: 3, max_pow_bits: Some(10), hash_id: 03e01749ebcc0477924254eb482066b864a8dd4d77252464ca6f5b6f5cc05b4c, _embedding: PhantomData, 1>>> }, ctx = RoundContext { round_index: 0, vector_size: 16, log_inv_rate: 1, folding_factor: 1, prev_round_in_domain_samples: 0, prev_round_query_error: 0.0 } +cc 8c4300cc375640956f81e9da5aef9ea11ef476ddc4dd253dc560afa07609262d # shrinks to spec = SecuritySpec { mode: ZeroKnowledge, target_security_bits: 98, vector_size: 256, starting_log_inv_rate: 1, initial_folding_factor: 4, folding_factor: 3, max_pow_bits: None, hash_id: 03e01749ebcc0477924254eb482066b864a8dd4d77252464ca6f5b6f5cc05b4c, _embedding: PhantomData, 1>>> }, ctx = RoundContext { round_index: 0, vector_size: 16, log_inv_rate: 1, folding_factor: 1, prev_round_in_domain_samples: 0, prev_round_query_error: 0.0 } +cc 8ea40f13c63b4c0021386369ce698a5d9289381a39dc85db43d2d69b9b4877bb # shrinks to spec = SecuritySpec { mode: Standard { unique_decoding: false }, target_security_bits: 88, vector_size: 256, starting_log_inv_rate: 1, initial_folding_factor: 4, folding_factor: 3, max_pow_bits: None, hash_id: 03e01749ebcc0477924254eb482066b864a8dd4d77252464ca6f5b6f5cc05b4c, _embedding: PhantomData, 1>>> }, ctx = RoundContext { round_index: 0, vector_size: 16, log_inv_rate: 3, folding_factor: 1, prev_round_in_domain_samples: 0, prev_round_query_error: 0.0 } +cc f1dca600886474c74d857c547baea0c2b4faf45b2946036f21a008106396eb1c # shrinks to spec = SecuritySpec { mode: Standard { unique_decoding: false }, target_security_bits: 80, vector_size: 256, starting_log_inv_rate: 1, initial_folding_factor: 4, folding_factor: 3, max_pow_bits: None, hash_id: 03e01749ebcc0477924254eb482066b864a8dd4d77252464ca6f5b6f5cc05b4c, _embedding: PhantomData, 1>>> }, ctx = RoundContext { round_index: 0, vector_size: 256, log_inv_rate: 3, folding_factor: 1, prev_round_in_domain_samples: 0, prev_round_query_error: 0.0 } +cc 36d0f5929e8099fa8644b0511229cf11634e5a7a66d99c06099c304f5f7a8c6e # shrinks to spec = SecuritySpec { mode: ZeroKnowledge, target_security_bits: 47, max_pow_bits: None, hash_id: 03e01749ebcc0477924254eb482066b864a8dd4d77252464ca6f5b6f5cc05b4c, _embedding: PhantomData, 1>>> }, ctx = RoundContext { round_index: 0, vector_size: 128, log_inv_rate: 4, folding_factor: 1, prev_round_in_domain_samples: 0, prev_round_query_error: 0.0 } diff --git a/proptest-regressions/protocols/sumcheck.txt b/proptest-regressions/protocols/sumcheck.txt new file mode 100644 index 00000000..a25df3ca --- /dev/null +++ b/proptest-regressions/protocols/sumcheck.txt @@ -0,0 +1,7 @@ +# Seeds for failure cases proptest has generated in the past. It is +# automatically read and these particular cases re-run before any +# novel cases are generated. +# +# It is recommended to check this file in to source control so that +# everyone who runs the test benefits from these saved cases. +cc 3a705bcc9147c4da8cf518feed3a83ca5031e1ccc09c3b20ba272427ee701cd2 # shrinks to seed = 0, config = Config { field: FieldInfo { characteristic: [255, 255, 255, 255, 0, 0, 0, 1], extension_degree: 1 }, initial_size: 2, round_pow: Config { hash_id: 03e01749ebcc0477924254eb482066b864a8dd4d77252464ca6f5b6f5cc05b4c, threshold: 18446744073709551615 }, num_rounds: 1, mask_length: 9, mask_config: Some(Config { embedding: Identity { field: FieldInfo { characteristic: [255, 255, 255, 255, 0, 0, 0, 1], extension_degree: 1 } }, num_vectors: 1, vector_size: 9, mask_length: 0, codeword_length: 18, interleaving_depth: 1, matrix_commit: Config { element_type: FieldInfo { characteristic: [255, 255, 255, 255, 0, 0, 0, 1], extension_degree: 1 }, num_cols: 1, leaf_hash_id: 03e01749ebcc0477924254eb482066b864a8dd4d77252464ca6f5b6f5cc05b4c, merkle_tree: Config { num_leaves: 18, layers: [LayerConfig { hash_id: 03e01749ebcc0477924254eb482066b864a8dd4d77252464ca6f5b6f5cc05b4c }, LayerConfig { hash_id: 03e01749ebcc0477924254eb482066b864a8dd4d77252464ca6f5b6f5cc05b4c }, LayerConfig { hash_id: 03e01749ebcc0477924254eb482066b864a8dd4d77252464ca6f5b6f5cc05b4c }, LayerConfig { hash_id: 03e01749ebcc0477924254eb482066b864a8dd4d77252464ca6f5b6f5cc05b4c }, LayerConfig { hash_id: 03e01749ebcc0477924254eb482066b864a8dd4d77252464ca6f5b6f5cc05b4c }] } }, johnson_slack: 0.035355339059327376, in_domain_samples: 75, out_domain_samples: 1, deduplicate_in_domain: false }) } diff --git a/src/algebra/ntt/cooley_tukey.rs b/src/algebra/ntt/cooley_tukey.rs index fde9dc5d..67e17f17 100644 --- a/src/algebra/ntt/cooley_tukey.rs +++ b/src/algebra/ntt/cooley_tukey.rs @@ -14,12 +14,13 @@ use {crate::utils::workload_size, rayon::prelude::*, std::cmp::max}; use super::{ transpose, utils::{lcm, sqrt_factor}, - ReedSolomon, + Messages, ReedSolomon, }; #[cfg(not(feature = "rs_in_order"))] use crate::algebra::ntt::transpose::transpose_permute; use crate::{ algebra::ntt::utils::divisors, + buffer::{ActiveBuffer, BufferOps}, utils::{chunks_exact_or_empty, zip_strict}, }; @@ -402,17 +403,33 @@ impl ReedSolomon for NttEngine { } #[cfg_attr(feature = "tracing", instrument(skip(self, messages, masks), fields( - num_messages = messages.len(), - message_len = messages.first().map(|c| c.len()), + num_messages = messages.vectors.len() * messages.interleaving_depth, + message_len = messages.message_length, codeword_length = codeword_length, - mask_len = masks.len().checked_div(messages.len()) + mask_len = masks.len().checked_div(messages.vectors.len() * messages.interleaving_depth) )))] - fn interleaved_encode(&self, messages: &[&[F]], masks: &[F], codeword_length: usize) -> Vec { + fn interleaved_encode( + &self, + messages: Messages<'_, F>, + masks: &ActiveBuffer, + codeword_length: usize, + ) -> ActiveBuffer { + let vectors = messages + .vectors + .iter() + .map(|v| v.to_slice()) + .collect::>(); + let messages = vectors + .iter() + .flat_map(|v| { + chunks_exact_or_empty(v, messages.message_length, messages.interleaving_depth) + }) + .collect::>(); assert!(self.order.is_multiple_of(codeword_length)); if messages.is_empty() { assert!(masks.is_empty()); - return Vec::new(); + return ActiveBuffer::from_vec(Vec::new()); } let num_messages = messages.len(); let message_len = messages[0].len(); @@ -445,7 +462,7 @@ impl ReedSolomon for NttEngine { let mut result = Vec::with_capacity(num_messages * codeword_length); for (message, mask) in zip_strict( messages, - chunks_exact_or_empty(masks, mask_length, num_messages), + chunks_exact_or_empty(masks.to_slice(), mask_length, num_messages), ) { // FFT[a 0 0 0] = [a a a a], so just replicate input in coset dimension. for _ in 0..num_cosets { @@ -473,7 +490,7 @@ impl ReedSolomon for NttEngine { // Transpose to row-major order with vectors stacked horizontally. transpose(&mut result, num_messages, codeword_length); - result + ActiveBuffer::from_vec(result) } } diff --git a/src/algebra/ntt/mod.rs b/src/algebra/ntt/mod.rs index 9e3fc74d..9deeca44 100644 --- a/src/algebra/ntt/mod.rs +++ b/src/algebra/ntt/mod.rs @@ -22,34 +22,35 @@ pub use self::{ }; use crate::{ algebra::fields, + buffer::{ActiveBuffer, BufferOps, DefaultRs}, type_map::{self, TypeMap}, }; pub static NTT: LazyLock> = LazyLock::new(|| { let map = TypeMap::new(); map.insert( - Arc::new(NttEngine::::new_from_fftfield()) as Arc> + Arc::new(DefaultRs::::new_from_fftfield()) as Arc> ); map.insert( - Arc::new(NttEngine::::new_from_fftfield()) as Arc> + Arc::new(DefaultRs::::new_from_fftfield()) as Arc> ); map.insert( - Arc::new(NttEngine::::new_from_fftfield()) as Arc> + Arc::new(DefaultRs::::new_from_fftfield()) as Arc> ); map.insert( - Arc::new(NttEngine::::new_from_fftfield()) as Arc> + Arc::new(DefaultRs::::new_from_fftfield()) as Arc> ); map.insert( - Arc::new(NttEngine::::new_from_fftfield()) as Arc>, + Arc::new(DefaultRs::::new_from_fftfield()) as Arc>, ); map.insert( - Arc::new(NttEngine::::new_from_fftfield()) as Arc>, + Arc::new(DefaultRs::::new_from_fftfield()) as Arc>, ); map.insert(Arc::new( - NttEngine::<::BasePrimeField>::new_from_fftfield(), + DefaultRs::<::BasePrimeField>::new_from_fftfield(), ) as Arc>); map.insert(Arc::new( - NttEngine::<::BasePrimeField>::new_from_fftfield(), + DefaultRs::<::BasePrimeField>::new_from_fftfield(), ) as Arc>); map }); @@ -61,6 +62,37 @@ impl type_map::Family for NttFamily { type Dyn = dyn ReedSolomon; } +/// Interleaved Reed-Solomon input buffers. +/// +/// Chunking buffer-backed vectors into per-message views can require backend work. This descriptor +/// keeps the encoding input compact and lets each backend derive the chunked messages internally. +/// +/// Each vector stores `interleaving_depth` consecutive messages of `message_length` field +/// elements. +#[derive(Clone, Copy)] +pub struct Messages<'a, F> { + pub(crate) vectors: &'a [&'a ActiveBuffer], + pub(crate) message_length: usize, + pub(crate) interleaving_depth: usize, +} + +impl<'a, F: Field> Messages<'a, F> { + pub fn new( + vectors: &'a [&'a ActiveBuffer], + message_length: usize, + interleaving_depth: usize, + ) -> Self { + assert!(vectors + .iter() + .all(|vector| vector.len() == message_length * interleaving_depth)); + Self { + vectors, + message_length, + interleaving_depth, + } + } +} + /// Trait for a Reed-Solomon encoder implementation for a given field `F`. pub trait ReedSolomon: Debug + Send + Sync { /// Returns the next supported order equal or larger than `size`. @@ -88,15 +120,20 @@ pub trait ReedSolomon: Debug + Send + Sync { /// Compute a masked interleaved Reed-Solomon encoding. /// - /// `messages` are `num_messages` slices of `message_length` elements. - /// `masks` is a `num_messages` × `mask_length` matrix of blinding coefficients. + /// `messages` contains `num_vectors` buffers, each holding `interleaving_depth` messages of + /// `message_length` elements laid out contiguously. `masks` is a + /// `(num_vectors * interleaving_depth) × mask_length` matrix of blinding coefficients. /// `codeword_length` must be an NTT-smooth number >= `message_length + mask_length`. - /// returns an `codeword_length × num_messages` matrix. + /// Returns a `codeword_length × (num_vectors * interleaving_depth)` matrix. /// /// Each output value is the univariate polynomial evaluation in the evaluation point /// corresponding with the index of a coefficient list formed by concatenating message and mask. - /// - fn interleaved_encode(&self, messages: &[&[F]], masks: &[F], codeword_length: usize) -> Vec; + fn interleaved_encode( + &self, + messages: Messages<'_, F>, + masks: &ActiveBuffer, + codeword_length: usize, + ) -> ActiveBuffer; } assert_obj_safe!(ReedSolomon); @@ -118,10 +155,10 @@ pub fn evaluation_points( } pub fn interleaved_rs_encode( - messages: &[&[F]], - masks: &[F], + messages: Messages<'_, F>, + masks: &ActiveBuffer, codeword_length: usize, -) -> Vec { +) -> ActiveBuffer { NTT.get::() .expect("Unsupported NTT field.") .interleaved_encode(messages, masks, codeword_length) @@ -145,6 +182,7 @@ mod tests { use super::*; use crate::{ algebra::{random_vector, univariate_evaluate}, + buffer::BufferOps, utils::{chunks_exact_or_empty, zip_strict}, }; @@ -188,10 +226,16 @@ mod tests { .map(|_| random_vector(&mut rng, message_length)) .collect::>(); let masks = random_vector(&mut rng, mask_length * num_messages); - let message_refs = messages.iter().map(|v| v.as_slice()).collect::>(); + let vectors = messages + .iter() + .map(|message| ActiveBuffer::from_slice(message)) + .collect::>(); + let vector_refs = vectors.iter().collect::>(); + let mask_buffer = ActiveBuffer::from_slice(&masks); + let rs_messages = Messages::new(&vector_refs, message_length, 1); let codeword = ntt.interleaved_encode( - &message_refs, - &masks, + rs_messages, + &mask_buffer, codeword_length, ); @@ -200,6 +244,7 @@ mod tests { // Output values are polynomial evaluations in the evaluation points. let mut evaluation_points = ntt.evaluation_points(message_length + mask_length, codeword_length, &sampled_indices); + let codeword = codeword.to_slice(); for (&index, &evaluation_point) in zip_strict(&sampled_indices, &evaluation_points) { let evaluations = &codeword[index * num_messages.. (index + 1) * num_messages]; let masks = chunks_exact_or_empty(&masks, mask_length, num_messages); diff --git a/src/algebra/sumcheck.rs b/src/algebra/sumcheck.rs index aa911b3f..15d9c1df 100644 --- a/src/algebra/sumcheck.rs +++ b/src/algebra/sumcheck.rs @@ -13,7 +13,7 @@ pub fn compute_sumcheck_polynomial(a: &[F], b: &[F]) -> (F, F) { fn recurse(a0: &[F], a1: &[F], b0: &[F], b1: &[F]) -> (F, F) { debug_assert_eq!(a0.len(), b0.len()); debug_assert_eq!(a1.len(), b1.len()); - debug_assert!(a0.len() == a1.len()); + debug_assert_eq!(a0.len(), a1.len()); #[cfg(feature = "parallel")] if a0.len() * 4 > workload_size::() { diff --git a/src/bin/benchmark.rs b/src/bin/benchmark.rs index d9e79ce6..b3014086 100644 --- a/src/bin/benchmark.rs +++ b/src/bin/benchmark.rs @@ -15,9 +15,11 @@ use whir::{ linear_form::{Evaluate, LinearForm, MultilinearExtension}, }, bits::Bits, + buffer::{ActiveBuffer, BufferOps}, cmdline_utils::{AvailableFields, AvailableHash}, hash::HASH_COUNTER, parameters::ProtocolParameters, + protocols::params::DecodingRegime, transcript::{codecs::Empty, Codec, DomainSeparator, ProverState, VerifierState}, }; @@ -48,8 +50,8 @@ struct Args { #[arg(short = 'k', long = "fold", default_value = "4")] folding_factor: usize, - #[arg(long = "unique-decoding", default_value_t = false)] - unique_decoding: bool, + #[arg(long = "decoding-regime", default_value = "Johnson")] + decoding_regime: DecodingRegime, #[arg(short = 'f', long = "field", default_value = "Goldilocks3")] field: AvailableFields, @@ -67,7 +69,7 @@ struct BenchmarkOutput { repetitions: usize, initial_folding_factor: usize, folding_factor: usize, - unique_decoding: bool, + decoding_regime: DecodingRegime, field: AvailableFields, hash: AvailableHash, @@ -117,7 +119,7 @@ where let reps = args.verifier_repetitions; let folding_factor = args.folding_factor; let first_round_folding_factor = args.first_round_folding_factor; - let unique_decoding = args.unique_decoding; + let decoding_regime = args.decoding_regime; std::fs::create_dir_all("outputs").unwrap(); @@ -128,7 +130,7 @@ where pow_bits, initial_folding_factor: first_round_folding_factor, folding_factor, - unique_decoding, + decoding_regime, starting_log_inv_rate: starting_rate, batch_size: 1, hash_id: args.hash.hash_id(), @@ -161,12 +163,13 @@ where HASH_COUNTER.reset(); - let witness = params.commit(&mut prover_state, &[&vector]); + let vector_buffer = ActiveBuffer::from_slice(&vector); + let witness = params.commit(&mut prover_state, &[&vector_buffer]); let _ = params.prove( &mut prover_state, - vec![Cow::Borrowed(vector.as_slice())], - vec![Cow::Owned(witness)], + &[&vector_buffer], + vec![&witness], vec![], Cow::Owned(vec![]), ); @@ -238,7 +241,8 @@ where HASH_COUNTER.reset(); let whir_prover_time = Instant::now(); - let witness = params.commit(&mut prover_state, &[&vector]); + let vector_buffer = ActiveBuffer::from_slice(&vector); + let witness = params.commit(&mut prover_state, &[&vector_buffer]); let prove_linear_forms: Vec>> = points .iter() @@ -249,8 +253,8 @@ where let _ = params.prove( &mut prover_state, - vec![Cow::Borrowed(vector.as_slice())], - vec![Cow::Owned(witness)], + &[&vector_buffer], + vec![&witness], prove_linear_forms, Cow::Borrowed(evaluations.as_slice()), ); @@ -298,7 +302,7 @@ where repetitions: reps, initial_folding_factor: first_round_folding_factor, folding_factor, - unique_decoding, + decoding_regime, field: args.field, hash: args.hash, diff --git a/src/bin/main.rs b/src/bin/main.rs index bafe16f5..56f97487 100644 --- a/src/bin/main.rs +++ b/src/bin/main.rs @@ -11,9 +11,11 @@ use whir::{ linear_form::{Covector, Evaluate, LinearForm, MultilinearExtension}, }, bits::Bits, + buffer::{ActiveBuffer, BufferOps}, cmdline_utils::{AvailableFields, AvailableHash}, hash::HASH_COUNTER, parameters::ProtocolParameters, + protocols::params::DecodingRegime, transcript::{codecs::Empty, Codec, DomainSeparator, ProverState, VerifierState}, }; @@ -48,9 +50,9 @@ struct Args { #[arg(short = 'k', long = "fold", default_value = "4")] folding_factor: usize, - /// Restrict PCS to the Unique Decoding regime. LDT is always UD. - #[arg(long = "unique-decoding", default_value_t = false)] - unique_decoding: bool, + /// Reed–Solomon decoding regime: Unique or Johnson (list-decoding). + #[arg(long = "decoding-regime", default_value = "Johnson")] + decoding_regime: DecodingRegime, #[arg(short = 'f', long = "field", default_value = "Goldilocks3")] field: AvailableFields, @@ -109,7 +111,7 @@ where let reps = args.verifier_repetitions; let first_round_folding_factor = args.first_round_folding_factor; let folding_factor = args.folding_factor; - let unique_decoding = args.unique_decoding; + let decoding_regime = args.decoding_regime; let num_evaluations = args.num_evaluations; let num_linear_constraints = args.num_linear_constraints; let hash_id = args.hash.hash_id(); @@ -125,7 +127,7 @@ where pow_bits, initial_folding_factor: first_round_folding_factor, folding_factor, - unique_decoding, + decoding_regime, starting_log_inv_rate: starting_rate, batch_size: 1, hash_id, @@ -148,9 +150,10 @@ where } let vector = (0..num_coeffs).map(M::Source::from).collect::>(); + let vector_buffer = ActiveBuffer::from_slice(&vector); let whir_commit_time = Instant::now(); - let witness = params.commit(&mut prover_state, &[&vector]); + let witness = params.commit(&mut prover_state, &[&vector_buffer]); let whir_commit_time = whir_commit_time.elapsed(); // Allocate constraints @@ -183,8 +186,8 @@ where let whir_prove_time = Instant::now(); let _ = params.prove( &mut prover_state, - vec![Cow::Borrowed(vector.as_slice())], - vec![Cow::Owned(witness)], + &[&vector_buffer], + vec![&witness], prove_linear_forms, Cow::Borrowed(evaluations.as_slice()), ); @@ -254,7 +257,7 @@ where let num_coeffs = 1 << num_variables; let whir_params = ProtocolParameters { - unique_decoding: args.unique_decoding, + decoding_regime: args.decoding_regime, security_level, pow_bits, initial_folding_factor: first_round_folding_factor, @@ -314,13 +317,14 @@ where } let whir_commit_time = Instant::now(); - let witness = params.commit(&mut prover_state, &[vector.as_slice()]); + let vector_buffer = ActiveBuffer::from_slice(&vector); + let witness = params.commit(&mut prover_state, &[&vector_buffer]); let whir_commit_time = whir_commit_time.elapsed(); let whir_prove_time = Instant::now(); let _ = params.prove( &mut prover_state, - vec![Cow::Borrowed(&vector)], + &[&vector_buffer], witness, prove_linear_forms, Cow::Borrowed(&evaluations), diff --git a/src/bits.rs b/src/bits.rs index 537c8ab2..74f51276 100644 --- a/src/bits.rs +++ b/src/bits.rs @@ -2,6 +2,8 @@ use std::{ cmp::Ordering, fmt::{self, Display}, hash::Hash, + iter::Sum, + ops::{Add, Sub}, }; use serde::{Deserialize, Serialize}; @@ -27,6 +29,32 @@ impl Bits { pub fn is_zero(&self) -> bool { self.0 == 0.0 } + + /// Absolute difference, e.g. for tolerance comparisons. + #[must_use] + pub fn abs_diff(self, other: Self) -> Self { + Self::new((self.0 - other.0).abs()) + } +} + +impl Add for Bits { + type Output = Self; + fn add(self, rhs: Self) -> Self { + Self::new(self.0 + rhs.0) + } +} + +impl Sub for Bits { + type Output = Self; + fn sub(self, rhs: Self) -> Self { + Self::new(self.0 - rhs.0) + } +} + +impl Sum for Bits { + fn sum>(iter: I) -> Self { + iter.fold(Self(0.0), Add::add) + } } impl From for Bits { diff --git a/src/buffer/cpu.rs b/src/buffer/cpu.rs new file mode 100644 index 00000000..85bff3f7 --- /dev/null +++ b/src/buffer/cpu.rs @@ -0,0 +1,261 @@ +//! In-memory CPU backend for the buffer abstraction. + +use std::{any::Any, mem}; + +use ark_ff::Field; +use ark_std::rand::{distributions::Standard, prelude::Distribution, CryptoRng, Rng, RngCore}; + +use crate::{ + algebra::{ + embedding::Embedding, + linear_form::{Covector, LinearForm, UnivariateEvaluation}, + }, + buffer::{Buffer, BufferOps}, + engines::EngineId, + hash::{self, Hash}, + protocols::{ + matrix_commit::{hash_rows, Encodable}, + merkle_tree, + }, +}; + +#[derive( + Clone, + PartialEq, + Eq, + PartialOrd, + Ord, + Hash, + Debug, + Default, + serde::Serialize, + serde::Deserialize, +)] +pub struct CpuBuffer { + data: Vec, +} + +impl BufferOps for CpuBuffer { + type Nodes = CpuBuffer; + + fn from_vec(source: Vec) -> Self { + Self { data: source } + } + + fn from_slice(source: &[T]) -> Self { + Self { + data: source.to_vec(), + } + } + + fn to_slice(&self) -> &[T] { + &self.data + } + + fn len(&self) -> usize { + self.data.len() + } + + fn read_rows(&self, num_cols: usize, indices: &[usize]) -> Vec { + let mut result = Vec::with_capacity(indices.len() * num_cols); + for i in indices { + result.extend_from_slice(&self.data[i * num_cols..(i + 1) * num_cols]); + } + result + } + + fn gather_at_indices(&self, indices: &[usize]) -> Vec { + indices.iter().map(|&i| self.data[i]).collect() + } + + fn merklize( + &self, + num_cols: usize, + leaf_hash: EngineId, + merkle: &merkle_tree::Config, + ) -> (Self::Nodes, Hash) + where + T: Encodable + Send + Sync, + { + assert_eq!(self.len(), num_cols * merkle.num_leaves); + let engine = hash::ENGINES + .retrieve(leaf_hash) + .expect("Failed to retrieve hash engine"); + #[cfg(feature = "tracing")] + tracing::Span::current().record("engine", engine.name().as_ref()); + + let mut leaves = vec![Hash::default(); merkle.num_leaves]; + hash_rows(&*engine, &self.data, &mut leaves); + let nodes = merkle.build_nodes(leaves); + let root = nodes[merkle.num_nodes() - 1]; + (CpuBuffer { data: nodes }, root) + } +} + +impl Buffer for CpuBuffer { + type TargetBuffer = CpuBuffer; + + fn zeros(length: usize) -> Self { + Self { + data: vec![F::ZERO; length], + } + } + + fn random(rng: &mut R, length: usize) -> Self + where + R: RngCore + CryptoRng, + Standard: Distribution, + { + Self { + data: (0..length).map(|_| rng.gen()).collect(), + } + } + + fn dot(&self, other: &Self) -> F { + crate::algebra::dot(&self.data, &other.data) + } + + fn sumcheck_polynomial(&self, other: &Self) -> (F, F) { + crate::algebra::sumcheck::compute_sumcheck_polynomial(&self.data, &other.data) + } + + fn fold(&mut self, weight: F) { + crate::algebra::sumcheck::fold(&mut self.data, weight); + } + + fn fold_pair_sumcheck_polynomial(&mut self, other: &mut Self, weight: F) -> (F, F) { + crate::algebra::sumcheck::fold_and_compute_polynomial( + &mut self.data, + &mut other.data, + weight, + ) + } + + fn scalar_mul(&mut self, weight: F) { + crate::algebra::scalar_mul(&mut self.data, weight); + } + + fn accumulate_univariate_evaluations( + &mut self, + evaluators: &[UnivariateEvaluation], + scalars: &[F], + ) { + let Some(size) = evaluators.first().map(|e| e.size) else { + return; + }; + UnivariateEvaluation::accumulate_many(evaluators, &mut self.data[..size], scalars); + } + + fn mixed_univariate_evaluate>( + &self, + embedding: &M, + point: M::Target, + ) -> M::Target { + crate::algebra::mixed_univariate_evaluate(embedding, &self.data, point) + } + + fn mixed_dot>( + &self, + embedding: &M, + other: &CpuBuffer, + ) -> M::Target { + crate::algebra::mixed_dot(embedding, &other.data, &self.data) + } + + fn mixed_scalar_mul_add_to>( + &self, + embedding: &M, + accumulator: &mut CpuBuffer, + weight: M::Target, + ) { + crate::algebra::mixed_scalar_mul_add(embedding, &mut accumulator.data, weight, &self.data); + } + + fn linear_forms_rlc( + size: usize, + linear_forms: &mut [Box>], + rlc_coeffs: &[F], + ) -> Self { + assert_eq!(linear_forms.len(), rlc_coeffs.len()); + let mut covector = vec![F::ZERO; size]; + if let Some((first, linear_forms)) = linear_forms.split_first_mut() { + debug_assert_eq!(rlc_coeffs[0], F::ONE); + if let Some(covector_form) = + (first.as_mut() as &mut dyn Any).downcast_mut::>() + { + mem::swap(&mut covector, &mut covector_form.vector); + } else { + first.accumulate(&mut covector, F::ONE); + } + for (rlc_coeff, linear_form) in rlc_coeffs[1..].iter().zip(linear_forms) { + linear_form.accumulate(&mut covector, *rlc_coeff); + } + } + Self { data: covector } + } + + fn mixed_linear_combination>( + embedding: &M, + vectors: &[&Self], + coeffs: &[M::Target], + ) -> CpuBuffer { + assert_eq!(vectors.len(), coeffs.len()); + let Some((first, vectors)) = vectors.split_first() else { + return CpuBuffer { data: Vec::new() }; + }; + debug_assert_eq!(coeffs[0], M::Target::ONE); + let mut accumulator = crate::algebra::lift(embedding, &first.data); + for (coeff, vector) in coeffs[1..].iter().zip(vectors) { + crate::algebra::mixed_scalar_mul_add(embedding, &mut accumulator, *coeff, &vector.data); + } + CpuBuffer { data: accumulator } + } +} + +#[cfg(test)] +mod tests { + use ark_ff::AdditiveGroup; + + use super::*; + use crate::algebra::{fields::Field64, geometric_accumulate}; + + type F = Field64; + + #[test] + fn scalar_mul_multiplies_in_place() { + let values = vec![F::from(1u64), F::from(2u64), F::from(3u64), F::from(4u64)]; + let weight = F::from(5u64); + let mut buffer = CpuBuffer::from_vec(values.clone()); + buffer.scalar_mul(weight); + let expected: Vec = values.iter().map(|&v| v * weight).collect(); + assert_eq!(buffer.to_slice(), expected.as_slice()); + } + + #[test] + fn accumulate_matches_geometric_accumulate_over_prefix() { + let len = 8usize; + let points = vec![F::from(3u64), F::from(5u64)]; + let scalars = vec![F::from(2u64), F::from(9u64)]; + + // Full-length and prefix accumulation. + for size in [len, 5] { + let mut buffer = CpuBuffer::from_vec(vec![F::ZERO; len]); + let evaluators: Vec<_> = points + .iter() + .map(|&point| UnivariateEvaluation::new(point, size)) + .collect(); + buffer.accumulate_univariate_evaluations(&evaluators, &scalars); + + // Reference: accumulate Σ_j scalars[j]·points[j]^i into the prefix + // of a plain vector. + let mut reference = vec![F::ZERO; len]; + geometric_accumulate(&mut reference[..size], scalars.clone(), &points); + + assert_eq!( + buffer.to_slice(), + reference.as_slice(), + "accumulate mismatch for evaluator size {size}" + ); + } + } +} diff --git a/src/buffer/mod.rs b/src/buffer/mod.rs new file mode 100644 index 00000000..9f320111 --- /dev/null +++ b/src/buffer/mod.rs @@ -0,0 +1,151 @@ +//! Backend-agnostic buffers for protocol data. +//! +//! Protocol code uses the [`ActiveBuffer`] alias to select the backend at +//! compile time. Buffers are owned, backend-managed storage: on the CPU +//! backend they wrap a `Vec`, on an accelerator backend they would own +//! device memory and only [`BufferOps::to_slice`] (and the other readback +//! methods) force a host copy. +//! +//! The trait split follows the element type. [`BufferOps`] is generic over +//! any element and also covers [`struct@Hash`] buffers for Merkle tree +//! nodes. [`Buffer`] adds field arithmetic used by the protocols. +//! +//! [`DefaultRs`] selects the Reed-Solomon encoder for the active backend. + +pub mod cpu; + +use ark_ff::Field; +use ark_std::rand::{ + distributions::{Distribution, Standard}, + CryptoRng, RngCore, +}; +pub use cpu::CpuBuffer; + +use crate::{ + algebra::{ + embedding::Embedding, + linear_form::{LinearForm, UnivariateEvaluation}, + }, + engines::EngineId, + hash::Hash, + protocols::{matrix_commit::Encodable, merkle_tree}, +}; + +pub type ActiveBuffer = CpuBuffer; +pub type DefaultRs = crate::algebra::ntt::NttEngine; + +/// Owned buffer operations over any element type. +/// +/// This trait is not field-specific, so it also covers hash buffers and +/// Merkle node layers. Field arithmetic lives on [`Buffer`]. +pub trait BufferOps { + /// Same-backend buffer type used for Merkle tree nodes. + type Nodes: BufferOps; + + fn from_vec(source: Vec) -> Self; + fn from_slice(source: &[T]) -> Self; + /// Read back the buffer contents as a host slice. + fn to_slice(&self) -> &[T]; + fn len(&self) -> usize; + fn is_empty(&self) -> bool { + self.len() == 0 + } + /// Gather full rows `indices[i] * num_cols .. (indices[i] + 1) * num_cols`. + fn read_rows(&self, num_cols: usize, indices: &[usize]) -> Vec; + /// Gather elements at arbitrary indices. + fn gather_at_indices(&self, indices: &[usize]) -> Vec; + /// Hash rows of width `num_cols` and build a Merkle tree. + fn merklize( + &self, + num_cols: usize, + leaf_hash: EngineId, + merkle: &merkle_tree::Config, + ) -> (Self::Nodes, Hash) + where + T: Encodable + Send + Sync; +} + +/// Field operations on owned buffers. +pub trait Buffer: BufferOps + Clone { + /// Same-backend owned buffer over another field. + /// + /// Used by the `mixed_*` operations that lift base-field data into an + /// extension field through an [`Embedding`]. + type TargetBuffer: Buffer; + + fn zeros(length: usize) -> Self; + + fn random(rng: &mut R, length: usize) -> Self + where + R: RngCore + CryptoRng, + Standard: Distribution; + + /// Inner product with another buffer of the same length. + fn dot(&self, other: &Self) -> F; + + /// Sumcheck round coefficients `(c0, c2)` for `dot(self, other)`. + fn sumcheck_polynomial(&self, other: &Self) -> (F, F); + + fn fold(&mut self, weight: F); + + fn fold_pair(&mut self, other: &mut Self, weight: F) { + self.fold(weight); + other.fold(weight); + } + + fn fold_pair_sumcheck_polynomial(&mut self, other: &mut Self, weight: F) -> (F, F) { + self.fold_pair(other, weight); + self.sumcheck_polynomial(other) + } + + /// In-place scalar multiplication: `self[i] *= weight`. + fn scalar_mul(&mut self, weight: F); + + /// Accumulate `Σ_j scalars[j] · evaluators[j].point^i` into entry `i`. + /// + /// The evaluators must share a common size `s ≤ self.len()`; only the + /// first `s` entries are updated. This allows accumulating constraints + /// that cover a prefix of the buffer (e.g. the unmasked message part of + /// a covector). + fn accumulate_univariate_evaluations( + &mut self, + evaluators: &[UnivariateEvaluation], + scalars: &[F], + ); + + /// Univariate evaluation at a target-field point. + fn mixed_univariate_evaluate>( + &self, + embedding: &M, + point: M::Target, + ) -> M::Target; + + /// Inner product with a target-field buffer. + fn mixed_dot>( + &self, + embedding: &M, + other: &Self::TargetBuffer, + ) -> M::Target; + + /// `accumulator += weight * self`, lifted into the target field. + fn mixed_scalar_mul_add_to>( + &self, + embedding: &M, + accumulator: &mut Self::TargetBuffer, + weight: M::Target, + ); + + /// Random linear combination of linear forms into a covector buffer. + fn linear_forms_rlc( + size: usize, + linear_forms: &mut [Box>], + rlc_coeffs: &[F], + ) -> Self; + + /// Random linear combination of vectors, lifted into the target field. + fn mixed_linear_combination>( + embedding: &M, + vectors: &[&Self], + coeffs: &[M::Target], + ) -> Self::TargetBuffer; +} diff --git a/src/lib.rs b/src/lib.rs index 7d1d8b45..4c1a5c3d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,7 @@ pub mod algebra; pub mod ark_serde; pub mod bits; +pub mod buffer; pub mod cmdline_utils; pub mod engines; pub mod hash; diff --git a/src/parameters.rs b/src/parameters.rs index 242b9749..8e257c79 100644 --- a/src/parameters.rs +++ b/src/parameters.rs @@ -2,13 +2,13 @@ use std::fmt::{Debug, Display}; use serde::{Deserialize, Serialize}; -use crate::engines::EngineId; +use crate::{engines::EngineId, protocols::params::DecodingRegime}; /// Configuration parameters for WHIR proofs. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct ProtocolParameters { - /// Whether to require unique decoding. - pub unique_decoding: bool, + /// Reed–Solomon decoding regime: `Unique` or `Johnson` (list-decoding). + pub decoding_regime: DecodingRegime, /// The logarithmic inverse rate for sampling. pub starting_log_inv_rate: usize, /// Folding factor for the initial round. @@ -30,13 +30,7 @@ impl Display for ProtocolParameters { writeln!( f, "Targeting {}-bits of security with {}-bits of PoW using {} decoding", - self.security_level, - self.pow_bits, - if self.unique_decoding { - "unique" - } else { - "list" - } + self.security_level, self.pow_bits, self.decoding_regime, )?; writeln!( f, diff --git a/src/protocols/basecase.rs b/src/protocols/basecase.rs index e142aa55..ce9f56e8 100644 --- a/src/protocols/basecase.rs +++ b/src/protocols/basecase.rs @@ -1,8 +1,4 @@ -//! Base Case Linear Opening Protocol -//! -//! It support honest verifier zero-knowledge (HVZK), but is not succinct. -//! -//! § 7. +//! Non-succinct linear opening (Construction 7.2, p.43). HVZK in ZK mode. use ark_ff::Field; use ark_std::rand::{distributions::Standard, prelude::Distribution, CryptoRng, RngCore}; @@ -10,12 +6,10 @@ use serde::{Deserialize, Serialize}; use spongefish::{Decoding, VerificationResult}; use crate::{ - algebra::{ - dot, embedding::Identity, multilinear_extend, random_vector, scalar_mul_add_new, - univariate_evaluate, - }, + algebra::{embedding::Identity, multilinear_extend, univariate_evaluate}, + buffer::{ActiveBuffer, Buffer, BufferOps}, hash::Hash, - protocols::{irs_commit, sumcheck}, + protocols::{irs_commit, proof_of_work, sumcheck}, transcript::{ codecs::U64, Codec, DuplexSpongeInterface, ProverMessage, ProverState, VerifierMessage, VerifierState, @@ -24,29 +18,90 @@ use crate::{ verify, }; +#[must_use] +pub struct Opening { + pub evaluation_points: Vec, + pub linear_form_evaluation: F, +} + +/// Standard / ZeroKnowledge selector for basecase. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum BasecaseMode { + Standard, + ZeroKnowledge, +} + +#[must_use] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(bound = "")] pub struct Config { - pub commit: irs_commit::Config>, - pub sumcheck: sumcheck::Config, - - /// Whether to mask the vectors, whichs adds HVZK. - pub masked: bool, + commit: irs_commit::Config>, + sumcheck: sumcheck::Config, + mode: BasecaseMode, + pow: proof_of_work::Config, } impl Config { + /// Standard basecase has no γ challenge — PoW must be `none()`. ZK + /// basecase has a γ-combination slot (Lemma 7.4) and may or may not need + /// PoW depending on whether the analytic floor already clears the target + /// (under unique decoding it often does). + pub fn new( + commit: irs_commit::Config>, + sumcheck: sumcheck::Config, + mode: BasecaseMode, + pow: proof_of_work::Config, + ) -> Self { + let has_pow = pow != proof_of_work::Config::none(); + debug_assert!( + !matches!(mode, BasecaseMode::Standard) || !has_pow, + "Standard basecase has no γ challenge — pow must be none()", + ); + Self { + commit, + sumcheck, + mode, + pow, + } + } + + pub const fn commit(&self) -> &irs_commit::Config> { + &self.commit + } + + pub const fn sumcheck(&self) -> &sumcheck::Config { + &self.sumcheck + } + + pub const fn mode(&self) -> BasecaseMode { + self.mode + } + + pub const fn pow(&self) -> proof_of_work::Config { + self.pow + } + + #[cfg(test)] + pub(crate) const fn set_pow_for_test(&mut self, pow: proof_of_work::Config) { + self.pow = pow; + } + pub const fn size(&self) -> usize { - self.sumcheck.initial_size + self.sumcheck.initial_size() + } + + pub const fn is_zk(&self) -> bool { + matches!(self.mode, BasecaseMode::ZeroKnowledge) } pub fn prove( &self, prover_state: &mut ProverState, - mut vector: Vec, + mut vector: ActiveBuffer, witness: &irs_commit::Witness, - mut covector: Vec, + mut covector: ActiveBuffer, mut sum: F, - ) -> (Vec, F) + ) -> Opening where H: DuplexSpongeInterface, R: RngCore + CryptoRng, @@ -57,80 +112,110 @@ impl Config { Hash: ProverMessage<[H::U]>, Standard: Distribution, { - assert_eq!(self.commit.interleaving_depth, 1); - assert_eq!(self.commit.num_vectors, 1); - assert_eq!(self.commit.vector_size, self.sumcheck.initial_size); - assert_eq!(self.sumcheck.final_size(), 1.min(self.commit.vector_size)); - debug_assert_eq!(dot(&vector, &covector), sum); + assert_eq!(self.commit.interleaving_depth(), 1); + assert_eq!(self.commit.num_vectors(), 1); + assert_eq!(self.commit.vector_size(), self.sumcheck.initial_size()); + assert_eq!(self.sumcheck.final_size(), 1.min(self.commit.vector_size())); + debug_assert_eq!(vector.dot(&covector), sum); if self.size() == 0 { - return (Vec::new(), F::ZERO); + return Opening { + evaluation_points: Vec::new(), + linear_form_evaluation: F::ZERO, + }; } - // Even more trivial non-zk protocol: send f an r directly. - if !self.masked { - prover_state.prover_messages(&vector); - prover_state.prover_messages(&witness.masks); - let _ = self.commit.open(prover_state, &[witness]); - let point = self - .sumcheck - .prove(prover_state, &mut vector, &mut covector, &mut sum, &[]) - .0; - assert!(!vector[0].is_zero(), "Proof failed"); - return (point, covector[0]); - } - - // Create masking vector. - let mask = random_vector(prover_state.rng(), vector.len()); - - // Commit to the masking vector. - let mask_witness = self.commit.commit(prover_state, &[&mask]); - - // Compute and send linear form of mask (μ' in paper). - let mask_sum = dot(&mask, &covector); - prover_state.prover_message(&mask_sum); - - // RLC the mask with the vector - let mask_rlc = prover_state.verifier_message::(); - assert!(!mask_rlc.is_zero(), "Proof failed"); - let mut masked_vector = scalar_mul_add_new(&mask, mask_rlc, &vector); - prover_state.prover_messages(&masked_vector); + let blinding_witness = + self.maybe_blind_prove(prover_state, &mut vector, witness, &covector, &mut sum); - // Send combined IRS randomness. (r^* in paper) - let masked_masks = scalar_mul_add_new(&mask_witness.masks, mask_rlc, &witness.masks); - prover_state.prover_messages(&masked_masks); + let witnesses: Vec<&irs_commit::Witness> = blinding_witness + .as_ref() + .map_or_else(|| vec![witness], |b| vec![b, witness]); + let _ = self.commit.open(prover_state, &witnesses); - // Open the commitment and mask simultaneously. - let _ = self.commit.open(prover_state, &[&mask_witness, witness]); - - // Run sumcheck to reduce linear form claim - let mut masked_sum = mask_sum + mask_rlc * sum; let point = self .sumcheck - .prove( - prover_state, - &mut masked_vector, - &mut covector, - &mut masked_sum, - &[], - ) - .0; - - // If the MLE of `masked_vector` evaluates to zero, the verifier can not proceed. - // Basically the sumcheck equation has degenerated to 0 * l(r) = 0, which provides - // no constraints on l(r) that the verifier can return. - // This event is cryptographically unlikely as `F` is challenge sized. - assert!(!masked_vector[0].is_zero(), "Proof failed"); - - // Return evaluation point and value of the covector. - (point, covector[0]) + .prove(prover_state, &mut vector, &mut covector, &mut sum, &[]) + .round_challenges; + + // Negligible event over a challenge-sized field; without it the verifier + // cannot derive `l(r) = sum / vector_mle(r)`. + assert!( + !vector.to_slice().first().expect("Proof failed").is_zero(), + "Proof failed" + ); + + Opening { + evaluation_points: point, + linear_form_evaluation: *covector.to_slice().first().expect("Proof failed"), + } + } + + /// ZK: commits a blinding codeword, runs the RLC, mutates `vector`/`sum` to + /// the combined values, sends them cleartext. Standard: sends `vector` and + /// `witness.masks` cleartext (no ZK). + fn maybe_blind_prove( + &self, + prover_state: &mut ProverState, + vector: &mut ActiveBuffer, + witness: &irs_commit::Witness, + covector: &ActiveBuffer, + sum: &mut F, + ) -> Option> + where + H: DuplexSpongeInterface, + R: RngCore + CryptoRng, + F: Codec<[H::U]>, + [u8; 32]: Decoding<[H::U]>, + U64: Codec<[H::U]>, + Hash: ProverMessage<[H::U]>, + Standard: Distribution, + { + match self.mode { + BasecaseMode::Standard => { + prover_state.prover_messages(vector.to_slice()); + prover_state.prover_messages(witness.masks.to_slice()); + None + } + BasecaseMode::ZeroKnowledge => { + let mut blinding_vector = ActiveBuffer::random(prover_state.rng(), vector.len()); + let blinding_witness = self.commit.commit(prover_state, &[&blinding_vector]); + let blinding_inner_product = blinding_vector.dot(covector); + prover_state.prover_message(&blinding_inner_product); + + // Grind the Theorem 7.1 γ-combination gap before γ is sampled. + self.pow.prove(prover_state); + + let combination_randomness = prover_state.verifier_message::(); + assert!(!combination_randomness.is_zero(), "Proof failed"); + + vector.mixed_scalar_mul_add_to( + &Identity::::new(), + &mut blinding_vector, + combination_randomness, + ); + *vector = blinding_vector; + prover_state.prover_messages(vector.to_slice()); + + let mut combined_irs_randomness = blinding_witness.masks.clone(); + witness.masks.mixed_scalar_mul_add_to( + &Identity::::new(), + &mut combined_irs_randomness, + combination_randomness, + ); + prover_state.prover_messages(combined_irs_randomness.to_slice()); + + *sum = blinding_inner_product + combination_randomness * *sum; + Some(blinding_witness) + } + } } pub fn verify( &self, verifier_state: &mut VerifierState, - commitment: &irs_commit::Commitment, + commitment: &irs_commit::Commitment, mut sum: F, - ) -> VerificationResult<(Vec, F)> + ) -> VerificationResult> where H: DuplexSpongeInterface, F: Codec<[H::U]>, @@ -139,67 +224,81 @@ impl Config { U64: Codec<[H::U]>, Hash: ProverMessage<[H::U]>, { - assert_eq!(self.commit.interleaving_depth, 1); - assert_eq!(self.commit.num_vectors, 1); - assert_eq!(self.commit.vector_size, self.sumcheck.initial_size); - assert_eq!(self.sumcheck.final_size(), 1.min(self.commit.vector_size)); + assert_eq!(self.commit.interleaving_depth(), 1); + assert_eq!(self.commit.num_vectors(), 1); + assert_eq!(self.commit.vector_size(), self.sumcheck.initial_size()); + assert_eq!(self.sumcheck.final_size(), 1.min(self.commit.vector_size())); if self.size() == 0 { - return Ok((Vec::new(), F::ZERO)); + return Ok(Opening { + evaluation_points: Vec::new(), + linear_form_evaluation: F::ZERO, + }); } - // Unmasked protocol - if !self.masked { - let vector = verifier_state.prover_messages_vec(self.commit.vector_size)?; - let masks = verifier_state - .prover_messages_vec(self.commit.mask_length * self.commit.num_messages())?; - let evals = self.commit.verify(verifier_state, &[commitment])?; - let point = self.sumcheck.verify(verifier_state, &mut sum)?.0; - - for (&point, value) in zip_strict(&evals.points, evals.values(&[F::ONE])) { - // We expected `f(x) + x^l · g(x)` where l = deg(f) + 1, f is the message and g the mask. - let expected = univariate_evaluate(&vector, point) - + point.pow([self.commit.message_length() as u64]) - * univariate_evaluate(&masks, point); - verify!(value == expected); - } - let mle = multilinear_extend(&vector, &point); - verify!(!mle.is_zero()); - let linear_mle = sum / mle; - return Ok((point, linear_mle)); - } + let blind = self.maybe_receive_blind(verifier_state, &mut sum)?; - let mask_commitment = self.commit.receive_commitment(verifier_state)?; - let mask_sum: F = verifier_state.prover_message()?; - let mask_rlc: F = verifier_state.verifier_message(); - verify!(!mask_rlc.is_zero()); - let masked_vector: Vec = verifier_state.prover_messages_vec(self.commit.vector_size)?; - let masked_masks: Vec = verifier_state.prover_messages_vec(self.commit.mask_length)?; + let vector = verifier_state.prover_messages_vec(self.commit.vector_size())?; + let irs_randomness = verifier_state + .prover_messages_vec(self.commit.mask_length() * self.commit.num_messages())?; - // Open the commitment and mask simultaneously. - let evals = self - .commit - .verify(verifier_state, &[&mask_commitment, commitment])?; + let (commitments, weights): (Vec<&irs_commit::Commitment>, Vec) = match &blind { + Some((b, gamma)) => (vec![b, commitment], vec![F::ONE, *gamma]), + None => (vec![commitment], vec![F::ONE]), + }; + let evals = self.commit.verify(verifier_state, &commitments)?; - // Spot check evaluations. - for (&point, value) in zip_strict(&evals.points, evals.values(&[F::ONE, mask_rlc])) { - // We expected `f(x) + x^l · g(x)` where l = deg(f) + 1, f is the message and g the mask. - let expected = univariate_evaluate(&masked_vector, point) + // Spot-check: Enc_C(vector, irs_randomness)(x) = Σ weights · opened_row(x). + for (&point, value) in zip_strict(&evals.points, evals.values(&weights)) { + let expected = univariate_evaluate(&vector, point) + point.pow([self.commit.message_length() as u64]) - * univariate_evaluate(&masked_masks, point); + * univariate_evaluate(&irs_randomness, point); verify!(value == expected); } - // Sumcheck on masked inner product - let mut masked_sum = mask_sum + mask_rlc * sum; - let point = self.sumcheck.verify(verifier_state, &mut masked_sum)?.0; - - // Compute implied MLE of the linear form - // f*(r) · l(r) = sum => l(r) = sum / f*(r) - let masked_mle = multilinear_extend(&masked_vector, &point); - verify!(!masked_mle.is_zero()); - let linear_mle = masked_sum / masked_mle; + let point = self + .sumcheck + .verify(verifier_state, &mut sum)? + .round_challenges; + + // l(r) = sum / vector_mle(r), where l is the implicit linear form. + let mle = multilinear_extend(&vector, &point); + verify!(!mle.is_zero()); + let linear_mle = sum / mle; + + Ok(Opening { + evaluation_points: point, + linear_form_evaluation: linear_mle, + }) + } - Ok((point, linear_mle)) + /// ZK: reads the blinding commitment + μ' + γ, mutates `sum` to the + /// combined value, returns `(commitment, γ)`. Standard: no-op. + fn maybe_receive_blind( + &self, + verifier_state: &mut VerifierState, + sum: &mut F, + ) -> VerificationResult> + where + H: DuplexSpongeInterface, + F: Codec<[H::U]>, + u8: Decoding<[H::U]>, + [u8; 32]: Decoding<[H::U]>, + U64: Codec<[H::U]>, + Hash: ProverMessage<[H::U]>, + { + match self.mode { + BasecaseMode::Standard => Ok(None), + BasecaseMode::ZeroKnowledge => { + let blinding_commitment = self.commit.receive_commitment(verifier_state)?; + let blinding_inner_product: F = verifier_state.prover_message()?; + // Grind the Theorem 7.1 γ-combination gap before γ is sampled. + self.pow.verify(verifier_state)?; + let combination_randomness: F = verifier_state.verifier_message(); + verify!(!combination_randomness.is_zero()); + *sum = blinding_inner_product + combination_randomness * *sum; + Ok(Some((blinding_commitment, combination_randomness))) + } + } } } @@ -211,27 +310,26 @@ mod tests { use tracing::instrument; use super::*; - use crate::{ - algebra::fields, protocols::proof_of_work, transcript::DomainSeparator, type_info::Type, - }; + use crate::{algebra::fields, protocols::proof_of_work, transcript::DomainSeparator}; impl Config { pub fn arbitrary(size: usize, mask_length: usize) -> impl Strategy { let commit = irs_commit::Config::arbitrary(Identity::::new(), 1, size, mask_length, 1); - (commit, bool::weighted(0.8)).prop_map(move |(commit, masked)| Self { - commit: irs_commit::Config { - out_domain_samples: 0, - ..commit + (commit, bool::weighted(0.8)).prop_map(move |(commit, is_zk)| Self { + commit, + sumcheck: sumcheck::Config::new( + size, + proof_of_work::Config::none(), + size.next_power_of_two().trailing_zeros() as usize, + sumcheck::SumcheckMode::Standard, + ), + mode: if is_zk { + BasecaseMode::ZeroKnowledge + } else { + BasecaseMode::Standard }, - sumcheck: sumcheck::Config { - field: Type::new(), - initial_size: size, - round_pow: proof_of_work::Config::none(), - num_rounds: size.next_power_of_two().trailing_zeros() as usize, - mask_length: 0, - }, - masked, + pow: proof_of_work::Config::none(), }) } } @@ -242,40 +340,46 @@ mod tests { F: Field + Codec, Standard: Distribution, { - // Pseudo-random Instance let instance = U64(seed); let ds = DomainSeparator::protocol(config) .session(&format!("Test at {}:{}", file!(), line!())) .instance(&instance); let mut rng = StdRng::seed_from_u64(seed); - let vector = random_vector(&mut rng, config.size()); - let covector = random_vector(&mut rng, config.size()); - let sum = dot(&vector, &covector); + let vector = ActiveBuffer::random(&mut rng, config.size()); + let covector = ActiveBuffer::random(&mut rng, config.size()); + let sum = vector.dot(&covector); - // Prover let mut prover_state = ProverState::new_std(&ds); let witness = config.commit.commit(&mut prover_state, &[&vector]); - let (point, value) = config.prove( + let prover_result = config.prove( &mut prover_state, vector.clone(), &witness, covector.clone(), sum, ); - assert_eq!(multilinear_extend(&covector, &point), value); + assert_eq!( + multilinear_extend(covector.to_slice(), &prover_result.evaluation_points), + prover_result.linear_form_evaluation + ); let proof = prover_state.proof(); - // Verifier let mut verifier_state = VerifierState::new_std(&ds, &proof); let commitment = config .commit .receive_commitment(&mut verifier_state) .unwrap(); - let (verifier_point, verifier_value) = config + let verifier_result = config .verify(&mut verifier_state, &commitment, sum) .unwrap(); - assert_eq!(verifier_point, point); - assert_eq!(verifier_value, value); + assert_eq!( + verifier_result.evaluation_points, + prover_result.evaluation_points + ); + assert_eq!( + verifier_result.linear_form_evaluation, + prover_result.linear_form_evaluation + ); verifier_state.check_eof().unwrap(); } diff --git a/src/protocols/code_switch.rs b/src/protocols/code_switch.rs index 31ef08a3..e0db0460 100644 --- a/src/protocols/code_switch.rs +++ b/src/protocols/code_switch.rs @@ -3,7 +3,7 @@ //! Reduces a proximity claim about oracle f (source code C) to a proximity //! claim about oracle g (target code C'). Supports optional ZK via mask oracle. -use std::fmt; +use std::{fmt, num::NonZeroUsize}; use ark_ff::Field; use ark_std::rand::{distributions::Standard, prelude::Distribution, CryptoRng, RngCore}; @@ -15,192 +15,217 @@ use crate::{ algebra::{ dot, embedding::{Embedding, Identity}, - fields::FieldWithSize, - geometric_accumulate, lift, mixed_dot, random_vector, scalar_mul, univariate_evaluate, + eq_weights, lift, + linear_form::UnivariateEvaluation, + mixed_dot, }, - engines::EngineId, + buffer::{ActiveBuffer, Buffer, BufferOps}, hash::Hash, protocols::{ geometric_challenge::geometric_challenge, - irs_commit::{ - num_ood_samples, Commitment as IrsCommitment, Config as IrsConfig, - Witness as IrsWitness, - }, + irs_commit::{Commitment as IrsCommitment, Config as IrsConfig, Witness as IrsWitness}, + proof_of_work, }, transcript::{ - Codec, Decoding, DuplexSpongeInterface, ProverMessage, ProverState, VerificationResult, - VerifierMessage, VerifierState, + codecs::U64, Codec, Decoding, DuplexSpongeInterface, ProverMessage, ProverState, + VerificationResult, VerifierMessage, VerifierState, }, - type_info::Typed, + verify, }; +/// Standard / ZeroKnowledge selector for code-switch. +#[derive(Clone, PartialEq, Eq, Debug, Hash, Serialize, Deserialize)] +pub enum CodeSwitchMode { + Standard, + ZeroKnowledge { message_mask_length: NonZeroUsize }, +} + /// Code-switching IOR config with optional ZK. +#[must_use] #[derive(Clone, PartialEq, Eq, Debug, Hash, Serialize, Deserialize)] #[serde(bound = "")] pub struct Config { - pub embedding: Typed, - pub source: IrsConfig, - pub target: IrsConfig>, - pub out_domain_samples: usize, - pub mask_commit: Option>>, -} - -/// Next stage's query budgets for ZK encoding (Prop 3.19). -#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash, Serialize, Deserialize)] -pub struct ZkQueryBudget { - /// Queries the next stage makes to the target oracle g. - pub target: usize, - /// Queries the next stage makes to the mask oracle s. - pub mask: usize, -} - -/// ZK mask oracle: the message and its IRS witness. -#[derive(Clone, Debug)] -pub struct MaskOracle { - pub message: Vec, - pub witness: IrsWitness, + source: IrsConfig, + target: IrsConfig>, + mode: CodeSwitchMode, + out_domain_samples: usize, + pow: proof_of_work::Config, } /// Prover output from the code-switch. +#[must_use] #[derive(Clone, Debug)] pub struct Witness { - pub message: Vec, + pub message: ActiveBuffer, pub target_witness: IrsWitness, - pub mask: Option>, } /// Verifier output from the code-switch. -#[derive(Clone, Debug)] -pub struct Commitment { - pub target: IrsCommitment, - pub mask: Option>, +pub type Commitment = IrsCommitment; + +#[inline] +fn univariate_evaluators(points: &[F], size: usize) -> Vec> { + points + .iter() + .map(|&point| UnivariateEvaluation::new(point, size)) + .collect() } impl Config { - /// `zk`: `None` for non-ZK, `Some(budget)` for ZK with next stage's query budgets. + /// Create a code-switch config. pub fn new( - security_target: f64, - unique_decoding: bool, - hash_id: EngineId, source_config: IrsConfig, - target_log_inv_rate: usize, - target_interleaving_depth: usize, - zk: Option, - ) -> Self - where - M: Default, - M::Target: Default, - { - assert!(target_log_inv_rate > 0); - assert!(target_interleaving_depth > 0); - - let source_message_length = source_config.message_length(); - let target_rate = 0.5_f64.powf(target_log_inv_rate as f64); - let target_vector_size = source_message_length * target_interleaving_depth; - - let mut target_config = IrsConfig::>::new( - security_target, - unique_decoding, - hash_id, + target_config: IrsConfig>, + out_domain_samples: usize, + mode: CodeSwitchMode, + pow: proof_of_work::Config, + ) -> Self { + assert_eq!( + source_config.num_vectors(), 1, - target_vector_size, - target_interleaving_depth, - target_rate, + "code-switch requires a single source vector" ); - - let mask_commit = zk.map(|budget| { - assert!(budget.target > 0, "ZK requires nonzero target query budget"); - assert!(budget.mask > 0, "ZK requires nonzero mask query budget"); - // TODO : find a better way to set mask length for ZK code-switch - // leaving right now for code switching. This will change in parameter - // selection - let target_slack = - target_config.codeword_length - target_config.message_length(); + assert_eq!( + target_config.num_vectors(), + 1, + "code-switch requires a single target vector" + ); + // Construction 9.7 needs at least one OOD challenge; unique-decoding + // Standard mode (`t_ood = 0`) is incompatible with code-switch. + assert!( + out_domain_samples > 0, + "code-switch requires t_ood ≥ 1 (Construction 9.7)", + ); + // Target encodes one polynomial of length ℓ = source.message_length() + // under C' = D^{ι_t}. The IRS splits the input of length ℓ into ι_t + // parallel slices of length ℓ/ι_t, each encoded under D. + assert_eq!( + target_config.vector_size(), + source_config.message_length(), + "target vector_size must equal source message_length (target encodes one polynomial of length ℓ)" + ); + assert!( + target_config.interleaving_depth().is_power_of_two(), + "target.interleaving_depth must be a power of 2" + ); + assert!( + source_config.interleaving_depth().is_power_of_two(), + "source.interleaving_depth must be a power of 2" + ); + if let CodeSwitchMode::ZeroKnowledge { + message_mask_length, + } = &mode + { + let l_zk = message_mask_length.get(); + // Theorem 9.6: ℓ_zk ≥ r (mask oracle must cover source randomness). assert!( - budget.target <= target_slack, - "budget.target ({}) exceeds target codeword slack ({codeword_len} - {msg_len} = {target_slack})", - budget.target, - codeword_len = target_config.codeword_length, - msg_len = target_config.message_length(), + l_zk >= source_config.mask_length(), + "message_mask_length ({l_zk}) must be >= source randomness length ({})", + source_config.mask_length(), ); - target_config.mask_length = budget.target; - target_config.recompute_security_parameters(security_target, unique_decoding); - - let source_randomness_len = source_config.mask_length * source_config.num_messages(); assert!( - source_randomness_len > 0, - "ZK code-switch requires source_config.mask_length > 0" - ); - // TODO : move the mask config out for shared mask tree per iteration - // inputs to the new function will also change - let mut mask_config = IrsConfig::>::new( - security_target, - unique_decoding, - hash_id, - 1, - source_randomness_len, - 1, - source_config.rate(), + l_zk - source_config.mask_length() >= out_domain_samples, + "sampled randomness (s) length must cover all out-of-domain sample requests" ); - let mask_slack = - mask_config.codeword_length - mask_config.message_length(); + // t' = target in-domain queries + OOD queries (Construction 9.7 step 4). + // Definition 3.16: a t'-query ZK encoding requires r' ≥ t'; here + // r' = target.mask_length. assert!( - budget.mask <= mask_slack, - "budget.mask ({}) exceeds mask codeword slack ({codeword_len} - {msg_len} = {mask_slack})", - budget.mask, - codeword_len = mask_config.codeword_length, - msg_len = mask_config.message_length(), + target_config.mask_length() + >= target_config.in_domain_samples() + out_domain_samples, + "target encoder violates t' ≤ r': queries must be covered by target mask" ); - mask_config.mask_length = budget.mask; - mask_config.recompute_security_parameters(security_target, unique_decoding); - mask_config - }); - - assert!( - source_config.mask_length == 0 || mask_commit.is_some(), - "source with ZK randomness requires ZK code-switch (mask_commit)" - ); - - let (list_size, degree) = mask_commit.as_ref().map_or_else( - || (target_config.list_size(), source_message_length), - |mask_cfg| { - ( - target_config.list_size() * mask_cfg.list_size(), - source_message_length + mask_cfg.message_length(), - ) - }, - ); - let out_domain_samples = num_ood_samples( - unique_decoding, - security_target, - M::Target::field_size_bits(), - list_size, - degree, - ); + } else { + assert_eq!( + source_config.mask_length(), + 0, + "source with IRS randomness requires ZK mode", + ); + } Self { - embedding: Typed::::default(), source: source_config, target: target_config, + mode, out_domain_samples, - mask_commit, + pow, } } + pub const fn source(&self) -> &IrsConfig { + &self.source + } + + pub const fn target(&self) -> &IrsConfig> { + &self.target + } + + pub const fn mode(&self) -> &CodeSwitchMode { + &self.mode + } + + pub const fn out_domain_samples(&self) -> usize { + self.out_domain_samples + } + + pub const fn pow(&self) -> proof_of_work::Config { + self.pow + } + + #[cfg(test)] + pub(crate) const fn target_mut_for_test(&mut self) -> &mut IrsConfig> { + &mut self.target + } + + /// Mask oracle length `ℓ_zk`. Returns 0 in Standard mode. + pub const fn message_mask_length(&self) -> usize { + match &self.mode { + CodeSwitchMode::Standard => 0, + CodeSwitchMode::ZeroKnowledge { + message_mask_length, + } => message_mask_length.get(), + } + } + + /// `true` iff the protocol is configured for ZK. + pub const fn is_zk(&self) -> bool { + matches!(&self.mode, CodeSwitchMode::ZeroKnowledge { .. }) + } + /// Length of the covector for this code-switch. pub fn covector_length(&self) -> usize { - self.source.masked_message_length() + self.source.message_length() + self.message_mask_length() } - /// Prove the code-switch + /// Prove the code-switch. + /// + /// # Soundness-critical inputs + /// + /// `folding_randomness` is the **sumcheck folding randomness `γ`** that + /// was sampled from the verifier in the preceding sumcheck protocol + /// (Construction 6.3, p.37-38). It must be the same `γ` the verifier + /// derived from the transcript — it is NOT caller-supplied randomness. + /// + /// Used by the verifier to collapse ι_s parallel codeword columns into a + /// single value of `Fold(f, γ)` via `eq_weights(γ)`. Passing different + /// randomness here breaks IOR completeness; passing locally-sampled + /// randomness breaks Fiat-Shamir soundness in the composed protocol. + /// + /// `message` is `Fold(f, γ)`, the post-sumcheck polynomial of length + /// `source.message_length()`. + /// + /// `mask` is `(r || s)` from the orchestrator's shared mask tree + /// (see Construction 9.7 Step 1, p.55). Length must equal + /// `self.message_mask_length()` — pass an empty slice in Standard mode. #[cfg_attr(feature = "tracing", instrument(skip_all))] pub fn prove( &self, prover_state: &mut ProverState, - message: Vec, - masks: &[M::Source], - witness: &IrsWitness, - covector: &mut [M::Target], + message: ActiveBuffer, + witness: &IrsWitness, + covector: &mut ActiveBuffer, + folding_randomness: &[M::Target], + mask: &ActiveBuffer, ) -> Witness where H: DuplexSpongeInterface, @@ -208,125 +233,177 @@ impl Config { Standard: Distribution, M::Target: Codec<[H::U]>, u8: Decoding<[H::U]>, + [u8; 32]: Decoding<[H::U]>, + U64: Codec<[H::U]>, Hash: ProverMessage<[H::U]>, { assert_eq!(message.len(), self.source.message_length()); assert_eq!(covector.len(), self.covector_length()); + assert_eq!(mask.len(), self.message_mask_length()); assert_eq!( - self.source.num_messages(), - 1, - "code-switch only supports num_messages() == 1 (single polynomial)" - ); - assert_eq!( - masks.len(), - self.source.mask_length * self.source.num_messages() - ); - assert!( - self.mask_commit.is_some() == (self.target.mask_length > 0), - "mask config and target mask_length must agree" + 1 << folding_randomness.len(), + self.source.interleaving_depth(), + "folding_randomness must have length log2(source.interleaving_depth) ({} != log2({}))", + folding_randomness.len(), + self.source.interleaving_depth(), ); - - // Step 1a: g := Enc_{C'}(f, r') — Construction 9.7 Step 1, p.55 + // Step 1: g := Enc_{C'}(f, r') — Construction 9.7 Step 1, p.55 let target_witness = self.target.commit(prover_state, &[&message]); - // Step 1b: s := Enc_{C_zk}((r || padding), r'') — Construction 9.7 Step 1, p.55 - #[allow(clippy::option_if_let_else)] - let mask = if let Some(mask_config) = &self.mask_commit { - let mask_msg_len = mask_config.message_length(); - let r_embedded = lift(self.source.embedding(), masks); - let embedded_randomness_len = r_embedded.len(); - let mut mask_msg = Vec::with_capacity(mask_msg_len); - mask_msg.extend_from_slice(&r_embedded); - let random_padding: Vec = - random_vector(prover_state.rng(), mask_msg_len - embedded_randomness_len); - mask_msg.extend_from_slice(&random_padding); - let witness = mask_config.commit(prover_state, &[&mask_msg]); - Some(MaskOracle { - message: mask_msg, - witness, - }) - } else { - None - }; + // Grind Lemma 9.9 OOD gap before α is sampled. + self.pow.prove(prover_state); // Step 2-3: OOD challenge + answers — Construction 9.7 Steps 2-3, p.55 - // TODO : check the private zero evader for code switch protocol. let ood_points: Vec = prover_state.verifier_message_vec(self.out_domain_samples); - let msg_len = message.len(); - for &point in &ood_points { - let f_eval = univariate_evaluate(&message, point); - if let Some(ref mask_oracle) = mask { - let mask_msg_eval = univariate_evaluate(&mask_oracle.message, point); - let shift = point.pow([msg_len as u64]); - prover_state.prover_message(&(f_eval + shift * mask_msg_eval)); - } else { - prover_state.prover_message(&f_eval); - } - } + self.maybe_send_ood_answers(prover_state, &message, mask, &ood_points); // Step 4: in-domain queries — Construction 9.7 Step 4, p.55 let source_evaluations = self.source.open(prover_state, &[witness]); // Step 4.1: batching — Construction 9.7 Step 4, p.55 let num_ood = self.out_domain_samples; - let num_in_domain = source_evaluations.matrix.len(); + let num_in_domain = source_evaluations.points.len(); let batching_coeffs = geometric_challenge::<_, M::Target>(prover_state, 1 + num_ood + num_in_domain); let (&original_sl_coeff, constraint_rlc_coeffs) = batching_coeffs.split_first().unwrap(); let (ood_rlc_coeffs, in_domain_rlc_coeffs) = constraint_rlc_coeffs.split_at(num_ood); - // Covector update — sl' from Completeness proof (p.55-56) + // Covector update — sl' from Completeness proof (p.55-56). + covector.scalar_mul(original_sl_coeff); let eval_points = lift(self.source.embedding(), &source_evaluations.points); - let all_points: Vec<_> = ood_points.iter().chain(&eval_points).copied().collect(); - let pows: Vec<_> = ood_rlc_coeffs - .iter() - .chain(in_domain_rlc_coeffs) - .copied() - .collect(); - scalar_mul(covector, original_sl_coeff); - geometric_accumulate(covector, pows, &all_points); + self.update_covector( + covector, + ood_rlc_coeffs, + &ood_points, + in_domain_rlc_coeffs, + &eval_points, + ); Witness { message, target_witness, - mask, } } - /// Verify the code-switch + /// Send OOD answers `y_i = f(α_i) [+ α_i^ℓ · (r ‖ s)(α_i)]`. + /// In Standard mode the bracketed term is omitted. + fn maybe_send_ood_answers( + &self, + prover_state: &mut ProverState, + message: &ActiveBuffer, + mask: &ActiveBuffer, + ood_points: &[M::Target], + ) where + H: DuplexSpongeInterface, + R: RngCore + CryptoRng, + M::Target: Codec<[H::U]>, + { + let msg_len = message.len(); + for &point in ood_points { + let f_eval = message.mixed_univariate_evaluate(&Identity::::new(), point); + let answer = match &self.mode { + CodeSwitchMode::Standard => f_eval, + CodeSwitchMode::ZeroKnowledge { .. } => { + let mask_eval = + mask.mixed_univariate_evaluate(&Identity::::new(), point); + let shift = point.pow([msg_len as u64]); + f_eval + shift * mask_eval + } + }; + prover_state.prover_message(&answer); + } + } + + /// Accumulate OOD and in-domain weights into the covector. + /// Standard mode treats all points uniformly; ZK mode applies OOD over + /// the full `[f; r; s]` and in-domain over the `[f; r]` prefix only. + fn update_covector( + &self, + covector: &mut ActiveBuffer, + ood_rlc_coeffs: &[M::Target], + ood_points: &[M::Target], + in_domain_rlc_coeffs: &[M::Target], + in_domain_points: &[M::Target], + ) { + match &self.mode { + CodeSwitchMode::Standard => { + let mut evaluators = univariate_evaluators(ood_points, covector.len()); + evaluators.extend(univariate_evaluators(in_domain_points, covector.len())); + let scalars = ood_rlc_coeffs + .iter() + .chain(in_domain_rlc_coeffs) + .copied() + .collect::>(); + covector.accumulate_univariate_evaluations(&evaluators, &scalars); + } + CodeSwitchMode::ZeroKnowledge { .. } => { + let ood_evaluators = univariate_evaluators(ood_points, covector.len()); + covector.accumulate_univariate_evaluations(&ood_evaluators, ood_rlc_coeffs); + let in_domain_evaluators = + univariate_evaluators(in_domain_points, self.source.masked_message_length()); + covector + .accumulate_univariate_evaluations(&in_domain_evaluators, in_domain_rlc_coeffs); + } + } + } + + /// Verify the code-switch. + /// + /// `folding_randomness` is the **sumcheck folding randomness `γ`** the + /// verifier derived from the transcript during the preceding sumcheck. + /// It must match what the prover received from the same transcript — + /// not caller-supplied randomness. See `prove` doc for details. + /// + /// Returns the target commitment. In ZK mode, the caller **must** + /// additionally run `mask_proximity::verify` on the mask commitment + /// to ensure the mask oracle `(r, s)` is close to a `C_zk` codeword. + /// Without this check, soundness is not guaranteed. + /// + /// # Soundness composition note + /// + /// This verifier checks the OOD/in-domain consistency of the target + /// codeword `g` against transcript-supplied mask values `s(α_i)`. It + /// does **not** check that `s` is close to a `C_zk` codeword — that + /// is the job of mask-proximity (Construction 7.2). Without a + /// downstream mask-proximity invocation against the same `s`, a + /// prover can submit non-codeword mask values that satisfy the OOD + /// equation, breaking the soundness reduction in Theorem 9.10. + /// + /// In the orchestrated WHIR protocol, the orchestrator owns the + /// per-round mask tree containing `s` and is responsible for + /// running `mask_proximity::verify` on that same tree before + /// accepting the round. #[cfg_attr(feature = "tracing", instrument(skip_all))] pub fn verify( &self, verifier_state: &mut VerifierState, sum: &mut M::Target, - commitment: &IrsCommitment, - ) -> VerificationResult> + folding_randomness: &[M::Target], + commitment: &IrsCommitment, + ) -> VerificationResult where H: DuplexSpongeInterface, Standard: Distribution, M::Target: Codec<[H::U]>, u8: Decoding<[H::U]>, + [u8; 32]: Decoding<[H::U]>, + U64: Codec<[H::U]>, Hash: ProverMessage<[H::U]>, { - assert_eq!( - self.source.num_messages(), - 1, - "code-switch only supports num_messages() == 1 (single polynomial)" - ); - assert!( - self.mask_commit.is_some() == (self.target.mask_length > 0), - "mask config and target mask_length must agree" - ); + verify!(1 << folding_randomness.len() == self.source.interleaving_depth()); + + let collapse_weights = eq_weights(folding_randomness); - // Step 1: commitments — Construction 9.7 Step 1, p.55 + // Step 1: target commitment — Construction 9.7 Step 1, p.55 + // Mask oracle is committed in the shared mask tree by the orchestrator. let target_commitment = self.target.receive_commitment(verifier_state)?; - let mask_commitment = self - .mask_commit - .as_ref() - .map(|mask_cfg| mask_cfg.receive_commitment(verifier_state)) - .transpose()?; + + // Grind Lemma 9.9 OOD gap before α is sampled. + self.pow.verify(verifier_state)?; // Step 2-3: OOD — Construction 9.7 Steps 2-3, p.55 + // In ZK mode, ood_answers = f(α) + α^ℓ · (r,s)(α) where (r,s) is + // the mask oracle message committed in the shared tree. let _ood_points: Vec = verifier_state.verifier_message_vec(self.out_domain_samples); let ood_answers: Vec = @@ -334,26 +411,24 @@ impl Config { // Step 4: source opening — Construction 9.7 Step 4, p.55 let source_evaluations = self.source.verify(verifier_state, &[commitment])?; + let collapsed_values: Vec = source_evaluations + .matrix + .chunks_exact(self.source.interleaving_depth()) + .map(|row| mixed_dot(self.source.embedding(), &collapse_weights, row)) + .collect(); // Step 4.1: batching + μ' — Construction 9.7 Decision phase, p.55 let num_ood = self.out_domain_samples; - let num_in_domain = source_evaluations.matrix.len(); + let num_in_domain = source_evaluations.points.len(); let coeffs = geometric_challenge(verifier_state, 1 + num_ood + num_in_domain); - let (&original_sl_coeff, all_rlc_coeffs) = coeffs.split_first().unwrap(); - let (ood_rlc_coeffs, in_domain_rlc_coeffs) = all_rlc_coeffs.split_at(num_ood); + let (&original_sl_coeff, constraint_rlc_coeffs) = coeffs.split_first().unwrap(); + let (ood_rlc_coeffs, in_domain_rlc_coeffs) = constraint_rlc_coeffs.split_at(num_ood); *sum = original_sl_coeff * *sum + dot(ood_rlc_coeffs, &ood_answers) - + mixed_dot( - self.source.embedding(), - in_domain_rlc_coeffs, - &source_evaluations.matrix, - ); + + dot(in_domain_rlc_coeffs, &collapsed_values); - Ok(Commitment { - target: target_commitment, - mask: mask_commitment, - }) + Ok(target_commitment) } } @@ -361,13 +436,12 @@ impl fmt::Display for Config { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!( f, - "Config(source={}, target={}, ood_samples={}", - self.source, self.target, self.out_domain_samples, - )?; - if let Some(mask) = &self.mask_commit { - write!(f, ", mask={mask}")?; - } - write!(f, ")") + "CodeSwitch(source={}, target={}, ood={}, zk={})", + self.source, + self.target, + self.out_domain_samples, + self.is_zk(), + ) } } @@ -376,7 +450,7 @@ mod tests { use ark_std::rand::{ distributions::Standard, prelude::Distribution, rngs::StdRng, Rng, SeedableRng, }; - use proptest::{bool, prelude::Strategy, proptest, sample::select, strategy::Just}; + use proptest::{bool, prelude::Strategy, prop_assume, proptest, sample::select}; use super::*; use crate::{ @@ -387,60 +461,148 @@ mod tests { impl Config { pub fn arbitrary(embedding: M) -> impl Strategy where - M: 'static, + M: Default + 'static, { - let valid_sizes = (1..=256) + // Sizes ≥ 4 to allow ι ∈ {1, 2, 4} with non-trivial message_length. + let valid_sizes = (4..=256) .filter(|&n| ntt::next_order::(n) == Some(n)) + .filter(|&n| n.is_power_of_two()) .collect::>(); - let emb1 = embedding.clone(); - let emb2 = embedding; - - (select(valid_sizes), 0_usize..=3, bool::ANY) - .prop_flat_map(move |(size, mask_length, masked)| { - let source_mask = if masked { mask_length.max(1) } else { 0 }; - let source = IrsConfig::arbitrary(emb1.clone(), 1, size, source_mask, 1); - (source, Just(masked)) - }) - .prop_flat_map(|(source, masked)| { - let msg_len = source.message_length(); - let rnd_len = source.mask_length * source.num_messages(); - let target_mask = usize::from(masked); - - let target = IrsConfig::>::arbitrary( - Identity::new(), - 1, - msg_len, - target_mask, - 1, - ); - - let mask = if masked && rnd_len > 0 { - IrsConfig::>::arbitrary( - Identity::new(), - 1, - rnd_len, - 0, - 1, - ) - .prop_map(Some) - .boxed() + let scalars = ( + select(valid_sizes), + 0_usize..=3, // src_mask_len (source IRS randomness, post-fold) + bool::ANY, // zk + 1_usize..=5, // ood (= code-switch t_ood; ≥ 1 per Construction 9.7) + 0_usize..=5, // fresh_s_len (≥ ood for assumption (c)) + select(vec![1_usize, 2, 4]), // ι_s (source interleaving) + 0_usize..=10, // target.in_domain_samples (t'_in) + ); + + scalars.prop_flat_map( + move |(size, src_mask_len, zk, ood, fresh_s_len, iota_s, t_in)| { + // Bound 3 assumption (c): ℓ_zk - r ≥ t_ood ⇒ fresh_s_len ≥ ood. + // Also enforce `ℓ_zk = r + fresh_s_len > 0` so NonZeroUsize + // construction below is total in ZK mode. + let fresh_s_len = if zk { + let min_fresh = usize::from(src_mask_len == 0); + fresh_s_len.max(ood).max(min_fresh) } else { - Just(None).boxed() + fresh_s_len }; + // Bound 4 assumption (a): target.mask_length ≥ t' = t_in + ood. + let target_mask = if zk { t_in + ood } else { 0 }; + let source_mask = if zk { src_mask_len } else { 0 }; + + IrsConfig::arbitrary(embedding.clone(), 1, size, source_mask, iota_s) + .prop_flat_map(move |source| { + // ι_t must divide msg_len and be a power of 2. + let msg_len = source.message_length(); + let iota_t_choices: Vec = [1, 2, 4] + .into_iter() + .filter(|&i| msg_len.is_multiple_of(i)) + .collect(); + + select(iota_t_choices).prop_flat_map(move |iota_t| { + // target.vector_size = ℓ; C' = D^{ι_t} where D's + // message length = ℓ / ι_t. + let target = IrsConfig::>::arbitrary( + Identity::new(), + 1, + msg_len, + target_mask, + iota_t, + ); + let source = source.clone(); + target.prop_map(move |mut target| { + // IrsConfig::arbitrary samples in_domain_samples + // in [0,10] independently of mask_length; pin it + // to the value target_mask was sized for so + // assumption (a) holds. + if zk { + target.set_in_domain_samples_for_test(t_in); + } + // r = post-fold randomness length (ι_s parallel + // masks fold to a single length-mask_length chunk). + let r = source.mask_length(); + let mode = if zk { + CodeSwitchMode::ZeroKnowledge { + message_mask_length: NonZeroUsize::new(r + fresh_s_len) + .expect("ZK ⇒ r + fresh_s_len > 0"), + } + } else { + CodeSwitchMode::Standard + }; + Self::new( + source.clone(), + target, + ood, + mode, + proof_of_work::Config::none(), + ) + }) + }) + }) + }, + ) + } + } + + /// Fold ι parallel chunks of length `chunk_len` into a single chunk via + /// eq_weights(γ). Layout: values = [chunk_0; chunk_1; ...; chunk_{ι-1}], + /// each of length `chunk_len`. Returns Σ_l eq_weights(γ)[l] · chunk_l. + fn fold_chunks(values: &[F], chunk_len: usize, folding_randomness: &[F]) -> Vec { + let iota = 1 << folding_randomness.len(); + assert_eq!(values.len(), chunk_len * iota); + if iota == 1 { + return values.to_vec(); + } + let weights = eq_weights(folding_randomness); + (0..chunk_len) + .map(|j| { + (0..iota) + .map(|l| weights[l] * values[l * chunk_len + j]) + .sum() + }) + .collect() + } + + /// Sample folding randomness of length log2(source.interleaving_depth). + fn sample_folding_randomness( + config: &Config>, + rng: &mut impl RngCore, + ) -> Vec + where + Standard: Distribution, + { + let log_iota = config.source.interleaving_depth().trailing_zeros() as usize; + random_vector(rng, log_iota) + } - (Just(source), target, mask, 0_usize..=5) - }) - .prop_map( - move |(source, target, mask_commit, out_domain_samples)| Self { - embedding: Typed::new(emb2.clone()), - source, - target, - out_domain_samples, - mask_commit, - }, - ) + /// Simulate what the orchestrator does: build (r || fresh_s) where r is + /// the *folded* source IRS randomness. Returns empty vec in non-ZK mode. + fn build_mask_msg( + config: &Config>, + source_witness: &IrsWitness, + folding_randomness: &[F], + rng: &mut impl RngCore, + ) -> Vec + where + Standard: Distribution, + { + if !config.is_zk() { + return Vec::new(); } + // Lift ι parallel masks (total length source.mask_length × ι) and fold + // chunks of length source.mask_length down to a single chunk. + let raw = lift(config.source.embedding(), source_witness.masks.to_slice()); + let mut mask = fold_chunks(&raw, config.source.mask_length(), folding_randomness); + // Append fresh padding s of length message_mask_length - source.mask_length. + mask.extend(random_vector::( + rng, + config.message_mask_length() - mask.len(), + )); + mask } fn test_config>(seed: u64, config: &Config>) @@ -448,28 +610,115 @@ mod tests { Standard: Distribution, Hash: ProverMessage<[u8]>, { - crate::tests::init(); + let mut rng = StdRng::seed_from_u64(seed); + // Commit the full pre-fold vector of length source.vector_size + // (= ι · message_length), which IRS encodes as ι parallel codewords. + let f_full: Vec = random_vector(&mut rng, config.source.vector_size()); + let initial_sum: F = rng.gen(); + + let mut covector: Vec = random_vector(&mut rng, config.source.message_length()); + covector.resize(config.covector_length(), F::ZERO); + let mut covector = ActiveBuffer::from_vec(covector); let instance = U64(seed); let ds = DomainSeparator::protocol(config) .session(&format!("Test at {}:{}", file!(), line!())) .instance(&instance); + let mut prover_state = ProverState::new_std(&ds); + let f_full_buffer = ActiveBuffer::from_slice(&f_full); + let source_witness = config.source.commit(&mut prover_state, &[&f_full_buffer]); + + // Sample γ for sumcheck folding (length log2(ι)). + let folding_randomness = sample_folding_randomness(config, &mut rng); + // Post-fold message Fold(f_full, γ) of length message_length. + let folded_message = ActiveBuffer::from_vec(fold_chunks( + &f_full, + config.source.message_length(), + &folding_randomness, + )); + let mask_msg = build_mask_msg(config, &source_witness, &folding_randomness, &mut rng); + + let witness = config.prove( + &mut prover_state, + folded_message.clone(), + &source_witness, + &mut covector, + &folding_randomness, + &ActiveBuffer::from_slice(&mask_msg), + ); + let proof = prover_state.proof(); + + let mut verifier_state = VerifierState::new_std(&ds, &proof); + let source_commitment = config + .source + .receive_commitment(&mut verifier_state) + .unwrap(); + let mut verifier_sum = initial_sum; + let _ = config + .verify( + &mut verifier_state, + &mut verifier_sum, + &folding_randomness, + &source_commitment, + ) + .unwrap(); + verifier_state.check_eof().unwrap(); + assert_eq!(witness.message, folded_message); + } + + fn test_ior_identity_config>(seed: u64, config: &Config>) + where + Standard: Distribution, + Hash: ProverMessage<[u8]>, + { let mut rng = StdRng::seed_from_u64(seed); - let message: Vec = random_vector(&mut rng, config.source.message_length()); - let target_mu: F = rng.gen(); + let f_full: Vec = random_vector(&mut rng, config.source.vector_size()); - let msg_len = config.source.message_length(); - let mut covector: Vec = random_vector(&mut rng, msg_len); + let mut covector: Vec = random_vector(&mut rng, config.source.message_length()); covector.resize(config.covector_length(), F::ZERO); + let mut covector = ActiveBuffer::from_vec(covector); + let instance = U64(seed); + let ds = DomainSeparator::protocol(config) + .session(&format!("Test at {}:{}", file!(), line!())) + .instance(&instance); let mut prover_state = ProverState::new_std(&ds); - let source_witness = config.source.commit(&mut prover_state, &[&message]); - let witness = config.prove( + let f_full_buffer = ActiveBuffer::from_slice(&f_full); + let source_witness = config.source.commit(&mut prover_state, &[&f_full_buffer]); + + let folding_randomness = sample_folding_randomness(config, &mut rng); + let folded_message = ActiveBuffer::from_vec(fold_chunks( + &f_full, + config.source.message_length(), + &folding_randomness, + )); + let mask_msg = build_mask_msg(config, &source_witness, &folding_randomness, &mut rng); + + // h is the post-fold polynomial whose inner product with covector + // should equal the verifier sum: + // - non-ZK: h = folded_message (length message_length) + // - ZK: h = [folded_message; mask_msg] (length message_length + l_zk) + let h: ActiveBuffer = if mask_msg.is_empty() { + folded_message.clone() + } else { + ActiveBuffer::from_vec( + folded_message + .to_slice() + .iter() + .chain(mask_msg.iter()) + .copied() + .collect(), + ) + }; + let initial_mu = h.dot(&covector); + + let _witness = config.prove( &mut prover_state, - message.clone(), - &source_witness.masks, + folded_message, &source_witness, &mut covector, + &folding_randomness, + &ActiveBuffer::from_slice(&mask_msg), ); let proof = prover_state.proof(); @@ -478,17 +727,80 @@ mod tests { .source .receive_commitment(&mut verifier_state) .unwrap(); - let mut verifier_sum = target_mu; - let commitments = config - .verify(&mut verifier_state, &mut verifier_sum, &source_commitment) + let mut verifier_sum = initial_mu; + let _ = config + .verify( + &mut verifier_state, + &mut verifier_sum, + &folding_randomness, + &source_commitment, + ) .unwrap(); + verifier_state.check_eof().unwrap(); + + assert_eq!(h.dot(&covector), verifier_sum); + } + + fn test_tampered_ood_config>(seed: u64, config: &Config>) + where + Standard: Distribution, + Hash: ProverMessage<[u8]>, + { + let instance = U64(seed); + let ds = DomainSeparator::protocol(config) + .session(&format!("Test at {}:{}", file!(), line!())) + .instance(&instance); + let mut rng = StdRng::seed_from_u64(seed); + let f_full: Vec = random_vector(&mut rng, config.source.vector_size()); - // Transcript fully consumed — prover and verifier are in sync + let mut covector: Vec = random_vector(&mut rng, config.source.message_length()); + covector.resize(config.covector_length(), F::ZERO); + let mut covector = ActiveBuffer::from_vec(covector); + + // Commit honest f_full, fold to get the honest post-fold message. + let mut prover_state = ProverState::new_std(&ds); + let f_full_buffer = ActiveBuffer::from_slice(&f_full); + let source_witness = config.source.commit(&mut prover_state, &[&f_full_buffer]); + let folding_randomness = sample_folding_randomness(config, &mut rng); + let folded_message = + fold_chunks(&f_full, config.source.message_length(), &folding_randomness); + + // For non-ZK and source.mask_length == 0, h = folded_message and identity holds. + let folded_message_buffer = ActiveBuffer::from_slice(&folded_message); + let initial_mu = folded_message_buffer.dot(&covector); + + // Tamper the post-fold message before proving. + let mut tampered = folded_message; + tampered[0] += F::ONE; + let tampered = ActiveBuffer::from_vec(tampered); + let _witness = config.prove( + &mut prover_state, + tampered, + &source_witness, + &mut covector, + &folding_randomness, + &ActiveBuffer::from_vec(Vec::new()), + ); + let proof = prover_state.proof(); + + let mut verifier_state = VerifierState::new_std(&ds, &proof); + let source_commitment = config + .source + .receive_commitment(&mut verifier_state) + .unwrap(); + let mut verifier_sum = initial_mu; + let _ = config + .verify( + &mut verifier_state, + &mut verifier_sum, + &folding_randomness, + &source_commitment, + ) + .unwrap(); verifier_state.check_eof().unwrap(); - // Mask commitment present if ZK mode - assert_eq!(commitments.mask.is_some(), config.mask_commit.is_some()); - assert_eq!(witness.message, message); - assert_eq!(witness.mask.is_some(), config.mask_commit.is_some()); + + // Sum diverges — downstream sumcheck would reject + assert_ne!(folded_message_buffer.dot(&covector), verifier_sum); } fn test + 'static>() @@ -496,6 +808,7 @@ mod tests { Standard: Distribution, Hash: ProverMessage<[u8]>, { + crate::tests::init(); let configs = Config::arbitrary(Identity::::new()); proptest!(|(seed: u64, config in configs)| { test_config(seed, &config); @@ -536,4 +849,26 @@ mod tests { fn test_field256() { test::(); } + + #[test] + fn test_ior_identity() { + crate::tests::init(); + let configs = Config::arbitrary(Identity::::new()); + proptest!(|(seed: u64, config in configs)| { + prop_assume!(config.source.in_domain_samples() > 0); + test_ior_identity_config(seed, &config); + }); + } + + #[test] + fn test_tampered_ood() { + crate::tests::init(); + let configs = Config::arbitrary(Identity::::new()) + .prop_filter("non-ZK", |config| { + !config.is_zk() && config.source.mask_length() == 0 + }); + proptest!(|(seed: u64, config in configs)| { + test_tampered_ood_config(seed, &config); + }); + } } diff --git a/src/protocols/irs_commit.rs b/src/protocols/irs_commit.rs index 04c12717..c33ca6c4 100644 --- a/src/protocols/irs_commit.rs +++ b/src/protocols/irs_commit.rs @@ -7,106 +7,108 @@ //! using an NTT friendly Reed-Solomon code to produce a `num_vectors * interleaving_depth` //! by `codeword_size` matrix. This matrix is committed using the [`matrix_commit`] protocol. //! -//! After committing the encoded matrix, the protocol generates a random Reed-Solomon code of -//! length `out_domain_samples` over an extension field `G` of `F` and encodes the original -//! matrix using this code to produce a `num_vectors` by `out_domain_samples` matrix over `G`. -//! Together, these two encoded matrices form a commitment to the original matrix. -//! //! On opening the commitment, the protocol randomly selects `in_domain_samples` rows and opens -//! it using the [`matrix_commit`] protocol. Sampling is done with replacement, so may produce -//! fewer than `in_domain_samples` distinct rows. This produces `in_domain_samples` evaluation -//! points in `F` and `in_domain_samples` by `num_vectors * interleaving_depth`. +//! them using the [`matrix_commit`] protocol. Sampling is done with replacement, so may produce +//! fewer than `in_domain_samples` distinct rows. //! -use std::{ - f64::{self, consts::LOG2_10}, - fmt, - ops::Neg, -}; +use std::{f64, fmt, num::NonZeroUsize}; use ark_ff::{AdditiveGroup, Field}; use ark_std::rand::{distributions::Standard, prelude::Distribution, CryptoRng, RngCore}; -use ordered_float::OrderedFloat; use serde::{Deserialize, Serialize}; +use thiserror::Error; #[cfg(feature = "tracing")] use tracing::instrument; use crate::{ algebra::{ dot, embedding::Embedding, fields::FieldWithSize, lift, linear_form::UnivariateEvaluation, - mixed_univariate_evaluate, ntt, random_vector, + ntt, }, + buffer::{ActiveBuffer, Buffer, BufferOps}, engines::EngineId, hash::Hash, - protocols::{challenge_indices::challenge_indices, matrix_commit}, + protocols::{ + challenge_indices::challenge_indices, + matrix_commit, + params::{bounds::ood_per_sample_log2, regime::DecodingRegimeParams, spec::DecodingRegime}, + }, transcript::{ Codec, Decoding, DuplexSpongeInterface, ProverMessage, ProverState, VerificationResult, VerifierMessage, VerifierState, }, type_info::Typed, - utils::{chunks_exact_or_empty, zip_strict}, - verify, + utils::zip_strict, }; +#[derive(Clone, PartialEq, Eq, Debug, Hash, Serialize, Deserialize)] +pub enum IrsMode { + Standard, + ZeroKnowledge { mask_length: NonZeroUsize }, +} + +impl IrsMode { + /// Per-polynomial IRS randomness length. Returns 0 in Standard mode. + pub const fn mask_length(&self) -> usize { + match self { + Self::Standard => 0, + Self::ZeroKnowledge { mask_length } => mask_length.get(), + } + } +} + /// Commit to vectors over an fft-friendly field F +#[must_use] #[derive(Clone, PartialEq, Eq, Debug, Hash, Serialize, Deserialize)] #[serde(bound = "")] pub struct Config { /// Embedding into a (larger) field used for weights and drawing challenges. - pub embedding: Typed, + embedding: Typed, /// The number of vectors to commit to in one operation. - pub num_vectors: usize, + num_vectors: usize, /// The number of coefficients in each vector. - pub vector_size: usize, - - /// The number of masking values to add per codeword. - pub mask_length: usize, + vector_size: usize, /// The number of Reed-Solomon evaluation points. - pub codeword_length: usize, + codeword_length: usize, /// The number of independent codewords that are interleaved together. - pub interleaving_depth: usize, + interleaving_depth: usize, /// The matrix commitment configuration. - pub matrix_commit: matrix_commit::Config, + matrix_commit: matrix_commit::Config, - /// Slack to the Jonhnson bound in list decoding. - /// Zero indicates unique decoding. - pub johnson_slack: OrderedFloat, + /// Materialized Reed–Solomon decoding regime (Unique / Johnson w/ slack). + regime: DecodingRegimeParams, /// The number of in-domain samples. - pub in_domain_samples: usize, - - /// The number of out-of-domain samples. - pub out_domain_samples: usize, + in_domain_samples: usize, /// Whether to sort and deduplicate the in-domain samples. /// /// Deduplication can slightly reduce proof size and prover/verifier /// complexity, but it makes transcript pattern and control flow /// non-deterministic. - pub deduplicate_in_domain: bool, + deduplicate_in_domain: bool, + + /// Standard / ZeroKnowledge. + mode: IrsMode, } #[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Debug, Hash, Default, Serialize, Deserialize)] #[must_use] -pub struct Witness -where - G: Field, -{ - pub masks: Vec, - pub matrix: Vec, +pub struct Witness { + pub masks: ActiveBuffer, + pub matrix: ActiveBuffer, pub matrix_witness: matrix_commit::Witness, - pub out_of_domain: Evaluations, } #[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Debug, Hash, Default, Serialize, Deserialize)] #[must_use] -pub struct Commitment { - matrix_commitment: matrix_commit::Commitment, - out_of_domain: Evaluations, +pub struct Commitment { + pub matrix_commitment: matrix_commit::Commitment, } /// Interleaved Reed-Solomon code. @@ -121,66 +123,82 @@ pub struct Evaluations { pub matrix: Vec, } +/// Named-field inputs to [`Config::new`] / [`Config::try_new`]. +/// +/// `num_vectors`, `vector_size`, and `interleaving_depth` share a primitive +/// type; named construction keeps call sites swap-proof. +#[derive(Debug, Clone)] +pub struct IrsParams { + pub security_target: f64, + pub decoding_regime: DecodingRegime, + pub hash_id: EngineId, + pub num_vectors: usize, + pub vector_size: usize, + pub interleaving_depth: usize, + pub rate: f64, + pub mode: IrsMode, +} + +/// The computed codeword length exceeds the NTT engine's supported order. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] +#[error("codeword length {length} exceeds the NTT engine's supported order")] +pub struct CodewordLengthError { + pub length: usize, +} + impl Config { - pub fn new( - security_target: f64, - unique_decoding: bool, - hash_id: EngineId, - num_vectors: usize, - vector_size: usize, - interleaving_depth: usize, - rate: f64, - ) -> Self + /// Panicking version of [`Config::try_new`] for call sites that construct + /// from already-vetted parameters. + /// + /// # Panics + /// + /// If the codeword length exceeds the NTT engine's supported order. + pub fn new(params: IrsParams) -> Self where M: Default, { + Self::try_new(params).expect("IRS config construction failed") + } + + /// # Errors + /// + /// [`CodewordLengthError`] when `masked_message_length / rate` exceeds the + /// NTT engine's supported order. + pub fn try_new(params: IrsParams) -> Result + where + M: Default, + { + let IrsParams { + security_target, + decoding_regime, + hash_id, + num_vectors, + vector_size, + interleaving_depth, + rate, + mode, + } = params; assert!(vector_size.is_multiple_of(interleaving_depth)); assert!(rate > 0. && rate <= 1.); - let message_length = vector_size / interleaving_depth; + let masked_message_length = vector_size / interleaving_depth + mode.mask_length(); + // `interleaved_encode` requires `codeword_length` to divide the NTT root + // order. `masked_message_length` is allowed to be arbitrary (the coset + // NTT zero-extends internally), so we only round the codeword side here. #[allow(clippy::cast_sign_loss)] - let codeword_length = (message_length as f64 / rate).ceil() as usize; - let rate = message_length as f64 / codeword_length as f64; - - // Pick in- and out-of-domain samples. - // η = slack to Johnson bound. We pick η = √ρ / 20. - // TODO: Optimize picking η. - let johnson_slack = if unique_decoding { - 0.0 - } else { - rate.sqrt() / 20. - }; - let out_domain_samples = { - let list_size = 1. / (2. * johnson_slack * rate.sqrt()); - num_ood_samples( - unique_decoding, - security_target, - M::Target::field_size_bits(), - list_size, - vector_size, - ) - }; - #[allow(clippy::cast_sign_loss)] - let in_domain_samples = { - // Query error is (1 - δ)^q, so we compute 1 - δ - let per_sample = if unique_decoding { - // Unique decoding bound: δ = (1 - ρ) / 2 - f64::midpoint(1., rate) - } else { - // Johnson bound: δ = 1 - √ρ - η - rate.sqrt() + johnson_slack - }; - (security_target / (-per_sample.log2())).ceil() as usize - }; - debug_assert_eq!( - in_domain_samples, - num_in_domain_queries(unique_decoding, security_target, rate) - ); + let raw_codeword_length = (masked_message_length as f64 / rate).ceil() as usize; + let codeword_length = + ntt::next_order::(raw_codeword_length).ok_or(CodewordLengthError { + length: raw_codeword_length, + })?; + let rate = masked_message_length as f64 / codeword_length as f64; + + let regime = DecodingRegimeParams::from_policy(decoding_regime, rate); + let in_domain_samples = num_in_domain_queries(decoding_regime, security_target, rate).get(); - Self { + Ok(Self { embedding: Typed::::default(), num_vectors, vector_size, - mask_length: 0, codeword_length, interleaving_depth, matrix_commit: matrix_commit::Config::with_hash( @@ -188,11 +206,57 @@ impl Config { codeword_length, interleaving_depth * num_vectors, ), - johnson_slack: OrderedFloat(johnson_slack), + regime, in_domain_samples, - out_domain_samples, deduplicate_in_domain: false, - } + mode, + }) + } + + pub const fn num_vectors(&self) -> usize { + self.num_vectors + } + + pub const fn vector_size(&self) -> usize { + self.vector_size + } + + pub const fn codeword_length(&self) -> usize { + self.codeword_length + } + + pub const fn interleaving_depth(&self) -> usize { + self.interleaving_depth + } + + pub const fn matrix_commit(&self) -> &matrix_commit::Config { + &self.matrix_commit + } + + pub const fn regime(&self) -> DecodingRegimeParams { + self.regime + } + + pub const fn in_domain_samples(&self) -> usize { + self.in_domain_samples + } + + pub const fn deduplicate_in_domain(&self) -> bool { + self.deduplicate_in_domain + } + + pub const fn mode(&self) -> &IrsMode { + &self.mode + } + + #[cfg(test)] + pub(crate) const fn set_vector_size_for_test(&mut self, vector_size: usize) { + self.vector_size = vector_size; + } + + #[cfg(test)] + pub(crate) const fn set_in_domain_samples_for_test(&mut self, in_domain_samples: usize) { + self.in_domain_samples = in_domain_samples; } pub const fn num_cols(&self) -> usize { @@ -216,9 +280,14 @@ impl Config { self.vector_size / self.interleaving_depth } + /// Per-polynomial IRS randomness length. Returns 0 in Standard mode. + pub const fn mask_length(&self) -> usize { + self.mode.mask_length() + } + /// Message length including mask coefficients. pub fn masked_message_length(&self) -> usize { - self.message_length() + self.mask_length + self.message_length() + self.mask_length() } pub fn evaluation_points(&self, indices: &[usize]) -> Vec { @@ -233,79 +302,34 @@ impl Config { self.masked_message_length() as f64 / self.codeword_length as f64 } - pub fn unique_decoding(&self) -> bool { - self.out_domain_samples == 0 && self.johnson_slack == 0.0 + pub const fn unique_decoding(&self) -> bool { + self.regime.is_unique() } - /// Recompute `johnson_slack`, `in_domain_samples`, and `out_domain_samples` - /// for the current rate (which accounts for `mask_length`). - pub fn recompute_security_parameters(&mut self, security_target: f64, unique_decoding: bool) { - let rate = self.rate(); - let johnson_slack = if unique_decoding { - 0.0 - } else { - rate.sqrt() / 20. - }; - self.johnson_slack = OrderedFloat(johnson_slack); - self.in_domain_samples = num_in_domain_queries(unique_decoding, security_target, rate); - let list_size = self.list_size(); - self.out_domain_samples = num_ood_samples( - unique_decoding, - security_target, - M::Target::field_size_bits(), - list_size, - self.vector_size, - ); + fn log_inv_rate(&self) -> f64 { + -self.rate().log2() } /// Compute a list size bound. pub fn list_size(&self) -> f64 { - if self.unique_decoding() { - 1. - } else { - // This is the Johnson bound $1 / (2 η √ρ)$. - 1. / (2. * self.johnson_slack.into_inner() * self.rate().sqrt()) - } - } - - /// Round-by-round soundness of the out-of-domain samples in bits. - pub fn rbr_ood_sample(&self) -> f64 { - let list_size = self.list_size(); - let log_field_size = M::Target::field_size_bits(); - // See [STIR] lemma 4.5. - let l_choose_2 = list_size * (list_size - 1.) / 2.; - let log_per_sample = ((self.vector_size - 1) as f64).log2() - log_field_size; - -l_choose_2.log2() - self.out_domain_samples as f64 * log_per_sample + let log_degree = (self.masked_message_length() as f64).log2(); + self.regime.list_size(log_degree, self.log_inv_rate()) } /// Round-by-round soundness of the in-domain queries in bits. pub fn rbr_queries(&self) -> f64 { - let per_sample = if self.unique_decoding() { - // 1 - δ = 1 - (1 + ρ) / 2 - f64::midpoint(1., self.rate()) - } else { - // 1 - δ = sqrt(ρ) + η - self.rate().sqrt() + self.johnson_slack.into_inner() - }; - self.in_domain_samples as f64 * per_sample.log2().neg() + // Query error is (1 - δ)^q in bits = -q · log2(1 - δ). + -(self.in_domain_samples as f64) * self.regime.one_minus_distance_log2(self.log_inv_rate()) } - // Compute the proximity gaps term of the fold + /// Round-by-round soundness of the proximity-gaps fold in bits. + /// See WHIR Theorem 4.8. pub fn rbr_soundness_fold_prox_gaps(&self) -> f64 { - let log_field_size = M::Target::field_size_bits(); - let log_inv_rate = self.rate().log2().neg(); - let log_k = (self.masked_message_length() as f64).log2(); - // See WHIR Theorem 4.8 - // Recall, at each round we are only folding by two at a time - let error = if self.unique_decoding() { - log_k + log_inv_rate - } else { - let log_eta = self.johnson_slack.into_inner().log2(); - // Make sure η hits the min bound. - assert!(log_eta >= -(0.5 * log_inv_rate + LOG2_10 + 1.0) - 1e-6); - 7. * LOG2_10 + 3.5 * log_inv_rate + 2. * log_k - }; - log_field_size - error + -self.regime.eps_mca_log2( + self.log_inv_rate(), + self.masked_message_length(), + M::Target::field_size_bits(), + ) } /// Commit to one or more vectors. @@ -313,8 +337,8 @@ impl Config { pub fn commit( &self, prover_state: &mut ProverState, - vectors: &[&[M::Source]], - ) -> Witness + vectors: &[&ActiveBuffer], + ) -> Witness where Standard: Distribution, H: DuplexSpongeInterface, @@ -331,41 +355,20 @@ impl Config { assert_eq!(vectors.len(), self.num_vectors); assert!(vectors.iter().all(|p| p.len() == self.vector_size)); - // Generate random mask - let masks = random_vector(prover_state.rng(), self.mask_length * self.num_messages()); - - // Interleaved RS Encode the vectors - let messages = vectors - .iter() - .flat_map(|v| chunks_exact_or_empty(v, self.message_length(), self.interleaving_depth)) - .collect::>(); - let matrix = ntt::interleaved_rs_encode(&messages, &masks, self.codeword_length); + let masks = ActiveBuffer::::random( + prover_state.rng(), + self.mask_length() * self.num_messages(), + ); + let messages = ntt::Messages::new(vectors, self.message_length(), self.interleaving_depth); + let matrix = ntt::interleaved_rs_encode(messages, &masks, self.codeword_length); // Commit to the matrix let matrix_witness = self.matrix_commit.commit(prover_state, &matrix); - // Handle out-of-domain points and values - // TODO : Remove this logic after main whir protocol is updated - // as this is not required in the new construction. - let oods_points: Vec = - prover_state.verifier_message_vec(self.out_domain_samples); - let mut oods_matrix = Vec::with_capacity(self.out_domain_samples * self.num_vectors); - for &point in &oods_points { - for &vector in vectors { - let value = mixed_univariate_evaluate(&*self.embedding, vector, point); - prover_state.prover_message(&value); - oods_matrix.push(value); - } - } - Witness { masks, matrix, matrix_witness, - out_of_domain: Evaluations { - points: oods_points, - matrix: oods_matrix, - }, } } @@ -374,24 +377,67 @@ impl Config { pub fn receive_commitment( &self, verifier_state: &mut VerifierState, - ) -> VerificationResult> + ) -> VerificationResult where H: DuplexSpongeInterface, Hash: ProverMessage<[H::U]>, M::Target: Codec<[H::U]>, { let matrix_commitment = self.matrix_commit.receive_commitment(verifier_state)?; - let oods_points: Vec = - verifier_state.verifier_message_vec(self.out_domain_samples); - let oods_matrix = - verifier_state.prover_messages_vec(self.out_domain_samples * self.num_vectors)?; - Ok(Commitment { - matrix_commitment, - out_of_domain: Evaluations { - points: oods_points, - matrix: oods_matrix, - }, - }) + Ok(Commitment { matrix_commitment }) + } + + /// Commit to vectors and run the legacy WHIR OOD step in one call. + /// + /// Layered helper bundling `commit` + the OOD message exchange (sample + /// `out_domain_samples` random points, send each vector's evaluation at + /// each point). Used by the legacy WHIR protocol while the OOD step + /// is still part of the per-commit protocol shape; the new construction + /// (Construction 9.7) handles OOD at the code-switch level instead. + #[cfg_attr(feature = "tracing", instrument(skip_all, fields(self = %self)))] + pub fn commit_with_ood( + &self, + prover_state: &mut ProverState, + vectors: &[&ActiveBuffer], + out_domain_samples: usize, + ) -> (Witness, Evaluations) + where + Standard: Distribution, + H: DuplexSpongeInterface, + R: RngCore + CryptoRng, + M::Target: Codec<[H::U]>, + Hash: ProverMessage<[H::U]>, + { + let witness = self.commit(prover_state, vectors); + let points: Vec = prover_state.verifier_message_vec(out_domain_samples); + let mut matrix = Vec::with_capacity(out_domain_samples * vectors.len()); + for &point in &points { + for &vector in vectors { + let value = vector.mixed_univariate_evaluate(&*self.embedding, point); + prover_state.prover_message(&value); + matrix.push(value); + } + } + (witness, Evaluations { points, matrix }) + } + + /// Receive a commitment and the legacy WHIR OOD evaluations in one call. + /// Verifier mirror of `commit_with_ood`. + #[cfg_attr(feature = "tracing", instrument(skip_all, fields(self = %self)))] + pub fn receive_commitment_with_ood( + &self, + verifier_state: &mut VerifierState, + out_domain_samples: usize, + ) -> VerificationResult<(Commitment, Evaluations)> + where + H: DuplexSpongeInterface, + Hash: ProverMessage<[H::U]>, + M::Target: Codec<[H::U]>, + { + let commitment = self.receive_commitment(verifier_state)?; + let points: Vec = verifier_state.verifier_message_vec(out_domain_samples); + let matrix = verifier_state.prover_messages_vec(out_domain_samples * self.num_vectors)?; + Ok((commitment, Evaluations { points, matrix })) } /// Opens the commitment and returns the evaluations of the vectors. @@ -405,7 +451,7 @@ impl Config { pub fn open( &self, prover_state: &mut ProverState, - witnesses: &[&Witness], + witnesses: &[&Witness], ) -> Evaluations where H: DuplexSpongeInterface, @@ -415,11 +461,6 @@ impl Config { { for witness in witnesses { assert_eq!(witness.matrix.len(), self.size()); - assert_eq!(witness.out_of_domain.points.len(), self.out_domain_samples); - assert_eq!( - witness.out_of_domain.matrix.len(), - self.out_domain_samples * self.num_vectors - ); } // Get in-domain openings @@ -429,18 +470,15 @@ impl Config { // and collect them in the evaluation matrix. let stride = witnesses.len() * self.num_cols(); let mut matrix = vec![M::Source::ZERO; indices.len() * stride]; - let mut submatrix = Vec::with_capacity(indices.len() * self.num_cols()); let mut matrix_col_offset = 0; for witness in witnesses { - submatrix.clear(); - for (point_index, &code_index) in indices.iter().enumerate() { - let row = &witness.matrix - [code_index * self.num_cols()..(code_index + 1) * self.num_cols()]; - submatrix.extend_from_slice(row); - - let matrix_row = &mut matrix[point_index * stride..(point_index + 1) * stride]; - matrix_row[matrix_col_offset..matrix_col_offset + self.num_cols()] - .copy_from_slice(row); + let submatrix = witness.matrix.read_rows(self.num_cols(), &indices); + if self.num_cols() != 0 { + for (point_index, row) in submatrix.chunks_exact(self.num_cols()).enumerate() { + let matrix_row = &mut matrix[point_index * stride..(point_index + 1) * stride]; + matrix_row[matrix_col_offset..matrix_col_offset + self.num_cols()] + .copy_from_slice(row); + } } prover_state.prover_hint_ark(&submatrix); self.matrix_commit @@ -458,20 +496,13 @@ impl Config { pub fn verify( &self, verifier_state: &mut VerifierState, - commitments: &[&Commitment], + commitments: &[&Commitment], ) -> VerificationResult> where H: DuplexSpongeInterface, u8: Decoding<[H::U]>, Hash: ProverMessage<[H::U]>, { - for commitment in commitments { - verify!(commitment.out_of_domain.points.len() == self.out_domain_samples); - verify!( - commitment.out_of_domain.matrix.len() == self.num_vectors * self.out_domain_samples - ); - } - // Get in-domain openings let (indices, points) = self.in_domain_challenges(verifier_state); @@ -520,28 +551,6 @@ impl Config { } } -impl Commitment { - /// Returns the out-of-domain evaluations. - pub const fn out_of_domain(&self) -> &Evaluations { - &self.out_of_domain - } - - pub fn num_vectors(&self) -> usize { - self.out_of_domain().num_columns() - } -} - -impl Witness { - /// Returns the out-of-domain evaluations. - pub const fn out_of_domain(&self) -> &Evaluations { - &self.out_of_domain - } - - pub fn num_vectors(&self) -> usize { - self.out_of_domain().num_columns() - } -} - impl Evaluations { pub const fn num_points(&self) -> usize { self.points.len() @@ -588,11 +597,7 @@ impl fmt::Display for Config { self.num_vectors, self.vector_size, self.interleaving_depth, )?; write!(f, " rate 2⁻{:.2}", -self.rate().log2())?; - write!( - f, - " samples {} in- {} out-domain", - self.in_domain_samples, self.out_domain_samples - ) + write!(f, " samples {} in-domain", self.in_domain_samples) } } @@ -602,19 +607,19 @@ impl fmt::Display for Config { /// where `L` is the list size and `degree` is the polynomial degree bound. /// See [STIR] Lemma 4.5. #[allow(clippy::cast_sign_loss)] -pub(crate) fn num_ood_samples( - unique_decoding: bool, +pub fn num_ood_samples( + decoding_regime: DecodingRegime, security_target: f64, field_size_bits: f64, list_size: f64, degree: usize, ) -> usize { - if unique_decoding { + if matches!(decoding_regime, DecodingRegime::Unique) { return 0; } - let l_choose_2 = list_size * (list_size - 1.) / 2.; - let log_per_sample = field_size_bits - ((degree - 1) as f64).log2(); + let log_per_sample = -ood_per_sample_log2(degree, field_size_bits); assert!(log_per_sample > 0.); + let l_choose_2 = list_size * (list_size - 1.) / 2.; ((security_target + l_choose_2.log2()) / log_per_sample) .ceil() .max(1.) as usize @@ -622,31 +627,22 @@ pub(crate) fn num_ood_samples( /// Return the number of in-domain queries. /// +/// Always ≥ 1 — the type carries that invariant so callers don't need to +/// re-prove it locally. +/// /// This is used by [`whir_zk`]. // TODO: A method with cleaner abstraction. #[allow(clippy::cast_sign_loss)] pub(crate) fn num_in_domain_queries( - unique_decoding: bool, + decoding_regime: DecodingRegime, security_target: f64, rate: f64, -) -> usize { - // Pick in- and out-of-domain samples. - // η = slack to Johnson bound. We pick η = √ρ / 20. - // TODO: Optimize picking η. - let johnson_slack = if unique_decoding { - 0.0 - } else { - rate.sqrt() / 20. - }; - // Query error is (1 - δ)^q, so we compute 1 - δ - let per_sample = if unique_decoding { - // Unique decoding bound: δ = (1 - ρ) / 2 - f64::midpoint(1., rate) - } else { - // Johnson bound: δ = 1 - √ρ - η - rate.sqrt() + johnson_slack - }; - (security_target / (-per_sample.log2())).ceil() as usize +) -> NonZeroUsize { + let regime = DecodingRegimeParams::from_policy(decoding_regime, rate); + // Query error is (1 - δ)^q in bits = -q · log2(1 - δ). + let log_one_minus_delta = regime.one_minus_distance_log2(-rate.log2()); + let q = (security_target / -log_one_minus_delta).ceil() as usize; + NonZeroUsize::new(q).unwrap_or(NonZeroUsize::MIN) } #[cfg(test)] @@ -703,24 +699,27 @@ pub(crate) mod tests { ) }); - (codeword_matrix, 0_usize..=10, 0_usize..=10, bool::ANY).prop_map( + (codeword_matrix, 0_usize..=10, bool::ANY).prop_map( move |( (codeword_length, matrix_commit), in_domain_samples, - out_domain_samples, - deduplicate_in_domain, - )| Self { - embedding: Typed::new(embedding.clone()), - num_vectors, - vector_size, - mask_length, - codeword_length, - interleaving_depth, - matrix_commit, - johnson_slack: OrderedFloat::default(), - in_domain_samples, - out_domain_samples, deduplicate_in_domain, + )| { + let mode = NonZeroUsize::new(mask_length).map_or(IrsMode::Standard, |n| { + IrsMode::ZeroKnowledge { mask_length: n } + }); + Self { + embedding: Typed::new(embedding.clone()), + num_vectors, + vector_size, + codeword_length, + interleaving_depth, + matrix_commit, + regime: DecodingRegimeParams::Unique, + in_domain_samples, + deduplicate_in_domain, + mode, + } }, ) } @@ -748,34 +747,12 @@ pub(crate) mod tests { // Prover let mut prover_state = ProverState::new_std(&ds); - let witness = config.commit( - &mut prover_state, - &vectors.iter().map(|p| p.as_slice()).collect::>(), - ); - assert_eq!( - witness.out_of_domain().points.len(), - config.out_domain_samples - ); - assert_eq!( - witness.out_of_domain().matrix.len(), - config.out_domain_samples * config.num_vectors - ); - if config.num_vectors > 0 { - for (point, evals) in zip_strict( - witness.out_of_domain().points.iter(), - witness - .out_of_domain() - .matrix - .chunks_exact(config.num_vectors), - ) { - for (vector, expected) in zip_strict(vectors.iter(), evals.iter()) { - assert_eq!( - mixed_univariate_evaluate(config.embedding(), vector, *point), - *expected - ); - } - } - } + let vector_buffers = vectors + .iter() + .map(|v| ActiveBuffer::from_slice(v)) + .collect::>(); + let vector_refs = vector_buffers.iter().collect::>(); + let witness = config.commit(&mut prover_state, &vector_refs); let in_domain_evals = config.open(&mut prover_state, &[&witness]); if config.deduplicate_in_domain { // Sorting is over index order, not points @@ -793,7 +770,11 @@ pub(crate) mod tests { in_domain_evals.matrix.len(), in_domain_evals.points.len() * config.num_vectors * config.interleaving_depth ); - if config.num_vectors > 0 { + // Value-correctness assertion only valid in non-ZK mode: in ZK the + // encoding is `Enc(f, r) = f(x) + x^ℓ · r(x)`, so opened values + // include the mask term. The lifecycle round-trip (open/verify + // agreement below) covers both modes. + if config.num_vectors > 0 && config.mask_length() == 0 { let base = config.vector_size / config.interleaving_depth; for (point, evals) in zip_strict( &in_domain_evals.points, @@ -819,7 +800,6 @@ pub(crate) mod tests { // Verifier let mut verifier_state = VerifierState::new_std(&ds, &proof); let commitment = config.receive_commitment(&mut verifier_state).unwrap(); - assert_eq!(commitment.out_of_domain(), witness.out_of_domain()); let verifier_in_domain_evals = config.verify(&mut verifier_state, &[&commitment]).unwrap(); assert_eq!(&verifier_in_domain_evals, &in_domain_evals); verifier_state.check_eof().unwrap(); @@ -836,13 +816,13 @@ pub(crate) mod tests { .collect::>(); let size = select(valid_sizes); - let config = (0_usize..=3, size, 1_usize..=10).prop_flat_map( - |(num_vectors, size, interleaving_depth)| { + let config = (0_usize..=3, size, 1_usize..=10, 0_usize..=8).prop_flat_map( + |(num_vectors, size, interleaving_depth, mask_length)| { Config::arbitrary( embedding.clone(), num_vectors, size * interleaving_depth, - 0, + mask_length, interleaving_depth, ) }, diff --git a/src/protocols/mask_proximity.rs b/src/protocols/mask_proximity.rs new file mode 100644 index 00000000..3ed33a33 --- /dev/null +++ b/src/protocols/mask_proximity.rs @@ -0,0 +1,586 @@ +//! Mask proximity verification via γ-combination. +//! +//! Implements Construction 7.2 (p.43-44) specialized for zero-constraint mask +//! oracles. Given a shared Merkle tree containing 2n vectors — n original masks +//! and n fresh mask-of-masks — this protocol proves that each original mask is +//! close to a C_zk codeword without revealing the mask polynomials. +//! +//! The tree layout is: +//! columns 0..n-1: original masks ξ_1, ..., ξ_n +//! columns n..2n-1: mask-of-masks s_1, ..., s_n +//! +//! Protocol: +//! 1. Verifier sends γ (combination randomness) +//! 2. Prover sends combined polynomials ξ*_i = s_i + γ·ξ_i and +//! combined IRS randomness r*_i = r'_i + γ·r_i for each mask pair +//! 3. Shared tree is opened at random positions +//! 4. Verifier checks: Enc(ξ*_i, r*_i)(y_j) = s_i(y_j) + γ·ξ_i(y_j) +//! at each opened position, using linearity of the RS encoding +//! +//! ZK safety (follows the pattern of Construction 7.2, §7.1): +//! - Only the combined ξ*_i = s_i + γ·ξ_i is revealed in full. Since s_i +//! is uniformly random, ξ*_i is uniform regardless of ξ_i. +//! - The tree is opened at ≤ t_zk positions, revealing ξ_i(y_j) and s_i(y_j) +//! at each opened position. This is within the ZK query budget per mask. +//! +//! Note: Lemma 7.3 (p.44) derives the ZK bound ζ_C + n·ζ_{C_zk} assuming +//! each mask is a separate oracle. This implementation uses a shared tree +//! for all 2n vectors, so a single opening reveals 2n values. The per-mask +//! query count is unchanged (each ξ_i is opened at t_zk positions), but the +//! shared tree means all masks are opened at the SAME positions. +//! +//! Assumed: the shared-tree variant preserves the ZK bound. The argument +//! is that the simulator can simulate all 2n values at each position +//! independently (each pair (ξ_i, s_i) is simulatable from C_zk's ZK +//! property, and the pairs are independent across i). Formal derivation +//! for the shared-tree case is pending. +//! +//! Soundness: if ξ_i is far from C_zk, the spot-check fails with high +//! probability over γ (Lemma 7.4, p.45). + +use std::fmt; + +use ark_ff::Field; +use ark_std::rand::{distributions::Standard, prelude::Distribution, CryptoRng, RngCore}; +use serde::{Deserialize, Serialize}; + +use crate::{ + algebra::{embedding::Identity, scalar_mul_add, univariate_evaluate}, + buffer::{ActiveBuffer, Buffer, BufferOps}, + hash::Hash, + protocols::{ + irs_commit::{Commitment as IrsCommitment, Config as IrsConfig, Witness as IrsWitness}, + proof_of_work, + }, + transcript::{ + codecs::U64, Codec, Decoding, DuplexSpongeInterface, ProverMessage, ProverState, + VerificationResult, VerifierMessage, VerifierState, + }, + utils::zip_strict, + verify, +}; + +/// Mask proximity configuration. +/// +/// Wraps an IRS config for the shared mask tree and the number of mask pairs. +#[must_use] +#[derive(Clone, PartialEq, Eq, Debug, Hash, Serialize, Deserialize)] +#[serde(bound = "")] +pub struct Config { + c_zk_commit: IrsConfig>, + num_masks: usize, + pow: proof_of_work::Config, +} + +/// Prover output from the commit phase. +#[must_use] +pub struct Witness { + pub(crate) mask_witness: IrsWitness, + pub(crate) fresh_msgs: Vec>, +} + +/// Verifier output from the commit phase. +pub type Commitment = IrsCommitment; + +impl Config { + /// Required `c_zk.num_vectors` for `num_masks` originals: one fresh + /// mask-of-mask per original (Construction 7.2 originals + fresh pairs). + pub const fn num_vectors_for(num_masks: usize) -> usize { + 2 * num_masks + } + + pub fn new( + c_zk_commit: IrsConfig>, + num_masks: usize, + pow: proof_of_work::Config, + ) -> Self { + assert_eq!( + c_zk_commit.num_vectors(), + Self::num_vectors_for(num_masks), + "c_zk.num_vectors must be 2 * num_masks" + ); + assert_eq!( + c_zk_commit.interleaving_depth(), + 1, + "mask proximity requires interleaving_depth = 1" + ); + Self { + c_zk_commit, + num_masks, + pow, + } + } + + pub const fn c_zk_commit(&self) -> &IrsConfig> { + &self.c_zk_commit + } + + pub const fn num_masks(&self) -> usize { + self.num_masks + } + + pub const fn pow(&self) -> proof_of_work::Config { + self.pow + } + + /// Commit all masks and their mask-of-masks in a single shared tree. + /// + /// Samples n fresh mask-of-mask polynomials, combines them with the + /// provided original masks into a 2n-vector tree, and commits via IRS. + pub fn commit( + &self, + prover_state: &mut ProverState, + original_msgs: &[&ActiveBuffer], + ) -> Witness + where + F: Codec<[H::U]>, + H: DuplexSpongeInterface, + R: RngCore + CryptoRng, + Standard: Distribution, + Hash: ProverMessage<[H::U]>, + { + assert_eq!(original_msgs.len(), self.num_masks); + for msg in original_msgs { + assert_eq!(msg.len(), self.c_zk_commit.vector_size()); + } + + // Sample fresh mask-of-masks + let fresh_msgs: Vec> = (0..self.num_masks) + .map(|_| ActiveBuffer::random(prover_state.rng(), self.c_zk_commit.vector_size())) + .collect(); + + // Tree layout: [originals..., freshes...] + let all_vectors: Vec<&ActiveBuffer> = original_msgs + .iter() + .copied() + .chain(fresh_msgs.iter()) + .collect(); + + let mask_witness = self.c_zk_commit.commit(prover_state, &all_vectors); + + Witness { + mask_witness, + fresh_msgs, + } + } + + /// Receive a mask proximity commitment + pub fn receive_commitment( + &self, + verifier_state: &mut VerifierState, + ) -> VerificationResult + where + F: Codec<[H::U]>, + H: DuplexSpongeInterface, + Hash: ProverMessage<[H::U]>, + { + self.c_zk_commit.receive_commitment(verifier_state) + } + + /// Prove that each original mask is close to a C_zk codeword. + pub fn prove( + &self, + prover_state: &mut ProverState, + witness: &Witness, + original_msgs: &[&ActiveBuffer], + ) where + F: Codec<[H::U]>, + H: DuplexSpongeInterface, + R: RngCore + CryptoRng, + Standard: Distribution, + u8: Decoding<[H::U]>, + [u8; 32]: Decoding<[H::U]>, + U64: Codec<[H::U]>, + Hash: ProverMessage<[H::U]>, + { + assert_eq!(original_msgs.len(), self.num_masks); + assert_eq!(witness.fresh_msgs.len(), self.num_masks); + + // Grind the Lemma 7.4 γ-combination gap before γ is sampled. + self.pow.prove(prover_state); + + // Step 1: receive combination randomness γ + let gamma: F = prover_state.verifier_message(); + + // Step 2: compute and send combined polynomials + IRS randomness + let irs_masks_per_vector = + self.c_zk_commit.mask_length() * self.c_zk_commit.interleaving_depth(); + let irs_masks = &witness.mask_witness.masks; + assert_eq!(irs_masks.len(), 2 * self.num_masks * irs_masks_per_vector); + for (i, (orig_msg, fresh_msg)) in original_msgs + .iter() + .copied() + .zip(witness.fresh_msgs.iter()) + .enumerate() + { + // ξ*_i = s_i + γ · ξ_i + let mut combined_msg = fresh_msg.clone(); + orig_msg.mixed_scalar_mul_add_to(&Identity::::new(), &mut combined_msg, gamma); + prover_state.prover_messages(combined_msg.to_slice()); + + // r*_i = r'_i + γ · r_i + // Combined on the host: the result is transcript data anyway, and + // the IRS randomness is only a few elements per vector. + if irs_masks_per_vector > 0 { + let base = i * irs_masks_per_vector; + let fresh_base = (self.num_masks + i) * irs_masks_per_vector; + let masks = irs_masks.to_slice(); + let mut combined_r = masks[fresh_base..fresh_base + irs_masks_per_vector].to_vec(); + scalar_mul_add( + &mut combined_r, + gamma, + &masks[base..base + irs_masks_per_vector], + ); + prover_state.prover_messages(&combined_r); + } + } + + // Step 3: open the shared tree at random in-domain positions + self.c_zk_commit + .open(prover_state, &[&witness.mask_witness]); + } + + /// Verify that each original mask is close to a C_zk codeword. + pub fn verify( + &self, + verifier_state: &mut VerifierState, + commitment: &Commitment, + ) -> VerificationResult<()> + where + F: Codec<[H::U]>, + H: DuplexSpongeInterface, + u8: Decoding<[H::U]>, + [u8; 32]: Decoding<[H::U]>, + U64: Codec<[H::U]>, + Hash: ProverMessage<[H::U]>, + { + // Grind the Lemma 7.4 γ-combination gap before γ is sampled. + self.pow.verify(verifier_state)?; + + // Step 1: send combination randomness γ + let gamma: F = verifier_state.verifier_message(); + + // Step 2: read combined polynomials + IRS randomness + let msg_len = self.c_zk_commit.message_length(); + let irs_masks_per_vector = + self.c_zk_commit.mask_length() * self.c_zk_commit.interleaving_depth(); + let has_irs_masks = irs_masks_per_vector > 0; + let mut combined_msgs = Vec::with_capacity(self.num_masks); + let mut combined_rs: Option>> = + has_irs_masks.then(|| Vec::with_capacity(self.num_masks)); + for _ in 0..self.num_masks { + combined_msgs.push(verifier_state.prover_messages_vec(msg_len)?); + if let Some(ref mut rs) = combined_rs { + rs.push(verifier_state.prover_messages_vec(irs_masks_per_vector)?); + } + } + + // Step 3: verify tree openings and get codeword values at opened positions + let evaluations = self.c_zk_commit.verify(verifier_state, &[commitment])?; + + // Step 4: spot-check γ-combination at each opened position + let num_cols = self.c_zk_commit.num_cols(); + for (row, &point) in zip_strict( + evaluations.matrix.chunks_exact(num_cols), + &evaluations.points, + ) { + let shift = combined_rs.as_ref().map(|_| point.pow([msg_len as u64])); + // Direct indexing: c_zk_commit.interleaving_depth == 1 by Config::new. + for i in 0..self.num_masks { + let original_val = row[i]; + let fresh_val = row[self.num_masks + i]; + + // Enc(ξ*_i, r*_i)(point) = ξ*_i(point) + point^msg_len · r*_i(point) + let mut expected = univariate_evaluate(&combined_msgs[i], point); + if let Some((rs, shift)) = combined_rs.as_ref().zip(shift) { + expected += shift * univariate_evaluate(&rs[i], point); + } + + let actual = fresh_val + gamma * original_val; + verify!(expected == actual); + } + } + + Ok(()) + } +} + +impl fmt::Display for Config { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "masks {} tree {}", self.num_masks, self.c_zk_commit) + } +} + +#[cfg(test)] +mod tests { + use ark_std::rand::{ + distributions::{Distribution, Standard}, + rngs::StdRng, + SeedableRng, + }; + use proptest::{prelude::Strategy, prop_assume, prop_oneof, proptest, strategy::Just}; + + use super::*; + use crate::{ + algebra::{fields, ntt, random_vector}, + transcript::{codecs::U64, DomainSeparator}, + }; + + impl Config + where + Standard: Distribution, + { + pub fn arbitrary() -> impl Strategy { + let valid_sizes = (1..=256) + .filter(|&n| ntt::next_order::(n) == Some(n)) + .collect::>(); + + // mask_length=0: tests non-ZK mask tree (no IRS masking on the committed vectors). + let mask_length = prop_oneof![ + 3 => Just(0_usize), + 7 => 1_usize..=4, + ]; + ( + 1_usize..=6, + proptest::sample::select(valid_sizes), + mask_length, + ) + .prop_flat_map(|(num_masks, vector_size, mask_length)| { + let c_zk = IrsConfig::>::arbitrary( + Identity::new(), + Self::num_vectors_for(num_masks), + vector_size, + mask_length, + 1, + ); + (Just(num_masks), c_zk) + }) + .prop_map(|(num_masks, c_zk)| { + Self::new(c_zk, num_masks, proof_of_work::Config::none()) + }) + } + } + + fn test_config(seed: u64, config: &Config) + where + F: Field + Codec<[u8]> + 'static, + Standard: Distribution, + Hash: crate::transcript::ProverMessage<[u8]>, + { + let instance = U64(seed); + let ds = DomainSeparator::protocol(config) + .session(&format!("Test at {}:{}", file!(), line!())) + .instance(&instance); + let mut rng = StdRng::seed_from_u64(seed); + + let original_msgs: Vec> = (0..config.num_masks) + .map(|_| random_vector(&mut rng, config.c_zk_commit.vector_size())) + .collect(); + let original_buffers = original_msgs + .iter() + .map(|msg| ActiveBuffer::from_slice(msg)) + .collect::>(); + let original_refs = original_buffers.iter().collect::>(); + + let mut prover_state = ProverState::new_std(&ds); + let witness = config.commit(&mut prover_state, &original_refs); + config.prove(&mut prover_state, &witness, &original_refs); + let proof = prover_state.proof(); + + let mut verifier_state = VerifierState::new_std(&ds, &proof); + let commitment = config.receive_commitment(&mut verifier_state).unwrap(); + config.verify(&mut verifier_state, &commitment).unwrap(); + verifier_state.check_eof().unwrap(); + } + + fn test + 'static>() + where + Standard: Distribution, + Hash: crate::transcript::ProverMessage<[u8]>, + { + crate::tests::init(); + proptest!(|(seed: u64, config in Config::arbitrary())| { + test_config(seed, &config); + }); + } + + #[test] + fn test_field64_1() { + test::(); + } + + #[test] + #[ignore = "Somewhat expensive and redundant"] + fn test_field64_2() { + test::(); + } + + #[test] + #[ignore = "Somewhat expensive and redundant"] + fn test_field64_3() { + test::(); + } + + #[test] + #[ignore = "Somewhat expensive and redundant"] + fn test_field128() { + test::(); + } + + #[test] + #[ignore = "Somewhat expensive and redundant"] + fn test_field192() { + test::(); + } + + #[test] + #[ignore = "Somewhat expensive and redundant"] + fn test_field256() { + test::(); + } + + /// Pre-γ tamper: commit honestly, then prove with different original masks. + fn test_tampered_mask_config(seed: u64, config: &Config) + where + F: Field + Codec<[u8]> + 'static, + Standard: Distribution, + Hash: crate::transcript::ProverMessage<[u8]>, + { + let instance = U64(seed); + let ds = DomainSeparator::protocol(config) + .session(&format!("Test at {}:{}", file!(), line!())) + .instance(&instance); + let mut rng = StdRng::seed_from_u64(seed); + + let original_msgs: Vec> = (0..config.num_masks) + .map(|_| random_vector(&mut rng, config.c_zk_commit.vector_size())) + .collect(); + let original_buffers = original_msgs + .iter() + .map(|msg| ActiveBuffer::from_slice(msg)) + .collect::>(); + let original_refs = original_buffers.iter().collect::>(); + + let mut prover_state = ProverState::new_std(&ds); + let witness = config.commit(&mut prover_state, &original_refs); + + let mut tampered_msgs = original_msgs; + tampered_msgs[0][0] += F::ONE; + let tampered_buffers = tampered_msgs + .iter() + .map(|msg| ActiveBuffer::from_slice(msg)) + .collect::>(); + let tampered_refs = tampered_buffers.iter().collect::>(); + config.prove(&mut prover_state, &witness, &tampered_refs); + let proof = prover_state.proof(); + + let mut verifier_state = VerifierState::new_std(&ds, &proof); + let commitment = config.receive_commitment(&mut verifier_state).unwrap(); + assert_rejected(|| config.verify(&mut verifier_state, &commitment)); + } + + /// Post-γ tamper: commit honestly, then corrupt the combined message to + /// exercise the verifier's spot-check (step 4). + fn test_tampered_combined_msg_config(seed: u64, config: &Config) + where + F: Field + Codec<[u8]> + 'static, + Standard: Distribution, + Hash: crate::transcript::ProverMessage<[u8]>, + { + let instance = U64(seed); + let ds = DomainSeparator::protocol(config) + .session(&format!("Test at {}:{}", file!(), line!())) + .instance(&instance); + let mut rng = StdRng::seed_from_u64(seed); + + let original_msgs: Vec> = (0..config.num_masks) + .map(|_| random_vector(&mut rng, config.c_zk_commit.vector_size())) + .collect(); + let original_buffers = original_msgs + .iter() + .map(|msg| ActiveBuffer::from_slice(msg)) + .collect::>(); + let original_refs = original_buffers.iter().collect::>(); + + let mut prover_state = ProverState::new_std(&ds); + let witness = config.commit(&mut prover_state, &original_refs); + + let gamma: F = prover_state.verifier_message(); + let irs_masks_per_vector = + config.c_zk_commit.mask_length() * config.c_zk_commit.interleaving_depth(); + let irs_masks = &witness.mask_witness.masks; + + for (i, (orig_msg, fresh_msg)) in original_refs + .iter() + .copied() + .zip(witness.fresh_msgs.iter()) + .enumerate() + { + let mut combined_msg = fresh_msg.clone(); + orig_msg.mixed_scalar_mul_add_to(&Identity::::new(), &mut combined_msg, gamma); + if i == 0 { + let mut tampered = combined_msg.to_slice().to_vec(); + tampered[0] += F::ONE; + combined_msg = ActiveBuffer::from_slice(&tampered); + } + prover_state.prover_messages(combined_msg.to_slice()); + + if irs_masks_per_vector > 0 { + let base = i * irs_masks_per_vector; + let fresh_base = (config.num_masks + i) * irs_masks_per_vector; + let masks = irs_masks.to_slice(); + let mut combined_r = masks[fresh_base..fresh_base + irs_masks_per_vector].to_vec(); + scalar_mul_add( + &mut combined_r, + gamma, + &masks[base..base + irs_masks_per_vector], + ); + prover_state.prover_messages(&combined_r); + } + } + + config + .c_zk_commit + .open(&mut prover_state, &[&witness.mask_witness]); + let proof = prover_state.proof(); + + let mut verifier_state = VerifierState::new_std(&ds, &proof); + let commitment = config.receive_commitment(&mut verifier_state).unwrap(); + assert_rejected(|| config.verify(&mut verifier_state, &commitment)); + } + + fn assert_rejected(verify: impl FnOnce() -> VerificationResult) { + #[cfg(feature = "verifier_panics")] + use std::panic::AssertUnwindSafe; + #[cfg(feature = "verifier_panics")] + { + let result = std::panic::catch_unwind(AssertUnwindSafe(verify)); + assert!( + !matches!(result, Ok(Ok(_))), + "verification unexpectedly succeeded" + ); + } + #[cfg(not(feature = "verifier_panics"))] + { + assert!(verify().is_err(), "verification unexpectedly succeeded"); + } + } + + #[test] + fn test_tampered_mask_rejected() { + crate::tests::init(); + proptest!(|(seed: u64, config in Config::::arbitrary())| { + prop_assume!(config.c_zk_commit.in_domain_samples() > 0); + test_tampered_mask_config(seed, &config); + }); + } + + #[test] + fn test_tampered_combined_msg_rejected() { + crate::tests::init(); + proptest!(|(seed: u64, config in Config::::arbitrary())| { + prop_assume!(config.c_zk_commit.in_domain_samples() > 0); + test_tampered_combined_msg_config(seed, &config); + }); + } +} diff --git a/src/protocols/matrix_commit.rs b/src/protocols/matrix_commit.rs index 1e823162..8ccef4f4 100644 --- a/src/protocols/matrix_commit.rs +++ b/src/protocols/matrix_commit.rs @@ -12,6 +12,7 @@ use tracing::instrument; use zerocopy::{Immutable, IntoBytes}; use crate::{ + buffer::{ActiveBuffer, BufferOps}, engines::EngineId, hash::{self, Hash}, protocols::merkle_tree, @@ -168,7 +169,7 @@ impl Encoder for ZeroCopyEncoder { } } -impl Config { +impl Config { /// Create a new matrix commit configuration with the recommended hash function. pub fn new(num_rows: usize, num_cols: usize) -> Self { // Select a leaf hash function. @@ -208,7 +209,11 @@ impl Config { /// Commit the matrix (in row-major order). #[cfg_attr(feature = "tracing", instrument(skip_all, fields(self = %self, size = matrix.len(), engine)))] - pub fn commit(&self, prover_state: &mut ProverState, matrix: &[T]) -> Witness + pub fn commit( + &self, + prover_state: &mut ProverState, + matrix: &ActiveBuffer, + ) -> Witness where H: DuplexSpongeInterface, R: RngCore + CryptoRng, @@ -216,19 +221,9 @@ impl Config { { assert_eq!(matrix.len(), self.num_rows() * self.num_cols); - let engine = hash::ENGINES - .retrieve(self.leaf_hash_id) - .expect("Failed to retrieve hash engine"); - #[cfg(feature = "tracing")] - tracing::Span::current().record("engine", engine.name().as_ref()); - - // Compute leaf hashes - let mut leaves = Vec::with_capacity(self.merkle_tree.num_nodes()); - leaves.resize(self.merkle_tree.num_leaves, Hash::default()); - hash_rows(&*engine, matrix, &mut leaves[..self.num_rows()]); - - // Commit the leaf hashes - self.merkle_tree.commit(prover_state, leaves) + let (nodes, root) = matrix.merklize(self.num_cols, self.leaf_hash_id, &self.merkle_tree); + prover_state.prover_message(&root); + merkle_tree::Witness::new(nodes) } #[cfg_attr(feature = "tracing", instrument(skip_all, fields(self = %self)))] @@ -292,14 +287,14 @@ impl Config { } } -impl fmt::Display for Config { +impl fmt::Display for Config { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "MatrixCommit({} x {})", self.num_rows(), self.num_cols) } } #[cfg(not(feature = "parallel"))] -fn hash_rows( +pub fn hash_rows( engine: &dyn hash::HashEngine, matrix: &[T], out: &mut [Hash], @@ -308,7 +303,7 @@ fn hash_rows( } #[cfg(feature = "parallel")] -fn hash_rows( +pub fn hash_rows( engine: &dyn hash::HashEngine, matrix: &[T], out: &mut [Hash], @@ -406,7 +401,7 @@ pub(crate) mod tests { num_cols: usize, indices: &[usize], ) where - T: Clone + TypeInfo + Encodable + Send + Sync, + T: Copy + TypeInfo + Encodable + Send + Sync, Standard: Distribution, { crate::tests::init(); @@ -428,22 +423,9 @@ pub(crate) mod tests { .instance(&Empty); // Instance - let matrix: Vec = (0..config.size()).map(|_| rng.gen()).collect(); - let submatrix: Vec = if num_cols > 0 { - indices - .iter() - .flat_map(|&index| { - matrix - .chunks_exact(num_cols) - .nth(index) - .unwrap() - .iter() - .cloned() - }) - .collect::>() - } else { - Vec::new() - }; + let matrix: ActiveBuffer = + ActiveBuffer::from_vec((0..config.size()).map(|_| rng.gen()).collect()); + let submatrix: Vec = matrix.read_rows(num_cols, indices); // Prover let mut prover_state = ProverState::new_std(&ds); @@ -462,7 +444,7 @@ pub(crate) mod tests { fn proptest() where - T: Clone + TypeInfo + Encodable + Send + Sync, + T: Copy + TypeInfo + Encodable + Send + Sync, Standard: Distribution, { let hashes = [hash::COPY, hash::SHA2, hash::SHA3, hash::BLAKE3]; diff --git a/src/protocols/merkle_tree.rs b/src/protocols/merkle_tree.rs index fcf44ba0..dc18e22d 100644 --- a/src/protocols/merkle_tree.rs +++ b/src/protocols/merkle_tree.rs @@ -12,6 +12,7 @@ use tracing::{instrument, span, Level}; use zerocopy::IntoBytes; use crate::{ + buffer::{ActiveBuffer, BufferOps}, engines::EngineId, hash::{self, Hash, HashEngine, ENGINES}, transcript::{ @@ -58,7 +59,7 @@ pub struct Commitment { #[must_use] pub struct Witness { /// The nodes in the Merkle tree, starting with the leaf hash layer. - nodes: Vec, + nodes: ActiveBuffer, } impl Config { @@ -78,13 +79,19 @@ impl Config { (1 << (self.layers.len() + 1)) - 1 } - #[cfg_attr(feature = "tracing", instrument(skip(prover_state, leaves), fields(self = %self)))] - pub fn commit(&self, prover_state: &mut ProverState, leaves: Vec) -> Witness - where - H: DuplexSpongeInterface, - R: RngCore + CryptoRng, - Hash: ProverMessage<[H::U]>, - { + /// Build the full node array from leaf hashes, without touching the transcript. + /// + /// The returned vector holds the leaf layer first and the root last (at `num_nodes() - 1`). + /// + /// # Caller obligation + /// + /// This does **not** commit to anything: the caller must send the root + /// (`nodes[num_nodes() - 1]`) to the transcript before using the tree as + /// a commitment. In production this is funneled through + /// [`matrix_commit::Config::commit`](crate::protocols::matrix_commit::Config::commit), + /// which sends the root returned by `merklize`. A tree whose root was + /// never sent is not bound by the proof. + pub fn build_nodes(&self, leaves: Vec) -> Vec { assert_eq!( leaves.len(), self.num_leaves, @@ -120,10 +127,7 @@ impl Config { remaining = next_remaining; } - // Commit to the root hash. - prover_state.prover_message(&previous[0]); - - Witness { nodes } + nodes } pub fn receive_commitment( @@ -155,33 +159,9 @@ impl Config { assert_eq!(witness.nodes.len(), self.num_nodes()); assert!(indices.iter().all(|&i| i < self.num_leaves)); - // Abstract execution of verify algorithm writing required hashes. - let mut indices = indices.to_vec(); - indices.sort_unstable(); - indices.dedup(); - let (mut layer, mut remaining) = witness.nodes.split_at(1 << self.layers.len()); - while layer.len() > 1 { - let mut next_indices = Vec::with_capacity(indices.len()); - let mut iter = indices.iter().copied().peekable(); - loop { - match (iter.next(), iter.peek()) { - (Some(a), Some(&b)) if b == a ^ 1 => { - // Neighboring indices, merging branches. - next_indices.push(a >> 1); - iter.next(); // Skip the next index. - } - (Some(a), _) => { - // Single index, pushing the neighbor hash. - prover_state.prover_hint(&layer[a ^ 1]); - next_indices.push(a >> 1); - } - (None, _) => break, - } - } - indices = next_indices; - let (next_layer, next_remaining) = remaining.split_at(layer.len() / 2); - layer = next_layer; - remaining = next_remaining; + let node_indices = opening_sibling_indices(self.num_leaves, self.layers.len(), indices); + for hint in witness.nodes.gather_at_indices(&node_indices) { + prover_state.prover_hint(&hint); } } @@ -273,7 +253,12 @@ impl Config { } impl Witness { - pub const fn num_nodes(&self) -> usize { + /// Wrap a fully built node buffer (leaf layer first, root last) as a witness. + pub const fn new(nodes: ActiveBuffer) -> Self { + Self { nodes } + } + + pub fn num_nodes(&self) -> usize { self.nodes.len() } } @@ -303,6 +288,40 @@ fn parallel_hash(engine: &dyn HashEngine, size: usize, input: &[u8], output: &mu } } +/// Flat node indices of sibling hashes required to open at `indices`. +pub fn opening_sibling_indices(num_leaves: usize, layers: usize, indices: &[usize]) -> Vec { + debug_assert!(indices.iter().all(|&i| i < num_leaves)); + + let mut indices = indices.to_vec(); + indices.sort_unstable(); + indices.dedup(); + + let mut node_indices = Vec::new(); + let mut layer_offset = 0usize; + let mut layer_len = 1usize << layers; + while layer_len > 1 { + let mut next_indices = Vec::with_capacity(indices.len()); + let mut iter = indices.iter().copied().peekable(); + loop { + match (iter.next(), iter.peek()) { + (Some(a), Some(&b)) if b == a ^ 1 => { + next_indices.push(a >> 1); + iter.next(); + } + (Some(a), _) => { + node_indices.push(layer_offset + (a ^ 1)); + next_indices.push(a >> 1); + } + (None, _) => break, + } + } + indices = next_indices; + layer_offset += layer_len; + layer_len /= 2; + } + node_indices +} + #[cfg(test)] pub(crate) mod tests { use proptest::{collection::vec, prelude::Strategy}; @@ -340,8 +359,10 @@ pub(crate) mod tests { // Prover let mut prover_state = ProverState::new_std(&ds); - let tree = config.commit(&mut prover_state, leaves); - config.open(&mut prover_state, &tree, &[13, 42]); + let nodes = config.build_nodes(leaves); + prover_state.prover_message(&nodes[config.num_nodes() - 1]); + let witness = Witness::new(ActiveBuffer::from_vec(nodes)); + config.open(&mut prover_state, &witness, &[13, 42]); let proof = prover_state.proof(); // Verifier diff --git a/src/protocols/mod.rs b/src/protocols/mod.rs index 3c232ccb..64f1e0b9 100644 --- a/src/protocols/mod.rs +++ b/src/protocols/mod.rs @@ -13,8 +13,10 @@ pub mod challenge_indices; pub mod code_switch; pub mod geometric_challenge; pub mod irs_commit; +pub mod mask_proximity; pub mod matrix_commit; pub mod merkle_tree; +pub mod params; pub mod proof_of_work; pub mod sumcheck; pub mod whir; diff --git a/src/protocols/params/basecase.rs b/src/protocols/params/basecase.rs new file mode 100644 index 00000000..87ce439b --- /dev/null +++ b/src/protocols/params/basecase.rs @@ -0,0 +1,198 @@ +//! Basecase (Construction 7.2, p.43) parameter selection + γ-combination bound. + +use ark_ff::Field; + +use crate::{ + algebra::{embedding::Identity, fields::FieldWithSize}, + bits::Bits, + protocols::{ + basecase::{self, Config as BasecaseConfig}, + irs_commit::Config as IrsConfig, + params::{ + error::{grind_to_at, DeriveError, Pow}, + irs_commit as irs_params, + protocol_config::BasecasePlan, + spec::{Mode as SpecMode, OodSampleBudget, RoundContext, SecuritySpec}, + sumcheck as sumcheck_params, + }, + proof_of_work::Config as PowConfig, + sumcheck::{self, Config as SumcheckConfig}, + }, +}; + +pub fn solve( + spec: &SecuritySpec, + vector_size: usize, + log_inv_rate: u32, +) -> Result, DeriveError> { + assert!(vector_size > 0, "basecase requires vector_size ≥ 1"); + + let ctx = RoundContext { + vector_size, + log_inv_rate, + folding_factor: 0, + }; + let commit = irs_params::solve(spec, &ctx, OodSampleBudget::ZERO)?; + + let sumcheck_analytic = sumcheck_params::analytic_error_bits(&commit, None); + let sumcheck_pow = grind_to_at(spec, sumcheck_analytic, Pow::BasecaseSumcheck)?; + let sumcheck = SumcheckConfig::new( + vector_size, + sumcheck_pow, + vector_size.next_power_of_two().trailing_zeros() as usize, + sumcheck::SumcheckMode::Standard, + ); + + let gamma_analytic = analytic_error_bits(&commit); + let (mode, pow) = match spec.mode { + SpecMode::Standard => (basecase::BasecaseMode::Standard, PowConfig::none()), + SpecMode::ZeroKnowledge => ( + basecase::BasecaseMode::ZeroKnowledge, + grind_to_at(spec, gamma_analytic, Pow::BasecaseGammaCombination)?, + ), + }; + + Ok(BasecasePlan::new( + BasecaseConfig::new(commit, sumcheck, mode, pow), + sumcheck_analytic, + gamma_analytic, + )) +} + +/// γ-combination soundness (Lemma 7.4 combination-randomness slot, paper p.45). +pub fn analytic_error_bits(commit: &IrsConfig>) -> Bits { + let field_bits = F::field_size_bits(); + let log_list = commit.list_size().log2(); + let prox_gaps = commit.rbr_soundness_fold_prox_gaps(); + let poly_id = field_bits - log_list; + Bits::new(prox_gaps.min(poly_id).max(0.0)) +} + +impl BasecaseConfig { + /// Analytic soundness bits (excluding PoW): `min(sumcheck round error, γ-slot error)`. + pub fn analytic_bits(&self) -> Bits { + let sumcheck_term = f64::from(sumcheck_params::analytic_error_bits(self.commit(), None)); + let min_bits = match self.mode() { + basecase::BasecaseMode::Standard => sumcheck_term, + basecase::BasecaseMode::ZeroKnowledge => { + sumcheck_term.min(f64::from(analytic_error_bits(self.commit()))) + } + }; + Bits::new(min_bits.max(0.0)) + } +} + +#[cfg(test)] +mod tests { + use proptest::prelude::*; + + use super::*; + use crate::protocols::params::test_utils::{ + arb_standard_spec, arb_zk_spec, assert_close, assert_pow_closes_gap, deterministic_spec, + TestField, TEST_TARGET_RANGE, + }; + + const FIXTURE_VECTOR_SIZE: usize = 16; + const FIXTURE_LOG_INV_RATE: u32 = 2; + + fn arb_dims() -> impl Strategy { + (1u32..=4, 1u32..=3) + } + + #[test] + fn analytic_error_formula() { + use crate::protocols::params::{ + irs_commit as irs_params, + spec::{Mode, OodSampleBudget, RoundContext}, + }; + + let spec = deterministic_spec(Mode::ZeroKnowledge); + let ctx = RoundContext { + vector_size: FIXTURE_VECTOR_SIZE, + log_inv_rate: FIXTURE_LOG_INV_RATE, + folding_factor: 0, + }; + let commit: IrsConfig> = + irs_params::solve(&spec, &ctx, OodSampleBudget::ZERO).expect("IRS fixture must solve"); + + let got = f64::from(analytic_error_bits(&commit)); + let field_bits = TestField::field_size_bits(); + let log_list = commit.list_size().log2(); + let prox_gaps = commit.rbr_soundness_fold_prox_gaps(); + let poly_id = field_bits - log_list; + let expected = prox_gaps.min(poly_id).max(0.0); + + assert_close(got, expected); + } + + #[test] + fn analytic_error_uses_eps_mca_when_limiting() { + use crate::protocols::params::{ + irs_commit as irs_params, + spec::{Mode, OodSampleBudget, RoundContext}, + }; + + let spec = deterministic_spec(Mode::ZeroKnowledge); + let ctx = RoundContext { + vector_size: FIXTURE_VECTOR_SIZE, + log_inv_rate: 1, + folding_factor: 0, + }; + let commit: IrsConfig> = + irs_params::solve(&spec, &ctx, OodSampleBudget::ZERO).expect("IRS fixture must solve"); + + let field_bits = TestField::field_size_bits(); + let log_list = commit.list_size().log2(); + let prox_gaps = commit.rbr_soundness_fold_prox_gaps(); + let poly_id = field_bits - log_list; + assert!( + prox_gaps < poly_id, + "fixture wants prox_gaps to bind: prox_gaps {prox_gaps} ≥ poly_id {poly_id}", + ); + + let got = f64::from(analytic_error_bits(&commit)); + assert_close(got, prox_gaps.max(0.0)); + } + + proptest! { + #[test] + fn solve_standard_assembles( + spec in arb_standard_spec(TEST_TARGET_RANGE), + (log_size, log_inv_rate) in arb_dims(), + ) { + let config = solve::(&spec, 1usize << log_size, log_inv_rate).unwrap(); + prop_assert!(matches!(config.mode(), basecase::BasecaseMode::Standard)); + prop_assert_eq!(config.commit().interleaving_depth(), 1); + prop_assert_eq!(config.commit().num_vectors(), 1); + prop_assert_eq!(config.commit().vector_size(), config.sumcheck().initial_size()); + } + + #[test] + fn solve_zk_assembles( + spec in arb_zk_spec(TEST_TARGET_RANGE), + (log_size, log_inv_rate) in arb_dims(), + ) { + let config = solve::(&spec, 1usize << log_size, log_inv_rate).unwrap(); + prop_assert!(matches!(config.mode(), basecase::BasecaseMode::ZeroKnowledge)); + prop_assert!(config.commit().mask_length() > 0); + } + + #[test] + fn pow_closes_gap_to_target_zk( + spec in arb_zk_spec(TEST_TARGET_RANGE), + (log_size, log_inv_rate) in arb_dims(), + ) { + let config = solve::(&spec, 1usize << log_size, log_inv_rate).unwrap(); + assert_pow_closes_gap(&spec, analytic_error_bits(config.commit()), &config.pow()); + } + + #[test] + fn standard_mode_has_no_pow( + spec in arb_standard_spec(TEST_TARGET_RANGE), + (log_size, log_inv_rate) in arb_dims(), + ) { + let config = solve::(&spec, 1usize << log_size, log_inv_rate).unwrap(); + prop_assert_eq!(config.pow(), PowConfig::none()); + } + } +} diff --git a/src/protocols/params/bounds.rs b/src/protocols/params/bounds.rs new file mode 100644 index 00000000..c8cad942 --- /dev/null +++ b/src/protocols/params/bounds.rs @@ -0,0 +1,40 @@ +//! Regime-agnostic analytic primitives shared across the params solvers. +//! +//! Regime-specific math (Unique / Johnson / Capacity branches) lives on +//! [`super::regime::DecodingRegimeParams`]. + +/// `ρ = 2^-log_inv_rate`. Centralized so the rate formula lives in one place. +pub(super) fn rate(log_inv_rate: f64) -> f64 { + 2_f64.powf(-log_inv_rate) +} + +/// Lossy `usize → f64` for analytic-error formulas. Named so individual call +/// sites can stay terse and intent-tagged. +pub(super) const fn usize_to_f64(x: usize) -> f64 { + x as f64 +} + +/// log2 of the per-OOD-sample Schwartz–Zippel error: `(k-1)/|F|`. +pub fn ood_per_sample_log2(message_length: usize, field_bits: f64) -> f64 { + ((message_length - 1) as f64).log2() - field_bits +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::protocols::params::test_utils::assert_close; + + /// OOD per-sample Schwartz–Zippel: `log₂((k−1) / |F|) = log₂(k−1) − field_bits`. + #[test] + fn ood_per_sample_log2_formula() { + // `k = 129` so `k − 1 = 128 = 2^7` for exact `log2`. + const K: usize = 129; + const FIELD_BITS: f64 = 64.0; + + let got = ood_per_sample_log2(K, FIELD_BITS); + let expected = ((K - 1) as f64).log2() - FIELD_BITS; + assert_close(got, expected); + // (k−1)/|F| < 1 for sane parameters ⇒ log is negative. + assert!(got < 0.0); + } +} diff --git a/src/protocols/params/branch.rs b/src/protocols/params/branch.rs new file mode 100644 index 00000000..7e2681ad --- /dev/null +++ b/src/protocols/params/branch.rs @@ -0,0 +1,65 @@ +//! Standard-vs-ZK branching used to thread mode through the build pipeline. +//! +//! [`Branch`] is the shared shape: a transient choice between the standard +//! path (no payload) and the zero-knowledge path (payload `T`). Concrete +//! pipeline stages alias it with the payload they carry: +//! +//! - [`RoundBuildMode`] — input to [`super::build_round::build_round_config`]. +//! - [`OodMode`] — input to OOD-bound helpers. +//! - [`SolveMode`] — input to the per-sub-protocol solvers (`sumcheck`, `code_switch`). +//! +//! Sharing one enum gives us a free [`Branch::map`] for stage-to-stage +//! payload conversions, replacing one-off `to_ood_mode`-style helpers. + +use crate::protocols::params::{ + protocol_config::MaskOracleInfo, + spec::{LogInvRate, ZkSpec}, +}; + +/// Standard (no payload) vs. zero-knowledge (payload `T`). +#[derive(Clone, Copy, Debug)] +pub enum Branch { + Standard, + ZeroKnowledge(T), +} + +impl Branch { + pub const fn is_zk(&self) -> bool { + matches!(self, Self::ZeroKnowledge(_)) + } + + /// Transform the ZK payload, leaving `Standard` unchanged. Replaces + /// per-stage `to_*` conversion helpers. + pub fn map(self, f: impl FnOnce(T) -> U) -> Branch { + match self { + Self::Standard => Branch::Standard, + Self::ZeroKnowledge(t) => Branch::ZeroKnowledge(f(t)), + } + } + + pub const fn as_ref(&self) -> Branch<&T> { + match self { + Self::Standard => Branch::Standard, + Self::ZeroKnowledge(t) => Branch::ZeroKnowledge(t), + } + } +} + +/// Payload carried by [`RoundBuildMode::ZeroKnowledge`] — references the +/// `SecuritySpec` (so its lifetime threads through) plus the planner-chosen +/// `C_zk` rate. +#[derive(Clone, Copy, Debug)] +pub struct RoundBuildPayload<'a> { + pub zk_spec: ZkSpec<'a>, + pub c_zk_log_inv_rate: LogInvRate, +} + +/// Mode-dispatch input for [`super::build_round::build_round_config`]. +pub type RoundBuildMode<'a> = Branch>; + +/// Mode flag for the OOD security bound. Payload is the `C_zk` log-inverse +/// rate; formulas coerce to `f64` at the point of use. +pub type OodMode = Branch; + +/// Solver-input mode for the per-round sumcheck and code-switch builders. +pub type SolveMode = Branch; diff --git a/src/protocols/params/build_round.rs b/src/protocols/params/build_round.rs new file mode 100644 index 00000000..e533815b --- /dev/null +++ b/src/protocols/params/build_round.rs @@ -0,0 +1,238 @@ +//! Per-round build: turns a [`RoundShape`] into a [`RoundConfig`]. +//! +//! Solves the `t_ood` fix-point, builds source/target IRS configs, and +//! (in ZK) assembles the per-round mask oracle. Consumed by +//! [`super::derive`], which drives the per-round loop. + +use crate::{ + algebra::{ + embedding::{Embedding, Identity}, + fields::FieldWithSize, + }, + protocols::{ + irs_commit::Config as IrsConfig, + mask_proximity::Config as MaskProximityConfig, + params::{ + bounds::usize_to_f64, + branch::{Branch, OodMode, RoundBuildMode, RoundBuildPayload, SolveMode}, + code_switch as code_switch_params, + error::{DeriveError, Pow}, + irs_commit as irs_params, + layout::{round_context, target_context, RoundShape}, + mask_proximity as mask_proximity_params, + protocol_config::{MaskOracleConfig, RoundConfig, RoundMode}, + spec::{ + DecodingRegime, LogInvRate, MaskCodeMessageLen, OodSampleBudget, RoundContext, + SecuritySpec, ZkSpec, + }, + sumcheck as sumcheck_params, + }, + }, +}; + +const T_OOD_MAX_ITER: usize = 32; + +pub(super) fn build_round_config( + spec: &SecuritySpec, + shape: &RoundShape, + mode: RoundBuildMode<'_>, +) -> Result, DeriveError> { + let ctx = round_context(shape); + let ood_mode = mode.map(|p| p.c_zk_log_inv_rate); + let (source, t_ood) = solve_round_source::(spec, shape, ood_mode)?; + + let (target_budget, solve_mode, round_mode) = match mode { + Branch::Standard => ( + OodSampleBudget::ZERO, + SolveMode::Standard, + RoundMode::Standard, + ), + Branch::ZeroKnowledge(RoundBuildPayload { + zk_spec, + c_zk_log_inv_rate, + }) => { + let num_masks = + sumcheck_params::masks_required(&ctx) + code_switch_params::masks_required(); + let mask_oracle = build_mask_oracle::( + zk_spec, + &source, + t_ood, + num_masks, + c_zk_log_inv_rate, + shape.round_index, + )?; + let solve_mode = SolveMode::ZeroKnowledge(mask_oracle.info()); + ( + OodSampleBudget::new(t_ood), + solve_mode, + RoundMode::ZeroKnowledge { + t_ood: OodSampleBudget::new(t_ood), + mask_oracle: Box::new(mask_oracle), + }, + ) + } + }; + + let target: IrsConfig> = + irs_params::solve(spec, &target_context(shape, &source), target_budget)?; + let sumcheck = sumcheck_params::solve( + spec, + &ctx, + &source, + solve_mode, + Pow::RoundSumcheck { + index: shape.round_index, + }, + )?; + let code_switch = + code_switch_params::solve(spec, source, target, t_ood, solve_mode, shape.round_index)?; + + Ok(RoundConfig::new( + shape.round_index, + sumcheck, + code_switch, + round_mode, + )) +} + +fn solve_round_source( + spec: &SecuritySpec, + shape: &RoundShape, + ood_mode: OodMode, +) -> Result<(IrsConfig, usize), DeriveError> { + let src_ctx = round_context(shape); + let target_log_inv_rate = f64::from( + shape + .source_log_inv_rate + .saturating_add(shape.source_folding_factor.saturating_sub(1)), + ); + let target_log_degree = f64::from( + shape + .source_vector_size + .trailing_zeros() + .saturating_sub(shape.source_folding_factor), + ); + let target_list_size = spec + .decoding_regime + .list_size_estimate(target_log_degree, target_log_inv_rate); + solve_t_ood::( + spec, + &src_ctx, + target_list_size, + ood_mode, + shape.round_index, + ) +} + +/// ZK-only: assemble the per-round mask oracle (C_zk codeword + mask-proximity +/// check). +fn build_mask_oracle( + zk_spec: ZkSpec<'_>, + source: &IrsConfig, + t_ood: usize, + num_masks: usize, + c_zk_log_inv_rate: LogInvRate, + round_index: usize, +) -> Result, DeriveError> { + let spec = zk_spec.as_inner(); + let l_zk = compute_l_zk(source, t_ood); + let c_zk: IrsConfig> = irs_params::solve_mask_code( + zk_spec, + l_zk, + source.mask_length(), + c_zk_log_inv_rate, + MaskProximityConfig::::num_vectors_for(num_masks), + )?; + let c_zk_list_size_estimate = spec.decoding_regime.list_size_estimate( + (l_zk.get() as f64).log2(), + f64::from(c_zk_log_inv_rate.get()), + ); + debug_assert!( + (c_zk.list_size() - c_zk_list_size_estimate).abs() + < 1e-9 * c_zk_list_size_estimate.max(1.0), + "c_zk.list_size() {} drifted from planner estimate {}", + c_zk.list_size(), + c_zk_list_size_estimate, + ); + let mask_proximity = mask_proximity_params::solve(spec, c_zk.clone(), num_masks, round_index)?; + Ok(MaskOracleConfig::new(c_zk, l_zk, mask_proximity)) +} + +/// `ℓ_zk = next_pow2(r + t_ood)` (Theorem 9.6 + Lemma 9.3). +pub(super) const fn compute_l_zk( + source: &IrsConfig, + t_ood: usize, +) -> MaskCodeMessageLen { + MaskCodeMessageLen::new( + source + .mask_length() + .saturating_add(t_ood) + .next_power_of_two(), + ) +} + +/// Per-round `(source, t_ood)`. +/// +/// Under `Unique`, `t_ood = 1` is pinned (the `log(L·(L−1)/2)` term degenerates +/// when `L = 1`, and Construction 9.7 requires `out_domain_samples ≥ 1`). +/// Otherwise linear search over `t_ood = 1..=T_OOD_MAX_ITER` for the smallest +/// value where [`ood_security_bits_at`] meets `protocol_security_target_bits`. +pub(super) fn solve_t_ood( + spec: &SecuritySpec, + src_ctx: &RoundContext, + target_list_size: f64, + ood_mode: OodMode, + round_index: usize, +) -> Result<(IrsConfig, usize), DeriveError> { + if matches!(spec.decoding_regime, DecodingRegime::Unique) { + let source = irs_params::solve(spec, src_ctx, OodSampleBudget::new(1))?; + return Ok((source, 1)); + } + + let security_target = f64::from(spec.protocol_security_target_bits()); + let field_bits = M::Target::field_size_bits(); + + for t_ood in 1..=T_OOD_MAX_ITER { + let source: IrsConfig = irs_params::solve(spec, src_ctx, OodSampleBudget::new(t_ood))?; + let bits = + ood_security_bits_at(spec, &source, t_ood, target_list_size, ood_mode, field_bits); + if bits >= security_target { + return Ok((source, t_ood)); + } + } + Err(DeriveError::FixedPointDidNotConverge { round_index }) +} + +/// OOD security bits at candidate `t_ood`, per STIR Lemma 4.5: +/// `bits = t · (|F| − log d) − log(L · (L − 1) / 2) ≈ t·(|F| − log d) − 2·log L + 1`. +fn ood_security_bits_at( + spec: &SecuritySpec, + source: &IrsConfig, + t_ood: usize, + target_list_size: f64, + ood_mode: OodMode, + field_bits: f64, +) -> f64 { + let (log_degree, log_combined_list) = match ood_mode { + Branch::Standard => ( + usize_to_f64(source.message_length()).log2(), + target_list_size.log2(), + ), + Branch::ZeroKnowledge(c_zk_log_inv_rate) => { + let l_zk = source + .mask_length() + .saturating_add(t_ood) + .next_power_of_two(); + let c_zk_list = spec.decoding_regime.list_size_estimate( + usize_to_f64(l_zk).log2(), + f64::from(c_zk_log_inv_rate.get()), + ); + ( + usize_to_f64(source.message_length().saturating_add(l_zk)).log2(), + (target_list_size * c_zk_list).log2(), + ) + } + }; + let ood = usize_to_f64(t_ood); + ood * (field_bits - log_degree) - 2.0 * log_combined_list + 1.0 +} diff --git a/src/protocols/params/code_switch.rs b/src/protocols/params/code_switch.rs new file mode 100644 index 00000000..cc116bd4 --- /dev/null +++ b/src/protocols/params/code_switch.rs @@ -0,0 +1,436 @@ +//! Code-switching IOR (Construction 9.7, p.55) builder + Lemma 9.9 OOD bound. + +use std::num::NonZeroUsize; + +use crate::{ + algebra::{ + embedding::{Embedding, Identity}, + fields::FieldWithSize, + }, + bits::Bits, + protocols::{ + code_switch::{self, Config as CodeSwitchConfig}, + irs_commit::Config as IrsConfig, + params::{ + bounds::usize_to_f64, + branch::SolveMode, + error::{grind_to_at, DeriveError, Pow}, + protocol_config::MaskOracleInfo, + solved::Solved, + spec::SecuritySpec, + }, + }, +}; + +/// Per-round code-switch builder. +pub fn solve( + spec: &SecuritySpec, + source: IrsConfig, + target: IrsConfig>, + t_ood: usize, + mode: SolveMode, + round_index: usize, +) -> Result>, DeriveError> { + let (mask_oracle, output_mode) = match mode { + SolveMode::Standard => (None, code_switch::CodeSwitchMode::Standard), + SolveMode::ZeroKnowledge(mask_oracle) => { + let l_zk = mask_oracle.l_zk.get(); + assert!( + l_zk >= source.mask_length().saturating_add(t_ood), + "ℓ_zk ({l_zk}) < r + t_ood ({} + {}) — violates Theorem 9.6 witness sizing", + source.mask_length(), + t_ood, + ); + ( + Some(mask_oracle), + code_switch::CodeSwitchMode::ZeroKnowledge { + message_mask_length: NonZeroUsize::new(l_zk).expect("ℓ_zk > 0"), + }, + ) + } + }; + + let analytic = analytic_error_bits(&source, &target, t_ood, mask_oracle); + let pow = grind_to_at(spec, analytic, Pow::RoundCodeSwitch { index: round_index })?; + + Ok(Solved::new( + CodeSwitchConfig::new(source, target, t_ood, output_mode, pow), + analytic, + )) +} + +/// Per-round code-switch soundness in bits: `min` over Lemma 9.9's three RBR +/// error slots (OOD, in-domain, combination). +pub fn analytic_error_bits( + source: &IrsConfig, + target: &IrsConfig>, + t_ood: usize, + mask_oracle: Option, +) -> Bits { + assert!(t_ood > 0, "code-switch requires t_ood ≥ 1"); + + let field_bits = M::Target::field_size_bits(); + let combined_list = + target.list_size() * mask_oracle.map_or(1.0, |info| info.c_zk_list_size.get()); + // OOD polynomial is over witness `[f; r_C; s]` of length `ℓ + ℓ_zk` (ZK) or + // `ℓ` (Standard). + let degree = mask_oracle.map_or_else( + || source.message_length(), + |info| source.message_length().saturating_add(info.l_zk.get()), + ); + let t_ood_f = usize_to_f64(t_ood); + + // OOD term — Lemma 9.9, term 1. + let log_degree_minus_1 = usize_to_f64(degree.saturating_sub(1)).log2(); + let log_l_choose_2 = (combined_list * (combined_list - 1.0) / 2.0).log2(); + let ood_term = t_ood_f * (field_bits - log_degree_minus_1) - log_l_choose_2; + + // In-domain term — Lemma 9.9, term 2. + let in_domain_term = source.rbr_queries(); + + // Combination term — Lemma 9.9, term 3 (γ-RLC, bounds doc §5.1). + let log_count = usize_to_f64( + t_ood.saturating_add(source.in_domain_samples() * source.interleaving_depth()), + ) + .log2(); + let combination_term = field_bits - log_count - combined_list.log2(); + + Bits::new(ood_term.min(in_domain_term).min(combination_term).max(0.0)) +} + +/// Number of `(r ‖ s)` mask polynomials code-switch contributes to C_zk per +/// round. +pub const fn masks_required() -> usize { + 1 +} + +#[cfg(test)] +mod tests { + use proptest::prelude::*; + + use super::*; + use crate::protocols::params::{ + branch::OodMode, + build_round::{compute_l_zk, solve_t_ood}, + irs_commit as irs_params, + spec::{ + ListSize, LogInvRate, MaskCodeMessageLen, Mode, OodSampleBudget, PowBudget, + RoundContext, SecuritySpec, ZkSpec, + }, + test_utils::{ + arb_standard_spec as utils_standard_spec, arb_zk_spec as utils_zk_spec, assert_close, + assert_pow_closes_gap, build_round_io, deterministic_spec, TestEmbedding, + TestExtensionField, TestField, TestNonIdentityEmbedding, TEST_TARGET_RANGE, + }, + }; + + type M = TestEmbedding; + + fn arb_zk_spec() -> impl Strategy { + utils_zk_spec(TEST_TARGET_RANGE) + } + + fn arb_standard_spec() -> impl Strategy { + utils_standard_spec(TEST_TARGET_RANGE) + } + + const NUM_VARS_HEADROOM: u32 = 4; + + fn arb_dims() -> impl Strategy { + (1u32..=3, 1u32..=2).prop_flat_map(|(log_inv_rate, folding_factor)| { + let min_num_vars = 2 * folding_factor; + ( + Just(log_inv_rate), + Just(folding_factor), + min_num_vars..=(min_num_vars + NUM_VARS_HEADROOM), + ) + }) + } + + const FORMULA_LOG_INV_RATE: u32 = 1; + const FORMULA_FOLDING_FACTOR: u32 = 2; + const FORMULA_NUM_VARS: u32 = 6; + + #[test] + fn analytic_error_standard_formula() { + let spec: SecuritySpec = deterministic_spec(Mode::Standard); + let (source, target, t_ood) = build_round_io::( + &spec, + FORMULA_LOG_INV_RATE, + FORMULA_FOLDING_FACTOR, + FORMULA_NUM_VARS, + None, + ); + let got = f64::from(analytic_error_bits(&source, &target, t_ood, None)); + + let field_bits = ::field_size_bits(); + let target_list = target.list_size(); + let degree = source.message_length(); + let log_deg_m1 = ((degree - 1) as f64).log2(); + let l_choose_2 = target_list * (target_list - 1.0) / 2.0; + let ood = (t_ood as f64) * (field_bits - log_deg_m1) - l_choose_2.log2(); + let in_domain = source.rbr_queries(); + let count = t_ood + source.in_domain_samples() * source.interleaving_depth(); + let comb = field_bits - (count as f64).log2() - target_list.log2(); + let expected = ood.min(in_domain).min(comb).max(0.0); + + assert_close(got, expected); + } + + #[test] + fn analytic_error_zk_formula() { + const C_ZK_LIST_SIZE: f64 = 4.0; + const L_ZK_USIZE: usize = 8; + + let spec: SecuritySpec = deterministic_spec(Mode::ZeroKnowledge); + let mask_oracle = MaskOracleInfo { + c_zk_list_size: ListSize::new(C_ZK_LIST_SIZE), + l_zk: MaskCodeMessageLen::new(L_ZK_USIZE), + }; + let (source, target, t_ood) = build_round_io::( + &spec, + FORMULA_LOG_INV_RATE, + FORMULA_FOLDING_FACTOR, + FORMULA_NUM_VARS, + Some(FORMULA_LOG_INV_RATE), + ); + let got = f64::from(analytic_error_bits( + &source, + &target, + t_ood, + Some(mask_oracle), + )); + + let field_bits = ::field_size_bits(); + let target_list = target.list_size(); + let combined_list = target_list * C_ZK_LIST_SIZE; + let degree = source.message_length() + L_ZK_USIZE; + let log_deg_m1 = ((degree - 1) as f64).log2(); + let l_choose_2 = combined_list * (combined_list - 1.0) / 2.0; + let ood = (t_ood as f64) * (field_bits - log_deg_m1) - l_choose_2.log2(); + let in_domain = source.rbr_queries(); + let count = t_ood + source.in_domain_samples() * source.interleaving_depth(); + let comb = field_bits - (count as f64).log2() - target_list.log2() - C_ZK_LIST_SIZE.log2(); + let expected = ood.min(in_domain).min(comb).max(0.0); + + assert_close(got, expected); + } + + #[test] + fn analytic_error_uses_in_domain_when_limiting() { + const LIMITING_TARGET_BITS: u32 = 16; + const LIMITING_LOG_INV_RATE: u32 = 1; + const LIMITING_FOLDING_FACTOR: u32 = 1; + const LIMITING_NUM_VARS: u32 = 4; + + let spec = SecuritySpec::new(LIMITING_TARGET_BITS).with_pow_budget(PowBudget::Forbidden); + let (source, target, t_ood) = build_round_io::( + &spec, + LIMITING_LOG_INV_RATE, + LIMITING_FOLDING_FACTOR, + LIMITING_NUM_VARS, + None, + ); + + let field_bits = ::field_size_bits(); + let target_list = target.list_size(); + let degree = source.message_length(); + let log_deg_m1 = ((degree - 1) as f64).log2(); + let l_choose_2 = target_list * (target_list - 1.0) / 2.0; + let ood = (t_ood as f64) * (field_bits - log_deg_m1) - l_choose_2.log2(); + let in_domain = source.rbr_queries(); + let count = t_ood + source.in_domain_samples() * source.interleaving_depth(); + let comb = field_bits - (count as f64).log2() - target_list.log2(); + assert!( + in_domain < ood && in_domain < comb, + "fixture wants in_domain to bind: in_domain {in_domain}, ood {ood}, comb {comb}", + ); + + let got = f64::from(analytic_error_bits(&source, &target, t_ood, None)); + assert_close(got, in_domain.max(0.0)); + } + + proptest! { + #[test] + fn solve_standard_assembles( + spec in arb_standard_spec(), + (log_inv_rate, folding_factor, num_vars) in arb_dims(), + ) { + let (source, target, t_ood) = + build_round_io::(&spec, log_inv_rate, folding_factor, num_vars, None); + let config = solve(&spec, source, target, t_ood, SolveMode::Standard, 0).unwrap(); + prop_assert!(matches!(config.mode(), code_switch::CodeSwitchMode::Standard)); + prop_assert!(config.out_domain_samples() >= 1); + } + + #[test] + fn solve_zk_mask_equals_padded_r_plus_t_ood( + spec in arb_zk_spec(), + (log_inv_rate, folding_factor, num_vars) in arb_dims(), + ) { + let (source, target, t_ood) = build_round_io::( + &spec, log_inv_rate, folding_factor, num_vars, Some(log_inv_rate), + ); + let r = source.mask_length(); + let l_zk = compute_l_zk(&source, t_ood); + let zk_spec = ZkSpec::try_new(&spec).expect("arb_zk_spec"); + let c_zk = irs_params::solve_mask_code::( + zk_spec, + l_zk, + r, + LogInvRate::new(log_inv_rate), + 2, + ) + .expect("C_zk fixture must solve"); + let mask_oracle = MaskOracleInfo { + c_zk_list_size: ListSize::new(c_zk.list_size()), + l_zk, + }; + let config = solve( + &spec, + source, + target, + t_ood, + SolveMode::ZeroKnowledge(mask_oracle), + 0, + ) + .unwrap(); + prop_assert_eq!(config.message_mask_length(), (r + t_ood).next_power_of_two()); + } + + #[test] + fn pow_closes_gap_to_target_standard( + spec in arb_standard_spec(), + (log_inv_rate, folding_factor, num_vars) in arb_dims(), + ) { + let (source, target, t_ood) = + build_round_io::(&spec, log_inv_rate, folding_factor, num_vars, None); + let error = analytic_error_bits(&source, &target, t_ood, None); + let config = solve(&spec, source, target, t_ood, SolveMode::Standard, 0).unwrap(); + assert_pow_closes_gap(&spec, error, &config.pow()); + } + } + + fn non_identity_smoke_ctxs() -> (RoundContext, RoundContext) { + const SOURCE_VECTOR_SIZE: usize = 64; + const SOURCE_LOG_INV_RATE: u32 = 1; + const FOLDING_FACTOR: u32 = 2; + + let source_ctx = RoundContext { + vector_size: SOURCE_VECTOR_SIZE, + log_inv_rate: SOURCE_LOG_INV_RATE, + folding_factor: FOLDING_FACTOR, + }; + let target_ctx = RoundContext { + vector_size: source_ctx.vector_size / (1 << source_ctx.folding_factor), + log_inv_rate: source_ctx.log_inv_rate + source_ctx.folding_factor - 1, + folding_factor: source_ctx.folding_factor, + }; + (source_ctx, target_ctx) + } + + #[test] + #[should_panic(expected = "violates Theorem 9.6")] + fn solve_zk_rejects_l_zk_below_r_plus_t_ood() { + const TOO_SMALL_L_ZK: usize = 1; + + let spec: SecuritySpec = deterministic_spec(Mode::ZeroKnowledge); + let (source, target, t_ood) = build_round_io::( + &spec, + FORMULA_LOG_INV_RATE, + FORMULA_FOLDING_FACTOR, + FORMULA_NUM_VARS, + Some(FORMULA_LOG_INV_RATE), + ); + assert!(source.mask_length() + t_ood > TOO_SMALL_L_ZK); + + let mask_oracle = MaskOracleInfo { + c_zk_list_size: ListSize::new(SMOKE_C_ZK_LIST_SIZE), + l_zk: MaskCodeMessageLen::new(TOO_SMALL_L_ZK), + }; + let _ = solve( + &spec, + source, + target, + t_ood, + SolveMode::ZeroKnowledge(mask_oracle), + 0, + ); + } + + #[test] + fn solve_works_with_basefield_embedding_standard() { + let spec: SecuritySpec = deterministic_spec(Mode::Standard); + let (source_ctx, target_ctx) = non_identity_smoke_ctxs(); + let target_log_degree = + f64::from((source_ctx.vector_size / (1 << source_ctx.folding_factor)).trailing_zeros()); + let target_list_size = spec + .decoding_regime + .list_size_estimate(target_log_degree, f64::from(target_ctx.log_inv_rate)); + let (source, t_ood) = solve_t_ood::( + &spec, + &source_ctx, + target_list_size, + OodMode::Standard, + 0, + ) + .unwrap(); + let target = irs_params::solve::>( + &spec, + &target_ctx, + OodSampleBudget::ZERO, + ) + .expect("target IRS fixture must solve"); + + let config = solve(&spec, source, target, t_ood, SolveMode::Standard, 0).unwrap(); + assert!(matches!( + config.mode(), + code_switch::CodeSwitchMode::Standard + )); + } + + const SMOKE_C_ZK_LIST_SIZE: f64 = 4.0; + + #[test] + fn solve_works_with_basefield_embedding_zk() { + let spec: SecuritySpec = deterministic_spec(Mode::ZeroKnowledge); + let (source_ctx, target_ctx) = non_identity_smoke_ctxs(); + let target_log_degree = + f64::from((source_ctx.vector_size / (1 << source_ctx.folding_factor)).trailing_zeros()); + let target_list_size = spec + .decoding_regime + .list_size_estimate(target_log_degree, f64::from(target_ctx.log_inv_rate)); + let (source, t_ood) = solve_t_ood::( + &spec, + &source_ctx, + target_list_size, + OodMode::ZeroKnowledge(LogInvRate::new(source_ctx.log_inv_rate)), + 0, + ) + .unwrap(); + let target = irs_params::solve::>( + &spec, + &target_ctx, + OodSampleBudget::new(t_ood), + ) + .expect("target IRS fixture must solve"); + + let mask_oracle = MaskOracleInfo { + c_zk_list_size: ListSize::new(SMOKE_C_ZK_LIST_SIZE), + l_zk: MaskCodeMessageLen::new((source.mask_length() + t_ood).next_power_of_two()), + }; + let config = solve( + &spec, + source, + target, + t_ood, + SolveMode::ZeroKnowledge(mask_oracle), + 0, + ) + .unwrap(); + assert!(matches!( + config.mode(), + code_switch::CodeSwitchMode::ZeroKnowledge { .. } + )); + } +} diff --git a/src/protocols/params/derive.rs b/src/protocols/params/derive.rs new file mode 100644 index 00000000..ca5cdd30 --- /dev/null +++ b/src/protocols/params/derive.rs @@ -0,0 +1,653 @@ +//! Derives a [`ProtocolConfig`] from a spec + tuning. + +use crate::{ + algebra::embedding::Embedding, + protocols::params::{ + basecase as basecase_params, + branch::{Branch, RoundBuildMode, RoundBuildPayload}, + build_round::build_round_config, + error::DeriveError, + layout::{round_layout, RoundLayout}, + protocol_config::{ProtocolConfig, RoundConfig}, + spec::{LogInvRate, SecuritySpec, TuningSpec}, + }, +}; + +impl ProtocolConfig { + /// Fails with [`DeriveError`] when the spec/tuning combination is + /// infeasible. + pub fn derive(spec: SecuritySpec, tuning: TuningSpec) -> Result { + let RoundLayout { + shapes, + basecase_vector_size, + basecase_log_inv_rate, + } = round_layout(&tuning)?; + + let mode: RoundBuildMode<'_> = spec.as_zk().map_or(Branch::Standard, |zk_spec| { + Branch::ZeroKnowledge(RoundBuildPayload { + zk_spec, + c_zk_log_inv_rate: LogInvRate::new(tuning.starting_log_inv_rate), + }) + }); + + let rounds: Vec> = shapes + .iter() + .map(|shape| build_round_config::(&spec, shape, mode)) + .collect::>()?; + + let basecase = basecase_params::solve(&spec, basecase_vector_size, basecase_log_inv_rate)?; + + let plan = Self::new(spec, tuning, rounds, basecase); + plan.validate()?; + Ok(plan) + } +} + +#[cfg(test)] +mod tests { + use proptest::prelude::*; + + use crate::{ + algebra::{ + embedding::Embedding, + fields::{Field64, FieldWithSize}, + }, + protocols::{ + basecase::BasecaseMode, + params::{ + error::{ChainSource, ChainTarget, DeriveError, Pow}, + protocol_config::{ProtocolConfig, RoundMode}, + spec::{DecodingRegime, FoldingFactor, Mode, PowBudget, SecuritySpec, TuningSpec}, + test_utils::{assert_close, assert_pow_closes_gap, TestEmbedding}, + }, + }, + }; + + fn arb_tuning() -> impl Strategy { + let folding = prop_oneof![ + (1usize..=3).prop_map(FoldingFactor::Constant), + (1usize..=3, 1usize..=3).prop_map(|(initial, rest)| { + FoldingFactor::ConstantFromSecondRound { initial, rest } + }), + ]; + (4u32..=8, 1u32..=3, folding).prop_map(|(log_size, log_inv_rate, folding_factor)| { + TuningSpec { + vector_size: 1usize << log_size, + starting_log_inv_rate: log_inv_rate, + folding_factor, + } + }) + } + + const FIXTURE_FOLDING_FACTOR: usize = 2; + const FIXTURE_LOG_INV_RATE: u32 = 1; + + const LOG_VECTOR_SIZE_NO_ROUNDS: u32 = 3; + const LOG_VECTOR_SIZE_MULTI_ROUND: u32 = 8; + + fn tuning_with(vector_size: usize) -> TuningSpec { + TuningSpec { + vector_size, + starting_log_inv_rate: FIXTURE_LOG_INV_RATE, + folding_factor: FoldingFactor::Constant(FIXTURE_FOLDING_FACTOR), + } + } + + const PLAN_FIXTURE_TARGET_BITS: u32 = 40; + + fn test_spec(mode: Mode) -> SecuritySpec { + SecuritySpec::new(PLAN_FIXTURE_TARGET_BITS) + .with_mode(mode) + .with_pow_budget(PowBudget::per_slot(LOOSE_POW_BUDGET_BITS)) + } + + #[test] + fn derive_standard_with_no_rounds_uses_basecase_only() { + let spec = test_spec(Mode::Standard); + let vector_size = 1usize << LOG_VECTOR_SIZE_NO_ROUNDS; + let plan = ProtocolConfig::::derive(spec, tuning_with(vector_size)).unwrap(); + assert!(plan.rounds().is_empty()); + assert_eq!(plan.basecase().commit().vector_size(), vector_size); + } + + #[test] + fn derive_zk_with_no_rounds_uses_zk_basecase_only() { + let spec = test_spec(Mode::ZeroKnowledge); + let plan = ProtocolConfig::::derive( + spec, + tuning_with(1 << LOG_VECTOR_SIZE_NO_ROUNDS), + ) + .unwrap(); + assert!(plan.rounds().is_empty()); + assert!(matches!( + plan.basecase().mode(), + BasecaseMode::ZeroKnowledge + )); + } + + #[test] + fn t_ood_nonzero_in_johnson_zk() { + let spec = SecuritySpec { + decoding_regime: DecodingRegime::Johnson, + ..test_spec(Mode::ZeroKnowledge) + }; + let plan = ProtocolConfig::::derive( + spec, + tuning_with(1 << LOG_VECTOR_SIZE_MULTI_ROUND), + ) + .unwrap(); + for r in plan.rounds() { + let RoundMode::ZeroKnowledge { t_ood, .. } = r.mode() else { + panic!("expected ZK round") + }; + assert!(t_ood.get() >= 1); + } + } + + #[test] + fn t_ood_pinned_to_one_in_unique_zk() { + let spec = SecuritySpec { + decoding_regime: DecodingRegime::Unique, + ..test_spec(Mode::ZeroKnowledge) + }; + let plan = ProtocolConfig::::derive( + spec, + tuning_with(1 << LOG_VECTOR_SIZE_MULTI_ROUND), + ) + .unwrap(); + for r in plan.rounds() { + let RoundMode::ZeroKnowledge { t_ood, .. } = r.mode() else { + panic!("expected ZK round") + }; + assert_eq!(t_ood.get(), 1); + } + } + + #[test] + fn c_zk_keeps_code_switch_mask_under_unique() { + let spec = SecuritySpec { + decoding_regime: DecodingRegime::Unique, + ..test_spec(Mode::ZeroKnowledge) + }; + let plan = ProtocolConfig::::derive( + spec, + tuning_with(1 << LOG_VECTOR_SIZE_MULTI_ROUND), + ) + .unwrap(); + for r in plan.rounds() { + let mask_oracle = r.mask_oracle().expect("ZK round has a mask oracle"); + let k = r + .code_switch() + .source() + .interleaving_depth() + .trailing_zeros() as usize; + let expected_num_masks = k + 1; + assert_eq!(mask_oracle.c_zk().num_vectors(), 2 * expected_num_masks); + } + } + + #[test] + fn analytic_bits_finite_and_positive_standard() { + let spec = test_spec(Mode::Standard); + let plan = ProtocolConfig::::derive( + spec, + tuning_with(1 << LOG_VECTOR_SIZE_MULTI_ROUND), + ) + .unwrap(); + let bits: f64 = plan.analytic_bits().into(); + assert!(bits.is_finite() && bits > 0.0, "bits = {bits}"); + let min_round = plan + .rounds() + .iter() + .map(|r| f64::from(r.analytic_bits())) + .fold(f64::INFINITY, f64::min); + let expected = min_round.min(f64::from(plan.basecase().analytic_bits())); + assert_close(bits, expected); + } + + #[test] + fn analytic_bits_includes_mask_oracle_in_zk() { + let spec = test_spec(Mode::ZeroKnowledge); + let plan = ProtocolConfig::::derive( + spec, + tuning_with(1 << LOG_VECTOR_SIZE_MULTI_ROUND), + ) + .unwrap(); + let plan_bits: f64 = plan.analytic_bits().into(); + let mo_floor = plan + .rounds() + .iter() + .filter_map(|r| r.mask_oracle().map(|mo| f64::from(mo.analytic_bits()))) + .fold(f64::INFINITY, f64::min); + assert!( + mo_floor.is_finite(), + "ZK plan must contribute mask-oracle bits" + ); + let min_round = plan + .rounds() + .iter() + .map(|r| f64::from(r.analytic_bits())) + .fold(f64::INFINITY, f64::min); + let expected = mo_floor + .min(min_round) + .min(f64::from(plan.basecase().analytic_bits())); + assert_close(plan_bits, expected); + } + + #[test] + fn derive_plans_basecase() { + let spec = test_spec(Mode::ZeroKnowledge); + let plan = ProtocolConfig::::derive( + spec, + tuning_with(1 << LOG_VECTOR_SIZE_MULTI_ROUND), + ) + .unwrap(); + assert!(matches!( + plan.basecase().mode(), + BasecaseMode::ZeroKnowledge + )); + assert_eq!(plan.basecase().commit().interleaving_depth(), 1); + assert_eq!(plan.basecase().sumcheck().final_size(), 1); + } + + const LOOSE_POW_BUDGET_BITS: u32 = 60; + const OVER_BUDGET_INJECTED_BITS: f64 = 50.0; + + /// Bounds doc §5.3 + §5.7: HVZK privacy error in bits matches the closed + /// form `−log Σ_r (t_ood_r² + t_ood_r) / (2|F|)` over ZK rounds. + #[test] + fn privacy_error_bits_matches_bound_3_sum() { + let spec = test_spec(Mode::ZeroKnowledge); + let plan = ProtocolConfig::::derive( + spec, + tuning_with(1 << LOG_VECTOR_SIZE_MULTI_ROUND), + ) + .unwrap(); + let field_bits = ::field_size_bits(); + let mut expected_total = 0.0_f64; + for r in plan.rounds() { + let RoundMode::ZeroKnowledge { t_ood, .. } = r.mode() else { + panic!("expected ZK round"); + }; + let t = t_ood.get() as f64; + expected_total += 2_f64.powf(f64::midpoint(t * t, t).log2() - field_bits); + } + let expected_bits = -expected_total.log2(); + let got = f64::from(plan.privacy_error_bits()); + assert_close(got, expected_bits); + } + + #[test] + fn privacy_error_bits_standard_returns_target_sentinel() { + let spec = test_spec(Mode::Standard); + let plan = ProtocolConfig::::derive( + spec, + tuning_with(1 << LOG_VECTOR_SIZE_MULTI_ROUND), + ) + .unwrap(); + assert_close( + f64::from(plan.privacy_error_bits()), + f64::from(PLAN_FIXTURE_TARGET_BITS), + ); + } + + #[test] + fn check_pow_bits_passes_on_derived_plan() { + let plan = ProtocolConfig::::derive( + test_spec(Mode::ZeroKnowledge), + tuning_with(1 << LOG_VECTOR_SIZE_MULTI_ROUND), + ) + .unwrap(); + assert!(plan.check_pow_bits()); + } + + #[test] + fn check_pow_bits_detects_over_budget_slot() { + use crate::{bits::Bits, protocols::proof_of_work::Config as PowConfig}; + const MODERATE_POW_BUDGET_BITS: u32 = 30; + let spec = SecuritySpec { + pow_budget: PowBudget::per_slot(MODERATE_POW_BUDGET_BITS), + ..test_spec(Mode::ZeroKnowledge) + }; + let mut plan = ProtocolConfig::::derive( + spec, + tuning_with(1 << LOG_VECTOR_SIZE_MULTI_ROUND), + ) + .unwrap(); + plan.override_basecase_pow_for_test(PowConfig::from_difficulty(Bits::new( + OVER_BUDGET_INJECTED_BITS, + ))); + assert!(!plan.check_pow_bits()); + } + + #[test] + fn validate_round_chaining_detects_adjacent_round_mismatch() { + let spec = test_spec(Mode::ZeroKnowledge); + let mut plan = ProtocolConfig::::derive( + spec, + tuning_with(1 << LOG_VECTOR_SIZE_MULTI_ROUND), + ) + .unwrap(); + let n = plan.rounds().len(); + assert!(n >= 2, "need ≥ 2 rounds to break a mid-chain link"); + assert!(plan.check_all_invariants(), "fresh plan must validate"); + + let bad_size = plan.rounds()[0].code_switch().target().vector_size() + 1; + plan.corrupt_round_target_vector_size_for_test(0, bad_size); + + let err = plan + .validate_round_chaining() + .expect_err("adjacent-round mismatch must trip the chain check"); + assert!( + matches!( + err, + DeriveError::RoundChainBroken { + from: ChainSource::Round(0), + to: ChainTarget::NextRound(1), + .. + } + ), + "got {err:?}", + ); + assert!(!plan.check_all_invariants()); + } + + #[test] + fn validate_round_chaining_detects_basecase_mismatch() { + let spec = test_spec(Mode::ZeroKnowledge); + let mut plan = ProtocolConfig::::derive( + spec, + tuning_with(1 << LOG_VECTOR_SIZE_MULTI_ROUND), + ) + .unwrap(); + let n = plan.rounds().len(); + assert!(n >= 2, "need ≥ 2 rounds to break the chain by truncation"); + assert!(plan.check_all_invariants(), "fresh plan must validate"); + + plan.truncate_rounds_for_test(n - 1); + let err = plan + .validate_round_chaining() + .expect_err("truncated tail breaks basecase chaining"); + assert!( + matches!( + err, + DeriveError::RoundChainBroken { + to: ChainTarget::Basecase, + .. + } + ), + "got {err:?}", + ); + assert!(!plan.check_all_invariants()); + } + + #[test] + fn validate_security_target_met_passes_on_fresh_plan() { + let spec = test_spec(Mode::ZeroKnowledge); + let plan = ProtocolConfig::::derive( + spec, + tuning_with(1 << LOG_VECTOR_SIZE_MULTI_ROUND), + ) + .unwrap(); + plan.validate_security_target_met() + .expect("fresh plan must satisfy per-slot target check"); + } + + #[test] + fn validate_security_target_met_catches_recorded_analytic_drift() { + use crate::bits::Bits; + let spec = test_spec(Mode::ZeroKnowledge); + let mut plan = ProtocolConfig::::derive( + spec, + tuning_with(1 << LOG_VECTOR_SIZE_MULTI_ROUND), + ) + .unwrap(); + assert!(!plan.rounds().is_empty(), "need a round to corrupt"); + let recorded = plan + .rounds() + .first() + .map(|r| r.sumcheck().analytic()) + .expect("params solver records sumcheck analytic"); + // Bump the recorded value far from the recompute → triggers drift. + plan.corrupt_round_sumcheck_analytic_for_test(0, Bits::new(f64::from(recorded) + 10.0)); + let err = plan + .validate_security_target_met() + .expect_err("recorded vs recompute mismatch must trip drift check"); + assert!( + matches!( + err, + DeriveError::AnalyticDrift { + pow: Pow::RoundSumcheck { index: 0 }, + .. + } + ), + "got {err:?}", + ); + } + + #[test] + fn derive_reports_pow_ungrindable() { + const UNREACHABLE_TARGET_BITS: u32 = 200; + let spec = SecuritySpec { + target_security_bits: UNREACHABLE_TARGET_BITS, + ..test_spec(Mode::Standard) + }; + let err = ProtocolConfig::::derive( + spec, + tuning_with(1 << LOG_VECTOR_SIZE_MULTI_ROUND), + ) + .expect_err("target above grind cap must fail"); + assert!( + matches!(err, DeriveError::PowUngrindable { .. }), + "got {err:?}", + ); + } + + #[test] + fn derive_reports_pow_budget_exceeded() { + const TIGHT_MAX_POW: u32 = 5; + let spec = SecuritySpec { + pow_budget: PowBudget::per_slot(TIGHT_MAX_POW), + ..test_spec(Mode::ZeroKnowledge) + }; + let err = ProtocolConfig::::derive( + spec, + tuning_with(1 << LOG_VECTOR_SIZE_MULTI_ROUND), + ) + .expect_err("tight pow_budget must trip auto-validation"); + assert!( + matches!(err, DeriveError::PowBudgetExceeded { .. }), + "got {err:?}", + ); + } + + #[test] + fn derive_threads_unique_decoding_standard() { + let spec = SecuritySpec { + decoding_regime: DecodingRegime::Unique, + ..test_spec(Mode::Standard) + }; + let plan = ProtocolConfig::::derive( + spec, + tuning_with(1 << LOG_VECTOR_SIZE_NO_ROUNDS), + ) + .unwrap(); + assert!(plan.rounds().is_empty()); + assert!(plan.basecase().commit().unique_decoding()); + } + + #[test] + fn derive_threads_unique_decoding_zk() { + let spec = SecuritySpec { + decoding_regime: DecodingRegime::Unique, + ..test_spec(Mode::ZeroKnowledge) + }; + let plan = ProtocolConfig::::derive( + spec, + tuning_with(1 << LOG_VECTOR_SIZE_NO_ROUNDS), + ) + .unwrap(); + assert!(plan.rounds().is_empty()); + assert!(plan.basecase().commit().unique_decoding()); + } + + #[test] + fn derive_multi_round_unique_decoding_succeeds() { + let spec = SecuritySpec { + decoding_regime: DecodingRegime::Unique, + ..test_spec(Mode::Standard) + }; + let plan = ProtocolConfig::::derive( + spec, + tuning_with(1 << LOG_VECTOR_SIZE_MULTI_ROUND), + ) + .unwrap(); + assert!(!plan.rounds().is_empty(), "expected multi-round plan"); + for r in plan.rounds() { + let cs = r.code_switch(); + assert!(cs.source().unique_decoding()); + assert!(cs.target().unique_decoding()); + assert!(cs.out_domain_samples() >= 1); + } + assert!(plan.basecase().commit().unique_decoding()); + } + + #[test] + fn derive_multi_round_unique_decoding_zk_succeeds() { + let spec = SecuritySpec { + decoding_regime: DecodingRegime::Unique, + ..test_spec(Mode::ZeroKnowledge) + }; + let plan = ProtocolConfig::::derive( + spec, + tuning_with(1 << LOG_VECTOR_SIZE_MULTI_ROUND), + ) + .unwrap(); + assert!(!plan.rounds().is_empty(), "expected multi-round plan"); + for r in plan.rounds() { + let mo = r.mask_oracle().expect("ZK round must own a mask oracle"); + assert!(mo.c_zk().unique_decoding()); + assert!(r.code_switch().source().unique_decoding()); + assert!(r.code_switch().out_domain_samples() >= 1); + } + assert!(plan.basecase().commit().unique_decoding()); + } + + #[test] + fn derive_multi_round_capacity_decoding_succeeds() { + let spec = SecuritySpec { + decoding_regime: DecodingRegime::Capacity, + ..test_spec(Mode::Standard) + }; + let plan = ProtocolConfig::::derive( + spec, + tuning_with(1 << LOG_VECTOR_SIZE_MULTI_ROUND), + ) + .unwrap(); + assert!(!plan.rounds().is_empty(), "expected multi-round plan"); + for r in plan.rounds() { + assert!(r.code_switch().out_domain_samples() >= 1); + } + } + + #[test] + fn derive_multi_round_capacity_decoding_zk_succeeds() { + let spec = SecuritySpec { + decoding_regime: DecodingRegime::Capacity, + ..test_spec(Mode::ZeroKnowledge) + }; + let plan = ProtocolConfig::::derive( + spec, + tuning_with(1 << LOG_VECTOR_SIZE_MULTI_ROUND), + ) + .unwrap(); + assert!(!plan.rounds().is_empty(), "expected multi-round plan"); + for r in plan.rounds() { + r.mask_oracle().expect("ZK round must own a mask oracle"); + assert!(r.code_switch().out_domain_samples() >= 1); + } + } + + /// Every slot from [`ProtocolConfig::pow_slots`] must close the gap to the + /// target with its configured grind, judged from a fresh recompute. + fn assert_plan_meets_target_per_slot( + spec: &SecuritySpec, + plan: &ProtocolConfig, + ) { + for slot in plan.pow_slots() { + assert_pow_closes_gap(spec, slot.recompute, &slot.pow); + } + } + + proptest! { + #[test] + fn derived_plan_meets_target_per_slot_standard(tuning in arb_tuning()) { + let spec = test_spec(Mode::Standard); + let plan = ProtocolConfig::::derive(spec.clone(), tuning).unwrap(); + assert_plan_meets_target_per_slot(&spec, &plan); + } + + #[test] + fn derived_plan_meets_target_per_slot_zk(tuning in arb_tuning()) { + let log_threshold = + tuning.folding_factor.at_round(0) + tuning.folding_factor.at_round(1); + prop_assume!(tuning.vector_size.trailing_zeros() as usize >= log_threshold); + let spec = test_spec(Mode::ZeroKnowledge); + let plan = ProtocolConfig::::derive(spec.clone(), tuning).unwrap(); + assert_plan_meets_target_per_slot(&spec, &plan); + } + + #[test] + fn derive_standard_succeeds_over_tunings(tuning in arb_tuning()) { + let spec = test_spec(Mode::Standard); + let plan = ProtocolConfig::::derive(spec, tuning).unwrap(); + for r in plan.rounds() { + prop_assert!(matches!(r.mode(), RoundMode::Standard)); + prop_assert!(r.mask_oracle().is_none()); + } + prop_assert!(matches!( + plan.basecase().mode(), + BasecaseMode::Standard + )); + prop_assert_eq!(plan.basecase().commit().interleaving_depth(), 1); + } + + #[test] + fn derive_zk_succeeds_over_tunings(tuning in arb_tuning()) { + let log_threshold = + tuning.folding_factor.at_round(0) + tuning.folding_factor.at_round(1); + prop_assume!(tuning.vector_size.trailing_zeros() as usize >= log_threshold); + + let spec = test_spec(Mode::ZeroKnowledge); + let plan = ProtocolConfig::::derive(spec, tuning).unwrap(); + for r in plan.rounds() { + let mask_oracle = r + .mask_oracle() + .expect("ZK round must have a mask oracle"); + let RoundMode::ZeroKnowledge { t_ood, .. } = r.mode() else { + panic!("expected ZK round"); + }; + let cs = r.code_switch(); + let k = cs.source().interleaving_depth().trailing_zeros() as usize; + let num_masks = k + 1; + prop_assert_eq!(mask_oracle.c_zk().num_vectors(), 2 * num_masks); + prop_assert_eq!(mask_oracle.mask_proximity().num_masks(), num_masks); + let source_mask = cs.source().mask_length(); + prop_assert!(mask_oracle.l_zk().get() >= source_mask + t_ood.get()); + } + prop_assert!(matches!( + plan.basecase().mode(), + BasecaseMode::ZeroKnowledge + )); + } + + #[test] + fn analytic_bits_finite_and_non_negative_standard(tuning in arb_tuning()) { + let spec = test_spec(Mode::Standard); + let plan = ProtocolConfig::::derive(spec, tuning).unwrap(); + let analytic = f64::from(plan.analytic_bits()); + prop_assert!(analytic.is_finite()); + prop_assert!(analytic >= 0.0); + } + } +} diff --git a/src/protocols/params/error.rs b/src/protocols/params/error.rs new file mode 100644 index 00000000..121a2e85 --- /dev/null +++ b/src/protocols/params/error.rs @@ -0,0 +1,196 @@ +//! Errors raised by [`super::derive::ProtocolConfig::derive`] and the +//! sub-protocol solvers. + +use std::fmt::{self, Display, Formatter}; + +use thiserror::Error; + +use crate::{ + bits::Bits, + protocols::{ + irs_commit::CodewordLengthError, + params::{layout::LayoutError, spec::SecuritySpec}, + proof_of_work::{Config as PowConfig, PowError}, + }, +}; + +/// Identifies a single PoW grind in the derived protocol. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Pow { + /// Basecase γ-RLC grind (Lemma 7.4) — ZK mode only. + BasecaseGammaCombination, + /// Basecase sumcheck grind. + BasecaseSumcheck, + /// Per-round sumcheck grind at `index`. + RoundSumcheck { index: usize }, + /// Per-round code-switch grind at `index`. + RoundCodeSwitch { index: usize }, + /// Per-round mask-proximity grind at `index` — ZK mode only. + RoundMaskProximity { index: usize }, +} + +impl Display for Pow { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + match self { + Self::BasecaseGammaCombination => f.write_str("basecase γ-combination"), + Self::BasecaseSumcheck => f.write_str("basecase sumcheck"), + Self::RoundSumcheck { index } => write!(f, "round {index} sumcheck"), + Self::RoundCodeSwitch { index } => write!(f, "round {index} code-switch"), + Self::RoundMaskProximity { index } => write!(f, "round {index} mask-proximity"), + } + } +} + +/// Origin side of a [`DeriveError::RoundChainBroken`]: either a numbered round +/// or the pre-round `tuning` shape (for plans with no rounds at all). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ChainSource { + Tuning, + Round(usize), +} + +impl Display for ChainSource { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + match self { + Self::Tuning => f.write_str("tuning"), + Self::Round(i) => write!(f, "round {i}"), + } + } +} + +/// Destination side of a [`DeriveError::RoundChainBroken`]: the next round in +/// sequence, or the basecase. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ChainTarget { + NextRound(usize), + Basecase, +} + +impl Display for ChainTarget { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + match self { + Self::NextRound(i) => write!(f, "round {i}"), + Self::Basecase => f.write_str("basecase"), + } + } +} + +/// Failure modes for [`super::derive::ProtocolConfig::derive`] and the +/// sub-protocol solvers it calls. +#[derive(Debug, Error, Clone, PartialEq, Eq)] +pub enum DeriveError { + /// The `t_ood` fixed-point in [`super::build_round::solve_t_ood`] ran out of + /// iterations — usually the field is too small for the security target. + #[error( + "t_ood fixed-point did not converge for round {round_index}; \ + lower target_security_bits or use a larger field" + )] + FixedPointDidNotConverge { round_index: usize }, + + /// A PoW grind cannot close the analytic-to-target gap — the spec is too + /// tight for any single grind to reach `target_security_bits`. + #[error( + "{pow} cannot be ground: {source}; lower target_security_bits or \ + switch to a less conservative decoding regime (Johnson/Capacity)" + )] + PowUngrindable { + pow: Pow, + #[source] + source: PowError, + }, + + /// A PoW grind fits the grind cap but exceeds the per-slot budget set by + /// [`super::spec::SecuritySpec::pow_budget`]. + #[error( + "{pow} requires {required} bits, exceeds spec.pow_budget = {max}; \ + raise pow_budget or lower target_security_bits" + )] + PowBudgetExceeded { pow: Pow, required: Bits, max: Bits }, + + /// Computed codeword length exceeds the NTT engine's supported order. + #[error( + "codeword length {length} exceeds the NTT engine's supported order; \ + reduce vector_size or starting_log_inv_rate" + )] + CodewordExceedsNtt { length: usize }, + + /// The tuning spec cannot produce a valid round layout. + #[error(transparent)] + Layout(#[from] LayoutError), + + /// Cross-round (or round → basecase) shape chain broken: the next + /// component's source `vector_size` does not match the previous + /// component's target `vector_size`. Surfaced by + /// [`super::protocol_config::ProtocolConfig::validate_round_chaining`]. + #[error("chain broken: {from} → {to} expected vector_size {expected}, found {found}")] + RoundChainBroken { + from: ChainSource, + to: ChainTarget, + expected: usize, + found: usize, + }, + + /// A PoW slot's `analytic + pow.difficulty()` is below `target_security_bits`. + /// `grind_to_at` guarantees this at construction; this error fires only if + /// the analytic-error formulas applied at validate-time disagree with what + /// the per-protocol `solve` functions consumed (e.g., a planner regression + /// drifts away from the actual configured IRS rate). Catches the case where + /// the rate-schedule plumbing under-reports a per-slot rate. + #[error("{pow} soundness gap: analytic {analytic} + pow {pow_bits} < target {target}")] + SecurityTargetNotMet { + pow: Pow, + analytic: Bits, + pow_bits: Bits, + target: Bits, + }, + + /// The analytic floor recorded at solve time disagrees with a fresh + /// recompute from the same config's state. Indicates that the inputs to + /// the `analytic_error_bits` formula drifted between solve and validate + /// (e.g., an IRS field was overwritten after construction). + #[error("{pow} analytic drift: recorded {recorded} vs recompute {recompute}")] + AnalyticDrift { + pow: Pow, + recorded: Bits, + recompute: Bits, + }, +} + +impl From for DeriveError { + fn from(e: CodewordLengthError) -> Self { + Self::CodewordExceedsNtt { length: e.length } + } +} + +/// Lift `Result` into `Result` by attaching a +/// [`Pow`] label. +pub trait PowResultExt { + fn at(self, pow: Pow) -> Result; +} + +impl PowResultExt for Result { + fn at(self, pow: Pow) -> Result { + self.map_err(|source| DeriveError::PowUngrindable { pow, source }) + } +} + +/// Grind `analytic → spec.target_security_bits`, then check the result against +/// `spec.pow_budget`. +pub fn grind_to_at( + spec: &SecuritySpec, + analytic: Bits, + pow_kind: Pow, +) -> Result { + let target = Bits::new(f64::from(spec.target_security_bits)); + let pow = PowConfig::grind_to(target, analytic, spec.hash_id).at(pow_kind)?; + let required = pow.difficulty(); + let max = Bits::new(f64::from(spec.pow_budget.bits())); + if required > max { + return Err(DeriveError::PowBudgetExceeded { + pow: pow_kind, + required, + max, + }); + } + Ok(pow) +} diff --git a/src/protocols/params/irs_commit.rs b/src/protocols/params/irs_commit.rs new file mode 100644 index 00000000..0d076cd6 --- /dev/null +++ b/src/protocols/params/irs_commit.rs @@ -0,0 +1,203 @@ +//! IRS-commit parameter selection. +//! +//! ZK mask sizing follows Construction 9.7 / Theorem 9.6: +//! `mask_length = in_domain + t_ood` (Proposition 3.19). + +use crate::{ + algebra::embedding::Embedding, + protocols::{ + irs_commit::{num_in_domain_queries, Config as IrsConfig, IrsMode, IrsParams}, + params::{ + bounds::rate, + error::DeriveError, + spec::{ + LogInvRate, MaskCodeMessageLen, Mode, OodSampleBudget, RoundContext, SecuritySpec, + ZkSpec, + }, + }, + }, +}; + +pub fn solve( + spec: &SecuritySpec, + ctx: &RoundContext, + out_domain_samples: OodSampleBudget, +) -> Result, DeriveError> { + let security_target = f64::from(spec.protocol_security_target_bits()); + let rate = rate(f64::from(ctx.log_inv_rate)); + let interleaving_depth = 1_usize << ctx.folding_factor; + + let mode = match spec.mode { + Mode::Standard => IrsMode::Standard, + Mode::ZeroKnowledge => { + let mask_length = num_in_domain_queries(spec.decoding_regime, security_target, rate) + .saturating_add(out_domain_samples.get()); + IrsMode::ZeroKnowledge { mask_length } + } + }; + + Ok(IrsConfig::try_new(IrsParams { + security_target, + decoding_regime: spec.decoding_regime, + hash_id: spec.hash_id, + num_vectors: 1, + vector_size: ctx.vector_size, + interleaving_depth, + rate, + mode, + })?) +} + +/// Shared C_zk IRS config for mask polynomials. +/// +/// - `l_zk`: message length, must be a power of 2. +/// - `source_mask_length`: `r` from Theorem 9.6. +/// - `num_vectors`: `2 * num_masks` (Construction 7.2: originals + fresh). +pub fn solve_mask_code( + spec: ZkSpec<'_>, + l_zk: MaskCodeMessageLen, + source_mask_length: usize, + log_inv_rate: LogInvRate, + num_vectors: usize, +) -> Result, DeriveError> { + let l_zk = l_zk.get(); + assert!( + l_zk >= source_mask_length, + "Theorem 9.6: ℓ_zk ({l_zk}) ≥ source mask length ({source_mask_length})", + ); + assert!(l_zk.is_power_of_two(), "ℓ_zk ({l_zk}) must be a power of 2"); + assert!( + num_vectors.is_multiple_of(2), + "num_vectors ({num_vectors}) must be even (mask-proximity original/fresh pairs)", + ); + + let security_target = f64::from(spec.protocol_security_target_bits()); + let rate = rate(f64::from(log_inv_rate.get())); + + Ok(IrsConfig::try_new(IrsParams { + security_target, + decoding_regime: spec.decoding_regime, + hash_id: spec.hash_id, + num_vectors, + vector_size: l_zk, + interleaving_depth: 1, + rate, + mode: IrsMode::Standard, + })?) +} + +#[cfg(test)] +mod tests { + use proptest::prelude::*; + + use super::*; + use crate::protocols::params::test_utils::{ + arb_round_ctx, arb_spec, arb_zk_spec, deterministic_spec, TestEmbedding, + TestNonIdentityEmbedding, + }; + + type M = TestEmbedding; + + #[test] + fn zk_spec_rejects_standard_mode() { + let spec: SecuritySpec = deterministic_spec(Mode::Standard); + assert!(ZkSpec::try_new(&spec).is_none()); + } + + #[test] + #[should_panic(expected = "must be a power of 2")] + fn solve_mask_code_rejects_non_pow2_l_zk() { + let spec: SecuritySpec = deterministic_spec(Mode::ZeroKnowledge); + let zk_spec = ZkSpec::try_new(&spec).unwrap(); + let _ = solve_mask_code::( + zk_spec, + MaskCodeMessageLen::new(3), + 0, + LogInvRate::new(1), + 2, + ); + } + + #[test] + #[should_panic(expected = "Theorem 9.6")] + fn solve_mask_code_rejects_l_zk_below_source_mask_length() { + let spec: SecuritySpec = deterministic_spec(Mode::ZeroKnowledge); + let zk_spec = ZkSpec::try_new(&spec).unwrap(); + let _ = solve_mask_code::( + zk_spec, + MaskCodeMessageLen::new(2), + 4, + LogInvRate::new(1), + 2, + ); + } + + #[test] + #[should_panic(expected = "must be even")] + fn solve_mask_code_rejects_odd_num_vectors() { + let spec: SecuritySpec = deterministic_spec(Mode::ZeroKnowledge); + let zk_spec = ZkSpec::try_new(&spec).unwrap(); + let _ = solve_mask_code::( + zk_spec, + MaskCodeMessageLen::new(2), + 0, + LogInvRate::new(1), + 3, + ); + } + + const IRS_TARGET_RANGE: std::ops::RangeInclusive = 80..=128; + + fn arb_zk_spec_default() -> impl Strategy { + arb_zk_spec(IRS_TARGET_RANGE) + } + + fn arb_standard_spec() -> impl Strategy { + arb_spec(Mode::Standard, IRS_TARGET_RANGE) + } + + proptest! { + #[test] + fn zk_mask_covers_in_domain_plus_ood( + spec in arb_zk_spec_default(), + ctx in arb_round_ctx(), + out_domain in 0usize..16, + ) { + let config = solve::(&spec, &ctx, OodSampleBudget::new(out_domain)).unwrap(); + prop_assert!( + config.mask_length() >= config.in_domain_samples() + out_domain, + "mask {} < in_domain {} + out_domain {}", + config.mask_length(), config.in_domain_samples(), out_domain, + ); + } + + #[test] + fn standard_has_no_mask( + spec in arb_standard_spec(), + ctx in arb_round_ctx(), + out_domain in 0usize..8, + ) { + let config = solve::(&spec, &ctx, OodSampleBudget::new(out_domain)).unwrap(); + prop_assert_eq!(config.mask_length(), 0); + } + } + + const SMOKE_VECTOR_SIZE: usize = 64; + const SMOKE_LOG_INV_RATE: u32 = 1; + const SMOKE_FOLDING_FACTOR: u32 = 2; + const SMOKE_OOD_BUDGET: usize = 2; + + #[test] + fn solve_works_with_basefield_embedding_zk() { + let spec = deterministic_spec(Mode::ZeroKnowledge); + let ctx = RoundContext { + vector_size: SMOKE_VECTOR_SIZE, + log_inv_rate: SMOKE_LOG_INV_RATE, + folding_factor: SMOKE_FOLDING_FACTOR, + }; + let config: IrsConfig = + solve(&spec, &ctx, OodSampleBudget::new(SMOKE_OOD_BUDGET)) + .expect("IRS fixture must solve"); + assert!(config.mask_length() > 0); + } +} diff --git a/src/protocols/params/layout.rs b/src/protocols/params/layout.rs new file mode 100644 index 00000000..8588b4b7 --- /dev/null +++ b/src/protocols/params/layout.rs @@ -0,0 +1,229 @@ +//! Round-skeleton layout: pure-data walk over the witness shape. +//! +//! Produces per-round shapes (vector size, log_inv_rate, folding factors) +//! independent of [`SecuritySpec`] and IRS solving. Consumed by +//! [`super::build_round`] to instantiate per-round configs and by +//! [`super::derive`] to drive the round/basecase split. + +use thiserror::Error; + +use crate::{ + algebra::embedding::Embedding, + protocols::{ + irs_commit::Config as IrsConfig, + params::spec::{RoundContext, TuningSpec}, + }, +}; + +/// Reasons a [`TuningSpec`] cannot produce a valid round layout. +/// +/// Nested into [`super::error::DeriveError`] via `#[from]`, so +/// [`round_layout`] failures propagate through +/// [`super::derive::ProtocolConfig::derive`] with `?`. +#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)] +pub enum LayoutError { + /// `tuning.vector_size` must be a power of 2. + #[error("tuning.vector_size ({vector_size}) must be a power of 2; pad the vector")] + VectorSizeNotPowerOfTwo { vector_size: usize }, + + /// `tuning.folding_factor` must yield at least 1 at every round. + #[error("tuning.folding_factor min ({min}) must be ≥ 1")] + FoldingFactorBelowOne { min: usize }, +} + +#[derive(Debug, Clone, Copy)] +pub(super) struct RoundShape { + pub(super) round_index: usize, + pub(super) source_vector_size: usize, + pub(super) source_log_inv_rate: u32, + pub(super) source_folding_factor: u32, + pub(super) target_folding_factor: u32, +} + +#[derive(Debug)] +pub(super) struct RoundLayout { + pub(super) shapes: Vec, + pub(super) basecase_vector_size: usize, + pub(super) basecase_log_inv_rate: u32, +} + +pub(super) fn round_layout(tuning: &TuningSpec) -> Result { + if !tuning.vector_size.is_power_of_two() { + return Err(LayoutError::VectorSizeNotPowerOfTwo { + vector_size: tuning.vector_size, + }); + } + let min_folding = tuning.folding_factor.min(); + if min_folding < 1 { + return Err(LayoutError::FoldingFactorBelowOne { min: min_folding }); + } + + let mut num_vars = tuning.vector_size.trailing_zeros() as usize; + let mut log_inv_rate = tuning.starting_log_inv_rate; + let mut shapes = Vec::new(); + + loop { + let round = shapes.len(); + let source_folding = tuning.folding_factor.at_round(round); + let target_folding = tuning.folding_factor.at_round(round.saturating_add(1)); + if num_vars < source_folding.saturating_add(target_folding) { + break; + } + shapes.push(RoundShape { + round_index: round, + source_vector_size: 1usize << num_vars, + source_log_inv_rate: log_inv_rate, + source_folding_factor: source_folding as u32, + target_folding_factor: target_folding as u32, + }); + num_vars = num_vars.saturating_sub(source_folding); + log_inv_rate = log_inv_rate.saturating_add((source_folding as u32).saturating_sub(1)); + } + + Ok(RoundLayout { + shapes, + basecase_vector_size: 1usize << num_vars, + basecase_log_inv_rate: log_inv_rate, + }) +} + +pub(super) const fn round_context(shape: &RoundShape) -> RoundContext { + RoundContext { + vector_size: shape.source_vector_size, + log_inv_rate: shape.source_log_inv_rate, + folding_factor: shape.source_folding_factor, + } +} + +pub(super) fn target_context( + shape: &RoundShape, + source: &IrsConfig, +) -> RoundContext { + RoundContext { + vector_size: source.message_length(), + log_inv_rate: shape + .source_log_inv_rate + .saturating_add(shape.source_folding_factor.saturating_sub(1)), + folding_factor: shape.target_folding_factor, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::protocols::params::spec::FoldingFactor; + + const FIXTURE_FOLDING_FACTOR: usize = 2; + const FIXTURE_LOG_INV_RATE: u32 = 1; + + const LOG_VECTOR_SIZE_NO_ROUNDS: u32 = 3; + const LOG_VECTOR_SIZE_MULTI_ROUND: u32 = 8; + + const VARIED_INITIAL_FOLDING: usize = 3; + const VARIED_STEADY_FOLDING: usize = 2; + + const RATE_STEPPING_STARTING_LOG_INV_RATE: u32 = 2; + const MIN_ROUNDS_FOR_CHAINING_TEST: usize = 2; + + fn tuning_with(vector_size: usize) -> TuningSpec { + TuningSpec { + vector_size, + starting_log_inv_rate: FIXTURE_LOG_INV_RATE, + folding_factor: FoldingFactor::Constant(FIXTURE_FOLDING_FACTOR), + } + } + + #[test] + fn round_layout_rate_steps_up_by_folding_minus_one() { + let tuning = TuningSpec { + vector_size: 1 << LOG_VECTOR_SIZE_MULTI_ROUND, + starting_log_inv_rate: RATE_STEPPING_STARTING_LOG_INV_RATE, + folding_factor: FoldingFactor::ConstantFromSecondRound { + initial: VARIED_INITIAL_FOLDING, + rest: VARIED_STEADY_FOLDING, + }, + }; + let layout = round_layout(&tuning).unwrap(); + + let mut expected_log_inv_rate = RATE_STEPPING_STARTING_LOG_INV_RATE; + for shape in &layout.shapes { + assert_eq!(shape.source_log_inv_rate, expected_log_inv_rate); + expected_log_inv_rate += shape.source_folding_factor.saturating_sub(1); + } + assert_eq!(layout.basecase_log_inv_rate, expected_log_inv_rate); + } + + #[test] + fn round_layout_chains_target_to_next_source_folding() { + let tuning = TuningSpec { + vector_size: 1 << LOG_VECTOR_SIZE_MULTI_ROUND, + starting_log_inv_rate: FIXTURE_LOG_INV_RATE, + folding_factor: FoldingFactor::ConstantFromSecondRound { + initial: VARIED_INITIAL_FOLDING, + rest: VARIED_STEADY_FOLDING, + }, + }; + let layout = round_layout(&tuning).unwrap(); + assert!( + layout.shapes.len() >= MIN_ROUNDS_FOR_CHAINING_TEST, + "need ≥ {MIN_ROUNDS_FOR_CHAINING_TEST} rounds to test chaining", + ); + for window in layout.shapes.windows(2) { + assert_eq!( + window[0].target_folding_factor, + window[1].source_folding_factor + ); + } + } + + #[test] + fn round_layout_basecase_size_consumes_remaining_num_vars() { + let tuning = tuning_with(1 << LOG_VECTOR_SIZE_MULTI_ROUND); + let layout = round_layout(&tuning).unwrap(); + let consumed: u32 = layout.shapes.iter().map(|s| s.source_folding_factor).sum(); + let initial_num_vars = tuning.vector_size.trailing_zeros(); + let remaining = initial_num_vars - consumed; + assert_eq!(layout.basecase_vector_size, 1usize << remaining); + } + + #[test] + fn round_layout_stops_when_no_room_for_source_plus_target() { + let vector_size = 1usize << LOG_VECTOR_SIZE_NO_ROUNDS; + let tuning = tuning_with(vector_size); + let layout = round_layout(&tuning).unwrap(); + assert!(layout.shapes.is_empty()); + assert_eq!(layout.basecase_vector_size, vector_size); + assert_eq!(layout.basecase_log_inv_rate, FIXTURE_LOG_INV_RATE); + } + + #[test] + fn round_layout_rejects_non_pow2_vector_size() { + let tuning = TuningSpec { + vector_size: 12, + starting_log_inv_rate: FIXTURE_LOG_INV_RATE, + folding_factor: FoldingFactor::Constant(FIXTURE_FOLDING_FACTOR), + }; + let err = round_layout(&tuning).expect_err("non-pow2 vector_size must fail"); + assert!( + matches!( + err, + LayoutError::VectorSizeNotPowerOfTwo { vector_size: 12 } + ), + "got {err:?}", + ); + } + + #[test] + fn round_layout_rejects_zero_folding_factor() { + let tuning = TuningSpec { + vector_size: 1 << LOG_VECTOR_SIZE_MULTI_ROUND, + starting_log_inv_rate: FIXTURE_LOG_INV_RATE, + folding_factor: FoldingFactor::Constant(0), + }; + let err = round_layout(&tuning).expect_err("folding_factor = 0 must fail"); + assert!( + matches!(err, LayoutError::FoldingFactorBelowOne { min: 0 }), + "got {err:?}", + ); + } +} diff --git a/src/protocols/params/mask_proximity.rs b/src/protocols/params/mask_proximity.rs new file mode 100644 index 00000000..9f9e367f --- /dev/null +++ b/src/protocols/params/mask_proximity.rs @@ -0,0 +1,167 @@ +//! Mask-proximity (Construction 7.2) builder + Lemma 7.4 γ-combination bound. +//! ZK-only. + +use ark_ff::Field; + +use crate::{ + algebra::{embedding::Identity, fields::FieldWithSize}, + bits::Bits, + protocols::{ + irs_commit::Config as IrsConfig, + mask_proximity::Config as MaskProximityConfig, + params::{ + bounds::usize_to_f64, + error::{grind_to_at, DeriveError, Pow}, + solved::Solved, + spec::SecuritySpec, + }, + }, +}; + +/// `c_zk.num_vectors` must equal `2 * num_masks` (originals + fresh). +pub fn solve( + spec: &SecuritySpec, + c_zk: IrsConfig>, + num_masks: usize, + round_index: usize, +) -> Result>, DeriveError> { + let analytic = analytic_error_bits(&c_zk, num_masks); + let pow = grind_to_at( + spec, + analytic, + Pow::RoundMaskProximity { index: round_index }, + )?; + Ok(Solved::new( + MaskProximityConfig::new(c_zk, num_masks, pow), + analytic, + )) +} + +/// γ-combination soundness (Lemma 7.4): +/// `log|F| − log(num_masks · (deg − 1))`, with `deg = c_zk.masked_message_length()`. +pub fn analytic_error_bits(c_zk: &IrsConfig>, num_masks: usize) -> Bits { + let field_bits = F::field_size_bits(); + let deg = c_zk.masked_message_length(); + if deg <= 1 || num_masks == 0 { + return Bits::new(field_bits.max(0.0)); + } + let log_combined = usize_to_f64(num_masks * deg.saturating_sub(1)).log2(); + Bits::new((field_bits - log_combined).max(0.0)) +} + +impl MaskProximityConfig { + /// Analytic soundness bits (excluding PoW) for the Lemma 7.4 γ-combination. + pub fn analytic_bits(&self) -> Bits { + analytic_error_bits(self.c_zk_commit(), self.num_masks()) + } +} + +#[cfg(test)] +mod tests { + use proptest::prelude::*; + + use super::*; + use crate::{ + algebra::fields::Field64, + hash, + protocols::{ + irs_commit::{IrsMode, IrsParams}, + params::{ + spec::{DecodingRegime, Mode}, + test_utils::{ + arb_zk_spec, assert_close, assert_pow_closes_gap, build_test_c_zk, + deterministic_spec, TEST_TARGET_RANGE, + }, + }, + }, + }; + + const FIXTURE_L_ZK: usize = 8; + const FIXTURE_NUM_MASKS: usize = 3; + const FIXTURE_LOG_INV_RATE: u32 = 1; + + #[test] + fn analytic_error_formula() { + let spec = deterministic_spec(Mode::ZeroKnowledge); + let c_zk = build_test_c_zk(&spec, FIXTURE_L_ZK, FIXTURE_LOG_INV_RATE, FIXTURE_NUM_MASKS); + + let got = f64::from(analytic_error_bits(&c_zk, FIXTURE_NUM_MASKS)); + + let field_bits = ::field_size_bits(); + let deg = c_zk.masked_message_length(); + let log_combined = ((FIXTURE_NUM_MASKS * (deg - 1)) as f64).log2(); + let expected = (field_bits - log_combined).max(0.0); + + assert_close(got, expected); + } + + #[test] + fn analytic_error_saturates_when_no_masks() { + let spec = deterministic_spec(Mode::ZeroKnowledge); + let c_zk = build_test_c_zk(&spec, 2, 1, 1); + let bits = f64::from(analytic_error_bits(&c_zk, 0)); + let field_bits = ::field_size_bits(); + assert_close(bits, field_bits.max(0.0)); + } + + proptest! { + #[test] + fn solve_assembles( + spec in arb_zk_spec(TEST_TARGET_RANGE), + log_inv_rate in 1u32..=3, + num_masks in 1usize..=8, + l_zk_log in 1u32..=5, + ) { + let c_zk = build_test_c_zk(&spec, 1usize << l_zk_log, log_inv_rate, num_masks); + let config = solve(&spec, c_zk, num_masks, 0).unwrap(); + prop_assert_eq!(config.num_masks(), num_masks); + prop_assert_eq!(config.c_zk_commit().num_vectors(), 2 * num_masks); + prop_assert_eq!(config.c_zk_commit().interleaving_depth(), 1); + } + + #[test] + fn pow_closes_gap_to_target( + spec in arb_zk_spec(TEST_TARGET_RANGE), + log_inv_rate in 1u32..=3, + num_masks in 1usize..=8, + l_zk_log in 1u32..=5, + ) { + let c_zk = build_test_c_zk(&spec, 1usize << l_zk_log, log_inv_rate, num_masks); + let analytic = analytic_error_bits(&c_zk, num_masks); + let config = solve(&spec, c_zk, num_masks, 0).unwrap(); + assert_pow_closes_gap(&spec, analytic, &config.pow()); + } + } + + #[test] + #[should_panic(expected = "c_zk.num_vectors must be 2 * num_masks")] + fn solve_rejects_mismatched_num_vectors() { + let spec = deterministic_spec(Mode::ZeroKnowledge); + let c_zk = build_test_c_zk(&spec, 2, 1, 2); + let _ = solve(&spec, c_zk, 3, 0); + } + + #[test] + #[should_panic(expected = "interleaving_depth = 1")] + fn solve_rejects_non_unit_interleaving() { + const SECURITY_TARGET_BITS: f64 = 80.0; + const NUM_VECTORS: usize = 2; + const VECTOR_SIZE: usize = 8; + const NON_UNIT_INTERLEAVING_DEPTH: usize = 2; + const RATE: f64 = 0.5; + const NUM_MASKS: usize = 1; + + let spec = deterministic_spec(Mode::ZeroKnowledge); + let c_zk = IrsConfig::>::new(IrsParams { + security_target: SECURITY_TARGET_BITS, + decoding_regime: DecodingRegime::Johnson, + hash_id: hash::BLAKE3, + num_vectors: NUM_VECTORS, + vector_size: VECTOR_SIZE, + interleaving_depth: NON_UNIT_INTERLEAVING_DEPTH, + rate: RATE, + mode: IrsMode::Standard, + }); + let _ = solve(&spec, c_zk, NUM_MASKS, 0); + } +} diff --git a/src/protocols/params/mod.rs b/src/protocols/params/mod.rs new file mode 100644 index 00000000..df9c026b --- /dev/null +++ b/src/protocols/params/mod.rs @@ -0,0 +1,37 @@ +//! Parameter selection for HVZK-WHIR. +//! +//! Soundness and ZK bound derivations (referred to in submodule comments as +//! "the bounds doc, §N") live at +//! . + +pub(crate) mod basecase; +pub(crate) mod bounds; +pub(crate) mod branch; +pub(crate) mod build_round; +pub(crate) mod code_switch; +pub(crate) mod derive; +pub(crate) mod error; +pub(crate) mod irs_commit; +pub(crate) mod layout; +pub(crate) mod mask_proximity; +pub(crate) mod protocol_config; +pub(crate) mod regime; +pub(crate) mod solved; +pub(crate) mod spec; +pub(crate) mod sumcheck; + +#[cfg(test)] +pub(crate) mod test_utils; + +pub use branch::{Branch, SolveMode}; +pub use error::{ChainSource, ChainTarget, DeriveError, Pow}; +pub use layout::LayoutError; +pub use protocol_config::{ + BasecasePlan, MaskOracleConfig, MaskOracleInfo, ProtocolConfig, RoundConfig, RoundMode, +}; +pub use solved::Solved; +pub use spec::{ + DecodingRegime, FoldingFactor, ListSize, LogInvRate, MaskCodeMessageLen, Mode, OodSampleBudget, + ParseDecodingRegimeError, PowBudget, RoundContext, SecuritySpec, TuningSpec, ZkSpec, + DEFAULT_POW_BUDGET_BITS, +}; diff --git a/src/protocols/params/protocol_config.rs b/src/protocols/params/protocol_config.rs new file mode 100644 index 00000000..42bdb4fb --- /dev/null +++ b/src/protocols/params/protocol_config.rs @@ -0,0 +1,522 @@ +//! Output of [`super::derive`]: the assembled per-round and basecase configs. + +use ark_ff::Field; + +use crate::{ + algebra::{ + embedding::{Embedding, Identity}, + fields::FieldWithSize, + }, + bits::Bits, + protocols::{ + basecase::Config as BasecaseConfig, + code_switch::Config as CodeSwitchConfig, + irs_commit::Config as IrsConfig, + mask_proximity::Config as MaskProximityConfig, + params::{ + basecase as basecase_params, + bounds::usize_to_f64, + code_switch as code_switch_params, + error::{ChainSource, ChainTarget, DeriveError, Pow}, + mask_proximity as mask_proximity_params, + solved::Solved, + spec::{ListSize, MaskCodeMessageLen, OodSampleBudget, SecuritySpec, TuningSpec}, + sumcheck as sumcheck_params, + }, + proof_of_work::Config as PowConfig, + sumcheck::Config as SumcheckConfig, + }, +}; + +#[derive(Clone, Debug)] +pub struct ProtocolConfig { + security: SecuritySpec, + tuning: TuningSpec, + rounds: Vec>, + basecase: BasecasePlan, +} + +impl ProtocolConfig { + pub(crate) const fn new( + security: SecuritySpec, + tuning: TuningSpec, + rounds: Vec>, + basecase: BasecasePlan, + ) -> Self { + Self { + security, + tuning, + rounds, + basecase, + } + } + + pub const fn security(&self) -> &SecuritySpec { + &self.security + } + + pub const fn tuning(&self) -> &TuningSpec { + &self.tuning + } + + pub fn rounds(&self) -> &[RoundConfig] { + &self.rounds + } + + pub const fn basecase(&self) -> &BasecaseConfig { + self.basecase.config() + } + + pub const fn basecase_plan(&self) -> &BasecasePlan { + &self.basecase + } + + /// `true` if every PoW slot's difficulty fits within `security.pow_budget`. + pub fn check_pow_bits(&self) -> bool { + self.validate_pow_budget().is_ok() + } + + /// Returns `true` if every post-construction invariant holds. + pub fn check_all_invariants(&self) -> bool { + self.validate().is_ok() + } + + /// Run every post-construction invariant check. + /// + /// Shape chaining runs first: the analytic recomputes behind the PoW + /// checks assume per-config shape invariants hold. + pub fn validate(&self) -> Result<(), DeriveError> { + self.validate_round_chaining()?; + self.validate_pow_budget()?; + self.validate_security_target_met()?; + Ok(()) + } + + /// Every PoW slot in the plan, in round order followed by the basecase. + /// + /// The single source of truth for slot enumeration: budget validation, + /// target validation, and the per-slot test assertions all iterate this. + /// A sub-protocol added here is automatically covered by every check. + pub(crate) fn pow_slots(&self) -> impl Iterator + '_ { + let round_slots = self.rounds.iter().flat_map(|r| { + let mask_info = r.mask_oracle_info(); + let index = r.round_index; + let cs = r.code_switch.config(); + let sumcheck = PowSlot { + kind: Pow::RoundSumcheck { index }, + pow: r.sumcheck.round_pow(), + recorded: r.sumcheck.analytic(), + recompute: sumcheck_params::analytic_error_bits(cs.source(), mask_info), + }; + let code_switch = PowSlot { + kind: Pow::RoundCodeSwitch { index }, + pow: cs.pow(), + recorded: r.code_switch.analytic(), + recompute: code_switch_params::analytic_error_bits( + cs.source(), + cs.target(), + cs.out_domain_samples(), + mask_info, + ), + }; + let mask_proximity = r.mask_oracle().map(|mo| PowSlot { + kind: Pow::RoundMaskProximity { index }, + pow: mo.mask_proximity.pow(), + recorded: mo.mask_proximity.analytic(), + recompute: mask_proximity_params::analytic_error_bits( + mo.mask_proximity.c_zk_commit(), + mo.mask_proximity.num_masks(), + ), + }); + [Some(sumcheck), Some(code_switch), mask_proximity] + .into_iter() + .flatten() + }); + + let basecase = self.basecase.config(); + let basecase_sumcheck = PowSlot { + kind: Pow::BasecaseSumcheck, + pow: basecase.sumcheck().round_pow(), + recorded: self.basecase.sumcheck_analytic(), + recompute: sumcheck_params::analytic_error_bits(basecase.commit(), None), + }; + let gamma = basecase.is_zk().then(|| PowSlot { + kind: Pow::BasecaseGammaCombination, + pow: basecase.pow(), + recorded: self.basecase.gamma_analytic(), + recompute: basecase_params::analytic_error_bits(basecase.commit()), + }); + + round_slots.chain([Some(basecase_sumcheck), gamma].into_iter().flatten()) + } + + /// For each PoW slot: verify (a) the analytic-bits floor recorded at + /// solve time still matches a fresh recompute from the config's current + /// state, and (b) `recorded + pow.difficulty() ≥ target_security_bits`. + /// + /// `grind_to_at` guarantees (b) at solve time. If (a) holds, (b) holds + /// trivially. If (a) drifts, (b) may fail — most often because a planner + /// regression overwrote an IRS field after the solver consumed it. + /// + /// `EPS` matches the `assert_pow_closes_gap` slack used by the per-slot + /// proptest helper, so validation stays consistent with test-time + /// assertions. + pub fn validate_security_target_met(&self) -> Result<(), DeriveError> { + const EPS: f64 = 1e-3; + let eps = Bits::new(EPS); + let target = Bits::new(f64::from(self.security.target_security_bits)); + for slot in self.pow_slots() { + if slot.recorded.abs_diff(slot.recompute) > eps { + return Err(DeriveError::AnalyticDrift { + pow: slot.kind, + recorded: slot.recorded, + recompute: slot.recompute, + }); + } + let pow_bits = slot.pow.difficulty(); + if slot.recorded + pow_bits + eps < target { + return Err(DeriveError::SecurityTargetNotMet { + pow: slot.kind, + analytic: slot.recorded, + pow_bits, + target, + }); + } + } + Ok(()) + } + + /// PoW slot difficulty ≤ `security.pow_budget` for every slot. + pub fn validate_pow_budget(&self) -> Result<(), DeriveError> { + let max = Bits::new(f64::from(self.security.pow_budget.bits())); + for slot in self.pow_slots() { + let required = slot.pow.difficulty(); + if required > max { + return Err(DeriveError::PowBudgetExceeded { + pow: slot.kind, + required, + max, + }); + } + } + Ok(()) + } + + /// Cross-round shape chaining: + /// - adjacent rounds: `round[i+1].source.vector_size == round[i].target.vector_size` + /// - last round → basecase: `basecase.commit.vector_size == last.target.vector_size` + /// - no rounds: `basecase.commit.vector_size == tuning.vector_size` + pub fn validate_round_chaining(&self) -> Result<(), DeriveError> { + for window in self.rounds.windows(2) { + let prev = &window[0]; + let next = &window[1]; + let expected = prev.code_switch.target().vector_size(); + let found = next.code_switch.source().vector_size(); + if expected != found { + return Err(DeriveError::RoundChainBroken { + from: ChainSource::Round(prev.round_index), + to: ChainTarget::NextRound(next.round_index), + expected, + found, + }); + } + } + + let basecase_vector_size = self.basecase.commit().vector_size(); + let expected = self.rounds.last().map_or(self.tuning.vector_size, |last| { + last.code_switch.target().vector_size() + }); + if expected != basecase_vector_size { + let from = self + .rounds + .last() + .map_or(ChainSource::Tuning, |r| ChainSource::Round(r.round_index)); + return Err(DeriveError::RoundChainBroken { + from, + to: ChainTarget::Basecase, + expected, + found: basecase_vector_size, + }); + } + + Ok(()) + } + + /// HVZK privacy error in bits, summed across ZK rounds: + /// `−log Σ_r (t_ood_r² + t_ood_r) / (2|F|)` (bounds doc, §5.3 + §5.7). + pub fn privacy_error_bits(&self) -> Bits { + let field_bits = ::field_size_bits(); + let mut total_error = 0.0_f64; + for r in &self.rounds { + if let RoundMode::ZeroKnowledge { t_ood, .. } = &r.mode { + let t = usize_to_f64(t_ood.get()); + let log_err = f64::midpoint(t * t, t).log2() - field_bits; + total_error += 2_f64.powf(log_err); + } + } + if total_error == 0.0 { + return Bits::new(f64::from(self.security.target_security_bits)); + } + Bits::new((-total_error.log2()).max(0.0)) + } +} + +impl ProtocolConfig { + /// Analytic soundness bits (excluding PoW). + pub fn analytic_bits(&self) -> Bits { + let mut min_bits = f64::from(self.basecase.config().analytic_bits()); + for round in &self.rounds { + min_bits = min_bits.min(f64::from(round.analytic_bits())); + } + Bits::new(min_bits.max(0.0)) + } +} + +/// One PoW grind slot in a derived plan: its identity, configured grind, the +/// analytic floor recorded at solve time, and a fresh recompute of that floor +/// for drift detection. +pub struct PowSlot { + pub kind: Pow, + pub pow: PowConfig, + pub recorded: Bits, + pub recompute: Bits, +} + +/// Output of [`super::basecase::solve`]: the runtime basecase config plus the +/// analytic floors its two PoW slots were ground against. +/// +/// `gamma_analytic` is always computed (the Lemma 7.4 formula is well-defined +/// in both modes) but only enters validation when the basecase is ZK — the +/// γ slot does not exist otherwise. +#[derive(Clone, Debug)] +pub struct BasecasePlan { + config: BasecaseConfig, + sumcheck_analytic: Bits, + gamma_analytic: Bits, +} + +impl BasecasePlan { + pub(crate) const fn new( + config: BasecaseConfig, + sumcheck_analytic: Bits, + gamma_analytic: Bits, + ) -> Self { + Self { + config, + sumcheck_analytic, + gamma_analytic, + } + } + + pub const fn config(&self) -> &BasecaseConfig { + &self.config + } + + pub const fn sumcheck_analytic(&self) -> Bits { + self.sumcheck_analytic + } + + pub const fn gamma_analytic(&self) -> Bits { + self.gamma_analytic + } +} + +impl std::ops::Deref for BasecasePlan { + type Target = BasecaseConfig; + + fn deref(&self) -> &BasecaseConfig { + &self.config + } +} + +#[cfg(test)] +impl ProtocolConfig { + pub(crate) const fn override_basecase_pow_for_test(&mut self, pow: PowConfig) { + self.basecase.config.set_pow_for_test(pow); + } + + pub(crate) fn truncate_rounds_for_test(&mut self, len: usize) { + self.rounds.truncate(len); + } + + pub(crate) fn corrupt_round_target_vector_size_for_test( + &mut self, + round_idx: usize, + new_size: usize, + ) { + self.rounds[round_idx] + .code_switch + .config_mut_for_test() + .target_mut_for_test() + .set_vector_size_for_test(new_size); + } + + pub(crate) fn corrupt_round_sumcheck_analytic_for_test( + &mut self, + round_idx: usize, + new_value: Bits, + ) { + self.rounds[round_idx] + .sumcheck + .set_analytic_for_test(new_value); + } +} + +#[derive(Clone, Debug)] +pub struct RoundConfig { + round_index: usize, + sumcheck: Solved>, + code_switch: Solved>, + mode: RoundMode, +} + +impl RoundConfig { + pub(crate) const fn new( + round_index: usize, + sumcheck: Solved>, + code_switch: Solved>, + mode: RoundMode, + ) -> Self { + Self { + round_index, + sumcheck, + code_switch, + mode, + } + } + + pub const fn round_index(&self) -> usize { + self.round_index + } + + pub const fn sumcheck(&self) -> &Solved> { + &self.sumcheck + } + + pub const fn code_switch(&self) -> &Solved> { + &self.code_switch + } + + pub const fn mode(&self) -> &RoundMode { + &self.mode + } + + /// Borrow the round's mask oracle if this is a ZK round. + pub fn mask_oracle(&self) -> Option<&MaskOracleConfig> { + match &self.mode { + RoundMode::Standard => None, + RoundMode::ZeroKnowledge { mask_oracle, .. } => Some(mask_oracle), + } + } + + /// Slim mask-oracle view derived from `mask_oracle()`. + pub fn mask_oracle_info(&self) -> Option { + self.mask_oracle().map(MaskOracleConfig::info) + } +} + +/// Standard vs. ZK round. +/// +/// The ZK payload owns the round's mask oracle, so a mode/oracle mismatch is +/// unrepresentable. Boxed to keep the `Standard` variant from paying the +/// oracle's footprint. +#[derive(Clone, Debug)] +pub enum RoundMode { + Standard, + ZeroKnowledge { + /// Lemma 9.9 OOD-sample budget (bounds doc §5.2). + t_ood: OodSampleBudget, + /// Sized for this round's `k + 1` masks. + mask_oracle: Box>, + }, +} + +impl RoundMode { + pub const fn is_zk(&self) -> bool { + matches!(self, Self::ZeroKnowledge { .. }) + } +} + +impl RoundConfig { + /// Round-level analytic floor: the smallest of `sumcheck`, `code_switch`, + /// and (when present) the per-round mask-oracle proximity check. + pub fn analytic_bits(&self) -> Bits { + let source = &self.code_switch.source(); + let target = &self.code_switch.target(); + let mask_info = self.mask_oracle_info(); + + let sumcheck_term = f64::from(sumcheck_params::analytic_error_bits(source, mask_info)); + let code_switch_term = f64::from(code_switch_params::analytic_error_bits( + source, + target, + self.code_switch.out_domain_samples(), + mask_info, + )); + let mask_oracle_term = self + .mask_oracle() + .map_or(f64::INFINITY, |mo| f64::from(mo.analytic_bits())); + + Bits::new( + sumcheck_term + .min(code_switch_term) + .min(mask_oracle_term) + .max(0.0), + ) + } +} + +/// One round's mask oracle: a C_zk codeword + ℓ_zk + mask-proximity check. +#[derive(Clone, Debug)] +pub struct MaskOracleConfig { + c_zk: IrsConfig>, + /// `next_pow2(r + t_ood)` (Theorem 9.6 + Lemma 9.3). + l_zk: MaskCodeMessageLen, + mask_proximity: Solved>, +} + +impl MaskOracleConfig { + pub(crate) const fn new( + c_zk: IrsConfig>, + l_zk: MaskCodeMessageLen, + mask_proximity: Solved>, + ) -> Self { + Self { + c_zk, + l_zk, + mask_proximity, + } + } + + pub const fn c_zk(&self) -> &IrsConfig> { + &self.c_zk + } + + pub const fn l_zk(&self) -> MaskCodeMessageLen { + self.l_zk + } + + pub const fn mask_proximity(&self) -> &Solved> { + &self.mask_proximity + } + + pub fn info(&self) -> MaskOracleInfo { + MaskOracleInfo { + c_zk_list_size: ListSize::new(self.c_zk.list_size()), + l_zk: self.l_zk, + } + } +} + +/// Slim mask-oracle view (C_zk's list size + ℓ_zk). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct MaskOracleInfo { + pub c_zk_list_size: ListSize, + pub l_zk: MaskCodeMessageLen, +} + +impl MaskOracleConfig { + /// Analytic soundness bits (excluding PoW) for this round's mask oracle. + pub fn analytic_bits(&self) -> Bits { + self.mask_proximity.analytic_bits() + } +} diff --git a/src/protocols/params/regime.rs b/src/protocols/params/regime.rs new file mode 100644 index 00000000..3504461d --- /dev/null +++ b/src/protocols/params/regime.rs @@ -0,0 +1,358 @@ +//! Reed–Solomon decoding regime — materialized per-round parameters and the +//! analytic helpers that depend on them. +//! +//! # References +//! +//! - Johnson proximity-gap error follows the BCSS25 improvement +//! (`O(n/η^5)`, m=10 at canonical slack) over BCIKS '20. +//! - Capacity bound follows STIR Conjecture 5.6: `(1 − ρ − η, d/(ρ·η))`-list +//! decodability for RS codes. + +use std::f64::consts::LOG2_10; + +use ordered_float::OrderedFloat; +use serde::{Deserialize, Serialize}; + +use crate::protocols::params::{ + bounds::{rate, usize_to_f64}, + spec::DecodingRegime, +}; + +/// Materialized decoding-regime parameters at a known rate. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum DecodingRegimeParams { + Unique, + Johnson { slack: OrderedFloat }, + Capacity { slack: OrderedFloat }, +} + +impl DecodingRegimeParams { + /// Materialize spec policy at a known rate. Canonical slacks: `√ρ/20` for + /// Johnson, `ρ/20` for Capacity. + // TODO: Optimize picking η. + pub fn from_policy(policy: DecodingRegime, rate: f64) -> Self { + match policy { + DecodingRegime::Unique => Self::Unique, + DecodingRegime::Johnson => Self::johnson_canonical(rate), + DecodingRegime::Capacity => Self::capacity_canonical(rate), + } + } + + /// Johnson regime with the canonical `η = √ρ / 20` slack. + pub fn johnson_canonical(rate: f64) -> Self { + Self::Johnson { + slack: OrderedFloat(rate.sqrt() / 20.0), + } + } + + /// Capacity regime with the canonical `η = ρ / 20` slack. + pub fn capacity_canonical(rate: f64) -> Self { + Self::Capacity { + slack: OrderedFloat(rate / 20.0), + } + } + + pub const fn is_unique(self) -> bool { + matches!(self, Self::Unique) + } + + /// `log₂ |Λ(C, δ)|`. + pub fn list_size_log2(self, log_degree: f64, log_inv_rate: f64) -> f64 { + match self { + Self::Unique => 0.0, + // Johnson: |Λ| = 1 / (2 η √ρ). + Self::Johnson { slack } => -1.0 - slack.into_inner().log2() + 0.5 * log_inv_rate, + // Capacity (STIR Conj 5.6): |Λ| = d / (ρ · η). + Self::Capacity { slack } => log_degree + log_inv_rate - slack.into_inner().log2(), + } + } + + /// `|Λ(C, δ)|`. + pub fn list_size(self, log_degree: f64, log_inv_rate: f64) -> f64 { + 2_f64.powf(self.list_size_log2(log_degree, log_inv_rate)) + } + + /// `log₂(1 − δ)`. + pub fn one_minus_distance_log2(self, log_inv_rate: f64) -> f64 { + let one_minus_delta = match self { + Self::Unique => f64::midpoint(1.0, rate(log_inv_rate)), + Self::Johnson { slack } => rate(log_inv_rate).sqrt() + slack.into_inner(), + Self::Capacity { slack } => rate(log_inv_rate) + slack.into_inner(), + }; + one_minus_delta.log2() + } + + /// Bits of security delivered by `ood_samples` OOD challenges (STIR Lemma 4.5): + /// `ood · (|F| − log d) − 2·log|Λ| + 1`. Returns `0` under `Unique`. + pub fn ood_security_bits( + self, + log_degree: f64, + log_inv_rate: f64, + field_bits: f64, + ood_samples: usize, + ) -> f64 { + if self.is_unique() { + return 0.0; + } + let log_list = self.list_size_log2(log_degree, log_inv_rate); + let ood = usize_to_f64(ood_samples); + ood * (field_bits - log_degree) - 2.0 * log_list + 1.0 + } + + /// `log₂ ε_mca(C, δ)` for the per-step proximity-gaps error. + /// + /// - Unique: `(k − 1) / |F|`, log = `log k − |F|` (with `+ log ρ⁻¹`). + /// - Johnson: BCSS25 Theorem 1.5 at canonical `η = √ρ/20`, `m = 10`: + /// `ε ≈ (2·10.5⁵/3) · n · ρ^{−3/2} / |F|`. + /// - Capacity: STIR Conj 5.6, `ε ≈ d / (η · ρ²) / |F|`. + pub fn eps_mca_log2(self, log_inv_rate: f64, message_length: usize, field_bits: f64) -> f64 { + let log_k = usize_to_f64(message_length).log2(); + let error = match self { + Self::Unique => log_k + log_inv_rate, + Self::Johnson { slack } => { + debug_assert!( + slack.into_inner().log2() >= -(0.5 * log_inv_rate + LOG2_10 + 1.0) - 1e-6 + ); + // BCSS25 with m = 10: log_2(2·10.5⁵/3) + log n + 1.5·log ρ⁻¹. + let bcss25_const = (2.0 * 10.5_f64.powi(5) / 3.0).log2(); + bcss25_const + log_k + 2.5 * log_inv_rate + } + Self::Capacity { slack } => { + debug_assert!(slack.into_inner().log2() >= -(log_inv_rate + LOG2_10 + 1.0) - 1e-6); + // d / (η · ρ²) at canonical η = ρ/20: log d + log 20 + 3·log ρ⁻¹. + log_k + 3.0 * log_inv_rate + LOG2_10 + 1.0 + } + }; + error - field_bits + } +} + +impl DecodingRegime { + /// `|Λ|` at canonical slack, before an IRS config exists. + pub fn list_size_estimate(self, log_degree: f64, log_inv_rate: f64) -> f64 { + DecodingRegimeParams::from_policy(self, rate(log_inv_rate)) + .list_size(log_degree, log_inv_rate) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::protocols::params::test_utils::assert_close; + + const TIGHT_EPS: f64 = 1e-12; + + fn johnson(slack: f64) -> DecodingRegimeParams { + DecodingRegimeParams::Johnson { + slack: OrderedFloat(slack), + } + } + + fn capacity(slack: f64) -> DecodingRegimeParams { + DecodingRegimeParams::Capacity { + slack: OrderedFloat(slack), + } + } + + /// Johnson list size: `|Λ| = 1 / (2η√ρ)`, log₂ form. + #[test] + fn list_size_log2_johnson_formula() { + let got = johnson(0.1).list_size_log2(/* log_degree */ 4.0, 2.0); + let expected = -1.0 - 0.1_f64.log2() + 0.5 * 2.0; + assert_close(got, expected); + } + + /// Capacity list size: `|Λ| = d / (ρ · η)`, log₂ form. + #[test] + fn list_size_log2_capacity_formula() { + let got = capacity(0.125).list_size_log2(4.0, 2.0); + let expected = 4.0 + 2.0 - 0.125_f64.log2(); + assert_close(got, expected); + } + + /// Unique-decoding regime gives `|Λ| = 1`, i.e. log = 0. + #[test] + fn list_size_log2_unique_decoding_is_zero() { + assert_close(DecodingRegimeParams::Unique.list_size_log2(4.0, 2.0), 0.0); + } + + /// `η = √ρ/20` ⇒ `|Λ| = 10/ρ` ⇒ `list_size_estimate(_, b) = 10 · 2^b`. + #[test] + fn johnson_list_size_closed_form() { + for b in [1.0, 2.0, 3.0, 5.0] { + let got = DecodingRegime::Johnson.list_size_estimate(/* log_degree */ 4.0, b); + let expected = 10.0 * 2_f64.powf(b); + assert!( + (got - expected).abs() / expected < TIGHT_EPS, + "log_inv_rate={b}: got {got} vs {expected}", + ); + } + } + + /// `η = ρ/20` ⇒ `|Λ| = 20 · d / ρ²`. + #[test] + fn capacity_list_size_closed_form() { + for (log_d, b) in [(4.0, 1.0), (6.0, 2.0), (8.0, 3.0)] { + let got = DecodingRegime::Capacity.list_size_estimate(log_d, b); + let expected = 20.0 * 2_f64.powf(log_d) * 2_f64.powf(2.0 * b); + assert!( + (got - expected).abs() / expected < TIGHT_EPS, + "log_d={log_d}, log_inv_rate={b}: got {got} vs {expected}", + ); + } + } + + #[test] + fn johnson_list_size_matches_config_list_size() { + use crate::{ + algebra::{embedding::Identity, fields::Field64}, + hash, + protocols::irs_commit::{Config, IrsMode, IrsParams}, + }; + const PLACEHOLDER_SECURITY_TARGET_BITS: f64 = 80.0; + const PLACEHOLDER_NUM_VECTORS: usize = 2; + const PLACEHOLDER_VECTOR_SIZE: usize = 8; + const PLACEHOLDER_INTERLEAVING_DEPTH: usize = 1; + const LOG_INV_RATE: u32 = 2; + + let config: Config> = Config::new(IrsParams { + security_target: PLACEHOLDER_SECURITY_TARGET_BITS, + decoding_regime: DecodingRegime::Johnson, + hash_id: hash::BLAKE3, + num_vectors: PLACEHOLDER_NUM_VECTORS, + vector_size: PLACEHOLDER_VECTOR_SIZE, + interleaving_depth: PLACEHOLDER_INTERLEAVING_DEPTH, + rate: 2_f64.powf(-f64::from(LOG_INV_RATE)), + mode: IrsMode::Standard, + }); + let log_degree = (config.masked_message_length() as f64).log2(); + let got = DecodingRegime::Johnson.list_size_estimate(log_degree, f64::from(LOG_INV_RATE)); + let expected = config.list_size(); + assert!( + (got - expected).abs() / expected < TIGHT_EPS, + "regime helper ({got}) vs Config::list_size ({expected})", + ); + } + + /// `1 − δ` in unique-decoding mode: midpoint of 1 and ρ. + #[test] + fn one_minus_distance_log2_unique() { + let log_inv_rate = 2.0; + let got = DecodingRegimeParams::Unique.one_minus_distance_log2(log_inv_rate); + let rho = 2_f64.powf(-log_inv_rate); + let expected = f64::midpoint(1.0, rho).log2(); + assert_close(got, expected); + } + + /// `1 − δ` in Johnson regime: `√ρ + η`. + #[test] + fn one_minus_distance_log2_johnson() { + let log_inv_rate = 2.0; + let eta = 0.1; + let got = johnson(eta).one_minus_distance_log2(log_inv_rate); + let rho = 2_f64.powf(-log_inv_rate); + let expected = (rho.sqrt() + eta).log2(); + assert_close(got, expected); + } + + /// `1 − δ` in Capacity regime: `ρ + η`. + #[test] + fn one_minus_distance_log2_capacity() { + let log_inv_rate = 2.0; + let eta = 0.05; + let got = capacity(eta).one_minus_distance_log2(log_inv_rate); + let rho = 2_f64.powf(-log_inv_rate); + let expected = (rho + eta).log2(); + assert_close(got, expected); + } + + /// `ood_security_bits = t · (|F| − log d) − 2·log|Λ| + 1`. Returns 0 + /// under Unique. + #[test] + fn ood_security_bits_formula() { + const LOG_DEGREE: f64 = 6.0; + const LOG_INV_RATE: f64 = 2.0; + const FIELD_BITS: f64 = 64.0; + const OOD: usize = 3; + + // Unique → 0 (no soundness from OOD). + let unique = DecodingRegimeParams::Unique.ood_security_bits( + LOG_DEGREE, + LOG_INV_RATE, + FIELD_BITS, + OOD, + ); + assert_close(unique, 0.0); + + let slack = 2_f64.powf(-LOG_INV_RATE).sqrt() / 20.0; + let got = johnson(slack).ood_security_bits(LOG_DEGREE, LOG_INV_RATE, FIELD_BITS, OOD); + let log_list = johnson(slack).list_size_log2(LOG_DEGREE, LOG_INV_RATE); + let expected = (OOD as f64) * (FIELD_BITS - LOG_DEGREE) - 2.0 * log_list + 1.0; + assert_close(got, expected); + } + + const MCA_MESSAGE_LENGTH: usize = 16; + const MCA_LOG_INV_RATE: f64 = 2.0; + const MCA_FIELD_BITS: f64 = 64.0; + + /// MCA error, unique-decoding branch: `log k + log_inv_rate − field_bits`. + #[test] + fn eps_mca_log2_unique_decoding_formula() { + let got = DecodingRegimeParams::Unique.eps_mca_log2( + MCA_LOG_INV_RATE, + MCA_MESSAGE_LENGTH, + MCA_FIELD_BITS, + ); + let expected = (MCA_MESSAGE_LENGTH as f64).log2() + MCA_LOG_INV_RATE - MCA_FIELD_BITS; + assert_close(got, expected); + } + + /// MCA error, Johnson (BCSS25): `log₂(2·10.5⁵/3) + log k + 2.5·log_inv_rate − field_bits`. + #[test] + fn eps_mca_log2_johnson_formula() { + let canonical_slack = 2_f64.powf(-MCA_LOG_INV_RATE).sqrt() / 20.0; + + let got = johnson(canonical_slack).eps_mca_log2( + MCA_LOG_INV_RATE, + MCA_MESSAGE_LENGTH, + MCA_FIELD_BITS, + ); + let bcss25_const = (2.0 * 10.5_f64.powi(5) / 3.0).log2(); + let expected = bcss25_const + (MCA_MESSAGE_LENGTH as f64).log2() + 2.5 * MCA_LOG_INV_RATE + - MCA_FIELD_BITS; + assert_close(got, expected); + } + + /// MCA error, Capacity (STIR Conj 5.6 at canonical η): + /// `log k + 3·log_inv_rate + log₂10 + 1 − field_bits`. + #[test] + fn eps_mca_log2_capacity_formula() { + let canonical_slack = 2_f64.powf(-MCA_LOG_INV_RATE) / 20.0; + + let got = capacity(canonical_slack).eps_mca_log2( + MCA_LOG_INV_RATE, + MCA_MESSAGE_LENGTH, + MCA_FIELD_BITS, + ); + let expected = (MCA_MESSAGE_LENGTH as f64).log2() + 3.0 * MCA_LOG_INV_RATE + LOG2_10 + 1.0 + - MCA_FIELD_BITS; + assert_close(got, expected); + } + + /// `from_policy` dispatches to the canonical constructor for each regime. + #[test] + fn from_policy_matches_canonical() { + assert_eq!( + DecodingRegimeParams::from_policy(DecodingRegime::Unique, 0.25), + DecodingRegimeParams::Unique, + ); + assert_eq!( + DecodingRegimeParams::from_policy(DecodingRegime::Johnson, 0.25), + DecodingRegimeParams::johnson_canonical(0.25), + ); + assert_eq!( + DecodingRegimeParams::from_policy(DecodingRegime::Capacity, 0.25), + DecodingRegimeParams::capacity_canonical(0.25), + ); + } +} diff --git a/src/protocols/params/solved.rs b/src/protocols/params/solved.rs new file mode 100644 index 00000000..f5d6aed6 --- /dev/null +++ b/src/protocols/params/solved.rs @@ -0,0 +1,57 @@ +//! Solver-recorded analytic floors. + +use std::ops::Deref; + +use crate::bits::Bits; + +/// A sub-protocol config paired with the analytic-error floor the params +/// solver consumed when grinding its PoW slot. +/// +/// Being wrapped in `Solved` is a type-level guarantee that the config came +/// out of a params solver; ad-hoc construction paths only produce the bare +/// config. Drift checks in +/// [`super::protocol_config::ProtocolConfig::validate`] compare the recorded +/// floor against a fresh recompute. +#[derive(Clone, Debug)] +pub struct Solved { + config: C, + analytic: Bits, +} + +impl Solved { + pub(crate) const fn new(config: C, analytic: Bits) -> Self { + Self { config, analytic } + } + + /// Analytic-error floor recorded at solve time. + pub const fn analytic(&self) -> Bits { + self.analytic + } + + pub const fn config(&self) -> &C { + &self.config + } + + pub fn into_config(self) -> C { + self.config + } +} + +#[cfg(test)] +impl Solved { + pub(crate) const fn config_mut_for_test(&mut self) -> &mut C { + &mut self.config + } + + pub(crate) const fn set_analytic_for_test(&mut self, analytic: Bits) { + self.analytic = analytic; + } +} + +impl Deref for Solved { + type Target = C; + + fn deref(&self) -> &C { + &self.config + } +} diff --git a/src/protocols/params/spec.rs b/src/protocols/params/spec.rs new file mode 100644 index 00000000..7673acdb --- /dev/null +++ b/src/protocols/params/spec.rs @@ -0,0 +1,415 @@ +use std::{ + fmt::{self, Debug, Display, Formatter}, + hash::{Hash, Hasher}, + marker::PhantomData, + num::NonZeroU32, + ops::Deref, + str::FromStr, +}; + +use ordered_float::OrderedFloat; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use crate::{bits::Bits, engines::EngineId, hash}; + +/// Default per-slot PoW budget when the user does not specify one. +/// +/// 16 bits balances prover grinding cost against the security credit it buys: +/// higher values slow the prover on every slot; lower values shrink the PoW +/// contribution and push the analytic floor (and thus proof size) up. +pub const DEFAULT_POW_BUDGET_BITS: u32 = 16; + +/// Per-slot proof-of-work policy. +/// +/// `bits` plays two roles: +/// - **Planning credit**: subtracted from `target_security_bits` so solvers +/// know the analytic floor they must reach. +/// - **Validation cap**: rejects any per-slot PoW that exceeds `bits`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum PowBudget { + Forbidden, + PerSlot { bits: NonZeroU32 }, +} + +impl PowBudget { + /// `Forbidden` when `bits == 0`, else `PerSlot { bits }`. + pub const fn per_slot(bits: u32) -> Self { + match NonZeroU32::new(bits) { + Some(bits) => Self::PerSlot { bits }, + None => Self::Forbidden, + } + } + + /// Bits of grinding allowed per slot. `0` for [`PowBudget::Forbidden`]. + pub const fn bits(self) -> u32 { + match self { + Self::Forbidden => 0, + Self::PerSlot { bits } => bits.get(), + } + } +} + +/// Phantom-typed newtype. +/// +/// Trait impls are written by hand so that bounds apply to `T` only — tag +/// types stay bare, uninhabited enums. +pub struct Tagged(T, PhantomData); + +impl Tagged { + pub const fn new(v: T) -> Self { + Self(v, PhantomData) + } + + pub const fn get(self) -> T { + self.0 + } +} + +impl Debug for Tagged { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + f.debug_tuple("Tagged").field(&self.0).finish() + } +} + +impl Clone for Tagged { + fn clone(&self) -> Self { + Self(self.0.clone(), PhantomData) + } +} + +impl Copy for Tagged {} + +impl PartialEq for Tagged { + fn eq(&self, other: &Self) -> bool { + self.0 == other.0 + } +} + +impl Eq for Tagged {} + +impl Hash for Tagged { + fn hash(&self, state: &mut H) { + self.0.hash(state); + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SecuritySpec { + pub mode: Mode, + pub decoding_regime: DecodingRegime, + pub target_security_bits: u32, + pub pow_budget: PowBudget, + pub hash_id: EngineId, +} + +impl SecuritySpec { + /// Spec with canonical defaults: [`Mode::Standard`], + /// [`DecodingRegime::Johnson`], BLAKE3, and a + /// [`DEFAULT_POW_BUDGET_BITS`]-bit per-slot PoW budget. + /// + /// Override individual choices with the `with_*` methods or struct-update + /// syntax. + pub const fn new(target_security_bits: u32) -> Self { + Self { + mode: Mode::Standard, + decoding_regime: DecodingRegime::Johnson, + target_security_bits, + pow_budget: PowBudget::per_slot(DEFAULT_POW_BUDGET_BITS), + hash_id: hash::BLAKE3, + } + } + + #[must_use] + pub const fn with_mode(mut self, mode: Mode) -> Self { + self.mode = mode; + self + } + + #[must_use] + pub const fn with_decoding_regime(mut self, decoding_regime: DecodingRegime) -> Self { + self.decoding_regime = decoding_regime; + self + } + + #[must_use] + pub const fn with_pow_budget(mut self, pow_budget: PowBudget) -> Self { + self.pow_budget = pow_budget; + self + } + + #[must_use] + pub const fn with_hash(mut self, hash_id: EngineId) -> Self { + self.hash_id = hash_id; + self + } + + pub fn protocol_security_target_bits(&self) -> Bits { + let pow = self.pow_budget.bits(); + Bits::new(f64::from(self.target_security_bits.saturating_sub(pow))) + } + + /// Borrow this spec as a [`ZkSpec`] proof, or `None` in standard mode. + /// Prefer branching on this over matching `mode` and re-proving with + /// [`ZkSpec::try_new`]. + pub fn as_zk(&self) -> Option> { + ZkSpec::try_new(self) + } +} + +/// Per-round folding strategy. `at_round(i)` returns the factor for round `i`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum FoldingFactor { + /// Same folding factor across all rounds. + Constant(usize), + /// `at_round(0) = initial`; `at_round(i) = rest` for `i ≥ 1`. + ConstantFromSecondRound { initial: usize, rest: usize }, +} + +impl FoldingFactor { + pub const fn at_round(&self, round: usize) -> usize { + match self { + Self::Constant(f) => *f, + Self::ConstantFromSecondRound { initial, rest } => { + if round == 0 { + *initial + } else { + *rest + } + } + } + } + + /// Smallest factor across rounds. + pub const fn min(&self) -> usize { + match self { + Self::Constant(f) => *f, + Self::ConstantFromSecondRound { initial, rest } => { + if *initial < *rest { + *initial + } else { + *rest + } + } + } + } +} + +/// Proof-size / prover-time / soundness-margin tradeoffs. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TuningSpec { + pub vector_size: usize, + pub starting_log_inv_rate: u32, + pub folding_factor: FoldingFactor, +} + +/// Per-round context handed to a sub-protocol builder. +#[derive(Debug, Clone)] +pub struct RoundContext { + pub vector_size: usize, + pub log_inv_rate: u32, + pub folding_factor: u32, +} + +/// Standard vs. zero-knowledge selection. Orthogonal to [`DecodingRegime`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum Mode { + Standard, + ZeroKnowledge, +} + +/// A `SecuritySpec` borrow proven to be in [`Mode::ZeroKnowledge`]. +#[derive(Debug, Clone, Copy)] +pub struct ZkSpec<'a>(&'a SecuritySpec); + +impl<'a> ZkSpec<'a> { + pub fn try_new(spec: &'a SecuritySpec) -> Option { + matches!(spec.mode, Mode::ZeroKnowledge).then_some(Self(spec)) + } + + pub const fn as_inner(self) -> &'a SecuritySpec { + self.0 + } +} + +impl Deref for ZkSpec<'_> { + type Target = SecuritySpec; + fn deref(&self) -> &SecuritySpec { + self.0 + } +} + +/// Reed–Solomon decoding regime selection. +/// +/// - `Unique`: `δ < (1 − ρ)/2`, list size 1, no conjectures. +/// - `Johnson`: `δ < 1 − √ρ − η`, canonical `η = √ρ/20`. Proximity-gap error +/// per the BCSS25 improvement to BCIKS '20. +/// - `Capacity`: `δ < 1 − ρ − η`, canonical `η = ρ/20`. Conjectured list size +/// `d/(ρ·η)` and proximity-gap error per STIR Conjecture 5.6. +/// +/// WHIR's rate stepping (each round bumps `log_inv_rate` by +/// `folding_factor − 1`) pushes ρ → 1, shrinking the unique-decoding +/// radius. At high security targets or deep folding, `Unique` may exceed +/// the grind cap on per-round PoW and [`super::derive::ProtocolConfig::derive`] +/// will return `PowUngrindable` — pick `Johnson` or `Capacity` for those. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum DecodingRegime { + Unique, + Johnson, + Capacity, +} + +impl Display for DecodingRegime { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + match self { + Self::Unique => f.write_str("Unique"), + Self::Johnson => f.write_str("Johnson"), + Self::Capacity => f.write_str("Capacity"), + } + } +} + +/// Error returned by [`DecodingRegime`]'s [`FromStr`] impl. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +#[error("invalid decoding regime: {0}, options are: Unique, Johnson, Capacity")] +pub struct ParseDecodingRegimeError(String); + +impl FromStr for DecodingRegime { + type Err = ParseDecodingRegimeError; + + fn from_str(s: &str) -> Result { + match s { + "Unique" => Ok(Self::Unique), + "Johnson" => Ok(Self::Johnson), + "Capacity" => Ok(Self::Capacity), + _ => Err(ParseDecodingRegimeError(s.to_owned())), + } + } +} + +#[cfg(test)] +mod decoding_regime_tests { + use super::*; + + #[test] + fn from_str_round_trips_display() { + for r in [ + DecodingRegime::Unique, + DecodingRegime::Johnson, + DecodingRegime::Capacity, + ] { + assert_eq!(r.to_string().parse::().unwrap(), r); + } + } + + #[test] + fn from_str_rejects_unknown() { + assert!("johnson".parse::().is_err()); // case-sensitive + assert!("".parse::().is_err()); + assert!("capacity".parse::().is_err()); + } +} + +pub enum OodSampleBudgetTag {} +pub enum MaskCodeMessageLenTag {} +pub enum LogInvRateTag {} + +/// OOD-sample budget (Lemma 9.9 / bounds doc §5.2). +pub type OodSampleBudget = Tagged; + +impl Tagged { + /// Sentinel for "no OOD samples". + pub const ZERO: Self = Self::new(0); +} + +/// C_zk message length (Theorem 9.6: `ℓ_zk ≥ source mask length`). +pub type MaskCodeMessageLen = Tagged; + +/// `rate = 2^-log_inv_rate`. +pub type LogInvRate = Tagged; + +/// Reed–Solomon list-decoding ball size `|Λ(C, δ)|`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct ListSize(OrderedFloat); + +impl ListSize { + pub const fn new(v: f64) -> Self { + Self(OrderedFloat(v)) + } + + pub const fn get(self) -> f64 { + self.0 .0 + } + + /// `log₂ |Λ|` — the form every analytic-error formula consumes. + pub fn log2(self) -> f64 { + self.get().log2() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::hash; + + const TARGET_BITS: u32 = 100; + + fn spec(pow_budget: PowBudget) -> SecuritySpec { + SecuritySpec::new(TARGET_BITS) + .with_mode(Mode::ZeroKnowledge) + .with_pow_budget(pow_budget) + } + + #[test] + fn new_uses_documented_defaults() { + let spec = SecuritySpec::new(128); + assert_eq!(spec.mode, Mode::Standard); + assert_eq!(spec.decoding_regime, DecodingRegime::Johnson); + assert_eq!(spec.target_security_bits, 128); + assert_eq!( + spec.pow_budget, + PowBudget::per_slot(DEFAULT_POW_BUDGET_BITS) + ); + assert_eq!(spec.hash_id, hash::BLAKE3); + } + + #[test] + fn forbidden_means_no_pow_credit() { + assert_eq!( + spec(PowBudget::Forbidden).protocol_security_target_bits(), + Bits::new(f64::from(TARGET_BITS)), + ); + } + + #[test] + fn per_slot_zero_collapses_to_forbidden() { + assert_eq!(PowBudget::per_slot(0), PowBudget::Forbidden); + } + + #[test] + fn per_slot_bits_round_trip() { + assert_eq!(PowBudget::per_slot(20).bits(), 20); + assert_eq!(PowBudget::Forbidden.bits(), 0); + } + + #[test] + fn pow_credit_shifts_analytic_floor() { + assert_eq!( + spec(PowBudget::per_slot(20)).protocol_security_target_bits(), + Bits::new(80.0), + ); + assert_eq!( + spec(PowBudget::per_slot(60)).protocol_security_target_bits(), + Bits::new(40.0), + ); + } + + #[test] + fn pow_exceeding_target_saturates_to_zero() { + let pow_over_target = TARGET_BITS + 100; + assert_eq!( + spec(PowBudget::per_slot(pow_over_target)).protocol_security_target_bits(), + Bits::new(0.0), + ); + } +} diff --git a/src/protocols/params/sumcheck.rs b/src/protocols/params/sumcheck.rs new file mode 100644 index 00000000..a34cca86 --- /dev/null +++ b/src/protocols/params/sumcheck.rs @@ -0,0 +1,285 @@ +//! Sumcheck parameter selection. ZK mode adds a degree-2 mask per round +//! (Lemma 6.4, p.38). + +use crate::{ + algebra::{embedding::Embedding, fields::FieldWithSize}, + bits::Bits, + protocols::{ + irs_commit::Config as IrsConfig, + params::{ + bounds::usize_to_f64, + branch::SolveMode, + error::{grind_to_at, DeriveError, Pow}, + protocol_config::MaskOracleInfo, + solved::Solved, + spec::{RoundContext, SecuritySpec}, + }, + sumcheck::{self, Config as SumcheckConfig, SumcheckMaskLen}, + }, +}; + +/// Per-round sumcheck builder. +pub fn solve( + spec: &SecuritySpec, + ctx: &RoundContext, + source_irs: &IrsConfig, + mode: SolveMode, + pow: Pow, +) -> Result>, DeriveError> { + let (mask_oracle, output_mode) = match mode { + SolveMode::Standard => (None, sumcheck::SumcheckMode::Standard), + SolveMode::ZeroKnowledge(mask_oracle) => ( + Some(mask_oracle), + sumcheck::SumcheckMode::ZeroKnowledge { + mask_length: zk_mask_length(), + }, + ), + }; + let analytic = analytic_error_bits(source_irs, mask_oracle); + let round_pow = grind_to_at(spec, analytic, pow)?; + Ok(Solved::new( + SumcheckConfig::new( + ctx.vector_size, + round_pow, + num_sumcheck_rounds(ctx), + output_mode, + ), + analytic, + )) +} + +/// Per-sumcheck-round soundness in bits: `min(ε_mca, poly_identity_term)`. +/// +/// - Standard (degree-2): `log|F| − log|Λ(C)| − 1`. +/// - ZK (Lemma 6.5, p.40): `log|F| − log|Λ(C)| − log|Λ(C_zk)| − log ℓ_zk`. +pub fn analytic_error_bits( + source_irs: &IrsConfig, + mask_oracle: Option, +) -> Bits { + let field_bits = M::Target::field_size_bits(); + let log_list_size = source_irs.list_size().log2(); + let prox_gaps = source_irs.rbr_soundness_fold_prox_gaps(); + + let poly_id = mask_oracle.map_or(field_bits - log_list_size - 1.0, |info| { + let log_list_size_c_zk = info.c_zk_list_size.log2(); + let log_l_zk = usize_to_f64(info.l_zk.get()).log2(); + field_bits - log_list_size - log_list_size_c_zk - log_l_zk + }); + + Bits::new(prox_gaps.min(poly_id).max(0.0)) +} + +/// Number of degree-2 round-polynomial masks sumcheck contributes to C_zk +/// per round (Lemma 6.4). +pub const fn masks_required(ctx: &RoundContext) -> usize { + num_sumcheck_rounds(ctx) +} + +const fn num_sumcheck_rounds(ctx: &RoundContext) -> usize { + ctx.folding_factor as usize +} + +/// Construction 6.3 step 4(a) sends `h_j ∈ F^{ SumcheckMaskLen { + SumcheckMaskLen::new(3) +} + +#[cfg(test)] +mod tests { + use proptest::prelude::*; + + use super::*; + use crate::protocols::params::{ + irs_commit as irs_params, + spec::{ListSize, MaskCodeMessageLen, Mode, OodSampleBudget}, + test_utils::{ + arb_round_ctx, arb_standard_spec, arb_zk_spec, assert_close, assert_pow_closes_gap, + build_minimal_mask_oracle, deterministic_spec, TestEmbedding, TestField, + TestNonIdentityEmbedding, EPS, TEST_TARGET_RANGE, + }, + }; + + const FIXTURE_C_ZK_LIST_SIZE: f64 = 4.0; + const FIXTURE_L_ZK: usize = 8; + + fn build_source_irs(spec: &SecuritySpec, ctx: &RoundContext) -> IrsConfig { + irs_params::solve(spec, ctx, OodSampleBudget::ZERO).expect("source IRS fixture must solve") + } + + const FIXTURE_LOG_VECTOR_SIZE: u32 = 4; + const FIXTURE_LOG_INV_RATE: u32 = 1; + const FIXTURE_FOLDING_FACTOR: u32 = 2; + + fn fixture_ctx() -> RoundContext { + RoundContext { + vector_size: 1 << FIXTURE_LOG_VECTOR_SIZE, + log_inv_rate: FIXTURE_LOG_INV_RATE, + folding_factor: FIXTURE_FOLDING_FACTOR, + } + } + + #[test] + fn zk_mode_has_three_mask_coefficients() { + let spec = deterministic_spec(Mode::ZeroKnowledge); + let ctx = fixture_ctx(); + let source_irs = build_source_irs(&spec, &ctx); + let mask_oracle = + build_minimal_mask_oracle(&spec).expect("ZK spec must produce a mask oracle"); + let config = solve( + &spec, + &ctx, + &source_irs, + SolveMode::ZeroKnowledge(mask_oracle), + Pow::RoundSumcheck { index: 0 }, + ) + .unwrap(); + match config.mode() { + sumcheck::SumcheckMode::ZeroKnowledge { mask_length } => { + assert_eq!(mask_length.get(), 3); + } + sumcheck::SumcheckMode::Standard => panic!("expected ZK"), + } + } + + #[test] + fn analytic_error_standard_formula() { + let spec = deterministic_spec(Mode::Standard); + let ctx = fixture_ctx(); + let irs = build_source_irs(&spec, &ctx); + + let got = f64::from(analytic_error_bits::(&irs, None)); + + let field_bits = TestField::field_size_bits(); + let log_list = irs.list_size().log2(); + let prox = irs.rbr_soundness_fold_prox_gaps(); + let expected = prox.min(field_bits - log_list - 1.0).max(0.0); + + assert_close(got, expected); + } + + #[test] + fn analytic_error_zk_formula() { + let log_c_zk_list = FIXTURE_C_ZK_LIST_SIZE.log2(); + let log_l_zk = (FIXTURE_L_ZK as f64).log2(); + + let spec = deterministic_spec(Mode::ZeroKnowledge); + let ctx = fixture_ctx(); + let irs = build_source_irs(&spec, &ctx); + let info = MaskOracleInfo { + c_zk_list_size: ListSize::new(FIXTURE_C_ZK_LIST_SIZE), + l_zk: MaskCodeMessageLen::new(FIXTURE_L_ZK), + }; + + let got = f64::from(analytic_error_bits::(&irs, Some(info))); + + let field_bits = TestField::field_size_bits(); + let log_list = irs.list_size().log2(); + let prox = irs.rbr_soundness_fold_prox_gaps(); + let expected = prox + .min(field_bits - log_list - log_c_zk_list - log_l_zk) + .max(0.0); + + assert_close(got, expected); + } + + #[test] + fn analytic_error_clamps_to_zero() { + const OVERSIZED_LOG_C_ZK_LIST: i32 = 60; + const OVERSIZED_LOG_L_ZK: u32 = 30; + + let spec = deterministic_spec(Mode::ZeroKnowledge); + let ctx = fixture_ctx(); + let irs = build_source_irs(&spec, &ctx); + let huge = MaskOracleInfo { + c_zk_list_size: ListSize::new(2_f64.powi(OVERSIZED_LOG_C_ZK_LIST)), + l_zk: MaskCodeMessageLen::new(1 << OVERSIZED_LOG_L_ZK), + }; + let bits = f64::from(analytic_error_bits::(&irs, Some(huge))); + assert_close(bits, 0.0); + } + + proptest! { + #[test] + fn standard_mode_propagates( + spec in arb_standard_spec(TEST_TARGET_RANGE), + ctx in arb_round_ctx(), + ) { + let source_irs = build_source_irs(&spec, &ctx); + let pow = Pow::RoundSumcheck { index: 0 }; + let config = solve(&spec, &ctx, &source_irs, SolveMode::Standard, pow).unwrap(); + prop_assert!(matches!(config.mode(), sumcheck::SumcheckMode::Standard)); + } + + #[test] + fn num_rounds_matches_folding_factor( + spec in prop_oneof![ + arb_standard_spec(TEST_TARGET_RANGE), + arb_zk_spec(TEST_TARGET_RANGE), + ], + ctx in arb_round_ctx(), + ) { + let source_irs = build_source_irs(&spec, &ctx); + let pow = Pow::RoundSumcheck { index: 0 }; + let mode = build_minimal_mask_oracle(&spec) + .map_or(SolveMode::Standard, SolveMode::ZeroKnowledge); + let config = solve(&spec, &ctx, &source_irs, mode, pow).unwrap(); + prop_assert_eq!(config.num_rounds(), ctx.folding_factor as usize); + } + + #[test] + fn zk_error_le_standard_error( + spec in arb_zk_spec(TEST_TARGET_RANGE), + ctx in arb_round_ctx(), + ) { + let irs = build_source_irs(&spec, &ctx); + let mo = build_minimal_mask_oracle(&spec); + let zk = f64::from(analytic_error_bits::(&irs, mo)); + let standard = f64::from(analytic_error_bits::(&irs, None)); + prop_assert!(zk <= standard + EPS, "zk {} > standard {}", zk, standard); + } + + #[test] + fn round_pow_closes_gap_to_target( + spec in prop_oneof![ + arb_standard_spec(TEST_TARGET_RANGE), + arb_zk_spec(TEST_TARGET_RANGE), + ], + ctx in arb_round_ctx(), + ) { + let source_irs = build_source_irs(&spec, &ctx); + let mask_oracle = build_minimal_mask_oracle(&spec); + let error = analytic_error_bits(&source_irs, mask_oracle); + let pow = Pow::RoundSumcheck { index: 0 }; + let mode = mask_oracle.map_or(SolveMode::Standard, SolveMode::ZeroKnowledge); + let config = solve(&spec, &ctx, &source_irs, mode, pow).unwrap(); + assert_pow_closes_gap(&spec, error, &config.round_pow()); + } + } + + #[test] + fn solve_works_with_basefield_embedding_zk() { + let spec = deterministic_spec(Mode::ZeroKnowledge); + let ctx = fixture_ctx(); + let source_irs: IrsConfig = + irs_params::solve(&spec, &ctx, OodSampleBudget::ZERO) + .expect("source IRS fixture must solve"); + let info = MaskOracleInfo { + c_zk_list_size: ListSize::new(FIXTURE_C_ZK_LIST_SIZE), + l_zk: MaskCodeMessageLen::new(FIXTURE_L_ZK), + }; + let config = solve( + &spec, + &ctx, + &source_irs, + SolveMode::ZeroKnowledge(info), + Pow::RoundSumcheck { index: 0 }, + ) + .unwrap(); + assert!(matches!( + config.mode(), + sumcheck::SumcheckMode::ZeroKnowledge { .. } + )); + } +} diff --git a/src/protocols/params/test_utils.rs b/src/protocols/params/test_utils.rs new file mode 100644 index 00000000..bcf38665 --- /dev/null +++ b/src/protocols/params/test_utils.rs @@ -0,0 +1,171 @@ +//! Shared test fixtures. + +use std::ops::RangeInclusive; + +use proptest::prelude::*; + +use crate::{ + algebra::{ + embedding::{Basefield, Embedding, Identity}, + fields::{Field64, Field64_2}, + }, + bits::Bits, + protocols::{ + irs_commit::Config as IrsConfig, + mask_proximity::Config as MaskProximityConfig, + params::{ + branch::OodMode, + build_round::solve_t_ood, + irs_commit as irs_params, + protocol_config::MaskOracleInfo, + spec::{ + DecodingRegime, ListSize, LogInvRate, MaskCodeMessageLen, Mode, OodSampleBudget, + PowBudget, RoundContext, SecuritySpec, ZkSpec, + }, + }, + proof_of_work::Config as PowConfig, + }, +}; + +pub type TestField = Field64; +pub type TestEmbedding = Identity; +pub type TestExtensionField = Field64_2; +/// `Source = Field64, Target = Field64_2`. +pub type TestNonIdentityEmbedding = Basefield; + +pub const TEST_TARGET_RANGE: RangeInclusive = 30..=50; + +pub const FIXTURE_TARGET_BITS: u32 = 80; + +pub const EPS: f64 = 1e-9; + +pub const FIXTURE_POW_BUDGET_BITS: u32 = 60; + +pub fn deterministic_spec(mode: Mode) -> SecuritySpec { + SecuritySpec::new(FIXTURE_TARGET_BITS) + .with_mode(mode) + .with_pow_budget(PowBudget::per_slot(FIXTURE_POW_BUDGET_BITS)) +} + +fn arb_decoding_regime() -> impl Strategy { + prop_oneof![ + Just(DecodingRegime::Johnson), + Just(DecodingRegime::Unique), + Just(DecodingRegime::Capacity), + ] +} + +pub fn arb_spec( + mode: Mode, + target_range: RangeInclusive, +) -> impl Strategy { + (target_range, arb_decoding_regime()).prop_map(move |(target, decoding_regime)| { + SecuritySpec::new(target) + .with_mode(mode) + .with_decoding_regime(decoding_regime) + .with_pow_budget(PowBudget::per_slot(FIXTURE_POW_BUDGET_BITS)) + }) +} + +pub fn arb_zk_spec(target_range: RangeInclusive) -> impl Strategy { + arb_spec(Mode::ZeroKnowledge, target_range) +} + +pub fn arb_standard_spec(target_range: RangeInclusive) -> impl Strategy { + arb_spec(Mode::Standard, target_range) +} + +pub fn arb_round_ctx() -> impl Strategy { + (4u32..=8, 1u32..=4, 1u32..=3).prop_map(|(log_size, log_inv_rate, folding_factor)| { + RoundContext { + vector_size: 1usize << log_size, + log_inv_rate, + folding_factor, + } + }) +} + +/// `None` in Standard; `Some(ℓ_zk=2, c_zk rate 1/2)` in ZK. +pub fn build_minimal_mask_oracle(spec: &SecuritySpec) -> Option { + let zk_spec = ZkSpec::try_new(spec)?; + let l_zk = MaskCodeMessageLen::new(2); + let c_zk: IrsConfig = + irs_params::solve_mask_code(zk_spec, l_zk, 0, LogInvRate::new(1), 2) + .expect("minimal mask-code fixture must solve"); + Some(MaskOracleInfo { + c_zk_list_size: ListSize::new(c_zk.list_size()), + l_zk, + }) +} + +/// `analytic_error_bits + pow.difficulty() ≥ target_security_bits`. +pub fn assert_pow_closes_gap(spec: &SecuritySpec, analytic: Bits, pow: &PowConfig) { + let error = f64::from(analytic); + let pow_bits = f64::from(pow.difficulty()); + let target = f64::from(spec.target_security_bits); + assert!( + error + pow_bits >= target - 1e-3, + "error {error} + pow {pow_bits} < target {target}", + ); +} + +/// `|got − expected| < EPS`. +pub fn assert_close(got: f64, expected: f64) { + assert!( + (got - expected).abs() < EPS, + "got {got} vs expected {expected}", + ); +} + +/// C_zk fixture for `mask_proximity` tests. +pub fn build_test_c_zk( + spec: &SecuritySpec, + l_zk: usize, + log_inv_rate: u32, + num_masks: usize, +) -> IrsConfig { + let zk_spec = ZkSpec::try_new(spec).expect("build_test_c_zk requires a ZK spec"); + irs_params::solve_mask_code( + zk_spec, + MaskCodeMessageLen::new(l_zk), + 0, + LogInvRate::new(log_inv_rate), + MaskProximityConfig::::num_vectors_for(num_masks), + ) + .expect("C_zk fixture must solve") +} + +/// Builds a self-consistent `(source, target, t_ood)` triplet matching the +/// per-round shape that `code_switch::solve` expects. +pub fn build_round_io( + spec: &SecuritySpec, + log_inv_rate: u32, + folding_factor: u32, + num_vars: u32, + c_zk_log_inv_rate: Option, +) -> (IrsConfig, IrsConfig>, usize) { + let source_ctx = RoundContext { + vector_size: 1usize << num_vars, + log_inv_rate, + folding_factor, + }; + let target_log_inv_rate = log_inv_rate + folding_factor - 1; + let target_log_degree = f64::from(num_vars - folding_factor); + let target_list_size = spec + .decoding_regime + .list_size_estimate(target_log_degree, f64::from(target_log_inv_rate)); + let ood_mode = c_zk_log_inv_rate.map_or(OodMode::Standard, |rate| { + OodMode::ZeroKnowledge(LogInvRate::new(rate)) + }); + let (source, t_ood) = solve_t_ood::(spec, &source_ctx, target_list_size, ood_mode, 0) + .expect("solve_t_ood diverged in test fixture"); + + let target_ctx = RoundContext { + vector_size: source.message_length(), + log_inv_rate: target_log_inv_rate, + folding_factor, + }; + let target = irs_params::solve(spec, &target_ctx, OodSampleBudget::new(t_ood)) + .expect("target IRS fixture must solve"); + (source, target, t_ood) +} diff --git a/src/protocols/proof_of_work.rs b/src/protocols/proof_of_work.rs index a2a8aabc..ccdc3a60 100644 --- a/src/protocols/proof_of_work.rs +++ b/src/protocols/proof_of_work.rs @@ -26,8 +26,20 @@ pub struct Config { pub threshold: u64, } +/// Failure modes for [`Config::grind_to`]. +#[derive(Debug, thiserror::Error, Clone, Copy, PartialEq, Eq)] +pub enum PowError { + /// `target − analytic_error` exceeds what a single grind slot can deliver + /// (`MAX_DIFFICULTY = 60` bits, matching the grinding engine's capacity). + #[error("required {required} bits exceeds the {max} grind cap")] + GapExceedsGrindCap { required: Bits, max: Bits }, +} + +/// Largest gap a single grind slot can close (in bits). +pub const MAX_DIFFICULTY: f64 = 60.0; + pub fn threshold(difficulty: Bits) -> u64 { - assert!((0.0..=60.0).contains(&difficulty.into())); + assert!((0.0..=MAX_DIFFICULTY).contains(&difficulty.into())); let threshold = (64.0 - f64::from(difficulty)).exp2().ceil(); #[allow(clippy::cast_sign_loss)] @@ -64,6 +76,34 @@ impl Config { difficulty(self.threshold) } + /// Build a PoW config whose difficulty closes the gap `target - analytic_error`, + /// clamped at zero. + /// + /// Used by parameter solvers: each PoW slot independently lifts its own + /// soundness up to `target`. The caller is responsible for ensuring + /// `analytic_error` is computed from the local protocol step (see e.g. + /// `params::sumcheck`). + /// + /// Returns [`PowError::GapExceedsGrindCap`] if the required difficulty + /// exceeds [`MAX_DIFFICULTY`] — the spec is too tight for any single slot. + pub fn grind_to( + target: Bits, + analytic_error: Bits, + hash_id: EngineId, + ) -> Result { + let gap = (f64::from(target) - f64::from(analytic_error)).max(0.0); + if gap > MAX_DIFFICULTY { + return Err(PowError::GapExceedsGrindCap { + required: Bits::new(gap), + max: Bits::new(MAX_DIFFICULTY), + }); + } + Ok(Self { + hash_id, + threshold: threshold(Bits::new(gap)), + }) + } + #[cfg_attr(feature = "tracing", instrument(skip_all, fields(engine)))] pub fn prove(&self, prover_state: &mut ProverState) where diff --git a/src/protocols/sumcheck.rs b/src/protocols/sumcheck.rs index f721edd1..41b19161 100644 --- a/src/protocols/sumcheck.rs +++ b/src/protocols/sumcheck.rs @@ -9,11 +9,8 @@ use serde::{Deserialize, Serialize}; use tracing::instrument; use crate::{ - algebra::{ - dot, - sumcheck::{compute_sumcheck_polynomial, fold, fold_and_compute_polynomial}, - univariate_evaluate, - }, + algebra::univariate_evaluate, + buffer::{ActiveBuffer, Buffer, BufferOps}, protocols::proof_of_work, transcript::{ codecs::U64, Codec, Decoding, DuplexSpongeInterface, ProverState, VerificationResult, @@ -23,20 +20,109 @@ use crate::{ utils::chunks_exact_or_empty, }; +/// Output from the sumcheck protocol (shared by prover and verifier). +#[must_use] +pub struct SumcheckOpening { + pub round_challenges: Vec, + pub mask_rlc: F, +} + +/// ZK sumcheck mask polynomial dimension. +/// +/// Validated at construction to be at least `MIN = 3` — the round polynomial +/// has 3 coefficients (degree-2), so the mask must have at least as many to +/// hide it. Lemma 6.4 itself only requires `ℓ_zk ≥ 2`; the `3` floor is a +/// WHIR design choice tied to the degree-2 round polynomial (see +/// `params::sumcheck::zk_mask_length`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct SumcheckMaskLen(usize); + +impl SumcheckMaskLen { + pub const MIN: usize = 3; + + pub const fn new(n: usize) -> Self { + assert!(n >= Self::MIN); + Self(n) + } + + pub const fn get(self) -> usize { + self.0 + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum SumcheckMode { + Standard, + ZeroKnowledge { mask_length: SumcheckMaskLen }, +} + +#[must_use] #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(bound = "")] pub struct Config where F: Field, { - pub field: Type, - pub initial_size: usize, - pub round_pow: proof_of_work::Config, - pub num_rounds: usize, - pub mask_length: usize, + field: Type, + initial_size: usize, + round_pow: proof_of_work::Config, + num_rounds: usize, + mode: SumcheckMode, } impl Config { + pub fn new( + initial_size: usize, + round_pow: proof_of_work::Config, + num_rounds: usize, + mode: SumcheckMode, + ) -> Self { + assert!(num_rounds == 0 || initial_size.next_power_of_two() >= 1 << num_rounds); + // `SumcheckMaskLen::new` already enforces the ≥ 3 floor at construction; + // here we only need the field-characteristic precondition from Lemma 6.4. + if matches!(mode, SumcheckMode::ZeroKnowledge { .. }) { + assert!( + !F::ONE.double().is_zero(), + "ZK sumcheck requires char(F) ≠ 2" + ); + } + Self { + field: Type::new(), + initial_size, + round_pow, + num_rounds, + mode, + } + } + + pub const fn initial_size(&self) -> usize { + self.initial_size + } + + pub const fn round_pow(&self) -> proof_of_work::Config { + self.round_pow + } + + pub const fn num_rounds(&self) -> usize { + self.num_rounds + } + + pub const fn mode(&self) -> &SumcheckMode { + &self.mode + } + + const fn mask_length(&self) -> usize { + match &self.mode { + SumcheckMode::Standard => 0, + SumcheckMode::ZeroKnowledge { mask_length } => mask_length.get(), + } + } + + #[cfg(test)] + pub(crate) const fn override_round_pow_for_test(&mut self, round_pow: proof_of_work::Config) { + self.round_pow = round_pow; + } + pub fn final_size(&self) -> usize { assert!( self.num_rounds == 0 || self.initial_size.next_power_of_two() >= 1 << self.num_rounds @@ -62,11 +148,11 @@ impl Config { pub fn prove( &self, prover_state: &mut ProverState, - a: &mut Vec, - b: &mut Vec, + a: &mut ActiveBuffer, + b: &mut ActiveBuffer, sum: &mut F, masks: &[F], - ) -> (Vec, F, F) + ) -> SumcheckOpening where H: DuplexSpongeInterface, R: CryptoRng + RngCore, @@ -77,82 +163,93 @@ impl Config { assert!( self.num_rounds == 0 || self.initial_size.next_power_of_two() >= 1 << self.num_rounds ); - assert!(self.mask_length == 0 || self.mask_length >= 3); assert_eq!(a.len(), self.initial_size); assert_eq!(b.len(), self.initial_size); - debug_assert_eq!(dot(a, b), *sum); - assert_eq!(masks.len(), self.num_rounds * self.mask_length); + debug_assert_eq!(a.dot(b), *sum); + assert_eq!(masks.len(), self.num_rounds * self.mask_length()); let half = F::from(2).inverse().unwrap(); + let polynomial_len = self.mask_length().max(3); - // Send mask sum and get combination randomness. - let mut mask_sum = F::ZERO; - let mut mask_rlc = F::ONE; - if !masks.is_empty() { - let sum_multiple = F::from(1 << self.num_rounds.saturating_sub(1)); - mask_sum = masks - .chunks_exact(self.mask_length) - .map(eval_01) // s(0) + s(1) - .sum::() - * sum_multiple; - prover_state.prover_message(&mask_sum); - mask_rlc = prover_state.verifier_message(); - } + let (mut mask_sum, mask_rlc) = self.maybe_send_initial_mask_sum(prover_state, masks); - // We do a staggered Sumcheck loop so we can merge the inner fold+compute loops. - let mut univariate = Vec::new(); - let mut res = Vec::with_capacity(self.num_rounds); - let mut folding_randomness = None; + let mut univariate = Vec::with_capacity(polynomial_len); + let mut round_challenges = Vec::with_capacity(self.num_rounds); + let mut prev_round_challenge = None; for (round, mask) in - chunks_exact_or_empty(masks, self.mask_length, self.num_rounds).enumerate() + chunks_exact_or_empty(masks, self.mask_length(), self.num_rounds).enumerate() { // Fold and compute sumcheck polynomial in one pass. - let (c0, c2) = if let Some(w) = folding_randomness { - fold_and_compute_polynomial(a, b, w) + let (c0, c2) = if let Some(w) = prev_round_challenge { + a.fold_pair_sumcheck_polynomial(b, w) } else { - compute_sumcheck_polynomial(a, b) + a.sumcheck_polynomial(b) }; let c1 = *sum - c0.double() - c2; - // Optionally mask with univariate - if mask.is_empty() { - prover_state.prover_messages(&[c0, c2]); - } else { - // Initialize to round masking univariate polynomial. - univariate.clear(); - let sum_multiple = F::from(1 << self.num_rounds.saturating_sub(round + 1)); - univariate.extend(mask.iter().map(|m| sum_multiple * *m)); - - // Add constant term from previous and future masks. - univariate[0] += (mask_sum - sum_multiple * eval_01(mask)) * half; - - // Add plain sumcheck polynomial - univariate[0] += mask_rlc * c0; - univariate[1] += mask_rlc * c1; - univariate[2] += mask_rlc * c2; - - prover_state.prover_message(&univariate[0]); - prover_state.prover_messages(&univariate[2..]); + // Build round polynomial. In Standard (`mask = []`, `mask_rlc = 1`, + // `mask_sum = 0`) this collapses to `[c0, c1, c2]`. + univariate.clear(); + univariate.resize(polynomial_len, F::ZERO); + let sum_multiple = F::from(1 << self.num_rounds.saturating_sub(round + 1)); + for (u, m) in univariate.iter_mut().zip(mask.iter()) { + *u = sum_multiple * *m; } + univariate[0] += (mask_sum - sum_multiple * eval_01(mask)) * half; + univariate[0] += mask_rlc * c0; + univariate[1] += mask_rlc * c1; + univariate[2] += mask_rlc * c2; - // Receive the random evaluation point and update the sum + prover_state.prover_message(&univariate[0]); + prover_state.prover_messages(&univariate[2..]); + + // Receive the random evaluation point and update the sum. self.round_pow.prove(prover_state); let r = prover_state.verifier_message::(); - res.push(r); + round_challenges.push(r); *sum = (c2 * r + c1) * r + c0; - if !masks.is_empty() { - let masked_sum = univariate_evaluate(&univariate, r); - mask_sum = masked_sum - mask_rlc * *sum; - } - folding_randomness = Some(r); + + mask_sum = univariate_evaluate(&univariate, r) - mask_rlc * *sum; + prev_round_challenge = Some(r); } - if let Some(w) = folding_randomness { - // Final fold of the inputs (no polynomial computation) - fold(a, w); - fold(b, w); + if let Some(w) = prev_round_challenge { + // Final fold of the inputs (no polynomial computation). + a.fold_pair(b, w); } *sum = mask_sum + mask_rlc * *sum; - (res, mask_sum, mask_rlc) + SumcheckOpening { + round_challenges, + mask_rlc, + } + } + + fn maybe_send_initial_mask_sum( + &self, + prover_state: &mut ProverState, + masks: &[F], + ) -> (F, F) + where + H: DuplexSpongeInterface, + R: CryptoRng + RngCore, + F: Codec<[H::U]>, + { + match &self.mode { + SumcheckMode::Standard => (F::ZERO, F::ONE), + SumcheckMode::ZeroKnowledge { mask_length } => { + if self.num_rounds == 0 { + return (F::ZERO, F::ONE); + } + let sum_multiple = F::from(1 << self.num_rounds.saturating_sub(1)); + let mask_sum = masks + .chunks_exact(mask_length.get()) + .map(eval_01) + .sum::() + * sum_multiple; + prover_state.prover_message(&mask_sum); + let mask_rlc = prover_state.verifier_message(); + (mask_sum, mask_rlc) + } + } } #[cfg_attr(feature = "tracing", instrument(skip_all))] @@ -160,7 +257,7 @@ impl Config { &self, verifier_state: &mut VerifierState, sum: &mut F, - ) -> VerificationResult<(Vec, F)> + ) -> VerificationResult> where H: DuplexSpongeInterface, F: Codec<[H::U]>, @@ -170,17 +267,11 @@ impl Config { assert!( self.num_rounds == 0 || self.initial_size.next_power_of_two() >= 1 << self.num_rounds ); - assert!(self.mask_length == 0 || self.mask_length >= 3); - let mut mask_rlc = F::ONE; - if self.mask_length > 0 && self.num_rounds > 0 { - let mask_sum: F = verifier_state.prover_message()?; - mask_rlc = verifier_state.verifier_message(); - *sum = mask_sum + mask_rlc * *sum; - } + let mask_rlc = self.maybe_receive_initial_mask_sum(verifier_state, sum)?; - let mut univariate = vec![F::ZERO; self.mask_length.max(3)]; - let mut res = Vec::with_capacity(self.num_rounds); + let mut univariate = vec![F::ZERO; self.mask_length().max(3)]; + let mut round_challenges = Vec::with_capacity(self.num_rounds); for _ in 0..self.num_rounds { // Receive all but linear coefficient. univariate[0] = verifier_state.prover_message()?; @@ -188,32 +279,64 @@ impl Config { *c = verifier_state.prover_message()?; } - // Derive linear coefficient from relation `univariate(0) + univariate(1) = sum` + // Derive linear coefficient from relation `univariate(0) + univariate(1) = sum`. univariate[1] = *sum - univariate[0].double() - univariate[2..].iter().sum::(); - // Check proof of work (if any) + // Check proof of work (if any). self.round_pow.verify(verifier_state)?; - // Receive the random evaluation point - let folding_randomness = verifier_state.verifier_message::(); - res.push(folding_randomness); + // Receive the random evaluation point. + let round_challenge = verifier_state.verifier_message::(); + round_challenges.push(round_challenge); - // Update the sum - *sum = univariate_evaluate(&univariate, folding_randomness); + // Update the sum. + *sum = univariate_evaluate(&univariate, round_challenge); + } + Ok(SumcheckOpening { + round_challenges, + mask_rlc, + }) + } + + fn maybe_receive_initial_mask_sum( + &self, + verifier_state: &mut VerifierState, + sum: &mut F, + ) -> VerificationResult + where + H: DuplexSpongeInterface, + F: Codec<[H::U]>, + { + match &self.mode { + SumcheckMode::Standard => Ok(F::ONE), + SumcheckMode::ZeroKnowledge { .. } => { + if self.num_rounds == 0 { + return Ok(F::ONE); + } + let mask_sum: F = verifier_state.prover_message()?; + let mask_rlc = verifier_state.verifier_message(); + *sum = mask_sum + mask_rlc * *sum; + Ok(mask_rlc) + } } - Ok((res, mask_rlc)) } } impl fmt::Display for Config { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let mode_str = match &self.mode { + SumcheckMode::Standard => "standard".to_string(), + SumcheckMode::ZeroKnowledge { mask_length } => { + format!("zk ℓ_zk={}", mask_length.get()) + } + }; write!( f, - "size {} rounds {} pow {:.2} ℓ_zk {}", + "size {} rounds {} pow {:.2} {}", self.initial_size, self.num_rounds, self.round_pow.difficulty(), - self.mask_length + mode_str, ) } } @@ -228,7 +351,6 @@ fn eval_01(coefficients: &[F]) -> F { #[cfg(test)] mod tests { - // TODO: Proptest based tests checking invariants and post conditions. use ark_std::rand::{ distributions::{Distribution, Standard}, rngs::StdRng, @@ -241,30 +363,35 @@ mod tests { use super::*; use crate::{ algebra::{ + dot, fields::{self, Field64}, multilinear_extend, random_vector, }, + buffer::ActiveBuffer, transcript::DomainSeparator, - utils::zip_strict, }; - impl Config { + impl Config + where + Standard: Distribution, + { pub fn arbitrary() -> impl Strategy { - let mask_length = prop_oneof![ - 3 => Just(0_usize), - 7 => 3_usize..100, + let mode_strategy = prop_oneof![ + 3 => Just(SumcheckMode::Standard), + 7 => (3_usize..20).prop_map(|n| SumcheckMode::ZeroKnowledge { + mask_length: SumcheckMaskLen::new(n), + }), ]; - (0_usize..(1 << 12), 0_usize..12, mask_length).prop_map( - |(initial_size, num_rounds, mask_length)| { + (0_usize..(1 << 12), 0_usize..12, mode_strategy).prop_map( + |(initial_size, num_rounds, mode)| { let num_rounds = num_rounds.min(initial_size.next_power_of_two().trailing_zeros() as usize); - Self { - field: Type::new(), + Self::new( initial_size, + proof_of_work::Config::none(), num_rounds, - round_pow: proof_of_work::Config::none(), - mask_length, - } + mode, + ) }, ) } @@ -273,7 +400,7 @@ mod tests { #[cfg_attr(feature = "tracing", instrument)] fn test_config(seed: u64, config: &Config) where - F: Field + Codec, + F: Field + Codec<[u8]> + 'static, Standard: Distribution, { // Pseudo-random Instance @@ -285,53 +412,72 @@ mod tests { let initial_vector = random_vector(&mut rng, config.initial_size); let initial_covector = random_vector(&mut rng, config.initial_size); let initial_sum = dot(&initial_vector, &initial_covector); - let masks = random_vector(&mut rng, config.mask_length * config.num_rounds); + let masks = random_vector(&mut rng, config.mask_length() * config.num_rounds); // Prover - let mut vector = initial_vector.clone(); - let mut covector = initial_covector.clone(); + let mut vector = ActiveBuffer::from_slice(&initial_vector); + let mut covector = ActiveBuffer::from_slice(&initial_covector); let mut sum = initial_sum; let mut prover_state = ProverState::new_std(&ds); - let (point, mask_sum, mask_rlc) = config.prove( + let SumcheckOpening { + round_challenges: point, + mask_rlc, + } = config.prove( &mut prover_state, &mut vector, &mut covector, &mut sum, &masks, ); - let expected_mask_sum = zip_strict( - chunks_exact_or_empty(&masks, config.mask_length, config.num_rounds), - &point, - ) - .map(|(m, x)| univariate_evaluate(m, *x)) - .sum::(); assert_eq!(vector.len(), config.final_size()); assert_eq!(covector.len(), config.final_size()); - assert_eq!(mask_sum, expected_mask_sum); - assert_eq!(mask_sum + mask_rlc * dot(&vector, &covector), sum); if config.final_size() == 1 { - assert_eq!(multilinear_extend(&initial_vector, &point), vector[0]); - assert_eq!(multilinear_extend(&initial_covector, &point), covector[0]); + assert_eq!( + multilinear_extend(&initial_vector, &point), + vector.to_slice()[0] + ); + assert_eq!( + multilinear_extend(&initial_covector, &point), + covector.to_slice()[0] + ); } else { // TODO: Check correct folding. } + + let expected_mask_sum: F = + chunks_exact_or_empty(&masks, config.mask_length(), config.num_rounds) + .zip(&point) + .map(|(m, x)| univariate_evaluate(m, *x)) + .sum(); + assert_eq!( + sum, + expected_mask_sum + mask_rlc * dot(vector.to_slice(), covector.to_slice()) + ); + let proof = prover_state.proof(); // Verifier let mut verifier_sum = initial_sum; let mut verifier_state = VerifierState::new_std(&ds, &proof); - let (verifier_point, verifier_mask_rlc) = config + let SumcheckOpening { + round_challenges: verifier_point, + mask_rlc: verifier_mask_rlc, + } = config .verify(&mut verifier_state, &mut verifier_sum) .unwrap(); assert_eq!(verifier_point, point); assert_eq!(verifier_mask_rlc, mask_rlc); assert_eq!(verifier_sum, sum); verifier_state.check_eof().unwrap(); + + // Standard path: mask_rlc defaults to ONE (no combination randomness sampled). + if matches!(config.mode, SumcheckMode::Standard) || config.num_rounds == 0 { + assert_eq!(mask_rlc, F::ONE); + } } - fn test_sumcheck() + fn test + 'static>() where - F: Field + Codec, Standard: Distribution, { crate::tests::init(); @@ -344,13 +490,14 @@ mod tests { fn test_single_round() { test_config( 0, - &Config:: { - field: Type::new(), - initial_size: 2, - round_pow: proof_of_work::Config::none(), - num_rounds: 1, - mask_length: 3, - }, + &Config::::new( + 2, + proof_of_work::Config::none(), + 1, + SumcheckMode::ZeroKnowledge { + mask_length: SumcheckMaskLen::new(3), + }, + ), ); } @@ -358,13 +505,14 @@ mod tests { fn test_two_rounds() { test_config( 0, - &Config:: { - field: Type::new(), - initial_size: 3, - round_pow: proof_of_work::Config::none(), - num_rounds: 2, - mask_length: 3, - }, + &Config::::new( + 3, + proof_of_work::Config::none(), + 2, + SumcheckMode::ZeroKnowledge { + mask_length: SumcheckMaskLen::new(3), + }, + ), ); } @@ -372,48 +520,49 @@ mod tests { fn test_three_rounds() { test_config( 0, - &Config:: { - field: Type::new(), - initial_size: 5, - round_pow: proof_of_work::Config::none(), - num_rounds: 3, - mask_length: 3, - }, + &Config::::new( + 5, + proof_of_work::Config::none(), + 3, + SumcheckMode::ZeroKnowledge { + mask_length: SumcheckMaskLen::new(3), + }, + ), ); } #[test] fn test_field64_1() { - test_sumcheck::(); + test::(); } #[test] #[ignore = "Somewhat expensive and redundant"] fn test_field64_2() { - test_sumcheck::(); + test::(); } #[test] #[ignore = "Somewhat expensive and redundant"] fn test_field64_3() { - test_sumcheck::(); + test::(); } #[test] #[ignore = "Somewhat expensive and redundant"] fn test_field128() { - test_sumcheck::(); + test::(); } #[test] #[ignore = "Somewhat expensive and redundant"] fn test_field192() { - test_sumcheck::(); + test::(); } #[test] #[ignore = "Somewhat expensive and redundant"] fn test_field256() { - test_sumcheck::(); + test::(); } } diff --git a/src/protocols/whir/config.rs b/src/protocols/whir/config.rs index 89892425..9ce1b166 100644 --- a/src/protocols/whir/config.rs +++ b/src/protocols/whir/config.rs @@ -7,10 +7,26 @@ use crate::{ algebra::{embedding::Embedding, fields::FieldWithSize}, bits::Bits, parameters::ProtocolParameters, - protocols::{irs_commit, proof_of_work, sumcheck}, - type_info::Type, + protocols::{ + irs_commit::{self, num_ood_samples, IrsMode}, + proof_of_work, sumcheck, + }, }; +/// log2 round-by-round soundness of `t_ood` OOD samples against a code with +/// the given list size — formerly `irs_commit::Config::rbr_ood_sample`. +fn rbr_ood_sample( + list_size: f64, + log_field_size: f64, + vector_size: usize, + out_domain_samples: usize, +) -> f64 { + // [STIR] Lemma 4.5. + let l_choose_2 = list_size * (list_size - 1.) / 2.; + let log_per_sample = ((vector_size - 1) as f64).log2() - log_field_size; + -l_choose_2.log2() - out_domain_samples as f64 * log_per_sample +} + impl Config { #[allow(clippy::too_many_lines)] pub fn new(size: usize, whir_parameters: &ProtocolParameters) -> Self @@ -37,14 +53,22 @@ impl Config { let mut num_variables = size.trailing_zeros() as usize; #[allow(clippy::cast_possible_wrap)] - let initial_committer = irs_commit::Config::new( + let initial_committer = irs_commit::Config::new(irs_commit::IrsParams { + security_target: protocol_security_level, + decoding_regime: whir_parameters.decoding_regime, + hash_id: whir_parameters.hash_id, + num_vectors: whir_parameters.batch_size, + vector_size: size, + interleaving_depth: 1 << whir_parameters.initial_folding_factor, + rate: 0.5_f64.powi(whir_parameters.starting_log_inv_rate as i32), + mode: IrsMode::Standard, + }); + let initial_out_domain_samples = num_ood_samples( + whir_parameters.decoding_regime, protocol_security_level, - whir_parameters.unique_decoding, - whir_parameters.hash_id, - whir_parameters.batch_size, + field_size_bits, + initial_committer.list_size(), size, - 1 << whir_parameters.initial_folding_factor, - 0.5_f64.powi(whir_parameters.starting_log_inv_rate as i32), ); // Initial sumcheck round pow bits. @@ -64,7 +88,7 @@ impl Config { let mut round_configs = Vec::new(); let mut round = 0; - let mut in_domain_samples = initial_committer.in_domain_samples; + let mut in_domain_samples = initial_committer.in_domain_samples(); let mut query_error = initial_committer.rbr_queries(); num_variables -= whir_parameters.initial_folding_factor; while num_variables >= whir_parameters.folding_factor { @@ -77,18 +101,26 @@ impl Config { let next_rate = log_inv_rate + (round_folding_factor - 1); #[allow(clippy::cast_possible_wrap)] - let irs_committer = irs_commit::Config::new( + let irs_committer = irs_commit::Config::new(irs_commit::IrsParams { + security_target: protocol_security_level, + decoding_regime: whir_parameters.decoding_regime, + hash_id: whir_parameters.hash_id, + num_vectors: 1, + vector_size: 1 << num_variables, + interleaving_depth: 1 << whir_parameters.folding_factor, + rate: 0.5_f64.powi(next_rate as i32), + mode: IrsMode::Standard, + }); + let round_out_domain_samples = num_ood_samples( + whir_parameters.decoding_regime, protocol_security_level, - whir_parameters.unique_decoding, - whir_parameters.hash_id, - 1, + field_size_bits, + irs_committer.list_size(), 1 << num_variables, - 1 << whir_parameters.folding_factor, - 0.5_f64.powi(next_rate as i32), ); let combination_error = { let log_list_size = irs_committer.list_size().log2(); - let count = irs_committer.out_domain_samples + in_domain_samples; + let count = round_out_domain_samples + in_domain_samples; let log_combination = (count as f64).log2(); field_size_bits - (log_combination + log_list_size + 1.) }; @@ -103,20 +135,20 @@ impl Config { let config = RoundConfig { irs_committer, - sumcheck: sumcheck::Config { - field: Type::new(), - initial_size: 1 << num_variables, - round_pow: pow(folding_pow_bits), - num_rounds: whir_parameters.folding_factor, - mask_length: 0, - }, + out_domain_samples: round_out_domain_samples, + sumcheck: sumcheck::Config::new( + 1 << num_variables, + pow(folding_pow_bits), + whir_parameters.folding_factor, + sumcheck::SumcheckMode::Standard, + ), pow: pow(pow_bits), }; round += 1; num_variables -= whir_parameters.folding_factor; log_inv_rate = next_rate; - in_domain_samples = config.irs_committer.in_domain_samples; + in_domain_samples = config.irs_committer.in_domain_samples(); query_error = config.irs_committer.rbr_queries(); round_configs.push(config); } @@ -131,22 +163,21 @@ impl Config { Self { initial_committer, - initial_sumcheck: sumcheck::Config { - field: Type::new(), - initial_size: size, - round_pow: pow(starting_folding_pow_bits), - num_rounds: whir_parameters.initial_folding_factor, - mask_length: 0, - }, + initial_out_domain_samples, + initial_sumcheck: sumcheck::Config::new( + size, + pow(starting_folding_pow_bits), + whir_parameters.initial_folding_factor, + sumcheck::SumcheckMode::Standard, + ), initial_skip_pow: pow(initial_skip_pow_bits), round_configs, - final_sumcheck: sumcheck::Config { - field: Type::new(), - initial_size: 1 << num_variables, - round_pow: pow(final_folding_pow_bits), - num_rounds: num_variables, - mask_length: 0, - }, + final_sumcheck: sumcheck::Config::new( + 1 << num_variables, + pow(final_folding_pow_bits), + num_variables, + sumcheck::SumcheckMode::Standard, + ), final_pow: pow(final_pow_bits), } } @@ -171,11 +202,15 @@ impl Config { security_level = security_level.min(field_size_bits - ((num_linear_forms - 1) as f64).log2()); } - let has_initial_constraints = - num_linear_forms > 0 || self.initial_committer.out_domain_samples > 0; + let has_initial_constraints = num_linear_forms > 0 || self.initial_out_domain_samples > 0; if !self.initial_committer.unique_decoding() { - security_level = security_level.min(self.initial_committer.rbr_ood_sample()); + security_level = security_level.min(rbr_ood_sample( + self.initial_committer.list_size(), + field_size_bits, + self.initial_committer.vector_size(), + self.initial_out_domain_samples, + )); } // Initial sumcheck error (or the skipped version for LDT). @@ -184,29 +219,34 @@ impl Config { let log_list_size = self.initial_committer.list_size().log2(); let initial_sumcheck_error = field_size_bits - (log_list_size + 1.); let initial_fold_error = initial_prox_gaps_error.min(initial_sumcheck_error) - + f64::from(self.initial_sumcheck.round_pow.difficulty()); + + f64::from(self.initial_sumcheck.round_pow().difficulty()); security_level = security_level.min(initial_fold_error); } else { let skipped_initial_fold_error = initial_prox_gaps_error - + (self.initial_sumcheck.num_rounds as f64).log2() + + (self.initial_sumcheck.num_rounds() as f64).log2() + f64::from(self.initial_skip_pow.difficulty()); security_level = security_level.min(skipped_initial_fold_error); } let mut rbr_queries = self.initial_committer.rbr_queries(); - let mut old_in_domain_samples = self.initial_committer.in_domain_samples; + let mut old_in_domain_samples = self.initial_committer.in_domain_samples(); for round in &self.round_configs { // Query soundness is computed at the old rate, while all fold and OOD terms use the new rate. let new_unique_decoding = round.irs_committer.unique_decoding(); if !new_unique_decoding { - let ood_error = round.irs_committer.rbr_ood_sample(); + let ood_error = rbr_ood_sample( + round.irs_committer.list_size(), + field_size_bits, + round.irs_committer.vector_size(), + round.out_domain_samples, + ); security_level = security_level.min(ood_error); } let log_list_size = round.irs_committer.list_size().log2(); let combination_error = { - let count = round.irs_committer.out_domain_samples + old_in_domain_samples; + let count = round.out_domain_samples + old_in_domain_samples; let log_combination = (count as f64).log2(); field_size_bits - (log_combination + log_list_size + 1.) }; @@ -217,19 +257,19 @@ impl Config { let prox_gaps_error = round.irs_committer.rbr_soundness_fold_prox_gaps(); let sumcheck_error = field_size_bits - (log_list_size + 1.); let round_fold_error = prox_gaps_error.min(sumcheck_error) - + f64::from(round.sumcheck.round_pow.difficulty()); + + f64::from(round.sumcheck.round_pow().difficulty()); security_level = security_level.min(round_fold_error); - old_in_domain_samples = round.irs_committer.in_domain_samples; + old_in_domain_samples = round.irs_committer.in_domain_samples(); rbr_queries = round.irs_committer.rbr_queries(); } let final_query_error = rbr_queries + f64::from(self.final_pow.difficulty()); security_level = security_level.min(final_query_error); - if self.final_sumcheck.num_rounds > 0 { + if self.final_sumcheck.num_rounds() > 0 { let final_combination_error = - field_size_bits - 1. + f64::from(self.final_sumcheck.round_pow.difficulty()); + field_size_bits - 1. + f64::from(self.final_sumcheck.round_pow().difficulty()); security_level = security_level.min(final_combination_error); } @@ -241,21 +281,21 @@ impl Config { } pub fn check_max_pow_bits(&self, max_bits: Bits) -> bool { - if self.initial_sumcheck.round_pow.difficulty() > max_bits { + if self.initial_sumcheck.round_pow().difficulty() > max_bits { return false; } for round_config in &self.round_configs { if round_config.pow.difficulty() > max_bits { return false; } - if round_config.sumcheck.round_pow.difficulty() > max_bits { + if round_config.sumcheck.round_pow().difficulty() > max_bits { return false; } } if self.final_pow.difficulty() > max_bits { return false; } - if self.final_sumcheck.round_pow.difficulty() > max_bits { + if self.final_sumcheck.round_pow().difficulty() > max_bits { return false; } true @@ -266,7 +306,7 @@ impl Config { } pub const fn initial_size(&self) -> usize { - self.initial_committer.vector_size + self.initial_committer.vector_size() } pub fn initial_num_variables(&self) -> usize { @@ -289,7 +329,7 @@ impl Display for Config { writeln!( f, "Security level: {:.2} bits using {} decoding", - self.security_level(self.initial_committer.num_vectors, 1), + self.security_level(self.initial_committer.num_vectors(), 1), if self.unique_decoding() { "unique" } else { @@ -319,7 +359,7 @@ impl Display for Config { writeln!(f, "------------------------------------")?; let field_size_bits = M::Target::field_size_bits(); - let num_vectors = self.initial_committer.num_vectors; + let num_vectors = self.initial_committer.num_vectors(); let num_linear_forms = 10; // TODO if num_vectors > 1 { let rlc_error = field_size_bits - ((num_vectors - 1) as f64).log2(); @@ -347,7 +387,12 @@ impl Display for Config { writeln!( f, "{:.1} bits -- OOD commitment", - self.initial_committer.rbr_ood_sample() + rbr_ood_sample( + self.initial_committer.list_size(), + field_size_bits, + self.initial_committer.vector_size(), + self.initial_out_domain_samples, + ) )?; } let prox_gaps_error = self.initial_committer.rbr_soundness_fold_prox_gaps(); @@ -357,28 +402,33 @@ impl Display for Config { f, "{:.1} bits -- (x{}) prox gaps: {:.1}, sumcheck: {:.1}, pow: {:.1}, list size 2^{:.1}", prox_gaps_error.min(sumcheck_error) - + f64::from(self.initial_sumcheck.round_pow.difficulty()), - self.initial_sumcheck.num_rounds, + + f64::from(self.initial_sumcheck.round_pow().difficulty()), + self.initial_sumcheck.num_rounds(), prox_gaps_error, sumcheck_error, - self.initial_sumcheck.round_pow.difficulty(), + self.initial_sumcheck.round_pow().difficulty(), log_list_size, )?; let mut query_error = self.initial_committer.rbr_queries(); - let mut old_in_domain_samples = self.initial_committer.in_domain_samples; + let mut old_in_domain_samples = self.initial_committer.in_domain_samples(); for r in &self.round_configs { if !r.irs_committer.unique_decoding() { writeln!( f, "{:.1} bits -- OOD sample", - r.irs_committer.rbr_ood_sample() + rbr_ood_sample( + r.irs_committer.list_size(), + field_size_bits, + r.irs_committer.vector_size(), + r.out_domain_samples, + ) )?; } let log_list_size = r.irs_committer.list_size().log2(); let combination_error = { - let count = r.irs_committer.out_domain_samples + old_in_domain_samples; + let count = r.out_domain_samples + old_in_domain_samples; let log_combination = (count as f64).log2(); field_size_bits - (log_combination + log_list_size + 1.) }; @@ -396,15 +446,15 @@ impl Display for Config { writeln!( f, "{:.1} bits -- (x{}) prox gaps: {:.1}, sumcheck: {:.1}, pow: {:.1}, list size 2^{:.1}", - prox_gaps_error.min(sumcheck_error) + f64::from(r.sumcheck.round_pow.difficulty()), - r.sumcheck.num_rounds, + prox_gaps_error.min(sumcheck_error) + f64::from(r.sumcheck.round_pow().difficulty()), + r.sumcheck.num_rounds(), prox_gaps_error, sumcheck_error, - r.sumcheck.round_pow.difficulty(), + r.sumcheck.round_pow().difficulty(), log_list_size )?; - old_in_domain_samples = r.irs_committer.in_domain_samples; + old_in_domain_samples = r.irs_committer.in_domain_samples(); query_error = r.irs_committer.rbr_queries(); } @@ -416,15 +466,15 @@ impl Display for Config { self.final_pow.difficulty(), )?; - if self.final_sumcheck.num_rounds > 0 { + if self.final_sumcheck.num_rounds() > 0 { let combination_error = field_size_bits - 1.; writeln!( f, "{:.1} bits -- (x{}) combination: {:.1}, pow: {:.1}", - combination_error + f64::from(self.final_sumcheck.round_pow.difficulty()), - self.final_sumcheck.num_rounds, + combination_error + f64::from(self.final_sumcheck.round_pow().difficulty()), + self.final_sumcheck.num_rounds(), combination_error, - self.final_sumcheck.round_pow.difficulty(), + self.final_sumcheck.round_pow().difficulty(), )?; } @@ -434,8 +484,11 @@ impl Display for Config { impl RoundConfig { pub fn initial_size(&self) -> usize { - assert_eq!(self.irs_committer.vector_size, self.sumcheck.initial_size); - self.sumcheck.initial_size + assert_eq!( + self.irs_committer.vector_size(), + self.sumcheck.initial_size() + ); + self.sumcheck.initial_size() } pub fn final_size(&self) -> usize { @@ -443,12 +496,12 @@ impl RoundConfig { } pub fn initial_num_variables(&self) -> usize { - assert!(self.irs_committer.vector_size.is_power_of_two()); - self.irs_committer.vector_size.ilog2() as usize + assert!(self.irs_committer.vector_size().is_power_of_two()); + self.irs_committer.vector_size().ilog2() as usize } pub fn final_num_variables(&self) -> usize { - self.initial_num_variables() - self.sumcheck.num_rounds + self.initial_num_variables() - self.sumcheck.num_rounds() } } @@ -462,8 +515,6 @@ impl Display for RoundConfig { #[cfg(test)] mod tests { - use ordered_float::OrderedFloat; - use super::*; use crate::{ algebra::{ @@ -472,11 +523,24 @@ mod tests { }, bits::Bits, hash, - protocols::matrix_commit, - type_info::Typed, utils::test_serde, }; + /// IRS committer fixture for pow-bit tests; `check_max_pow_bits` never + /// inspects it, so any well-formed config works. + fn test_irs_committer(log_inv_rate: i32) -> irs_commit::Config> { + irs_commit::Config::new(irs_commit::IrsParams { + security_target: 60.0, + decoding_regime: crate::protocols::params::DecodingRegime::Unique, + hash_id: hash::BLAKE3, + num_vectors: 1, + vector_size: 1 << 10, + interleaving_depth: 1 << 2, + rate: 0.5_f64.powi(log_inv_rate), + mode: IrsMode::Standard, + }) + } + /// Generates default WHIR parameters fn default_whir_params() -> ProtocolParameters { ProtocolParameters { @@ -484,7 +548,7 @@ mod tests { pow_bits: 20, initial_folding_factor: 4, folding_factor: 4, - unique_decoding: false, + decoding_regime: crate::protocols::params::DecodingRegime::Johnson, starting_log_inv_rate: 1, batch_size: 1, hash_id: hash::BLAKE3, @@ -519,56 +583,36 @@ mod tests { let mut config = Config::>::new(1 << 10, ¶ms); // Set all values within limits - config.initial_sumcheck.round_pow = proof_of_work::Config::from_difficulty(Bits::new(15.0)); + config + .initial_sumcheck + .override_round_pow_for_test(proof_of_work::Config::from_difficulty(Bits::new(15.0))); config.final_pow = proof_of_work::Config::from_difficulty(Bits::new(18.0)); - config.final_sumcheck.round_pow = proof_of_work::Config::from_difficulty(Bits::new(19.5)); + config + .final_sumcheck + .override_round_pow_for_test(proof_of_work::Config::from_difficulty(Bits::new(19.5))); // Ensure all rounds are within limits config.round_configs = vec![ RoundConfig { - irs_committer: irs_commit::Config { - embedding: Typed::new(embedding::Identity::new()), - num_vectors: 1, - vector_size: 1 << 10, - mask_length: 0, - codeword_length: 1 << (10 + 3 - 2), - interleaving_depth: 1 << 2, - matrix_commit: matrix_commit::Config::::new(0, 0), - johnson_slack: OrderedFloat::default(), - in_domain_samples: 5, - out_domain_samples: 2, - deduplicate_in_domain: true, - }, - sumcheck: sumcheck::Config { - field: Type::::new(), - initial_size: 1 << 10, - round_pow: proof_of_work::Config::from_difficulty(Bits::new(19.0)), - num_rounds: 2, - mask_length: 0, - }, + irs_committer: test_irs_committer(3), + out_domain_samples: 2, + sumcheck: sumcheck::Config::::new( + 1 << 10, + proof_of_work::Config::from_difficulty(Bits::new(19.0)), + 2, + sumcheck::SumcheckMode::Standard, + ), pow: proof_of_work::Config::from_difficulty(Bits::new(17.0)), }, RoundConfig { - irs_committer: irs_commit::Config { - embedding: Typed::new(embedding::Identity::new()), - num_vectors: 1, - vector_size: 1 << 10, - mask_length: 0, - codeword_length: 1 << (10 + 4 - 2), - interleaving_depth: 1 << 2, - matrix_commit: matrix_commit::Config::::new(0, 0), - johnson_slack: OrderedFloat::default(), - in_domain_samples: 6, - out_domain_samples: 2, - deduplicate_in_domain: true, - }, - sumcheck: sumcheck::Config { - field: Type::::new(), - initial_size: 1 << 10, - round_pow: proof_of_work::Config::from_difficulty(Bits::new(19.5)), - num_rounds: 2, - mask_length: 0, - }, + irs_committer: test_irs_committer(4), + out_domain_samples: 2, + sumcheck: sumcheck::Config::::new( + 1 << 10, + proof_of_work::Config::from_difficulty(Bits::new(19.5)), + 2, + sumcheck::SumcheckMode::Standard, + ), pow: proof_of_work::Config::from_difficulty(Bits::new(18.0)), }, ]; @@ -584,9 +628,13 @@ mod tests { let params = default_whir_params(); let mut config = Config::>::new(1 << 10, ¶ms); - config.initial_sumcheck.round_pow = proof_of_work::Config::from_difficulty(Bits::new(21.0)); + config + .initial_sumcheck + .override_round_pow_for_test(proof_of_work::Config::from_difficulty(Bits::new(21.0))); config.final_pow = proof_of_work::Config::from_difficulty(Bits::new(18.0)); - config.final_sumcheck.round_pow = proof_of_work::Config::from_difficulty(Bits::new(19.5)); + config + .final_sumcheck + .override_round_pow_for_test(proof_of_work::Config::from_difficulty(Bits::new(19.5))); assert!( !config.check_max_pow_bits(Bits::new(20.0)), diff --git a/src/protocols/whir/mod.rs b/src/protocols/whir/mod.rs index 805a206e..68a950ce 100644 --- a/src/protocols/whir/mod.rs +++ b/src/protocols/whir/mod.rs @@ -17,6 +17,7 @@ use crate::{ embedding::{Embedding, Identity}, linear_form::LinearForm, }, + buffer::ActiveBuffer, hash::Hash, protocols::{irs_commit, proof_of_work, sumcheck}, transcript::{ @@ -30,6 +31,9 @@ use crate::{ #[serde(bound = "")] pub struct Config { pub initial_committer: irs_commit::Config, + /// OOD samples on the initial commit (Construction 9.7-style OOD step, + /// formerly inside `irs_commit::commit`). + pub initial_out_domain_samples: usize, pub initial_sumcheck: sumcheck::Config, pub initial_skip_pow: proof_of_work::Config, pub round_configs: Vec>, @@ -41,12 +45,42 @@ pub struct Config { #[serde(bound = "")] pub struct RoundConfig { pub irs_committer: irs_commit::Config>, + /// OOD samples for this round's commit. + pub out_domain_samples: usize, pub sumcheck: sumcheck::Config, pub pow: proof_of_work::Config, } -pub type Witness> = irs_commit::Witness; -pub type Commitment = irs_commit::Commitment; +/// WHIR-level witness: IRS witness + OOD evaluations sampled at commit time. +#[derive(Clone, PartialEq, Eq, Debug, Hash, Serialize, Deserialize)] +#[serde(bound( + serialize = "M::Source: Serialize, F: Serialize", + deserialize = "M::Source: Deserialize<'de>, F: Deserialize<'de>" +))] +pub struct Witness> { + pub irs: irs_commit::Witness, + pub out_of_domain: irs_commit::Evaluations, +} + +/// WHIR-level commitment: IRS commitment + OOD evaluations received at commit time. +#[derive(Clone, PartialEq, Eq, Debug, Hash, Serialize, Deserialize)] +#[serde(bound(serialize = "F: Serialize", deserialize = "F: Deserialize<'de>"))] +pub struct Commitment { + pub irs: irs_commit::Commitment, + pub out_of_domain: irs_commit::Evaluations, +} + +impl> Witness { + pub fn num_vectors(&self) -> usize { + self.out_of_domain.num_columns() + } +} + +impl Commitment { + pub fn num_vectors(&self) -> usize { + self.out_of_domain.num_columns() + } +} #[must_use = "The final claim must be checked if there where any linear forms."] #[derive(Debug, Clone, PartialEq, Eq, Default)] @@ -75,11 +109,15 @@ impl FinalClaim { impl Config { /// Commit to one or more vectors. - #[cfg_attr(feature = "tracing", instrument(skip_all, fields(size = vectors.first().unwrap().len())))] + /// + /// After the IRS commit, runs the legacy WHIR OOD step: samples + /// `initial_out_domain_samples` random points from the verifier and sends + /// each vector's evaluation at each point. + #[cfg_attr(feature = "tracing", instrument(skip_all, fields(size = vectors.first().map_or(0, |v| crate::buffer::BufferOps::len(*v)))))] pub fn commit( &self, prover_state: &mut ProverState, - vectors: &[&[M::Source]], + vectors: &[&ActiveBuffer], ) -> Witness where Standard: Distribution, @@ -88,7 +126,12 @@ impl Config { M::Target: Codec<[H::U]>, Hash: ProverMessage<[H::U]>, { - self.initial_committer.commit(prover_state, vectors) + let (irs, out_of_domain) = self.initial_committer.commit_with_ood( + prover_state, + vectors, + self.initial_out_domain_samples, + ); + Witness { irs, out_of_domain } } /// Receive a commitment to vectors. @@ -101,19 +144,22 @@ impl Config { M::Target: Codec<[H::U]>, Hash: ProverMessage<[H::U]>, { - self.initial_committer.receive_commitment(verifier_state) + let (irs, out_of_domain) = self + .initial_committer + .receive_commitment_with_ood(verifier_state, self.initial_out_domain_samples)?; + Ok(Commitment { irs, out_of_domain }) } /// Disable proof-of-work for test. #[cfg(test)] pub(crate) fn disable_pow(&mut self) { - self.initial_sumcheck.round_pow.threshold = u64::MAX; + self.initial_sumcheck.round_pow().threshold = u64::MAX; self.initial_skip_pow.threshold = u64::MAX; for round in &mut self.round_configs { - round.sumcheck.round_pow.threshold = u64::MAX; + round.sumcheck.round_pow().threshold = u64::MAX; round.pow.threshold = u64::MAX; } - self.final_sumcheck.round_pow.threshold = u64::MAX; + self.final_sumcheck.round_pow().threshold = u64::MAX; self.final_pow.threshold = u64::MAX; } } @@ -133,8 +179,10 @@ mod tests { linear_form::{Covector, Evaluate, LinearForm, MultilinearExtension}, random_vector, }, + buffer::{ActiveBuffer, BufferOps}, hash, parameters::ProtocolParameters, + protocols::params::DecodingRegime, transcript::{codecs::Empty, DomainSeparator, ProverState, VerifierState}, utils::test_serde, }; @@ -178,7 +226,7 @@ mod tests { initial_folding_factor: usize, folding_factor: usize, num_points: usize, - unique_decoding: bool, + decoding_regime: DecodingRegime, pow_bits: usize, ) { // Number of coefficients in the multilinear polynomial (2^num_variables) @@ -190,7 +238,7 @@ mod tests { pow_bits, initial_folding_factor, folding_factor, - unique_decoding, + decoding_regime, starting_log_inv_rate: 1, batch_size: 1, hash_id: hash::SHA2, @@ -239,15 +287,16 @@ mod tests { let mut prover_state = ProverState::new_std(&ds); // Commit to the polynomial and generate auxiliary witness data - let witness = params.commit(&mut prover_state, &[&vector]); + let vector_buffer = ActiveBuffer::from_slice(&vector); + let witness = params.commit(&mut prover_state, &[&vector_buffer]); let prove_linear_forms = build_prove_forms(&points, num_variables, true); // Generate a proof for the given statement and witness let _ = params.prove( &mut prover_state, - vec![Cow::from(vector)], - vec![Cow::Owned(witness)], + &[&vector_buffer], + vec![&witness], prove_linear_forms, Cow::Borrowed(evaluations.as_slice()), ); @@ -276,14 +325,14 @@ mod tests { let num_variables = folding_factor..=3 * folding_factor; for num_variable in num_variables { for num_points in [0, 1, 2] { - for unique_decoding in [true, false] { + for decoding_regime in [DecodingRegime::Unique, DecodingRegime::Johnson] { for pow_bits in [0, 5, 10] { eprintln!(); dbg!( folding_factor, num_variable, num_points, - unique_decoding, + decoding_regime, pow_bits ); @@ -292,7 +341,7 @@ mod tests { folding_factor, folding_factor, num_points, - unique_decoding, + decoding_regime, pow_bits, ); } @@ -304,7 +353,7 @@ mod tests { #[test] fn test_fail() { - make_whir_things(3, 2, 2, 0, false, 0); + make_whir_things(3, 2, 2, 0, DecodingRegime::Johnson, 0); } #[test] @@ -334,7 +383,7 @@ mod tests { initial_folding_factor, folding_factor, num_points, - false, + DecodingRegime::Johnson, 5, ); } @@ -353,7 +402,7 @@ mod tests { folding_factor: usize, num_points_per_poly: usize, num_vectors: usize, - unique_decoding: bool, + decoding_regime: DecodingRegime, pow_bits: usize, ) { let num_coeffs = 1 << num_variables; @@ -363,7 +412,7 @@ mod tests { pow_bits, initial_folding_factor, folding_factor, - unique_decoding, + decoding_regime, starting_log_inv_rate: 1, batch_size: 1, hash_id: hash::SHA2, @@ -380,7 +429,7 @@ mod tests { vec![F::from((i + 1) as u64); num_coeffs] }) .collect(); - let vec_refs = vectors.iter().map(|v| v.as_slice()).collect::>(); + let vec_refs = vectors.iter().collect::>(); let points: Vec<_> = (0..num_points_per_poly) .map(|_| random_vector(thread_rng(), num_variables)) @@ -401,7 +450,7 @@ mod tests { .flat_map(|linear_form| { vec_refs .iter() - .map(|vec| linear_form.evaluate(params.embedding(), vec)) + .map(|&vec| linear_form.evaluate(params.embedding(), vec)) }) .collect::>(); @@ -412,8 +461,12 @@ mod tests { let mut prover_state = ProverState::new_std(&ds); // Commit to each polynomial and generate witnesses + let vector_buffers = vectors + .iter() + .map(|v| ActiveBuffer::from_slice(v)) + .collect::>(); let mut witnesses = Vec::new(); - for &vec in &vec_refs { + for vec in &vector_buffers { let witness = params.commit(&mut prover_state, &[vec]); witnesses.push(witness); } @@ -423,11 +476,8 @@ mod tests { // Batch prove all polynomials together let _ = params.prove( &mut prover_state, - vectors - .iter() - .map(|v| Cow::Borrowed(v.as_slice())) - .collect(), - witnesses.into_iter().map(Cow::Owned).collect(), + &vector_buffers.iter().collect::>(), + witnesses.iter().collect(), prove_linear_forms, Cow::Borrowed(evaluations.as_slice()), ); @@ -484,7 +534,7 @@ mod tests { folding_factor, num_points_per_poly, num_polys, - false, + DecodingRegime::Johnson, 0, // pow_bits ); } @@ -503,7 +553,8 @@ mod tests { 2, // folding_factor 2, // num_points_per_poly 1, // num_polynomials (single!) - false, 0, + DecodingRegime::Johnson, + 0, ); } @@ -531,7 +582,7 @@ mod tests { pow_bits: 0, initial_folding_factor, folding_factor, - unique_decoding: false, + decoding_regime: DecodingRegime::Johnson, starting_log_inv_rate: 1, batch_size: 1, hash_id: hash::SHA2, @@ -569,16 +620,19 @@ mod tests { .instance(&Empty); let mut prover_state = ProverState::new_std(&ds); - let witness1 = params.commit(&mut prover_state, &[&vec1]); - let witness2 = params.commit(&mut prover_state, &[&vec2]); + let vec1_buffer = ActiveBuffer::from_slice(&vec1); + let vec2_buffer = ActiveBuffer::from_slice(&vec2); + let witness1 = params.commit(&mut prover_state, &[&vec1_buffer]); + let witness2 = params.commit(&mut prover_state, &[&vec2_buffer]); let prove_linear_forms = build_prove_forms(&constraint_points, num_variables, false); // Generate proof with mismatched polynomials + let vec_wrong_buffer = ActiveBuffer::from_vec(vec_wrong); let _ = params.prove( &mut prover_state, - vec![Cow::Borrowed(vec1.as_slice()), Cow::from(vec_wrong)], - vec![Cow::Owned(witness1), Cow::Owned(witness2)], + &[&vec1_buffer, &vec_wrong_buffer], + vec![&witness1, &witness2], prove_linear_forms, Cow::Borrowed(evaluations.as_slice()), ); @@ -627,7 +681,7 @@ mod tests { num_points_per_poly: usize, num_witnesses: usize, batch_size: usize, - unique_decoding: bool, + decoding_regime: DecodingRegime, pow_bits: usize, ) { let num_coeffs = 1 << num_variables; @@ -637,7 +691,7 @@ mod tests { pow_bits, initial_folding_factor, folding_factor, - unique_decoding, + decoding_regime, starting_log_inv_rate: 1, batch_size, // KEY: batch_size > 1 hash_id: hash::SHA2, @@ -651,7 +705,7 @@ mod tests { let all_vectors: Vec> = (0..num_witnesses * batch_size) .map(|i| vec![F::from((i + 1) as u64); num_coeffs]) .collect::>(); - let vec_refs = all_vectors.iter().map(|p| p.as_slice()).collect::>(); + let vec_refs = all_vectors.iter().collect::>(); let points: Vec<_> = (0..num_points_per_poly) .map(|_| random_vector(thread_rng(), num_variables)) @@ -672,7 +726,7 @@ mod tests { .flat_map(|linear_form| { vec_refs .iter() - .map(|vec| linear_form.evaluate(params.embedding(), vec)) + .map(|&vec| linear_form.evaluate(params.embedding(), vec)) }) .collect::>(); @@ -683,8 +737,13 @@ mod tests { let mut prover_state = ProverState::new_std(&ds); // Commit using commit_batch (stacks batch_size polynomials per witness) + let vector_buffers = all_vectors + .iter() + .map(|v| ActiveBuffer::from_slice(v)) + .collect::>(); + let buffer_refs = vector_buffers.iter().collect::>(); let mut witnesses = Vec::new(); - for witness_polys in vec_refs.chunks(batch_size) { + for witness_polys in buffer_refs.chunks(batch_size) { let witness = params.commit(&mut prover_state, witness_polys); witnesses.push(witness); } @@ -694,11 +753,8 @@ mod tests { // Batch prove all witnesses together let _ = params.prove( &mut prover_state, - all_vectors - .iter() - .map(|v| Cow::Borrowed(v.as_slice())) - .collect(), - witnesses.into_iter().map(Cow::Owned).collect(), + &vector_buffers.iter().collect::>(), + witnesses.iter().collect(), prove_linear_forms, Cow::Borrowed(evaluations.as_slice()), ); @@ -743,7 +799,7 @@ mod tests { 1, // num_points_per_poly num_witness, batch_size, - false, + DecodingRegime::Johnson, 0, // pow_bits ); } @@ -758,7 +814,7 @@ mod tests { initial_folding_factor: usize, folding_factor: usize, num_points: usize, - unique_decoding: bool, + decoding_regime: DecodingRegime, pow_bits: usize, ) { eprintln!("\n---------------------"); @@ -768,7 +824,7 @@ mod tests { eprintln!(" initial_folding : {initial_folding_factor}"); eprintln!(" folding_factor : {folding_factor}"); eprintln!(" num_points : {num_points:?}"); - eprintln!(" unique_decoding : {unique_decoding:?}"); + eprintln!(" decoding_regime : {decoding_regime:?}"); eprintln!(" pow_bits : {pow_bits}"); // Number of coefficients in the multilinear polynomial (2^num_variables) @@ -780,7 +836,7 @@ mod tests { pow_bits, initial_folding_factor, folding_factor, - unique_decoding, + decoding_regime, starting_log_inv_rate: 1, batch_size, hash_id: hash::SHA2, @@ -793,7 +849,7 @@ mod tests { let vectors: Vec> = (0..batch_size) .map(|_| random_vector(thread_rng(), num_coeffs)) .collect(); - let vec_refs = vectors.iter().map(|v| v.as_slice()).collect::>(); + let vec_refs = vectors.iter().collect::>(); // Generate `num_points` random points in the multilinear domain let points: Vec<_> = (0..num_points) @@ -809,7 +865,12 @@ mod tests { let mut prover_state = ProverState::new_std(&ds); // Create a commitment to the polynomial and generate auxiliary witness data - let batched_witness = params.commit(&mut prover_state, &vec_refs); + let vector_buffers = vectors + .iter() + .map(|v| ActiveBuffer::from_slice(v)) + .collect::>(); + let buffer_refs = vector_buffers.iter().collect::>(); + let batched_witness = params.commit(&mut prover_state, &buffer_refs); // Create a weights matrix and evaluations for each polynomial let mut linear_forms: Vec>>> = Vec::new(); @@ -839,11 +900,8 @@ mod tests { .collect::>(); let _ = params.prove( &mut prover_state, - vectors - .iter() - .map(|v| Cow::Borrowed(v.as_slice())) - .collect(), - vec![Cow::Owned(batched_witness)], + &buffer_refs, + vec![&batched_witness], prove_linear_forms, Cow::Borrowed(values.as_slice()), ); @@ -865,7 +923,7 @@ mod tests { #[test] fn test_batched_whir() { let folding_factors = [1, 4]; - let unique_decoding_options = [false, true]; + let decoding_regime_options = [DecodingRegime::Johnson, DecodingRegime::Unique]; let num_points = [0, 2]; let pow_bits = [0, 10]; @@ -873,7 +931,7 @@ mod tests { let num_variables = (2 * folding_factor)..=3 * folding_factor; for num_variable in num_variables { for num_points in num_points { - for unique_decoding in unique_decoding_options { + for decoding_regime in decoding_regime_options { for pow_bits in pow_bits { for batch_size in 1..=4 { make_batched_whir_things( @@ -882,7 +940,7 @@ mod tests { folding_factor, folding_factor, num_points, - unique_decoding, + decoding_regime, pow_bits, ); } diff --git a/src/protocols/whir/prover.rs b/src/protocols/whir/prover.rs index 87962f44..0710d0ee 100644 --- a/src/protocols/whir/prover.rs +++ b/src/protocols/whir/prover.rs @@ -1,4 +1,4 @@ -use std::{any::Any, borrow::Cow, mem}; +use std::borrow::Cow; use ark_ff::{AdditiveGroup, Field}; use ark_std::rand::{distributions::Standard, prelude::Distribution, CryptoRng, RngCore}; @@ -7,15 +7,8 @@ use tracing::instrument; use super::{Config, Witness}; use crate::{ - algebra::{ - dot, - embedding::Embedding, - eq_weights, lift, - linear_form::{Covector, Evaluate, LinearForm, UnivariateEvaluation}, - mixed_scalar_mul_add, - sumcheck::fold, - tensor_product, - }, + algebra::{dot, embedding::Embedding, eq_weights, linear_form::LinearForm, tensor_product}, + buffer::{ActiveBuffer, Buffer, BufferOps}, hash::Hash, protocols::{geometric_challenge::geometric_challenge, irs_commit, whir::FinalClaim}, transcript::{ @@ -26,8 +19,8 @@ use crate::{ }; enum RoundWitness<'a, F: Field, M: Embedding> { - Initial(Vec>>), - Round(irs_commit::Witness), + Initial(Vec<&'a Witness>), + Round(irs_commit::Witness), } impl Config { @@ -50,8 +43,8 @@ impl Config { pub fn prove<'a, H, R>( &self, prover_state: &mut ProverState, - vectors: Vec>, - witnesses: Vec>>, + vectors: &[&ActiveBuffer], + witnesses: Vec<&'a Witness>, linear_forms: Vec>>, evaluations: Cow<'a, [M::Target]>, ) -> FinalClaim @@ -70,10 +63,10 @@ impl Config { // Input validation assert_eq!( num_vectors, - witnesses.len() * self.initial_committer.num_vectors + witnesses.len() * self.initial_committer.num_vectors() ); assert_eq!(evaluations.len(), num_vectors * linear_forms.len()); - for vector in &vectors { + for vector in vectors { assert_eq!(vector.len(), self.initial_size()); } for linear_form in &linear_forms { @@ -85,8 +78,14 @@ impl Config { { use crate::algebra::linear_form::Covector; let covector = Covector::from(&**linear_form); - for (vector, evaluation) in zip_strict(&vectors, evaluations) { - debug_assert_eq!(covector.evaluate(self.embedding(), vector), *evaluation); + for (vector, evaluation) in zip_strict(vectors, evaluations) { + debug_assert_eq!( + vector.mixed_dot( + self.embedding(), + &ActiveBuffer::from_slice(covector.vector.as_slice()) + ), + *evaluation + ); } } if vectors.is_empty() { @@ -103,19 +102,20 @@ impl Config { let mut vector_offset = 0; for witness in &witnesses { for (oods_eval, oods_row) in zip_strict( - witness.out_of_domain().evaluators(self.initial_size()), - witness.out_of_domain().rows(), + witness.out_of_domain.evaluators(self.initial_size()), + witness.out_of_domain.rows(), ) { for (j, vector) in vectors.iter().enumerate() { if j >= vector_offset && j < oods_row.len() + vector_offset { debug_assert_eq!( oods_row[j - vector_offset], - oods_eval.evaluate(self.embedding(), vector) + vector.mixed_univariate_evaluate(self.embedding(), oods_eval.point) ); oods_matrix.push(oods_row[j - vector_offset]); } else { - let eval = oods_eval.evaluate(self.embedding(), vector); + let eval = + vector.mixed_univariate_evaluate(self.embedding(), oods_eval.point); prover_state.prover_message(&eval); oods_matrix.push(eval); } @@ -130,16 +130,11 @@ impl Config { // Random linear combination of the vectors. let mut vector_rlc_coeffs: Vec = geometric_challenge(prover_state, num_vectors); assert_eq!(vector_rlc_coeffs[0], M::Target::ONE); - // Recycle the first input as the accumulator (its coefficient is always ONE). - let mut vectors = vectors.into_iter(); - let first = vectors.next().expect("non-empty"); - let mut vector = match first { - Cow::Borrowed(slice) => lift(self.embedding(), slice), - Cow::Owned(vec) => self.embedding().map_vec(vec), - }; - for (rlc_coeff, input_vector) in zip_strict(&vector_rlc_coeffs[1..], vectors) { - mixed_scalar_mul_add(self.embedding(), &mut vector, *rlc_coeff, &input_vector); - } + let mut vector = ActiveBuffer::::mixed_linear_combination( + self.embedding(), + vectors, + &vector_rlc_coeffs, + ); let mut prev_witness: RoundWitness<'a, M::Target, M> = RoundWitness::Initial(witnesses); @@ -149,26 +144,16 @@ impl Config { let has_constraints = !constraint_rlc_coeffs.is_empty(); let (initial_forms_rlc_coeffs, oods_rlc_coeffs) = constraint_rlc_coeffs.split_at(linear_forms.len()); - // Try to recycle the first linear form as Covector. - let mut covector = vec![]; let mut linear_forms = linear_forms; - if let Some((first, linear_forms)) = linear_forms.split_first_mut() { - debug_assert_eq!(initial_forms_rlc_coeffs[0], M::Target::ONE); - if let Some(covector_form) = - (first.as_mut() as &mut dyn Any).downcast_mut::>() - { - mem::swap(&mut covector, &mut covector_form.vector); - } else { - covector.resize(self.initial_size(), M::Target::ZERO); - first.accumulate(&mut covector, M::Target::ONE); - } - for (rlc_coeff, linear_form) in zip_strict(&initial_forms_rlc_coeffs[1..], linear_forms) - { - linear_form.accumulate(&mut covector, *rlc_coeff); - } - } else if has_constraints { - covector.resize(self.initial_size(), M::Target::ZERO); - } + let mut covector = if has_constraints { + ActiveBuffer::::linear_forms_rlc( + self.initial_size(), + &mut linear_forms, + initial_forms_rlc_coeffs, + ) + } else { + ActiveBuffer::::zeros(0) + }; drop(linear_forms); // Compute "The Sum" @@ -180,47 +165,51 @@ impl Config { .sum(); drop(evaluations); - debug_assert!(!has_constraints || dot(&vector, &covector) == the_sum); + debug_assert!(!has_constraints || vector.dot(&covector) == the_sum); // Add OODS constraints - UnivariateEvaluation::accumulate_many(&oods_evals, &mut covector, oods_rlc_coeffs); + covector.accumulate_univariate_evaluations(&oods_evals, oods_rlc_coeffs); the_sum += zip_strict(oods_rlc_coeffs, oods_matrix.chunks_exact(num_vectors)) .map(|(poly_coeff, row)| *poly_coeff * dot(&vector_rlc_coeffs, row)) .sum::(); drop(oods_evals); drop(oods_matrix); - debug_assert!(!has_constraints || dot(&vector, &covector) == the_sum); + debug_assert!(!has_constraints || vector.dot(&covector) == the_sum); // Run initial sumcheck on batched vectors with combined statement let mut folding_randomness = if has_constraints { self.initial_sumcheck .prove(prover_state, &mut vector, &mut covector, &mut the_sum, &[]) - .0 + .round_challenges } else { // There are no constraints yet, so we can skip the sumcheck. // (If we did run it, all sumcheck vectors would be constant zero) // TODO: Don't compute evaluations and constraints in the first place. - let folding_randomness = (0..self.initial_sumcheck.num_rounds) + let folding_randomness = (0..self.initial_sumcheck.num_rounds()) .map(|_| prover_state.verifier_message()) .collect(); self.initial_skip_pow.prove(prover_state); // Fold vector for &f in &folding_randomness { - fold(&mut vector, f); + vector.fold(f); } // Covector must be all zeros. - covector = vec![M::Target::ZERO; self.initial_sumcheck.final_size()]; + covector = ActiveBuffer::::zeros(self.initial_sumcheck.final_size()); folding_randomness }; let mut evaluation_point = folding_randomness.clone(); - debug_assert_eq!(dot(&vector, &covector), the_sum); + debug_assert_eq!(vector.dot(&covector), the_sum); // Execute standard WHIR rounds on the batched vectors for (round_index, round_config) in self.round_configs.iter().enumerate() { - // Commit to the vector, this generates out-of-domain evaluations. - let new_witness = round_config.irs_committer.commit(prover_state, &[&vector]); + // Commit to the folded vector and run the per-round OOD step. + let (new_witness, out_of_domain) = round_config.irs_committer.commit_with_ood( + prover_state, + &[&vector], + round_config.out_domain_samples, + ); // Proof of work before in-domain challenges round_config.pow.prove(prover_state); @@ -228,9 +217,9 @@ impl Config { // Open the previous round's witness. let in_domain = match prev_witness { RoundWitness::Initial(init_witnesses) => { - let witness_refs: Vec<&_> = init_witnesses.iter().map(|c| &**c).collect(); + let irs_refs: Vec<&_> = init_witnesses.iter().map(|c| &c.irs).collect(); self.initial_committer - .open(prover_state, &witness_refs) + .open(prover_state, &irs_refs) .lift(self.embedding()) } RoundWitness::Round(old_witness) => { @@ -242,13 +231,11 @@ impl Config { }; // Collect constraints for this round and RLC them in - let stir_challenges = new_witness - .out_of_domain() + let stir_challenges = out_of_domain .evaluators(round_config.initial_size()) .chain(in_domain.evaluators(round_config.initial_size())) .collect::>(); - let stir_evaluations = new_witness - .out_of_domain() + let stir_evaluations = out_of_domain .values(&[M::Target::ONE]) .chain(in_domain.values(&tensor_product( &vector_rlc_coeffs, @@ -256,30 +243,26 @@ impl Config { ))) .collect::>(); let stir_rlc_coeffs = geometric_challenge(prover_state, stir_challenges.len()); - UnivariateEvaluation::accumulate_many( - &stir_challenges, - &mut covector, - &stir_rlc_coeffs, - ); + covector.accumulate_univariate_evaluations(&stir_challenges, &stir_rlc_coeffs); the_sum += dot(&stir_rlc_coeffs, &stir_evaluations); - debug_assert_eq!(dot(&vector, &covector), the_sum); + debug_assert_eq!(vector.dot(&covector), the_sum); // Run sumcheck for this round folding_randomness = round_config .sumcheck .prove(prover_state, &mut vector, &mut covector, &mut the_sum, &[]) - .0; + .round_challenges; evaluation_point.extend(folding_randomness.iter().copied()); - debug_assert_eq!(dot(&vector, &covector), the_sum); + debug_assert_eq!(vector.dot(&covector), the_sum); prev_witness = RoundWitness::Round(new_witness); vector_rlc_coeffs = vec![M::Target::ONE]; } // Directly send the vector to the verifier. - assert_eq!(vector.len(), self.final_sumcheck.initial_size); - for coeff in &vector { + assert_eq!(vector.len(), self.final_sumcheck.initial_size()); + for coeff in vector.to_slice() { prover_state.prover_message(coeff); } @@ -289,8 +272,8 @@ impl Config { // Open and consume the final previous witness. match prev_witness { RoundWitness::Initial(init_witnesses) => { - let witness_refs: Vec<&_> = init_witnesses.iter().map(|c| &**c).collect(); - let _in_domain = self.initial_committer.open(prover_state, &witness_refs); + let irs_refs: Vec<&_> = init_witnesses.iter().map(|c| &c.irs).collect(); + let _in_domain = self.initial_committer.open(prover_state, &irs_refs); } RoundWitness::Round(old_witness) => { let prev_config = self.round_configs.last().unwrap(); @@ -304,7 +287,7 @@ impl Config { let final_folding_randomness = self .final_sumcheck .prove(prover_state, &mut vector, &mut covector, &mut the_sum, &[]) - .0; + .round_challenges; evaluation_point.extend(final_folding_randomness.iter().copied()); FinalClaim { diff --git a/src/protocols/whir/verifier.rs b/src/protocols/whir/verifier.rs index df9c7c71..de1736d5 100644 --- a/src/protocols/whir/verifier.rs +++ b/src/protocols/whir/verifier.rs @@ -23,11 +23,11 @@ use crate::{ enum RoundCommitment<'a, F: Field> { Initial { - commitments: &'a [&'a irs_commit::Commitment], + commitments: &'a [&'a Commitment], batching_weights: Vec, }, Round { - commitment: irs_commit::Commitment, + commitment: irs_commit::Commitment, }, } @@ -56,13 +56,20 @@ impl Config { U64: Codec<[H::U]>, Hash: ProverMessage<[H::U]>, { - let num_vectors = commitments.len() * self.initial_committer.num_vectors; + let num_vectors = commitments.len() * self.initial_committer.num_vectors(); verify!(evaluations.len().is_multiple_of(num_vectors)); let num_linear_forms = evaluations.len() / num_vectors; if num_vectors == 0 { return Ok(FinalClaim::default()); } + let expected_matrix_len = + self.initial_out_domain_samples * self.initial_committer.num_vectors(); + for commitment in commitments { + verify!(commitment.out_of_domain.points.len() == self.initial_out_domain_samples); + verify!(commitment.out_of_domain.matrix.len() == expected_matrix_len); + } + // Complete the constraint and evaluation matrix with OODs and their cross-terms. let (oods_evals, oods_matrix) = { let mut oods_evals = Vec::new(); @@ -72,8 +79,8 @@ impl Config { let mut vector_offset = 0; for commitment in commitments { for (weights, oods_row) in zip_strict( - commitment.out_of_domain().evaluators(self.initial_size()), - commitment.out_of_domain().rows(), + commitment.out_of_domain.evaluators(self.initial_size()), + commitment.out_of_domain.rows(), ) { for j in 0..num_vectors { if j >= vector_offset && j < oods_row.len() + vector_offset { @@ -122,21 +129,21 @@ impl Config { // (If we did run it, all sumcheck polynomials would be constant zero) assert_eq!(the_sum, M::Target::ZERO); let folding_randomness = - verifier_state.verifier_message_vec(self.initial_sumcheck.num_rounds); + verifier_state.verifier_message_vec(self.initial_sumcheck.num_rounds()); self.initial_skip_pow.verify(verifier_state)?; folding_randomness } else { self.initial_sumcheck .verify(verifier_state, &mut the_sum)? - .0 + .round_challenges }; round_folding_randomness.push(folding_randomness); for (round_index, round_config) in self.round_configs.iter().enumerate() { - // Receive commitment to the folded vector, plus out-of-domain constraints - let commitment = round_config + // Receive commitment to the folded vector and the per-round OOD evaluations. + let (commitment, out_of_domain) = round_config .irs_committer - .receive_commitment(verifier_state)?; + .receive_commitment_with_ood(verifier_state, round_config.out_domain_samples)?; // Proof of work before in-domain challenges round_config.pow.verify(verifier_state)?; @@ -147,7 +154,8 @@ impl Config { commitments, batching_weights, } => { - let in_domain = self.initial_committer.verify(verifier_state, commitments)?; + let irs_refs: Vec<&_> = commitments.iter().map(|c| &c.irs).collect(); + let in_domain = self.initial_committer.verify(verifier_state, &irs_refs)?; // TODO: Skip lift and keep initial in-domain in subfield for evaluation. // This should be every so slightly more performant. (in_domain.lift(self.embedding()), batching_weights) @@ -162,13 +170,11 @@ impl Config { }; // Random linear combination of out- and in-domain constraints - let constraint_weights = commitment - .out_of_domain() + let constraint_weights = out_of_domain .evaluators(round_config.initial_size()) .chain(in_domain.evaluators(round_config.initial_size())) .collect::>(); - let constraint_values = commitment - .out_of_domain() + let constraint_values = out_of_domain .values(&[M::Target::ONE]) .chain(in_domain.values(&tensor_product( &poly_rlc, @@ -184,14 +190,15 @@ impl Config { let folding_randomness = round_config .sumcheck .verify(verifier_state, &mut the_sum)? - .0; + .round_challenges; round_folding_randomness.push(folding_randomness); prev_commitment = RoundCommitment::Round { commitment }; } // Final round (we receive the full vector instead of a commitment) - let final_vector = verifier_state.prover_messages_vec(self.final_sumcheck.initial_size)?; + let final_vector = + verifier_state.prover_messages_vec(self.final_sumcheck.initial_size())?; // Final proof of work. self.final_pow.verify(verifier_state)?; @@ -202,7 +209,8 @@ impl Config { commitments, batching_weights, } => { - let in_domain = self.initial_committer.verify(verifier_state, commitments)?; + let irs_refs: Vec<&_> = commitments.iter().map(|c| &c.irs).collect(); + let in_domain = self.initial_committer.verify(verifier_state, &irs_refs)?; (in_domain.lift(self.embedding()), batching_weights) } RoundCommitment::Round { commitment } => { @@ -226,7 +234,10 @@ impl Config { } // Final sumcheck - let final_sumcheck_randomness = self.final_sumcheck.verify(verifier_state, &mut the_sum)?.0; + let final_sumcheck_randomness = self + .final_sumcheck + .verify(verifier_state, &mut the_sum)? + .round_challenges; round_folding_randomness.push(final_sumcheck_randomness.clone()); // Compute folding randomness across all rounds diff --git a/src/protocols/whir_zk/committer.rs b/src/protocols/whir_zk/committer.rs index 942b87e1..08ce6ba7 100644 --- a/src/protocols/whir_zk/committer.rs +++ b/src/protocols/whir_zk/committer.rs @@ -1,3 +1,5 @@ +// IGNORE CHANGES TO THIS FILE - NOT FULLY PORTED TO PROPERLY USE BUFFER ABSTRACTION. + use ark_ff::Field; use ark_std::rand::{distributions::Standard, prelude::Distribution}; #[cfg(feature = "tracing")] @@ -5,8 +7,10 @@ use tracing::instrument; use super::{utils::BlindingPolynomials, Config}; use crate::{ + algebra::embedding::Identity, + buffer::{ActiveBuffer, BufferOps}, hash::Hash, - protocols::{irs_commit, whir}, + protocols::whir, transcript::{ Codec, DuplexSpongeInterface, ProverMessage, ProverState, VerificationResult, VerifierState, }, @@ -26,10 +30,10 @@ pub struct Commitment { #[derive(Clone, Debug)] pub struct Witness { pub f_hat_vectors: Vec>, - pub f_hat_witnesses: Vec>, + pub f_hat_witnesses: Vec>>, pub blinding_polynomials: Vec>, pub blinding_vectors: Vec>, - pub blinding_witness: irs_commit::Witness, + pub blinding_witness: whir::Witness>, } impl Config { @@ -43,7 +47,7 @@ impl Config { pub fn commit( &self, prover_state: &mut ProverState, - polynomials: &[&[F]], + polynomials: &[&ActiveBuffer], ) -> Witness where Standard: Distribution, @@ -53,7 +57,8 @@ impl Config { Hash: ProverMessage<[H::U]>, { assert_eq!( - self.blinded_commitment.initial_committer.num_vectors, 1, + self.blinded_commitment.initial_committer.num_vectors(), + 1, "zkWHIR currently expects one vector per commitment" ); @@ -63,6 +68,7 @@ impl Config { let num_blinding_variables = self.num_blinding_variables(); let num_witness_variables = self.num_witness_variables(); for &poly in polynomials { + let poly = poly.to_slice(); let blinding = BlindingPolynomials::sample( prover_state.rng(), num_blinding_variables, @@ -86,15 +92,16 @@ impl Config { .zip(mask.iter().cycle()) .map(|(&coeff, &m)| coeff + m) .collect::>(); + let f_hat_buffer = ActiveBuffer::from_slice(&f_hat_vec); let witness = self .blinded_commitment - .commit(prover_state, &[f_hat_vec.as_slice()]); + .commit(prover_state, &[&f_hat_buffer]); f_hat_vectors.push(f_hat_vec); f_hat_witnesses.push(witness); blinding_polynomials.push(blinding); } - let blinding_num_vectors = self.blinding_commitment.initial_committer.num_vectors; + let blinding_num_vectors = self.blinding_commitment.initial_committer.num_vectors(); assert_eq!( blinding_num_vectors, polynomials.len() * (num_witness_variables + 1), @@ -110,10 +117,11 @@ impl Config { ); blinding_vectors.extend(layout); } - let blinding_vector_refs = blinding_vectors + let blinding_buffers = blinding_vectors .iter() - .map(Vec::as_slice) + .map(|v| ActiveBuffer::from_slice(v)) .collect::>(); + let blinding_vector_refs = blinding_buffers.iter().collect::>(); let blinding_witness = self .blinding_commitment .commit(prover_state, &blinding_vector_refs); diff --git a/src/protocols/whir_zk/mod.rs b/src/protocols/whir_zk/mod.rs index b5e41c6e..b4c5eedb 100644 --- a/src/protocols/whir_zk/mod.rs +++ b/src/protocols/whir_zk/mod.rs @@ -1,3 +1,5 @@ +// IGNORE CHANGES TO THIS FILE - NOT FULLY PORTED TO PROPERLY USE BUFFER ABSTRACTION. + #![cfg(feature = "rs_in_order")] // TODO: Support permuted. mod committer; mod prover; @@ -43,16 +45,18 @@ impl BlindingSizePolicy { .saturating_sub(main_whir_params.pow_bits); #[allow(clippy::cast_possible_wrap)] let q_delta_1 = irs_commit::num_in_domain_queries( - main_whir_params.unique_decoding, + main_whir_params.decoding_regime, protocol_security_level_main as f64, 0.5_f64.powi(main_whir_params.starting_log_inv_rate as i32), - ); + ) + .get(); #[allow(clippy::cast_possible_wrap)] let q_delta_2 = irs_commit::num_in_domain_queries( - main_whir_params.unique_decoding, + main_whir_params.decoding_regime, main_whir_params.security_level as f64, 0.5_f64.powi(main_whir_params.starting_log_inv_rate as i32), - ); + ) + .get(); // Default send-in-clear thresholds match query complexities. Self { @@ -132,7 +136,7 @@ impl Config { size_policy.t2 >= size_policy.q_delta_2, "invalid blinding size policy: T2 must satisfy T2 >= q(delta2)" ); - let k1 = 1usize << blinded.initial_sumcheck.num_rounds; + let k1 = 1usize << blinded.initial_sumcheck.num_rounds(); let k2 = blinding_first_round_interleaving_depth; let sumcheck_coeff_leakage = size_policy .sumcheck_round_degree @@ -171,12 +175,14 @@ impl Config { /// Interleaving depth of the initial IRS commitment (= 2^folding_factor). pub(crate) const fn interleaving_depth(&self) -> usize { - self.blinded_commitment.initial_committer.interleaving_depth + self.blinded_commitment + .initial_committer + .interleaving_depth() } /// Generator ω of the full NTT domain (size = num_rows × interleaving_depth). pub(crate) fn omega_full(&self) -> F { - let codeword_length = self.blinded_commitment.initial_committer.codeword_length; + let codeword_length = self.blinded_commitment.initial_committer.codeword_length(); let full_domain_size = codeword_length * self.interleaving_depth(); crate::algebra::ntt::generator(full_domain_size) } @@ -185,19 +191,19 @@ impl Config { // #[deprecated = "RS codes do not necessarily have a generator."] fn omega_sub(&self) -> F { // Assume it has a generator, and the evaluation order is 1, g, g^2, ... - let codeword_length = self.blinded_commitment.initial_committer.codeword_length; + let codeword_length = self.blinded_commitment.initial_committer.codeword_length(); crate::algebra::ntt::generator(codeword_length) } /// ζ = ω^num_rows — the interleaving_depth-th root of unity. pub(crate) fn zeta(&self) -> F { - let codeword_length = self.blinded_commitment.initial_committer.codeword_length; + let codeword_length = self.blinded_commitment.initial_committer.codeword_length(); self.omega_full().pow([codeword_length as u64]) } /// Precomputed sub-domain powers [1, ω_sub, ω_sub², ..., ω_sub^(num_rows-1)]. pub(crate) fn omega_powers(&self) -> Vec { - let codeword_length = self.blinded_commitment.initial_committer.codeword_length; + let codeword_length = self.blinded_commitment.initial_committer.codeword_length(); crate::algebra::geometric_sequence(self.omega_sub(), codeword_length) } @@ -253,8 +259,10 @@ mod tests { linear_form::{Covector, Evaluate, LinearForm, MultilinearExtension}, random_vector, }, + buffer::{ActiveBuffer, BufferOps}, hash, parameters::ProtocolParameters, + protocols::params::DecodingRegime, transcript::{codecs::Empty, DomainSeparator, ProverState, VerifierState}, }; @@ -275,7 +283,7 @@ mod tests { fn make_test_config(num_polynomials: usize) -> Config { let whir_params = ProtocolParameters { - unique_decoding: false, + decoding_regime: DecodingRegime::Johnson, security_level: 16, pow_bits: 0, initial_folding_factor: 2, @@ -350,13 +358,15 @@ mod tests { .session(&tag) .instance(&Empty); let mut prover_state = ProverState::new_std(&ds); - let witness = params.commit(&mut prover_state, vectors); + let vector_buffers = vectors + .iter() + .map(|v| ActiveBuffer::from_slice(v)) + .collect::>(); + let vector_refs = vector_buffers.iter().collect::>(); + let witness = params.commit(&mut prover_state, &vector_refs); let _ = params.prove( &mut prover_state, - vectors - .iter() - .map(|&v| Cow::Borrowed(v)) - .collect::>(), + &vector_refs, witness, prove_forms, Cow::Borrowed(evaluations), @@ -442,13 +452,15 @@ mod tests { .session(&format!("zk-stage1-negative {}:{}", file!(), line!())) .instance(&Empty); let mut prover_state = ProverState::new_std(&ds); - let witness = params.commit(&mut prover_state, &vectors); + let vector_buffers = vectors + .iter() + .map(|v| ActiveBuffer::from_slice(v)) + .collect::>(); + let vector_refs = vector_buffers.iter().collect::>(); + let witness = params.commit(&mut prover_state, &vector_refs); let _ = params.prove( &mut prover_state, - vectors - .iter() - .map(|&v| Cow::Borrowed(v)) - .collect::>(), + &vector_refs, witness, prove_forms, Cow::Borrowed(&evaluations), @@ -496,13 +508,15 @@ mod tests { .session(&format!("zk-stage1-tamper {}:{}", file!(), line!())) .instance(&Empty); let mut prover_state = ProverState::new_std(&ds); - let witness = params.commit(&mut prover_state, &vectors); + let vector_buffers = vectors + .iter() + .map(|v| ActiveBuffer::from_slice(v)) + .collect::>(); + let vector_refs = vector_buffers.iter().collect::>(); + let witness = params.commit(&mut prover_state, &vector_refs); let _ = params.prove( &mut prover_state, - vectors - .iter() - .map(|&v| Cow::Borrowed(v)) - .collect::>(), + &vector_refs, witness, prove_forms, Cow::Borrowed(&evaluations), @@ -557,10 +571,11 @@ mod tests { let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { let mut prover_state = ProverState::new_std(&ds); - let witness = params.commit(&mut prover_state, &[&vector]); + let vector_buffer = ActiveBuffer::from_slice(&vector); + let witness = params.commit(&mut prover_state, &[&vector_buffer]); let _ = params.prove( &mut prover_state, - vec![Cow::Borrowed(&vector)], + &[&vector_buffer], witness, prove_forms, Cow::Owned(vec![wrong_evaluation]), diff --git a/src/protocols/whir_zk/prover.rs b/src/protocols/whir_zk/prover.rs index d1527c13..8bd23ac9 100644 --- a/src/protocols/whir_zk/prover.rs +++ b/src/protocols/whir_zk/prover.rs @@ -1,3 +1,5 @@ +// IGNORE CHANGES TO THIS FILE - NOT FULLY PORTED TO PROPERLY USE BUFFER ABSTRACTION. + use std::borrow::Cow; use ark_ff::Field; @@ -14,6 +16,7 @@ use crate::{ linear_form::{Covector, Evaluate, LinearForm}, mixed_dot, scalar_mul_add, }, + buffer::{ActiveBuffer, BufferOps}, hash::Hash, protocols::{ whir::FinalClaim, @@ -206,13 +209,13 @@ impl Config { /// inner witness-side WHIR prover. #[allow(clippy::too_many_lines)] #[cfg_attr(feature = "tracing", instrument(skip_all))] - pub fn prove<'a, H, R>( + pub fn prove( &self, prover_state: &mut ProverState, - vectors: Vec>, + vectors: &[&ActiveBuffer], witness: Witness, linear_forms: Vec>>, - evaluations: Cow<'a, [F]>, + evaluations: Cow<'_, [F]>, ) -> FinalClaim where Standard: Distribution, @@ -225,7 +228,8 @@ impl Config { Hash: ProverMessage<[H::U]>, { assert_eq!( - self.blinded_commitment.initial_committer.num_vectors, 1, + self.blinded_commitment.initial_committer.num_vectors(), + 1, "zkWHIR currently expects one vector per commitment" ); assert_eq!( @@ -240,7 +244,7 @@ impl Config { ); assert_eq!( witness.blinding_vectors.len(), - self.blinding_commitment.initial_committer.num_vectors, + self.blinding_commitment.initial_committer.num_vectors(), "blinding vectors/witness mismatch" ); assert_eq!( @@ -265,7 +269,6 @@ impl Config { let num_witness_variables = self.num_witness_variables(); let num_blinding_variables = self.num_blinding_variables(); let num_witness_variables_plus_1 = num_witness_variables + 1; - drop(vectors); // TODO: These are never touched? // Compute w_folded evaluations of all blinding vectors before rho for binding. let (w_folded_weights, m_evals, w_folded_blinding_evals) = { @@ -320,7 +323,7 @@ impl Config { let initial_in_domain = { #[cfg(feature = "tracing")] let _span = tracing::info_span!("open_f_hat").entered(); - let witness_refs: Vec<_> = f_hat_witnesses.iter().collect(); + let witness_refs: Vec<_> = f_hat_witnesses.iter().map(|w| &w.irs).collect(); self.blinded_commitment .initial_committer .open(prover_state, &witness_refs) @@ -396,10 +399,16 @@ impl Config { let result = { #[cfg(feature = "tracing")] let _span = tracing::info_span!("inner_blinded_prove").entered(); + let f_hat_buffers = f_hat_vectors + .iter() + .map(|v| ActiveBuffer::from_slice(v)) + .collect::>(); + let f_hat_refs = f_hat_buffers.iter().collect::>(); + let f_hat_witness_refs = f_hat_witnesses.iter().collect::>(); self.blinded_commitment.prove( prover_state, - f_hat_vectors.into_iter().map(Cow::Owned).collect(), - f_hat_witnesses.into_iter().map(Cow::Owned).collect(), + &f_hat_refs, + f_hat_witness_refs, linear_forms, Cow::Owned(modified_evaluations), ) @@ -423,10 +432,15 @@ impl Config { .concat(); // Blinding sub-proof result is discarded: the blinding WHIR's // evaluation point is not needed by the outer protocol. + let blinding_buffers = blinding_vectors + .iter() + .map(|v| ActiveBuffer::from_slice(v)) + .collect::>(); + let blinding_refs = blinding_buffers.iter().collect::>(); let _ = self.blinding_commitment.prove( prover_state, - blinding_vectors.into_iter().map(Cow::Owned).collect(), - vec![Cow::Owned(blinding_witness)], + &blinding_refs, + vec![&blinding_witness], blinding_forms, Cow::Owned(all_blinding_claims), ); diff --git a/src/protocols/whir_zk/verifier.rs b/src/protocols/whir_zk/verifier.rs index 7df12023..6021fd7b 100644 --- a/src/protocols/whir_zk/verifier.rs +++ b/src/protocols/whir_zk/verifier.rs @@ -53,7 +53,8 @@ impl Config { Hash: ProverMessage<[H::U]>, { assert_eq!( - self.blinded_commitment.initial_committer.num_vectors, 1, + self.blinded_commitment.initial_committer.num_vectors(), + 1, "zkWHIR currently expects one vector per commitment" ); let num_polynomials = commitment.f_hat.len(); @@ -73,10 +74,11 @@ impl Config { let masking_challenge: F = verifier_state.verifier_message(); verify!(masking_challenge != F::ZERO); let commitments = commitment.f_hat.iter().collect::>(); + let irs_commitments = commitments.iter().map(|c| &c.irs).collect::>(); let initial_in_domain = self .blinded_commitment .initial_committer - .verify(verifier_state, &commitments)?; + .verify(verifier_state, &irs_commitments)?; // Expand base queries into coset points for the first folding round. let h_gammas = self.all_gammas(&initial_in_domain.points); diff --git a/src/transcript/mock_sponge.rs b/src/transcript/mock_sponge.rs index 822463db..df17af08 100644 --- a/src/transcript/mock_sponge.rs +++ b/src/transcript/mock_sponge.rs @@ -13,7 +13,7 @@ impl DuplexSpongeInterface for MockSponge<'_> { fn absorb(&mut self, input: &[Self::U]) -> &mut Self { if let Some(absorb) = self.absorb.as_mut() { - assert!(&absorb[..input.len()] == input); + assert_eq!(&absorb[..input.len()], input); *absorb = &absorb[input.len()..]; } self @@ -27,10 +27,10 @@ impl DuplexSpongeInterface for MockSponge<'_> { fn ratchet(&mut self) -> &mut Self { if let Some(absorb) = self.absorb.as_mut() { - assert!(&absorb[..7] == b"RATCHET"); + assert_eq!(&absorb[..7], b"RATCHET"); *absorb = &absorb[7..]; } - assert!(&self.squeeze[..7] == b"RATCHET"); + assert_eq!(&self.squeeze[..7], b"RATCHET"); self.squeeze = &self.squeeze[7..]; self }