diff --git a/Cargo.lock b/Cargo.lock index 1ce4cca7a..6942dadbd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -82,9 +82,11 @@ dependencies = [ "jolt-field", "jolt-transcript", "num-traits", + "postcard", "rand 0.8.6", "rand_core 0.6.4", "rayon", + "serde", "thiserror", ] @@ -567,6 +569,15 @@ version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror", +] + [[package]] name = "const-oid" version = "0.10.2" @@ -746,6 +757,18 @@ version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +[[package]] +name = "embedded-io" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + [[package]] name = "enum-ordinalize" version = "4.3.2" @@ -1372,6 +1395,18 @@ dependencies = [ "plotters-backend", ] +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "serde", +] + [[package]] name = "ppv-lite86" version = "0.2.21" diff --git a/crates/akita-field/Cargo.toml b/crates/akita-field/Cargo.toml index 1bc238a51..1b4acdec5 100644 --- a/crates/akita-field/Cargo.toml +++ b/crates/akita-field/Cargo.toml @@ -22,9 +22,12 @@ jolt-field = { git = "https://github.com/a16z/jolt", rev = "2509bdcea9bb3e8978bb num-traits = "0.2" rand_core = { version = "0.6", features = ["getrandom"] } rayon = { version = "1.10", optional = true } +serde = "1" thiserror = "2.0" [dev-dependencies] +# Binary serde for fixed-width `[u8; N]` encoding tests. +postcard = { version = "1", default-features = false, features = ["use-std"] } jolt-transcript = { git = "https://github.com/a16z/jolt", rev = "2509bdcea9bb3e8978bb578380383ef06dbbada7", default-features = false } rand = "0.8" diff --git a/crates/akita-field/src/ext/mod.rs b/crates/akita-field/src/ext/mod.rs index 07e2df869..43a459079 100644 --- a/crates/akita-field/src/ext/mod.rs +++ b/crates/akita-field/src/ext/mod.rs @@ -10,6 +10,7 @@ mod fp_ext4; mod fp_ext8; pub(crate) mod lift; mod native_algebra; +mod serde_support; #[cfg(test)] mod tests; diff --git a/crates/akita-field/src/ext/serde_support.rs b/crates/akita-field/src/ext/serde_support.rs new file mode 100644 index 000000000..3f2c03fb8 --- /dev/null +++ b/crates/akita-field/src/ext/serde_support.rs @@ -0,0 +1,125 @@ +//! `serde` for the extension fields, as base-coefficient arrays. +//! +//! An element encodes as its `[F; K]` coefficients in the same basis order as +//! [`AkitaSerialize`](akita_serialization::AkitaSerialize), so canonicality +//! follows from the base field's decode. As with the prime fields, +//! verifier-reachable decoding of Akita containers stays on +//! [`AkitaDeserialize`](akita_serialization::AkitaDeserialize), which is the +//! only path that bounds container lengths. + +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +use super::{FpExt2, FpExt2Config, FpExt4, FpExt8}; +use crate::FieldCore; + +macro_rules! impl_ext_serde { + ($ty:ident $(, $cfg:ident: $bound:path)?; $k:literal; |$coeffs:ident| $new:expr) => { + impl Serialize for $ty { + fn serialize(&self, serializer: S) -> Result { + self.coeffs.serialize(serializer) + } + } + + impl<'de, F: FieldCore + Deserialize<'de> $(, $cfg: $bound)?> Deserialize<'de> + for $ty + { + fn deserialize>(deserializer: D) -> Result { + let $coeffs = <[F; $k]>::deserialize(deserializer)?; + Ok($new) + } + } + }; +} + +impl_ext_serde!(FpExt2, C: FpExt2Config; 2; |coeffs| Self::new(coeffs[0], coeffs[1])); +impl_ext_serde!(FpExt4; 4; |coeffs| Self::new(coeffs)); +impl_ext_serde!(FpExt8; 8; |coeffs| Self::new(coeffs)); + +#[cfg(test)] +mod tests { + use serde::de::DeserializeOwned; + + use super::*; + use crate::ext::Ext2; + use crate::prime::Fp128; + use crate::CanonicalField; + + type F = Fp128<0xffff_ffff_ffff_ffff_ffff_ffff_ffff_feed>; + type E2 = Ext2; + type E4 = FpExt4; + type E8 = FpExt8; + + const BASE_WIDTH: usize = 16; + + fn encode(value: T) -> Vec { + postcard::to_stdvec(&value).expect("encoding cannot fail") + } + + fn decode(bytes: &[u8]) -> Result { + postcard::from_bytes(bytes) + } + + fn base(value: u128) -> F { + F::from_canonical_u128_checked(value).expect("value is canonical") + } + + fn assert_layout(bytes: &[u8], coeffs: &[u128]) { + assert_eq!(bytes.len(), coeffs.len() * BASE_WIDTH); + for (index, coeff) in coeffs.iter().enumerate() { + let start = index * BASE_WIDTH; + assert_eq!(&bytes[start..start + BASE_WIDTH], coeff.to_le_bytes()); + } + } + + #[test] + fn coefficients_encode_in_basis_order_at_fixed_width() { + assert_layout(&encode(E2::new(base(1), base(2))), &[1, 2]); + assert_layout( + &encode(E4::new([base(1), base(2), base(3), base(4)])), + &[1, 2, 3, 4], + ); + assert_layout( + &encode(E8::new([ + base(1), + base(2), + base(3), + base(4), + base(5), + base(6), + base(7), + base(8), + ])), + &[1, 2, 3, 4, 5, 6, 7, 8], + ); + } + + #[test] + fn extension_fields_round_trip() { + let e2 = E2::new(base(3), base(0xffff_ffff_ffff_ffff_ffff_ffff_ffff_feec)); + assert_eq!(decode::(&encode(e2)).unwrap(), e2); + + let e4 = E4::new([base(0), base(1), base(1 << 100), base(7)]); + assert_eq!(decode::(&encode(e4)).unwrap(), e4); + + let e8 = E8::new([ + base(0), + base(1), + base(2), + base(1 << 64), + base(4), + base(5), + base(6), + base(0xffff_ffff_ffff_ffff_ffff_ffff_ffff_feec), + ]); + assert_eq!(decode::(&encode(e8)).unwrap(), e8); + } + + #[test] + fn non_canonical_and_short_coefficient_arrays_are_rejected() { + let mut bytes = encode(E2::new(base(1), base(2))); + assert!(decode::(&bytes[..BASE_WIDTH]).is_err()); + + bytes[BASE_WIDTH..].copy_from_slice(&u128::MAX.to_le_bytes()); + assert!(decode::(&bytes).is_err()); + } +} diff --git a/crates/akita-field/src/prime/mod.rs b/crates/akita-field/src/prime/mod.rs index a3ef66994..d0e3dd650 100644 --- a/crates/akita-field/src/prime/mod.rs +++ b/crates/akita-field/src/prime/mod.rs @@ -12,6 +12,7 @@ pub(crate) mod fp64; mod native_algebra; mod native_capability; pub(crate) mod pseudo_mersenne; +mod serde_support; pub(crate) mod util; pub use fp128::{ diff --git a/crates/akita-field/src/prime/serde_support.rs b/crates/akita-field/src/prime/serde_support.rs new file mode 100644 index 000000000..e24dee409 --- /dev/null +++ b/crates/akita-field/src/prime/serde_support.rs @@ -0,0 +1,159 @@ +//! `serde` for the prime fields, as fixed-width canonical little-endian bytes. +//! +//! An element encodes as the little-endian bytes of its canonical representative +//! (`Fp32` → `[u8; 4]`, `Fp64` → `[u8; 8]`, `Fp128` → `[u8; 16]`); decode rejects +//! non-canonical values (`val >= P`) rather than reducing. Encoding the byte +//! array rather than the storage integer keeps the length value-independent +//! under formats that varint-encode integers, matches Jolt's `JoltProof` +//! convention, and agrees with +//! [`AkitaSerialize`](akita_serialization::AkitaSerialize) for these types. +//! +//! This is a host and tooling surface. Verifier-reachable decoding of Akita +//! containers stays on +//! [`AkitaDeserialize`](akita_serialization::AkitaDeserialize), which is the +//! only path that bounds container lengths. + +use serde::{de, Deserialize, Deserializer, Serialize, Serializer}; + +use super::{Fp128, Fp32, Fp64}; +use crate::CanonicalField; + +macro_rules! impl_prime_serde { + ($ty:ident<$p:ident: $p_ty:ty>) => { + impl Serialize for $ty<$p> { + fn serialize(&self, serializer: S) -> Result { + (self.to_canonical_u128() as $p_ty) + .to_le_bytes() + .serialize(serializer) + } + } + + impl<'de, const $p: $p_ty> Deserialize<'de> for $ty<$p> { + fn deserialize>(deserializer: D) -> Result { + let bytes = <[u8; size_of::<$p_ty>()]>::deserialize(deserializer)?; + let raw = <$p_ty>::from_le_bytes(bytes); + Self::from_canonical_u128_checked(u128::from(raw)).ok_or_else(|| { + de::Error::custom(format_args!( + concat!(stringify!($ty), " value {} is not a canonical residue"), + raw + )) + }) + } + } + }; +} + +impl_prime_serde!(Fp32); +impl_prime_serde!(Fp64); +impl_prime_serde!(Fp128); + +#[cfg(test)] +mod tests { + use akita_serialization::AkitaSerialize; + use serde::de::DeserializeOwned; + + use super::*; + + type F32 = Fp32<4_294_967_291>; + type F64 = Fp64<18_446_744_073_709_551_557>; + type F128 = Fp128<0xffff_ffff_ffff_ffff_ffff_ffff_ffff_feed>; + + fn encode(value: T) -> Vec { + postcard::to_stdvec(&value).expect("encoding cannot fail") + } + + fn decode(bytes: &[u8]) -> Result { + postcard::from_bytes(bytes) + } + + fn fp32(value: u128) -> F32 { + F32::from_canonical_u128_checked(value).expect("value is canonical") + } + + fn fp64(value: u128) -> F64 { + F64::from_canonical_u128_checked(value).expect("value is canonical") + } + + fn fp128(value: u128) -> F128 { + F128::from_canonical_u128_checked(value).expect("value is canonical") + } + + #[test] + fn fp32_encodes_as_four_canonical_le_bytes() { + assert_eq!(encode(fp32(200)), [0xc8, 0x00, 0x00, 0x00]); + assert_eq!(encode(fp32(4_294_967_290)), [0xfa, 0xff, 0xff, 0xff]); + } + + #[test] + fn fp64_encodes_as_eight_canonical_le_bytes() { + assert_eq!( + encode(fp64(200)), + [0xc8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00] + ); + assert_eq!( + encode(fp64(18_446_744_073_709_551_556)), + [0xc4, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff] + ); + } + + #[test] + fn fp128_encodes_as_sixteen_canonical_le_bytes() { + let mut small = [0x00; 16]; + small[0] = 0xc8; + assert_eq!(encode(fp128(200)), small); + + let mut large = [0xff; 16]; + large[0] = 0xec; + large[1] = 0xfe; + assert_eq!( + encode(fp128(0xffff_ffff_ffff_ffff_ffff_ffff_ffff_feec)), + large + ); + } + + #[test] + fn prime_fields_round_trip() { + for value in [0, 1, 200, 4_294_967_290] { + assert_eq!(decode::(&encode(fp32(value))).unwrap(), fp32(value)); + } + for value in [0, 1, 200, 4_294_967_296, 18_446_744_073_709_551_556] { + assert_eq!(decode::(&encode(fp64(value))).unwrap(), fp64(value)); + } + for value in [ + 0, + 1, + 200, + 18_446_744_073_709_551_616, + 0xffff_ffff_ffff_ffff_ffff_ffff_ffff_feec, + ] { + assert_eq!(decode::(&encode(fp128(value))).unwrap(), fp128(value)); + } + } + + #[test] + fn non_canonical_and_truncated_encodings_are_rejected() { + assert!(decode::(&encode(4_294_967_291u32.to_le_bytes())).is_err()); + assert!(decode::(&encode(18_446_744_073_709_551_557u64.to_le_bytes())).is_err()); + assert!(decode::(&encode( + 0xffff_ffff_ffff_ffff_ffff_ffff_ffff_feedu128.to_le_bytes() + )) + .is_err()); + assert!(decode::(&encode(u128::MAX.to_le_bytes())).is_err()); + assert!(decode::(&encode(fp128(200))[..15]).is_err()); + } + + #[test] + fn serde_bytes_match_akita_serialize_bytes() { + fn assert_agrees(value: T) { + let mut akita = Vec::new(); + value + .serialize_uncompressed(&mut akita) + .expect("encoding cannot fail"); + assert_eq!(encode(&value), akita); + } + + assert_agrees(fp32(4_294_967_290)); + assert_agrees(fp64(18_446_744_073_709_551_556)); + assert_agrees(fp128(0xffff_ffff_ffff_ffff_ffff_ffff_ffff_feec)); + } +} diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index 9e8d7e319..6e61bda8a 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -30,6 +30,7 @@ dependencies = [ "akita-serialization", "num-traits", "rand_core 0.6.4", + "serde", "thiserror", ] diff --git a/profile/akita-recursion/Cargo.lock b/profile/akita-recursion/Cargo.lock index 8d2e87172..ff49151e3 100644 --- a/profile/akita-recursion/Cargo.lock +++ b/profile/akita-recursion/Cargo.lock @@ -99,6 +99,7 @@ dependencies = [ "num-traits", "rand_core 0.6.4", "rayon", + "serde", "thiserror", ]