Skip to content

FRI arity-4 folding with BatchProof openings#531

Draft
nicole-graus wants to merge 9 commits into
mainfrom
fri-arity-4
Draft

FRI arity-4 folding with BatchProof openings#531
nicole-graus wants to merge 9 commits into
mainfrom
fri-arity-4

Conversation

@nicole-graus
Copy link
Copy Markdown
Collaborator

@nicole-graus nicole-graus commented Apr 22, 2026

Description

Implements FRI arity-4 folding (2 binary folds per committed layer using a quad-leaf Merkle tree) with batch Merkle proofs for the arity-4 trace openings. Each query reveals 4 orbit positions per trace tree via a single shared auth path instead of 4 independent paths.

Benchmark Results

Prover-only (Fibonacci AIR, Criterion median of 10)

Config main this branch Δ prove Δ verify
fib_2col_16k 55.26 ms 70.28 ms +27% 🔴 -31% 🟢
fib_4col_8k 44.97 ms 37.56 ms -16% 🟢 -29% 🟢
fib_4col_16k 74.51 ms 62.50 ms -16% 🟢 -30% 🟢
fib_16col_16k 99.73 ms 90.67 ms -9% 🟢 -24% 🟢

Full VM (Lambda VM)

Size Metric main this branch Δ
1M Prove time 6.66 s 6.35 s -4.7% 🟢
1M Proof size 26.68 MB 23.38 MB -12.4% 🟢
8M Prove time 31.84 s 31.31 s -1.5% ⚪
8M Proof size 120.48 MB 100.17 MB -16.9% 🟢

@nicole-graus
Copy link
Copy Markdown
Collaborator Author

/bench

@github-actions
Copy link
Copy Markdown

github-actions Bot commented Apr 22, 2026

Benchmark — fib_iterative_8M (median of 7)

Table parallelism: auto (cores / 3)

Metric main PR Δ
Peak heap 52516 MB 51782 MB -734 MB (-1.4%) ⚪
Prove time 25.536s 24.940s -0.596s (-2.3%) ⚪

✅ No significant change.

✅ Low variance (time: 2.3%, heap: 0.8%)

Commit: 4d19460 · Baseline: cached · Runner: self-hosted bench

@github-actions
Copy link
Copy Markdown

Codex Code Review

Findings

  1. High - Verifier panic (DoS) via unchecked FRI root count / zeta schedule mismatch
    In verify_query_and_sym_openings, num_double_rounds is derived from zetas.len() and then used to index zetas[zeta_idx] / zetas[zeta_idx + 1] inside a loop over proof.fri_layers_merkle_roots.len().
    There is no validation that fri_layers_merkle_roots.len() matches the protocol-expected count for domain.root_order, so a malformed proof with extra roots can drive zeta_idx past bounds and panic the verifier.

    Action: Before verification, enforce exact expected FRI layer/root count from domain.root_order and reject otherwise.

  2. Medium - Verifier panic (DoS) via unchecked decommitment vector lengths
    The verifier directly indexes per-layer decommitment arrays without length checks:
    fri_decommitment.layers_evaluations_siblings[i] and layers_auth_paths[i].
    A malformed proof with shorter vectors causes out-of-bounds panic instead of clean rejection.

    Action: Validate layers_evaluations_siblings.len() == fri_layers_merkle_roots.len() and layers_auth_paths.len() == fri_layers_merkle_roots.len() before loop.

No other concrete security/logic/performance issues stood out in the PR diff.

.map(|chunk| [chunk[0].clone(), chunk[1].clone()])
// Commit the doubly-folded evaluations as quad (4-element) Merkle leaves.
let leaves: Vec<[FieldElement<E>; 4]> = evals
.chunks_exact(4)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

High – silent data loss / panic for small domains

chunks_exact(4) silently drops trailing elements when evals.len() is not a multiple of 4, and produces zero chunks (leading to a MerkleTree::build panic on an empty slice) when evals.len() < 4.

For number_layers = 3 (domain_size = 8), the double-fold round reduces evals to 2 elements before this line, so zero leaves are produced and the expect below panics. The query_domain_size == 0 guard in sample_query_indexes only protects against domain_size ≤ 3, not this case.

Consider asserting the invariant explicitly:

debug_assert!(
    evals.len() >= 4 && evals.len() % 4 == 0,
    "evals length {} is not a multiple of 4 after double fold",
    evals.len()
);

