Skip to content

feat(akita-field): add serde to akita-field - #400

Merged
quangvdao merged 5 commits into
mainfrom
feat/add-serde-to-akita-field
Aug 13, 2026
Merged

feat(akita-field): add serde to akita-field#400
quangvdao merged 5 commits into
mainfrom
feat/add-serde-to-akita-field

Conversation

@mtbadakhshan

@mtbadakhshan mtbadakhshan commented Aug 13, 2026

Copy link
Copy Markdown

Summary

Makes akita-field types serializable with serde, so any consumer can encode field
elements without reaching for Akita's internal wire traits.

Serialize / Deserialize are implemented for the three prime fields (Fp32, Fp64,
Fp128) and the three extension towers (FpExt2, FpExt4, FpExt8):

  • Prime fields encode as the fixed-width little-endian bytes of the canonical
    residue — Fp32[u8; 4], Fp64[u8; 8], Fp128[u8; 16]. Decode goes
    through CanonicalField::from_canonical_u128_checked, so a non-canonical value
    (val >= P) is rejected, never silently reduced.
  • Extension fields encode as their [F; K] coefficient array in the same basis
    order as AkitaSerialize, so canonicality follows from the base field's decode and
    the width is K times the base width.

serde is a plain dependency rather than a feature, because a feature nothing forwards
is not reachable: akita-types, akita-verifier, and akita-prover forward only
parallel to akita-field, so a downstream user of akita-pcs would have had no way
to 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.

JoltProof derives serde and bounds PCS::Field: Serialize + Deserialize, and jolt-sdk
encodes it with bincode::config::standard(). Once Akita is the PCS in Jolt, these impls
therefore 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 with
Jolt's own fields, which serialize as [u8; 4] / [u8; 8] / [u8; 16]. The byte-array
form is fixed-width, matches that convention, and happens to reproduce the bytes
AkitaSerialize already 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 by AkitaSerialize; the outer proof only carries it. Verifier-
reachable decoding of Akita containers likewise stays on AkitaDeserialize, because a
serde 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 existing native_algebra.rs / native_capability.rs
boilerplate modules. The trait layer in traits.rs is untouched and still has no
knowledge 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 Serialize impl and the format
together:

  • Exact bytes for each prime field, at a small value and a value near the modulus. The
    two values together pin the width, because a varint encoding would use value-dependent
    lengths.
  • Round trips across zero, small, and wide values for all three prime fields and all
    three towers.
  • Rejection of the modulus itself, of u128::MAX, and of truncated input; for the
    towers, rejection of a non-canonical coefficient and of a short coefficient array.
  • Coefficient basis order and fixed width for FpExt2 / FpExt4 / FpExt8 over
    Fp128 (32, 64, and 128 bytes).
  • Byte-identity between the serde encoding and AkitaSerialize, pinning the claim the
    module docs make.

postcard 1.1.3 is a new dev-dependency only, with default-features = false and
use-std. It pulls in cobs and embedded-io; all are covered by the deny.toml
license allowlist. Nothing enters the non-dev dependency graph, and the five
check-crate-deps.sh gates confirm it.

Full preflight: cargo fmt --all --check, taplo fmt --check, both workspace Clippy
configurations, all three akita-field feature graphs, cargo machete --with-metadata,
typos, both Rust file-line scripts, the Python script tests, and the doc guardrails.
cargo nextest run -p akita-field is 150 passing.

Security Considerations

  • Verifier acceptance behavior is unchanged, or the intended change is specified.
  • Verifier-reachable malformed inputs return typed errors instead of panicking.
  • Transcript labels, challenge order, and domain separation are unchanged.
  • Serialization changes preserve canonical decoding and bounded untrusted input handling.
  • New dependencies, Git dependencies, or CI actions are justified and pass supply-chain policy.
  • Unsafe code is unchanged.

