From c258a02b823f3c2e806e7e3b42643078e1e79cbd Mon Sep 17 00:00:00 2001 From: "John C. Burnham" Date: Sun, 30 Aug 2026 17:55:21 -0400 Subject: [PATCH 1/6] feat: implement complete Flock Stage 3 prover --- Cargo.lock | 13 +- Cargo.toml | 11 +- crates/aiur/src/synthesis.rs | 8 + crates/aiur/src/vk_codec.rs | 207 +- crates/terminal/Cargo.toml | 15 + crates/terminal/src/lib.rs | 617 ++ flock-stage3/Cargo.lock | 1251 ++++ flock-stage3/Cargo.toml | 30 + flock-stage3/README.md | 115 + flock-stage3/host/Cargo.toml | 18 + flock-stage3/host/src/air.rs | 1048 +++ flock-stage3/host/src/arithmetic.rs | 676 ++ flock-stage3/host/src/artifact.rs | 381 + .../host/src/bin/flock-stage3-config.rs | 19 + flock-stage3/host/src/binding.rs | 670 ++ flock-stage3/host/src/boolean.rs | 337 + flock-stage3/host/src/config.rs | 121 + flock-stage3/host/src/conformance.rs | 186 + flock-stage3/host/src/equality.rs | 119 + flock-stage3/host/src/extension.rs | 330 + flock-stage3/host/src/fri.rs | 6525 +++++++++++++++++ flock-stage3/host/src/goldilocks.rs | 590 ++ flock-stage3/host/src/lib.rs | 256 + flock-stage3/host/src/merkle.rs | 607 ++ flock-stage3/host/src/multiplication.rs | 421 ++ flock-stage3/host/src/relation.rs | 377 + flock-stage3/host/src/transcript.rs | 2792 +++++++ flock-stage3/host/src/typed_witness.rs | 451 ++ flock-stage3/host/src/window.rs | 161 + sp1-compress/Cargo.lock | 13 +- sp1-compress/host/Cargo.toml | 2 +- sp1-compress/host/src/lib.rs | 92 +- 32 files changed, 18365 insertions(+), 94 deletions(-) create mode 100644 crates/terminal/Cargo.toml create mode 100644 crates/terminal/src/lib.rs create mode 100644 flock-stage3/Cargo.lock create mode 100644 flock-stage3/Cargo.toml create mode 100644 flock-stage3/README.md create mode 100644 flock-stage3/host/Cargo.toml create mode 100644 flock-stage3/host/src/air.rs create mode 100644 flock-stage3/host/src/arithmetic.rs create mode 100644 flock-stage3/host/src/artifact.rs create mode 100644 flock-stage3/host/src/bin/flock-stage3-config.rs create mode 100644 flock-stage3/host/src/binding.rs create mode 100644 flock-stage3/host/src/boolean.rs create mode 100644 flock-stage3/host/src/config.rs create mode 100644 flock-stage3/host/src/conformance.rs create mode 100644 flock-stage3/host/src/equality.rs create mode 100644 flock-stage3/host/src/extension.rs create mode 100644 flock-stage3/host/src/fri.rs create mode 100644 flock-stage3/host/src/goldilocks.rs create mode 100644 flock-stage3/host/src/lib.rs create mode 100644 flock-stage3/host/src/merkle.rs create mode 100644 flock-stage3/host/src/multiplication.rs create mode 100644 flock-stage3/host/src/relation.rs create mode 100644 flock-stage3/host/src/transcript.rs create mode 100644 flock-stage3/host/src/typed_witness.rs create mode 100644 flock-stage3/host/src/window.rs diff --git a/Cargo.lock b/Cargo.lock index e6871cc6..3bba5044 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2809,6 +2809,17 @@ dependencies = [ "rustc-hash", ] +[[package]] +name = "ix-terminal" +version = "0.1.0" +dependencies = [ + "aiur", + "anyhow", + "bincode 2.0.1", + "blake3", + "multi-stark", +] + [[package]] name = "ixon" version = "0.1.0" @@ -5937,11 +5948,11 @@ dependencies = [ name = "sp1-compress-host" version = "0.1.0" dependencies = [ - "aiur", "anyhow", "bincode 1.3.3", "blake3", "hex", + "ix-terminal", "multi-stark", "sp1-build", "sp1-sdk", diff --git a/Cargo.toml b/Cargo.toml index daa8246e..7299cb23 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,11 +9,12 @@ members = [ "crates/ixvm-codegen", "crates/ixon", "crates/kernel", + "crates/terminal", ] -# `zisk/`, `sp1/`, and `sp1-compress/` are their own Cargo workspaces built -# via their respective zkVM toolchains; excluded so host workspace ops don't -# pick them up. -exclude = ["zisk", "sp1", "sp1-compress", "multi-stark"] +# `zisk/`, `sp1/`, `sp1-compress/`, and `flock-stage3/` are isolated Cargo +# workspaces with heavyweight or specialized toolchains; keep normal host +# workspace operations from picking them up. +exclude = ["zisk", "sp1", "sp1-compress", "flock-stage3", "multi-stark"] resolver = "2" [profile.dev] @@ -40,6 +41,7 @@ ix-common = { path = "crates/common" } ix-compile = { path = "crates/compile" } ixon = { path = "crates/ixon" } ix-kernel = { path = "crates/kernel" } +ix-terminal = { path = "crates/terminal" } # lean-ffi tree (lean-ffi crate + factored-out bignat sub-crate) bignat = { git = "https://github.com/argumentcomputer/lean-ffi.git", rev = "93c7e52952ae94546be08313f4ff3922984c84d5" } @@ -47,6 +49,7 @@ lean-ffi = { git = "https://github.com/argumentcomputer/lean-ffi.git", rev = "93 # External shared deps anyhow = "1" +bincode = { version = "2.0.1", features = ["serde"] } blake3 = "1.8.4" dashmap = "6.1.0" hashbrown = "0.15" diff --git a/crates/aiur/src/synthesis.rs b/crates/aiur/src/synthesis.rs index a4c50b7a..17982153 100644 --- a/crates/aiur/src/synthesis.rs +++ b/crates/aiur/src/synthesis.rs @@ -585,6 +585,10 @@ mod tests { .expect("decode verifier key"); assert_eq!(vk.to_bytes(), vk_bytes, "verifier key is canonical"); vk.verify(&claim, &proof).expect("decoded verifier key must verify"); + let advice = vk + .proof_to_advice_bytes(&claim, &proof) + .expect("decoded verifier key must expand valid proof advice"); + assert!(!advice.is_empty(), "expanded verifier advice must not be empty"); let mut tampered_claim = claim.clone(); tampered_claim[2] += G::ONE; @@ -592,6 +596,10 @@ mod tests { vk.verify(&tampered_claim, &proof).is_err(), "decoded verifier key must bind the outer claim" ); + assert!( + vk.proof_to_advice_bytes(&tampered_claim, &proof).is_err(), + "advice expansion must verify and bind the outer claim" + ); } /// Hand-build a toplevel exercising the two migrated integration paths that diff --git a/crates/aiur/src/vk_codec.rs b/crates/aiur/src/vk_codec.rs index 6495e0c8..18f3f475 100644 --- a/crates/aiur/src/vk_codec.rs +++ b/crates/aiur/src/vk_codec.rs @@ -63,12 +63,15 @@ #![allow(dead_code)] use multi_stark::{ + config::StarkGenericConfig, expr::{ColRef, RowOffset, Source}, graph::{ConstraintGraph, Node, NodeId}, lookup::{Lookup, WidthBinding}, p3_field::{PrimeCharacteristicRing, PrimeField64}, system::{Circuit, System}, - types::{Commitment, CommitmentParameters, FriParameters, PcsError, Val}, + types::{ + Commitment, CommitmentParameters, ExtVal, FriParameters, PcsError, Val, + }, }; use crate::synthesis::{AiurConfig, AiurSystem}; @@ -245,6 +248,20 @@ pub(crate) fn to_bytes( buf } +/// Serialize a verifier key for a custom [`AiurConfig`] circuit system. +/// +/// Most callers should use [`aiur_system_to_bytes`]. This lower-level entry +/// point exists for custom frontends which build the same concrete Aiur STARK +/// configuration without going through [`AiurSystem`]. The supplied protocol +/// parameters must be the ones used to construct `system.config`. +pub fn aiur_config_system_to_bytes( + system: &System, + commitment_parameters: CommitmentParameters, + fri_parameters: FriParameters, +) -> Vec { + to_bytes(system, commitment_parameters, fri_parameters) +} + /// Convenience: serialize the verifying key of a built [`AiurSystem`]. pub fn aiur_system_to_bytes(sys: &AiurSystem) -> Result, String> { Ok(to_bytes(&sys.system, sys.commitment_parameters, sys.fri_parameters)) @@ -403,9 +420,7 @@ fn decode_circuit(seg: &mut Seg<'_>) -> Result, String> { }; let num_lookups = graph.lookups.len(); let ext_degree = - >::DIMENSION; + >::DIMENSION; Ok(Circuit { graph, main_width, @@ -513,6 +528,34 @@ pub struct AiurVerifyingKey { fri_parameters: FriParameters, } +/// Verifier-known matrix geometry needed to specialise the terminal PCS +/// relation without carrying the full constraint graph into that relation. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct AiurPcsCircuitMetadata { + pub main_width: usize, + pub stage_2_width: usize, + pub quotient_width: usize, + pub preprocessed_width: usize, + pub preprocessed_height: usize, + pub preprocessed_slot: Option, +} + +/// Constraint program and geometry needed to specialise the terminal AIR +/// evaluation relation for one circuit. +/// +/// This is deliberately a clone of the verifier-owned compiled graph. Stage 3 +/// treats the graph as fixed circuit data, never as proof witness data. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AiurAirCircuitMetadata { + pub graph: ConstraintGraph, + pub main_width: usize, + pub stage_2_width: usize, + pub quotient_degree: usize, + pub preprocessed_width: usize, + pub preprocessed_slot: Option, + pub lookup_group_size: usize, +} + impl AiurVerifyingKey { /// Decode a verifying key and require full input consumption. pub fn from_bytes(bytes: &[u8]) -> Result { @@ -534,10 +577,105 @@ impl AiurVerifyingKey { self.fri_parameters } + pub fn width_binding(&self) -> WidthBinding { + self.system.config.width_binding() + } + pub fn num_circuits(&self) -> usize { self.system.circuits.len() } + /// Canonical PCS matrix widths and preprocessed slots in circuit order. + pub fn pcs_circuit_metadata(&self) -> Vec { + let extension_degree = + >::DIMENSION; + self + .system + .circuits + .iter() + .zip(&self.system.preprocessed_indices) + .map(|(circuit, &preprocessed_slot)| AiurPcsCircuitMetadata { + main_width: circuit.main_width, + stage_2_width: circuit.stage_2_width, + quotient_width: circuit.quotient_degree() * extension_degree, + preprocessed_width: circuit.preprocessed_width, + preprocessed_height: circuit.preprocessed_height, + preprocessed_slot, + }) + .collect() + } + + /// Fixed compiled AIR programs in canonical circuit order. + pub fn air_circuit_metadata(&self) -> Vec { + self + .system + .circuits + .iter() + .zip(&self.system.preprocessed_indices) + .map(|(circuit, &preprocessed_slot)| AiurAirCircuitMetadata { + graph: circuit.graph.clone(), + main_width: circuit.main_width, + stage_2_width: circuit.stage_2_width, + quotient_degree: circuit.quotient_degree(), + preprocessed_width: circuit.preprocessed_width, + preprocessed_slot, + lookup_group_size: circuit.lookup_group_size, + }) + .collect() + } + + /// Exact challenger seed followed by the `System::observe_shape` words, + /// serialized as the Goldilocks byte challenger observes them. + /// + /// Terminal verifier circuits use this instead of duplicating the shape + /// derivation from the compact verifying-key codec. + pub fn transcript_seed_and_shape_bytes(&self) -> Vec { + let mut bytes = b"multi-stark/v0".to_vec(); + for parameter in [ + self.commitment_parameters.log_blowup, + self.commitment_parameters.cap_height, + self.fri_parameters.log_final_poly_len, + self.fri_parameters.max_log_arity, + self.fri_parameters.num_queries, + self.fri_parameters.commit_proof_of_work_bits, + self.fri_parameters.query_proof_of_work_bits, + ] { + bytes.extend_from_slice( + &u64::try_from(parameter) + .expect("protocol parameter fits u64") + .to_le_bytes(), + ); + } + let mut observe = |value: usize| { + bytes.extend_from_slice( + &u64::try_from(value) + .expect("system shape value fits u64") + .to_le_bytes(), + ); + }; + observe(self.system.config.width_binding() as usize); + observe(self.system.circuits.len()); + for circuit in &self.system.circuits { + observe(circuit.constraint_count()); + observe(circuit.max_constraint_degree()); + observe(circuit.preprocessed_height); + observe(circuit.preprocessed_width); + observe(circuit.main_width); + observe(circuit.stage_2_width); + observe(circuit.lookup_group_size); + } + bytes + } + + /// Roots observed for the optional preprocessed commitment, in cap order. + pub fn preprocessed_commitment_roots(&self) -> Option> { + self + .system + .preprocessed_commit + .as_ref() + .map(|commitment| commitment.roots().to_vec()) + } + pub fn verify( &self, claim: &[Val], @@ -545,6 +683,25 @@ impl AiurVerifyingKey { ) -> Result<(), multi_stark::verifier::VerificationError> { self.system.verify(claim, proof) } + + /// Verify and convert a compact proof into the canonical per-query advice + /// transport consumed by circuit verifiers. The expansion authenticates + /// every reconstructed path against the original commitments before + /// returning any bytes. + pub fn proof_to_advice_bytes( + &self, + claim: &[Val], + proof: &crate::synthesis::AiurProof, + ) -> Result, String> { + multi_stark::advice::proof_to_advice_bytes( + &self.system, + self.commitment_parameters, + self.fri_parameters, + &[claim], + proof, + ) + .map_err(|error| format!("{error:?}")) + } } #[cfg(test)] @@ -622,6 +779,48 @@ mod tests { } } + #[test] + fn transcript_seed_and_shape_bytes_match_observe_shape_order() { + let (system, cp, fp) = test_system(); + let key = AiurVerifyingKey { + system, + commitment_parameters: cp, + fri_parameters: fp, + }; + let mut expected = b"multi-stark/v0".to_vec(); + for value in [ + cp.log_blowup, + cp.cap_height, + fp.log_final_poly_len, + fp.max_log_arity, + fp.num_queries, + fp.commit_proof_of_work_bits, + fp.query_proof_of_work_bits, + WidthBinding::ByConstruction as usize, + key.system.circuits.len(), + ] { + expected.extend_from_slice(&(value as u64).to_le_bytes()); + } + for circuit in &key.system.circuits { + for value in [ + circuit.constraint_count(), + circuit.max_constraint_degree(), + circuit.preprocessed_height, + circuit.preprocessed_width, + circuit.main_width, + circuit.stage_2_width, + circuit.lookup_group_size, + ] { + expected.extend_from_slice(&(value as u64).to_le_bytes()); + } + } + assert_eq!(key.transcript_seed_and_shape_bytes(), expected); + assert_eq!( + key.preprocessed_commitment_roots().is_some(), + key.system.preprocessed_commit.is_some() + ); + } + #[test] fn rejects_trailing_bytes() { let (system, cp, fp) = test_system(); diff --git a/crates/terminal/Cargo.toml b/crates/terminal/Cargo.toml new file mode 100644 index 00000000..afdfa654 --- /dev/null +++ b/crates/terminal/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "ix-terminal" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +aiur = { workspace = true, features = ["parallel"] } +anyhow = { workspace = true } +bincode = { workspace = true } +blake3 = { workspace = true } +multi-stark = { workspace = true, features = ["parallel"] } + +[lints] +workspace = true diff --git a/crates/terminal/src/lib.rs b/crates/terminal/src/lib.rs new file mode 100644 index 00000000..43c1b792 --- /dev/null +++ b/crates/terminal/src/lib.rs @@ -0,0 +1,617 @@ +//! Canonical boundary between the recursive Aiur aggregate and terminal +//! compression backends. +//! +//! SP1 and Flock must validate and bind exactly the same Stage 2 statement. +//! This crate owns that byte-level contract so terminal backends cannot drift. + +use aiur::{G, synthesis::AiurProof, vk_codec::AiurVerifyingKey}; +use anyhow::{Result, bail}; +use bincode::{config, serde::decode_from_slice}; +use multi_stark::{ + advice::AdviceProof, + p3_field::{PrimeCharacteristicRing, PrimeField64}, + types::FriParameters, +}; + +/// Domain of the canonical Stage 2 aggregate-root statement. +/// +/// This value is already used by the SP1 compressor and must not change +/// without introducing a new statement version. +pub const STAGE2_ROOT_DOMAIN: &[u8; 8] = b"IXROOT01"; +/// Backwards-compatible name used by the SP1 public-values API. +pub const PUBLIC_VALUES_DOMAIN: &[u8; 8] = STAGE2_ROOT_DOMAIN; +pub const OUTER_CLAIM_ELEMENTS: usize = 18; +pub const FRI_PARAMETER_ELEMENTS: usize = 5; +pub const FRI_PARAMETERS_BYTES: usize = FRI_PARAMETER_ELEMENTS * 8; +pub const OUTER_CLAIM_BYTES: usize = OUTER_CLAIM_ELEMENTS * 8; +pub const STAGE2_CLAIMS_BYTES: usize = 8 + 8 + OUTER_CLAIM_BYTES; +pub const STAGE2_ROOT_STATEMENT_BYTES: usize = + STAGE2_ROOT_DOMAIN.len() + 32 + FRI_PARAMETERS_BYTES + OUTER_CLAIM_BYTES; + +const ADVICE_PROFILE_DOMAIN: &[u8; 8] = b"IXADVP01"; + +/// Versioned, canonical public statement asserted by a closed Stage 2 root. +/// +/// Wire format: +/// `IXROOT01 || blake3(aiur_vk) || five FRI u64s LE || 18 Goldilocks u64s LE`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Stage2RootStatementV1 { + verifying_key_digest: [u8; 32], + fri_parameters: [u64; FRI_PARAMETER_ELEMENTS], + outer_claim: [u64; OUTER_CLAIM_ELEMENTS], +} + +/// Shape census of the verified, per-query proof transport consumed by the +/// recursive verifier. This is diagnostic input to the Flock capacity model; +/// it is not itself a proof or a substitute for in-relation shape checks. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Stage2AdviceProfileV1 { + pub advice_bytes: u64, + pub total_circuits: u64, + pub active_circuits: u64, + pub queries: u64, + pub fri_rounds: u64, + pub input_rounds_per_query: u64, + pub commitment_cap_digests: u64, + pub input_merkle_siblings: u64, + pub fri_merkle_siblings: u64, + pub opened_base_values: u64, + pub fri_sibling_extension_values: u64, + pub other_extension_values: u64, +} + +impl Stage2AdviceProfileV1 { + /// Parse the canonical recursive-verifier advice and census its fixed and + /// capacity-driving dimensions. The parser requires exact byte consumption. + pub fn from_advice_bytes(bytes: &[u8], fri: &FriParameters) -> Result { + profile_advice(bytes, fri) + } + + pub fn to_bytes(&self) -> Vec { + let words = [ + self.advice_bytes, + self.total_circuits, + self.active_circuits, + self.queries, + self.fri_rounds, + self.input_rounds_per_query, + self.commitment_cap_digests, + self.input_merkle_siblings, + self.fri_merkle_siblings, + self.opened_base_values, + self.fri_sibling_extension_values, + self.other_extension_values, + ]; + let mut bytes = + Vec::with_capacity(ADVICE_PROFILE_DOMAIN.len() + words.len() * 8); + bytes.extend_from_slice(ADVICE_PROFILE_DOMAIN); + for word in words { + bytes.extend_from_slice(&word.to_le_bytes()); + } + bytes + } + + pub fn digest(&self) -> [u8; 32] { + *blake3::hash(&self.to_bytes()).as_bytes() + } +} + +/// A compact proof that has been verified and expanded to the exact advice +/// layout used by `Ix.MultiStark`. The verifying key is retained for compiling +/// a specialised typed verifier witness; the claims remain the private words +/// bound by the Stage 2 statement inside that relation. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ValidatedStage2RootV1 { + statement: Stage2RootStatementV1, + verifying_key_bytes: Vec, + claims_bytes: Vec, + advice_bytes: Vec, + advice_profile: Stage2AdviceProfileV1, +} + +impl ValidatedStage2RootV1 { + pub fn statement(&self) -> &Stage2RootStatementV1 { + &self.statement + } + + pub fn verifying_key_bytes(&self) -> &[u8] { + &self.verifying_key_bytes + } + + pub fn claims_bytes(&self) -> &[u8] { + &self.claims_bytes + } + + pub fn advice_bytes(&self) -> &[u8] { + &self.advice_bytes + } + + pub fn advice_profile(&self) -> &Stage2AdviceProfileV1 { + &self.advice_profile + } +} + +impl Stage2RootStatementV1 { + /// Construct the statement while enforcing the exact claim shape and + /// canonical Goldilocks encoding. + pub fn new( + vk_bytes: &[u8], + claim_bytes: &[u8], + fri: &FriParameters, + ) -> Result { + Ok(Self { + verifying_key_digest: *blake3::hash(vk_bytes).as_bytes(), + fri_parameters: fri_parameter_words(fri), + outer_claim: decode_claim_words(claim_bytes)?, + }) + } + + /// Parse the canonical format. Exact length, domain, and field encodings are + /// checked; trailing bytes are rejected. + pub fn from_bytes(bytes: &[u8]) -> Result { + if bytes.len() != STAGE2_ROOT_STATEMENT_BYTES { + bail!( + "Stage 2 root statement is {} bytes; expected {STAGE2_ROOT_STATEMENT_BYTES}", + bytes.len() + ); + } + if &bytes[..STAGE2_ROOT_DOMAIN.len()] != STAGE2_ROOT_DOMAIN { + bail!("invalid Stage 2 root statement domain"); + } + + let mut verifying_key_digest = [0u8; 32]; + verifying_key_digest.copy_from_slice(&bytes[8..40]); + + let mut fri_parameters = [0u64; FRI_PARAMETER_ELEMENTS]; + for (word, chunk) in + fri_parameters.iter_mut().zip(bytes[40..80].as_chunks::<8>().0) + { + *word = u64::from_le_bytes(*chunk); + } + + Ok(Self { + verifying_key_digest, + fri_parameters, + outer_claim: decode_claim_words(&bytes[80..])?, + }) + } + + pub fn to_bytes(&self) -> Vec { + let mut bytes = Vec::with_capacity(STAGE2_ROOT_STATEMENT_BYTES); + bytes.extend_from_slice(STAGE2_ROOT_DOMAIN); + bytes.extend_from_slice(&self.verifying_key_digest); + for word in self.fri_parameters { + bytes.extend_from_slice(&word.to_le_bytes()); + } + for word in self.outer_claim { + bytes.extend_from_slice(&word.to_le_bytes()); + } + debug_assert_eq!(bytes.len(), STAGE2_ROOT_STATEMENT_BYTES); + bytes + } + + /// BLAKE3 digest of the complete, domain-separated canonical statement. + pub fn digest(&self) -> [u8; 32] { + *blake3::hash(&self.to_bytes()).as_bytes() + } + + pub fn verifying_key_digest(&self) -> &[u8; 32] { + &self.verifying_key_digest + } + + pub fn fri_parameter_words(&self) -> &[u64; FRI_PARAMETER_ELEMENTS] { + &self.fri_parameters + } + + pub fn outer_claim_words(&self) -> &[u64; OUTER_CLAIM_ELEMENTS] { + &self.outer_claim + } +} + +pub fn fri_parameter_words( + fri: &FriParameters, +) -> [u64; FRI_PARAMETER_ELEMENTS] { + [ + fri.log_final_poly_len as u64, + fri.max_log_arity as u64, + fri.num_queries as u64, + fri.commit_proof_of_work_bits as u64, + fri.query_proof_of_work_bits as u64, + ] +} + +pub fn fri_parameters_to_bytes(fri: &FriParameters) -> Vec { + fri_parameter_words(fri) + .iter() + .flat_map(|value| value.to_le_bytes()) + .collect() +} + +/// Decode the exact 18-word outer claim and reject non-canonical Goldilocks +/// representatives. +pub fn decode_claim_words( + claim_bytes: &[u8], +) -> Result<[u64; OUTER_CLAIM_ELEMENTS]> { + if claim_bytes.len() != OUTER_CLAIM_BYTES { + bail!( + "ix_aggr outer claim is {} bytes; expected {OUTER_CLAIM_BYTES} (18 Goldilocks words)", + claim_bytes.len() + ); + } + + let mut words = [0u64; OUTER_CLAIM_ELEMENTS]; + for (index, (word_out, chunk)) in + words.iter_mut().zip(claim_bytes.as_chunks::<8>().0).enumerate() + { + let word = u64::from_le_bytes(*chunk); + let value = G::from_u64(word); + if value.as_canonical_u64() != word { + bail!("outer claim word {index} is not canonical Goldilocks"); + } + *word_out = word; + } + Ok(words) +} + +/// Canonical `&[&[Goldilocks]]` encoding consumed by the existing recursive +/// verifier: one claim, its 18-word length, then its little-endian words. +pub fn stage2_claims_bytes(claim_bytes: &[u8]) -> Result> { + let words = decode_claim_words(claim_bytes)?; + let mut bytes = Vec::with_capacity(STAGE2_CLAIMS_BYTES); + bytes.extend_from_slice(&1u64.to_le_bytes()); + bytes.extend_from_slice(&(OUTER_CLAIM_ELEMENTS as u64).to_le_bytes()); + for word in words { + bytes.extend_from_slice(&word.to_le_bytes()); + } + Ok(bytes) +} + +fn fri_matches(actual: &FriParameters, expected: &FriParameters) -> bool { + fri_parameter_words(actual) == fri_parameter_words(expected) +} + +struct DecodedRootInputs { + statement: Stage2RootStatementV1, + claim: Vec, + verifying_key: AiurVerifyingKey, + proof: AiurProof, +} + +fn decode_root_inputs( + vk_bytes: &[u8], + claim_bytes: &[u8], + proof_bytes: &[u8], + fri: &FriParameters, +) -> Result { + let statement = Stage2RootStatementV1::new(vk_bytes, claim_bytes, fri)?; + let claim: Vec = + statement.outer_claim.iter().copied().map(G::from_u64).collect(); + let verifying_key = AiurVerifyingKey::from_bytes(vk_bytes) + .map_err(|error| anyhow::anyhow!("invalid Aiur verifying key: {error}"))?; + if verifying_key.to_bytes() != vk_bytes { + bail!("Aiur verifying key is not canonically encoded"); + } + if !fri_matches(&verifying_key.fri_parameters(), fri) { + bail!("requested recursion FRI parameters do not match the Aiur vk"); + } + let proof = AiurProof::from_bytes(proof_bytes) + .map_err(|error| anyhow::anyhow!("invalid Aiur proof: {error}"))?; + let canonical_proof = proof + .to_bytes() + .map_err(|error| anyhow::anyhow!("re-encode Aiur proof: {error}"))?; + if canonical_proof != proof_bytes { + bail!("Aiur proof is non-canonical or contains trailing bytes"); + } + Ok(DecodedRootInputs { statement, claim, verifying_key, proof }) +} + +/// Validate a persisted aggregate root natively and return the exact public +/// statement terminal backends must prove. A backend circuit must repeat all +/// verification checks; this native pass is a cost and ergonomics guard. +pub fn validate_root_inputs( + vk_bytes: &[u8], + claim_bytes: &[u8], + proof_bytes: &[u8], + fri: &FriParameters, +) -> Result { + let decoded = decode_root_inputs(vk_bytes, claim_bytes, proof_bytes, fri)?; + decoded.verifying_key.verify(&decoded.claim, &decoded.proof).map_err( + |error| anyhow::anyhow!("aggregate root does not verify: {error:?}"), + )?; + Ok(decoded.statement) +} + +/// Verify a compact Stage 2 root and expand its pruned Merkle multiproofs into +/// the per-query advice layout already consumed by the recursive Lean verifier. +/// No host-derived acceptance bit crosses the boundary: Stage 3 must parse and +/// re-check these retained vk, claim, and advice bytes inside its relation. +pub fn validate_and_expand_root_inputs( + vk_bytes: &[u8], + claim_bytes: &[u8], + proof_bytes: &[u8], + fri: &FriParameters, +) -> Result { + let decoded = decode_root_inputs(vk_bytes, claim_bytes, proof_bytes, fri)?; + let advice_bytes = decoded + .verifying_key + .proof_to_advice_bytes(&decoded.claim, &decoded.proof) + .map_err(|error| anyhow::anyhow!("expand verified Aiur proof: {error}"))?; + let advice_profile = + Stage2AdviceProfileV1::from_advice_bytes(&advice_bytes, fri)?; + Ok(ValidatedStage2RootV1 { + statement: decoded.statement, + verifying_key_bytes: vk_bytes.to_vec(), + claims_bytes: stage2_claims_bytes(claim_bytes)?, + advice_bytes, + advice_profile, + }) +} + +fn profile_advice( + bytes: &[u8], + fri: &FriParameters, +) -> Result { + let proof = decode_stage2_advice(bytes, fri)?; + + let input_rounds_per_query = proof + .opening_proof + .query_proofs + .first() + .map_or(0, |query| query.input_proof.len()); + let commitment_cap_digests = proof.commitments.stage_1_trace.roots().len() + + proof.commitments.stage_2_trace.roots().len() + + proof.commitments.quotient_chunks.roots().len() + + proof + .opening_proof + .commit_phase_commits + .iter() + .map(|commitment| commitment.roots().len()) + .sum::(); + let input_merkle_siblings = proof + .opening_proof + .query_proofs + .iter() + .flat_map(|query| &query.input_proof) + .map(|opening| opening.opening_proof.len()) + .sum(); + let fri_merkle_siblings = proof + .opening_proof + .query_proofs + .iter() + .flat_map(|query| &query.commit_phase_openings) + .map(|opening| opening.opening_proof.len()) + .sum(); + let opened_base_values = proof + .opening_proof + .query_proofs + .iter() + .flat_map(|query| &query.input_proof) + .flat_map(|opening| &opening.opened_values) + .map(Vec::len) + .sum(); + let fri_sibling_extension_values = proof + .opening_proof + .query_proofs + .iter() + .flat_map(|query| &query.commit_phase_openings) + .map(|opening| opening.sibling_values.len()) + .sum(); + let other_extension_values = proof.intermediate_accumulators.len() + + count_opened_values(&proof.quotient_opened_values) + + proof + .preprocessed_opened_values + .as_ref() + .map_or(0, |values| count_opened_values(values)) + + count_opened_values(&proof.stage_1_opened_values) + + count_opened_values(&proof.stage_2_opened_values) + + proof.opening_proof.final_poly.len(); + + Ok(Stage2AdviceProfileV1 { + advice_bytes: to_u64(bytes.len(), "advice bytes")?, + total_circuits: to_u64(proof.active.len(), "circuit count")?, + active_circuits: to_u64( + proof.active.iter().filter(|&&active| active).count(), + "active circuit count", + )?, + queries: to_u64(proof.opening_proof.query_proofs.len(), "query count")?, + fri_rounds: to_u64( + proof.opening_proof.commit_phase_commits.len(), + "FRI round count", + )?, + input_rounds_per_query: to_u64( + input_rounds_per_query, + "input rounds per query", + )?, + commitment_cap_digests: to_u64( + commitment_cap_digests, + "commitment cap digests", + )?, + input_merkle_siblings: to_u64( + input_merkle_siblings, + "input Merkle siblings", + )?, + fri_merkle_siblings: to_u64(fri_merkle_siblings, "FRI Merkle siblings")?, + opened_base_values: to_u64(opened_base_values, "opened base values")?, + fri_sibling_extension_values: to_u64( + fri_sibling_extension_values, + "FRI sibling extension values", + )?, + other_extension_values: to_u64( + other_extension_values, + "other extension values", + )?, + }) +} + +/// Decode the canonical per-query Stage 2 proof transport into semantic proof +/// fields. The persisted bincode representation ends here: Flock backends +/// should lower this typed value, not reproduce byte parsing in their +/// relation. +pub fn decode_stage2_advice( + bytes: &[u8], + fri: &FriParameters, +) -> Result { + let codec = config::standard().with_little_endian().with_fixed_int_encoding(); + let (proof, consumed): (AdviceProof, usize) = decode_from_slice(bytes, codec) + .map_err(|error| { + anyhow::anyhow!("decode canonical Stage 2 advice: {error}") + })?; + if consumed != bytes.len() { + bail!("Stage 2 advice contains trailing bytes"); + } + if proof.opening_proof.query_proofs.len() != fri.num_queries { + bail!( + "Stage 2 advice has {} queries; expected {}", + proof.opening_proof.query_proofs.len(), + fri.num_queries + ); + } + + let input_rounds_per_query = proof + .opening_proof + .query_proofs + .first() + .map_or(0, |query| query.input_proof.len()); + if proof.opening_proof.query_proofs.iter().any(|query| { + query.input_proof.len() != input_rounds_per_query + || query.commit_phase_openings.len() + != proof.opening_proof.commit_phase_commits.len() + }) { + bail!("Stage 2 advice has non-uniform per-query round counts"); + } + Ok(proof) +} + +fn count_opened_values(values: &[Vec>]) -> usize { + values.iter().flat_map(|matrix| matrix.iter()).map(Vec::len).sum() +} + +fn to_u64(value: usize, label: &str) -> Result { + u64::try_from(value) + .map_err(|error| anyhow::anyhow!("{label} exceeds u64: {error}")) +} + +/// Backwards-compatible SP1 public-values constructor. +pub fn expected_public_values( + vk_bytes: &[u8], + claim_bytes: &[u8], + fri: &FriParameters, +) -> Result> { + Ok(Stage2RootStatementV1::new(vk_bytes, claim_bytes, fri)?.to_bytes()) +} + +#[cfg(test)] +mod tests { + use super::*; + use multi_stark::{ + advice::proof_to_advice_bytes, + p3_matrix::dense::RowMajorMatrix, + system::{CircuitInputs, System, SystemWitness}, + types::{CommitmentParameters, GoldilocksBlake3Config}, + }; + + fn test_fri() -> FriParameters { + FriParameters { + log_final_poly_len: 0, + max_log_arity: 1, + num_queries: 100, + commit_proof_of_work_bits: 0, + query_proof_of_work_bits: 20, + } + } + + fn canonical_claim() -> Vec { + (0..OUTER_CLAIM_ELEMENTS as u64).flat_map(u64::to_le_bytes).collect() + } + + #[test] + fn statement_round_trip_is_exact() { + let statement = + Stage2RootStatementV1::new(b"vk", &canonical_claim(), &test_fri()) + .expect("statement"); + let bytes = statement.to_bytes(); + assert_eq!(bytes.len(), STAGE2_ROOT_STATEMENT_BYTES); + assert_eq!(&bytes[..8], STAGE2_ROOT_DOMAIN); + assert_eq!(&bytes[8..40], blake3::hash(b"vk").as_bytes()); + assert_eq!(&bytes[40..80], fri_parameters_to_bytes(&test_fri())); + assert_eq!(&bytes[80..], canonical_claim()); + assert_eq!(Stage2RootStatementV1::from_bytes(&bytes).unwrap(), statement); + assert_eq!( + blake3::Hash::from_bytes(statement.digest()).to_hex().as_str(), + "f1e778aa3d903008a6e755daee2e4f36f1a7a168277cbb6625984602c12dbe4f" + ); + } + + #[test] + fn parser_rejects_domain_length_and_noncanonical_claim() { + let mut bytes = + Stage2RootStatementV1::new(b"vk", &canonical_claim(), &test_fri()) + .unwrap() + .to_bytes(); + bytes[0] ^= 1; + assert!(Stage2RootStatementV1::from_bytes(&bytes).is_err()); + + let mut bytes = + Stage2RootStatementV1::new(b"vk", &canonical_claim(), &test_fri()) + .unwrap() + .to_bytes(); + bytes.extend_from_slice(&[0]); + assert!(Stage2RootStatementV1::from_bytes(&bytes).is_err()); + + let mut claim = canonical_claim(); + claim[..8].copy_from_slice(&u64::MAX.to_le_bytes()); + assert!(Stage2RootStatementV1::new(b"vk", &claim, &test_fri()).is_err()); + } + + #[test] + fn claims_transport_is_the_recursive_verifier_wire_format() { + let claim = canonical_claim(); + let bytes = stage2_claims_bytes(&claim).unwrap(); + assert_eq!(bytes.len(), STAGE2_CLAIMS_BYTES); + assert_eq!(&bytes[..8], &1u64.to_le_bytes()); + assert_eq!(&bytes[8..16], &(OUTER_CLAIM_ELEMENTS as u64).to_le_bytes()); + assert_eq!(&bytes[16..], claim); + } + + #[test] + fn advice_profile_parses_a_real_proof_and_rejects_extensions() { + let commitment = CommitmentParameters { log_blowup: 1, cap_height: 0 }; + let fri = FriParameters { + log_final_poly_len: 0, + max_log_arity: 1, + num_queries: 2, + commit_proof_of_work_bits: 0, + query_proof_of_work_bits: 0, + }; + let (system, key) = System::new( + GoldilocksBlake3Config::new(commitment, fri), + [ + CircuitInputs { main_width: 2, ..Default::default() }, + CircuitInputs { main_width: 3, ..Default::default() }, + ], + ); + let trace_1 = + RowMajorMatrix::new((0..16u32).map(G::from_u32).collect::>(), 2); + let trace_2 = RowMajorMatrix::new( + (0..12u32).map(|value| G::from_u32(7 * value + 3)).collect(), + 3, + ); + let witness = SystemWitness::from_stage_1(vec![trace_1, trace_2], &system); + let proof = system.prove_multiple_claims(&key, &[], witness); + let advice = proof_to_advice_bytes(&system, commitment, fri, &[], &proof) + .expect("expand proof advice"); + + let profile = Stage2AdviceProfileV1::from_advice_bytes(&advice, &fri) + .expect("profile canonical advice"); + assert_eq!(profile.advice_bytes, advice.len() as u64); + assert_eq!(profile.total_circuits, 2); + assert_eq!(profile.active_circuits, 2); + assert_eq!(profile.queries, 2); + assert!(profile.input_rounds_per_query > 0); + assert!(profile.input_merkle_siblings > 0); + + let mut extended = advice; + extended.push(0); + assert!(Stage2AdviceProfileV1::from_advice_bytes(&extended, &fri).is_err()); + } +} diff --git a/flock-stage3/Cargo.lock b/flock-stage3/Cargo.lock new file mode 100644 index 00000000..264ebe79 --- /dev/null +++ b/flock-stage3/Cargo.lock @@ -0,0 +1,1251 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "aiur" +version = "0.1.0" +dependencies = [ + "hashbrown 0.15.5", + "indexmap", + "libc", + "multi-stark", + "num-bigint 0.4.8", + "rayon", + "rustc-hash", + "tracing", + "tracing-texray", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + +[[package]] +name = "bincode" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36eaf5d7b090263e8150820482d5d93cd964a81e4019913c972f4edcc6edb740" +dependencies = [ + "bincode_derive", + "serde", + "unty", +] + +[[package]] +name = "bincode_derive" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf95709a440f45e986983918d0e8a1f30a9b1df04918fc828670606804ac3c09" +dependencies = [ + "virtue", +] + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "blake3" +version = "1.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9e454fc11f76977dc803893aff6304ed33d6a26efae8696573bea74baa27ae" +dependencies = [ + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures 0.3.1", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "cc" +version = "1.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core 0.10.1", +] + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" + +[[package]] +name = "flock-core" +version = "0.1.0" +source = "git+https://github.com/succinctlabs/flock?rev=b310f35f35f68095537150a1c8c0a43caca9a29e#b310f35f35f68095537150a1c8c0a43caca9a29e" +dependencies = [ + "bincode 1.3.3", + "blake3", + "rand_core 0.9.5", + "rayon", + "serde", + "sha2", + "toml", +] + +[[package]] +name = "flock-prover" +version = "0.1.0" +source = "git+https://github.com/succinctlabs/flock?rev=b310f35f35f68095537150a1c8c0a43caca9a29e#b310f35f35f68095537150a1c8c0a43caca9a29e" +dependencies = [ + "bincode 1.3.3", + "blake3", + "flock-core", + "rayon", + "serde", + "sha2", +] + +[[package]] +name = "flock-stage3-host" +version = "0.1.0" +dependencies = [ + "aiur", + "anyhow", + "bincode 1.3.3", + "blake3", + "flock-prover", + "ix-terminal", + "multi-stark", + "serde", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "generator" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3b854b0e584ead1a33f18b2fcad7cf7be18b3875c78816b753639aa501513ae" +dependencies = [ + "cc", + "cfg-if", + "libc", + "log", + "rustversion", + "windows-link", + "windows-result", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "indexmap" +version = "2.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07aa2048142242915a31d35844fb311e0e53fcca590c3a0a40dcf1b841fa09eb" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "itertools" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc" +dependencies = [ + "either", +] + +[[package]] +name = "ix-terminal" +version = "0.1.0" +dependencies = [ + "aiur", + "anyhow", + "bincode 2.0.1", + "blake3", + "multi-stark", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "loom" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" +dependencies = [ + "cfg-if", + "generator", + "scoped-tls", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "multi-stark" +version = "0.1.0" +source = "git+https://github.com/argumentcomputer/multi-stark.git?rev=2892243e674f9a0b3aca9004a8d00c79a23beec1#2892243e674f9a0b3aca9004a8d00c79a23beec1" +dependencies = [ + "bincode 2.0.1", + "num-bigint 0.5.1", + "p3-air", + "p3-blake3", + "p3-challenger", + "p3-commit", + "p3-dft", + "p3-field", + "p3-fri", + "p3-goldilocks", + "p3-keccak", + "p3-matrix", + "p3-maybe-rayon", + "p3-merkle-tree", + "p3-symmetric", + "p3-util", + "serde", + "tracing", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93e7820bc0a80a0238e650327316f929ba18d5be054b647490a3a6a339f3e7c0" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "p3-air" +version = "0.6.0" +source = "git+https://github.com/Plonky3/Plonky3?rev=3c84c158c0939345a3becba60a387643935593d2#3c84c158c0939345a3becba60a387643935593d2" +dependencies = [ + "p3-field", + "p3-matrix", + "serde", + "tracing", +] + +[[package]] +name = "p3-blake3" +version = "0.6.0" +source = "git+https://github.com/Plonky3/Plonky3?rev=3c84c158c0939345a3becba60a387643935593d2#3c84c158c0939345a3becba60a387643935593d2" +dependencies = [ + "blake3", + "p3-symmetric", + "p3-util", +] + +[[package]] +name = "p3-challenger" +version = "0.6.0" +source = "git+https://github.com/Plonky3/Plonky3?rev=3c84c158c0939345a3becba60a387643935593d2#3c84c158c0939345a3becba60a387643935593d2" +dependencies = [ + "p3-field", + "p3-maybe-rayon", + "p3-symmetric", + "p3-util", + "tracing", +] + +[[package]] +name = "p3-commit" +version = "0.6.0" +source = "git+https://github.com/Plonky3/Plonky3?rev=3c84c158c0939345a3becba60a387643935593d2#3c84c158c0939345a3becba60a387643935593d2" +dependencies = [ + "itertools", + "p3-field", + "p3-matrix", + "p3-multilinear-util", + "p3-util", + "serde", +] + +[[package]] +name = "p3-dft" +version = "0.6.0" +source = "git+https://github.com/Plonky3/Plonky3?rev=3c84c158c0939345a3becba60a387643935593d2#3c84c158c0939345a3becba60a387643935593d2" +dependencies = [ + "itertools", + "p3-field", + "p3-matrix", + "p3-maybe-rayon", + "p3-util", + "spin", + "tracing", +] + +[[package]] +name = "p3-field" +version = "0.6.0" +source = "git+https://github.com/Plonky3/Plonky3?rev=3c84c158c0939345a3becba60a387643935593d2#3c84c158c0939345a3becba60a387643935593d2" +dependencies = [ + "itertools", + "num-bigint 0.5.1", + "p3-maybe-rayon", + "p3-util", + "paste", + "rand", + "serde", + "tracing", +] + +[[package]] +name = "p3-fri" +version = "0.6.0" +source = "git+https://github.com/Plonky3/Plonky3?rev=3c84c158c0939345a3becba60a387643935593d2#3c84c158c0939345a3becba60a387643935593d2" +dependencies = [ + "itertools", + "p3-challenger", + "p3-commit", + "p3-dft", + "p3-field", + "p3-matrix", + "p3-maybe-rayon", + "p3-security", + "p3-util", + "rand", + "serde", + "spin", + "thiserror", + "tracing", +] + +[[package]] +name = "p3-goldilocks" +version = "0.6.0" +source = "git+https://github.com/Plonky3/Plonky3?rev=3c84c158c0939345a3becba60a387643935593d2#3c84c158c0939345a3becba60a387643935593d2" +dependencies = [ + "num-bigint 0.5.1", + "p3-dft", + "p3-field", + "p3-mds", + "p3-poseidon1", + "p3-poseidon2", + "p3-symmetric", + "p3-util", + "paste", + "rand", + "serde", + "spin", +] + +[[package]] +name = "p3-keccak" +version = "0.6.0" +source = "git+https://github.com/Plonky3/Plonky3?rev=3c84c158c0939345a3becba60a387643935593d2#3c84c158c0939345a3becba60a387643935593d2" +dependencies = [ + "p3-symmetric", + "p3-util", + "tiny-keccak", +] + +[[package]] +name = "p3-matrix" +version = "0.6.0" +source = "git+https://github.com/Plonky3/Plonky3?rev=3c84c158c0939345a3becba60a387643935593d2#3c84c158c0939345a3becba60a387643935593d2" +dependencies = [ + "itertools", + "p3-field", + "p3-maybe-rayon", + "p3-util", + "rand", + "serde", + "tracing", +] + +[[package]] +name = "p3-maybe-rayon" +version = "0.6.0" +source = "git+https://github.com/Plonky3/Plonky3?rev=3c84c158c0939345a3becba60a387643935593d2#3c84c158c0939345a3becba60a387643935593d2" +dependencies = [ + "rayon", +] + +[[package]] +name = "p3-mds" +version = "0.6.0" +source = "git+https://github.com/Plonky3/Plonky3?rev=3c84c158c0939345a3becba60a387643935593d2#3c84c158c0939345a3becba60a387643935593d2" +dependencies = [ + "p3-dft", + "p3-field", + "p3-symmetric", + "p3-util", + "rand", +] + +[[package]] +name = "p3-merkle-tree" +version = "0.6.0" +source = "git+https://github.com/Plonky3/Plonky3?rev=3c84c158c0939345a3becba60a387643935593d2#3c84c158c0939345a3becba60a387643935593d2" +dependencies = [ + "itertools", + "p3-commit", + "p3-field", + "p3-matrix", + "p3-maybe-rayon", + "p3-symmetric", + "p3-util", + "rand", + "serde", + "spin", + "thiserror", + "tracing", +] + +[[package]] +name = "p3-multilinear-util" +version = "0.6.0" +source = "git+https://github.com/Plonky3/Plonky3?rev=3c84c158c0939345a3becba60a387643935593d2#3c84c158c0939345a3becba60a387643935593d2" +dependencies = [ + "itertools", + "p3-field", + "p3-matrix", + "p3-maybe-rayon", + "p3-util", + "rand", + "serde", + "tracing", +] + +[[package]] +name = "p3-poseidon1" +version = "0.6.0" +source = "git+https://github.com/Plonky3/Plonky3?rev=3c84c158c0939345a3becba60a387643935593d2#3c84c158c0939345a3becba60a387643935593d2" +dependencies = [ + "p3-field", + "p3-mds", + "p3-symmetric", + "rand", +] + +[[package]] +name = "p3-poseidon2" +version = "0.6.0" +source = "git+https://github.com/Plonky3/Plonky3?rev=3c84c158c0939345a3becba60a387643935593d2#3c84c158c0939345a3becba60a387643935593d2" +dependencies = [ + "p3-field", + "p3-mds", + "p3-symmetric", + "p3-util", + "rand", +] + +[[package]] +name = "p3-security" +version = "0.6.0" +source = "git+https://github.com/Plonky3/Plonky3?rev=3c84c158c0939345a3becba60a387643935593d2#3c84c158c0939345a3becba60a387643935593d2" +dependencies = [ + "libm", + "p3-air", + "p3-field", + "p3-util", + "serde", +] + +[[package]] +name = "p3-symmetric" +version = "0.6.0" +source = "git+https://github.com/Plonky3/Plonky3?rev=3c84c158c0939345a3becba60a387643935593d2#3c84c158c0939345a3becba60a387643935593d2" +dependencies = [ + "itertools", + "p3-field", + "p3-util", + "serde", +] + +[[package]] +name = "p3-util" +version = "0.6.0" +source = "git+https://github.com/Plonky3/Plonky3?rev=3c84c158c0939345a3becba60a387643935593d2#3c84c158c0939345a3becba60a387643935593d2" +dependencies = [ + "p3-maybe-rayon", + "serde", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", + "sha2-asm", +] + +[[package]] +name = "sha2-asm" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b845214d6175804686b2bd482bcffe96651bb2d1200742b712003504a2dac1ab" +dependencies = [ + "cc", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "spin" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0134f9043ed38b087ac4f7d4af44c79e2c9e5094421fe3164f435ce585953b10" +dependencies = [ + "lock_api", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "terminal_size" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" +dependencies = [ + "rustix", + "windows-sys", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "tracing-texray" +version = "0.2.0" +source = "git+https://github.com/argumentcomputer/tracing-texray?rev=465bbca0bea4721e58419c11cabd8cce21757822#465bbca0bea4721e58419c11cabd8cce21757822" +dependencies = [ + "loom", + "parking_lot", + "terminal_size", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unty" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "virtue" +version = "0.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "051eb1abcf10076295e815102942cc58f9d5e3b4560e46e53c21e8ff6f3af7b1" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] diff --git a/flock-stage3/Cargo.toml b/flock-stage3/Cargo.toml new file mode 100644 index 00000000..f519d67c --- /dev/null +++ b/flock-stage3/Cargo.toml @@ -0,0 +1,30 @@ +[workspace] +members = ["host"] +resolver = "2" + +[workspace.package] +version = "0.1.0" +edition = "2024" +license = "MIT OR Apache-2.0" + +[workspace.dependencies] +anyhow = "1" +aiur = { path = "../crates/aiur" } +bincode = "1.3" +blake3 = "1.8.4" +flock-prover = { git = "https://github.com/succinctlabs/flock", rev = "b310f35f35f68095537150a1c8c0a43caca9a29e" } +ix-terminal = { path = "../crates/terminal" } +multi-stark = { git = "https://github.com/argumentcomputer/multi-stark.git", rev = "2892243e674f9a0b3aca9004a8d00c79a23beec1" } +serde = { version = "1", features = ["derive"] } + +[workspace.lints.rust] +invalid_reference_casting = "warn" +nonstandard_style = "warn" +rust_2018_idioms = { level = "warn", priority = -1 } +unreachable_pub = "warn" +unused_lifetimes = "warn" +unused_qualifications = "warn" + +[workspace.lints.clippy] +all = { level = "warn", priority = -1 } +dbg_macro = "warn" diff --git a/flock-stage3/README.md b/flock-stage3/README.md new file mode 100644 index 00000000..f0fe0e7f --- /dev/null +++ b/flock-stage3/README.md @@ -0,0 +1,115 @@ +# Ix Flock Stage 3 + +This workspace implements the no-RISC-V Stage 3 compressor: + +```text +Stage 2 Aiur recursive-FRI root + -> Flock proof of statement + AIR/logUp + PCS + FRI verification + -> Stage 4 terminal SNARK +``` + +The backend uses Flock `Fast128` over `F128`, BLAKE3 Merkle commitments, and +chained-BLAKE3 Fiat-Shamir. The upstream revision is pinned to +`b310f35f35f68095537150a1c8c0a43caca9a29e`; changing it is a protocol change. + +## Current status + +`FlockStage3Backend::prove_stage2` now generates a complete Stage 3 proof. +The production path: + +1. validates canonical Aiur verifier-key, claim, and proof encodings; +2. expands the compact multiproof into the typed verifier witness; +3. compiles the specialised fixed-shape Flock relation; +4. proves it under the production Stage 3 transcript domain; +5. binds the compiled circuit, Flock configuration, Stage 2 key, witness + layout, capacity, and completed phase mask in `Stage3RelationManifestV1`; +6. returns a strict, versioned `Stage3ArtifactV1`. + +`FlockStage3Backend::verify_stage2` requires an externally expected +`Stage3StatementV1`. It reconstructs the relation from the canonical Stage 2 +transport, checks the expected root and relation-manifest digest, and verifies +the Flock bundle. The relation digest therefore has to be pinned by the +deployment; accepting a relation digest supplied only by the prover would not +specialise the verifier key. + +The single relation constrains all eleven registered verifier phases: + +- typed witness shape, sparse activation, and active trace heights; +- specialised Aiur verifying-key/AIR metadata; +- all 18 canonical Goldilocks claim words and the 224-byte Stage 2 statement; +- lookup-message inversion, intermediate logUp accumulators, and final balance; +- exact chained-BLAKE3 transcript replay; +- Goldilocks and degree-two extension arithmetic; +- first/last/transition selectors and compiled AIR DAG evaluation; +- alpha-folded OOD composition and quotient recombination; +- every multi-matrix, multi-height PCS opening and BLAKE3 MMCS path; +- every binary FRI beta, grinding draw, query index, fold, roll-in, and final + polynomial check; and +- one published BLAKE3 Stage 2 root shared by the statement and proof checks. + +PCS leaves use the full BLAKE3 tree hasher, including rows wider than one block +and messages beyond one 1,024-byte chunk. Transcript field sampling follows +Plonky3 rejection sampling across the current digest plus one constrained +chained refill. The bounded circuit fails closed only if fewer than two values +are canonical among eight candidates, or among seven after a raw commit-PoW +draw. The latter probability is below roughly `2^-189`. + +## Measured complete proof + +The production regression fixture is a real canonical multi-STARK proof with +an inactive leading circuit, active circuits at heights 8 and 4, an active +preprocessed matrix, an 18-word claim lookup, nontrivial first-row/transition +constraints, and two FRI queries. + +On the debug profile, the complete production round trip produced: + +- Stage 3 artifact: **326,019 bytes**; +- encoded production payload: **325,893 bytes**; and +- prove + decode + valid verify + negative checks: **529.55 seconds**. + +The negative checks reject a different relation digest and a corrupted proof. +This size is expected: Stage 3 is the off-chain proof whose small fixed +verifier is compressed by Stage 4. It is not the sub-kilobyte Ethereum proof. + +Run the exact regression with: + +```sh +cargo test -p flock-stage3-host real_stage2_production_artifact_round_trip -- --ignored --nocapture +``` + +The ordinary suite exercises relation construction and native/circuit +differential checks without paying the full proving cost: + +```sh +cargo test -p flock-stage3-host --lib +cargo clippy -p flock-stage3-host --all-targets -- -D warnings +``` + +Print the selected Flock configuration and digest with: + +```sh +cargo run -p flock-stage3-host --bin flock-stage3-config +``` + +## Scope and remaining work + +The current relation is deliberately specialised to the configuration used by +Ix: 18 claim words, binary FRI, cap height zero, and an exact activation/height +shape. Host deserialization is witness generation rather than trusted +acceptance; every lowered value reaches a verifier constraint. Native +prevalidation remains an ergonomics and cost guard. + +Before freezing a production deployment, Stage 3 still needs: + +- capacity measurements over the intended aggregate-proof corpus rather than + one small fixture; +- differential vectors for production-sized Aiur roots and nonzero grinding; +- an independent review of the local Boolean R1CS tables and pinned Flock + soundness profile; and +- a canonical export of the fixed Flock verifier inputs for Stage 4 witness + generation. + +The next implementation boundary is Stage 4: compile verification of this +fixed relation into the universal-setup FFLONK development backend, measure its +constraint/gas costs, and retain the option to switch the same statement to a +circuit-specific Groth16 endpoint once the relation is stable. diff --git a/flock-stage3/host/Cargo.toml b/flock-stage3/host/Cargo.toml new file mode 100644 index 00000000..77c77068 --- /dev/null +++ b/flock-stage3/host/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "flock-stage3-host" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +anyhow = { workspace = true } +aiur = { workspace = true } +bincode = { workspace = true } +blake3 = { workspace = true } +flock-prover = { workspace = true } +ix-terminal = { workspace = true } +multi-stark = { workspace = true } +serde = { workspace = true } + +[lints] +workspace = true diff --git a/flock-stage3/host/src/air.rs b/flock-stage3/host/src/air.rs new file mode 100644 index 00000000..a77f63f7 --- /dev/null +++ b/flock-stage3/host/src/air.rs @@ -0,0 +1,1048 @@ +//! Compiled Aiur AIR evaluation inside the Stage 3 Flock relation. +//! +//! The verifier evaluates every compiled base-polynomial node in the degree-2 +//! challenge field. LogUp is then evaluated in coordinates: one logical +//! accumulator consists of two such challenge-field values. This mirrors +//! `multi_stark::verifier` rather than trusting a host-computed composition. + +use aiur::vk_codec::{AiurAirCircuitMetadata, AiurVerifyingKey}; +use anyhow::{Result, bail}; +use flock_prover::{ + circuit::builder::{ShapeBuilder, SlotId, Wire}, + field::F128, +}; +use ix_terminal::{ + STAGE2_ROOT_STATEMENT_BYTES, ValidatedStage2RootV1, fri_parameter_words, +}; +use multi_stark::{ + expr::{RowOffset, Source}, + graph::Node, + lookup::{Lookup, WidthBinding}, + p3_field::{BasedVectorSpace, Field, PrimeCharacteristicRing, PrimeField64}, + types::{ExtVal, FriParameters, Val}, +}; + +use crate::{ + Stage2PcsInstanceV1, Stage2TranscriptByteBindingV1, Stage2TranscriptReplayV1, + Stage2TranscriptSegmentV1, Stage3TypedProofWitnessV1, + binding::pack_bytes, + extension::GoldilocksCircuitSlots, + fri::{ + assert_f128_equal, bound_transcript_extension, bound_transcript_window, + record_fixed, + }, + goldilocks::GOLDILOCKS_MODULUS, + transcript::TranscriptConstraintRegion, + transcript::{constrain_hash, hash_trace}, +}; + +const EXTENSION_DEGREE: usize = 2; +const STAGE2_STATEMENT_PREFIX_BYTES: usize = 80; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Stage2ActiveAirCircuitV1 { + pub circuit_index: usize, + pub log_degree: u8, + pub metadata: AiurAirCircuitMetadata, + pub log_degree_binding: Stage2TranscriptByteBindingV1, + pub accumulator_binding: Stage2TranscriptByteBindingV1, +} + +/// Fixed compiled programs and transcript locations for one Stage 2 proof +/// shape. Claim values remain dynamic public transcript words. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Stage2AirProgramV1 { + pub active: Vec, + pub activation_bindings: Vec, + pub active_circuits: Vec, + pub claim_bindings: Vec, + pub width_binding: WidthBinding, + pub statement_prefix: [u8; STAGE2_STATEMENT_PREFIX_BYTES], + pub statement_digest: [u8; 32], +} + +impl Stage2AirProgramV1 { + pub fn from_prepared( + prepared: &ValidatedStage2RootV1, + fri: &FriParameters, + pcs: &Stage2PcsInstanceV1, + ) -> Result { + let typed = Stage3TypedProofWitnessV1::from_prepared(prepared, fri)?; + Self::from_prepared_and_typed(prepared, fri, pcs, &typed) + } + + pub fn from_prepared_and_typed( + prepared: &ValidatedStage2RootV1, + fri: &FriParameters, + pcs: &Stage2PcsInstanceV1, + typed: &Stage3TypedProofWitnessV1, + ) -> Result { + typed.ensure_profile(prepared.advice_profile())?; + let key = AiurVerifyingKey::from_bytes(prepared.verifying_key_bytes()) + .map_err(|error| anyhow::anyhow!("decode Aiur AIR key: {error}"))?; + if key.to_bytes() != prepared.verifying_key_bytes() { + bail!("Aiur AIR key is not canonically encoded"); + } + if fri_parameter_words(&key.fri_parameters()) != fri_parameter_words(fri) { + bail!("Stage 3 AIR lowering uses different FRI parameters"); + } + if key.commitment_parameters().cap_height != 0 { + bail!("Stage 3 AIR lowering currently requires cap height zero"); + } + + let metadata = key.air_circuit_metadata(); + if metadata.len() != typed.active.len() { + bail!("Aiur AIR metadata and activation lengths disagree"); + } + let active_indices: Vec<_> = typed + .active + .iter() + .enumerate() + .filter_map(|(index, &active)| active.then_some(index)) + .collect(); + if active_indices.len() != typed.log_degrees.len() + || active_indices.len() != typed.intermediate_accumulators.len() + { + bail!("Aiur AIR active-circuit vectors disagree"); + } + if typed.intermediate_accumulators.last() != Some(&[0, 0]) { + bail!("Aiur AIR lookup accumulator is not balanced"); + } + + validate_pcs_geometry(pcs, &metadata, &active_indices, typed)?; + + let seed_bytes = key.transcript_seed_and_shape_bytes().len(); + let activation_base = seed_bytes; + let preprocessed_bytes = key + .preprocessed_commitment_roots() + .as_ref() + .map_or(0, |roots| roots.len() * 32); + let stage_1_bytes = typed.commitments.stage_1_trace.len() * 32; + let log_degree_base = activation_base + .checked_add(typed.active.len() * 8) + .and_then(|offset| offset.checked_add(preprocessed_bytes)) + .and_then(|offset| offset.checked_add(stage_1_bytes)) + .ok_or_else(|| anyhow::anyhow!("AIR transcript offset overflow"))?; + let claims_base = log_degree_base + .checked_add(typed.log_degrees.len() * 8) + .ok_or_else(|| anyhow::anyhow!("AIR claim offset overflow"))?; + + let claim_words = prepared.statement().outer_claim_words().to_vec(); + if prepared.claims_bytes().len() != 16 + claim_words.len() * 8 { + bail!("Stage 2 recursive claim transport has the wrong length"); + } + let claim_bindings = (0..claim_words.len()) + .map(|word| { + Stage2TranscriptByteBindingV1::new( + Stage2TranscriptSegmentV1::Initial, + claims_base + 16 + word * 8, + ) + }) + .collect(); + let activation_bindings = (0..typed.active.len()) + .map(|circuit| { + Stage2TranscriptByteBindingV1::new( + Stage2TranscriptSegmentV1::Initial, + activation_base + circuit * 8, + ) + }) + .collect(); + + let active_circuits = active_indices + .iter() + .enumerate() + .map(|(position, &circuit_index)| Stage2ActiveAirCircuitV1 { + circuit_index, + log_degree: typed.log_degrees[position], + metadata: metadata[circuit_index].clone(), + log_degree_binding: Stage2TranscriptByteBindingV1::new( + Stage2TranscriptSegmentV1::Initial, + log_degree_base + position * 8, + ), + accumulator_binding: Stage2TranscriptByteBindingV1::new( + Stage2TranscriptSegmentV1::Stage2AndAccumulator, + typed.commitments.stage_2_trace.len() * 32 + position * 16, + ), + }) + .collect::>(); + + let height_weight = + active_circuits.iter().try_fold(1u128, |total, circuit| { + let per_row: u128 = circuit + .metadata + .graph + .lookups + .iter() + .map(|lookup| u128::from(lookup.max_multiplicity)) + .sum(); + let height = 1u128 << circuit.log_degree; + total + .checked_add(per_row.saturating_mul(height)) + .ok_or_else(|| anyhow::anyhow!("AIR multiplicity bound overflow")) + })?; + if height_weight >= u128::from(GOLDILOCKS_MODULUS) { + bail!("AIR multiplicity height bound exceeds Goldilocks"); + } + + let statement_bytes = prepared.statement().to_bytes(); + if statement_bytes.len() != STAGE2_ROOT_STATEMENT_BYTES { + bail!("Stage 2 statement has the wrong length"); + } + let mut statement_prefix = [0u8; STAGE2_STATEMENT_PREFIX_BYTES]; + statement_prefix + .copy_from_slice(&statement_bytes[..STAGE2_STATEMENT_PREFIX_BYTES]); + + Ok(Self { + active: typed.active.clone(), + activation_bindings, + active_circuits, + claim_bindings, + width_binding: key.width_binding(), + statement_prefix, + statement_digest: prepared.statement().digest(), + }) + } + + pub(crate) fn row_budget(&self) -> usize { + let graph_rows = self + .active_circuits + .iter() + .map(|circuit| { + let nodes = circuit.metadata.graph.nodes.len(); + let constraints = circuit.metadata.graph.zeros.len() + + circuit + .metadata + .graph + .lookups + .len() + .div_ceil(circuit.metadata.lookup_group_size) + * EXTENSION_DEGREE; + nodes + .saturating_mul(96) + .saturating_add(constraints.saturating_mul(192)) + .saturating_add(circuit.metadata.quotient_degree * 96) + .saturating_add(2048) + }) + .sum::(); + graph_rows + .saturating_add(self.claim_bindings.len().saturating_mul(128)) + .max(1) + } +} + +fn validate_pcs_geometry( + pcs: &Stage2PcsInstanceV1, + metadata: &[AiurAirCircuitMetadata], + active_indices: &[usize], + typed: &Stage3TypedProofWitnessV1, +) -> Result<()> { + let expected_batches = + 3 + usize::from(typed.preprocessed_opened_values.is_some()); + if pcs.batches.len() != expected_batches { + bail!("AIR PCS batch count disagrees with the typed proof"); + } + for batch in &pcs.batches[..3] { + if batch.matrices.len() != active_indices.len() { + bail!("AIR PCS active-matrix count disagrees with the typed proof"); + } + } + for (position, &circuit_index) in active_indices.iter().enumerate() { + let circuit = &metadata[circuit_index]; + let expected = [ + (circuit.main_width, 2usize), + (circuit.stage_2_width, 2), + (circuit.quotient_degree * EXTENSION_DEGREE, 1), + ]; + for (batch, (width, points)) in expected.into_iter().enumerate() { + let matrix = &pcs.batches[batch].matrices[position]; + if matrix.width != width || matrix.opening_points.len() != points { + bail!("AIR PCS matrix geometry disagrees with the verifier key"); + } + } + } + Ok(()) +} + +#[allow(clippy::too_many_arguments)] +pub(crate) fn constrain_stage2_air( + builder: &mut ShapeBuilder, + arithmetic: &GoldilocksCircuitSlots, + blake3: SlotId, + equality: SlotId, + equality_zero: Wire, + window: SlotId, + data_zero: Wire, + one: Wire, + iv: [Wire; 2], + inputs: &mut Vec, + public: &mut Vec, + prefix_region: &TranscriptConstraintRegion, + prefix: &Stage2TranscriptReplayV1, + pcs: &Stage2PcsInstanceV1, + program: &Stage2AirProgramV1, +) -> Result<()> { + let challenges = prefix.challenges()?; + let lookup = prefix_region.challenges.lookup; + let fingerprint = prefix_region.challenges.fingerprint; + let alpha = prefix_region.challenges.constraint; + let zeta = prefix_region.challenges.zeta; + for wire in [lookup, fingerprint, alpha, zeta] { + arithmetic.assert_canonical(builder, wire); + } + + // Bind the specialised activation pattern and active trace heights to the + // exact words already consumed by Fiat--Shamir. + for (&active, &binding) in + program.active.iter().zip(&program.activation_bindings) + { + let observed = bound_low_word( + builder, + arithmetic, + window, + data_zero, + inputs, + public, + prefix_region, + binding, + ); + let expected = + record_fixed(builder, inputs, public, F128::new(u64::from(active), 0)); + assert_f128_equal(builder, equality, equality_zero, observed, expected); + } + for circuit in &program.active_circuits { + let observed = bound_low_word( + builder, + arithmetic, + window, + data_zero, + inputs, + public, + prefix_region, + circuit.log_degree_binding, + ); + let expected = record_fixed( + builder, + inputs, + public, + F128::new(u64::from(circuit.log_degree), 0), + ); + assert_f128_equal(builder, equality, equality_zero, observed, expected); + } + + let claim_wires: Vec<_> = program + .claim_bindings + .iter() + .map(|&binding| { + let wire = bound_low_word( + builder, + arithmetic, + window, + data_zero, + inputs, + public, + prefix_region, + binding, + ); + arithmetic.assert_canonical(builder, wire); + wire + }) + .collect(); + constrain_stage2_statement( + builder, + arithmetic, + blake3, + data_zero, + iv, + inputs, + public, + &claim_wires, + program, + )?; + + let native_lookup = native_extension(challenges.lookup); + let native_fingerprint = native_extension(challenges.fingerprint); + let mut native_message = ExtVal::ZERO; + let native_claim_words = program + .claim_bindings + .iter() + .map(|&binding| read_bound_u64(prefix, binding)) + .collect::>>()?; + for &word in native_claim_words.iter().rev() { + native_message = native_message * native_fingerprint + Val::from_u64(word); + } + native_message += native_lookup; + let native_inverse = native_message + .try_inverse() + .ok_or_else(|| anyhow::anyhow!("Stage 2 claim lookup message is zero"))?; + + let mut claim_fingerprint = data_zero; + for &word in claim_wires.iter().rev() { + let scaled = arithmetic.ext2_mul(builder, claim_fingerprint, fingerprint); + claim_fingerprint = arithmetic.add(builder, scaled, word); + } + let message = arithmetic.add(builder, lookup, claim_fingerprint); + let inverse = record_private( + builder, + inputs, + pack_extension(extension_words(native_inverse)), + ); + arithmetic.assert_canonical(builder, inverse); + let inverse_check = arithmetic.ext2_mul(builder, message, inverse); + assert_f128_equal(builder, equality, equality_zero, inverse_check, one); + let mut accumulator = inverse; + + let neg_one = + record_fixed(builder, inputs, public, F128::new(GOLDILOCKS_MODULUS - 1, 0)); + let seven = record_fixed(builder, inputs, public, F128::new(7, 0)); + let basis_u = record_fixed(builder, inputs, public, F128::new(0, 1)); + + for (position, circuit) in program.active_circuits.iter().enumerate() { + let next_accumulator = bound_transcript_extension( + builder, + window, + data_zero, + inputs, + public, + prefix_region, + circuit.accumulator_binding, + 0, + ); + arithmetic.assert_canonical(builder, next_accumulator); + if position + 1 == program.active_circuits.len() { + assert_f128_equal( + builder, + equality, + equality_zero, + next_accumulator, + data_zero, + ); + } + + let lookup_coords = arithmetic.ext2_coordinates(builder, lookup); + let fingerprint_coords = arithmetic.ext2_coordinates(builder, fingerprint); + let accumulator_coords = arithmetic.ext2_coordinates(builder, accumulator); + let next_accumulator_coords = + arithmetic.ext2_coordinates(builder, next_accumulator); + let publics = [ + lookup_coords[0], + lookup_coords[1], + fingerprint_coords[0], + fingerprint_coords[1], + accumulator_coords[0], + accumulator_coords[1], + next_accumulator_coords[0], + next_accumulator_coords[1], + ]; + + let openings = bind_air_openings( + builder, + window, + data_zero, + inputs, + public, + prefix_region, + pcs, + circuit, + position, + )?; + let selector_values = + native_selectors(challenges.zeta, circuit.log_degree)?; + let is_first = + record_private(builder, inputs, pack_extension(selector_values.is_first)); + let is_last = + record_private(builder, inputs, pack_extension(selector_values.is_last)); + let inv_vanishing = record_private( + builder, + inputs, + pack_extension(selector_values.inv_vanishing), + ); + for selector in [is_first, is_last, inv_vanishing] { + arithmetic.assert_canonical(builder, selector); + } + + let mut zeta_pow_n = zeta; + for _ in 0..circuit.log_degree { + zeta_pow_n = arithmetic.ext2_mul(builder, zeta_pow_n, zeta_pow_n); + } + let z_h = ext_sub(builder, arithmetic, neg_one, zeta_pow_n, one); + let inv_check = arithmetic.ext2_mul(builder, inv_vanishing, z_h); + assert_f128_equal(builder, equality, equality_zero, inv_check, one); + let zeta_minus_one = ext_sub(builder, arithmetic, neg_one, zeta, one); + let first_check = arithmetic.ext2_mul(builder, is_first, zeta_minus_one); + assert_f128_equal(builder, equality, equality_zero, first_check, z_h); + + let generator = Val::TWO_ADIC_GENERATORS[usize::from(circuit.log_degree)]; + let generator_inverse = generator.inverse(); + let generator_inverse_wire = record_fixed( + builder, + inputs, + public, + F128::new(generator_inverse.as_canonical_u64(), 0), + ); + let is_transition = + ext_sub(builder, arithmetic, neg_one, zeta, generator_inverse_wire); + let last_check = arithmetic.ext2_mul(builder, is_last, is_transition); + assert_f128_equal(builder, equality, equality_zero, last_check, z_h); + + let n = Val::from_u64(1u64 << circuit.log_degree); + let injection_scale = (n * generator).inverse(); + let injection_scale = record_fixed( + builder, + inputs, + public, + F128::new(injection_scale.as_canonical_u64(), 0), + ); + let delta_scaled = std::array::from_fn(|coordinate| { + let delta = ext_sub( + builder, + arithmetic, + neg_one, + next_accumulator_coords[coordinate], + accumulator_coords[coordinate], + ); + arithmetic.ext2_mul(builder, delta, injection_scale) + }); + + let node_values = constrain_graph( + builder, + arithmetic, + neg_one, + inputs, + public, + circuit, + &openings, + &publics, + is_first, + is_last, + is_transition, + )?; + let mut constraints: Vec<_> = circuit + .metadata + .graph + .zeros + .iter() + .map(|root| node_values[root.index()]) + .collect(); + constraints.extend(constrain_logup( + builder, + arithmetic, + neg_one, + seven, + data_zero, + one, + &circuit.metadata.graph.lookups, + circuit.metadata.lookup_group_size, + program.width_binding, + &node_values, + &openings.stage2[0], + &openings.stage2[1], + &publics, + &delta_scaled, + is_last, + inputs, + public, + )); + + let mut composition = data_zero; + for constraint in constraints { + let scaled = arithmetic.ext2_mul(builder, composition, alpha); + composition = arithmetic.add(builder, scaled, constraint); + } + + let mut quotient = data_zero; + let mut power = one; + for chunk in openings.quotient.as_chunks::().0 { + let high = arithmetic.ext2_mul(builder, chunk[1], basis_u); + let coefficient = arithmetic.add(builder, chunk[0], high); + let term = arithmetic.ext2_mul(builder, power, coefficient); + quotient = arithmetic.add(builder, quotient, term); + power = arithmetic.ext2_mul(builder, power, zeta_pow_n); + } + let ood = arithmetic.ext2_mul(builder, composition, inv_vanishing); + assert_f128_equal(builder, equality, equality_zero, ood, quotient); + accumulator = next_accumulator; + } + Ok(()) +} + +#[allow(clippy::too_many_arguments)] +fn constrain_stage2_statement( + builder: &mut ShapeBuilder, + arithmetic: &GoldilocksCircuitSlots, + blake3: SlotId, + data_zero: Wire, + iv: [Wire; 2], + inputs: &mut Vec, + public: &mut Vec, + claims: &[Wire], + program: &Stage2AirProgramV1, +) -> Result<()> { + if claims.len() != 18 { + bail!("Stage 2 statement binding requires 18 claim words"); + } + let mut message = program + .statement_prefix + .as_chunks::<16>() + .0 + .iter() + .map(|word| record_fixed(builder, inputs, public, pack_bytes(word))) + .collect::>(); + for pair in claims.as_chunks::<2>().0 { + let high_lanes = builder.gate(arithmetic.repack, &[pair[1], data_zero]); + let packed = builder.gate(arithmetic.repack, &[pair[0], high_lanes[0]])[3]; + message.push(packed); + } + let trace = hash_trace(STAGE2_ROOT_STATEMENT_BYTES); + let parameters = trace + .rows + .iter() + .map(|&(_cv, _message, counter, block_len, flags)| { + record_fixed( + builder, + inputs, + public, + crate::binding::pack_params(counter, block_len, flags), + ) + }) + .collect::>(); + let root = constrain_hash( + builder, + blake3, + &trace, + ¶meters, + iv, + data_zero, + &message, + )?; + builder.publish(root[0]); + builder.publish(root[1]); + public.extend_from_slice(&[ + pack_bytes(&program.statement_digest[..16]), + pack_bytes(&program.statement_digest[16..]), + ]); + Ok(()) +} + +struct BoundAirOpenings { + preprocessed: [Vec; 2], + main: [Vec; 2], + stage2: [Vec; 2], + quotient: Vec, +} + +#[allow(clippy::too_many_arguments)] +fn bind_air_openings( + builder: &mut ShapeBuilder, + window: SlotId, + data_zero: Wire, + inputs: &mut Vec, + public: &mut Vec, + prefix_region: &TranscriptConstraintRegion, + pcs: &Stage2PcsInstanceV1, + circuit: &Stage2ActiveAirCircuitV1, + position: usize, +) -> Result { + let main = bind_matrix( + builder, + window, + data_zero, + inputs, + public, + prefix_region, + pcs, + 0, + position, + )?; + let stage2 = bind_matrix( + builder, + window, + data_zero, + inputs, + public, + prefix_region, + pcs, + 1, + position, + )?; + let quotient = bind_matrix( + builder, + window, + data_zero, + inputs, + public, + prefix_region, + pcs, + 2, + position, + )?; + if main.len() != 2 || stage2.len() != 2 || quotient.len() != 1 { + bail!("AIR opening-point geometry is invalid"); + } + let preprocessed = if let Some(slot) = circuit.metadata.preprocessed_slot { + let values = bind_matrix( + builder, + window, + data_zero, + inputs, + public, + prefix_region, + pcs, + 3, + slot, + )?; + if values.len() != 2 { + bail!("active AIR preprocessed matrix has the wrong opening count"); + } + [values[0].clone(), values[1].clone()] + } else { + [Vec::new(), Vec::new()] + }; + Ok(BoundAirOpenings { + preprocessed, + main: [main[0].clone(), main[1].clone()], + stage2: [stage2[0].clone(), stage2[1].clone()], + quotient: quotient[0].clone(), + }) +} + +#[allow(clippy::too_many_arguments)] +fn bind_matrix( + builder: &mut ShapeBuilder, + window: SlotId, + data_zero: Wire, + inputs: &mut Vec, + public: &mut Vec, + prefix_region: &TranscriptConstraintRegion, + pcs: &Stage2PcsInstanceV1, + batch: usize, + matrix: usize, +) -> Result>> { + let matrix = pcs + .batches + .get(batch) + .and_then(|batch| batch.matrices.get(matrix)) + .ok_or_else(|| anyhow::anyhow!("AIR PCS matrix is missing"))?; + Ok( + (0..matrix.opening_points.len()) + .map(|point| { + (0..matrix.width) + .map(|column| { + bound_transcript_extension( + builder, + window, + data_zero, + inputs, + public, + prefix_region, + matrix.opened_values, + point * matrix.width + column, + ) + }) + .collect() + }) + .collect(), + ) +} + +#[allow(clippy::too_many_arguments)] +fn constrain_graph( + builder: &mut ShapeBuilder, + arithmetic: &GoldilocksCircuitSlots, + neg_one: Wire, + inputs: &mut Vec, + public: &mut Vec, + circuit: &Stage2ActiveAirCircuitV1, + openings: &BoundAirOpenings, + publics: &[Wire; 8], + is_first: Wire, + is_last: Wire, + is_transition: Wire, +) -> Result> { + let mut values = Vec::with_capacity(circuit.metadata.graph.nodes.len()); + for node in &circuit.metadata.graph.nodes { + let value = match *node { + Node::Const(value) => record_fixed( + builder, + inputs, + public, + F128::new(value.as_canonical_u64(), 0), + ), + Node::Var(column) => { + let rows = match column.source { + Source::Preprocessed => &openings.preprocessed, + Source::Main => &openings.main, + Source::Stage2 => &openings.stage2, + }; + let row = match column.offset { + RowOffset::Current => 0, + RowOffset::Next => 1, + }; + *rows[row] + .get(usize::try_from(column.index).unwrap()) + .ok_or_else(|| anyhow::anyhow!("AIR graph column is out of range"))? + }, + Node::Public(index) => *publics + .get(usize::try_from(index).unwrap()) + .ok_or_else(|| anyhow::anyhow!("AIR graph public is out of range"))?, + Node::IsFirstRow => is_first, + Node::IsLastRow => is_last, + Node::IsTransition => is_transition, + Node::Add(left, right) => { + arithmetic.add(builder, values[left.index()], values[right.index()]) + }, + Node::Sub(left, right) => ext_sub( + builder, + arithmetic, + neg_one, + values[left.index()], + values[right.index()], + ), + Node::Mul(left, right) => arithmetic.ext2_mul( + builder, + values[left.index()], + values[right.index()], + ), + Node::Neg(value) => { + arithmetic.ext2_mul(builder, values[value.index()], neg_one) + }, + }; + values.push(value); + } + Ok(values) +} + +#[allow(clippy::too_many_arguments)] +fn constrain_logup( + builder: &mut ShapeBuilder, + arithmetic: &GoldilocksCircuitSlots, + neg_one: Wire, + seven: Wire, + zero: Wire, + one: Wire, + lookups: &[Lookup], + group_size: usize, + width_binding: WidthBinding, + node_values: &[Wire], + stage2: &[Wire], + stage2_next: &[Wire], + publics: &[Wire; 8], + delta_scaled: &[Wire; 2], + is_last: Wire, + inputs: &mut Vec, + public_inputs: &mut Vec, +) -> Vec { + let beta = [publics[0], publics[1]]; + let gamma = [publics[2], publics[3]]; + let injection = [ + arithmetic.ext2_mul(builder, is_last, delta_scaled[0]), + arithmetic.ext2_mul(builder, is_last, delta_scaled[1]), + ]; + if lookups.is_empty() { + return (0..EXTENSION_DEGREE) + .map(|coordinate| { + let difference = ext_sub( + builder, + arithmetic, + neg_one, + stage2_next[coordinate], + stage2[coordinate], + ); + arithmetic.add(builder, difference, injection[coordinate]) + }) + .collect(); + } + + let group_size = group_size.max(1); + let last_group = lookups.len().div_ceil(group_size) - 1; + let mut constraints = Vec::new(); + for (group, chunk) in lookups.chunks(group_size).enumerate() { + let source = [stage2[2 * group], stage2[2 * group + 1]]; + let target = if group < last_group { + [stage2[2 * group + 2], stage2[2 * group + 3]] + } else { + [ + arithmetic.add(builder, stage2_next[0], injection[0]), + arithmetic.add(builder, stage2_next[1], injection[1]), + ] + }; + let difference = [ + ext_sub(builder, arithmetic, neg_one, target[0], source[0]), + ext_sub(builder, arithmetic, neg_one, target[1], source[1]), + ]; + let messages: Vec<_> = chunk + .iter() + .map(|lookup| { + let seed = match width_binding { + WidthBinding::Fingerprint => lookup.args.len() as u64, + WidthBinding::ByConstruction => 0, + }; + let seed = + record_fixed(builder, inputs, public_inputs, F128::new(seed, 0)); + let mut fingerprint = [seed, zero]; + for &argument in lookup.args.iter().rev() { + fingerprint = + coord_mul(builder, arithmetic, seven, fingerprint, gamma); + fingerprint[0] = arithmetic.add( + builder, + fingerprint[0], + node_values[argument.index()], + ); + } + [ + arithmetic.add(builder, fingerprint[0], beta[0]), + arithmetic.add(builder, fingerprint[1], beta[1]), + ] + }) + .collect(); + let mut product = [one, zero]; + for &message in &messages { + product = coord_mul(builder, arithmetic, seven, product, message); + } + let lhs = coord_mul(builder, arithmetic, seven, product, difference); + let mut rhs = [zero, zero]; + for (excluded, lookup) in chunk.iter().enumerate() { + let mut others = [one, zero]; + for (index, &message) in messages.iter().enumerate() { + if index != excluded { + others = coord_mul(builder, arithmetic, seven, others, message); + } + } + for coordinate in 0..EXTENSION_DEGREE { + let term = arithmetic.ext2_mul( + builder, + others[coordinate], + node_values[lookup.multiplicity.index()], + ); + rhs[coordinate] = arithmetic.add(builder, rhs[coordinate], term); + } + } + constraints.extend((0..EXTENSION_DEGREE).map(|coordinate| { + ext_sub(builder, arithmetic, neg_one, lhs[coordinate], rhs[coordinate]) + })); + } + constraints +} + +fn coord_mul( + builder: &mut ShapeBuilder, + arithmetic: &GoldilocksCircuitSlots, + seven: Wire, + left: [Wire; 2], + right: [Wire; 2], +) -> [Wire; 2] { + let low = arithmetic.ext2_mul(builder, left[0], right[0]); + let high_product = arithmetic.ext2_mul(builder, left[1], right[1]); + let reduced_high = arithmetic.ext2_mul(builder, high_product, seven); + let cross_0 = arithmetic.ext2_mul(builder, left[0], right[1]); + let cross_1 = arithmetic.ext2_mul(builder, left[1], right[0]); + [ + arithmetic.add(builder, low, reduced_high), + arithmetic.add(builder, cross_0, cross_1), + ] +} + +fn ext_sub( + builder: &mut ShapeBuilder, + arithmetic: &GoldilocksCircuitSlots, + neg_one: Wire, + left: Wire, + right: Wire, +) -> Wire { + let negated = arithmetic.ext2_mul(builder, right, neg_one); + arithmetic.add(builder, left, negated) +} + +#[allow(clippy::too_many_arguments)] +fn bound_low_word( + builder: &mut ShapeBuilder, + arithmetic: &GoldilocksCircuitSlots, + window: SlotId, + data_zero: Wire, + inputs: &mut Vec, + public: &mut Vec, + prefix_region: &TranscriptConstraintRegion, + binding: Stage2TranscriptByteBindingV1, +) -> Wire { + let word = bound_transcript_window( + builder, + window, + data_zero, + inputs, + public, + prefix_region, + binding, + 0, + ); + arithmetic.embed_low_lane(builder, word) +} + +fn record_private( + builder: &mut ShapeBuilder, + inputs: &mut Vec, + value: F128, +) -> Wire { + inputs.push(value); + builder.input() +} + +struct NativeSelectors { + is_first: [u64; 2], + is_last: [u64; 2], + inv_vanishing: [u64; 2], +} + +fn native_selectors(zeta: [u64; 2], log_degree: u8) -> Result { + let zeta = native_extension(zeta); + let z_h = zeta.exp_power_of_2(usize::from(log_degree)) - ExtVal::ONE; + let generator = Val::TWO_ADIC_GENERATORS[usize::from(log_degree)]; + let generator_inverse = ExtVal::from(generator.inverse()); + let is_first = z_h + * (zeta - ExtVal::ONE) + .try_inverse() + .ok_or_else(|| anyhow::anyhow!("OOD point is the first trace point"))?; + let is_last = z_h + * (zeta - generator_inverse) + .try_inverse() + .ok_or_else(|| anyhow::anyhow!("OOD point is the last trace point"))?; + let inv_vanishing = z_h + .try_inverse() + .ok_or_else(|| anyhow::anyhow!("OOD point is inside the trace domain"))?; + Ok(NativeSelectors { + is_first: extension_words(is_first), + is_last: extension_words(is_last), + inv_vanishing: extension_words(inv_vanishing), + }) +} + +fn native_extension(value: [u64; 2]) -> ExtVal { + ExtVal::new([Val::from_u64(value[0]), Val::from_u64(value[1])]) +} + +fn extension_words(value: ExtVal) -> [u64; 2] { + let values: &[Val] = value.as_basis_coefficients_slice(); + [values[0].as_canonical_u64(), values[1].as_canonical_u64()] +} + +fn pack_extension(value: [u64; 2]) -> F128 { + F128::new(value[0], value[1]) +} + +fn read_bound_u64( + prefix: &Stage2TranscriptReplayV1, + binding: Stage2TranscriptByteBindingV1, +) -> Result { + let segment = match binding.segment { + Stage2TranscriptSegmentV1::Initial => &prefix.initial_observations, + Stage2TranscriptSegmentV1::Stage2AndAccumulator => { + &prefix.stage2_and_accumulator_observations + }, + Stage2TranscriptSegmentV1::QuotientCommitment => { + &prefix.quotient_commitment_observations + }, + Stage2TranscriptSegmentV1::PcsOpening => &prefix.pcs_opening_observations, + }; + let bytes = segment + .get(binding.byte_offset..binding.byte_offset + 8) + .ok_or_else(|| anyhow::anyhow!("AIR transcript word is out of range"))?; + Ok(u64::from_le_bytes(bytes.try_into().unwrap())) +} diff --git a/flock-stage3/host/src/arithmetic.rs b/flock-stage3/host/src/arithmetic.rs new file mode 100644 index 00000000..228e2725 --- /dev/null +++ b/flock-stage3/host/src/arithmetic.rs @@ -0,0 +1,676 @@ +//! Real Flock circuit proof for the custom Goldilocks arithmetic tables. +//! +//! This remains a labelled conformance artifact, not a Stage 3 proof. It +//! exists to ensure new non-native field tables survive the complete union, +//! wiring, PCS, Fiat-Shamir, serialization, and verifier path before verifier +//! phases depend on them. + +use anyhow::{Context, Result, bail}; +use bincode::Options; +use flock_prover::{ + challenger::FsChallenger, + circuit::builder::{CircuitShape, ShapeBuilder, SlotId}, + field::F128, + pcs::Commitment, + proof::R1csProofCircuitMerged, + prover::{self, UnionSlotProverInput}, + union::UnionInstance, + verifier, +}; +use serde::{Deserialize, Serialize}; + +use crate::{ + ARITHMETIC_CONFORMANCE_TRANSCRIPT_DOMAIN, FlockConfigV1, + binding::pcs_params, + extension::{ + GoldilocksCircuitSlots, GoldilocksLaneRepackGate, build_lane_repack_r1cs, + generate_lane_repack_witness, goldilocks_ext2_mul, + }, + goldilocks::{ + CanonicalGoldilocksPairGate, GOLDILOCKS_MODULUS, GoldilocksAddPairGate, + build_canonical_pair_r1cs, build_goldilocks_add_r1cs, + generate_canonical_pair_witness, generate_goldilocks_add_witness, + }, + multiplication::{ + GoldilocksMulPairGate, build_goldilocks_mul_r1cs, + generate_goldilocks_mul_witness, + }, +}; + +pub const ARITHMETIC_CONFORMANCE_ARTIFACT_MAGIC: &[u8; 8] = b"IXFLKGA1"; +const ARTIFACT_VERSION: u16 = 1; +const FIXED_PREFIX_BYTES: usize = 8 + 2 + 32 + 2 + 2 + 2; +const FIXED_SUFFIX_BYTES: usize = 32 + 8; +const OPERAND_BYTES: usize = 4 * 8; +const MAX_ADDITIONS: usize = 64; +const MAX_MULTIPLICATIONS: usize = 64; +const MAX_EXTENSION_MULTIPLICATIONS: usize = 16; +const MAX_BUNDLE_BYTES: usize = 64 * 1024 * 1024; +// The shared row domain leaves ample virtual address space for the largest +// custom table and always reaches Flock's audited Fast128 geometries. +// Declared row counts remain exact; this only supplies zero padding. +const MIN_SECURE_NU: usize = 10; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct GoldilocksAddPairV1 { + pub left: [u64; 2], + pub right: [u64; 2], +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct GoldilocksMulPairV1 { + pub left: [u64; 2], + pub right: [u64; 2], +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct GoldilocksExt2MulV1 { + pub left: [u64; 2], + pub right: [u64; 2], +} + +impl GoldilocksExt2MulV1 { + pub fn result(self) -> [u64; 2] { + let result = goldilocks_ext2_mul( + F128::new(self.left[0], self.left[1]), + F128::new(self.right[0], self.right[1]), + ); + [result.lo, result.hi] + } +} + +/// A real Flock proof of public lane-wise Goldilocks arithmetic. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ArithmeticConformanceArtifactV1 { + additions: Vec, + multiplications: Vec, + extension_multiplications: Vec, + circuit_digest: [u8; 32], + proof_bundle_bytes: Vec, +} + +impl ArithmeticConformanceArtifactV1 { + pub fn to_bytes(&self) -> Vec { + let mut bytes = Vec::with_capacity( + FIXED_PREFIX_BYTES + + (self.additions.len() + + self.multiplications.len() + + self.extension_multiplications.len()) + * OPERAND_BYTES + + FIXED_SUFFIX_BYTES + + self.proof_bundle_bytes.len(), + ); + bytes.extend_from_slice(ARITHMETIC_CONFORMANCE_ARTIFACT_MAGIC); + bytes.extend_from_slice(&ARTIFACT_VERSION.to_le_bytes()); + bytes.extend_from_slice(&FlockConfigV1.digest()); + bytes.extend_from_slice( + &u16::try_from(self.additions.len()) + .expect("addition count") + .to_le_bytes(), + ); + bytes.extend_from_slice( + &u16::try_from(self.multiplications.len()) + .expect("multiplication count") + .to_le_bytes(), + ); + bytes.extend_from_slice( + &u16::try_from(self.extension_multiplications.len()) + .expect("extension multiplication count") + .to_le_bytes(), + ); + for addition in &self.additions { + encode_operands(&mut bytes, addition.left, addition.right); + } + for multiplication in &self.multiplications { + encode_operands(&mut bytes, multiplication.left, multiplication.right); + } + for multiplication in &self.extension_multiplications { + encode_operands(&mut bytes, multiplication.left, multiplication.right); + } + bytes.extend_from_slice(&self.circuit_digest); + bytes.extend_from_slice( + &u64::try_from(self.proof_bundle_bytes.len()) + .expect("proof bundle length") + .to_le_bytes(), + ); + bytes.extend_from_slice(&self.proof_bundle_bytes); + bytes + } + + pub fn from_bytes(bytes: &[u8]) -> Result { + if bytes.len() < FIXED_PREFIX_BYTES + FIXED_SUFFIX_BYTES { + bail!("truncated Flock arithmetic conformance artifact"); + } + if &bytes[..8] != ARITHMETIC_CONFORMANCE_ARTIFACT_MAGIC { + bail!("invalid Flock arithmetic conformance artifact magic"); + } + let version = u16::from_le_bytes(bytes[8..10].try_into().unwrap()); + if version != ARTIFACT_VERSION { + bail!("unsupported Flock arithmetic artifact version {version}"); + } + if bytes[10..42] != FlockConfigV1.digest() { + bail!("Flock arithmetic artifact configuration mismatch"); + } + let addition_count = + usize::from(u16::from_le_bytes(bytes[42..44].try_into().unwrap())); + let multiplication_count = + usize::from(u16::from_le_bytes(bytes[44..46].try_into().unwrap())); + let extension_multiplication_count = + usize::from(u16::from_le_bytes(bytes[46..48].try_into().unwrap())); + validate_counts( + addition_count, + multiplication_count, + extension_multiplication_count, + )?; + let operation_count = + addition_count + multiplication_count + extension_multiplication_count; + let operands_end = FIXED_PREFIX_BYTES + .checked_add(operation_count * OPERAND_BYTES) + .ok_or_else(|| anyhow::anyhow!("arithmetic operand length overflow"))?; + let suffix_end = operands_end + .checked_add(FIXED_SUFFIX_BYTES) + .ok_or_else(|| anyhow::anyhow!("arithmetic artifact length overflow"))?; + if bytes.len() < suffix_end { + bail!("truncated Flock arithmetic operands or proof header"); + } + let mut additions = Vec::with_capacity(addition_count); + let mut multiplications = Vec::with_capacity(multiplication_count); + let mut extension_multiplications = + Vec::with_capacity(extension_multiplication_count); + let (encoded_operations, remainder) = + bytes[FIXED_PREFIX_BYTES..operands_end].as_chunks::(); + debug_assert!(remainder.is_empty()); + for encoded in &encoded_operations[..addition_count] { + let (left, right) = decode_operands(encoded); + let addition = GoldilocksAddPairV1 { left, right }; + validate_operands(addition.left, addition.right)?; + additions.push(addition); + } + let multiplication_end = addition_count + multiplication_count; + for encoded in &encoded_operations[addition_count..multiplication_end] { + let (left, right) = decode_operands(encoded); + let multiplication = GoldilocksMulPairV1 { left, right }; + validate_operands(multiplication.left, multiplication.right)?; + multiplications.push(multiplication); + } + for encoded in &encoded_operations[multiplication_end..] { + let (left, right) = decode_operands(encoded); + let multiplication = GoldilocksExt2MulV1 { left, right }; + validate_operands(multiplication.left, multiplication.right)?; + extension_multiplications.push(multiplication); + } + let mut circuit_digest = [0u8; 32]; + circuit_digest.copy_from_slice(&bytes[operands_end..operands_end + 32]); + let bundle_len = usize::try_from(u64::from_le_bytes( + bytes[operands_end + 32..suffix_end].try_into().unwrap(), + )) + .map_err(|error| { + anyhow::anyhow!("proof bundle length does not fit usize: {error}") + })?; + if bundle_len == 0 || bundle_len > MAX_BUNDLE_BYTES { + bail!("invalid Flock arithmetic proof bundle length {bundle_len}"); + } + let expected_len = suffix_end + .checked_add(bundle_len) + .ok_or_else(|| anyhow::anyhow!("arithmetic proof length overflow"))?; + if bytes.len() != expected_len { + bail!( + "Flock arithmetic artifact is {} bytes; header declares {expected_len}", + bytes.len() + ); + } + let proof_bundle_bytes = bytes[suffix_end..].to_vec(); + decode_bundle(&proof_bundle_bytes) + .context("decode Flock arithmetic conformance proof bundle")?; + Ok(Self { + additions, + multiplications, + extension_multiplications, + circuit_digest, + proof_bundle_bytes, + }) + } + + pub fn additions(&self) -> &[GoldilocksAddPairV1] { + &self.additions + } + + pub fn multiplications(&self) -> &[GoldilocksMulPairV1] { + &self.multiplications + } + + pub fn extension_multiplications(&self) -> &[GoldilocksExt2MulV1] { + &self.extension_multiplications + } + + pub fn circuit_digest(&self) -> &[u8; 32] { + &self.circuit_digest + } + + pub fn proof_bundle_bytes(&self) -> &[u8] { + &self.proof_bundle_bytes + } +} + +#[derive(Serialize, Deserialize)] +struct ArithmeticProofBundle { + commitment: Commitment, + proof: R1csProofCircuitMerged, +} + +pub fn prove_arithmetic_conformance( + additions: &[GoldilocksAddPairV1], + multiplications: &[GoldilocksMulPairV1], + extension_multiplications: &[GoldilocksExt2MulV1], +) -> Result { + validate_operations(additions, multiplications, extension_multiplications)?; + let relation = ArithmeticRelation::build( + additions.len(), + multiplications.len(), + extension_multiplications.len(), + )?; + let inputs = + relation_inputs(additions, multiplications, extension_multiplications); + let witness = relation.shape.run(&inputs, &[]); + relation.ensure_registry_order()?; + + let add_rows = witness.rows::(relation.add_slot); + let mul_rows = witness.rows::(relation.mul_slot); + let repack_rows = + witness.rows::(relation.repack_slot); + let canonical_rows = + witness.rows::(relation.canonical_slot); + let add_r1cs = build_goldilocks_add_r1cs(relation.nu); + let add_lincheck = add_r1cs.csc_lincheck_circuit(); + let mul_r1cs = build_goldilocks_mul_r1cs(relation.nu); + let mul_lincheck = mul_r1cs.csc_lincheck_circuit(); + let repack_r1cs = build_lane_repack_r1cs(relation.nu); + let repack_lincheck = repack_r1cs.csc_lincheck_circuit(); + let canonical_r1cs = build_canonical_pair_r1cs(relation.nu); + let canonical_lincheck = canonical_r1cs.csc_lincheck_circuit(); + let union = + UnionInstance::new(&relation.shape.registry, relation.shape.counts.clone()); + let params = pcs_params(&union); + let mut challenger = + FsChallenger::with_chained_blake3(ARITHMETIC_CONFORMANCE_TRANSCRIPT_DOMAIN); + let (proof, commitment, _) = prover::prove_fast_ligerito_union_circuit( + &union, + &relation.shape.circuit, + &witness.public, + ¶ms, + vec![ + UnionSlotProverInput::new( + generate_goldilocks_mul_witness(mul_rows, relation.nu), + mul_lincheck, + ), + UnionSlotProverInput::new( + generate_goldilocks_add_witness(add_rows, relation.nu), + add_lincheck, + ), + UnionSlotProverInput::new( + generate_lane_repack_witness(repack_rows, relation.nu), + repack_lincheck, + ), + UnionSlotProverInput::new( + generate_canonical_pair_witness(canonical_rows, relation.nu), + canonical_lincheck, + ), + ], + Vec::new(), + &mut challenger, + ); + let proof_bundle_bytes = + encode_bundle(&ArithmeticProofBundle { commitment, proof })?; + if proof_bundle_bytes.len() > MAX_BUNDLE_BYTES { + bail!("Flock arithmetic proof bundle exceeds {MAX_BUNDLE_BYTES} bytes"); + } + Ok(ArithmeticConformanceArtifactV1 { + additions: additions.to_vec(), + multiplications: multiplications.to_vec(), + extension_multiplications: extension_multiplications.to_vec(), + circuit_digest: relation.shape.circuit.digest(), + proof_bundle_bytes, + }) +} + +pub fn verify_arithmetic_conformance( + artifact: &ArithmeticConformanceArtifactV1, +) -> Result<()> { + validate_operations( + &artifact.additions, + &artifact.multiplications, + &artifact.extension_multiplications, + )?; + let relation = ArithmeticRelation::build( + artifact.additions.len(), + artifact.multiplications.len(), + artifact.extension_multiplications.len(), + )?; + relation.ensure_registry_order()?; + if artifact.circuit_digest != relation.shape.circuit.digest() { + bail!("Flock arithmetic conformance circuit digest mismatch"); + } + let witness = relation.shape.run( + &relation_inputs( + &artifact.additions, + &artifact.multiplications, + &artifact.extension_multiplications, + ), + &[], + ); + let bundle = decode_bundle(&artifact.proof_bundle_bytes) + .context("decode Flock arithmetic conformance proof bundle")?; + let add_r1cs = build_goldilocks_add_r1cs(relation.nu); + let add_lincheck = add_r1cs.csc_lincheck_circuit(); + let mul_r1cs = build_goldilocks_mul_r1cs(relation.nu); + let mul_lincheck = mul_r1cs.csc_lincheck_circuit(); + let repack_r1cs = build_lane_repack_r1cs(relation.nu); + let repack_lincheck = repack_r1cs.csc_lincheck_circuit(); + let canonical_r1cs = build_canonical_pair_r1cs(relation.nu); + let canonical_lincheck = canonical_r1cs.csc_lincheck_circuit(); + let linchecks: [&dyn flock_prover::lincheck::LincheckCircuit; 4] = + [mul_lincheck, add_lincheck, repack_lincheck, canonical_lincheck]; + let union = + UnionInstance::new(&relation.shape.registry, relation.shape.counts.clone()); + let params = pcs_params(&union); + let mut challenger = + FsChallenger::with_chained_blake3(ARITHMETIC_CONFORMANCE_TRANSCRIPT_DOMAIN); + verifier::verify_ligerito_union_circuit( + &union, + &relation.shape.circuit, + &witness.public, + &linchecks, + &bundle.commitment, + &bundle.proof, + ¶ms, + &mut challenger, + ) + .map_err(|error| { + anyhow::anyhow!("Flock arithmetic conformance proof rejected: {error:?}") + })?; + Ok(()) +} + +struct ArithmeticRelation { + shape: CircuitShape, + add_slot: SlotId, + mul_slot: SlotId, + canonical_slot: SlotId, + repack_slot: SlotId, + nu: usize, +} + +impl ArithmeticRelation { + fn build( + addition_count: usize, + multiplication_count: usize, + extension_multiplication_count: usize, + ) -> Result { + validate_counts( + addition_count, + multiplication_count, + extension_multiplication_count, + )?; + let row_bound = [ + addition_count + 5 * extension_multiplication_count, + multiplication_count + 2 * extension_multiplication_count, + 3 * addition_count + + 3 * multiplication_count + + 9 * extension_multiplication_count, + 3 * extension_multiplication_count, + ] + .into_iter() + .max() + .unwrap(); + let nu = usize::try_from(row_bound.next_power_of_two().ilog2()) + .unwrap() + .max(MIN_SECURE_NU); + let mut builder = ShapeBuilder::new(nu); + let slots = GoldilocksCircuitSlots::declare(&mut builder, nu); + for _ in 0..addition_count { + let left = builder.public_input(); + let right = builder.public_input(); + for value in [left, right] { + slots.assert_canonical(&mut builder, value); + } + let result = slots.add(&mut builder, left, right); + builder.publish(result); + } + for _ in 0..multiplication_count { + let left = builder.public_input(); + let right = builder.public_input(); + for value in [left, right] { + slots.assert_canonical(&mut builder, value); + } + let result = slots.mul(&mut builder, left, right); + builder.publish(result); + } + for _ in 0..extension_multiplication_count { + let left = builder.public_input(); + let right = builder.public_input(); + let result = slots.ext2_mul(&mut builder, left, right); + builder.publish(result); + } + let shape = builder.finish().map_err(|error| { + anyhow::anyhow!("build Flock arithmetic conformance circuit: {error:?}") + })?; + Ok(Self { + shape, + add_slot: slots.add, + mul_slot: slots.mul, + canonical_slot: slots.canonical, + repack_slot: slots.repack, + nu, + }) + } + + fn ensure_registry_order(&self) -> Result<()> { + // Registry::new sorts Boolean tables by descending k_log. + if self.shape.registry_slot(self.mul_slot) != 0 + || self.shape.registry_slot(self.add_slot) != 1 + || self.shape.registry_slot(self.repack_slot) != 2 + || self.shape.registry_slot(self.canonical_slot) != 3 + { + bail!("unexpected Flock arithmetic table registry order"); + } + Ok(()) + } +} + +fn relation_inputs( + additions: &[GoldilocksAddPairV1], + multiplications: &[GoldilocksMulPairV1], + extension_multiplications: &[GoldilocksExt2MulV1], +) -> Vec { + let mut inputs = Vec::with_capacity( + 1 + 2 + * (additions.len() + + multiplications.len() + + extension_multiplications.len()), + ); + inputs.push(F128::ZERO); + for addition in additions { + inputs.push(F128::new(addition.left[0], addition.left[1])); + inputs.push(F128::new(addition.right[0], addition.right[1])); + } + for multiplication in multiplications { + inputs.push(F128::new(multiplication.left[0], multiplication.left[1])); + inputs.push(F128::new(multiplication.right[0], multiplication.right[1])); + } + for multiplication in extension_multiplications { + inputs.push(F128::new(multiplication.left[0], multiplication.left[1])); + inputs.push(F128::new(multiplication.right[0], multiplication.right[1])); + } + inputs +} + +fn validate_operations( + additions: &[GoldilocksAddPairV1], + multiplications: &[GoldilocksMulPairV1], + extension_multiplications: &[GoldilocksExt2MulV1], +) -> Result<()> { + validate_counts( + additions.len(), + multiplications.len(), + extension_multiplications.len(), + )?; + for addition in additions { + validate_operands(addition.left, addition.right)?; + } + for multiplication in multiplications { + validate_operands(multiplication.left, multiplication.right)?; + } + for multiplication in extension_multiplications { + validate_operands(multiplication.left, multiplication.right)?; + } + Ok(()) +} + +fn validate_counts( + addition_count: usize, + multiplication_count: usize, + extension_multiplication_count: usize, +) -> Result<()> { + if addition_count > MAX_ADDITIONS { + bail!( + "Flock arithmetic conformance has {addition_count} additions; maximum is {MAX_ADDITIONS}" + ); + } + if multiplication_count > MAX_MULTIPLICATIONS { + bail!( + "Flock arithmetic conformance has {multiplication_count} multiplications; maximum is {MAX_MULTIPLICATIONS}" + ); + } + if extension_multiplication_count > MAX_EXTENSION_MULTIPLICATIONS { + bail!( + "Flock arithmetic conformance has {extension_multiplication_count} extension multiplications; maximum is {MAX_EXTENSION_MULTIPLICATIONS}" + ); + } + if addition_count + multiplication_count + extension_multiplication_count == 0 + { + bail!("Flock arithmetic conformance requires at least one operation"); + } + Ok(()) +} + +fn validate_operands(left: [u64; 2], right: [u64; 2]) -> Result<()> { + if left.iter().chain(&right).any(|&word| word >= GOLDILOCKS_MODULUS) { + bail!("Flock arithmetic operand is not canonical Goldilocks"); + } + Ok(()) +} + +fn encode_operands(bytes: &mut Vec, left: [u64; 2], right: [u64; 2]) { + for word in [left[0], left[1], right[0], right[1]] { + bytes.extend_from_slice(&word.to_le_bytes()); + } +} + +fn decode_operands(encoded: &[u8; OPERAND_BYTES]) -> ([u64; 2], [u64; 2]) { + let words: [u64; 4] = std::array::from_fn(|index| { + let offset = index * 8; + u64::from_le_bytes(encoded[offset..offset + 8].try_into().unwrap()) + }); + ([words[0], words[1]], [words[2], words[3]]) +} + +fn encode_bundle(bundle: &ArithmeticProofBundle) -> Result> { + bincode::DefaultOptions::new() + .with_fixint_encoding() + .serialize(bundle) + .context("encode Flock arithmetic conformance proof bundle") +} + +fn decode_bundle(bytes: &[u8]) -> Result { + bincode::DefaultOptions::new() + .with_fixint_encoding() + .with_limit(MAX_BUNDLE_BYTES as u64) + .reject_trailing_bytes() + .deserialize(bytes) + .context("invalid Flock arithmetic conformance proof bundle") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn addition_fixture() -> Vec { + (0..8u64) + .map(|index| GoldilocksAddPairV1 { + left: [index, GOLDILOCKS_MODULUS - 1 - index], + right: [GOLDILOCKS_MODULUS - 1 - index, index + 1], + }) + .collect() + } + + fn multiplication_fixture() -> Vec { + (0..4u64) + .map(|index| GoldilocksMulPairV1 { + left: [index + 1, GOLDILOCKS_MODULUS - 1 - index], + right: [GOLDILOCKS_MODULUS - 2 - index, index + 3], + }) + .collect() + } + + fn extension_multiplication_fixture() -> Vec { + vec![ + GoldilocksExt2MulV1 { left: [3, 5], right: [7, 11] }, + GoldilocksExt2MulV1 { + left: [GOLDILOCKS_MODULUS - 1, 17], + right: [23, GOLDILOCKS_MODULUS - 2], + }, + ] + } + + #[test] + fn artifact_parser_is_strict_before_crypto() { + let artifact = ArithmeticConformanceArtifactV1 { + additions: addition_fixture(), + multiplications: multiplication_fixture(), + extension_multiplications: extension_multiplication_fixture(), + circuit_digest: [7; 32], + proof_bundle_bytes: vec![1, 2, 3], + }; + let mut bytes = artifact.to_bytes(); + assert!(ArithmeticConformanceArtifactV1::from_bytes(&bytes).is_err()); + bytes[0] ^= 1; + assert!(ArithmeticConformanceArtifactV1::from_bytes(&bytes).is_err()); + } + + #[test] + #[ignore = "real Flock arithmetic circuit proof; run explicitly"] + fn real_goldilocks_arithmetic_round_trip_and_mutations() { + let artifact = prove_arithmetic_conformance( + &addition_fixture(), + &multiplication_fixture(), + &extension_multiplication_fixture(), + ) + .expect("prove arithmetic"); + eprintln!( + "Flock Goldilocks-arithmetic conformance bundle: {} bytes", + artifact.proof_bundle_bytes().len() + ); + verify_arithmetic_conformance(&artifact).expect("verify arithmetic"); + + let bytes = artifact.to_bytes(); + let decoded = ArithmeticConformanceArtifactV1::from_bytes(&bytes).unwrap(); + verify_arithmetic_conformance(&decoded).expect("verify decoded arithmetic"); + + let mut wrong_operand = decoded.clone(); + wrong_operand.additions[0].left[0] ^= 1; + assert!(verify_arithmetic_conformance(&wrong_operand).is_err()); + + let mut wrong_multiplication = decoded.clone(); + wrong_multiplication.multiplications[0].right[1] ^= 1; + assert!(verify_arithmetic_conformance(&wrong_multiplication).is_err()); + + let mut wrong_extension = decoded.clone(); + wrong_extension.extension_multiplications[0].left[1] ^= 1; + assert!(verify_arithmetic_conformance(&wrong_extension).is_err()); + + let mut wrong_proof = decoded; + let flip_at = wrong_proof.proof_bundle_bytes.len() / 2; + wrong_proof.proof_bundle_bytes[flip_at] ^= 1; + assert!(verify_arithmetic_conformance(&wrong_proof).is_err()); + } +} diff --git a/flock-stage3/host/src/artifact.rs b/flock-stage3/host/src/artifact.rs new file mode 100644 index 00000000..aac8793f --- /dev/null +++ b/flock-stage3/host/src/artifact.rs @@ -0,0 +1,381 @@ +use anyhow::{Context, Result, bail}; +use bincode::Options; +use ix_terminal::Stage2RootStatementV1; +use serde::{Deserialize, Serialize}; + +use crate::config::FlockConfigV1; + +pub const STAGE3_STATEMENT_DOMAIN: &[u8; 8] = b"IXFLK301"; +pub const STAGE3_STATEMENT_BYTES: usize = 8 + 32 + 32 + 32; +const ARTIFACT_MAGIC: &[u8; 8] = b"IXFLOCK3"; +const ARTIFACT_VERSION: u16 = 1; +const ARTIFACT_HEADER_BYTES: usize = 8 + 2 + 4 + 8; +const MAX_PROOF_BYTES: usize = 64 * 1024 * 1024; +const PRODUCTION_PAYLOAD_MAGIC: [u8; 8] = *b"IXFLK3P1"; +const PRODUCTION_PAYLOAD_VERSION: u16 = 1; + +/// Public input to the complete Flock Stage 3 relation. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Stage3StatementV1 { + stage2_root_digest: [u8; 32], + relation_digest: [u8; 32], + config_digest: [u8; 32], +} + +impl Stage3StatementV1 { + pub fn new( + stage2_root: &Stage2RootStatementV1, + relation_digest: [u8; 32], + ) -> Self { + Self { + stage2_root_digest: stage2_root.digest(), + relation_digest, + config_digest: FlockConfigV1.digest(), + } + } + + pub fn from_bytes(bytes: &[u8]) -> Result { + if bytes.len() != STAGE3_STATEMENT_BYTES { + bail!( + "Stage 3 statement is {} bytes; expected {STAGE3_STATEMENT_BYTES}", + bytes.len() + ); + } + if &bytes[..8] != STAGE3_STATEMENT_DOMAIN { + bail!("invalid Stage 3 statement domain"); + } + let mut stage2_root_digest = [0u8; 32]; + stage2_root_digest.copy_from_slice(&bytes[8..40]); + let mut relation_digest = [0u8; 32]; + relation_digest.copy_from_slice(&bytes[40..72]); + let mut config_digest = [0u8; 32]; + config_digest.copy_from_slice(&bytes[72..104]); + if config_digest != FlockConfigV1.digest() { + bail!("Stage 3 statement uses a different Flock configuration"); + } + Ok(Self { stage2_root_digest, relation_digest, config_digest }) + } + + pub fn to_bytes(&self) -> Vec { + let mut bytes = Vec::with_capacity(STAGE3_STATEMENT_BYTES); + bytes.extend_from_slice(STAGE3_STATEMENT_DOMAIN); + bytes.extend_from_slice(&self.stage2_root_digest); + bytes.extend_from_slice(&self.relation_digest); + bytes.extend_from_slice(&self.config_digest); + bytes + } + + pub fn digest(&self) -> [u8; 32] { + *blake3::hash(&self.to_bytes()).as_bytes() + } + + pub fn stage2_root_digest(&self) -> &[u8; 32] { + &self.stage2_root_digest + } + + pub fn relation_digest(&self) -> &[u8; 32] { + &self.relation_digest + } + + pub fn config_digest(&self) -> &[u8; 32] { + &self.config_digest + } +} + +/// Strict transport framing for a complete Stage 3 proof. +/// +/// Parsing establishes canonical framing only; cryptographic acceptance also +/// requires `FlockStage3Backend::verify_stage2` with an expected statement. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Stage3ArtifactV1 { + statement: Stage3StatementV1, + proof: Vec, +} + +impl Stage3ArtifactV1 { + pub(crate) fn new( + statement: Stage3StatementV1, + proof: Vec, + ) -> Result { + if proof.is_empty() { + bail!("Stage 3 proof is empty"); + } + if proof.len() > MAX_PROOF_BYTES { + bail!("Stage 3 proof exceeds {MAX_PROOF_BYTES} bytes"); + } + Ok(Self { statement, proof }) + } + + pub fn to_bytes(&self) -> Vec { + let statement = self.statement.to_bytes(); + let mut bytes = Vec::with_capacity( + ARTIFACT_HEADER_BYTES + statement.len() + self.proof.len(), + ); + bytes.extend_from_slice(ARTIFACT_MAGIC); + bytes.extend_from_slice(&ARTIFACT_VERSION.to_le_bytes()); + bytes.extend_from_slice( + &u32::try_from(statement.len()).expect("statement length").to_le_bytes(), + ); + bytes.extend_from_slice( + &u64::try_from(self.proof.len()).expect("proof length").to_le_bytes(), + ); + bytes.extend_from_slice(&statement); + bytes.extend_from_slice(&self.proof); + bytes + } + + pub fn from_bytes(bytes: &[u8]) -> Result { + if bytes.len() < ARTIFACT_HEADER_BYTES { + bail!("truncated Stage 3 artifact header"); + } + if &bytes[..8] != ARTIFACT_MAGIC { + bail!("invalid Stage 3 artifact magic"); + } + let version = read_u16(&bytes[8..10]); + if version != ARTIFACT_VERSION { + bail!("unsupported Stage 3 artifact version {version}"); + } + let statement_len = + usize::try_from(read_u32(&bytes[10..14])).expect("u32 fits in usize"); + if statement_len != STAGE3_STATEMENT_BYTES { + bail!("invalid Stage 3 statement length {statement_len}"); + } + let proof_len = + usize::try_from(read_u64(&bytes[14..22])).map_err(|_| { + anyhow::anyhow!("Stage 3 proof length does not fit usize") + })?; + if proof_len == 0 { + bail!("Stage 3 proof is empty"); + } + if proof_len > MAX_PROOF_BYTES { + bail!("Stage 3 proof exceeds {MAX_PROOF_BYTES} bytes"); + } + let expected_len = ARTIFACT_HEADER_BYTES + .checked_add(statement_len) + .and_then(|len| len.checked_add(proof_len)) + .ok_or_else(|| anyhow::anyhow!("Stage 3 artifact length overflow"))?; + if bytes.len() != expected_len { + bail!( + "Stage 3 artifact is {} bytes; header declares {expected_len}", + bytes.len() + ); + } + let statement_end = ARTIFACT_HEADER_BYTES + statement_len; + let statement = Stage3StatementV1::from_bytes( + &bytes[ARTIFACT_HEADER_BYTES..statement_end], + )?; + Self::new(statement, bytes[statement_end..].to_vec()) + } + + pub fn ensure_statement(&self, expected: &Stage3StatementV1) -> Result<()> { + if &self.statement != expected { + bail!("Stage 3 artifact statement does not match the expected root"); + } + Ok(()) + } + + pub fn statement(&self) -> &Stage3StatementV1 { + &self.statement + } + + pub fn proof_bytes(&self) -> &[u8] { + &self.proof + } +} + +/// Canonical host transport needed to reconstruct the Flock public input. +/// +/// The compact Stage 2 inputs are not trusted by verification: they are +/// decoded again, lowered into the fixed relation, and checked against the +/// proof. Keeping them here avoids making Rust's serializer part of the Flock +/// circuit while giving Stage 4 a deterministic source for the verifier +/// witness. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct Stage3ProductionPayloadV1 { + magic: [u8; 8], + version: u16, + config_digest: [u8; 32], + vk_bytes: Vec, + claim_bytes: Vec, + stage2_proof_bytes: Vec, + circuit_digest: [u8; 32], + flock_proof_bundle_bytes: Vec, +} + +impl Stage3ProductionPayloadV1 { + pub(crate) fn new( + vk_bytes: &[u8], + claim_bytes: &[u8], + stage2_proof_bytes: &[u8], + circuit_digest: [u8; 32], + flock_proof_bundle_bytes: &[u8], + ) -> Result { + let payload = Self { + magic: PRODUCTION_PAYLOAD_MAGIC, + version: PRODUCTION_PAYLOAD_VERSION, + config_digest: FlockConfigV1.digest(), + vk_bytes: vk_bytes.to_vec(), + claim_bytes: claim_bytes.to_vec(), + stage2_proof_bytes: stage2_proof_bytes.to_vec(), + circuit_digest, + flock_proof_bundle_bytes: flock_proof_bundle_bytes.to_vec(), + }; + payload.validate()?; + Ok(payload) + } + + pub(crate) fn encode(&self) -> Result> { + self.validate()?; + let bytes = bincode::DefaultOptions::new() + .with_fixint_encoding() + .serialize(self) + .context("encode Stage 3 production payload")?; + if bytes.len() > MAX_PROOF_BYTES { + bail!("Stage 3 production payload exceeds {MAX_PROOF_BYTES} bytes"); + } + Ok(bytes) + } + + pub(crate) fn decode(bytes: &[u8]) -> Result { + let payload: Self = bincode::DefaultOptions::new() + .with_fixint_encoding() + .with_limit(MAX_PROOF_BYTES as u64) + .reject_trailing_bytes() + .deserialize(bytes) + .context("invalid Stage 3 production payload")?; + payload.validate()?; + Ok(payload) + } + + fn validate(&self) -> Result<()> { + if self.magic != PRODUCTION_PAYLOAD_MAGIC { + bail!("invalid Stage 3 production payload magic"); + } + if self.version != PRODUCTION_PAYLOAD_VERSION { + bail!("unsupported Stage 3 production payload version {}", self.version); + } + if self.config_digest != FlockConfigV1.digest() { + bail!("Stage 3 production payload configuration mismatch"); + } + for (bytes, label) in [ + (self.vk_bytes.as_slice(), "verifying key"), + (self.claim_bytes.as_slice(), "claim"), + (self.stage2_proof_bytes.as_slice(), "Stage 2 proof"), + (self.flock_proof_bundle_bytes.as_slice(), "Flock proof bundle"), + ] { + if bytes.is_empty() { + bail!("Stage 3 production payload has an empty {label}"); + } + } + Ok(()) + } + + pub(crate) fn vk_bytes(&self) -> &[u8] { + &self.vk_bytes + } + + pub(crate) fn claim_bytes(&self) -> &[u8] { + &self.claim_bytes + } + + pub(crate) fn stage2_proof_bytes(&self) -> &[u8] { + &self.stage2_proof_bytes + } + + pub(crate) const fn circuit_digest(&self) -> [u8; 32] { + self.circuit_digest + } + + pub(crate) fn flock_proof_bundle_bytes(&self) -> &[u8] { + &self.flock_proof_bundle_bytes + } +} + +fn read_u16(bytes: &[u8]) -> u16 { + u16::from_le_bytes(bytes.try_into().expect("fixed u16")) +} + +fn read_u32(bytes: &[u8]) -> u32 { + u32::from_le_bytes(bytes.try_into().expect("fixed u32")) +} + +fn read_u64(bytes: &[u8]) -> u64 { + u64::from_le_bytes(bytes.try_into().expect("fixed u64")) +} + +#[cfg(test)] +mod tests { + use super::*; + use multi_stark::types::FriParameters; + + fn statement() -> Stage3StatementV1 { + let fri = FriParameters { + log_final_poly_len: 0, + max_log_arity: 1, + num_queries: 100, + commit_proof_of_work_bits: 0, + query_proof_of_work_bits: 20, + }; + let claim: Vec = (0..18u64).flat_map(u64::to_le_bytes).collect(); + let root = Stage2RootStatementV1::new(b"vk", &claim, &fri).unwrap(); + Stage3StatementV1::new(&root, [7; 32]) + } + + #[test] + fn artifact_round_trip_rejects_extensions_and_mutations() { + let artifact = Stage3ArtifactV1::new(statement(), vec![1, 2, 3]).unwrap(); + assert_eq!( + blake3::Hash::from_bytes(artifact.statement().digest()).to_hex().as_str(), + "9f8062ce1801b29ed755cfb394fe888d5d82af77fe1ba2e5f539567e14e8b00d" + ); + let bytes = artifact.to_bytes(); + assert_eq!(Stage3ArtifactV1::from_bytes(&bytes).unwrap(), artifact); + + let mut extended = bytes.clone(); + extended.push(0); + assert!(Stage3ArtifactV1::from_bytes(&extended).is_err()); + + let mut wrong_domain = bytes.clone(); + wrong_domain[ARTIFACT_HEADER_BYTES] ^= 1; + assert!(Stage3ArtifactV1::from_bytes(&wrong_domain).is_err()); + + let mut wrong_config = bytes; + wrong_config[ARTIFACT_HEADER_BYTES + 72] ^= 1; + assert!(Stage3ArtifactV1::from_bytes(&wrong_config).is_err()); + } + + #[test] + fn expected_statement_is_checked_before_crypto() { + let artifact = Stage3ArtifactV1::new(statement(), vec![1]).unwrap(); + assert!(artifact.ensure_statement(&statement()).is_ok()); + let mut other = statement(); + other.relation_digest[0] ^= 1; + assert!(artifact.ensure_statement(&other).is_err()); + } + + #[test] + fn production_payload_is_strict_and_configuration_bound() { + let payload = Stage3ProductionPayloadV1::new( + b"vk", + b"claim", + b"stage2 proof", + [9; 32], + b"flock proof", + ) + .unwrap(); + let bytes = payload.encode().unwrap(); + assert_eq!(Stage3ProductionPayloadV1::decode(&bytes).unwrap(), payload); + + let mut extended = bytes.clone(); + extended.push(0); + assert!(Stage3ProductionPayloadV1::decode(&extended).is_err()); + + let mut wrong_magic = bytes; + wrong_magic[0] ^= 1; + assert!(Stage3ProductionPayloadV1::decode(&wrong_magic).is_err()); + + let mut wrong_config = payload; + wrong_config.config_digest[0] ^= 1; + assert!(wrong_config.encode().is_err()); + } +} diff --git a/flock-stage3/host/src/bin/flock-stage3-config.rs b/flock-stage3/host/src/bin/flock-stage3-config.rs new file mode 100644 index 00000000..d389b79c --- /dev/null +++ b/flock-stage3/host/src/bin/flock-stage3-config.rs @@ -0,0 +1,19 @@ +use flock_stage3_host::{ + FLOCK_UPSTREAM_REVISION, FlockConfigV1, STAGE3_TRANSCRIPT_DOMAIN, +}; + +fn main() { + println!("flock_revision={FLOCK_UPSTREAM_REVISION}"); + println!("field=f128"); + println!("profile=fast128"); + println!("merkle_hash=blake3"); + println!("transcript=chained-blake3"); + println!( + "transcript_domain={}", + String::from_utf8_lossy(STAGE3_TRANSCRIPT_DOMAIN) + ); + println!( + "config_digest={}", + blake3::Hash::from_bytes(FlockConfigV1.digest()).to_hex() + ); +} diff --git a/flock-stage3/host/src/binding.rs b/flock-stage3/host/src/binding.rs new file mode 100644 index 00000000..d682ce5e --- /dev/null +++ b/flock-stage3/host/src/binding.rs @@ -0,0 +1,670 @@ +//! First production-shaped Flock circuit slice: hash the canonical Stage 2 +//! root statement and expose only its BLAKE3 digest. The 80-byte domain/vk/FRI +//! prefix and BLAKE3 padding are fixed by the circuit; the 144 claim bytes are +//! private inputs whose 18 u64 limbs are constrained to be canonical +//! Goldilocks representatives. +//! +//! This proves real Boolean R1CS plus inter-row wiring with statement-bound +//! public I/O. It is deliberately not called a Stage 3 proof: it does not yet +//! parse the statement or verify the Aiur proof whose root it commits to. + +use anyhow::{Context, Result, bail}; +use bincode::Options; +use flock_prover::{ + challenger::FsChallenger, + circuit::builder::{ + CircuitShape, GateType, ShapeBuilder, SlotId, SlotWitness, Wire, + }, + field::F128, + pcs::{Commitment, PcsParams, ligerito::embedded_initial_k_or_default}, + proof::R1csProofCircuitMerged, + prover::{self, UnionSlotProverInput}, + r1cs_hashes::blake3, + schedule::TableType, + union::UnionInstance, + verifier, +}; +use ix_terminal::{STAGE2_ROOT_STATEMENT_BYTES, Stage2RootStatementV1}; +use serde::{Deserialize, Serialize}; + +use crate::{ + FlockConfigV1, STAGE3_TRANSCRIPT_DOMAIN, + goldilocks::{ + CanonicalGoldilocksPairGate, build_canonical_pair_r1cs, + generate_canonical_pair_witness, + }, +}; + +pub const STAGE3_BINDING_ARTIFACT_MAGIC: &[u8; 8] = b"IXFLK3B1"; +const STAGE3_BINDING_ARTIFACT_VERSION: u16 = 1; +const STAGE2_FIXED_PREFIX_BYTES: usize = 80; +const CONFIG_OFFSET: usize = 10; +const PREFIX_OFFSET: usize = CONFIG_OFFSET + 32; +const CIRCUIT_DIGEST_OFFSET: usize = PREFIX_OFFSET + STAGE2_FIXED_PREFIX_BYTES; +const ROOT_DIGEST_OFFSET: usize = CIRCUIT_DIGEST_OFFSET + 32; +const BUNDLE_LENGTH_OFFSET: usize = ROOT_DIGEST_OFFSET + 32; +const ARTIFACT_HEADER_BYTES: usize = BUNDLE_LENGTH_OFFSET + 8; +const MAX_PROOF_BUNDLE_BYTES: usize = 64 * 1024 * 1024; + +const BLAKE3_CAPACITY_LOG: usize = 8; +const BLOCK_BYTES: usize = 64; +const WORD_BYTES: usize = 16; +const MESSAGE_BLOCKS: usize = STAGE2_ROOT_STATEMENT_BYTES.div_ceil(BLOCK_BYTES); +const FIRST_CLAIM_WORD: usize = STAGE2_FIXED_PREFIX_BYTES / WORD_BYTES; +const CLAIM_WORDS: usize = + (STAGE2_ROOT_STATEMENT_BYTES - STAGE2_FIXED_PREFIX_BYTES) / WORD_BYTES; +pub(crate) const CHUNK_START: u32 = 1 << 0; +pub(crate) const CHUNK_END: u32 = 1 << 1; +pub(crate) const ROOT: u32 = 1 << 3; +pub(crate) const IV: [u32; 8] = [ + 0x6A09_E667, + 0xBB67_AE85, + 0x3C6E_F372, + 0xA54F_F53A, + 0x510E_527F, + 0x9B05_688C, + 0x1F83_D9AB, + 0x5BE0_CD19, +]; + +/// A genuine Flock circuit proof of the statement-hash subrelation. +/// +/// This artifact must never be accepted as [`crate::Stage3ArtifactV1`]. It +/// establishes only +/// `BLAKE3(fixed_domain_vk_fri_prefix || private_claim) = stage2_root_digest`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Stage3BindingArtifactV1 { + fixed_statement_prefix: [u8; STAGE2_FIXED_PREFIX_BYTES], + circuit_digest: [u8; 32], + stage2_root_digest: [u8; 32], + proof_bundle_bytes: Vec, +} + +impl Stage3BindingArtifactV1 { + pub fn to_bytes(&self) -> Vec { + let mut bytes = + Vec::with_capacity(ARTIFACT_HEADER_BYTES + self.proof_bundle_bytes.len()); + bytes.extend_from_slice(STAGE3_BINDING_ARTIFACT_MAGIC); + bytes.extend_from_slice(&STAGE3_BINDING_ARTIFACT_VERSION.to_le_bytes()); + bytes.extend_from_slice(&FlockConfigV1.digest()); + bytes.extend_from_slice(&self.fixed_statement_prefix); + bytes.extend_from_slice(&self.circuit_digest); + bytes.extend_from_slice(&self.stage2_root_digest); + bytes.extend_from_slice( + &u64::try_from(self.proof_bundle_bytes.len()) + .expect("proof bundle length") + .to_le_bytes(), + ); + bytes.extend_from_slice(&self.proof_bundle_bytes); + bytes + } + + pub fn from_bytes(bytes: &[u8]) -> Result { + if bytes.len() < ARTIFACT_HEADER_BYTES { + bail!("truncated Flock statement-binding artifact"); + } + if &bytes[..8] != STAGE3_BINDING_ARTIFACT_MAGIC { + bail!("invalid Flock statement-binding artifact magic"); + } + let version = u16::from_le_bytes(bytes[8..10].try_into().unwrap()); + if version != STAGE3_BINDING_ARTIFACT_VERSION { + bail!("unsupported Flock statement-binding artifact version {version}"); + } + if bytes[CONFIG_OFFSET..PREFIX_OFFSET] != FlockConfigV1.digest() { + bail!("Flock statement-binding artifact configuration mismatch"); + } + let mut fixed_statement_prefix = [0u8; STAGE2_FIXED_PREFIX_BYTES]; + fixed_statement_prefix + .copy_from_slice(&bytes[PREFIX_OFFSET..CIRCUIT_DIGEST_OFFSET]); + let mut circuit_digest = [0u8; 32]; + circuit_digest + .copy_from_slice(&bytes[CIRCUIT_DIGEST_OFFSET..ROOT_DIGEST_OFFSET]); + let mut stage2_root_digest = [0u8; 32]; + stage2_root_digest + .copy_from_slice(&bytes[ROOT_DIGEST_OFFSET..BUNDLE_LENGTH_OFFSET]); + let bundle_len = usize::try_from(u64::from_le_bytes( + bytes[BUNDLE_LENGTH_OFFSET..ARTIFACT_HEADER_BYTES].try_into().unwrap(), + )) + .map_err(|error| { + anyhow::anyhow!("Flock proof bundle length does not fit usize: {error}") + })?; + if bundle_len == 0 || bundle_len > MAX_PROOF_BUNDLE_BYTES { + bail!("invalid Flock proof bundle length {bundle_len}"); + } + let expected_len = + ARTIFACT_HEADER_BYTES.checked_add(bundle_len).ok_or_else(|| { + anyhow::anyhow!("Flock binding artifact length overflow") + })?; + if bytes.len() != expected_len { + bail!( + "Flock statement-binding artifact is {} bytes; header declares {expected_len}", + bytes.len() + ); + } + let proof_bundle_bytes = bytes[ARTIFACT_HEADER_BYTES..].to_vec(); + decode_proof_bundle(&proof_bundle_bytes) + .context("decode Flock statement-binding proof bundle")?; + Ok(Self { + fixed_statement_prefix, + circuit_digest, + stage2_root_digest, + proof_bundle_bytes, + }) + } + + pub fn fixed_statement_prefix(&self) -> &[u8; STAGE2_FIXED_PREFIX_BYTES] { + &self.fixed_statement_prefix + } + + pub fn circuit_digest(&self) -> &[u8; 32] { + &self.circuit_digest + } + + pub fn stage2_root_digest(&self) -> &[u8; 32] { + &self.stage2_root_digest + } + + pub fn proof_bundle_bytes(&self) -> &[u8] { + &self.proof_bundle_bytes + } +} + +#[derive(Serialize, Deserialize)] +struct CircuitProofBundle { + commitment: Commitment, + proof: R1csProofCircuitMerged, +} + +/// Produce a real Flock circuit proof that checks the private claim's +/// Goldilocks encodings and hashes the canonical 224-byte Stage 2 statement +/// to its public root digest. +pub fn prove_stage3_statement_binding( + statement: &Stage2RootStatementV1, +) -> Result { + let statement_bytes = statement.to_bytes(); + let fixed_statement_prefix = statement_prefix(&statement_bytes); + let relation = StatementHashRelation::build(&fixed_statement_prefix)?; + let inputs = relation_inputs(&statement_bytes); + let witness = relation.shape.run(&inputs, &[]); + let stage2_root_digest = statement.digest(); + let expected_public = + relation_public(&fixed_statement_prefix, &stage2_root_digest); + if witness.public != expected_public { + bail!("Flock BLAKE3 gate output disagrees with native Stage 2 digest"); + } + + let rows = witness.rows::(relation.blake3_slot); + let canonical_rows = witness + .rows::(relation.canonical_goldilocks_slot); + relation.ensure_registry_order()?; + let blake3_r1cs = blake3::build_block_r1cs(BLAKE3_CAPACITY_LOG); + let blake3_lincheck = blake3_r1cs.csc_lincheck_circuit(); + let canonical_r1cs = build_canonical_pair_r1cs(BLAKE3_CAPACITY_LOG); + let canonical_lincheck = canonical_r1cs.csc_lincheck_circuit(); + let union = + UnionInstance::new(&relation.shape.registry, relation.shape.counts.clone()); + let pcs_params = pcs_params(&union); + let mut challenger = + FsChallenger::with_chained_blake3(STAGE3_TRANSCRIPT_DOMAIN); + let (proof, commitment, _) = prover::prove_fast_ligerito_union_circuit( + &union, + &relation.shape.circuit, + &witness.public, + &pcs_params, + vec![ + UnionSlotProverInput::new( + blake3::generate_witness_batch_major_partial(rows, BLAKE3_CAPACITY_LOG), + blake3_lincheck, + ), + UnionSlotProverInput::new( + generate_canonical_pair_witness(canonical_rows, BLAKE3_CAPACITY_LOG), + canonical_lincheck, + ), + ], + Vec::new(), + &mut challenger, + ); + let proof_bundle_bytes = + encode_proof_bundle(&CircuitProofBundle { commitment, proof })?; + if proof_bundle_bytes.len() > MAX_PROOF_BUNDLE_BYTES { + bail!("Flock proof bundle exceeds {MAX_PROOF_BUNDLE_BYTES} bytes"); + } + Ok(Stage3BindingArtifactV1 { + fixed_statement_prefix, + circuit_digest: relation.shape.circuit.digest(), + stage2_root_digest, + proof_bundle_bytes, + }) +} + +/// Verify the statement-hash circuit proof against the digest carried by its +/// strict artifact. Callers that expect a particular Stage 2 root must also +/// use [`verify_stage3_statement_binding_for`]. +pub fn verify_stage3_statement_binding( + artifact: &Stage3BindingArtifactV1, +) -> Result<()> { + let relation = + StatementHashRelation::build(&artifact.fixed_statement_prefix)?; + relation.ensure_registry_order()?; + if artifact.circuit_digest != relation.shape.circuit.digest() { + bail!("Flock statement-binding circuit digest mismatch"); + } + let bundle = decode_proof_bundle(&artifact.proof_bundle_bytes) + .context("decode Flock statement-binding proof bundle")?; + let public = relation_public( + &artifact.fixed_statement_prefix, + &artifact.stage2_root_digest, + ); + let union = + UnionInstance::new(&relation.shape.registry, relation.shape.counts.clone()); + let pcs_params = pcs_params(&union); + let blake3_r1cs = blake3::build_block_r1cs(BLAKE3_CAPACITY_LOG); + let blake3_lincheck = blake3_r1cs.csc_lincheck_circuit(); + let canonical_r1cs = build_canonical_pair_r1cs(BLAKE3_CAPACITY_LOG); + let canonical_lincheck = canonical_r1cs.csc_lincheck_circuit(); + let linchecks: [&dyn flock_prover::lincheck::LincheckCircuit; 2] = + [blake3_lincheck, canonical_lincheck]; + let mut challenger = + FsChallenger::with_chained_blake3(STAGE3_TRANSCRIPT_DOMAIN); + verifier::verify_ligerito_union_circuit( + &union, + &relation.shape.circuit, + &public, + &linchecks, + &bundle.commitment, + &bundle.proof, + &pcs_params, + &mut challenger, + ) + .map_err(|error| { + anyhow::anyhow!("Flock statement-binding proof rejected: {error:?}") + })?; + Ok(()) +} + +/// Verify and bind the proof to an expected canonical Stage 2 statement. +pub fn verify_stage3_statement_binding_for( + artifact: &Stage3BindingArtifactV1, + expected: &Stage2RootStatementV1, +) -> Result<()> { + if artifact.fixed_statement_prefix != statement_prefix(&expected.to_bytes()) { + bail!("Flock binding proof uses a different Stage 2 vk or FRI prefix"); + } + if artifact.stage2_root_digest != expected.digest() { + bail!("Flock binding proof targets a different Stage 2 root"); + } + verify_stage3_statement_binding(artifact) +} + +/// Content digest of this partial circuit. It is useful for diagnostics and +/// reproducibility, but is not the complete Stage 3 relation digest. +pub fn stage3_statement_binding_circuit_digest( + statement: &Stage2RootStatementV1, +) -> Result<[u8; 32]> { + let prefix = statement_prefix(&statement.to_bytes()); + Ok(StatementHashRelation::build(&prefix)?.shape.circuit.digest()) +} + +struct StatementHashRelation { + shape: CircuitShape, + blake3_slot: SlotId, + canonical_goldilocks_slot: SlotId, +} + +impl StatementHashRelation { + fn build(prefix: &[u8; STAGE2_FIXED_PREFIX_BYTES]) -> Result { + let mut builder = ShapeBuilder::new(BLAKE3_CAPACITY_LOG); + let blake3_slot = builder.slot(Blake3Gate { nu: BLAKE3_CAPACITY_LOG }); + let canonical_goldilocks_slot = + builder.slot(CanonicalGoldilocksPairGate { nu: BLAKE3_CAPACITY_LOG }); + let packed_iv = pack8(&IV); + let initial_cv = [ + builder.fixed_public_input(packed_iv[0]), + builder.fixed_public_input(packed_iv[1]), + ]; + let canonical_zero = builder.fixed_public_input(F128::ZERO); + let mut messages = Vec::<[Wire; 4]>::with_capacity(MESSAGE_BLOCKS); + let mut params = Vec::::with_capacity(MESSAGE_BLOCKS); + for block in 0..MESSAGE_BLOCKS { + let message: [_; 4] = std::array::from_fn(|word| { + let word = block * 4 + word; + match fixed_statement_word(prefix, word) { + Some(value) => builder.fixed_public_input(value), + None => builder.input(), + } + }); + messages.push(message); + params.push(builder.fixed_public_input(block_params(block))); + } + + for word in FIRST_CLAIM_WORD..FIRST_CLAIM_WORD + CLAIM_WORDS { + let message = messages[word / 4][word % 4]; + let violation = builder.gate(canonical_goldilocks_slot, &[message])[0]; + builder.connect(violation, canonical_zero); + } + + let mut cv = initial_cv; + for block in 0..MESSAGE_BLOCKS { + let message = messages[block]; + let outputs = builder.gate( + blake3_slot, + &[ + cv[0], + cv[1], + message[0], + message[1], + message[2], + message[3], + params[block], + ], + ); + cv = [outputs[0], outputs[1]]; + } + builder.publish(cv[0]); + builder.publish(cv[1]); + let shape = builder.finish().map_err(|error| { + anyhow::anyhow!("build Flock binding circuit: {error:?}") + })?; + Ok(Self { shape, blake3_slot, canonical_goldilocks_slot }) + } + + fn ensure_registry_order(&self) -> Result<()> { + if self.shape.registry_slot(self.blake3_slot) != 0 + || self.shape.registry_slot(self.canonical_goldilocks_slot) != 1 + { + bail!("unexpected Flock Boolean table registry order"); + } + Ok(()) + } +} + +pub(crate) struct Blake3Gate { + pub(crate) nu: usize, +} + +impl GateType for Blake3Gate { + type Row = blake3::Compression; + type Hint = (); + + fn table(&self) -> TableType { + TableType::from_block_r1cs(&blake3::build_block_r1cs(self.nu)) + .with_io_schema(blake3::io_schema()) + } + + fn eval( + &self, + inputs: &[F128], + _hint: &(), + outputs: &mut Vec, + ) -> Self::Row { + let cv = unpack8(inputs[0], inputs[1]); + let mut message = [0u32; 16]; + for index in 0..4 { + message[4 * index..4 * index + 4] + .copy_from_slice(&unpack4(inputs[2 + index])); + } + let (counter, block_len, flags) = unpack_params(inputs[6]); + let output = + blake3::blake3_compress(&cv, &message, counter, block_len, flags); + let output_lo: [u32; 8] = output[..8].try_into().unwrap(); + let output_hi: [u32; 8] = output[8..].try_into().unwrap(); + outputs.extend_from_slice(&[ + pack8(&output_lo)[0], + pack8(&output_lo)[1], + pack8(&output_hi)[0], + pack8(&output_hi)[1], + ]); + (cv, message, counter, block_len, flags) + } + + fn witness(&self, _rows: &[Self::Row], _nu: usize) -> SlotWitness { + SlotWitness::DeferredToRows + } +} + +fn relation_inputs(statement: &[u8]) -> Vec { + assert_eq!(statement.len(), STAGE2_ROOT_STATEMENT_BYTES); + let mut padded = [0u8; MESSAGE_BLOCKS * BLOCK_BYTES]; + padded[..statement.len()].copy_from_slice(statement); + let packed_iv = pack8(&IV); + let mut inputs = Vec::with_capacity(3 + MESSAGE_BLOCKS * 5); + inputs.extend_from_slice(&packed_iv); + inputs.push(F128::ZERO); + for block in 0..MESSAGE_BLOCKS { + let start = block * BLOCK_BYTES; + for word in 0..4 { + let offset = start + word * WORD_BYTES; + inputs.push(pack_bytes(&padded[offset..offset + WORD_BYTES])); + } + inputs.push(block_params(block)); + } + inputs +} + +fn relation_public( + prefix: &[u8; STAGE2_FIXED_PREFIX_BYTES], + digest: &[u8; 32], +) -> Vec { + let packed_iv = pack8(&IV); + let mut public = Vec::with_capacity(3 + 7 + MESSAGE_BLOCKS + 2); + public.extend_from_slice(&packed_iv); + public.push(F128::ZERO); + for block in 0..MESSAGE_BLOCKS { + for word in 0..4 { + if let Some(value) = fixed_statement_word(prefix, block * 4 + word) { + public.push(value); + } + } + public.push(block_params(block)); + } + public.push(pack_bytes(&digest[..16])); + public.push(pack_bytes(&digest[16..])); + public +} + +fn statement_prefix(statement: &[u8]) -> [u8; STAGE2_FIXED_PREFIX_BYTES] { + statement[..STAGE2_FIXED_PREFIX_BYTES].try_into().unwrap() +} + +fn fixed_statement_word( + prefix: &[u8; STAGE2_FIXED_PREFIX_BYTES], + word: usize, +) -> Option { + if word * WORD_BYTES < STAGE2_FIXED_PREFIX_BYTES { + let offset = word * WORD_BYTES; + Some(pack_bytes(&prefix[offset..offset + WORD_BYTES])) + } else if word * WORD_BYTES >= STAGE2_ROOT_STATEMENT_BYTES { + Some(F128::ZERO) + } else { + None + } +} + +fn block_params(block: usize) -> F128 { + let is_first = block == 0; + let is_last = block + 1 == MESSAGE_BLOCKS; + let mut flags = 0; + if is_first { + flags |= CHUNK_START; + } + if is_last { + flags |= CHUNK_END | ROOT; + } + let consumed = block * BLOCK_BYTES; + let remaining = STAGE2_ROOT_STATEMENT_BYTES - consumed; + let block_len = u32::try_from(remaining.min(BLOCK_BYTES)).unwrap(); + pack_params(0, block_len, flags) +} + +pub(crate) fn pcs_params(union: &UnionInstance<'_>) -> PcsParams { + let profile = FlockConfigV1.profile(); + let m = union.dense_m(); + let log_batch_size = embedded_initial_k_or_default(m, profile); + PcsParams { + m, + log_inv_rate: profile.log_inv_rate(), + log_batch_size, + profile, + num_lanes: union.commit_lanes(log_batch_size), + merkle_hash: FlockConfigV1.merkle_hash(), + } +} + +pub(crate) fn pack_bytes(bytes: &[u8]) -> F128 { + assert_eq!(bytes.len(), WORD_BYTES); + F128::new( + u64::from_le_bytes(bytes[..8].try_into().unwrap()), + u64::from_le_bytes(bytes[8..].try_into().unwrap()), + ) +} + +pub(crate) fn pack4(words: [u32; 4]) -> F128 { + F128::new( + words[0] as u64 | ((words[1] as u64) << 32), + words[2] as u64 | ((words[3] as u64) << 32), + ) +} + +pub(crate) fn unpack4(value: F128) -> [u32; 4] { + [ + value.lo as u32, + (value.lo >> 32) as u32, + value.hi as u32, + (value.hi >> 32) as u32, + ] +} + +pub(crate) fn pack8(words: &[u32; 8]) -> [F128; 2] { + [ + pack4([words[0], words[1], words[2], words[3]]), + pack4([words[4], words[5], words[6], words[7]]), + ] +} + +pub(crate) fn unpack8(first: F128, second: F128) -> [u32; 8] { + let first = unpack4(first); + let second = unpack4(second); + [ + first[0], first[1], first[2], first[3], second[0], second[1], second[2], + second[3], + ] +} + +pub(crate) fn pack_params(counter: u64, block_len: u32, flags: u32) -> F128 { + F128::new(counter, block_len as u64 | ((flags as u64) << 32)) +} + +fn unpack_params(value: F128) -> (u64, u32, u32) { + (value.lo, value.hi as u32, (value.hi >> 32) as u32) +} + +fn encode_proof_bundle(bundle: &CircuitProofBundle) -> Result> { + bincode::DefaultOptions::new() + .with_fixint_encoding() + .serialize(bundle) + .context("encode Flock statement-binding proof bundle") +} + +fn decode_proof_bundle(bytes: &[u8]) -> Result { + bincode::DefaultOptions::new() + .with_fixint_encoding() + .with_limit(MAX_PROOF_BUNDLE_BYTES as u64) + .reject_trailing_bytes() + .deserialize(bytes) + .context("invalid Flock statement-binding proof bundle") +} + +#[cfg(test)] +mod tests { + use super::*; + use ix_terminal::OUTER_CLAIM_ELEMENTS; + use multi_stark::types::FriParameters; + + fn statement() -> Stage2RootStatementV1 { + let claim: Vec = + (0..OUTER_CLAIM_ELEMENTS as u64).flat_map(u64::to_le_bytes).collect(); + Stage2RootStatementV1::new( + b"binding-test-vk", + &claim, + &FriParameters { + log_final_poly_len: 0, + max_log_arity: 1, + num_queries: 100, + commit_proof_of_work_bits: 0, + query_proof_of_work_bits: 20, + }, + ) + .unwrap() + } + + #[test] + fn circuit_hashes_the_exact_stage2_statement() { + let statement = statement(); + let statement_bytes = statement.to_bytes(); + let prefix = statement_prefix(&statement_bytes); + let relation = StatementHashRelation::build(&prefix).unwrap(); + let witness = relation.shape.run(&relation_inputs(&statement_bytes), &[]); + assert_eq!(witness.public, relation_public(&prefix, &statement.digest())); + assert_eq!(relation.shape.counts, vec![MESSAGE_BLOCKS, CLAIM_WORDS]); + assert_eq!(witness.rows::(relation.blake3_slot).len(), 4); + assert_eq!( + witness + .rows::(relation.canonical_goldilocks_slot) + .len(), + CLAIM_WORDS + ); + + let mut changed = statement.to_bytes(); + let last = changed.len() - 1; + changed[last] ^= 1; + let changed = relation.shape.run(&relation_inputs(&changed), &[]); + assert_ne!(changed.public, witness.public); + + let mut other_prefix = prefix; + other_prefix[8] ^= 1; + let other = StatementHashRelation::build(&other_prefix).unwrap(); + assert_ne!(other.shape.circuit.digest(), relation.shape.circuit.digest()); + } + + #[test] + fn artifact_parser_rejects_short_and_wrong_magic() { + assert!(Stage3BindingArtifactV1::from_bytes(&[]).is_err()); + let mut header = vec![0u8; ARTIFACT_HEADER_BYTES]; + header[..8].copy_from_slice(b"NOTFLOCK"); + assert!(Stage3BindingArtifactV1::from_bytes(&header).is_err()); + } + + #[test] + #[ignore = "large upstream Flock circuit proof; run explicitly"] + fn real_statement_binding_proof_round_trip_and_mutations() { + let statement = statement(); + let artifact = + prove_stage3_statement_binding(&statement).expect("prove binding"); + eprintln!( + "Flock statement-binding circuit proof bundle: {} bytes", + artifact.proof_bundle_bytes().len() + ); + verify_stage3_statement_binding_for(&artifact, &statement) + .expect("verify binding"); + + let encoded = artifact.to_bytes(); + let decoded = Stage3BindingArtifactV1::from_bytes(&encoded).unwrap(); + verify_stage3_statement_binding_for(&decoded, &statement) + .expect("verify decoded binding"); + + let mut wrong_prefix = decoded.clone(); + wrong_prefix.fixed_statement_prefix[8] ^= 1; + assert!( + verify_stage3_statement_binding_for(&wrong_prefix, &statement).is_err() + ); + + let mut wrong_root = decoded.clone(); + wrong_root.stage2_root_digest[0] ^= 1; + assert!(verify_stage3_statement_binding(&wrong_root).is_err()); + + let mut wrong_proof = decoded; + let flip_at = wrong_proof.proof_bundle_bytes.len() / 2; + wrong_proof.proof_bundle_bytes[flip_at] ^= 1; + assert!(verify_stage3_statement_binding(&wrong_proof).is_err()); + } +} diff --git a/flock-stage3/host/src/boolean.rs b/flock-stage3/host/src/boolean.rs new file mode 100644 index 00000000..45b6761f --- /dev/null +++ b/flock-stage3/host/src/boolean.rs @@ -0,0 +1,337 @@ +//! Small builder for custom block-diagonal Boolean R1CS tables. +//! +//! Flock fixes `C = I`, so every constraint row also names its output +//! variable. This builder allocates derived variables in dependency order, +//! records their `(linear A) * (linear B) = output` operations once, and uses +//! that same record for both the sparse matrices and native witness filling. + +use std::sync::OnceLock; + +use flock_prover::{ + field::F128, + lincheck::pack_z_lincheck, + r1cs::{BlockR1cs, SparseBinaryMatrix, WitnessLayout}, +}; + +#[derive(Clone, Debug)] +struct BooleanOperation { + output: usize, + a: Vec, + b: Vec, +} + +/// A finished Boolean table plan, independent of its outer row capacity. +#[derive(Clone, Debug)] +pub(crate) struct BooleanR1csPlan { + k_log: usize, + useful_bits: usize, + a_rows: Vec>, + b_rows: Vec>, + operations: Vec, + const_pin: Option, +} + +impl BooleanR1csPlan { + pub(crate) fn k_log(&self) -> usize { + self.k_log + } + + pub(crate) fn k(&self) -> usize { + 1usize << self.k_log + } + + #[cfg(test)] + pub(crate) fn useful_bits(&self) -> usize { + self.useful_bits + } + + pub(crate) fn block_r1cs(&self, nu: usize) -> BlockR1cs { + assert!(nu >= 3, "Flock lincheck requires at least eight rows"); + let k = self.k(); + BlockR1cs { + m: self.k_log + nu, + k_log: self.k_log, + k_skip: 6, + useful_bits: self.useful_bits, + a_0: sparse_matrix(k, self.a_rows.clone()), + b_0: sparse_matrix(k, self.b_rows.clone()), + c_0: sparse_matrix(k, (0..k).map(|row| vec![row]).collect()), + layout: WitnessLayout::BatchMajor, + const_pin: self.const_pin, + digest_cache: OnceLock::new(), + csc_cache: OnceLock::new(), + } + } + + /// Fill fixed/free columns, then derive every internal/output column from + /// the same operation list that created the R1CS matrices. + pub(crate) fn fill_row( + &self, + bits: &mut [bool], + fill_free: impl FnOnce(&mut [bool]), + ) { + assert_eq!(bits.len(), self.k()); + bits.fill(false); + if let Some(column) = self.const_pin { + bits[column] = true; + } + fill_free(bits); + for operation in &self.operations { + let a = parity(bits, &operation.a); + let b = parity(bits, &operation.b); + bits[operation.output] = a & b; + } + } +} + +/// Mutable construction half of [`BooleanR1csPlan`]. +pub(crate) struct BooleanR1csBuilder { + k_log: usize, + k: usize, + next_column: usize, + a_rows: Vec>, + b_rows: Vec>, + assigned: Vec, + operations: Vec, + const_pin: Option, +} + +impl BooleanR1csBuilder { + /// Reserve `[0, reserved_columns)` for word-aligned circuit I/O. + pub(crate) fn new(k_log: usize, reserved_columns: usize) -> Self { + assert!(k_log >= 7, "BatchMajor Boolean tables need k_log >= 7"); + let k = 1usize << k_log; + assert!(reserved_columns <= k); + Self { + k_log, + k, + next_column: reserved_columns, + a_rows: vec![Vec::new(); k], + b_rows: vec![Vec::new(); k], + assigned: vec![false; k], + operations: Vec::new(), + const_pin: None, + } + } + + /// Mark a supplied bit as free Boolean advice (`x * x = x`). + pub(crate) fn free_boolean_at(&mut self, column: usize) { + self.set_constraint(column, vec![column], vec![column], false); + } + + /// Require a supplied Boolean advice bit to be zero. + /// + /// With `one = 1`, the constraint `x * (x + one) = x` accepts `x = 0` + /// and rejects `x = 1`. This is useful for word-aligned gates whose logical + /// input occupies only one lane of an `F128` word. + pub(crate) fn assert_zero_at(&mut self, column: usize, one: usize) { + self.set_constraint(column, vec![column], vec![column, one], false); + } + + /// Allocate one supplied Boolean advice bit after the reserved I/O region. + pub(crate) fn alloc_free_boolean(&mut self) -> usize { + let column = self.alloc_column(); + self.free_boolean_at(column); + column + } + + /// Allocate the table's one constant-one column and bind it through + /// Flock's count-aware lincheck pin. + pub(crate) fn alloc_constant_one(&mut self) -> usize { + assert!(self.const_pin.is_none(), "constant-one column already allocated"); + let column = self.alloc_free_boolean(); + self.const_pin = Some(column); + column + } + + pub(crate) fn and(&mut self, lhs: usize, rhs: usize) -> usize { + self.alloc_gate(vec![lhs], vec![rhs]) + } + + /// Multiply two non-empty GF(2) linear forms. + /// + /// This is useful for compact full adders: `z * (x + y)` is one R1CS + /// constraint and does not need an intermediate column for `x + y`. + pub(crate) fn product_of_parities( + &mut self, + lhs: &[usize], + rhs: &[usize], + ) -> usize { + assert!(!lhs.is_empty()); + assert!(!rhs.is_empty()); + self.alloc_gate(lhs.to_vec(), rhs.to_vec()) + } + + /// Derive a pre-reserved output from two GF(2) linear forms. + pub(crate) fn write_product_of_parities( + &mut self, + output: usize, + lhs: &[usize], + rhs: &[usize], + ) { + assert!(!lhs.is_empty()); + assert!(!rhs.is_empty()); + self.set_constraint(output, lhs.to_vec(), rhs.to_vec(), true); + } + + /// XOR a non-empty set of bits using multiplication by the pinned one. + pub(crate) fn xor(&mut self, inputs: &[usize], one: usize) -> usize { + assert!(!inputs.is_empty()); + self.alloc_gate(inputs.to_vec(), vec![one]) + } + + /// Derive a pre-reserved output bit instead of allocating a new column. + pub(crate) fn write_xor( + &mut self, + output: usize, + inputs: &[usize], + one: usize, + ) { + assert!(!inputs.is_empty()); + self.set_constraint(output, inputs.to_vec(), vec![one], true); + } + + pub(crate) fn finish(self) -> BooleanR1csPlan { + BooleanR1csPlan { + k_log: self.k_log, + useful_bits: self.next_column, + a_rows: self.a_rows, + b_rows: self.b_rows, + operations: self.operations, + const_pin: self.const_pin, + } + } + + fn alloc_gate(&mut self, a: Vec, b: Vec) -> usize { + let output = self.alloc_column(); + self.set_constraint(output, a, b, true); + output + } + + fn alloc_column(&mut self) -> usize { + assert!(self.next_column < self.k, "Boolean R1CS table exceeded 2^k_log"); + let column = self.next_column; + self.next_column += 1; + column + } + + fn set_constraint( + &mut self, + output: usize, + a: Vec, + b: Vec, + derive: bool, + ) { + assert!(output < self.k); + assert!(!self.assigned[output], "Boolean column {output} assigned twice"); + assert!(a.iter().chain(&b).all(|&column| column < self.k)); + self.a_rows[output] = a.clone(); + self.b_rows[output] = b.clone(); + self.assigned[output] = true; + if derive { + self.operations.push(BooleanOperation { output, a, b }); + } + } +} + +/// Produce Flock's BatchMajor `(z, A z, B z, lincheck stripe)` tuple from a +/// row filler that supplies only the plan's free columns. +pub(crate) fn generate_boolean_witness( + plan: &BooleanR1csPlan, + rows: &[T], + nu: usize, + fill_free: impl Fn(&T, &mut [bool]), +) -> (Vec, Vec, Vec, Vec) { + let capacity = 1usize << nu; + assert!(rows.len() <= capacity); + let r1cs = plan.block_r1cs(nu); + let k = plan.k(); + let mut z = vec![false; r1cs.n()]; + for (outer, row) in rows.iter().enumerate() { + plan.fill_row(&mut z[outer * k..(outer + 1) * k], |bits| { + fill_free(row, bits) + }); + } + let a = r1cs.apply_a(&z); + let b = r1cs.apply_b(&z); + assert!( + a.iter() + .zip(&b) + .zip(&z) + .all(|((a_bit, b_bit), z_bit)| (*a_bit & *b_bit) == *z_bit), + "custom Boolean witness does not satisfy its R1CS" + ); + let stripe = pack_z_lincheck(&z, r1cs.m, r1cs.k_log); + ( + pack_batch_major(&z, plan.k_log(), nu), + pack_batch_major(&a, plan.k_log(), nu), + pack_batch_major(&b, plan.k_log(), nu), + stripe, + ) +} + +pub(crate) fn write_f128(bits: &mut [bool], offset: usize, value: F128) { + assert!(offset + 128 <= bits.len()); + for local in 0..64 { + bits[offset + local] = (value.lo >> local) & 1 == 1; + bits[offset + 64 + local] = (value.hi >> local) & 1 == 1; + } +} + +fn parity(bits: &[bool], columns: &[usize]) -> bool { + columns.iter().fold(false, |value, &column| value ^ bits[column]) +} + +fn sparse_matrix(k: usize, rows: Vec>) -> SparseBinaryMatrix { + SparseBinaryMatrix { num_rows: k, num_cols: k, rows } +} + +fn pack_batch_major(bits: &[bool], k_log: usize, nu: usize) -> Vec { + let capacity = 1usize << nu; + let k = 1usize << k_log; + assert_eq!(bits.len(), capacity * k); + let chunks = k / 128; + let mut packed = vec![F128::ZERO; chunks * capacity]; + for chunk in 0..chunks { + for outer in 0..capacity { + let start = outer * k + chunk * 128; + let mut lo = 0u64; + let mut hi = 0u64; + for local in 0..64 { + lo |= u64::from(bits[start + local]) << local; + hi |= u64::from(bits[start + 64 + local]) << local; + } + packed[(chunk << nu) + outer] = F128::new(lo, hi); + } + } + packed +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn one_description_builds_matrices_and_witness() { + let mut builder = BooleanR1csBuilder::new(7, 3); + builder.free_boolean_at(0); + builder.free_boolean_at(1); + let one = builder.alloc_constant_one(); + let product = builder.and(0, 1); + builder.write_xor(2, &[product, 0], one); + let plan = builder.finish(); + let r1cs = plan.block_r1cs(3); + + for (x, y) in [(false, false), (false, true), (true, false), (true, true)] { + let mut row = vec![false; plan.k()]; + plan.fill_row(&mut row, |bits| { + bits[0] = x; + bits[1] = y; + }); + assert_eq!(row[2], (x & y) ^ x); + let mut witness = vec![false; r1cs.n()]; + witness[..plan.k()].copy_from_slice(&row); + assert!(r1cs.satisfies(&witness)); + } + } +} diff --git a/flock-stage3/host/src/config.rs b/flock-stage3/host/src/config.rs new file mode 100644 index 00000000..6c9377c4 --- /dev/null +++ b/flock-stage3/host/src/config.rs @@ -0,0 +1,121 @@ +use flock_prover::{ + hash::HashKind, pcs::ligerito::LigeritoProfile, + r1cs_hashes::blake3::Blake3Setup, +}; + +pub const FLOCK_UPSTREAM_REVISION: &str = + "b310f35f35f68095537150a1c8c0a43caca9a29e"; +pub const STAGE3_TRANSCRIPT_DOMAIN: &[u8] = b"ix:flock-stage3:fri-verifier:v1"; +pub const ENGINE_CONFORMANCE_TRANSCRIPT_DOMAIN: &[u8] = + b"ix:flock-stage3:blake3-engine-conformance:v1"; +pub const ARITHMETIC_CONFORMANCE_TRANSCRIPT_DOMAIN: &[u8] = + b"ix:flock-stage3:goldilocks-arithmetic-conformance:v1"; +pub const MERKLE_CONFORMANCE_TRANSCRIPT_DOMAIN: &[u8] = + b"ix:flock-stage3:blake3-merkle-conformance:v1"; +pub const FRI_FOLD_CONFORMANCE_TRANSCRIPT_DOMAIN: &[u8] = + b"ix:flock-stage3:authenticated-fri-fold-conformance:v1"; +pub const FRI_QUERY_CONFORMANCE_TRANSCRIPT_DOMAIN: &[u8] = + b"ix:flock-stage3:fri-commit-phase-query-conformance:v1"; +pub const PCS_REDUCTION_CONFORMANCE_TRANSCRIPT_DOMAIN: &[u8] = + b"ix:flock-stage3:pcs-reduced-opening-conformance:v1"; +pub const STAGE2_TRANSCRIPT_CONFORMANCE_TRANSCRIPT_DOMAIN: &[u8] = + b"ix:flock-stage3:stage2-transcript-conformance:v1"; +pub const TRANSCRIPT_BOUND_PCS_CONFORMANCE_TRANSCRIPT_DOMAIN: &[u8] = + b"ix:flock-stage3:transcript-bound-pcs-conformance:v1"; +pub const TRANSCRIPT_BOUND_FRI_CONFORMANCE_TRANSCRIPT_DOMAIN: &[u8] = + b"ix:flock-stage3:transcript-bound-fri-query-conformance:v1"; +pub const TRANSCRIPT_BOUND_FRI_QUERIES_CONFORMANCE_TRANSCRIPT_DOMAIN: &[u8] = + b"ix:flock-stage3:transcript-bound-fri-all-queries-conformance:v1"; +pub const TRANSCRIPT_BOUND_PCS_FRI_QUERIES_CONFORMANCE_TRANSCRIPT_DOMAIN: + &[u8] = + b"ix:flock-stage3:transcript-bound-pcs-fri-all-queries-conformance:v1"; +pub const STAGE2_AIR_PCS_FRI_CONFORMANCE_TRANSCRIPT_DOMAIN: &[u8] = + b"ix:flock-stage3:stage2-air-pcs-fri-conformance:v1"; + +const CONFIG_DOMAIN: &[u8; 8] = b"IXFLKCF1"; +const FIELD_F128: u8 = 1; +const PROFILE_FAST128: u8 = 1; +const MERKLE_BLAKE3: u8 = 1; +const TRANSCRIPT_CHAINED_BLAKE3: u8 = 1; + +/// The only Flock protocol configuration accepted by this backend version. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct FlockConfigV1; + +impl FlockConfigV1 { + /// Canonical bytes committed by every Stage 3 statement: + /// `IXFLKCF1 || len(rev) u16 LE || rev || field || profile || merkle || + /// transcript || len(domain) u16 LE || domain`. + /// + /// The four one-byte IDs are respectively F128=1, Fast128=1, BLAKE3=1, + /// and chained-BLAKE3=1. New choices require a new configuration version. + pub fn to_bytes(self) -> Vec { + let revision = FLOCK_UPSTREAM_REVISION.as_bytes(); + let domain = STAGE3_TRANSCRIPT_DOMAIN; + let mut bytes = + Vec::with_capacity(8 + 2 + revision.len() + 4 + 2 + domain.len()); + bytes.extend_from_slice(CONFIG_DOMAIN); + bytes.extend_from_slice( + &u16::try_from(revision.len()).expect("revision length").to_le_bytes(), + ); + bytes.extend_from_slice(revision); + bytes.extend_from_slice(&[ + FIELD_F128, + PROFILE_FAST128, + MERKLE_BLAKE3, + TRANSCRIPT_CHAINED_BLAKE3, + ]); + bytes.extend_from_slice( + &u16::try_from(domain.len()).expect("domain length").to_le_bytes(), + ); + bytes.extend_from_slice(domain); + bytes + } + + pub fn digest(self) -> [u8; 32] { + *blake3::hash(&self.to_bytes()).as_bytes() + } + + pub const fn profile(self) -> LigeritoProfile { + LigeritoProfile::Fast128 + } + + pub const fn merkle_hash(self) -> HashKind { + HashKind::Blake3 + } + + /// Construct the pinned Flock BLAKE3 relation used by the engine smoke test. + /// The production Stage 2-verifier relation will reuse these PCS parameters. + pub(crate) fn blake3_setup(self, n_blocks: usize) -> Blake3Setup { + let mut setup = Blake3Setup::with_profile(n_blocks, self.profile()); + setup.pcs_params.merkle_hash = self.merkle_hash(); + setup + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn config_is_explicit_and_domain_separated() { + let bytes = FlockConfigV1.to_bytes(); + assert_eq!(&bytes[..8], CONFIG_DOMAIN); + assert!( + bytes + .windows(FLOCK_UPSTREAM_REVISION.len()) + .any(|window| window == FLOCK_UPSTREAM_REVISION.as_bytes()) + ); + assert!( + bytes + .windows(STAGE3_TRANSCRIPT_DOMAIN.len()) + .any(|window| window == STAGE3_TRANSCRIPT_DOMAIN) + ); + assert_eq!(FlockConfigV1.profile(), LigeritoProfile::Fast128); + assert_eq!(FlockConfigV1.merkle_hash(), HashKind::Blake3); + assert_eq!( + blake3::Hash::from_bytes(FlockConfigV1.digest()).to_hex().as_str(), + "1897ad7e36bc1a11a9dc4170552b1b48f5689b8f04ecc3c3825ce0273ecfaffc" + ); + } +} diff --git a/flock-stage3/host/src/conformance.rs b/flock-stage3/host/src/conformance.rs new file mode 100644 index 00000000..fc42d89f --- /dev/null +++ b/flock-stage3/host/src/conformance.rs @@ -0,0 +1,186 @@ +//! A real Flock/BLAKE3 round trip used to lock the upstream engine API. +//! +//! This is intentionally not exposed as a Stage 3 proof: the standalone +//! upstream BLAKE3 batch relation has existential (unbound) I/O. + +use anyhow::{Context, Result, bail}; +use flock_prover::{ + challenger::FsChallenger, proof_io::R1csProofBundleLigerito, + r1cs_hashes::blake3::Compression, +}; + +use crate::config::{ENGINE_CONFORMANCE_TRANSCRIPT_DOMAIN, FlockConfigV1}; + +const MAGIC: &[u8; 8] = b"IXFLKB3C"; +const VERSION: u16 = 1; +const HEADER_BYTES: usize = 8 + 2 + 32 + 4 + 8; +const MIN_BLOCKS: usize = 256; +const MAX_BLOCKS: usize = 1 << 20; +const MAX_BUNDLE_BYTES: usize = 64 * 1024 * 1024; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct EngineConformanceArtifact { + n_blocks: usize, + bundle_bytes: Vec, +} + +impl EngineConformanceArtifact { + pub fn to_bytes(&self) -> Vec { + let mut bytes = Vec::with_capacity(HEADER_BYTES + self.bundle_bytes.len()); + bytes.extend_from_slice(MAGIC); + bytes.extend_from_slice(&VERSION.to_le_bytes()); + bytes.extend_from_slice(&FlockConfigV1.digest()); + bytes.extend_from_slice( + &u32::try_from(self.n_blocks).expect("block count").to_le_bytes(), + ); + bytes.extend_from_slice( + &u64::try_from(self.bundle_bytes.len()) + .expect("bundle length") + .to_le_bytes(), + ); + bytes.extend_from_slice(&self.bundle_bytes); + bytes + } + + pub fn from_bytes(bytes: &[u8]) -> Result { + if bytes.len() < HEADER_BYTES { + bail!("truncated Flock conformance artifact"); + } + if &bytes[..8] != MAGIC { + bail!("invalid Flock conformance artifact magic"); + } + let version = u16::from_le_bytes(bytes[8..10].try_into().unwrap()); + if version != VERSION { + bail!("unsupported Flock conformance artifact version {version}"); + } + if bytes[10..42] != FlockConfigV1.digest() { + bail!("Flock conformance artifact configuration mismatch"); + } + let n_blocks = + usize::try_from(u32::from_le_bytes(bytes[42..46].try_into().unwrap())) + .expect("u32 fits usize"); + validate_n_blocks(n_blocks)?; + let bundle_len = + usize::try_from(u64::from_le_bytes(bytes[46..54].try_into().unwrap())) + .map_err(|_| { + anyhow::anyhow!("Flock bundle length does not fit usize") + })?; + if bundle_len == 0 || bundle_len > MAX_BUNDLE_BYTES { + bail!("invalid Flock bundle length {bundle_len}"); + } + let expected_len = HEADER_BYTES + .checked_add(bundle_len) + .ok_or_else(|| anyhow::anyhow!("Flock artifact length overflow"))?; + if bytes.len() != expected_len { + bail!( + "Flock conformance artifact is {} bytes; header declares {expected_len}", + bytes.len() + ); + } + Ok(Self { n_blocks, bundle_bytes: bytes[HEADER_BYTES..].to_vec() }) + } + + pub fn n_blocks(&self) -> usize { + self.n_blocks + } + + pub fn bundle_bytes(&self) -> &[u8] { + &self.bundle_bytes + } +} + +/// Prove an existential batch of valid BLAKE3 compression rows using the +/// exact hash/profile choices intended for Stage 3. +pub fn prove_engine_conformance( + blocks: &[Compression], +) -> Result { + validate_n_blocks(blocks.len())?; + let setup = FlockConfigV1.blake3_setup(blocks.len()); + let mut challenger = + FsChallenger::with_chained_blake3(ENGINE_CONFORMANCE_TRANSCRIPT_DOMAIN); + let (proof, commitment, _) = setup.prove_fast(blocks, &mut challenger); + let bundle = R1csProofBundleLigerito { commitment, proof }; + let bundle_bytes = bundle.to_bytes(); + if bundle_bytes.len() > MAX_BUNDLE_BYTES { + bail!("Flock proof bundle exceeds {MAX_BUNDLE_BYTES} bytes"); + } + Ok(EngineConformanceArtifact { n_blocks: blocks.len(), bundle_bytes }) +} + +pub fn verify_engine_conformance( + artifact: &EngineConformanceArtifact, +) -> Result<()> { + validate_n_blocks(artifact.n_blocks)?; + let bundle = R1csProofBundleLigerito::from_bytes(&artifact.bundle_bytes) + .context("decode Flock proof bundle")?; + let setup = FlockConfigV1.blake3_setup(artifact.n_blocks); + let mut challenger = + FsChallenger::with_chained_blake3(ENGINE_CONFORMANCE_TRANSCRIPT_DOMAIN); + setup.verify(&bundle.commitment, &bundle.proof, &mut challenger).map_err( + |error| anyhow::anyhow!("Flock engine proof rejected: {error:?}"), + )?; + Ok(()) +} + +fn validate_n_blocks(n_blocks: usize) -> Result<()> { + if !(MIN_BLOCKS..=MAX_BLOCKS).contains(&n_blocks) { + bail!( + "Flock conformance batch has {n_blocks} blocks; expected {MIN_BLOCKS}..={MAX_BLOCKS}" + ); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn envelope_is_strict_and_configuration_bound() { + let artifact = EngineConformanceArtifact { + n_blocks: MIN_BLOCKS, + bundle_bytes: vec![1, 2, 3], + }; + let bytes = artifact.to_bytes(); + assert_eq!( + EngineConformanceArtifact::from_bytes(&bytes).unwrap(), + artifact + ); + + let mut extended = bytes.clone(); + extended.push(0); + assert!(EngineConformanceArtifact::from_bytes(&extended).is_err()); + + let mut wrong_config = bytes; + wrong_config[10] ^= 1; + assert!(EngineConformanceArtifact::from_bytes(&wrong_config).is_err()); + } + + #[test] + #[ignore = "large upstream Flock proof; run explicitly for revision conformance"] + fn real_fast128_blake3_round_trip() { + let blocks: Vec = (0..MIN_BLOCKS) + .map(|index| { + let mut message = [0u32; 16]; + message[0] = u32::try_from(index).unwrap(); + ([0u32; 8], message, index as u64, 64, 0) + }) + .collect(); + let artifact = prove_engine_conformance(&blocks).expect("prove"); + eprintln!( + "Flock Fast128/BLAKE3 conformance bundle: {} bytes", + artifact.bundle_bytes().len() + ); + let encoded = artifact.to_bytes(); + let decoded = EngineConformanceArtifact::from_bytes(&encoded).unwrap(); + verify_engine_conformance(&decoded).expect("verify"); + + let mut mutated = decoded; + let flip_at = mutated.bundle_bytes.len() / 2; + mutated.bundle_bytes[flip_at] ^= 1; + assert!( + verify_engine_conformance(&mutated).is_err(), + "mutated Flock proof must be rejected" + ); + } +} diff --git a/flock-stage3/host/src/equality.rs b/flock-stage3/host/src/equality.rs new file mode 100644 index 00000000..60f80e13 --- /dev/null +++ b/flock-stage3/host/src/equality.rs @@ -0,0 +1,119 @@ +//! A directed equality assertion for two `F128` wires. +//! +//! Flock wire connections merge producer classes, so connecting two values +//! that were independently computed can create a cyclic circuit graph. This +//! gate keeps the graph directed: it emits their bitwise XOR, which callers +//! pin to the fixed zero wire. + +use flock_prover::{ + circuit::builder::{GateType, SlotWitness}, + field::F128, + r1cs::BlockR1cs, + schedule::{IoWord, TableType}, +}; + +use crate::boolean::{ + BooleanR1csBuilder, BooleanR1csPlan, generate_boolean_witness, write_f128, +}; + +const K_LOG: usize = 9; +const LEFT_BASE: usize = 0; +const RIGHT_BASE: usize = 128; +const RESIDUAL_BASE: usize = 256; +const COLUMNS: usize = 384; + +#[derive(Clone, Copy, Debug)] +pub(crate) struct F128EqualityGate { + pub(crate) nu: usize, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct F128EqualityRow { + left: F128, + right: F128, +} + +impl GateType for F128EqualityGate { + type Row = F128EqualityRow; + type Hint = (); + + fn table(&self) -> TableType { + TableType::from_block_r1cs(&build_f128_equality_r1cs(self.nu)) + .with_io_schema(vec![ + IoWord::input(0), + IoWord::input(1), + IoWord::output(2), + ]) + } + + fn eval( + &self, + inputs: &[F128], + _hint: &(), + outputs: &mut Vec, + ) -> Self::Row { + let left = inputs[0]; + let right = inputs[1]; + outputs.push(F128::new(left.lo ^ right.lo, left.hi ^ right.hi)); + F128EqualityRow { left, right } + } + + fn witness(&self, _rows: &[Self::Row], _nu: usize) -> SlotWitness { + SlotWitness::DeferredToRows + } +} + +pub(crate) fn build_f128_equality_r1cs(nu: usize) -> BlockR1cs { + build_plan().block_r1cs(nu) +} + +pub(crate) fn generate_f128_equality_witness( + rows: &[F128EqualityRow], + nu: usize, +) -> (Vec, Vec, Vec, Vec) { + let plan = build_plan(); + generate_boolean_witness(&plan, rows, nu, |row, bits| { + write_f128(bits, LEFT_BASE, row.left); + write_f128(bits, RIGHT_BASE, row.right); + }) +} + +fn build_plan() -> BooleanR1csPlan { + let mut builder = BooleanR1csBuilder::new(K_LOG, COLUMNS); + for column in LEFT_BASE..RIGHT_BASE + 128 { + builder.free_boolean_at(column); + } + let one = builder.alloc_constant_one(); + for bit in 0..128 { + builder.write_xor( + RESIDUAL_BASE + bit, + &[LEFT_BASE + bit, RIGHT_BASE + bit], + one, + ); + } + builder.finish() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn equality_r1cs_rejects_nonzero_residual() { + let plan = build_plan(); + let r1cs = plan.block_r1cs(3); + let left = F128::new(0x1234, 0x5678); + let mut logical = vec![false; plan.k()]; + plan.fill_row(&mut logical, |bits| { + write_f128(bits, LEFT_BASE, left); + write_f128(bits, RIGHT_BASE, left); + }); + let mut witness = vec![false; r1cs.n()]; + witness[..plan.k()].copy_from_slice(&logical); + assert!(r1cs.satisfies(&witness)); + + let mut wrong = witness; + wrong[RIGHT_BASE + 7] ^= true; + assert!(!r1cs.satisfies(&wrong)); + } +} diff --git a/flock-stage3/host/src/extension.rs b/flock-stage3/host/src/extension.rs new file mode 100644 index 00000000..450ca111 --- /dev/null +++ b/flock-stage3/host/src/extension.rs @@ -0,0 +1,330 @@ +//! Degree-two Goldilocks extension arithmetic lowered to reusable base gates. +//! +//! Extension elements are packed as `F128::new(c0, c1)` and use +//! `X^2 = 7`, matching Plonky3's Goldilocks binomial extension. The lowering +//! deliberately composes the already checked base-field addition and +//! multiplication relations rather than introducing another large monolithic +//! arithmetic table. + +use flock_prover::{ + circuit::builder::{GateType, ShapeBuilder, SlotId, SlotWitness, Wire}, + field::F128, + r1cs::BlockR1cs, + schedule::{IoWord, TableType}, +}; + +use crate::{ + boolean::{ + BooleanR1csBuilder, BooleanR1csPlan, generate_boolean_witness, write_f128, + }, + goldilocks::{CanonicalGoldilocksPairGate, GoldilocksAddPairGate}, + multiplication::{GoldilocksMulPairGate, goldilocks_mul}, +}; + +const REPACK_K_LOG: usize = 10; +const FIRST_BASE: usize = 0; +const SECOND_BASE: usize = 128; +const DUPLICATE_LOW_BASE: usize = 256; +const DUPLICATE_HIGH_BASE: usize = 384; +const SWAP_BASE: usize = 512; +const SELECT_BASE: usize = 640; +const REPACK_COLUMNS: usize = 768; + +/// Fixed lane transforms used by the degree-two extension lowering. +/// +/// For `first = [a,b]` and `second = [c,d]`, the outputs are +/// `[a,a]`, `[b,b]`, `[b,a]`, and `[a,d]`. +#[derive(Clone, Copy, Debug)] +pub(crate) struct GoldilocksLaneRepackGate { + pub(crate) nu: usize, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct GoldilocksLaneRepackRow { + first: F128, + second: F128, +} + +impl GateType for GoldilocksLaneRepackGate { + type Row = GoldilocksLaneRepackRow; + type Hint = (); + + fn table(&self) -> TableType { + TableType::from_block_r1cs(&build_lane_repack_r1cs(self.nu)).with_io_schema( + vec![ + IoWord::input(0), + IoWord::input(1), + IoWord::output(2), + IoWord::output(3), + IoWord::output(4), + IoWord::output(5), + ], + ) + } + + fn eval( + &self, + inputs: &[F128], + _hint: &(), + outputs: &mut Vec, + ) -> Self::Row { + let first = inputs[0]; + let second = inputs[1]; + outputs.extend_from_slice(&[ + F128::new(first.lo, first.lo), + F128::new(first.hi, first.hi), + F128::new(first.hi, first.lo), + F128::new(first.lo, second.hi), + ]); + GoldilocksLaneRepackRow { first, second } + } + + fn witness(&self, _rows: &[Self::Row], _nu: usize) -> SlotWitness { + SlotWitness::DeferredToRows + } +} + +pub(crate) fn build_lane_repack_r1cs(nu: usize) -> BlockR1cs { + build_lane_repack_plan().block_r1cs(nu) +} + +pub(crate) fn generate_lane_repack_witness( + rows: &[GoldilocksLaneRepackRow], + nu: usize, +) -> (Vec, Vec, Vec, Vec) { + let plan = build_lane_repack_plan(); + generate_boolean_witness(&plan, rows, nu, |row, bits| { + write_f128(bits, FIRST_BASE, row.first); + write_f128(bits, SECOND_BASE, row.second); + }) +} + +fn build_lane_repack_plan() -> BooleanR1csPlan { + let mut builder = BooleanR1csBuilder::new(REPACK_K_LOG, REPACK_COLUMNS); + for column in FIRST_BASE..SECOND_BASE + 128 { + builder.free_boolean_at(column); + } + for bit in 0..64 { + let first_low = FIRST_BASE + bit; + let first_high = FIRST_BASE + 64 + bit; + let second_high = SECOND_BASE + 64 + bit; + for output in [DUPLICATE_LOW_BASE + bit, DUPLICATE_LOW_BASE + 64 + bit] { + builder.write_product_of_parities(output, &[first_low], &[first_low]); + } + for output in [DUPLICATE_HIGH_BASE + bit, DUPLICATE_HIGH_BASE + 64 + bit] { + builder.write_product_of_parities(output, &[first_high], &[first_high]); + } + builder.write_product_of_parities( + SWAP_BASE + bit, + &[first_high], + &[first_high], + ); + builder.write_product_of_parities( + SWAP_BASE + 64 + bit, + &[first_low], + &[first_low], + ); + builder.write_product_of_parities( + SELECT_BASE + bit, + &[first_low], + &[first_low], + ); + builder.write_product_of_parities( + SELECT_BASE + 64 + bit, + &[second_high], + &[second_high], + ); + } + builder.finish() +} + +/// The four table slots and fixed zero wire needed by Goldilocks arithmetic. +pub(crate) struct GoldilocksCircuitSlots { + pub(crate) add: SlotId, + pub(crate) mul: SlotId, + pub(crate) canonical: SlotId, + pub(crate) repack: SlotId, + zero: Wire, +} + +impl GoldilocksCircuitSlots { + pub(crate) fn declare(builder: &mut ShapeBuilder, nu: usize) -> Self { + let add = builder.slot(GoldilocksAddPairGate { nu }); + let mul = builder.slot(GoldilocksMulPairGate { nu }); + let canonical = builder.slot(CanonicalGoldilocksPairGate { nu }); + let repack = builder.slot(GoldilocksLaneRepackGate { nu }); + let zero = builder.fixed_public_input(F128::ZERO); + Self { add, mul, canonical, repack, zero } + } + + pub(crate) fn assert_canonical( + &self, + builder: &mut ShapeBuilder, + value: Wire, + ) { + let violation = builder.gate(self.canonical, &[value])[0]; + builder.connect(violation, self.zero); + } + + pub(crate) fn add( + &self, + builder: &mut ShapeBuilder, + left: Wire, + right: Wire, + ) -> Wire { + let outputs = builder.gate(self.add, &[left, right]); + for &residual in &outputs[1..] { + builder.connect(residual, self.zero); + } + self.assert_canonical(builder, outputs[0]); + outputs[0] + } + + pub(crate) fn mul( + &self, + builder: &mut ShapeBuilder, + left: Wire, + right: Wire, + ) -> Wire { + let outputs = builder.gate(self.mul, &[left, right]); + for &residual in &outputs[1..] { + builder.connect(residual, self.zero); + } + self.assert_canonical(builder, outputs[0]); + outputs[0] + } + + /// Multiply two packed extension values in `Goldilocks[X]/(X^2 - 7)`. + pub(crate) fn ext2_mul( + &self, + builder: &mut ShapeBuilder, + left: Wire, + right: Wire, + ) -> Wire { + self.assert_canonical(builder, left); + self.assert_canonical(builder, right); + + let left_lanes = builder.gate(self.repack, &[left, self.zero]); + let products_low = self.mul(builder, left_lanes[0], right); + let products_high = self.mul(builder, left_lanes[1], right); + + let high_repacked = builder.gate(self.repack, &[products_high, self.zero]); + let reversed_high = high_repacked[2]; + let twice = self.add(builder, reversed_high, reversed_high); + let four_times = self.add(builder, twice, twice); + let six_times = self.add(builder, four_times, twice); + let seven_times = self.add(builder, six_times, reversed_high); + let selected = builder.gate(self.repack, &[seven_times, reversed_high])[3]; + self.add(builder, products_low, selected) + } + + /// Embed the low `u64` lane as the constant-coordinate element `[lo, 0]`. + pub(crate) fn embed_low_lane( + &self, + builder: &mut ShapeBuilder, + value: Wire, + ) -> Wire { + builder.gate(self.repack, &[value, self.zero])[3] + } + + /// Split `[c0, c1]` into the two base-coordinate embeddings `[c0, 0]` + /// and `[c1, 0]` used by the coordinate-expanded AIR constraints. + pub(crate) fn ext2_coordinates( + &self, + builder: &mut ShapeBuilder, + value: Wire, + ) -> [Wire; 2] { + let lanes = builder.gate(self.repack, &[value, self.zero]); + let low = lanes[3]; + let high_first = lanes[2]; + let high = builder.gate(self.repack, &[high_first, self.zero])[3]; + [low, high] + } +} + +pub(crate) fn goldilocks_ext2_mul(left: F128, right: F128) -> F128 { + F128::new( + crate::goldilocks::goldilocks_add( + goldilocks_mul(left.lo, right.lo), + goldilocks_mul(7, goldilocks_mul(left.hi, right.hi)), + ), + crate::goldilocks::goldilocks_add( + goldilocks_mul(left.lo, right.hi), + goldilocks_mul(left.hi, right.lo), + ), + ) +} + +#[cfg(test)] +mod tests { + use multi_stark::{ + p3_field::{ + BasedVectorSpace, PrimeCharacteristicRing, PrimeField64, + extension::BinomialExtensionField, + }, + p3_goldilocks::Goldilocks, + }; + + use super::*; + use crate::goldilocks::GOLDILOCKS_MODULUS; + + #[test] + fn native_ext2_mul_matches_plonky3() { + let cases = [ + ([0, 0], [0, 0]), + ([1, 0], [0, 1]), + ([GOLDILOCKS_MODULUS - 1, 17], [23, GOLDILOCKS_MODULUS - 2]), + ([0x1234_5678_9abc_def0, 0xfedc_ba98_7654_3210], [7, 11]), + ]; + for (left, right) in cases { + let reference = BinomialExtensionField::::new([ + Goldilocks::from_u64(left[0]), + Goldilocks::from_u64(left[1]), + ]) * BinomialExtensionField::::new([ + Goldilocks::from_u64(right[0]), + Goldilocks::from_u64(right[1]), + ]); + let reference: &[Goldilocks] = reference.as_basis_coefficients_slice(); + let actual = goldilocks_ext2_mul( + F128::new(left[0], left[1]), + F128::new(right[0], right[1]), + ); + assert_eq!(actual.lo, reference[0].as_canonical_u64()); + assert_eq!(actual.hi, reference[1].as_canonical_u64()); + } + } + + #[test] + fn lane_repack_r1cs_matches_gate_semantics() { + let row = GoldilocksLaneRepackRow { + first: F128::new(0x0123_4567_89ab_cdef, 0xfedc_ba98_7654_3210), + second: F128::new(9, 0x55aa_aa55_1234_5678), + }; + let plan = build_lane_repack_plan(); + let r1cs = plan.block_r1cs(3); + let mut logical = vec![false; plan.k()]; + plan.fill_row(&mut logical, |bits| { + write_f128(bits, FIRST_BASE, row.first); + write_f128(bits, SECOND_BASE, row.second); + }); + let mut witness = vec![false; r1cs.n()]; + witness[..plan.k()].copy_from_slice(&logical); + assert!(r1cs.satisfies(&witness)); + + let outputs = [ + F128::new(row.first.lo, row.first.lo), + F128::new(row.first.hi, row.first.hi), + F128::new(row.first.hi, row.first.lo), + F128::new(row.first.lo, row.second.hi), + ]; + for (index, output) in outputs.into_iter().enumerate() { + let mut encoded = vec![false; 128]; + write_f128(&mut encoded, 0, output); + assert_eq!( + &logical[DUPLICATE_LOW_BASE + index * 128 + ..DUPLICATE_LOW_BASE + (index + 1) * 128], + encoded + ); + } + } +} diff --git a/flock-stage3/host/src/fri.rs b/flock-stage3/host/src/fri.rs new file mode 100644 index 00000000..b0971535 --- /dev/null +++ b/flock-stage3/host/src/fri.rs @@ -0,0 +1,6525 @@ +//! One authenticated binary FRI fold lowered into the Flock relation. +//! +//! This is the first conformance artifact that composes proof semantics rather +//! than testing an isolated primitive. It reconstructs the ordered evaluation +//! pair from the query-index bit, hashes the four Goldilocks coordinates with +//! Plonky3's serialized BLAKE3 leaf convention, authenticates the leaf, derives +//! the bit-reversed subgroup point, and constrains the binary fold. +//! +//! Division is deliberately absent from the circuit. The usual equation +//! +//! `f = (e0 + e1)/2 + beta * (e0 - e1)/(2s)` +//! +//! is constrained in the equivalent denominator-free form +//! +//! `2s*f + beta*e1 = s*(e0 + e1) + beta*e0`. + +use ::blake3 as native_blake3; +use aiur::vk_codec::AiurVerifyingKey; +use anyhow::{Context, Result, bail}; +use bincode::Options; +use flock_prover::{ + challenger::FsChallenger, + circuit::builder::{CircuitShape, ShapeBuilder, SlotId, Wire}, + field::F128, + lincheck::LincheckCircuit, + pcs::Commitment, + proof::R1csProofCircuitMerged, + prover::{self, UnionSlotProverInput}, + r1cs_hashes::blake3 as flock_blake3, + union::UnionInstance, + verifier, +}; +use ix_terminal::{ + Stage2RootStatementV1, ValidatedStage2RootV1, fri_parameter_words, +}; +use multi_stark::{ + p3_field::{BasedVectorSpace, PrimeCharacteristicRing, PrimeField64}, + types::{ExtVal, FriParameters, Val}, +}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +use crate::{ + FRI_FOLD_CONFORMANCE_TRANSCRIPT_DOMAIN, + FRI_QUERY_CONFORMANCE_TRANSCRIPT_DOMAIN, FlockConfigV1, + PCS_REDUCTION_CONFORMANCE_TRANSCRIPT_DOMAIN, + STAGE2_AIR_PCS_FRI_CONFORMANCE_TRANSCRIPT_DOMAIN, Stage3TypedProofWitnessV1, + TRANSCRIPT_BOUND_FRI_CONFORMANCE_TRANSCRIPT_DOMAIN, + TRANSCRIPT_BOUND_FRI_QUERIES_CONFORMANCE_TRANSCRIPT_DOMAIN, + TRANSCRIPT_BOUND_PCS_CONFORMANCE_TRANSCRIPT_DOMAIN, + TRANSCRIPT_BOUND_PCS_FRI_QUERIES_CONFORMANCE_TRANSCRIPT_DOMAIN, + air::{Stage2AirProgramV1, constrain_stage2_air}, + binding::{ + Blake3Gate, CHUNK_END, CHUNK_START, IV, ROOT, pack_bytes, pack_params, + pack8, pcs_params, + }, + equality::{ + F128EqualityGate, build_f128_equality_r1cs, generate_f128_equality_witness, + }, + extension::{ + GoldilocksCircuitSlots, GoldilocksLaneRepackGate, build_lane_repack_r1cs, + generate_lane_repack_witness, + }, + goldilocks::{ + CanonicalGoldilocksPairGate, GOLDILOCKS_MODULUS, GoldilocksAddPairGate, + build_canonical_pair_r1cs, build_goldilocks_add_r1cs, + generate_canonical_pair_witness, generate_goldilocks_add_witness, + }, + merkle::{ + DigestOrderGate, build_digest_order_r1cs, generate_digest_order_witness, + }, + multiplication::{ + GoldilocksMulPairGate, build_goldilocks_mul_r1cs, + generate_goldilocks_mul_witness, goldilocks_mul, + }, + transcript::{ + FriTranscriptCircuitSlots, GoldilocksSampleGate, HashSampleGate, + Stage2FriTranscriptChallengesV1, Stage2FriTranscriptReplayV1, + Stage2TranscriptByteBindingV1, Stage2TranscriptReplayV1, + Stage2TranscriptSegmentV1, TranscriptCircuitSlots, U64SplitGate, + build_goldilocks_sample_r1cs, build_hash_sample_r1cs, build_u64_split_r1cs, + constrain_hash, constrain_stage2_fri_transcript, + constrain_stage2_transcript, fri_transcript_blake3_rows, + fri_transcript_split_rows, generate_goldilocks_sample_witness, + generate_hash_sample_witness, generate_u64_split_witness, hash_trace, + transcript_challenge_words, transcript_nu, + }, + window::{ + ByteWindowGate, build_byte_window_r1cs, generate_byte_window_witness, + }, +}; + +pub const FRI_FOLD_CONFORMANCE_ARTIFACT_MAGIC: &[u8; 8] = b"IXFLKFR1"; +pub const FRI_COMMIT_PHASE_CONFORMANCE_ARTIFACT_MAGIC: &[u8; 8] = b"IXFLFQ01"; +pub const PCS_REDUCTION_CONFORMANCE_ARTIFACT_MAGIC: &[u8; 8] = b"IXFLPR01"; +const ARTIFACT_VERSION: u16 = 1; +const CONFIG_OFFSET: usize = 10; +const LOG_HEIGHT_OFFSET: usize = CONFIG_OFFSET + 32; +const QUERY_INDEX_OFFSET: usize = LOG_HEIGHT_OFFSET + 1; +const FOLDED_OFFSET: usize = QUERY_INDEX_OFFSET + 4; +const SIBLING_OFFSET: usize = FOLDED_OFFSET + 16; +const BETA_OFFSET: usize = SIBLING_OFFSET + 16; +const RESULT_OFFSET: usize = BETA_OFFSET + 16; +const PATH_OFFSET: usize = RESULT_OFFSET + 16; +const FIXED_SUFFIX_BYTES: usize = 32 + 32 + 8; +const MAX_BUNDLE_BYTES: usize = 64 * 1024 * 1024; +const MIN_LOG_HEIGHT: u8 = 1; +const MAX_LOG_HEIGHT: u8 = 31; +const MAX_COMMIT_PHASE_ROUNDS: usize = 8; +const MAX_REDUCED_OPENING_WIDTH: usize = 1 << 16; +// The arithmetic slots need enough rows for the bit-reversed exponentiation +// at the maximum supported height. This also keeps every table in a Flock +// Fast128 geometry exercised by the existing conformance proofs. +const NU: usize = 10; + +/// The witness values consumed by one binary FRI commit-phase opening. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct FriFoldQueryV1 { + /// Height of the folded row (the original query has one additional bit). + pub log_height: u8, + /// Original FRI query index. Bit zero chooses the evaluation within the + /// pair; bits `1..=log_height` authenticate the pair and derive `s`. + pub query_index: u32, + pub folded: [u64; 2], + pub sibling: [u64; 2], + pub beta: [u64; 2], + /// Cap-height-zero authentication path for the row `[e0, e1]`. + pub opening_proof: Vec<[u8; 32]>, +} + +impl FriFoldQueryV1 { + pub fn folded_result(&self) -> Result<[u64; 2]> { + validate_query(self)?; + Ok(native_fold(self)) + } + + pub fn commitment_root(&self) -> Result<[u8; 32]> { + validate_query(self)?; + Ok(native_root(self)) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct FriCommitPhaseRoundV1 { + pub sibling: [u64; 2], + pub beta: [u64; 2], + /// Reduced opening at this folded height, if one is scheduled. It is rolled + /// in as `beta^2 * reduced_opening` after the binary fold. + pub reduced_opening: Option<[u64; 2]>, + pub opening_proof: Vec<[u8; 32]>, +} + +/// A complete binary FRI commit-phase fold chain for one sampled query. +/// +/// This intentionally excludes reduced-opening roll-ins and transcript replay; +/// those are separate semantic slices. Each round consumes the next low query +/// bit, authenticates its extension pair, and feeds its constrained result +/// directly into the following round. The last result must equal the constant +/// final polynomial. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct FriCommitPhaseQueryV1 { + /// Folded height of the first round. Round `r` has height + /// `initial_log_height - r`. + pub initial_log_height: u8, + pub query_index: u32, + pub initial_folded: [u64; 2], + pub rounds: Vec, + pub final_polynomial: [u64; 2], +} + +impl FriCommitPhaseQueryV1 { + pub fn commitment_roots(&self) -> Result> { + let computation = compute_commit_phase(self)?; + ensure_final_polynomial(self, &computation)?; + Ok(computation.roots) + } + + pub fn folded_results(&self) -> Result> { + let computation = compute_commit_phase(self)?; + ensure_final_polynomial(self, &computation)?; + Ok(computation.results) + } +} + +/// One authenticated PCS row reduced into the FRI accumulator. +/// +/// The conformance circuit supports one BLAKE3 leaf block (at most eight +/// Goldilocks values). Wider rows will use the same arithmetic but require the +/// multi-block/tree leaf hasher before the production relation is complete. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PcsReducedOpeningV1 { + pub log_height: u8, + pub query_index: u32, + pub opened_values: Vec, + pub opened_at_z: Vec<[u64; 2]>, + pub zeta: [u64; 2], + pub alpha: [u64; 2], + pub initial_alpha_power: [u64; 2], + pub initial_accumulator: [u64; 2], + pub opening_proof: Vec<[u8; 32]>, +} + +impl PcsReducedOpeningV1 { + pub fn reduced_accumulator(&self) -> Result<[u64; 2]> { + Ok(compute_pcs_reduction(self)?.accumulator) + } + + pub fn next_alpha_power(&self) -> Result<[u64; 2]> { + Ok(compute_pcs_reduction(self)?.alpha_power) + } + + pub fn commitment_root(&self) -> Result<[u8; 32]> { + Ok(compute_pcs_reduction(self)?.root) + } +} + +/// An opening point used by the specialised Stage 2 PCS verifier. +/// +/// Stage 1, Stage 2, and preprocessed matrices are opened at both `zeta` and +/// `zeta * g`, while quotient matrices are opened only at `zeta`. Keeping the +/// point derivation in the relation prevents the prover from supplying a +/// second, unbound point. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Stage2PcsOpeningPointV1 { + Zeta, + ZetaNext { log_degree: u8 }, +} + +/// Verifier-known metadata for one matrix in a Stage 2 input commitment. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Stage2PcsMatrixV1 { + /// Log2 of the LDE matrix height, including the FRI blowup. + pub log_height: u8, + /// Number of base-field columns in the authenticated row. + pub width: usize, + /// Opening points in the exact PCS batching order. + pub opening_points: Vec, + /// First `u64` lane of this matrix's contiguous extension-valued OOD + /// openings in the transcript's PCS-opening observation segment. + pub opened_values: Stage2TranscriptByteBindingV1, +} + +/// One multi-matrix MMCS commitment used as a PCS input batch. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Stage2PcsBatchV1 { + /// First `u64` lane of the 32-byte cap-height-zero commitment root in the + /// constrained transcript prefix. + pub commitment: Stage2TranscriptByteBindingV1, + pub matrices: Vec, +} + +/// Shared, verifier-known PCS instance for all sampled FRI queries. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Stage2PcsInstanceV1 { + pub log_global_height: u8, + pub log_blowup: u8, + pub batches: Vec, +} + +/// Per-query rows and a legacy full Merkle path for one input batch. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Stage2PcsBatchOpeningV1 { + pub opened_rows: Vec>, + pub opening_proof: Vec<[u8; 32]>, +} + +/// Every input-batch opening belonging to one transcript-derived query. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Stage2PcsQueryV1 { + pub batch_openings: Vec, +} + +/// One query's authenticated PCS input followed by its FRI commit-phase +/// opening chain. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TranscriptBoundPcsFriQueryV1 { + pub pcs: Stage2PcsQueryV1, + pub fri: FriCommitPhaseQueryV1, +} + +/// Exact typed Stage 2 PCS/FRI witness prepared for the combined Flock +/// relation. Commitment and OOD bindings point into `prefix`; query indices +/// and betas come from `fri_transcript`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Stage2PcsFriWitnessV1 { + pub prefix: Stage2TranscriptReplayV1, + pub fri_transcript: Stage2FriTranscriptReplayV1, + pub pcs_instance: Stage2PcsInstanceV1, + pub queries: Vec, +} + +impl Stage2PcsFriWitnessV1 { + pub fn from_prepared( + prepared: &ValidatedStage2RootV1, + fri: &FriParameters, + ) -> Result { + let typed = Stage3TypedProofWitnessV1::from_prepared(prepared, fri)?; + Self::from_prepared_and_typed(prepared, fri, &typed) + } + + pub fn from_prepared_and_typed( + prepared: &ValidatedStage2RootV1, + fri: &FriParameters, + typed: &Stage3TypedProofWitnessV1, + ) -> Result { + build_stage2_pcs_fri_witness(prepared, fri, typed) + } +} + +/// All currently lowered Stage 2 verifier semantics: compiled AIR/logUp OOD +/// evaluation plus transcript-bound PCS and every FRI query. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Stage2AirPcsFriWitnessV1 { + pub pcs_fri: Stage2PcsFriWitnessV1, + pub air: Stage2AirProgramV1, +} + +impl Stage2AirPcsFriWitnessV1 { + pub fn from_prepared( + prepared: &ValidatedStage2RootV1, + fri: &FriParameters, + ) -> Result { + let typed = Stage3TypedProofWitnessV1::from_prepared(prepared, fri)?; + let pcs_fri = + Stage2PcsFriWitnessV1::from_prepared_and_typed(prepared, fri, &typed)?; + let air = Stage2AirProgramV1::from_prepared_and_typed( + prepared, + fri, + &pcs_fri.pcs_instance, + &typed, + )?; + Ok(Self { pcs_fri, air }) + } +} + +/// A real Flock proof of an authenticated Plonky3-compatible binary FRI fold. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct FriFoldConformanceArtifactV1 { + query: FriFoldQueryV1, + folded_result: [u64; 2], + circuit_digest: [u8; 32], + commitment_root: [u8; 32], + proof_bundle_bytes: Vec, +} + +impl FriFoldConformanceArtifactV1 { + pub fn to_bytes(&self) -> Vec { + let mut bytes = Vec::with_capacity( + PATH_OFFSET + + 32 * self.query.opening_proof.len() + + FIXED_SUFFIX_BYTES + + self.proof_bundle_bytes.len(), + ); + bytes.extend_from_slice(FRI_FOLD_CONFORMANCE_ARTIFACT_MAGIC); + bytes.extend_from_slice(&ARTIFACT_VERSION.to_le_bytes()); + bytes.extend_from_slice(&FlockConfigV1.digest()); + bytes.push(self.query.log_height); + bytes.extend_from_slice(&self.query.query_index.to_le_bytes()); + encode_extension(&mut bytes, self.query.folded); + encode_extension(&mut bytes, self.query.sibling); + encode_extension(&mut bytes, self.query.beta); + encode_extension(&mut bytes, self.folded_result); + for sibling in &self.query.opening_proof { + bytes.extend_from_slice(sibling); + } + bytes.extend_from_slice(&self.circuit_digest); + bytes.extend_from_slice(&self.commitment_root); + bytes.extend_from_slice( + &u64::try_from(self.proof_bundle_bytes.len()) + .expect("proof bundle length") + .to_le_bytes(), + ); + bytes.extend_from_slice(&self.proof_bundle_bytes); + bytes + } + + pub fn from_bytes(bytes: &[u8]) -> Result { + if bytes.len() < PATH_OFFSET + FIXED_SUFFIX_BYTES { + bail!("truncated Flock FRI-fold conformance artifact"); + } + if &bytes[..8] != FRI_FOLD_CONFORMANCE_ARTIFACT_MAGIC { + bail!("invalid Flock FRI-fold conformance artifact magic"); + } + let version = u16::from_le_bytes(bytes[8..10].try_into().unwrap()); + if version != ARTIFACT_VERSION { + bail!("unsupported Flock FRI-fold artifact version {version}"); + } + if bytes[CONFIG_OFFSET..LOG_HEIGHT_OFFSET] != FlockConfigV1.digest() { + bail!("Flock FRI-fold artifact configuration mismatch"); + } + let log_height = bytes[LOG_HEIGHT_OFFSET]; + validate_log_height(log_height)?; + let query_index = u32::from_le_bytes( + bytes[QUERY_INDEX_OFFSET..FOLDED_OFFSET].try_into().unwrap(), + ); + let folded = decode_extension(&bytes[FOLDED_OFFSET..SIBLING_OFFSET]); + let sibling = decode_extension(&bytes[SIBLING_OFFSET..BETA_OFFSET]); + let beta = decode_extension(&bytes[BETA_OFFSET..RESULT_OFFSET]); + let folded_result = decode_extension(&bytes[RESULT_OFFSET..PATH_OFFSET]); + let path_end = PATH_OFFSET + .checked_add(usize::from(log_height) * 32) + .ok_or_else(|| anyhow::anyhow!("FRI-fold path length overflow"))?; + let suffix_end = path_end + .checked_add(FIXED_SUFFIX_BYTES) + .ok_or_else(|| anyhow::anyhow!("FRI-fold artifact length overflow"))?; + if bytes.len() < suffix_end { + bail!("truncated Flock FRI-fold path or proof header"); + } + let opening_proof = + bytes[PATH_OFFSET..path_end].as_chunks::<32>().0.to_vec(); + let query = FriFoldQueryV1 { + log_height, + query_index, + folded, + sibling, + beta, + opening_proof, + }; + validate_query(&query)?; + validate_extension(folded_result, "folded result")?; + let mut circuit_digest = [0u8; 32]; + circuit_digest.copy_from_slice(&bytes[path_end..path_end + 32]); + let mut commitment_root = [0u8; 32]; + commitment_root.copy_from_slice(&bytes[path_end + 32..path_end + 64]); + let bundle_len = usize::try_from(u64::from_le_bytes( + bytes[path_end + 64..suffix_end].try_into().unwrap(), + )) + .map_err(|error| { + anyhow::anyhow!("proof bundle length does not fit usize: {error}") + })?; + if bundle_len == 0 || bundle_len > MAX_BUNDLE_BYTES { + bail!("invalid Flock FRI-fold proof bundle length {bundle_len}"); + } + let expected_len = suffix_end + .checked_add(bundle_len) + .ok_or_else(|| anyhow::anyhow!("FRI-fold proof length overflow"))?; + if bytes.len() != expected_len { + bail!( + "Flock FRI-fold artifact is {} bytes; header declares {expected_len}", + bytes.len() + ); + } + let proof_bundle_bytes = bytes[suffix_end..].to_vec(); + decode_bundle(&proof_bundle_bytes) + .context("decode Flock FRI-fold conformance proof bundle")?; + Ok(Self { + query, + folded_result, + circuit_digest, + commitment_root, + proof_bundle_bytes, + }) + } + + pub fn query(&self) -> &FriFoldQueryV1 { + &self.query + } + + pub fn folded_result(&self) -> [u64; 2] { + self.folded_result + } + + pub fn circuit_digest(&self) -> &[u8; 32] { + &self.circuit_digest + } + + pub fn commitment_root(&self) -> &[u8; 32] { + &self.commitment_root + } + + pub fn proof_bundle_bytes(&self) -> &[u8] { + &self.proof_bundle_bytes + } +} + +const COMMIT_PHASE_ROUND_COUNT_OFFSET: usize = LOG_HEIGHT_OFFSET + 1; +const COMMIT_PHASE_QUERY_INDEX_OFFSET: usize = + COMMIT_PHASE_ROUND_COUNT_OFFSET + 1; +const COMMIT_PHASE_INITIAL_OFFSET: usize = COMMIT_PHASE_QUERY_INDEX_OFFSET + 4; +const COMMIT_PHASE_FINAL_OFFSET: usize = COMMIT_PHASE_INITIAL_OFFSET + 16; +const COMMIT_PHASE_ROUNDS_OFFSET: usize = COMMIT_PHASE_FINAL_OFFSET + 16; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct FriCommitPhaseConformanceArtifactV1 { + query: FriCommitPhaseQueryV1, + circuit_digest: [u8; 32], + commitment_roots: Vec<[u8; 32]>, + proof_bundle_bytes: Vec, +} + +impl FriCommitPhaseConformanceArtifactV1 { + pub fn to_bytes(&self) -> Vec { + let round_bytes = self + .query + .rounds + .iter() + .map(|round| 49 + round.opening_proof.len() * 32) + .sum::(); + let mut bytes = Vec::with_capacity( + COMMIT_PHASE_ROUNDS_OFFSET + + round_bytes + + 32 + + self.commitment_roots.len() * 32 + + 8 + + self.proof_bundle_bytes.len(), + ); + bytes.extend_from_slice(FRI_COMMIT_PHASE_CONFORMANCE_ARTIFACT_MAGIC); + bytes.extend_from_slice(&ARTIFACT_VERSION.to_le_bytes()); + bytes.extend_from_slice(&FlockConfigV1.digest()); + bytes.push(self.query.initial_log_height); + bytes.push(u8::try_from(self.query.rounds.len()).expect("FRI round count")); + bytes.extend_from_slice(&self.query.query_index.to_le_bytes()); + encode_extension(&mut bytes, self.query.initial_folded); + encode_extension(&mut bytes, self.query.final_polynomial); + for round in &self.query.rounds { + encode_extension(&mut bytes, round.sibling); + encode_extension(&mut bytes, round.beta); + bytes.push(u8::from(round.reduced_opening.is_some())); + encode_extension(&mut bytes, round.reduced_opening.unwrap_or([0, 0])); + for sibling in &round.opening_proof { + bytes.extend_from_slice(sibling); + } + } + bytes.extend_from_slice(&self.circuit_digest); + for root in &self.commitment_roots { + bytes.extend_from_slice(root); + } + bytes.extend_from_slice( + &u64::try_from(self.proof_bundle_bytes.len()) + .expect("proof bundle length") + .to_le_bytes(), + ); + bytes.extend_from_slice(&self.proof_bundle_bytes); + bytes + } + + pub fn from_bytes(bytes: &[u8]) -> Result { + if bytes.len() < COMMIT_PHASE_ROUNDS_OFFSET + 32 + 32 + 8 { + bail!("truncated Flock FRI commit-phase conformance artifact"); + } + if &bytes[..8] != FRI_COMMIT_PHASE_CONFORMANCE_ARTIFACT_MAGIC { + bail!("invalid Flock FRI commit-phase artifact magic"); + } + let version = u16::from_le_bytes(bytes[8..10].try_into().unwrap()); + if version != ARTIFACT_VERSION { + bail!("unsupported Flock FRI commit-phase artifact version {version}"); + } + if bytes[CONFIG_OFFSET..LOG_HEIGHT_OFFSET] != FlockConfigV1.digest() { + bail!("Flock FRI commit-phase artifact configuration mismatch"); + } + let initial_log_height = bytes[LOG_HEIGHT_OFFSET]; + validate_log_height(initial_log_height)?; + let round_count = usize::from(bytes[COMMIT_PHASE_ROUND_COUNT_OFFSET]); + validate_commit_phase_round_count(initial_log_height, round_count)?; + let query_index = u32::from_le_bytes( + bytes[COMMIT_PHASE_QUERY_INDEX_OFFSET..COMMIT_PHASE_INITIAL_OFFSET] + .try_into() + .unwrap(), + ); + let initial_folded = decode_extension( + &bytes[COMMIT_PHASE_INITIAL_OFFSET..COMMIT_PHASE_FINAL_OFFSET], + ); + let final_polynomial = decode_extension( + &bytes[COMMIT_PHASE_FINAL_OFFSET..COMMIT_PHASE_ROUNDS_OFFSET], + ); + let rounds_bytes = + commit_phase_rounds_bytes(initial_log_height, round_count)?; + let rounds_end = COMMIT_PHASE_ROUNDS_OFFSET + .checked_add(rounds_bytes) + .ok_or_else(|| anyhow::anyhow!("FRI commit-phase rounds overflow"))?; + let suffix_len = 32usize + .checked_add(round_count * 32) + .and_then(|length| length.checked_add(8)) + .ok_or_else(|| anyhow::anyhow!("FRI commit-phase suffix overflow"))?; + let suffix_end = rounds_end + .checked_add(suffix_len) + .ok_or_else(|| anyhow::anyhow!("FRI commit-phase artifact overflow"))?; + if bytes.len() < suffix_end { + bail!("truncated Flock FRI commit-phase rounds or proof header"); + } + + let mut cursor = COMMIT_PHASE_ROUNDS_OFFSET; + let mut rounds = Vec::with_capacity(round_count); + for round_index in 0..round_count { + let log_height = usize::from(initial_log_height) - round_index; + let sibling = decode_extension(&bytes[cursor..cursor + 16]); + cursor += 16; + let beta = decode_extension(&bytes[cursor..cursor + 16]); + cursor += 16; + let has_reduced_opening = bytes[cursor]; + cursor += 1; + if has_reduced_opening > 1 { + bail!( + "FRI commit-phase round {round_index} has invalid roll-in flag {has_reduced_opening}" + ); + } + let encoded_reduced_opening = + decode_extension(&bytes[cursor..cursor + 16]); + cursor += 16; + let reduced_opening = if has_reduced_opening == 1 { + Some(encoded_reduced_opening) + } else { + if encoded_reduced_opening != [0, 0] { + bail!("absent FRI reduced opening has nonzero encoding"); + } + None + }; + let path_end = cursor + log_height * 32; + let opening_proof = bytes[cursor..path_end].as_chunks::<32>().0.to_vec(); + cursor = path_end; + rounds.push(FriCommitPhaseRoundV1 { + sibling, + beta, + reduced_opening, + opening_proof, + }); + } + debug_assert_eq!(cursor, rounds_end); + let query = FriCommitPhaseQueryV1 { + initial_log_height, + query_index, + initial_folded, + rounds, + final_polynomial, + }; + let computation = compute_commit_phase(&query)?; + ensure_final_polynomial(&query, &computation)?; + + let mut circuit_digest = [0u8; 32]; + circuit_digest.copy_from_slice(&bytes[rounds_end..rounds_end + 32]); + let roots_end = rounds_end + 32 + round_count * 32; + let commitment_roots = + bytes[rounds_end + 32..roots_end].as_chunks::<32>().0.to_vec(); + let bundle_len = usize::try_from(u64::from_le_bytes( + bytes[roots_end..suffix_end].try_into().unwrap(), + )) + .map_err(|error| { + anyhow::anyhow!("proof bundle length does not fit usize: {error}") + })?; + if bundle_len == 0 || bundle_len > MAX_BUNDLE_BYTES { + bail!("invalid Flock FRI commit-phase proof length {bundle_len}"); + } + let expected_len = suffix_end + .checked_add(bundle_len) + .ok_or_else(|| anyhow::anyhow!("FRI commit-phase proof overflow"))?; + if bytes.len() != expected_len { + bail!( + "Flock FRI commit-phase artifact is {} bytes; header declares {expected_len}", + bytes.len() + ); + } + let proof_bundle_bytes = bytes[suffix_end..].to_vec(); + decode_bundle(&proof_bundle_bytes) + .context("decode Flock FRI commit-phase proof bundle")?; + Ok(Self { query, circuit_digest, commitment_roots, proof_bundle_bytes }) + } + + pub fn query(&self) -> &FriCommitPhaseQueryV1 { + &self.query + } + + pub fn circuit_digest(&self) -> &[u8; 32] { + &self.circuit_digest + } + + pub fn commitment_roots(&self) -> &[[u8; 32]] { + &self.commitment_roots + } + + pub fn proof_bundle_bytes(&self) -> &[u8] { + &self.proof_bundle_bytes + } +} + +const PCS_WIDTH_OFFSET: usize = LOG_HEIGHT_OFFSET + 1; +const PCS_QUERY_INDEX_OFFSET: usize = PCS_WIDTH_OFFSET + 1; +const PCS_ZETA_OFFSET: usize = PCS_QUERY_INDEX_OFFSET + 4; +const PCS_ALPHA_OFFSET: usize = PCS_ZETA_OFFSET + 16; +const PCS_INITIAL_ALPHA_POWER_OFFSET: usize = PCS_ALPHA_OFFSET + 16; +const PCS_INITIAL_ACCUMULATOR_OFFSET: usize = + PCS_INITIAL_ALPHA_POWER_OFFSET + 16; +const PCS_REDUCED_ACCUMULATOR_OFFSET: usize = + PCS_INITIAL_ACCUMULATOR_OFFSET + 16; +const PCS_NEXT_ALPHA_POWER_OFFSET: usize = PCS_REDUCED_ACCUMULATOR_OFFSET + 16; +const PCS_DYNAMIC_OFFSET: usize = PCS_NEXT_ALPHA_POWER_OFFSET + 16; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PcsReductionConformanceArtifactV1 { + opening: PcsReducedOpeningV1, + reduced_accumulator: [u64; 2], + next_alpha_power: [u64; 2], + circuit_digest: [u8; 32], + commitment_root: [u8; 32], + proof_bundle_bytes: Vec, +} + +impl PcsReductionConformanceArtifactV1 { + pub fn to_bytes(&self) -> Vec { + let width = self.opening.opened_values.len(); + let dynamic_bytes = + width * 8 + width * 16 + self.opening.opening_proof.len() * 32; + let mut bytes = Vec::with_capacity( + PCS_DYNAMIC_OFFSET + + dynamic_bytes + + FIXED_SUFFIX_BYTES + + self.proof_bundle_bytes.len(), + ); + bytes.extend_from_slice(PCS_REDUCTION_CONFORMANCE_ARTIFACT_MAGIC); + bytes.extend_from_slice(&ARTIFACT_VERSION.to_le_bytes()); + bytes.extend_from_slice(&FlockConfigV1.digest()); + bytes.push(self.opening.log_height); + bytes.push(u8::try_from(width).expect("PCS row width")); + bytes.extend_from_slice(&self.opening.query_index.to_le_bytes()); + encode_extension(&mut bytes, self.opening.zeta); + encode_extension(&mut bytes, self.opening.alpha); + encode_extension(&mut bytes, self.opening.initial_alpha_power); + encode_extension(&mut bytes, self.opening.initial_accumulator); + encode_extension(&mut bytes, self.reduced_accumulator); + encode_extension(&mut bytes, self.next_alpha_power); + for value in &self.opening.opened_values { + bytes.extend_from_slice(&value.to_le_bytes()); + } + for value in &self.opening.opened_at_z { + encode_extension(&mut bytes, *value); + } + for sibling in &self.opening.opening_proof { + bytes.extend_from_slice(sibling); + } + bytes.extend_from_slice(&self.circuit_digest); + bytes.extend_from_slice(&self.commitment_root); + bytes.extend_from_slice( + &u64::try_from(self.proof_bundle_bytes.len()) + .expect("proof bundle length") + .to_le_bytes(), + ); + bytes.extend_from_slice(&self.proof_bundle_bytes); + bytes + } + + pub fn from_bytes(bytes: &[u8]) -> Result { + if bytes.len() < PCS_DYNAMIC_OFFSET + FIXED_SUFFIX_BYTES { + bail!("truncated Flock PCS-reduction conformance artifact"); + } + if &bytes[..8] != PCS_REDUCTION_CONFORMANCE_ARTIFACT_MAGIC { + bail!("invalid Flock PCS-reduction artifact magic"); + } + let version = u16::from_le_bytes(bytes[8..10].try_into().unwrap()); + if version != ARTIFACT_VERSION { + bail!("unsupported Flock PCS-reduction artifact version {version}"); + } + if bytes[CONFIG_OFFSET..LOG_HEIGHT_OFFSET] != FlockConfigV1.digest() { + bail!("Flock PCS-reduction artifact configuration mismatch"); + } + let log_height = bytes[LOG_HEIGHT_OFFSET]; + validate_log_height(log_height)?; + let width = usize::from(bytes[PCS_WIDTH_OFFSET]); + validate_reduced_opening_width(width)?; + let query_index = u32::from_le_bytes( + bytes[PCS_QUERY_INDEX_OFFSET..PCS_ZETA_OFFSET].try_into().unwrap(), + ); + let zeta = decode_extension(&bytes[PCS_ZETA_OFFSET..PCS_ALPHA_OFFSET]); + let alpha = decode_extension( + &bytes[PCS_ALPHA_OFFSET..PCS_INITIAL_ALPHA_POWER_OFFSET], + ); + let initial_alpha_power = decode_extension( + &bytes[PCS_INITIAL_ALPHA_POWER_OFFSET..PCS_INITIAL_ACCUMULATOR_OFFSET], + ); + let initial_accumulator = decode_extension( + &bytes[PCS_INITIAL_ACCUMULATOR_OFFSET..PCS_REDUCED_ACCUMULATOR_OFFSET], + ); + let reduced_accumulator = decode_extension( + &bytes[PCS_REDUCED_ACCUMULATOR_OFFSET..PCS_NEXT_ALPHA_POWER_OFFSET], + ); + let next_alpha_power = + decode_extension(&bytes[PCS_NEXT_ALPHA_POWER_OFFSET..PCS_DYNAMIC_OFFSET]); + let opened_values_end = PCS_DYNAMIC_OFFSET + .checked_add(width * 8) + .ok_or_else(|| anyhow::anyhow!("PCS opened-values length overflow"))?; + let opened_at_z_end = opened_values_end + .checked_add(width * 16) + .ok_or_else(|| anyhow::anyhow!("PCS OOD-values length overflow"))?; + let path_end = opened_at_z_end + .checked_add(usize::from(log_height) * 32) + .ok_or_else(|| anyhow::anyhow!("PCS Merkle path length overflow"))?; + let suffix_end = path_end + .checked_add(FIXED_SUFFIX_BYTES) + .ok_or_else(|| anyhow::anyhow!("PCS artifact length overflow"))?; + if bytes.len() < suffix_end { + bail!("truncated Flock PCS values, path, or proof header"); + } + let opened_values = bytes[PCS_DYNAMIC_OFFSET..opened_values_end] + .as_chunks::<8>() + .0 + .iter() + .map(|word| u64::from_le_bytes(*word)) + .collect(); + let opened_at_z = bytes[opened_values_end..opened_at_z_end] + .as_chunks::<16>() + .0 + .iter() + .map(|value| decode_extension(value)) + .collect(); + let opening_proof = + bytes[opened_at_z_end..path_end].as_chunks::<32>().0.to_vec(); + let opening = PcsReducedOpeningV1 { + log_height, + query_index, + opened_values, + opened_at_z, + zeta, + alpha, + initial_alpha_power, + initial_accumulator, + opening_proof, + }; + let computation = compute_pcs_reduction(&opening)?; + validate_extension(reduced_accumulator, "reduced accumulator")?; + validate_extension(next_alpha_power, "next alpha power")?; + + let mut circuit_digest = [0u8; 32]; + circuit_digest.copy_from_slice(&bytes[path_end..path_end + 32]); + let mut commitment_root = [0u8; 32]; + commitment_root.copy_from_slice(&bytes[path_end + 32..path_end + 64]); + let bundle_len = usize::try_from(u64::from_le_bytes( + bytes[path_end + 64..suffix_end].try_into().unwrap(), + )) + .map_err(|error| { + anyhow::anyhow!("proof bundle length does not fit usize: {error}") + })?; + if bundle_len == 0 || bundle_len > MAX_BUNDLE_BYTES { + bail!("invalid Flock PCS-reduction proof length {bundle_len}"); + } + let expected_len = suffix_end + .checked_add(bundle_len) + .ok_or_else(|| anyhow::anyhow!("PCS-reduction proof overflow"))?; + if bytes.len() != expected_len { + bail!( + "Flock PCS-reduction artifact is {} bytes; header declares {expected_len}", + bytes.len() + ); + } + let proof_bundle_bytes = bytes[suffix_end..].to_vec(); + decode_bundle(&proof_bundle_bytes) + .context("decode Flock PCS-reduction proof bundle")?; + if reduced_accumulator != computation.accumulator + || next_alpha_power != computation.alpha_power + || commitment_root != computation.root + { + bail!("Flock PCS-reduction artifact carries inconsistent native outputs"); + } + Ok(Self { + opening, + reduced_accumulator, + next_alpha_power, + circuit_digest, + commitment_root, + proof_bundle_bytes, + }) + } + + pub fn opening(&self) -> &PcsReducedOpeningV1 { + &self.opening + } + + pub fn reduced_accumulator(&self) -> [u64; 2] { + self.reduced_accumulator + } + + pub fn next_alpha_power(&self) -> [u64; 2] { + self.next_alpha_power + } + + pub fn circuit_digest(&self) -> &[u8; 32] { + &self.circuit_digest + } + + pub fn commitment_root(&self) -> &[u8; 32] { + &self.commitment_root + } + + pub fn proof_bundle_bytes(&self) -> &[u8] { + &self.proof_bundle_bytes + } +} + +/// One Flock proof in which the exact Stage 2 BLAKE3 transcript directly +/// supplies zeta and the PCS opening-batch challenge to an authenticated +/// reduced-opening check. +/// +/// This is the first composed semantic slice: changing any transcript byte +/// changes the wires used by the PCS arithmetic inside the same circuit. It +/// remains a conformance artifact, not the complete Stage 3 proof. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TranscriptBoundPcsReductionArtifactV1 { + replay: Stage2TranscriptReplayV1, + opening: PcsReducedOpeningV1, + reduced_accumulator: [u64; 2], + next_alpha_power: [u64; 2], + circuit_digest: [u8; 32], + commitment_root: [u8; 32], + proof_bundle_bytes: Vec, +} + +impl TranscriptBoundPcsReductionArtifactV1 { + pub fn replay(&self) -> &Stage2TranscriptReplayV1 { + &self.replay + } + + pub fn opening(&self) -> &PcsReducedOpeningV1 { + &self.opening + } + + pub fn reduced_accumulator(&self) -> [u64; 2] { + self.reduced_accumulator + } + + pub fn next_alpha_power(&self) -> [u64; 2] { + self.next_alpha_power + } + + pub fn circuit_digest(&self) -> &[u8; 32] { + &self.circuit_digest + } + + pub fn commitment_root(&self) -> &[u8; 32] { + &self.commitment_root + } + + pub fn proof_bundle_bytes(&self) -> &[u8] { + &self.proof_bundle_bytes + } +} + +/// One Flock proof that continues the exact Stage 2 transcript through FRI, +/// then uses a transcript-derived query index and folding challenges to check +/// one complete authenticated binary commit-phase chain. +/// +/// Cap roots and the final polynomial are consumed from the same transcript +/// wires used by the fold relation. This is still a conformance slice: a full +/// Stage 3 proof must check every sampled query and all PCS reduced openings. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TranscriptBoundFriCommitPhaseArtifactV1 { + prefix: Stage2TranscriptReplayV1, + fri_transcript: Stage2FriTranscriptReplayV1, + query_number: usize, + query: FriCommitPhaseQueryV1, + circuit_digest: [u8; 32], + commitment_roots: Vec<[u8; 32]>, + proof_bundle_bytes: Vec, +} + +impl TranscriptBoundFriCommitPhaseArtifactV1 { + pub fn prefix(&self) -> &Stage2TranscriptReplayV1 { + &self.prefix + } + + pub fn fri_transcript(&self) -> &Stage2FriTranscriptReplayV1 { + &self.fri_transcript + } + + pub const fn query_number(&self) -> usize { + self.query_number + } + + pub fn query(&self) -> &FriCommitPhaseQueryV1 { + &self.query + } + + pub fn circuit_digest(&self) -> &[u8; 32] { + &self.circuit_digest + } + + pub fn commitment_roots(&self) -> &[[u8; 32]] { + &self.commitment_roots + } + + pub fn proof_bundle_bytes(&self) -> &[u8] { + &self.proof_bundle_bytes + } +} + +/// One Flock proof of every transcript-derived FRI query. All queries share +/// one constrained transcript, beta vector, cap set, and final polynomial. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TranscriptBoundFriQueriesArtifactV1 { + prefix: Stage2TranscriptReplayV1, + fri_transcript: Stage2FriTranscriptReplayV1, + queries: Vec, + circuit_digest: [u8; 32], + proof_bundle_bytes: Vec, +} + +impl TranscriptBoundFriQueriesArtifactV1 { + pub fn prefix(&self) -> &Stage2TranscriptReplayV1 { + &self.prefix + } + + pub fn fri_transcript(&self) -> &Stage2FriTranscriptReplayV1 { + &self.fri_transcript + } + + pub fn queries(&self) -> &[FriCommitPhaseQueryV1] { + &self.queries + } + + pub fn circuit_digest(&self) -> &[u8; 32] { + &self.circuit_digest + } + + pub fn proof_bundle_bytes(&self) -> &[u8] { + &self.proof_bundle_bytes + } +} + +/// One Flock proof that authenticates every Stage 2 PCS input row, computes +/// all per-height reduced openings from transcript-bound OOD values, and feeds +/// those accumulators into every transcript-derived FRI query. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TranscriptBoundPcsFriQueriesArtifactV1 { + prefix: Stage2TranscriptReplayV1, + fri_transcript: Stage2FriTranscriptReplayV1, + pcs_instance: Stage2PcsInstanceV1, + queries: Vec, + circuit_digest: [u8; 32], + proof_bundle_bytes: Vec, +} + +impl TranscriptBoundPcsFriQueriesArtifactV1 { + pub fn prefix(&self) -> &Stage2TranscriptReplayV1 { + &self.prefix + } + + pub fn fri_transcript(&self) -> &Stage2FriTranscriptReplayV1 { + &self.fri_transcript + } + + pub fn pcs_instance(&self) -> &Stage2PcsInstanceV1 { + &self.pcs_instance + } + + pub fn queries(&self) -> &[TranscriptBoundPcsFriQueryV1] { + &self.queries + } + + pub fn circuit_digest(&self) -> &[u8; 32] { + &self.circuit_digest + } + + pub fn proof_bundle_bytes(&self) -> &[u8] { + &self.proof_bundle_bytes + } +} + +/// A real Flock proof of statement binding and compiled AIR/logUp OOD checks +/// composed with the exact PCS-to-FRI relation. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Stage2AirPcsFriArtifactV1 { + witness: Stage2AirPcsFriWitnessV1, + circuit_digest: [u8; 32], + proof_bundle_bytes: Vec, +} + +impl Stage2AirPcsFriArtifactV1 { + pub(crate) fn from_parts( + witness: Stage2AirPcsFriWitnessV1, + circuit_digest: [u8; 32], + proof_bundle_bytes: Vec, + ) -> Result { + if proof_bundle_bytes.is_empty() { + bail!("Stage 2 AIR/PCS/FRI proof bundle is empty"); + } + if proof_bundle_bytes.len() > MAX_BUNDLE_BYTES { + bail!( + "Stage 2 AIR/PCS/FRI proof bundle exceeds {MAX_BUNDLE_BYTES} bytes" + ); + } + Ok(Self { witness, circuit_digest, proof_bundle_bytes }) + } + + pub fn witness(&self) -> &Stage2AirPcsFriWitnessV1 { + &self.witness + } + + pub fn circuit_digest(&self) -> &[u8; 32] { + &self.circuit_digest + } + + pub fn proof_bundle_bytes(&self) -> &[u8] { + &self.proof_bundle_bytes + } + + pub fn stage2_root_digest(&self) -> &[u8; 32] { + &self.witness.air.statement_digest + } +} + +#[derive(Serialize, Deserialize)] +struct FriFoldProofBundle { + commitment: Commitment, + proof: R1csProofCircuitMerged, +} + +pub fn prove_fri_fold_conformance( + query: &FriFoldQueryV1, +) -> Result { + validate_query(query)?; + let folded_result = native_fold(query); + let commitment_root = native_root(query); + let relation = FriFoldRelation::build(query.log_height)?; + let inputs = relation_inputs(query, folded_result); + let expected_public = relation_public(query, folded_result, &commitment_root); + let proof_bundle_bytes = prove_fri_circuit( + &relation.shape, + relation.table_slots(), + None, + None, + None, + NU, + &inputs, + &expected_public, + FRI_FOLD_CONFORMANCE_TRANSCRIPT_DOMAIN, + )?; + Ok(FriFoldConformanceArtifactV1 { + query: query.clone(), + folded_result, + circuit_digest: relation.shape.circuit.digest(), + commitment_root, + proof_bundle_bytes, + }) +} + +pub fn verify_fri_fold_conformance( + artifact: &FriFoldConformanceArtifactV1, +) -> Result<()> { + validate_query(&artifact.query)?; + if artifact.folded_result != native_fold(&artifact.query) { + bail!("Flock FRI-fold artifact carries the wrong folded result"); + } + if artifact.commitment_root != native_root(&artifact.query) { + bail!("Flock FRI-fold artifact carries the wrong commitment root"); + } + let relation = FriFoldRelation::build(artifact.query.log_height)?; + if artifact.circuit_digest != relation.shape.circuit.digest() { + bail!("Flock FRI-fold conformance circuit digest mismatch"); + } + let public = relation_public( + &artifact.query, + artifact.folded_result, + &artifact.commitment_root, + ); + verify_fri_circuit( + &relation.shape, + relation.table_slots(), + None, + None, + None, + NU, + &public, + &artifact.proof_bundle_bytes, + FRI_FOLD_CONFORMANCE_TRANSCRIPT_DOMAIN, + ) +} + +pub fn prove_fri_commit_phase_conformance( + query: &FriCommitPhaseQueryV1, +) -> Result { + let computation = compute_commit_phase(query)?; + ensure_final_polynomial(query, &computation)?; + let relation = FriCommitPhaseRelation::build(query)?; + let inputs = commit_phase_relation_inputs(query, &computation); + let public = commit_phase_relation_public(query, &computation); + let proof_bundle_bytes = prove_fri_circuit( + &relation.shape, + relation.slots, + None, + None, + None, + relation.nu, + &inputs, + &public, + FRI_QUERY_CONFORMANCE_TRANSCRIPT_DOMAIN, + )?; + Ok(FriCommitPhaseConformanceArtifactV1 { + query: query.clone(), + circuit_digest: relation.shape.circuit.digest(), + commitment_roots: computation.roots, + proof_bundle_bytes, + }) +} + +pub fn verify_fri_commit_phase_conformance( + artifact: &FriCommitPhaseConformanceArtifactV1, +) -> Result<()> { + let computation = compute_commit_phase(&artifact.query)?; + ensure_final_polynomial(&artifact.query, &computation)?; + if artifact.commitment_roots != computation.roots { + bail!("Flock FRI commit-phase artifact carries the wrong round roots"); + } + let relation = FriCommitPhaseRelation::build(&artifact.query)?; + if artifact.circuit_digest != relation.shape.circuit.digest() { + bail!("Flock FRI commit-phase circuit digest mismatch"); + } + let public = commit_phase_relation_public(&artifact.query, &computation); + verify_fri_circuit( + &relation.shape, + relation.slots, + None, + None, + None, + relation.nu, + &public, + &artifact.proof_bundle_bytes, + FRI_QUERY_CONFORMANCE_TRANSCRIPT_DOMAIN, + ) +} + +pub fn prove_pcs_reduction_conformance( + opening: &PcsReducedOpeningV1, +) -> Result { + let computation = compute_pcs_reduction(opening)?; + let relation = PcsReductionRelation::build(opening)?; + let inputs = pcs_reduction_relation_inputs(opening, &computation); + let public = pcs_reduction_relation_public(opening, &computation); + let proof_bundle_bytes = prove_fri_circuit( + &relation.shape, + relation.slots, + None, + None, + None, + relation.nu, + &inputs, + &public, + PCS_REDUCTION_CONFORMANCE_TRANSCRIPT_DOMAIN, + )?; + Ok(PcsReductionConformanceArtifactV1 { + opening: opening.clone(), + reduced_accumulator: computation.accumulator, + next_alpha_power: computation.alpha_power, + circuit_digest: relation.shape.circuit.digest(), + commitment_root: computation.root, + proof_bundle_bytes, + }) +} + +pub fn verify_pcs_reduction_conformance( + artifact: &PcsReductionConformanceArtifactV1, +) -> Result<()> { + let computation = compute_pcs_reduction(&artifact.opening)?; + if artifact.reduced_accumulator != computation.accumulator + || artifact.next_alpha_power != computation.alpha_power + || artifact.commitment_root != computation.root + { + bail!("Flock PCS-reduction artifact carries the wrong native outputs"); + } + let relation = PcsReductionRelation::build(&artifact.opening)?; + if artifact.circuit_digest != relation.shape.circuit.digest() { + bail!("Flock PCS-reduction circuit digest mismatch"); + } + let public = pcs_reduction_relation_public(&artifact.opening, &computation); + verify_fri_circuit( + &relation.shape, + relation.slots, + None, + None, + None, + relation.nu, + &public, + &artifact.proof_bundle_bytes, + PCS_REDUCTION_CONFORMANCE_TRANSCRIPT_DOMAIN, + ) +} + +pub fn prove_transcript_bound_pcs_reduction_conformance( + replay: &Stage2TranscriptReplayV1, + opening: &PcsReducedOpeningV1, +) -> Result { + let challenges = replay.challenges()?; + ensure_transcript_binds_opening(challenges, opening)?; + let computation = compute_pcs_reduction(opening)?; + let relation = TranscriptBoundPcsReductionRelation::build( + replay, + opening, + &computation, + challenges, + )?; + let proof_bundle_bytes = prove_fri_circuit( + &relation.shape, + relation.slots, + None, + None, + None, + relation.nu, + &relation.inputs, + &relation.public, + TRANSCRIPT_BOUND_PCS_CONFORMANCE_TRANSCRIPT_DOMAIN, + )?; + Ok(TranscriptBoundPcsReductionArtifactV1 { + replay: replay.clone(), + opening: opening.clone(), + reduced_accumulator: computation.accumulator, + next_alpha_power: computation.alpha_power, + circuit_digest: relation.shape.circuit.digest(), + commitment_root: computation.root, + proof_bundle_bytes, + }) +} + +pub fn verify_transcript_bound_pcs_reduction_conformance( + artifact: &TranscriptBoundPcsReductionArtifactV1, +) -> Result<()> { + let challenges = artifact.replay.challenges()?; + ensure_transcript_binds_opening(challenges, &artifact.opening)?; + let computation = compute_pcs_reduction(&artifact.opening)?; + if artifact.reduced_accumulator != computation.accumulator + || artifact.next_alpha_power != computation.alpha_power + || artifact.commitment_root != computation.root + { + bail!("transcript-bound PCS artifact carries the wrong native outputs"); + } + let relation = TranscriptBoundPcsReductionRelation::build( + &artifact.replay, + &artifact.opening, + &computation, + challenges, + )?; + if artifact.circuit_digest != relation.shape.circuit.digest() { + bail!("transcript-bound PCS circuit digest mismatch"); + } + verify_fri_circuit( + &relation.shape, + relation.slots, + None, + None, + None, + relation.nu, + &relation.public, + &artifact.proof_bundle_bytes, + TRANSCRIPT_BOUND_PCS_CONFORMANCE_TRANSCRIPT_DOMAIN, + ) +} + +pub fn prove_transcript_bound_fri_commit_phase_conformance( + prefix: &Stage2TranscriptReplayV1, + fri_transcript: &Stage2FriTranscriptReplayV1, + query_number: usize, + query: &FriCommitPhaseQueryV1, +) -> Result { + let challenges = fri_transcript.challenges(prefix)?; + ensure_transcript_binds_fri_query( + fri_transcript, + &challenges, + query_number, + query, + )?; + let computation = compute_commit_phase(query)?; + ensure_final_polynomial(query, &computation)?; + let relation = TranscriptBoundFriCommitPhaseRelation::build( + prefix, + fri_transcript, + &challenges, + query_number, + query, + &computation, + )?; + let proof_bundle_bytes = prove_fri_circuit( + &relation.shape, + relation.slots, + Some(relation.sample_slot), + Some(relation.split_slot), + relation.window_slot, + relation.nu, + &relation.inputs, + &relation.public, + TRANSCRIPT_BOUND_FRI_CONFORMANCE_TRANSCRIPT_DOMAIN, + )?; + Ok(TranscriptBoundFriCommitPhaseArtifactV1 { + prefix: prefix.clone(), + fri_transcript: fri_transcript.clone(), + query_number, + query: query.clone(), + circuit_digest: relation.shape.circuit.digest(), + commitment_roots: computation.roots, + proof_bundle_bytes, + }) +} + +pub fn verify_transcript_bound_fri_commit_phase_conformance( + artifact: &TranscriptBoundFriCommitPhaseArtifactV1, +) -> Result<()> { + let challenges = artifact.fri_transcript.challenges(&artifact.prefix)?; + ensure_transcript_binds_fri_query( + &artifact.fri_transcript, + &challenges, + artifact.query_number, + &artifact.query, + )?; + let computation = compute_commit_phase(&artifact.query)?; + ensure_final_polynomial(&artifact.query, &computation)?; + if artifact.commitment_roots != computation.roots { + bail!("transcript-bound FRI artifact carries the wrong native roots"); + } + let relation = TranscriptBoundFriCommitPhaseRelation::build( + &artifact.prefix, + &artifact.fri_transcript, + &challenges, + artifact.query_number, + &artifact.query, + &computation, + )?; + if artifact.circuit_digest != relation.shape.circuit.digest() { + bail!("transcript-bound FRI circuit digest mismatch"); + } + verify_fri_circuit( + &relation.shape, + relation.slots, + Some(relation.sample_slot), + Some(relation.split_slot), + relation.window_slot, + relation.nu, + &relation.public, + &artifact.proof_bundle_bytes, + TRANSCRIPT_BOUND_FRI_CONFORMANCE_TRANSCRIPT_DOMAIN, + ) +} + +pub fn prove_transcript_bound_fri_queries_conformance( + prefix: &Stage2TranscriptReplayV1, + fri_transcript: &Stage2FriTranscriptReplayV1, + queries: &[FriCommitPhaseQueryV1], +) -> Result { + let challenges = fri_transcript.challenges(prefix)?; + let computations = validate_all_transcript_bound_fri_queries( + fri_transcript, + &challenges, + queries, + )?; + let relation = TranscriptBoundFriCommitPhaseRelation::build_all( + prefix, + fri_transcript, + &challenges, + queries, + &computations, + )?; + let proof_bundle_bytes = prove_fri_circuit( + &relation.shape, + relation.slots, + Some(relation.sample_slot), + Some(relation.split_slot), + relation.window_slot, + relation.nu, + &relation.inputs, + &relation.public, + TRANSCRIPT_BOUND_FRI_QUERIES_CONFORMANCE_TRANSCRIPT_DOMAIN, + )?; + Ok(TranscriptBoundFriQueriesArtifactV1 { + prefix: prefix.clone(), + fri_transcript: fri_transcript.clone(), + queries: queries.to_vec(), + circuit_digest: relation.shape.circuit.digest(), + proof_bundle_bytes, + }) +} + +pub fn verify_transcript_bound_fri_queries_conformance( + artifact: &TranscriptBoundFriQueriesArtifactV1, +) -> Result<()> { + let challenges = artifact.fri_transcript.challenges(&artifact.prefix)?; + let computations = validate_all_transcript_bound_fri_queries( + &artifact.fri_transcript, + &challenges, + &artifact.queries, + )?; + let relation = TranscriptBoundFriCommitPhaseRelation::build_all( + &artifact.prefix, + &artifact.fri_transcript, + &challenges, + &artifact.queries, + &computations, + )?; + if artifact.circuit_digest != relation.shape.circuit.digest() { + bail!("all-query transcript-bound FRI circuit digest mismatch"); + } + verify_fri_circuit( + &relation.shape, + relation.slots, + Some(relation.sample_slot), + Some(relation.split_slot), + relation.window_slot, + relation.nu, + &relation.public, + &artifact.proof_bundle_bytes, + TRANSCRIPT_BOUND_FRI_QUERIES_CONFORMANCE_TRANSCRIPT_DOMAIN, + ) +} + +pub fn prove_transcript_bound_pcs_fri_queries_conformance( + prefix: &Stage2TranscriptReplayV1, + fri_transcript: &Stage2FriTranscriptReplayV1, + pcs_instance: &Stage2PcsInstanceV1, + queries: &[TranscriptBoundPcsFriQueryV1], +) -> Result { + let prefix_challenges = prefix.challenges()?; + let fri_challenges = fri_transcript.challenges(prefix)?; + let (fri_computations, pcs_computations) = + validate_all_transcript_bound_pcs_fri_queries( + prefix, + fri_transcript, + &fri_challenges, + prefix_challenges, + pcs_instance, + queries, + )?; + let relation = TranscriptBoundFriCommitPhaseRelation::build_all_with_pcs( + prefix, + fri_transcript, + &fri_challenges, + pcs_instance, + queries, + &fri_computations, + &pcs_computations, + )?; + let proof_bundle_bytes = prove_fri_circuit( + &relation.shape, + relation.slots, + Some(relation.sample_slot), + Some(relation.split_slot), + relation.window_slot, + relation.nu, + &relation.inputs, + &relation.public, + TRANSCRIPT_BOUND_PCS_FRI_QUERIES_CONFORMANCE_TRANSCRIPT_DOMAIN, + )?; + Ok(TranscriptBoundPcsFriQueriesArtifactV1 { + prefix: prefix.clone(), + fri_transcript: fri_transcript.clone(), + pcs_instance: pcs_instance.clone(), + queries: queries.to_vec(), + circuit_digest: relation.shape.circuit.digest(), + proof_bundle_bytes, + }) +} + +pub fn verify_transcript_bound_pcs_fri_queries_conformance( + artifact: &TranscriptBoundPcsFriQueriesArtifactV1, +) -> Result<()> { + let prefix_challenges = artifact.prefix.challenges()?; + let fri_challenges = artifact.fri_transcript.challenges(&artifact.prefix)?; + let (fri_computations, pcs_computations) = + validate_all_transcript_bound_pcs_fri_queries( + &artifact.prefix, + &artifact.fri_transcript, + &fri_challenges, + prefix_challenges, + &artifact.pcs_instance, + &artifact.queries, + )?; + let relation = TranscriptBoundFriCommitPhaseRelation::build_all_with_pcs( + &artifact.prefix, + &artifact.fri_transcript, + &fri_challenges, + &artifact.pcs_instance, + &artifact.queries, + &fri_computations, + &pcs_computations, + )?; + if artifact.circuit_digest != relation.shape.circuit.digest() { + bail!("transcript-bound PCS/FRI all-query circuit digest mismatch"); + } + verify_fri_circuit( + &relation.shape, + relation.slots, + Some(relation.sample_slot), + Some(relation.split_slot), + relation.window_slot, + relation.nu, + &relation.public, + &artifact.proof_bundle_bytes, + TRANSCRIPT_BOUND_PCS_FRI_QUERIES_CONFORMANCE_TRANSCRIPT_DOMAIN, + ) +} + +pub fn prove_stage2_air_pcs_fri_conformance( + witness: &Stage2AirPcsFriWitnessV1, +) -> Result { + prove_stage2_air_pcs_fri_with_domain( + witness, + STAGE2_AIR_PCS_FRI_CONFORMANCE_TRANSCRIPT_DOMAIN, + ) +} + +pub(crate) fn prove_stage2_air_pcs_fri_production( + witness: &Stage2AirPcsFriWitnessV1, +) -> Result { + prove_stage2_air_pcs_fri_with_domain(witness, crate::STAGE3_TRANSCRIPT_DOMAIN) +} + +fn prove_stage2_air_pcs_fri_with_domain( + witness: &Stage2AirPcsFriWitnessV1, + transcript_domain: &[u8], +) -> Result { + let relation = build_stage2_air_pcs_fri_relation(witness)?; + let proof_bundle_bytes = prove_fri_circuit( + &relation.shape, + relation.slots, + Some(relation.sample_slot), + Some(relation.split_slot), + relation.window_slot, + relation.nu, + &relation.inputs, + &relation.public, + transcript_domain, + )?; + Stage2AirPcsFriArtifactV1::from_parts( + witness.clone(), + relation.shape.circuit.digest(), + proof_bundle_bytes, + ) +} + +fn build_stage2_air_pcs_fri_relation( + witness: &Stage2AirPcsFriWitnessV1, +) -> Result { + let pcs_fri = &witness.pcs_fri; + let prefix_challenges = pcs_fri.prefix.challenges()?; + let fri_challenges = pcs_fri.fri_transcript.challenges(&pcs_fri.prefix)?; + let (fri_computations, pcs_computations) = + validate_all_transcript_bound_pcs_fri_queries( + &pcs_fri.prefix, + &pcs_fri.fri_transcript, + &fri_challenges, + prefix_challenges, + &pcs_fri.pcs_instance, + &pcs_fri.queries, + )?; + let relation = + TranscriptBoundFriCommitPhaseRelation::build_all_with_pcs_and_air( + &pcs_fri.prefix, + &pcs_fri.fri_transcript, + &fri_challenges, + &pcs_fri.pcs_instance, + &witness.air, + &pcs_fri.queries, + &fri_computations, + &pcs_computations, + )?; + Ok(relation) +} + +pub(crate) fn stage2_air_pcs_fri_circuit_digest( + witness: &Stage2AirPcsFriWitnessV1, +) -> Result<[u8; 32]> { + Ok(build_stage2_air_pcs_fri_relation(witness)?.shape.circuit.digest()) +} + +pub fn verify_stage2_air_pcs_fri_conformance( + artifact: &Stage2AirPcsFriArtifactV1, +) -> Result<()> { + verify_stage2_air_pcs_fri_with_domain( + artifact, + STAGE2_AIR_PCS_FRI_CONFORMANCE_TRANSCRIPT_DOMAIN, + ) +} + +pub(crate) fn verify_stage2_air_pcs_fri_production( + artifact: &Stage2AirPcsFriArtifactV1, +) -> Result<()> { + verify_stage2_air_pcs_fri_with_domain( + artifact, + crate::STAGE3_TRANSCRIPT_DOMAIN, + ) +} + +fn verify_stage2_air_pcs_fri_with_domain( + artifact: &Stage2AirPcsFriArtifactV1, + transcript_domain: &[u8], +) -> Result<()> { + let witness = &artifact.witness; + let relation = build_stage2_air_pcs_fri_relation(witness)?; + if artifact.circuit_digest != relation.shape.circuit.digest() { + bail!("Stage 2 AIR/PCS/FRI circuit digest mismatch"); + } + verify_fri_circuit( + &relation.shape, + relation.slots, + Some(relation.sample_slot), + Some(relation.split_slot), + relation.window_slot, + relation.nu, + &relation.public, + &artifact.proof_bundle_bytes, + transcript_domain, + ) +} + +pub fn verify_stage2_air_pcs_fri_conformance_for( + artifact: &Stage2AirPcsFriArtifactV1, + expected: &Stage2RootStatementV1, +) -> Result<()> { + let expected_bytes = expected.to_bytes(); + if artifact.witness.air.statement_prefix != expected_bytes[..80] { + bail!("Stage 2 AIR/PCS/FRI proof uses a different vk or FRI prefix"); + } + if artifact.witness.air.statement_digest != expected.digest() { + bail!("Stage 2 AIR/PCS/FRI proof targets a different Stage 2 root"); + } + verify_stage2_air_pcs_fri_conformance(artifact) +} + +fn validate_all_transcript_bound_pcs_fri_queries( + prefix: &Stage2TranscriptReplayV1, + fri_transcript: &Stage2FriTranscriptReplayV1, + fri_challenges: &Stage2FriTranscriptChallengesV1, + prefix_challenges: crate::Stage2TranscriptChallengesV1, + pcs_instance: &Stage2PcsInstanceV1, + queries: &[TranscriptBoundPcsFriQueryV1], +) -> Result<(Vec, Vec)> { + validate_stage2_pcs_instance(prefix, pcs_instance)?; + if queries.len() != fri_transcript.num_queries + || queries.len() != fri_challenges.query_indices.len() + { + bail!( + "all-query PCS/FRI relation has {} queries; transcript requires {}", + queries.len(), + fri_transcript.num_queries + ); + } + let mut fri_computations = Vec::with_capacity(queries.len()); + let mut pcs_computations = Vec::with_capacity(queries.len()); + for (query_number, query) in queries.iter().enumerate() { + ensure_transcript_binds_fri_query( + fri_transcript, + fri_challenges, + query_number, + &query.fri, + )?; + let pcs_computation = + compute_stage2_pcs_query(prefix, pcs_instance, query, prefix_challenges)?; + ensure_stage2_pcs_feeds_fri(pcs_instance, query, &pcs_computation)?; + let fri_computation = compute_commit_phase(&query.fri)?; + ensure_final_polynomial(&query.fri, &fri_computation)?; + fri_computations.push(fri_computation); + pcs_computations.push(pcs_computation); + } + Ok((fri_computations, pcs_computations)) +} + +fn validate_all_transcript_bound_fri_queries( + fri_transcript: &Stage2FriTranscriptReplayV1, + challenges: &Stage2FriTranscriptChallengesV1, + queries: &[FriCommitPhaseQueryV1], +) -> Result> { + if queries.len() != fri_transcript.num_queries + || queries.len() != challenges.query_indices.len() + { + bail!( + "all-query FRI relation has {} queries; transcript requires {}", + queries.len(), + fri_transcript.num_queries + ); + } + queries + .iter() + .enumerate() + .map(|(query_number, query)| { + ensure_transcript_binds_fri_query( + fri_transcript, + challenges, + query_number, + query, + )?; + let computation = compute_commit_phase(query)?; + ensure_final_polynomial(query, &computation)?; + Ok(computation) + }) + .collect() +} + +fn ensure_transcript_binds_fri_query( + fri_transcript: &Stage2FriTranscriptReplayV1, + challenges: &Stage2FriTranscriptChallengesV1, + query_number: usize, + query: &FriCommitPhaseQueryV1, +) -> Result<()> { + if query_number >= challenges.query_indices.len() { + bail!("FRI query number {query_number} is out of range"); + } + if fri_transcript.log_arities.iter().any(|&arity| arity != 1) { + bail!("binary FRI composition requires every log arity to equal one"); + } + if query.rounds.len() != challenges.betas.len() + || query.rounds.len() != fri_transcript.commit_phase_commitments.len() + { + bail!("transcript and FRI query round counts disagree"); + } + for (round, (query_round, &beta)) in + query.rounds.iter().zip(&challenges.betas).enumerate() + { + if query_round.beta != beta { + bail!("FRI round {round} beta does not equal the transcript challenge"); + } + } + let query_index = challenges.query_indices[query_number]; + if u64::from(query.query_index) != query_index { + bail!("FRI query index does not equal the transcript-derived index"); + } + if usize::from(fri_transcript.query_index_bits) + != usize::from(query.initial_log_height) + 1 + { + bail!("FRI query height does not equal the transcript sampling width"); + } + if fri_transcript.final_polynomial.as_slice() != [query.final_polynomial] { + bail!("FRI final polynomial does not equal the transcript observation"); + } + if fri_transcript.commit_phase_commitments.iter().any(|cap| cap.len() != 1) { + bail!("current transcript-bound FRI composition requires cap height zero"); + } + let roots = query.commitment_roots()?; + for (round, (cap, root)) in + fri_transcript.commit_phase_commitments.iter().zip(&roots).enumerate() + { + if cap[0] != *root { + bail!( + "FRI round {round} opening does not authenticate to its transcript cap" + ); + } + } + Ok(()) +} + +fn ensure_transcript_binds_opening( + challenges: crate::Stage2TranscriptChallengesV1, + opening: &PcsReducedOpeningV1, +) -> Result<()> { + if opening.zeta != challenges.zeta { + bail!("PCS zeta does not equal the constrained Stage 2 transcript zeta"); + } + if opening.alpha != challenges.pcs_alpha { + bail!( + "PCS batching challenge does not equal the constrained Stage 2 transcript challenge" + ); + } + Ok(()) +} + +struct FriFoldRelation { + shape: CircuitShape, + blake3_slot: SlotId, + order_slot: SlotId, + add_slot: SlotId, + mul_slot: SlotId, + repack_slot: SlotId, + canonical_slot: SlotId, + equality_slot: SlotId, +} + +#[derive(Clone, Copy)] +struct FriTableSlots { + blake3: SlotId, + order: SlotId, + add: SlotId, + mul: SlotId, + repack: SlotId, + canonical: SlotId, + equality: SlotId, + field_sample: Option, +} + +impl FriFoldRelation { + fn build(log_height: u8) -> Result { + validate_log_height(log_height)?; + let mut builder = ShapeBuilder::new(NU); + let arithmetic = GoldilocksCircuitSlots::declare(&mut builder, NU); + let blake3_slot = builder.slot(Blake3Gate { nu: NU }); + let order_slot = builder.slot(DigestOrderGate { nu: NU }); + let equality_slot = builder.slot(F128EqualityGate { nu: NU }); + let data_zero = builder.fixed_public_input(F128::ZERO); + let equality_zero = builder.fixed_public_input(F128::ZERO); + + let packed_iv = pack8(&IV); + let iv = [ + builder.fixed_public_input(packed_iv[0]), + builder.fixed_public_input(packed_iv[1]), + ]; + let leaf_params = builder.fixed_public_input(pack_params( + 0, + 32, + CHUNK_START | CHUNK_END | ROOT, + )); + let node_params = builder.fixed_public_input(pack_params( + 0, + 64, + CHUNK_START | CHUNK_END | ROOT, + )); + let one = builder.fixed_public_input(F128::new(1, 0)); + let factor_wires: Vec<_> = twiddle_factors(log_height) + .into_iter() + .map(|factor| builder.fixed_public_input(F128::new(factor, 0))) + .collect(); + + let folded = builder.public_input(); + let sibling = builder.public_input(); + let beta = builder.public_input(); + let index_bits: Vec<_> = + (0..=log_height).map(|_| builder.public_input()).collect(); + let path: Vec<_> = (0..log_height) + .map(|_| [builder.public_input(), builder.public_input()]) + .collect(); + let folded_result = builder.public_input(); + + let root = constrain_authenticated_fold( + &mut builder, + &arithmetic, + blake3_slot, + order_slot, + equality_slot, + data_zero, + equality_zero, + iv, + leaf_params, + node_params, + one, + &factor_wires, + folded, + sibling, + beta, + &index_bits, + &path, + folded_result, + ); + + builder.publish(root[0]); + builder.publish(root[1]); + let shape = builder.finish().map_err(|error| { + anyhow::anyhow!("build Flock authenticated FRI-fold circuit: {error:?}") + })?; + Ok(Self { + shape, + blake3_slot, + order_slot, + add_slot: arithmetic.add, + mul_slot: arithmetic.mul, + repack_slot: arithmetic.repack, + canonical_slot: arithmetic.canonical, + equality_slot, + }) + } + + fn table_slots(&self) -> FriTableSlots { + FriTableSlots { + blake3: self.blake3_slot, + order: self.order_slot, + add: self.add_slot, + mul: self.mul_slot, + repack: self.repack_slot, + canonical: self.canonical_slot, + equality: self.equality_slot, + field_sample: None, + } + } +} + +struct FriCommitPhaseRelation { + shape: CircuitShape, + slots: FriTableSlots, + nu: usize, +} + +struct FriCommitPhaseRoundWires { + sibling: Wire, + beta: Wire, + reduced_opening: Option, + path: Vec<[Wire; 2]>, + result: Wire, +} + +impl FriCommitPhaseRelation { + fn build(query: &FriCommitPhaseQueryV1) -> Result { + validate_commit_phase_structure(query)?; + let nu = commit_phase_nu(query); + let mut builder = ShapeBuilder::new(nu); + let arithmetic = GoldilocksCircuitSlots::declare(&mut builder, nu); + let blake3 = builder.slot(Blake3Gate { nu }); + let order = builder.slot(DigestOrderGate { nu }); + let equality = builder.slot(F128EqualityGate { nu }); + let slots = FriTableSlots { + blake3, + order, + add: arithmetic.add, + mul: arithmetic.mul, + repack: arithmetic.repack, + canonical: arithmetic.canonical, + equality, + field_sample: None, + }; + let data_zero = builder.fixed_public_input(F128::ZERO); + let equality_zero = builder.fixed_public_input(F128::ZERO); + let packed_iv = pack8(&IV); + let iv = [ + builder.fixed_public_input(packed_iv[0]), + builder.fixed_public_input(packed_iv[1]), + ]; + let leaf_params = builder.fixed_public_input(pack_params( + 0, + 32, + CHUNK_START | CHUNK_END | ROOT, + )); + let node_params = builder.fixed_public_input(pack_params( + 0, + 64, + CHUNK_START | CHUNK_END | ROOT, + )); + let one = builder.fixed_public_input(F128::new(1, 0)); + let factor_wires: Vec> = (0..query.rounds.len()) + .map(|round| { + let log_height = query.initial_log_height - round as u8; + twiddle_factors(log_height) + .into_iter() + .map(|factor| builder.fixed_public_input(F128::new(factor, 0))) + .collect() + }) + .collect(); + + // Declare all free values before publishing computed roots, keeping the + // public layout equal to `inputs || roots`. + let initial_folded = builder.public_input(); + let index_bits: Vec<_> = + (0..=query.initial_log_height).map(|_| builder.public_input()).collect(); + let round_wires: Vec<_> = (0..query.rounds.len()) + .map(|round| { + let log_height = usize::from(query.initial_log_height) - round; + FriCommitPhaseRoundWires { + sibling: builder.public_input(), + beta: builder.public_input(), + reduced_opening: query.rounds[round] + .reduced_opening + .map(|_| builder.public_input()), + path: (0..log_height) + .map(|_| [builder.public_input(), builder.public_input()]) + .collect(), + result: builder.public_input(), + } + }) + .collect(); + let final_polynomial = builder.public_input(); + + let mut folded = initial_folded; + for (round, wires) in round_wires.iter().enumerate() { + let root = constrain_authenticated_fold( + &mut builder, + &arithmetic, + blake3, + order, + equality, + data_zero, + equality_zero, + iv, + leaf_params, + node_params, + one, + &factor_wires[round], + folded, + wires.sibling, + wires.beta, + &index_bits[round..], + &wires.path, + wires.result, + ); + builder.publish(root[0]); + builder.publish(root[1]); + folded = if let Some(reduced_opening) = wires.reduced_opening { + let beta_squared = + arithmetic.ext2_mul(&mut builder, wires.beta, wires.beta); + let rollin = + arithmetic.ext2_mul(&mut builder, beta_squared, reduced_opening); + arithmetic.add(&mut builder, wires.result, rollin) + } else { + wires.result + }; + } + let final_residual = builder.gate(equality, &[folded, final_polynomial])[0]; + builder.connect(final_residual, equality_zero); + + let shape = builder.finish().map_err(|error| { + anyhow::anyhow!("build Flock FRI commit-phase circuit: {error:?}") + })?; + Ok(Self { shape, slots, nu }) + } +} + +struct PcsReductionRelation { + shape: CircuitShape, + slots: FriTableSlots, + nu: usize, +} + +impl PcsReductionRelation { + fn build(opening: &PcsReducedOpeningV1) -> Result { + validate_pcs_reduction(opening)?; + let nu = pcs_reduction_nu(opening); + let mut builder = ShapeBuilder::new(nu); + let arithmetic = GoldilocksCircuitSlots::declare(&mut builder, nu); + let blake3 = builder.slot(Blake3Gate { nu }); + let order = builder.slot(DigestOrderGate { nu }); + let equality = builder.slot(F128EqualityGate { nu }); + let slots = FriTableSlots { + blake3, + order, + add: arithmetic.add, + mul: arithmetic.mul, + repack: arithmetic.repack, + canonical: arithmetic.canonical, + equality, + field_sample: None, + }; + let data_zero = builder.fixed_public_input(F128::ZERO); + let equality_zero = builder.fixed_public_input(F128::ZERO); + let packed_iv = pack8(&IV); + let iv = [ + builder.fixed_public_input(packed_iv[0]), + builder.fixed_public_input(packed_iv[1]), + ]; + let leaf_trace = hash_trace(opening.opened_values.len() * 8); + let leaf_params: Vec<_> = leaf_trace + .rows + .iter() + .map(|&(_cv, _message, counter, block_len, flags)| { + builder.fixed_public_input(pack_params(counter, block_len, flags)) + }) + .collect(); + let node_params = builder.fixed_public_input(pack_params( + 0, + 64, + CHUNK_START | CHUNK_END | ROOT, + )); + let one = builder.fixed_public_input(F128::new(1, 0)); + let coset_shift = builder.fixed_public_input(F128::new(7, 0)); + let factor_wires: Vec<_> = pcs_x_factors(opening.log_height) + .into_iter() + .map(|factor| builder.fixed_public_input(F128::new(factor, 0))) + .collect(); + + let packed_values: Vec<_> = + opening.opened_values.chunks(2).map(|_| builder.public_input()).collect(); + let opened_at_z: Vec<_> = + opening.opened_at_z.iter().map(|_| builder.public_input()).collect(); + let zeta = builder.public_input(); + let alpha = builder.public_input(); + let initial_alpha_power = builder.public_input(); + let initial_accumulator = builder.public_input(); + let index_bits: Vec<_> = + (0..opening.log_height).map(|_| builder.public_input()).collect(); + let path: Vec<_> = (0..opening.log_height) + .map(|_| [builder.public_input(), builder.public_input()]) + .collect(); + let denominator = builder.public_input(); + let quotients: Vec<_> = + opening.opened_values.iter().map(|_| builder.public_input()).collect(); + let reduced_accumulator = builder.public_input(); + let next_alpha_power = builder.public_input(); + + for value in [zeta, alpha, initial_alpha_power, initial_accumulator] { + arithmetic.assert_canonical(&mut builder, value); + } + for &value in &opened_at_z { + arithmetic.assert_canonical(&mut builder, value); + } + arithmetic.assert_canonical(&mut builder, denominator); + for "ient in "ients { + arithmetic.assert_canonical(&mut builder, quotient); + } + + let mut px_values = Vec::with_capacity(opening.opened_values.len()); + for (packed_index, packed) in packed_values.iter().enumerate() { + arithmetic.assert_canonical(&mut builder, *packed); + let lanes = builder.gate(arithmetic.repack, &[*packed, data_zero]); + px_values.push(lanes[3]); + if 2 * packed_index + 1 < opening.opened_values.len() { + let high = builder.gate(arithmetic.repack, &[lanes[1], data_zero])[3]; + px_values.push(high); + } else { + let high = builder.gate(arithmetic.repack, &[lanes[1], data_zero])[3]; + let padding_residual = builder.gate(equality, &[high, data_zero])[0]; + builder.connect(padding_residual, equality_zero); + } + } + + let mut x = coset_shift; + for (bit, factor) in index_bits.iter().zip(&factor_wires) { + let selected = + builder.gate(order, &[*bit, one, data_zero, *factor, data_zero])[0]; + x = arithmetic.ext2_mul(&mut builder, x, selected); + } + let denominator_check = arithmetic.add(&mut builder, denominator, x); + let denominator_residual = + builder.gate(equality, &[denominator_check, zeta])[0]; + builder.connect(denominator_residual, equality_zero); + + let mut accumulator = initial_accumulator; + let mut alpha_power = initial_alpha_power; + for ((px, pz), quotient) in + px_values.iter().zip(&opened_at_z).zip("ients) + { + let quotient_product = + arithmetic.ext2_mul(&mut builder, denominator, *quotient); + let reconstructed = arithmetic.add(&mut builder, quotient_product, *px); + let quotient_residual = builder.gate(equality, &[reconstructed, *pz])[0]; + builder.connect(quotient_residual, equality_zero); + let term = arithmetic.ext2_mul(&mut builder, alpha_power, *quotient); + accumulator = arithmetic.add(&mut builder, accumulator, term); + alpha_power = arithmetic.ext2_mul(&mut builder, alpha_power, alpha); + } + let accumulator_residual = + builder.gate(equality, &[accumulator, reduced_accumulator])[0]; + builder.connect(accumulator_residual, equality_zero); + let alpha_power_residual = + builder.gate(equality, &[alpha_power, next_alpha_power])[0]; + builder.connect(alpha_power_residual, equality_zero); + + let mut current = constrain_hash( + &mut builder, + blake3, + &leaf_trace, + &leaf_params, + iv, + data_zero, + &packed_values, + )?; + for (level, sibling) in path.iter().enumerate() { + let ordered = builder.gate( + order, + &[index_bits[level], current[0], current[1], sibling[0], sibling[1]], + ); + let parent = builder.gate( + blake3, + &[ + iv[0], + iv[1], + ordered[0], + ordered[1], + ordered[2], + ordered[3], + node_params, + ], + ); + current = [parent[0], parent[1]]; + } + builder.publish(current[0]); + builder.publish(current[1]); + let shape = builder.finish().map_err(|error| { + anyhow::anyhow!("build Flock PCS-reduction circuit: {error:?}") + })?; + Ok(Self { shape, slots, nu }) + } +} + +struct TranscriptBoundPcsReductionRelation { + shape: CircuitShape, + slots: FriTableSlots, + nu: usize, + inputs: Vec, + public: Vec, +} + +impl TranscriptBoundPcsReductionRelation { + fn build( + replay: &Stage2TranscriptReplayV1, + opening: &PcsReducedOpeningV1, + computation: &PcsReductionComputation, + challenges: crate::Stage2TranscriptChallengesV1, + ) -> Result { + validate_pcs_reduction(opening)?; + ensure_transcript_binds_opening(challenges, opening)?; + let transcript_capacity = 1usize << transcript_nu(replay)?; + let pcs_capacity = 1usize << pcs_reduction_nu(opening); + let nu = usize::try_from( + transcript_capacity + .checked_add(pcs_capacity) + .ok_or_else(|| anyhow::anyhow!("transcript-bound PCS row overflow"))? + .next_power_of_two() + .ilog2(), + ) + .expect("PCS row logarithm fits usize") + .max(NU); + let mut builder = ShapeBuilder::new(nu); + let arithmetic = GoldilocksCircuitSlots::declare(&mut builder, nu); + let blake3 = builder.slot(Blake3Gate { nu }); + let order = builder.slot(DigestOrderGate { nu }); + let equality = builder.slot(F128EqualityGate { nu }); + let sample_slot = builder.slot(GoldilocksSampleGate { nu }); + let slots = FriTableSlots { + blake3, + order, + add: arithmetic.add, + mul: arithmetic.mul, + repack: arithmetic.repack, + canonical: arithmetic.canonical, + equality, + field_sample: Some(sample_slot), + }; + + // `GoldilocksCircuitSlots::declare` creates its fixed canonical zero first. + let mut inputs = vec![F128::ZERO]; + let transcript = constrain_stage2_transcript( + &mut builder, + TranscriptCircuitSlots { + blake3, + sample: sample_slot, + canonical: arithmetic.canonical, + }, + replay, + nu, + )?; + inputs.extend_from_slice(&transcript.inputs); + for challenge in transcript.challenges.all() { + builder.publish(challenge); + } + let mut public = inputs.clone(); + public.extend(transcript_challenge_words(challenges)); + + let data_zero = + record_fixed(&mut builder, &mut inputs, &mut public, F128::ZERO); + let equality_zero = + record_fixed(&mut builder, &mut inputs, &mut public, F128::ZERO); + let packed_iv = pack8(&IV); + let iv = [ + record_fixed(&mut builder, &mut inputs, &mut public, packed_iv[0]), + record_fixed(&mut builder, &mut inputs, &mut public, packed_iv[1]), + ]; + let leaf_trace = hash_trace(opening.opened_values.len() * 8); + let leaf_params: Vec<_> = leaf_trace + .rows + .iter() + .map(|&(_cv, _message, counter, block_len, flags)| { + record_fixed( + &mut builder, + &mut inputs, + &mut public, + pack_params(counter, block_len, flags), + ) + }) + .collect(); + let node_params = record_fixed( + &mut builder, + &mut inputs, + &mut public, + pack_params(0, 64, CHUNK_START | CHUNK_END | ROOT), + ); + let one = + record_fixed(&mut builder, &mut inputs, &mut public, F128::new(1, 0)); + let coset_shift = + record_fixed(&mut builder, &mut inputs, &mut public, F128::new(7, 0)); + let factor_wires: Vec<_> = pcs_x_factors(opening.log_height) + .into_iter() + .map(|factor| { + record_fixed( + &mut builder, + &mut inputs, + &mut public, + F128::new(factor, 0), + ) + }) + .collect(); + + let packed_values: Vec<_> = opening + .opened_values + .chunks(2) + .map(|pair| { + record_public( + &mut builder, + &mut inputs, + &mut public, + F128::new(pair[0], pair.get(1).copied().unwrap_or(0)), + ) + }) + .collect(); + let opened_at_z: Vec<_> = opening + .opened_at_z + .iter() + .copied() + .map(|value| { + record_public( + &mut builder, + &mut inputs, + &mut public, + pack_extension(value), + ) + }) + .collect(); + let initial_alpha_power = record_public( + &mut builder, + &mut inputs, + &mut public, + pack_extension(opening.initial_alpha_power), + ); + let initial_accumulator = record_public( + &mut builder, + &mut inputs, + &mut public, + pack_extension(opening.initial_accumulator), + ); + let index_bits: Vec<_> = (0..opening.log_height) + .map(|bit| { + record_public( + &mut builder, + &mut inputs, + &mut public, + F128::new(u64::from((opening.query_index >> bit) & 1), 0), + ) + }) + .collect(); + let path: Vec<_> = opening + .opening_proof + .iter() + .map(|sibling| { + let digest = pack_digest(sibling); + [ + record_public(&mut builder, &mut inputs, &mut public, digest[0]), + record_public(&mut builder, &mut inputs, &mut public, digest[1]), + ] + }) + .collect(); + let denominator = record_public( + &mut builder, + &mut inputs, + &mut public, + pack_extension(computation.denominator), + ); + let quotients: Vec<_> = computation + .quotients + .iter() + .copied() + .map(|quotient| { + record_public( + &mut builder, + &mut inputs, + &mut public, + pack_extension(quotient), + ) + }) + .collect(); + let reduced_accumulator = record_public( + &mut builder, + &mut inputs, + &mut public, + pack_extension(computation.accumulator), + ); + let next_alpha_power = record_public( + &mut builder, + &mut inputs, + &mut public, + pack_extension(computation.alpha_power), + ); + + let zeta = transcript.challenges.zeta; + let alpha = transcript.challenges.pcs_alpha; + for value in [zeta, alpha, initial_alpha_power, initial_accumulator] { + arithmetic.assert_canonical(&mut builder, value); + } + for &value in &opened_at_z { + arithmetic.assert_canonical(&mut builder, value); + } + arithmetic.assert_canonical(&mut builder, denominator); + for "ient in "ients { + arithmetic.assert_canonical(&mut builder, quotient); + } + + let mut px_values = Vec::with_capacity(opening.opened_values.len()); + for (packed_index, packed) in packed_values.iter().enumerate() { + arithmetic.assert_canonical(&mut builder, *packed); + let lanes = builder.gate(arithmetic.repack, &[*packed, data_zero]); + px_values.push(lanes[3]); + if 2 * packed_index + 1 < opening.opened_values.len() { + let high = builder.gate(arithmetic.repack, &[lanes[1], data_zero])[3]; + px_values.push(high); + } else { + let high = builder.gate(arithmetic.repack, &[lanes[1], data_zero])[3]; + let padding_residual = builder.gate(equality, &[high, data_zero])[0]; + builder.connect(padding_residual, equality_zero); + } + } + + let mut x = coset_shift; + for (bit, factor) in index_bits.iter().zip(&factor_wires) { + let selected = + builder.gate(order, &[*bit, one, data_zero, *factor, data_zero])[0]; + x = arithmetic.ext2_mul(&mut builder, x, selected); + } + let denominator_check = arithmetic.add(&mut builder, denominator, x); + let denominator_residual = + builder.gate(equality, &[denominator_check, zeta])[0]; + builder.connect(denominator_residual, equality_zero); + + let mut accumulator = initial_accumulator; + let mut alpha_power = initial_alpha_power; + for ((px, pz), quotient) in + px_values.iter().zip(&opened_at_z).zip("ients) + { + let quotient_product = + arithmetic.ext2_mul(&mut builder, denominator, *quotient); + let reconstructed = arithmetic.add(&mut builder, quotient_product, *px); + let quotient_residual = builder.gate(equality, &[reconstructed, *pz])[0]; + builder.connect(quotient_residual, equality_zero); + let term = arithmetic.ext2_mul(&mut builder, alpha_power, *quotient); + accumulator = arithmetic.add(&mut builder, accumulator, term); + alpha_power = arithmetic.ext2_mul(&mut builder, alpha_power, alpha); + } + let accumulator_residual = + builder.gate(equality, &[accumulator, reduced_accumulator])[0]; + builder.connect(accumulator_residual, equality_zero); + let alpha_power_residual = + builder.gate(equality, &[alpha_power, next_alpha_power])[0]; + builder.connect(alpha_power_residual, equality_zero); + + let mut current = constrain_hash( + &mut builder, + blake3, + &leaf_trace, + &leaf_params, + iv, + data_zero, + &packed_values, + )?; + for (level, sibling) in path.iter().enumerate() { + let ordered = builder.gate( + order, + &[index_bits[level], current[0], current[1], sibling[0], sibling[1]], + ); + let parent = builder.gate( + blake3, + &[ + iv[0], + iv[1], + ordered[0], + ordered[1], + ordered[2], + ordered[3], + node_params, + ], + ); + current = [parent[0], parent[1]]; + } + builder.publish(current[0]); + builder.publish(current[1]); + public.extend_from_slice(&pack_digest(&computation.root)); + + let shape = builder.finish().map_err(|error| { + anyhow::anyhow!("build transcript-bound PCS circuit: {error:?}") + })?; + Ok(Self { shape, slots, nu, inputs, public }) + } +} + +struct TranscriptBoundFriCommitPhaseRelation { + shape: CircuitShape, + slots: FriTableSlots, + sample_slot: SlotId, + split_slot: SlotId, + window_slot: Option, + nu: usize, + inputs: Vec, + public: Vec, +} + +#[derive(Clone, Copy)] +struct SelectedFriQuery<'a> { + query_number: usize, + query: &'a FriCommitPhaseQueryV1, + computation: &'a FriCommitPhaseComputation, + pcs_query: Option<&'a Stage2PcsQueryV1>, + pcs_computation: Option<&'a Stage2PcsQueryComputation>, +} + +impl TranscriptBoundFriCommitPhaseRelation { + #[allow(clippy::too_many_arguments)] + fn build( + prefix: &Stage2TranscriptReplayV1, + fri_transcript: &Stage2FriTranscriptReplayV1, + challenges: &Stage2FriTranscriptChallengesV1, + query_number: usize, + query: &FriCommitPhaseQueryV1, + computation: &FriCommitPhaseComputation, + ) -> Result { + Self::build_selected( + prefix, + fri_transcript, + challenges, + &[SelectedFriQuery { + query_number, + query, + computation, + pcs_query: None, + pcs_computation: None, + }], + None, + None, + ) + } + + fn build_all( + prefix: &Stage2TranscriptReplayV1, + fri_transcript: &Stage2FriTranscriptReplayV1, + challenges: &Stage2FriTranscriptChallengesV1, + queries: &[FriCommitPhaseQueryV1], + computations: &[FriCommitPhaseComputation], + ) -> Result { + if queries.len() != computations.len() { + bail!("FRI query/computation vector lengths disagree"); + } + let selected: Vec<_> = queries + .iter() + .zip(computations) + .enumerate() + .map(|(query_number, (query, computation))| SelectedFriQuery { + query_number, + query, + computation, + pcs_query: None, + pcs_computation: None, + }) + .collect(); + Self::build_selected( + prefix, + fri_transcript, + challenges, + &selected, + None, + None, + ) + } + + fn build_all_with_pcs( + prefix: &Stage2TranscriptReplayV1, + fri_transcript: &Stage2FriTranscriptReplayV1, + challenges: &Stage2FriTranscriptChallengesV1, + pcs_instance: &Stage2PcsInstanceV1, + queries: &[TranscriptBoundPcsFriQueryV1], + fri_computations: &[FriCommitPhaseComputation], + pcs_computations: &[Stage2PcsQueryComputation], + ) -> Result { + if queries.len() != fri_computations.len() + || queries.len() != pcs_computations.len() + { + bail!("PCS/FRI query and computation vector lengths disagree"); + } + let selected: Vec<_> = queries + .iter() + .zip(fri_computations) + .zip(pcs_computations) + .enumerate() + .map(|(query_number, ((query, computation), pcs_computation))| { + SelectedFriQuery { + query_number, + query: &query.fri, + computation, + pcs_query: Some(&query.pcs), + pcs_computation: Some(pcs_computation), + } + }) + .collect(); + Self::build_selected( + prefix, + fri_transcript, + challenges, + &selected, + Some(pcs_instance), + None, + ) + } + + #[allow(clippy::too_many_arguments)] + fn build_all_with_pcs_and_air( + prefix: &Stage2TranscriptReplayV1, + fri_transcript: &Stage2FriTranscriptReplayV1, + challenges: &Stage2FriTranscriptChallengesV1, + pcs_instance: &Stage2PcsInstanceV1, + air: &Stage2AirProgramV1, + queries: &[TranscriptBoundPcsFriQueryV1], + fri_computations: &[FriCommitPhaseComputation], + pcs_computations: &[Stage2PcsQueryComputation], + ) -> Result { + if queries.len() != fri_computations.len() + || queries.len() != pcs_computations.len() + { + bail!("PCS/FRI query and computation vector lengths disagree"); + } + let selected: Vec<_> = queries + .iter() + .zip(fri_computations) + .zip(pcs_computations) + .enumerate() + .map(|(query_number, ((query, computation), pcs_computation))| { + SelectedFriQuery { + query_number, + query: &query.fri, + computation, + pcs_query: Some(&query.pcs), + pcs_computation: Some(pcs_computation), + } + }) + .collect(); + Self::build_selected( + prefix, + fri_transcript, + challenges, + &selected, + Some(pcs_instance), + Some(air), + ) + } + + fn build_selected( + prefix: &Stage2TranscriptReplayV1, + fri_transcript: &Stage2FriTranscriptReplayV1, + challenges: &Stage2FriTranscriptChallengesV1, + selected: &[SelectedFriQuery<'_>], + pcs_instance: Option<&Stage2PcsInstanceV1>, + air: Option<&Stage2AirProgramV1>, + ) -> Result { + if selected.is_empty() { + bail!("transcript-bound FRI relation has no selected queries"); + } + for item in selected { + ensure_transcript_binds_fri_query( + fri_transcript, + challenges, + item.query_number, + item.query, + )?; + ensure_final_polynomial(item.query, item.computation)?; + if item.pcs_query.is_some() != pcs_instance.is_some() + || item.pcs_computation.is_some() != pcs_instance.is_some() + { + bail!("transcript-bound FRI relation has inconsistent PCS inputs"); + } + } + if air.is_some() && pcs_instance.is_none() { + bail!("AIR evaluation requires the transcript-bound PCS instance"); + } + let nu = transcript_bound_fri_nu( + prefix, + fri_transcript, + selected, + pcs_instance, + air, + )?; + let mut builder = ShapeBuilder::new(nu); + let arithmetic = GoldilocksCircuitSlots::declare(&mut builder, nu); + let blake3 = builder.slot(Blake3Gate { nu }); + let order = builder.slot(DigestOrderGate { nu }); + let equality = builder.slot(F128EqualityGate { nu }); + let sample_slot = builder.slot(HashSampleGate { nu }); + let field_sample_slot = builder.slot(GoldilocksSampleGate { nu }); + let split_slot = builder.slot(U64SplitGate { nu }); + let window_slot = pcs_instance.map(|_| builder.slot(ByteWindowGate { nu })); + let slots = FriTableSlots { + blake3, + order, + add: arithmetic.add, + mul: arithmetic.mul, + repack: arithmetic.repack, + canonical: arithmetic.canonical, + equality, + field_sample: Some(field_sample_slot), + }; + + // GoldilocksCircuitSlots declares its canonical-zero fixed input first. + let mut inputs = vec![F128::ZERO]; + let mut public = vec![F128::ZERO]; + let prefix_region = constrain_stage2_transcript( + &mut builder, + TranscriptCircuitSlots { + blake3, + sample: field_sample_slot, + canonical: arithmetic.canonical, + }, + prefix, + nu, + )?; + inputs.extend_from_slice(&prefix_region.inputs); + public.extend_from_slice(&prefix_region.inputs); + for challenge in prefix_region.challenges.all() { + builder.publish(challenge); + } + public.extend(transcript_challenge_words(prefix.challenges()?)); + + let fri_region = constrain_stage2_fri_transcript( + &mut builder, + FriTranscriptCircuitSlots { + blake3, + sample: sample_slot, + field_sample: field_sample_slot, + canonical: arithmetic.canonical, + repack: arithmetic.repack, + split: split_slot, + }, + fri_transcript, + prefix_region.state_digest, + nu, + )?; + inputs.extend_from_slice(&fri_region.inputs); + public.extend_from_slice(&fri_region.inputs); + for &beta in &fri_region.betas { + builder.publish(beta); + } + public.extend(challenges.betas.iter().copied().map(pack_extension)); + for bits in &fri_region.query_index_bits { + for &bit in bits { + builder.publish(bit); + } + } + for &index in &challenges.query_indices { + public.extend( + (0..fri_transcript.query_index_bits) + .map(|bit| F128::new((index >> bit) & 1, 0)), + ); + } + + let data_zero = + record_fixed(&mut builder, &mut inputs, &mut public, F128::ZERO); + let equality_zero = + record_fixed(&mut builder, &mut inputs, &mut public, F128::ZERO); + let packed_iv = pack8(&IV); + let iv = [ + record_fixed(&mut builder, &mut inputs, &mut public, packed_iv[0]), + record_fixed(&mut builder, &mut inputs, &mut public, packed_iv[1]), + ]; + let leaf_params = record_fixed( + &mut builder, + &mut inputs, + &mut public, + pack_params(0, 32, CHUNK_START | CHUNK_END | ROOT), + ); + let node_params = record_fixed( + &mut builder, + &mut inputs, + &mut public, + pack_params(0, 64, CHUNK_START | CHUNK_END | ROOT), + ); + let one = + record_fixed(&mut builder, &mut inputs, &mut public, F128::new(1, 0)); + let fixed = TranscriptBoundFriFixedWires { + blake3, + order, + equality, + data_zero, + equality_zero, + iv, + leaf_params, + node_params, + one, + }; + if let Some(air) = air { + constrain_stage2_air( + &mut builder, + &arithmetic, + blake3, + equality, + equality_zero, + window_slot.expect("AIR byte-window slot declared above"), + data_zero, + one, + iv, + &mut inputs, + &mut public, + &prefix_region, + prefix, + pcs_instance.expect("AIR PCS instance checked above"), + air, + )?; + } + for item in selected { + let reduced_openings = if let Some(instance) = pcs_instance { + Some(constrain_stage2_pcs_query( + &mut builder, + &arithmetic, + fixed, + &mut inputs, + &mut public, + &prefix_region, + &fri_region, + window_slot.expect("PCS byte-window slot declared above"), + instance, + item.query_number, + item.pcs_query.expect("PCS query presence checked above"), + item.pcs_computation.expect("PCS computation presence checked above"), + )?) + } else { + None + }; + constrain_transcript_bound_fri_query( + &mut builder, + &arithmetic, + fixed, + &mut inputs, + &mut public, + &fri_region, + item.query_number, + item.query, + item.computation, + reduced_openings.as_ref(), + ); + } + + let shape = builder.finish().map_err(|error| { + anyhow::anyhow!("build transcript-bound FRI circuit: {error:?}") + })?; + Ok(Self { + shape, + slots, + sample_slot, + split_slot, + window_slot, + nu, + inputs, + public, + }) + } +} + +#[derive(Clone, Copy)] +struct TranscriptBoundFriFixedWires { + blake3: SlotId, + order: SlotId, + equality: SlotId, + data_zero: Wire, + equality_zero: Wire, + iv: [Wire; 2], + leaf_params: Wire, + node_params: Wire, + one: Wire, +} + +#[allow(clippy::too_many_arguments)] +fn constrain_transcript_bound_fri_query( + builder: &mut ShapeBuilder, + arithmetic: &GoldilocksCircuitSlots, + fixed: TranscriptBoundFriFixedWires, + inputs: &mut Vec, + public: &mut Vec, + fri_region: &crate::transcript::FriTranscriptConstraintRegion, + query_number: usize, + query: &FriCommitPhaseQueryV1, + computation: &FriCommitPhaseComputation, + authenticated_reduced_openings: Option<&BTreeMap>, +) { + let factor_wires: Vec> = computation + .round_queries + .iter() + .map(|round| { + twiddle_factors(round.log_height) + .into_iter() + .map(|factor| { + record_fixed(builder, inputs, public, F128::new(factor, 0)) + }) + .collect() + }) + .collect(); + let initial_folded = authenticated_reduced_openings.map_or_else( + || { + record_public( + builder, + inputs, + public, + pack_extension(query.initial_folded), + ) + }, + |openings| openings[&(query.initial_log_height + 1)], + ); + let round_wires: Vec<_> = query + .rounds + .iter() + .zip(&computation.fold_results) + .enumerate() + .map(|(round, (source, &fold_result))| { + let depth = usize::from(query.initial_log_height) - round; + FriCommitPhaseRoundWires { + sibling: record_public( + builder, + inputs, + public, + pack_extension(source.sibling), + ), + // The beta is the transcript wire, not a duplicated query input. + beta: fri_region.betas[round], + reduced_opening: if let Some(openings) = authenticated_reduced_openings + { + let height = query.initial_log_height + - u8::try_from(round).expect("bounded FRI round index"); + openings.get(&height).copied() + } else { + source.reduced_opening.map(|value| { + record_public(builder, inputs, public, pack_extension(value)) + }) + }, + path: source + .opening_proof + .iter() + .take(depth) + .map(|sibling| { + let digest = pack_digest(sibling); + [ + record_public(builder, inputs, public, digest[0]), + record_public(builder, inputs, public, digest[1]), + ] + }) + .collect(), + result: record_public( + builder, + inputs, + public, + pack_extension(fold_result), + ), + } + }) + .collect(); + + let index_bits = &fri_region.query_index_bits[query_number]; + let mut folded = initial_folded; + for (round, wires) in round_wires.iter().enumerate() { + let root = constrain_authenticated_fold( + builder, + arithmetic, + fixed.blake3, + fixed.order, + fixed.equality, + fixed.data_zero, + fixed.equality_zero, + fixed.iv, + fixed.leaf_params, + fixed.node_params, + fixed.one, + &factor_wires[round], + folded, + wires.sibling, + wires.beta, + &index_bits[round..], + &wires.path, + wires.result, + ); + let cap_root = fri_region.commitment_roots[round][0]; + for lane in 0..2 { + let residual = + builder.gate(fixed.equality, &[root[lane], cap_root[lane]])[0]; + builder.connect(residual, fixed.equality_zero); + } + folded = if let Some(reduced_opening) = wires.reduced_opening { + let beta_squared = arithmetic.ext2_mul(builder, wires.beta, wires.beta); + let rollin = arithmetic.ext2_mul(builder, beta_squared, reduced_opening); + arithmetic.add(builder, wires.result, rollin) + } else { + wires.result + }; + } + let final_residual = + builder.gate(fixed.equality, &[folded, fri_region.final_polynomial[0]])[0]; + builder.connect(final_residual, fixed.equality_zero); +} + +struct Stage2PcsRowWires { + lanes: Vec, + base_extensions: Vec, +} + +#[allow(clippy::too_many_arguments)] +fn constrain_stage2_pcs_query( + builder: &mut ShapeBuilder, + arithmetic: &GoldilocksCircuitSlots, + fixed: TranscriptBoundFriFixedWires, + inputs: &mut Vec, + public: &mut Vec, + prefix_region: &crate::transcript::TranscriptConstraintRegion, + fri_region: &crate::transcript::FriTranscriptConstraintRegion, + window: SlotId, + instance: &Stage2PcsInstanceV1, + query_number: usize, + query: &Stage2PcsQueryV1, + computation: &Stage2PcsQueryComputation, +) -> Result> { + let index_bits = &fri_region.query_index_bits[query_number]; + let alpha = prefix_region.challenges.pcs_alpha; + let zeta = prefix_region.challenges.zeta; + arithmetic.assert_canonical(builder, alpha); + arithmetic.assert_canonical(builder, zeta); + + let mut all_rows = Vec::with_capacity(instance.batches.len()); + for (batch, opening) in instance.batches.iter().zip(&query.batch_openings) { + let mut batch_rows = Vec::with_capacity(batch.matrices.len()); + for row in &opening.opened_rows { + let mut lanes = Vec::with_capacity(row.len()); + let mut base_extensions = Vec::with_capacity(row.len()); + for &value in row { + let lane = + record_public(builder, inputs, public, F128::new(value, value)); + arithmetic.assert_canonical(builder, lane); + let extension = + builder.gate(arithmetic.repack, &[lane, fixed.data_zero])[3]; + lanes.push(lane); + base_extensions.push(extension); + } + batch_rows.push(Stage2PcsRowWires { lanes, base_extensions }); + } + all_rows.push(batch_rows); + } + + // Authenticate every multi-height batch. Rows sharing a height are + // concatenated in matrix order before hashing; shorter-height leaves are + // injected on the right after the corresponding path compression. + for (((batch, opening), batch_computation), rows) in instance + .batches + .iter() + .zip(&query.batch_openings) + .zip(&computation.batches) + .zip(&all_rows) + { + let log_batch_height = + batch.matrices.iter().map(|matrix| matrix.log_height).max().unwrap(); + let mut leaves = BTreeMap::new(); + for height in 0..=log_batch_height { + let leaf_lanes: Vec<_> = batch + .matrices + .iter() + .zip(rows) + .filter(|(matrix, _)| matrix.log_height == height) + .flat_map(|(_, row)| row.lanes.iter().copied()) + .collect(); + if leaf_lanes.is_empty() { + continue; + } + let message: Vec<_> = leaf_lanes + .chunks(2) + .map(|pair| { + builder.gate( + arithmetic.repack, + &[pair[0], pair.get(1).copied().unwrap_or(fixed.data_zero)], + )[3] + }) + .collect(); + let trace = hash_trace(leaf_lanes.len() * 8); + let parameters: Vec<_> = trace + .rows + .iter() + .map(|&(_cv, _message, counter, block_len, flags)| { + record_fixed( + builder, + inputs, + public, + pack_params(counter, block_len, flags), + ) + }) + .collect(); + let leaf = constrain_hash( + builder, + fixed.blake3, + &trace, + ¶meters, + fixed.iv, + fixed.data_zero, + &message, + )?; + leaves.insert(height, leaf); + } + + let mut current = leaves[&log_batch_height]; + let bit_offset = usize::from(instance.log_global_height - log_batch_height); + for (level, sibling) in opening.opening_proof.iter().enumerate() { + let sibling = pack_digest(sibling); + let sibling = [ + record_public(builder, inputs, public, sibling[0]), + record_public(builder, inputs, public, sibling[1]), + ]; + let ordered = builder.gate( + fixed.order, + &[ + index_bits[bit_offset + level], + current[0], + current[1], + sibling[0], + sibling[1], + ], + ); + let parent = builder.gate( + fixed.blake3, + &[ + fixed.iv[0], + fixed.iv[1], + ordered[0], + ordered[1], + ordered[2], + ordered[3], + fixed.node_params, + ], + ); + current = [parent[0], parent[1]]; + let next_height = log_batch_height + - 1 + - u8::try_from(level).expect("bounded Merkle path level"); + if let Some(&injected) = leaves.get(&next_height) { + let parent = builder.gate( + fixed.blake3, + &[ + fixed.iv[0], + fixed.iv[1], + current[0], + current[1], + injected[0], + injected[1], + fixed.node_params, + ], + ); + current = [parent[0], parent[1]]; + } + } + let expected = bound_transcript_digest( + builder, + window, + fixed.data_zero, + inputs, + public, + prefix_region, + batch.commitment, + ); + for lane in 0..2 { + let residual = + builder.gate(fixed.equality, &[current[lane], expected[lane]])[0]; + builder.connect(residual, fixed.equality_zero); + } + let _ = batch_computation.root; + } + + let mut buckets: BTreeMap = instance + .batches + .iter() + .flat_map(|batch| batch.matrices.iter().map(|matrix| matrix.log_height)) + .map(|height| (height, (fixed.one, fixed.data_zero))) + .collect(); + let coset_shift = record_fixed(builder, inputs, public, F128::new(7, 0)); + + for (((batch, opening), batch_computation), rows) in instance + .batches + .iter() + .zip(&query.batch_openings) + .zip(&computation.batches) + .zip(&all_rows) + { + for (((matrix, _row_values), matrix_computation), row) in batch + .matrices + .iter() + .zip(&opening.opened_rows) + .zip(&batch_computation.matrices) + .zip(rows) + { + let bit_offset = + usize::from(instance.log_global_height - matrix.log_height); + let mut x = coset_shift; + for (bit, factor) in index_bits[bit_offset..] + .iter() + .take(usize::from(matrix.log_height)) + .zip(pcs_x_factors(matrix.log_height)) + { + let factor = + record_fixed(builder, inputs, public, F128::new(factor, 0)); + let selected = builder.gate( + fixed.order, + &[*bit, fixed.one, fixed.data_zero, factor, fixed.data_zero], + )[0]; + x = arithmetic.ext2_mul(builder, x, selected); + } + + for (point_index, (point_kind, point_computation)) in + matrix.opening_points.iter().zip(&matrix_computation.points).enumerate() + { + let point = match *point_kind { + Stage2PcsOpeningPointV1::Zeta => zeta, + Stage2PcsOpeningPointV1::ZetaNext { log_degree } => { + let generator = Val::TWO_ADIC_GENERATORS[usize::from(log_degree)] + .as_canonical_u64(); + let generator = + record_fixed(builder, inputs, public, F128::new(generator, 0)); + arithmetic.ext2_mul(builder, zeta, generator) + }, + }; + let denominator = record_public( + builder, + inputs, + public, + pack_extension(point_computation.denominator), + ); + arithmetic.assert_canonical(builder, denominator); + let denominator_check = arithmetic.add(builder, denominator, x); + assert_f128_equal( + builder, + fixed.equality, + fixed.equality_zero, + denominator_check, + point, + ); + + let (mut alpha_power, mut accumulator) = buckets[&matrix.log_height]; + for (column, (&p_at_x, "ient_value)) in row + .base_extensions + .iter() + .zip(&point_computation.quotients) + .enumerate() + { + let p_at_z = bound_transcript_extension( + builder, + window, + fixed.data_zero, + inputs, + public, + prefix_region, + matrix.opened_values, + point_index * matrix.width + column, + ); + arithmetic.assert_canonical(builder, p_at_z); + let quotient = record_public( + builder, + inputs, + public, + pack_extension(quotient_value), + ); + arithmetic.assert_canonical(builder, quotient); + let quotient_product = + arithmetic.ext2_mul(builder, denominator, quotient); + let reconstructed = arithmetic.add(builder, quotient_product, p_at_x); + assert_f128_equal( + builder, + fixed.equality, + fixed.equality_zero, + reconstructed, + p_at_z, + ); + let term = arithmetic.ext2_mul(builder, alpha_power, quotient); + accumulator = arithmetic.add(builder, accumulator, term); + alpha_power = arithmetic.ext2_mul(builder, alpha_power, alpha); + } + buckets.insert(matrix.log_height, (alpha_power, accumulator)); + } + } + } + + Ok( + buckets + .into_iter() + .map(|(height, (_, accumulator))| (height, accumulator)) + .collect(), + ) +} + +#[allow(clippy::too_many_arguments)] +pub(crate) fn bound_transcript_extension( + builder: &mut ShapeBuilder, + window: SlotId, + data_zero: Wire, + inputs: &mut Vec, + public: &mut Vec, + region: &crate::transcript::TranscriptConstraintRegion, + binding: Stage2TranscriptByteBindingV1, + extension_offset: usize, +) -> Wire { + bound_transcript_window( + builder, + window, + data_zero, + inputs, + public, + region, + binding, + extension_offset * 16, + ) +} + +fn bound_transcript_digest( + builder: &mut ShapeBuilder, + window: SlotId, + data_zero: Wire, + inputs: &mut Vec, + public: &mut Vec, + region: &crate::transcript::TranscriptConstraintRegion, + binding: Stage2TranscriptByteBindingV1, +) -> [Wire; 2] { + [ + bound_transcript_window( + builder, window, data_zero, inputs, public, region, binding, 0, + ), + bound_transcript_window( + builder, window, data_zero, inputs, public, region, binding, 16, + ), + ] +} + +#[allow(clippy::too_many_arguments)] +pub(crate) fn bound_transcript_window( + builder: &mut ShapeBuilder, + window: SlotId, + data_zero: Wire, + inputs: &mut Vec, + public: &mut Vec, + region: &crate::transcript::TranscriptConstraintRegion, + binding: Stage2TranscriptByteBindingV1, + relative_byte: usize, +) -> Wire { + let byte_offset = binding.byte_offset + relative_byte; + let word_index = byte_offset / 16; + let byte_in_word = byte_offset % 16; + let words = ®ion.observation_words[binding.segment.index()]; + let first = words[word_index]; + let second = words.get(word_index + 1).copied().unwrap_or(data_zero); + let selector = + record_fixed(builder, inputs, public, F128::new(1 << byte_in_word, 0)); + builder.gate(window, &[first, second, selector])[0] +} + +pub(crate) fn assert_f128_equal( + builder: &mut ShapeBuilder, + equality: SlotId, + equality_zero: Wire, + left: Wire, + right: Wire, +) { + let residual = builder.gate(equality, &[left, right])[0]; + builder.connect(residual, equality_zero); +} + +fn transcript_bound_fri_nu( + prefix: &Stage2TranscriptReplayV1, + fri_transcript: &Stage2FriTranscriptReplayV1, + selected: &[SelectedFriQuery<'_>], + pcs_instance: Option<&Stage2PcsInstanceV1>, + air: Option<&Stage2AirProgramV1>, +) -> Result { + let prefix_capacity = 1usize << transcript_nu(prefix)?; + let commit_capacity = selected.iter().try_fold(0usize, |rows, item| { + rows.checked_add(1usize << commit_phase_nu(item.query)).ok_or_else(|| { + anyhow::anyhow!("transcript-bound FRI query row budget overflow") + }) + })?; + let fri_blake3_rows = fri_transcript_blake3_rows(fri_transcript)?; + let fri_split_rows = fri_transcript_split_rows(fri_transcript)?; + let pcs_capacity = if let Some(instance) = pcs_instance { + selected.iter().try_fold(0usize, |rows, item| { + let query = item + .pcs_query + .ok_or_else(|| anyhow::anyhow!("missing PCS query row budget"))?; + rows + .checked_add(1usize << stage2_pcs_query_nu(instance, query)) + .ok_or_else(|| anyhow::anyhow!("PCS query row budget overflow")) + })? + } else { + 0 + }; + let row_budget = prefix_capacity + .checked_add(commit_capacity) + .and_then(|rows| rows.checked_add(fri_blake3_rows)) + .and_then(|rows| rows.checked_add(fri_split_rows)) + .and_then(|rows| rows.checked_add(pcs_capacity)) + .and_then(|rows| { + rows.checked_add(air.map_or(0, Stage2AirProgramV1::row_budget)) + }) + .ok_or_else(|| { + anyhow::anyhow!("transcript-bound FRI row budget overflow") + })?; + Ok( + usize::try_from(row_budget.max(1).next_power_of_two().ilog2()) + .expect("FRI row logarithm fits usize") + .max(NU), + ) +} + +fn stage2_pcs_query_nu( + instance: &Stage2PcsInstanceV1, + query: &Stage2PcsQueryV1, +) -> usize { + let values = query + .batch_openings + .iter() + .flat_map(|batch| &batch.opened_rows) + .map(Vec::len) + .sum::(); + let points = instance + .batches + .iter() + .flat_map(|batch| &batch.matrices) + .map(|matrix| matrix.opening_points.len()) + .sum::(); + let path_nodes = query + .batch_openings + .iter() + .map(|batch| batch.opening_proof.len()) + .sum::(); + let leaf_rows = instance + .batches + .iter() + .zip(&query.batch_openings) + .map(|(batch, opening)| { + (0..=batch.matrices.iter().map(|matrix| matrix.log_height).max().unwrap()) + .filter(|height| { + batch.matrices.iter().any(|matrix| matrix.log_height == *height) + }) + .map(|height| { + let width = batch + .matrices + .iter() + .zip(&opening.opened_rows) + .filter(|(matrix, _)| matrix.log_height == height) + .map(|(_, row)| row.len()) + .sum::(); + hash_trace(width * 8).rows.len() + }) + .sum::() + }) + .sum::(); + let row_bound = values + .saturating_mul(256) + .saturating_add(points.saturating_mul(128)) + .saturating_add(path_nodes.saturating_mul(64)) + .saturating_add(leaf_rows) + .max(1); + usize::try_from(row_bound.next_power_of_two().ilog2()).unwrap().max(NU) +} + +pub(crate) fn record_fixed( + builder: &mut ShapeBuilder, + inputs: &mut Vec, + public: &mut Vec, + value: F128, +) -> Wire { + inputs.push(value); + public.push(value); + builder.fixed_public_input(value) +} + +fn record_public( + builder: &mut ShapeBuilder, + inputs: &mut Vec, + public: &mut Vec, + value: F128, +) -> Wire { + inputs.push(value); + public.push(value); + builder.public_input() +} + +#[allow(clippy::too_many_arguments)] +fn constrain_authenticated_fold( + builder: &mut ShapeBuilder, + arithmetic: &GoldilocksCircuitSlots, + blake3_slot: SlotId, + order_slot: SlotId, + equality_slot: SlotId, + data_zero: Wire, + equality_zero: Wire, + iv: [Wire; 2], + leaf_params: Wire, + node_params: Wire, + one: Wire, + factor_wires: &[Wire], + folded: Wire, + sibling: Wire, + beta: Wire, + index_bits: &[Wire], + path: &[[Wire; 2]], + folded_result: Wire, +) -> [Wire; 2] { + assert_eq!(index_bits.len(), path.len() + 1); + assert_eq!(factor_wires.len(), path.len()); + + // The low query bit determines which value is e0 and which is e1. + let ordered_evals = builder + .gate(order_slot, &[index_bits[0], folded, data_zero, sibling, data_zero]); + let e0 = ordered_evals[0]; + let e1 = ordered_evals[2]; + + // ExtensionMmcs serializes `[e0.c0,e0.c1,e1.c0,e1.c1]` as 32 little- + // endian bytes before hashing the leaf. + let leaf = builder.gate( + blake3_slot, + &[iv[0], iv[1], e0, e1, data_zero, data_zero, leaf_params], + ); + let mut current = [leaf[0], leaf[1]]; + for (level, sibling_digest) in path.iter().enumerate() { + let ordered = builder.gate( + order_slot, + &[ + index_bits[level + 1], + current[0], + current[1], + sibling_digest[0], + sibling_digest[1], + ], + ); + let parent = builder.gate( + blake3_slot, + &[ + iv[0], + iv[1], + ordered[0], + ordered[1], + ordered[2], + ordered[3], + node_params, + ], + ); + current = [parent[0], parent[1]]; + } + + // `s = g_(h+1)^reverse_bits(index >> 1, h)`. Each original LSB-first + // bit selects its corresponding pre-squared factor. + let mut s = one; + for (bit, factor) in index_bits[1..].iter().zip(factor_wires) { + let selected = + builder.gate(order_slot, &[*bit, one, data_zero, *factor, data_zero])[0]; + s = arithmetic.ext2_mul(builder, s, selected); + } + + let sum = arithmetic.add(builder, e0, e1); + let two_s = arithmetic.add(builder, s, s); + let lhs_fold = arithmetic.ext2_mul(builder, two_s, folded_result); + let lhs_beta = arithmetic.ext2_mul(builder, beta, e1); + let lhs = arithmetic.add(builder, lhs_fold, lhs_beta); + let rhs_sum = arithmetic.ext2_mul(builder, s, sum); + let rhs_beta = arithmetic.ext2_mul(builder, beta, e0); + let rhs = arithmetic.add(builder, rhs_sum, rhs_beta); + let equality_residual = builder.gate(equality_slot, &[lhs, rhs])[0]; + builder.connect(equality_residual, equality_zero); + current +} + +#[allow(clippy::too_many_arguments)] +fn prove_fri_circuit( + shape: &CircuitShape, + slots: FriTableSlots, + sample_slot: Option, + split_slot: Option, + window_slot: Option, + nu: usize, + inputs: &[F128], + expected_public: &[F128], + transcript_domain: &[u8], +) -> Result> { + let witness = shape.run(inputs, &[]); + if witness.public != expected_public { + bail!("Flock authenticated-FRI circuit disagrees with native semantics"); + } + let blake3_rows = witness.rows::(slots.blake3); + let order_rows = witness.rows::(slots.order); + let add_rows = witness.rows::(slots.add); + let mul_rows = witness.rows::(slots.mul); + let repack_rows = witness.rows::(slots.repack); + let canonical_rows = + witness.rows::(slots.canonical); + let equality_rows = witness.rows::(slots.equality); + let sample_rows = + sample_slot.map(|slot| witness.rows::(slot)); + let field_sample_rows = + slots.field_sample.map(|slot| witness.rows::(slot)); + let split_rows = split_slot.map(|slot| witness.rows::(slot)); + let window_rows = + window_slot.map(|slot| witness.rows::(slot)); + + let blake3_r1cs = flock_blake3::build_block_r1cs(nu); + let blake3_lincheck = blake3_r1cs.csc_lincheck_circuit(); + let order_r1cs = build_digest_order_r1cs(nu); + let order_lincheck = order_r1cs.csc_lincheck_circuit(); + let add_r1cs = build_goldilocks_add_r1cs(nu); + let add_lincheck = add_r1cs.csc_lincheck_circuit(); + let mul_r1cs = build_goldilocks_mul_r1cs(nu); + let mul_lincheck = mul_r1cs.csc_lincheck_circuit(); + let repack_r1cs = build_lane_repack_r1cs(nu); + let repack_lincheck = repack_r1cs.csc_lincheck_circuit(); + let canonical_r1cs = build_canonical_pair_r1cs(nu); + let canonical_lincheck = canonical_r1cs.csc_lincheck_circuit(); + let equality_r1cs = build_f128_equality_r1cs(nu); + let equality_lincheck = equality_r1cs.csc_lincheck_circuit(); + let sample_r1cs = build_hash_sample_r1cs(nu); + let sample_lincheck = sample_r1cs.csc_lincheck_circuit(); + let field_sample_r1cs = build_goldilocks_sample_r1cs(nu); + let field_sample_lincheck = field_sample_r1cs.csc_lincheck_circuit(); + let split_r1cs = build_u64_split_r1cs(nu); + let split_lincheck = split_r1cs.csc_lincheck_circuit(); + let window_r1cs = build_byte_window_r1cs(nu); + let window_lincheck = window_r1cs.csc_lincheck_circuit(); + + let mut slot_inputs = vec![ + ( + shape.registry_slot(slots.blake3), + UnionSlotProverInput::new( + flock_blake3::generate_witness_batch_major_partial(blake3_rows, nu), + blake3_lincheck, + ), + ), + ( + shape.registry_slot(slots.order), + UnionSlotProverInput::new( + generate_digest_order_witness(order_rows, nu), + order_lincheck, + ), + ), + ( + shape.registry_slot(slots.add), + UnionSlotProverInput::new( + generate_goldilocks_add_witness(add_rows, nu), + add_lincheck, + ), + ), + ( + shape.registry_slot(slots.mul), + UnionSlotProverInput::new( + generate_goldilocks_mul_witness(mul_rows, nu), + mul_lincheck, + ), + ), + ( + shape.registry_slot(slots.repack), + UnionSlotProverInput::new( + generate_lane_repack_witness(repack_rows, nu), + repack_lincheck, + ), + ), + ( + shape.registry_slot(slots.canonical), + UnionSlotProverInput::new( + generate_canonical_pair_witness(canonical_rows, nu), + canonical_lincheck, + ), + ), + ( + shape.registry_slot(slots.equality), + UnionSlotProverInput::new( + generate_f128_equality_witness(equality_rows, nu), + equality_lincheck, + ), + ), + ]; + if let (Some(slot), Some(rows)) = (sample_slot, sample_rows) { + slot_inputs.push(( + shape.registry_slot(slot), + UnionSlotProverInput::new( + generate_hash_sample_witness(rows, nu), + sample_lincheck, + ), + )); + } + if let (Some(slot), Some(rows)) = (slots.field_sample, field_sample_rows) { + slot_inputs.push(( + shape.registry_slot(slot), + UnionSlotProverInput::new( + generate_goldilocks_sample_witness(rows, nu), + field_sample_lincheck, + ), + )); + } + if let (Some(slot), Some(rows)) = (split_slot, split_rows) { + slot_inputs.push(( + shape.registry_slot(slot), + UnionSlotProverInput::new( + generate_u64_split_witness(rows, nu), + split_lincheck, + ), + )); + } + if let (Some(slot), Some(rows)) = (window_slot, window_rows) { + slot_inputs.push(( + shape.registry_slot(slot), + UnionSlotProverInput::new( + generate_byte_window_witness(rows, nu), + window_lincheck, + ), + )); + } + sort_and_validate_slots(&mut slot_inputs)?; + let slot_inputs = slot_inputs.into_iter().map(|(_, input)| input).collect(); + let union = UnionInstance::new(&shape.registry, shape.counts.clone()); + let params = pcs_params(&union); + let mut challenger = FsChallenger::with_chained_blake3(transcript_domain); + let (proof, commitment, _) = prover::prove_fast_ligerito_union_circuit( + &union, + &shape.circuit, + &witness.public, + ¶ms, + slot_inputs, + Vec::new(), + &mut challenger, + ); + let proof_bundle_bytes = + encode_bundle(&FriFoldProofBundle { commitment, proof })?; + if proof_bundle_bytes.len() > MAX_BUNDLE_BYTES { + bail!("Flock authenticated-FRI proof exceeds {MAX_BUNDLE_BYTES} bytes"); + } + Ok(proof_bundle_bytes) +} + +#[allow(clippy::too_many_arguments)] +fn verify_fri_circuit( + shape: &CircuitShape, + slots: FriTableSlots, + sample_slot: Option, + split_slot: Option, + window_slot: Option, + nu: usize, + public: &[F128], + proof_bundle_bytes: &[u8], + transcript_domain: &[u8], +) -> Result<()> { + let bundle = decode_bundle(proof_bundle_bytes) + .context("decode Flock authenticated-FRI conformance proof bundle")?; + let blake3_r1cs = flock_blake3::build_block_r1cs(nu); + let blake3_lincheck = blake3_r1cs.csc_lincheck_circuit(); + let order_r1cs = build_digest_order_r1cs(nu); + let order_lincheck = order_r1cs.csc_lincheck_circuit(); + let add_r1cs = build_goldilocks_add_r1cs(nu); + let add_lincheck = add_r1cs.csc_lincheck_circuit(); + let mul_r1cs = build_goldilocks_mul_r1cs(nu); + let mul_lincheck = mul_r1cs.csc_lincheck_circuit(); + let repack_r1cs = build_lane_repack_r1cs(nu); + let repack_lincheck = repack_r1cs.csc_lincheck_circuit(); + let canonical_r1cs = build_canonical_pair_r1cs(nu); + let canonical_lincheck = canonical_r1cs.csc_lincheck_circuit(); + let equality_r1cs = build_f128_equality_r1cs(nu); + let equality_lincheck = equality_r1cs.csc_lincheck_circuit(); + let sample_r1cs = build_hash_sample_r1cs(nu); + let sample_lincheck = sample_r1cs.csc_lincheck_circuit(); + let field_sample_r1cs = build_goldilocks_sample_r1cs(nu); + let field_sample_lincheck = field_sample_r1cs.csc_lincheck_circuit(); + let split_r1cs = build_u64_split_r1cs(nu); + let split_lincheck = split_r1cs.csc_lincheck_circuit(); + let window_r1cs = build_byte_window_r1cs(nu); + let window_lincheck = window_r1cs.csc_lincheck_circuit(); + + let mut linchecks: Vec<(usize, &dyn LincheckCircuit)> = vec![ + (shape.registry_slot(slots.blake3), blake3_lincheck), + (shape.registry_slot(slots.order), order_lincheck), + (shape.registry_slot(slots.add), add_lincheck), + (shape.registry_slot(slots.mul), mul_lincheck), + (shape.registry_slot(slots.repack), repack_lincheck), + (shape.registry_slot(slots.canonical), canonical_lincheck), + (shape.registry_slot(slots.equality), equality_lincheck), + ]; + if let Some(slot) = sample_slot { + linchecks.push((shape.registry_slot(slot), sample_lincheck)); + } + if let Some(slot) = slots.field_sample { + linchecks.push((shape.registry_slot(slot), field_sample_lincheck)); + } + if let Some(slot) = split_slot { + linchecks.push((shape.registry_slot(slot), split_lincheck)); + } + if let Some(slot) = window_slot { + linchecks.push((shape.registry_slot(slot), window_lincheck)); + } + sort_and_validate_slots(&mut linchecks)?; + let linchecks: Vec<&dyn LincheckCircuit> = + linchecks.into_iter().map(|(_, lincheck)| lincheck).collect(); + let union = UnionInstance::new(&shape.registry, shape.counts.clone()); + let params = pcs_params(&union); + let mut challenger = FsChallenger::with_chained_blake3(transcript_domain); + verifier::verify_ligerito_union_circuit( + &union, + &shape.circuit, + public, + &linchecks, + &bundle.commitment, + &bundle.proof, + ¶ms, + &mut challenger, + ) + .map_err(|error| { + anyhow::anyhow!("Flock authenticated-FRI proof rejected: {error:?}") + })?; + Ok(()) +} + +fn relation_inputs( + query: &FriFoldQueryV1, + folded_result: [u64; 2], +) -> Vec { + let packed_iv = pack8(&IV); + let mut inputs = Vec::with_capacity(10 + 4 * usize::from(query.log_height)); + inputs.push(F128::ZERO); + inputs.push(F128::ZERO); + inputs.push(F128::ZERO); + inputs.extend_from_slice(&packed_iv); + inputs.push(pack_params(0, 32, CHUNK_START | CHUNK_END | ROOT)); + inputs.push(pack_params(0, 64, CHUNK_START | CHUNK_END | ROOT)); + inputs.push(F128::new(1, 0)); + inputs.extend( + twiddle_factors(query.log_height) + .into_iter() + .map(|factor| F128::new(factor, 0)), + ); + inputs.push(pack_extension(query.folded)); + inputs.push(pack_extension(query.sibling)); + inputs.push(pack_extension(query.beta)); + inputs.extend( + (0..=query.log_height) + .map(|bit| F128::new(u64::from((query.query_index >> bit) & 1), 0)), + ); + for sibling in &query.opening_proof { + inputs.extend_from_slice(&pack_digest(sibling)); + } + inputs.push(pack_extension(folded_result)); + inputs +} + +fn relation_public( + query: &FriFoldQueryV1, + folded_result: [u64; 2], + root: &[u8; 32], +) -> Vec { + let mut public = relation_inputs(query, folded_result); + public.extend_from_slice(&pack_digest(root)); + public +} + +struct FriCommitPhaseComputation { + round_queries: Vec, + fold_results: Vec<[u64; 2]>, + results: Vec<[u64; 2]>, + roots: Vec<[u8; 32]>, +} + +fn compute_commit_phase( + query: &FriCommitPhaseQueryV1, +) -> Result { + validate_commit_phase_structure(query)?; + let mut folded = query.initial_folded; + let mut round_queries = Vec::with_capacity(query.rounds.len()); + let mut fold_results = Vec::with_capacity(query.rounds.len()); + let mut results = Vec::with_capacity(query.rounds.len()); + let mut roots = Vec::with_capacity(query.rounds.len()); + for (round_index, round) in query.rounds.iter().enumerate() { + let round_u8 = u8::try_from(round_index).expect("bounded FRI round count"); + let fold_query = FriFoldQueryV1 { + log_height: query.initial_log_height - round_u8, + query_index: query.query_index >> round_index, + folded, + sibling: round.sibling, + beta: round.beta, + opening_proof: round.opening_proof.clone(), + }; + validate_query(&fold_query)?; + let fold_result = native_fold(&fold_query); + let result = round.reduced_opening.map_or(fold_result, |reduced_opening| { + let beta = native_extension(round.beta); + extension_words( + native_extension(fold_result) + + beta * beta * native_extension(reduced_opening), + ) + }); + roots.push(native_root(&fold_query)); + fold_results.push(fold_result); + results.push(result); + round_queries.push(fold_query); + folded = result; + } + Ok(FriCommitPhaseComputation { round_queries, fold_results, results, roots }) +} + +fn ensure_final_polynomial( + query: &FriCommitPhaseQueryV1, + computation: &FriCommitPhaseComputation, +) -> Result<()> { + if computation.results.last().copied() != Some(query.final_polynomial) { + bail!("FRI commit-phase fold chain does not equal the final polynomial"); + } + Ok(()) +} + +fn commit_phase_relation_inputs( + query: &FriCommitPhaseQueryV1, + computation: &FriCommitPhaseComputation, +) -> Vec { + let packed_iv = pack8(&IV); + let factor_count = computation + .round_queries + .iter() + .map(|round| usize::from(round.log_height)) + .sum::(); + let path_words = computation + .round_queries + .iter() + .map(|round| 2 * round.opening_proof.len()) + .sum::(); + let mut inputs = Vec::with_capacity( + 8 + factor_count + + 1 + + usize::from(query.initial_log_height) + + 1 + + 3 * query.rounds.len() + + query + .rounds + .iter() + .filter(|round| round.reduced_opening.is_some()) + .count() + + path_words + + 1, + ); + inputs.extend_from_slice(&[F128::ZERO, F128::ZERO, F128::ZERO]); + inputs.extend_from_slice(&packed_iv); + inputs.push(pack_params(0, 32, CHUNK_START | CHUNK_END | ROOT)); + inputs.push(pack_params(0, 64, CHUNK_START | CHUNK_END | ROOT)); + inputs.push(F128::new(1, 0)); + for round in &computation.round_queries { + inputs.extend( + twiddle_factors(round.log_height) + .into_iter() + .map(|factor| F128::new(factor, 0)), + ); + } + inputs.push(pack_extension(query.initial_folded)); + inputs.extend( + (0..=query.initial_log_height) + .map(|bit| F128::new(u64::from((query.query_index >> bit) & 1), 0)), + ); + for ((round, source), fold_result) in computation + .round_queries + .iter() + .zip(&query.rounds) + .zip(&computation.fold_results) + { + inputs.push(pack_extension(round.sibling)); + inputs.push(pack_extension(round.beta)); + if let Some(reduced_opening) = source.reduced_opening { + inputs.push(pack_extension(reduced_opening)); + } + for sibling in &round.opening_proof { + inputs.extend_from_slice(&pack_digest(sibling)); + } + inputs.push(pack_extension(*fold_result)); + } + inputs.push(pack_extension(query.final_polynomial)); + inputs +} + +fn commit_phase_relation_public( + query: &FriCommitPhaseQueryV1, + computation: &FriCommitPhaseComputation, +) -> Vec { + let mut public = commit_phase_relation_inputs(query, computation); + for root in &computation.roots { + public.extend_from_slice(&pack_digest(root)); + } + public +} + +fn commit_phase_nu(query: &FriCommitPhaseQueryV1) -> usize { + let rounds = query.rounds.len(); + let rollins = + query.rounds.iter().filter(|round| round.reduced_opening.is_some()).count(); + let height_sum = (0..rounds) + .map(|round| usize::from(query.initial_log_height) - round) + .sum::(); + let extension_multiplications = height_sum + 4 * rounds + 2 * rollins; + let row_bound = [ + 5 * extension_multiplications + 4 * rounds + rollins, + 2 * extension_multiplications, + 3 * extension_multiplications, + 9 * extension_multiplications + 4 * rounds + rollins, + 2 * height_sum + rounds, + height_sum + rounds, + rounds + 1, + ] + .into_iter() + .max() + .unwrap(); + usize::try_from(row_bound.next_power_of_two().ilog2()).unwrap().max(NU) +} + +fn validate_commit_phase_structure( + query: &FriCommitPhaseQueryV1, +) -> Result<()> { + validate_log_height(query.initial_log_height)?; + validate_commit_phase_round_count( + query.initial_log_height, + query.rounds.len(), + )?; + if u64::from(query.query_index) >= 1u64 << (query.initial_log_height + 1) { + bail!( + "FRI commit-phase query index {} does not fit {} bits", + query.query_index, + query.initial_log_height + 1 + ); + } + validate_extension(query.initial_folded, "initial folded evaluation")?; + validate_extension(query.final_polynomial, "final polynomial")?; + for (round_index, round) in query.rounds.iter().enumerate() { + let expected_depth = usize::from(query.initial_log_height) - round_index; + if round.opening_proof.len() != expected_depth { + bail!( + "FRI commit-phase round {round_index} has path depth {}; expected {expected_depth}", + round.opening_proof.len() + ); + } + validate_extension(round.sibling, "FRI round sibling")?; + validate_extension(round.beta, "FRI round challenge")?; + if let Some(reduced_opening) = round.reduced_opening { + validate_extension(reduced_opening, "FRI reduced opening")?; + } + } + Ok(()) +} + +fn validate_commit_phase_round_count( + initial_log_height: u8, + round_count: usize, +) -> Result<()> { + let maximum = usize::from(initial_log_height).min(MAX_COMMIT_PHASE_ROUNDS); + if !(1..=maximum).contains(&round_count) { + bail!("FRI commit-phase round count {round_count}; expected 1..={maximum}"); + } + Ok(()) +} + +fn commit_phase_rounds_bytes( + initial_log_height: u8, + round_count: usize, +) -> Result { + validate_commit_phase_round_count(initial_log_height, round_count)?; + (0..round_count).try_fold(0usize, |length, round| { + let depth = usize::from(initial_log_height) - round; + length + .checked_add(49 + depth * 32) + .ok_or_else(|| anyhow::anyhow!("FRI commit-phase round bytes overflow")) + }) +} + +fn build_stage2_pcs_fri_witness( + prepared: &ValidatedStage2RootV1, + fri: &FriParameters, + typed: &Stage3TypedProofWitnessV1, +) -> Result { + typed.ensure_profile(prepared.advice_profile())?; + let key = AiurVerifyingKey::from_bytes(prepared.verifying_key_bytes()) + .map_err(|error| anyhow::anyhow!("decode Aiur PCS key: {error}"))?; + if key.to_bytes() != prepared.verifying_key_bytes() { + bail!("Aiur PCS key is not canonically encoded"); + } + if key.commitment_parameters().cap_height != 0 { + bail!("Stage 3 PCS lowering currently requires cap height zero"); + } + if fri_parameter_words(&key.fri_parameters()) != fri_parameter_words(fri) { + bail!("Stage 3 PCS lowering uses different FRI parameters"); + } + + let prefix = + Stage2TranscriptReplayV1::from_prepared_and_typed(prepared, fri, typed)?; + let fri_transcript = + Stage2FriTranscriptReplayV1::from_prepared_and_typed(prepared, fri, typed)?; + let metadata = key.pcs_circuit_metadata(); + if metadata.len() != typed.active.len() { + bail!("Aiur PCS metadata and activation lengths disagree"); + } + let active_indices: Vec<_> = typed + .active + .iter() + .enumerate() + .filter_map(|(index, &active)| active.then_some(index)) + .collect(); + if active_indices.len() != typed.log_degrees.len() { + bail!("Aiur PCS active-circuit and log-degree counts disagree"); + } + let mut active_position = vec![None; typed.active.len()]; + for (position, &circuit) in active_indices.iter().enumerate() { + active_position[circuit] = Some(position); + } + + let preprocessed_roots = key.preprocessed_commitment_roots(); + let initial_preprocessed_offset = key + .transcript_seed_and_shape_bytes() + .len() + .checked_add(typed.active.len() * 8) + .ok_or_else(|| anyhow::anyhow!("initial transcript offset overflow"))?; + let initial_stage_1_offset = initial_preprocessed_offset + .checked_add( + preprocessed_roots.as_ref().map_or(0, |roots| roots.len() * 32), + ) + .ok_or_else(|| anyhow::anyhow!("Stage 1 commitment offset overflow"))?; + ensure_single_root(&typed.commitments.stage_1_trace, "Stage 1")?; + ensure_single_root(&typed.commitments.stage_2_trace, "Stage 2")?; + ensure_single_root(&typed.commitments.quotient_chunks, "quotient")?; + + let log_blowup = u8::try_from(key.commitment_parameters().log_blowup) + .map_err(|_| anyhow::anyhow!("PCS blowup height exceeds u8"))?; + let mut opening_offset = 0usize; + + let mut stage_1_matrices = Vec::with_capacity(active_indices.len()); + for (position, &circuit_index) in active_indices.iter().enumerate() { + let circuit = metadata[circuit_index]; + ensure_opened_matrix_shape( + &typed.stage_1_opened_values, + position, + 2, + circuit.main_width, + "Stage 1", + )?; + let log_degree = typed.log_degrees[position]; + stage_1_matrices.push(stage2_pcs_matrix( + log_degree, + log_blowup, + circuit.main_width, + vec![ + Stage2PcsOpeningPointV1::Zeta, + Stage2PcsOpeningPointV1::ZetaNext { log_degree }, + ], + &mut opening_offset, + )?); + } + + let mut stage_2_matrices = Vec::with_capacity(active_indices.len()); + for (position, &circuit_index) in active_indices.iter().enumerate() { + let circuit = metadata[circuit_index]; + ensure_opened_matrix_shape( + &typed.stage_2_opened_values, + position, + 2, + circuit.stage_2_width, + "Stage 2", + )?; + let log_degree = typed.log_degrees[position]; + stage_2_matrices.push(stage2_pcs_matrix( + log_degree, + log_blowup, + circuit.stage_2_width, + vec![ + Stage2PcsOpeningPointV1::Zeta, + Stage2PcsOpeningPointV1::ZetaNext { log_degree }, + ], + &mut opening_offset, + )?); + } + + let mut quotient_matrices = Vec::with_capacity(active_indices.len()); + for (position, &circuit_index) in active_indices.iter().enumerate() { + let circuit = metadata[circuit_index]; + ensure_opened_matrix_shape( + &typed.quotient_opened_values, + position, + 1, + circuit.quotient_width, + "quotient", + )?; + quotient_matrices.push(stage2_pcs_matrix( + typed.log_degrees[position], + log_blowup, + circuit.quotient_width, + vec![Stage2PcsOpeningPointV1::Zeta], + &mut opening_offset, + )?); + } + + let mut batches = vec![ + Stage2PcsBatchV1 { + commitment: Stage2TranscriptByteBindingV1::new( + Stage2TranscriptSegmentV1::Initial, + initial_stage_1_offset, + ), + matrices: stage_1_matrices, + }, + Stage2PcsBatchV1 { + commitment: Stage2TranscriptByteBindingV1::new( + Stage2TranscriptSegmentV1::Stage2AndAccumulator, + 0, + ), + matrices: stage_2_matrices, + }, + Stage2PcsBatchV1 { + commitment: Stage2TranscriptByteBindingV1::new( + Stage2TranscriptSegmentV1::QuotientCommitment, + 0, + ), + matrices: quotient_matrices, + }, + ]; + + if let Some(roots) = &preprocessed_roots { + ensure_single_root(roots, "preprocessed")?; + let opened = + typed.preprocessed_opened_values.as_ref().ok_or_else(|| { + anyhow::anyhow!("preprocessed commitment has no opened-value round") + })?; + let mut preprocessed: Vec<_> = metadata + .iter() + .enumerate() + .filter_map(|(circuit, metadata)| { + metadata.preprocessed_slot.map(|slot| (slot, circuit, *metadata)) + }) + .collect(); + preprocessed.sort_by_key(|(slot, _, _)| *slot); + if opened.len() != preprocessed.len() { + bail!("preprocessed PCS metadata and opened-value counts disagree"); + } + let mut matrices = Vec::with_capacity(preprocessed.len()); + for (expected_slot, (slot, circuit_index, circuit)) in + preprocessed.into_iter().enumerate() + { + if slot != expected_slot { + bail!("preprocessed PCS slots are not contiguous"); + } + let (log_degree, opening_points) = + if let Some(position) = active_position[circuit_index] { + ensure_opened_matrix_shape( + opened, + slot, + 2, + circuit.preprocessed_width, + "preprocessed", + )?; + let log_degree = typed.log_degrees[position]; + ( + log_degree, + vec![ + Stage2PcsOpeningPointV1::Zeta, + Stage2PcsOpeningPointV1::ZetaNext { log_degree }, + ], + ) + } else { + ensure_opened_matrix_shape( + opened, + slot, + 0, + circuit.preprocessed_width, + "inactive preprocessed", + )?; + let height = circuit.preprocessed_height; + if !height.is_power_of_two() { + bail!("preprocessed matrix height is not a power of two"); + } + ( + u8::try_from(height.ilog2()) + .map_err(|_| anyhow::anyhow!("preprocessed height exceeds u8"))?, + Vec::new(), + ) + }; + matrices.push(stage2_pcs_matrix( + log_degree, + log_blowup, + circuit.preprocessed_width, + opening_points, + &mut opening_offset, + )?); + } + batches.push(Stage2PcsBatchV1 { + commitment: Stage2TranscriptByteBindingV1::new( + Stage2TranscriptSegmentV1::Initial, + initial_preprocessed_offset, + ), + matrices, + }); + } else if typed.preprocessed_opened_values.is_some() { + bail!("proof has preprocessed openings but the key has no commitment"); + } + if opening_offset != prefix.pcs_opening_observations.len() { + bail!( + "Stage 2 PCS adapter consumed {opening_offset} opening bytes; transcript has {}", + prefix.pcs_opening_observations.len() + ); + } + + let pcs_instance = Stage2PcsInstanceV1 { + log_global_height: fri_transcript.query_index_bits, + log_blowup, + batches, + }; + validate_stage2_pcs_instance(&prefix, &pcs_instance)?; + let prefix_challenges = prefix.challenges()?; + let fri_challenges = fri_transcript.challenges(&prefix)?; + if typed.opening_proof.query_proofs.len() + != fri_challenges.query_indices.len() + { + bail!("typed PCS query count disagrees with transcript samples"); + } + if typed.opening_proof.final_poly.as_slice() + != fri_transcript.final_polynomial + { + bail!("typed and transcript final polynomials disagree"); + } + let final_polynomial = + *typed.opening_proof.final_poly.first().ok_or_else(|| { + anyhow::anyhow!("Stage 2 FRI final polynomial is empty") + })?; + if typed.opening_proof.final_poly.len() != 1 { + bail!( + "Stage 3 PCS/FRI adapter currently requires a constant final polynomial" + ); + } + + let mut queries = Vec::with_capacity(fri_challenges.query_indices.len()); + for (query_number, (typed_query, &query_index)) in typed + .opening_proof + .query_proofs + .iter() + .zip(&fri_challenges.query_indices) + .enumerate() + { + if typed_query.input_proof.len() != pcs_instance.batches.len() { + bail!("typed PCS query {query_number} has the wrong batch count"); + } + let pcs = Stage2PcsQueryV1 { + batch_openings: typed_query + .input_proof + .iter() + .map(|opening| Stage2PcsBatchOpeningV1 { + opened_rows: opening.opened_values.clone(), + opening_proof: opening.opening_proof.clone(), + }) + .collect(), + }; + if typed_query.commit_phase_openings.len() != fri_challenges.betas.len() { + bail!("typed FRI query {query_number} has the wrong round count"); + } + let rounds: Vec<_> = typed_query + .commit_phase_openings + .iter() + .zip(&fri_challenges.betas) + .enumerate() + .map(|(round, (opening, &beta))| { + if opening.log_arity != 1 || opening.sibling_values.len() != 1 { + bail!("typed FRI query {query_number} round {round} is not binary"); + } + Ok(FriCommitPhaseRoundV1 { + sibling: opening.sibling_values[0], + beta, + reduced_opening: None, + opening_proof: opening.opening_proof.clone(), + }) + }) + .collect::>()?; + let mut query = TranscriptBoundPcsFriQueryV1 { + pcs, + fri: FriCommitPhaseQueryV1 { + initial_log_height: pcs_instance + .log_global_height + .checked_sub(1) + .ok_or_else(|| anyhow::anyhow!("FRI global height is zero"))?, + query_index: u32::try_from(query_index) + .map_err(|_| anyhow::anyhow!("FRI query index exceeds u32"))?, + initial_folded: [0, 0], + rounds, + final_polynomial, + }, + }; + let computation = compute_stage2_pcs_query( + &prefix, + &pcs_instance, + &query, + prefix_challenges, + )?; + query.fri.initial_folded = *computation + .reduced_openings + .get(&pcs_instance.log_global_height) + .ok_or_else(|| { + anyhow::anyhow!("typed PCS query has no initial bucket") + })?; + for (round, opening) in query.fri.rounds.iter_mut().enumerate() { + let height = pcs_instance.log_global_height + - 1 + - u8::try_from(round).expect("bounded FRI round index"); + opening.reduced_opening = + computation.reduced_openings.get(&height).copied(); + } + ensure_stage2_pcs_feeds_fri(&pcs_instance, &query, &computation)?; + let fri_computation = compute_commit_phase(&query.fri)?; + ensure_final_polynomial(&query.fri, &fri_computation)?; + ensure_transcript_binds_fri_query( + &fri_transcript, + &fri_challenges, + query_number, + &query.fri, + )?; + queries.push(query); + } + + Ok(Stage2PcsFriWitnessV1 { prefix, fri_transcript, pcs_instance, queries }) +} + +fn ensure_single_root(roots: &[[u8; 32]], label: &str) -> Result<()> { + if roots.len() != 1 { + bail!("{label} PCS commitment has {} roots; expected one", roots.len()); + } + Ok(()) +} + +fn ensure_opened_matrix_shape( + round: &[Vec>], + matrix: usize, + points: usize, + width: usize, + label: &str, +) -> Result<()> { + let opened = round.get(matrix).ok_or_else(|| { + anyhow::anyhow!("{label} opened matrix {matrix} is missing") + })?; + if opened.len() != points { + bail!( + "{label} opened matrix {matrix} has {} points; expected {points}", + opened.len() + ); + } + if opened.iter().any(|values| values.len() != width) { + bail!("{label} opened matrix {matrix} has the wrong width"); + } + Ok(()) +} + +fn stage2_pcs_matrix( + log_degree: u8, + log_blowup: u8, + width: usize, + opening_points: Vec, + opening_offset: &mut usize, +) -> Result { + let opened_values = Stage2TranscriptByteBindingV1::new( + Stage2TranscriptSegmentV1::PcsOpening, + *opening_offset, + ); + *opening_offset = opening_points + .len() + .checked_mul(width) + .and_then(|count| count.checked_mul(16)) + .and_then(|bytes| opening_offset.checked_add(bytes)) + .ok_or_else(|| anyhow::anyhow!("PCS opening transcript offset overflow"))?; + Ok(Stage2PcsMatrixV1 { + log_height: log_degree + .checked_add(log_blowup) + .ok_or_else(|| anyhow::anyhow!("PCS matrix height overflow"))?, + width, + opening_points, + opened_values, + }) +} + +struct Stage2PcsPointComputation { + denominator: [u64; 2], + quotients: Vec<[u64; 2]>, +} + +struct Stage2PcsMatrixComputation { + points: Vec, +} + +struct Stage2PcsBatchComputation { + root: [u8; 32], + matrices: Vec, +} + +struct Stage2PcsQueryComputation { + batches: Vec, + reduced_openings: BTreeMap, +} + +fn validate_stage2_pcs_instance( + replay: &Stage2TranscriptReplayV1, + instance: &Stage2PcsInstanceV1, +) -> Result<()> { + validate_log_height(instance.log_global_height)?; + if instance.log_blowup >= instance.log_global_height { + bail!( + "Stage 2 PCS blowup height {} must be below global height {}", + instance.log_blowup, + instance.log_global_height + ); + } + if instance.batches.is_empty() { + bail!("Stage 2 PCS instance has no input batches"); + } + + let mut observed_global_height = 0u8; + for (batch_index, batch) in instance.batches.iter().enumerate() { + validate_transcript_binding( + replay, + batch.commitment, + 4, + &format!("PCS batch {batch_index} commitment"), + )?; + if batch.matrices.is_empty() { + bail!("Stage 2 PCS batch {batch_index} has no matrices"); + } + let batch_max = + batch.matrices.iter().map(|matrix| matrix.log_height).max().unwrap(); + observed_global_height = observed_global_height.max(batch_max); + if batch_max > instance.log_global_height { + bail!( + "Stage 2 PCS batch {batch_index} height {batch_max} exceeds global height {}", + instance.log_global_height + ); + } + for (matrix_index, matrix) in batch.matrices.iter().enumerate() { + if matrix.log_height > batch_max { + unreachable!("batch maximum was computed from all matrices"); + } + validate_reduced_opening_width(matrix.width)?; + for point in &matrix.opening_points { + if let Stage2PcsOpeningPointV1::ZetaNext { log_degree } = point + && usize::from(*log_degree) >= Val::TWO_ADIC_GENERATORS.len() + { + bail!( + "Stage 2 PCS batch {batch_index} matrix {matrix_index} opening generator exceeds Goldilocks two-adicity" + ); + } + } + let lane_count = matrix + .opening_points + .len() + .checked_mul(matrix.width) + .and_then(|count| count.checked_mul(2)) + .ok_or_else(|| { + anyhow::anyhow!("Stage 2 PCS OOD binding length overflow") + })?; + validate_transcript_binding( + replay, + matrix.opened_values, + lane_count, + &format!("PCS batch {batch_index} matrix {matrix_index} OOD values"), + )?; + } + } + if observed_global_height != instance.log_global_height { + bail!( + "Stage 2 PCS global height is {}; tallest matrix has height {observed_global_height}", + instance.log_global_height + ); + } + Ok(()) +} + +fn validate_stage2_pcs_query( + replay: &Stage2TranscriptReplayV1, + instance: &Stage2PcsInstanceV1, + query: &TranscriptBoundPcsFriQueryV1, +) -> Result<()> { + validate_stage2_pcs_instance(replay, instance)?; + if query.pcs.batch_openings.len() != instance.batches.len() { + bail!( + "Stage 2 PCS query has {} batch openings; expected {}", + query.pcs.batch_openings.len(), + instance.batches.len() + ); + } + if query.fri.initial_log_height + 1 != instance.log_global_height { + bail!("Stage 2 PCS and FRI global heights disagree"); + } + if usize::from(instance.log_global_height - instance.log_blowup) + != query.fri.rounds.len() + { + bail!("Stage 2 PCS and FRI final heights disagree"); + } + for (batch_index, (batch, opening)) in + instance.batches.iter().zip(&query.pcs.batch_openings).enumerate() + { + if opening.opened_rows.len() != batch.matrices.len() { + bail!( + "Stage 2 PCS batch {batch_index} has {} opened rows; expected {}", + opening.opened_rows.len(), + batch.matrices.len() + ); + } + let batch_max = + batch.matrices.iter().map(|matrix| matrix.log_height).max().unwrap(); + if opening.opening_proof.len() != usize::from(batch_max) { + bail!( + "Stage 2 PCS batch {batch_index} path has {} siblings; expected {batch_max}", + opening.opening_proof.len() + ); + } + for (matrix_index, (matrix, row)) in + batch.matrices.iter().zip(&opening.opened_rows).enumerate() + { + if row.len() != matrix.width { + bail!( + "Stage 2 PCS batch {batch_index} matrix {matrix_index} row width is {}; expected {}", + row.len(), + matrix.width + ); + } + for (column, &value) in row.iter().enumerate() { + if value >= GOLDILOCKS_MODULUS { + bail!( + "Stage 2 PCS batch {batch_index} matrix {matrix_index} column {column} is not canonical Goldilocks" + ); + } + } + } + } + Ok(()) +} + +fn compute_stage2_pcs_query( + replay: &Stage2TranscriptReplayV1, + instance: &Stage2PcsInstanceV1, + query: &TranscriptBoundPcsFriQueryV1, + challenges: crate::Stage2TranscriptChallengesV1, +) -> Result { + validate_stage2_pcs_query(replay, instance, query)?; + let alpha = native_extension(challenges.pcs_alpha); + let zeta = native_extension(challenges.zeta); + let mut buckets: BTreeMap = instance + .batches + .iter() + .flat_map(|batch| batch.matrices.iter().map(|matrix| matrix.log_height)) + .map(|height| (height, (ExtVal::ONE, ExtVal::ZERO))) + .collect(); + let mut batches = Vec::with_capacity(instance.batches.len()); + + for (batch, opening) in instance.batches.iter().zip(&query.pcs.batch_openings) + { + let root = native_stage2_pcs_batch_root( + instance.log_global_height, + query.fri.query_index, + batch, + opening, + ); + if root != read_bound_digest(replay, batch.commitment)? { + bail!( + "Stage 2 PCS input opening does not authenticate to its transcript commitment" + ); + } + let mut matrices = Vec::with_capacity(batch.matrices.len()); + for (matrix_index, (matrix, row)) in + batch.matrices.iter().zip(&opening.opened_rows).enumerate() + { + let local_index = query.fri.query_index + >> (instance.log_global_height - matrix.log_height); + let x = ExtVal::new([ + Val::from_u64(pcs_query_point(matrix.log_height, local_index)), + Val::ZERO, + ]); + let opened_at_z = read_bound_extensions( + replay, + matrix.opened_values, + matrix.opening_points.len() * matrix.width, + )?; + let mut points = Vec::with_capacity(matrix.opening_points.len()); + for (point_index, point_kind) in matrix.opening_points.iter().enumerate() + { + let point = native_stage2_pcs_point(zeta, *point_kind); + let denominator = point - x; + if denominator == ExtVal::ZERO { + bail!( + "Stage 2 PCS batch matrix {matrix_index} opening point {point_index} equals its query point" + ); + } + let values = &opened_at_z + [point_index * matrix.width..(point_index + 1) * matrix.width]; + let mut quotients = Vec::with_capacity(matrix.width); + let (alpha_power, accumulator) = buckets + .get_mut(&matrix.log_height) + .expect("one PCS bucket per matrix height"); + for (&p_at_x, &p_at_z) in row.iter().zip(values) { + let p_at_x = ExtVal::new([Val::from_u64(p_at_x), Val::ZERO]); + let quotient = (native_extension(p_at_z) - p_at_x) / denominator; + *accumulator += *alpha_power * quotient; + *alpha_power *= alpha; + quotients.push(extension_words(quotient)); + } + points.push(Stage2PcsPointComputation { + denominator: extension_words(denominator), + quotients, + }); + } + matrices.push(Stage2PcsMatrixComputation { points }); + } + batches.push(Stage2PcsBatchComputation { root, matrices }); + } + + let reduced_openings = buckets + .into_iter() + .map(|(height, (_, accumulator))| (height, extension_words(accumulator))) + .collect(); + Ok(Stage2PcsQueryComputation { batches, reduced_openings }) +} + +fn ensure_stage2_pcs_feeds_fri( + instance: &Stage2PcsInstanceV1, + query: &TranscriptBoundPcsFriQueryV1, + computation: &Stage2PcsQueryComputation, +) -> Result<()> { + let initial = + computation.reduced_openings.get(&instance.log_global_height).ok_or_else( + || anyhow::anyhow!("missing initial Stage 2 reduced opening"), + )?; + if query.fri.initial_folded != *initial { + bail!("FRI initial value is not the authenticated PCS reduced opening"); + } + for (round, fri_round) in query.fri.rounds.iter().enumerate() { + let height = instance.log_global_height + - 1 + - u8::try_from(round).expect("bounded FRI round index"); + let expected = computation.reduced_openings.get(&height).copied(); + if fri_round.reduced_opening != expected { + bail!( + "FRI round {round} reduced-opening schedule disagrees with PCS buckets" + ); + } + } + let covered_minimum = instance.log_global_height + - u8::try_from(query.fri.rounds.len()).expect("bounded FRI round count"); + if computation.reduced_openings.keys().any(|&height| { + height != instance.log_global_height && height < covered_minimum + }) { + bail!("PCS reduced opening remains below the FRI final height"); + } + Ok(()) +} + +fn native_stage2_pcs_point( + zeta: ExtVal, + point: Stage2PcsOpeningPointV1, +) -> ExtVal { + match point { + Stage2PcsOpeningPointV1::Zeta => zeta, + Stage2PcsOpeningPointV1::ZetaNext { log_degree } => { + let generator = Val::TWO_ADIC_GENERATORS[usize::from(log_degree)]; + zeta * ExtVal::new([generator, Val::ZERO]) + }, + } +} + +fn native_stage2_pcs_batch_root( + log_global_height: u8, + query_index: u32, + batch: &Stage2PcsBatchV1, + opening: &Stage2PcsBatchOpeningV1, +) -> [u8; 32] { + let log_batch_height = + batch.matrices.iter().map(|matrix| matrix.log_height).max().unwrap(); + let local_index = query_index >> (log_global_height - log_batch_height); + let mut current = native_stage2_pcs_leaf(batch, opening, log_batch_height); + for (level, sibling) in opening.opening_proof.iter().enumerate() { + let mut message = [0u8; 64]; + let (left, right) = if (local_index >> level) & 1 == 0 { + (¤t, sibling) + } else { + (sibling, ¤t) + }; + message[..32].copy_from_slice(left); + message[32..].copy_from_slice(right); + current = *native_blake3::hash(&message).as_bytes(); + let next_height = log_batch_height + - 1 + - u8::try_from(level).expect("bounded Merkle path level"); + if batch.matrices.iter().any(|matrix| matrix.log_height == next_height) { + let injected = native_stage2_pcs_leaf(batch, opening, next_height); + let mut message = [0u8; 64]; + message[..32].copy_from_slice(¤t); + message[32..].copy_from_slice(&injected); + current = *native_blake3::hash(&message).as_bytes(); + } + } + current +} + +fn native_stage2_pcs_leaf( + batch: &Stage2PcsBatchV1, + opening: &Stage2PcsBatchOpeningV1, + log_height: u8, +) -> [u8; 32] { + let mut bytes = Vec::new(); + for (matrix, row) in batch.matrices.iter().zip(&opening.opened_rows) { + if matrix.log_height == log_height { + for &value in row { + bytes.extend_from_slice(&value.to_le_bytes()); + } + } + } + *native_blake3::hash(&bytes).as_bytes() +} + +fn validate_transcript_binding( + replay: &Stage2TranscriptReplayV1, + binding: Stage2TranscriptByteBindingV1, + lane_count: usize, + label: &str, +) -> Result<()> { + let bytes = transcript_segment(replay, binding.segment); + let start = binding.byte_offset; + let end = lane_count + .checked_mul(8) + .and_then(|length| start.checked_add(length)) + .ok_or_else(|| anyhow::anyhow!("{label} byte range overflow"))?; + if end > bytes.len() { + bail!( + "{label} binding ends at byte {end}; transcript segment has {} bytes", + bytes.len() + ); + } + Ok(()) +} + +fn read_bound_digest( + replay: &Stage2TranscriptReplayV1, + binding: Stage2TranscriptByteBindingV1, +) -> Result<[u8; 32]> { + validate_transcript_binding(replay, binding, 4, "PCS commitment")?; + let bytes = transcript_segment(replay, binding.segment); + let start = binding.byte_offset; + Ok(bytes[start..start + 32].try_into().unwrap()) +} + +fn read_bound_extensions( + replay: &Stage2TranscriptReplayV1, + binding: Stage2TranscriptByteBindingV1, + count: usize, +) -> Result> { + validate_transcript_binding(replay, binding, count * 2, "PCS OOD opening")?; + let bytes = transcript_segment(replay, binding.segment); + let start = binding.byte_offset; + (0..count) + .map(|index| { + let offset = start + index * 16; + let value = [ + u64::from_le_bytes(bytes[offset..offset + 8].try_into().unwrap()), + u64::from_le_bytes(bytes[offset + 8..offset + 16].try_into().unwrap()), + ]; + validate_extension(value, "transcript-bound PCS OOD value")?; + Ok(value) + }) + .collect() +} + +fn transcript_segment( + replay: &Stage2TranscriptReplayV1, + segment: Stage2TranscriptSegmentV1, +) -> &[u8] { + match segment { + Stage2TranscriptSegmentV1::Initial => &replay.initial_observations, + Stage2TranscriptSegmentV1::Stage2AndAccumulator => { + &replay.stage2_and_accumulator_observations + }, + Stage2TranscriptSegmentV1::QuotientCommitment => { + &replay.quotient_commitment_observations + }, + Stage2TranscriptSegmentV1::PcsOpening => &replay.pcs_opening_observations, + } +} + +struct PcsReductionComputation { + denominator: [u64; 2], + quotients: Vec<[u64; 2]>, + accumulator: [u64; 2], + alpha_power: [u64; 2], + root: [u8; 32], +} + +fn compute_pcs_reduction( + opening: &PcsReducedOpeningV1, +) -> Result { + validate_pcs_reduction(opening)?; + let x = ExtVal::new([ + Val::from_u64(pcs_query_point(opening.log_height, opening.query_index)), + Val::ZERO, + ]); + let zeta = native_extension(opening.zeta); + let denominator = zeta - x; + if denominator == ExtVal::ZERO { + bail!("PCS reduced opening has zeta equal to the query-domain point"); + } + let alpha = native_extension(opening.alpha); + let mut alpha_power = native_extension(opening.initial_alpha_power); + let mut accumulator = native_extension(opening.initial_accumulator); + let mut quotients = Vec::with_capacity(opening.opened_values.len()); + for (&px, &pz) in opening.opened_values.iter().zip(&opening.opened_at_z) { + let px = ExtVal::new([Val::from_u64(px), Val::ZERO]); + let pz = native_extension(pz); + let quotient = (pz - px) / denominator; + accumulator += alpha_power * quotient; + alpha_power *= alpha; + quotients.push(extension_words(quotient)); + } + Ok(PcsReductionComputation { + denominator: extension_words(denominator), + quotients, + accumulator: extension_words(accumulator), + alpha_power: extension_words(alpha_power), + root: native_pcs_row_root(opening), + }) +} + +fn pcs_reduction_relation_inputs( + opening: &PcsReducedOpeningV1, + computation: &PcsReductionComputation, +) -> Vec { + let packed_iv = pack8(&IV); + let width = opening.opened_values.len(); + let mut inputs = Vec::with_capacity( + 9 + usize::from(opening.log_height) + + width.div_ceil(2) + + width + + 4 + + usize::from(opening.log_height) * 3 + + 1 + + width + + 2, + ); + inputs.extend_from_slice(&[F128::ZERO, F128::ZERO, F128::ZERO]); + inputs.extend_from_slice(&packed_iv); + inputs.extend(hash_trace(width * 8).rows.iter().map( + |&(_cv, _message, counter, block_len, flags)| { + pack_params(counter, block_len, flags) + }, + )); + inputs.push(pack_params(0, 64, CHUNK_START | CHUNK_END | ROOT)); + inputs.push(F128::new(1, 0)); + inputs.push(F128::new(7, 0)); + inputs.extend( + pcs_x_factors(opening.log_height) + .into_iter() + .map(|factor| F128::new(factor, 0)), + ); + for pair in opening.opened_values.chunks(2) { + inputs.push(F128::new(pair[0], pair.get(1).copied().unwrap_or(0))); + } + inputs.extend(opening.opened_at_z.iter().copied().map(pack_extension)); + inputs.push(pack_extension(opening.zeta)); + inputs.push(pack_extension(opening.alpha)); + inputs.push(pack_extension(opening.initial_alpha_power)); + inputs.push(pack_extension(opening.initial_accumulator)); + inputs.extend( + (0..opening.log_height) + .map(|bit| F128::new(u64::from((opening.query_index >> bit) & 1), 0)), + ); + for sibling in &opening.opening_proof { + inputs.extend_from_slice(&pack_digest(sibling)); + } + inputs.push(pack_extension(computation.denominator)); + inputs.extend(computation.quotients.iter().copied().map(pack_extension)); + inputs.push(pack_extension(computation.accumulator)); + inputs.push(pack_extension(computation.alpha_power)); + inputs +} + +fn pcs_reduction_relation_public( + opening: &PcsReducedOpeningV1, + computation: &PcsReductionComputation, +) -> Vec { + let mut public = pcs_reduction_relation_inputs(opening, computation); + public.extend_from_slice(&pack_digest(&computation.root)); + public +} + +fn validate_pcs_reduction(opening: &PcsReducedOpeningV1) -> Result<()> { + validate_log_height(opening.log_height)?; + validate_reduced_opening_width(opening.opened_values.len())?; + if opening.opened_at_z.len() != opening.opened_values.len() { + bail!( + "PCS reduced opening has {} base values but {} OOD values", + opening.opened_values.len(), + opening.opened_at_z.len() + ); + } + if opening.opening_proof.len() != usize::from(opening.log_height) { + bail!( + "PCS reduced opening has path depth {}; expected {}", + opening.opening_proof.len(), + opening.log_height + ); + } + if u64::from(opening.query_index) >= 1u64 << opening.log_height { + bail!( + "PCS query index {} does not fit {} bits", + opening.query_index, + opening.log_height + ); + } + for (column, &value) in opening.opened_values.iter().enumerate() { + if value >= GOLDILOCKS_MODULUS { + bail!("PCS opened value {column} is not canonical Goldilocks"); + } + } + for (column, &value) in opening.opened_at_z.iter().enumerate() { + validate_extension(value, &format!("PCS OOD value {column}"))?; + } + for (value, name) in [ + (opening.zeta, "PCS zeta"), + (opening.alpha, "PCS alpha"), + (opening.initial_alpha_power, "PCS initial alpha power"), + (opening.initial_accumulator, "PCS initial accumulator"), + ] { + validate_extension(value, name)?; + } + Ok(()) +} + +fn validate_reduced_opening_width(width: usize) -> Result<()> { + if !(1..=MAX_REDUCED_OPENING_WIDTH).contains(&width) { + bail!( + "PCS reduced-opening width {width}; expected 1..={MAX_REDUCED_OPENING_WIDTH}" + ); + } + Ok(()) +} + +fn pcs_reduction_nu(opening: &PcsReducedOpeningV1) -> usize { + let width = opening.opened_values.len(); + let height = usize::from(opening.log_height); + // This deliberately over-approximates the busiest shared slot. It keeps the + // circuit builder fail-closed while supporting tree-hashed rows wider than a + // single BLAKE3 block. + let row_bound = width + .saturating_mul(128) + .saturating_add(height.saturating_mul(64)) + .saturating_add(hash_trace(width * 8).rows.len()) + .max(1); + usize::try_from(row_bound.next_power_of_two().ilog2()).unwrap().max(NU) +} + +fn native_pcs_row_root(opening: &PcsReducedOpeningV1) -> [u8; 32] { + let mut leaf = Vec::with_capacity(opening.opened_values.len() * 8); + for value in &opening.opened_values { + leaf.extend_from_slice(&value.to_le_bytes()); + } + let mut current = *native_blake3::hash(&leaf).as_bytes(); + for (level, sibling) in opening.opening_proof.iter().enumerate() { + let mut block = [0u8; 64]; + let (left, right) = if (opening.query_index >> level) & 1 == 0 { + (¤t, sibling) + } else { + (sibling, ¤t) + }; + block[..32].copy_from_slice(left); + block[32..].copy_from_slice(right); + current = *native_blake3::hash(&block).as_bytes(); + } + current +} + +fn pcs_query_point(log_height: u8, query_index: u32) -> u64 { + pcs_x_factors(log_height) + .into_iter() + .enumerate() + .filter(|(bit, _)| (query_index >> bit) & 1 == 1) + .fold(7, |point, (_, factor)| goldilocks_mul(point, factor)) +} + +fn twiddle_factors(log_height: u8) -> Vec { + reversed_exponent_factors(log_height + 1, log_height) +} + +fn pcs_x_factors(log_height: u8) -> Vec { + reversed_exponent_factors(log_height, log_height) +} + +fn reversed_exponent_factors(generator_log: u8, exponent_bits: u8) -> Vec { + let generator = + Val::TWO_ADIC_GENERATORS[usize::from(generator_log)].as_canonical_u64(); + (0..exponent_bits) + .map(|bit| { + let squarings = usize::from(exponent_bits - 1 - bit); + (0..squarings).fold(generator, |value, _| goldilocks_mul(value, value)) + }) + .collect() +} + +fn subgroup_point(query: &FriFoldQueryV1) -> u64 { + let index = query.query_index >> 1; + twiddle_factors(query.log_height) + .into_iter() + .enumerate() + .filter(|(bit, _)| (index >> bit) & 1 == 1) + .fold(1, |point, (_, factor)| goldilocks_mul(point, factor)) +} + +fn ordered_evaluations(query: &FriFoldQueryV1) -> ([u64; 2], [u64; 2]) { + if query.query_index & 1 == 0 { + (query.folded, query.sibling) + } else { + (query.sibling, query.folded) + } +} + +fn native_fold(query: &FriFoldQueryV1) -> [u64; 2] { + let (e0_words, e1_words) = ordered_evaluations(query); + let e0 = native_extension(e0_words); + let e1 = native_extension(e1_words); + let beta = native_extension(query.beta); + let s = Val::from_u64(subgroup_point(query)); + let two = Val::ONE + Val::ONE; + let half = ExtVal::new([two, Val::ZERO]); + let two_s = ExtVal::new([two * s, Val::ZERO]); + extension_words((e0 + e1) / half + beta * ((e0 - e1) / two_s)) +} + +fn native_root(query: &FriFoldQueryV1) -> [u8; 32] { + let (e0, e1) = ordered_evaluations(query); + let mut leaf = [0u8; 32]; + for (chunk, word) in + leaf.as_chunks_mut::<8>().0.iter_mut().zip([e0[0], e0[1], e1[0], e1[1]]) + { + chunk.copy_from_slice(&word.to_le_bytes()); + } + let mut current = *native_blake3::hash(&leaf).as_bytes(); + for (level, sibling) in query.opening_proof.iter().enumerate() { + let mut block = [0u8; 64]; + let direction = (query.query_index >> (level + 1)) & 1; + let (left, right) = + if direction == 0 { (¤t, sibling) } else { (sibling, ¤t) }; + block[..32].copy_from_slice(left); + block[32..].copy_from_slice(right); + current = *native_blake3::hash(&block).as_bytes(); + } + current +} + +fn native_extension(value: [u64; 2]) -> ExtVal { + ExtVal::new(value.map(Val::from_u64)) +} + +fn extension_words(value: ExtVal) -> [u64; 2] { + let coefficients: &[Val] = value.as_basis_coefficients_slice(); + [coefficients[0].as_canonical_u64(), coefficients[1].as_canonical_u64()] +} + +fn pack_extension(value: [u64; 2]) -> F128 { + F128::new(value[0], value[1]) +} + +fn pack_digest(digest: &[u8; 32]) -> [F128; 2] { + [pack_bytes(&digest[..16]), pack_bytes(&digest[16..])] +} + +fn validate_query(query: &FriFoldQueryV1) -> Result<()> { + validate_log_height(query.log_height)?; + if query.opening_proof.len() != usize::from(query.log_height) { + bail!( + "FRI-fold opening has depth {}; expected {} for cap height zero", + query.opening_proof.len(), + query.log_height + ); + } + if u64::from(query.query_index) >= 1u64 << (query.log_height + 1) { + bail!( + "FRI query index {} does not fit {} bits", + query.query_index, + query.log_height + 1 + ); + } + validate_extension(query.folded, "folded evaluation")?; + validate_extension(query.sibling, "sibling evaluation")?; + validate_extension(query.beta, "FRI challenge")?; + Ok(()) +} + +fn validate_log_height(log_height: u8) -> Result<()> { + if !(MIN_LOG_HEIGHT..=MAX_LOG_HEIGHT).contains(&log_height) { + bail!( + "FRI fold log height {log_height}; expected {MIN_LOG_HEIGHT}..={MAX_LOG_HEIGHT}" + ); + } + Ok(()) +} + +fn validate_extension(value: [u64; 2], name: &str) -> Result<()> { + for (coordinate, word) in value.into_iter().enumerate() { + if word >= GOLDILOCKS_MODULUS { + bail!("{name} coordinate {coordinate} is not canonical Goldilocks"); + } + } + Ok(()) +} + +fn encode_extension(bytes: &mut Vec, value: [u64; 2]) { + bytes.extend_from_slice(&value[0].to_le_bytes()); + bytes.extend_from_slice(&value[1].to_le_bytes()); +} + +fn decode_extension(bytes: &[u8]) -> [u64; 2] { + [ + u64::from_le_bytes(bytes[..8].try_into().unwrap()), + u64::from_le_bytes(bytes[8..16].try_into().unwrap()), + ] +} + +fn sort_and_validate_slots(slots: &mut [(usize, T)]) -> Result<()> { + slots.sort_by_key(|(slot, _)| *slot); + for (expected, (observed, _)) in slots.iter().enumerate() { + if *observed != expected { + bail!( + "Flock FRI-fold table registry is incomplete: expected slot {expected}, observed {observed}" + ); + } + } + Ok(()) +} + +fn encode_bundle(bundle: &FriFoldProofBundle) -> Result> { + bincode::DefaultOptions::new() + .with_fixint_encoding() + .serialize(bundle) + .context("encode Flock FRI-fold conformance proof bundle") +} + +fn decode_bundle(bytes: &[u8]) -> Result { + bincode::DefaultOptions::new() + .with_fixint_encoding() + .with_limit(MAX_BUNDLE_BYTES as u64) + .reject_trailing_bytes() + .deserialize(bytes) + .context("invalid Flock FRI-fold conformance proof bundle") +} + +#[cfg(test)] +mod tests { + use aiur::vk_codec::aiur_config_system_to_bytes; + use ix_terminal::validate_and_expand_root_inputs; + use multi_stark::{ + expr::Expr, + lookup::{Lookup, WidthBinding}, + p3_matrix::dense::RowMajorMatrix, + system::{CircuitInputs, System, SystemWitness}, + types::{CommitmentParameters, GoldilocksBlake3Config}, + }; + + use super::*; + + fn prepared_stage2_pcs_fixture() + -> (ValidatedStage2RootV1, FriParameters, Vec, Vec, Vec) { + const CLAIM_WORDS: usize = 18; + const CLAIM_CIRCUIT_WIDTH: usize = CLAIM_WORDS + 2; + const TALL_HEIGHT: usize = 8; + const SHORT_HEIGHT: usize = 4; + + let commitment = CommitmentParameters { log_blowup: 1, cap_height: 0 }; + let fri = FriParameters { + log_final_poly_len: 0, + max_log_arity: 1, + num_queries: 2, + commit_proof_of_work_bits: 0, + query_proof_of_work_bits: 0, + }; + let claim: Vec<_> = (0..CLAIM_WORDS) + .map(|word| Val::from_u64(0x100 + u64::try_from(word).unwrap())) + .collect(); + let claim_lookup = Lookup::pull( + Expr::main(0), + (1..=CLAIM_WORDS) + .map(|column| Expr::main(u32::try_from(column).unwrap())) + .collect(), + ); + let multiplicity = Expr::main(0); + let multiplicity_is_boolean = + multiplicity.clone() * (multiplicity - Expr::constant(Val::ONE)); + let preprocessed_matches = + Expr::main(u32::try_from(CLAIM_CIRCUIT_WIDTH - 1).unwrap()) + - Expr::preprocessed(0); + let short_first = + Expr::IsFirstRow * (Expr::main(0) - Expr::constant(Val::from_u64(7))); + let short_transition = Expr::IsTransition + * (Expr::main_next(0) - Expr::main(0) - Expr::constant(Val::from_u64(9))); + let config = GoldilocksBlake3Config::new(commitment, fri) + .with_width_binding(WidthBinding::ByConstruction); + let (system, key) = System::new( + config, + [ + CircuitInputs { main_width: 1, ..Default::default() }, + CircuitInputs { + main_width: CLAIM_CIRCUIT_WIDTH, + preprocessed: Some(RowMajorMatrix::new( + (0..TALL_HEIGHT) + .map(|row| Val::from_u64(5 * u64::try_from(row).unwrap() + 3)) + .collect(), + 1, + )), + constraints: vec![multiplicity_is_boolean, preprocessed_matches], + lookups: vec![claim_lookup], + ..Default::default() + }, + CircuitInputs { + main_width: 3, + constraints: vec![short_first, short_transition], + ..Default::default() + }, + ], + ); + + let mut tall_values = vec![Val::ZERO; TALL_HEIGHT * CLAIM_CIRCUIT_WIDTH]; + tall_values[0] = Val::ONE; + tall_values[1..=CLAIM_WORDS].copy_from_slice(&claim); + for row in 0..TALL_HEIGHT { + tall_values[row * CLAIM_CIRCUIT_WIDTH + CLAIM_CIRCUIT_WIDTH - 1] = + Val::from_u64(5 * u64::try_from(row).unwrap() + 3); + } + let tall_trace = RowMajorMatrix::new(tall_values, CLAIM_CIRCUIT_WIDTH); + let short_trace = RowMajorMatrix::new( + (0..SHORT_HEIGHT * 3) + .map(|word| Val::from_u64(3 * u64::try_from(word).unwrap() + 7)) + .collect(), + 3, + ); + let proof = system.prove( + &key, + &claim, + SystemWitness::from_stage_1( + vec![RowMajorMatrix::new(Vec::new(), 1), tall_trace, short_trace], + &system, + ), + ); + system.verify(&claim, &proof).expect("fixture proof must verify"); + + let vk_bytes = aiur_config_system_to_bytes(&system, commitment, fri); + let claim_bytes: Vec<_> = claim + .iter() + .flat_map(|word| word.as_canonical_u64().to_le_bytes()) + .collect(); + let proof_bytes = proof.to_bytes().expect("encode fixture proof"); + let prepared = validate_and_expand_root_inputs( + &vk_bytes, + &claim_bytes, + &proof_bytes, + &fri, + ) + .expect("validate and expand fixture proof"); + (prepared, fri, vk_bytes, claim_bytes, proof_bytes) + } + + fn fixture() -> FriFoldQueryV1 { + FriFoldQueryV1 { + log_height: 4, + query_index: 0b1_0110, + folded: [0x1234_5678_9abc_def0, 0x0fed_cba9_8765_4321], + sibling: [17, GOLDILOCKS_MODULUS - 9], + beta: [0x1111_2222_3333_4444, 0x5555_6666_7777_8888], + opening_proof: (0..4u8) + .map(|level| { + *native_blake3::hash(&[b'f', b'r', b'i', level]).as_bytes() + }) + .collect(), + } + } + + fn commit_phase_fixture() -> FriCommitPhaseQueryV1 { + let mut query = FriCommitPhaseQueryV1 { + initial_log_height: 4, + query_index: 0b1_0110, + initial_folded: [0x1234_5678_9abc_def0, 0x0fed_cba9_8765_4321], + rounds: (0..3u8) + .map(|round| { + let depth = 4 - usize::from(round); + FriCommitPhaseRoundV1 { + sibling: [ + 17 + u64::from(round), + GOLDILOCKS_MODULUS - 9 - u64::from(round), + ], + beta: [ + 0x1111_2222_3333_4444 + u64::from(round), + 0x5555_6666_7777_8888 + u64::from(round), + ], + reduced_opening: (round != 1) + .then_some([100 + u64::from(round), 200 + u64::from(round)]), + opening_proof: (0..depth) + .map(|level| { + *native_blake3::hash(&[ + b'q', + round, + u8::try_from(level).unwrap(), + ]) + .as_bytes() + }) + .collect(), + } + }) + .collect(), + final_polynomial: [0, 0], + }; + let computation = compute_commit_phase(&query).unwrap(); + query.final_polynomial = *computation.results.last().unwrap(); + query + } + + fn pcs_reduction_fixture() -> PcsReducedOpeningV1 { + PcsReducedOpeningV1 { + log_height: 4, + query_index: 0b1010, + opened_values: vec![3, 5, 8, 13, 21], + opened_at_z: vec![ + [34, 55], + [89, 144], + [233, 377], + [610, 987], + [1597, 2584], + ], + zeta: [0x1020_3040_5060_7080, 0x1122_3344_5566_7788], + alpha: [0x3141_5926_5358_9793, 0x2384_6264_3383_2795], + initial_alpha_power: [7, 11], + initial_accumulator: [17, 19], + opening_proof: (0..4u8) + .map(|level| { + *native_blake3::hash(&[b'p', b'c', b's', level]).as_bytes() + }) + .collect(), + } + } + + fn wide_pcs_reduction_fixture() -> PcsReducedOpeningV1 { + let mut opening = pcs_reduction_fixture(); + opening.opened_values = (0..129u64).map(|column| 3 * column + 5).collect(); + opening.opened_at_z = + (0..129u64).map(|column| [7 * column + 11, 13 * column + 17]).collect(); + opening + } + + fn transcript_replay_fixture() -> Stage2TranscriptReplayV1 { + let mut initial_observations = b"multi-stark/v0".to_vec(); + for value in 0..19u64 { + initial_observations.extend_from_slice(&(value * 17 + 3).to_le_bytes()); + } + initial_observations.extend_from_slice(&[0xa5, 0x5a, 0x11]); + Stage2TranscriptReplayV1 { + initial_observations, + stage2_and_accumulator_observations: (0..79u8) + .map(|value| value.wrapping_mul(29)) + .collect(), + quotient_commitment_observations: (0..32u8) + .map(|value| value ^ 0x6d) + .collect(), + pcs_opening_observations: (0..117u8) + .map(|value| value.wrapping_mul(7).wrapping_add(1)) + .collect(), + } + } + + fn transcript_bound_pcs_fixture() + -> (Stage2TranscriptReplayV1, PcsReducedOpeningV1) { + let replay = transcript_replay_fixture(); + let challenges = replay.challenges().unwrap(); + let mut opening = pcs_reduction_fixture(); + opening.zeta = challenges.zeta; + opening.alpha = challenges.pcs_alpha; + (replay, opening) + } + + fn zero_extension_tree(log_height: u8) -> (Vec<[u8; 32]>, [u8; 32]) { + let mut current = *native_blake3::hash(&[0u8; 32]).as_bytes(); + let mut path = Vec::with_capacity(usize::from(log_height)); + for _ in 0..log_height { + path.push(current); + let mut message = [0u8; 64]; + message[..32].copy_from_slice(¤t); + message[32..].copy_from_slice(¤t); + current = *native_blake3::hash(&message).as_bytes(); + } + (path, current) + } + + fn transcript_bound_fri_fixture() -> ( + Stage2TranscriptReplayV1, + Stage2FriTranscriptReplayV1, + FriCommitPhaseQueryV1, + ) { + let prefix = transcript_replay_fixture(); + let initial_log_height = 4u8; + let round_count = 3usize; + let trees: Vec<_> = (0..round_count) + .map(|round| { + zero_extension_tree(initial_log_height - u8::try_from(round).unwrap()) + }) + .collect(); + let mut fri_transcript = Stage2FriTranscriptReplayV1 { + commit_phase_commitments: trees + .iter() + .map(|(_, root)| vec![*root]) + .collect(), + commit_pow_witnesses: vec![0; round_count], + final_polynomial: vec![[0, 0]], + log_arities: vec![1; round_count], + query_pow_witness: 0, + commit_pow_bits: 0, + query_pow_bits: 4, + num_queries: 5, + query_index_bits: initial_log_height + 1, + }; + let challenges = (0..1_000u64) + .find_map(|witness| { + fri_transcript.query_pow_witness = witness; + fri_transcript.challenges(&prefix).ok() + }) + .expect("small query-PoW fixture has a witness"); + let query = FriCommitPhaseQueryV1 { + initial_log_height, + query_index: u32::try_from(challenges.query_indices[0]).unwrap(), + initial_folded: [0, 0], + rounds: trees + .into_iter() + .zip(challenges.betas) + .map(|((opening_proof, _), beta)| FriCommitPhaseRoundV1 { + sibling: [0, 0], + beta, + reduced_opening: None, + opening_proof, + }) + .collect(), + final_polynomial: [0, 0], + }; + (prefix, fri_transcript, query) + } + + fn transcript_bound_fri_all_queries_fixture() -> ( + Stage2TranscriptReplayV1, + Stage2FriTranscriptReplayV1, + Vec, + ) { + let (prefix, mut fri_transcript, template) = transcript_bound_fri_fixture(); + fri_transcript.num_queries = 2; + let challenges = fri_transcript.challenges(&prefix).unwrap(); + let queries = challenges + .query_indices + .iter() + .map(|&query_index| { + let mut query = template.clone(); + query.query_index = u32::try_from(query_index).unwrap(); + query + }) + .collect(); + (prefix, fri_transcript, queries) + } + + fn linear_base_value(slope: u64, intercept: u64, x: u64) -> u64 { + (Val::from_u64(slope) * Val::from_u64(x) + Val::from_u64(intercept)) + .as_canonical_u64() + } + + fn linear_extension_value(slope: u64, intercept: u64, x: ExtVal) -> [u64; 2] { + extension_words( + ExtVal::new([Val::from_u64(slope), Val::ZERO]) * x + + ExtVal::new([Val::from_u64(intercept), Val::ZERO]), + ) + } + + fn hash_base_rows(rows: &[&[u64]]) -> [u8; 32] { + let mut bytes = Vec::new(); + for row in rows { + for &value in *row { + bytes.extend_from_slice(&value.to_le_bytes()); + } + } + *native_blake3::hash(&bytes).as_bytes() + } + + fn hash_children(left: &[u8; 32], right: &[u8; 32]) -> [u8; 32] { + let mut bytes = [0u8; 64]; + bytes[..32].copy_from_slice(left); + bytes[32..].copy_from_slice(right); + *native_blake3::hash(&bytes).as_bytes() + } + + fn multiheight_batch_root_and_path( + matrix_heights: &[u8], + matrix_rows: &[Vec>], + query_index: usize, + ) -> ([u8; 32], Vec<[u8; 32]>) { + let log_max = *matrix_heights.iter().max().unwrap(); + let leaf_layer = |height: u8| { + (0..1usize << height) + .map(|row_index| { + let rows: Vec<_> = matrix_heights + .iter() + .zip(matrix_rows) + .filter(|(matrix_height, _)| **matrix_height == height) + .map(|(_, rows)| rows[row_index].as_slice()) + .collect(); + hash_base_rows(&rows) + }) + .collect::>() + }; + let mut current = leaf_layer(log_max); + let mut index = query_index; + let mut path = Vec::with_capacity(usize::from(log_max)); + for next_height in (0..log_max).rev() { + path.push(current[index ^ 1]); + let mut parents: Vec<_> = current + .as_chunks::<2>() + .0 + .iter() + .map(|children| hash_children(&children[0], &children[1])) + .collect(); + if matrix_heights.contains(&next_height) { + for (parent, injected) in + parents.iter_mut().zip(leaf_layer(next_height)) + { + *parent = hash_children(parent, &injected); + } + } + current = parents; + index >>= 1; + } + (current[0], path) + } + + fn constant_extension_tree_root_and_path( + value: [u64; 2], + log_height: u8, + query_index: usize, + ) -> ([u8; 32], Vec<[u8; 32]>) { + let mut leaf_bytes = [0u8; 32]; + for (chunk, word) in leaf_bytes + .as_chunks_mut::<8>() + .0 + .iter_mut() + .zip([value[0], value[1], value[0], value[1]]) + { + chunk.copy_from_slice(&word.to_le_bytes()); + } + let leaf = *native_blake3::hash(&leaf_bytes).as_bytes(); + let mut current = vec![leaf; 1usize << log_height]; + let mut index = query_index; + let mut path = Vec::with_capacity(usize::from(log_height)); + for _ in 0..log_height { + path.push(current[index ^ 1]); + current = current + .as_chunks::<2>() + .0 + .iter() + .map(|children| hash_children(&children[0], &children[1])) + .collect(); + index >>= 1; + } + (current[0], path) + } + + fn transcript_bound_pcs_fri_fixture() -> ( + Stage2TranscriptReplayV1, + Stage2FriTranscriptReplayV1, + Stage2PcsInstanceV1, + Vec, + ) { + const LOG_GLOBAL: u8 = 4; + const LOG_BLOWUP: u8 = 1; + let matrix_heights = [4u8, 3u8]; + let matrix_rows = vec![ + (0..1u32 << matrix_heights[0]) + .map(|index| { + let x = pcs_query_point(matrix_heights[0], index); + vec![linear_base_value(2, 11, x), linear_base_value(3, 17, x)] + }) + .collect::>(), + (0..1u32 << matrix_heights[1]) + .map(|index| { + let x = pcs_query_point(matrix_heights[1], index); + vec![linear_base_value(5, 23, x)] + }) + .collect::>(), + ]; + let (input_root, _) = + multiheight_batch_root_and_path(&matrix_heights, &matrix_rows, 0); + + let mut prefix = Stage2TranscriptReplayV1 { + initial_observations: input_root.to_vec(), + stage2_and_accumulator_observations: vec![0x31; 48], + quotient_commitment_observations: vec![0x52; 32], + pcs_opening_observations: vec![0; 3 * 16], + }; + let zeta = native_extension(prefix.challenges().unwrap().zeta); + let opened_values = [ + linear_extension_value(2, 11, zeta), + linear_extension_value(3, 17, zeta), + linear_extension_value(5, 23, zeta), + ]; + prefix.pcs_opening_observations.clear(); + for value in opened_values { + encode_extension(&mut prefix.pcs_opening_observations, value); + } + + let prefix_challenges = prefix.challenges().unwrap(); + let alpha = native_extension(prefix_challenges.pcs_alpha); + let max_reduced = ExtVal::new([Val::from_u64(2), Val::ZERO]) + + alpha * ExtVal::new([Val::from_u64(3), Val::ZERO]); + let shorter_reduced = ExtVal::new([Val::from_u64(5), Val::ZERO]); + let mut current = max_reduced; + let mut round_values = Vec::new(); + + let (round_0_root, _) = + constant_extension_tree_root_and_path(extension_words(current), 3, 0); + let mut fri_transcript = Stage2FriTranscriptReplayV1 { + commit_phase_commitments: vec![ + vec![round_0_root], + vec![[0; 32]], + vec![[0; 32]], + ], + commit_pow_witnesses: vec![0; 3], + final_polynomial: vec![[0, 0]], + log_arities: vec![1; 3], + query_pow_witness: 0, + commit_pow_bits: 0, + query_pow_bits: 0, + num_queries: 1, + query_index_bits: LOG_GLOBAL, + }; + let beta_0 = + native_extension(fri_transcript.challenges(&prefix).unwrap().betas[0]); + round_values.push(extension_words(current)); + current += beta_0 * beta_0 * shorter_reduced; + let (round_1_root, _) = + constant_extension_tree_root_and_path(extension_words(current), 2, 0); + fri_transcript.commit_phase_commitments[1][0] = round_1_root; + + let _beta_1 = fri_transcript.challenges(&prefix).unwrap().betas[1]; + round_values.push(extension_words(current)); + let (round_2_root, _) = + constant_extension_tree_root_and_path(extension_words(current), 1, 0); + fri_transcript.commit_phase_commitments[2][0] = round_2_root; + round_values.push(extension_words(current)); + fri_transcript.final_polynomial[0] = extension_words(current); + let challenges = fri_transcript.challenges(&prefix).unwrap(); + let query_index = u32::try_from(challenges.query_indices[0]).unwrap(); + + let (_, input_path) = multiheight_batch_root_and_path( + &matrix_heights, + &matrix_rows, + usize::try_from(query_index).unwrap(), + ); + let batch_opening = Stage2PcsBatchOpeningV1 { + opened_rows: vec![ + matrix_rows[0][usize::try_from(query_index).unwrap()].clone(), + matrix_rows[1][usize::try_from(query_index >> 1).unwrap()].clone(), + ], + opening_proof: input_path, + }; + let rounds = (0..3usize) + .map(|round| { + let tree_height = 3 - u8::try_from(round).unwrap(); + let row_index = usize::try_from(query_index >> (round + 1)).unwrap(); + let (_, path) = constant_extension_tree_root_and_path( + round_values[round], + tree_height, + row_index, + ); + FriCommitPhaseRoundV1 { + sibling: round_values[round], + beta: challenges.betas[round], + reduced_opening: (round == 0) + .then_some(extension_words(shorter_reduced)), + opening_proof: path, + } + }) + .collect(); + let fri_query = FriCommitPhaseQueryV1 { + initial_log_height: LOG_GLOBAL - 1, + query_index, + initial_folded: extension_words(max_reduced), + rounds, + final_polynomial: extension_words(current), + }; + let instance = Stage2PcsInstanceV1 { + log_global_height: LOG_GLOBAL, + log_blowup: LOG_BLOWUP, + batches: vec![Stage2PcsBatchV1 { + commitment: Stage2TranscriptByteBindingV1::new( + Stage2TranscriptSegmentV1::Initial, + 0, + ), + matrices: vec![ + Stage2PcsMatrixV1 { + log_height: matrix_heights[0], + width: 2, + opening_points: vec![Stage2PcsOpeningPointV1::Zeta], + opened_values: Stage2TranscriptByteBindingV1::new( + Stage2TranscriptSegmentV1::PcsOpening, + 0, + ), + }, + Stage2PcsMatrixV1 { + log_height: matrix_heights[1], + width: 1, + opening_points: vec![Stage2PcsOpeningPointV1::Zeta], + opened_values: Stage2TranscriptByteBindingV1::new( + Stage2TranscriptSegmentV1::PcsOpening, + 32, + ), + }, + ], + }], + }; + let query = TranscriptBoundPcsFriQueryV1 { + pcs: Stage2PcsQueryV1 { batch_openings: vec![batch_opening] }, + fri: fri_query, + }; + (prefix, fri_transcript, instance, vec![query]) + } + + #[test] + fn native_fold_satisfies_denominator_free_identity() { + for query_index in [0, 1, 0b1_0110, 0b1_1111] { + let mut query = fixture(); + query.query_index = query_index; + let result = native_extension(query.folded_result().unwrap()); + let (e0, e1) = ordered_evaluations(&query); + let e0 = native_extension(e0); + let e1 = native_extension(e1); + let beta = native_extension(query.beta); + let s = ExtVal::new([Val::from_u64(subgroup_point(&query)), Val::ZERO]); + let two_s = s + s; + assert_eq!(two_s * result + beta * e1, s * (e0 + e1) + beta * e0); + } + } + + #[test] + fn leaf_and_path_bind_pair_order_and_all_index_bits() { + let query = fixture(); + let root = query.commitment_root().unwrap(); + let mut pair_bit = query.clone(); + pair_bit.query_index ^= 1; + assert_ne!(pair_bit.commitment_root().unwrap(), root); + let mut path_bit = query.clone(); + path_bit.query_index ^= 1 << 3; + assert_ne!(path_bit.commitment_root().unwrap(), root); + assert_ne!( + pair_bit.folded_result().unwrap(), + query.folded_result().unwrap() + ); + } + + #[test] + fn parser_is_strict_before_crypto() { + let query = fixture(); + let artifact = FriFoldConformanceArtifactV1 { + folded_result: query.folded_result().unwrap(), + commitment_root: query.commitment_root().unwrap(), + query, + circuit_digest: [7; 32], + proof_bundle_bytes: vec![1, 2, 3], + }; + let mut bytes = artifact.to_bytes(); + assert!(FriFoldConformanceArtifactV1::from_bytes(&bytes).is_err()); + bytes[0] ^= 1; + assert!(FriFoldConformanceArtifactV1::from_bytes(&bytes).is_err()); + } + + #[test] + fn commit_phase_threads_shifted_index_and_folded_results() { + let query = commit_phase_fixture(); + let computation = compute_commit_phase(&query).unwrap(); + ensure_final_polynomial(&query, &computation).unwrap(); + assert_eq!(computation.round_queries.len(), 3); + assert_eq!( + computation.round_queries[1].query_index, + query.query_index >> 1 + ); + assert_eq!(computation.round_queries[1].folded, computation.results[0]); + assert_ne!(computation.fold_results[0], computation.results[0]); + assert_eq!(computation.fold_results[1], computation.results[1]); + assert_eq!(computation.results[2], query.final_polynomial); + assert_eq!(query.commitment_roots().unwrap(), computation.roots); + + let mut wrong_beta = query.clone(); + wrong_beta.rounds[1].beta[0] ^= 1; + assert!(wrong_beta.folded_results().is_err()); + } + + #[test] + fn commit_phase_parser_is_strict_before_crypto() { + let query = commit_phase_fixture(); + let artifact = FriCommitPhaseConformanceArtifactV1 { + commitment_roots: query.commitment_roots().unwrap(), + query, + circuit_digest: [9; 32], + proof_bundle_bytes: vec![1, 2, 3], + }; + let mut bytes = artifact.to_bytes(); + assert!(FriCommitPhaseConformanceArtifactV1::from_bytes(&bytes).is_err()); + bytes[0] ^= 1; + assert!(FriCommitPhaseConformanceArtifactV1::from_bytes(&bytes).is_err()); + } + + #[test] + fn pcs_reduction_quotients_and_accumulator_match_reference_field() { + let opening = pcs_reduction_fixture(); + let computation = compute_pcs_reduction(&opening).unwrap(); + let denominator = native_extension(computation.denominator); + for (((&px, &pz), "ient), column) in opening + .opened_values + .iter() + .zip(&opening.opened_at_z) + .zip(&computation.quotients) + .zip(0..opening.opened_values.len()) + { + let px = ExtVal::new([Val::from_u64(px), Val::ZERO]); + assert_eq!( + denominator * native_extension(quotient) + px, + native_extension(pz), + "column {column}" + ); + } + assert_eq!(opening.reduced_accumulator().unwrap(), computation.accumulator); + assert_eq!(opening.next_alpha_power().unwrap(), computation.alpha_power); + assert_eq!(opening.commitment_root().unwrap(), computation.root); + + let mut changed_index = opening; + changed_index.query_index ^= 1; + assert_ne!(changed_index.commitment_root().unwrap(), computation.root); + } + + #[test] + fn pcs_leaf_hash_supports_multiple_blocks_and_blake3_chunks() { + let opening = wide_pcs_reduction_fixture(); + assert!(opening.opened_values.len() * 8 > 1_024); + let computation = compute_pcs_reduction(&opening).unwrap(); + let relation = PcsReductionRelation::build(&opening).unwrap(); + let inputs = pcs_reduction_relation_inputs(&opening, &computation); + let public = pcs_reduction_relation_public(&opening, &computation); + let witness = relation.shape.run(&inputs, &[]); + assert_eq!(witness.public, public); + assert!( + witness.rows::(relation.slots.blake3).len() + > opening.opening_proof.len() + 1 + ); + assert_eq!(computation.root, native_pcs_row_root(&opening)); + } + + #[test] + fn pcs_reduction_parser_is_strict_before_crypto() { + let opening = pcs_reduction_fixture(); + let computation = compute_pcs_reduction(&opening).unwrap(); + let artifact = PcsReductionConformanceArtifactV1 { + opening, + reduced_accumulator: computation.accumulator, + next_alpha_power: computation.alpha_power, + circuit_digest: [11; 32], + commitment_root: computation.root, + proof_bundle_bytes: vec![1, 2, 3], + }; + let mut bytes = artifact.to_bytes(); + assert!(PcsReductionConformanceArtifactV1::from_bytes(&bytes).is_err()); + bytes[0] ^= 1; + assert!(PcsReductionConformanceArtifactV1::from_bytes(&bytes).is_err()); + } + + #[test] + fn transcript_challenges_feed_pcs_wires_in_one_circuit() { + let (replay, opening) = transcript_bound_pcs_fixture(); + let challenges = replay.challenges().unwrap(); + let computation = compute_pcs_reduction(&opening).unwrap(); + let relation = TranscriptBoundPcsReductionRelation::build( + &replay, + &opening, + &computation, + challenges, + ) + .unwrap(); + let witness = relation.shape.run(&relation.inputs, &[]); + assert_eq!(witness.public, relation.public); + + let mut wrong_opening = opening; + wrong_opening.alpha[0] ^= 1; + assert!( + TranscriptBoundPcsReductionRelation::build( + &replay, + &wrong_opening, + &compute_pcs_reduction(&wrong_opening).unwrap(), + challenges, + ) + .is_err() + ); + } + + #[test] + fn transcript_betas_indices_caps_and_final_poly_feed_one_fri_circuit() { + let (prefix, fri_transcript, query) = transcript_bound_fri_fixture(); + let challenges = fri_transcript.challenges(&prefix).unwrap(); + assert_eq!(challenges.query_indices.len(), 5); + let computation = compute_commit_phase(&query).unwrap(); + let relation = TranscriptBoundFriCommitPhaseRelation::build( + &prefix, + &fri_transcript, + &challenges, + 0, + &query, + &computation, + ) + .unwrap(); + let witness = relation.shape.run(&relation.inputs, &[]); + assert_eq!(witness.public, relation.public); + + let mut wrong_beta = query.clone(); + wrong_beta.rounds[0].beta[0] ^= 1; + assert!( + ensure_transcript_binds_fri_query( + &fri_transcript, + &challenges, + 0, + &wrong_beta, + ) + .is_err() + ); + let mut wrong_index = query; + wrong_index.query_index ^= 1; + assert!( + ensure_transcript_binds_fri_query( + &fri_transcript, + &challenges, + 0, + &wrong_index, + ) + .is_err() + ); + } + + #[test] + fn one_transcript_drives_every_fri_query_in_one_circuit() { + let (prefix, fri_transcript, queries) = + transcript_bound_fri_all_queries_fixture(); + let challenges = fri_transcript.challenges(&prefix).unwrap(); + let computations = validate_all_transcript_bound_fri_queries( + &fri_transcript, + &challenges, + &queries, + ) + .unwrap(); + let relation = TranscriptBoundFriCommitPhaseRelation::build_all( + &prefix, + &fri_transcript, + &challenges, + &queries, + &computations, + ) + .unwrap(); + let witness = relation.shape.run(&relation.inputs, &[]); + assert_eq!(witness.public, relation.public); + + let mut missing = queries.clone(); + missing.pop(); + assert!( + validate_all_transcript_bound_fri_queries( + &fri_transcript, + &challenges, + &missing, + ) + .is_err() + ); + } + + #[test] + fn authenticated_multiheight_pcs_buckets_feed_fri_in_one_circuit() { + let (prefix, fri_transcript, pcs_instance, queries) = + transcript_bound_pcs_fri_fixture(); + let prefix_challenges = prefix.challenges().unwrap(); + let fri_challenges = fri_transcript.challenges(&prefix).unwrap(); + let (fri_computations, pcs_computations) = + validate_all_transcript_bound_pcs_fri_queries( + &prefix, + &fri_transcript, + &fri_challenges, + prefix_challenges, + &pcs_instance, + &queries, + ) + .unwrap(); + assert_eq!(pcs_computations[0].reduced_openings.len(), 2); + let relation = TranscriptBoundFriCommitPhaseRelation::build_all_with_pcs( + &prefix, + &fri_transcript, + &fri_challenges, + &pcs_instance, + &queries, + &fri_computations, + &pcs_computations, + ) + .unwrap(); + let witness = relation.shape.run(&relation.inputs, &[]); + assert_eq!(witness.public, relation.public); + assert!(witness.rows::(relation.slots.blake3).len() > 20); + + let mut wrong_row = queries.clone(); + wrong_row[0].pcs.batch_openings[0].opened_rows[1][0] ^= 1; + assert!( + validate_all_transcript_bound_pcs_fri_queries( + &prefix, + &fri_transcript, + &fri_challenges, + prefix_challenges, + &pcs_instance, + &wrong_row, + ) + .is_err() + ); + let mut wrong_rollin = queries; + wrong_rollin[0].fri.rounds[0].reduced_opening.as_mut().unwrap()[0] ^= 1; + assert!( + validate_all_transcript_bound_pcs_fri_queries( + &prefix, + &fri_transcript, + &fri_challenges, + prefix_challenges, + &pcs_instance, + &wrong_rollin, + ) + .is_err() + ); + } + + #[test] + fn real_stage2_root_lowers_to_the_combined_pcs_fri_relation() { + let (prepared, fri, _, _, _) = prepared_stage2_pcs_fixture(); + let lowered = + Stage2PcsFriWitnessV1::from_prepared(&prepared, &fri).unwrap(); + let air = + Stage2AirProgramV1::from_prepared(&prepared, &fri, &lowered.pcs_instance) + .unwrap(); + + assert_eq!(lowered.pcs_instance.batches.len(), 4); + assert!( + lowered + .pcs_instance + .batches + .iter() + .take(3) + .all(|batch| batch.matrices.len() == 2) + ); + assert_eq!(lowered.pcs_instance.batches.get(3).unwrap().matrices.len(), 1); + assert_eq!(lowered.queries.len(), fri.num_queries); + + let prefix_challenges = lowered.prefix.challenges().unwrap(); + let fri_challenges = + lowered.fri_transcript.challenges(&lowered.prefix).unwrap(); + let (fri_computations, pcs_computations) = + validate_all_transcript_bound_pcs_fri_queries( + &lowered.prefix, + &lowered.fri_transcript, + &fri_challenges, + prefix_challenges, + &lowered.pcs_instance, + &lowered.queries, + ) + .unwrap(); + let relation = + TranscriptBoundFriCommitPhaseRelation::build_all_with_pcs_and_air( + &lowered.prefix, + &lowered.fri_transcript, + &fri_challenges, + &lowered.pcs_instance, + &air, + &lowered.queries, + &fri_computations, + &pcs_computations, + ) + .unwrap(); + let relation_witness = relation.shape.run(&relation.inputs, &[]); + assert_eq!(relation_witness.public, relation.public); + + let mut wrong_row = lowered.queries; + wrong_row[0].pcs.batch_openings[0].opened_rows[0][0] ^= 1; + assert!( + validate_all_transcript_bound_pcs_fri_queries( + &lowered.prefix, + &lowered.fri_transcript, + &fri_challenges, + prefix_challenges, + &lowered.pcs_instance, + &wrong_row, + ) + .is_err() + ); + } + + #[test] + #[ignore = "real production Flock proof of a complete Stage 2 verifier"] + fn real_stage2_production_artifact_round_trip() { + let (prepared, fri, vk_bytes, claim_bytes, proof_bytes) = + prepared_stage2_pcs_fixture(); + let backend = crate::FlockStage3Backend; + let artifact = backend + .prove_stage2(&vk_bytes, &claim_bytes, &proof_bytes, &fri) + .expect("prove complete Stage 3 relation"); + eprintln!( + "Flock complete Stage 3 artifact: {} bytes (payload: {} bytes)", + artifact.to_bytes().len(), + artifact.proof_bytes().len(), + ); + let encoded = artifact.to_bytes(); + let decoded = crate::Stage3ArtifactV1::from_bytes(&encoded).unwrap(); + backend + .verify_stage2(&decoded, decoded.statement()) + .expect("verify complete Stage 3 relation"); + + let wrong_relation = + crate::Stage3StatementV1::new(prepared.statement(), [0xa5; 32]); + assert!(backend.verify_stage2(&decoded, &wrong_relation).is_err()); + + let mut corrupted = encoded; + let flip_at = corrupted.len() - 1; + corrupted[flip_at] ^= 1; + let corrupted = crate::Stage3ArtifactV1::from_bytes(&corrupted).unwrap(); + assert!(backend.verify_stage2(&corrupted, corrupted.statement()).is_err()); + } + + #[test] + #[ignore = "real Flock authenticated FRI-fold proof; run explicitly"] + fn real_authenticated_fri_fold_round_trip_and_mutations() { + let artifact = prove_fri_fold_conformance(&fixture()).expect("prove fold"); + eprintln!( + "Flock authenticated FRI-fold conformance bundle: {} bytes", + artifact.proof_bundle_bytes().len() + ); + verify_fri_fold_conformance(&artifact).expect("verify fold"); + let decoded = + FriFoldConformanceArtifactV1::from_bytes(&artifact.to_bytes()).unwrap(); + verify_fri_fold_conformance(&decoded).expect("verify decoded fold"); + + let mut wrong_sibling = decoded.clone(); + wrong_sibling.query.opening_proof[2][7] ^= 1; + assert!(verify_fri_fold_conformance(&wrong_sibling).is_err()); + let mut wrong_beta = decoded.clone(); + wrong_beta.query.beta[1] ^= 1; + assert!(verify_fri_fold_conformance(&wrong_beta).is_err()); + let mut wrong_result = decoded.clone(); + wrong_result.folded_result[0] ^= 1; + assert!(verify_fri_fold_conformance(&wrong_result).is_err()); + let mut wrong_proof = decoded; + let flip_at = wrong_proof.proof_bundle_bytes.len() / 2; + wrong_proof.proof_bundle_bytes[flip_at] ^= 1; + assert!(verify_fri_fold_conformance(&wrong_proof).is_err()); + } + + #[test] + #[ignore = "real Flock FRI commit-phase query proof; run explicitly"] + fn real_fri_commit_phase_round_trip_and_mutations() { + let artifact = prove_fri_commit_phase_conformance(&commit_phase_fixture()) + .expect("prove commit phase"); + eprintln!( + "Flock FRI commit-phase conformance bundle: {} bytes", + artifact.proof_bundle_bytes().len() + ); + verify_fri_commit_phase_conformance(&artifact) + .expect("verify commit phase"); + let decoded = + FriCommitPhaseConformanceArtifactV1::from_bytes(&artifact.to_bytes()) + .unwrap(); + verify_fri_commit_phase_conformance(&decoded) + .expect("verify decoded commit phase"); + + let mut wrong_path = decoded.clone(); + wrong_path.query.rounds[1].opening_proof[0][5] ^= 1; + assert!(verify_fri_commit_phase_conformance(&wrong_path).is_err()); + let mut wrong_final = decoded.clone(); + wrong_final.query.final_polynomial[1] ^= 1; + assert!(verify_fri_commit_phase_conformance(&wrong_final).is_err()); + let mut wrong_root = decoded.clone(); + wrong_root.commitment_roots[2][3] ^= 1; + assert!(verify_fri_commit_phase_conformance(&wrong_root).is_err()); + let mut wrong_proof = decoded; + let flip_at = wrong_proof.proof_bundle_bytes.len() / 2; + wrong_proof.proof_bundle_bytes[flip_at] ^= 1; + assert!(verify_fri_commit_phase_conformance(&wrong_proof).is_err()); + } + + #[test] + #[ignore = "real Flock authenticated PCS-reduction proof; run explicitly"] + fn real_pcs_reduction_round_trip_and_mutations() { + let artifact = prove_pcs_reduction_conformance(&pcs_reduction_fixture()) + .expect("prove PCS reduction"); + eprintln!( + "Flock PCS-reduction conformance bundle: {} bytes", + artifact.proof_bundle_bytes().len() + ); + verify_pcs_reduction_conformance(&artifact).expect("verify PCS reduction"); + let decoded = + PcsReductionConformanceArtifactV1::from_bytes(&artifact.to_bytes()) + .unwrap(); + verify_pcs_reduction_conformance(&decoded) + .expect("verify decoded PCS reduction"); + + let mut wrong_value = decoded.clone(); + wrong_value.opening.opened_values[2] ^= 1; + assert!(verify_pcs_reduction_conformance(&wrong_value).is_err()); + let mut wrong_ood = decoded.clone(); + wrong_ood.opening.opened_at_z[1][0] ^= 1; + assert!(verify_pcs_reduction_conformance(&wrong_ood).is_err()); + let mut wrong_result = decoded.clone(); + wrong_result.reduced_accumulator[0] ^= 1; + assert!(verify_pcs_reduction_conformance(&wrong_result).is_err()); + let mut wrong_proof = decoded; + let flip_at = wrong_proof.proof_bundle_bytes.len() / 2; + wrong_proof.proof_bundle_bytes[flip_at] ^= 1; + assert!(verify_pcs_reduction_conformance(&wrong_proof).is_err()); + } + + #[test] + #[ignore = "real transcript-bound Flock PCS proof; run explicitly"] + fn real_transcript_bound_pcs_round_trip_and_mutations() { + let (replay, opening) = transcript_bound_pcs_fixture(); + let artifact = + prove_transcript_bound_pcs_reduction_conformance(&replay, &opening) + .expect("prove transcript-bound PCS reduction"); + eprintln!( + "Flock transcript-bound PCS conformance bundle: {} bytes", + artifact.proof_bundle_bytes().len() + ); + verify_transcript_bound_pcs_reduction_conformance(&artifact) + .expect("verify transcript-bound PCS reduction"); + + let mut wrong_transcript = artifact.clone(); + wrong_transcript.replay.pcs_opening_observations[0] ^= 1; + assert!( + verify_transcript_bound_pcs_reduction_conformance(&wrong_transcript) + .is_err() + ); + let mut wrong_opening = artifact.clone(); + wrong_opening.opening.opened_values[0] ^= 1; + assert!( + verify_transcript_bound_pcs_reduction_conformance(&wrong_opening) + .is_err() + ); + let mut wrong_proof = artifact; + let flip_at = wrong_proof.proof_bundle_bytes.len() / 2; + wrong_proof.proof_bundle_bytes[flip_at] ^= 1; + assert!( + verify_transcript_bound_pcs_reduction_conformance(&wrong_proof).is_err() + ); + } + + #[test] + #[ignore = "real transcript-bound Flock FRI-query proof; run explicitly"] + fn real_transcript_bound_fri_round_trip_and_mutations() { + let (prefix, fri_transcript, query) = transcript_bound_fri_fixture(); + let artifact = prove_transcript_bound_fri_commit_phase_conformance( + &prefix, + &fri_transcript, + 0, + &query, + ) + .expect("prove transcript-bound FRI query"); + eprintln!( + "Flock transcript-bound FRI-query conformance bundle: {} bytes", + artifact.proof_bundle_bytes().len() + ); + verify_transcript_bound_fri_commit_phase_conformance(&artifact) + .expect("verify transcript-bound FRI query"); + + let mut wrong_cap = artifact.clone(); + wrong_cap.fri_transcript.commit_phase_commitments[1][0][7] ^= 1; + assert!( + verify_transcript_bound_fri_commit_phase_conformance(&wrong_cap).is_err() + ); + let mut wrong_query = artifact.clone(); + wrong_query.query.query_index ^= 1; + assert!( + verify_transcript_bound_fri_commit_phase_conformance(&wrong_query) + .is_err() + ); + let mut wrong_final = artifact.clone(); + wrong_final.fri_transcript.final_polynomial[0][0] ^= 1; + assert!( + verify_transcript_bound_fri_commit_phase_conformance(&wrong_final) + .is_err() + ); + let mut wrong_proof = artifact; + let flip_at = wrong_proof.proof_bundle_bytes.len() / 2; + wrong_proof.proof_bundle_bytes[flip_at] ^= 1; + assert!( + verify_transcript_bound_fri_commit_phase_conformance(&wrong_proof) + .is_err() + ); + } + + #[test] + #[ignore = "real all-query transcript-bound Flock FRI proof; run explicitly"] + fn real_transcript_bound_fri_all_queries_round_trip_and_mutations() { + let (prefix, fri_transcript, queries) = + transcript_bound_fri_all_queries_fixture(); + let artifact = prove_transcript_bound_fri_queries_conformance( + &prefix, + &fri_transcript, + &queries, + ) + .expect("prove every transcript-bound FRI query"); + eprintln!( + "Flock all-query transcript-bound FRI bundle: {} bytes", + artifact.proof_bundle_bytes().len() + ); + verify_transcript_bound_fri_queries_conformance(&artifact) + .expect("verify every transcript-bound FRI query"); + + let mut wrong_query = artifact.clone(); + wrong_query.queries[1].query_index ^= 1; + assert!( + verify_transcript_bound_fri_queries_conformance(&wrong_query).is_err() + ); + let mut missing_query = artifact.clone(); + missing_query.queries.pop(); + assert!( + verify_transcript_bound_fri_queries_conformance(&missing_query).is_err() + ); + let mut wrong_proof = artifact; + let flip_at = wrong_proof.proof_bundle_bytes.len() / 2; + wrong_proof.proof_bundle_bytes[flip_at] ^= 1; + assert!( + verify_transcript_bound_fri_queries_conformance(&wrong_proof).is_err() + ); + } + + #[test] + #[ignore = "real transcript-bound PCS-to-FRI Flock proof; run explicitly"] + fn real_transcript_bound_pcs_fri_round_trip_and_mutations() { + let (prefix, fri_transcript, pcs_instance, queries) = + transcript_bound_pcs_fri_fixture(); + let artifact = prove_transcript_bound_pcs_fri_queries_conformance( + &prefix, + &fri_transcript, + &pcs_instance, + &queries, + ) + .expect("prove transcript-bound PCS-to-FRI relation"); + eprintln!( + "Flock transcript-bound PCS-to-FRI bundle: {} bytes", + artifact.proof_bundle_bytes().len() + ); + verify_transcript_bound_pcs_fri_queries_conformance(&artifact) + .expect("verify transcript-bound PCS-to-FRI relation"); + + let mut wrong_row = artifact.clone(); + wrong_row.queries[0].pcs.batch_openings[0].opened_rows[0][0] ^= 1; + assert!( + verify_transcript_bound_pcs_fri_queries_conformance(&wrong_row).is_err() + ); + let mut wrong_ood = artifact.clone(); + wrong_ood.prefix.pcs_opening_observations[0] ^= 1; + assert!( + verify_transcript_bound_pcs_fri_queries_conformance(&wrong_ood).is_err() + ); + let mut wrong_proof = artifact; + let flip_at = wrong_proof.proof_bundle_bytes.len() / 2; + wrong_proof.proof_bundle_bytes[flip_at] ^= 1; + assert!( + verify_transcript_bound_pcs_fri_queries_conformance(&wrong_proof) + .is_err() + ); + } +} diff --git a/flock-stage3/host/src/goldilocks.rs b/flock-stage3/host/src/goldilocks.rs new file mode 100644 index 00000000..fa4253f3 --- /dev/null +++ b/flock-stage3/host/src/goldilocks.rs @@ -0,0 +1,590 @@ +//! Boolean R1CS gadgets for Goldilocks values carried as little-endian u64s. +//! +//! Flock's circuit wiring moves one `F128` word at a time. One gate therefore +//! checks two Goldilocks representatives at once and exposes a 128-bit +//! violation word. The circuit connects that output to a fixed zero wire. + +use std::sync::OnceLock; + +use flock_prover::{ + circuit::builder::{GateType, SlotWitness}, + field::F128, + lincheck::pack_z_lincheck, + r1cs::{BlockR1cs, SparseBinaryMatrix, WitnessLayout}, + schedule::{IoWord, TableType}, +}; + +use crate::boolean::{ + BooleanR1csBuilder, BooleanR1csPlan, generate_boolean_witness, + write_f128 as write_boolean_f128, +}; + +pub(crate) const GOLDILOCKS_MODULUS: u64 = 0xffff_ffff_0000_0001; +const K_LOG: usize = 9; +const K: usize = 1 << K_LOG; +const K_SKIP: usize = 6; +const INPUT_BASE: usize = 0; +const VIOLATION_BASE: usize = 128; +const FIRST_CHAIN_BASE: usize = 256; +const SECOND_CHAIN_BASE: usize = FIRST_CHAIN_BASE + 31; +const USEFUL_BITS: usize = SECOND_CHAIN_BASE + 31; + +const ADD_K_LOG: usize = 11; +const ADD_LEFT_BASE: usize = 0; +const ADD_RIGHT_BASE: usize = 128; +const ADD_RESULT_BASE: usize = 256; +const ADD_VIOLATION_BASE: usize = 384; +const ADD_TOP_VIOLATION_BASE: usize = 512; +const ADD_RESERVED_COLUMNS: usize = 640; + +/// One R1CS row record for a pair of little-endian Goldilocks candidates. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct CanonicalGoldilocksPairRow(F128); + +/// A word-aligned Boolean gate that checks two canonical Goldilocks values. +/// +/// Its sole output is zero exactly when both input u64 limbs are below +/// `2^64 - 2^32 + 1`. Callers must connect that output to a fixed zero wire; +/// the table alone intentionally exposes, rather than silently pins, the +/// violation bits. +#[derive(Clone, Copy, Debug)] +pub(crate) struct CanonicalGoldilocksPairGate { + pub(crate) nu: usize, +} + +impl GateType for CanonicalGoldilocksPairGate { + type Row = CanonicalGoldilocksPairRow; + type Hint = (); + + fn table(&self) -> TableType { + TableType::from_block_r1cs(&build_canonical_pair_r1cs(self.nu)) + .with_io_schema(vec![IoWord::input(0), IoWord::output(1)]) + } + + fn eval( + &self, + inputs: &[F128], + _hint: &(), + outputs: &mut Vec, + ) -> Self::Row { + let value = inputs[0]; + outputs.push(violation_word(value)); + CanonicalGoldilocksPairRow(value) + } + + fn witness(&self, _rows: &[Self::Row], _nu: usize) -> SlotWitness { + SlotWitness::DeferredToRows + } +} + +/// Build the Boolean relation used by [`CanonicalGoldilocksPairGate`]. +pub(crate) fn build_canonical_pair_r1cs(nu: usize) -> BlockR1cs { + assert!(nu >= 3, "Flock lincheck requires at least eight rows"); + + let mut a_rows = vec![Vec::new(); K]; + let mut b_rows = vec![Vec::new(); K]; + + // Input bits are free Boolean values: x * x = x over GF(2). + for bit in 0..128 { + a_rows[INPUT_BASE + bit].push(INPUT_BASE + bit); + b_rows[INPUT_BASE + bit].push(INPUT_BASE + bit); + } + + // Fold each limb's high 32 bits to one `high_is_all_ones` bit. + add_and_chain(&mut a_rows, &mut b_rows, 32, FIRST_CHAIN_BASE); + add_and_chain(&mut a_rows, &mut b_rows, 96, SECOND_CHAIN_BASE); + + // x >= p iff its high 32 bits are all one and at least one low bit is one. + // Materialize all 32 products. Wiring pins the complete output word to zero. + let first_high_all = FIRST_CHAIN_BASE + 30; + let second_high_all = SECOND_CHAIN_BASE + 30; + for low_bit in 0..32 { + a_rows[VIOLATION_BASE + low_bit].push(first_high_all); + b_rows[VIOLATION_BASE + low_bit].push(low_bit); + a_rows[VIOLATION_BASE + 32 + low_bit].push(second_high_all); + b_rows[VIOLATION_BASE + 32 + low_bit].push(64 + low_bit); + } + + let identity_rows = (0..K).map(|row| vec![row]).collect(); + BlockR1cs { + m: K_LOG + nu, + k_log: K_LOG, + k_skip: K_SKIP, + useful_bits: USEFUL_BITS, + a_0: sparse_matrix(a_rows), + b_0: sparse_matrix(b_rows), + c_0: sparse_matrix(identity_rows), + layout: WitnessLayout::BatchMajor, + const_pin: None, + digest_cache: OnceLock::new(), + csc_cache: OnceLock::new(), + } +} + +/// Produce Flock's batch-major `(z, A z, B z, lincheck stripe)` tuple. +pub(crate) fn generate_canonical_pair_witness( + rows: &[CanonicalGoldilocksPairRow], + nu: usize, +) -> (Vec, Vec, Vec, Vec) { + let capacity = 1usize << nu; + assert!(rows.len() <= capacity); + let r1cs = build_canonical_pair_r1cs(nu); + let mut z = vec![false; r1cs.n()]; + for (outer, row) in rows.iter().enumerate() { + let range = outer * K..(outer + 1) * K; + fill_logical_row(&mut z[range], row.0); + } + let a = r1cs.apply_a(&z); + let b = r1cs.apply_b(&z); + debug_assert!( + a.iter() + .zip(&b) + .zip(&z) + .all(|((a_bit, b_bit), z_bit)| (*a_bit & *b_bit) == *z_bit) + ); + let stripe = pack_z_lincheck(&z, r1cs.m, r1cs.k_log); + ( + pack_batch_major(&z, nu), + pack_batch_major(&a, nu), + pack_batch_major(&b, nu), + stripe, + ) +} + +/// One row of two lane-wise Goldilocks additions packed into `F128` words. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct GoldilocksAddPairRow { + left: F128, + right: F128, +} + +/// Two independent canonical Goldilocks additions, one per u64 lane. +/// +/// The gate exposes the result plus two zero-valued equation-residual words. +/// Callers connect both residuals to a fixed zero wire and pass every input +/// and result through [`CanonicalGoldilocksPairGate`]. Keeping canonicality a +/// shared table avoids duplicating its constraints in every arithmetic table. +#[derive(Clone, Copy, Debug)] +pub(crate) struct GoldilocksAddPairGate { + pub(crate) nu: usize, +} + +impl GateType for GoldilocksAddPairGate { + type Row = GoldilocksAddPairRow; + type Hint = (); + + fn table(&self) -> TableType { + TableType::from_block_r1cs(&build_goldilocks_add_r1cs(self.nu)) + .with_io_schema(vec![ + IoWord::input(0), + IoWord::input(1), + IoWord::output(2), + IoWord::output(3), + IoWord::output(4), + ]) + } + + fn eval( + &self, + inputs: &[F128], + _hint: &(), + outputs: &mut Vec, + ) -> Self::Row { + let left = inputs[0]; + let right = inputs[1]; + outputs.extend_from_slice(&[ + F128::new( + goldilocks_add(left.lo, right.lo), + goldilocks_add(left.hi, right.hi), + ), + F128::ZERO, + F128::ZERO, + ]); + GoldilocksAddPairRow { left, right } + } + + fn witness(&self, _rows: &[Self::Row], _nu: usize) -> SlotWitness { + SlotWitness::DeferredToRows + } +} + +struct GoldilocksAddPlan { + boolean: BooleanR1csPlan, + quotient_bits: [usize; 2], +} + +pub(crate) fn build_goldilocks_add_r1cs(nu: usize) -> BlockR1cs { + build_goldilocks_add_plan().boolean.block_r1cs(nu) +} + +pub(crate) fn generate_goldilocks_add_witness( + rows: &[GoldilocksAddPairRow], + nu: usize, +) -> (Vec, Vec, Vec, Vec) { + let plan = build_goldilocks_add_plan(); + generate_boolean_witness(&plan.boolean, rows, nu, |row, bits| { + fill_goldilocks_add_row(&plan, *row, bits) + }) +} + +fn build_goldilocks_add_plan() -> GoldilocksAddPlan { + let mut builder = BooleanR1csBuilder::new(ADD_K_LOG, ADD_RESERVED_COLUMNS); + for column in ADD_LEFT_BASE..ADD_RESULT_BASE + 128 { + builder.free_boolean_at(column); + } + let one = builder.alloc_constant_one(); + let quotient_bits = + [builder.alloc_free_boolean(), builder.alloc_free_boolean()]; + + for (lane, "ient) in quotient_bits.iter().enumerate() { + let lane_offset = lane * 64; + let left: [usize; 64] = + std::array::from_fn(|bit| ADD_LEFT_BASE + lane_offset + bit); + let right: [usize; 64] = + std::array::from_fn(|bit| ADD_RIGHT_BASE + lane_offset + bit); + let result: [usize; 64] = + std::array::from_fn(|bit| ADD_RESULT_BASE + lane_offset + bit); + let right_terms = right.map(Some); + let modulus_terms: [Option; 64] = std::array::from_fn(|bit| { + (((GOLDILOCKS_MODULUS >> bit) & 1) == 1).then_some(quotient) + }); + let (left_sum, left_carry) = + ripple_add(&mut builder, &left, &right_terms, one); + let (right_sum, right_carry) = + ripple_add(&mut builder, &result, &modulus_terms, one); + + for bit in 0..64 { + builder.write_xor( + ADD_VIOLATION_BASE + lane_offset + bit, + &[left_sum[bit], right_sum[bit]], + one, + ); + } + builder.write_xor( + ADD_TOP_VIOLATION_BASE + lane, + &[left_carry, right_carry], + one, + ); + } + + GoldilocksAddPlan { boolean: builder.finish(), quotient_bits } +} + +fn ripple_add( + builder: &mut BooleanR1csBuilder, + left: &[usize; 64], + right: &[Option; 64], + one: usize, +) -> (Vec, usize) { + let mut sums = Vec::with_capacity(64); + let mut carry = None; + for bit in 0..64 { + let xor_lr = right[bit] + .map_or(left[bit], |right| builder.xor(&[left[bit], right], one)); + let sum = carry.map_or(xor_lr, |carry| builder.xor(&[xor_lr, carry], one)); + sums.push(sum); + + let left_and_right = right[bit].map(|right| builder.and(left[bit], right)); + let carry_and_xor = carry.map(|carry| builder.and(carry, xor_lr)); + carry = match (left_and_right, carry_and_xor) { + (Some(first), Some(second)) => Some(builder.xor(&[first, second], one)), + (Some(carry), None) | (None, Some(carry)) => Some(carry), + (None, None) => None, + }; + } + (sums, carry.expect("addition has a carry variable from bit zero")) +} + +fn fill_goldilocks_add_row( + plan: &GoldilocksAddPlan, + row: GoldilocksAddPairRow, + bits: &mut [bool], +) { + let result = F128::new( + goldilocks_add(row.left.lo, row.right.lo), + goldilocks_add(row.left.hi, row.right.hi), + ); + write_boolean_f128(bits, ADD_LEFT_BASE, row.left); + write_boolean_f128(bits, ADD_RIGHT_BASE, row.right); + write_boolean_f128(bits, ADD_RESULT_BASE, result); + for (lane, quotient) in plan.quotient_bits.iter().enumerate() { + let (left, right) = if lane == 0 { + (row.left.lo, row.right.lo) + } else { + (row.left.hi, row.right.hi) + }; + bits[*quotient] = + left as u128 + right as u128 >= GOLDILOCKS_MODULUS as u128; + } +} + +pub(crate) fn goldilocks_add(left: u64, right: u64) -> u64 { + ((left as u128 + right as u128) % GOLDILOCKS_MODULUS as u128) as u64 +} + +fn add_and_chain( + a_rows: &mut [Vec], + b_rows: &mut [Vec], + high_base: usize, + chain_base: usize, +) { + for step in 0..31 { + let output = chain_base + step; + let lhs = if step == 0 { high_base } else { output - 1 }; + let rhs = high_base + step + 1; + a_rows[output].push(lhs); + b_rows[output].push(rhs); + } +} + +fn sparse_matrix(rows: Vec>) -> SparseBinaryMatrix { + SparseBinaryMatrix { num_rows: K, num_cols: K, rows } +} + +fn fill_logical_row(bits: &mut [bool], value: F128) { + assert_eq!(bits.len(), K); + write_f128(bits, INPUT_BASE, value); + write_f128(bits, VIOLATION_BASE, violation_word(value)); + fill_and_chain(bits, value.lo, FIRST_CHAIN_BASE); + fill_and_chain(bits, value.hi, SECOND_CHAIN_BASE); +} + +fn fill_and_chain(bits: &mut [bool], limb: u64, chain_base: usize) { + let mut accumulator = bit(limb, 32); + for step in 0..31 { + accumulator &= bit(limb, 33 + step); + bits[chain_base + step] = accumulator; + } +} + +fn violation_word(value: F128) -> F128 { + let first = limb_violation_bits(value.lo) as u64; + let second = limb_violation_bits(value.hi) as u64; + F128::new(first | (second << 32), 0) +} + +fn limb_violation_bits(value: u64) -> u32 { + if value >= GOLDILOCKS_MODULUS { value as u32 } else { 0 } +} + +fn write_f128(bits: &mut [bool], offset: usize, value: F128) { + for local in 0..64 { + bits[offset + local] = bit(value.lo, local); + bits[offset + 64 + local] = bit(value.hi, local); + } +} + +fn bit(value: u64, index: usize) -> bool { + (value >> index) & 1 == 1 +} + +fn pack_batch_major(bits: &[bool], nu: usize) -> Vec { + let capacity = 1usize << nu; + assert_eq!(bits.len(), capacity * K); + let chunks = K / 128; + let mut packed = vec![F128::ZERO; chunks * capacity]; + for chunk in 0..chunks { + for outer in 0..capacity { + let start = outer * K + chunk * 128; + let mut lo = 0u64; + let mut hi = 0u64; + for local in 0..64 { + lo |= u64::from(bits[start + local]) << local; + hi |= u64::from(bits[start + 64 + local]) << local; + } + packed[(chunk << nu) + outer] = F128::new(lo, hi); + } + } + packed +} + +#[cfg(test)] +mod tests { + use std::panic::{AssertUnwindSafe, catch_unwind}; + + use flock_prover::circuit::builder::ShapeBuilder; + use multi_stark::{ + p3_field::{PrimeCharacteristicRing, PrimeField64}, + p3_goldilocks::Goldilocks, + }; + + use super::*; + + #[test] + fn canonicality_boundary_matches_goldilocks_modulus() { + for value in [0, 1, GOLDILOCKS_MODULUS - 1] { + assert_eq!(violation_word(F128::new(value, value)), F128::ZERO); + } + assert_ne!(violation_word(F128::new(GOLDILOCKS_MODULUS, 0)), F128::ZERO); + assert_ne!(violation_word(F128::new(0, GOLDILOCKS_MODULUS)), F128::ZERO); + assert_ne!(violation_word(F128::new(u64::MAX, 0)), F128::ZERO); + } + + #[test] + fn r1cs_recomputes_every_violation_bit() { + let r1cs = build_canonical_pair_r1cs(3); + for value in [ + F128::new(0, GOLDILOCKS_MODULUS - 1), + F128::new(GOLDILOCKS_MODULUS, u64::MAX), + ] { + let mut row = vec![false; K]; + fill_logical_row(&mut row, value); + let mut witness = vec![false; r1cs.n()]; + witness[..K].copy_from_slice(&row); + assert!(r1cs.satisfies(&witness)); + + if violation_word(value) != F128::ZERO { + witness[VIOLATION_BASE..VIOLATION_BASE + 128].fill(false); + assert!(!r1cs.satisfies(&witness)); + } + } + } + + #[test] + fn circuit_wiring_pins_violation_output_to_zero() { + let nu = 3; + let mut builder = ShapeBuilder::new(nu); + let slot = builder.slot(CanonicalGoldilocksPairGate { nu }); + let candidate = builder.input(); + let zero = builder.fixed_public_input(F128::ZERO); + let violation = builder.gate(slot, &[candidate])[0]; + builder.connect(violation, zero); + let shape = builder.finish().unwrap(); + + shape.run(&[F128::new(GOLDILOCKS_MODULUS - 1, 0), F128::ZERO], &[]); + let invalid = catch_unwind(AssertUnwindSafe(|| { + shape.run(&[F128::new(GOLDILOCKS_MODULUS, 0), F128::ZERO], &[]) + })); + assert!(invalid.is_err()); + } + + #[test] + fn batch_major_witness_has_zero_dummy_rows() { + let rows = [ + CanonicalGoldilocksPairRow(F128::new(1, 2)), + CanonicalGoldilocksPairRow(F128::new(3, 4)), + ]; + let (z, a, b, stripe) = generate_canonical_pair_witness(&rows, 3); + assert_eq!(z.len(), 32); + assert_eq!(a.len(), z.len()); + assert_eq!(b.len(), z.len()); + assert_eq!(stripe.len(), K); + for chunk in 0..K / 128 { + for outer in rows.len()..8 { + assert_eq!(z[(chunk << 3) + outer], F128::ZERO); + assert_eq!(a[(chunk << 3) + outer], F128::ZERO); + assert_eq!(b[(chunk << 3) + outer], F128::ZERO); + } + } + } + + #[test] + fn modular_add_matches_reference_goldilocks() { + let boundary = [ + 0, + 1, + 2, + (1u64 << 32) - 1, + 1u64 << 32, + GOLDILOCKS_MODULUS - 2, + GOLDILOCKS_MODULUS - 1, + ]; + for &left in &boundary { + for &right in &boundary { + let expected = (Goldilocks::from_u64(left) + + Goldilocks::from_u64(right)) + .as_canonical_u64(); + assert_eq!(goldilocks_add(left, right), expected); + } + } + + let mut state = 0x6a09_e667_f3bc_c909u64; + for _ in 0..256 { + state = state + .wrapping_mul(0x9e37_79b9_7f4a_7c15) + .wrapping_add(0xbf58_476d_1ce4_e5b9); + let left = state % GOLDILOCKS_MODULUS; + state ^= state.rotate_left(29); + let right = state % GOLDILOCKS_MODULUS; + let expected = (Goldilocks::from_u64(left) + Goldilocks::from_u64(right)) + .as_canonical_u64(); + assert_eq!(goldilocks_add(left, right), expected); + } + } + + #[test] + fn modular_add_r1cs_rejects_wrong_result_and_quotient() { + let plan = build_goldilocks_add_plan(); + let r1cs = plan.boolean.block_r1cs(3); + let cases = [ + GoldilocksAddPairRow { + left: F128::new(0, GOLDILOCKS_MODULUS - 1), + right: F128::new(0, 0), + }, + GoldilocksAddPairRow { + left: F128::new(GOLDILOCKS_MODULUS - 1, 1 << 32), + right: F128::new(GOLDILOCKS_MODULUS - 1, u64::MAX >> 32), + }, + ]; + for row in cases { + let mut logical = vec![false; plan.boolean.k()]; + plan.boolean.fill_row(&mut logical, |bits| { + fill_goldilocks_add_row(&plan, row, bits) + }); + let mut witness = vec![false; r1cs.n()]; + witness[..plan.boolean.k()].copy_from_slice(&logical); + assert!(r1cs.satisfies(&witness)); + + let mut wrong_result = witness.clone(); + wrong_result[ADD_RESULT_BASE + 17] ^= true; + assert!(!r1cs.satisfies(&wrong_result)); + + let mut wrong_quotient = witness; + wrong_quotient[plan.quotient_bits[0]] ^= true; + assert!(!r1cs.satisfies(&wrong_quotient)); + } + } + + #[test] + fn modular_add_gate_pins_equation_residuals() { + let nu = 3; + let mut builder = ShapeBuilder::new(nu); + let slot = builder.slot(GoldilocksAddPairGate { nu }); + let left = builder.input(); + let right = builder.input(); + let zero = builder.fixed_public_input(F128::ZERO); + let outputs = builder.gate(slot, &[left, right]); + builder.connect(outputs[1], zero); + builder.connect(outputs[2], zero); + builder.publish(outputs[0]); + let shape = builder.finish().unwrap(); + + let left = F128::new(GOLDILOCKS_MODULUS - 1, 7); + let right = F128::new(2, GOLDILOCKS_MODULUS - 3); + let witness = shape.run(&[left, right, F128::ZERO], &[]); + assert_eq!(*witness.public.last().unwrap(), F128::new(1, 4)); + assert_eq!( + witness.rows::(slot), + &[GoldilocksAddPairRow { left, right }] + ); + } + + #[test] + fn modular_add_batch_witness_zeroes_dummy_rows() { + let rows = + [GoldilocksAddPairRow { left: F128::new(1, 2), right: F128::new(3, 4) }]; + let (z, a, b, stripe) = generate_goldilocks_add_witness(&rows, 3); + let chunks = (1usize << ADD_K_LOG) / 128; + assert_eq!(z.len(), chunks * 8); + assert_eq!(a.len(), z.len()); + assert_eq!(b.len(), z.len()); + assert_eq!(stripe.len(), 1usize << ADD_K_LOG); + for chunk in 0..chunks { + for outer in rows.len()..8 { + assert_eq!(z[(chunk << 3) + outer], F128::ZERO); + assert_eq!(a[(chunk << 3) + outer], F128::ZERO); + assert_eq!(b[(chunk << 3) + outer], F128::ZERO); + } + } + } +} diff --git a/flock-stage3/host/src/lib.rs b/flock-stage3/host/src/lib.rs new file mode 100644 index 00000000..a313018e --- /dev/null +++ b/flock-stage3/host/src/lib.rs @@ -0,0 +1,256 @@ +//! Ix Stage 3 backend for a specialised Aiur verifier over Flock's binary +//! field proof system. + +mod air; +mod arithmetic; +mod artifact; +mod binding; +mod boolean; +mod config; +mod conformance; +mod equality; +mod extension; +mod fri; +mod goldilocks; +mod merkle; +mod multiplication; +mod relation; +mod transcript; +mod typed_witness; +mod window; + +use aiur::vk_codec::AiurVerifyingKey; +use anyhow::{Result, bail}; +use ix_terminal::{ValidatedStage2RootV1, validate_and_expand_root_inputs}; +use multi_stark::types::FriParameters; + +pub use air::{Stage2ActiveAirCircuitV1, Stage2AirProgramV1}; +pub use arithmetic::{ + ARITHMETIC_CONFORMANCE_ARTIFACT_MAGIC, ArithmeticConformanceArtifactV1, + GoldilocksAddPairV1, GoldilocksExt2MulV1, GoldilocksMulPairV1, + prove_arithmetic_conformance, verify_arithmetic_conformance, +}; +use artifact::Stage3ProductionPayloadV1; +pub use artifact::{ + STAGE3_STATEMENT_BYTES, STAGE3_STATEMENT_DOMAIN, Stage3ArtifactV1, + Stage3StatementV1, +}; +pub use binding::{ + STAGE3_BINDING_ARTIFACT_MAGIC, Stage3BindingArtifactV1, + prove_stage3_statement_binding, stage3_statement_binding_circuit_digest, + verify_stage3_statement_binding, verify_stage3_statement_binding_for, +}; +pub use config::{ + ARITHMETIC_CONFORMANCE_TRANSCRIPT_DOMAIN, + ENGINE_CONFORMANCE_TRANSCRIPT_DOMAIN, FLOCK_UPSTREAM_REVISION, + FRI_FOLD_CONFORMANCE_TRANSCRIPT_DOMAIN, + FRI_QUERY_CONFORMANCE_TRANSCRIPT_DOMAIN, FlockConfigV1, + MERKLE_CONFORMANCE_TRANSCRIPT_DOMAIN, + PCS_REDUCTION_CONFORMANCE_TRANSCRIPT_DOMAIN, + STAGE2_AIR_PCS_FRI_CONFORMANCE_TRANSCRIPT_DOMAIN, + STAGE2_TRANSCRIPT_CONFORMANCE_TRANSCRIPT_DOMAIN, STAGE3_TRANSCRIPT_DOMAIN, + TRANSCRIPT_BOUND_FRI_CONFORMANCE_TRANSCRIPT_DOMAIN, + TRANSCRIPT_BOUND_FRI_QUERIES_CONFORMANCE_TRANSCRIPT_DOMAIN, + TRANSCRIPT_BOUND_PCS_CONFORMANCE_TRANSCRIPT_DOMAIN, + TRANSCRIPT_BOUND_PCS_FRI_QUERIES_CONFORMANCE_TRANSCRIPT_DOMAIN, +}; +pub use conformance::{ + EngineConformanceArtifact, prove_engine_conformance, + verify_engine_conformance, +}; +pub use flock_prover::r1cs_hashes::blake3::Compression; +pub use fri::{ + FRI_COMMIT_PHASE_CONFORMANCE_ARTIFACT_MAGIC, + FRI_FOLD_CONFORMANCE_ARTIFACT_MAGIC, FriCommitPhaseConformanceArtifactV1, + FriCommitPhaseQueryV1, FriCommitPhaseRoundV1, FriFoldConformanceArtifactV1, + FriFoldQueryV1, PCS_REDUCTION_CONFORMANCE_ARTIFACT_MAGIC, + PcsReducedOpeningV1, PcsReductionConformanceArtifactV1, + Stage2AirPcsFriArtifactV1, Stage2AirPcsFriWitnessV1, Stage2PcsBatchOpeningV1, + Stage2PcsBatchV1, Stage2PcsFriWitnessV1, Stage2PcsInstanceV1, + Stage2PcsMatrixV1, Stage2PcsOpeningPointV1, Stage2PcsQueryV1, + TranscriptBoundFriCommitPhaseArtifactV1, TranscriptBoundFriQueriesArtifactV1, + TranscriptBoundPcsFriQueriesArtifactV1, TranscriptBoundPcsFriQueryV1, + TranscriptBoundPcsReductionArtifactV1, prove_fri_commit_phase_conformance, + prove_fri_fold_conformance, prove_pcs_reduction_conformance, + prove_stage2_air_pcs_fri_conformance, + prove_transcript_bound_fri_commit_phase_conformance, + prove_transcript_bound_fri_queries_conformance, + prove_transcript_bound_pcs_fri_queries_conformance, + prove_transcript_bound_pcs_reduction_conformance, + verify_fri_commit_phase_conformance, verify_fri_fold_conformance, + verify_pcs_reduction_conformance, verify_stage2_air_pcs_fri_conformance, + verify_stage2_air_pcs_fri_conformance_for, + verify_transcript_bound_fri_commit_phase_conformance, + verify_transcript_bound_fri_queries_conformance, + verify_transcript_bound_pcs_fri_queries_conformance, + verify_transcript_bound_pcs_reduction_conformance, +}; +use fri::{ + prove_stage2_air_pcs_fri_production, verify_stage2_air_pcs_fri_production, +}; +pub use merkle::{ + MERKLE_CONFORMANCE_ARTIFACT_MAGIC, MerkleConformanceArtifactV1, MerklePathV1, + prove_merkle_conformance, verify_merkle_conformance, +}; +pub use relation::{ + STAGE3_RELATION_MANIFEST_DOMAIN, STAGE3_VERIFIER_PHASES_V1, + Stage3LoweringStatusV1, Stage3RelationBoundsV1, Stage3RelationManifestV1, + Stage3VerifierPhaseV1, +}; +pub use transcript::{ + STAGE2_TRANSCRIPT_CONFORMANCE_ARTIFACT_MAGIC, + Stage2FriTranscriptChallengesV1, Stage2FriTranscriptReplayV1, + Stage2TranscriptByteBindingV1, Stage2TranscriptChallengesV1, + Stage2TranscriptConformanceArtifactV1, Stage2TranscriptReplayV1, + Stage2TranscriptSegmentV1, prove_stage2_transcript_conformance, + verify_stage2_transcript_conformance, +}; +pub use typed_witness::{ + STAGE3_TYPED_WITNESS_LAYOUT_DOMAIN, Stage3DigestV1, Stage3ExtensionValueV1, + Stage3OpenedRoundV1, Stage3TypedBatchOpeningV1, Stage3TypedCommitPhaseStepV1, + Stage3TypedCommitmentsV1, Stage3TypedFriProofV1, Stage3TypedProofCountsV1, + Stage3TypedProofWitnessV1, Stage3TypedQueryProofV1, +}; + +/// Host facade for the production Stage 3 relation. +#[derive(Clone, Copy, Debug, Default)] +pub struct FlockStage3Backend; + +impl FlockStage3Backend { + /// Verify the compact Stage 2 root and produce the exact vk/claims/advice + /// transport that the Flock relation must consume. This is usable while the + /// relation itself is still being lowered. + pub fn prepare_witness( + self, + vk_bytes: &[u8], + claim_bytes: &[u8], + proof_bytes: &[u8], + fri: &FriParameters, + ) -> Result { + validate_and_expand_root_inputs(vk_bytes, claim_bytes, proof_bytes, fri) + } + + /// Compile and content-address the complete relation for a prepared root. + /// This builds the circuit but does not run the expensive Flock prover. + pub fn relation_manifest( + self, + prepared: &ValidatedStage2RootV1, + ) -> Result { + Stage3RelationManifestV1::for_prepared(prepared) + } + + /// Decode the verified advice transport into the primitive, fixed-schema + /// witness consumed by the no-RISC-V Flock lowering. + pub fn prepare_typed_proof_witness( + self, + prepared: &ValidatedStage2RootV1, + fri: &FriParameters, + ) -> Result { + Stage3TypedProofWitnessV1::from_prepared(prepared, fri) + } + + /// Construct a public Stage 3 statement only from a complete manifest that + /// is specialised to, and has capacity for, this prepared root. + pub fn prepare_statement( + self, + prepared: &ValidatedStage2RootV1, + manifest: &Stage3RelationManifestV1, + ) -> Result { + manifest.ensure_accommodates(prepared)?; + Ok(Stage3StatementV1::new( + prepared.statement(), + manifest.relation_digest()?, + )) + } + + /// Validate and lower a compact Stage 2 proof, prove the complete + /// statement/AIR/PCS/FRI relation using the production transcript domain, + /// and return its strictly framed Stage 3 artifact. + pub fn prove_stage2( + self, + vk_bytes: &[u8], + claim_bytes: &[u8], + proof_bytes: &[u8], + fri: &FriParameters, + ) -> Result { + Stage3LoweringStatusV1::current().ensure_complete()?; + let prepared = + self.prepare_witness(vk_bytes, claim_bytes, proof_bytes, fri)?; + let witness = Stage2AirPcsFriWitnessV1::from_prepared(&prepared, fri)?; + let flock_artifact = prove_stage2_air_pcs_fri_production(&witness)?; + let manifest = Stage3RelationManifestV1::for_prepared_and_program_digest( + &prepared, + *flock_artifact.circuit_digest(), + )?; + let statement = self.prepare_statement(&prepared, &manifest)?; + let payload = Stage3ProductionPayloadV1::new( + vk_bytes, + claim_bytes, + proof_bytes, + *flock_artifact.circuit_digest(), + flock_artifact.proof_bundle_bytes(), + )? + .encode()?; + Stage3ArtifactV1::new(statement, payload) + } + + /// Verify the expected public statement, reconstruct the fixed relation + /// from canonical Stage 2 inputs, pin its manifest digest, and verify the + /// Flock proof under the production transcript domain. + pub fn verify_stage2( + self, + artifact: &Stage3ArtifactV1, + expected: &Stage3StatementV1, + ) -> Result<()> { + artifact.ensure_statement(expected)?; + let payload = Stage3ProductionPayloadV1::decode(artifact.proof_bytes())?; + let key = AiurVerifyingKey::from_bytes(payload.vk_bytes()) + .map_err(|error| anyhow::anyhow!("decode Stage 3 Aiur key: {error}"))?; + let fri = key.fri_parameters(); + let prepared = self.prepare_witness( + payload.vk_bytes(), + payload.claim_bytes(), + payload.stage2_proof_bytes(), + &fri, + )?; + if prepared.statement().digest() != *expected.stage2_root_digest() { + bail!("Stage 3 proof targets a different Stage 2 root"); + } + let witness = Stage2AirPcsFriWitnessV1::from_prepared(&prepared, &fri)?; + let manifest = Stage3RelationManifestV1::for_prepared_and_program_digest( + &prepared, + payload.circuit_digest(), + )?; + if manifest.relation_digest()? != *expected.relation_digest() { + bail!("Stage 3 relation manifest does not match the expected relation"); + } + let flock_artifact = Stage2AirPcsFriArtifactV1::from_parts( + witness, + payload.circuit_digest(), + payload.flock_proof_bundle_bytes().to_vec(), + )?; + verify_stage2_air_pcs_fri_production(&flock_artifact) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn malformed_stage2_input_fails_before_proving() { + let fri = FriParameters { + log_final_poly_len: 0, + max_log_arity: 1, + num_queries: 100, + commit_proof_of_work_bits: 0, + query_proof_of_work_bits: 20, + }; + assert!( + FlockStage3Backend + .prove_stage2(b"vk", &[0; 144], b"proof", &fri) + .is_err() + ); + assert!(Stage3LoweringStatusV1::current().is_complete()); + } +} diff --git a/flock-stage3/host/src/merkle.rs b/flock-stage3/host/src/merkle.rs new file mode 100644 index 00000000..92c2d82a --- /dev/null +++ b/flock-stage3/host/src/merkle.rs @@ -0,0 +1,607 @@ +//! Circuit-bound BLAKE3 Merkle authentication paths. +//! +//! Plonky3's `CompressionFunctionFromHasher` hashes the +//! concatenation of two 32-byte digests. One constrained direction bit orders +//! the current and sibling digests at every level, then the existing Flock +//! BLAKE3 compression table computes the parent. + +use ::blake3 as native_blake3; +use anyhow::{Context, Result, bail}; +use bincode::Options; +use flock_prover::{ + challenger::FsChallenger, + circuit::builder::{ + CircuitShape, GateType, ShapeBuilder, SlotId, SlotWitness, + }, + field::F128, + pcs::Commitment, + proof::R1csProofCircuitMerged, + prover::{self, UnionSlotProverInput}, + r1cs::BlockR1cs, + r1cs_hashes::blake3 as flock_blake3, + schedule::{IoWord, TableType}, + union::UnionInstance, + verifier, +}; +use serde::{Deserialize, Serialize}; + +use crate::{ + FlockConfigV1, MERKLE_CONFORMANCE_TRANSCRIPT_DOMAIN, + binding::{ + Blake3Gate, CHUNK_END, CHUNK_START, IV, ROOT, pack_bytes, pack_params, + pack8, pcs_params, + }, + boolean::{ + BooleanR1csBuilder, BooleanR1csPlan, generate_boolean_witness, write_f128, + }, +}; + +pub const MERKLE_CONFORMANCE_ARTIFACT_MAGIC: &[u8; 8] = b"IXFLKMP1"; +const ARTIFACT_VERSION: u16 = 1; +const CONFIG_OFFSET: usize = 10; +const DEPTH_OFFSET: usize = CONFIG_OFFSET + 32; +const INDEX_OFFSET: usize = DEPTH_OFFSET + 1; +const LEAF_OFFSET: usize = INDEX_OFFSET + 4; +const PATH_OFFSET: usize = LEAF_OFFSET + 32; +const FIXED_SUFFIX_BYTES: usize = 32 + 32 + 8; +const MAX_DEPTH: usize = 32; +const MAX_BUNDLE_BYTES: usize = 64 * 1024 * 1024; + +const NU: usize = 8; +const ORDER_K_LOG: usize = 11; +const BIT_BASE: usize = 0; +const CURRENT_BASE: usize = 128; +const SIBLING_BASE: usize = 384; +const LEFT_BASE: usize = 640; +const RIGHT_BASE: usize = 896; +const ORDER_RESERVED_COLUMNS: usize = 1152; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct MerklePathV1 { + pub leaf: [u8; 32], + pub siblings: Vec<[u8; 32]>, + /// Leaf index; level zero consumes its least-significant bit. + pub index: u32, +} + +impl MerklePathV1 { + pub fn root(&self) -> Result<[u8; 32]> { + validate_path(self)?; + Ok(native_root(self)) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct MerkleConformanceArtifactV1 { + path: MerklePathV1, + circuit_digest: [u8; 32], + root: [u8; 32], + proof_bundle_bytes: Vec, +} + +impl MerkleConformanceArtifactV1 { + pub fn to_bytes(&self) -> Vec { + let mut bytes = Vec::with_capacity( + PATH_OFFSET + + 32 * self.path.siblings.len() + + FIXED_SUFFIX_BYTES + + self.proof_bundle_bytes.len(), + ); + bytes.extend_from_slice(MERKLE_CONFORMANCE_ARTIFACT_MAGIC); + bytes.extend_from_slice(&ARTIFACT_VERSION.to_le_bytes()); + bytes.extend_from_slice(&FlockConfigV1.digest()); + bytes.push(u8::try_from(self.path.siblings.len()).expect("Merkle depth")); + bytes.extend_from_slice(&self.path.index.to_le_bytes()); + bytes.extend_from_slice(&self.path.leaf); + for sibling in &self.path.siblings { + bytes.extend_from_slice(sibling); + } + bytes.extend_from_slice(&self.circuit_digest); + bytes.extend_from_slice(&self.root); + bytes.extend_from_slice( + &u64::try_from(self.proof_bundle_bytes.len()) + .expect("proof bundle length") + .to_le_bytes(), + ); + bytes.extend_from_slice(&self.proof_bundle_bytes); + bytes + } + + pub fn from_bytes(bytes: &[u8]) -> Result { + if bytes.len() < PATH_OFFSET + FIXED_SUFFIX_BYTES { + bail!("truncated Flock Merkle conformance artifact"); + } + if &bytes[..8] != MERKLE_CONFORMANCE_ARTIFACT_MAGIC { + bail!("invalid Flock Merkle conformance artifact magic"); + } + let version = u16::from_le_bytes(bytes[8..10].try_into().unwrap()); + if version != ARTIFACT_VERSION { + bail!("unsupported Flock Merkle artifact version {version}"); + } + if bytes[CONFIG_OFFSET..DEPTH_OFFSET] != FlockConfigV1.digest() { + bail!("Flock Merkle artifact configuration mismatch"); + } + let depth = usize::from(bytes[DEPTH_OFFSET]); + validate_depth(depth)?; + let index = + u32::from_le_bytes(bytes[INDEX_OFFSET..LEAF_OFFSET].try_into().unwrap()); + let path_end = PATH_OFFSET + .checked_add(depth * 32) + .ok_or_else(|| anyhow::anyhow!("Merkle path length overflow"))?; + let suffix_end = path_end + .checked_add(FIXED_SUFFIX_BYTES) + .ok_or_else(|| anyhow::anyhow!("Merkle artifact length overflow"))?; + if bytes.len() < suffix_end { + bail!("truncated Flock Merkle path or proof header"); + } + let mut leaf = [0u8; 32]; + leaf.copy_from_slice(&bytes[LEAF_OFFSET..PATH_OFFSET]); + let siblings = bytes[PATH_OFFSET..path_end].as_chunks::<32>().0.to_vec(); + let path = MerklePathV1 { leaf, siblings, index }; + validate_path(&path)?; + let mut circuit_digest = [0u8; 32]; + circuit_digest.copy_from_slice(&bytes[path_end..path_end + 32]); + let mut root = [0u8; 32]; + root.copy_from_slice(&bytes[path_end + 32..path_end + 64]); + let bundle_len = usize::try_from(u64::from_le_bytes( + bytes[path_end + 64..suffix_end].try_into().unwrap(), + )) + .map_err(|error| { + anyhow::anyhow!("Merkle proof bundle length does not fit usize: {error}") + })?; + if bundle_len == 0 || bundle_len > MAX_BUNDLE_BYTES { + bail!("invalid Flock Merkle proof bundle length {bundle_len}"); + } + let expected_len = suffix_end + .checked_add(bundle_len) + .ok_or_else(|| anyhow::anyhow!("Merkle proof length overflow"))?; + if bytes.len() != expected_len { + bail!( + "Flock Merkle artifact is {} bytes; header declares {expected_len}", + bytes.len() + ); + } + let proof_bundle_bytes = bytes[suffix_end..].to_vec(); + decode_bundle(&proof_bundle_bytes) + .context("decode Flock Merkle conformance proof bundle")?; + Ok(Self { path, circuit_digest, root, proof_bundle_bytes }) + } + + pub fn path(&self) -> &MerklePathV1 { + &self.path + } + + pub fn circuit_digest(&self) -> &[u8; 32] { + &self.circuit_digest + } + + pub fn root(&self) -> &[u8; 32] { + &self.root + } + + pub fn proof_bundle_bytes(&self) -> &[u8] { + &self.proof_bundle_bytes + } +} + +#[derive(Serialize, Deserialize)] +struct MerkleProofBundle { + commitment: Commitment, + proof: R1csProofCircuitMerged, +} + +pub fn prove_merkle_conformance( + path: &MerklePathV1, +) -> Result { + validate_path(path)?; + let relation = MerkleRelation::build(path.siblings.len())?; + relation.ensure_registry_order()?; + let witness = relation.shape.run(&relation_inputs(path), &[]); + let root = native_root(path); + if witness.public != relation_public(path, &root) { + bail!("Flock Merkle circuit output disagrees with native BLAKE3 root"); + } + let blake3_rows = witness.rows::(relation.blake3_slot); + let order_rows = witness.rows::(relation.order_slot); + let blake3_r1cs = flock_blake3::build_block_r1cs(NU); + let blake3_lincheck = blake3_r1cs.csc_lincheck_circuit(); + let order_r1cs = build_digest_order_r1cs(NU); + let order_lincheck = order_r1cs.csc_lincheck_circuit(); + let union = + UnionInstance::new(&relation.shape.registry, relation.shape.counts.clone()); + let params = pcs_params(&union); + let mut challenger = + FsChallenger::with_chained_blake3(MERKLE_CONFORMANCE_TRANSCRIPT_DOMAIN); + let (proof, commitment, _) = prover::prove_fast_ligerito_union_circuit( + &union, + &relation.shape.circuit, + &witness.public, + ¶ms, + vec![ + UnionSlotProverInput::new( + flock_blake3::generate_witness_batch_major_partial(blake3_rows, NU), + blake3_lincheck, + ), + UnionSlotProverInput::new( + generate_digest_order_witness(order_rows, NU), + order_lincheck, + ), + ], + Vec::new(), + &mut challenger, + ); + let proof_bundle_bytes = + encode_bundle(&MerkleProofBundle { commitment, proof })?; + if proof_bundle_bytes.len() > MAX_BUNDLE_BYTES { + bail!("Flock Merkle proof bundle exceeds {MAX_BUNDLE_BYTES} bytes"); + } + Ok(MerkleConformanceArtifactV1 { + path: path.clone(), + circuit_digest: relation.shape.circuit.digest(), + root, + proof_bundle_bytes, + }) +} + +pub fn verify_merkle_conformance( + artifact: &MerkleConformanceArtifactV1, +) -> Result<()> { + validate_path(&artifact.path)?; + let relation = MerkleRelation::build(artifact.path.siblings.len())?; + relation.ensure_registry_order()?; + if artifact.circuit_digest != relation.shape.circuit.digest() { + bail!("Flock Merkle conformance circuit digest mismatch"); + } + let bundle = decode_bundle(&artifact.proof_bundle_bytes) + .context("decode Flock Merkle conformance proof bundle")?; + let public = relation_public(&artifact.path, &artifact.root); + let blake3_r1cs = flock_blake3::build_block_r1cs(NU); + let blake3_lincheck = blake3_r1cs.csc_lincheck_circuit(); + let order_r1cs = build_digest_order_r1cs(NU); + let order_lincheck = order_r1cs.csc_lincheck_circuit(); + let linchecks: [&dyn flock_prover::lincheck::LincheckCircuit; 2] = + [blake3_lincheck, order_lincheck]; + let union = + UnionInstance::new(&relation.shape.registry, relation.shape.counts.clone()); + let params = pcs_params(&union); + let mut challenger = + FsChallenger::with_chained_blake3(MERKLE_CONFORMANCE_TRANSCRIPT_DOMAIN); + verifier::verify_ligerito_union_circuit( + &union, + &relation.shape.circuit, + &public, + &linchecks, + &bundle.commitment, + &bundle.proof, + ¶ms, + &mut challenger, + ) + .map_err(|error| { + anyhow::anyhow!("Flock Merkle conformance proof rejected: {error:?}") + })?; + Ok(()) +} + +struct MerkleRelation { + shape: CircuitShape, + blake3_slot: SlotId, + order_slot: SlotId, +} + +impl MerkleRelation { + fn build(depth: usize) -> Result { + validate_depth(depth)?; + let mut builder = ShapeBuilder::new(NU); + let blake3_slot = builder.slot(Blake3Gate { nu: NU }); + let order_slot = builder.slot(DigestOrderGate { nu: NU }); + let packed_iv = pack8(&IV); + let iv = [ + builder.fixed_public_input(packed_iv[0]), + builder.fixed_public_input(packed_iv[1]), + ]; + let params = builder.fixed_public_input(pack_params( + 0, + 64, + CHUNK_START | CHUNK_END | ROOT, + )); + let mut current = [builder.public_input(), builder.public_input()]; + for _ in 0..depth { + let direction = builder.public_input(); + let sibling = [builder.public_input(), builder.public_input()]; + let ordered = builder.gate( + order_slot, + &[direction, current[0], current[1], sibling[0], sibling[1]], + ); + let parent = builder.gate( + blake3_slot, + &[iv[0], iv[1], ordered[0], ordered[1], ordered[2], ordered[3], params], + ); + current = [parent[0], parent[1]]; + } + builder.publish(current[0]); + builder.publish(current[1]); + let shape = builder.finish().map_err(|error| { + anyhow::anyhow!("build Flock Merkle conformance circuit: {error:?}") + })?; + Ok(Self { shape, blake3_slot, order_slot }) + } + + fn ensure_registry_order(&self) -> Result<()> { + if self.shape.registry_slot(self.blake3_slot) != 0 + || self.shape.registry_slot(self.order_slot) != 1 + { + bail!("unexpected Flock Merkle table registry order"); + } + Ok(()) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct DigestOrderRow { + direction: bool, + current: [F128; 2], + sibling: [F128; 2], +} + +#[derive(Clone, Copy, Debug)] +pub(crate) struct DigestOrderGate { + pub(crate) nu: usize, +} + +impl GateType for DigestOrderGate { + type Row = DigestOrderRow; + type Hint = (); + + fn table(&self) -> TableType { + TableType::from_block_r1cs(&build_digest_order_r1cs(self.nu)) + .with_io_schema(vec![ + IoWord::input(0), + IoWord::input(1), + IoWord::input(2), + IoWord::input(3), + IoWord::input(4), + IoWord::output(5), + IoWord::output(6), + IoWord::output(7), + IoWord::output(8), + ]) + } + + fn eval( + &self, + inputs: &[F128], + _hint: &(), + outputs: &mut Vec, + ) -> Self::Row { + let direction_word = inputs[0]; + assert_eq!(direction_word.hi, 0); + assert!(direction_word.lo <= 1); + let direction = direction_word.lo == 1; + let current = [inputs[1], inputs[2]]; + let sibling = [inputs[3], inputs[4]]; + if direction { + outputs + .extend_from_slice(&[sibling[0], sibling[1], current[0], current[1]]); + } else { + outputs + .extend_from_slice(&[current[0], current[1], sibling[0], sibling[1]]); + } + DigestOrderRow { direction, current, sibling } + } + + fn witness(&self, _rows: &[Self::Row], _nu: usize) -> SlotWitness { + SlotWitness::DeferredToRows + } +} + +pub(crate) fn build_digest_order_r1cs(nu: usize) -> BlockR1cs { + build_digest_order_plan().block_r1cs(nu) +} + +pub(crate) fn generate_digest_order_witness( + rows: &[DigestOrderRow], + nu: usize, +) -> (Vec, Vec, Vec, Vec) { + let plan = build_digest_order_plan(); + generate_boolean_witness(&plan, rows, nu, |row, bits| { + bits[BIT_BASE] = row.direction; + write_f128(bits, CURRENT_BASE, row.current[0]); + write_f128(bits, CURRENT_BASE + 128, row.current[1]); + write_f128(bits, SIBLING_BASE, row.sibling[0]); + write_f128(bits, SIBLING_BASE + 128, row.sibling[1]); + }) +} + +fn build_digest_order_plan() -> BooleanR1csPlan { + let mut builder = + BooleanR1csBuilder::new(ORDER_K_LOG, ORDER_RESERVED_COLUMNS); + builder.free_boolean_at(BIT_BASE); + for column in CURRENT_BASE..SIBLING_BASE + 256 { + builder.free_boolean_at(column); + } + let one = builder.alloc_constant_one(); + for bit in 0..256 { + let current = CURRENT_BASE + bit; + let sibling = SIBLING_BASE + bit; + let selected = + builder.product_of_parities(&[BIT_BASE], &[current, sibling]); + builder.write_xor(LEFT_BASE + bit, &[current, selected], one); + builder.write_xor( + RIGHT_BASE + bit, + &[current, sibling, LEFT_BASE + bit], + one, + ); + } + builder.finish() +} + +fn relation_inputs(path: &MerklePathV1) -> Vec { + let packed_iv = pack8(&IV); + let mut inputs = Vec::with_capacity(5 + 3 * path.siblings.len()); + inputs.extend_from_slice(&packed_iv); + inputs.push(pack_params(0, 64, CHUNK_START | CHUNK_END | ROOT)); + inputs.extend_from_slice(&pack_digest(&path.leaf)); + for (level, sibling) in path.siblings.iter().enumerate() { + inputs.push(F128::new(u64::from((path.index >> level) & 1), 0)); + inputs.extend_from_slice(&pack_digest(sibling)); + } + inputs +} + +fn relation_public(path: &MerklePathV1, root: &[u8; 32]) -> Vec { + let mut public = relation_inputs(path); + public.extend_from_slice(&pack_digest(root)); + public +} + +fn pack_digest(digest: &[u8; 32]) -> [F128; 2] { + [pack_bytes(&digest[..16]), pack_bytes(&digest[16..])] +} + +fn native_root(path: &MerklePathV1) -> [u8; 32] { + let mut current = path.leaf; + for (level, sibling) in path.siblings.iter().enumerate() { + let mut input = [0u8; 64]; + let (left, right) = if (path.index >> level) & 1 == 0 { + (¤t, sibling) + } else { + (sibling, ¤t) + }; + input[..32].copy_from_slice(left); + input[32..].copy_from_slice(right); + current = *native_blake3::hash(&input).as_bytes(); + } + current +} + +fn validate_path(path: &MerklePathV1) -> Result<()> { + validate_depth(path.siblings.len())?; + if u64::from(path.index) >= 1u64 << path.siblings.len() { + bail!( + "Merkle index {} does not fit depth {}", + path.index, + path.siblings.len() + ); + } + Ok(()) +} + +fn validate_depth(depth: usize) -> Result<()> { + if !(1..=MAX_DEPTH).contains(&depth) { + bail!("Merkle depth {depth}; expected 1..={MAX_DEPTH}"); + } + Ok(()) +} + +fn encode_bundle(bundle: &MerkleProofBundle) -> Result> { + bincode::DefaultOptions::new() + .with_fixint_encoding() + .serialize(bundle) + .context("encode Flock Merkle conformance proof bundle") +} + +fn decode_bundle(bytes: &[u8]) -> Result { + bincode::DefaultOptions::new() + .with_fixint_encoding() + .with_limit(MAX_BUNDLE_BYTES as u64) + .reject_trailing_bytes() + .deserialize(bytes) + .context("invalid Flock Merkle conformance proof bundle") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fixture() -> MerklePathV1 { + MerklePathV1 { + leaf: *native_blake3::hash(b"ix-stage3-merkle-leaf").as_bytes(), + siblings: (0..4u8) + .map(|level| *native_blake3::hash(&[0xa5, level]).as_bytes()) + .collect(), + index: 0b1010, + } + } + + #[test] + fn native_path_matches_manual_blake3_compression() { + let path = fixture(); + let root = path.root().unwrap(); + assert_ne!(root, path.leaf); + let mut changed = path; + changed.index ^= 1; + assert_ne!(changed.root().unwrap(), root); + } + + #[test] + fn digest_order_r1cs_rejects_direction_and_output_mutations() { + let plan = build_digest_order_plan(); + let r1cs = plan.block_r1cs(3); + let row = DigestOrderRow { + direction: true, + current: [F128::new(1, 2), F128::new(3, 4)], + sibling: [F128::new(5, 6), F128::new(7, 8)], + }; + let mut logical = vec![false; plan.k()]; + plan.fill_row(&mut logical, |bits| { + bits[BIT_BASE] = row.direction; + write_f128(bits, CURRENT_BASE, row.current[0]); + write_f128(bits, CURRENT_BASE + 128, row.current[1]); + write_f128(bits, SIBLING_BASE, row.sibling[0]); + write_f128(bits, SIBLING_BASE + 128, row.sibling[1]); + }); + let mut witness = vec![false; r1cs.n()]; + witness[..plan.k()].copy_from_slice(&logical); + assert!(r1cs.satisfies(&witness)); + + let mut wrong_direction = witness.clone(); + wrong_direction[BIT_BASE] ^= true; + assert!(!r1cs.satisfies(&wrong_direction)); + let mut wrong_output = witness; + wrong_output[LEFT_BASE + 17] ^= true; + assert!(!r1cs.satisfies(&wrong_output)); + } + + #[test] + fn artifact_parser_is_strict_before_crypto() { + let path = fixture(); + let artifact = MerkleConformanceArtifactV1 { + root: path.root().unwrap(), + path, + circuit_digest: [7; 32], + proof_bundle_bytes: vec![1, 2, 3], + }; + let mut bytes = artifact.to_bytes(); + assert!(MerkleConformanceArtifactV1::from_bytes(&bytes).is_err()); + bytes[0] ^= 1; + assert!(MerkleConformanceArtifactV1::from_bytes(&bytes).is_err()); + } + + #[test] + #[ignore = "real Flock BLAKE3 Merkle circuit proof; run explicitly"] + fn real_merkle_path_round_trip_and_mutations() { + let artifact = prove_merkle_conformance(&fixture()).expect("prove path"); + eprintln!( + "Flock BLAKE3-Merkle conformance bundle: {} bytes", + artifact.proof_bundle_bytes().len() + ); + verify_merkle_conformance(&artifact).expect("verify path"); + let decoded = + MerkleConformanceArtifactV1::from_bytes(&artifact.to_bytes()).unwrap(); + verify_merkle_conformance(&decoded).expect("verify decoded path"); + + let mut wrong_sibling = decoded.clone(); + wrong_sibling.path.siblings[1][7] ^= 1; + assert!(verify_merkle_conformance(&wrong_sibling).is_err()); + let mut wrong_index = decoded.clone(); + wrong_index.path.index ^= 1; + assert!(verify_merkle_conformance(&wrong_index).is_err()); + let mut wrong_root = decoded.clone(); + wrong_root.root[0] ^= 1; + assert!(verify_merkle_conformance(&wrong_root).is_err()); + let mut wrong_proof = decoded; + let flip_at = wrong_proof.proof_bundle_bytes.len() / 2; + wrong_proof.proof_bundle_bytes[flip_at] ^= 1; + assert!(verify_merkle_conformance(&wrong_proof).is_err()); + } +} diff --git a/flock-stage3/host/src/multiplication.rs b/flock-stage3/host/src/multiplication.rs new file mode 100644 index 00000000..da7e040b --- /dev/null +++ b/flock-stage3/host/src/multiplication.rs @@ -0,0 +1,421 @@ +//! Boolean R1CS relation for multiplication in the Goldilocks base field. +//! +//! For canonical `a`, `b`, and `c`, the gate proves that a private 64-bit +//! quotient `q` satisfies the exact non-negative integer identity +//! +//! ```text +//! a * b + (q << 32) = c + q + (q << 64). +//! ``` +//! +//! This is `a*b = c + q*(2^64 - 2^32 + 1)`, so canonical `c` is exactly +//! `a*b mod p`. The multiplication bits are reduced with a carry-save tree +//! before one ripple pass; this is substantially smaller than adding 64 +//! shifted partial-product rows sequentially. + +use flock_prover::{ + circuit::builder::{GateType, SlotWitness}, + field::F128, + r1cs::BlockR1cs, + schedule::{IoWord, TableType}, +}; + +use crate::{ + boolean::{ + BooleanR1csBuilder, BooleanR1csPlan, generate_boolean_witness, write_f128, + }, + goldilocks::GOLDILOCKS_MODULUS, +}; + +const MUL_K_LOG: usize = 16; +const LEFT_BASE: usize = 0; +const RIGHT_BASE: usize = 128; +const RESULT_BASE: usize = 256; +const LOW_RESIDUAL_BASE: usize = 384; +const HIGH_RESIDUAL_BASE: usize = 512; +const TOP_RESIDUAL_BASE: usize = 640; +const RESERVED_COLUMNS: usize = 768; + +// Both sides of the quotient identity are below 2^129 for all 64-bit +// inputs. Comparing 130 sum bits is therefore an exact integer comparison, +// not merely equality modulo a power of two. +const INTEGER_SUM_BITS: usize = 130; + +/// One row of two independent Goldilocks multiplications packed by u64 lane. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct GoldilocksMulPairRow { + left: F128, + right: F128, +} + +/// Two lane-wise Goldilocks multiplications with explicit equation outputs. +/// +/// The result is the first output. Callers must connect all residual words +/// to zero and route the two inputs and result through the shared canonical +/// Goldilocks table. +#[derive(Clone, Copy, Debug)] +pub(crate) struct GoldilocksMulPairGate { + pub(crate) nu: usize, +} + +impl GateType for GoldilocksMulPairGate { + type Row = GoldilocksMulPairRow; + type Hint = (); + + fn table(&self) -> TableType { + TableType::from_block_r1cs(&build_goldilocks_mul_r1cs(self.nu)) + .with_io_schema(vec![ + IoWord::input(0), + IoWord::input(1), + IoWord::output(2), + IoWord::output(3), + IoWord::output(4), + IoWord::output(5), + ]) + } + + fn eval( + &self, + inputs: &[F128], + _hint: &(), + outputs: &mut Vec, + ) -> Self::Row { + let left = inputs[0]; + let right = inputs[1]; + outputs.extend_from_slice(&[ + F128::new( + goldilocks_mul(left.lo, right.lo), + goldilocks_mul(left.hi, right.hi), + ), + F128::ZERO, + F128::ZERO, + F128::ZERO, + ]); + GoldilocksMulPairRow { left, right } + } + + fn witness(&self, _rows: &[Self::Row], _nu: usize) -> SlotWitness { + SlotWitness::DeferredToRows + } +} + +struct GoldilocksMulPlan { + boolean: BooleanR1csPlan, + quotient_bits: [[usize; 64]; 2], +} + +pub(crate) fn build_goldilocks_mul_r1cs(nu: usize) -> BlockR1cs { + build_goldilocks_mul_plan().boolean.block_r1cs(nu) +} + +pub(crate) fn generate_goldilocks_mul_witness( + rows: &[GoldilocksMulPairRow], + nu: usize, +) -> (Vec, Vec, Vec, Vec) { + let plan = build_goldilocks_mul_plan(); + generate_boolean_witness(&plan.boolean, rows, nu, |row, bits| { + fill_goldilocks_mul_row(&plan, *row, bits) + }) +} + +fn build_goldilocks_mul_plan() -> GoldilocksMulPlan { + let mut builder = BooleanR1csBuilder::new(MUL_K_LOG, RESERVED_COLUMNS); + for column in LEFT_BASE..RESULT_BASE + 128 { + builder.free_boolean_at(column); + } + let one = builder.alloc_constant_one(); + let quotient_bits = std::array::from_fn(|_| { + std::array::from_fn(|_| builder.alloc_free_boolean()) + }); + + for (lane, quotient) in quotient_bits.iter().enumerate() { + let lane_offset = lane * 64; + let left: [usize; 64] = + std::array::from_fn(|bit| LEFT_BASE + lane_offset + bit); + let right: [usize; 64] = + std::array::from_fn(|bit| RIGHT_BASE + lane_offset + bit); + let result: [usize; 64] = + std::array::from_fn(|bit| RESULT_BASE + lane_offset + bit); + + let mut left_columns = vec![Vec::new(); INTEGER_SUM_BITS + 1]; + for (left_bit, &left_column) in left.iter().enumerate() { + for (right_bit, &right_column) in right.iter().enumerate() { + let product = builder.and(left_column, right_column); + left_columns[left_bit + right_bit].push(product); + } + } + for (bit, "ient_bit) in quotient.iter().enumerate() { + left_columns[bit + 32].push(quotient_bit); + } + + let mut right_columns = vec![Vec::new(); INTEGER_SUM_BITS + 1]; + for bit in 0..64 { + right_columns[bit].push(result[bit]); + right_columns[bit].push(quotient[bit]); + right_columns[bit + 64].push(quotient[bit]); + } + + let left_sum = + sum_bit_columns(&mut builder, left_columns, one, INTEGER_SUM_BITS); + let right_sum = + sum_bit_columns(&mut builder, right_columns, one, INTEGER_SUM_BITS); + for bit in 0..INTEGER_SUM_BITS { + let residual = if bit < 128 { + if bit < 64 { + LOW_RESIDUAL_BASE + lane_offset + bit + } else { + HIGH_RESIDUAL_BASE + lane_offset + bit - 64 + } + } else { + TOP_RESIDUAL_BASE + lane_offset + bit - 128 + }; + let terms: Vec<_> = + [left_sum[bit], right_sum[bit]].into_iter().flatten().collect(); + if !terms.is_empty() { + builder.write_xor(residual, &terms, one); + } + } + } + + GoldilocksMulPlan { boolean: builder.finish(), quotient_bits } +} + +/// Convert a set of same-weight Boolean terms into canonical binary bits. +fn sum_bit_columns( + builder: &mut BooleanR1csBuilder, + mut columns: Vec>, + one: usize, + output_bits: usize, +) -> Vec> { + assert!(columns.len() > output_bits); + + // Carry-save reduction leaves at most two bits in each weight column. + for bit in 0..output_bits { + while columns[bit].len() > 2 { + let third = columns[bit].pop().unwrap(); + let second = columns[bit].pop().unwrap(); + let first = columns[bit].pop().unwrap(); + let (sum, carry) = full_adder(builder, first, second, third, one); + columns[bit].push(sum); + columns[bit + 1].push(carry); + } + } + + // Add the final two carry-save rows with one ripple pass. + let mut result = Vec::with_capacity(output_bits); + let mut carry = None; + for column in columns.iter().take(output_bits) { + let mut terms = column.clone(); + if let Some(carry_bit) = carry.take() { + terms.push(carry_bit); + } + match terms.as_slice() { + [] => result.push(None), + &[only] => result.push(Some(only)), + &[first, second] => { + let (sum, next_carry) = half_adder(builder, first, second, one); + result.push(Some(sum)); + carry = Some(next_carry); + }, + &[first, second, third] => { + let (sum, next_carry) = full_adder(builder, first, second, third, one); + result.push(Some(sum)); + carry = Some(next_carry); + }, + _ => unreachable!("carry-save column contains more than two bits"), + } + } + // The represented integers are strictly below 2^129. Any structurally + // allocated carry at weight 2^130 is therefore the constant-zero Boolean + // function; all of its source operations remain constrained in the table. + result +} + +fn half_adder( + builder: &mut BooleanR1csBuilder, + first: usize, + second: usize, + one: usize, +) -> (usize, usize) { + (builder.xor(&[first, second], one), builder.and(first, second)) +} + +fn full_adder( + builder: &mut BooleanR1csBuilder, + first: usize, + second: usize, + third: usize, + one: usize, +) -> (usize, usize) { + let sum = builder.xor(&[first, second, third], one); + let first_and_second = builder.and(first, second); + let third_and_difference = + builder.product_of_parities(&[third], &[first, second]); + let carry = builder.xor(&[first_and_second, third_and_difference], one); + (sum, carry) +} + +fn fill_goldilocks_mul_row( + plan: &GoldilocksMulPlan, + row: GoldilocksMulPairRow, + bits: &mut [bool], +) { + let result = F128::new( + goldilocks_mul(row.left.lo, row.right.lo), + goldilocks_mul(row.left.hi, row.right.hi), + ); + write_f128(bits, LEFT_BASE, row.left); + write_f128(bits, RIGHT_BASE, row.right); + write_f128(bits, RESULT_BASE, result); + for (lane, quotient_columns) in plan.quotient_bits.iter().enumerate() { + let (left, right) = if lane == 0 { + (row.left.lo, row.right.lo) + } else { + (row.left.hi, row.right.hi) + }; + let quotient = (left as u128 * right as u128) / GOLDILOCKS_MODULUS as u128; + for (bit, &column) in quotient_columns.iter().enumerate() { + bits[column] = (quotient >> bit) & 1 == 1; + } + } +} + +pub(crate) fn goldilocks_mul(left: u64, right: u64) -> u64 { + ((left as u128 * right as u128) % GOLDILOCKS_MODULUS as u128) as u64 +} + +#[cfg(test)] +mod tests { + use std::panic::{AssertUnwindSafe, catch_unwind}; + + use flock_prover::circuit::builder::ShapeBuilder; + use multi_stark::{ + p3_field::{PrimeCharacteristicRing, PrimeField64}, + p3_goldilocks::Goldilocks, + }; + + use super::*; + + #[test] + fn modular_mul_matches_reference_goldilocks() { + let boundary = [ + 0, + 1, + 2, + (1u64 << 32) - 1, + 1u64 << 32, + GOLDILOCKS_MODULUS - 2, + GOLDILOCKS_MODULUS - 1, + ]; + for &left in &boundary { + for &right in &boundary { + let expected = (Goldilocks::from_u64(left) + * Goldilocks::from_u64(right)) + .as_canonical_u64(); + assert_eq!(goldilocks_mul(left, right), expected); + } + } + + let mut state = 0xbb67_ae85_84ca_a73bu64; + for _ in 0..256 { + state = state + .wrapping_mul(0x9e37_79b9_7f4a_7c15) + .wrapping_add(0x94d0_49bb_1331_11eb); + let left = state % GOLDILOCKS_MODULUS; + state ^= state.rotate_left(23); + let right = state % GOLDILOCKS_MODULUS; + let expected = (Goldilocks::from_u64(left) * Goldilocks::from_u64(right)) + .as_canonical_u64(); + assert_eq!(goldilocks_mul(left, right), expected); + } + } + + #[test] + fn modular_mul_r1cs_rejects_wrong_result_and_quotient() { + let plan = build_goldilocks_mul_plan(); + eprintln!( + "Goldilocks multiplication table uses {} Boolean columns", + plan.boolean.useful_bits() + ); + let r1cs = plan.boolean.block_r1cs(3); + let cases = [ + GoldilocksMulPairRow { + left: F128::new(0, GOLDILOCKS_MODULUS - 1), + right: F128::new(GOLDILOCKS_MODULUS - 1, GOLDILOCKS_MODULUS - 1), + }, + GoldilocksMulPairRow { + left: F128::new(1 << 32, 0x1234_5678_9abc_def0), + right: F128::new(GOLDILOCKS_MODULUS - 2, 0xfedc_ba98_7654_3210), + }, + ]; + for row in cases { + let mut logical = vec![false; plan.boolean.k()]; + plan.boolean.fill_row(&mut logical, |bits| { + fill_goldilocks_mul_row(&plan, row, bits) + }); + let mut witness = vec![false; r1cs.n()]; + witness[..plan.boolean.k()].copy_from_slice(&logical); + assert!(r1cs.satisfies(&witness)); + + let mut wrong_result = witness.clone(); + wrong_result[RESULT_BASE + 17] ^= true; + assert!(!r1cs.satisfies(&wrong_result)); + + let mut wrong_quotient = witness; + wrong_quotient[plan.quotient_bits[0][31]] ^= true; + assert!(!r1cs.satisfies(&wrong_quotient)); + } + } + + #[test] + fn multiplication_gate_pins_equation_residuals() { + let nu = 3; + let mut builder = ShapeBuilder::new(nu); + let slot = builder.slot(GoldilocksMulPairGate { nu }); + let left = builder.input(); + let right = builder.input(); + let zero = builder.fixed_public_input(F128::ZERO); + let outputs = builder.gate(slot, &[left, right]); + builder.connect(outputs[1], zero); + builder.connect(outputs[2], zero); + builder.connect(outputs[3], zero); + let shape = builder.finish().unwrap(); + shape.run( + &[ + F128::new(3, GOLDILOCKS_MODULUS - 1), + F128::new(7, GOLDILOCKS_MODULUS - 1), + F128::ZERO, + ], + &[], + ); + + let invalid = catch_unwind(AssertUnwindSafe(|| { + shape.run( + &[F128::new(GOLDILOCKS_MODULUS, 1), F128::new(1, 1), F128::ZERO], + &[], + ) + })); + // The multiplication identity itself accepts any u64 representation; + // canonicality is deliberately a shared, separately wired gate. + assert!(invalid.is_ok()); + } + + #[test] + fn modular_mul_batch_witness_zeroes_dummy_rows() { + let rows = + [GoldilocksMulPairRow { left: F128::new(3, 5), right: F128::new(7, 11) }]; + let plan = build_goldilocks_mul_plan(); + let (z, a, b, stripe) = generate_goldilocks_mul_witness(&rows, 3); + let chunks = plan.boolean.k() / 128; + assert_eq!(z.len(), chunks * 8); + assert_eq!(a.len(), z.len()); + assert_eq!(b.len(), z.len()); + assert_eq!(stripe.len(), plan.boolean.k()); + for chunk in 0..chunks { + for outer in rows.len()..8 { + assert_eq!(z[(chunk << 3) + outer], F128::ZERO); + assert_eq!(a[(chunk << 3) + outer], F128::ZERO); + assert_eq!(b[(chunk << 3) + outer], F128::ZERO); + } + } + } +} diff --git a/flock-stage3/host/src/relation.rs b/flock-stage3/host/src/relation.rs new file mode 100644 index 00000000..52fa4bd3 --- /dev/null +++ b/flock-stage3/host/src/relation.rs @@ -0,0 +1,377 @@ +use anyhow::{Result, bail}; +use ix_terminal::{Stage2AdviceProfileV1, ValidatedStage2RootV1}; +use multi_stark::types::FriParameters; + +use crate::{ + FlockConfigV1, Stage2AirPcsFriWitnessV1, Stage3TypedProofWitnessV1, + fri::stage2_air_pcs_fri_circuit_digest, +}; + +pub const STAGE3_RELATION_MANIFEST_DOMAIN: &[u8; 8] = b"IXFLKR01"; +const STAGE3_RELATION_MANIFEST_VERSION: u16 = 1; + +/// A semantic obligation that the production Flock relation must enforce. +/// +/// These are deliberately coarser than individual helper functions, but fine +/// grained enough that a partial port cannot silently omit an entire verifier +/// phase. A phase bit may only be enabled together with tests that compare the +/// Flock lowering against the existing Aiur verifier. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[repr(u8)] +pub enum Stage3VerifierPhaseV1 { + TypedProofWitnessShape = 0, + SpecializedVerifyingKeyBinding = 1, + ClaimsDecodeAndCanonicality = 2, + Stage2StatementBinding = 3, + ShapeAndActivation = 4, + LookupAccumulatorBalance = 5, + FiatShamirReplay = 6, + AirOodEvaluation = 7, + PcsOpeningReduction = 8, + MerkleMmcs = 9, + FriGrindingFoldAndFinalPolynomial = 10, +} + +pub const STAGE3_VERIFIER_PHASES_V1: [Stage3VerifierPhaseV1; 11] = [ + Stage3VerifierPhaseV1::TypedProofWitnessShape, + Stage3VerifierPhaseV1::SpecializedVerifyingKeyBinding, + Stage3VerifierPhaseV1::ClaimsDecodeAndCanonicality, + Stage3VerifierPhaseV1::Stage2StatementBinding, + Stage3VerifierPhaseV1::ShapeAndActivation, + Stage3VerifierPhaseV1::LookupAccumulatorBalance, + Stage3VerifierPhaseV1::FiatShamirReplay, + Stage3VerifierPhaseV1::AirOodEvaluation, + Stage3VerifierPhaseV1::PcsOpeningReduction, + Stage3VerifierPhaseV1::MerkleMmcs, + Stage3VerifierPhaseV1::FriGrindingFoldAndFinalPolynomial, +]; + +const REQUIRED_PHASE_MASK: u16 = (1 << STAGE3_VERIFIER_PHASES_V1.len()) - 1; + +// Every phase is consumed by the single statement/AIR/PCS/FRI relation. The +// manifest still refuses to identify a deployable relation until the concrete +// compiled circuit digest has been installed. +const IMPLEMENTED_PHASE_MASK: u16 = REQUIRED_PHASE_MASK; + +impl Stage3VerifierPhaseV1 { + const fn bit(self) -> u16 { + 1 << self as u8 + } + + pub const fn name(self) -> &'static str { + match self { + Self::TypedProofWitnessShape => "typed-proof-witness-shape", + Self::SpecializedVerifyingKeyBinding => { + "specialized-verifying-key-binding" + }, + Self::ClaimsDecodeAndCanonicality => "claims-decode-and-canonicality", + Self::Stage2StatementBinding => "stage2-statement-binding", + Self::ShapeAndActivation => "shape-and-activation", + Self::LookupAccumulatorBalance => "lookup-accumulator-balance", + Self::FiatShamirReplay => "fiat-shamir-replay", + Self::AirOodEvaluation => "air-ood-evaluation", + Self::PcsOpeningReduction => "pcs-opening-reduction", + Self::MerkleMmcs => "merkle-mmcs", + Self::FriGrindingFoldAndFinalPolynomial => { + "fri-grinding-fold-and-final-polynomial" + }, + } + } +} + +/// Auditable progress gate for the verifier lowering. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Stage3LoweringStatusV1 { + implemented_phase_mask: u16, +} + +impl Stage3LoweringStatusV1 { + pub const fn current() -> Self { + Self { implemented_phase_mask: IMPLEMENTED_PHASE_MASK } + } + + pub const fn required_phase_mask(self) -> u16 { + REQUIRED_PHASE_MASK + } + + pub const fn implemented_phase_mask(self) -> u16 { + self.implemented_phase_mask + } + + pub const fn is_complete(self) -> bool { + self.implemented_phase_mask == REQUIRED_PHASE_MASK + } + + pub fn missing_phases(self) -> Vec { + STAGE3_VERIFIER_PHASES_V1 + .into_iter() + .filter(|phase| self.implemented_phase_mask & phase.bit() == 0) + .collect() + } + + pub fn ensure_complete(self) -> Result<()> { + if self.is_complete() { + return Ok(()); + } + let missing = self + .missing_phases() + .into_iter() + .map(Stage3VerifierPhaseV1::name) + .collect::>() + .join(", "); + bail!("Flock Stage 3 verifier lowering is incomplete; missing: {missing}") + } +} + +/// Fixed capacity of one compiled Stage 3 verifier relation. +/// +/// `for_prepared` seeds every maximum from one measured root. That is useful +/// while developing the lowering, but it is not a production capacity study: +/// the final values must cover the intended corpus before the relation program +/// digest is frozen. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Stage3RelationBoundsV1 { + pub verifying_key_bytes: u64, + pub claims_bytes: u64, + pub advice: Stage2AdviceProfileV1, +} + +impl Stage3RelationBoundsV1 { + fn for_prepared(prepared: &ValidatedStage2RootV1) -> Result { + Ok(Self { + verifying_key_bytes: as_u64( + prepared.verifying_key_bytes().len(), + "verifying-key bytes", + )?, + claims_bytes: as_u64(prepared.claims_bytes().len(), "claims bytes")?, + advice: prepared.advice_profile().clone(), + }) + } + + fn canonical_words(&self) -> [u64; 14] { + [ + self.verifying_key_bytes, + self.claims_bytes, + self.advice.advice_bytes, + self.advice.total_circuits, + self.advice.active_circuits, + self.advice.queries, + self.advice.fri_rounds, + self.advice.input_rounds_per_query, + self.advice.commitment_cap_digests, + self.advice.input_merkle_siblings, + self.advice.fri_merkle_siblings, + self.advice.opened_base_values, + self.advice.fri_sibling_extension_values, + self.advice.other_extension_values, + ] + } + + fn ensure_accommodates( + &self, + prepared: &ValidatedStage2RootV1, + ) -> Result<()> { + let observed = Self::for_prepared(prepared)?; + if observed.verifying_key_bytes != self.verifying_key_bytes { + bail!("Stage 2 verifying-key byte length differs from relation shape"); + } + if observed.claims_bytes != self.claims_bytes { + bail!("Stage 2 claims byte length differs from relation shape"); + } + if observed.advice.total_circuits != self.advice.total_circuits { + bail!("Stage 2 circuit count differs from relation shape"); + } + if observed.advice.queries != self.advice.queries { + bail!("Stage 2 query count differs from relation shape"); + } + + let maxima = [ + (observed.advice.advice_bytes, self.advice.advice_bytes, "advice bytes"), + ( + observed.advice.active_circuits, + self.advice.active_circuits, + "active circuits", + ), + (observed.advice.fri_rounds, self.advice.fri_rounds, "FRI rounds"), + ( + observed.advice.input_rounds_per_query, + self.advice.input_rounds_per_query, + "input rounds per query", + ), + ( + observed.advice.commitment_cap_digests, + self.advice.commitment_cap_digests, + "commitment cap digests", + ), + ( + observed.advice.input_merkle_siblings, + self.advice.input_merkle_siblings, + "input Merkle siblings", + ), + ( + observed.advice.fri_merkle_siblings, + self.advice.fri_merkle_siblings, + "FRI Merkle siblings", + ), + ( + observed.advice.opened_base_values, + self.advice.opened_base_values, + "opened base values", + ), + ( + observed.advice.fri_sibling_extension_values, + self.advice.fri_sibling_extension_values, + "FRI sibling extension values", + ), + ( + observed.advice.other_extension_values, + self.advice.other_extension_values, + "other extension values", + ), + ]; + if let Some((observed, maximum, label)) = + maxima.into_iter().find(|(observed, maximum, _)| observed > maximum) + { + bail!( + "Stage 2 {label} ({observed}) exceeds relation capacity ({maximum})" + ); + } + Ok(()) + } +} + +/// Canonical identity of a specialised Stage 3 verifier relation. +/// +/// The constructor compiles the relation and installs its circuit digest. +/// `relation_digest` additionally binds the Flock configuration, specialised +/// Stage 2 key, witness layout, phase mask, and exact measured capacity. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Stage3RelationManifestV1 { + stage2_verifying_key_digest: [u8; 32], + typed_witness_layout_digest: [u8; 32], + relation_program_digest: Option<[u8; 32]>, + bounds: Stage3RelationBoundsV1, + lowering_status: Stage3LoweringStatusV1, +} + +impl Stage3RelationManifestV1 { + pub fn for_prepared(prepared: &ValidatedStage2RootV1) -> Result { + let fri = statement_fri_parameters(prepared)?; + let witness = Stage2AirPcsFriWitnessV1::from_prepared(prepared, &fri)?; + let relation_program_digest = stage2_air_pcs_fri_circuit_digest(&witness)?; + Self::for_prepared_and_program_digest(prepared, relation_program_digest) + } + + pub(crate) fn for_prepared_and_program_digest( + prepared: &ValidatedStage2RootV1, + relation_program_digest: [u8; 32], + ) -> Result { + let fri = statement_fri_parameters(prepared)?; + let typed_witness = + Stage3TypedProofWitnessV1::from_prepared(prepared, &fri)?; + Ok(Self { + stage2_verifying_key_digest: *prepared.statement().verifying_key_digest(), + typed_witness_layout_digest: typed_witness.layout_digest(), + relation_program_digest: Some(relation_program_digest), + bounds: Stage3RelationBoundsV1::for_prepared(prepared)?, + lowering_status: Stage3LoweringStatusV1::current(), + }) + } + + pub fn stage2_verifying_key_digest(&self) -> &[u8; 32] { + &self.stage2_verifying_key_digest + } + + pub fn typed_witness_layout_digest(&self) -> &[u8; 32] { + &self.typed_witness_layout_digest + } + + pub fn bounds(&self) -> &Stage3RelationBoundsV1 { + &self.bounds + } + + pub const fn lowering_status(&self) -> Stage3LoweringStatusV1 { + self.lowering_status + } + + pub fn ensure_accommodates( + &self, + prepared: &ValidatedStage2RootV1, + ) -> Result<()> { + if prepared.statement().verifying_key_digest() + != &self.stage2_verifying_key_digest + { + bail!("Stage 2 verifying key differs from the specialised relation"); + } + self.bounds.ensure_accommodates(prepared) + } + + /// Return the digest used in `Stage3StatementV1` for the complete, + /// content-addressed relation program and its exact capacity. + pub fn relation_digest(&self) -> Result<[u8; 32]> { + self.lowering_status.ensure_complete()?; + if self.relation_program_digest.is_none() { + bail!("Flock Stage 3 relation program has not been built and digested"); + } + Ok(*blake3::hash(&self.canonical_bytes()).as_bytes()) + } + + fn canonical_bytes(&self) -> Vec { + let mut bytes = Vec::with_capacity(8 + 2 + 32 * 4 + 2 + 2 + 14 * 8); + bytes.extend_from_slice(STAGE3_RELATION_MANIFEST_DOMAIN); + bytes.extend_from_slice(&STAGE3_RELATION_MANIFEST_VERSION.to_le_bytes()); + bytes.extend_from_slice(&FlockConfigV1.digest()); + bytes.extend_from_slice(&self.stage2_verifying_key_digest); + bytes.extend_from_slice(&self.typed_witness_layout_digest); + bytes.extend_from_slice(&self.relation_program_digest.unwrap_or([0; 32])); + bytes.extend_from_slice( + &self.lowering_status.required_phase_mask().to_le_bytes(), + ); + bytes.extend_from_slice( + &self.lowering_status.implemented_phase_mask().to_le_bytes(), + ); + for word in self.bounds.canonical_words() { + bytes.extend_from_slice(&word.to_le_bytes()); + } + bytes + } +} + +fn statement_fri_parameters( + prepared: &ValidatedStage2RootV1, +) -> Result { + let [log_final_poly_len, max_log_arity, num_queries, commit_pow, query_pow] = + *prepared.statement().fri_parameter_words(); + let convert = |value, label| { + usize::try_from(value).map_err(|error| { + anyhow::anyhow!("Stage 2 {label} does not fit usize: {error}") + }) + }; + Ok(FriParameters { + log_final_poly_len: convert(log_final_poly_len, "final polynomial log")?, + max_log_arity: convert(max_log_arity, "maximum FRI arity log")?, + num_queries: convert(num_queries, "query count")?, + commit_proof_of_work_bits: convert(commit_pow, "commit PoW bits")?, + query_proof_of_work_bits: convert(query_pow, "query PoW bits")?, + }) +} + +fn as_u64(value: usize, label: &str) -> Result { + u64::try_from(value) + .map_err(|error| anyhow::anyhow!("{label} exceeds u64: {error}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn phase_registry_is_complete_and_unique() { + let status = Stage3LoweringStatusV1::current(); + assert_eq!(status.required_phase_mask(), 0x07ff); + assert_eq!(status.implemented_phase_mask(), 0x07ff); + assert!(status.is_complete()); + assert!(status.missing_phases().is_empty()); + status.ensure_complete().unwrap(); + } +} diff --git a/flock-stage3/host/src/transcript.rs b/flock-stage3/host/src/transcript.rs new file mode 100644 index 00000000..407436db --- /dev/null +++ b/flock-stage3/host/src/transcript.rs @@ -0,0 +1,2792 @@ +//! Exact BLAKE3 `HashChallenger` replay for the Stage 2 verifier transcript. +//! +//! Plonky3's byte challenger replaces its input with `BLAKE3(input)` whenever +//! an empty output buffer is sampled, then pops sample bytes from the END of +//! that digest. An observation discards any unused output bytes. This module +//! lowers the protocol-shaped prefix through the PCS opening-batch sample: +//! +//! 1. sample and re-observe the lookup challenge; +//! 2. sample and re-observe the fingerprint challenge; +//! 3. observe Stage 2 data and sample the constraint challenge; +//! 4. observe the quotient commitment and sample zeta; +//! 5. observe all PCS openings, then sample the FRI/PCS batching challenge +//! used to reduce every opening before the commit-phase folds. +//! +//! The replay can then continue through FRI commitments, commit grinding and +//! betas, the final polynomial/arity observations, query grinding, and every +//! masked query draw. Every BLAKE3 compression is constrained, including +//! chunk-tree parents for messages longer than 1,024 bytes. Sampled Goldilocks +//! limbs are checked canonical. Field sampling constrains one chained digest +//! refill and fails closed only if those eight candidates still contain fewer +//! than two canonical Goldilocks values (seven candidates after a raw PoW +//! draw). + +use aiur::vk_codec::AiurVerifyingKey; +use anyhow::{Context, Result, bail}; +use bincode::Options; +use flock_prover::{ + challenger::FsChallenger, + circuit::builder::{ + CircuitShape, GateType, ShapeBuilder, SlotId, SlotWitness, Wire, + }, + field::F128, + lincheck::LincheckCircuit, + pcs::Commitment, + proof::R1csProofCircuitMerged, + prover::{self, UnionSlotProverInput}, + r1cs_hashes::{ + blake3 as flock_blake3, + fs_chain::{CvSource, FsChain, FsChainTrace}, + }, + schedule::{IoWord, TableType}, + union::UnionInstance, + verifier, +}; +use ix_terminal::{ValidatedStage2RootV1, fri_parameter_words}; +use multi_stark::types::FriParameters; +use serde::{Deserialize, Serialize}; + +use crate::{ + FlockConfigV1, STAGE2_TRANSCRIPT_CONFORMANCE_TRANSCRIPT_DOMAIN, + binding::{Blake3Gate, IV, pack_bytes, pack_params, pack8, pcs_params}, + boolean::{ + BooleanR1csBuilder, BooleanR1csPlan, generate_boolean_witness, write_f128, + }, + goldilocks::{ + CanonicalGoldilocksPairGate, GOLDILOCKS_MODULUS, build_canonical_pair_r1cs, + generate_canonical_pair_witness, + }, + typed_witness::{Stage3OpenedRoundV1, Stage3TypedProofWitnessV1}, +}; + +pub const STAGE2_TRANSCRIPT_CONFORMANCE_ARTIFACT_MAGIC: &[u8; 8] = b"IXFLTR01"; + +const ARTIFACT_VERSION: u16 = 1; +const CONFIG_OFFSET: usize = 10; +const LENGTHS_OFFSET: usize = CONFIG_OFFSET + 32; +const SEGMENT_COUNT: usize = 4; +const LENGTH_BYTES: usize = SEGMENT_COUNT * 4; +const SEGMENTS_OFFSET: usize = LENGTHS_OFFSET + LENGTH_BYTES; +const CHALLENGE_COUNT: usize = 5; +const CHALLENGE_BYTES: usize = CHALLENGE_COUNT * 16; +const FIXED_SUFFIX_BYTES: usize = CHALLENGE_BYTES + 32 + 8; +const MAX_OBSERVATION_BYTES: usize = 16 * 1024 * 1024; +const MAX_BUNDLE_BYTES: usize = 64 * 1024 * 1024; +const WORD_BYTES: usize = 16; +const MIN_NU: usize = 8; +const MAX_NU: usize = 20; +const MAX_FRI_ROUNDS: usize = 32; +const MAX_FRI_QUERIES: usize = 1_024; +const MAX_CAP_ROOTS: usize = 256; + +const SAMPLE_K_LOG: usize = 9; +const SAMPLE_INPUT_BASE: usize = 0; +const SAMPLE_OUTPUT_BASE: usize = 128; +const SAMPLE_COLUMNS: usize = 256; + +const FIELD_SAMPLE_K_LOG: usize = 14; +const FIELD_SAMPLE_HIGH_BASE: usize = 0; +const FIELD_SAMPLE_LOW_BASE: usize = 128; +const FIELD_SAMPLE_REFILL_HIGH_BASE: usize = 256; +const FIELD_SAMPLE_REFILL_LOW_BASE: usize = 384; +const FIELD_SAMPLE_OUTPUT_BASE: usize = 512; +const FIELD_SAMPLE_FAILURE_BASE: usize = 640; +const FIELD_SAMPLE_RAW_FIRST_BASE: usize = 768; +const FIELD_SAMPLE_SKIP_OUTPUT_BASE: usize = 896; +const FIELD_SAMPLE_SKIP_FAILURE_BASE: usize = 1_024; +const FIELD_SAMPLE_STATE_LOW_BASE: usize = 1_152; +const FIELD_SAMPLE_STATE_HIGH_BASE: usize = 1_280; +const FIELD_SAMPLE_SKIP_STATE_LOW_BASE: usize = 1_408; +const FIELD_SAMPLE_SKIP_STATE_HIGH_BASE: usize = 1_536; +const FIELD_SAMPLE_COLUMNS: usize = 1_664; + +/// The variable observation segments around the fixed challenger operations. +/// +/// `initial_observations` is the complete seed/shape/activation/stage-1/ +/// claims prefix. The other fields correspond to the comments on their +/// names and are already serialized exactly as the Stage 2 challenger sees +/// them. The production composition will build these byte words directly +/// from the typed proof and verifying-key constants. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Stage2TranscriptReplayV1 { + pub initial_observations: Vec, + pub stage2_and_accumulator_observations: Vec, + pub quotient_commitment_observations: Vec, + pub pcs_opening_observations: Vec, +} + +/// Challenges derived by [`Stage2TranscriptReplayV1`], in protocol order. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Stage2TranscriptChallengesV1 { + pub lookup: [u64; 2], + pub fingerprint: [u64; 2], + pub constraint: [u64; 2], + pub zeta: [u64; 2], + pub pcs_alpha: [u64; 2], +} + +/// One of the four byte segments consumed by the constrained Stage 2 +/// transcript prefix. +/// +/// PCS composition uses these identifiers to consume commitment roots and +/// out-of-domain values from the exact wires already hashed by the +/// transcript, rather than accepting duplicated public values. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Stage2TranscriptSegmentV1 { + Initial, + Stage2AndAccumulator, + QuotientCommitment, + PcsOpening, +} + +impl Stage2TranscriptSegmentV1 { + pub(crate) const fn index(self) -> usize { + match self { + Self::Initial => 0, + Self::Stage2AndAccumulator => 1, + Self::QuotientCommitment => 2, + Self::PcsOpening => 3, + } + } +} + +/// A little-endian `u64` lane inside one constrained transcript segment. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Stage2TranscriptByteBindingV1 { + pub segment: Stage2TranscriptSegmentV1, + pub byte_offset: usize, +} + +impl Stage2TranscriptByteBindingV1 { + pub const fn new( + segment: Stage2TranscriptSegmentV1, + byte_offset: usize, + ) -> Self { + Self { segment, byte_offset } + } +} + +/// The FRI portion of the Stage 2 byte transcript after the opening-batch +/// challenge has been sampled. +/// +/// Commitments are kept as caps so the relation can expose and bind every cap +/// root individually. `query_index_bits` is the exact bit width passed to +/// `SerializingChallenger64::sample_bits`; it equals the global FRI height for +/// the two-adic folding strategy used by Ix. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Stage2FriTranscriptReplayV1 { + pub commit_phase_commitments: Vec>, + pub commit_pow_witnesses: Vec, + pub final_polynomial: Vec<[u64; 2]>, + pub log_arities: Vec, + pub query_pow_witness: u64, + pub commit_pow_bits: u8, + pub query_pow_bits: u8, + pub num_queries: usize, + pub query_index_bits: u8, +} + +/// Challenges sampled by the FRI verifier after the PCS batching challenge. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Stage2FriTranscriptChallengesV1 { + pub betas: Vec<[u64; 2]>, + pub query_indices: Vec, +} + +impl Stage2TranscriptReplayV1 { + /// Replay the exact native byte challenger through the first FRI challenge. + pub fn challenges(&self) -> Result { + compute_challenges(self) + } + + /// Build the exact transcript segments from an already validated Stage 2 + /// root and its serializer-independent typed proof witness. + pub fn from_prepared( + prepared: &ValidatedStage2RootV1, + fri: &FriParameters, + ) -> Result { + let typed = Stage3TypedProofWitnessV1::from_prepared(prepared, fri)?; + Self::from_prepared_and_typed(prepared, fri, &typed) + } + + pub fn from_prepared_and_typed( + prepared: &ValidatedStage2RootV1, + fri: &FriParameters, + typed: &Stage3TypedProofWitnessV1, + ) -> Result { + if prepared.statement().fri_parameter_words() != &fri_parameter_words(fri) { + bail!("Stage 2 transcript uses different FRI parameters"); + } + typed.ensure_profile(prepared.advice_profile())?; + let key = AiurVerifyingKey::from_bytes(prepared.verifying_key_bytes()) + .map_err(|error| { + anyhow::anyhow!("decode Aiur transcript key: {error}") + })?; + if key.to_bytes() != prepared.verifying_key_bytes() { + bail!("Aiur transcript key is not canonically encoded"); + } + if fri_parameter_words(&key.fri_parameters()) != fri_parameter_words(fri) { + bail!("Aiur transcript key uses different FRI parameters"); + } + if key.num_circuits() != typed.active.len() { + bail!( + "Aiur transcript key has {} circuits but activation has {} bits", + key.num_circuits(), + typed.active.len() + ); + } + + let mut initial_observations = key.transcript_seed_and_shape_bytes(); + for &active in &typed.active { + push_u64_observation(&mut initial_observations, u64::from(active)); + } + if let Some(preprocessed) = key.preprocessed_commitment_roots() { + push_cap_observations(&mut initial_observations, &preprocessed); + } + push_cap_observations( + &mut initial_observations, + &typed.commitments.stage_1_trace, + ); + for &log_degree in &typed.log_degrees { + push_u64_observation(&mut initial_observations, u64::from(log_degree)); + } + initial_observations.extend_from_slice(prepared.claims_bytes()); + + let mut stage2_and_accumulator_observations = Vec::new(); + push_cap_observations( + &mut stage2_and_accumulator_observations, + &typed.commitments.stage_2_trace, + ); + for &accumulator in &typed.intermediate_accumulators { + push_extension_observation( + &mut stage2_and_accumulator_observations, + accumulator, + ); + } + + let mut quotient_commitment_observations = Vec::new(); + push_cap_observations( + &mut quotient_commitment_observations, + &typed.commitments.quotient_chunks, + ); + + let mut pcs_opening_observations = Vec::new(); + push_opened_round_observations( + &mut pcs_opening_observations, + &typed.stage_1_opened_values, + ); + push_opened_round_observations( + &mut pcs_opening_observations, + &typed.stage_2_opened_values, + ); + push_opened_round_observations( + &mut pcs_opening_observations, + &typed.quotient_opened_values, + ); + if let Some(preprocessed) = &typed.preprocessed_opened_values { + push_opened_round_observations( + &mut pcs_opening_observations, + preprocessed, + ); + } + + let replay = Self { + initial_observations, + stage2_and_accumulator_observations, + quotient_commitment_observations, + pcs_opening_observations, + }; + validate_replay(&replay)?; + Ok(replay) + } +} + +impl Stage2FriTranscriptReplayV1 { + /// Build the exact post-opening FRI transcript from the validated Stage 2 + /// advice transport and the commitment parameters embedded in its key. + pub fn from_prepared( + prepared: &ValidatedStage2RootV1, + fri: &FriParameters, + ) -> Result { + let typed = Stage3TypedProofWitnessV1::from_prepared(prepared, fri)?; + Self::from_prepared_and_typed(prepared, fri, &typed) + } + + pub fn from_prepared_and_typed( + prepared: &ValidatedStage2RootV1, + fri: &FriParameters, + typed: &Stage3TypedProofWitnessV1, + ) -> Result { + if prepared.statement().fri_parameter_words() != &fri_parameter_words(fri) { + bail!("Stage 2 FRI transcript uses different FRI parameters"); + } + typed.ensure_profile(prepared.advice_profile())?; + let key = AiurVerifyingKey::from_bytes(prepared.verifying_key_bytes()) + .map_err(|error| { + anyhow::anyhow!("decode Aiur FRI transcript key: {error}") + })?; + if key.to_bytes() != prepared.verifying_key_bytes() { + bail!("Aiur FRI transcript key is not canonically encoded"); + } + if fri_parameter_words(&key.fri_parameters()) != fri_parameter_words(fri) { + bail!("Aiur FRI transcript key uses different FRI parameters"); + } + + let first_query = typed + .opening_proof + .query_proofs + .first() + .ok_or_else(|| anyhow::anyhow!("Stage 2 FRI proof has no queries"))?; + let log_arities: Vec = first_query + .commit_phase_openings + .iter() + .map(|step| step.log_arity) + .collect(); + if typed.opening_proof.query_proofs.iter().any(|query| { + query + .commit_phase_openings + .iter() + .map(|step| step.log_arity) + .ne(log_arities.iter().copied()) + }) { + bail!("Stage 2 FRI queries disagree on the folding-arity schedule"); + } + let total_log_reduction = + log_arities.iter().try_fold(0usize, |sum, &arity| { + sum + .checked_add(usize::from(arity)) + .ok_or_else(|| anyhow::anyhow!("FRI folding-arity sum overflow")) + })?; + let query_index_bits = total_log_reduction + .checked_add(key.commitment_parameters().log_blowup) + .and_then(|height| height.checked_add(fri.log_final_poly_len)) + .ok_or_else(|| anyhow::anyhow!("FRI global height overflow"))?; + + let replay = Self { + commit_phase_commitments: typed + .opening_proof + .commit_phase_commits + .clone(), + commit_pow_witnesses: typed.opening_proof.commit_pow_witnesses.clone(), + final_polynomial: typed.opening_proof.final_poly.clone(), + log_arities, + query_pow_witness: typed.opening_proof.query_pow_witness, + commit_pow_bits: u8::try_from(fri.commit_proof_of_work_bits) + .map_err(|_| anyhow::anyhow!("commit PoW bits exceed u8"))?, + query_pow_bits: u8::try_from(fri.query_proof_of_work_bits) + .map_err(|_| anyhow::anyhow!("query PoW bits exceed u8"))?, + num_queries: fri.num_queries, + query_index_bits: u8::try_from(query_index_bits) + .map_err(|_| anyhow::anyhow!("FRI query-index width exceeds u8"))?, + }; + validate_fri_replay(&replay)?; + Ok(replay) + } + + /// Replay the native challenger from the prefix's retained digest state. + pub fn challenges( + &self, + prefix: &Stage2TranscriptReplayV1, + ) -> Result { + compute_fri_challenges(prefix, self) + } +} + +/// A real Flock proof of the transcript prefix relation. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Stage2TranscriptConformanceArtifactV1 { + replay: Stage2TranscriptReplayV1, + challenges: Stage2TranscriptChallengesV1, + circuit_digest: [u8; 32], + proof_bundle_bytes: Vec, +} + +impl Stage2TranscriptConformanceArtifactV1 { + pub fn replay(&self) -> &Stage2TranscriptReplayV1 { + &self.replay + } + + pub const fn challenges(&self) -> Stage2TranscriptChallengesV1 { + self.challenges + } + + pub fn circuit_digest(&self) -> &[u8; 32] { + &self.circuit_digest + } + + pub fn proof_bundle_bytes(&self) -> &[u8] { + &self.proof_bundle_bytes + } + + pub fn to_bytes(&self) -> Vec { + let segments = replay_segments(&self.replay); + let segment_bytes: usize = + segments.iter().map(|segment| segment.len()).sum(); + let mut bytes = Vec::with_capacity( + SEGMENTS_OFFSET + + segment_bytes + + FIXED_SUFFIX_BYTES + + self.proof_bundle_bytes.len(), + ); + bytes.extend_from_slice(STAGE2_TRANSCRIPT_CONFORMANCE_ARTIFACT_MAGIC); + bytes.extend_from_slice(&ARTIFACT_VERSION.to_le_bytes()); + bytes.extend_from_slice(&FlockConfigV1.digest()); + for segment in &segments { + bytes.extend_from_slice( + &u32::try_from(segment.len()) + .expect("bounded transcript segment length") + .to_le_bytes(), + ); + } + for segment in &segments { + bytes.extend_from_slice(segment); + } + encode_challenges(&mut bytes, self.challenges); + bytes.extend_from_slice(&self.circuit_digest); + bytes.extend_from_slice( + &u64::try_from(self.proof_bundle_bytes.len()) + .expect("proof bundle length") + .to_le_bytes(), + ); + bytes.extend_from_slice(&self.proof_bundle_bytes); + bytes + } + + pub fn from_bytes(bytes: &[u8]) -> Result { + if bytes.len() < SEGMENTS_OFFSET + FIXED_SUFFIX_BYTES { + bail!("truncated Flock Stage 2 transcript artifact"); + } + if &bytes[..8] != STAGE2_TRANSCRIPT_CONFORMANCE_ARTIFACT_MAGIC { + bail!("invalid Flock Stage 2 transcript artifact magic"); + } + let version = u16::from_le_bytes(bytes[8..10].try_into().unwrap()); + if version != ARTIFACT_VERSION { + bail!("unsupported Flock Stage 2 transcript artifact version {version}"); + } + if bytes[CONFIG_OFFSET..LENGTHS_OFFSET] != FlockConfigV1.digest() { + bail!("Flock Stage 2 transcript artifact configuration mismatch"); + } + + let mut lengths = [0usize; SEGMENT_COUNT]; + for (index, length) in lengths.iter_mut().enumerate() { + let offset = LENGTHS_OFFSET + index * 4; + *length = usize::try_from(u32::from_le_bytes( + bytes[offset..offset + 4].try_into().unwrap(), + )) + .expect("u32 fits usize"); + } + let segment_bytes = lengths.iter().try_fold(0usize, |total, &length| { + total + .checked_add(length) + .ok_or_else(|| anyhow::anyhow!("transcript segment length overflow")) + })?; + let suffix_offset = SEGMENTS_OFFSET + .checked_add(segment_bytes) + .ok_or_else(|| anyhow::anyhow!("transcript artifact length overflow"))?; + let minimum_end = suffix_offset + .checked_add(FIXED_SUFFIX_BYTES) + .ok_or_else(|| anyhow::anyhow!("transcript artifact length overflow"))?; + if bytes.len() < minimum_end { + bail!("truncated Flock Stage 2 transcript artifact segments"); + } + + let mut cursor = SEGMENTS_OFFSET; + let mut take_segment = |length: usize| { + let end = cursor + length; + let segment = bytes[cursor..end].to_vec(); + cursor = end; + segment + }; + let replay = Stage2TranscriptReplayV1 { + initial_observations: take_segment(lengths[0]), + stage2_and_accumulator_observations: take_segment(lengths[1]), + quotient_commitment_observations: take_segment(lengths[2]), + pcs_opening_observations: take_segment(lengths[3]), + }; + validate_replay(&replay)?; + debug_assert_eq!(cursor, suffix_offset); + + let challenges = + decode_challenges(&bytes[suffix_offset..suffix_offset + CHALLENGE_BYTES]); + validate_challenges(challenges)?; + let digest_offset = suffix_offset + CHALLENGE_BYTES; + let mut circuit_digest = [0u8; 32]; + circuit_digest.copy_from_slice(&bytes[digest_offset..digest_offset + 32]); + let bundle_length_offset = digest_offset + 32; + let bundle_len = usize::try_from(u64::from_le_bytes( + bytes[bundle_length_offset..bundle_length_offset + 8].try_into().unwrap(), + )) + .map_err(|_| anyhow::anyhow!("Flock proof length does not fit usize"))?; + if bundle_len == 0 || bundle_len > MAX_BUNDLE_BYTES { + bail!("invalid Flock Stage 2 transcript proof length {bundle_len}"); + } + let bundle_offset = bundle_length_offset + 8; + let declared_end = bundle_offset + .checked_add(bundle_len) + .ok_or_else(|| anyhow::anyhow!("transcript proof length overflow"))?; + if bytes.len() != declared_end { + bail!( + "Flock Stage 2 transcript artifact is {} bytes; header declares {declared_end}", + bytes.len() + ); + } + Ok(Self { + replay, + challenges, + circuit_digest, + proof_bundle_bytes: bytes[bundle_offset..].to_vec(), + }) + } +} + +#[derive(Serialize, Deserialize)] +struct TranscriptProofBundle { + commitment: Commitment, + proof: R1csProofCircuitMerged, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct HashSampleRow(F128); + +/// Convert digest bytes `[16..32]` into the first sampled extension element. +/// +/// For an input word `[LE(digest[16..24]), LE(digest[24..32])]`, popping +/// bytes from the end and then decoding each draw as LE produces +/// `[BE(digest[24..32]), BE(digest[16..24])]`. +#[derive(Clone, Copy, Debug)] +pub(crate) struct HashSampleGate { + pub(crate) nu: usize, +} + +impl GateType for HashSampleGate { + type Row = HashSampleRow; + type Hint = (); + + fn table(&self) -> TableType { + TableType::from_block_r1cs(&build_hash_sample_r1cs(self.nu)) + .with_io_schema(vec![IoWord::input(0), IoWord::output(1)]) + } + + fn eval( + &self, + inputs: &[F128], + _hint: &(), + outputs: &mut Vec, + ) -> Self::Row { + let input = inputs[0]; + outputs.push(sample_word(input)); + HashSampleRow(input) + } + + fn witness(&self, _rows: &[Self::Row], _nu: usize) -> SlotWitness { + SlotWitness::DeferredToRows + } +} + +pub(crate) fn build_hash_sample_r1cs( + nu: usize, +) -> flock_prover::r1cs::BlockR1cs { + hash_sample_plan().block_r1cs(nu) +} + +pub(crate) fn generate_hash_sample_witness( + rows: &[HashSampleRow], + nu: usize, +) -> (Vec, Vec, Vec, Vec) { + generate_boolean_witness(hash_sample_plan(), rows, nu, |row, bits| { + write_f128(bits, SAMPLE_INPUT_BASE, row.0); + }) +} + +fn hash_sample_plan() -> &'static BooleanR1csPlan { + static PLAN: std::sync::OnceLock = + std::sync::OnceLock::new(); + PLAN.get_or_init(|| { + let mut builder = BooleanR1csBuilder::new(SAMPLE_K_LOG, SAMPLE_COLUMNS); + for column in SAMPLE_INPUT_BASE..SAMPLE_INPUT_BASE + 128 { + builder.free_boolean_at(column); + } + for output_bit in 0..128 { + let lane_bit = output_bit % 64; + let source_lane = if output_bit < 64 { 1 } else { 0 }; + let source = + SAMPLE_INPUT_BASE + source_lane * 64 + reverse_bytes_bit(lane_bit); + builder.write_product_of_parities( + SAMPLE_OUTPUT_BASE + output_bit, + &[source], + &[source], + ); + } + builder.finish() + }) +} + +const fn reverse_bytes_bit(bit: usize) -> usize { + (7 - bit / 8) * 8 + bit % 8 +} + +fn sample_word(input: F128) -> F128 { + F128::new(input.hi.swap_bytes(), input.lo.swap_bytes()) +} + +/// Eight raw draws from a digest and its chained refill, lowered to the first +/// two canonical Goldilocks values. The second result variant skips the first +/// raw draw, as required when commit-phase grinding consumes it before beta. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct GoldilocksSampleRow([F128; 4]); + +#[derive(Clone, Copy, Debug)] +pub(crate) struct GoldilocksSampleGate { + pub(crate) nu: usize, +} + +impl GateType for GoldilocksSampleGate { + type Row = GoldilocksSampleRow; + type Hint = (); + + fn table(&self) -> TableType { + TableType::from_block_r1cs(&build_goldilocks_sample_r1cs(self.nu)) + .with_io_schema(vec![ + IoWord::input(0), + IoWord::input(1), + IoWord::input(2), + IoWord::input(3), + IoWord::output(4), + IoWord::output(5), + IoWord::output(6), + IoWord::output(7), + IoWord::output(8), + IoWord::output(9), + IoWord::output(10), + IoWord::output(11), + IoWord::output(12), + ]) + } + + fn eval( + &self, + inputs: &[F128], + _hint: &(), + outputs: &mut Vec, + ) -> Self::Row { + let words = [inputs[0], inputs[1], inputs[2], inputs[3]]; + let candidates = digest_candidates(words); + let (sample, failure, used_refill) = select_two_candidates(&candidates, 4); + let (skip_sample, skip_failure, skip_used_refill) = + select_two_candidates(&candidates[1..], 3); + let state = + if used_refill { [words[3], words[2]] } else { [words[1], words[0]] }; + let skip_state = if skip_used_refill { + [words[3], words[2]] + } else { + [words[1], words[0]] + }; + outputs.extend_from_slice(&[ + sample, + F128::new(u64::from(failure), 0), + F128::new(candidates[0], 0), + skip_sample, + F128::new(u64::from(skip_failure), 0), + state[0], + state[1], + skip_state[0], + skip_state[1], + ]); + GoldilocksSampleRow(words) + } + + fn witness(&self, _rows: &[Self::Row], _nu: usize) -> SlotWitness { + SlotWitness::DeferredToRows + } +} + +pub(crate) fn build_goldilocks_sample_r1cs( + nu: usize, +) -> flock_prover::r1cs::BlockR1cs { + goldilocks_sample_plan().block_r1cs(nu) +} + +pub(crate) fn generate_goldilocks_sample_witness( + rows: &[GoldilocksSampleRow], + nu: usize, +) -> (Vec, Vec, Vec, Vec) { + generate_boolean_witness(goldilocks_sample_plan(), rows, nu, |row, bits| { + write_f128(bits, FIELD_SAMPLE_HIGH_BASE, row.0[0]); + write_f128(bits, FIELD_SAMPLE_LOW_BASE, row.0[1]); + write_f128(bits, FIELD_SAMPLE_REFILL_HIGH_BASE, row.0[2]); + write_f128(bits, FIELD_SAMPLE_REFILL_LOW_BASE, row.0[3]); + }) +} + +fn goldilocks_sample_plan() -> &'static BooleanR1csPlan { + static PLAN: std::sync::OnceLock = + std::sync::OnceLock::new(); + PLAN.get_or_init(|| { + let mut builder = + BooleanR1csBuilder::new(FIELD_SAMPLE_K_LOG, FIELD_SAMPLE_COLUMNS); + let one = builder.alloc_constant_one(); + for column in FIELD_SAMPLE_HIGH_BASE..FIELD_SAMPLE_REFILL_LOW_BASE + 128 { + builder.free_boolean_at(column); + } + let candidate_bits = digest_candidate_columns(); + let rejection: Vec<_> = candidate_bits + .iter() + .map(|candidate| rejection_bit(&mut builder, candidate, one)) + .collect(); + let acceptance: Vec<_> = rejection + .iter() + .map(|&reject| builder.xor(&[reject, one], one)) + .collect(); + + let (first, second, failure) = + selection_masks(&mut builder, &acceptance, &rejection, one); + write_selected_word( + &mut builder, + FIELD_SAMPLE_OUTPUT_BASE, + &candidate_bits, + &first, + &second, + one, + ); + write_flag_word( + &mut builder, + FIELD_SAMPLE_FAILURE_BASE, + failure, + candidate_bits[0][0], + one, + ); + write_candidate_low_word( + &mut builder, + FIELD_SAMPLE_RAW_FIRST_BASE, + &candidate_bits[0], + one, + ); + let used_refill = + selection_uses_refill(&mut builder, &first, &second, 4, one); + write_selected_state( + &mut builder, + FIELD_SAMPLE_STATE_LOW_BASE, + FIELD_SAMPLE_STATE_HIGH_BASE, + used_refill, + one, + ); + + let (skip_first, skip_second, skip_failure) = + selection_masks(&mut builder, &acceptance[1..], &rejection[1..], one); + write_selected_word( + &mut builder, + FIELD_SAMPLE_SKIP_OUTPUT_BASE, + &candidate_bits[1..], + &skip_first, + &skip_second, + one, + ); + write_flag_word( + &mut builder, + FIELD_SAMPLE_SKIP_FAILURE_BASE, + skip_failure, + candidate_bits[0][0], + one, + ); + let skip_used_refill = + selection_uses_refill(&mut builder, &skip_first, &skip_second, 3, one); + write_selected_state( + &mut builder, + FIELD_SAMPLE_SKIP_STATE_LOW_BASE, + FIELD_SAMPLE_SKIP_STATE_HIGH_BASE, + skip_used_refill, + one, + ); + builder.finish() + }) +} + +fn digest_candidate_columns() -> [[usize; 64]; 8] { + [ + std::array::from_fn(|bit| { + FIELD_SAMPLE_HIGH_BASE + 64 + reverse_bytes_bit(bit) + }), + std::array::from_fn(|bit| FIELD_SAMPLE_HIGH_BASE + reverse_bytes_bit(bit)), + std::array::from_fn(|bit| { + FIELD_SAMPLE_LOW_BASE + 64 + reverse_bytes_bit(bit) + }), + std::array::from_fn(|bit| FIELD_SAMPLE_LOW_BASE + reverse_bytes_bit(bit)), + std::array::from_fn(|bit| { + FIELD_SAMPLE_REFILL_HIGH_BASE + 64 + reverse_bytes_bit(bit) + }), + std::array::from_fn(|bit| { + FIELD_SAMPLE_REFILL_HIGH_BASE + reverse_bytes_bit(bit) + }), + std::array::from_fn(|bit| { + FIELD_SAMPLE_REFILL_LOW_BASE + 64 + reverse_bytes_bit(bit) + }), + std::array::from_fn(|bit| { + FIELD_SAMPLE_REFILL_LOW_BASE + reverse_bytes_bit(bit) + }), + ] +} + +fn rejection_bit( + builder: &mut BooleanR1csBuilder, + candidate: &[usize; 64], + one: usize, +) -> usize { + let high_all = candidate[33..] + .iter() + .fold(candidate[32], |all, &bit| builder.and(all, bit)); + let low_any = candidate[1..32].iter().fold(candidate[0], |any, &bit| { + let both = builder.and(any, bit); + builder.xor(&[any, bit, both], one) + }); + builder.and(high_all, low_any) +} + +fn selection_masks( + builder: &mut BooleanR1csBuilder, + acceptance: &[usize], + rejection: &[usize], + one: usize, +) -> (Vec, Vec, usize) { + let zero = builder.xor(&[one, one], one); + let mut none = one; + let mut exactly_one = zero; + let mut first = Vec::with_capacity(acceptance.len()); + let mut second = Vec::with_capacity(acceptance.len()); + for (&accept, &reject) in acceptance.iter().zip(rejection) { + let first_here = builder.and(none, accept); + let second_here = builder.and(exactly_one, accept); + first.push(first_here); + second.push(second_here); + let one_stays = builder.and(exactly_one, reject); + exactly_one = builder.xor(&[one_stays, first_here], one); + none = builder.and(none, reject); + } + let failure = builder.xor(&[none, exactly_one], one); + (first, second, failure) +} + +fn selection_uses_refill( + builder: &mut BooleanR1csBuilder, + first: &[usize], + second: &[usize], + refill_start: usize, + one: usize, +) -> usize { + first[refill_start..].iter().chain(&second[refill_start..]).copied().fold( + builder.xor(&[one, one], one), + |used, mask| { + let both = builder.and(used, mask); + builder.xor(&[used, mask, both], one) + }, + ) +} + +fn write_selected_state( + builder: &mut BooleanR1csBuilder, + output_low_base: usize, + output_high_base: usize, + use_refill: usize, + one: usize, +) { + for (output_base, primary_base, refill_base) in [ + (output_low_base, FIELD_SAMPLE_LOW_BASE, FIELD_SAMPLE_REFILL_LOW_BASE), + (output_high_base, FIELD_SAMPLE_HIGH_BASE, FIELD_SAMPLE_REFILL_HIGH_BASE), + ] { + for bit in 0..128 { + let primary = primary_base + bit; + let refill = refill_base + bit; + let remove_primary = builder.and(use_refill, primary); + let add_refill = builder.and(use_refill, refill); + builder.write_xor( + output_base + bit, + &[primary, remove_primary, add_refill], + one, + ); + } + } +} + +#[allow(clippy::too_many_arguments)] +fn write_selected_word( + builder: &mut BooleanR1csBuilder, + output_base: usize, + candidates: &[[usize; 64]], + first: &[usize], + second: &[usize], + one: usize, +) { + for bit in 0..64 { + let first_terms: Vec<_> = candidates + .iter() + .zip(first) + .map(|(candidate, &mask)| builder.and(candidate[bit], mask)) + .collect(); + let second_terms: Vec<_> = candidates + .iter() + .zip(second) + .map(|(candidate, &mask)| builder.and(candidate[bit], mask)) + .collect(); + builder.write_xor(output_base + bit, &first_terms, one); + builder.write_xor(output_base + 64 + bit, &second_terms, one); + } +} + +fn write_flag_word( + builder: &mut BooleanR1csBuilder, + output_base: usize, + flag: usize, + zero_source: usize, + one: usize, +) { + builder.write_product_of_parities(output_base, &[flag], &[flag]); + for bit in 1..128 { + builder.write_xor(output_base + bit, &[zero_source, zero_source], one); + } +} + +fn write_candidate_low_word( + builder: &mut BooleanR1csBuilder, + output_base: usize, + candidate: &[usize; 64], + one: usize, +) { + for (bit, &candidate_bit) in candidate.iter().enumerate() { + builder.write_product_of_parities( + output_base + bit, + &[candidate_bit], + &[candidate_bit], + ); + } + for bit in 64..128 { + builder.write_xor(output_base + bit, &[candidate[0], candidate[0]], one); + } +} + +fn digest_candidates(words: [F128; 4]) -> [u64; 8] { + [ + words[0].hi.swap_bytes(), + words[0].lo.swap_bytes(), + words[1].hi.swap_bytes(), + words[1].lo.swap_bytes(), + words[2].hi.swap_bytes(), + words[2].lo.swap_bytes(), + words[3].hi.swap_bytes(), + words[3].lo.swap_bytes(), + ] +} + +fn select_two_candidates( + candidates: &[u64], + refill_start: usize, +) -> (F128, bool, bool) { + let accepted: Vec<_> = candidates + .iter() + .copied() + .enumerate() + .filter(|&(_, candidate)| candidate < GOLDILOCKS_MODULUS) + .take(2) + .collect(); + if accepted.len() == 2 { + ( + F128::new(accepted[0].1, accepted[1].1), + false, + accepted[1].0 >= refill_start, + ) + } else { + (F128::ZERO, true, true) + } +} + +const U64_SPLIT_K_LOG: usize = 9; +const U64_SPLIT_INPUT_BASE: usize = 0; +const U64_SPLIT_BIT_BASE: usize = 128; +const U64_SPLIT_QUOTIENT_BASE: usize = 256; +const U64_SPLIT_COLUMNS: usize = 384; + +/// Split a low-lane `u64` into its least-significant bit and the remaining +/// quotient. Repeating this gate exposes exactly the low bits consumed by +/// `SerializingChallenger64::sample_bits`, without making those bits separate +/// public inputs. +#[derive(Clone, Copy, Debug)] +pub(crate) struct U64SplitGate { + pub(crate) nu: usize, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct U64SplitRow(F128); + +impl GateType for U64SplitGate { + type Row = U64SplitRow; + type Hint = (); + + fn table(&self) -> TableType { + TableType::from_block_r1cs(&build_u64_split_r1cs(self.nu)).with_io_schema( + vec![IoWord::input(0), IoWord::output(1), IoWord::output(2)], + ) + } + + fn eval( + &self, + inputs: &[F128], + _hint: &(), + outputs: &mut Vec, + ) -> Self::Row { + let input = inputs[0]; + assert_eq!(input.hi, 0, "u64 split input must occupy the low lane"); + outputs.push(F128::new(input.lo & 1, 0)); + outputs.push(F128::new(input.lo >> 1, 0)); + U64SplitRow(input) + } + + fn witness(&self, _rows: &[Self::Row], _nu: usize) -> SlotWitness { + SlotWitness::DeferredToRows + } +} + +pub(crate) fn build_u64_split_r1cs(nu: usize) -> flock_prover::r1cs::BlockR1cs { + u64_split_plan().block_r1cs(nu) +} + +pub(crate) fn generate_u64_split_witness( + rows: &[U64SplitRow], + nu: usize, +) -> (Vec, Vec, Vec, Vec) { + generate_boolean_witness(u64_split_plan(), rows, nu, |row, bits| { + write_f128(bits, U64_SPLIT_INPUT_BASE, row.0); + }) +} + +fn u64_split_plan() -> &'static BooleanR1csPlan { + static PLAN: std::sync::OnceLock = + std::sync::OnceLock::new(); + PLAN.get_or_init(|| { + let mut builder = + BooleanR1csBuilder::new(U64_SPLIT_K_LOG, U64_SPLIT_COLUMNS); + let one = builder.alloc_constant_one(); + for bit in 0..64 { + builder.free_boolean_at(U64_SPLIT_INPUT_BASE + bit); + } + for bit in 64..128 { + builder.assert_zero_at(U64_SPLIT_INPUT_BASE + bit, one); + } + + let write_copy = |builder: &mut BooleanR1csBuilder, output, source| { + builder.write_product_of_parities(output, &[source], &[source]); + }; + let write_zero = |builder: &mut BooleanR1csBuilder, output| { + builder.write_xor( + output, + &[U64_SPLIT_INPUT_BASE, U64_SPLIT_INPUT_BASE], + one, + ); + }; + + write_copy(&mut builder, U64_SPLIT_BIT_BASE, U64_SPLIT_INPUT_BASE); + for bit in 1..128 { + write_zero(&mut builder, U64_SPLIT_BIT_BASE + bit); + } + for bit in 0..63 { + write_copy( + &mut builder, + U64_SPLIT_QUOTIENT_BASE + bit, + U64_SPLIT_INPUT_BASE + bit + 1, + ); + } + for bit in 63..128 { + write_zero(&mut builder, U64_SPLIT_QUOTIENT_BASE + bit); + } + builder.finish() + }) +} + +#[derive(Clone, Copy)] +pub(crate) struct TranscriptCircuitSlots { + pub(crate) blake3: SlotId, + pub(crate) sample: SlotId, + pub(crate) canonical: SlotId, +} + +#[derive(Clone, Copy)] +pub(crate) struct TranscriptChallengeWires { + pub(crate) lookup: Wire, + pub(crate) fingerprint: Wire, + pub(crate) constraint: Wire, + pub(crate) zeta: Wire, + pub(crate) pcs_alpha: Wire, +} + +impl TranscriptChallengeWires { + pub(crate) fn all(self) -> [Wire; CHALLENGE_COUNT] { + [self.lookup, self.fingerprint, self.constraint, self.zeta, self.pcs_alpha] + } +} + +pub(crate) struct TranscriptConstraintRegion { + pub(crate) inputs: Vec, + pub(crate) challenges: TranscriptChallengeWires, + /// Packed public words for each of the four observation segments. These are + /// the same wires passed to the BLAKE3 transcript gates. + pub(crate) observation_words: Vec>, + /// HashChallenger input state after sampling the PCS challenge. The low + /// half of this digest remains in the output buffer until the next + /// observation; every valid FRI proof immediately observes a non-empty cap. + pub(crate) state_digest: [Wire; 2], +} + +struct TranscriptRelation { + shape: CircuitShape, + slots: TranscriptCircuitSlots, + nu: usize, + inputs: Vec, +} + +impl TranscriptRelation { + fn build(replay: &Stage2TranscriptReplayV1) -> Result { + let nu = transcript_nu(replay)?; + let mut builder = ShapeBuilder::new(nu); + let slots = TranscriptCircuitSlots { + blake3: builder.slot(Blake3Gate { nu }), + sample: builder.slot(GoldilocksSampleGate { nu }), + canonical: builder.slot(CanonicalGoldilocksPairGate { nu }), + }; + let region = constrain_stage2_transcript(&mut builder, slots, replay, nu)?; + for challenge in region.challenges.all() { + builder.publish(challenge); + } + let shape = builder.finish().map_err(|error| { + anyhow::anyhow!("build Flock Stage 2 transcript circuit: {error:?}") + })?; + Ok(Self { shape, slots, nu, inputs: region.inputs }) + } + + fn public(&self, challenges: Stage2TranscriptChallengesV1) -> Vec { + let mut public = self.inputs.clone(); + public.extend(challenge_words(challenges)); + public + } +} + +pub(crate) fn transcript_nu( + replay: &Stage2TranscriptReplayV1, +) -> Result { + let traces = transcript_traces(replay)?; + let blake3_rows = traces.iter().map(|trace| trace.rows.len()).sum::() + + CHALLENGE_COUNT * hash_trace(32).rows.len(); + let needed_rows = blake3_rows.max(CHALLENGE_COUNT).max(1); + let nu = MIN_NU.max(needed_rows.next_power_of_two().ilog2() as usize); + if nu > MAX_NU { + bail!( + "Stage 2 transcript needs {blake3_rows} BLAKE3 rows (nu={nu}); maximum is nu={MAX_NU}" + ); + } + Ok(nu) +} + +pub(crate) fn constrain_stage2_transcript( + builder: &mut ShapeBuilder, + slots: TranscriptCircuitSlots, + replay: &Stage2TranscriptReplayV1, + nu: usize, +) -> Result { + let traces = transcript_traces(replay)?; + let row_count = traces.iter().map(|trace| trace.rows.len()).sum::() + + CHALLENGE_COUNT * hash_trace(32).rows.len(); + if row_count > 1usize << nu { + bail!("Stage 2 transcript exceeds the supplied Flock row capacity"); + } + let segment_wires: Vec> = replay_segments(replay) + .iter() + .map(|segment| { + (0..segment.len().div_ceil(WORD_BYTES)) + .map(|_| builder.public_input()) + .collect() + }) + .collect(); + + let mut inputs: Vec = + replay_segments(replay).into_iter().flat_map(pack_segment).collect(); + let packed_iv = pack8(&IV); + let iv = [ + fixed(builder, &mut inputs, packed_iv[0]), + fixed(builder, &mut inputs, packed_iv[1]), + ]; + // Data padding is only consumed; assertion zero only receives residual + // outputs. Keeping the wiring classes separate preserves a directed DAG. + let data_zero = fixed(builder, &mut inputs, F128::ZERO); + let assertion_zero = fixed(builder, &mut inputs, F128::ZERO); + let parameter_wires: Vec> = traces + .iter() + .map(|trace| { + trace + .rows + .iter() + .map(|&(_cv, _message, counter, block_len, flags)| { + fixed(builder, &mut inputs, pack_params(counter, block_len, flags)) + }) + .collect() + }) + .collect(); + + let digest_1 = constrain_hash( + builder, + slots.blake3, + &traces[0], + ¶meter_wires[0], + iv, + data_zero, + &segment_wires[0], + )?; + let sampled = constrain_field_sample( + builder, + slots.blake3, + slots.sample, + slots.canonical, + assertion_zero, + iv, + data_zero, + &mut inputs, + digest_1, + false, + )?; + let lookup = sampled.value; + let state_1 = sampled.state; + + let digest_2 = constrain_hash( + builder, + slots.blake3, + &traces[1], + ¶meter_wires[1], + iv, + data_zero, + &[state_1[0], state_1[1], lookup], + )?; + let sampled = constrain_field_sample( + builder, + slots.blake3, + slots.sample, + slots.canonical, + assertion_zero, + iv, + data_zero, + &mut inputs, + digest_2, + false, + )?; + let fingerprint = sampled.value; + let state_2 = sampled.state; + + let mut message_3 = vec![state_2[0], state_2[1], fingerprint]; + message_3.extend_from_slice(&segment_wires[1]); + let digest_3 = constrain_hash( + builder, + slots.blake3, + &traces[2], + ¶meter_wires[2], + iv, + data_zero, + &message_3, + )?; + let sampled = constrain_field_sample( + builder, + slots.blake3, + slots.sample, + slots.canonical, + assertion_zero, + iv, + data_zero, + &mut inputs, + digest_3, + false, + )?; + let constraint = sampled.value; + let state_3 = sampled.state; + + let mut message_4 = vec![state_3[0], state_3[1]]; + message_4.extend_from_slice(&segment_wires[2]); + let digest_4 = constrain_hash( + builder, + slots.blake3, + &traces[3], + ¶meter_wires[3], + iv, + data_zero, + &message_4, + )?; + let sampled = constrain_field_sample( + builder, + slots.blake3, + slots.sample, + slots.canonical, + assertion_zero, + iv, + data_zero, + &mut inputs, + digest_4, + false, + )?; + let zeta = sampled.value; + let state_4 = sampled.state; + + let mut message_5 = vec![state_4[0], state_4[1]]; + message_5.extend_from_slice(&segment_wires[3]); + let digest_5 = constrain_hash( + builder, + slots.blake3, + &traces[4], + ¶meter_wires[4], + iv, + data_zero, + &message_5, + )?; + let sampled = constrain_field_sample( + builder, + slots.blake3, + slots.sample, + slots.canonical, + assertion_zero, + iv, + data_zero, + &mut inputs, + digest_5, + false, + )?; + let pcs_alpha = sampled.value; + Ok(TranscriptConstraintRegion { + inputs, + challenges: TranscriptChallengeWires { + lookup, + fingerprint, + constraint, + zeta, + pcs_alpha, + }, + observation_words: segment_wires, + state_digest: sampled.state, + }) +} + +#[derive(Clone, Copy)] +pub(crate) struct FriTranscriptCircuitSlots { + pub(crate) blake3: SlotId, + pub(crate) sample: SlotId, + pub(crate) field_sample: SlotId, + pub(crate) canonical: SlotId, + pub(crate) repack: SlotId, + pub(crate) split: SlotId, +} + +pub(crate) struct FriTranscriptConstraintRegion { + pub(crate) inputs: Vec, + pub(crate) betas: Vec, + pub(crate) query_index_bits: Vec>, + pub(crate) commitment_roots: Vec>, + pub(crate) final_polynomial: Vec, +} + +/// Continue an already constrained Stage 2 transcript through every FRI +/// challenge and query draw. The returned beta/index wires are intended to be +/// consumed directly by the PCS/FRI verifier relation. +pub(crate) fn constrain_stage2_fri_transcript( + builder: &mut ShapeBuilder, + slots: FriTranscriptCircuitSlots, + replay: &Stage2FriTranscriptReplayV1, + initial_digest: [Wire; 2], + nu: usize, +) -> Result { + validate_fri_replay(replay)?; + let capacity = 1usize << nu; + if fri_transcript_blake3_rows(replay)? > capacity + || fri_transcript_split_rows(replay)? > capacity + { + bail!("Stage 2 FRI transcript exceeds the supplied Flock row capacity"); + } + let mut inputs = Vec::new(); + let packed_iv = pack8(&IV); + let iv = [ + fixed(builder, &mut inputs, packed_iv[0]), + fixed(builder, &mut inputs, packed_iv[1]), + ]; + let data_zero = fixed(builder, &mut inputs, F128::ZERO); + let assertion_zero = fixed(builder, &mut inputs, F128::ZERO); + + let mut state = initial_digest; + let mut betas = Vec::with_capacity(replay.commit_phase_commitments.len()); + let mut commitment_roots = + Vec::with_capacity(replay.commit_phase_commitments.len()); + for (round, cap) in replay.commit_phase_commitments.iter().enumerate() { + let cap_bytes = cap_observation_bytes(cap); + let cap_words = declare_public_segment(builder, &mut inputs, &cap_bytes); + commitment_roots.push(cap_words.as_chunks::<2>().0.to_vec()); + let mut message = Vec::with_capacity( + 2 + cap_words.len() + usize::from(replay.commit_pow_bits != 0), + ); + message.extend_from_slice(&state); + message.extend_from_slice(&cap_words); + if replay.commit_pow_bits != 0 { + message.push(declare_public_word( + builder, + &mut inputs, + F128::new(replay.commit_pow_witnesses[round], 0), + )); + } + let message_len = + 32 + cap_bytes.len() + usize::from(replay.commit_pow_bits != 0) * 8; + let trace = hash_trace(message_len); + let parameters = declare_trace_parameters(builder, &mut inputs, &trace); + let digest = constrain_hash( + builder, + slots.blake3, + &trace, + ¶meters, + iv, + data_zero, + &message, + )?; + + let sampled = constrain_field_sample( + builder, + slots.blake3, + slots.field_sample, + slots.canonical, + assertion_zero, + iv, + data_zero, + &mut inputs, + digest, + replay.commit_pow_bits != 0, + )?; + if replay.commit_pow_bits != 0 { + constrain_low_zero_bits( + builder, + slots.split, + assertion_zero, + sampled.raw_first, + replay.commit_pow_bits, + ); + } + betas.push(sampled.value); + state = sampled.state; + } + + let mut final_suffix = final_observation_bytes(replay); + if replay.query_pow_bits != 0 { + push_u64_observation(&mut final_suffix, replay.query_pow_witness); + } + let final_words = declare_public_segment(builder, &mut inputs, &final_suffix); + let final_polynomial = final_words[..replay.final_polynomial.len()].to_vec(); + let mut final_message = Vec::with_capacity(2 + final_words.len()); + final_message.extend_from_slice(&state); + final_message.extend_from_slice(&final_words); + let final_trace = hash_trace(32 + final_suffix.len()); + let final_parameters = + declare_trace_parameters(builder, &mut inputs, &final_trace); + state = constrain_hash( + builder, + slots.blake3, + &final_trace, + &final_parameters, + iv, + data_zero, + &final_message, + )?; + + let draw_count = replay + .num_queries + .checked_add(usize::from(replay.query_pow_bits != 0)) + .ok_or_else(|| anyhow::anyhow!("FRI transcript draw count overflow"))?; + let digest_count = draw_count.div_ceil(4); + let mut draws = Vec::with_capacity(4 * digest_count); + for digest_index in 0..digest_count { + if digest_index != 0 { + let trace = hash_trace(32); + let parameters = declare_trace_parameters(builder, &mut inputs, &trace); + state = constrain_hash( + builder, + slots.blake3, + &trace, + ¶meters, + iv, + data_zero, + &state, + )?; + } + let high_samples = builder.gate(slots.sample, &[state[1]])[0]; + let low_samples = builder.gate(slots.sample, &[state[0]])[0]; + draws.extend(split_sample_lanes( + builder, + slots.repack, + data_zero, + high_samples, + )); + draws.extend(split_sample_lanes( + builder, + slots.repack, + data_zero, + low_samples, + )); + } + draws.truncate(draw_count); + let query_draws = if replay.query_pow_bits == 0 { + &draws[..] + } else { + constrain_low_zero_bits( + builder, + slots.split, + assertion_zero, + draws[0], + replay.query_pow_bits, + ); + &draws[1..] + }; + let query_index_bits = query_draws + .iter() + .map(|&draw| { + split_low_bits(builder, slots.split, draw, replay.query_index_bits) + }) + .collect(); + + Ok(FriTranscriptConstraintRegion { + inputs, + betas, + query_index_bits, + commitment_roots, + final_polynomial, + }) +} + +fn declare_public_segment( + builder: &mut ShapeBuilder, + inputs: &mut Vec, + bytes: &[u8], +) -> Vec { + pack_segment(bytes) + .into_iter() + .map(|word| declare_public_word(builder, inputs, word)) + .collect() +} + +fn declare_public_word( + builder: &mut ShapeBuilder, + inputs: &mut Vec, + value: F128, +) -> Wire { + inputs.push(value); + builder.public_input() +} + +fn declare_trace_parameters( + builder: &mut ShapeBuilder, + inputs: &mut Vec, + trace: &FsChainTrace, +) -> Vec { + trace + .rows + .iter() + .map(|&(_cv, _message, counter, block_len, flags)| { + fixed(builder, inputs, pack_params(counter, block_len, flags)) + }) + .collect() +} + +fn split_sample_lanes( + builder: &mut ShapeBuilder, + repack_slot: SlotId, + zero: Wire, + samples: Wire, +) -> [Wire; 2] { + let repacked = builder.gate(repack_slot, &[samples, zero]); + let low = repacked[3]; + let high_duplicate = repacked[1]; + let high = builder.gate(repack_slot, &[high_duplicate, zero])[3]; + [low, high] +} + +fn split_low_bits( + builder: &mut ShapeBuilder, + split_slot: SlotId, + mut value: Wire, + bits: u8, +) -> Vec { + (0..bits) + .map(|_| { + let outputs = builder.gate(split_slot, &[value]); + value = outputs[1]; + outputs[0] + }) + .collect() +} + +fn constrain_low_zero_bits( + builder: &mut ShapeBuilder, + split_slot: SlotId, + zero: Wire, + value: Wire, + bits: u8, +) { + for bit in split_low_bits(builder, split_slot, value, bits) { + builder.connect(bit, zero); + } +} + +fn fixed( + builder: &mut ShapeBuilder, + fixed_inputs: &mut Vec, + value: F128, +) -> Wire { + fixed_inputs.push(value); + builder.fixed_public_input(value) +} + +struct ConstrainedFieldSample { + value: Wire, + raw_first: Wire, + state: [Wire; 2], +} + +#[allow(clippy::too_many_arguments)] +fn constrain_field_sample( + builder: &mut ShapeBuilder, + blake3: SlotId, + sample: SlotId, + canonical: SlotId, + zero: Wire, + iv: [Wire; 2], + data_zero: Wire, + inputs: &mut Vec, + digest: [Wire; 2], + skip_first: bool, +) -> Result { + let trace = hash_trace(32); + let parameters = declare_trace_parameters(builder, inputs, &trace); + let refill = constrain_hash( + builder, + blake3, + &trace, + ¶meters, + iv, + data_zero, + &digest, + )?; + let sampled = + builder.gate(sample, &[digest[1], digest[0], refill[1], refill[0]]); + let (challenge, failure, state) = if skip_first { + (sampled[3], sampled[4], [sampled[7], sampled[8]]) + } else { + (sampled[0], sampled[1], [sampled[5], sampled[6]]) + }; + builder.connect(failure, zero); + let violation = builder.gate(canonical, &[challenge])[0]; + builder.connect(violation, zero); + Ok(ConstrainedFieldSample { value: challenge, raw_first: sampled[2], state }) +} + +pub(crate) fn constrain_hash( + builder: &mut ShapeBuilder, + slot: SlotId, + trace: &FsChainTrace, + parameters: &[Wire], + iv: [Wire; 2], + zero: Wire, + message: &[Wire], +) -> Result<[Wire; 2]> { + if parameters.len() != trace.rows.len() { + bail!("BLAKE3 trace parameter count mismatch"); + } + let mut row_outputs = Vec::<[Wire; 4]>::with_capacity(trace.rows.len()); + for (row_index, ¶meter) in parameters.iter().enumerate() { + let link = trace.links[row_index]; + let (cv, block) = if let Some(right_row) = link.right { + let CvSource::Row(left_row) = link.cv else { + bail!("BLAKE3 parent row does not name its left child"); + }; + let left = row_outputs + .get(left_row) + .ok_or_else(|| anyhow::anyhow!("BLAKE3 left-child link is forward"))?; + let right = row_outputs + .get(right_row) + .ok_or_else(|| anyhow::anyhow!("BLAKE3 right-child link is forward"))?; + (iv, [left[0], left[1], right[0], right[1]]) + } else { + if link.repeats.is_some() { + bail!("unexpected BLAKE3 XOF row in a 32-byte transcript hash"); + } + let cv = match link.cv { + CvSource::Iv => iv, + CvSource::Row(source) => { + let output = row_outputs.get(source).ok_or_else(|| { + anyhow::anyhow!("BLAKE3 chaining-value link is forward") + })?; + [output[0], output[1]] + }, + CvSource::RowHi(source) => { + let output = row_outputs.get(source).ok_or_else(|| { + anyhow::anyhow!("BLAKE3 high-half link is forward") + })?; + [output[2], output[3]] + }, + }; + let offset = trace.block_offsets[row_index].ok_or_else(|| { + anyhow::anyhow!("BLAKE3 data row is missing its message offset") + })?; + if offset % WORD_BYTES != 0 { + bail!("BLAKE3 message block is not word aligned"); + } + let first_word = offset / WORD_BYTES; + let block = std::array::from_fn(|word| { + message.get(first_word + word).copied().unwrap_or(zero) + }); + (cv, block) + }; + let outputs = builder.gate( + slot, + &[cv[0], cv[1], block[0], block[1], block[2], block[3], parameter], + ); + row_outputs.push(outputs.try_into().expect("BLAKE3 gate has four outputs")); + } + let root_row = *trace + .squeezes + .first() + .and_then(|rows| rows.first()) + .ok_or_else(|| anyhow::anyhow!("BLAKE3 trace has no root squeeze"))?; + let root = row_outputs + .get(root_row) + .ok_or_else(|| anyhow::anyhow!("BLAKE3 root row is out of range"))?; + Ok([root[0], root[1]]) +} + +/// Prove exact Stage 2 transcript replay through the PCS opening-batch sample. +pub fn prove_stage2_transcript_conformance( + replay: &Stage2TranscriptReplayV1, +) -> Result { + let challenges = compute_challenges(replay)?; + let relation = TranscriptRelation::build(replay)?; + let inputs = relation.inputs.clone(); + let expected_public = relation.public(challenges); + let witness = relation.shape.run(&inputs, &[]); + if witness.public != expected_public { + bail!("Flock transcript circuit disagrees with native HashChallenger"); + } + + let proof_bundle_bytes = prove_relation(&relation, &witness)?; + Ok(Stage2TranscriptConformanceArtifactV1 { + replay: replay.clone(), + challenges, + circuit_digest: relation.shape.circuit.digest(), + proof_bundle_bytes, + }) +} + +/// Verify and bind a transcript conformance proof to every observation and +/// sampled challenge carried by its strict artifact. +pub fn verify_stage2_transcript_conformance( + artifact: &Stage2TranscriptConformanceArtifactV1, +) -> Result<()> { + let challenges = compute_challenges(&artifact.replay)?; + if challenges != artifact.challenges { + bail!("Flock Stage 2 transcript artifact challenge mismatch"); + } + let relation = TranscriptRelation::build(&artifact.replay)?; + if relation.shape.circuit.digest() != artifact.circuit_digest { + bail!("Flock Stage 2 transcript circuit digest mismatch"); + } + let public = relation.public(artifact.challenges); + verify_relation(&relation, &public, &artifact.proof_bundle_bytes) +} + +fn prove_relation( + relation: &TranscriptRelation, + witness: &flock_prover::circuit::builder::CircuitWitness, +) -> Result> { + let blake3_rows = witness.rows::(relation.slots.blake3); + let sample_rows = witness.rows::(relation.slots.sample); + let canonical_rows = + witness.rows::(relation.slots.canonical); + + let blake3_r1cs = flock_blake3::build_block_r1cs(relation.nu); + let blake3_lincheck = blake3_r1cs.csc_lincheck_circuit(); + let sample_r1cs = build_goldilocks_sample_r1cs(relation.nu); + let sample_lincheck = sample_r1cs.csc_lincheck_circuit(); + let canonical_r1cs = build_canonical_pair_r1cs(relation.nu); + let canonical_lincheck = canonical_r1cs.csc_lincheck_circuit(); + + let mut slots = vec![ + ( + relation.shape.registry_slot(relation.slots.blake3), + UnionSlotProverInput::new( + flock_blake3::generate_witness_batch_major_partial( + blake3_rows, + relation.nu, + ), + blake3_lincheck, + ), + ), + ( + relation.shape.registry_slot(relation.slots.sample), + UnionSlotProverInput::new( + generate_goldilocks_sample_witness(sample_rows, relation.nu), + sample_lincheck, + ), + ), + ( + relation.shape.registry_slot(relation.slots.canonical), + UnionSlotProverInput::new( + generate_canonical_pair_witness(canonical_rows, relation.nu), + canonical_lincheck, + ), + ), + ]; + sort_slots(&mut slots)?; + let slots = slots.into_iter().map(|(_, input)| input).collect(); + let union = + UnionInstance::new(&relation.shape.registry, relation.shape.counts.clone()); + let params = pcs_params(&union); + let mut challenger = FsChallenger::with_chained_blake3( + STAGE2_TRANSCRIPT_CONFORMANCE_TRANSCRIPT_DOMAIN, + ); + let (proof, commitment, _) = prover::prove_fast_ligerito_union_circuit( + &union, + &relation.shape.circuit, + &witness.public, + ¶ms, + slots, + Vec::new(), + &mut challenger, + ); + let bytes = encode_bundle(&TranscriptProofBundle { commitment, proof })?; + if bytes.len() > MAX_BUNDLE_BYTES { + bail!("Flock Stage 2 transcript proof exceeds {MAX_BUNDLE_BYTES} bytes"); + } + Ok(bytes) +} + +fn verify_relation( + relation: &TranscriptRelation, + public: &[F128], + proof_bundle_bytes: &[u8], +) -> Result<()> { + let bundle = decode_bundle(proof_bundle_bytes) + .context("decode Flock Stage 2 transcript proof bundle")?; + let blake3_r1cs = flock_blake3::build_block_r1cs(relation.nu); + let blake3_lincheck = blake3_r1cs.csc_lincheck_circuit(); + let sample_r1cs = build_goldilocks_sample_r1cs(relation.nu); + let sample_lincheck = sample_r1cs.csc_lincheck_circuit(); + let canonical_r1cs = build_canonical_pair_r1cs(relation.nu); + let canonical_lincheck = canonical_r1cs.csc_lincheck_circuit(); + let mut linchecks: Vec<(usize, &dyn LincheckCircuit)> = vec![ + (relation.shape.registry_slot(relation.slots.blake3), blake3_lincheck), + (relation.shape.registry_slot(relation.slots.sample), sample_lincheck), + ( + relation.shape.registry_slot(relation.slots.canonical), + canonical_lincheck, + ), + ]; + sort_slots(&mut linchecks)?; + let linchecks: Vec<&dyn LincheckCircuit> = + linchecks.into_iter().map(|(_, lincheck)| lincheck).collect(); + let union = + UnionInstance::new(&relation.shape.registry, relation.shape.counts.clone()); + let params = pcs_params(&union); + let mut challenger = FsChallenger::with_chained_blake3( + STAGE2_TRANSCRIPT_CONFORMANCE_TRANSCRIPT_DOMAIN, + ); + verifier::verify_ligerito_union_circuit( + &union, + &relation.shape.circuit, + public, + &linchecks, + &bundle.commitment, + &bundle.proof, + ¶ms, + &mut challenger, + ) + .map_err(|error| { + anyhow::anyhow!("Flock Stage 2 transcript proof rejected: {error:?}") + })?; + Ok(()) +} + +fn sort_slots(slots: &mut [(usize, T)]) -> Result<()> { + slots.sort_by_key(|(index, _)| *index); + if slots.iter().enumerate().any(|(expected, (actual, _))| expected != *actual) + { + bail!("Flock Stage 2 transcript registry is not contiguous"); + } + Ok(()) +} + +pub(crate) fn hash_trace(message_len: usize) -> FsChainTrace { + let mut chain = FsChain::new(); + chain.absorb(&vec![0u8; message_len]); + let output = chain.finalize(32); + debug_assert_eq!(output.len(), 32); + chain.finish() +} + +fn transcript_traces( + replay: &Stage2TranscriptReplayV1, +) -> Result> { + validate_replay(replay)?; + let message_lengths = [ + replay.initial_observations.len(), + 32 + 16, + 32 + 16 + replay.stage2_and_accumulator_observations.len(), + 32 + replay.quotient_commitment_observations.len(), + 32 + replay.pcs_opening_observations.len(), + ]; + Ok(message_lengths.iter().copied().map(hash_trace).collect()) +} + +fn compute_challenges( + replay: &Stage2TranscriptReplayV1, +) -> Result { + Ok(compute_challenges_and_state(replay)?.0) +} + +fn compute_challenges_and_state( + replay: &Stage2TranscriptReplayV1, +) -> Result<(Stage2TranscriptChallengesV1, [u8; 32])> { + validate_replay(replay)?; + + let digest_1 = hash_parts(&[&replay.initial_observations]); + let (lookup, state_1) = sample_digest_high(&digest_1)?; + let lookup_bytes = extension_bytes(lookup); + + let digest_2 = hash_parts(&[&state_1, &lookup_bytes]); + let (fingerprint, state_2) = sample_digest_high(&digest_2)?; + let fingerprint_bytes = extension_bytes(fingerprint); + + let digest_3 = hash_parts(&[ + &state_2, + &fingerprint_bytes, + &replay.stage2_and_accumulator_observations, + ]); + let (constraint, state_3) = sample_digest_high(&digest_3)?; + + let digest_4 = + hash_parts(&[&state_3, &replay.quotient_commitment_observations]); + let (zeta, state_4) = sample_digest_high(&digest_4)?; + + let digest_5 = hash_parts(&[&state_4, &replay.pcs_opening_observations]); + let (pcs_alpha, state_5) = sample_digest_high(&digest_5)?; + Ok(( + Stage2TranscriptChallengesV1 { + lookup, + fingerprint, + constraint, + zeta, + pcs_alpha, + }, + state_5, + )) +} + +fn compute_fri_challenges( + prefix: &Stage2TranscriptReplayV1, + replay: &Stage2FriTranscriptReplayV1, +) -> Result { + validate_fri_replay(replay)?; + let (_, state) = compute_challenges_and_state(prefix)?; + let mut challenger = NativeByteChallenger { + input: state.to_vec(), + // Sampling the PCS extension challenge consumed the high sixteen bytes. + output: state[..16].to_vec(), + }; + let mut betas = Vec::with_capacity(replay.commit_phase_commitments.len()); + for (round, cap) in replay.commit_phase_commitments.iter().enumerate() { + challenger.observe(&cap_observation_bytes(cap)); + if !challenger + .check_witness(replay.commit_pow_bits, replay.commit_pow_witnesses[round]) + { + bail!("Stage 2 FRI commit PoW witness {round} is invalid"); + } + betas.push(challenger.sample_extension()?); + } + challenger.observe(&final_observation_bytes(replay)); + if !challenger.check_witness(replay.query_pow_bits, replay.query_pow_witness) + { + bail!("Stage 2 FRI query PoW witness is invalid"); + } + let query_indices = (0..replay.num_queries) + .map(|_| challenger.sample_bits(replay.query_index_bits)) + .collect(); + Ok(Stage2FriTranscriptChallengesV1 { betas, query_indices }) +} + +struct NativeByteChallenger { + input: Vec, + output: Vec, +} + +impl NativeByteChallenger { + fn observe(&mut self, bytes: &[u8]) { + if bytes.is_empty() { + return; + } + self.output.clear(); + self.input.extend_from_slice(bytes); + } + + fn sample_u64(&mut self) -> u64 { + if self.output.is_empty() { + let digest = *blake3::hash(&self.input).as_bytes(); + self.input = digest.to_vec(); + self.output = digest.to_vec(); + } + let bytes: [u8; 8] = + std::array::from_fn(|_| self.output.pop().expect("fresh digest bytes")); + u64::from_le_bytes(bytes) + } + + fn sample_extension(&mut self) -> Result<[u64; 2]> { + let mut accepted = Vec::with_capacity(2); + loop { + let value = self.sample_u64(); + if value < GOLDILOCKS_MODULUS { + accepted.push(value); + if accepted.len() == 2 { + return Ok([accepted[0], accepted[1]]); + } + } + } + } + + fn sample_bits(&mut self, bits: u8) -> u64 { + debug_assert!(bits < 64); + self.sample_u64() & ((1u64 << bits) - 1) + } + + fn check_witness(&mut self, bits: u8, witness: u64) -> bool { + if bits == 0 { + return true; + } + self.observe(&witness.to_le_bytes()); + self.sample_bits(bits) == 0 + } +} + +fn cap_observation_bytes(cap: &[[u8; 32]]) -> Vec { + cap.iter().flatten().copied().collect() +} + +fn final_observation_bytes(replay: &Stage2FriTranscriptReplayV1) -> Vec { + let mut bytes = Vec::with_capacity( + 16 * replay.final_polynomial.len() + 8 * replay.log_arities.len(), + ); + for &coefficient in &replay.final_polynomial { + push_extension_observation(&mut bytes, coefficient); + } + for &log_arity in &replay.log_arities { + push_u64_observation(&mut bytes, u64::from(log_arity)); + } + bytes +} + +pub(crate) fn fri_transcript_blake3_rows( + replay: &Stage2FriTranscriptReplayV1, +) -> Result { + validate_fri_replay(replay)?; + let commit_rows = + replay.commit_phase_commitments.iter().try_fold(0usize, |rows, cap| { + let message_len = + 32 + 32 * cap.len() + 8 * usize::from(replay.commit_pow_bits != 0); + rows + .checked_add(hash_trace(message_len).rows.len()) + .and_then(|rows| rows.checked_add(hash_trace(32).rows.len())) + .ok_or_else(|| anyhow::anyhow!("FRI transcript row count overflow")) + })?; + let final_rows = hash_trace( + 32 + final_observation_bytes(replay).len() + + 8 * usize::from(replay.query_pow_bits != 0), + ) + .rows + .len(); + let draws = replay.num_queries + usize::from(replay.query_pow_bits != 0); + let followup_digests = draws.div_ceil(4).saturating_sub(1); + commit_rows + .checked_add(final_rows) + .and_then(|rows| { + rows.checked_add(followup_digests * hash_trace(32).rows.len()) + }) + .ok_or_else(|| anyhow::anyhow!("FRI transcript row count overflow")) +} + +pub(crate) fn fri_transcript_split_rows( + replay: &Stage2FriTranscriptReplayV1, +) -> Result { + validate_fri_replay(replay)?; + usize::from(replay.commit_pow_bits) + .checked_mul(replay.commit_phase_commitments.len()) + .and_then(|rows| rows.checked_add(usize::from(replay.query_pow_bits))) + .and_then(|rows| { + rows + .checked_add(replay.num_queries * usize::from(replay.query_index_bits)) + }) + .ok_or_else(|| anyhow::anyhow!("FRI transcript split row count overflow")) +} + +fn sample_digest_high(digest: &[u8; 32]) -> Result<([u64; 2], [u8; 32])> { + let refill = hash_parts(&[digest]); + let candidates = digest_candidates([ + pack_bytes(&digest[16..]), + pack_bytes(&digest[..16]), + pack_bytes(&refill[16..]), + pack_bytes(&refill[..16]), + ]); + let (sample, failure, used_refill) = select_two_candidates(&candidates, 4); + if failure { + bail!("Stage 2 transcript needs more than eight Goldilocks candidates"); + } + Ok(([sample.lo, sample.hi], if used_refill { refill } else { *digest })) +} + +fn hash_parts(parts: &[&[u8]]) -> [u8; 32] { + let mut hasher = blake3::Hasher::new(); + for part in parts { + hasher.update(part); + } + *hasher.finalize().as_bytes() +} + +fn extension_bytes(value: [u64; 2]) -> [u8; 16] { + let mut bytes = [0u8; 16]; + bytes[..8].copy_from_slice(&value[0].to_le_bytes()); + bytes[8..].copy_from_slice(&value[1].to_le_bytes()); + bytes +} + +fn push_u64_observation(bytes: &mut Vec, value: u64) { + bytes.extend_from_slice(&value.to_le_bytes()); +} + +fn push_extension_observation(bytes: &mut Vec, value: [u64; 2]) { + bytes.extend_from_slice(&extension_bytes(value)); +} + +fn push_cap_observations(bytes: &mut Vec, roots: &[[u8; 32]]) { + for root in roots { + bytes.extend_from_slice(root); + } +} + +fn push_opened_round_observations( + bytes: &mut Vec, + round: &Stage3OpenedRoundV1, +) { + for matrix in round { + for point in matrix { + for &value in point { + push_extension_observation(bytes, value); + } + } + } +} + +fn replay_segments(replay: &Stage2TranscriptReplayV1) -> [&[u8]; 4] { + [ + &replay.initial_observations, + &replay.stage2_and_accumulator_observations, + &replay.quotient_commitment_observations, + &replay.pcs_opening_observations, + ] +} + +fn pack_segment(segment: &[u8]) -> Vec { + segment + .chunks(WORD_BYTES) + .map(|chunk| { + let mut word = [0u8; WORD_BYTES]; + word[..chunk.len()].copy_from_slice(chunk); + pack_bytes(&word) + }) + .collect() +} + +pub(crate) fn transcript_challenge_words( + challenges: Stage2TranscriptChallengesV1, +) -> [F128; 5] { + [ + pack_extension(challenges.lookup), + pack_extension(challenges.fingerprint), + pack_extension(challenges.constraint), + pack_extension(challenges.zeta), + pack_extension(challenges.pcs_alpha), + ] +} + +fn challenge_words(challenges: Stage2TranscriptChallengesV1) -> [F128; 5] { + transcript_challenge_words(challenges) +} + +fn pack_extension(value: [u64; 2]) -> F128 { + F128::new(value[0], value[1]) +} + +fn validate_replay(replay: &Stage2TranscriptReplayV1) -> Result<()> { + if replay.initial_observations.is_empty() { + bail!("Stage 2 transcript initial observations are empty"); + } + let total = + replay_segments(replay).iter().try_fold(0usize, |total, segment| { + total.checked_add(segment.len()).ok_or_else(|| { + anyhow::anyhow!("Stage 2 transcript observation length overflow") + }) + })?; + if total > MAX_OBSERVATION_BYTES { + bail!( + "Stage 2 transcript carries {total} observation bytes; maximum is {MAX_OBSERVATION_BYTES}" + ); + } + Ok(()) +} + +fn validate_fri_replay(replay: &Stage2FriTranscriptReplayV1) -> Result<()> { + let rounds = replay.commit_phase_commitments.len(); + if rounds == 0 || rounds > MAX_FRI_ROUNDS { + bail!( + "Stage 2 FRI transcript has {rounds} rounds; expected 1..={MAX_FRI_ROUNDS}" + ); + } + if replay.commit_pow_witnesses.len() != rounds + || replay.log_arities.len() != rounds + { + bail!("Stage 2 FRI transcript round-vector lengths disagree"); + } + if replay.num_queries == 0 || replay.num_queries > MAX_FRI_QUERIES { + bail!( + "Stage 2 FRI transcript has {} queries; expected 1..={MAX_FRI_QUERIES}", + replay.num_queries + ); + } + if replay.query_index_bits == 0 || replay.query_index_bits >= 64 { + bail!( + "Stage 2 FRI query-index width {} is outside 1..64", + replay.query_index_bits + ); + } + for (label, bits) in + [("commit", replay.commit_pow_bits), ("query", replay.query_pow_bits)] + { + if bits >= 64 || (1u64 << bits) >= GOLDILOCKS_MODULUS { + bail!("Stage 2 FRI {label} PoW width {bits} is invalid"); + } + } + if (1u64 << replay.query_index_bits) >= GOLDILOCKS_MODULUS { + bail!("Stage 2 FRI query-index mask exceeds the field order"); + } + if replay.final_polynomial.is_empty() + || !replay.final_polynomial.len().is_power_of_two() + { + bail!( + "Stage 2 FRI final polynomial length must be a non-zero power of two" + ); + } + for coefficient in &replay.final_polynomial { + if coefficient.iter().any(|&limb| limb >= GOLDILOCKS_MODULUS) { + bail!("Stage 2 FRI final polynomial contains a non-canonical limb"); + } + } + if replay + .commit_pow_witnesses + .iter() + .chain(std::iter::once(&replay.query_pow_witness)) + .any(|&witness| witness >= GOLDILOCKS_MODULUS) + { + bail!("Stage 2 FRI transcript contains a non-canonical PoW witness"); + } + let mut cap_roots = 0usize; + for cap in &replay.commit_phase_commitments { + if cap.is_empty() || cap.len() > MAX_CAP_ROOTS { + bail!( + "Stage 2 FRI commitment cap has {} roots; expected 1..={MAX_CAP_ROOTS}", + cap.len() + ); + } + cap_roots = cap_roots + .checked_add(cap.len()) + .ok_or_else(|| anyhow::anyhow!("Stage 2 FRI cap-root count overflow"))?; + } + if replay.log_arities.iter().any(|&arity| arity == 0 || arity >= 64) { + bail!("Stage 2 FRI transcript contains an invalid folding log-arity"); + } + let observation_bytes = 32usize + .checked_mul(cap_roots) + .and_then(|bytes| bytes.checked_add(final_observation_bytes(replay).len())) + .and_then(|bytes| { + bytes.checked_add(8 * usize::from(replay.query_pow_bits != 0)) + }) + .ok_or_else(|| { + anyhow::anyhow!("Stage 2 FRI observation length overflow") + })?; + if observation_bytes > MAX_OBSERVATION_BYTES { + bail!( + "Stage 2 FRI transcript carries {observation_bytes} observation bytes; maximum is {MAX_OBSERVATION_BYTES}" + ); + } + Ok(()) +} + +fn validate_challenges(challenges: Stage2TranscriptChallengesV1) -> Result<()> { + for challenge in [ + challenges.lookup, + challenges.fingerprint, + challenges.constraint, + challenges.zeta, + challenges.pcs_alpha, + ] { + if challenge.iter().any(|&value| value >= GOLDILOCKS_MODULUS) { + bail!("non-canonical Goldilocks transcript challenge"); + } + } + Ok(()) +} + +fn encode_challenges( + bytes: &mut Vec, + challenges: Stage2TranscriptChallengesV1, +) { + for challenge in [ + challenges.lookup, + challenges.fingerprint, + challenges.constraint, + challenges.zeta, + challenges.pcs_alpha, + ] { + bytes.extend_from_slice(&extension_bytes(challenge)); + } +} + +fn decode_challenges(bytes: &[u8]) -> Stage2TranscriptChallengesV1 { + debug_assert_eq!(bytes.len(), CHALLENGE_BYTES); + let mut values = [[0u64; 2]; CHALLENGE_COUNT]; + for (value, chunk) in values.iter_mut().zip(bytes.as_chunks::<16>().0) { + value[0] = u64::from_le_bytes(chunk[..8].try_into().unwrap()); + value[1] = u64::from_le_bytes(chunk[8..].try_into().unwrap()); + } + Stage2TranscriptChallengesV1 { + lookup: values[0], + fingerprint: values[1], + constraint: values[2], + zeta: values[3], + pcs_alpha: values[4], + } +} + +fn encode_bundle(bundle: &TranscriptProofBundle) -> Result> { + bincode::DefaultOptions::new() + .with_fixint_encoding() + .serialize(bundle) + .context("encode Flock Stage 2 transcript proof bundle") +} + +fn decode_bundle(bytes: &[u8]) -> Result { + bincode::DefaultOptions::new() + .with_fixint_encoding() + .with_limit(MAX_BUNDLE_BYTES as u64) + .reject_trailing_bytes() + .deserialize(bytes) + .context("invalid Flock Stage 2 transcript proof bundle") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn replay_fixture() -> Stage2TranscriptReplayV1 { + let mut initial = b"multi-stark/v0".to_vec(); + for value in 0..19u64 { + initial.extend_from_slice(&(value * 17 + 3).to_le_bytes()); + } + // Exercise a final partial BLAKE3 word in the first flush. + initial.extend_from_slice(&[0xa5, 0x5a, 0x11]); + + let stage2_and_accumulator_observations = + (0..79u8).map(|value| value.wrapping_mul(29)).collect(); + let quotient_commitment_observations = + (0..32u8).map(|value| value ^ 0x6d).collect(); + let pcs_opening_observations = + (0..117u8).map(|value| value.wrapping_mul(7).wrapping_add(1)).collect(); + Stage2TranscriptReplayV1 { + initial_observations: initial, + stage2_and_accumulator_observations, + quotient_commitment_observations, + pcs_opening_observations, + } + } + + #[derive(Clone)] + struct ReferenceHashChallenger { + input: Vec, + output: Vec, + } + + impl ReferenceHashChallenger { + fn new(initial: Vec) -> Self { + Self { input: initial, output: Vec::new() } + } + + fn observe(&mut self, bytes: &[u8]) { + self.output.clear(); + self.input.extend_from_slice(bytes); + } + + fn sample8(&mut self) -> [u8; 8] { + if self.output.is_empty() { + let digest = *blake3::hash(&self.input).as_bytes(); + self.input = digest.to_vec(); + self.output = digest.to_vec(); + } + std::array::from_fn(|_| self.output.pop().unwrap()) + } + + fn sample_field(&mut self) -> u64 { + loop { + let value = u64::from_le_bytes(self.sample8()); + if value < GOLDILOCKS_MODULUS { + return value; + } + } + } + + fn sample_ext(&mut self) -> [u64; 2] { + [self.sample_field(), self.sample_field()] + } + } + + #[test] + fn optimized_replay_matches_hash_challenger_buffers() { + let replay = replay_fixture(); + let expected = replay.challenges().unwrap(); + let mut challenger = + ReferenceHashChallenger::new(replay.initial_observations.clone()); + let lookup = challenger.sample_ext(); + challenger.observe(&extension_bytes(lookup)); + let fingerprint = challenger.sample_ext(); + challenger.observe(&extension_bytes(fingerprint)); + challenger.observe(&replay.stage2_and_accumulator_observations); + let constraint = challenger.sample_ext(); + challenger.observe(&replay.quotient_commitment_observations); + let zeta = challenger.sample_ext(); + challenger.observe(&replay.pcs_opening_observations); + let pcs_alpha = challenger.sample_ext(); + assert_eq!( + expected, + Stage2TranscriptChallengesV1 { + lookup, + fingerprint, + constraint, + zeta, + pcs_alpha, + } + ); + } + + #[test] + fn sample_gate_is_the_reverse_pop_order_permutation() { + let input = F128::new(0x0706_0504_0302_0100, 0x0f0e_0d0c_0b0a_0908); + assert_eq!( + sample_word(input), + F128::new(0x0809_0a0b_0c0d_0e0f, 0x0001_0203_0405_0607) + ); + let rows = [HashSampleRow(input)]; + let (z, _, _, _) = generate_hash_sample_witness(&rows, MIN_NU); + assert!(!z.is_empty()); + } + + #[test] + fn goldilocks_sample_gate_redraws_and_supports_pow_skip() { + let cases = [ + ( + [GOLDILOCKS_MODULUS, 5, 6, 7, 8, 9, 10, 11], + F128::new(5, 6), + F128::new(5, 6), + false, + false, + false, + false, + ), + ( + [1, GOLDILOCKS_MODULUS, 2, 3, 4, 5, 6, 7], + F128::new(1, 2), + F128::new(2, 3), + false, + false, + false, + false, + ), + ( + [GOLDILOCKS_MODULUS, GOLDILOCKS_MODULUS, 2, 3, 4, 5, 6, 7], + F128::new(2, 3), + F128::new(2, 3), + false, + false, + false, + false, + ), + ( + [1, 2, GOLDILOCKS_MODULUS, GOLDILOCKS_MODULUS, 4, 5, 6, 7], + F128::new(1, 2), + F128::new(2, 4), + false, + false, + false, + true, + ), + ( + [ + GOLDILOCKS_MODULUS, + GOLDILOCKS_MODULUS, + GOLDILOCKS_MODULUS, + 3, + 4, + 5, + 6, + 7, + ], + F128::new(3, 4), + F128::new(3, 4), + false, + false, + true, + true, + ), + ]; + for ( + candidates, + expected, + expected_skip, + failure, + skip_failure, + used_refill, + skip_used_refill, + ) in cases + { + let words = [ + F128::new(candidates[1].swap_bytes(), candidates[0].swap_bytes()), + F128::new(candidates[3].swap_bytes(), candidates[2].swap_bytes()), + F128::new(candidates[5].swap_bytes(), candidates[4].swap_bytes()), + F128::new(candidates[7].swap_bytes(), candidates[6].swap_bytes()), + ]; + let mut outputs = Vec::new(); + GoldilocksSampleGate { nu: MIN_NU }.eval(&words, &(), &mut outputs); + assert_eq!(outputs[0], expected); + assert_eq!(outputs[1], F128::new(u64::from(failure), 0)); + assert_eq!(outputs[2], F128::new(candidates[0], 0)); + assert_eq!(outputs[3], expected_skip); + assert_eq!(outputs[4], F128::new(u64::from(skip_failure), 0)); + let state = + if used_refill { [words[3], words[2]] } else { [words[1], words[0]] }; + let skip_state = if skip_used_refill { + [words[3], words[2]] + } else { + [words[1], words[0]] + }; + assert_eq!(outputs[5..7], state); + assert_eq!(outputs[7..9], skip_state); + let rows = [GoldilocksSampleRow(words)]; + let (z, _, _, _) = generate_goldilocks_sample_witness(&rows, MIN_NU); + assert!(!z.is_empty()); + } + } + + #[test] + fn u64_split_gate_exposes_low_bits_and_rejects_a_high_lane() { + let plan = u64_split_plan(); + let r1cs = plan.block_r1cs(MIN_NU); + let value = 0x8bad_f00d_dead_beefu64; + let mut row = vec![false; plan.k()]; + plan.fill_row(&mut row, |bits| { + write_f128(bits, U64_SPLIT_INPUT_BASE, F128::new(value, 0)); + }); + let mut witness = vec![false; r1cs.n()]; + witness[..plan.k()].copy_from_slice(&row); + assert!(r1cs.satisfies(&witness)); + assert_eq!(row[U64_SPLIT_BIT_BASE], value & 1 == 1); + for bit in 0..63 { + assert_eq!( + row[U64_SPLIT_QUOTIENT_BASE + bit], + (value >> (bit + 1)) & 1 == 1 + ); + } + + let mut wrong_quotient = witness; + wrong_quotient[U64_SPLIT_QUOTIENT_BASE + 17] ^= true; + assert!(!r1cs.satisfies(&wrong_quotient)); + + let mut high_lane_row = vec![false; plan.k()]; + plan.fill_row(&mut high_lane_row, |bits| { + write_f128(bits, U64_SPLIT_INPUT_BASE, F128::new(value, 1)); + }); + let mut high_lane = vec![false; r1cs.n()]; + high_lane[..plan.k()].copy_from_slice(&high_lane_row); + assert!(!r1cs.satisfies(&high_lane)); + } + + #[test] + fn circuit_matches_native_replay_and_uses_tree_hashing() { + let replay = replay_fixture(); + let challenges = replay.challenges().unwrap(); + let relation = TranscriptRelation::build(&replay).unwrap(); + let witness = relation.shape.run(&relation.inputs, &[]); + assert_eq!(witness.public, relation.public(challenges)); + + let mut long = replay; + long.initial_observations.resize(1_103, 0x42); + let long_relation = TranscriptRelation::build(&long).unwrap(); + let long_challenges = long.challenges().unwrap(); + let long_witness = long_relation.shape.run(&long_relation.inputs, &[]); + assert_eq!(long_witness.public, long_relation.public(long_challenges)); + assert!( + long_witness.rows::(long_relation.slots.blake3).len() + > witness.rows::(relation.slots.blake3).len() + ); + } + + #[test] + fn post_fri_circuit_matches_native_pow_betas_and_refilled_queries() { + let prefix = replay_fixture(); + let mut replay = Stage2FriTranscriptReplayV1 { + commit_phase_commitments: vec![ + vec![*blake3::hash(b"fri-cap-0").as_bytes()], + vec![*blake3::hash(b"fri-cap-1").as_bytes()], + ], + commit_pow_witnesses: vec![0, 0], + final_polynomial: vec![[17, 29]], + log_arities: vec![1, 1], + query_pow_witness: 0, + commit_pow_bits: 2, + query_pow_bits: 3, + num_queries: 5, + query_index_bits: 5, + }; + let challenges = (0..4_096u64) + .find_map(|nonce| { + replay.commit_pow_witnesses = vec![nonce & 15, (nonce >> 4) & 15]; + replay.query_pow_witness = (nonce >> 8) & 15; + replay.challenges(&prefix).ok() + }) + .expect("small commit/query PoW fixture has witnesses"); + + let nu = 11; + let mut builder = ShapeBuilder::new(nu); + let blake3 = builder.slot(Blake3Gate { nu }); + let sample = builder.slot(HashSampleGate { nu }); + let field_sample = builder.slot(GoldilocksSampleGate { nu }); + let canonical = builder.slot(CanonicalGoldilocksPairGate { nu }); + let repack = + builder.slot(crate::extension::GoldilocksLaneRepackGate { nu }); + let split = builder.slot(U64SplitGate { nu }); + let prefix_region = constrain_stage2_transcript( + &mut builder, + TranscriptCircuitSlots { blake3, sample: field_sample, canonical }, + &prefix, + nu, + ) + .unwrap(); + let mut inputs = prefix_region.inputs.clone(); + let mut public = inputs.clone(); + for challenge in prefix_region.challenges.all() { + builder.publish(challenge); + } + public.extend(challenge_words(prefix.challenges().unwrap())); + + let fri_region = constrain_stage2_fri_transcript( + &mut builder, + FriTranscriptCircuitSlots { + blake3, + sample, + field_sample, + canonical, + repack, + split, + }, + &replay, + prefix_region.state_digest, + nu, + ) + .unwrap(); + inputs.extend_from_slice(&fri_region.inputs); + public.extend_from_slice(&fri_region.inputs); + for &beta in &fri_region.betas { + builder.publish(beta); + } + public.extend(challenges.betas.iter().copied().map(pack_extension)); + for bits in &fri_region.query_index_bits { + for &bit in bits { + builder.publish(bit); + } + } + for &index in &challenges.query_indices { + public.extend( + (0..replay.query_index_bits) + .map(|bit| F128::new((index >> bit) & 1, 0)), + ); + } + let shape = builder.finish().unwrap(); + let witness = shape.run(&inputs, &[]); + assert_eq!(witness.public, public); + } + + #[test] + fn artifact_parser_is_strict_before_crypto() { + let replay = replay_fixture(); + let artifact = Stage2TranscriptConformanceArtifactV1 { + challenges: replay.challenges().unwrap(), + replay, + circuit_digest: [7; 32], + proof_bundle_bytes: vec![1, 2, 3], + }; + let bytes = artifact.to_bytes(); + assert_eq!( + Stage2TranscriptConformanceArtifactV1::from_bytes(&bytes).unwrap(), + artifact + ); + + let mut trailing = bytes.clone(); + trailing.push(0); + assert!( + Stage2TranscriptConformanceArtifactV1::from_bytes(&trailing).is_err() + ); + let mut wrong_config = bytes.clone(); + wrong_config[CONFIG_OFFSET] ^= 1; + assert!( + Stage2TranscriptConformanceArtifactV1::from_bytes(&wrong_config).is_err() + ); + let mut wrong_length = bytes; + wrong_length[LENGTHS_OFFSET] ^= 1; + assert!( + Stage2TranscriptConformanceArtifactV1::from_bytes(&wrong_length).is_err() + ); + } + + #[test] + #[ignore = "large upstream Flock proof; run explicitly for transcript conformance"] + fn real_transcript_round_trip_and_mutations() { + let artifact = prove_stage2_transcript_conformance(&replay_fixture()) + .expect("prove Stage 2 transcript replay"); + eprintln!( + "Flock Stage 2 transcript conformance bundle: {} bytes", + artifact.proof_bundle_bytes().len() + ); + verify_stage2_transcript_conformance(&artifact) + .expect("verify Stage 2 transcript replay"); + + let encoded = artifact.to_bytes(); + let decoded = + Stage2TranscriptConformanceArtifactV1::from_bytes(&encoded).unwrap(); + verify_stage2_transcript_conformance(&decoded) + .expect("verify decoded Stage 2 transcript replay"); + + let mut wrong_observation = decoded.clone(); + wrong_observation.replay.pcs_opening_observations[0] ^= 1; + assert!(verify_stage2_transcript_conformance(&wrong_observation).is_err()); + + let mut wrong_challenge = decoded.clone(); + wrong_challenge.challenges.pcs_alpha[0] ^= 1; + assert!(verify_stage2_transcript_conformance(&wrong_challenge).is_err()); + + let mut wrong_proof = decoded; + let flip_at = wrong_proof.proof_bundle_bytes.len() / 2; + wrong_proof.proof_bundle_bytes[flip_at] ^= 1; + assert!(verify_stage2_transcript_conformance(&wrong_proof).is_err()); + } +} diff --git a/flock-stage3/host/src/typed_witness.rs b/flock-stage3/host/src/typed_witness.rs new file mode 100644 index 00000000..fd734aec --- /dev/null +++ b/flock-stage3/host/src/typed_witness.rs @@ -0,0 +1,451 @@ +//! Owned, serialization-independent witness consumed by the Stage 3 lowering. +//! +//! The source advice uses bincode only as an off-circuit transport. This +//! module converts it once into primitive semantic values so the Flock +//! relation never depends on Rust layout or byte-parser execution. + +use anyhow::{Result, bail}; +use ix_terminal::{ + Stage2AdviceProfileV1, ValidatedStage2RootV1, decode_stage2_advice, + fri_parameter_words, +}; +use multi_stark::{ + advice::AdviceProof, + p3_field::{BasedVectorSpace, PrimeField64}, + types::{ExtVal, FriParameters, Val}, +}; + +pub const STAGE3_TYPED_WITNESS_LAYOUT_DOMAIN: &[u8; 8] = b"IXTYPW01"; +const STAGE3_TYPED_WITNESS_LAYOUT_VERSION: u16 = 1; + +pub type Stage3DigestV1 = [u8; 32]; +pub type Stage3ExtensionValueV1 = [u64; 2]; +pub type Stage3OpenedRoundV1 = Vec>>; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Stage3TypedCommitmentsV1 { + pub stage_1_trace: Vec, + pub stage_2_trace: Vec, + pub quotient_chunks: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Stage3TypedBatchOpeningV1 { + pub opened_values: Vec>, + pub opening_proof: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Stage3TypedCommitPhaseStepV1 { + pub log_arity: u8, + pub sibling_values: Vec, + pub opening_proof: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Stage3TypedQueryProofV1 { + pub input_proof: Vec, + pub commit_phase_openings: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Stage3TypedFriProofV1 { + pub commit_phase_commits: Vec>, + pub commit_pow_witnesses: Vec, + pub query_proofs: Vec, + pub final_poly: Vec, + pub query_pow_witness: u64, +} + +/// Primitive, typed mirror of `multi_stark::advice::AdviceProof`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Stage3TypedProofWitnessV1 { + pub active: Vec, + pub commitments: Stage3TypedCommitmentsV1, + pub intermediate_accumulators: Vec, + pub log_degrees: Vec, + pub opening_proof: Stage3TypedFriProofV1, + pub quotient_opened_values: Stage3OpenedRoundV1, + pub preprocessed_opened_values: Option, + pub stage_1_opened_values: Stage3OpenedRoundV1, + pub stage_2_opened_values: Stage3OpenedRoundV1, +} + +/// Counts that must agree with the independently recorded advice profile. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Stage3TypedProofCountsV1 { + pub total_circuits: u64, + pub active_circuits: u64, + pub queries: u64, + pub fri_rounds: u64, + pub input_rounds_per_query: u64, + pub commitment_cap_digests: u64, + pub input_merkle_siblings: u64, + pub fri_merkle_siblings: u64, + pub opened_base_values: u64, + pub fri_sibling_extension_values: u64, + pub other_extension_values: u64, +} + +impl Stage3TypedProofWitnessV1 { + /// Decode the strict advice transport and immediately erase its serializer + /// representation in favor of semantic primitive values. + pub fn from_advice_bytes(bytes: &[u8], fri: &FriParameters) -> Result { + Ok(Self::from_advice(decode_stage2_advice(bytes, fri)?)) + } + + /// Prepare the typed proof attached to an already validated Stage 2 root. + pub fn from_prepared( + prepared: &ValidatedStage2RootV1, + fri: &FriParameters, + ) -> Result { + if prepared.statement().fri_parameter_words() != &fri_parameter_words(fri) { + bail!("typed Stage 3 witness uses different FRI parameters"); + } + let witness = Self::from_advice_bytes(prepared.advice_bytes(), fri)?; + witness.ensure_profile(prepared.advice_profile())?; + Ok(witness) + } + + /// Digest of the exact nested vector/option layout, excluding witness + /// values. It is a capacity/compiler input, not a proof-content commitment. + pub fn layout_digest(&self) -> [u8; 32] { + let mut bytes = Vec::new(); + bytes.extend_from_slice(STAGE3_TYPED_WITNESS_LAYOUT_DOMAIN); + bytes.extend_from_slice(&STAGE3_TYPED_WITNESS_LAYOUT_VERSION.to_le_bytes()); + for word in self.layout_words() { + bytes.extend_from_slice(&word.to_le_bytes()); + } + *blake3::hash(&bytes).as_bytes() + } + + pub fn counts(&self) -> Stage3TypedProofCountsV1 { + let commitment_cap_digests = self.commitments.stage_1_trace.len() + + self.commitments.stage_2_trace.len() + + self.commitments.quotient_chunks.len() + + self + .opening_proof + .commit_phase_commits + .iter() + .map(Vec::len) + .sum::(); + let input_merkle_siblings = self + .opening_proof + .query_proofs + .iter() + .flat_map(|query| &query.input_proof) + .map(|opening| opening.opening_proof.len()) + .sum(); + let fri_merkle_siblings = self + .opening_proof + .query_proofs + .iter() + .flat_map(|query| &query.commit_phase_openings) + .map(|opening| opening.opening_proof.len()) + .sum(); + let opened_base_values = self + .opening_proof + .query_proofs + .iter() + .flat_map(|query| &query.input_proof) + .flat_map(|opening| &opening.opened_values) + .map(Vec::len) + .sum(); + let fri_sibling_extension_values = self + .opening_proof + .query_proofs + .iter() + .flat_map(|query| &query.commit_phase_openings) + .map(|opening| opening.sibling_values.len()) + .sum(); + let other_extension_values = self.intermediate_accumulators.len() + + count_opened_values(&self.quotient_opened_values) + + self.preprocessed_opened_values.as_ref().map_or(0, count_opened_values) + + count_opened_values(&self.stage_1_opened_values) + + count_opened_values(&self.stage_2_opened_values) + + self.opening_proof.final_poly.len(); + let input_rounds_per_query = self + .opening_proof + .query_proofs + .first() + .map_or(0, |query| query.input_proof.len()); + + Stage3TypedProofCountsV1 { + total_circuits: as_u64(self.active.len()), + active_circuits: as_u64( + self.active.iter().filter(|&&active| active).count(), + ), + queries: as_u64(self.opening_proof.query_proofs.len()), + fri_rounds: as_u64(self.opening_proof.commit_phase_commits.len()), + input_rounds_per_query: as_u64(input_rounds_per_query), + commitment_cap_digests: as_u64(commitment_cap_digests), + input_merkle_siblings: as_u64(input_merkle_siblings), + fri_merkle_siblings: as_u64(fri_merkle_siblings), + opened_base_values: as_u64(opened_base_values), + fri_sibling_extension_values: as_u64(fri_sibling_extension_values), + other_extension_values: as_u64(other_extension_values), + } + } + + pub fn ensure_profile(&self, profile: &Stage2AdviceProfileV1) -> Result<()> { + let expected = Stage3TypedProofCountsV1 { + total_circuits: profile.total_circuits, + active_circuits: profile.active_circuits, + queries: profile.queries, + fri_rounds: profile.fri_rounds, + input_rounds_per_query: profile.input_rounds_per_query, + commitment_cap_digests: profile.commitment_cap_digests, + input_merkle_siblings: profile.input_merkle_siblings, + fri_merkle_siblings: profile.fri_merkle_siblings, + opened_base_values: profile.opened_base_values, + fri_sibling_extension_values: profile.fri_sibling_extension_values, + other_extension_values: profile.other_extension_values, + }; + let observed = self.counts(); + if observed != expected { + bail!( + "typed Stage 3 witness counts differ from advice profile: expected {expected:?}, observed {observed:?}" + ); + } + Ok(()) + } + + /// Structural verifier step 2: the chained lookup accumulator must end at + /// zero. The full relation will wire this value to the accumulator updates. + pub fn last_accumulator_is_zero(&self) -> bool { + self.intermediate_accumulators.last().is_some_and(|value| *value == [0, 0]) + } + + fn from_advice(proof: AdviceProof) -> Self { + Self { + active: proof.active, + commitments: Stage3TypedCommitmentsV1 { + stage_1_trace: proof.commitments.stage_1_trace.roots().to_vec(), + stage_2_trace: proof.commitments.stage_2_trace.roots().to_vec(), + quotient_chunks: proof.commitments.quotient_chunks.roots().to_vec(), + }, + intermediate_accumulators: proof + .intermediate_accumulators + .into_iter() + .map(extension_words) + .collect(), + log_degrees: proof.log_degrees, + opening_proof: Stage3TypedFriProofV1 { + commit_phase_commits: proof + .opening_proof + .commit_phase_commits + .iter() + .map(|commitment| commitment.roots().to_vec()) + .collect(), + commit_pow_witnesses: proof + .opening_proof + .commit_pow_witnesses + .into_iter() + .map(base_word) + .collect(), + query_proofs: proof + .opening_proof + .query_proofs + .into_iter() + .map(|query| Stage3TypedQueryProofV1 { + input_proof: query + .input_proof + .into_iter() + .map(|opening| Stage3TypedBatchOpeningV1 { + opened_values: opening + .opened_values + .into_iter() + .map(|row| row.into_iter().map(base_word).collect()) + .collect(), + opening_proof: opening.opening_proof, + }) + .collect(), + commit_phase_openings: query + .commit_phase_openings + .into_iter() + .map(|step| Stage3TypedCommitPhaseStepV1 { + log_arity: step.log_arity, + sibling_values: step + .sibling_values + .into_iter() + .map(extension_words) + .collect(), + opening_proof: step.opening_proof, + }) + .collect(), + }) + .collect(), + final_poly: proof + .opening_proof + .final_poly + .into_iter() + .map(extension_words) + .collect(), + query_pow_witness: base_word(proof.opening_proof.query_pow_witness), + }, + quotient_opened_values: opened_round(proof.quotient_opened_values), + preprocessed_opened_values: proof + .preprocessed_opened_values + .map(opened_round), + stage_1_opened_values: opened_round(proof.stage_1_opened_values), + stage_2_opened_values: opened_round(proof.stage_2_opened_values), + } + } + + fn layout_words(&self) -> Vec { + let mut words = Vec::new(); + push_len(&mut words, &self.active); + push_len(&mut words, &self.commitments.stage_1_trace); + push_len(&mut words, &self.commitments.stage_2_trace); + push_len(&mut words, &self.commitments.quotient_chunks); + push_len(&mut words, &self.intermediate_accumulators); + push_len(&mut words, &self.log_degrees); + push_len(&mut words, &self.opening_proof.commit_phase_commits); + for cap in &self.opening_proof.commit_phase_commits { + push_len(&mut words, cap); + } + push_len(&mut words, &self.opening_proof.commit_pow_witnesses); + push_len(&mut words, &self.opening_proof.query_proofs); + for query in &self.opening_proof.query_proofs { + push_len(&mut words, &query.input_proof); + for opening in &query.input_proof { + push_len(&mut words, &opening.opened_values); + for row in &opening.opened_values { + push_len(&mut words, row); + } + push_len(&mut words, &opening.opening_proof); + } + push_len(&mut words, &query.commit_phase_openings); + for step in &query.commit_phase_openings { + words.push(u64::from(step.log_arity)); + push_len(&mut words, &step.sibling_values); + push_len(&mut words, &step.opening_proof); + } + } + push_len(&mut words, &self.opening_proof.final_poly); + push_opened_round(&mut words, &self.quotient_opened_values); + words.push(u64::from(self.preprocessed_opened_values.is_some())); + if let Some(round) = &self.preprocessed_opened_values { + push_opened_round(&mut words, round); + } + push_opened_round(&mut words, &self.stage_1_opened_values); + push_opened_round(&mut words, &self.stage_2_opened_values); + words + } +} + +fn base_word(value: Val) -> u64 { + value.as_canonical_u64() +} + +fn extension_words(value: ExtVal) -> Stage3ExtensionValueV1 { + let coefficients = value.as_basis_coefficients_slice(); + [base_word(coefficients[0]), base_word(coefficients[1])] +} + +fn opened_round(values: Vec>>) -> Stage3OpenedRoundV1 { + values + .into_iter() + .map(|matrix| { + matrix + .into_iter() + .map(|point| point.into_iter().map(extension_words).collect()) + .collect() + }) + .collect() +} + +fn count_opened_values(values: &Stage3OpenedRoundV1) -> usize { + values.iter().flat_map(|matrix| matrix.iter()).map(Vec::len).sum() +} + +fn push_len(words: &mut Vec, values: &[T]) { + words.push(as_u64(values.len())); +} + +fn push_opened_round(words: &mut Vec, values: &Stage3OpenedRoundV1) { + push_len(words, values); + for matrix in values { + push_len(words, matrix); + for point in matrix { + push_len(words, point); + } + } +} + +fn as_u64(value: usize) -> u64 { + u64::try_from(value).expect("Stage 3 witness length fits u64") +} + +#[cfg(test)] +mod tests { + use multi_stark::{ + advice::proof_to_advice_bytes, + p3_field::PrimeCharacteristicRing, + p3_matrix::dense::RowMajorMatrix, + system::{CircuitInputs, System, SystemWitness}, + types::{CommitmentParameters, GoldilocksBlake3Config}, + }; + + use super::*; + + fn typed_fixture() -> (Stage3TypedProofWitnessV1, Stage2AdviceProfileV1) { + let commitment = CommitmentParameters { log_blowup: 1, cap_height: 0 }; + let fri = FriParameters { + log_final_poly_len: 0, + max_log_arity: 1, + num_queries: 2, + commit_proof_of_work_bits: 0, + query_proof_of_work_bits: 0, + }; + let (system, key) = System::new( + GoldilocksBlake3Config::new(commitment, fri), + [ + CircuitInputs { main_width: 2, ..Default::default() }, + CircuitInputs { main_width: 3, ..Default::default() }, + ], + ); + let trace_1 = + RowMajorMatrix::new((0..16u32).map(Val::from_u32).collect::>(), 2); + let trace_2 = RowMajorMatrix::new( + (0..12u32).map(|value| Val::from_u32(7 * value + 3)).collect(), + 3, + ); + let proof = system.prove_multiple_claims( + &key, + &[], + SystemWitness::from_stage_1(vec![trace_1, trace_2], &system), + ); + let advice = + proof_to_advice_bytes(&system, commitment, fri, &[], &proof).unwrap(); + let profile = + Stage2AdviceProfileV1::from_advice_bytes(&advice, &fri).unwrap(); + let typed = + Stage3TypedProofWitnessV1::from_advice_bytes(&advice, &fri).unwrap(); + (typed, profile) + } + + #[test] + fn typed_layout_preserves_every_profile_count() { + let (typed, profile) = typed_fixture(); + typed.ensure_profile(&profile).unwrap(); + assert!(typed.last_accumulator_is_zero()); + assert_ne!(typed.layout_digest(), [0; 32]); + } + + #[test] + fn layout_digest_changes_with_nested_shape_not_values() { + let (typed, _) = typed_fixture(); + let digest = typed.layout_digest(); + + let mut value_change = typed.clone(); + value_change.opening_proof.query_pow_witness ^= 1; + assert_eq!(value_change.layout_digest(), digest); + + let mut shape_change = typed; + shape_change.opening_proof.query_proofs[0].input_proof[0] + .opening_proof + .pop(); + assert_ne!(shape_change.layout_digest(), digest); + } +} diff --git a/flock-stage3/host/src/window.rs b/flock-stage3/host/src/window.rs new file mode 100644 index 00000000..348c79a6 --- /dev/null +++ b/flock-stage3/host/src/window.rs @@ -0,0 +1,161 @@ +//! A fixed-selector 16-byte window over two adjacent transcript words. +//! +//! Stage 2 starts its challenger seed with the 14-byte `multi-stark/v0` tag, +//! so later commitment digests are not necessarily aligned to the Flock +//! circuit's 16-byte `F128` words. This Boolean table selects one of the 16 +//! possible byte offsets and returns the exact next 16 bytes. The selector is +//! always a relation-fixed one-hot word at call sites. + +use flock_prover::{ + circuit::builder::{GateType, SlotWitness}, + field::F128, + r1cs::BlockR1cs, + schedule::{IoWord, TableType}, +}; + +use crate::boolean::{ + BooleanR1csBuilder, BooleanR1csPlan, generate_boolean_witness, write_f128, +}; + +const K_LOG: usize = 12; +const FIRST_BASE: usize = 0; +const SECOND_BASE: usize = 128; +const SELECTOR_BASE: usize = 256; +const OUTPUT_BASE: usize = 384; +const RESERVED_COLUMNS: usize = 512; + +#[derive(Clone, Copy, Debug)] +pub(crate) struct ByteWindowGate { + pub(crate) nu: usize, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct ByteWindowRow { + first: F128, + second: F128, + selector: F128, +} + +impl GateType for ByteWindowGate { + type Row = ByteWindowRow; + type Hint = (); + + fn table(&self) -> TableType { + TableType::from_block_r1cs(&build_byte_window_r1cs(self.nu)).with_io_schema( + vec![ + IoWord::input(0), + IoWord::input(1), + IoWord::input(2), + IoWord::output(3), + ], + ) + } + + fn eval( + &self, + inputs: &[F128], + _hint: &(), + outputs: &mut Vec, + ) -> Self::Row { + let first = inputs[0]; + let second = inputs[1]; + let selector = inputs[2]; + assert_eq!(selector.hi, 0, "byte-window selector high lane must be zero"); + assert_eq!( + selector.lo.count_ones(), + 1, + "byte-window selector must be one-hot" + ); + let offset = selector.lo.trailing_zeros() as usize; + assert!(offset < 16, "byte-window offset exceeds one F128 word"); + outputs.push(byte_window(first, second, offset)); + ByteWindowRow { first, second, selector } + } + + fn witness(&self, _rows: &[Self::Row], _nu: usize) -> SlotWitness { + SlotWitness::DeferredToRows + } +} + +pub(crate) fn build_byte_window_r1cs(nu: usize) -> BlockR1cs { + byte_window_plan().block_r1cs(nu) +} + +pub(crate) fn generate_byte_window_witness( + rows: &[ByteWindowRow], + nu: usize, +) -> (Vec, Vec, Vec, Vec) { + generate_boolean_witness(byte_window_plan(), rows, nu, |row, bits| { + write_f128(bits, FIRST_BASE, row.first); + write_f128(bits, SECOND_BASE, row.second); + write_f128(bits, SELECTOR_BASE, row.selector); + }) +} + +fn byte_window_plan() -> &'static BooleanR1csPlan { + static PLAN: std::sync::OnceLock = + std::sync::OnceLock::new(); + PLAN.get_or_init(|| { + let mut builder = BooleanR1csBuilder::new(K_LOG, RESERVED_COLUMNS); + for column in FIRST_BASE..SELECTOR_BASE + 128 { + builder.free_boolean_at(column); + } + let one = builder.alloc_constant_one(); + for output_bit in 0..128 { + let products: Vec<_> = (0..16) + .map(|offset| { + let source_bit = offset * 8 + output_bit; + let source = if source_bit < 128 { + FIRST_BASE + source_bit + } else { + SECOND_BASE + source_bit - 128 + }; + builder.and(SELECTOR_BASE + offset, source) + }) + .collect(); + builder.write_xor(OUTPUT_BASE + output_bit, &products, one); + } + builder.finish() + }) +} + +fn byte_window(first: F128, second: F128, offset: usize) -> F128 { + let mut bytes = [0u8; 32]; + bytes[..8].copy_from_slice(&first.lo.to_le_bytes()); + bytes[8..16].copy_from_slice(&first.hi.to_le_bytes()); + bytes[16..24].copy_from_slice(&second.lo.to_le_bytes()); + bytes[24..].copy_from_slice(&second.hi.to_le_bytes()); + F128::new( + u64::from_le_bytes(bytes[offset..offset + 8].try_into().unwrap()), + u64::from_le_bytes(bytes[offset + 8..offset + 16].try_into().unwrap()), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_byte_offset_matches_native_slice() { + let first = F128::new(0x0706_0504_0302_0100, 0x0f0e_0d0c_0b0a_0908); + let second = F128::new(0x1716_1514_1312_1110, 0x1f1e_1d1c_1b1a_1918); + let plan = byte_window_plan(); + let r1cs = plan.block_r1cs(3); + for offset in 0..16 { + let selector = F128::new(1 << offset, 0); + let mut logical = vec![false; plan.k()]; + plan.fill_row(&mut logical, |bits| { + write_f128(bits, FIRST_BASE, first); + write_f128(bits, SECOND_BASE, second); + write_f128(bits, SELECTOR_BASE, selector); + }); + let mut witness = vec![false; r1cs.n()]; + witness[..plan.k()].copy_from_slice(&logical); + assert!(r1cs.satisfies(&witness)); + let expected = byte_window(first, second, offset); + let mut output = vec![false; 128]; + write_f128(&mut output, 0, expected); + assert_eq!(&logical[OUTPUT_BASE..OUTPUT_BASE + 128], output); + } + } +} diff --git a/sp1-compress/Cargo.lock b/sp1-compress/Cargo.lock index fa72757c..e70d366a 100644 --- a/sp1-compress/Cargo.lock +++ b/sp1-compress/Cargo.lock @@ -1770,6 +1770,17 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "ix-terminal" +version = "0.1.0" +dependencies = [ + "aiur", + "anyhow", + "bincode 2.0.1", + "blake3", + "multi-stark", +] + [[package]] name = "js-sys" version = "0.3.104" @@ -4154,11 +4165,11 @@ dependencies = [ name = "sp1-compress-host" version = "0.1.0" dependencies = [ - "aiur", "anyhow", "bincode 1.3.3", "blake3", "hex", + "ix-terminal", "multi-stark", "sp1-build", "sp1-sdk", diff --git a/sp1-compress/host/Cargo.toml b/sp1-compress/host/Cargo.toml index 671b5c21..0068528a 100644 --- a/sp1-compress/host/Cargo.toml +++ b/sp1-compress/host/Cargo.toml @@ -5,7 +5,7 @@ edition = "2024" license = "MIT OR Apache-2.0" [dependencies] -aiur = { path = "../../crates/aiur", features = ["parallel"] } +ix-terminal = { path = "../../crates/terminal" } # Keep this identical to the root workspace and the guest. multi-stark = { git = "https://github.com/argumentcomputer/multi-stark.git", rev = "2892243e674f9a0b3aca9004a8d00c79a23beec1", features = ["parallel"] } blake3 = "1" diff --git a/sp1-compress/host/src/lib.rs b/sp1-compress/host/src/lib.rs index 3195a613..f947a2c2 100644 --- a/sp1-compress/host/src/lib.rs +++ b/sp1-compress/host/src/lib.rs @@ -3,12 +3,12 @@ use std::{path::Path, str::FromStr}; -use aiur::{G, synthesis::AiurProof, vk_codec::AiurVerifyingKey}; use anyhow::{Context, Result, bail}; -use multi_stark::{ - p3_field::{PrimeCharacteristicRing, PrimeField64}, - types::FriParameters, +pub use ix_terminal::{ + OUTER_CLAIM_ELEMENTS, PUBLIC_VALUES_DOMAIN, expected_public_values, + fri_parameters_to_bytes, }; +use multi_stark::types::FriParameters; #[cfg(not(clippy))] use sp1_sdk::include_elf; use sp1_sdk::{ @@ -22,9 +22,6 @@ pub const GUEST_ELF: Elf = include_elf!("sp1-compress-guest"); // clippy itself. #[cfg(clippy)] pub const GUEST_ELF: Elf = Elf::Static(&[]); -pub const PUBLIC_VALUES_DOMAIN: &[u8; 8] = b"IXROOT01"; -pub const OUTER_CLAIM_ELEMENTS: usize = 18; - #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Mode { Execute, @@ -51,89 +48,18 @@ impl FromStr for Mode { } } -pub fn fri_parameters_to_bytes(fri: &FriParameters) -> Vec { - [ - fri.log_final_poly_len, - fri.max_log_arity, - fri.num_queries, - fri.commit_proof_of_work_bits, - fri.query_proof_of_work_bits, - ] - .iter() - .flat_map(|&value| (value as u64).to_le_bytes()) - .collect() -} - -fn decode_claim(claim_bytes: &[u8]) -> Result> { - if claim_bytes.len() != OUTER_CLAIM_ELEMENTS * 8 { - bail!( - "ix_aggr outer claim is {} bytes; expected {} (18 Goldilocks words)", - claim_bytes.len(), - OUTER_CLAIM_ELEMENTS * 8 - ); - } - claim_bytes - .as_chunks::<8>() - .0 - .iter() - .enumerate() - .map(|(index, chunk)| { - let word = u64::from_le_bytes(*chunk); - let value = G::from_u64(word); - if value.as_canonical_u64() != word { - bail!("outer claim word {index} is not canonical Goldilocks"); - } - Ok(value) - }) - .collect() -} - -fn fri_matches(actual: &FriParameters, expected: &FriParameters) -> bool { - actual.log_final_poly_len == expected.log_final_poly_len - && actual.max_log_arity == expected.max_log_arity - && actual.num_queries == expected.num_queries - && actual.commit_proof_of_work_bits == expected.commit_proof_of_work_bits - && actual.query_proof_of_work_bits == expected.query_proof_of_work_bits -} - /// Fail fast natively before starting SP1 setup or proving. The guest repeats -/// every one of these checks; this preflight is an ergonomics and cost guard, -/// not part of the soundness argument. +/// every check; this preflight is an ergonomics and cost guard, not part of the +/// soundness argument. Keep the original `Result<()>` host API while sharing +/// its implementation with other terminal backends. pub fn validate_root_inputs( vk_bytes: &[u8], claim_bytes: &[u8], proof_bytes: &[u8], fri: &FriParameters, ) -> Result<()> { - let claim = decode_claim(claim_bytes)?; - let vk = AiurVerifyingKey::from_bytes(vk_bytes) - .map_err(|error| anyhow::anyhow!("invalid Aiur verifying key: {error}"))?; - if !fri_matches(&vk.fri_parameters(), fri) { - bail!("requested recursion FRI parameters do not match the Aiur vk"); - } - let proof = AiurProof::from_bytes(proof_bytes) - .map_err(|error| anyhow::anyhow!("invalid Aiur proof: {error}"))?; - vk.verify(&claim, &proof).map_err(|error| { - anyhow::anyhow!("aggregate root does not verify: {error:?}") - }) -} - -/// Canonical SP1 public values: -/// `IXROOT01 || blake3(aiur_vk) || fri_parameters || ix_aggr_outer_claim`. -pub fn expected_public_values( - vk_bytes: &[u8], - claim_bytes: &[u8], - fri: &FriParameters, -) -> Result> { - let claim = decode_claim(claim_bytes)?; - let mut expected = Vec::with_capacity(8 + 32 + 40 + OUTER_CLAIM_ELEMENTS * 8); - expected.extend_from_slice(PUBLIC_VALUES_DOMAIN); - expected.extend_from_slice(blake3::hash(vk_bytes).as_bytes()); - expected.extend_from_slice(&fri_parameters_to_bytes(fri)); - for value in claim { - expected.extend_from_slice(&value.as_canonical_u64().to_le_bytes()); - } - Ok(expected) + ix_terminal::validate_root_inputs(vk_bytes, claim_bytes, proof_bytes, fri)?; + Ok(()) } /// Execute or prove the SP1 terminal. `output` receives the SDK's verified From b55db694eb1d3d19c6bcdadc18ee4bd05a0a9655 Mon Sep 17 00:00:00 2001 From: "John C. Burnham" Date: Mon, 31 Aug 2026 06:22:09 -0400 Subject: [PATCH 2/6] feat: prepare Flock Stage 3 for production aggregates Remove the fixed eight-round FRI ceiling, add deep-round relation regressions, and expose aggregate-root preflight diagnostics. Add the optional Flock CLI/FFI bridge with shared canonical root preparation, statement pinning, proof verification, and atomic artifact output. --- Cargo.lock | 105 ++++++++++++++ Ix/Aiur/Protocol.lean | 9 ++ Ix/Cli/CompressRootCmd.lean | 76 ++++++---- Ix/Cli/FlockRootCmd.lean | 71 ++++++++++ Main.lean | 2 + crates/ffi/Cargo.toml | 5 + crates/ffi/src/aiur/protocol.rs | 124 ++++++++++++++++ flock-stage3/README.md | 27 ++++ flock-stage3/host/src/fri.rs | 241 +++++++++++++++++++++++++++----- flock-stage3/host/src/lib.rs | 139 +++++++++++++++++- lakefile.lean | 13 +- 11 files changed, 742 insertions(+), 70 deletions(-) create mode 100644 Ix/Cli/FlockRootCmd.lean diff --git a/Cargo.lock b/Cargo.lock index 3bba5044..a0f484d4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1661,6 +1661,47 @@ dependencies = [ "zlib-rs", ] +[[package]] +name = "flock-core" +version = "0.1.0" +source = "git+https://github.com/succinctlabs/flock?rev=b310f35f35f68095537150a1c8c0a43caca9a29e#b310f35f35f68095537150a1c8c0a43caca9a29e" +dependencies = [ + "bincode 1.3.3", + "blake3", + "rand_core 0.9.5", + "rayon", + "serde", + "sha2 0.10.9", + "toml", +] + +[[package]] +name = "flock-prover" +version = "0.1.0" +source = "git+https://github.com/succinctlabs/flock?rev=b310f35f35f68095537150a1c8c0a43caca9a29e#b310f35f35f68095537150a1c8c0a43caca9a29e" +dependencies = [ + "bincode 1.3.3", + "blake3", + "flock-core", + "rayon", + "serde", + "sha2 0.10.9", +] + +[[package]] +name = "flock-stage3-host" +version = "0.1.0" +dependencies = [ + "aiur", + "anyhow", + "bincode 1.3.3", + "blake3", + "flock-prover", + "ix-terminal", + "multi-stark", + "serde", +] + [[package]] name = "fnv" version = "1.0.7" @@ -2753,6 +2794,7 @@ dependencies = [ "blake3", "bytes", "dashmap", + "flock-stage3-host", "getrandom 0.3.4", "indexmap 2.14.0", "iroh", @@ -5331,6 +5373,15 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -5393,6 +5444,7 @@ dependencies = [ "cfg-if", "cpufeatures 0.2.17", "digest 0.10.7", + "sha2-asm", ] [[package]] @@ -5406,6 +5458,15 @@ dependencies = [ "digest 0.11.0-rc.10", ] +[[package]] +name = "sha2-asm" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b845214d6175804686b2bd482bcffe96651bb2d1200742b712003504a2dac1ab" +dependencies = [ + "cc", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -7003,11 +7064,26 @@ dependencies = [ "tokio-util", ] +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", +] + [[package]] name = "toml_datetime" version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] [[package]] name = "toml_datetime" @@ -7029,6 +7105,20 @@ dependencies = [ "winnow 0.5.40", ] +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap 2.14.0", + "serde", + "serde_spanned", + "toml_datetime 0.6.11", + "toml_write", + "winnow 0.7.15", +] + [[package]] name = "toml_edit" version = "0.25.11+spec-1.1.0" @@ -7050,6 +7140,12 @@ dependencies = [ "winnow 1.0.2", ] +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "tonic" version = "0.12.3" @@ -7962,6 +8058,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + [[package]] name = "winnow" version = "1.0.2" diff --git a/Ix/Aiur/Protocol.lean b/Ix/Aiur/Protocol.lean index dbe6b4d0..5cc4cb5a 100644 --- a/Ix/Aiur/Protocol.lean +++ b/Ix/Aiur/Protocol.lean @@ -296,6 +296,15 @@ descriptive error while remaining linkable. -/ opaque sp1CompressAggregateRoot : @& ByteArray → @& ByteArray → @& ByteArray → @& FriParameters → @& String → @& String → @& String → Except String Unit +/-- Compile/evaluate (`preflight`) or prove (`prove`) the complete no-RISC-V +Flock Stage 3 relation for one Aiur aggregate root. `prove` requires an output +path and atomically installs the verified `Stage3ArtifactV1`. Without the Cargo +`flock` feature this binding returns a descriptive error while remaining +linkable. -/ +@[extern "rs_flock_stage3_aggregate_root"] +opaque flockStage3AggregateRoot : @& ByteArray → @& ByteArray → @& ByteArray → + @& FriParameters → @& String → @& String → Except String Unit + end Aiur end diff --git a/Ix/Cli/CompressRootCmd.lean b/Ix/Cli/CompressRootCmd.lean index e3ded372..43e19bc3 100644 --- a/Ix/Cli/CompressRootCmd.lean +++ b/Ix/Cli/CompressRootCmd.lean @@ -24,17 +24,46 @@ public section namespace Ix.Cli.CompressRootCmd -private def addrOfHex! (label : String) (s : String) : IO Address := do - match Address.fromString s with - | some a => pure a - | none => - throw <| IO.userError - s!"error: {label}: expected 64-char hex (32-byte address), got {s.length}-char {s}" - /-- Canonical guest claim encoding: one little-endian u64 per Goldilocks word. -/ def outerClaimBytes (claim : Array Aiur.G) : ByteArray := claim.foldl (init := .empty) fun bytes value => bytes ++ value.val.toLEBytes +/-- Canonical terminal-backend transport reconstructed from a persisted +aggregate wrapper. SP1 and Flock share this adapter so they cannot drift on the +recursion key, outer claim, proof bytes, or FRI parameters. -/ +structure AggregateRootInputs where + rootAddress : Address + bundledClaim : Ix.Claim + verifyingKey : ByteArray + outerClaim : ByteArray + proof : ByteArray + fri : Aiur.FriParameters + +def prepareAggregateRootInputs (rootHex : String) : IO (Except String AggregateRootInputs) := do + let some rootAddress := Address.fromString rootHex + | return .error s!"aggregate root: expected 64-char hex (32-byte address), \ + got {rootHex.length}-char {rootHex}" + let wrapper ← match Ixon.Proof.de (← StoreIO.toIO (Store.read rootAddress)) with + | .ok wrapper => pure wrapper + | .error error => + return .error s!"aggregate wrapper {rootAddress} does not decode: {error}" + let recursionParameters := MultiStark.defaultRecursionParameters + let backend ← match ← Ix.Cli.VerifyCmd.buildAggregateBackend recursionParameters with + | .ok backend => pure backend + | .error error => return .error error + let outerClaim := Ix.Cli.AggregateCmd.aggregateOuterClaim + backend.allowed backend.aggrIdx wrapper.claim + if outerClaim.size != 18 then + return .error s!"internal ix_aggr claim width is {outerClaim.size}, expected 18" + return .ok { + rootAddress + bundledClaim := wrapper.claim + verifyingKey := backend.system.vkBytes + outerClaim := outerClaimBytes outerClaim + proof := wrapper.proof + fri := recursionParameters.fri + } + /-- Final compression accepts only closed `CheckEnv` roots. The explicit open escape hatch is intentionally execute-only: it exists for cycle profiling and cannot produce a misleading terminal proof. -/ @@ -58,35 +87,22 @@ def runCompressRootCmd (p : Cli.Parsed) : IO UInt32 := do let allowOpenRoot := p.hasFlag "allow-open-root" let output := (p.flag? "output").map (·.as! String) |>.getD "" let onchainOutput := (p.flag? "onchain-output").map (·.as! String) |>.getD "" - let rootAddress ← addrOfHex! "aggregate root" rootHex - let wrapper ← match Ixon.Proof.de (← StoreIO.toIO (Store.read rootAddress)) with - | .ok wrapper => pure wrapper - | .error error => - IO.eprintln s!"error: aggregate wrapper {rootAddress} does not decode: {error}" - return 1 - match validateBundledClaim wrapper.claim mode allowOpenRoot with + let inputs ← match ← prepareAggregateRootInputs rootHex with + | .ok inputs => pure inputs + | .error error => IO.eprintln s!"error: {error}"; return 1 + match validateBundledClaim inputs.bundledClaim mode allowOpenRoot with | .ok () => pure () | .error error => IO.eprintln s!"error: {error}"; return 1 - let recursionParameters := MultiStark.defaultRecursionParameters - let backend ← match ← Ix.Cli.VerifyCmd.buildAggregateBackend recursionParameters with - | .ok backend => pure backend - | .error error => IO.eprintln s!"error: {error}"; return 1 - let outerClaim := Ix.Cli.AggregateCmd.aggregateOuterClaim - backend.allowed backend.aggrIdx wrapper.claim - if outerClaim.size != 18 then - IO.eprintln s!"error: internal ix_aggr claim width is {outerClaim.size}, expected 18" - return 1 - - IO.println s!"Compressing aggregate root {rootAddress} with SP1 ({mode})" - IO.println s!" bundled claim: {wrapper.claim}" - IO.println s!" recursion vk: {Address.blake3 backend.system.vkBytes}" + IO.println s!"Compressing aggregate root {inputs.rootAddress} with SP1 ({mode})" + IO.println s!" bundled claim: {inputs.bundledClaim}" + IO.println s!" recursion vk: {Address.blake3 inputs.verifyingKey}" (← IO.getStdout).flush - match Aiur.sp1CompressAggregateRoot backend.system.vkBytes - (outerClaimBytes outerClaim) wrapper.proof recursionParameters.fri + match Aiur.sp1CompressAggregateRoot inputs.verifyingKey + inputs.outerClaim inputs.proof inputs.fri mode output onchainOutput with | .ok () => - IO.println s!"ok: SP1 {mode} accepted aggregate root {rootAddress}" + IO.println s!"ok: SP1 {mode} accepted aggregate root {inputs.rootAddress}" return 0 | .error error => IO.eprintln s!"error: SP1 root compression failed: {error}" diff --git a/Ix/Cli/FlockRootCmd.lean b/Ix/Cli/FlockRootCmd.lean new file mode 100644 index 00000000..bd9ba8f8 --- /dev/null +++ b/Ix/Cli/FlockRootCmd.lean @@ -0,0 +1,71 @@ +/- +`ix flock-root ROOT_ADDRESS` consumes the same canonical aggregate-root +transport as `ix compress-root`, then either compiles/evaluates the complete +Stage 3 relation (`preflight`) or emits a verified Flock artifact (`prove`). + +Preflight deliberately stops before the cryptographic prover. It is the cheap +compatibility and capacity gate for a production-sized Stage 2 aggregate. +-/ +module +public import Cli +public import Ix.Cli.CompressRootCmd + +public section + +namespace Ix.Cli.FlockRootCmd + +def runFlockRootCmd (p : Cli.Parsed) : IO UInt32 := do + let roots := (p.variableArgsAs! String).toList + let rootHex ← match roots with + | [root] => pure root + | [] => p.printError "error: expected one aggregate root address"; return 1 + | _ => p.printError "error: expected exactly one aggregate root address"; return 1 + let mode := (p.flag? "mode").map (·.as! String) |>.getD "preflight" + if mode != "preflight" && mode != "prove" then + IO.eprintln s!"error: unknown Flock mode `{mode}` (expected preflight|prove)" + return 1 + let output := (p.flag? "output").map (·.as! String) |>.getD "" + if mode == "preflight" && !output.isEmpty then + IO.eprintln "error: --output is only valid with --mode prove" + return 1 + if mode == "prove" && output.isEmpty then + IO.eprintln "error: Flock proving requires --output" + return 1 + + let inputs ← match ← Ix.Cli.CompressRootCmd.prepareAggregateRootInputs rootHex with + | .ok inputs => pure inputs + | .error error => IO.eprintln s!"error: {error}"; return 1 + match Ix.Cli.CompressRootCmd.validateBundledClaim + inputs.bundledClaim mode false with + | .ok () => pure () + | .error error => IO.eprintln s!"error: {error}"; return 1 + + IO.println s!"Flock Stage 3 {mode}: aggregate root {inputs.rootAddress}" + IO.println s!" bundled claim: {inputs.bundledClaim}" + IO.println s!" recursion vk: {Address.blake3 inputs.verifyingKey}" + (← IO.getStdout).flush + match Aiur.flockStage3AggregateRoot inputs.verifyingKey inputs.outerClaim + inputs.proof inputs.fri mode output with + | .ok () => + IO.println s!"ok: Flock Stage 3 {mode} accepted aggregate root {inputs.rootAddress}" + return 0 + | .error error => + IO.eprintln s!"error: Flock Stage 3 {mode} failed: {error}" + return 1 + +end Ix.Cli.FlockRootCmd + +open Ix.Cli.FlockRootCmd in +def flockRootCmd : Cli.Cmd := `[Cli| + "flock-root" VIA runFlockRootCmd; + "Preflight or prove one closed ix_aggr root with Flock Stage 3 (build with IX_FLOCK=1)" + + FLAGS: + "mode" : String; "Stage 3 action: preflight | prove (default: preflight)." + "output" : String; "Atomically save the verified Stage3ArtifactV1 (required for prove)." + + ARGS: + ...root : String; "Exactly one 32-byte store address of a persisted aggregate root." +] + +end diff --git a/Main.lean b/Main.lean index 66115925..b4b737f4 100644 --- a/Main.lean +++ b/Main.lean @@ -14,6 +14,7 @@ import Ix.Cli.CompileCmd import Ix.Cli.CompressRootCmd import Ix.Cli.DecompileCmd import Ix.Cli.DiffCmd +import Ix.Cli.FlockRootCmd import Ix.Cli.IngressCmd import Ix.Cli.MergeCmd import Ix.Cli.NameOfCmd @@ -54,6 +55,7 @@ def ixCmd : Cli.Cmd := `[Cli| profileCmd; proveCmd; compressRootCmd; + flockRootCmd; shardCmd; codegenCmd; verifyCmd; diff --git a/crates/ffi/Cargo.toml b/crates/ffi/Cargo.toml index 26e28009..7c95de6e 100644 --- a/crates/ffi/Cargo.toml +++ b/crates/ffi/Cargo.toml @@ -39,6 +39,10 @@ tracing-texray = { workspace = true } # stub; enabling this dependency builds the aggregate-verifier guest ELF. sp1-compress-host = { path = "../../sp1-compress/host", optional = true } +# Optional no-RISC-V Flock Stage 3 connector. Like SP1, this stays out of the +# normal host build because the pinned prover stack is intentionally isolated. +flock-stage3-host = { path = "../../flock-stage3/host", optional = true } + # Iroh dependencies bytes = { version = "1.10.1", optional = true } tokio = { version = "1.44.1", optional = true } @@ -57,6 +61,7 @@ test-ffi = [] net = ["bytes", "tokio", "iroh", "iroh-base", "n0-error", "getrandom", "bincode", "serde"] sp1 = ["dep:sp1-compress-host"] sp1-cuda = ["sp1", "sp1-compress-host/cuda"] +flock = ["dep:flock-stage3-host"] [lints] workspace = true diff --git a/crates/ffi/src/aiur/protocol.rs b/crates/ffi/src/aiur/protocol.rs index f71a8b12..89c1c6a0 100644 --- a/crates/ffi/src/aiur/protocol.rs +++ b/crates/ffi/src/aiur/protocol.rs @@ -1630,3 +1630,127 @@ extern "C" fn rs_sp1_compress_aggregate_root( ) } } + +// ============================================================================= +// Flock aggregate-root Stage 3 (feature `flock`) +// ============================================================================= + +#[cfg(feature = "flock")] +fn write_flock_artifact_atomic( + path: &std::path::Path, + bytes: &[u8], +) -> anyhow::Result<()> { + use anyhow::{Context, bail}; + + let Some(file_name) = path.file_name() else { + bail!("Flock output path has no file name: {}", path.display()); + }; + let temporary = path.with_file_name(format!( + ".{}.tmp-{}", + file_name.to_string_lossy(), + std::process::id(), + )); + std::fs::write(&temporary, bytes).with_context(|| { + format!("write temporary Flock artifact {}", temporary.display()) + })?; + if let Err(error) = std::fs::rename(&temporary, path) { + let _ = std::fs::remove_file(&temporary); + return Err(error) + .with_context(|| format!("install Flock artifact {}", path.display())); + } + Ok(()) +} + +/// Compile/evaluate or prove the complete no-RISC-V Flock relation for one +/// canonical `ix_aggr` root. Default builds retain a checked feature-disabled +/// stub so the Lean CLI remains linkable. +#[unsafe(no_mangle)] +extern "C" fn rs_flock_stage3_aggregate_root( + vk_bytes: LeanByteArray>, + claim_bytes: LeanByteArray>, + proof_bytes: LeanByteArray>, + fri_parameters: LeanAiurFriParameters>, + mode: LeanString>, + output: LeanString>, +) -> LeanExcept { + #[cfg(feature = "flock")] + { + let fri = decode_fri_parameters(&fri_parameters); + let backend = flock_stage3_host::FlockStage3Backend; + let result = match mode.as_str().to_ascii_lowercase().as_str() { + "preflight" => { + if !output.as_str().is_empty() { + Err(anyhow::anyhow!("--output is only valid with --mode prove")) + } else { + backend + .preflight_stage2( + vk_bytes.as_bytes(), + claim_bytes.as_bytes(), + proof_bytes.as_bytes(), + &fri, + ) + .map(|report| println!("{report}")) + } + }, + "prove" => { + if output.as_str().is_empty() { + Err(anyhow::anyhow!( + "Flock proving requires --output so the expensive artifact is retained" + )) + } else { + (|| { + let report = backend.preflight_stage2( + vk_bytes.as_bytes(), + claim_bytes.as_bytes(), + proof_bytes.as_bytes(), + &fri, + )?; + println!("{report}"); + println!("starting Flock Stage 3 prover"); + let artifact = backend.prove_stage2( + vk_bytes.as_bytes(), + claim_bytes.as_bytes(), + proof_bytes.as_bytes(), + &fri, + )?; + if artifact.statement().stage2_root_digest() + != &report.stage2_root_digest + || artifact.statement().relation_digest() + != &report.relation_digest + || artifact.statement().digest() != report.stage3_statement_digest + { + return Err(anyhow::anyhow!( + "Flock prover rebuilt a statement different from preflight" + )); + } + backend.verify_stage2(&artifact, artifact.statement())?; + let encoded = artifact.to_bytes(); + let output_path = std::path::Path::new(output.as_str()); + write_flock_artifact_atomic(output_path, &encoded)?; + println!( + "Flock Stage 3 proof verified; artifact={} bytes; saved to {}", + encoded.len(), + output_path.display(), + ); + Ok(()) + })() + } + }, + other => Err(anyhow::anyhow!( + "unknown Flock mode `{other}` (expected preflight|prove)" + )), + }; + match result { + Ok(()) => LeanExcept::ok(LeanOwned::box_usize(0)), + Err(error) => LeanExcept::error_string(&format!("{error:#}")), + } + } + #[cfg(not(feature = "flock"))] + { + let _ = + (&vk_bytes, &claim_bytes, &proof_bytes, &fri_parameters, &mode, &output); + LeanExcept::error_string( + "ix was built without Flock Stage 3; rebuild with IX_FLOCK=1", + ) + } +} diff --git a/flock-stage3/README.md b/flock-stage3/README.md index f0fe0e7f..c67eba0c 100644 --- a/flock-stage3/README.md +++ b/flock-stage3/README.md @@ -91,6 +91,33 @@ Print the selected Flock configuration and digest with: cargo run -p flock-stage3-host --bin flock-stage3-config ``` +## Production aggregate preflight + +Build the optional root connector and compile/evaluate the complete Stage 3 +relation for a persisted `ix_aggr` root without starting the Flock prover: + +```sh +IX_FLOCK=1 nix develop --command lake exe ix flock-root ROOT_ADDRESS \ + --mode preflight +``` + +Preflight natively verifies and expands the compact Stage 2 proof, constructs +the typed AIR/PCS/FRI witness, evaluates every Flock gate, and prints the Stage +2 advice geometry, `nu`, table capacity, relation/public sizes, per-gate row +counts, and content-addressed relation/statement digests. It is the mandatory +gate before a production-sized proof. + +Once preflight succeeds, retain the expensive verified artifact explicitly: + +```sh +IX_FLOCK=1 nix develop --command lake exe ix flock-root ROOT_ADDRESS \ + --mode prove --output root.stage3.flock +``` + +The output is installed atomically. The binary-FRI lowering supports the full +height-derived schedule: the prior eight-round implementation ceiling is gone, +with evaluated regressions at 9, 16, and the current 30-round maximum. + ## Scope and remaining work The current relation is deliberately specialised to the configuration used by diff --git a/flock-stage3/host/src/fri.rs b/flock-stage3/host/src/fri.rs index b0971535..92d11f8e 100644 --- a/flock-stage3/host/src/fri.rs +++ b/flock-stage3/host/src/fri.rs @@ -106,7 +106,6 @@ const FIXED_SUFFIX_BYTES: usize = 32 + 32 + 8; const MAX_BUNDLE_BYTES: usize = 64 * 1024 * 1024; const MIN_LOG_HEIGHT: u8 = 1; const MAX_LOG_HEIGHT: u8 = 31; -const MAX_COMMIT_PHASE_ROUNDS: usize = 8; const MAX_REDUCED_OPENING_WIDTH: usize = 1 << 16; // The arithmetic slots need enough rows for the bit-reversed exponentiation // at the maximum supported height. This also keeps every table in a Flock @@ -333,6 +332,48 @@ impl Stage2AirPcsFriWitnessV1 { } } +/// Exact circuit census produced by the no-prove Stage 3 preflight. +/// +/// Counts are witness rows before Flock pads each table to `2^nu`. Keeping +/// them named makes production-root growth visible without exposing Flock's +/// internal slot identifiers as part of the Ix API. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Stage3RelationCensusV1 { + pub circuit_digest: [u8; 32], + pub nu: u64, + pub table_capacity: u64, + pub relation_inputs: u64, + pub public_values: u64, + pub blake3_rows: u64, + pub digest_order_rows: u64, + pub goldilocks_add_rows: u64, + pub goldilocks_mul_rows: u64, + pub lane_repack_rows: u64, + pub canonical_goldilocks_rows: u64, + pub equality_rows: u64, + pub hash_sample_rows: u64, + pub field_sample_rows: u64, + pub u64_split_rows: u64, + pub byte_window_rows: u64, +} + +impl Stage3RelationCensusV1 { + pub fn total_rows(&self) -> u64 { + self + .blake3_rows + .saturating_add(self.digest_order_rows) + .saturating_add(self.goldilocks_add_rows) + .saturating_add(self.goldilocks_mul_rows) + .saturating_add(self.lane_repack_rows) + .saturating_add(self.canonical_goldilocks_rows) + .saturating_add(self.equality_rows) + .saturating_add(self.hash_sample_rows) + .saturating_add(self.field_sample_rows) + .saturating_add(self.u64_split_rows) + .saturating_add(self.byte_window_rows) + } +} + /// A real Flock proof of an authenticated Plonky3-compatible binary FRI fold. #[derive(Clone, Debug, PartialEq, Eq)] pub struct FriFoldConformanceArtifactV1 { @@ -1652,6 +1693,83 @@ pub(crate) fn stage2_air_pcs_fri_circuit_digest( Ok(build_stage2_air_pcs_fri_relation(witness)?.shape.circuit.digest()) } +pub(crate) fn preflight_stage2_air_pcs_fri( + stage2_witness: &Stage2AirPcsFriWitnessV1, +) -> Result { + let relation = build_stage2_air_pcs_fri_relation(stage2_witness)?; + let evaluated = relation.shape.run(&relation.inputs, &[]); + if evaluated.public != relation.public { + bail!("Flock Stage 3 preflight disagrees with native verifier semantics"); + } + + let count = |value: usize, label: &str| { + u64::try_from(value) + .map_err(|error| anyhow::anyhow!("{label} exceeds u64: {error}")) + }; + let nu = count(relation.nu, "Flock table logarithm")?; + let shift = u32::try_from(nu).map_err(|error| { + anyhow::anyhow!("Flock table logarithm exceeds u32: {error}") + })?; + let table_capacity = 1u64.checked_shl(shift).ok_or_else(|| { + anyhow::anyhow!("Flock table logarithm {nu} exceeds the preflight report") + })?; + let field_sample_rows = relation + .slots + .field_sample + .map_or(0, |slot| evaluated.rows::(slot).len()); + let byte_window_rows = relation + .window_slot + .map_or(0, |slot| evaluated.rows::(slot).len()); + + Ok(Stage3RelationCensusV1 { + circuit_digest: relation.shape.circuit.digest(), + nu, + table_capacity, + relation_inputs: count(relation.inputs.len(), "relation input count")?, + public_values: count(relation.public.len(), "public-value count")?, + blake3_rows: count( + evaluated.rows::(relation.slots.blake3).len(), + "BLAKE3 row count", + )?, + digest_order_rows: count( + evaluated.rows::(relation.slots.order).len(), + "digest-order row count", + )?, + goldilocks_add_rows: count( + evaluated.rows::(relation.slots.add).len(), + "Goldilocks-add row count", + )?, + goldilocks_mul_rows: count( + evaluated.rows::(relation.slots.mul).len(), + "Goldilocks-mul row count", + )?, + lane_repack_rows: count( + evaluated.rows::(relation.slots.repack).len(), + "lane-repack row count", + )?, + canonical_goldilocks_rows: count( + evaluated + .rows::(relation.slots.canonical) + .len(), + "canonical-Goldilocks row count", + )?, + equality_rows: count( + evaluated.rows::(relation.slots.equality).len(), + "equality row count", + )?, + hash_sample_rows: count( + evaluated.rows::(relation.sample_slot).len(), + "hash-sample row count", + )?, + field_sample_rows: count(field_sample_rows, "field-sample row count")?, + u64_split_rows: count( + evaluated.rows::(relation.split_slot).len(), + "u64-split row count", + )?, + byte_window_rows: count(byte_window_rows, "byte-window row count")?, + }) +} + pub fn verify_stage2_air_pcs_fri_conformance( artifact: &Stage2AirPcsFriArtifactV1, ) -> Result<()> { @@ -4104,7 +4222,12 @@ fn validate_commit_phase_round_count( initial_log_height: u8, round_count: usize, ) -> Result<()> { - let maximum = usize::from(initial_log_height).min(MAX_COMMIT_PHASE_ROUNDS); + // Every round lowers the authenticated tree by one level. The height is + // already capped by `MAX_LOG_HEIGHT`, so this is a protocol-derived bound + // rather than a second, arbitrary implementation ceiling. The + // transcript-bound path additionally fixes the exact count through its + // folding-arity schedule and FRI parameters. + let maximum = usize::from(initial_log_height); if !(1..=maximum).contains(&round_count) { bail!("FRI commit-phase round count {round_count}; expected 1..={maximum}"); } @@ -5540,14 +5663,19 @@ mod tests { (path, current) } - fn transcript_bound_fri_fixture() -> ( + fn transcript_bound_fri_fixture_with_round_count( + round_count: usize, + ) -> ( Stage2TranscriptReplayV1, Stage2FriTranscriptReplayV1, FriCommitPhaseQueryV1, ) { let prefix = transcript_replay_fixture(); - let initial_log_height = 4u8; - let round_count = 3usize; + // Model the production binary schedule with logBlowup=2 and a constant + // final polynomial: global height = rounds + 2, while the first folded + // height is global height - 1. + assert!((1..=30).contains(&round_count)); + let initial_log_height = u8::try_from(round_count + 1).unwrap(); let trees: Vec<_> = (0..round_count) .map(|round| { zero_extension_tree(initial_log_height - u8::try_from(round).unwrap()) @@ -5592,6 +5720,14 @@ mod tests { (prefix, fri_transcript, query) } + fn transcript_bound_fri_fixture() -> ( + Stage2TranscriptReplayV1, + Stage2FriTranscriptReplayV1, + FriCommitPhaseQueryV1, + ) { + transcript_bound_fri_fixture_with_round_count(3) + } + fn transcript_bound_fri_all_queries_fixture() -> ( Stage2TranscriptReplayV1, Stage2FriTranscriptReplayV1, @@ -5944,6 +6080,63 @@ mod tests { assert!(wrong_beta.folded_results().is_err()); } + #[test] + fn deep_binary_fri_schedules_construct_and_evaluate() { + for round_count in [9, 16, 30] { + let (prefix, fri_transcript, query) = + transcript_bound_fri_fixture_with_round_count(round_count); + validate_commit_phase_structure(&query).unwrap(); + assert_eq!(query.rounds.len(), round_count); + + let challenges = fri_transcript.challenges(&prefix).unwrap(); + ensure_transcript_binds_fri_query( + &fri_transcript, + &challenges, + 0, + &query, + ) + .unwrap(); + let computation = compute_commit_phase(&query).unwrap(); + ensure_final_polynomial(&query, &computation).unwrap(); + + let relation = TranscriptBoundFriCommitPhaseRelation::build( + &prefix, + &fri_transcript, + &challenges, + 0, + &query, + &computation, + ) + .unwrap(); + let witness = relation.shape.run(&relation.inputs, &[]); + assert_eq!(witness.public, relation.public); + + let mut missing_last_round = query.clone(); + missing_last_round.rounds.pop(); + assert!( + ensure_transcript_binds_fri_query( + &fri_transcript, + &challenges, + 0, + &missing_last_round, + ) + .is_err() + ); + + let mut wrong_last_path = query; + wrong_last_path.rounds.last_mut().unwrap().opening_proof[0][0] ^= 1; + assert!( + ensure_transcript_binds_fri_query( + &fri_transcript, + &challenges, + 0, + &wrong_last_path, + ) + .is_err() + ); + } + } + #[test] fn commit_phase_parser_is_strict_before_crypto() { let query = commit_phase_fixture(); @@ -6187,12 +6380,10 @@ mod tests { #[test] fn real_stage2_root_lowers_to_the_combined_pcs_fri_relation() { - let (prepared, fri, _, _, _) = prepared_stage2_pcs_fixture(); + let (prepared, fri, vk_bytes, claim_bytes, proof_bytes) = + prepared_stage2_pcs_fixture(); let lowered = Stage2PcsFriWitnessV1::from_prepared(&prepared, &fri).unwrap(); - let air = - Stage2AirProgramV1::from_prepared(&prepared, &fri, &lowered.pcs_instance) - .unwrap(); assert_eq!(lowered.pcs_instance.batches.len(), 4); assert!( @@ -6209,30 +6400,16 @@ mod tests { let prefix_challenges = lowered.prefix.challenges().unwrap(); let fri_challenges = lowered.fri_transcript.challenges(&lowered.prefix).unwrap(); - let (fri_computations, pcs_computations) = - validate_all_transcript_bound_pcs_fri_queries( - &lowered.prefix, - &lowered.fri_transcript, - &fri_challenges, - prefix_challenges, - &lowered.pcs_instance, - &lowered.queries, - ) - .unwrap(); - let relation = - TranscriptBoundFriCommitPhaseRelation::build_all_with_pcs_and_air( - &lowered.prefix, - &lowered.fri_transcript, - &fri_challenges, - &lowered.pcs_instance, - &air, - &lowered.queries, - &fri_computations, - &pcs_computations, - ) + let report = crate::FlockStage3Backend + .preflight_stage2(&vk_bytes, &claim_bytes, &proof_bytes, &fri) .unwrap(); - let relation_witness = relation.shape.run(&relation.inputs, &[]); - assert_eq!(relation_witness.public, relation.public); + let census = &report.relation; + assert!(census.nu >= u64::try_from(NU).unwrap()); + assert!(census.blake3_rows > 0); + assert!(census.total_rows() > census.blake3_rows); + assert_eq!(report.advice.queries, u64::try_from(fri.num_queries).unwrap()); + assert_eq!(report.stage2_root_digest, prepared.statement().digest()); + assert!(report.to_string().contains("gate rows: blake3=")); let mut wrong_row = lowered.queries; wrong_row[0].pcs.batch_openings[0].opened_rows[0][0] ^= 1; diff --git a/flock-stage3/host/src/lib.rs b/flock-stage3/host/src/lib.rs index a313018e..e222f0a4 100644 --- a/flock-stage3/host/src/lib.rs +++ b/flock-stage3/host/src/lib.rs @@ -21,8 +21,12 @@ mod window; use aiur::vk_codec::AiurVerifyingKey; use anyhow::{Result, bail}; -use ix_terminal::{ValidatedStage2RootV1, validate_and_expand_root_inputs}; +use ix_terminal::{ + Stage2AdviceProfileV1, ValidatedStage2RootV1, + validate_and_expand_root_inputs, validate_root_inputs, +}; use multi_stark::types::FriParameters; +use std::fmt; pub use air::{Stage2ActiveAirCircuitV1, Stage2AirProgramV1}; pub use arithmetic::{ @@ -68,11 +72,11 @@ pub use fri::{ Stage2AirPcsFriArtifactV1, Stage2AirPcsFriWitnessV1, Stage2PcsBatchOpeningV1, Stage2PcsBatchV1, Stage2PcsFriWitnessV1, Stage2PcsInstanceV1, Stage2PcsMatrixV1, Stage2PcsOpeningPointV1, Stage2PcsQueryV1, - TranscriptBoundFriCommitPhaseArtifactV1, TranscriptBoundFriQueriesArtifactV1, - TranscriptBoundPcsFriQueriesArtifactV1, TranscriptBoundPcsFriQueryV1, - TranscriptBoundPcsReductionArtifactV1, prove_fri_commit_phase_conformance, - prove_fri_fold_conformance, prove_pcs_reduction_conformance, - prove_stage2_air_pcs_fri_conformance, + Stage3RelationCensusV1, TranscriptBoundFriCommitPhaseArtifactV1, + TranscriptBoundFriQueriesArtifactV1, TranscriptBoundPcsFriQueriesArtifactV1, + TranscriptBoundPcsFriQueryV1, TranscriptBoundPcsReductionArtifactV1, + prove_fri_commit_phase_conformance, prove_fri_fold_conformance, + prove_pcs_reduction_conformance, prove_stage2_air_pcs_fri_conformance, prove_transcript_bound_fri_commit_phase_conformance, prove_transcript_bound_fri_queries_conformance, prove_transcript_bound_pcs_fri_queries_conformance, @@ -86,7 +90,8 @@ pub use fri::{ verify_transcript_bound_pcs_reduction_conformance, }; use fri::{ - prove_stage2_air_pcs_fri_production, verify_stage2_air_pcs_fri_production, + preflight_stage2_air_pcs_fri, prove_stage2_air_pcs_fri_production, + verify_stage2_air_pcs_fri_production, }; pub use merkle::{ MERKLE_CONFORMANCE_ARTIFACT_MAGIC, MerkleConformanceArtifactV1, MerklePathV1, @@ -112,6 +117,85 @@ pub use typed_witness::{ Stage3TypedProofWitnessV1, Stage3TypedQueryProofV1, }; +/// Result of compiling and evaluating the complete Stage 3 relation without +/// invoking the Flock prover. This is the mandatory cost/compatibility gate +/// before attempting a production-sized aggregate root. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Stage3PreflightReportV1 { + pub stage2_root_digest: [u8; 32], + pub relation_digest: [u8; 32], + pub stage3_statement_digest: [u8; 32], + pub verifying_key_bytes: u64, + pub claim_bytes: u64, + pub compact_proof_bytes: u64, + pub advice: Stage2AdviceProfileV1, + pub relation: Stage3RelationCensusV1, +} + +impl fmt::Display for Stage3PreflightReportV1 { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + let hex = |digest| blake3::Hash::from_bytes(digest).to_hex(); + writeln!(formatter, "Flock Stage 3 preflight accepted the aggregate root")?; + writeln!(formatter, " Stage 2 root: {}", hex(self.stage2_root_digest))?; + writeln!(formatter, " relation: {}", hex(self.relation_digest))?; + writeln!( + formatter, + " Stage 3 stmt: {}", + hex(self.stage3_statement_digest) + )?; + writeln!( + formatter, + " transport: vk={} B, claim={} B, compact proof={} B, advice={} B", + self.verifying_key_bytes, + self.claim_bytes, + self.compact_proof_bytes, + self.advice.advice_bytes, + )?; + writeln!( + formatter, + " Stage 2 shape: circuits={}/{} active, queries={}, FRI rounds={}, input rounds/query={}", + self.advice.active_circuits, + self.advice.total_circuits, + self.advice.queries, + self.advice.fri_rounds, + self.advice.input_rounds_per_query, + )?; + writeln!( + formatter, + " openings: input siblings={}, FRI siblings={}, base values={}, FRI extension siblings={}, other extensions={}", + self.advice.input_merkle_siblings, + self.advice.fri_merkle_siblings, + self.advice.opened_base_values, + self.advice.fri_sibling_extension_values, + self.advice.other_extension_values, + )?; + writeln!( + formatter, + " Flock relation: nu={}, capacity/table={}, inputs={}, public={}, rows={}", + self.relation.nu, + self.relation.table_capacity, + self.relation.relation_inputs, + self.relation.public_values, + self.relation.total_rows(), + )?; + write!( + formatter, + " gate rows: blake3={}, order={}, add={}, mul={}, repack={}, canonical={}, equality={}, hash-sample={}, field-sample={}, split={}, window={}", + self.relation.blake3_rows, + self.relation.digest_order_rows, + self.relation.goldilocks_add_rows, + self.relation.goldilocks_mul_rows, + self.relation.lane_repack_rows, + self.relation.canonical_goldilocks_rows, + self.relation.equality_rows, + self.relation.hash_sample_rows, + self.relation.field_sample_rows, + self.relation.u64_split_rows, + self.relation.byte_window_rows, + ) + } +} + /// Host facade for the production Stage 3 relation. #[derive(Clone, Copy, Debug, Default)] pub struct FlockStage3Backend; @@ -127,9 +211,50 @@ impl FlockStage3Backend { proof_bytes: &[u8], fri: &FriParameters, ) -> Result { + // Fail before relation construction on an invalid compact root. The + // Flock relation repeats verification; this native pass is only the + // inexpensive guard needed before allocating a production-scale circuit. + validate_root_inputs(vk_bytes, claim_bytes, proof_bytes, fri)?; validate_and_expand_root_inputs(vk_bytes, claim_bytes, proof_bytes, fri) } + /// Validate a compact aggregate root, compile the complete specialised + /// relation, and evaluate every gate without running the Flock prover. + pub fn preflight_stage2( + self, + vk_bytes: &[u8], + claim_bytes: &[u8], + proof_bytes: &[u8], + fri: &FriParameters, + ) -> Result { + Stage3LoweringStatusV1::current().ensure_complete()?; + let prepared = + self.prepare_witness(vk_bytes, claim_bytes, proof_bytes, fri)?; + let witness = Stage2AirPcsFriWitnessV1::from_prepared(&prepared, fri)?; + let relation = preflight_stage2_air_pcs_fri(&witness)?; + let manifest = Stage3RelationManifestV1::for_prepared_and_program_digest( + &prepared, + relation.circuit_digest, + )?; + let statement = self.prepare_statement(&prepared, &manifest)?; + Ok(Stage3PreflightReportV1 { + stage2_root_digest: prepared.statement().digest(), + relation_digest: manifest.relation_digest()?, + stage3_statement_digest: statement.digest(), + verifying_key_bytes: u64::try_from(vk_bytes.len()).map_err(|error| { + anyhow::anyhow!("verifying-key length exceeds u64: {error}") + })?, + claim_bytes: u64::try_from(claim_bytes.len()).map_err(|error| { + anyhow::anyhow!("claim length exceeds u64: {error}") + })?, + compact_proof_bytes: u64::try_from(proof_bytes.len()).map_err( + |error| anyhow::anyhow!("compact-proof length exceeds u64: {error}"), + )?, + advice: prepared.advice_profile().clone(), + relation, + }) + } + /// Compile and content-address the complete relation for a prepared root. /// This builds the circuit but does not run the expensive Flock prover. pub fn relation_manifest( diff --git a/lakefile.lean b/lakefile.lean index 18df90a4..a4c89383 100644 --- a/lakefile.lean +++ b/lakefile.lean @@ -62,6 +62,9 @@ def cargoArgs (testFfi : Bool := false) (net : Bool := false) : IO (Array String -- runtime-selectable CUDA prover. Both require the SP1/Succinct toolchain. let ixSp1 ← IO.getEnv "IX_SP1" let ixSp1Cuda ← IO.getEnv "IX_SP1_CUDA" + -- IX_FLOCK=1 builds `ix flock-root` against the isolated, pinned Flock + -- Stage 3 workspace. Default builds retain a descriptive FFI stub. + let ixFlock ← IO.getEnv "IX_FLOCK" let mut features : Array String := #[] if ixNoPar != some "1" then features := features.push "parallel" if ixCuda == some "1" || ixCuda == some "true" || ixCuda == some "yes" then @@ -71,6 +74,7 @@ def cargoArgs (testFfi : Bool := false) (net : Bool := false) : IO (Array String if ixSp1 == some "1" || ixSp1Cuda == some "1" then features := features.push "sp1" if ixSp1Cuda == some "1" then features := features.push "sp1-cuda" + if ixFlock == some "1" then features := features.push "flock" IO.println s!"Ix Rust features: {if features.isEmpty then "none" else ",".intercalate features.toList}" let buildArgs := #["build", "--release", "-p", "ix-ffi"] if features.isEmpty then return buildArgs @@ -82,8 +86,15 @@ arguments, so changing `IX_CUDA` cannot silently reuse a differently-featured archive from a previous invocation. -/ def buildRustStatic (pkg : Package) (args : Array String) (tag : String) : SpawnM (Job FilePath) := do - let sources ← inputDir (pkg.dir / "crates") true fun path => + let coreSources ← inputDir (pkg.dir / "crates") true fun path => path.extension == some "rs" || path.fileName == "Cargo.toml" + -- `flock-stage3-host` is an optional path dependency outside `crates/`. + -- Trace it even in default builds so toggling IX_FLOCK or editing the + -- connector can never reuse a stale static archive. + let flockSources ← inputDir (pkg.dir / "flock-stage3") true fun path => + path.extension == some "rs" || path.fileName == "Cargo.toml" || + path.fileName == "Cargo.lock" + let sources := coreSources.zipWith (fun core flock => core ++ flock) flockSources let manifests := Job.collectArray #[ ← inputTextFile (pkg.dir / "Cargo.toml"), ← inputTextFile (pkg.dir / "Cargo.lock") From 18eb42bc863a92df73c98c0b75d1b96ea9a9878f Mon Sep 17 00:00:00 2001 From: "John C. Burnham" Date: Mon, 31 Aug 2026 07:10:25 -0400 Subject: [PATCH 3/6] test: report Flock Stage 3 phase timings Instrument the complete production regression across setup, proving, artifact transport, valid verification, and negative checks. Document the measured 524.315-second breakdown and clarify that corrupted-proof rejection performs a second full verifier run. --- flock-stage3/README.md | 24 ++++++++++----- flock-stage3/host/src/fri.rs | 57 ++++++++++++++++++++++++++++++++++-- 2 files changed, 72 insertions(+), 9 deletions(-) diff --git a/flock-stage3/README.md b/flock-stage3/README.md index c67eba0c..3ebf235f 100644 --- a/flock-stage3/README.md +++ b/flock-stage3/README.md @@ -61,15 +61,25 @@ an inactive leading circuit, active circuits at heights 8 and 4, an active preprocessed matrix, an 18-word claim lookup, nontrivial first-row/transition constraints, and two FRI queries. -On the debug profile, the complete production round trip produced: +On the debug profile, the instrumented complete production round trip produced: - Stage 3 artifact: **326,019 bytes**; -- encoded production payload: **325,893 bytes**; and -- prove + decode + valid verify + negative checks: **529.55 seconds**. - -The negative checks reject a different relation digest and a corrupted proof. -This size is expected: Stage 3 is the off-chain proof whose small fixed -verifier is compressed by Stage 4. It is not the sub-kilobyte Ethereum proof. +- encoded production payload: **325,893 bytes**; +- fixture setup: **0.010 seconds**; +- complete `prove_stage2` path: **491.624 seconds**; +- artifact encode and decode: **less than 0.001 seconds each**; +- valid cryptographic verification: **16.245 seconds**; +- rejection of a wrong relation statement: **0.000011 seconds**; +- rejection of a corrupted proof: **16.437 seconds**; and +- complete round trip: **524.315 seconds**. + +The complete prove-path timing includes native validation, witness lowering, +relation construction, Flock proving, and artifact packaging. Proving accounts +for 93.8% of the round trip. The negative-check time is almost entirely a +second full cryptographic verifier run against the corrupted proof; the wrong +relation digest fails before cryptography in 11 microseconds. This size is +expected: Stage 3 is the off-chain proof whose small fixed verifier is +compressed by Stage 4. It is not the sub-kilobyte Ethereum proof. Run the exact regression with: diff --git a/flock-stage3/host/src/fri.rs b/flock-stage3/host/src/fri.rs index 92d11f8e..edaa6cf0 100644 --- a/flock-stage3/host/src/fri.rs +++ b/flock-stage3/host/src/fri.rs @@ -6429,32 +6429,85 @@ mod tests { #[test] #[ignore = "real production Flock proof of a complete Stage 2 verifier"] fn real_stage2_production_artifact_round_trip() { + let total_started = std::time::Instant::now(); + + let fixture_started = std::time::Instant::now(); let (prepared, fri, vk_bytes, claim_bytes, proof_bytes) = prepared_stage2_pcs_fixture(); + let fixture_elapsed = fixture_started.elapsed(); + let backend = crate::FlockStage3Backend; + + let prove_started = std::time::Instant::now(); let artifact = backend .prove_stage2(&vk_bytes, &claim_bytes, &proof_bytes, &fri) .expect("prove complete Stage 3 relation"); + let prove_elapsed = prove_started.elapsed(); + + let encode_started = std::time::Instant::now(); + let encoded = artifact.to_bytes(); + let encode_elapsed = encode_started.elapsed(); eprintln!( "Flock complete Stage 3 artifact: {} bytes (payload: {} bytes)", - artifact.to_bytes().len(), + encoded.len(), artifact.proof_bytes().len(), ); - let encoded = artifact.to_bytes(); + + let decode_started = std::time::Instant::now(); let decoded = crate::Stage3ArtifactV1::from_bytes(&encoded).unwrap(); + let decode_elapsed = decode_started.elapsed(); + + let valid_verify_started = std::time::Instant::now(); backend .verify_stage2(&decoded, decoded.statement()) .expect("verify complete Stage 3 relation"); + let valid_verify_elapsed = valid_verify_started.elapsed(); let wrong_relation = crate::Stage3StatementV1::new(prepared.statement(), [0xa5; 32]); + let wrong_relation_started = std::time::Instant::now(); assert!(backend.verify_stage2(&decoded, &wrong_relation).is_err()); + let wrong_relation_elapsed = wrong_relation_started.elapsed(); + let corrupt_decode_started = std::time::Instant::now(); let mut corrupted = encoded; let flip_at = corrupted.len() - 1; corrupted[flip_at] ^= 1; let corrupted = crate::Stage3ArtifactV1::from_bytes(&corrupted).unwrap(); + let corrupt_decode_elapsed = corrupt_decode_started.elapsed(); + + let corrupt_verify_started = std::time::Instant::now(); assert!(backend.verify_stage2(&corrupted, corrupted.statement()).is_err()); + let corrupt_verify_elapsed = corrupt_verify_started.elapsed(); + + let total_elapsed = total_started.elapsed(); + let negative_checks_elapsed = + wrong_relation_elapsed + corrupt_decode_elapsed + corrupt_verify_elapsed; + eprintln!( + concat!( + "Flock complete Stage 3 timings (seconds):\n", + " fixture setup: {:>10.3}\n", + " prove: {:>10.3}\n", + " artifact encode: {:>10.3}\n", + " artifact decode: {:>10.3}\n", + " valid cryptographic verification: {:>10.3}\n", + " reject wrong relation statement: {:>10.6}\n", + " corrupt and decode artifact: {:>10.3}\n", + " reject corrupted proof: {:>10.3}\n", + " all negative checks: {:>10.3}\n", + " total: {:>10.3}", + ), + fixture_elapsed.as_secs_f64(), + prove_elapsed.as_secs_f64(), + encode_elapsed.as_secs_f64(), + decode_elapsed.as_secs_f64(), + valid_verify_elapsed.as_secs_f64(), + wrong_relation_elapsed.as_secs_f64(), + corrupt_decode_elapsed.as_secs_f64(), + corrupt_verify_elapsed.as_secs_f64(), + negative_checks_elapsed.as_secs_f64(), + total_elapsed.as_secs_f64(), + ); } #[test] From 3fb6d04e558705e9353269cec37e1795aa7dd750 Mon Sep 17 00:00:00 2001 From: "John C. Burnham" Date: Mon, 31 Aug 2026 07:24:05 -0400 Subject: [PATCH 4/6] fix(nix): stabilize LSpec fetch and Flock source tracing --- flake.lock | 18 ++++++++++++++++++ flake.nix | 23 ++++++++++++++++++++--- 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/flake.lock b/flake.lock index a18b189f..13c8f0a9 100644 --- a/flake.lock +++ b/flake.lock @@ -268,6 +268,23 @@ "type": "github" } }, + "lspec": { + "flake": false, + "locked": { + "lastModified": 1787413499, + "narHash": "sha256-EPGav84gN1Ki96SzVnH+TGjMAI1IGk4qsQVIaYsr4VM=", + "owner": "argumentcomputer", + "repo": "LSpec", + "rev": "ab4d5eb461941837f48eb891be755c8c73e89fdd", + "type": "github" + }, + "original": { + "owner": "argumentcomputer", + "repo": "LSpec", + "rev": "ab4d5eb461941837f48eb891be755c8c73e89fdd", + "type": "github" + } + }, "nixpkgs": { "locked": { "lastModified": 1765779637, @@ -382,6 +399,7 @@ "fenix": "fenix_2", "flake-parts": "flake-parts_2", "lean4-nix": "lean4-nix", + "lspec": "lspec", "nixpkgs": [ "lean4-nix", "nixpkgs" diff --git a/flake.nix b/flake.nix index ed89122c..b1115c03 100644 --- a/flake.nix +++ b/flake.nix @@ -36,6 +36,13 @@ inputs.lean4-nix.follows = "lean4-nix"; }; + # Fetch LSpec through the flake input machinery instead of lake2nix's + # unauthenticated builtins.fetchGit evaluation path. + lspec = { + url = "github:argumentcomputer/LSpec/ab4d5eb461941837f48eb891be755c8c73e89fdd"; + flake = false; + }; + # Zisk dev shell (cargo-zisk, ziskemu, RISC-V toolchain) for `zisk-guest`. zisk.url = "github:argumentcomputer/zisk.nix/blake3-precompile"; @@ -53,6 +60,7 @@ fenix, crane, blake3-lean, + lspec, zisk, sp1, ... @@ -163,9 +171,9 @@ ./lean-toolchain ./Cargo.toml ./Cargo.lock - (pkgs.lib.fileset.fileFilter - (f: f.hasExt "rs" || f.hasExt "toml") - ./crates) + (pkgs.lib.fileset.fileFilter (f: f.hasExt "rs" || f.hasExt "toml") ./crates) + ./flock-stage3/Cargo.lock + (pkgs.lib.fileset.fileFilter (f: f.hasExt "rs" || f.hasExt "toml") ./flock-stage3) (pkgs.lib.fileset.fileFilter (f: f.hasExt "lean") ./.) ]; }; @@ -189,6 +197,15 @@ }; depOverrideDeriv = { Blake3 = blake3-lean.packages.${system}.rust; + # Keep the root manifest authoritative for LSpec's inherited + # plausible dependency while sourcing LSpec from its locked, + # content-addressed flake input. + LSpec = lake2nix.mkLakeDerivation { + name = "LSpec"; + src = lspec; + deps = { inherit (lakeDeps) plausible; }; + buildLibrary = true; + }; }; }; # Shared Lake build args: patches out the Cargo build (Crane handles it) From eee28fdc625b428c0657f52868ffadbbe460da08 Mon Sep 17 00:00:00 2001 From: "John C. Burnham" Date: Mon, 31 Aug 2026 07:33:06 -0400 Subject: [PATCH 5/6] perf(flock-stage3): eliminate padded prover overhead --- flock-stage3/Cargo.lock | 1 + flock-stage3/Cargo.toml | 1 + flock-stage3/host/Cargo.toml | 1 + flock-stage3/host/src/binding.rs | 2 +- flock-stage3/host/src/boolean.rs | 290 +++++++++++++++ flock-stage3/host/src/equality.rs | 13 +- flock-stage3/host/src/extension.rs | 23 +- flock-stage3/host/src/fri.rs | 467 +++++++++++++++++++----- flock-stage3/host/src/goldilocks.rs | 48 ++- flock-stage3/host/src/merkle.rs | 22 +- flock-stage3/host/src/multiplication.rs | 30 +- flock-stage3/host/src/transcript.rs | 61 +++- flock-stage3/host/src/window.rs | 32 +- 13 files changed, 859 insertions(+), 132 deletions(-) diff --git a/flock-stage3/Cargo.lock b/flock-stage3/Cargo.lock index 264ebe79..92c9b50b 100644 --- a/flock-stage3/Cargo.lock +++ b/flock-stage3/Cargo.lock @@ -275,6 +275,7 @@ dependencies = [ "flock-prover", "ix-terminal", "multi-stark", + "rayon", "serde", ] diff --git a/flock-stage3/Cargo.toml b/flock-stage3/Cargo.toml index f519d67c..a5cf3e04 100644 --- a/flock-stage3/Cargo.toml +++ b/flock-stage3/Cargo.toml @@ -15,6 +15,7 @@ blake3 = "1.8.4" flock-prover = { git = "https://github.com/succinctlabs/flock", rev = "b310f35f35f68095537150a1c8c0a43caca9a29e" } ix-terminal = { path = "../crates/terminal" } multi-stark = { git = "https://github.com/argumentcomputer/multi-stark.git", rev = "2892243e674f9a0b3aca9004a8d00c79a23beec1" } +rayon = "1" serde = { version = "1", features = ["derive"] } [workspace.lints.rust] diff --git a/flock-stage3/host/Cargo.toml b/flock-stage3/host/Cargo.toml index 77c77068..d8c35001 100644 --- a/flock-stage3/host/Cargo.toml +++ b/flock-stage3/host/Cargo.toml @@ -12,6 +12,7 @@ blake3 = { workspace = true } flock-prover = { workspace = true } ix-terminal = { workspace = true } multi-stark = { workspace = true } +rayon = { workspace = true } serde = { workspace = true } [lints] diff --git a/flock-stage3/host/src/binding.rs b/flock-stage3/host/src/binding.rs index d682ce5e..b961d47a 100644 --- a/flock-stage3/host/src/binding.rs +++ b/flock-stage3/host/src/binding.rs @@ -387,7 +387,7 @@ impl GateType for Blake3Gate { type Hint = (); fn table(&self) -> TableType { - TableType::from_block_r1cs(&blake3::build_block_r1cs(self.nu)) + crate::boolean::table_from_block_r1cs(blake3::build_block_r1cs(self.nu)) .with_io_schema(blake3::io_schema()) } diff --git a/flock-stage3/host/src/boolean.rs b/flock-stage3/host/src/boolean.rs index 45b6761f..1eacee9e 100644 --- a/flock-stage3/host/src/boolean.rs +++ b/flock-stage3/host/src/boolean.rs @@ -8,10 +8,31 @@ use std::sync::OnceLock; use flock_prover::{ + bits::transpose_8_u64s_to_64_bytes, field::F128, lincheck::pack_z_lincheck, r1cs::{BlockR1cs, SparseBinaryMatrix, WitnessLayout}, + schedule::{TableClass, TableType}, + scratch, + union::SlotWitnessDest, }; +use rayon::prelude::*; + +/// Move a freshly built Boolean R1CS into a union table without cloning its +/// sparse matrices. `TableType::from_block_r1cs` accepts a borrow and must +/// deep-copy all three matrices, which is especially costly for BLAKE3. +pub(crate) fn table_from_block_r1cs(r1cs: BlockR1cs) -> TableType { + TableType { + k_log: r1cs.k_log, + useful_bits: r1cs.useful_bits, + a_0: r1cs.a_0, + b_0: r1cs.b_0, + c_0: r1cs.c_0, + const_pin: r1cs.const_pin, + class: TableClass::Boolean, + io_schema: Vec::new(), + } +} #[derive(Clone, Debug)] struct BooleanOperation { @@ -270,6 +291,235 @@ pub(crate) fn generate_boolean_witness( ) } +/// Generate a partial-count Boolean witness directly into one union slot. +/// +/// The allocating compatibility driver above constructs three logical +/// `capacity * k` bit-vectors, applies both sparse matrices over every dummy +/// row, repacks all three vectors, and then makes the union copy them again. +/// Stage 3 tables are deliberately sparse, so that work is overwhelmingly +/// padding. This driver evaluates only declared rows, eight at a time (the +/// lincheck stripe's native grouping), and writes their packed words directly +/// into Flock's pooled union buffers. +pub(crate) fn generate_boolean_witness_into( + plan: &BooleanR1csPlan, + rows: &[T], + nu: usize, + dst: SlotWitnessDest<'_>, + fill_free: impl Fn(&T, &mut [bool]) + Send + Sync, +) -> Vec { + generate_boolean_rows_into( + plan.k_log, + plan.useful_bits, + &plan.a_rows, + &plan.b_rows, + rows, + nu, + dst, + |row, bits| plan.fill_row(bits, |bits| fill_free(row, bits)), + ) +} + +/// Low-level form of [`generate_boolean_witness_into`] for Boolean tables +/// whose matrices and row filler predate [`BooleanR1csPlan`]. +pub(crate) fn generate_boolean_rows_into( + k_log: usize, + useful_bits: usize, + a_rows: &[Vec], + b_rows: &[Vec], + rows: &[T], + nu: usize, + dst: SlotWitnessDest<'_>, + fill_row: impl Fn(&T, &mut [bool]) + Send + Sync, +) -> Vec { + const GROUP_ROWS: usize = 8; + const ZERO_CHUNK_WORDS: usize = 1 << 16; + + assert!(nu >= 3, "Flock lincheck requires at least eight rows"); + assert!(k_log >= 7, "BatchMajor Boolean tables need k_log >= 7"); + let capacity = 1usize << nu; + let k = 1usize << k_log; + assert!(rows.len() <= capacity); + assert!(useful_bits <= k); + assert_eq!(a_rows.len(), k); + assert_eq!(b_rows.len(), k); + + let chunks = k / 128; + let slot_words = capacity * chunks; + let useful_chunks = useful_bits.div_ceil(128); + let useful_words = useful_bits.div_ceil(64); + let stored_words = 2 * useful_chunks; + let SlotWitnessDest { z, a, b, elide_padding_writes } = dst; + for buffer in [&*z, &*a, &*b] { + assert_eq!(buffer.len(), slot_words, "Boolean slot destination length"); + } + + // When padding is observable, initialize it once with parallel contiguous + // stores. The merged union path marks it unread, so production normally + // skips this multi-gigabyte memset entirely. + if !elide_padding_writes { + rayon::join( + || { + z.par_chunks_mut(ZERO_CHUNK_WORDS) + .for_each(|chunk| chunk.fill(F128::ZERO)); + }, + || { + rayon::join( + || { + a.par_chunks_mut(ZERO_CHUNK_WORDS) + .for_each(|chunk| chunk.fill(F128::ZERO)); + }, + || { + b.par_chunks_mut(ZERO_CHUNK_WORDS) + .for_each(|chunk| chunk.fill(F128::ZERO)); + }, + ); + }, + ); + } + + let stripe_len = capacity * k / GROUP_ROWS; + let mut stripe = scratch::take_u8(stripe_len); + if !elide_padding_writes { + stripe.par_chunks_mut(1 << 20).for_each(|chunk| chunk.fill(0)); + } + + let z_ptr = SendPtr(z.as_mut_ptr()); + let a_ptr = SendPtr(a.as_mut_ptr()); + let b_ptr = SendPtr(b.as_mut_ptr()); + let stripe_ptr = SendPtr(stripe.as_mut_ptr()); + let groups = rows.len().div_ceil(GROUP_ROWS); + + (0..groups).into_par_iter().for_each_init( + || BooleanGroupScratch::new(k, stored_words), + |scratch, group| { + scratch.clear_words(); + let first_row = group * GROUP_ROWS; + let live = rows.len().saturating_sub(first_row).min(GROUP_ROWS); + for lane in 0..live { + scratch.z_bits.fill(false); + scratch.a_bits.fill(false); + scratch.b_bits.fill(false); + fill_row(&rows[first_row + lane], &mut scratch.z_bits); + for column in 0..useful_bits { + let a_bit = parity(&scratch.z_bits, &a_rows[column]); + let b_bit = parity(&scratch.z_bits, &b_rows[column]); + scratch.a_bits[column] = a_bit; + scratch.b_bits[column] = b_bit; + assert_eq!( + a_bit & b_bit, + scratch.z_bits[column], + "custom Boolean witness does not satisfy column {column}", + ); + } + for word in 0..useful_words { + let bit = word * 64; + scratch.z_words[word][lane] = pack_bool_word(&scratch.z_bits, bit); + scratch.a_words[word][lane] = pack_bool_word(&scratch.a_bits, bit); + scratch.b_words[word][lane] = pack_bool_word(&scratch.b_bits, bit); + } + } + + // Every group owns disjoint row positions in every chunk-column and a + // disjoint `k`-byte stripe block. The raw pointers avoid materializing + // and then copying a second capacity-sized set of slot buffers. + for chunk in 0..useful_chunks { + for lane in 0..live { + let at = (chunk << nu) + first_row + lane; + let word = 2 * chunk; + unsafe { + z_ptr.get().add(at).write(F128::new( + scratch.z_words[word][lane], + scratch.z_words[word + 1][lane], + )); + a_ptr.get().add(at).write(F128::new( + scratch.a_words[word][lane], + scratch.a_words[word + 1][lane], + )); + b_ptr.get().add(at).write(F128::new( + scratch.b_words[word][lane], + scratch.b_words[word + 1][lane], + )); + } + } + } + + let stripe_base = group * k; + for (word, lanes) in scratch.z_words.iter().take(useful_words).enumerate() + { + let out = unsafe { + std::slice::from_raw_parts_mut( + stripe_ptr.get().add(stripe_base + word * 64), + 64, + ) + }; + transpose_8_u64s_to_64_bytes(lanes, out); + } + if elide_padding_writes { + let tail_start = stripe_base + useful_words * 64; + let tail_len = k - useful_words * 64; + if tail_len != 0 { + unsafe { + std::slice::from_raw_parts_mut( + stripe_ptr.get().add(tail_start), + tail_len, + ) + .fill(0); + } + } + } + }, + ); + + stripe +} + +struct BooleanGroupScratch { + z_bits: Vec, + a_bits: Vec, + b_bits: Vec, + z_words: Vec<[u64; 8]>, + a_words: Vec<[u64; 8]>, + b_words: Vec<[u64; 8]>, +} + +impl BooleanGroupScratch { + fn new(k: usize, stored_words: usize) -> Self { + Self { + z_bits: vec![false; k], + a_bits: vec![false; k], + b_bits: vec![false; k], + z_words: vec![[0; 8]; stored_words], + a_words: vec![[0; 8]; stored_words], + b_words: vec![[0; 8]; stored_words], + } + } + + fn clear_words(&mut self) { + self.z_words.fill([0; 8]); + self.a_words.fill([0; 8]); + self.b_words.fill([0; 8]); + } +} + +#[derive(Clone, Copy)] +struct SendPtr(*mut T); + +unsafe impl Send for SendPtr {} +unsafe impl Sync for SendPtr {} + +impl SendPtr { + fn get(self) -> *mut T { + self.0 + } +} + +fn pack_bool_word(bits: &[bool], start: usize) -> u64 { + bits[start..start + 64] + .iter() + .enumerate() + .fold(0, |word, (bit, value)| word | (u64::from(*value) << bit)) +} + pub(crate) fn write_f128(bits: &mut [bool], offset: usize, value: F128) { assert!(offset + 128 <= bits.len()); for local in 0..64 { @@ -334,4 +584,44 @@ mod tests { assert!(r1cs.satisfies(&witness)); } } + + #[test] + fn in_place_partial_driver_matches_allocating_driver() { + let mut builder = BooleanR1csBuilder::new(7, 3); + builder.free_boolean_at(0); + builder.free_boolean_at(1); + let one = builder.alloc_constant_one(); + let product = builder.and(0, 1); + builder.write_xor(2, &[product, 0], one); + let plan = builder.finish(); + let rows = [(false, false), (true, false), (false, true), (true, true)]; + let nu = 4; + let fill = |row: &(bool, bool), bits: &mut [bool]| { + bits[0] = row.0; + bits[1] = row.1; + }; + let expected = generate_boolean_witness(&plan, &rows, nu, fill); + + let words = 1usize << (nu + plan.k_log() - 7); + let mut z = vec![F128::ZERO; words]; + let mut a = vec![F128::ZERO; words]; + let mut b = vec![F128::ZERO; words]; + let stripe = generate_boolean_witness_into( + &plan, + &rows, + nu, + SlotWitnessDest { + z: &mut z, + a: &mut a, + b: &mut b, + elide_padding_writes: false, + }, + fill, + ); + + assert_eq!(z, expected.0); + assert_eq!(a, expected.1); + assert_eq!(b, expected.2); + assert_eq!(stripe, expected.3); + } } diff --git a/flock-stage3/host/src/equality.rs b/flock-stage3/host/src/equality.rs index 60f80e13..c74292ab 100644 --- a/flock-stage3/host/src/equality.rs +++ b/flock-stage3/host/src/equality.rs @@ -10,10 +10,12 @@ use flock_prover::{ field::F128, r1cs::BlockR1cs, schedule::{IoWord, TableType}, + union::SlotWitnessDest, }; use crate::boolean::{ - BooleanR1csBuilder, BooleanR1csPlan, generate_boolean_witness, write_f128, + BooleanR1csBuilder, BooleanR1csPlan, generate_boolean_witness_into, + write_f128, }; const K_LOG: usize = 9; @@ -38,7 +40,7 @@ impl GateType for F128EqualityGate { type Hint = (); fn table(&self) -> TableType { - TableType::from_block_r1cs(&build_f128_equality_r1cs(self.nu)) + crate::boolean::table_from_block_r1cs(build_f128_equality_r1cs(self.nu)) .with_io_schema(vec![ IoWord::input(0), IoWord::input(1), @@ -67,12 +69,13 @@ pub(crate) fn build_f128_equality_r1cs(nu: usize) -> BlockR1cs { build_plan().block_r1cs(nu) } -pub(crate) fn generate_f128_equality_witness( +pub(crate) fn generate_f128_equality_witness_into( rows: &[F128EqualityRow], nu: usize, -) -> (Vec, Vec, Vec, Vec) { + dst: SlotWitnessDest<'_>, +) -> Vec { let plan = build_plan(); - generate_boolean_witness(&plan, rows, nu, |row, bits| { + generate_boolean_witness_into(&plan, rows, nu, dst, |row, bits| { write_f128(bits, LEFT_BASE, row.left); write_f128(bits, RIGHT_BASE, row.right); }) diff --git a/flock-stage3/host/src/extension.rs b/flock-stage3/host/src/extension.rs index 450ca111..7b36800b 100644 --- a/flock-stage3/host/src/extension.rs +++ b/flock-stage3/host/src/extension.rs @@ -11,11 +11,13 @@ use flock_prover::{ field::F128, r1cs::BlockR1cs, schedule::{IoWord, TableType}, + union::SlotWitnessDest, }; use crate::{ boolean::{ - BooleanR1csBuilder, BooleanR1csPlan, generate_boolean_witness, write_f128, + BooleanR1csBuilder, BooleanR1csPlan, generate_boolean_witness, + generate_boolean_witness_into, write_f128, }, goldilocks::{CanonicalGoldilocksPairGate, GoldilocksAddPairGate}, multiplication::{GoldilocksMulPairGate, goldilocks_mul}, @@ -50,16 +52,15 @@ impl GateType for GoldilocksLaneRepackGate { type Hint = (); fn table(&self) -> TableType { - TableType::from_block_r1cs(&build_lane_repack_r1cs(self.nu)).with_io_schema( - vec![ + crate::boolean::table_from_block_r1cs(build_lane_repack_r1cs(self.nu)) + .with_io_schema(vec![ IoWord::input(0), IoWord::input(1), IoWord::output(2), IoWord::output(3), IoWord::output(4), IoWord::output(5), - ], - ) + ]) } fn eval( @@ -99,6 +100,18 @@ pub(crate) fn generate_lane_repack_witness( }) } +pub(crate) fn generate_lane_repack_witness_into( + rows: &[GoldilocksLaneRepackRow], + nu: usize, + dst: SlotWitnessDest<'_>, +) -> Vec { + let plan = build_lane_repack_plan(); + generate_boolean_witness_into(&plan, rows, nu, dst, |row, bits| { + write_f128(bits, FIRST_BASE, row.first); + write_f128(bits, SECOND_BASE, row.second); + }) +} + fn build_lane_repack_plan() -> BooleanR1csPlan { let mut builder = BooleanR1csBuilder::new(REPACK_K_LOG, REPACK_COLUMNS); for column in FIRST_BASE..SECOND_BASE + 128 { diff --git a/flock-stage3/host/src/fri.rs b/flock-stage3/host/src/fri.rs index edaa6cf0..7f895415 100644 --- a/flock-stage3/host/src/fri.rs +++ b/flock-stage3/host/src/fri.rs @@ -22,10 +22,11 @@ use flock_prover::{ challenger::FsChallenger, circuit::builder::{CircuitShape, ShapeBuilder, SlotId, Wire}, field::F128, - lincheck::LincheckCircuit, + lincheck::{CscCircuit, LincheckCircuit}, pcs::Commitment, proof::R1csProofCircuitMerged, prover::{self, UnionSlotProverInput}, + r1cs::BlockR1cs, r1cs_hashes::blake3 as flock_blake3, union::UnionInstance, verifier, @@ -37,8 +38,12 @@ use multi_stark::{ p3_field::{BasedVectorSpace, PrimeCharacteristicRing, PrimeField64}, types::{ExtVal, FriParameters, Val}, }; +use rayon::prelude::*; use serde::{Deserialize, Serialize}; -use std::collections::BTreeMap; +use std::{ + collections::BTreeMap, + sync::{Arc, Mutex, OnceLock}, +}; use crate::{ FRI_FOLD_CONFORMANCE_TRANSCRIPT_DOMAIN, @@ -55,23 +60,25 @@ use crate::{ pack8, pcs_params, }, equality::{ - F128EqualityGate, build_f128_equality_r1cs, generate_f128_equality_witness, + F128EqualityGate, build_f128_equality_r1cs, + generate_f128_equality_witness_into, }, extension::{ GoldilocksCircuitSlots, GoldilocksLaneRepackGate, build_lane_repack_r1cs, - generate_lane_repack_witness, + generate_lane_repack_witness_into, }, goldilocks::{ CanonicalGoldilocksPairGate, GOLDILOCKS_MODULUS, GoldilocksAddPairGate, build_canonical_pair_r1cs, build_goldilocks_add_r1cs, - generate_canonical_pair_witness, generate_goldilocks_add_witness, + generate_canonical_pair_witness_into, generate_goldilocks_add_witness_into, }, merkle::{ - DigestOrderGate, build_digest_order_r1cs, generate_digest_order_witness, + DigestOrderGate, build_digest_order_r1cs, + generate_digest_order_witness_into, }, multiplication::{ GoldilocksMulPairGate, build_goldilocks_mul_r1cs, - generate_goldilocks_mul_witness, goldilocks_mul, + generate_goldilocks_mul_witness_into, goldilocks_mul, }, transcript::{ FriTranscriptCircuitSlots, GoldilocksSampleGate, HashSampleGate, @@ -81,12 +88,12 @@ use crate::{ build_goldilocks_sample_r1cs, build_hash_sample_r1cs, build_u64_split_r1cs, constrain_hash, constrain_stage2_fri_transcript, constrain_stage2_transcript, fri_transcript_blake3_rows, - fri_transcript_split_rows, generate_goldilocks_sample_witness, - generate_hash_sample_witness, generate_u64_split_witness, hash_trace, - transcript_challenge_words, transcript_nu, + fri_transcript_split_rows, generate_goldilocks_sample_witness_into, + generate_hash_sample_witness_into, generate_u64_split_witness_into, + hash_trace, transcript_challenge_words, transcript_nu, }, window::{ - ByteWindowGate, build_byte_window_r1cs, generate_byte_window_witness, + ByteWindowGate, build_byte_window_r1cs, generate_byte_window_witness_into, }, }; @@ -1639,7 +1646,23 @@ fn prove_stage2_air_pcs_fri_with_domain( witness: &Stage2AirPcsFriWitnessV1, transcript_domain: &[u8], ) -> Result { - let relation = build_stage2_air_pcs_fri_relation(witness)?; + let trace = std::env::var_os("IX_FLOCK_TIMING").is_some(); + let total_started = std::time::Instant::now(); + let phase_started = std::time::Instant::now(); + let (relation, _) = rayon::join( + || cached_stage2_air_pcs_fri_relation(witness), + || { + stage3_linchecks(); + }, + ); + let relation = relation?; + if trace { + eprintln!( + " [stage3] relation build: {:.2} ms", + phase_started.elapsed().as_secs_f64() * 1e3, + ); + } + let phase_started = std::time::Instant::now(); let proof_bundle_bytes = prove_fri_circuit( &relation.shape, relation.slots, @@ -1651,19 +1674,42 @@ fn prove_stage2_air_pcs_fri_with_domain( &relation.public, transcript_domain, )?; - Stage2AirPcsFriArtifactV1::from_parts( + if trace { + eprintln!( + " [stage3] circuit evaluate + prove: {:.2} ms", + phase_started.elapsed().as_secs_f64() * 1e3, + ); + } + let artifact = Stage2AirPcsFriArtifactV1::from_parts( witness.clone(), relation.shape.circuit.digest(), proof_bundle_bytes, - ) + )?; + if trace { + eprintln!( + " [stage3] relation-to-artifact total: {:.2} ms", + total_started.elapsed().as_secs_f64() * 1e3, + ); + } + Ok(artifact) } fn build_stage2_air_pcs_fri_relation( witness: &Stage2AirPcsFriWitnessV1, ) -> Result { + let trace = std::env::var_os("IX_FLOCK_TIMING").is_some(); + let total_started = std::time::Instant::now(); let pcs_fri = &witness.pcs_fri; + let phase_started = std::time::Instant::now(); let prefix_challenges = pcs_fri.prefix.challenges()?; let fri_challenges = pcs_fri.fri_transcript.challenges(&pcs_fri.prefix)?; + if trace { + eprintln!( + " [stage3-relation] replay transcript: {:.2} ms", + phase_started.elapsed().as_secs_f64() * 1e3, + ); + } + let phase_started = std::time::Instant::now(); let (fri_computations, pcs_computations) = validate_all_transcript_bound_pcs_fri_queries( &pcs_fri.prefix, @@ -1673,6 +1719,13 @@ fn build_stage2_air_pcs_fri_relation( &pcs_fri.pcs_instance, &pcs_fri.queries, )?; + if trace { + eprintln!( + " [stage3-relation] native PCS/FRI validation: {:.2} ms", + phase_started.elapsed().as_secs_f64() * 1e3, + ); + } + let phase_started = std::time::Instant::now(); let relation = TranscriptBoundFriCommitPhaseRelation::build_all_with_pcs_and_air( &pcs_fri.prefix, @@ -1684,19 +1737,67 @@ fn build_stage2_air_pcs_fri_relation( &fri_computations, &pcs_computations, )?; + if trace { + eprintln!( + " [stage3-relation] build circuit shape: {:.2} ms", + phase_started.elapsed().as_secs_f64() * 1e3, + ); + eprintln!( + " [stage3-relation] total: {:.2} ms", + total_started.elapsed().as_secs_f64() * 1e3, + ); + } + Ok(relation) +} + +/// Retain only the most recently used complete relation. The circuit shape is +/// immutable and value-independent once built, while the input/public vectors +/// in this relation are specific to one witness. Comparing the full witness +/// (rather than a digest) makes an exact hit safe for preflight, proving, and +/// post-hoc verification without introducing a cache-collision assumption. +fn cached_stage2_air_pcs_fri_relation( + witness: &Stage2AirPcsFriWitnessV1, +) -> Result> { + type Entry = + (Stage2AirPcsFriWitnessV1, Arc); + static CACHE: OnceLock>> = OnceLock::new(); + + let cache = CACHE.get_or_init(|| Mutex::new(None)); + let mut cached = + cache.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); + if let Some((cached_witness, relation)) = cached.as_ref() + && cached_witness == witness + { + if std::env::var_os("IX_FLOCK_TIMING").is_some() { + eprintln!(" [stage3-relation] exact-witness cache: hit"); + } + return Ok(Arc::clone(relation)); + } + + if std::env::var_os("IX_FLOCK_TIMING").is_some() { + eprintln!(" [stage3-relation] exact-witness cache: miss"); + } + let relation = Arc::new(build_stage2_air_pcs_fri_relation(witness)?); + *cached = Some((witness.clone(), Arc::clone(&relation))); Ok(relation) } pub(crate) fn stage2_air_pcs_fri_circuit_digest( witness: &Stage2AirPcsFriWitnessV1, ) -> Result<[u8; 32]> { - Ok(build_stage2_air_pcs_fri_relation(witness)?.shape.circuit.digest()) + Ok(cached_stage2_air_pcs_fri_relation(witness)?.shape.circuit.digest()) } pub(crate) fn preflight_stage2_air_pcs_fri( stage2_witness: &Stage2AirPcsFriWitnessV1, ) -> Result { - let relation = build_stage2_air_pcs_fri_relation(stage2_witness)?; + let (relation, _) = rayon::join( + || cached_stage2_air_pcs_fri_relation(stage2_witness), + || { + stage3_linchecks(); + }, + ); + let relation = relation?; let evaluated = relation.shape.run(&relation.inputs, &[]); if evaluated.public != relation.public { bail!("Flock Stage 3 preflight disagrees with native verifier semantics"); @@ -1793,7 +1894,13 @@ fn verify_stage2_air_pcs_fri_with_domain( transcript_domain: &[u8], ) -> Result<()> { let witness = &artifact.witness; - let relation = build_stage2_air_pcs_fri_relation(witness)?; + let (relation, _) = rayon::join( + || cached_stage2_air_pcs_fri_relation(witness), + || { + stage3_linchecks(); + }, + ); + let relation = relation?; if artifact.circuit_digest != relation.shape.circuit.digest() { bail!("Stage 2 AIR/PCS/FRI circuit digest mismatch"); } @@ -2834,6 +2941,9 @@ impl TranscriptBoundFriCommitPhaseRelation { pcs_instance: Option<&Stage2PcsInstanceV1>, air: Option<&Stage2AirProgramV1>, ) -> Result { + let trace = std::env::var_os("IX_FLOCK_TIMING").is_some(); + let total_started = std::time::Instant::now(); + let phase_started = std::time::Instant::now(); if selected.is_empty() { bail!("transcript-bound FRI relation has no selected queries"); } @@ -2854,6 +2964,13 @@ impl TranscriptBoundFriCommitPhaseRelation { if air.is_some() && pcs_instance.is_none() { bail!("AIR evaluation requires the transcript-bound PCS instance"); } + if trace { + eprintln!( + " [stage3-shape] validate structure: {:.2} ms", + phase_started.elapsed().as_secs_f64() * 1e3, + ); + } + let phase_started = std::time::Instant::now(); let nu = transcript_bound_fri_nu( prefix, fri_transcript, @@ -2880,10 +2997,17 @@ impl TranscriptBoundFriCommitPhaseRelation { equality, field_sample: Some(field_sample_slot), }; + if trace { + eprintln!( + " [stage3-shape] size + declare slots: {:.2} ms", + phase_started.elapsed().as_secs_f64() * 1e3, + ); + } // GoldilocksCircuitSlots declares its canonical-zero fixed input first. let mut inputs = vec![F128::ZERO]; let mut public = vec![F128::ZERO]; + let phase_started = std::time::Instant::now(); let prefix_region = constrain_stage2_transcript( &mut builder, TranscriptCircuitSlots { @@ -2900,7 +3024,14 @@ impl TranscriptBoundFriCommitPhaseRelation { builder.publish(challenge); } public.extend(transcript_challenge_words(prefix.challenges()?)); + if trace { + eprintln!( + " [stage3-shape] prefix transcript: {:.2} ms", + phase_started.elapsed().as_secs_f64() * 1e3, + ); + } + let phase_started = std::time::Instant::now(); let fri_region = constrain_stage2_fri_transcript( &mut builder, FriTranscriptCircuitSlots { @@ -2932,7 +3063,14 @@ impl TranscriptBoundFriCommitPhaseRelation { .map(|bit| F128::new((index >> bit) & 1, 0)), ); } + if trace { + eprintln!( + " [stage3-shape] FRI transcript: {:.2} ms", + phase_started.elapsed().as_secs_f64() * 1e3, + ); + } + let phase_started = std::time::Instant::now(); let data_zero = record_fixed(&mut builder, &mut inputs, &mut public, F128::ZERO); let equality_zero = @@ -2967,6 +3105,13 @@ impl TranscriptBoundFriCommitPhaseRelation { node_params, one, }; + if trace { + eprintln!( + " [stage3-shape] fixed wires: {:.2} ms", + phase_started.elapsed().as_secs_f64() * 1e3, + ); + } + let phase_started = std::time::Instant::now(); if let Some(air) = air { constrain_stage2_air( &mut builder, @@ -2986,7 +3131,16 @@ impl TranscriptBoundFriCommitPhaseRelation { air, )?; } + if trace { + eprintln!( + " [stage3-shape] AIR constraints: {:.2} ms", + phase_started.elapsed().as_secs_f64() * 1e3, + ); + } + let mut pcs_elapsed = std::time::Duration::ZERO; + let mut fri_elapsed = std::time::Duration::ZERO; for item in selected { + let phase_started = std::time::Instant::now(); let reduced_openings = if let Some(instance) = pcs_instance { Some(constrain_stage2_pcs_query( &mut builder, @@ -3005,6 +3159,8 @@ impl TranscriptBoundFriCommitPhaseRelation { } else { None }; + pcs_elapsed += phase_started.elapsed(); + let phase_started = std::time::Instant::now(); constrain_transcript_bound_fri_query( &mut builder, &arithmetic, @@ -3017,11 +3173,33 @@ impl TranscriptBoundFriCommitPhaseRelation { item.computation, reduced_openings.as_ref(), ); + fri_elapsed += phase_started.elapsed(); + } + if trace { + eprintln!( + " [stage3-shape] PCS query constraints: {:.2} ms", + pcs_elapsed.as_secs_f64() * 1e3, + ); + eprintln!( + " [stage3-shape] FRI query constraints: {:.2} ms", + fri_elapsed.as_secs_f64() * 1e3, + ); } + let phase_started = std::time::Instant::now(); let shape = builder.finish().map_err(|error| { anyhow::anyhow!("build transcript-bound FRI circuit: {error:?}") })?; + if trace { + eprintln!( + " [stage3-shape] finish builder: {:.2} ms", + phase_started.elapsed().as_secs_f64() * 1e3, + ); + eprintln!( + " [stage3-shape] total: {:.2} ms", + total_started.elapsed().as_secs_f64() * 1e3, + ); + } Ok(Self { shape, slots, @@ -3744,6 +3922,76 @@ fn constrain_authenticated_fold( current } +struct Stage3Linchecks { + blake3: CscCircuit, + order: CscCircuit, + add: CscCircuit, + mul: CscCircuit, + repack: CscCircuit, + canonical: CscCircuit, + equality: CscCircuit, + sample: CscCircuit, + field_sample: CscCircuit, + split: CscCircuit, + window: CscCircuit, +} + +/// CSC transposes depend only on each table's inner Boolean matrices, not on +/// the shared outer-row logarithm. Build them once for the process and reuse +/// them across proving, verification, and every Stage 3 relation size. +fn stage3_linchecks() -> &'static Stage3Linchecks { + static CACHE: OnceLock = OnceLock::new(); + CACHE.get_or_init(|| { + let builders: [fn(usize) -> BlockR1cs; 11] = [ + flock_blake3::build_block_r1cs, + build_digest_order_r1cs, + build_goldilocks_add_r1cs, + build_goldilocks_mul_r1cs, + build_lane_repack_r1cs, + build_canonical_pair_r1cs, + build_f128_equality_r1cs, + build_hash_sample_r1cs, + build_goldilocks_sample_r1cs, + build_u64_split_r1cs, + build_byte_window_r1cs, + ]; + let circuits: Vec<_> = builders + .into_par_iter() + .map(|build| { + let r1cs = build(NU); + CscCircuit::from_matrices(&r1cs.a_0, &r1cs.b_0) + .with_const_pin(r1cs.const_pin) + }) + .collect(); + let [ + blake3, + order, + add, + mul, + repack, + canonical, + equality, + sample, + field_sample, + split, + window, + ] = circuits.try_into().ok().expect("eleven Stage 3 lincheck circuits"); + Stage3Linchecks { + blake3, + order, + add, + mul, + repack, + canonical, + equality, + sample, + field_sample, + split, + window, + } + }) +} + #[allow(clippy::too_many_arguments)] fn prove_fri_circuit( shape: &CircuitShape, @@ -3756,10 +4004,19 @@ fn prove_fri_circuit( expected_public: &[F128], transcript_domain: &[u8], ) -> Result> { + let trace = std::env::var_os("IX_FLOCK_TIMING").is_some(); + let total_started = std::time::Instant::now(); + let phase_started = std::time::Instant::now(); let witness = shape.run(inputs, &[]); if witness.public != expected_public { bail!("Flock authenticated-FRI circuit disagrees with native semantics"); } + if trace { + eprintln!( + " [stage3-circuit] evaluate rows: {:.2} ms", + phase_started.elapsed().as_secs_f64() * 1e3, + ); + } let blake3_rows = witness.rows::(slots.blake3); let order_rows = witness.rows::(slots.order); let add_rows = witness.rows::(slots.add); @@ -3776,76 +4033,82 @@ fn prove_fri_circuit( let window_rows = window_slot.map(|slot| witness.rows::(slot)); - let blake3_r1cs = flock_blake3::build_block_r1cs(nu); - let blake3_lincheck = blake3_r1cs.csc_lincheck_circuit(); - let order_r1cs = build_digest_order_r1cs(nu); - let order_lincheck = order_r1cs.csc_lincheck_circuit(); - let add_r1cs = build_goldilocks_add_r1cs(nu); - let add_lincheck = add_r1cs.csc_lincheck_circuit(); - let mul_r1cs = build_goldilocks_mul_r1cs(nu); - let mul_lincheck = mul_r1cs.csc_lincheck_circuit(); - let repack_r1cs = build_lane_repack_r1cs(nu); - let repack_lincheck = repack_r1cs.csc_lincheck_circuit(); - let canonical_r1cs = build_canonical_pair_r1cs(nu); - let canonical_lincheck = canonical_r1cs.csc_lincheck_circuit(); - let equality_r1cs = build_f128_equality_r1cs(nu); - let equality_lincheck = equality_r1cs.csc_lincheck_circuit(); - let sample_r1cs = build_hash_sample_r1cs(nu); - let sample_lincheck = sample_r1cs.csc_lincheck_circuit(); - let field_sample_r1cs = build_goldilocks_sample_r1cs(nu); - let field_sample_lincheck = field_sample_r1cs.csc_lincheck_circuit(); - let split_r1cs = build_u64_split_r1cs(nu); - let split_lincheck = split_r1cs.csc_lincheck_circuit(); - let window_r1cs = build_byte_window_r1cs(nu); - let window_lincheck = window_r1cs.csc_lincheck_circuit(); + let phase_started = std::time::Instant::now(); + let linchecks = stage3_linchecks(); + let blake3_lincheck = &linchecks.blake3; + let order_lincheck = &linchecks.order; + let add_lincheck = &linchecks.add; + let mul_lincheck = &linchecks.mul; + let repack_lincheck = &linchecks.repack; + let canonical_lincheck = &linchecks.canonical; + let equality_lincheck = &linchecks.equality; + let sample_lincheck = &linchecks.sample; + let field_sample_lincheck = &linchecks.field_sample; + let split_lincheck = &linchecks.split; + let window_lincheck = &linchecks.window; + if trace { + eprintln!( + " [stage3-circuit] compile R1CS/lincheck: {:.2} ms", + phase_started.elapsed().as_secs_f64() * 1e3, + ); + } + let phase_started = std::time::Instant::now(); let mut slot_inputs = vec![ ( shape.registry_slot(slots.blake3), - UnionSlotProverInput::new( - flock_blake3::generate_witness_batch_major_partial(blake3_rows, nu), + UnionSlotProverInput::in_place( + move |dst| { + flock_blake3::generate_witness_batch_major_partial_into( + blake3_rows, + nu, + dst, + ) + }, blake3_lincheck, ), ), ( shape.registry_slot(slots.order), - UnionSlotProverInput::new( - generate_digest_order_witness(order_rows, nu), + UnionSlotProverInput::in_place( + move |dst| generate_digest_order_witness_into(order_rows, nu, dst), order_lincheck, ), ), ( shape.registry_slot(slots.add), - UnionSlotProverInput::new( - generate_goldilocks_add_witness(add_rows, nu), + UnionSlotProverInput::in_place( + move |dst| generate_goldilocks_add_witness_into(add_rows, nu, dst), add_lincheck, ), ), ( shape.registry_slot(slots.mul), - UnionSlotProverInput::new( - generate_goldilocks_mul_witness(mul_rows, nu), + UnionSlotProverInput::in_place( + move |dst| generate_goldilocks_mul_witness_into(mul_rows, nu, dst), mul_lincheck, ), ), ( shape.registry_slot(slots.repack), - UnionSlotProverInput::new( - generate_lane_repack_witness(repack_rows, nu), + UnionSlotProverInput::in_place( + move |dst| generate_lane_repack_witness_into(repack_rows, nu, dst), repack_lincheck, ), ), ( shape.registry_slot(slots.canonical), - UnionSlotProverInput::new( - generate_canonical_pair_witness(canonical_rows, nu), + UnionSlotProverInput::in_place( + move |dst| { + generate_canonical_pair_witness_into(canonical_rows, nu, dst) + }, canonical_lincheck, ), ), ( shape.registry_slot(slots.equality), - UnionSlotProverInput::new( - generate_f128_equality_witness(equality_rows, nu), + UnionSlotProverInput::in_place( + move |dst| generate_f128_equality_witness_into(equality_rows, nu, dst), equality_lincheck, ), ), @@ -3853,8 +4116,8 @@ fn prove_fri_circuit( if let (Some(slot), Some(rows)) = (sample_slot, sample_rows) { slot_inputs.push(( shape.registry_slot(slot), - UnionSlotProverInput::new( - generate_hash_sample_witness(rows, nu), + UnionSlotProverInput::in_place( + move |dst| generate_hash_sample_witness_into(rows, nu, dst), sample_lincheck, ), )); @@ -3862,8 +4125,8 @@ fn prove_fri_circuit( if let (Some(slot), Some(rows)) = (slots.field_sample, field_sample_rows) { slot_inputs.push(( shape.registry_slot(slot), - UnionSlotProverInput::new( - generate_goldilocks_sample_witness(rows, nu), + UnionSlotProverInput::in_place( + move |dst| generate_goldilocks_sample_witness_into(rows, nu, dst), field_sample_lincheck, ), )); @@ -3871,8 +4134,8 @@ fn prove_fri_circuit( if let (Some(slot), Some(rows)) = (split_slot, split_rows) { slot_inputs.push(( shape.registry_slot(slot), - UnionSlotProverInput::new( - generate_u64_split_witness(rows, nu), + UnionSlotProverInput::in_place( + move |dst| generate_u64_split_witness_into(rows, nu, dst), split_lincheck, ), )); @@ -3880,8 +4143,8 @@ fn prove_fri_circuit( if let (Some(slot), Some(rows)) = (window_slot, window_rows) { slot_inputs.push(( shape.registry_slot(slot), - UnionSlotProverInput::new( - generate_byte_window_witness(rows, nu), + UnionSlotProverInput::in_place( + move |dst| generate_byte_window_witness_into(rows, nu, dst), window_lincheck, ), )); @@ -3891,6 +4154,13 @@ fn prove_fri_circuit( let union = UnionInstance::new(&shape.registry, shape.counts.clone()); let params = pcs_params(&union); let mut challenger = FsChallenger::with_chained_blake3(transcript_domain); + if trace { + eprintln!( + " [stage3-circuit] assemble prover inputs: {:.2} ms", + phase_started.elapsed().as_secs_f64() * 1e3, + ); + } + let phase_started = std::time::Instant::now(); let (proof, commitment, _) = prover::prove_fast_ligerito_union_circuit( &union, &shape.circuit, @@ -3900,11 +4170,28 @@ fn prove_fri_circuit( Vec::new(), &mut challenger, ); + if trace { + eprintln!( + " [stage3-circuit] Flock prove: {:.2} ms", + phase_started.elapsed().as_secs_f64() * 1e3, + ); + } + let phase_started = std::time::Instant::now(); let proof_bundle_bytes = encode_bundle(&FriFoldProofBundle { commitment, proof })?; if proof_bundle_bytes.len() > MAX_BUNDLE_BYTES { bail!("Flock authenticated-FRI proof exceeds {MAX_BUNDLE_BYTES} bytes"); } + if trace { + eprintln!( + " [stage3-circuit] encode proof: {:.2} ms", + phase_started.elapsed().as_secs_f64() * 1e3, + ); + eprintln!( + " [stage3-circuit] total: {:.2} ms", + total_started.elapsed().as_secs_f64() * 1e3, + ); + } Ok(proof_bundle_bytes) } @@ -3915,35 +4202,25 @@ fn verify_fri_circuit( sample_slot: Option, split_slot: Option, window_slot: Option, - nu: usize, + _nu: usize, public: &[F128], proof_bundle_bytes: &[u8], transcript_domain: &[u8], ) -> Result<()> { let bundle = decode_bundle(proof_bundle_bytes) .context("decode Flock authenticated-FRI conformance proof bundle")?; - let blake3_r1cs = flock_blake3::build_block_r1cs(nu); - let blake3_lincheck = blake3_r1cs.csc_lincheck_circuit(); - let order_r1cs = build_digest_order_r1cs(nu); - let order_lincheck = order_r1cs.csc_lincheck_circuit(); - let add_r1cs = build_goldilocks_add_r1cs(nu); - let add_lincheck = add_r1cs.csc_lincheck_circuit(); - let mul_r1cs = build_goldilocks_mul_r1cs(nu); - let mul_lincheck = mul_r1cs.csc_lincheck_circuit(); - let repack_r1cs = build_lane_repack_r1cs(nu); - let repack_lincheck = repack_r1cs.csc_lincheck_circuit(); - let canonical_r1cs = build_canonical_pair_r1cs(nu); - let canonical_lincheck = canonical_r1cs.csc_lincheck_circuit(); - let equality_r1cs = build_f128_equality_r1cs(nu); - let equality_lincheck = equality_r1cs.csc_lincheck_circuit(); - let sample_r1cs = build_hash_sample_r1cs(nu); - let sample_lincheck = sample_r1cs.csc_lincheck_circuit(); - let field_sample_r1cs = build_goldilocks_sample_r1cs(nu); - let field_sample_lincheck = field_sample_r1cs.csc_lincheck_circuit(); - let split_r1cs = build_u64_split_r1cs(nu); - let split_lincheck = split_r1cs.csc_lincheck_circuit(); - let window_r1cs = build_byte_window_r1cs(nu); - let window_lincheck = window_r1cs.csc_lincheck_circuit(); + let lincheck_cache = stage3_linchecks(); + let blake3_lincheck = &lincheck_cache.blake3; + let order_lincheck = &lincheck_cache.order; + let add_lincheck = &lincheck_cache.add; + let mul_lincheck = &lincheck_cache.mul; + let repack_lincheck = &lincheck_cache.repack; + let canonical_lincheck = &lincheck_cache.canonical; + let equality_lincheck = &lincheck_cache.equality; + let sample_lincheck = &lincheck_cache.sample; + let field_sample_lincheck = &lincheck_cache.field_sample; + let split_lincheck = &lincheck_cache.split; + let window_lincheck = &lincheck_cache.window; let mut linchecks: Vec<(usize, &dyn LincheckCircuit)> = vec![ (shape.registry_slot(slots.blake3), blake3_lincheck), @@ -6438,11 +6715,29 @@ mod tests { let backend = crate::FlockStage3Backend; + let preflight_started = std::time::Instant::now(); + let preflight = backend + .preflight_stage2(&vk_bytes, &claim_bytes, &proof_bytes, &fri) + .expect("preflight complete Stage 3 relation"); + let preflight_elapsed = preflight_started.elapsed(); + let prove_started = std::time::Instant::now(); let artifact = backend .prove_stage2(&vk_bytes, &claim_bytes, &proof_bytes, &fri) .expect("prove complete Stage 3 relation"); let prove_elapsed = prove_started.elapsed(); + assert_eq!( + artifact.statement().stage2_root_digest(), + &preflight.stage2_root_digest, + ); + assert_eq!( + artifact.statement().relation_digest(), + &preflight.relation_digest, + ); + assert_eq!( + artifact.statement().digest(), + preflight.stage3_statement_digest + ); let encode_started = std::time::Instant::now(); let encoded = artifact.to_bytes(); @@ -6487,7 +6782,8 @@ mod tests { concat!( "Flock complete Stage 3 timings (seconds):\n", " fixture setup: {:>10.3}\n", - " prove: {:>10.3}\n", + " relation preflight/setup: {:>10.3}\n", + " proof generation: {:>10.3}\n", " artifact encode: {:>10.3}\n", " artifact decode: {:>10.3}\n", " valid cryptographic verification: {:>10.3}\n", @@ -6498,6 +6794,7 @@ mod tests { " total: {:>10.3}", ), fixture_elapsed.as_secs_f64(), + preflight_elapsed.as_secs_f64(), prove_elapsed.as_secs_f64(), encode_elapsed.as_secs_f64(), decode_elapsed.as_secs_f64(), diff --git a/flock-stage3/host/src/goldilocks.rs b/flock-stage3/host/src/goldilocks.rs index fa4253f3..cf57b4d8 100644 --- a/flock-stage3/host/src/goldilocks.rs +++ b/flock-stage3/host/src/goldilocks.rs @@ -12,10 +12,12 @@ use flock_prover::{ lincheck::pack_z_lincheck, r1cs::{BlockR1cs, SparseBinaryMatrix, WitnessLayout}, schedule::{IoWord, TableType}, + union::SlotWitnessDest, }; use crate::boolean::{ - BooleanR1csBuilder, BooleanR1csPlan, generate_boolean_witness, + BooleanR1csBuilder, BooleanR1csPlan, generate_boolean_rows_into, + generate_boolean_witness, generate_boolean_witness_into, write_f128 as write_boolean_f128, }; @@ -57,7 +59,7 @@ impl GateType for CanonicalGoldilocksPairGate { type Hint = (); fn table(&self) -> TableType { - TableType::from_block_r1cs(&build_canonical_pair_r1cs(self.nu)) + crate::boolean::table_from_block_r1cs(build_canonical_pair_r1cs(self.nu)) .with_io_schema(vec![IoWord::input(0), IoWord::output(1)]) } @@ -151,6 +153,24 @@ pub(crate) fn generate_canonical_pair_witness( ) } +pub(crate) fn generate_canonical_pair_witness_into( + rows: &[CanonicalGoldilocksPairRow], + nu: usize, + dst: SlotWitnessDest<'_>, +) -> Vec { + let r1cs = build_canonical_pair_r1cs(nu); + generate_boolean_rows_into( + r1cs.k_log, + r1cs.useful_bits, + &r1cs.a_0.rows, + &r1cs.b_0.rows, + rows, + nu, + dst, + |row, bits| fill_logical_row(bits, row.0), + ) +} + /// One row of two lane-wise Goldilocks additions packed into `F128` words. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) struct GoldilocksAddPairRow { @@ -174,7 +194,7 @@ impl GateType for GoldilocksAddPairGate { type Hint = (); fn table(&self) -> TableType { - TableType::from_block_r1cs(&build_goldilocks_add_r1cs(self.nu)) + crate::boolean::table_from_block_r1cs(build_goldilocks_add_r1cs(self.nu)) .with_io_schema(vec![ IoWord::input(0), IoWord::input(1), @@ -214,19 +234,35 @@ struct GoldilocksAddPlan { } pub(crate) fn build_goldilocks_add_r1cs(nu: usize) -> BlockR1cs { - build_goldilocks_add_plan().boolean.block_r1cs(nu) + goldilocks_add_plan().boolean.block_r1cs(nu) } pub(crate) fn generate_goldilocks_add_witness( rows: &[GoldilocksAddPairRow], nu: usize, ) -> (Vec, Vec, Vec, Vec) { - let plan = build_goldilocks_add_plan(); + let plan = goldilocks_add_plan(); generate_boolean_witness(&plan.boolean, rows, nu, |row, bits| { - fill_goldilocks_add_row(&plan, *row, bits) + fill_goldilocks_add_row(plan, *row, bits) }) } +pub(crate) fn generate_goldilocks_add_witness_into( + rows: &[GoldilocksAddPairRow], + nu: usize, + dst: SlotWitnessDest<'_>, +) -> Vec { + let plan = goldilocks_add_plan(); + generate_boolean_witness_into(&plan.boolean, rows, nu, dst, |row, bits| { + fill_goldilocks_add_row(plan, *row, bits) + }) +} + +fn goldilocks_add_plan() -> &'static GoldilocksAddPlan { + static PLAN: OnceLock = OnceLock::new(); + PLAN.get_or_init(build_goldilocks_add_plan) +} + fn build_goldilocks_add_plan() -> GoldilocksAddPlan { let mut builder = BooleanR1csBuilder::new(ADD_K_LOG, ADD_RESERVED_COLUMNS); for column in ADD_LEFT_BASE..ADD_RESULT_BASE + 128 { diff --git a/flock-stage3/host/src/merkle.rs b/flock-stage3/host/src/merkle.rs index 92c2d82a..5c0186c1 100644 --- a/flock-stage3/host/src/merkle.rs +++ b/flock-stage3/host/src/merkle.rs @@ -20,7 +20,7 @@ use flock_prover::{ r1cs::BlockR1cs, r1cs_hashes::blake3 as flock_blake3, schedule::{IoWord, TableType}, - union::UnionInstance, + union::{SlotWitnessDest, UnionInstance}, verifier, }; use serde::{Deserialize, Serialize}; @@ -32,7 +32,8 @@ use crate::{ pack8, pcs_params, }, boolean::{ - BooleanR1csBuilder, BooleanR1csPlan, generate_boolean_witness, write_f128, + BooleanR1csBuilder, BooleanR1csPlan, generate_boolean_witness, + generate_boolean_witness_into, write_f128, }, }; @@ -353,7 +354,7 @@ impl GateType for DigestOrderGate { type Hint = (); fn table(&self) -> TableType { - TableType::from_block_r1cs(&build_digest_order_r1cs(self.nu)) + crate::boolean::table_from_block_r1cs(build_digest_order_r1cs(self.nu)) .with_io_schema(vec![ IoWord::input(0), IoWord::input(1), @@ -412,6 +413,21 @@ pub(crate) fn generate_digest_order_witness( }) } +pub(crate) fn generate_digest_order_witness_into( + rows: &[DigestOrderRow], + nu: usize, + dst: SlotWitnessDest<'_>, +) -> Vec { + let plan = build_digest_order_plan(); + generate_boolean_witness_into(&plan, rows, nu, dst, |row, bits| { + bits[BIT_BASE] = row.direction; + write_f128(bits, CURRENT_BASE, row.current[0]); + write_f128(bits, CURRENT_BASE + 128, row.current[1]); + write_f128(bits, SIBLING_BASE, row.sibling[0]); + write_f128(bits, SIBLING_BASE + 128, row.sibling[1]); + }) +} + fn build_digest_order_plan() -> BooleanR1csPlan { let mut builder = BooleanR1csBuilder::new(ORDER_K_LOG, ORDER_RESERVED_COLUMNS); diff --git a/flock-stage3/host/src/multiplication.rs b/flock-stage3/host/src/multiplication.rs index da7e040b..9f8f42e0 100644 --- a/flock-stage3/host/src/multiplication.rs +++ b/flock-stage3/host/src/multiplication.rs @@ -12,16 +12,20 @@ //! before one ripple pass; this is substantially smaller than adding 64 //! shifted partial-product rows sequentially. +use std::sync::OnceLock; + use flock_prover::{ circuit::builder::{GateType, SlotWitness}, field::F128, r1cs::BlockR1cs, schedule::{IoWord, TableType}, + union::SlotWitnessDest, }; use crate::{ boolean::{ - BooleanR1csBuilder, BooleanR1csPlan, generate_boolean_witness, write_f128, + BooleanR1csBuilder, BooleanR1csPlan, generate_boolean_witness, + generate_boolean_witness_into, write_f128, }, goldilocks::GOLDILOCKS_MODULUS, }; @@ -62,7 +66,7 @@ impl GateType for GoldilocksMulPairGate { type Hint = (); fn table(&self) -> TableType { - TableType::from_block_r1cs(&build_goldilocks_mul_r1cs(self.nu)) + crate::boolean::table_from_block_r1cs(build_goldilocks_mul_r1cs(self.nu)) .with_io_schema(vec![ IoWord::input(0), IoWord::input(1), @@ -104,19 +108,35 @@ struct GoldilocksMulPlan { } pub(crate) fn build_goldilocks_mul_r1cs(nu: usize) -> BlockR1cs { - build_goldilocks_mul_plan().boolean.block_r1cs(nu) + goldilocks_mul_plan().boolean.block_r1cs(nu) } pub(crate) fn generate_goldilocks_mul_witness( rows: &[GoldilocksMulPairRow], nu: usize, ) -> (Vec, Vec, Vec, Vec) { - let plan = build_goldilocks_mul_plan(); + let plan = goldilocks_mul_plan(); generate_boolean_witness(&plan.boolean, rows, nu, |row, bits| { - fill_goldilocks_mul_row(&plan, *row, bits) + fill_goldilocks_mul_row(plan, *row, bits) + }) +} + +pub(crate) fn generate_goldilocks_mul_witness_into( + rows: &[GoldilocksMulPairRow], + nu: usize, + dst: SlotWitnessDest<'_>, +) -> Vec { + let plan = goldilocks_mul_plan(); + generate_boolean_witness_into(&plan.boolean, rows, nu, dst, |row, bits| { + fill_goldilocks_mul_row(plan, *row, bits) }) } +fn goldilocks_mul_plan() -> &'static GoldilocksMulPlan { + static PLAN: OnceLock = OnceLock::new(); + PLAN.get_or_init(build_goldilocks_mul_plan) +} + fn build_goldilocks_mul_plan() -> GoldilocksMulPlan { let mut builder = BooleanR1csBuilder::new(MUL_K_LOG, RESERVED_COLUMNS); for column in LEFT_BASE..RESULT_BASE + 128 { diff --git a/flock-stage3/host/src/transcript.rs b/flock-stage3/host/src/transcript.rs index 407436db..61b62fd3 100644 --- a/flock-stage3/host/src/transcript.rs +++ b/flock-stage3/host/src/transcript.rs @@ -39,7 +39,7 @@ use flock_prover::{ fs_chain::{CvSource, FsChain, FsChainTrace}, }, schedule::{IoWord, TableType}, - union::UnionInstance, + union::{SlotWitnessDest, UnionInstance}, verifier, }; use ix_terminal::{ValidatedStage2RootV1, fri_parameter_words}; @@ -50,7 +50,8 @@ use crate::{ FlockConfigV1, STAGE2_TRANSCRIPT_CONFORMANCE_TRANSCRIPT_DOMAIN, binding::{Blake3Gate, IV, pack_bytes, pack_params, pack8, pcs_params}, boolean::{ - BooleanR1csBuilder, BooleanR1csPlan, generate_boolean_witness, write_f128, + BooleanR1csBuilder, BooleanR1csPlan, generate_boolean_witness, + generate_boolean_witness_into, write_f128, }, goldilocks::{ CanonicalGoldilocksPairGate, GOLDILOCKS_MODULUS, build_canonical_pair_r1cs, @@ -563,7 +564,7 @@ impl GateType for HashSampleGate { type Hint = (); fn table(&self) -> TableType { - TableType::from_block_r1cs(&build_hash_sample_r1cs(self.nu)) + crate::boolean::table_from_block_r1cs(build_hash_sample_r1cs(self.nu)) .with_io_schema(vec![IoWord::input(0), IoWord::output(1)]) } @@ -589,6 +590,7 @@ pub(crate) fn build_hash_sample_r1cs( hash_sample_plan().block_r1cs(nu) } +#[cfg(test)] pub(crate) fn generate_hash_sample_witness( rows: &[HashSampleRow], nu: usize, @@ -598,6 +600,22 @@ pub(crate) fn generate_hash_sample_witness( }) } +pub(crate) fn generate_hash_sample_witness_into( + rows: &[HashSampleRow], + nu: usize, + dst: SlotWitnessDest<'_>, +) -> Vec { + generate_boolean_witness_into( + hash_sample_plan(), + rows, + nu, + dst, + |row, bits| { + write_f128(bits, SAMPLE_INPUT_BASE, row.0); + }, + ) +} + fn hash_sample_plan() -> &'static BooleanR1csPlan { static PLAN: std::sync::OnceLock = std::sync::OnceLock::new(); @@ -645,7 +663,7 @@ impl GateType for GoldilocksSampleGate { type Hint = (); fn table(&self) -> TableType { - TableType::from_block_r1cs(&build_goldilocks_sample_r1cs(self.nu)) + crate::boolean::table_from_block_r1cs(build_goldilocks_sample_r1cs(self.nu)) .with_io_schema(vec![ IoWord::input(0), IoWord::input(1), @@ -718,6 +736,25 @@ pub(crate) fn generate_goldilocks_sample_witness( }) } +pub(crate) fn generate_goldilocks_sample_witness_into( + rows: &[GoldilocksSampleRow], + nu: usize, + dst: SlotWitnessDest<'_>, +) -> Vec { + generate_boolean_witness_into( + goldilocks_sample_plan(), + rows, + nu, + dst, + |row, bits| { + write_f128(bits, FIELD_SAMPLE_HIGH_BASE, row.0[0]); + write_f128(bits, FIELD_SAMPLE_LOW_BASE, row.0[1]); + write_f128(bits, FIELD_SAMPLE_REFILL_HIGH_BASE, row.0[2]); + write_f128(bits, FIELD_SAMPLE_REFILL_LOW_BASE, row.0[3]); + }, + ) +} + fn goldilocks_sample_plan() -> &'static BooleanR1csPlan { static PLAN: std::sync::OnceLock = std::sync::OnceLock::new(); @@ -1020,9 +1057,12 @@ impl GateType for U64SplitGate { type Hint = (); fn table(&self) -> TableType { - TableType::from_block_r1cs(&build_u64_split_r1cs(self.nu)).with_io_schema( - vec![IoWord::input(0), IoWord::output(1), IoWord::output(2)], - ) + crate::boolean::table_from_block_r1cs(build_u64_split_r1cs(self.nu)) + .with_io_schema(vec![ + IoWord::input(0), + IoWord::output(1), + IoWord::output(2), + ]) } fn eval( @@ -1047,11 +1087,12 @@ pub(crate) fn build_u64_split_r1cs(nu: usize) -> flock_prover::r1cs::BlockR1cs { u64_split_plan().block_r1cs(nu) } -pub(crate) fn generate_u64_split_witness( +pub(crate) fn generate_u64_split_witness_into( rows: &[U64SplitRow], nu: usize, -) -> (Vec, Vec, Vec, Vec) { - generate_boolean_witness(u64_split_plan(), rows, nu, |row, bits| { + dst: SlotWitnessDest<'_>, +) -> Vec { + generate_boolean_witness_into(u64_split_plan(), rows, nu, dst, |row, bits| { write_f128(bits, U64_SPLIT_INPUT_BASE, row.0); }) } diff --git a/flock-stage3/host/src/window.rs b/flock-stage3/host/src/window.rs index 348c79a6..dc459c79 100644 --- a/flock-stage3/host/src/window.rs +++ b/flock-stage3/host/src/window.rs @@ -11,10 +11,12 @@ use flock_prover::{ field::F128, r1cs::BlockR1cs, schedule::{IoWord, TableType}, + union::SlotWitnessDest, }; use crate::boolean::{ - BooleanR1csBuilder, BooleanR1csPlan, generate_boolean_witness, write_f128, + BooleanR1csBuilder, BooleanR1csPlan, generate_boolean_witness_into, + write_f128, }; const K_LOG: usize = 12; @@ -41,14 +43,13 @@ impl GateType for ByteWindowGate { type Hint = (); fn table(&self) -> TableType { - TableType::from_block_r1cs(&build_byte_window_r1cs(self.nu)).with_io_schema( - vec![ + crate::boolean::table_from_block_r1cs(build_byte_window_r1cs(self.nu)) + .with_io_schema(vec![ IoWord::input(0), IoWord::input(1), IoWord::input(2), IoWord::output(3), - ], - ) + ]) } fn eval( @@ -81,15 +82,22 @@ pub(crate) fn build_byte_window_r1cs(nu: usize) -> BlockR1cs { byte_window_plan().block_r1cs(nu) } -pub(crate) fn generate_byte_window_witness( +pub(crate) fn generate_byte_window_witness_into( rows: &[ByteWindowRow], nu: usize, -) -> (Vec, Vec, Vec, Vec) { - generate_boolean_witness(byte_window_plan(), rows, nu, |row, bits| { - write_f128(bits, FIRST_BASE, row.first); - write_f128(bits, SECOND_BASE, row.second); - write_f128(bits, SELECTOR_BASE, row.selector); - }) + dst: SlotWitnessDest<'_>, +) -> Vec { + generate_boolean_witness_into( + byte_window_plan(), + rows, + nu, + dst, + |row, bits| { + write_f128(bits, FIRST_BASE, row.first); + write_f128(bits, SECOND_BASE, row.second); + write_f128(bits, SELECTOR_BASE, row.selector); + }, + ) } fn byte_window_plan() -> &'static BooleanR1csPlan { From 5d6946d9cd13890e157d4ac1ebd9093f3cbae826 Mon Sep 17 00:00:00 2001 From: "John C. Burnham" Date: Mon, 31 Aug 2026 08:15:00 -0400 Subject: [PATCH 6/6] fix(ci): refresh parallel feature lockfile --- Cargo.lock | 1 + 1 file changed, 1 insertion(+) diff --git a/Cargo.lock b/Cargo.lock index a0f484d4..1e455180 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1699,6 +1699,7 @@ dependencies = [ "flock-prover", "ix-terminal", "multi-stark", + "rayon", "serde", ]