And add a guard at the top of the function (or at the call site) to skip FRI commit when the folded domain is too small for quad leaves.

Comment on lines +827 to +841
// The "b" pair is {index^2, index^3}; its twiddle position is j_b = (index>>1)^1.
// twiddle_d[j] = lde_coset_element(reverse_index(2^{d+1}·j, N))^{-2^d}
// Sign: index^2 is lo (even) when index is even, hi (odd) when index is odd.
let j_b = (index >> 1) ^ 1;
let lde_idx_b =
reverse_index((1usize << (folds_done + 1)) * j_b, domain.lde_length as u64);
let base_b = domain.lde_coset_element(lde_idx_b);
let twiddle_b_unsigned = base_b.pow(1u64 << folds_done).inv().unwrap();
let layer_eval_inv_b = if index & 1 == 0 {
twiddle_b_unsigned
} else {
-twiddle_b_unsigned
};

// Update `v` with next value pᵢ₊₁(𝜐^(2ⁱ⁺¹)).
v = (&v + evaluation_sym) + evaluation_point_inv * &zetas[i + 1] * (&v - evaluation_sym);
let inner_val_b = (sib_b + sib_c) + layer_eval_inv_b * z_a * (sib_b - sib_c);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

High – hard-to-audit twiddle formula with a suspicious sign condition

This hand-rolled twiddle reconstruction for the "b" pair is the most fragile part of the PR. A few concerns:

  1. Sign condition: if index & 1 == 0 applies negation based on the last bit of index in the committed layer. But the twiddle sign in FRI depends on which "half" of the coset a domain element falls in — that's determined by a bit in the original domain position, not the last bit of the current layer index. After multiple bit-reversal / halving steps the relationship between index & 1 and the correct sign is non-obvious. If this is wrong, the verifier silently accepts or rejects valid proofs with no assertion failure.

  2. Overflow: (1usize << (folds_done + 1)) * j_b overflows for folds_done >= usize::BITS - 1. Unreachable in practice but there's no assertion.

  3. Not tested for the odd-extra path: test_prove_fib_64_rows (7 layers, 3 double rounds) hits fold_is_double for rounds i=0..1 but never for a case where this path runs with a non-zero folds_done value beyond 4.

Consider extracting the twiddle derivation into a named helper with a doc-comment that spells out the math, and adding a unit test that directly validates it against fold_evaluations_in_place.

Comment thread crypto/stark/src/proof/stark.rs Outdated
Comment on lines +16 to +23
pub proof: Proof<Commitment>,
pub proof_sym: Proof<Commitment>,
pub proof_2: Proof<Commitment>,
pub proof_3: Proof<Commitment>,
pub evaluations: Vec<FieldElement<F>>,
pub evaluations_sym: Vec<FieldElement<F>>,
pub evaluations_2: Vec<FieldElement<F>>,
pub evaluations_3: Vec<FieldElement<F>>,
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Low – misleading naming

proof_sym / evaluations_sym now hold data for position index+1 (= index^1 in the arity-4 orbit), not a "symmetric" point in the FRI sense. The _sym suffix has a specific meaning in STARK/FRI literature (the evaluation at -x) which no longer applies here.

Suggest renaming to proof_1 / evaluations_1 to match the proof_2/proof_3 pattern already used below, or at minimum update the struct doc-comment to make clear what each field represents.

Comment on lines +53 to +60
// This is sound for arity-4 FRI: the prover commits *one* combined layer
// that covers both binary folds, so it fixes its evaluations before either
// challenge is revealed. It cannot choose zeta2 adaptively after seeing
// zeta1 because both are sampled from the same transcript state, before
// the commitment is appended.
let zeta1 = transcript.sample_field_element();
let zeta2 = transcript.sample_field_element();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Medium – both challenges sampled before committing: soundness note

The comment argues this is sound because the prover already fixed f_k in the previous commitment and f_{k+2} is fully determined by f_k, zeta1, zeta2. That argument is correct for a honest prover, but it deviates from the standard Fiat-Shamir assumption used in security proofs.

In standard arity-2 FRI the challenge zeta_k is derived from the transcript state including C_k, so the prover cannot choose C_k after seeing zeta_k. Here, zeta1 and zeta2 are both derived from the state before C_{k+1} is appended. A prover that has some slack in choosing f_{k+1} (e.g., from a weak binding), would learn both challenges before fixing the committed polynomial.