Notes for reviewers:

  • Purely additive. No existing code path changed: the diff is two new private modules,
    two mod lines, one dependency, and one dev-dependency. No proof, setup, transcript, or
    AkitaSerialize encoding moves.
  • Canonical decoding is preserved, container bounds are not claimed. Every decode here
    is fixed-arity and canonical-checked, and failures surface as serde::de::Error rather
    than a panic. But AkitaDeserialize caps Vec length at DEFAULT_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.
  • New dependency surface. serde is MIT OR Apache-2.0 from crates.io, passes deny.toml
    license and source allowlists, and is pulled without derive, so no syn / quote /
    proc-macro2 enter the graph — just serde and serde_core, neither with transitive
    dependencies. The honest cost: because it is not feature-gated, serde now enters the
    default dependency graphs of akita-verifier, akita-prover, and akita-pcs for the
    first 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 the
    guest is not no_std (it enables jolt/guest-std), so nothing here is blocked by the
    recursion 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.

@github-actions github-actions Bot added the no-spec PR has no spec file label Aug 13, 2026
@github-actions

Copy link
Copy Markdown

Documentation blast radius (advisory)

These regions may need doc/spec/book updates based on changed paths.
This is not a merge gate. See docs/documentation.md.

Changed files in this PR: 8

workspace-crates

Workspace membership and public crate surface

Code paths touched:

  • crates/akita-field/Cargo.toml

Consider updating:

  • README.md
  • docs/crate-graph.md
  • book/src/how/architecture.md

field-algebra

Field traits, packed SIMD, extension towers

Code paths touched:

  • crates/akita-field/Cargo.toml
  • crates/akita-field/src/ext/mod.rs
  • crates/akita-field/src/ext/serde_support.rs
  • crates/akita-field/src/prime/mod.rs
  • crates/akita-field/src/prime/serde_support.rs

Consider updating:

  • book/src/foundations/rings-and-fields.md
  • book/src/foundations/ntt-crt.md
  • book/src/how/optimizations.md
  • specs/akita-field-refactor.md
  • specs/crt-ntt-*.md
  • specs/avx-simd-port.md

Per-PR checklist: spec Status / acceptance criteria; book owning page; AGENTS.md if contracts changed; archive spec after fold.

@mtbadakhshan
mtbadakhshan marked this pull request as ready for review August 13, 2026 00:36
@cursor

cursor Bot commented Aug 13, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Serde shapes how field elements appear in Jolt outer proof bytes when Akita is the PCS, but changes are additive with strict canonical decode; inner verifier paths and AkitaDeserialize bounds are unchanged.

Overview
Adds serde as a normal dependency on akita-field and implements Serialize / Deserialize for Fp32, Fp64, Fp128, FpExt2, FpExt4, and FpExt8, so consumers (including Jolt’s outer JoltProof encoding) can serialize field elements without using Akita’s internal wire traits.

Prime fields now encode as fixed-width canonical little-endian byte arrays (4 / 8 / 16 bytes); decode uses from_canonical_u128_checked and rejects val >= P instead of reducing. Extension fields encode as [F; K] coefficients in the same order as AkitaSerialize, with canonicality enforced via the base field.

New private modules prime/serde_support.rs and ext/serde_support.rs are wired from the existing module trees; postcard is added as a dev-dependency for layout, round-trip, rejection, and byte-identity with AkitaSerialize tests. Module docs state that verifier-reachable container decoding stays on AkitaDeserialize (bounded lengths), not serde.

Reviewed by Cursor Bugbot for commit 2368c7c. Bugbot is set up for automated code reviews on this repo. Configure here.

@github-actions

github-actions Bot commented Aug 13, 2026

Copy link
Copy Markdown

CI test timing

  • Report generated: 2026-08-13T17:53:00Z.
  • Source: db6cd8b on feat/add-serde-to-akita-field.
  • Workflow run: 31726801730.
  • Main baseline: bcf3ebd.
  • Previous run: ebea03e.

Run summary

Wall s Main wall s Main Δ Ratio Tests Skipped Failed Status
326.0 337.0 -3.3% 0.97x 1404 0 0 ok

Wall time spans 2 parallel nextest slice shards.

Slowest tests

