Skip to content

feat(verifier-ray): FRI Low-Degree Testing Functions - #3651

Open
Tabaie wants to merge 9 commits into
mainfrom
feat/fri-verifier
Open

feat(verifier-ray): FRI Low-Degree Testing Functions#3651
Tabaie wants to merge 9 commits into
mainfrom
feat/fri-verifier

Conversation

@Tabaie

@Tabaie Tabaie commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

This PR implements the basic FRI machinery, tested against generated vectors from the prover-ray side. It does not implement a complete polynomial commitment scheme.

Tabaie added 4 commits July 27, 2026 15:49
Signed-off-by: Arya Tabaie <arya.pourtabatabaie@gmail.com>
Signed-off-by: Arya Tabaie <arya.pourtabatabaie@gmail.com>
Signed-off-by: Arya Tabaie <arya.pourtabatabaie@gmail.com>
Signed-off-by: Arya Tabaie <arya.pourtabatabaie@gmail.com>
@Tabaie
Tabaie requested review from YaoJGalteland, Copilot and ivokub and removed request for ivokub July 27, 2026 22:19

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Adds the core FRI low-degree-testing machinery to verifier-ray, along with a Merkle-branch verifier for running-layer authentication and cross-checked test vectors generated from prover-ray.

Changes:

  • Introduces query/fri.zig implementing proof shape checks, running-layer Merkle authentication, and the FRI fold recurrence.
  • Adds crypto/merkle.zig for Poseidon2-based complete-binary-tree branch verification (running-layer Merkle openings).
  • Adds end-to-end vector-based tests in Zig plus a Go gen_vectors generator and Makefile/build integration to embed and verify the generated JSON fixtures.

Reviewed changes

Copilot reviewed 8 out of 10 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
verifier-ray/src/query/fri.zig New FRI low-degree-test core: shape checks, running-layer verification, and fold recurrence.
verifier-ray/src/crypto/merkle.zig New Merkle branch verification for Poseidon2 complete binary trees used by running layers.
verifier-ray/test/fri_test.zig Vector-driven tests for Merkle root recovery and multi-round FRI fold checking.
verifier-ray/test/all.zig Registers the new FRI test file in the test suite.
verifier-ray/src/lib.zig Exposes new crypto.merkle and query.fri modules from the library root.
verifier-ray/build.zig Wires an embedded-fixtures module for FRI JSON vectors into the unit-test build.
verifier-ray/Makefile Adds generation + verification steps for FRI vector JSON alongside existing generated fixtures.
verifier-ray/testdata/generated/fri_vectors.json Generated JSON test vectors for Merkle and FRI fold cases.
verifier-ray/testdata/generated/fri_vectors_embed.zig Embeds the JSON vectors for Zig tests via @embedFile.
prover-ray/crypto/koalabear/fri/vectors_gen_test.go Go (tagged) generator producing the JSON vectors from prover internals.
Comments suppressed due to low confidence (1)

verifier-ray/src/query/fri.zig:170

  • checkFolds indexes rq.rounds[j], rq.aux[j], and rq.aux[params.num_rounds] without validating those slice lengths (and also assumes aux[0] is present per the doc comment). If a malformed ResolvedQuery is passed, this can read out of bounds or use an uninitialized rounds[0]. Adding local shape/range checks makes the API safer and prevents UB.
