diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 15cf08b..3eea418 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,6 +10,12 @@ concurrency: group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} cancel-in-progress: true +# Fail every cargo invocation on warnings from workspace-local packages +# (cargo's build.warnings, stable since 1.97). CI-only: local builds +# still just warn. +env: + CARGO_BUILD_WARNINGS: deny + jobs: linux-test: # Change to warp-ubuntu-latest-x64-16x for a more powerful runner (GitHub App must be enabled for this repo) @@ -42,7 +48,7 @@ jobs: - name: Check *everything* compiles run: cargo check --all-targets --all-features --workspace - name: Check clippy lints - run: cargo clippy --workspace --all-targets --all-features -- -D warnings + run: cargo clippy --workspace --all-targets --all-features - name: Doctests run: cargo test --doc --workspace - name: Get Rust version diff --git a/Cargo.lock b/Cargo.lock index 7d11a6e..e62c6a5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -342,9 +342,9 @@ dependencies = [ [[package]] name = "itertools" -version = "0.14.0" +version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc" dependencies = [ "either", ] @@ -377,6 +377,12 @@ version = "0.2.183" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -432,6 +438,7 @@ version = "0.1.0" dependencies = [ "bincode", "criterion", + "num-bigint", "p3-air", "p3-baby-bear", "p3-blake3", @@ -465,9 +472,9 @@ dependencies = [ [[package]] name = "num-bigint" -version = "0.4.6" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +checksum = "93e7820bc0a80a0238e650327316f929ba18d5be054b647490a3a6a339f3e7c0" dependencies = [ "num-integer", "num-traits", @@ -505,20 +512,20 @@ checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" [[package]] name = "p3-air" -version = "0.5.1" -source = "git+https://github.com/Plonky3/Plonky3?rev=e9d75614dd6816f9b5dbb4413c69be63536efd64#e9d75614dd6816f9b5dbb4413c69be63536efd64" +version = "0.6.0" +source = "git+https://github.com/Plonky3/Plonky3?rev=3c84c158c0939345a3becba60a387643935593d2#3c84c158c0939345a3becba60a387643935593d2" dependencies = [ "p3-field", "p3-matrix", + "serde", "tracing", ] [[package]] name = "p3-baby-bear" -version = "0.5.1" -source = "git+https://github.com/Plonky3/Plonky3?rev=e9d75614dd6816f9b5dbb4413c69be63536efd64#e9d75614dd6816f9b5dbb4413c69be63536efd64" +version = "0.6.0" +source = "git+https://github.com/Plonky3/Plonky3?rev=3c84c158c0939345a3becba60a387643935593d2#3c84c158c0939345a3becba60a387643935593d2" dependencies = [ - "p3-challenger", "p3-field", "p3-mds", "p3-monty-31", @@ -530,8 +537,8 @@ dependencies = [ [[package]] name = "p3-blake3" -version = "0.5.1" -source = "git+https://github.com/Plonky3/Plonky3?rev=e9d75614dd6816f9b5dbb4413c69be63536efd64#e9d75614dd6816f9b5dbb4413c69be63536efd64" +version = "0.6.0" +source = "git+https://github.com/Plonky3/Plonky3?rev=3c84c158c0939345a3becba60a387643935593d2#3c84c158c0939345a3becba60a387643935593d2" dependencies = [ "blake3", "p3-symmetric", @@ -540,12 +547,11 @@ dependencies = [ [[package]] name = "p3-challenger" -version = "0.5.1" -source = "git+https://github.com/Plonky3/Plonky3?rev=e9d75614dd6816f9b5dbb4413c69be63536efd64#e9d75614dd6816f9b5dbb4413c69be63536efd64" +version = "0.6.0" +source = "git+https://github.com/Plonky3/Plonky3?rev=3c84c158c0939345a3becba60a387643935593d2#3c84c158c0939345a3becba60a387643935593d2" dependencies = [ "p3-field", "p3-maybe-rayon", - "p3-monty-31", "p3-symmetric", "p3-util", "tracing", @@ -553,25 +559,23 @@ dependencies = [ [[package]] name = "p3-commit" -version = "0.5.1" -source = "git+https://github.com/Plonky3/Plonky3?rev=e9d75614dd6816f9b5dbb4413c69be63536efd64#e9d75614dd6816f9b5dbb4413c69be63536efd64" +version = "0.6.0" +source = "git+https://github.com/Plonky3/Plonky3?rev=3c84c158c0939345a3becba60a387643935593d2#3c84c158c0939345a3becba60a387643935593d2" dependencies = [ - "itertools 0.14.0", - "p3-challenger", - "p3-dft", + "itertools 0.15.0", "p3-field", - "p3-interpolation", "p3-matrix", + "p3-multilinear-util", "p3-util", "serde", ] [[package]] name = "p3-dft" -version = "0.5.1" -source = "git+https://github.com/Plonky3/Plonky3?rev=e9d75614dd6816f9b5dbb4413c69be63536efd64#e9d75614dd6816f9b5dbb4413c69be63536efd64" +version = "0.6.0" +source = "git+https://github.com/Plonky3/Plonky3?rev=3c84c158c0939345a3becba60a387643935593d2#3c84c158c0939345a3becba60a387643935593d2" dependencies = [ - "itertools 0.14.0", + "itertools 0.15.0", "p3-field", "p3-matrix", "p3-maybe-rayon", @@ -582,10 +586,10 @@ dependencies = [ [[package]] name = "p3-field" -version = "0.5.1" -source = "git+https://github.com/Plonky3/Plonky3?rev=e9d75614dd6816f9b5dbb4413c69be63536efd64#e9d75614dd6816f9b5dbb4413c69be63536efd64" +version = "0.6.0" +source = "git+https://github.com/Plonky3/Plonky3?rev=3c84c158c0939345a3becba60a387643935593d2#3c84c158c0939345a3becba60a387643935593d2" dependencies = [ - "itertools 0.14.0", + "itertools 0.15.0", "num-bigint", "p3-maybe-rayon", "p3-util", @@ -597,17 +601,17 @@ dependencies = [ [[package]] name = "p3-fri" -version = "0.5.1" -source = "git+https://github.com/Plonky3/Plonky3?rev=e9d75614dd6816f9b5dbb4413c69be63536efd64#e9d75614dd6816f9b5dbb4413c69be63536efd64" +version = "0.6.0" +source = "git+https://github.com/Plonky3/Plonky3?rev=3c84c158c0939345a3becba60a387643935593d2#3c84c158c0939345a3becba60a387643935593d2" dependencies = [ - "itertools 0.14.0", + "itertools 0.15.0", "p3-challenger", "p3-commit", "p3-dft", "p3-field", - "p3-interpolation", "p3-matrix", "p3-maybe-rayon", + "p3-security", "p3-util", "rand", "serde", @@ -618,11 +622,10 @@ dependencies = [ [[package]] name = "p3-goldilocks" -version = "0.5.1" -source = "git+https://github.com/Plonky3/Plonky3?rev=e9d75614dd6816f9b5dbb4413c69be63536efd64#e9d75614dd6816f9b5dbb4413c69be63536efd64" +version = "0.6.0" +source = "git+https://github.com/Plonky3/Plonky3?rev=3c84c158c0939345a3becba60a387643935593d2#3c84c158c0939345a3becba60a387643935593d2" dependencies = [ "num-bigint", - "p3-challenger", "p3-dft", "p3-field", "p3-mds", @@ -633,23 +636,13 @@ dependencies = [ "paste", "rand", "serde", -] - -[[package]] -name = "p3-interpolation" -version = "0.5.1" -source = "git+https://github.com/Plonky3/Plonky3?rev=e9d75614dd6816f9b5dbb4413c69be63536efd64#e9d75614dd6816f9b5dbb4413c69be63536efd64" -dependencies = [ - "p3-field", - "p3-matrix", - "p3-maybe-rayon", - "p3-util", + "spin", ] [[package]] name = "p3-keccak" -version = "0.5.1" -source = "git+https://github.com/Plonky3/Plonky3?rev=e9d75614dd6816f9b5dbb4413c69be63536efd64#e9d75614dd6816f9b5dbb4413c69be63536efd64" +version = "0.6.0" +source = "git+https://github.com/Plonky3/Plonky3?rev=3c84c158c0939345a3becba60a387643935593d2#3c84c158c0939345a3becba60a387643935593d2" dependencies = [ "p3-symmetric", "p3-util", @@ -658,10 +651,10 @@ dependencies = [ [[package]] name = "p3-matrix" -version = "0.5.1" -source = "git+https://github.com/Plonky3/Plonky3?rev=e9d75614dd6816f9b5dbb4413c69be63536efd64#e9d75614dd6816f9b5dbb4413c69be63536efd64" +version = "0.6.0" +source = "git+https://github.com/Plonky3/Plonky3?rev=3c84c158c0939345a3becba60a387643935593d2#3c84c158c0939345a3becba60a387643935593d2" dependencies = [ - "itertools 0.14.0", + "itertools 0.15.0", "p3-field", "p3-maybe-rayon", "p3-util", @@ -672,16 +665,16 @@ dependencies = [ [[package]] name = "p3-maybe-rayon" -version = "0.5.1" -source = "git+https://github.com/Plonky3/Plonky3?rev=e9d75614dd6816f9b5dbb4413c69be63536efd64#e9d75614dd6816f9b5dbb4413c69be63536efd64" +version = "0.6.0" +source = "git+https://github.com/Plonky3/Plonky3?rev=3c84c158c0939345a3becba60a387643935593d2#3c84c158c0939345a3becba60a387643935593d2" dependencies = [ "rayon", ] [[package]] name = "p3-mds" -version = "0.5.1" -source = "git+https://github.com/Plonky3/Plonky3?rev=e9d75614dd6816f9b5dbb4413c69be63536efd64#e9d75614dd6816f9b5dbb4413c69be63536efd64" +version = "0.6.0" +source = "git+https://github.com/Plonky3/Plonky3?rev=3c84c158c0939345a3becba60a387643935593d2#3c84c158c0939345a3becba60a387643935593d2" dependencies = [ "p3-dft", "p3-field", @@ -692,10 +685,10 @@ dependencies = [ [[package]] name = "p3-merkle-tree" -version = "0.5.1" -source = "git+https://github.com/Plonky3/Plonky3?rev=e9d75614dd6816f9b5dbb4413c69be63536efd64#e9d75614dd6816f9b5dbb4413c69be63536efd64" +version = "0.6.0" +source = "git+https://github.com/Plonky3/Plonky3?rev=3c84c158c0939345a3becba60a387643935593d2#3c84c158c0939345a3becba60a387643935593d2" dependencies = [ - "itertools 0.14.0", + "itertools 0.15.0", "p3-commit", "p3-field", "p3-matrix", @@ -704,16 +697,17 @@ dependencies = [ "p3-util", "rand", "serde", + "spin", "thiserror", "tracing", ] [[package]] name = "p3-monty-31" -version = "0.5.1" -source = "git+https://github.com/Plonky3/Plonky3?rev=e9d75614dd6816f9b5dbb4413c69be63536efd64#e9d75614dd6816f9b5dbb4413c69be63536efd64" +version = "0.6.0" +source = "git+https://github.com/Plonky3/Plonky3?rev=3c84c158c0939345a3becba60a387643935593d2#3c84c158c0939345a3becba60a387643935593d2" dependencies = [ - "itertools 0.14.0", + "itertools 0.15.0", "num-bigint", "p3-dft", "p3-field", @@ -731,20 +725,36 @@ dependencies = [ "tracing", ] +[[package]] +name = "p3-multilinear-util" +version = "0.6.0" +source = "git+https://github.com/Plonky3/Plonky3?rev=3c84c158c0939345a3becba60a387643935593d2#3c84c158c0939345a3becba60a387643935593d2" +dependencies = [ + "itertools 0.15.0", + "p3-field", + "p3-matrix", + "p3-maybe-rayon", + "p3-util", + "rand", + "serde", + "tracing", +] + [[package]] name = "p3-poseidon1" -version = "0.5.1" -source = "git+https://github.com/Plonky3/Plonky3?rev=e9d75614dd6816f9b5dbb4413c69be63536efd64#e9d75614dd6816f9b5dbb4413c69be63536efd64" +version = "0.6.0" +source = "git+https://github.com/Plonky3/Plonky3?rev=3c84c158c0939345a3becba60a387643935593d2#3c84c158c0939345a3becba60a387643935593d2" dependencies = [ "p3-field", + "p3-mds", "p3-symmetric", "rand", ] [[package]] name = "p3-poseidon2" -version = "0.5.1" -source = "git+https://github.com/Plonky3/Plonky3?rev=e9d75614dd6816f9b5dbb4413c69be63536efd64#e9d75614dd6816f9b5dbb4413c69be63536efd64" +version = "0.6.0" +source = "git+https://github.com/Plonky3/Plonky3?rev=3c84c158c0939345a3becba60a387643935593d2#3c84c158c0939345a3becba60a387643935593d2" dependencies = [ "p3-field", "p3-mds", @@ -753,12 +763,24 @@ dependencies = [ "rand", ] +[[package]] +name = "p3-security" +version = "0.6.0" +source = "git+https://github.com/Plonky3/Plonky3?rev=3c84c158c0939345a3becba60a387643935593d2#3c84c158c0939345a3becba60a387643935593d2" +dependencies = [ + "libm", + "p3-air", + "p3-field", + "p3-util", + "serde", +] + [[package]] name = "p3-symmetric" -version = "0.5.1" -source = "git+https://github.com/Plonky3/Plonky3?rev=e9d75614dd6816f9b5dbb4413c69be63536efd64#e9d75614dd6816f9b5dbb4413c69be63536efd64" +version = "0.6.0" +source = "git+https://github.com/Plonky3/Plonky3?rev=3c84c158c0939345a3becba60a387643935593d2#3c84c158c0939345a3becba60a387643935593d2" dependencies = [ - "itertools 0.14.0", + "itertools 0.15.0", "p3-field", "p3-util", "serde", @@ -766,11 +788,11 @@ dependencies = [ [[package]] name = "p3-util" -version = "0.5.1" -source = "git+https://github.com/Plonky3/Plonky3?rev=e9d75614dd6816f9b5dbb4413c69be63536efd64#e9d75614dd6816f9b5dbb4413c69be63536efd64" +version = "0.6.0" +source = "git+https://github.com/Plonky3/Plonky3?rev=3c84c158c0939345a3becba60a387643935593d2#3c84c158c0939345a3becba60a387643935593d2" dependencies = [ + "p3-maybe-rayon", "serde", - "transpose", ] [[package]] @@ -879,9 +901,9 @@ checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" [[package]] name = "rayon" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" dependencies = [ "either", "rayon-core", @@ -1041,19 +1063,13 @@ checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" [[package]] name = "spin" -version = "0.10.0" +version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591" +checksum = "0134f9043ed38b087ac4f7d4af44c79e2c9e5094421fe3164f435ce585953b10" dependencies = [ "lock_api", ] -[[package]] -name = "strength_reduce" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82" - [[package]] name = "syn" version = "2.0.117" @@ -1196,16 +1212,6 @@ dependencies = [ "tracing-subscriber", ] -[[package]] -name = "transpose" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ad61aed86bc3faea4300c7aee358b4c6d0c8d6ccc36524c96e4c92ccf26e77e" -dependencies = [ - "num-integer", - "strength_reduce", -] - [[package]] name = "unicode-ident" version = "1.0.24" diff --git a/Cargo.toml b/Cargo.toml index 7f50d65..fcb6144 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition = "2024" authors = ["Argument Engineering "] license = "MIT OR Apache-2.0" -rust-version = "1.91" +rust-version = "1.98" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html @@ -15,24 +15,25 @@ rust-version = "1.91" tracing = "0.1" serde = { version = "1", features = ["derive"] } bincode = { version = "2", features = ["serde"] } -p3-air = { git = "https://github.com/Plonky3/Plonky3", rev = "e9d75614dd6816f9b5dbb4413c69be63536efd64" } -p3-goldilocks = { git = "https://github.com/Plonky3/Plonky3", rev = "e9d75614dd6816f9b5dbb4413c69be63536efd64" } -p3-challenger = { git = "https://github.com/Plonky3/Plonky3", rev = "e9d75614dd6816f9b5dbb4413c69be63536efd64" } -p3-commit = { git = "https://github.com/Plonky3/Plonky3", rev = "e9d75614dd6816f9b5dbb4413c69be63536efd64" } -p3-dft = { git = "https://github.com/Plonky3/Plonky3", rev = "e9d75614dd6816f9b5dbb4413c69be63536efd64" } -p3-field = { git = "https://github.com/Plonky3/Plonky3", rev = "e9d75614dd6816f9b5dbb4413c69be63536efd64" } -p3-fri = { git = "https://github.com/Plonky3/Plonky3", rev = "e9d75614dd6816f9b5dbb4413c69be63536efd64" } -p3-keccak = { git = "https://github.com/Plonky3/Plonky3", rev = "e9d75614dd6816f9b5dbb4413c69be63536efd64" } -p3-blake3 = { git = "https://github.com/Plonky3/Plonky3", rev = "e9d75614dd6816f9b5dbb4413c69be63536efd64" } -p3-matrix = { git = "https://github.com/Plonky3/Plonky3", rev = "e9d75614dd6816f9b5dbb4413c69be63536efd64" } -p3-maybe-rayon = { git = "https://github.com/Plonky3/Plonky3", rev = "e9d75614dd6816f9b5dbb4413c69be63536efd64" } -p3-merkle-tree = { git = "https://github.com/Plonky3/Plonky3", rev = "e9d75614dd6816f9b5dbb4413c69be63536efd64" } -p3-symmetric = { git = "https://github.com/Plonky3/Plonky3", rev = "e9d75614dd6816f9b5dbb4413c69be63536efd64" } -p3-util = { git = "https://github.com/Plonky3/Plonky3", rev = "e9d75614dd6816f9b5dbb4413c69be63536efd64" } +num-bigint = "0.5" +p3-air = { git = "https://github.com/Plonky3/Plonky3", rev = "3c84c158c0939345a3becba60a387643935593d2" } +p3-goldilocks = { git = "https://github.com/Plonky3/Plonky3", rev = "3c84c158c0939345a3becba60a387643935593d2" } +p3-challenger = { git = "https://github.com/Plonky3/Plonky3", rev = "3c84c158c0939345a3becba60a387643935593d2" } +p3-commit = { git = "https://github.com/Plonky3/Plonky3", rev = "3c84c158c0939345a3becba60a387643935593d2" } +p3-dft = { git = "https://github.com/Plonky3/Plonky3", rev = "3c84c158c0939345a3becba60a387643935593d2" } +p3-field = { git = "https://github.com/Plonky3/Plonky3", rev = "3c84c158c0939345a3becba60a387643935593d2" } +p3-fri = { git = "https://github.com/Plonky3/Plonky3", rev = "3c84c158c0939345a3becba60a387643935593d2" } +p3-keccak = { git = "https://github.com/Plonky3/Plonky3", rev = "3c84c158c0939345a3becba60a387643935593d2" } +p3-blake3 = { git = "https://github.com/Plonky3/Plonky3", rev = "3c84c158c0939345a3becba60a387643935593d2" } +p3-matrix = { git = "https://github.com/Plonky3/Plonky3", rev = "3c84c158c0939345a3becba60a387643935593d2" } +p3-maybe-rayon = { git = "https://github.com/Plonky3/Plonky3", rev = "3c84c158c0939345a3becba60a387643935593d2" } +p3-merkle-tree = { git = "https://github.com/Plonky3/Plonky3", rev = "3c84c158c0939345a3becba60a387643935593d2" } +p3-symmetric = { git = "https://github.com/Plonky3/Plonky3", rev = "3c84c158c0939345a3becba60a387643935593d2" } +p3-util = { git = "https://github.com/Plonky3/Plonky3", rev = "3c84c158c0939345a3becba60a387643935593d2" } [dev-dependencies] criterion = "0.5" -p3-baby-bear = { git = "https://github.com/Plonky3/Plonky3", rev = "e9d75614dd6816f9b5dbb4413c69be63536efd64" } +p3-baby-bear = { git = "https://github.com/Plonky3/Plonky3", rev = "3c84c158c0939345a3becba60a387643935593d2" } rand = "0.10" tracing-subscriber = "0.3" tracing-texray = { git = "https://github.com/argumentcomputer/tracing-texray", rev = "465bbca0bea4721e58419c11cabd8cce21757822" } @@ -68,7 +69,6 @@ explicit_into_iter_loop = "warn" fallible_impl_from = "warn" filter_map_next = "warn" flat_map_option = "warn" -from_iter_instead_of_collect = "warn" implicit_clone = "warn" inefficient_to_string = "warn" large_stack_arrays = "warn" diff --git a/flake.lock b/flake.lock index c7879df..a205571 100644 --- a/flake.lock +++ b/flake.lock @@ -21,11 +21,11 @@ "rust-analyzer-src": "rust-analyzer-src" }, "locked": { - "lastModified": 1783676673, - "narHash": "sha256-A4Sy9xQrO5wTaPue9JISqSuJbFU/NECHLFQm70C1ehM=", + "lastModified": 1787381277, + "narHash": "sha256-ZTCk8w6UKzlWSJiFtQTy+HahC79d/p5kh97prdRkkHE=", "owner": "nix-community", "repo": "fenix", - "rev": "9239e256596333bf1eb49068e8544786c3a853c8", + "rev": "eacb66f3a1e40cc5ac88ef588ed6bc9bbff1c79c", "type": "github" }, "original": { @@ -54,11 +54,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1783224372, - "narHash": "sha256-8i/87eeoqiGE4yOTjwSA3Eh/ziJRQEmd/unYU+K27sk=", + "lastModified": 1787135253, + "narHash": "sha256-RD2kNWCG+Bjo6h+JVjWVNntZs2GtRoeY2xHjts/FNkA=", "owner": "nixos", "repo": "nixpkgs", - "rev": "d407951447dcd00442e97087bf374aad70c04cea", + "rev": "ffb3c9b700e759be2ef13237c9d8f953b32a1e46", "type": "github" }, "original": { @@ -97,11 +97,11 @@ "rust-analyzer-src": { "flake": false, "locked": { - "lastModified": 1783531694, - "narHash": "sha256-qnAn5Z/BhCj71mU/yWhRhapukrPYmIqu+kAsARdAHmg=", + "lastModified": 1787308443, + "narHash": "sha256-Ou4Up06Sr6pm+Q3nKm0zZdqajHXM9sCSRNrOhE3Fm7g=", "owner": "rust-lang", "repo": "rust-analyzer", - "rev": "e7e17b692a073ca9820d1822626646b9cc045153", + "rev": "d2e55da49132fa70a13dfbdc99122432b02cf464", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index 8ed206b..b4848e1 100644 --- a/flake.nix +++ b/flake.nix @@ -40,7 +40,7 @@ # Pins the Rust toolchain rustToolchain = fenix.packages.${system}.fromToolchainFile { file = ./rust-toolchain.toml; - sha256 = "sha256-SDu4snEWjuZU475PERvu+iO50Mi39KVjqCeJeNvpguU="; + sha256 = "sha256-P30Tm3O7vQAE725YtDCDHGjNrSsfZO4us11UwJGZSJo="; }; craneLib = (crane.mkLib pkgs).overrideToolchain rustToolchain; diff --git a/rust-toolchain.toml b/rust-toolchain.toml index dccca02..23de054 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,4 +1,4 @@ [toolchain] # The default profile includes rustc, rust-std, cargo, rust-docs, rustfmt and clippy. profile = "default" -channel = "1.91" +channel = "1.98" diff --git a/src/advice.rs b/src/advice.rs new file mode 100644 index 0000000..098058f --- /dev/null +++ b/src/advice.rs @@ -0,0 +1,795 @@ +//! Re-encoding of a proof's FRI opening transport for per-query verifiers. +//! +//! Plonky3 v0.6.0 ships FRI query openings as *pruned Merkle multiproofs*: +//! per commitment, one flat list of boundary sibling digests shared by all +//! queries, verified by an amortized bottom-up walk +//! (`MerkleTreeMmcs::verify_batch_pruned`). The in-circuit recursive +//! verifier instead consumes one full authentication path per query — the +//! legacy per-query layout — because its per-query control flow is a far +//! smaller circuit than the amortized walk's sort/merge bookkeeping. +//! +//! [`proof_to_advice_bytes`] converts a verified [`Proof`] into that +//! per-query **advice encoding**: the outer proof fields unchanged, the FRI +//! opening transport expanded from pruned multiproofs to per-query paths. +//! +//! # Soundness: encoding freedom +//! +//! The advice bytes are untrusted prover input to the recursive verifier — +//! never digest-bound, never observed into the transcript. What the +//! transcript binds are the *commitments* (read from the advice and +//! observed), and every expanded sibling digest is authenticated against +//! them by the per-query Merkle checks. Pruning is transport compression: +//! a pruned proof and its expansion authenticate identical opened values +//! against identical commitments, so a valid advice encoding exists iff a +//! valid pruned proof exists for the same statement. Per-query +//! verification is at least as strong as the amortized walk — duplicate +//! queries that disagree on opened values would need a hash collision to +//! both authenticate. +//! +//! # How the expansion recovers interior digests +//! +//! No tree data is available (the input is a proof, not prover data), and +//! the walk that recomputes interior digests lives inside p3. Rather than +//! reimplement it, the expansion *runs* it: `verify_fri` is invoked with a +//! [`MerkleTreeMmcs`] whose compression function records every +//! `(inputs → output)` call. The recorded map is functional (a collision +//! would break Blake3), so each query's full path is read back by walking +//! the tree top-down from its cap entry: at every level the map yields the +//! two children — one continues the path, the other is the sibling the +//! legacy wire format carries. Matrix-injection levels (a shorter matrix's +//! row hash compressed into the running digest) consume no wire sibling, +//! mirroring the per-path verifier's schedule. Query indices come from the +//! same instrumented run, via a challenger wrapper that records +//! `sample_bits` results. + +use std::collections::HashMap; +use std::marker::PhantomData; +use std::sync::{Arc, Mutex}; + +use bincode::serde::encode_to_vec; +use p3_blake3::Blake3; +use p3_challenger::{CanObserve, CanSample, CanSampleBits, FieldChallenger, GrindingChallenger}; +use p3_commit::{ExtensionMmcs, Mmcs, OpenedValuesForRound}; +use p3_field::{BasedVectorSpace, Field}; +use p3_fri::verifier::verify_fri; +use p3_fri::{BatchMultiOpening, CommitPhaseMultiStep, FriProof}; +use p3_fri::{FriParameters as InnerFriParameters, TwoAdicFriFolding}; +use p3_matrix::Dimensions; +use p3_merkle_tree::MerkleTreeMmcs; +use p3_symmetric::{ + CompressionFunctionFromHasher, CryptographicHasher, PseudoCompressionFunction, + SerializingHasher, +}; +use p3_util::log2_strict_usize; +use serde::{Deserialize, Serialize}; + +use crate::prover::{Commitments, Proof}; +use crate::system::System; +use crate::types::{ + Challenger, Commitment, CommitmentParameters, ExtVal, FriParameters, GoldilocksBlake3Config, + Val, +}; + +type Digest = [u8; 32]; +type Blake3Compress = CompressionFunctionFromHasher; +type RecMmcs = MerkleTreeMmcs, RecordingCompress, 2, 32>; +type RecExtMmcs = ExtensionMmcs; + +/// Why an advice re-encoding could not be produced. Every variant except +/// [`AdviceError::Encode`] indicates an invalid proof or a bug: expansion +/// only runs after native verification succeeds. +#[derive(Debug)] +pub enum AdviceError { + /// Native verification of the proof failed; nothing was expanded. + Verification(String), + /// The instrumented PCS run failed (unreachable after native + /// verification passed, absent a bug). + Recording(String), + /// A digest needed for path read-back was never computed by the + /// instrumented run. + MissingDigest, + /// A cap index fell outside the commitment. + CapIndexOutOfBounds, + /// The recorded `sample_bits` log did not contain the query indices. + MissingQueryIndices, + /// Serialization of the legacy layout failed. + Encode(bincode::error::EncodeError), +} + +// --------------------------------------------------------------------------- +// Instrumentation +// --------------------------------------------------------------------------- + +/// A compression function that forwards to Blake3 and records every call. +/// The map is keyed by output: Blake3 collisions aside, each digest has a +/// unique preimage pair, so read-back is unambiguous. +#[derive(Clone)] +struct RecordingCompress { + inner: Blake3Compress, + log: Arc>>, +} + +impl PseudoCompressionFunction for RecordingCompress { + fn compress(&self, input: [Digest; 2]) -> Digest { + let output = self.inner.compress(input); + self.log.lock().unwrap().insert(output, input); + output + } +} + +/// A challenger that forwards everything and records `sample_bits` calls. +/// FRI query indices are the last `num_queries` recorded samples: they are +/// drawn after the query proof-of-work check, and nothing samples bits +/// after them. +#[derive(Clone)] +struct RecordingChallenger { + inner: C, + samples: Arc>>, +} + +impl> CanObserve for RecordingChallenger { + fn observe(&mut self, value: T) { + self.inner.observe(value); + } +} + +impl> CanSample for RecordingChallenger { + fn sample(&mut self) -> T { + self.inner.sample() + } +} + +impl> CanSampleBits for RecordingChallenger { + fn sample_bits(&mut self, bits: usize) -> usize { + let value = self.inner.sample_bits(bits); + self.samples.lock().unwrap().push(value); + value + } +} + +impl> FieldChallenger for RecordingChallenger {} + +impl GrindingChallenger for RecordingChallenger { + type Witness = C::Witness; + + fn grind(&mut self, bits: usize) -> Self::Witness { + self.inner.grind(bits) + } + + // Delegate so the inner challenger's own `sample_bits` runs unrecorded: + // proof-of-work samples never pollute the query-index log. + fn check_witness(&mut self, bits: usize, witness: Self::Witness) -> bool { + self.inner.check_witness(bits, witness) + } +} + +// --------------------------------------------------------------------------- +// Legacy wire layout (the advice encoding) +// --------------------------------------------------------------------------- + +/// One round's input opening for one query: the opened rows and a full +/// Merkle authentication path. +#[derive(Serialize, Deserialize)] +pub struct AdviceBatchOpening { + pub opened_values: Vec>, + pub opening_proof: Vec, +} + +/// One FRI folding step for one query: the folding arity, the `arity - 1` +/// sibling evaluations, and a full path into that round's commitment. +#[derive(Serialize, Deserialize)] +pub struct AdviceCommitPhaseStep { + pub log_arity: u8, + pub sibling_values: Vec, + pub opening_proof: Vec, +} + +/// One query's complete opening data: input openings per round, then one +/// folding step per commit phase. +#[derive(Serialize, Deserialize)] +pub struct AdviceQueryProof { + pub input_proof: Vec, + pub commit_phase_openings: Vec, +} + +/// The FRI proof in per-query transport. +#[derive(Serialize, Deserialize)] +pub struct AdviceFriProof { + pub commit_phase_commits: Vec, + pub commit_pow_witnesses: Vec, + pub query_proofs: Vec, + pub final_poly: Vec, + pub query_pow_witness: Val, +} + +/// The full advice proof: every field of [`Proof`] unchanged except the +/// opening transport. +#[derive(Serialize, Deserialize)] +pub struct AdviceProof { + pub active: Vec, + pub commitments: Commitments, + pub intermediate_accumulators: Vec, + pub log_degrees: Vec, + pub opening_proof: AdviceFriProof, + pub quotient_opened_values: OpenedValuesForRound, + pub preprocessed_opened_values: Option>, + pub stage_1_opened_values: OpenedValuesForRound, + pub stage_2_opened_values: OpenedValuesForRound, +} + +// --------------------------------------------------------------------------- +// Path read-back +// --------------------------------------------------------------------------- + +/// One step of the per-path verifier's ascent, derived from the matrix +/// dimensions exactly as `verify_batch` derives it. +enum LevelOp { + /// An arity-`step` compression; the path consumes `step - 1` wire + /// siblings here. + Fold { step: usize }, + /// A shorter matrix's row hash is compressed into the running digest; + /// nothing on the wire. + Inject, +} + +/// The ascent schedule (leaf to cap) for a tree of the given dimensions, +/// mirroring `verify_batch`'s traversal: one `Fold` per arity-schedule +/// step, an `Inject` after any step whose folded height picks up shorter +/// matrices. +fn ascent_schedule( + schedule: &[usize], + dimensions: &[Dimensions], + max_height: usize, +) -> Vec { + let mut heights: Vec = dimensions.iter().map(|d| d.height).collect(); + heights.sort_unstable_by(|a, b| b.cmp(a)); + let leaf_npt = max_height.next_power_of_two(); + // Heights hashed into the leaf layer are consumed up front. + let mut next = heights + .iter() + .position(|h| h.next_power_of_two() != leaf_npt) + .unwrap_or(heights.len()); + + let mut ops = Vec::new(); + let mut curr_height_padded = max_height.next_multiple_of(2); + for &step in schedule { + ops.push(LevelOp::Fold { step }); + let logical_next = curr_height_padded / step; + curr_height_padded = logical_next.next_multiple_of(2); + let logical_next_npt = logical_next.next_power_of_two(); + if next < heights.len() && heights[next].next_power_of_two() == logical_next_npt { + ops.push(LevelOp::Inject); + while next < heights.len() && heights[next].next_power_of_two() == logical_next_npt { + next += 1; + } + } + } + ops +} + +/// Reads one query's full authentication path out of the recorded +/// compression map by walking its tree top-down from the cap entry, +/// emitting siblings in the bottom-up order the legacy wire carries. +/// +/// `leaf_check`, when supplied, is the expected leaf digest (the hash of +/// the query's opened rows); the walk must land exactly there. +fn expand_path( + map: &HashMap, + commitment: &Commitment, + schedule: &[usize], + dimensions: &[Dimensions], + max_height: usize, + index: usize, + leaf_check: Option, +) -> Result, AdviceError> { + let ops = ascent_schedule(schedule, dimensions, max_height); + + // Per-fold node index at each level, bottom-up. + let mut level_indices = Vec::new(); + let mut idx = index; + for op in &ops { + if let LevelOp::Fold { step } = op { + level_indices.push(idx); + idx /= step; + } + } + let cap_index = idx; + if cap_index >= commitment.num_roots() { + return Err(AdviceError::CapIndexOutOfBounds); + } + + let mut digest: Digest = commitment[cap_index]; + let mut fold_level = level_indices.len(); + // Wire order is bottom-up; the walk is top-down, so collect reversed. + let mut siblings_rev: Vec = Vec::new(); + for op in ops.iter().rev() { + let inputs = map.get(&digest).ok_or(AdviceError::MissingDigest)?; + match op { + LevelOp::Inject => { + // inject_inputs = [running digest, row hash]; descend left. + digest = inputs[0]; + } + LevelOp::Fold { step } => { + fold_level -= 1; + let pos_in_group = level_indices[fold_level] % step; + // The recording mmcs is binary (N = 2), so `step` is 2. + for k in (0..*step).rev() { + if k != pos_in_group { + siblings_rev.push(inputs[k]); + } + } + digest = inputs[pos_in_group]; + } + } + } + if let Some(expected) = leaf_check + && digest != expected + { + return Err(AdviceError::MissingDigest); + } + siblings_rev.reverse(); + Ok(siblings_rev) +} + +// --------------------------------------------------------------------------- +// Re-encoding +// --------------------------------------------------------------------------- + +/// Converts a natively-verified proof into the per-query advice encoding. +/// +/// `commitment_parameters` and `fri_parameters` must be the ones the +/// system's config was built from (the config does not expose them back). +/// Verifies the proof natively first and refuses to expand on failure. +pub fn proof_to_advice_bytes( + system: &System, + commitment_parameters: CommitmentParameters, + fri_parameters: FriParameters, + claims: &[&[Val]], + proof: &Proof, +) -> Result, AdviceError> { + system + .verify_multiple_claims(claims, proof) + .map_err(|e| AdviceError::Verification(format!("{e:?}")))?; + let ctx = system + .pcs_verification_context(claims, proof) + .map_err(|e| AdviceError::Verification(format!("{e:?}")))?; + + // Instrumented components sharing one compression log. + let log: Arc>> = Arc::new(Mutex::new(HashMap::new())); + let rec_compress = RecordingCompress { + inner: Blake3Compress::new(Blake3), + log: Arc::clone(&log), + }; + let rec_mmcs = RecMmcs::new( + SerializingHasher::new(Blake3), + rec_compress, + commitment_parameters.cap_height, + ); + let rec_params = InnerFriParameters { + log_blowup: commitment_parameters.log_blowup, + log_final_poly_len: fri_parameters.log_final_poly_len, + max_log_arity: fri_parameters.max_log_arity, + num_queries: fri_parameters.num_queries, + commit_proof_of_work_bits: fri_parameters.commit_proof_of_work_bits, + query_proof_of_work_bits: fri_parameters.query_proof_of_work_bits, + mmcs: ExtensionMmcs::::new(rec_mmcs.clone()), + }; + + // The stored FRI proof, re-typed over the instrumented mmcs. The + // commitment, digest and pruned-path types are identical; only the + // phantom mmcs parameter changes. + let fri = &proof.opening_proof; + let rec_proof: FriProof>> = + FriProof { + commit_phase_commits: fri.commit_phase_commits.clone(), + commit_pow_witnesses: fri.commit_pow_witnesses.clone(), + input_openings: fri + .input_openings + .iter() + .map(|o| BatchMultiOpening { + opened_values: o.opened_values.clone(), + opening_proof: o.opening_proof.clone(), + }) + .collect(), + commit_phase_openings: fri + .commit_phase_openings + .iter() + .map(|s| CommitPhaseMultiStep { + log_arity: s.log_arity, + sibling_values: s.sibling_values.clone(), + opening_proof: s.opening_proof.clone(), + }) + .collect(), + final_poly: fri.final_poly.clone(), + query_pow_witness: fri.query_pow_witness, + }; + + // Run the PCS phase exactly as `TwoAdicFriPcs::verify` would — the + // evaluation observations, then `verify_fri` — against the context's + // rounds and transcript state, with the instrumented components. + let mut challenger = RecordingChallenger:: { + inner: ctx.challenger, + samples: Arc::new(Mutex::new(Vec::new())), + }; + for (_, round) in &ctx.rounds { + for (_, mat) in round { + for (_, point) in mat { + challenger.observe_algebra_slice(point); + } + } + } + let folding = TwoAdicFriFolding::< + Vec>, + >::Error, + >(PhantomData); + verify_fri( + &folding, + &rec_params, + &rec_proof, + &mut challenger, + &ctx.rounds, + &rec_mmcs, + ) + .map_err(|e| AdviceError::Recording(format!("{e:?}")))?; + + // The query indices are the last `num_queries` recorded samples. + let samples = challenger.samples.lock().unwrap(); + let query_indices: Vec = samples + .len() + .checked_sub(fri_parameters.num_queries) + .map(|start| samples[start..].to_vec()) + .ok_or(AdviceError::MissingQueryIndices)?; + drop(samples); + let map = log.lock().unwrap(); + + let log_arities: Vec = fri + .commit_phase_openings + .iter() + .map(|s| usize::from(s.log_arity)) + .collect(); + let total_log_reduction: usize = log_arities.iter().sum(); + let log_global_max_height = + total_log_reduction + commitment_parameters.log_blowup + fri_parameters.log_final_poly_len; + + let hasher = SerializingHasher::new(Blake3); + + // Input rounds: per round, per query, opened rows + expanded path. + // Dimensions and reduced indices mirror p3's `open_inputs`. + let mut input_openings_per_query: Vec> = (0..fri_parameters + .num_queries) + .map(|_| Vec::new()) + .collect(); + for ((commit, mats), batch) in ctx.rounds.iter().zip(&fri.input_openings) { + let heights: Vec = mats + .iter() + .map(|(domain, _)| domain.size() << commitment_parameters.log_blowup) + .collect(); + let dims: Vec = heights + .iter() + .zip(mats) + .map(|(&height, (_, points))| Dimensions { + width: points.first().map_or(0, |(_, values)| values.len()), + height, + }) + .collect(); + let max_height = heights.iter().copied().max().unwrap_or(1); + let bits_reduced = log_global_max_height - log2_strict_usize(max_height); + let schedule = rec_mmcs + .proof_arity_schedule(&dims) + .map_err(|e| AdviceError::Recording(format!("{e:?}")))?; + + // Matrices whose padded height matches the tallest are hashed into + // the leaf digest, in tallest-first order — mirroring `verify_batch`. + let leaf_npt = max_height.next_power_of_two(); + let mut order: Vec = (0..dims.len()).collect(); + order.sort_by_key(|&i| std::cmp::Reverse(dims[i].height)); + let leaf_matrices: Vec = order + .iter() + .copied() + .take_while(|&i| dims[i].height.next_power_of_two() == leaf_npt) + .collect(); + + for (q, per_query) in input_openings_per_query.iter_mut().enumerate() { + let reduced_index = query_indices[q] >> bits_reduced; + let opened_values = batch.opened_values[q].clone(); + let leaf = hasher + .hash_iter_slices(leaf_matrices.iter().map(|&mi| opened_values[mi].as_slice())); + let opening_proof = expand_path( + &map, + commit, + &schedule, + &dims, + max_height, + reduced_index, + Some(leaf), + )?; + per_query.push(AdviceBatchOpening { + opened_values, + opening_proof, + }); + } + } + + // Commit-phase rounds: single-matrix trees of the folded codewords, + // flattened to base columns by the extension mmcs. The wire sibling + // values come straight off the multiproof; only the paths are read + // back. No leaf check: the folded row is not materialized here — the + // walk's landing digest is pinned by the functional map and the cap. + let ext_d = >::DIMENSION; + let mut steps_per_query: Vec> = (0..fri_parameters.num_queries) + .map(|_| Vec::new()) + .collect(); + let mut log_current_height = log_global_max_height; + for (round, step) in fri.commit_phase_openings.iter().enumerate() { + let log_arity = usize::from(step.log_arity); + let arity = 1 << log_arity; + let log_folded_height = log_current_height - log_arity; + let dims = [Dimensions { + width: arity * ext_d, + height: 1 << log_folded_height, + }]; + let schedule = rec_mmcs + .proof_arity_schedule(&dims) + .map_err(|e| AdviceError::Recording(format!("{e:?}")))?; + let commit = &fri.commit_phase_commits[round]; + let bits_consumed: usize = log_arities[..=round].iter().sum(); + for (q, per_query) in steps_per_query.iter_mut().enumerate() { + let group_index = query_indices[q] >> bits_consumed; + let opening_proof = expand_path( + &map, + commit, + &schedule, + &dims, + 1 << log_folded_height, + group_index, + None, + )?; + per_query.push(AdviceCommitPhaseStep { + log_arity: step.log_arity, + sibling_values: step.sibling_values[q].clone(), + opening_proof, + }); + } + log_current_height = log_folded_height; + } + + let query_proofs: Vec = input_openings_per_query + .into_iter() + .zip(steps_per_query) + .map(|(input_proof, commit_phase_openings)| AdviceQueryProof { + input_proof, + commit_phase_openings, + }) + .collect(); + + let advice = AdviceProof { + active: proof.active.clone(), + commitments: Commitments { + stage_1_trace: proof.commitments.stage_1_trace.clone(), + stage_2_trace: proof.commitments.stage_2_trace.clone(), + quotient_chunks: proof.commitments.quotient_chunks.clone(), + }, + intermediate_accumulators: proof.intermediate_accumulators.clone(), + log_degrees: proof.log_degrees.clone(), + opening_proof: AdviceFriProof { + commit_phase_commits: fri.commit_phase_commits.clone(), + commit_pow_witnesses: fri.commit_pow_witnesses.clone(), + query_proofs, + final_poly: fri.final_poly.clone(), + query_pow_witness: fri.query_pow_witness, + }, + quotient_opened_values: proof.quotient_opened_values.clone(), + preprocessed_opened_values: proof.preprocessed_opened_values.clone(), + stage_1_opened_values: proof.stage_1_opened_values.clone(), + stage_2_opened_values: proof.stage_2_opened_values.clone(), + }; + encode_to_vec(&advice, Proof::::serde_config()) + .map_err(AdviceError::Encode) +} + +#[cfg(test)] +mod tests { + use bincode::serde::decode_from_slice; + use p3_field::PrimeCharacteristicRing; + use p3_matrix::dense::RowMajorMatrix; + + use super::*; + use crate::system::ProverKey; + use crate::system::{CircuitInputs, SystemWitness}; + + fn parameters(cap_height: usize) -> (CommitmentParameters, FriParameters) { + ( + CommitmentParameters { + log_blowup: 1, + cap_height, + }, + FriParameters { + log_final_poly_len: 0, + max_log_arity: 1, + num_queries: 8, + commit_proof_of_work_bits: 0, + query_proof_of_work_bits: 0, + }, + ) + } + + /// Two constraint-free circuits at different trace heights (so the + /// stage-1 tree exercises matrix injection), one with a preprocessed + /// matrix (so the preprocessed round exists). + fn test_system( + cap_height: usize, + ) -> ( + System, + ProverKey, + ) { + let (cp, fp) = parameters(cap_height); + let config = GoldilocksBlake3Config::new(cp, fp); + let preprocessed = RowMajorMatrix::new((0..8u32).map(Val::from_u32).collect::>(), 1); + let inputs = [ + CircuitInputs { + main_width: 2, + preprocessed: Some(preprocessed), + ..Default::default() + }, + CircuitInputs { + main_width: 3, + ..Default::default() + }, + ]; + System::new(config, inputs) + } + + fn test_proof( + system: &System, + key: &ProverKey, + ) -> Proof { + let trace_1 = RowMajorMatrix::new((0..16u32).map(Val::from_u32).collect::>(), 2); + let trace_2 = RowMajorMatrix::new( + (0..12u32) + .map(|i| Val::from_u32(7 * i + 3)) + .collect::>(), + 3, + ); + let witness = SystemWitness::from_stage_1(vec![trace_1, trace_2], system); + system.prove_multiple_claims(key, &[], witness) + } + + #[test] + fn advice_expands_and_paths_verify_per_query() { + for cap_height in [0, 1] { + let (cp, fp) = parameters(cap_height); + let (system, key) = test_system(cap_height); + let proof = test_proof(&system, &key); + let bytes = proof_to_advice_bytes(&system, cp, fp, &[], &proof) + .unwrap_or_else(|e| panic!("advice expansion failed: {e:?}")); + + let (advice, consumed): (AdviceProof, usize) = + decode_from_slice(&bytes, Proof::::serde_config()) + .expect("advice bytes decode under the same serde config"); + assert_eq!(consumed, bytes.len(), "no trailing bytes"); + assert_eq!(advice.opening_proof.query_proofs.len(), fp.num_queries); + + // Recover the query indices the same way the expansion did. + let ctx = system.pcs_verification_context(&[], &proof).unwrap(); + let plain_mmcs = MerkleTreeMmcs::::new( + SerializingHasher::new(Blake3), + Blake3Compress::new(Blake3), + cp.cap_height, + ); + let plain_params = InnerFriParameters { + log_blowup: cp.log_blowup, + log_final_poly_len: fp.log_final_poly_len, + max_log_arity: fp.max_log_arity, + num_queries: fp.num_queries, + commit_proof_of_work_bits: fp.commit_proof_of_work_bits, + query_proof_of_work_bits: fp.query_proof_of_work_bits, + mmcs: ExtensionMmcs::::new(plain_mmcs.clone()), + }; + let mut challenger = RecordingChallenger:: { + inner: ctx.challenger, + samples: Arc::new(Mutex::new(Vec::new())), + }; + for (_, round) in &ctx.rounds { + for (_, mat) in round { + for (_, point) in mat { + challenger.observe_algebra_slice(point); + } + } + } + let folding = TwoAdicFriFolding::< + Vec>, + , Blake3Compress, 2, 32> as Mmcs< + Val, + >>::Error, + >(PhantomData); + verify_fri( + &folding, + &plain_params, + &proof.opening_proof, + &mut challenger, + &ctx.rounds, + &plain_mmcs, + ) + .unwrap(); + let samples = challenger.samples.lock().unwrap(); + let indices = &samples[samples.len() - fp.num_queries..]; + + let log_arities: Vec = proof + .opening_proof + .commit_phase_openings + .iter() + .map(|s| usize::from(s.log_arity)) + .collect(); + let log_global_max_height = + log_arities.iter().sum::() + cp.log_blowup + fp.log_final_poly_len; + + // Oracle: every expanded input-round path must satisfy p3's own + // per-path verifier. + for (round, (commit, mats)) in ctx.rounds.iter().enumerate() { + let heights: Vec = mats + .iter() + .map(|(domain, _)| domain.size() << cp.log_blowup) + .collect(); + let dims: Vec = heights + .iter() + .zip(mats) + .map(|(&height, (_, points))| Dimensions { + width: points.first().map_or(0, |(_, values)| values.len()), + height, + }) + .collect(); + let max_height = heights.iter().copied().max().unwrap(); + let bits_reduced = log_global_max_height - log2_strict_usize(max_height); + for (q, &index) in indices.iter().enumerate() { + let opening = &advice.opening_proof.query_proofs[q].input_proof[round]; + plain_mmcs + .verify_batch( + commit, + &dims, + index >> bits_reduced, + p3_commit::BatchOpeningRef::new( + &opening.opened_values, + &opening.opening_proof, + ), + ) + .unwrap_or_else(|e| { + panic!("round {round} query {q}: expanded path rejected: {e:?}") + }); + } + } + + // Commit-phase paths: length and sibling-value parity with the + // multiproof (the Lean verifier is the end-to-end oracle here). + let mut log_current = log_global_max_height; + for (round, step) in proof.opening_proof.commit_phase_openings.iter().enumerate() { + let log_arity = usize::from(step.log_arity); + let log_folded = log_current - log_arity; + let expected_levels = log_folded.saturating_sub(cp.cap_height); + for (q, qp) in advice.opening_proof.query_proofs.iter().enumerate() { + let s = &qp.commit_phase_openings[round]; + assert_eq!(usize::from(s.log_arity), log_arity); + assert_eq!(s.sibling_values, step.sibling_values[q]); + assert_eq!( + s.opening_proof.len(), + expected_levels, + "round {round} query {q}: phase path length" + ); + } + log_current = log_folded; + } + } + } + + #[test] + fn advice_refuses_invalid_proof() { + let cap_height = 0; + let (cp, fp) = parameters(cap_height); + let (system, key) = test_system(cap_height); + let mut proof = test_proof(&system, &key); + proof.log_degrees[0] ^= 1; + assert!(matches!( + proof_to_advice_bytes(&system, cp, fp, &[], &proof), + Err(AdviceError::Verification(_)) + )); + } +} diff --git a/src/config.rs b/src/config.rs index 1ff68c1..b297247 100644 --- a/src/config.rs +++ b/src/config.rs @@ -99,12 +99,14 @@ pub trait StarkGenericConfig { /// /// The prover evaluates the constraints on a domain `quotient_degree` /// times larger than the trace domain, obtained from the PCS via - /// `get_evaluations_on_domain`. For a FRI-based PCS this only works up - /// to the blowup factor: the committed low-degree extension has - /// `2^log_blowup · N` evaluations, and asking for a larger domain - /// produces invalid proofs. Since the quotient degree is - /// `next_power_of_two(max_constraint_degree - 1)`, this bounds the - /// constraint degree: `2^log_blowup + 1` (degree 3 at `log_blowup = 1`). + /// `get_evaluations_on_domain`. For a FRI-based PCS, up to the blowup + /// factor this is a cheap truncation of the committed low-degree + /// extension (`2^log_blowup · N` evaluations); beyond it the PCS falls + /// back to recovering coefficients and re-evaluating (an extra + /// iDFT + DFT per matrix). Since the quotient degree is + /// `next_power_of_two(max_constraint_degree - 1)`, capping it at the + /// blowup bounds the constraint degree: `2^log_blowup + 1` (degree 3 + /// at `log_blowup = 1`). /// /// [`System::new`](crate::system::System::new) rejects circuits whose /// constraint degree requires a larger quotient degree. @@ -120,4 +122,18 @@ pub trait StarkGenericConfig { /// module), and a mismatch produces commitments to the wrong /// evaluations. fn log_blowup(&self) -> usize; + + /// The system-wide message width-binding policy (see + /// [`crate::lookup::WidthBinding`]). The default binds each message's + /// slot width into its fingerprint, which is sound for any circuit + /// family; override to [`WidthBinding::ByConstruction`] only for + /// circuit families that guarantee prefix-free messages by + /// construction. The choice changes the protocol, so + /// [`crate::system::System::observe_shape`] binds it into the + /// Fiat-Shamir transcript. + /// + /// [`WidthBinding::ByConstruction`]: crate::lookup::WidthBinding::ByConstruction + fn width_binding(&self) -> crate::lookup::WidthBinding { + crate::lookup::WidthBinding::Fingerprint + } } diff --git a/src/eval.rs b/src/eval.rs index 87faec8..65c1357 100644 --- a/src/eval.rs +++ b/src/eval.rs @@ -117,6 +117,7 @@ impl ConstraintGraph { .map(|lookup| Lookup { multiplicity: buf[lookup.multiplicity.index()], args: lookup.args.iter().map(|a| buf[a.index()]).collect(), + max_multiplicity: lookup.max_multiplicity, }) .collect() } diff --git a/src/graph.rs b/src/graph.rs index 25826ac..1159fd1 100644 --- a/src/graph.rs +++ b/src/graph.rs @@ -131,7 +131,11 @@ pub(crate) fn compile( .iter() .map(|arg| interner.compile_expr(arg, spec, false)) .collect::, _>>()?; - lookups.push(Lookup { multiplicity, args }); + lookups.push(Lookup { + multiplicity, + args, + max_multiplicity: lookup.max_multiplicity, + }); } let lookup_prefix_len = interner.nodes.len(); diff --git a/src/lib.rs b/src/lib.rs index 0df70f6..292a19f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,4 @@ +pub mod advice; pub mod config; pub mod eval; pub mod expr; diff --git a/src/lookup.rs b/src/lookup.rs index cf94ad7..13b47bf 100644 --- a/src/lookup.rs +++ b/src/lookup.rs @@ -44,6 +44,12 @@ //! and degree budget to spare, others don't), bounded by //! [`MAX_LOOKUP_GROUP`] so the evaluator's scratch space stays on the //! stack. +//! +//! If any message in a group evaluates to zero, the multiplied-through +//! identity degenerates: the `acc′ − acc` factor is annihilated and the +//! step no longer constrains the accumulator on that row. Over the random +//! challenges this is a negligible-probability event, accounted for in the +//! verifier's soundness bound (see the verifier module docs). use p3_field::{ Algebra, ExtensionField, Field, PrimeCharacteristicRing, batch_multiplicative_inverse, @@ -60,6 +66,15 @@ use crate::expr::{Expr, ExtExpr, RowOffset}; pub struct Lookup { pub multiplicity: E, pub args: Vec, + /// Per-row bound on the multiplicity's magnitude as a signed integer: + /// the circuit must guarantee — by construction or by constraints — + /// that on every row the multiplicity evaluates to an integer in + /// `[-w, w]`. The prover and verifier enforce the logUp height bound + /// `Σ wᵢ·hᵢ + |claims| < p` over all slots (see + /// `multiplicity_height_bound_holds`), which makes the accumulator's + /// field-level cancellation imply exact integer multiset balance: no + /// per-message count can wrap modulo the field characteristic. + pub max_multiplicity: u64, } impl Lookup { @@ -72,16 +87,29 @@ impl Lookup { Self { multiplicity: E::ZERO, args: vec![], + max_multiplicity: 0, } } /// "Pushing" has the semantics of adding a claim to the claim set. + /// + /// The per-row multiplicity bound defaults to 1: the multiplicity must + /// evaluate to 0 or ±1 on every row. Lookups whose multiplicity can be + /// larger (e.g. a committed count column) must declare their bound via + /// [`Self::with_max_multiplicity`]. #[inline] pub fn push(multiplicity: E, args: Vec) -> Self { - Self { multiplicity, args } + Self { + multiplicity, + args, + max_multiplicity: 1, + } } /// "Pulling" has the semantics of removing a claim from the claim set. + /// + /// The per-row multiplicity bound defaults to 1, exactly as for + /// [`Self::push`]. #[inline] pub fn pull(multiplicity: E, args: Vec) -> Self where @@ -90,8 +118,54 @@ impl Lookup { Self { multiplicity: -multiplicity, args, + max_multiplicity: 1, } } + + /// Declares the per-row multiplicity bound (see + /// [`Self::max_multiplicity`]). + #[inline] + pub fn with_max_multiplicity(mut self, max_multiplicity: u64) -> Self { + self.max_multiplicity = max_multiplicity; + self + } +} + +/// How lookup messages of different argument widths are kept from aliasing. +/// +/// A message's fingerprint is a polynomial in the challenge γ whose +/// coefficients are the message's arguments, so a width-`n` tuple and its +/// zero-extension to width `m > n` evaluate identically unless something +/// distinguishes them. This policy is the system-wide choice of what does: +#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)] +pub enum WidthBinding { + /// The protocol binds each message's slot width into its fingerprint as + /// the leading Horner coefficient, `Σ_i args[i]·γ^i + W·γ^W` with + /// `W = args.len()` (see [`message_fingerprint`]). Sound for any + /// circuit family; this is the default. Zero-padding rows up to their + /// own slot's width remains transparent — the slot width, not the + /// per-row argument count, is what is bound — but the same logical + /// message sent from slots of different widths will NOT match. + #[default] + Fingerprint, + /// The fingerprint is the plain Horner fold `Σ_i args[i]·γ^i`, making + /// zero-extension fully transparent: a message equals its own + /// zero-padding to any width. This restores the superposition trick of + /// branch-shared lookup slots (mutually exclusive branches sharing one + /// accumulator slot at the maximum branch width), and the circuit + /// author takes on the width-disambiguation contract: + /// + /// **the messages must be prefix-free** — whenever one message that can + /// occur with nonzero multiplicity is a zero-extension of another, the + /// two must denote the same logical claim. In practice: every message's + /// natural width is a function of a constant-constrained leading prefix + /// (e.g. a channel tag and a per-channel discriminator), so messages + /// either differ within that prefix or agree on their natural width. + /// + /// This is the same epistemic standing as [`Lookup::max_multiplicity`]: + /// a declared property of the circuit family that the protocol builds + /// on rather than enforces. + ByConstruction, } /// Number of extension-valued public inputs the lookup argument uses: @@ -190,6 +264,7 @@ pub fn logup_constraint_values + Copy>( w: F, d: usize, group_size: usize, + width_binding: WidthBinding, out: &mut Vec, ) { let group_size = group_size.max(1); @@ -228,7 +303,14 @@ pub fn logup_constraint_values + Copy>( let mut msgs = [(A::ZERO, A::ZERO); MAX_LOOKUP_GROUP]; let s_len = chunk.len(); for (j, lookup) in chunk.iter().enumerate() { - let mut f = (A::ZERO, A::ZERO); + // Under `Fingerprint` width binding, seed the Horner fold + // with the argument count so the width is bound into the + // fingerprint (see `message_fingerprint`). + let seed = match width_binding { + WidthBinding::Fingerprint => F::from_usize(lookup.args.len()), + WidthBinding::ByConstruction => F::ZERO, + }; + let mut f = (A::from(seed), A::ZERO); for &arg in lookup.args.iter().rev() { f = mul2(f, gamma, w); f.0 += nv(arg); @@ -316,6 +398,11 @@ pub fn logup_constraint_values + Copy>( .iter() .map(|lookup| { let mut fingerprint = vec![A::ZERO; d]; + // Width seed mirroring the fast path. + fingerprint[0] = A::from(match width_binding { + WidthBinding::Fingerprint => F::from_usize(lookup.args.len()), + WidthBinding::ByConstruction => F::ZERO, + }); for &arg in lookup.args.iter().rev() { fingerprint = coord_mul(&fingerprint, gamma, w); fingerprint[0] += node_vals[arg.index()]; @@ -438,6 +525,7 @@ pub fn synthesize_lookups( lookups: &[Lookup>], d: usize, group_size: usize, + width_binding: WidthBinding, ) -> Vec> { let d = u32::try_from(d).expect("extension degree exceeds u32"); let group_size = group_size.max(1); @@ -473,13 +561,17 @@ pub fn synthesize_lookups( let messages: Vec> = chunk .iter() .map(|lookup| { - // fingerprint = Σ_i args[i] · γ^i, Horner over reversed args. - let mut coeffs = lookup.args.iter().rev(); - let mut fingerprint = match coeffs.next() { - Some(arg) => ExtExpr::from(arg.clone()), - None => ExtExpr::from(Expr::constant(F::ZERO)), + // fingerprint = Σ_i args[i] · γ^i, Horner over reversed args; + // under `Fingerprint` width binding the fold is seeded with + // the width W = args.len(), landing it at γ^W so messages + // of different widths can never alias (see + // `message_fingerprint`). + let seed = match width_binding { + WidthBinding::Fingerprint => F::from_usize(lookup.args.len()), + WidthBinding::ByConstruction => F::ZERO, }; - for arg in coeffs { + let mut fingerprint = ExtExpr::from(Expr::constant(seed)); + for arg in lookup.args.iter().rev() { fingerprint = fingerprint * gamma.clone() + arg.clone(); } beta.clone() + fingerprint @@ -523,6 +615,70 @@ where .fold(F::ZERO, |acc, coeff| acc * r.clone() + coeff.into()) } +/// The protocol's message fingerprint under the system's width-binding +/// policy (see [`WidthBinding`]). +/// +/// Under [`WidthBinding::Fingerprint`], Horner over the arguments with the +/// argument count appended as the highest coefficient, +/// `Σ_i args[i]·γ^i + W·γ^W` with `W = args.len()`: messages of different +/// widths are distinct as polynomials in γ (their leading coefficients sit +/// at different powers), so a shorter tuple can never systematically alias +/// a longer one whose high arguments happen to be zero. Zero-padding rows +/// up to their slot's width remains transparent: the slot width, not the +/// per-row argument count, is what is bound. +/// +/// Under [`WidthBinding::ByConstruction`], the plain Horner fold: the +/// circuit family's prefix-freeness contract stands in for the seed. +#[inline] +pub(crate) fn message_fingerprint(r: &EF, coeffs: &[F], width_binding: WidthBinding) -> EF +where + F: PrimeCharacteristicRing + Copy + Into, + EF: PrimeCharacteristicRing, +{ + match width_binding { + WidthBinding::Fingerprint => fingerprint( + r, + coeffs.iter().copied().chain([F::from_usize(coeffs.len())]), + ), + WidthBinding::ByConstruction => fingerprint(r, coeffs.iter().copied()), + } +} + +/// Whether the logUp multiplicity height bound `Σᵢ wᵢ·hᵢ + C < p` holds, +/// where the sum ranges over the lookup slots of every circuit (`wᵢ` the +/// slot's declared per-row bound [`Lookup::max_multiplicity`], `hᵢ` its +/// circuit's trace height) and `C` is the number of public claims (each +/// claim enters the channel with multiplicity 1). +/// +/// LogUp cancellation is arithmetic modulo the field characteristic: if the +/// total integer weight aimed at a single message could reach `p`, a count +/// could wrap and a prover could forge multiplicities. Keeping the +/// system-wide weight below `p` makes field-level accumulator balance imply +/// exact integer multiset balance — provided each circuit actually +/// constrains its multiplicities to the declared bounds, which is the +/// circuit author's contract. +/// +/// `per_circuit` yields `(Σ_slots max_multiplicity, height)` pairs. Assumes +/// the base field is a prime field, so [`Field::order`] equals its +/// characteristic (true of every supported configuration). A total that +/// overflows `u128` is rejected conservatively. +pub(crate) fn multiplicity_height_bound_holds( + per_circuit: impl IntoIterator, + num_claims: usize, +) -> bool { + let mut total = num_claims as u128; + for (weight, height) in per_circuit { + let Some(contribution) = weight.checked_mul(height as u128) else { + return false; + }; + let Some(sum) = total.checked_add(contribution) else { + return false; + }; + total = sum; + } + num_bigint::BigUint::from(total) < F::order() +} + /// Concrete lookup values of one circuit, stored flat. /// /// Every row of a circuit has the same lookups (each with a fixed number of @@ -614,6 +770,7 @@ impl LookupValues { group_sizes: &[usize], lookup_challenge: EF, fingerprint_challenge: &EF, + width_binding: WidthBinding, mut accumulator: EF, ) -> (Vec>, Vec) { // Compute the message for each lookup, in flat circuit-major order. @@ -630,7 +787,8 @@ impl LookupValues { .map(|idx| { let (row, lookup) = (idx / circuit.num_lookups, idx % circuit.num_lookups); let args = circuit.args_at(row, lookup); - lookup_challenge + fingerprint(fingerprint_challenge, args.iter().cloned()) + lookup_challenge + + message_fingerprint(fingerprint_challenge, args, width_binding) }) .collect::>(), ); @@ -931,13 +1089,16 @@ mod tests { Lookup { multiplicity: Expr::constant(Val::ONE), args: vec![], + max_multiplicity: 1, }, ]; // Cover the ungrouped argument, an uneven grouping (3 lookups in // groups of 2 → a full pair plus a singleton tail), and one full // group of 3. - for group_size in [1, 2, 3] { - let synthesized = synthesize_lookups(&lookups, d, group_size); + for (group_size, width_binding) in [1, 2, 3].into_iter().flat_map(|g| { + [WidthBinding::Fingerprint, WidthBinding::ByConstruction].map(|wb| (g, wb)) + }) { + let synthesized = synthesize_lookups(&lookups, d, group_size, width_binding); // Deterministic pseudo-random base-field values (an equality of // polynomials checked at random points). @@ -989,6 +1150,7 @@ mod tests { .map(|l| Lookup { multiplicity: fresh(eval_expr(&l.multiplicity, &view)), args: l.args.iter().map(|a| fresh(eval_expr(a, &view))).collect(), + max_multiplicity: l.max_multiplicity, }) .collect(); // The reference spec expresses the injection as @@ -1011,6 +1173,7 @@ mod tests { w, d, group_size, + width_binding, &mut direct, ); @@ -1018,7 +1181,10 @@ mod tests { direct.len(), logup_constraint_count(lookups.len(), group_size, d) ); - assert_eq!(direct, reference, "group_size={group_size}"); + assert_eq!( + direct, reference, + "group_size={group_size} width_binding={width_binding:?}" + ); } } @@ -1284,4 +1450,47 @@ mod tests { let result = system.verify_multiple_claims(split_claims, &proof); assert!(result.is_err()); } + + #[test] + fn message_width_is_bound_into_the_fingerprint() { + use crate::types::ExtVal; + use p3_field::PrimeCharacteristicRing; + + let gamma = ExtVal::from_u64(0x1234_5678_9abc_def0).exp_u64(3) + ExtVal::from_u64(7); + let x = Val::from_u32(42); + // A bare tuple and its zero-extension must not alias: the width + // coordinate separates them. + let wb = WidthBinding::Fingerprint; + let short: ExtVal = message_fingerprint(&gamma, &[x], wb); + let extended: ExtVal = message_fingerprint(&gamma, &[x, Val::ZERO], wb); + assert_ne!(short, extended); + // fingerprint([x]) = x + 1·γ and fingerprint([x, 0]) = x + 2·γ². + assert_eq!(short, ExtVal::from(x) + gamma); + assert_eq!(extended, ExtVal::from(x) + gamma.square() * ExtVal::TWO); + + // Under ByConstruction the fold is unseeded: zero-extension is + // transparent (the prefix-freeness contract stands in for the + // seed), which is what lets branch-shared slots superpose messages + // of different natural widths. + let wb = WidthBinding::ByConstruction; + let short: ExtVal = message_fingerprint(&gamma, &[x], wb); + let extended: ExtVal = message_fingerprint(&gamma, &[x, Val::ZERO], wb); + assert_eq!(short, extended); + assert_eq!(short, ExtVal::from(x)); + } + + #[test] + fn multiplicity_height_bound_thresholds() { + // Goldilocks: p = 2^64 − 2^32 + 1, so a weight·height product of + // (2^32 − 1)·2^32 = p − 1 sits exactly under the bound and one more + // unit (a claim) reaches p. + let w = (1u128 << 32) - 1; + let h = 1usize << 32; + assert!(multiplicity_height_bound_holds::([(w, h)], 0)); + assert!(!multiplicity_height_bound_holds::([(w, h)], 1)); + // Totals overflowing u128 are rejected, not wrapped. + assert!(!multiplicity_height_bound_holds::([(u128::MAX, 2)], 0)); + // Weight-0 slots (empty lookups) contribute nothing. + assert!(multiplicity_height_bound_holds::([(0, usize::MAX)], 1)); + } } diff --git a/src/p3_adapter.rs b/src/p3_adapter.rs index f29c348..846fb17 100644 --- a/src/p3_adapter.rs +++ b/src/p3_adapter.rs @@ -258,6 +258,9 @@ impl AirBuilder for P3AirBuilder { // Public inputs are reserved for the lookup argument in this system, so // the builder exposes none (the default `public_values()` is empty). type PublicVar = P3Var; + // Periodic columns are not supported; the default `periodic_values()` + // is empty. + type PeriodicVar = P3Var; fn main(&self) -> Self::MainWindow { self.main.clone() @@ -275,6 +278,10 @@ impl AirBuilder for P3AirBuilder { P3Expr(Expr::IsLastRow) } + fn is_transition(&self) -> Self::Expr { + P3Expr(Expr::IsTransition) + } + /// # Panics /// Panics if `size` is not `2`; only two-row windows are supported. fn is_transition_window(&self, size: usize) -> Self::Expr { @@ -312,6 +319,7 @@ impl>> From> for CircuitInputs< .map(|lookup| Lookup { multiplicity: lookup.multiplicity.0, args: lookup.args.into_iter().map(|arg| arg.0).collect(), + max_multiplicity: lookup.max_multiplicity, }) .collect(); inputs diff --git a/src/prover.rs b/src/prover.rs index 146a239..e5d7820 100644 --- a/src/prover.rs +++ b/src/prover.rs @@ -182,7 +182,9 @@ use crate::config::{ Com, Domain, EvaluationsOnDomain, PackedChallenge, PackedVal, PcsProof, StarkGenericConfig, Val, }; use crate::eval::VarValues; -use crate::lookup::{LookupValues, fingerprint}; +use crate::lookup::{ + LookupValues, WidthBinding, message_fingerprint, multiplicity_height_bound_holds, +}; use crate::system::{ProverKey, System, SystemWitness}; use bincode::config::{Configuration, Fixint, LittleEndian, standard}; @@ -239,7 +241,7 @@ pub struct Proof { } impl Proof { - fn serde_config() -> Configuration { + pub(crate) fn serde_config() -> Configuration { standard().with_little_endian().with_fixed_int_encoding() } @@ -297,6 +299,25 @@ where let pcs = self.config.pcs(); let mut challenger = self.config.initialise_challenger(); + // Fail fast on the logUp multiplicity height bound; the verifier + // enforces the same bound as a soundness check + // (`VerificationError::MultiplicityOverflow`). Inactive circuits + // have empty traces and contribute nothing. + assert!( + multiplicity_height_bound_holds::>( + self.circuits.iter().zip(&witness.traces).map(|(c, t)| ( + c.graph + .lookups + .iter() + .map(|l| u128::from(l.max_multiplicity)) + .sum(), + t.height(), + )), + claims.len(), + ), + "logUp multiplicity height bound violated: Σ max_multiplicity·height + |claims| ≥ field characteristic", + ); + // Bind the system shape into the transcript. The protocol parameters // are already bound via the challenger seed. self.observe_shape(&mut challenger); @@ -383,7 +404,7 @@ where let mut acc = SC::Challenge::ZERO; for claim in claims { let message = lookup_argument_challenge - + fingerprint(&fingerprint_challenge, claim.iter().cloned()); + + message_fingerprint(&fingerprint_challenge, claim, self.config.width_binding()); acc += message.inverse(); } @@ -407,6 +428,7 @@ where &group_sizes, lookup_argument_challenge, &fingerprint_challenge, + self.config.width_binding(), acc, ); // The lookup witness can be as large as the traces themselves; free it @@ -496,6 +518,7 @@ where &stage_2_trace_on_quotient_domain, constraint_challenge, circuit.constraint_count(), + self.config.width_binding(), ); let quotient_flat = RowMajorMatrix::new_col(quotient_values).flatten_to_base::>(); @@ -769,6 +792,7 @@ fn quotient_values( stage_2_on_quotient_domain: &EvaluationsOnDomain<'_, SC>, alpha: SC::Challenge, constraint_count: usize, + width_binding: WidthBinding, ) -> Vec where SC: StarkGenericConfig, @@ -849,6 +873,7 @@ where i_start, ext_params.w, ext_params.degree, + width_binding, ) }; #[cfg(feature = "parallel")] @@ -886,6 +911,7 @@ fn quotient_values_inner( i_start: usize, ext_w: Val, ext_degree: usize, + width_binding: WidthBinding, ) -> impl Iterator where SC: StarkGenericConfig, @@ -944,6 +970,7 @@ where ext_w, ext_degree, circuit.lookup_group_size, + width_binding, &mut constraint_values, ); debug_assert_eq!(constraint_values.len(), circuit.constraint_count()); diff --git a/src/system.rs b/src/system.rs index 63335d8..1dbf9ad 100644 --- a/src/system.rs +++ b/src/system.rs @@ -184,10 +184,10 @@ impl System { max_constraint_degree, }; // The prover obtains trace evaluations on the quotient domain - // from the PCS, which can only serve domains up to - // `max_quotient_degree` times the trace domain (the blowup - // factor for FRI). Beyond that, proving would silently produce - // invalid proofs, so reject the circuit upfront. + // from the PCS, which serves domains up to `max_quotient_degree` + // times the trace domain (the FRI blowup) by cheap truncation of + // the committed LDE; larger domains hit a coefficient-recovery + // fallback. Reject the circuit upfront rather than pay it. assert!( circuit.quotient_degree() <= config.max_quotient_degree(), "circuit {i}: constraint degree {} needs quotient degree {}, but the PCS only \ @@ -230,6 +230,9 @@ impl System { /// [`StarkGenericConfig::initialise_challenger`]). pub fn observe_shape(&self, challenger: &mut SC::Challenger) { let mut observe = |x: usize| challenger.observe(Val::::from_usize(x)); + // The width-binding policy changes every message fingerprint, so + // transcripts under different policies must never collide. + observe(self.config.width_binding() as usize); observe(self.circuits.len()); for circuit in &self.circuits { observe(circuit.constraint_count()); @@ -420,10 +423,9 @@ mod tests { } } - /// The prover can only evaluate constraints on a domain `2^log_blowup` - /// times the trace domain, so the constraint degree is bounded by - /// `2^log_blowup + 1` (degree 3 at `log_blowup = 1`). Exceeding it used - /// to silently produce invalid proofs; it must be rejected at setup. + /// The reference config caps the quotient degree at the FRI blowup, so + /// the constraint degree is bounded by `2^log_blowup + 1` (degree 3 at + /// `log_blowup = 1`); circuits exceeding it are rejected at setup. #[test] #[should_panic(expected = "needs quotient degree 4, but the PCS only supports 2")] fn excessive_constraint_degree_rejected() { diff --git a/src/test_circuits/blake3.rs b/src/test_circuits/blake3.rs index 2cdcd52..b7493da 100644 --- a/src/test_circuits/blake3.rs +++ b/src/test_circuits/blake3.rs @@ -12,6 +12,11 @@ mod tests { use std::array; use std::ops::Range; + // Per-row bound declared for the committed count columns (unconstrained; + // the weight-1 pushes of the querying circuits keep actual counts within + // this budget). + const TABLE_QUERY_BUDGET: u64 = 1 << 32; + struct CompressionInfo { cv: [u32; 8], block_words: [u32; 16], @@ -117,8 +122,8 @@ mod tests { fn words_from_little_endian_bytes(bytes: &[u8], words: &mut [u32]) { debug_assert_eq!(bytes.len(), 4 * words.len()); - for (four_bytes, word) in bytes.chunks_exact(4).zip(words) { - *word = u32::from_le_bytes(four_bytes.try_into().unwrap()); + for (four_bytes, word) in bytes.as_chunks::<4>().0.iter().zip(words) { + *word = u32::from_le_bytes(*four_bytes); } } @@ -822,6 +827,7 @@ mod tests { multiplicity, [vec![SymbExpr::from_usize(circuit_idx)], state_in, state_out].concat(), ) + .with_max_multiplicity(TABLE_QUERY_BUDGET) } fn push_round( @@ -898,6 +904,7 @@ mod tests { var: fn(usize) -> SymbExpr, ) -> Lookup { lookup_u32_inner(Lookup::pull, multiplicity, circuit_idx, v_ind, var) + .with_max_multiplicity(TABLE_QUERY_BUDGET) } fn lookup_u32_inner( @@ -942,7 +949,8 @@ mod tests { preprocessed_var(1), preprocessed_var(2), ], - ), + ) + .with_max_multiplicity(TABLE_QUERY_BUDGET), Lookup::pull( var(1), vec![ @@ -950,7 +958,8 @@ mod tests { preprocessed_var(0), preprocessed_var(1), ], - ), + ) + .with_max_multiplicity(TABLE_QUERY_BUDGET), ] } @@ -1006,21 +1015,24 @@ mod tests { // (2 push lookups to pair_range_check) Self::U32RightRotate8 => { - let mut lookups = vec![Lookup::pull( - var(0), - vec![ - SymbExpr::from_usize(u32_right_rotate_8_idx), - var(1) - + var(2) * SymbExpr::from_u32(256) - + var(3) * SymbExpr::from_u32(256 * 256) - + var(4) * SymbExpr::from_u32(256 * 256 * 256), - // note var indices - var(2) - + var(3) * SymbExpr::from_u32(256) - + var(4) * SymbExpr::from_u32(256 * 256) - + var(1) * SymbExpr::from_u32(256 * 256 * 256), - ], - )]; + let mut lookups = vec![ + Lookup::pull( + var(0), + vec![ + SymbExpr::from_usize(u32_right_rotate_8_idx), + var(1) + + var(2) * SymbExpr::from_u32(256) + + var(3) * SymbExpr::from_u32(256 * 256) + + var(4) * SymbExpr::from_u32(256 * 256 * 256), + // note var indices + var(2) + + var(3) * SymbExpr::from_u32(256) + + var(4) * SymbExpr::from_u32(256 * 256) + + var(1) * SymbExpr::from_u32(256 * 256 * 256), + ], + ) + .with_max_multiplicity(TABLE_QUERY_BUDGET), + ]; // range check only input u32 word (since output is constructed exactly from the same bytes) lookups.extend((0..2).map(|i| { @@ -1039,21 +1051,24 @@ mod tests { // (2 push lookups to pair_range_check) Self::U32RightRotate16 => { - let mut lookups = vec![Lookup::pull( - var(0), - vec![ - SymbExpr::from_usize(u32_right_rotate_16_idx), - var(1) - + var(2) * SymbExpr::from_u32(256) - + var(3) * SymbExpr::from_u32(256 * 256) - + var(4) * SymbExpr::from_u32(256 * 256 * 256), - // note var indices - var(3) - + var(4) * SymbExpr::from_u32(256) - + var(1) * SymbExpr::from_u32(256 * 256) - + var(2) * SymbExpr::from_u32(256 * 256 * 256), - ], - )]; + let mut lookups = vec![ + Lookup::pull( + var(0), + vec![ + SymbExpr::from_usize(u32_right_rotate_16_idx), + var(1) + + var(2) * SymbExpr::from_u32(256) + + var(3) * SymbExpr::from_u32(256 * 256) + + var(4) * SymbExpr::from_u32(256 * 256 * 256), + // note var indices + var(3) + + var(4) * SymbExpr::from_u32(256) + + var(1) * SymbExpr::from_u32(256 * 256) + + var(2) * SymbExpr::from_u32(256 * 256 * 256), + ], + ) + .with_max_multiplicity(TABLE_QUERY_BUDGET), + ]; // range check only input u32 word (since output is constructed exactly from the same 4 bytes) lookups.extend((0..2).map(|i| { @@ -1071,37 +1086,43 @@ mod tests { } Self::U32RightRotate12 => { - vec![Lookup::pull( - var(0), - vec![ - SymbExpr::from_usize(u32_right_rotate_12_idx), - var(1) - + var(2) * SymbExpr::from_u32(256) - + var(3) * SymbExpr::from_u32(256 * 256) - + var(4) * SymbExpr::from_u32(256 * 256 * 256), - var(5) - + var(6) * SymbExpr::from_u32(256) - + var(7) * SymbExpr::from_u32(256 * 256) - + var(8) * SymbExpr::from_u32(256 * 256 * 256), - ], - )] + vec![ + Lookup::pull( + var(0), + vec![ + SymbExpr::from_usize(u32_right_rotate_12_idx), + var(1) + + var(2) * SymbExpr::from_u32(256) + + var(3) * SymbExpr::from_u32(256 * 256) + + var(4) * SymbExpr::from_u32(256 * 256 * 256), + var(5) + + var(6) * SymbExpr::from_u32(256) + + var(7) * SymbExpr::from_u32(256 * 256) + + var(8) * SymbExpr::from_u32(256 * 256 * 256), + ], + ) + .with_max_multiplicity(TABLE_QUERY_BUDGET), + ] } Self::U32RightRotate7 => { - vec![Lookup::pull( - var(0), - vec![ - SymbExpr::from_usize(u32_right_rotate_7_idx), - var(1) - + var(2) * SymbExpr::from_u32(256) - + var(3) * SymbExpr::from_u32(256 * 256) - + var(4) * SymbExpr::from_u32(256 * 256 * 256), - var(5) - + var(6) * SymbExpr::from_u32(256) - + var(7) * SymbExpr::from_u32(256 * 256) - + var(8) * SymbExpr::from_u32(256 * 256 * 256), - ], - )] + vec![ + Lookup::pull( + var(0), + vec![ + SymbExpr::from_usize(u32_right_rotate_7_idx), + var(1) + + var(2) * SymbExpr::from_u32(256) + + var(3) * SymbExpr::from_u32(256 * 256) + + var(4) * SymbExpr::from_u32(256 * 256 * 256), + var(5) + + var(6) * SymbExpr::from_u32(256) + + var(7) * SymbExpr::from_u32(256 * 256) + + var(8) * SymbExpr::from_u32(256 * 256 * 256), + ], + ) + .with_max_multiplicity(TABLE_QUERY_BUDGET), + ] } // Totally 81 byte columns: @@ -1157,7 +1178,8 @@ mod tests { + var(79) * SymbExpr::from_u32(256 * 256) + var(80) * SymbExpr::from_u32(256 * 256 * 256), ], - ), + ) + .with_max_multiplicity(TABLE_QUERY_BUDGET), // interacting with lower-level circuits that constrain operations used in G function // a_in + b_in = a_0_tmp diff --git a/src/test_circuits/byte_operations.rs b/src/test_circuits/byte_operations.rs index 58238ac..60cbc23 100644 --- a/src/test_circuits/byte_operations.rs +++ b/src/test_circuits/byte_operations.rs @@ -15,6 +15,11 @@ mod tests { // Main trace consists of multiplicities for each operation: `xor`, `and`, `or` and range check const TRACE_WIDTH: usize = 4; const BYTE_VALUES_NUM: usize = 256; + // Per-row bound declared for the committed count columns: the table + // supports up to 2^32 queries per entry. The columns are not + // range-constrained; the weight-1 pushes of the querying circuits are + // what keep the actual counts within this budget. + const TABLE_QUERY_BUDGET: u64 = 1 << 32; struct ByteCS {} @@ -87,17 +92,21 @@ mod tests { preprocessed_var(2 + i), ], ) + .with_max_multiplicity(TABLE_QUERY_BUDGET) }) .collect::>(); // Range checks do not have a return value - lookups.push(Lookup::pull( - var(pair_range_check_idx), - vec![ - SymbExpr::from_usize(pair_range_check_idx), - preprocessed_var(0), - preprocessed_var(1), - ], - )); + lookups.push( + Lookup::pull( + var(pair_range_check_idx), + vec![ + SymbExpr::from_usize(pair_range_check_idx), + preprocessed_var(0), + preprocessed_var(1), + ], + ) + .with_max_multiplicity(TABLE_QUERY_BUDGET), + ); lookups } } diff --git a/src/test_circuits/u32_add.rs b/src/test_circuits/u32_add.rs index 4c7ff25..5b64472 100644 --- a/src/test_circuits/u32_add.rs +++ b/src/test_circuits/u32_add.rs @@ -86,34 +86,45 @@ mod tests { } } + // Per-row bound declared for the committed count columns (unconstrained; + // the weight-1 pushes of the querying circuits keep actual counts within + // this budget). + const TABLE_QUERY_BUDGET: u64 = 1 << 32; + impl U32CS { fn lookups(&self) -> Vec> { let byte_index = SymbExpr::from_u8(0); let u32_index = SymbExpr::from_u8(1); match self { Self::ByteTable => { - vec![Lookup::pull(var(0), vec![byte_index, preprocessed_var(0)])] + vec![ + Lookup::pull(var(0), vec![byte_index, preprocessed_var(0)]) + .with_max_multiplicity(TABLE_QUERY_BUDGET), + ] } Self::U32Add => { // Pull - let mut lookups = vec![Lookup::pull( - var(13), - vec![ - u32_index, - var(0) - + var(1) * SymbExpr::from_u32(256) - + var(2) * SymbExpr::from_u32(256 * 256) - + var(3) * SymbExpr::from_u32(256 * 256 * 256), - var(4) - + var(5) * SymbExpr::from_u32(256) - + var(6) * SymbExpr::from_u32(256 * 256) - + var(7) * SymbExpr::from_u32(256 * 256 * 256), - var(8) - + var(9) * SymbExpr::from_u32(256) - + var(10) * SymbExpr::from_u32(256 * 256) - + var(11) * SymbExpr::from_u32(256 * 256 * 256), - ], - )]; + let mut lookups = vec![ + Lookup::pull( + var(13), + vec![ + u32_index, + var(0) + + var(1) * SymbExpr::from_u32(256) + + var(2) * SymbExpr::from_u32(256 * 256) + + var(3) * SymbExpr::from_u32(256 * 256 * 256), + var(4) + + var(5) * SymbExpr::from_u32(256) + + var(6) * SymbExpr::from_u32(256 * 256) + + var(7) * SymbExpr::from_u32(256 * 256 * 256), + var(8) + + var(9) * SymbExpr::from_u32(256) + + var(10) * SymbExpr::from_u32(256 * 256) + + var(11) * SymbExpr::from_u32(256 * 256 * 256), + ], + ) + .with_max_multiplicity(TABLE_QUERY_BUDGET), + ]; // Push lookups .extend((0..12).map(|i| { diff --git a/src/types.rs b/src/types.rs index ee42b65..62a37df 100644 --- a/src/types.rs +++ b/src/types.rs @@ -6,16 +6,12 @@ //! concrete, batteries-included instantiation. use crate::config::StarkGenericConfig; +use crate::lookup::WidthBinding; use p3_blake3::Blake3; -use p3_challenger::{ - CanObserve, CanSample, CanSampleBits, FieldChallenger, GrindingChallenger, HashChallenger, - SerializingChallenger64, -}; +use p3_challenger::{HashChallenger, SerializingChallenger64}; use p3_commit::{ExtensionMmcs, Pcs as PcsTrait}; use p3_dft::Radix2DitParallel; -use p3_field::{ - ExtensionField, Field, PrimeCharacteristicRing, TwoAdicField, extension::BinomialExtensionField, -}; +use p3_field::{ExtensionField, Field, TwoAdicField, extension::BinomialExtensionField}; use p3_fri::{FriParameters as InnerFriParameters, TwoAdicFriPcs}; use p3_goldilocks::Goldilocks; use p3_merkle_tree::MerkleTreeMmcs; @@ -25,60 +21,7 @@ pub type Val = Goldilocks; pub type PackedVal = ::Packing; pub type ExtVal = BinomialExtensionField; pub type PackedExtVal = >::ExtensionPacking; -pub type Challenger = - DeterministicPow>>; - -/// Forwarding challenger wrapper that makes zero-bit proof-of-work -/// deterministic. -/// -/// p3's `SerializingChallenger64::grind` races `find_any` over every -/// candidate witness even at 0 bits — where EVERY candidate passes — so the -/// returned witness is whichever a rayon thread reports first: a -/// thread-race-dependent value that lands in the proof bytes and makes -/// zero-PoW proofs nondeterministic run to run. (The duplex challenger -/// special-cases this; the serializing one doesn't.) Zero bits mean any -/// witness verifies, so return the canonical ZERO; positive bit counts -/// delegate to the inner grind unchanged. Transcript semantics are -/// untouched — `check_witness` at 0 bits observes nothing on either side. -#[derive(Clone, Debug)] -pub struct DeterministicPow(pub C); - -impl Challenger { - pub fn from_hasher(initial_state: Vec, hasher: Blake3) -> Self { - Self(SerializingChallenger64::from_hasher(initial_state, hasher)) - } -} - -impl> CanObserve for DeterministicPow { - fn observe(&mut self, value: T) { - self.0.observe(value); - } -} - -impl> CanSample for DeterministicPow { - fn sample(&mut self) -> T { - self.0.sample() - } -} - -impl> CanSampleBits for DeterministicPow { - fn sample_bits(&mut self, bits: usize) -> usize { - self.0.sample_bits(bits) - } -} - -impl> FieldChallenger for DeterministicPow {} - -impl GrindingChallenger for DeterministicPow { - type Witness = C::Witness; - - fn grind(&mut self, bits: usize) -> Self::Witness { - if bits == 0 { - return Self::Witness::ZERO; - } - self.0.grind(bits) - } -} +pub type Challenger = SerializingChallenger64>; pub type Mmcs = MerkleTreeMmcs, Blake3CompressionFunction, 2, 32>; pub type ExtMmcs = ExtensionMmcs; @@ -105,6 +48,8 @@ pub struct GoldilocksBlake3Config { max_quotient_degree: usize, /// Log2 of the blowup the PCS applies when committing. log_blowup: usize, + /// The message width-binding policy (see [`WidthBinding`]). + width_binding: WidthBinding, } impl GoldilocksBlake3Config { @@ -136,8 +81,19 @@ impl GoldilocksBlake3Config { max_log_degree, max_quotient_degree, log_blowup: commitment_parameters.log_blowup, + width_binding: WidthBinding::default(), } } + + /// Declares the message width-binding policy (see [`WidthBinding`]; + /// the default is [`WidthBinding::Fingerprint`]). The policy is bound + /// into the Fiat-Shamir transcript via + /// [`crate::system::System::observe_shape`], not the challenger seed, + /// so it may be set after construction. + pub fn with_width_binding(mut self, width_binding: WidthBinding) -> Self { + self.width_binding = width_binding; + self + } } impl StarkGenericConfig for GoldilocksBlake3Config { @@ -164,6 +120,10 @@ impl StarkGenericConfig for GoldilocksBlake3Config { fn log_blowup(&self) -> usize { self.log_blowup } + + fn width_binding(&self) -> WidthBinding { + self.width_binding + } } /// Parameters of the polynomial commitment: Reed-Solomon rate and Merkle diff --git a/src/verifier.rs b/src/verifier.rs index 40398a7..cfd8c37 100644 --- a/src/verifier.rs +++ b/src/verifier.rs @@ -50,7 +50,9 @@ //! - ρ = 2^(-log_blowup) — FRI rate parameter (inverse of the blowup factor) //! - n — number of FRI queries (`num_queries`) //! - k — number of constraints (after lookup expansion) -//! - N — total number of lookup rows across all circuits +//! - N — total number of lookup messages: lookup slots × rows, summed across +//! all circuits, plus the public claims +//! - W — maximum lookup message width (number of arguments) //! - D — maximum degree of the quotient polynomial (trace_degree × quotient_degree) //! //! ## FRI proximity test @@ -97,16 +99,42 @@ //! ## Lookup argument //! //! The accumulator-based lookup argument uses two random challenges (β, γ) to -//! compress lookup messages into field elements. For each lookup interaction, the -//! message `m = β + fingerprint(γ, args)` is a random affine function of the -//! challenges. If the multiset of "pushed" values differs from the multiset of -//! "pulled" values, the running accumulator `Σ multiplicity_i / m_i` is a nonzero -//! rational function of the challenges. By Schwartz-Zippel (applied to the -//! numerator after clearing denominators), the accumulator evaluates to zero with -//! probability at most **N / |F_ext|**. Crucially, the challenges are sampled -//! *after* the prover has committed to the stage-1 traces and the claims have been +//! compress lookup messages into field elements. Under the default +//! `WidthBinding::Fingerprint` policy each message is +//! `m = β + Σ_i args[i]·γ^i + W·γ^W` with `W` the message width — the width is +//! bound as the leading coefficient so that messages of different widths can +//! never alias (see `message_fingerprint` in the lookup module). Under +//! `WidthBinding::ByConstruction` the width term is omitted and the circuit +//! family's prefix-freeness contract rules out cross-width aliasing instead +//! (see `WidthBinding`); the argument below is otherwise unchanged. `m` has +//! degree 1 in β and degree W in γ. If the multiset of "pushed" values +//! differs from the multiset of "pulled" values, the running accumulator +//! `Σ multiplicity_i / m_i` is a nonzero rational function of the challenges, +//! and a cheating prover survives only through one of two events: +//! +//! - **Numerator root**: clearing denominators leaves a nonzero polynomial of +//! degree ≤ N in β and ≤ N·W in γ; a random (β, γ) is a root with +//! probability at most **N·(W+1) / |F_ext|** (Schwartz-Zippel, per-variable +//! union bound). +//! - **Vanishing denominator**: if some message evaluates to zero, the +//! multiplied-through step constraint degenerates — the +//! `(Π_j m_j)·(acc′ − acc)` factor is annihilated, leaving the accumulator +//! unconstrained on that row. Every message is monic in β, so for each γ +//! exactly one β kills each message: probability at most **N / |F_ext|**. +//! +//! Together, the lookup argument's soundness error is at most +//! **N·(W+2) / |F_ext|**. Crucially, the challenges are sampled *after* the +//! prover has committed to the stage-1 traces and the claims have been //! observed, so the prover cannot adapt them. //! +//! Field-level accumulator balance implies exact integer multiset balance +//! only if no per-message count can wrap modulo the field characteristic: +//! the verifier enforces the height bound `Σ wᵢ·hᵢ + |claims| < p` over every +//! lookup slot's declared per-row multiplicity bound +//! ([`crate::lookup::Lookup::max_multiplicity`]), rejecting with +//! [`VerificationError::MultiplicityOverflow`] otherwise; each circuit must +//! in turn constrain its multiplicities to the declared bounds. +//! //! ## Fiat-Shamir (random oracle model) //! //! All challenges (α, ζ, β, γ) are derived from the transcript via the @@ -121,7 +149,7 @@ //! By a union bound, the total soundness error is at most: //! //! ```text -//! ε ≤ ε_FRI + (k - 1 + D + N) / |F_ext| +//! ε ≤ ε_FRI + (k - 1 + D + N·(W+2)) / |F_ext| //! ``` //! //! where ε_FRI is the FRI soundness error (see above for the conjectured vs proven @@ -159,10 +187,10 @@ //! actual low-degree-extension values of the witness. Do not use it when the //! witness must remain hidden from the verifier. -use crate::config::{PcsError, StarkGenericConfig, Val}; +use crate::config::{Com, Domain, PcsError, StarkGenericConfig, Val}; use crate::ensure_eq; use crate::eval::VarValues; -use crate::lookup::fingerprint; +use crate::lookup::{message_fingerprint, multiplicity_height_bound_holds}; use crate::prover::Proof; use crate::system::System; @@ -185,12 +213,60 @@ pub enum VerificationError { InvalidProofShape, /// The system configuration is invalid (e.g. no circuits). InvalidSystem, + /// The logUp multiplicity height bound `Σ wᵢ·hᵢ + |claims| < p` is + /// violated at the claimed trace heights: some per-message integer + /// count could wrap modulo the field characteristic, so accumulator + /// balance would no longer imply integer multiset balance. + MultiplicityOverflow, /// The recomputed composition polynomial does not match the quotient. OodEvaluationMismatch, + /// The out-of-domain point ζ landed inside a trace domain, where the + /// Lagrange selectors are undefined (they divide by the vanishing + /// polynomial). Honest Fiat-Shamir sampling reaches this only with + /// probability |H|/|F_ext| per circuit; rejecting keeps the verifier + /// panic-free on adversarial proofs. + OodPointInDomain, /// The lookup accumulator did not balance to zero. UnbalancedChannel, } +/// Everything the PCS-opening phase consumes, produced by replaying the +/// transcript up to (but not including) `pcs.verify`: the reconstructed +/// per-commitment opening rounds, the challenger positioned exactly +/// where `pcs.verify` expects it, the sampled challenges, and the +/// derived per-circuit data the post-opening checks reuse. +/// +/// Extracted so the advice re-encoder (see [`crate::advice`]) can drive +/// the PCS phase with instrumented components against the *same* rounds +/// and transcript state as [`System::verify_multiple_claims`], with no +/// second copy of the Fiat-Shamir replay. +/// The per-commitment opening rounds handed to `pcs.verify`, in prover +/// order: for each commitment, each matrix's domain and its opening +/// points with claimed values. +pub(crate) type OpeningRounds = Vec<( + Com, + Vec<( + Domain, + Vec<( + ::Challenge, + Vec<::Challenge>, + )>, + )>, +)>; + +pub(crate) struct PcsVerificationContext { + pub rounds: OpeningRounds, + pub challenger: SC::Challenger, + pub zeta: SC::Challenge, + pub lookup_argument_challenge: SC::Challenge, + pub fingerprint_challenge: SC::Challenge, + pub constraint_challenge: SC::Challenge, + /// The lookup accumulator seeded from the claims. + pub claim_accumulator: SC::Challenge, + pub active_indices: Vec, + pub quotient_degrees: Vec, +} + impl System { /// Verifies a STARK proof against a single claim. pub fn verify( @@ -204,12 +280,15 @@ impl System { self.verify_multiple_claims(&[claim], proof) } - /// Verifies a STARK proof against multiple claims. - pub fn verify_multiple_claims( + /// Replays the transcript up to the PCS-opening phase, producing the + /// [`PcsVerificationContext`] shared by [`System::verify_multiple_claims`] + /// and the advice re-encoder. Includes the shape, height-bound and + /// accumulator-balance checks that precede the replay. + pub(crate) fn pcs_verification_context( &self, claims: &[&[Val]], proof: &Proof, - ) -> Result<(), VerificationError>> + ) -> Result, VerificationError>> where Val: TwoAdicField, { @@ -218,7 +297,7 @@ impl System { commitments, intermediate_accumulators, log_degrees, - opening_proof, + opening_proof: _, quotient_opened_values, preprocessed_opened_values, stage_1_opened_values, @@ -234,6 +313,29 @@ impl System { .filter_map(|(i, &a)| a.then_some(i)) .collect(); + // Soundness: logUp height bound. With Σ wᵢ·hᵢ + |claims| < p, no + // per-message integer count can wrap modulo the field + // characteristic, so accumulator balance implies exact multiset + // balance (see `multiplicity_height_bound_holds`). + crate::ensure!( + multiplicity_height_bound_holds::>( + active_indices + .iter() + .zip(log_degrees) + .map(|(&ci, log_degree)| ( + self.circuits[ci] + .graph + .lookups + .iter() + .map(|l| u128::from(l.max_multiplicity)) + .sum(), + 1usize << usize::from(*log_degree), + )), + claims.len(), + ), + VerificationError::MultiplicityOverflow + ); + // Soundness: lookup argument. The accumulator was computed by the prover // under challenges (β, γ) that were sampled after the traces and claims were // committed. If the pushed and pulled multisets differ, the accumulator is a @@ -309,7 +411,7 @@ impl System { let mut acc = SC::Challenge::ZERO; for claim in claims { let message = lookup_argument_challenge - + fingerprint(&fingerprint_challenge, claim.iter().cloned()); + + message_fingerprint(&fingerprint_challenge, claim, self.config.width_binding()); acc += message.inverse(); } @@ -406,11 +508,57 @@ impl System { preprocessed_trace_evaluations, )); } + Ok(PcsVerificationContext { + rounds: coms_to_verify, + challenger, + zeta, + lookup_argument_challenge, + fingerprint_challenge, + constraint_challenge, + claim_accumulator: acc, + active_indices, + quotient_degrees, + }) + } + + /// Verifies a STARK proof against multiple claims. + pub fn verify_multiple_claims( + &self, + claims: &[&[Val]], + proof: &Proof, + ) -> Result<(), VerificationError>> + where + Val: TwoAdicField, + { + let Proof { + intermediate_accumulators, + log_degrees, + opening_proof, + quotient_opened_values, + preprocessed_opened_values, + stage_1_opened_values, + stage_2_opened_values, + .. + } = proof; + let PcsVerificationContext { + rounds, + mut challenger, + zeta, + lookup_argument_challenge, + fingerprint_challenge, + constraint_challenge, + claim_accumulator, + active_indices, + quotient_degrees, + } = self.pcs_verification_context(claims, proof)?; + let mut acc = claim_accumulator; + let pcs = self.config.pcs(); + // Soundness: FRI proximity test. Verifies that the committed polynomials // are close to low-degree polynomials and that the claimed evaluations are // consistent with the commitments. Soundness error ≤ ρ^num_queries, where // ρ = 2^(-log_blowup). This is the dominant term in the overall bound. - pcs.verify(coms_to_verify, opening_proof, &mut challenger) + pcs.verify(rounds, opening_proof, &mut challenger) .map_err(VerificationError::InvalidOpeningArgument)?; // use the opened values to compute the composition polynomial for each circuit @@ -424,6 +572,13 @@ impl System { let quotient_degree = quotient_degrees[pos]; let next_acc = intermediate_accumulators[pos]; let trace_domain = pcs.natural_domain_for_degree(degree); + // ζ inside the trace domain would make the Lagrange selectors + // divide by Z_H(ζ) = 0 and panic; reject it instead. Honest + // sampling lands here only with probability |H|/|F_ext|. + crate::ensure!( + !trace_domain.vanishing_poly_at_point(zeta).is_zero(), + VerificationError::OodPointInDomain + ); let sels = trace_domain.selectors_at_point(zeta); // The logUp boundary injection absorbs the last-row selector's // normalization constant into Δ (mirroring the prover): p3's @@ -491,6 +646,7 @@ impl System { crate::system::extension_params::().w, extension_d, circuit.lookup_group_size, + self.config.width_binding(), &mut constraint_values, ); debug_assert_eq!(constraint_values.len(), circuit.constraint_count());