Rank Duration s Test
1 26.3 akita-planner::schedule_params::recursive_setup_tests::scalar_recursive_nv36_selects_offloaded_schedule
2 18.7 akita-planner::schedule_params::recursive_setup_tests::recursive_exact_cutover_proof_size_is_documented
3 17.7 akita-planner::schedule_params::recursive_setup_tests::recursive_adaptive_search_selects_schedule_dimensions_and_setup_prefixes
4 11.7 akita-pcs::akita_fp128_e2e::fp128_dense
5 3.9 akita-pcs::akita_fp128_e2e::fp128_onehot
6 3.9 akita-pcs::scheme::tests::onehot::multi_group_root_allows_precommitted_arity_above_final_group
7 3.8 akita-prover::kernels::linear::tests::chunking::q128_many_blocks_digits_chunk_instead_of_unsafe_block_parallel
8 3.0 akita-pcs::akita_small_field_e2e::fp32_dense_pre
9 2.9 akita-pcs::akita_fp128_e2e::fp128_dense_batched
10 2.9 akita-pcs::scheme::tests::onehot::three_group_cached_and_streamed_proofs_are_identical
11 2.9 akita-pcs::akita_small_field_e2e::fp64_dense_pre
12 2.8 akita-pcs::akita_small_field_e2e::fp32_dense
13 2.7 akita-sis-estimator::optimizer::tests::proven_pruned_matches_finite_exhaustive_domains
14 2.7 akita-pcs::akita_fp128_e2e::fp128_mixed_batched
15 2.7 akita-config::runtime_fallback::adaptive_dense_searches_multi_group_roots_while_preserving_precommits
16 2.6 akita-pcs::akita_small_field_e2e::fp64_dense
17 2.6 akita-planner::schedule_params::adaptive_search_tests::uniform_suffix_dp_matches_unpruned_exact_cutover_search
18 2.4 akita-pcs::setup::adaptive_dense::large_setup_batch_passes
19 2.3 akita-pcs::setup::adaptive_dense::same_size_passes
20 2.3 akita-pcs::akita_fp128_e2e::fp128_dense_pre

Regressions vs main

No per-test regressions above the threshold.

New slow tests

No new tests ≥30s vs main baseline.

@github-actions

github-actions Bot commented Aug 13, 2026

Copy link
Copy Markdown

PCS Profile Benchmark

13 of 13 profiles passed.

Times are medians of 3 measured runs after 1 discarded warmup run. Peak RSS is the largest measured value.

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 13 of 13 profiles. For matching profiles, the head and merge-base binaries ran interleaved on the same runner.

Benchmark shards

CI shard Profiles
1-fp32-base Fp32 dense nv26, direct setup check
Fp32 one-hot nv30, direct setup check
2-fp64-base Fp64 dense nv26, direct setup check
Fp64 one-hot nv30, direct setup check
3-fp128-base Fp128 dense nv28, direct setup check
Fp128 one-hot nv36, direct setup check
Fp128 one-hot nv36, recursive setup check
4-multi-group-direct Fp128 multi-group, direct setup check
5-multi-group-recursive Fp128 multi-group, recursive setup check
6-multi-group-recursive-w8r2 Fp128 multi-group W8R2, recursive setup check
7-distributed Fp128 one-hot nv32 W2R2, direct setup check
Fp128 one-hot nv32 W4R2, direct setup check
Fp128 one-hot nv32 W8R2, direct setup check

Public opening statements