If the security proof for this construction has been written up, it would help to reference it in the comment.

@claude
Copy link
Copy Markdown

claude Bot commented Apr 22, 2026

Review: Arity-4 FRI (PR #531)

This PR upgrades the FRI commit/query/verify pipeline from arity-2 (binary folding, pair-leaf Merkle trees) to arity-4 (two binary folds per committed layer, quad-leaf trees), halving the number of Merkle commits and proof elements. The structural change is coherent and the new FieldElementQuadBackend, FriLayerQuadMerkleTree, and CompositionPolyOpenings types are cleanly factored.

High

Panic for small domain sizes (inline on fri/mod.rs:74)
When number_layers == 3 (domain_size = 8), the double-fold loop reduces evals to 2 elements, chunks_exact(4) produces zero chunks, and MerkleTree::build panics on the empty slice. The query_domain_size == 0 guard in sample_query_indexes only short-circuits when domain_size <= 3, missing this case. Either add a minimum-domain check before entering the commit loop, or assert the invariant early.

Fragile hand-rolled twiddle for the "b" pair (inline on verifier.rs:827-841)
The layer_eval_inv_b computation in verify_query_and_sym_openings reimplements twiddle-factor arithmetic from scratch rather than tracking it alongside the fold. The sign branch (if index & 1 == 0) is particularly hard to audit: the correct sign depends on which half of the coset the domain element lands in after bit-reversal and halving, not simply the last bit of the layer index. If this is wrong, the verifier silently rejects valid proofs (or, worse, accepts invalid ones). The existing test covers folds_done = 2 but not higher values. Recommend extracting it to a named helper with a doc-comment showing the derivation, plus a unit test comparing its output against fold_evaluations_in_place.

Medium

Both zeta challenges sampled before committing (inline on fri/mod.rs:53-60)
zeta1 and zeta2 are both derived from the transcript state before the new Merkle root is appended. The comment's soundness argument is correct for an honest prover (since f_{k+2} is determined by f_k, zeta1, zeta2), but this deviates from the standard Fiat-Shamir assumption. A reference to a written security proof would strengthen the review.

Missing test for even number_layers (odd-extra path)
test_prove_fib_64_rows uses number_layers = 7 (odd, no odd-extra branch). The if number_layers.saturating_sub(1) % 2 == 1 branch in the prover and the i >= num_double_rounds verifier path are untested. Add a trace that produces an even number_layers.

Low

Misleading _sym naming (inline on proof/stark.rs:16-23)
proof_sym / evaluations_sym now hold the index^1 element, not a symmetric point in the FRI sense. Rename to proof_1 / evaluations_1 to match the existing _2 / _3 convention.

@nicole-graus nicole-graus changed the title FRI arity-4 folding FRI arity-4 folding with BatchProof openings Apr 22, 2026
@nicole-graus
Copy link
Copy Markdown
Collaborator Author

/bench

@diegokingston
Copy link
Copy Markdown
Collaborator

/bench 7

Self-review follow-ups on the arity-4 FRI change:

Dead code
- `config.rs`: the switch to quad FRI layers left `FriLayerMerkleTree` /
  `FriLayerMerkleTreeBackend` (the old pair-leaf binary-FRI aliases)
  referenced only by their own definitions. Removed them and the stale
  `PairKeccak256Backend` import. `math-cuda/tests/keccak_leaves.rs` was
  the only other user — repointed at `crypto::merkle_tree::backends::
  types::PairKeccak256Backend` directly (same type).
- `verifier.rs`: `DeepPolynomialEvaluations` (a 2-tuple alias) was
  orphaned when `reconstruct_deep_composition_poly_evaluations_for_all_
  queries` started returning a 4-tuple. Removed.

Wrong comment
- `verify_composition_poly_opening` claimed the composition-poly tree
  "has pair leaves (PairKeccak256Backend)". It is a `BatchedMerkleTree`
  whose leaves each hold a row-pair. Comment corrected.

Naming
- `PolynomialOpenings` / `CompositionPolyOpenings` had fields
  `evaluations`, `evaluations_sym`, `evaluations_2`, `evaluations_3`.
  `evaluations_sym` is a binary-FRI carryover ("sym" = the -x point);
  in the arity-4 orbit it is just position index^1. Renamed to
  `evaluations_1` for consistency with `_2` / `_3`.

Behaviour-preserving: 125 stark lib tests pass; lint clean.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants