Skip to content
Open
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
418 changes: 418 additions & 0 deletions prover-ray/crypto/koalabear/fri/vectors_gen_test.go

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion verifier-ray/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -127,9 +127,10 @@ test-codegen:

generate-testdata:
cd testdata/generate && go run .
cd ../prover-ray/crypto/koalabear/fri && go test -tags gen_vectors -run '^TestGenerateVectors$$' .

verify-testdata: generate-testdata
git diff --exit-code -- testdata/generated/*.zig
git diff --exit-code -- testdata/generated/*.zig testdata/generated/*.json

# Legacy file-based input transport, unused while inputs are embedded; kept for
# reintroduction once input serialization lands.
Expand Down
6 changes: 6 additions & 0 deletions verifier-ray/build.zig
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,11 @@ pub fn build(b: *std.Build) void {
.{ .name = "verifier_ray", .module = verifier_mod },
},
});
const test_fri_vectors_mod = b.addModule("test_fri_vectors", .{
.root_source_file = b.path("testdata/generated/fri_vectors_embed.zig"),
.target = target,
.optimize = optimize,
});

const embedded_data_opts = b.addOptions();
embedded_data_opts.addOption(usize, "spec_index", embedded_spec);
Expand Down Expand Up @@ -130,6 +135,7 @@ pub fn build(b: *std.Build) void {
.{ .name = "verifier_ray", .module = verifier_mod },
.{ .name = "test_vectors", .module = test_vectors_mod },
.{ .name = "test_vanishing", .module = test_vanishing_mod },
.{ .name = "test_fri_vectors", .module = test_fri_vectors_mod },
},
}),
});
Expand Down
50 changes: 50 additions & 0 deletions verifier-ray/src/crypto/merkle.zig
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
const poseidon2 = @import("poseidon2.zig");

/// Merkle authentication for a running FRI layer: a plain complete binary
/// tree over Poseidon2 octuplet leaves (prover-ray's `newCompleteBinaryTree` /
/// `buildTreeExt`), with no auxiliary siblings. This is the running-layer
/// half of prover-ray's `tree.go` `Branch`; the input-tree half (row-preimage
/// branches over the multi-size aux-pair tree, `pcs.go`'s `InputTreeOpening`)
/// lands with the PCS layer.
pub const Error = error{EmptyBranch};

/// A Merkle opening for one running-layer leaf. Unlike a conventional Merkle
/// proof, the branch carries the leaf itself: a FRI query reads the leaf
/// value directly out of the authenticated branch rather than through a
/// separate lookup.
pub const Branch = struct {
/// The deepest leaf reachable through this branch.
leaf: poseidon2.Digest,
/// Sibling digests from the shallowest (just below the root) to the
/// deepest; `siblings[siblings.len - 1]` is `leaf`'s own conjugate.
siblings: []const poseidon2.Digest,

/// Recovers the tree root by re-hashing `leaf` up to the root along
/// `siblings`. `idx`'s bits, least significant first, decide at each
/// level whether the running digest is the left or right child.
pub fn recoverRoot(self: Branch, idx: usize) Error!poseidon2.Digest {
if (self.siblings.len == 0) return Error.EmptyBranch;

var ancestor = self.leaf;
var curr_pos = idx;
var i = self.siblings.len;
while (i != 0) {
i -= 1;
const sibling = self.siblings[i];
const left = if (curr_pos & 1 != 0) sibling else ancestor;
const right = if (curr_pos & 1 != 0) ancestor else sibling;
ancestor = hashNode(left, right, null);
curr_pos >>= 1;
}
return ancestor;
}
};

/// node = compress(left, right); if aux != null: node = compress(node, aux).
/// Mirrors prover-ray's `tree.go` `hashNode`, which calls the Poseidon2
/// compression function directly rather than the sponge.
pub fn hashNode(left: poseidon2.Digest, right: poseidon2.Digest, aux: ?poseidon2.Digest) poseidon2.Digest {
var node = poseidon2.compress(left, right);
if (aux) |a| node = poseidon2.compress(node, a);
return node;
}
2 changes: 2 additions & 0 deletions verifier-ray/src/lib.zig
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ pub const crypto = struct {
pub const commitment = @import("crypto/commitment.zig");
pub const fiat_shamir = @import("crypto/fiat_shamir.zig");
pub const poseidon2 = @import("crypto/poseidon2.zig");
pub const merkle = @import("crypto/merkle.zig");
};

pub const polynomial = struct {
Expand All @@ -23,4 +24,5 @@ pub const polynomial = struct {
pub const query = struct {
pub const vanishing = @import("query/vanishing.zig");
pub const logderivativesum = @import("query/logderivativesum.zig");
pub const fri = @import("query/fri.zig");
};
271 changes: 271 additions & 0 deletions verifier-ray/src/query/fri.zig
Original file line number Diff line number Diff line change
@@ -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.

const field = @import("../field/koalabear.zig");
const ext = @import("../field/koalabear_ext.zig");
const poseidon2 = @import("../crypto/poseidon2.zig");
const merkle = @import("../crypto/merkle.zig");

/// The low-degree-test core: the pure FRI fold recurrence plus running-layer
/// Merkle authentication, ported from prover-ray's `checkOpeningProofShape`,
/// the running-layer loop inside `pcs.Verify`, and `checkFolds`.
///
/// This module never sees a claim, a batching weight, or a committed row: it
/// consumes already-authenticated-and-reconstructed `ResolvedQuery` records.
/// The PCS/DEEP layer (input-tree authentication and the per-level Horner
/// reconstruction) supplies those records; see `query/pcs.zig`.
pub const Error = merkle.Error || error{
InvalidRoundRootCount,
InvalidRunningQueryCount,
InvalidFinalPolyLength,
InsufficientFoldAlphas,
InsufficientPositions,
PositionOutOfRange,
InvalidRunningLayerShape,
InvalidResolvedQueryCount,
MerkleProofInvalid,
NonCanonicalLeaf,
FoldMismatch,
FinalPolyMismatch,
BoundaryAuxNotConstant,
};

/// FRI configuration shared by every check in this module. Comptime, since it
/// is fixed at protocol-compile time and shared with the prover. Mirrors
/// prover-ray's `fri.Params`, restricted to what the low-degree-test core
/// needs; the DEEP/PCS layer carries its own encoder schedule on top.
pub const Params = struct {
Comment thread
ivokub marked this conversation as resolved.
/// log2 of the codeword domain size (`fri.Params.LogCodewordSize`).
log_codeword_size: u8,
/// log2 of the plaintext polynomial size (`fri.Params.LogPlainTextSize`).
log_plaintext_size: u8,
/// log2 of the number of coefficients the final polynomial reveals
/// (`fri.Params.logFinalPolySize`, private in Go); this length is the
/// enforced low-degree bound on the final layer.
log_final_poly_size: u8 = 0,
/// Number of independent FRI queries.
num_queries: usize,

/// 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 (self.log_final_poly_size > self.log_plaintext_size) {
@compileError("fri: log_final_poly_size exceeds log_plaintext_size");
}
if (self.log_plaintext_size >= self.log_codeword_size) {
@compileError("fri: log_plaintext_size must be less than log_codeword_size");
}
}
return self.log_plaintext_size - self.log_final_poly_size;
}
};

/// One fold round's conjugate pair (self, sibling): prover-ray's `inputPair`.
pub const Pair = struct {
self: ext.Ext,
sibling: ext.Ext,
};

/// One query's fully authenticated and reconstructed fold inputs: prover-ray's
/// `resolvedQuery`. `rounds[0]` is never read: round 0 always introduces the
/// top level (see `NewProverState`'s "there being no round -1 to fold a real
/// codeword from"), so `aux[0]` is always present and supersedes it.
pub const ResolvedQuery = struct {
/// rounds[j] = the running codeword's (self, sibling) pair authenticated
/// at round j; filled by `resolveRunningLayers` for j in [1, num_rounds).
/// Length num_rounds, checked by `checkFolds`.
rounds: []const Pair,
/// aux[j] = the level pair introduced at round j (reconstructed by the
/// DEEP/PCS layer), if any; when present it supersedes rounds[j]. Length
/// num_rounds + 1: a level may be introduced at the boundary round
/// (index num_rounds, one past the last fold), authenticated but never
/// folded -- see `checkFolds`, which also checks this length.
aux: []const ?Pair,
/// The final polynomial evaluated at this query's final-domain point.
final: ext.Ext,
};

/// The running-layer proof data `checkOpeningProofShape` / `checkFolds`
/// consume: prover-ray's `fri.Proof`, restricted to the running-layer path.
/// The input-tree openings live in the PCS `OpeningProof`, not here.
pub const Proof = struct {
/// Merkle roots for the running polynomial's committed layers
/// T_1..T_{num_rounds-1}; length `num_rounds - 1`.
round_roots: []const poseidon2.Digest,
/// The final polynomial's revealed coefficients; length
/// `1 << log_final_poly_size`.
final_poly: []const ext.Ext,
/// running_queries[k][j-1] is query k's Merkle branch authenticating
/// round j's leaf/conjugate pair, for j in [1, num_rounds).
running_queries: []const []const merkle.Branch,
};

/// Validates `proof`'s structure against `params` and the challenge/position
/// counts before any authentication or reconstruction runs, so a malformed
/// proof can never cause an out-of-bounds access later. Mirrors prover-ray's
/// `checkOpeningProofShape`.
///
/// `fold_alphas` is read only for its length here — checking that at least
/// `num_rounds` challenges are available — never for arithmetic; the fold
/// challenges themselves are consumed later, by `checkFolds`.
pub fn checkOpeningProofShape(
comptime params: Params,
proof: Proof,
fold_alphas: []const ext.Ext,
positions: []const usize,
) Error!void {
const num_rounds = params.numRounds();
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;
if (positions.len < params.num_queries) return Error.InsufficientPositions;

const codeword_size = @as(usize, 1) << params.log_codeword_size;
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;
}
}

/// Authenticates one query's running-layer branches against `round_roots` and
/// decodes each round's leaf/deepest-sibling pair into `rounds[1..num_rounds)`.
/// `rounds[0]` is left untouched: round 0 always introduces the top level (see
/// `ResolvedQuery`), so its running pair is never read. Mirrors the
/// running-layer loop inside prover-ray's `pcs.Verify`.
///
/// Validates every input length itself, since `rounds` is a caller-allocated
/// output buffer `checkOpeningProofShape` never sees.
pub fn resolveRunningLayers(
comptime params: Params,
round_roots: []const poseidon2.Digest,
query_branches: []const merkle.Branch,
position: usize,
rounds: []Pair,
) Error!void {
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;

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;

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.


rounds[j] = .{
.self = try octupletToExt(branch.leaf),
.sibling = try octupletToExt(branch.siblings[branch.siblings.len - 1]),
};
}
}
Comment on lines +138 to +164

/// Verifies the FRI fold recurrence for every query against values the caller
/// has already authenticated and reconstructed: pure arithmetic, no Merkle
/// proof or row ever passes through it. Mirrors prover-ray's `checkFolds`,
/// including the boundary-round check (a level introduced past the last fold
/// round must have a constant DEEP quotient).
///
/// At each round j, the fold point's inverse is squared from the previous
/// round's rather than recomputed from scratch: x_{j+1} = x_j^2 under the
/// domain-halving map, so 1/x_{j+1} = (1/x_j)^2. Only round 0's inverse is
/// computed directly, from the query position's (bit-reversed) domain point.
pub fn checkFolds(
comptime params: Params,
resolved: []const ResolvedQuery,
fold_alphas: []const ext.Ext,
positions: []const usize,
) Error!void {
const num_rounds = params.numRounds();
if (resolved.len != params.num_queries) return Error.InvalidResolvedQueryCount;
if (fold_alphas.len < num_rounds) return Error.InsufficientFoldAlphas;
if (positions.len < resolved.len) return Error.InsufficientPositions;

Comment thread
Tabaie marked this conversation as resolved.
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.

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();

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

// fold: (self + sib)/2 + alpha * (self - sib)/x, halved once at
// the end rather than twice (see prover-ray's checkFolds).
var sum = pair.self.add(pair.sibling);
var diff = pair.self.sub(pair.sibling);
diff = diff.mulByBase(x_inv);
diff = diff.mul(fold_alphas[j]);
sum = sum.add(diff);
sum = sum.mulByBase(inv_two);

if (j < num_rounds - 1) {
if (!sum.eql(rq.rounds[j + 1].self)) return Error.FoldMismatch;
} else if (!sum.eql(rq.final)) return Error.FinalPolyMismatch;

x_inv = x_inv.square();
}

// A level introduced at the boundary round (index num_rounds) is
// authenticated but never folded: its batched DEEP quotient must
// evaluate identically at both conjugate positions. The num_rounds
// == 0 case (no rounds at all) is handled entirely in query/pcs.zig,
// against the final polynomial directly rather than this check.
if (num_rounds > 0) {
if (rq.aux[num_rounds]) |pair| {
if (!pair.self.eql(pair.sibling)) return Error.BoundaryAuxNotConstant;
}
}
}
}

// 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 };

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)

_ = field.rootOfUnityBy(@as(usize, 1) << params.log_codeword_size) catch
@compileError("fri: log_codeword_size exceeds the supported KoalaBear root-of-unity order");
}
return field.rootOfUnityBy(@as(usize, 1) << params.log_codeword_size) catch unreachable;
}

/// 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

return generator.pow(@as(u64, bitReverse(position, log_size)));
}

fn bitReverse(value: usize, width: u8) usize {
if (width == 0) return 0;
// field.rootOfUnityBy bounds every domain log-size to <= max_order_root
// (24), so the low `width` bits of `value` always fit in a u32 with room
// to spare.
const v: u32 = @intCast(value);
const reversed: u32 = @bitReverse(v);
const shift: std.math.Log2Int(u32) = @intCast(32 - width);
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 shift: std.math.Log2Int(usize) = @intCast(amount);
return value >> shift;
}

/// 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.

if (!digest[6].isZero() or !digest[7].isZero()) return Error.NonCanonicalLeaf;
return .{
.B0 = .{ .a0 = digest[0], .a1 = digest[1] },
.B1 = .{ .a0 = digest[2], .a1 = digest[3] },
.B2 = .{ .a0 = digest[4], .a1 = digest[5] },
};
}
1 change: 1 addition & 0 deletions verifier-ray/test/all.zig
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@ comptime {
_ = @import("transcript_test.zig");
_ = @import("vanishing_test.zig");
_ = @import("logderivativesum_test.zig");
_ = @import("fri_test.zig");
_ = @import("verifier_test.zig");
}
Loading
Loading