Public opening statement Profiles
Over Fp32, one committed 26 variable multilinear polynomial with 2^26 coefficients is opened at one 26 coordinate point. Fp32 dense nv26, direct setup check
Over Fp32, one committed 30 variable multilinear polynomial with 2^30 coefficients is opened at one 30 coordinate point. Fp32 one-hot nv30, direct setup check
Over Fp64, one committed 26 variable multilinear polynomial with 2^26 coefficients is opened at one 26 coordinate point. Fp64 dense nv26, direct setup check
Over Fp64, one committed 30 variable multilinear polynomial with 2^30 coefficients is opened at one 30 coordinate point. Fp64 one-hot nv30, direct setup check
Over Fp128, one committed 28 variable multilinear polynomial with 2^28 coefficients is opened at one 28 coordinate point. Fp128 dense nv28, direct setup check
Over Fp128, one committed 36 variable multilinear polynomial with 2^36 coefficients is opened at one 36 coordinate point. Fp128 one-hot nv36, direct setup check
Fp128 one-hot nv36, recursive setup check
Over Fp128, 4 polynomials in 3 groups: one 16 variable polynomial at its own point, one 16 variable polynomial at its own point, and 2 32 variable polynomials at one shared point. Fp128 multi-group, direct setup check
Fp128 multi-group, recursive setup check
Fp128 multi-group W8R2, recursive setup check
Over Fp128, one committed 32 variable multilinear polynomial with 2^32 coefficients is opened at one 32 coordinate point. Fp128 one-hot nv32 W2R2, direct setup check
Fp128 one-hot nv32 W4R2, direct setup check
Fp128 one-hot nv32 W8R2, direct setup check

One-hot profiles generate deterministic witnesses with one 1 in every consecutive chunk of 256 coefficients. This witness shape is not a separate public claim.

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 W2R2, W4R2, W8R2 divide the witness relation into the stated number of exact chunks for the first two fold levels.

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

Profile Setup Commit Prove Verify, multi-threaded Verify, single-threaded
Fp32 dense nv26, direct setup check 0.035 s
-1.1%
0.287 s
-4.8%
1.350 s
+0.2%
15.2 ms
+0.5%
27.9 ms
-0.6%
Fp32 one-hot nv30, direct setup check 0.036 s
+2.1%
0.513 s
-0.0%
2.325 s
-0.6%
15.4 ms
-2.6%
28.2 ms
+0.5%
Fp64 dense nv26, direct setup check 0.071 s
-1.3%
1.143 s
-7.4%
1.561 s
+1.0%
13.4 ms
+4.1%
35.0 ms
-0.2%
Fp64 one-hot nv30, direct setup check 0.055 s
+2.0%
0.375 s
+0.8%
1.568 s
-1.2%
12.6 ms
+7.2%
27.6 ms
+0.7%
Fp128 dense nv28, direct setup check 0.315 s
-0.7%
7.868 s
-0.9%
2.421 s
-2.2%
16.2 ms
-1.3%
94.1 ms
+0.2%
Fp128 one-hot nv36, direct setup check 0.582 s
-0.4%
5.110 s
+3.7%
3.564 s
-0.7%
30.8 ms
+7.0%
221.2 ms
+0.2%
Fp128 one-hot nv36, recursive setup check 3.172 s
-0.1%
5.415 s
+1.7%
4.690 s
+1.0%
21.8 ms
+0.2%
64.9 ms
-0.1%
Fp128 multi-group, direct setup check 0.190 s
+0.6%
1.655 s
+0.2%
1.350 s
-0.1%
17.6 ms
+0.8%
82.6 ms
+0.4%
Fp128 multi-group, recursive setup check 0.780 s
-0.1%
1.638 s
-0.7%
1.865 s
-1.1%
16.3 ms
+0.4%
38.9 ms
+0.7%
Fp128 multi-group W8R2, recursive setup check 0.937 s
-0.1%
1.660 s
-1.2%
4.705 s
+0.6%
28.8 ms
-2.7%
130.9 ms
-0.1%
Fp128 one-hot nv32 W2R2, direct setup check 0.123 s
-3.7%
0.342 s
+3.0%
1.617 s
+2.2%
17.5 ms
+0.3%
68.3 ms
-0.1%
Fp128 one-hot nv32 W4R2, direct setup check 0.124 s
-0.7%
0.340 s
-0.1%
1.994 s
-0.1%
17.5 ms
+3.4%
76.8 ms
+0.0%
Fp128 one-hot nv32 W8R2, direct setup check 0.128 s
+0.6%
0.345 s
+0.8%
3.324 s
-0.7%
22.4 ms
-1.1%
97.5 ms
-0.9%

Memory and setup size

