-
Notifications
You must be signed in to change notification settings - Fork 145
feat(verifier-ray): FRI Low-Degree Testing Functions #3651
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 8 commits
6d8e59c
9b7b964
d18f45b
8396b2a
e12818c
d940353
3fd9c71
75bfae5
0793420
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| 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; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,271 @@ | ||
| const std = @import("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 { | ||
|
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 { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Here - we could just check that two digests are equal, not use |
||
|
|
||
| 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; | ||
|
|
||
|
Tabaie marked this conversation as resolved.
|
||
| const generator = fullDomainGenerator(params); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You can instead |
||
| 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 { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. log_size is also comptime known imo. This comes from FRI params.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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:
|
||
| 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 { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Perhaps we don't need the Log2Int here. 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 { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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] }, | ||
| }; | ||
| } | ||
There was a problem hiding this comment.
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.