feat(akita-field): add serde to akita-field - #400
Conversation
Documentation blast radius (advisory)These regions may need doc/spec/book updates based on changed paths. Changed files in this PR: 8
|
PR SummaryMedium Risk Overview Prime fields now encode as fixed-width canonical little-endian byte arrays (4 / 8 / 16 bytes); decode uses New private modules Reviewed by Cursor Bugbot for commit 2368c7c. Bugbot is set up for automated code reviews on this repo. Configure here. |
CI test timing
Run summary
Wall time spans 2 parallel nextest slice shards. Slowest tests
Regressions vs mainNo per-test regressions above the threshold. New slow testsNo new tests ≥30s vs main baseline. |
PCS Profile Benchmark
13 of 13 profiles passed. Times are medians of Each sample verifies the same proof first with the configured multi-threaded pool and then with one thread. Both timings reuse the same verifier setup. Merge-base comparisons are available for Benchmark shards
Public opening statements
One-hot profiles generate deterministic witnesses with one Direct evaluates the public setup contribution during Stage 2. Recursive carries the same check through a Stage 3 setup-product sumcheck. Both modes execute the complete fold schedule and terminal verification. The chunked profiles Generated profiles may select different A, B, and D ring dimensions at different fold levels. The short profile names omit those dimensions. Each sample generates deterministic witnesses and opening points, prepares setup, commits, proves, serializes the proof, checks its size, prepares verifier setup, and verifies the claimed openings. It does not test malformed proofs. Phase time
Memory and setup size
Proof size and protocol shape
Deltas are shown only for profiles with a matching merge-base case. Negative is smaller or faster. Terminal response components
The Detailed schedule and proof-size breakdowns by fold level are available in the uploaded |
| num-traits = "0.2" | ||
| rand_core = { version = "0.6", features = ["getrandom"] } | ||
| rayon = { version = "1.10", optional = true } | ||
| serde = "1" |
There was a problem hiding this comment.
any reason to not use a more recent version? say serde = "1.0.229" or is just to be aligned with jolt?
There was a problem hiding this comment.
serde = "1" already accepts every compatible Serde 1.x release. It is equivalent to Jolt's version = "1.0" requirement. The PR lock currently resolves 1.0.228, while Jolt's latest main resolves 1.0.229. Writing "1.0.229" would raise the minimum accepted version to 1.0.229, but it would not pin that exact release. An exact pin would require "=1.0.229".
We do not use an API introduced in 1.0.229, so I think "1" is the truthful manifest requirement and is aligned with Jolt. If we want the current resolved release in this PR, we can refresh the lock without raising the crate's minimum Serde version.
| impl_ext_serde!(FpExt2, C: FpExt2Config<F>; 2; |coeffs| Self::new(coeffs[0], coeffs[1])); | ||
| impl_ext_serde!(FpExt4; 4; |coeffs| Self::new(coeffs)); |
There was a problem hiding this comment.
Worth aligning the definitions/API or are there reasons to specify C and pass the coeffs explicitly for FpExt2?
There was a problem hiding this comment.
The difference follows the underlying public types. FpExt2<F, C> carries C: FpExt2Config<F> because the quadratic nonresidue configuration is part of its type, and its constructor is new(c0, c1). FpExt4<F> and FpExt8<F> use fixed Akita bases, carry no configuration type, and their constructors take coefficient arrays. Jolt's pending Solinas implementation has the same type and constructor split.
I therefore would not add a forwarding constructor or reshape the field APIs in this Serde PR just to make these three macro calls look identical. We can make the FpExt2 line clearer by destructuring the decoded array as let [c0, c1] = ... before calling Self::new(c0, c1), but C must remain in the implementation because it is an actual parameter of FpExt2.
PR Review findings1. Moderate — the new Serde representation is proof facing in Jolt, but it differs from Jolt's field representation and has no permanent testsThis is not a soundness bug. The implementation in this PR round trips and rejects noncanonical residues. The issue is that the PR makes a concrete field encoding choice that affects serialized Jolt proofs, while the PR describes the Serde surface as host and tooling only and deliberately leaves the representation untested. The exact serialization stack has three steps: "Fixed bytes" is not an alternative to Serde or Bincode. It is one possible value that the field's What this PR doesThe new self.to_canonical_u128().serialize(serializer)For the field value The final encoding is one byte. Bincode's standard configuration uses variable integer encoding. An unsigned integer below What Jolt doesJolt also uses Serde and Bincode. The difference is inside the field's Serde implementation. Jolt's current For an The final encoding is always sixteen bytes. Bincode writes each The two complete paths are therefore: Why this is proof facingThere are two serialization layers in the Akita Jolt integration. The outer The embedded Akita PCS proof remains different. Akita first encodes that inner payload with The actual structure is: It is therefore correct that this PR does not change Akita's inner protocol encoding. It is too broad to say that verifier decoding stays entirely on Concrete failure modeBoth representations work when the writer and reader use the same implementation. They are not compatible with each other. Suppose an old writer uses this PR and encodes A later reader using Jolt's fixed byte convention expects sixteen bytes. It consumes the next fifteen bytes of the proof as part of this field element. Those bytes belong to later proof fields, so decoding loses field boundaries and fails or produces a value that verification rejects. The reverse direction also fails. A reader expecting a Bincode Extension fields inherit the same choice. Why it matters: The choice affects outer Jolt proof bytes, proof size measurements, cached verifier objects, and the later cutover to Jolt's shared Solinas field implementation. The repository does not promise backward compatibility, so old proof compatibility is not itself a violation. The problem is that this PR presents the choice as non-proof-facing, differs from the stated Jolt target, and adds no tests that make the choice explicit. Required correction: Choose and test the intended representation. I recommend matching Jolt by serializing prime fields as fixed canonical little endian byte arrays: For impl<const P: u128> Serialize for Fp128<P> {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
self.to_canonical_u128().to_le_bytes().serialize(serializer)
}
}
impl<'de, const P: u128> Deserialize<'de> for Fp128<P> {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let bytes = <[u8; 16]>::deserialize(deserializer)?;
let value = u128::from_le_bytes(bytes);
Self::from_canonical_u128_checked(value)
.ok_or_else(|| serde::de::Error::custom("non-canonical Fp128 value"))
}
}Add in-tree tests using the exact If numeric Serde is intentional, keep it, but revise the host-only claim and add tests that pin the variable representation. Either choice can be internally correct. The current PR leaves an integration-significant choice implicit and untested. |
Summary
Makes
akita-fieldtypes serializable withserde, so any consumer can encode fieldelements without reaching for Akita's internal wire traits.
Serialize/Deserializeare implemented for the three prime fields (Fp32,Fp64,Fp128) and the three extension towers (FpExt2,FpExt4,FpExt8):residue —
Fp32→[u8; 4],Fp64→[u8; 8],Fp128→[u8; 16]. Decode goesthrough
CanonicalField::from_canonical_u128_checked, so a non-canonical value(
val >= P) is rejected, never silently reduced.[F; K]coefficient array in the same basisorder as
AkitaSerialize, so canonicality follows from the base field's decode andthe width is
Ktimes the base width.serdeis a plain dependency rather than a feature, because a feature nothing forwardsis not reachable:
akita-types,akita-verifier, andakita-proverforward onlyparalleltoakita-field, so a downstream user ofakita-pcswould have had no wayto turn serde on through their normal dependency edge.
Representation
Handing serde a fixed byte array rather than the storage integer is deliberate, and it
is proof-facing.
JoltProofderives serde and boundsPCS::Field: Serialize + Deserialize, and jolt-sdkencodes it with
bincode::config::standard(). Once Akita is the PCS in Jolt, these implstherefore decide how Akita field elements appear in the outer proof bytes, and Jolt's
decode of that outer proof is verifier-reachable. Bincode varint-encodes integers, so the
storage-integer form would make the encoded width depend on the value — one byte for a
small element, seventeen for a typical random
Fp128— and would not interoperate withJolt's own fields, which serialize as
[u8; 4]/[u8; 8]/[u8; 16]. The byte-arrayform is fixed-width, matches that convention, and happens to reproduce the bytes
AkitaSerializealready writes for the same types.What does not change is Akita's inner encoding. The embedded Akita PCS payload is a
Vec<u8>already produced byAkitaSerialize; the outer proof only carries it. Verifier-reachable decoding of Akita containers likewise stays on
AkitaDeserialize, because aserde format bounds sequence lengths only if its consumer configured a limit. That is a
statement about container bounds, not about whether serde reaches a proof.
Impls live beside the types they belong to (
prime/serde_support.rs,ext/serde_support.rs), next to the existingnative_algebra.rs/native_capability.rsboilerplate modules. The trait layer in
traits.rsis untouched and still has noknowledge of any concrete field type. Each module is one macro plus one line per type,
matching the per-type layout that a16z/jolt#1684 adopted for the same problem.
Testing
Nine tests run against Postcard 1.1.3. Postcard varint-encodes integers, so the small
and near-modulus exact-byte cases detect a regression from fixed byte arrays back to
storage integers. The encoded form is a property of the
Serializeimpl and the formattogether:
two values together pin the width, because a varint encoding would use value-dependent
lengths.
three towers.
u128::MAX, and of truncated input; for thetowers, rejection of a non-canonical coefficient and of a short coefficient array.
FpExt2/FpExt4/FpExt8overFp128(32, 64, and 128 bytes).AkitaSerialize, pinning the claim themodule docs make.
postcard1.1.3 is a new dev-dependency only, withdefault-features = falseanduse-std. It pulls incobsandembedded-io; all are covered by thedeny.tomllicense allowlist. Nothing enters the non-dev dependency graph, and the five
check-crate-deps.shgates confirm it.Full preflight:
cargo fmt --all --check,taplo fmt --check, both workspace Clippyconfigurations, all three
akita-fieldfeature graphs,cargo machete --with-metadata,typos, both Rust file-line scripts, the Python script tests, and the doc guardrails.cargo nextest run -p akita-fieldis 150 passing.Security Considerations
Notes for reviewers:
two
modlines, one dependency, and one dev-dependency. No proof, setup, transcript, orAkitaSerializeencoding moves.is fixed-arity and canonical-checked, and failures surface as
serde::de::Errorratherthan a panic. But
AkitaDeserializecapsVeclength atDEFAULT_MAX_SEQUENCE_LEN,whereas a serde format applies only whatever limit its consumer configured. These impls
cannot impose that bound on a consumer's behalf, so verifier-reachable decoding of Akita
containers must stay on
AkitaDeserialize. Both new modules say so in their module docs.serdeis MIT OR Apache-2.0 from crates.io, passesdeny.tomllicense and source allowlists, and is pulled without
derive, so nosyn/quote/proc-macro2enter the graph — justserdeandserde_core, neither with transitivedependencies. The honest cost: because it is not feature-gated,
serdenow enters thedefault dependency graphs of
akita-verifier,akita-prover, andakita-pcsfor thefirst time, which slightly enlarges the audited surface of the crate carrying the verifier
no-panic contract. It is already present in the zkVM guest graph via
jolt-sdk, and theguest is not
no_std(it enablesjolt/guest-std), so nothing here is blocked by therecursion target.
Breaking Changes
None against
main. Additive trait impls on existing public types; no signature, encoding,or feature removals. Note for anyone who built against an earlier commit of this branch:
the prime-field serde encoding changed from the storage integer to fixed little-endian
bytes, so bytes produced by the earlier revision will not decode.