Profile Setup vector Prepared NTT cache Verifier NTT cache Peak RSS
Fp32 dense nv26, direct setup check 16.0 MiB
+0.0%
117.0 MiB
+0.0%
1.2 MiB
+0.0%
1206.0 MiB
-0.0%
Fp32 one-hot nv30, direct setup check 16.0 MiB
+0.0%
117.0 MiB
+0.0%
1.2 MiB
+0.0%
604.2 MiB
+1.0%
Fp64 dense nv26, direct setup check 44.0 MiB
+0.0%
324.0 MiB
+0.0%
2.0 MiB
+0.0%
2521.6 MiB
+0.4%
Fp64 one-hot nv30, direct setup check 32.0 MiB
+0.0%
222.0 MiB
+0.0%
2.0 MiB
+0.0%
912.0 MiB
-0.1%
Fp128 dense nv28, direct setup check 240.0 MiB
+0.0%
1420.0 MiB
+0.0%
1.4 MiB
+0.0%
6740.2 MiB
-0.0%
Fp128 one-hot nv36, direct setup check 688.0 MiB
+0.0%
2501.2 MiB
+0.0%
1.4 MiB
+0.0%
5171.1 MiB
+0.1%
Fp128 one-hot nv36, recursive setup check 1024.0 MiB
+0.0%
2479.0 MiB
+0.0%
1.4 MiB
+0.0%
5692.6 MiB
+0.0%
Fp128 multi-group, direct setup check 256.0 MiB
+0.0%
751.7 MiB
+0.0%
1.4 MiB
+0.0%
1810.2 MiB
+0.1%
Fp128 multi-group, recursive setup check 256.0 MiB
+0.0%
759.7 MiB
+0.0%
1.4 MiB
+0.0%
1867.9 MiB
-0.1%
Fp128 multi-group W8R2, recursive setup check 256.0 MiB
+0.0%
881.6 MiB
+0.0%
1.4 MiB
+0.0%
3060.9 MiB
+0.0%
Fp128 one-hot nv32 W2R2, direct setup check 128.0 MiB
+0.0%
535.0 MiB
+0.0%
1.4 MiB
+0.0%
1313.4 MiB
+0.4%
Fp128 one-hot nv32 W4R2, direct setup check 128.0 MiB
+0.0%
535.0 MiB
+0.0%
1.4 MiB
+0.0%
1505.6 MiB
+0.0%
Fp128 one-hot nv32 W8R2, direct setup check 128.0 MiB
+0.0%
553.5 MiB
+0.0%
1.4 MiB
+0.0%
2291.5 MiB
-0.0%

Proof size and protocol shape

Profile Fold A/B/D schedule Total proof Fold payload Terminal response Fold levels
Fp32 dense nv26, direct setup check 1024/256/256 → 256/256/256 → 128/128/128 78,070 bytes
+0.0%
29,036 bytes
+0.0%
49,034 bytes
+0.0%
7
+0.0%
Fp32 one-hot nv30, direct setup check 1024/256/256 → 256/256/256 → 128/128/128 78,036 bytes
+0.0%
29,020 bytes
+0.0%
49,016 bytes
+0.0%
7
+0.0%
Fp64 dense nv26, direct setup check 512/256/256 → 64/64/64 84,388 bytes
+0.0%
26,428 bytes
+0.0%
57,960 bytes
+0.0%
7
+0.0%
Fp64 one-hot nv30, direct setup check 512/256/256 → 64/64/64 83,088 bytes
+0.0%
25,096 bytes
+0.0%
57,992 bytes
+0.0%
6
+0.0%
Fp128 dense nv28, direct setup check 256/64/64 → 64/64/64 83,355 bytes
+0.0%
25,676 bytes
+0.0%
57,679 bytes
+0.0%
7
+0.0%
Fp128 one-hot nv36, direct setup check 256/128/128 → 64/64/64 84,330 bytes
+0.0%
26,640 bytes
+0.0%
57,690 bytes
+0.0%
8
+0.0%
Fp128 one-hot nv36, recursive setup check 256/128/128 → 64/64/64 88,459 bytes
+0.0%
30,752 bytes
+0.0%
57,707 bytes
+0.0%
8
+0.0%
Fp128 multi-group, direct setup check 256/64/64 → 64/64/64 83,381 bytes
+0.0%
25,676 bytes
+0.0%
57,705 bytes
+0.0%
7
+0.0%
Fp128 multi-group, recursive setup check 256/64/64 → 64/64/64 87,031 bytes
+0.0%
29,360 bytes
+0.0%
57,671 bytes
+0.0%
8
+0.0%
Fp128 multi-group W8R2, recursive setup check 256/128/128 → 64/64/64 88,521 bytes
+0.0%
30,832 bytes
+0.0%
57,689 bytes
+0.0%
8
+0.0%
Fp128 one-hot nv32 W2R2, direct setup check 256/64/64 → 64/64/64 84,115 bytes
+0.0%
26,416 bytes
+0.0%
57,699 bytes
+0.0%
8
+0.0%
Fp128 one-hot nv32 W4R2, direct setup check 256/64/64 → 64/64/64 85,507 bytes
+0.0%
27,808 bytes
+0.0%
57,699 bytes
+0.0%
8
+0.0%
Fp128 one-hot nv32 W8R2, direct setup check 256/64/64 → 64/64/64 86,006 bytes
+0.0%
28,320 bytes
+0.0%
57,686 bytes
+0.0%
8
+0.0%