pub fn checkFolds(
    comptime params: Params,
    resolved: []const ResolvedQuery,
    fold_alphas: []const ext.Ext,
    positions: []const usize,
) Error!void {
    if (fold_alphas.len < params.num_rounds) return Error.InsufficientFoldAlphas;
    if (positions.len < resolved.len) return Error.InsufficientPositions;

    const generator = fullDomainGenerator(params);
    for (resolved, positions[0..resolved.len]) |rq, s| {
        var x_inv = domainPoint(params.log_codeword_size, generator, s).inverse();

        for (0..params.num_rounds) |j| {
            var pair = rq.rounds[j];
            if (rq.aux[j]) |level_pair| pair = level_pair;

Comment on lines +121 to +142
pub fn resolveRunningLayers(
comptime params: Params,
round_roots: []const poseidon2.Digest,
query_branches: []const merkle.Branch,
position: usize,
rounds: []Pair,
) Error!void {
if (params.num_rounds == 0) return; // no running layers at all (D=1)
for (1..@as(usize, params.num_rounds)) |j| {
const branch = query_branches[j - 1];
const want_siblings = params.log_codeword_size - j;
if (branch.siblings.len != want_siblings) return Error.InvalidRunningLayerShape;

const recovered = try branch.recoverRoot(shr(position, j));
if (!std.meta.eql(recovered, round_roots[j - 1])) return Error.MerkleProofInvalid;

rounds[j] = .{
.self = try octupletToExt(branch.leaf),
.sibling = try octupletToExt(branch.siblings[branch.siblings.len - 1]),
};
}
}
Comment thread verifier-ray/test/fri_test.zig Outdated
Comment on lines +204 to +218
const rounds = try allocator.alloc(fri.Pair, params.num_rounds);
const running_result = fri.resolveRunningLayers(params, round_roots, running_branches, case.position, rounds);

if (case.expect_running_error.len > 0) {
try std.testing.expectError(mapError(case.expect_running_error), running_result);
return;
}
try running_result;

const want_rounds = case.expected_rounds;
for (rounds[1..], want_rounds[1..]) |got, want| {
const want_pair = toPair(want);
try std.testing.expect(got.self.eql(want_pair.self));
try std.testing.expect(got.sibling.eql(want_pair.sibling));
}
Comment thread verifier-ray/src/query/fri.zig
Copilot AI review requested due to automatic review settings July 28, 2026 13:30

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 8 out of 10 changed files in this pull request and generated 4 comments.

Comment thread verifier-ray/src/query/fri.zig Outdated
Comment thread verifier-ray/src/query/fri.zig Outdated
Comment thread verifier-ray/src/query/fri.zig
Comment thread prover-ray/crypto/koalabear/fri/vectors_gen_test.go
Tabaie added 2 commits July 28, 2026 08:45
Signed-off-by: Arya Tabaie <arya.pourtabatabaie@gmail.com>
…neth-monorepo into feat/fri-verifier

Signed-off-by: Arya Tabaie <arya.pourtabatabaie@gmail.com>
Copilot AI review requested due to automatic review settings July 28, 2026 13:45

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 8 out of 10 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (4)

verifier-ray/src/query/fri.zig:116

  • checkOpeningProofShape mixes a u8 "want_round_roots" with slice lengths (usize), and also returns InvalidRunningQueryCount when a per-query branch count is wrong. Using usize for the expected count avoids integer-type friction, and returning InvalidRunningLayerShape for the per-query branch-length mismatch better matches the failure mode.
    const want_round_roots: u8 = if (num_rounds > 0) num_rounds - 1 else 0;
    if (proof.round_roots.len != want_round_roots) return Error.InvalidRoundRootCount;
    if (proof.running_queries.len != params.num_queries) return Error.InvalidRunningQueryCount;
    if (proof.final_poly.len != (@as(usize, 1) << params.log_final_poly_size)) return Error.InvalidFinalPolyLength;
    if (fold_alphas.len < num_rounds) return Error.InsufficientFoldAlphas;

verifier-ray/src/query/fri.zig:153

  • resolveRunningLayers computes want_siblings as params.log_codeword_size - j where j is usize; this should be an explicit usize computation to avoid unsigned underflow/type issues. Also, position should be range-checked here (not just in checkOpeningProofShape), otherwise out-of-range positions can slip through via ignored high bits when shifting.
    for (1..@as(usize, num_rounds)) |j| {
        const branch = query_branches[j - 1];
        const want_siblings = params.log_codeword_size - j;
        if (branch.siblings.len != want_siblings) return Error.InvalidRunningLayerShape;

        const recovered = try branch.recoverRoot(shr(position, j));
        if (!std.meta.eql(recovered, round_roots[j - 1])) return Error.MerkleProofInvalid;

verifier-ray/src/query/fri.zig:54

  • Params.numRounds() should enforce that the number of rounds cannot exceed log_codeword_size; otherwise resolveRunningLayers' log_codeword_size - j sibling-count derivation can underflow for valid-looking Params values.
    pub fn numRounds(comptime self: Params) u8 {
        comptime {
            if (self.log_final_poly_size > self.log_plaintext_size) {
                @compileError("fri: log_final_poly_size exceeds log_plaintext_size");
            }
        }
        return self.log_plaintext_size - self.log_final_poly_size;
    }

verifier-ray/src/query/fri.zig:187

  • checkFolds currently iterates over resolved.len and does not enforce resolved.len == params.num_queries, so callers can accidentally (or maliciously) verify only a subset of queries. It also doesn't bounds-check positions, which can trigger an @intCast safety trap in bitReverse() or cause incorrect domainPoint computation.
    const num_rounds = params.numRounds();
    if (fold_alphas.len < num_rounds) return Error.InsufficientFoldAlphas;
    if (positions.len < resolved.len) return Error.InsufficientPositions;

    const generator = fullDomainGenerator(params);
    for (resolved, positions[0..resolved.len]) |rq, s| {
        if (rq.rounds.len != num_rounds) return Error.InvalidRunningLayerShape;
        if (rq.aux.len != @as(usize, num_rounds) + 1) return Error.InvalidRunningLayerShape;

        var x_inv = domainPoint(params.log_codeword_size, generator, s).inverse();

Signed-off-by: Arya Tabaie <arya.pourtabatabaie@gmail.com>
Copilot AI review requested due to automatic review settings July 28, 2026 14:09

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 8 out of 10 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (3)

verifier-ray/src/query/fri.zig:191

  • checkFolds computes the domain point from s without validating that the position is within the codeword domain. If checkFolds is ever called without a prior checkOpeningProofShape, out-of-range positions will be effectively reduced to their low log_codeword_size bits (via bitReverse), which can make invalid proofs verify against an unintended query point. Add an explicit s < 2^log_codeword_size check (and reuse Error.PositionOutOfRange) before calling domainPoint.
    const generator = fullDomainGenerator(params);
    for (resolved, positions[0..resolved.len]) |rq, s| {
        if (rq.rounds.len != num_rounds) return Error.InvalidRunningLayerShape;
        if (rq.aux.len != @as(usize, num_rounds) + 1) return Error.InvalidRunningLayerShape;

verifier-ray/src/query/fri.zig:150

  • resolveRunningLayers uses position to derive Merkle branch indices (shr(position, j)), but it doesn't validate that position is within the codeword domain. If this helper is called without a prior checkOpeningProofShape, out-of-range positions can be silently reduced (via shifting) and potentially authenticate the wrong leaf path. Add an explicit position < 2^log_codeword_size check here to make the function self-contained and safer to use.

This issue also appears on line 187 of the same file.

    const num_rounds = params.numRounds();
    if (num_rounds == 0) return; // no running layers at all (D=1)
    if (rounds.len != num_rounds) return Error.InvalidRunningLayerShape;
    if (query_branches.len != num_rounds - 1) return Error.InvalidRunningLayerShape;
    if (round_roots.len != num_rounds - 1) return Error.InvalidRunningLayerShape;

verifier-ray/src/query/fri.zig:229

  • inv_two is duplicated as a raw modulus-specific constant. Since field.Element already provides init and inverse, computing 2^{-1} from those avoids a hard-coded magic number and stays correct if the field parameters ever change (or if this code is reused for a different field).
// 2^{-1} mod p (p = 2_130_706_433 = 2^31 - 2^24 + 1). Duplicated from
// crypto/poseidon2.zig's inv2Exp1, which is private to that module.
const inv_two: field.Element = .{ .value = 1_065_353_217 };

Copilot AI review requested due to automatic review settings July 29, 2026 20:36

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 8 out of 10 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (2)

verifier-ray/src/query/fri.zig:197

  • checkFolds unconditionally reads rq.rounds[j] before checking rq.aux[j]. For j==0 the doc comment says aux[0] supersedes rounds[0] and rounds[0] is “never read”, but the current code still reads rounds[0] to initialize pair, which can be uninitialized memory. Avoid the unconditional read (and enforce aux[0] presence when num_rounds>0) by selecting the pair via an if-expression.
        for (0..num_rounds) |j| {
            var pair = rq.rounds[j];
            if (rq.aux[j]) |level_pair| pair = level_pair;

verifier-ray/src/query/fri.zig:125

  • In checkOpeningProofShape, a per-query branch-count mismatch (query_branches.len != want_round_roots) is reported as InvalidRunningQueryCount, which is misleading because the query count is correct; the shape of each running layer is what's wrong. Returning InvalidRunningLayerShape here makes the error semantics consistent with other shape checks in this module.
    for (proof.running_queries, positions[0..params.num_queries]) |query_branches, position| {
        if (query_branches.len != want_round_roots) return Error.InvalidRunningQueryCount;
        if (position >= codeword_size) return Error.PositionOutOfRange;

@ivokub ivokub left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

In principle looks good. There are a few optimizations here and there what we could do, but I'm also fine if we postpone the optimization work for later (i.e. comptime usage etc.). But Imo minimally two things I would prefer to do:

  • define the fixture generator here on the verifier-ray side. If something is not public prover side then lets add a small PR which makes methods/types public what we need.
  • for fixtures lets write the Zig source directly (i.e. lets not do JSON serialization/deserialization).
  • avoid std import in fri.zig. I think we can completely avoid that (its only an equality of arrays and Log2Int method which imo we don't need).

For the rest of the comments -- I'm fine to postpone for a later bigger optimization round.

@@ -0,0 +1,271 @@
const std = @import("std");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I'd avoid importing std - this can bring in unwanted side effects/imports. Our goal would be to have as small and fast as possible implementation (so that we can execute verifier in zkVM for proof recursion).

I see down that there are essentially only a few places it is being used and I think we can avoid std.

if (branch.siblings.len != want_siblings) return Error.InvalidRunningLayerShape;

const recovered = try branch.recoverRoot(shr(position, j));
if (!std.meta.eql(recovered, round_roots[j - 1])) return Error.MerkleProofInvalid;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Here - we could just check that two digests are equal, not use std.meta.eql. Right now it seems we don't have a dedicated "equals" method on poseidon2.Digest, but it can be added nicely.


/// Converts a Poseidon2 digest into an extension element. Expects
/// coordinates 6 and 7 to be zero, matching prover-ray's `octupletToExt`.
fn octupletToExt(digest: poseidon2.Digest) Error!ext.Ext {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I'd move it into poseidon2.zig to be a method on Digest.

return reversed >> shift;
}

fn shr(value: usize, amount: usize) usize {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Perhaps we don't need the Log2Int here. amount parameter is essentially comptime known (it is a loop index), so perhaps we can just define

fn shr(value: usize, comptime amount: usize) usize {
    return value >> shift;
}

and then it becomes obvious enoufh that we can just inline it in the calling place? It is called only once imo.

const inv_two: field.Element = .{ .value = 1_065_353_217 };

fn fullDomainGenerator(comptime params: Params) field.Element {
comptime {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I don't understand why we have comptime check first and then we return runtime. Essentially, the generator is already known at compile time (from the FRI params), so this could be completely inlined at compile time (i.e. the generator will be embedded in the code already)

/// x = g^{bitrev_{log_size}(position)}, where g generates the size-2^log_size
/// subgroup. Matches prover-ray's `domainPoint`: the codeword is stored
/// bit-reversed so that FRI conjugate pairs land at adjacent positions.
fn domainPoint(log_size: u8, generator: field.Element, position: usize) field.Element {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

log_size is also comptime known imo. This comes from FRI params.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

And we can possibly use u32 or u64 for the position directly, then we don't need the type casts below. Considerations:

  • u64 -- we target R64IM anyway, so there is no difference in we use u32 vs u64 -- we would be using same instructions (I think there are no dedicated u32 arithmetic in R5-64IM). And pow takes u64 as input, so its more convenient (but I think it should take u32 due to 2-adicity anyway)
  • u32 -- the codeword size is bounded by 2-adicity anyway (imo 24), so cannot be bigger than u32. And perhaps this would also allow later to instead use R32 for example if we want to try out some more efficient arithmetization for recursion even


/// Number of folding rounds: `fri.Params.numRounds()`.
pub fn numRounds(comptime self: Params) u8 {
comptime {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Perhaps we can add comptime check that num_queries != 0. Otherwise any proof verifies. Normally it is always set, but then it ensures that in case there is some serialization bug etc we wouldn't have problems.

if (fold_alphas.len < num_rounds) return Error.InsufficientFoldAlphas;
if (positions.len < resolved.len) return Error.InsufficientPositions;

const generator = fullDomainGenerator(params);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

You can instead const generator = comptime fullDomainGenerator(params);. Then the runtime is a bit faster.

@@ -0,0 +1,418 @@
//go:build gen_vectors

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Imo the preferred approach would be instead of doing "push-based" test vector generation from prover-ray to rather be pull-based in verifier-ray. So for example for rest of testdata generation (but also the code generation we use to generate the Zig representation of Go-side wiop.System) we just read the prover-ray structures and parse the information.

This way:

  • we can pin the supported prover-ray version in verifier-ray (i.e. we have in go.mod for the codegen/fixturegen concrete prover-ray commits what we're using). This allows the prover to iterate while not caring about implementing same updates on the verifier side. Later on when things are stable we can probably keep the prover and verifier synced, but imo for now it has worked well
  • we can avoid doing some serialization round (i.e. right now everything goes through JSON). It is fine to have it, but still an unnecessary step what for tests adds overhead. For example, when we do pull-based approach (i.e. here we just compute the proof), then we can just write Zig source with the data, no serialization needed. See for example the vanishing/verify tests where we directly use templates to write output. Have a look how the test fixtures there look and how we generate.

When there is something which is not public on the prover side, then we can just create a separate PR there making things public (i.e. NewCompleteBinaryTree imo) and then we can use the pinned version here.

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.

4 participants