Deltas are shown only for profiles with a matching merge-base case. Negative is smaller or faster.

Terminal response components
Workload Folded response (z) Opening values (e) Inner-commitment values (t) Total terminal response
Fp32 dense nv26, direct setup check 21,386 bytes 3,072 bytes 24,576 bytes 49,034 bytes
Fp32 one-hot nv30, direct setup check 21,368 bytes 3,072 bytes 24,576 bytes 49,016 bytes
Fp64 dense nv26, direct setup check 22,120 bytes 3,584 bytes 32,256 bytes 57,960 bytes
Fp64 one-hot nv30, direct setup check 22,152 bytes 3,584 bytes 32,256 bytes 57,992 bytes
Fp128 dense nv28, direct setup check 21,839 bytes 7,168 bytes 28,672 bytes 57,679 bytes
Fp128 one-hot nv36, direct setup check 21,850 bytes 7,168 bytes 28,672 bytes 57,690 bytes
Fp128 one-hot nv36, recursive setup check 21,867 bytes 7,168 bytes 28,672 bytes 57,707 bytes
Fp128 multi-group, direct setup check 21,865 bytes 7,168 bytes 28,672 bytes 57,705 bytes
Fp128 multi-group, recursive setup check 21,831 bytes 7,168 bytes 28,672 bytes 57,671 bytes
Fp128 multi-group W8R2, recursive setup check 21,849 bytes 7,168 bytes 28,672 bytes 57,689 bytes
Fp128 one-hot nv32 W2R2, direct setup check 21,859 bytes 7,168 bytes 28,672 bytes 57,699 bytes
Fp128 one-hot nv32 W4R2, direct setup check 21,859 bytes 7,168 bytes 28,672 bytes 57,699 bytes
Fp128 one-hot nv32 W8R2, direct setup check 21,846 bytes 7,168 bytes 28,672 bytes 57,686 bytes

The z column includes its per-segment length prefixes and Golomb payload; e and t are raw field bytes. These three columns sum exactly to the serialized terminal response.

Detailed schedule and proof-size breakdowns by fold level are available in the uploaded report.md benchmark artifact.

num-traits = "0.2"
rand_core = { version = "0.6", features = ["getrandom"] }
rayon = { version = "1.10", optional = true }
serde = "1"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

any reason to not use a more recent version? say serde = "1.0.229" or is just to be aligned with jolt?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +36 to +37
impl_ext_serde!(FpExt2, C: FpExt2Config<F>; 2; |coeffs| Self::new(coeffs[0], coeffs[1]));
impl_ext_serde!(FpExt4; 4; |coeffs| Self::new(coeffs));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worth aligning the definitions/API or are there reasons to specify C and pass the coeffs explicitly for FpExt2?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

sumchecker
sumchecker previously approved these changes Aug 13, 2026
@quangvdao

Copy link
Copy Markdown

PR Review findings

1. Moderate — the new Serde representation is proof facing in Jolt, but it differs from Jolt's field representation and has no permanent tests

This 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:

Rust field element
    ↓
The field's Serialize implementation chooses a Serde value
    ↓
Bincode encodes that Serde value into final bytes

"Fixed bytes" is not an alternative to Serde or Bincode. It is one possible value that the field's Serialize implementation can give to Serde.

What this PR does

The new Fp128 implementation presents a field element to Serde as a u128:

self.to_canonical_u128().serialize(serializer)

For the field value 200, the calls are:

Fp128 value 200
    ↓
Serialize as u128(200)
    ↓
Bincode standard encoding
    ↓
c8

The final encoding is one byte. Bincode's standard configuration uses variable integer encoding. An unsigned integer below 251 takes one byte. Larger integers take a marker byte followed by two, four, eight, or sixteen bytes. A typical random Fp128 value is at least 2^64, so it normally takes seventeen bytes: one fe marker byte plus the sixteen bytes of the integer.

What Jolt does

Jolt also uses Serde and Bincode. The difference is inside the field's Serde implementation. Jolt's current Fr implementation first creates a fixed [u8; 32] and gives that array to Serde. Jolt's pending Solinas field implementation uses the same rule with [u8; 4], [u8; 8], and [u8; 16].

For an Fp128 value of 200, that path is:

Fp128 value 200
    ↓
Serialize as [u8; 16]
    ↓
Bincode standard encoding
    ↓
c8 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00

The final encoding is always sixteen bytes. Bincode writes each u8 directly, and a fixed Rust array has no encoded length.

The two complete paths are therefore:

This PR:
Fp128 → Serde u128 → Bincode variable integer → 1 to 17 bytes

Jolt convention:
Fp128 → Serde [u8; 16] → Bincode byte array → exactly 16 bytes

Why this is proof facing

There are two serialization layers in the Akita Jolt integration.

The outer JoltProof derives Serde and requires PCS::Field: Serialize + Deserialize. Jolt serializes and deserializes that proof using bincode::config::standard(). Its sumcheck messages and claims contain field elements, so this PR determines how those Akita field elements appear in the outer proof bytes.

The embedded Akita PCS proof remains different. Akita first encodes that inner payload with AkitaSerialize, and the outer Jolt proof carries the completed payload as a Vec<u8>. This PR does not change those inner Akita bytes.

The actual structure is:

Outer Jolt proof, encoded with Serde and Bincode

  Jolt sumcheck claims
    Akita Fp128 values
      encoded by this PR's Serde implementation

  Embedded Akita PCS proof
    Vec<u8>
      already encoded by AkitaSerialize

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 AkitaDeserialize. Jolt first decodes the outer proof with Serde and Bincode. Akita then decodes the embedded PCS payload with AkitaDeserialize.

Concrete failure mode

Both 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 Fp128(200) as one byte:

c8

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 u128 consumes only c8 from the sixteen byte representation. It treats the remaining fifteen zero bytes as the start of the next proof field.

Extension fields inherit the same choice. FpExt2<Fp128> serializes two base coefficients. With this PR its size varies with the two coefficient values. With the Jolt convention it is exactly 32 bytes. FpExt4<Fp128> is exactly 64 bytes, and FpExt8<Fp128> is exactly 128 bytes.

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:

Fp32  → [u8; 4]
Fp64  → [u8; 8]
Fp128 → [u8; 16]

For Fp128, the implementation shape is:

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 bincode::config::standard() configuration that Jolt uses. The tests should assert the exact bytes and fixed length for all three prime fields. They should test both small and large values. They should reject the modulus itself as noncanonical. Extension tests should assert round trips, coefficient order, and exact sizes.

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.

@quangvdao quangvdao left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at 2368c7c. The fixed-width canonical field encoding, malformed-input rejection, extension-field ordering, and regression coverage look good. All checks are green.

@quangvdao
quangvdao merged commit a0deb05 into main Aug 13, 2026
45 checks passed
@mtbadakhshan
mtbadakhshan deleted the feat/add-serde-to-akita-field branch August 13, 2026 23:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

no-spec PR has no spec file

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants