diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 15cf08b..aaf14e3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,7 +25,7 @@ jobs: - uses: Swatinem/rust-cache@v2 - name: Linux Tests run: | - cargo nextest run --profile ci --cargo-profile dev-ci --workspace --features parallel + cargo nextest run --profile ci --cargo-profile dev-ci --workspace --features parallel,kzg lints: runs-on: ubuntu-latest diff --git a/Cargo.lock b/Cargo.lock index 7d11a6e..54059f2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,18 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -11,6 +23,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "anes" version = "0.1.6" @@ -23,6 +41,131 @@ version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" +[[package]] +name = "ark-bls12-381" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3df4dcc01ff89867cd86b0da835f23c3f02738353aaee7dde7495af71363b8d5" +dependencies = [ + "ark-ec", + "ark-ff", + "ark-serialize", + "ark-std", +] + +[[package]] +name = "ark-ec" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d68f2d516162846c1238e755a7c4d131b892b70cc70c471a8e3ca3ed818fce" +dependencies = [ + "ahash", + "ark-ff", + "ark-poly", + "ark-serialize", + "ark-std", + "educe", + "fnv", + "hashbrown", + "itertools 0.13.0", + "num-bigint", + "num-integer", + "num-traits", + "zeroize", +] + +[[package]] +name = "ark-ff" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a177aba0ed1e0fbb62aa9f6d0502e9b46dad8c2eab04c14258a1212d2557ea70" +dependencies = [ + "ark-ff-asm", + "ark-ff-macros", + "ark-serialize", + "ark-std", + "arrayvec", + "digest", + "educe", + "itertools 0.13.0", + "num-bigint", + "num-traits", + "paste", + "zeroize", +] + +[[package]] +name = "ark-ff-asm" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62945a2f7e6de02a31fe400aa489f0e0f5b2502e69f95f853adb82a96c7a6b60" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "ark-ff-macros" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09be120733ee33f7693ceaa202ca41accd5653b779563608f1234f78ae07c4b3" +dependencies = [ + "num-bigint", + "num-traits", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "ark-poly" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "579305839da207f02b89cd1679e50e67b4331e2f9294a57693e5051b7703fe27" +dependencies = [ + "ahash", + "ark-ff", + "ark-serialize", + "ark-std", + "educe", + "fnv", + "hashbrown", +] + +[[package]] +name = "ark-serialize" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f4d068aaf107ebcd7dfb52bc748f8030e0fc930ac8e360146ca54c1203088f7" +dependencies = [ + "ark-serialize-derive", + "ark-std", + "arrayvec", + "digest", + "num-bigint", +] + +[[package]] +name = "ark-serialize-derive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "213888f660fddcca0d257e88e54ac05bca01885f258ccdf695bafd77031bb69d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "ark-std" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "246a225cc6131e9ee4f24619af0f19d67761fff15d7ccc22e42b80846e69449a" +dependencies = [ + "num-traits", + "rand 0.8.7", +] + [[package]] name = "arrayref" version = "0.3.9" @@ -117,7 +260,7 @@ checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" dependencies = [ "cfg-if", "cpufeatures", - "rand_core", + "rand_core 0.10.0", ] [[package]] @@ -254,12 +397,63 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "crypto-common", +] + +[[package]] +name = "educe" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d7bc049e1bd8cdeb31b68bbd586a9464ecf9f3944af3958a7a9d0f8b9799417" +dependencies = [ + "enum-ordinalize", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "either" version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "enum-ordinalize" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89dd01549b09589510cf0647475075d12071456586d70f5c75c98ae2a5537677" +dependencies = [ + "enum-ordinalize-derive", +] + +[[package]] +name = "enum-ordinalize-derive" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a65863d15a4ce2888bd2f0f543cc963d3879c3a022c8ee43f6141d479a3ac815" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "errno" version = "0.3.14" @@ -276,6 +470,12 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "generator" version = "0.8.9" @@ -291,6 +491,16 @@ dependencies = [ "windows-result", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.4.3" @@ -300,7 +510,7 @@ dependencies = [ "cfg-if", "libc", "r-efi", - "rand_core", + "rand_core 0.10.0", ] [[package]] @@ -314,6 +524,15 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", +] + [[package]] name = "hermit-abi" version = "0.5.2" @@ -340,6 +559,15 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.14.0" @@ -430,7 +658,13 @@ checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" name = "multi-stark" version = "0.1.0" dependencies = [ + "ark-bls12-381", + "ark-ec", + "ark-ff", + "ark-poly", + "ark-serialize", "bincode", + "blake3", "criterion", "p3-air", "p3-baby-bear", @@ -447,7 +681,7 @@ dependencies = [ "p3-merkle-tree", "p3-symmetric", "p3-util", - "rand", + "rand 0.10.2", "serde", "tracing", "tracing-subscriber", @@ -525,7 +759,7 @@ dependencies = [ "p3-poseidon1", "p3-poseidon2", "p3-symmetric", - "rand", + "rand 0.10.2", ] [[package]] @@ -590,7 +824,7 @@ dependencies = [ "p3-maybe-rayon", "p3-util", "paste", - "rand", + "rand 0.10.2", "serde", "tracing", ] @@ -609,7 +843,7 @@ dependencies = [ "p3-matrix", "p3-maybe-rayon", "p3-util", - "rand", + "rand 0.10.2", "serde", "spin", "thiserror", @@ -631,7 +865,7 @@ dependencies = [ "p3-symmetric", "p3-util", "paste", - "rand", + "rand 0.10.2", "serde", ] @@ -665,7 +899,7 @@ dependencies = [ "p3-field", "p3-maybe-rayon", "p3-util", - "rand", + "rand 0.10.2", "serde", "tracing", ] @@ -687,7 +921,7 @@ dependencies = [ "p3-field", "p3-symmetric", "p3-util", - "rand", + "rand 0.10.2", ] [[package]] @@ -702,7 +936,7 @@ dependencies = [ "p3-maybe-rayon", "p3-symmetric", "p3-util", - "rand", + "rand 0.10.2", "serde", "thiserror", "tracing", @@ -725,7 +959,7 @@ dependencies = [ "p3-symmetric", "p3-util", "paste", - "rand", + "rand 0.10.2", "serde", "spin", "tracing", @@ -738,7 +972,7 @@ source = "git+https://github.com/Plonky3/Plonky3?rev=e9d75614dd6816f9b5dbb4413c6 dependencies = [ "p3-field", "p3-symmetric", - "rand", + "rand 0.10.2", ] [[package]] @@ -750,7 +984,7 @@ dependencies = [ "p3-mds", "p3-symmetric", "p3-util", - "rand", + "rand 0.10.2", ] [[package]] @@ -836,6 +1070,15 @@ dependencies = [ "plotters-backend", ] +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -860,6 +1103,16 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +dependencies = [ + "rand_chacha", + "rand_core 0.6.4", +] + [[package]] name = "rand" version = "0.10.2" @@ -868,9 +1121,25 @@ checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ "chacha20", "getrandom", - "rand_core", + "rand_core 0.10.0", ] +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + [[package]] name = "rand_core" version = "0.10.0" @@ -1002,7 +1271,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1065,6 +1334,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "terminal_size" version = "0.4.4" @@ -1092,7 +1372,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1142,7 +1422,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1206,6 +1486,12 @@ dependencies = [ "strength_reduce", ] +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -1224,6 +1510,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "virtue" version = "0.0.18" @@ -1272,7 +1564,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wasm-bindgen-shared", ] @@ -1345,7 +1637,27 @@ checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 7f50d65..6eb1402 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,6 +30,14 @@ p3-merkle-tree = { git = "https://github.com/Plonky3/Plonky3", rev = "e9d75614dd p3-symmetric = { git = "https://github.com/Plonky3/Plonky3", rev = "e9d75614dd6816f9b5dbb4413c69be63536efd64" } p3-util = { git = "https://github.com/Plonky3/Plonky3", rev = "e9d75614dd6816f9b5dbb4413c69be63536efd64" } +# KZG backend (feature `kzg`) +ark-bls12-381 = { version = "0.5", features = ["curve"], optional = true } +ark-ec = { version = "0.5", optional = true } +ark-ff = { version = "0.5", optional = true } +ark-poly = { version = "0.5", optional = true } +ark-serialize = { version = "0.5", optional = true } +blake3 = { version = "1", optional = true } + [dev-dependencies] criterion = "0.5" p3-baby-bear = { git = "https://github.com/Plonky3/Plonky3", rev = "e9d75614dd6816f9b5dbb4413c69be63536efd64" } @@ -43,6 +51,14 @@ harness = false [features] parallel = ["p3-maybe-rayon/parallel"] +kzg = [ + "dep:ark-bls12-381", + "dep:ark-ec", + "dep:ark-ff", + "dep:ark-poly", + "dep:ark-serialize", + "dep:blake3", +] # Similar to `release`, but preserves debug info [profile.dev-ci] diff --git a/docs/pcs-abstraction.md b/docs/pcs-abstraction.md new file mode 100644 index 0000000..d667be3 --- /dev/null +++ b/docs/pcs-abstraction.md @@ -0,0 +1,389 @@ +# PCS abstraction: generalizing multi-stark over FRI and KZG + +Design document. Goal: make the multi-stark prover/verifier generic over the +polynomial commitment scheme through a trait layer **owned by this crate**, +with Plonky3 (FRI/Goldilocks) and arkworks (KZG/BLS12-381) living behind +adapters. Everything above the PCS — the constraint IR, logUp, sparse +activation, claims binding, quotient slicing, Fiat-Shamir structure — stays +exactly as it is. + +## 1. Why + +The proving pipeline is a compression funnel: + +| Stage | System | PCS | Proof size driver | +|---|---|---|---| +| 1. kernel | fat (~10k+ cols) | FRI | fast prover matters most | +| 2. recursive verifier | ~7.7k cols | FRI | `width x queries x 8B` (~4 MB @ q=50) | +| 3. final wrap | recursive verifier again | **KZG** | `width x 112B` (~0.9 MB), constant-ish opening | + +FRI proof bytes are `total_width x num_queries x 8` for the opened base rows +plus ~20% Merkle/commit-phase overhead. KZG proof bytes are per *column*, +independent of queries: + +- 48 B — one compressed BLS12-381 G1 commitment per column, +- 32 + 32 B — the two opened evaluations (zeta, zeta*g) per stage column + (quotient columns open only at zeta: 32 B), +- ~2 G1 + a few Fr for the batched opening proof (amortized to zero). + +So ~112 B/column: the same 7.7k-column system that costs 4.16 MB under FRI +costs ~0.87 MB under KZG, before any plonkish re-circuiting. Every column +removed upstream (inlining, grouping, packing) now pays twice — once per FRI +stage and 112 B in the wrap. + +A KZG stage is terminal: its verifier runs natively (two pairings), never +in-circuit, so nothing on the ix/Aiur side changes. + +## 2. Current coupling (measured, not guessed) + +`ProofConfig` (config.rs) binds Plonky3 traits directly: +`Pcs`, `ExtensionField`, `FieldChallenger` + +`CanObserve`. The full surface the core actually uses: + +- **PCS**: `natural_domain_for_degree`, `commit`, `commit_ldes` (quotient + fast path), `open`, `verify`, `get_evaluations_on_domain` (8+2+1+1+1+3 + call sites across prover.rs/verifier.rs). +- **Domain** (`PolynomialSpace`): `selectors_at_point`, `next_point` / + generator access, `size`. +- **Challenger**: `observe`, `observe_slice`, `observe_algebra_element`, + `sample_algebra_element`, `CanObserve`. (FRI's `sample_bits` and + grinding live *inside* the p3 PCS — already behind the boundary.) +- **Field/matrix plumbing**: `p3_field` (`Field`, `TwoAdicField`, + `ExtensionField`, `PrimeCharacteristicRing`, `BasedVectorSpace`, + packing), `p3_matrix::RowMajorMatrix`, `p3_dft` (quotient coefficient + path), `p3_util` bit-reversal helpers. + +Note: today's `p3_adapter.rs` is an **AIR frontend** adapter (p3_air -> +`CircuitInputs`), not a PCS adapter. It keeps that role and moves to +`p3_adapter/air.rs`. + +## 3. The trait layer (crate-owned, in `src/traits/`) + +Mirror the measured surface, nothing more. Naming convention (decided): +natural names, no prefixes — our traits are `Transcript`, +`EvaluationDomain`, `Pcs`; where a backend's name collides, the backend +import is renamed at the adapter (`use p3_commit::Pcs as P3Pcs`), never +ours. Associated types over generics throughout (inference-unambiguous, +no E0207 blanket-impl traps), no blanket impls — each config states its +instantiations concretely (impls must name concrete types anyway: +coherence cannot see through Pcs-projection aliases in impl headers). +The one deliberate fancy construct is the `Evaluations<'a>` GAT on +`Pcs` (borrowed LDE views beat copies in the constraint sweep). +Status: PHASES 0 AND 1 ARE COMPLETE. Phase 0: Transcript, +EvaluationDomain, Pcs, and the field layer (Field/TwoAdicField/Algebra/ +Packed/ExtensionOf/PackedExtension) all landed as behavioral no-ops +under the proof-bytes pin; core imports no p3 proof-system traits — +p3_matrix (container), p3_util (log2), and p3_maybe_rayon (parallelism) +remain as utility libraries. Phase 1: `ark_adapter/` (feature `kzg`) +lands the BLS12-381 backend — `Scalar` (D = 1 challenge field, width-1 +packing), `Radix2Coset`, `Blake3Transcript`, `Srs`, and `KzgPcs` +(monomial commitments, coefficient-slice quotient, per-point batched +witness openings, one 2-pairing verification) — with the multi-stark +proving and verifying end-to-end under `KzgConfig` on hand-authored +`CircuitInputs`. Next: Phase 2 at scale (recursive-verifier system, +differential + size/time benches; ceremony SRS loading). Original sketches +below (kept for the rationale; the landed signatures in src/traits/ are +authoritative and differ in detail): + +```rust +// traits/field.rs +pub trait MsField: + Copy + Send + Sync + Eq + Serialize + DeserializeOwned + + Add + Sub + Mul + Neg + ... // ring ops +{ + const ZERO: Self; const ONE: Self; + fn inverse(&self) -> Self; + fn from_u64(x: u64) -> Self; + /// Canonical little-endian bytes — the transcript encoding. + fn to_canonical_bytes(&self) -> Vec; // fixed len per impl +} + +pub trait MsTwoAdicField: MsField { + const TWO_ADICITY: usize; + fn two_adic_generator(bits: usize) -> Self; +} + +/// Challenge field as a based vector space over the base field. +/// CRUCIAL: D = 1 must be a first-class case (KZG over BLS12-381 Fr — +/// |Fr| ~ 2^255 needs no extension; the identity impl makes logUp, +/// stage-2 flattening, and quotient slicing collapse their D factor). +pub trait MsChallenge: MsField + From { + const D: usize; + fn from_basis_coefficients(slice: &[F]) -> Self; + fn as_basis_coefficients(&self) -> &[F]; +} +``` + +```rust +// traits/pcs.rs +pub trait MsPcs { + type F: MsTwoAdicField; + type Challenge: MsChallenge; + type Domain: MsDomain; + type Commitment: Clone + Serialize + DeserializeOwned; + type ProverData; + type Proof: Serialize + DeserializeOwned; + type Error: Debug; + + fn natural_domain_for_degree(&self, degree: usize) -> Self::Domain; + + /// The largest quotient degree (as a multiple of the trace degree) + /// this PCS serves ECONOMICALLY. Advisory: `System::new` validates + /// every circuit's quotient degree against it at build time (loud, + /// per-circuit error), and the lookup-grouping policy keys on it. + /// FRI: `1 << log_blowup` — the LDE-subsetting economy. KZG: the + /// coset-FFT budget (SRS-independent thanks to quotient chunking). + fn max_quotient_degree(&self) -> usize; + + /// Commit to matrices of evaluations over their domains. + /// FRI: coset LDE + one Merkle tree over all matrices. + /// KZG: per column, iFFT to coefficients + MSM against the monomial + /// SRS; Commitment = Vec in matrix-then-column order. + fn commit(&self, evals: Vec<(Self::Domain, Matrix)>) + -> (Self::Commitment, Self::ProverData); + + /// Commit the per-circuit quotients given by their EVALUATIONS on + /// the disjoint quotient domain plus the quotient degree. (Chosen + /// over a coefficients-based boundary: the evals->coeffs transform + /// needs a DFT engine, which is backend property, so the whole + /// conversion — slicing included — lives behind the trait. FRI: + /// fused shifted gather + zero-padded DFT; KZG: coset iDFT + MSM.) + fn commit_quotient(&self, quotients: Vec<(Self::Domain, Matrix, usize)>) + -> (Self::Commitment, Self::ProverData); + + /// Prover-side access to committed evaluations (constraint sweep). + fn get_evaluations_on_domain(&self, data: &Self::ProverData, + idx: usize, domain: Self::Domain) -> impl Matrix; + + fn open(&self, rounds: Vec<(&Self::ProverData, Vec>)>, + challenger: &mut impl MsChallenger<...>) + -> (OpenedValues, Self::Proof); + + fn verify(&self, rounds: ..., proof: &Self::Proof, + challenger: &mut impl MsChallenger<...>) -> Result<(), Self::Error>; +} +``` + +```rust +// traits/domain.rs — subgroup domains, both backends are two-adic +pub trait MsDomain: Copy { + fn size(&self) -> usize; + fn first_point(&self) -> F; + fn next_point(&self, x: EF) -> EF; // x * g + fn selectors_at_point(&self, z: EF) -> LagrangeSelectors; + fn vanishing_poly_at_point(&self, z: EF) -> EF; +} + +// traits/challenger.rs +pub trait MsChallenger { + fn observe_field(&mut self, x: F); + fn observe_slice(&mut self, xs: &[F]); + fn observe_algebra(&mut self, x: EF); + /// Commitments observe as canonical bytes: Merkle caps are digest + /// limbs, G1 points are 48-byte compressed encodings. This is the one + /// place the two backends' transcripts structurally differ. + fn observe_commitment(&mut self, c: &Com); + fn sample_algebra(&mut self) -> EF; +} + +// traits/config.rs — replaces ProofConfig +pub trait MsConfig { + type F: MsTwoAdicField; + type Challenge: MsChallenge; + type Pcs: MsPcs; + type Challenger: MsChallenger>; + fn pcs(&self) -> &Self::Pcs; + fn initialise_challenger(&self) -> Self::Challenger; + /// Protocol-parameter words bound into the challenger seed. FRI: + /// blowup/queries/PoW/arity as today. KZG: SRS digest + degree bound. + fn parameter_seed_words(&self) -> Vec; +} +``` + +**Matrix decision**: define a minimal crate-owned `Matrix` (row-major +`Vec` + width — what `RowMajorMatrix` is), so `ark_ff::Fr` needs no p3 +trait impls. The p3 adapter converts by reinterpretation (same layout, +zero-copy where possible). Packing (`PackedVal`) is a prover-speed detail of +the constraint sweep; expose it as an associated `Packing` type on `MsField` +with a scalar (identity) default so the ark backend works unpacked first. + +## 4. Adapter layout + +``` +src/ + traits/ field.rs pcs.rs domain.rs challenger.rs config.rs + p3_adapter/ + mod.rs + air.rs (today's p3_adapter.rs, unchanged role) + field.rs MsField/MsTwoAdicField/MsChallenge for Goldilocks + deg-2 ext + pcs.rs TwoAdicFriPcs wrapped as MsPcs (commit_coefficients = + today's commit_ldes path) + challenger.rs SerializingChallenger64 as MsChallenger + domain.rs TwoAdicMultiplicativeCoset as MsDomain + ark_adapter/ + mod.rs + field.rs Fr (BLS12-381 scalar): MsField + MsTwoAdicField + (TWO_ADICITY = 32); MsChallenge with D = 1 (identity) + domain.rs radix-2 subgroup domain (ark-poly Radix2EvaluationDomain + or a ~50-line own impl to keep deps thin) + pcs.rs KZG (section 5) + srs.rs SRS loading, ceremony formats, dev-mode tau setup + challenger.rs same Blake3 transcript over 32-byte canonical Fr + + 48-byte compressed G1 observations +``` + +Cargo features: `fri` (default, pulls p3), `kzg` (pulls ark-bls12-381, +ark-ec, ark-ff; NOT ark-poly-commit — the KZG we need is ~300 lines over +MSM + pairing, and ark-poly-commit's abstraction tax isn't worth it). +`p3_adapter/air.rs` stays under `fri` (it is the Aiur frontend's entry). + +## 5. KZG backend specification + +- **Commitment**: one G1 point per column. `commit` = per column: iFFT + (n log n over Fr) to monomial coefficients, then an MSM of size n against + the monomial SRS. `commit_coefficients` skips the iFFT (quotient slices + arrive as coefficients — the existing slicing already caps every slice's + degree at n, so **the SRS never needs to exceed the max trace height**). +- **Opening**: all stage-1/stage-2/preprocessed columns at {zeta, zeta*g}, + quotient columns at {zeta}. Two-point batch opening a la BDFG20 / the + standard Plonk shape: sample batching challenge v, aggregate per point, + one witness polynomial per point => proof = (W_zeta, W_zeta_g): 2 G1 = + 96 B + the opened evaluations. Verification: 2 pairings after combining + commitments homomorphically (the verifier-side MSM over ~width points is + the dominant native cost — fine for a terminal stage). +- **Transcript**: same Blake3 challenger; Fr observes as 32 canonical LE + bytes, G1 as 48 compressed bytes. The parameter seed binds an SRS digest. +- **SRS**: needs monomial powers up to max trace height (2^22-ish for + kernel-scale; the wrap stage proves the ~2^13-2^17 verifier system, so + 2^20 is comfortable). Source from a public ceremony (perpetual powers of + tau covers 2^28); `srs.rs` parses that format and carries a dev-mode + `unsafe_setup(tau)` for tests, feature-gated so it cannot ship. +- **Degrees**: Fr two-adicity 32 >> any trace height. `max_quotient_degree` + is a FRI/blowup economy, not a protocol constant — it becomes a + `MsPcs`-reported value (`fn max_quotient_degree(&self)`): FRI reports + `1 << log_blowup` (today's types.rs:132), KZG reports its coset-FFT + budget (SRS stays at `n_max` thanks to quotient chunking — see quirk 3). + The lookup grouping policy reads the same cap, so KZG configs group + deeper for free. + +## 6. What stays identical / what simplifies + +Identical: constraint graph + compilation, logUp chained accumulators and +grouping, sparse activation, claims observation, quotient construction and +slicing, verifier's OOD identity, `Commitments` / `Proof` shapes +(already generic over `Com`/`PcsProof`). + +Simplifies under D = 1: stage-2 "flatten to base columns" becomes identity; +`from_ext_basis` reconstruction disappears; `stage2_width(L, k, 1) = +groups(L, k)`; opened-value rows halve. Keep all code generic over +`MsChallenge::D` — the D = 2 FRI path is unchanged, the D = 1 path just +takes the degenerate branches. + +Serialization: `Proof` keeps bincode/serde; ark types get serde via +CanonicalSerialize newtype wrappers in the adapter. The ix manual codec is +untouched (it only ever reads FRI-stage proofs). + +## 7. Phased plan (each phase lands green) + +- **Phase 0 — carve the traits, behavioral no-op.** Introduce `traits/`, + implement in `p3_adapter/`, port prover/verifier/system to the own traits. + Gate: existing test suite passes AND proofs are **byte-identical** (pin a + serialized proof hash in a test before starting; ix-side vk/codegen and + all FFT pins must not move). +- **Phase 1 — ark adapter, KZG PCS.** Field/domain/SRS/pcs + unit tests + (commit/open/verify roundtrip, batch opening against hand-computed + pairings, transcript vectors). +- **Phase 2 — end-to-end.** Prove `test_circuits` and then the recursive + verifier system under a `Bls12Kzg` config; differential test (same + witness accepted by both configs; tampered claim rejected); bench row: + proof size + prove/verify time vs the FRI stage. Target: ~112 B/column + confirmed empirically. +- **Phase 3 (optional, separate design) — plonkish frontend** to collapse + the wrap system's column count; and **linearization** (use commitment + homomorphism to open a single linearized polynomial instead of every + column at zeta) — halves opened values, at the cost of diverging the + verifier logic between backends. Explicitly out of scope for v1: "the + proof shrinks even if everything else looks exactly the same." + +## 8. Quirk inventory (from the old `kzg` branch, origin/kzg `multi-plonk/`) + +The branch is a full parallel rewrite whose merge-base predates the +constraint IR, logUp grouping, committed-inverse elimination, sparse +activation, quotient chunking, and deterministic PoW — do not rebase it; +mine it as a reference for the arkworks mechanics. The exact generalization +points it exposes, each of which the trait layer must own explicitly: + +1. **Committed representation.** FRI commits Lagrange evaluations on a + shifted coset (the LDE) behind one Merkle tree; KZG commits monomial + coefficients per column (branch: `ifft_column` -> `DensePolynomial` -> + MSM). The trait absorbs this via `commit` (evaluations in) + + `commit_coefficients` (coefficients in) with each backend converting + internally; `ProverData` is representation-private. +2. **Quotient-domain acquisition.** FRI reads trace evals on the quotient + domain by subsetting the committed LDE — free, but it is what imposes + `max_quotient_degree = 1 << log_blowup` (types.rs:132). KZG has no LDE: + it pays a coset FFT to `qd*n` per column unconditionally (branch does + exactly this), so no blowup-shaped cap exists. Generalization: the + degree cap is a `Pcs`-reported economy + (`fn max_quotient_degree(&self) -> usize`), not a protocol constant; + FRI reports `B`, KZG reports its FFT budget. The lookup-grouping policy + keys on it, so KZG configs group deeper automatically. +3. **Quotient chunking stays.** The branch commits the quotient un-chunked + (degree `(qd-1)*n`), forcing `SRS >= (max_deg-1)*n_max` — its own setup + comment admits this. Main's slicing into qd degree-<=n slices must + survive under KZG: SRS pinned to `n_max`, smaller MSMs, slices cost + 48 B each. (Verifier recombination `Q(z) = sum z^(i*n) c_i(z)` is + already PCS-agnostic.) +4. **Coset disjointness is shared logic.** Both backends must evaluate the + composition on a coset disjoint from the trace subgroup (Z_H != 0 for + the pointwise division; branch: GENERATOR coset + `zh_inv`, FRI: the + disjoint LDE coset). The selector/vanishing-on-coset math and the + `k_next = k * stride` next-row indexing belong in the shared sweep, not + the adapters. + Enforcement of quirk 2's cap is two-layered, both layers owned by the + adapter: `max_quotient_degree()` is the advisory query (build-time + validation + grouping policy), and `get_evaluations_on_domain` PANICS + if asked for a domain larger than it serves economically (FRI: larger + than the LDE). Deliberately no silent fallback: a transparent + iFFT-and-bigger-FFT path would hide a serious prover regression behind + a working proof. The panic is a should-never-happen backstop — the + build-time check fires first with a per-circuit error. +5. **Commitment granularity and ordering.** FRI: one commitment per ROUND + (all matrices, one tree). KZG: one G1 per COLUMN, a round is + `Vec`. `Com` as an opaque associated type handles the data, but the + proof layout and the challenger absorption need one canonical + round -> matrix -> column order fixed by the core, and the opened-value + indexing must not assume "one commitment therefore one Merkle proof". +6. **Opening points.** Both backends open stages at {zeta, zeta*g} and + quotient at {zeta}; KZG batches all polynomials per point via a random + LC into one witness commitment (branch uses Sonic's 1xG1 per point; we + spec 2xG1 total). The `open`/`verify` trait shape (rounds of + (data, points-per-matrix)) already covers both. +7. **Transcript.** The branch swapped to a Poseidon sponge — unnecessary: + the KZG stage is terminal, nothing verifies its transcript in-circuit, + so the Blake3 challenger stays for both backends (observe Fr as 32 + canonical LE bytes, G1 as 48 compressed bytes). Re-deriving the branch's + Poseidon choice would only matter if the wrap were ever SNARK-verified. +8. **Dependency surface.** The branch pulls ark-poly-commit + + ark-crypto-primitives (labeled polynomials, sponge traits) — the + abstraction tax that motivated hand-rolling: MSM commit + two batched + witness polynomials + 2 pairings is ~300 lines over ark-ec/ark-ff. + +## 9. Open questions + +1. Packing for the ark constraint sweep (start scalar; SIMD later if the + wrap prover is ever hot). +2. `get_evaluations_on_domain` for KZG when the requested domain exceeds + the trace domain (quotient sweep needs qd*n points): forward FFT from + stored coefficients — decide whether ProverData caches evals, coeffs, + or both. +3. Whether `natural_domain_for_degree` needs cosets at all for KZG (no — + plain subgroup; the disjoint-coset machinery is FRI-only and stays in + the adapter). +4. Blinding: the current system is not zero-knowledge under either PCS; + KZG openings reveal evaluations exactly as FRI queries do. If ZK is + ever wanted, that is a separate design (random row padding + blinded + quotient), orthogonal to this abstraction. +5. Challenger uniformity: one Blake3 transcript implementation generic + over "observe canonical bytes" would let both adapters share code — + nice-to-have, not required. diff --git a/examples/pcs_example.rs b/examples/pcs_example.rs index a4540b8..4b41e37 100644 --- a/examples/pcs_example.rs +++ b/examples/pcs_example.rs @@ -15,13 +15,13 @@ //! ``` use bincode::{config::standard, serde::encode_to_vec}; -use multi_stark::config::StarkGenericConfig; +use multi_stark::config::ProofConfig; +use multi_stark::traits::{OpenedValues, Pcs as PcsTrait}; use multi_stark::types::{ Challenger, Commitment, CommitmentParameters, Domain, ExtVal, FriParameters, - GoldilocksBlake3Config, Pcs, PcsProof, ProverData, Val, + GoldilocksBlake3Config, PcsProof, ProverData, Val, }; use p3_challenger::{CanObserve, FieldChallenger}; -use p3_commit::{OpenedValues, Pcs as PcsTrait}; use p3_field::PrimeCharacteristicRing; use p3_matrix::dense::RowMajorMatrix; @@ -52,8 +52,7 @@ fn main() { let degree = 1usize << log_degree; let num_polys: usize = 1; - let domain: Domain = - >::natural_domain_for_degree(pcs, degree); + let domain: Domain = PcsTrait::natural_domain_for_degree(pcs, degree); // Simple non-trivial evaluations: entry (i, j) = i * num_polys + j. let n = u32::try_from(degree * num_polys).unwrap(); @@ -63,7 +62,7 @@ fn main() { // The PCS computes a coset LDE (blowup × domain size evaluations), then // builds a Merkle tree over the extended rows. The root is the commitment. let (commitment, prover_data): (Commitment, ProverData) = - >::commit(pcs, [(domain, matrix)]); + PcsTrait::commit(pcs, vec![(domain, matrix)]); println!( "Committed {} polynomials over domain of size {} (degree ≤ {})", num_polys, @@ -82,12 +81,11 @@ fn main() { // Open all polynomials at ζ and produce a FRI opening proof. // Input: for each committed batch, a list of opening-point vectors (one per matrix). // Output: claimed evaluations + proof. - let (opened_values, pcs_proof): (OpenedValues<_>, PcsProof) = - >::open( - pcs, - vec![(&prover_data, vec![vec![zeta; num_open]])], - &mut prover_challenger, - ); + let (opened_values, pcs_proof): (OpenedValues<_>, PcsProof) = PcsTrait::open( + pcs, + vec![(&prover_data, vec![vec![zeta; num_open]])], + &mut prover_challenger, + ); // opened_values[round][matrix_idx][point_idx] = Vec (one per column) let evals_at_zeta = opened_values[0][0][0].clone(); println!("Opened at ζ: {} polynomial values", evals_at_zeta.len()); @@ -107,7 +105,7 @@ fn main() { let verifier_zeta: ExtVal = verifier_challenger.sample_algebra_element(); assert_eq!(verifier_zeta, zeta, "Fiat-Shamir transcript mismatch"); - >::verify( + PcsTrait::verify( pcs, vec![( commitment, diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..f216078 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1 @@ +edition = "2024" diff --git a/src/ark_adapter/config.rs b/src/ark_adapter/config.rs new file mode 100644 index 0000000..b46a935 --- /dev/null +++ b/src/ark_adapter/config.rs @@ -0,0 +1,198 @@ +//! [`ProofConfig`] instantiation for the KZG backend: BLS12-381 +//! scalar field (its own challenge field, `D = 1`), Blake3 transcript, +//! monomial KZG commitments. + +use std::sync::Arc; + +use ark_serialize::CanonicalSerialize; + +use crate::config::ProofConfig; +use crate::traits::Pcs; + +use super::field::Scalar; +use super::pcs::KzgPcs; +use super::srs::Srs; +use super::transcript::Blake3Transcript; + +pub struct KzgConfig { + pcs: KzgPcs, + /// Bytes observed into every fresh challenger: a domain tag plus a + /// digest of the protocol parameters INCLUDING the SRS (see the + /// transcript contract on [`ProofConfig::initialise_challenger`]). + transcript_seed: Vec, + max_log_degree: usize, +} + +impl KzgConfig { + /// Public parameters are caller-supplied and taken on trust here: + /// call [`Srs::validate`] first on parameters you did not generate. + /// + /// # Panics + /// Panics if the SRS length is not a power of two (trace domains + /// are, and `max_log_degree` is read off the SRS). + pub fn new(srs: Arc, max_quotient_degree: usize) -> Self { + assert!( + srs.max_len().is_power_of_two(), + "SRS length must be a power of two" + ); + let max_log_degree = p3_util::log2_strict_usize(srs.max_len()); + let mut transcript_seed = b"multi-stark/kzg/v0".to_vec(); + for parameter in [max_log_degree, max_quotient_degree] { + transcript_seed.extend(u64::try_from(parameter).unwrap().to_le_bytes()); + } + // Bind the SRS: τ·G1 and τ·G2 determine it entirely. + srs.g1[1] + .serialize_compressed(&mut transcript_seed) + .expect("serialization into a Vec cannot fail"); + srs.tau_g2 + .serialize_compressed(&mut transcript_seed) + .expect("serialization into a Vec cannot fail"); + Self { + pcs: KzgPcs::new(srs, max_quotient_degree), + transcript_seed, + max_log_degree, + } + } +} + +impl ProofConfig for KzgConfig { + type Pcs = KzgPcs; + type Challenge = Scalar; + type Challenger = Blake3Transcript; + + fn pcs(&self) -> &KzgPcs { + &self.pcs + } + + fn initialise_challenger(&self) -> Blake3Transcript { + let mut challenger = Blake3Transcript::new(); + challenger.observe_bytes(&self.transcript_seed); + challenger + } + + fn max_log_degree(&self) -> usize { + self.max_log_degree + } + + fn max_quotient_degree(&self) -> usize { + self.pcs.max_quotient_degree() + } + + fn log_blowup(&self) -> usize { + // KZG commits polynomials, not evaluation blowups. + 0 + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::expr::Expr; + use crate::lookup::Lookup; + use crate::prover::Proof; + use crate::system::{CircuitInputs, System, SystemWitness}; + use crate::traits::{Algebra, Field}; + use p3_matrix::dense::RowMajorMatrix; + + /// `a·b = c` per row, with a self-canceling push/pull lookup pair to + /// exercise the stage-2 machinery — the KZG twin of the BabyBear + /// config's smoke test. + fn mul_circuit() -> CircuitInputs { + let m = Expr::main; + CircuitInputs { + main_width: 3, + constraints: vec![m(0) * m(1) - m(2)], + lookups: vec![ + Lookup::push(Expr::constant(Scalar::from_u32(1)), vec![m(0), m(2)]), + Lookup::pull(Expr::constant(Scalar::from_u32(1)), vec![m(0), m(2)]), + ], + ..Default::default() + } + } + + fn kzg_system() -> (System, crate::system::ProverKey) { + let srs = Arc::new(Srs::unsafe_dev_setup(1 << 8, b"test")); + let config = KzgConfig::new(srs, 8); + System::new(config, [mul_circuit()]) + } + + fn witness(system: &System) -> SystemWitness { + let f = Scalar::from_u32; + let trace = RowMajorMatrix::new([2, 3, 6, 4, 5, 20, 7, 8, 56, 1, 1, 1].map(f).to_vec(), 3); + SystemWitness::from_stage_1(vec![trace], system) + } + + #[test] + fn kzg_prove_verify() { + let (system, key) = kzg_system(); + let no_claims: &[&[Scalar]] = &[]; + let proof = system.prove_multiple_claims(&key, no_claims, witness(&system)); + system + .verify_multiple_claims(no_claims, &proof) + .expect("KZG proof failed to verify"); + } + + #[test] + fn kzg_tampering_rejected() { + let (system, key) = kzg_system(); + let no_claims: &[&[Scalar]] = &[]; + let prove = || system.prove_multiple_claims(&key, no_claims, witness(&system)); + + let mut tampered = prove(); + tampered.intermediate_accumulators[0] += >::ONE; + assert!(system.verify_multiple_claims(no_claims, &tampered).is_err()); + + let mut tampered = prove(); + tampered.stage_1_opened_values[0][0][0] += >::ONE; + assert!(system.verify_multiple_claims(no_claims, &tampered).is_err()); + + let mut tampered = prove(); + tampered.quotient_opened_values[0][0][0] += >::ONE; + assert!(system.verify_multiple_claims(no_claims, &tampered).is_err()); + } + + #[test] + fn kzg_wrong_claim_rejected() { + let (system, key) = kzg_system(); + let no_claims: &[&[Scalar]] = &[]; + let proof = system.prove_multiple_claims(&key, no_claims, witness(&system)); + let claim = [Scalar::from_u32(42)]; + assert!(system.verify(&claim, &proof).is_err()); + } + + #[test] + fn kzg_serialization_round_trip() { + let (system, key) = kzg_system(); + let no_claims: &[&[Scalar]] = &[]; + let proof = system.prove_multiple_claims(&key, no_claims, witness(&system)); + let bytes = proof.to_bytes().expect("serialize"); + let proof2 = Proof::::from_bytes(&bytes).expect("deserialize"); + system.verify_multiple_claims(no_claims, &proof2).unwrap(); + } + + /// Two circuits at different trace heights: ζ·g differs per height, + /// so the opening carries three distinct points and the per-point + /// witness batching (and cross-point pairing batch) is exercised. + #[test] + fn kzg_two_circuits_two_heights() { + let srs = Arc::new(Srs::unsafe_dev_setup(1 << 8, b"test")); + let config = KzgConfig::new(srs, 8); + let (system, key) = System::new(config, [mul_circuit(), mul_circuit()]); + let f = Scalar::from_u32; + let small = RowMajorMatrix::new([2, 3, 6, 4, 5, 20, 7, 8, 56, 1, 1, 1].map(f).to_vec(), 3); + let mut long = small.values.clone(); + for _ in 0..2 { + long.extend(long.clone()); + } + let witness = + SystemWitness::from_stage_1(vec![small, RowMajorMatrix::new(long, 3)], &system); + let no_claims: &[&[Scalar]] = &[]; + let proof = system.prove_multiple_claims(&key, no_claims, witness); + let bytes = proof.to_bytes().expect("serialize"); + println!( + "KZG proof: {} bytes (two circuits, heights 4 and 16)", + bytes.len() + ); + system.verify_multiple_claims(no_claims, &proof).unwrap(); + } +} diff --git a/src/ark_adapter/domain.rs b/src/ark_adapter/domain.rs new file mode 100644 index 0000000..0d29f89 --- /dev/null +++ b/src/ark_adapter/domain.rs @@ -0,0 +1,182 @@ +//! The crate evaluation domain over [`Scalar`]: a multiplicative coset +//! `shift · H` of the order-`2^log_size` subgroup `H`. +//! +//! The selector formulas reproduce the p3 coset semantics the core's +//! constraint math was written against (unnormalized selectors; the +//! logUp boundary injection pre-absorbs the `n·g` normalization): +//! with `s` the shift, `g` the subgroup generator, and `u = X/s`, +//! +//! - vanishing: `Z(X) = u^n − 1` +//! - first row: `Z(X)/(u − 1)` +//! - last row: `Z(X)/(u − g⁻¹)` +//! - transition: `u − g⁻¹` + +use crate::traits::{ + Algebra, EvaluationDomain, Field, LagrangeSelectors, TwoAdicField, batch_inverse, +}; + +use super::field::Scalar; + +const ONE: Scalar = >::ONE; + +/// A coset `shift · H`, `|H| = 2^log_size`, over the BLS12-381 scalar +/// field. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Radix2Coset { + pub log_size: usize, + pub shift: Scalar, +} + +impl Radix2Coset { + /// The subgroup generator `g`. + #[inline] + pub fn generator(&self) -> Scalar { + Scalar::two_adic_generator(self.log_size) + } + + /// The coset's points in natural order: `shift · g^i`. + pub fn points(&self) -> Vec { + self.generator() + .powers() + .take(self.size()) + .map(|x| x * self.shift) + .collect() + } +} + +impl EvaluationDomain for Radix2Coset { + type F = Scalar; + type Challenge = Scalar; + + #[inline] + fn size(&self) -> usize { + 1 << self.log_size + } + + #[inline] + fn first_point(&self) -> Scalar { + self.shift + } + + #[inline] + fn next_point(&self, x: Scalar) -> Scalar { + x * self.generator() + } + + fn create_disjoint_domain(&self, min_size: usize) -> Self { + // Multiplying the shift by the field's multiplicative generator + // leaves the subgroup (and every subgroup coset reachable by + // repeated application), exactly as in p3. + Self { + log_size: p3_util::log2_ceil_usize(min_size), + shift: self.shift * Scalar(::GENERATOR), + } + } + + fn selectors_at_point(&self, point: Scalar) -> LagrangeSelectors { + let unshifted = point * self.shift.inverse(); + let z_h = unshifted.exp_power_of_2(self.log_size) - ONE; + let g_inv = self.generator().inverse(); + LagrangeSelectors { + is_first_row: z_h * (unshifted - ONE).inverse(), + is_last_row: z_h * (unshifted - g_inv).inverse(), + is_transition: unshifted - g_inv, + inv_vanishing: z_h.inverse(), + } + } + + fn selectors_on_coset(&self, coset: Self) -> LagrangeSelectors> { + assert_eq!(self.shift, ONE, "selectors_on_coset needs the group itself"); + assert_ne!(coset.shift, ONE, "coset must be disjoint from the group"); + assert!(coset.log_size >= self.log_size); + let rate_bits = coset.log_size - self.log_size; + + // Z_H(X) = X^n − 1 is periodic over the coset with period + // 2^rate_bits: (s·w^j·h)^n = s^n·w^{jn} and h^n = 1. + let s_pow_n = coset.shift.exp_power_of_2(self.log_size); + let vanishing: Vec = Scalar::two_adic_generator(rate_bits) + .powers() + .take(1 << rate_bits) + .map(|x| s_pow_n * x - ONE) + .collect(); + + let xs = coset.points(); + let single_point_selector = |i: u64| { + let subgroup_point = self.generator().exp_u64(i); + let denoms: Vec = xs.iter().map(|&x| x - subgroup_point).collect(); + let inverses = batch_inverse(&denoms); + vanishing + .iter() + .cycle() + .zip(inverses) + .map(|(&z_h, inv)| z_h * inv) + .collect() + }; + + let subgroup_last = self.generator().inverse(); + LagrangeSelectors { + is_first_row: single_point_selector(0), + is_last_row: single_point_selector(self.size() as u64 - 1), + is_transition: xs.into_iter().map(|x| x - subgroup_last).collect(), + inv_vanishing: batch_inverse(&vanishing) + .into_iter() + .cycle() + .take(coset.size()) + .collect(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// `selectors_on_coset` must agree pointwise with `selectors_at_point` + /// evaluated at each coset point. + #[test] + fn coset_selectors_match_pointwise() { + for log_size in [0usize, 1, 3] { + for extra_bits in [0usize, 1, 2] { + let trace = Radix2Coset { + log_size, + shift: ONE, + }; + let coset = trace.create_disjoint_domain(1 << (log_size + extra_bits)); + let on_coset = trace.selectors_on_coset(coset); + for (i, x) in coset.points().into_iter().enumerate() { + let at_point = trace.selectors_at_point(x); + assert_eq!(on_coset.is_first_row[i], at_point.is_first_row); + assert_eq!(on_coset.is_last_row[i], at_point.is_last_row); + assert_eq!(on_coset.is_transition[i], at_point.is_transition); + assert_eq!(on_coset.inv_vanishing[i], at_point.inv_vanishing); + } + } + } + } + + /// The disjoint domain must be disjoint: the vanishing polynomial of + /// the trace domain is nonzero on every coset point. + #[test] + fn disjoint_domain_is_disjoint() { + let trace = Radix2Coset { + log_size: 4, + shift: ONE, + }; + let coset = trace.create_disjoint_domain(1 << 6); + for x in coset.points() { + assert!(!(x.exp_power_of_2(4) - ONE).is_zero()); + } + } + + #[test] + fn next_point_walks_the_domain() { + let domain = Radix2Coset { + log_size: 3, + shift: ONE, + }; + let points = domain.points(); + for i in 0..points.len() - 1 { + assert_eq!(domain.next_point(points[i]), points[i + 1]); + } + } +} diff --git a/src/ark_adapter/field.rs b/src/ark_adapter/field.rs new file mode 100644 index 0000000..f6e59bd --- /dev/null +++ b/src/ark_adapter/field.rs @@ -0,0 +1,260 @@ +//! Crate field traits for the BLS12-381 scalar field. +//! +//! [`Scalar`] is a `repr(transparent)` newtype over `ark_bls12_381::Fr`. +//! A newtype (unlike the p3 fields, which implement the crate traits +//! directly) because the traits require serde and arkworks types only +//! speak `CanonicalSerialize`; the wrapper carries a canonical 32-byte +//! little-endian serde encoding and every crate trait. +//! +//! The field is its own challenge field: `ExtensionOf` with +//! `D = 1` (|Fr| ~ 2^255 dwarfs every Schwartz-Zippel term), and its own +//! packing with `WIDTH = 1` (the sweep runs scalar; MSM dominates the +//! prover here, not constraint evaluation). + +use core::ops::{Add, AddAssign, Mul, MulAssign, Neg, Sub, SubAssign}; + +use ark_bls12_381::Fr; +use ark_ff::{AdditiveGroup, FftField, Field as ArkField}; +use ark_serialize::{CanonicalDeserialize, CanonicalSerialize}; +use p3_matrix::Matrix; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +use crate::traits::{Algebra, ExtensionOf, Field, Packed, PackedExtension, TwoAdicField}; + +/// The BLS12-381 scalar field as a crate [`Field`]. +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Default)] +#[repr(transparent)] +pub struct Scalar(pub Fr); + +impl Serialize for Scalar { + fn serialize(&self, serializer: S) -> Result { + let mut bytes = [0u8; 32]; + self.0 + .serialize_compressed(&mut bytes[..]) + .map_err(serde::ser::Error::custom)?; + bytes.serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for Scalar { + fn deserialize>(deserializer: D) -> Result { + let bytes = <[u8; 32]>::deserialize(deserializer)?; + Fr::deserialize_compressed(&bytes[..]) + .map(Self) + .map_err(serde::de::Error::custom) + } +} + +impl Add for Scalar { + type Output = Self; + #[inline] + fn add(self, rhs: Self) -> Self { + Self(self.0 + rhs.0) + } +} +impl Sub for Scalar { + type Output = Self; + #[inline] + fn sub(self, rhs: Self) -> Self { + Self(self.0 - rhs.0) + } +} +impl Mul for Scalar { + type Output = Self; + #[inline] + fn mul(self, rhs: Self) -> Self { + Self(self.0 * rhs.0) + } +} +impl Neg for Scalar { + type Output = Self; + #[inline] + fn neg(self) -> Self { + Self(-self.0) + } +} +impl AddAssign for Scalar { + #[inline] + fn add_assign(&mut self, rhs: Self) { + self.0 += rhs.0; + } +} +impl SubAssign for Scalar { + #[inline] + fn sub_assign(&mut self, rhs: Self) { + self.0 -= rhs.0; + } +} +impl MulAssign for Scalar { + #[inline] + fn mul_assign(&mut self, rhs: Self) { + self.0 *= rhs.0; + } +} + +impl core::fmt::Display for Scalar { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl Scalar { + /// Low 64 bits of the canonical value — exact for values < 2^64. + /// For Aiur-style consumers that only extract bytes/pointers/counters + /// (values small by construction). + pub fn canonical_low_u64(&self) -> u64 { + use ark_ff::PrimeField; + self.0.into_bigint().0[0] + } +} + +impl Algebra for Scalar { + const ZERO: Self = Self(::ZERO); + const ONE: Self = Self(::ONE); +} + +impl Field for Scalar { + type Packing = Self; + + #[inline] + fn inverse(&self) -> Self { + // `inverse(0)` is free per the trait contract; zero keeps it total. + self.0.inverse().map_or(>::ZERO, Self) + } + + #[inline] + fn exp_u64(&self, exp: u64) -> Self { + Self(self.0.pow([exp])) + } + + #[inline] + fn from_bool(b: bool) -> Self { + Self(Fr::from(b)) + } + + #[inline] + fn from_u8(x: u8) -> Self { + Self(Fr::from(x)) + } + + #[inline] + fn from_u32(x: u32) -> Self { + Self(Fr::from(x)) + } + + #[inline] + fn from_u64(x: u64) -> Self { + Self(Fr::from(x)) + } + + #[inline] + fn from_usize(x: usize) -> Self { + Self(Fr::from(u64::try_from(x).expect("usize fits u64"))) + } +} + +impl TwoAdicField for Scalar { + // Fr has a 2^32 root of unity; pinned by a test below. + const TWO_ADICITY: usize = 32; + + #[inline] + fn two_adic_generator(bits: usize) -> Self { + assert!(bits <= Self::TWO_ADICITY); + Self(Fr::get_root_of_unity(1u64 << bits).expect("two-adic subgroup exists")) + } +} + +impl Packed for Scalar { + const WIDTH: usize = 1; + + #[inline] + fn from_slice(slice: &[Self]) -> &Self { + &slice[0] + } + + #[inline] + fn as_slice(&self) -> &[Self] { + core::slice::from_ref(self) + } + + #[inline] + fn packed_row_pair>(matrix: &M, i: usize, step: usize) -> Vec { + let height = matrix.height(); + let width = matrix.width(); + let mut out = Vec::with_capacity(2 * width); + for r in [i % height, (i + step) % height] { + out.extend_from_slice(&matrix.row_slice(r).expect("row in range")); + } + out + } +} + +impl ExtensionOf for Scalar { + const D: usize = 1; + // Unused at D = 1 (the basis is the identity). + const W: Self = >::ZERO; + type ExtPacking = Self; + + #[inline] + fn as_basis_coefficients_slice(&self) -> &[Self] { + core::slice::from_ref(self) + } + + #[inline] + fn from_basis_coefficients_fn(mut f: impl FnMut(usize) -> Self) -> Self { + f(0) + } +} + +impl PackedExtension for Scalar { + #[inline] + fn from_basis_coefficients_fn(mut f: impl FnMut(usize) -> Self) -> Self { + f(0) + } + + #[inline] + fn as_basis_coefficients_slice(&self) -> &[Self] { + core::slice::from_ref(self) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn two_adicity_matches_ark() { + assert_eq!( + u32::try_from(Scalar::TWO_ADICITY).unwrap(), + ::TWO_ADICITY + ); + // The order-2^k generators nest: g_k^2 == g_{k-1}. + for bits in 1..=Scalar::TWO_ADICITY { + let g = Scalar::two_adic_generator(bits); + assert_eq!(g * g, Scalar::two_adic_generator(bits - 1)); + } + assert_eq!( + Scalar::two_adic_generator(0), + >::ONE + ); + } + + #[test] + fn serde_round_trip() { + let x = Scalar::from_u64(0xdead_beef_cafe_f00d); + let bytes = bincode::serde::encode_to_vec(x, bincode::config::standard()).unwrap(); + let (y, _): (Scalar, usize) = + bincode::serde::decode_from_slice(&bytes, bincode::config::standard()).unwrap(); + assert_eq!(x, y); + } + + #[test] + fn inverse_and_powers() { + let x = Scalar::from_u32(12345); + assert_eq!(x * x.inverse(), >::ONE); + let cube: Vec = x.powers().take(4).collect(); + assert_eq!(cube[3], x * x * x); + assert_eq!(x.exp_u64(3), cube[3]); + assert_eq!(x.exp_power_of_2(3), x.exp_u64(8)); + } +} diff --git a/src/ark_adapter/mod.rs b/src/ark_adapter/mod.rs new file mode 100644 index 0000000..112d80b --- /dev/null +++ b/src/ark_adapter/mod.rs @@ -0,0 +1,37 @@ +//! The arkworks/KZG backend: crate traits implemented over BLS12-381. +//! +//! The counterpart of [`crate::p3_adapter`] for the second PCS the +//! abstraction exists for (docs/pcs-abstraction.md, Phase 1). The +//! challenge field is the base field itself (`D = 1` — BLS12-381's +//! scalar field is already ~2^255, so no extension is needed), packing +//! is scalar (width 1), and commitments are monomial-basis KZG: one +//! G1 point per column, opening proofs one G1 point per distinct +//! opening point regardless of query count. +//! +//! Modules: +//! - [`field`]: [`field::Scalar`], a serde-able newtype over `Fr` +//! carrying the crate field traits. +//! - [`domain`]: [`domain::Radix2Coset`], the crate-owned evaluation +//! domain (the p3 coset semantics, reimplemented over `Scalar`). +//! - [`transcript`]: [`transcript::Blake3Transcript`], a byte-oriented +//! Fiat-Shamir challenger. +//! - [`srs`]: the structured reference string. +//! - [`pcs`]: [`pcs::KzgPcs`] and the proof/commitment types. +//! - [`config`]: [`config::KzgConfig`], the [`ProofConfig`] +//! instantiation tying it all together. +//! +//! [`ProofConfig`]: crate::config::ProofConfig + +pub mod config; +pub mod domain; +pub mod field; +pub mod pcs; +pub mod srs; +pub mod transcript; + +pub use config::KzgConfig; +pub use domain::Radix2Coset; +pub use field::Scalar; +pub use pcs::{KzgCommitment, KzgPcs, KzgProof}; +pub use srs::Srs; +pub use transcript::Blake3Transcript; diff --git a/src/ark_adapter/pcs.rs b/src/ark_adapter/pcs.rs new file mode 100644 index 0000000..1c90caf --- /dev/null +++ b/src/ark_adapter/pcs.rs @@ -0,0 +1,454 @@ +//! Monomial-basis KZG over BLS12-381 as a crate [`Pcs`]. +//! +//! Commitments are per column: interpolate each column of a committed +//! matrix over its domain (radix-2 iFFT) and MSM the coefficients +//! against the SRS — a round's commitment is the vector of G1 points, +//! ~48 bytes per column, and proof size is independent of any query +//! count (there are no queries). +//! +//! Opening batches per distinct point: all polynomials opened at `z` +//! (across every round and matrix) are folded with powers of one +//! transcript challenge `v`, and a single witness commitment +//! `W_z = [ (Σᵢ vⁱ·pᵢ − Σᵢ vⁱ·pᵢ(z)) / (X − z) ]·G` covers them. The +//! multi-stark opens at `ζ` and the per-trace-height `ζ·gₖ`, so a whole +//! proof carries a handful of G1 points. Verification folds the same +//! combination over the commitments and checks all points with one +//! 2-pairing equation, cross-batched by a second challenge `r`: +//! `e(Σ_z r^z·(C_z − y_z·G + z·W_z), H) = e(Σ_z r^z·W_z, τH)`. +//! +//! The quotient commit follows the core's coefficient-slice convention +//! (`Q(X) = Σₖ X^{k·n}·cₖ(X)`, verifier recombines at ζ): one coset +//! iFFT off the quotient domain, then each length-`n` slice is just a +//! range of the coefficient vector — no evaluation representation ever +//! needed. Trace evaluations on the quotient domain +//! ([`Pcs::get_evaluations_on_domain`]) are coset FFTs from the stored +//! coefficients; that FFT budget is what [`Pcs::max_quotient_degree`] +//! bounds (there is no blowup wall — exceeding it is slow, not +//! unsound, but the build-time check keeps the cost model honest). + +use std::sync::Arc; + +use ark_bls12_381::{Bls12_381, Fr, G1Affine, G1Projective}; +use ark_ec::{CurveGroup, VariableBaseMSM, pairing::Pairing}; +use ark_ff::{AdditiveGroup, Field as ArkField, Zero}; +use ark_poly::{EvaluationDomain as ArkEvaluationDomain, Radix2EvaluationDomain}; +use ark_serialize::{CanonicalDeserialize, CanonicalSerialize}; +use p3_matrix::Matrix; +use p3_matrix::dense::RowMajorMatrix; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +use crate::traits::{EvaluationDomain, OpenedValues, OpeningRounds, Pcs, Transcript, VerifyRounds}; + +use super::domain::Radix2Coset; +use super::field::Scalar; +use super::srs::Srs; +use super::transcript::Blake3Transcript; + +/// One KZG commitment round: per matrix, one G1 point per column. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct KzgCommitment(pub Vec>); + +/// One opening proof: one witness point per distinct opening point, in +/// transcript (first-appearance) order. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct KzgProof(pub Vec); + +/// Serde via the arkworks canonical (compressed, validated) encoding. +macro_rules! serde_via_canonical { + ($t:ty, $inner:ty) => { + impl Serialize for $t { + fn serialize(&self, serializer: S) -> Result { + let mut bytes = Vec::new(); + self.0 + .serialize_compressed(&mut bytes) + .map_err(serde::ser::Error::custom)?; + bytes.serialize(serializer) + } + } + impl<'de> Deserialize<'de> for $t { + fn deserialize>(deserializer: D) -> Result { + let bytes = Vec::::deserialize(deserializer)?; + <$inner>::deserialize_compressed(&bytes[..]) + .map(Self) + .map_err(serde::de::Error::custom) + } + } + }; +} +serde_via_canonical!(KzgCommitment, Vec>); +serde_via_canonical!(KzgProof, Vec); + +/// A committed matrix on the prover side: its domain and each column +/// polynomial in coefficient form (length = domain size). +pub struct CommittedMatrix { + domain: Radix2Coset, + columns: Vec>, +} + +/// Prover-side retained data for one commitment round. +pub struct KzgProverData { + pub matrices: Vec, +} + +#[derive(Debug)] +pub enum KzgError { + /// Commitment/opened-value dimensions disagree with the rounds. + ShapeMismatch, + /// The batched pairing equation does not hold. + PairingCheckFailed, +} + +/// See the module docs. +pub struct KzgPcs { + srs: Arc, + max_quotient_degree: usize, +} + +impl KzgPcs { + pub fn new(srs: Arc, max_quotient_degree: usize) -> Self { + Self { + srs, + max_quotient_degree, + } + } + + /// The arkworks FFT domain realizing one of ours. + fn ark_domain(domain: Radix2Coset) -> Radix2EvaluationDomain { + let base = Radix2EvaluationDomain::new(domain.size()).expect("size within Fr two-adicity"); + if domain.shift == crate::traits::Algebra::::ONE { + base + } else { + base.get_coset(domain.shift.0).expect("nonzero coset shift") + } + } + + fn commit_columns(&self, columns: &[Vec]) -> Vec { + let commits: Vec = columns.iter().map(|c| self.msm(c)).collect(); + G1Projective::normalize_batch(&commits) + } + + fn msm(&self, coeffs: &[Fr]) -> G1Projective { + assert!( + coeffs.len() <= self.srs.max_len(), + "polynomial length {} exceeds the SRS ({})", + coeffs.len(), + self.srs.max_len() + ); + if coeffs.is_empty() { + return G1Projective::zero(); + } + G1Projective::msm(&self.srs.g1[..coeffs.len()], coeffs).expect("equal lengths") + } + + /// Interpolate each column of `matrix` over `domain`. + fn interpolate_columns(domain: Radix2Coset, matrix: &RowMajorMatrix) -> Vec> { + assert_eq!( + matrix.height(), + domain.size(), + "matrix height != domain size" + ); + let width = matrix.width(); + let mut evals: Vec> = vec![Vec::with_capacity(matrix.height()); width]; + for (i, value) in matrix.values.iter().enumerate() { + evals[i % width].push(value.0); + } + let ark = Self::ark_domain(domain); + evals.into_iter().map(|col| ark.ifft(&col)).collect() + } +} + +impl Pcs for KzgPcs { + type F = Scalar; + type Challenge = Scalar; + type Domain = Radix2Coset; + type Challenger = Blake3Transcript; + type Commitment = KzgCommitment; + type ProverData = KzgProverData; + type Proof = KzgProof; + type Error = KzgError; + type Evaluations<'a> = RowMajorMatrix; + + fn natural_domain_for_degree(&self, degree: usize) -> Radix2Coset { + Radix2Coset { + log_size: p3_util::log2_strict_usize(degree), + shift: crate::traits::Algebra::::ONE, + } + } + + fn max_quotient_degree(&self) -> usize { + self.max_quotient_degree + } + + fn commit( + &self, + evaluations: Vec<(Radix2Coset, RowMajorMatrix)>, + ) -> (KzgCommitment, KzgProverData) { + let matrices: Vec = evaluations + .into_iter() + .map(|(domain, matrix)| CommittedMatrix { + domain, + columns: Self::interpolate_columns(domain, &matrix), + }) + .collect(); + let commitment = KzgCommitment( + matrices + .iter() + .map(|m| self.commit_columns(&m.columns)) + .collect(), + ); + (commitment, KzgProverData { matrices }) + } + + fn commit_quotient( + &self, + quotients: Vec<(Radix2Coset, RowMajorMatrix, usize)>, + ) -> (KzgCommitment, KzgProverData) { + let matrices: Vec = quotients + .into_iter() + .map(|(quotient_domain, evaluations, quotient_degree)| { + let big = quotient_domain.size(); + debug_assert_eq!(big % quotient_degree, 0); + let n = big / quotient_degree; + let coefficient_columns = Self::interpolate_columns(quotient_domain, &evaluations); + // Slice `Q(X) = Σₖ X^{k·n}·cₖ(X)`: slice k of coordinate d + // is coefficient range [k·n, (k+1)·n), laid out as column + // `k·D + d` — the order the verifier's ζ-recombination + // reads. + let columns: Vec> = (0..quotient_degree) + .flat_map(|k| { + coefficient_columns + .iter() + .map(move |c| c[k * n..(k + 1) * n].to_vec()) + }) + .collect(); + CommittedMatrix { + domain: Radix2Coset { + log_size: p3_util::log2_strict_usize(n), + shift: crate::traits::Algebra::::ONE, + }, + columns, + } + }) + .collect(); + let commitment = KzgCommitment( + matrices + .iter() + .map(|m| self.commit_columns(&m.columns)) + .collect(), + ); + (commitment, KzgProverData { matrices }) + } + + fn get_evaluations_on_domain( + &self, + data: &KzgProverData, + idx: usize, + domain: Radix2Coset, + ) -> RowMajorMatrix { + let matrix = &data.matrices[idx]; + assert!( + domain.size() <= matrix.domain.size() * self.max_quotient_degree, + "requested domain ({}) exceeds the coset-FFT budget ({}x trace); \ + raise max_quotient_degree if this cost is intended", + domain.size(), + self.max_quotient_degree + ); + let ark = Self::ark_domain(domain); + let column_evals: Vec> = matrix.columns.iter().map(|c| ark.fft(c)).collect(); + let width = column_evals.len(); + let height = domain.size(); + let mut values = Vec::with_capacity(width * height); + for row in 0..height { + values.extend(column_evals.iter().map(|col| Scalar(col[row]))); + } + RowMajorMatrix::new(values, width) + } + + fn open( + &self, + rounds: OpeningRounds<'_, KzgProverData, Scalar>, + challenger: &mut Blake3Transcript, + ) -> (OpenedValues, KzgProof) { + // Pass 1: evaluate everything, observing values in traversal + // order, and batch (polynomial, value) pairs per distinct point. + struct PointBatch<'a> { + z: Fr, + entries: Vec<(&'a [Fr], Fr)>, + } + let mut batches: Vec> = Vec::new(); + let mut opened: OpenedValues = Vec::new(); + for (data, points_per_matrix) in &rounds { + debug_assert_eq!(data.matrices.len(), points_per_matrix.len()); + let mut round_values = Vec::new(); + for (matrix, points) in data.matrices.iter().zip(points_per_matrix) { + let mut matrix_values = Vec::new(); + for &z in points { + let row: Vec = matrix + .columns + .iter() + .map(|c| Scalar(eval_poly(c, z.0))) + .collect(); + for &value in &row { + challenger.observe_challenge(value); + } + let batch = match batches.iter().position(|b| b.z == z.0) { + Some(i) => &mut batches[i], + None => { + batches.push(PointBatch { + z: z.0, + entries: Vec::new(), + }); + batches.last_mut().expect("just pushed") + } + }; + for (column, value) in matrix.columns.iter().zip(&row) { + batch.entries.push((column, value.0)); + } + matrix_values.push(row); + } + round_values.push(matrix_values); + } + opened.push(round_values); + } + + let v = challenger.sample_challenge().0; + + // Pass 2: per point, fold with powers of v and commit the witness. + let witnesses: Vec = batches + .iter() + .map(|batch| { + let max_len = batch + .entries + .iter() + .map(|(c, _)| c.len()) + .max() + .unwrap_or(0); + let mut combined = vec![Fr::ZERO; max_len]; + let mut power = Fr::ONE; + for (coeffs, _value) in &batch.entries { + for (acc, c) in combined.iter_mut().zip(*coeffs) { + *acc += power * c; + } + power *= v; + } + // The constant offset −Σ vⁱ·yᵢ only shifts the remainder; + // the witness quotient ignores it. + self.msm(÷_by_linear(&combined, batch.z)) + }) + .collect(); + let witnesses = G1Projective::normalize_batch(&witnesses); + for w in &witnesses { + challenger.observe_canonical(w); + } + // Mirror the verifier's cross-point batching sample to keep the + // transcripts in lockstep (the prover has no use for r). + let _r = challenger.sample_challenge(); + + (opened, KzgProof(witnesses)) + } + + fn verify( + &self, + rounds: VerifyRounds, + proof: &KzgProof, + challenger: &mut Blake3Transcript, + ) -> Result<(), KzgError> { + // Mirror `open`'s traversal exactly: observe claimed values and + // batch (commitment, value) pairs per distinct point. + struct PointBatch { + z: Fr, + commitments: Vec, + values: Vec, + } + let mut batches: Vec = Vec::new(); + for (commitment, matrices) in &rounds { + if commitment.0.len() != matrices.len() { + return Err(KzgError::ShapeMismatch); + } + for (column_commits, (_domain, openings)) in commitment.0.iter().zip(matrices) { + for (z, values) in openings { + if values.len() != column_commits.len() { + return Err(KzgError::ShapeMismatch); + } + for &value in values { + challenger.observe_challenge(value); + } + let batch = match batches.iter().position(|b| b.z == z.0) { + Some(i) => &mut batches[i], + None => { + batches.push(PointBatch { + z: z.0, + commitments: Vec::new(), + values: Vec::new(), + }); + batches.last_mut().expect("just pushed") + } + }; + batch.commitments.extend_from_slice(column_commits); + batch.values.extend(values.iter().map(|value| value.0)); + } + } + } + if proof.0.len() != batches.len() { + return Err(KzgError::ShapeMismatch); + } + + let v = challenger.sample_challenge().0; + for w in &proof.0 { + challenger.observe_canonical(w); + } + let r = challenger.sample_challenge().0; + + // Per point z (with witness W and v-powers u): + // e(C_z − y_z·G + z·W, H) = e(W, τH) + // where C_z = Σ uᵢ·Cᵢ and y_z = Σ uᵢ·yᵢ. Cross-batched over + // points with powers of r into one 2-pairing product. + let g = G1Projective::from(self.srs.g1[0]); + let mut lhs = G1Projective::zero(); + let mut rhs = G1Projective::zero(); + let mut r_power = Fr::ONE; + for (batch, &witness) in batches.iter().zip(&proof.0) { + let mut v_powers = Vec::with_capacity(batch.values.len()); + let mut power = Fr::ONE; + let mut y = Fr::ZERO; + for &value in &batch.values { + v_powers.push(power); + y += power * value; + power *= v; + } + let c = G1Projective::msm(&batch.commitments, &v_powers).expect("equal lengths"); + lhs += (c - g * y + witness * batch.z) * r_power; + rhs += witness * r_power; + r_power *= r; + } + let check = Bls12_381::multi_pairing( + [lhs.into_affine(), (-rhs).into_affine()], + [self.srs.g2, self.srs.tau_g2], + ); + if check.is_zero() { + Ok(()) + } else { + Err(KzgError::PairingCheckFailed) + } + } +} + +/// Horner evaluation. +fn eval_poly(coeffs: &[Fr], z: Fr) -> Fr { + coeffs.iter().rev().fold(Fr::ZERO, |acc, c| acc * z + c) +} + +/// The quotient of `p` by `(X − z)` (synthetic division; the remainder +/// — `p(z)` — is dropped). +fn divide_by_linear(p: &[Fr], z: Fr) -> Vec { + if p.len() <= 1 { + return Vec::new(); + } + let mut quotient = vec![Fr::ZERO; p.len() - 1]; + let mut carry = Fr::ZERO; + for j in (1..p.len()).rev() { + carry = p[j] + z * carry; + quotient[j - 1] = carry; + } + quotient +} diff --git a/src/ark_adapter/srs.rs b/src/ark_adapter/srs.rs new file mode 100644 index 0000000..900b666 --- /dev/null +++ b/src/ark_adapter/srs.rs @@ -0,0 +1,148 @@ +//! The KZG structured reference string: powers of a secret τ in G1, +//! plus the two G2 points verification pairs against. +//! +//! Public parameters are the LIBRARY USER'S to supply: [`Srs`] is plain +//! data (public fields), loadable from any perpetual-powers-of-tau +//! ceremony — nothing in the library assumes a particular one, only a +//! power-of-two G1 length (enforced by the config). The library's side +//! of the contract is transparency: the config binds `τ·G1`/`τ·G2` into +//! every transcript, [`Srs::validate`] lets untrusted loads be checked +//! for consistency, and deserialization of proofs/commitments uses the +//! validated arkworks decoders. [`Srs::unsafe_dev_setup`] generates +//! parameters from a seed for tests and development ONLY — its "secret" +//! is derived in the clear, so proofs under it carry no security. + +use ark_bls12_381::{Bls12_381, Fr, G1Affine, G1Projective, G2Affine, G2Projective}; +use ark_ec::{AffineRepr, CurveGroup, PrimeGroup, VariableBaseMSM, pairing::Pairing}; +use ark_ff::{Field, PrimeField, Zero}; +use ark_serialize::CanonicalSerialize; + +/// `[G, τG, τ²G, …]` in G1 and `[H, τH]` in G2. +pub struct Srs { + pub g1: Vec, + pub g2: G2Affine, + pub tau_g2: G2Affine, +} + +impl Srs { + /// The largest polynomial length (degree + 1) this SRS can commit. + #[inline] + pub fn max_len(&self) -> usize { + self.g1.len() + } + + /// Consistency check for user-supplied parameters: the G1 powers + /// must form one geometric progression in the secret the G2 pair + /// encodes — `e(g1[i+1], H) = e(g1[i], τH)` for every `i` — and the + /// anchors must not be the identity. Batched into two MSMs and one + /// 2-pairing product with a random combiner derived from the SRS + /// bytes themselves (whoever fixed the SRS could not predict it). + /// + /// Subgroup membership is NOT checked here: obtain the points + /// through validated deserialization (the arkworks default), which + /// already enforces it. + pub fn validate(&self) -> Result<(), &'static str> { + if self.g1.is_empty() { + return Err("SRS has no G1 powers"); + } + if self.g1[0].is_zero() || self.g2.is_zero() { + return Err("SRS anchor is the identity"); + } + if self.g1.len() == 1 { + return Ok(()); + } + + let mut bytes = Vec::new(); + self.g1 + .serialize_compressed(&mut bytes) + .expect("serialization into a Vec cannot fail"); + self.g2 + .serialize_compressed(&mut bytes) + .expect("serialization into a Vec cannot fail"); + self.tau_g2 + .serialize_compressed(&mut bytes) + .expect("serialization into a Vec cannot fail"); + let mut wide = [0u8; 64]; + blake3::Hasher::new() + .update(b"multi-stark/kzg/srs-validate") + .update(&bytes) + .finalize_xof() + .fill(&mut wide); + let r = Fr::from_le_bytes_mod_order(&wide); + + let mut r_powers = Vec::with_capacity(self.g1.len() - 1); + let mut acc = Fr::ONE; + for _ in 0..self.g1.len() - 1 { + r_powers.push(acc); + acc *= r; + } + let low = + G1Projective::msm(&self.g1[..self.g1.len() - 1], &r_powers).expect("equal lengths"); + let high = G1Projective::msm(&self.g1[1..], &r_powers).expect("equal lengths"); + // e(high, H) = e(low, τH) ⇔ e(high, H)·e(−low, τH) = 1. + let check = Bls12_381::multi_pairing( + [high.into_affine(), (-low).into_affine()], + [self.g2, self.tau_g2], + ); + if check.is_zero() { + Ok(()) + } else { + Err("G1 powers are not one τ-progression against the G2 pair") + } + } + + /// A deterministic SRS with τ derived from `seed`. TESTS AND + /// DEVELOPMENT ONLY: τ is recoverable, so commitments under this + /// SRS are not binding against anyone who knows the seed. + pub fn unsafe_dev_setup(max_len: usize, seed: &[u8]) -> Self { + let mut wide = [0u8; 64]; + blake3::Hasher::new() + .update(b"multi-stark/kzg/dev-srs") + .update(seed) + .finalize_xof() + .fill(&mut wide); + let tau = Fr::from_le_bytes_mod_order(&wide); + + let g1_gen = G1Projective::generator(); + let mut acc = Fr::ONE; + let powers: Vec = (0..max_len) + .map(|_| { + let point = g1_gen * acc; + acc *= tau; + point + }) + .collect(); + let g2_gen = G2Projective::generator(); + Self { + g1: G1Projective::normalize_batch(&powers), + g2: g2_gen.into_affine(), + tau_g2: (g2_gen * tau).into_affine(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn dev_setup_validates() { + Srs::unsafe_dev_setup(1 << 5, b"validate-test") + .validate() + .unwrap(); + } + + #[test] + fn corrupted_power_rejected() { + let mut srs = Srs::unsafe_dev_setup(1 << 5, b"validate-test"); + srs.g1[7] = (G1Projective::from(srs.g1[7]) + G1Projective::generator()).into_affine(); + assert!(srs.validate().is_err()); + } + + #[test] + fn mismatched_tau_g2_rejected() { + let mut srs = Srs::unsafe_dev_setup(1 << 5, b"validate-test"); + srs.tau_g2 = (G2Projective::from(srs.tau_g2) + G2Projective::generator()).into_affine(); + assert!(srs.validate().is_err()); + } +} diff --git a/src/ark_adapter/transcript.rs b/src/ark_adapter/transcript.rs new file mode 100644 index 0000000..6d75a11 --- /dev/null +++ b/src/ark_adapter/transcript.rs @@ -0,0 +1,137 @@ +//! A byte-oriented Blake3 Fiat-Shamir transcript for the KZG backend. +//! +//! Absorb/squeeze over a hash chain: observations append canonical +//! bytes to a buffer; each sample hashes `state ‖ buffer` with Blake3, +//! reads 64 bytes of XOF output for a negligibly-biased field element +//! (wide reduction mod the ~2^255 modulus) plus a fresh 32-byte chain +//! state, and clears the buffer. Every observation therefore influences +//! every later sample, and consecutive samples differ through the +//! chained state. + +use ark_bls12_381::Fr; +use ark_ff::PrimeField; +use ark_serialize::CanonicalSerialize; + +use crate::traits::Transcript; + +use super::field::Scalar; +use super::pcs::KzgCommitment; + +/// See the module docs. +#[derive(Clone)] +pub struct Blake3Transcript { + state: [u8; 32], + buffer: Vec, +} + +impl Blake3Transcript { + /// A fresh transcript. Domain separation and parameter binding are + /// the configuration's job (it observes a seed right away). + pub fn new() -> Self { + Self { + state: [0; 32], + buffer: Vec::new(), + } + } + + pub fn observe_bytes(&mut self, bytes: &[u8]) { + self.buffer.extend_from_slice(bytes); + } + + /// Absorb a canonically-serializable arkworks value (compressed). + pub fn observe_canonical(&mut self, value: &impl CanonicalSerialize) { + value + .serialize_compressed(&mut self.buffer) + .expect("serialization into a Vec cannot fail"); + } + + fn squeeze(&mut self) -> Fr { + let mut hasher = blake3::Hasher::new(); + hasher.update(&self.state); + hasher.update(&self.buffer); + self.buffer.clear(); + let mut output = hasher.finalize_xof(); + let mut wide = [0u8; 64]; + output.fill(&mut wide); + output.fill(&mut self.state); + Fr::from_le_bytes_mod_order(&wide) + } +} + +impl Default for Blake3Transcript { + fn default() -> Self { + Self::new() + } +} + +impl Transcript for Blake3Transcript { + type F = Scalar; + type Challenge = Scalar; + type Commitment = KzgCommitment; + + fn observe_field(&mut self, x: Scalar) { + self.observe_canonical(&x.0); + } + + fn observe_field_slice(&mut self, xs: &[Scalar]) { + for x in xs { + self.observe_field(*x); + } + } + + fn observe_challenge(&mut self, x: Scalar) { + self.observe_field(x); + } + + fn observe_commitment(&mut self, c: KzgCommitment) { + for matrix in &c.0 { + for point in matrix { + self.observe_canonical(point); + } + } + } + + fn sample_challenge(&mut self) -> Scalar { + Scalar(self.squeeze()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::Field; + + #[test] + fn deterministic_and_sensitive() { + let run = |values: &[u64]| { + let mut t = Blake3Transcript::new(); + for &v in values { + t.observe_field(Scalar::from_u64(v)); + } + t.sample_challenge() + }; + assert_eq!(run(&[1, 2, 3]), run(&[1, 2, 3])); + assert_ne!(run(&[1, 2, 3]), run(&[1, 2, 4])); + assert_ne!(run(&[1, 2, 3]), run(&[1, 2])); + } + + #[test] + fn consecutive_samples_differ() { + let mut t = Blake3Transcript::new(); + t.observe_field(Scalar::from_u64(7)); + assert_ne!(t.sample_challenge(), t.sample_challenge()); + } + + /// Slice observation must equal element-wise observation (trait + /// contract). + #[test] + fn slice_observation_flat() { + let xs = [Scalar::from_u64(5), Scalar::from_u64(6)]; + let mut a = Blake3Transcript::new(); + a.observe_field_slice(&xs); + let mut b = Blake3Transcript::new(); + b.observe_field(xs[0]); + b.observe_field(xs[1]); + assert_eq!(a.sample_challenge(), b.sample_challenge()); + } +} diff --git a/src/config.rs b/src/config.rs index 1ff68c1..5a416bf 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,78 +1,51 @@ -//! Generic STARK configuration. -//! -//! [`StarkGenericConfig`] bundles the three choices that instantiate the -//! protocol: the polynomial commitment scheme, the challenge (extension) -//! field, and the Fiat-Shamir challenger. The base field is determined -//! transitively by the PCS ([`Val`]). The prover, verifier and system are -//! generic over an implementation of this trait; see -//! [`crate::types::GoldilocksBlake3Config`] for the reference instantiation. - -use p3_challenger::{CanObserve, CanSample, FieldChallenger}; -use p3_commit::{Pcs, PolynomialSpace}; -use p3_field::{ExtensionField, Field}; - -/// The base field of a configuration, as determined by its PCS domain. -pub type Val = <<::Pcs as Pcs< - ::Challenge, - ::Challenger, ->>::Domain as PolynomialSpace>::Val; +//! [`ProofConfig`]: the complete definition of one proof-system +//! instance — base field (via the PCS), challenge field, transcript, +//! and degree budgets — plus the projection aliases the core uses. Everything projects through the crate-owned +//! [`crate::traits::Pcs`]; Plonky3 appears only through the field +//! machinery (packing, extension fields), which the field slice of the +//! PCS abstraction will absorb later. + +use crate::traits::{ExtensionOf, Field, Pcs, Transcript}; + +/// The base (trace) field of a configuration's PCS. +pub type Val = <::Pcs as Pcs>::F; /// The evaluation domain type of a configuration's PCS. -pub type Domain = <::Pcs as Pcs< - ::Challenge, - ::Challenger, ->>::Domain; +pub type Domain = <::Pcs as Pcs>::Domain; /// The commitment type of a configuration's PCS. -pub type Com = <::Pcs as Pcs< - ::Challenge, - ::Challenger, ->>::Commitment; +pub type Com = <::Pcs as Pcs>::Commitment; /// The opening proof type of a configuration's PCS. -pub type PcsProof = <::Pcs as Pcs< - ::Challenge, - ::Challenger, ->>::Proof; +pub type PcsProof = <::Pcs as Pcs>::Proof; /// The error type of a configuration's PCS. -pub type PcsError = <::Pcs as Pcs< - ::Challenge, - ::Challenger, ->>::Error; +pub type PcsError = <::Pcs as Pcs>::Error; /// The prover data type of a configuration's PCS. -pub type PcsData = <::Pcs as Pcs< - ::Challenge, - ::Challenger, ->>::ProverData; - -/// Evaluations of committed polynomials over a domain. -pub type EvaluationsOnDomain<'a, SC> = <::Pcs as Pcs< - ::Challenge, - ::Challenger, ->>::EvaluationsOnDomain<'a>; - -/// Packed base-field values of a configuration. +pub type PcsData = <::Pcs as Pcs>::ProverData; + +/// The borrowed evaluations view of a configuration's PCS. +pub type EvaluationsOnDomain<'a, SC> = <::Pcs as Pcs>::Evaluations<'a>; + +/// Packed (SIMD) representation of the base field. pub type PackedVal = as Field>::Packing; -/// Packed challenge-field values of a configuration. -pub type PackedChallenge = - <::Challenge as ExtensionField>>::ExtensionPacking; +/// Packed (SIMD) representation of the challenge field. +pub type PackedChallenge = <::Challenge as ExtensionOf>>::ExtPacking; -/// Configuration of a STARK system. -pub trait StarkGenericConfig { +pub trait ProofConfig { /// The PCS used to commit to trace polynomials. - type Pcs: Pcs; + type Pcs: Pcs; /// The field from which random challenges are drawn. Its size bounds the /// Schwartz-Zippel terms of the soundness error, so it must be large /// enough for the target security level (see the soundness argument in /// the verifier module docs). - type Challenge: ExtensionField>; + type Challenge: ExtensionOf>; /// The Fiat-Shamir challenger. - type Challenger: FieldChallenger> + CanObserve> + CanSample; + type Challenger: Transcript, Challenge = Self::Challenge, Commitment = Com>; /// Returns a reference to the PCS. fn pcs(&self) -> &Self::Pcs; diff --git a/src/eval.rs b/src/eval.rs index 87faec8..07777d7 100644 --- a/src/eval.rs +++ b/src/eval.rs @@ -5,7 +5,7 @@ //! trees also get a direct recursive evaluator, used as the reference in //! tests (with genuine extension-field arithmetic for `ExtExpr`). -use p3_field::{Algebra, Field}; +use crate::traits::{Algebra, Field}; use crate::expr::{ColRef, Expr, ExtExpr, RowOffset, Source}; use crate::graph::{ConstraintGraph, ExtensionParams, Node, NodeId}; @@ -50,26 +50,17 @@ impl VarValues<'_, W> { impl ConstraintGraph { /// Dense forward sweep over the whole node vector, in working type `W`; /// fills `buf` with one value per node. - pub fn sweep + Copy>(&self, values: &VarValues<'_, W>, buf: &mut Vec) { + pub fn sweep>(&self, values: &VarValues<'_, W>, buf: &mut Vec) { self.sweep_range(values, buf, self.nodes.len()); } /// Sweeps only the lookup prefix (partial evaluation for the lookup /// witness). - pub fn sweep_lookup_prefix + Copy>( - &self, - values: &VarValues<'_, W>, - buf: &mut Vec, - ) { + pub fn sweep_lookup_prefix>(&self, values: &VarValues<'_, W>, buf: &mut Vec) { self.sweep_range(values, buf, self.lookup_prefix_len); } - fn sweep_range + Copy>( - &self, - values: &VarValues<'_, W>, - buf: &mut Vec, - len: usize, - ) { + fn sweep_range>(&self, values: &VarValues<'_, W>, buf: &mut Vec, len: usize) { buf.clear(); buf.reserve(len); // Raw pointer into the reserved storage; captured by the `child` @@ -122,7 +113,7 @@ impl ConstraintGraph { } /// Convenience: sweep and return the constraint values. - pub fn evaluate_constraints + Copy>(&self, values: &VarValues<'_, W>) -> Vec { + pub fn evaluate_constraints>(&self, values: &VarValues<'_, W>) -> Vec { let mut buf = Vec::new(); self.sweep(values, &mut buf); self.constraint_values(&buf) diff --git a/src/expr.rs b/src/expr.rs index 2cb9744..dc184a6 100644 --- a/src/expr.rs +++ b/src/expr.rs @@ -6,7 +6,7 @@ use std::ops::{Add, Mul, Neg, Sub}; -use p3_field::Field; +use crate::traits::Field; use crate::lookup::Lookup; diff --git a/src/graph.rs b/src/graph.rs index 25826ac..ff7c45b 100644 --- a/src/graph.rs +++ b/src/graph.rs @@ -13,7 +13,7 @@ use std::collections::HashMap; -use p3_field::Field; +use crate::traits::Field; use crate::expr::{CircuitSpec, ColRef, Expr, ExtExpr, Source}; use crate::lookup::Lookup; diff --git a/src/lib.rs b/src/lib.rs index 0df70f6..6854012 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,5 @@ +#[cfg(feature = "kzg")] +pub mod ark_adapter; pub mod config; pub mod eval; pub mod expr; @@ -8,6 +10,7 @@ pub mod prover; pub mod system; #[cfg(test)] mod test_circuits; +pub mod traits; pub mod types; pub mod verifier; diff --git a/src/lookup.rs b/src/lookup.rs index cf94ad7..79cc324 100644 --- a/src/lookup.rs +++ b/src/lookup.rs @@ -45,9 +45,7 @@ //! [`MAX_LOOKUP_GROUP`] so the evaluator's scratch space stays on the //! stack. -use p3_field::{ - Algebra, ExtensionField, Field, PrimeCharacteristicRing, batch_multiplicative_inverse, -}; +use crate::traits::{Algebra, ExtensionOf, Field, batch_inverse}; use p3_matrix::dense::RowMajorMatrix; use p3_maybe_rayon::prelude::*; @@ -67,7 +65,7 @@ impl Lookup { #[inline] pub fn empty() -> Self where - E: PrimeCharacteristicRing, + E: Field, { Self { multiplicity: E::ZERO, @@ -514,13 +512,13 @@ pub fn synthesize_lookups( #[inline] pub(crate) fn fingerprint(r: &F, coeffs: Iter) -> F where - F: PrimeCharacteristicRing, + F: Field, I: Into, Iter: DoubleEndedIterator, { coeffs .rev() - .fold(F::ZERO, |acc, coeff| acc * r.clone() + coeff.into()) + .fold(F::ZERO, |acc, coeff| acc * *r + coeff.into()) } /// Concrete lookup values of one circuit, stored flat. @@ -609,7 +607,7 @@ impl LookupValues { /// Computes the stage 2 traces and the intermediate accumulators for each /// circuit given a lookup challenge, a fingerprint challenge and the current /// accumulator value (computed from the initial claims). - pub fn stage_2_traces>( + pub fn stage_2_traces>( circuits: &[Self], group_sizes: &[usize], lookup_challenge: EF, @@ -638,8 +636,8 @@ impl LookupValues { drop(_g); // Compute the inverses of all messages in batch. - let messages_inverses = tracing::info_span!("stark/batch_inverse") - .in_scope(|| batch_multiplicative_inverse(&messages)); + let messages_inverses = + tracing::info_span!("stark/batch_inverse").in_scope(|| batch_inverse(&messages)); // Only the inverses are consumed below. drop(messages); @@ -661,7 +659,7 @@ impl LookupValues { // Pass-through accumulator column; the committed values are // gauge-free (only differences are constrained), zero by // convention. - vec![EF::ZERO; circuit.height] + vec![>::ZERO; circuit.height] } else { // One partial accumulator per lookup GROUP: `acc_g` is the // running sum ENTERING group g's step; the last group's @@ -671,7 +669,7 @@ impl LookupValues { // accumulator; the circuit's total contribution is added to // the global chain at the end. let mut vec = Vec::with_capacity(circuit.height * num_slots); - let mut local = EF::ZERO; + let mut local = >::ZERO; for (row_multiplicities, row_messages_inverses) in circuit .multiplicities .chunks_exact(circuit.num_lookups) @@ -824,7 +822,6 @@ impl LookupRowMut<'_, F> { #[cfg(test)] mod tests { use p3_air::{Air, AirBuilder, BaseAir, WindowAccess}; - use p3_field::Field; use crate::{ p3_adapter::{LookupAir, SymbolicExpression, var}, @@ -843,10 +840,9 @@ mod tests { /// textbook Lagrange basis product, at arbitrary points, several sizes. #[test] fn selector_normalization_constants() { - use crate::config::StarkGenericConfig; + use crate::config::ProofConfig; + use crate::traits::{Algebra, EvaluationDomain, TwoAdicField}; use crate::types::ExtVal; - use p3_commit::PolynomialSpace; - use p3_field::{PrimeCharacteristicRing, TwoAdicField}; let config = GoldilocksBlake3Config::new( CommitmentParameters { @@ -864,12 +860,8 @@ mod tests { for log_n in [2usize, 3, 5, 8] { let n = 1usize << log_n; let g = Val::two_adic_generator(log_n); - let domain: crate::types::Domain = >::natural_domain_for_degree( - config.pcs(), n - ); + let domain: crate::types::Domain = + crate::traits::Pcs::natural_domain_for_degree(config.pcs(), n); for seed in [3u64, 12345, 0xdead_beef] { let zeta: ExtVal = ExtVal::from_u64(seed).exp_u64(7) + ExtVal::from_u64(seed * 31 + 1); @@ -877,7 +869,7 @@ mod tests { // Textbook Lagrange basis of the last row: // Π_{i≠n−1} (ζ − g^i)/(g^{n−1} − g^i). let last = g.exp_u64((n - 1) as u64); - let mut ref_last = ExtVal::ONE; + let mut ref_last = >::ONE; for i in 0..n - 1 { let gi = g.exp_u64(i as u64); ref_last *= (zeta - ExtVal::from(gi)) * ExtVal::from(last - gi).inverse(); @@ -888,7 +880,7 @@ mod tests { ref_last, "last-row normalization, log_n={log_n}" ); - let mut ref_first = ExtVal::ONE; + let mut ref_first = >::ONE; for i in 1..n { let gi = g.exp_u64(i as u64); ref_first *= (zeta - ExtVal::from(gi)) * ExtVal::from(Val::ONE - gi).inverse(); @@ -911,7 +903,6 @@ mod tests { fn direct_logup_matches_synthesized_reference() { use crate::eval::{VarValues, eval_expr, eval_ext_expr}; use crate::graph::ExtensionParams; - use p3_field::PrimeCharacteristicRing; let params = crate::system::extension_params::(); let (w, d) = (params.w, params.degree); diff --git a/src/p3_adapter.rs b/src/p3_adapter/air.rs similarity index 93% rename from src/p3_adapter.rs rename to src/p3_adapter/air.rs index f29c348..dfc36c1 100644 --- a/src/p3_adapter.rs +++ b/src/p3_adapter/air.rs @@ -22,7 +22,14 @@ use std::marker::PhantomData; use std::ops::{Add, AddAssign, Mul, MulAssign, Neg, Sub, SubAssign}; use p3_air::{Air, AirBuilder, WindowAccess}; -use p3_field::{Algebra, Dup, Field, PrimeCharacteristicRing}; +use p3_field::{Algebra, Dup, Field as P3Field, PrimeCharacteristicRing}; + +/// Both field layers as one bound: this module turns p3-air expressions +/// (p3 arithmetic) into core `CircuitInputs` (crate `Field` bounds), so +/// its generics need both. Public because the frontend entry points +/// (`var`, `LookupAir`, `circuit_inputs_from_air`) carry it. +pub trait Field: P3Field + crate::traits::Field {} +impl Field for T {} use crate::expr::{Expr, RowOffset, Source}; use crate::lookup::Lookup; @@ -99,7 +106,7 @@ impl Dup for P3Expr { impl Default for P3Expr { fn default() -> Self { - Self(Expr::Const(F::ZERO)) + Self(Expr::Const(::ZERO)) } } @@ -172,8 +179,8 @@ impl> Product for P3Expr { impl PrimeCharacteristicRing for P3Expr { type PrimeSubfield = F::PrimeSubfield; - const ZERO: Self = Self(Expr::Const(F::ZERO)); - const ONE: Self = Self(Expr::Const(F::ONE)); + const ZERO: Self = Self(Expr::Const(::ZERO)); + const ONE: Self = Self(Expr::Const(::ONE)); const TWO: Self = Self(Expr::Const(F::TWO)); const NEG_ONE: Self = Self(Expr::Const(F::NEG_ONE)); diff --git a/src/p3_adapter/challenger.rs b/src/p3_adapter/challenger.rs new file mode 100644 index 0000000..0be1483 --- /dev/null +++ b/src/p3_adapter/challenger.rs @@ -0,0 +1,41 @@ +//! [`Transcript`] for the p3-backed production challenger. +//! +//! Delegates to the Plonky3 challenger traits the concrete type already +//! implements; the transcript bytes are identical by construction (the +//! `proof_bytes_pin` test enforces it). + +use p3_challenger::{CanObserve, FieldChallenger}; + +use crate::traits::Transcript; +use crate::types::{Challenger, Commitment, ExtVal, Val}; + +impl Transcript for Challenger { + type F = Val; + type Challenge = ExtVal; + type Commitment = Commitment; + + #[inline] + fn observe_field(&mut self, x: Val) { + self.observe(x); + } + + #[inline] + fn observe_field_slice(&mut self, xs: &[Val]) { + self.observe_slice(xs); + } + + #[inline] + fn observe_challenge(&mut self, x: ExtVal) { + self.observe_algebra_element(x); + } + + #[inline] + fn observe_commitment(&mut self, c: Commitment) { + self.observe(c); + } + + #[inline] + fn sample_challenge(&mut self) -> ExtVal { + self.sample_algebra_element() + } +} diff --git a/src/p3_adapter/domain.rs b/src/p3_adapter/domain.rs new file mode 100644 index 0000000..56d447f --- /dev/null +++ b/src/p3_adapter/domain.rs @@ -0,0 +1,52 @@ +//! [`EvaluationDomain`] for the p3 two-adic coset domain used by the production +//! (Goldilocks/FRI) configuration. + +use p3_commit::{LagrangeSelectors as P3LagrangeSelectors, PolynomialSpace}; +use p3_field::coset::TwoAdicMultiplicativeCoset; + +use crate::traits::{EvaluationDomain, LagrangeSelectors}; +use crate::types::{ExtVal, Val}; + +fn convert(sels: P3LagrangeSelectors) -> LagrangeSelectors { + LagrangeSelectors { + is_first_row: sels.is_first_row, + is_last_row: sels.is_last_row, + is_transition: sels.is_transition, + inv_vanishing: sels.inv_vanishing, + } +} + +impl EvaluationDomain for TwoAdicMultiplicativeCoset { + type F = Val; + type Challenge = ExtVal; + + #[inline] + fn size(&self) -> usize { + PolynomialSpace::size(self) + } + + #[inline] + fn first_point(&self) -> Val { + PolynomialSpace::first_point(self) + } + + #[inline] + fn next_point(&self, x: ExtVal) -> ExtVal { + PolynomialSpace::next_point(self, x).expect("two-adic domain has a next point") + } + + #[inline] + fn create_disjoint_domain(&self, min_size: usize) -> Self { + PolynomialSpace::create_disjoint_domain(self, min_size) + } + + #[inline] + fn selectors_at_point(&self, point: ExtVal) -> LagrangeSelectors { + convert(PolynomialSpace::selectors_at_point(self, point)) + } + + #[inline] + fn selectors_on_coset(&self, coset: Self) -> LagrangeSelectors> { + convert(PolynomialSpace::selectors_on_coset(self, coset)) + } +} diff --git a/src/p3_adapter/field.rs b/src/p3_adapter/field.rs new file mode 100644 index 0000000..ce06b83 --- /dev/null +++ b/src/p3_adapter/field.rs @@ -0,0 +1,271 @@ +//! Crate field traits ([`crate::traits::Field`] and friends) for the +//! Plonky3 fields. +//! +//! Scalars implement the traits directly (macro-generated delegation — +//! local trait on a foreign type, no wrappers needed). Packings are +//! wrapped in the generic newtypes [`P3Packing`] / [`P3ExtPacking`] so +//! every impl is constructor-headed: coherence never has to reason +//! about which foreign types might implement which foreign traits, and +//! the arkworks adapter's scalar packings can never collide with these. +//! Both newtypes are `repr(transparent)`, so the slice reinterpretation +//! `from_slice`/`as_basis_coefficients_slice` rely on is layout-sound +//! and the delegation is zero-cost. + +use core::ops::{Add, AddAssign, Mul, MulAssign, Neg, Sub, SubAssign}; + +use p3_field::{ + Algebra as P3Algebra, BasedVectorSpace, ExtensionField as P3ExtensionField, Field as P3Field, + PackedValue, PrimeCharacteristicRing, +}; +use p3_matrix::Matrix; + +use crate::traits::{Algebra, Field, Packed, PackedExtension}; + +/// A p3 SIMD packing, newtyped so impls stay constructor-headed. +#[derive(Clone, Copy, Debug)] +#[repr(transparent)] +pub struct P3Packing(pub F::Packing); + +/// A p3 packed extension (D base packings), newtyped likewise. +#[derive(Clone, Copy, Debug)] +#[repr(transparent)] +pub struct P3ExtPacking>(pub EF::ExtensionPacking); + +// ---- ring ops for P3Packing, by delegation ---- + +impl Add for P3Packing { + type Output = Self; + #[inline] + fn add(self, rhs: Self) -> Self { + Self(self.0 + rhs.0) + } +} +impl Sub for P3Packing { + type Output = Self; + #[inline] + fn sub(self, rhs: Self) -> Self { + Self(self.0 - rhs.0) + } +} +impl Mul for P3Packing { + type Output = Self; + #[inline] + fn mul(self, rhs: Self) -> Self { + Self(self.0 * rhs.0) + } +} +impl Neg for P3Packing { + type Output = Self; + #[inline] + fn neg(self) -> Self { + Self(-self.0) + } +} +impl AddAssign for P3Packing { + #[inline] + fn add_assign(&mut self, rhs: Self) { + self.0 += rhs.0; + } +} +impl SubAssign for P3Packing { + #[inline] + fn sub_assign(&mut self, rhs: Self) { + self.0 -= rhs.0; + } +} +impl MulAssign for P3Packing { + #[inline] + fn mul_assign(&mut self, rhs: Self) { + self.0 *= rhs.0; + } +} +impl From for P3Packing { + #[inline] + fn from(f: F) -> Self { + Self(F::Packing::from(f)) + } +} +impl Mul for P3Packing { + type Output = Self; + #[inline] + fn mul(self, rhs: F) -> Self { + Self(self.0 * rhs) + } +} + +impl Algebra for P3Packing { + const ZERO: Self = Self(<::Packing as PrimeCharacteristicRing>::ZERO); + const ONE: Self = Self(<::Packing as PrimeCharacteristicRing>::ONE); +} + +impl Packed for P3Packing { + const WIDTH: usize = <::Packing as PackedValue>::WIDTH; + + #[inline] + fn from_slice(slice: &[F]) -> &Self { + let inner = <::Packing as PackedValue>::from_slice(slice); + // SAFETY: repr(transparent) over F::Packing. + unsafe { &*(inner as *const ::Packing).cast::() } + } + + #[inline] + fn as_slice(&self) -> &[F] { + self.0.as_slice() + } + + #[inline] + fn batched_linear_combination(vecs: &[Self], coeffs: &[F]) -> Self { + // SAFETY: repr(transparent) — &[P3Packing] and &[F::Packing] + // have identical layout. + let inner: &[::Packing] = unsafe { + core::slice::from_raw_parts(vecs.as_ptr().cast::<::Packing>(), vecs.len()) + }; + Self(<::Packing as P3Algebra>::batched_linear_combination(inner, coeffs)) + } + + #[inline] + fn packed_row_pair>(matrix: &M, i: usize, step: usize) -> Vec { + matrix + .vertically_packed_row_pair::<::Packing>(i, step) + .into_iter() + .map(Self) + .collect() + } +} + +// ---- packed extension, by delegation ---- + +impl> Mul> for P3ExtPacking { + type Output = Self; + #[inline] + fn mul(self, rhs: P3Packing) -> Self { + Self(self.0 * rhs.0) + } +} + +impl PackedExtension for P3ExtPacking +where + F: P3Field + Field>, + EF: P3ExtensionField + Field, +{ + #[inline] + fn from_basis_coefficients_fn(mut f: impl FnMut(usize) -> P3Packing) -> Self { + Self(::Packing, + >>::from_basis_coefficients_fn(|i| f(i).0)) + } + + #[inline] + fn as_basis_coefficients_slice(&self) -> &[P3Packing] { + let inner: &[::Packing] = ::Packing, + >>::as_basis_coefficients_slice(&self.0); + // SAFETY: repr(transparent) — identical slice layout. + unsafe { core::slice::from_raw_parts(inner.as_ptr().cast::>(), inner.len()) } + } +} + +// ---- scalar field impls, macro-generated delegation ---- + +/// Implement the crate field traits for a concrete p3 field. +/// Absolute paths throughout: `macro_rules` resolves names at the +/// expansion site. +macro_rules! impl_field_via_p3 { + ($f:ty) => { + impl $crate::traits::Algebra<$f> for $f { + const ZERO: Self = <$f as p3_field::PrimeCharacteristicRing>::ZERO; + const ONE: Self = <$f as p3_field::PrimeCharacteristicRing>::ONE; + } + + impl $crate::traits::Field for $f { + type Packing = $crate::p3_adapter::field::P3Packing<$f>; + + #[inline] + fn inverse(&self) -> Self { + p3_field::Field::inverse(self) + } + + #[inline] + fn exp_u64(&self, exp: u64) -> Self { + p3_field::PrimeCharacteristicRing::exp_u64(self, exp) + } + + #[inline] + fn from_bool(b: bool) -> Self { + p3_field::PrimeCharacteristicRing::from_bool(b) + } + + #[inline] + fn from_u8(x: u8) -> Self { + p3_field::PrimeCharacteristicRing::from_u8(x) + } + + #[inline] + fn from_u32(x: u32) -> Self { + p3_field::PrimeCharacteristicRing::from_u32(x) + } + + #[inline] + fn from_u64(x: u64) -> Self { + p3_field::PrimeCharacteristicRing::from_u64(x) + } + + #[inline] + fn from_usize(x: usize) -> Self { + p3_field::PrimeCharacteristicRing::from_usize(x) + } + } + }; +} + +/// Implement [`crate::traits::TwoAdicField`] for a concrete p3 field. +macro_rules! impl_two_adic_via_p3 { + ($f:ty) => { + impl $crate::traits::TwoAdicField for $f { + const TWO_ADICITY: usize = <$f as p3_field::TwoAdicField>::TWO_ADICITY; + + #[inline] + fn two_adic_generator(bits: usize) -> Self { + <$f as p3_field::TwoAdicField>::two_adic_generator(bits) + } + } + }; +} + +/// Implement [`ExtensionOf`] (and the base-field [`Algebra`]) for a +/// concrete p3 binomial extension over a concrete base. +macro_rules! impl_extension_via_p3 { + ($base:ty, $ext:ty, $d:literal) => { + impl $crate::traits::Algebra<$base> for $ext { + const ZERO: Self = <$ext as p3_field::PrimeCharacteristicRing>::ZERO; + const ONE: Self = <$ext as p3_field::PrimeCharacteristicRing>::ONE; + } + + impl $crate::traits::ExtensionOf<$base> for $ext { + const D: usize = $d; + const W: $base = <$base as p3_field::extension::BinomiallyExtendable<$d>>::W; + type ExtPacking = $crate::p3_adapter::field::P3ExtPacking<$base, $ext>; + + #[inline] + fn as_basis_coefficients_slice(&self) -> &[$base] { + p3_field::BasedVectorSpace::<$base>::as_basis_coefficients_slice(self) + } + + #[inline] + fn from_basis_coefficients_fn(f: impl FnMut(usize) -> $base) -> Self { + <$ext as p3_field::BasedVectorSpace<$base>>::from_basis_coefficients_fn(f) + } + } + }; +} + +pub(crate) use {impl_extension_via_p3, impl_field_via_p3, impl_two_adic_via_p3}; + +impl_field_via_p3!(p3_goldilocks::Goldilocks); +impl_two_adic_via_p3!(p3_goldilocks::Goldilocks); +impl_field_via_p3!(p3_field::extension::BinomialExtensionField); +impl_extension_via_p3!( + p3_goldilocks::Goldilocks, + p3_field::extension::BinomialExtensionField, + 2 +); diff --git a/src/p3_adapter/mod.rs b/src/p3_adapter/mod.rs new file mode 100644 index 0000000..bfefcf3 --- /dev/null +++ b/src/p3_adapter/mod.rs @@ -0,0 +1,16 @@ +//! Plonky3 adapter: everything that binds this crate to the Plonky3 +//! stack lives under this module (see `docs/pcs-abstraction.md`). +//! +//! - [`air`] (re-exported at this level for compatibility): build +//! [`crate::system::CircuitInputs`] from a Plonky3-style AIR — the +//! frontend adapter. +//! - [`challenger`]: [`crate::traits::Transcript`] for the p3-backed +//! challengers. + +mod air; +pub mod challenger; +pub mod domain; +pub mod field; +pub mod pcs; + +pub use air::*; diff --git a/src/p3_adapter/pcs.rs b/src/p3_adapter/pcs.rs new file mode 100644 index 0000000..7d05540 --- /dev/null +++ b/src/p3_adapter/pcs.rs @@ -0,0 +1,405 @@ +//! [`crate::traits::Pcs`] for the Plonky3 two-adic FRI PCS. +//! +//! [`FriPcs`] wraps [`TwoAdicFriPcs`] together with the blowup (which p3 +//! keeps private) and a DFT engine for the quotient path. Everything +//! FRI-representation-specific about the quotient commit lives here: the +//! prover hands over quotient EVALUATIONS on the disjoint domain, and +//! this adapter slices coefficients with the committed LDE's coset shift +//! folded in ([`shifted_quotient_slices`]) and builds the committed LDE +//! with one zero-padded DFT ([`lde_from_shifted_coefficients`]) — +//! bit-identical to `Pcs::commit` on the slices' trace-domain +//! evaluations, while skipping two size-n transforms per column. + +use std::marker::PhantomData; + +use p3_commit::{Pcs as P3Pcs, PolynomialSpace}; +use p3_dft::TwoAdicSubgroupDft; +use p3_field::{ExtensionField, TwoAdicField}; +use p3_fri::TwoAdicFriPcs; +use p3_matrix::Matrix; +use p3_matrix::bitrev::BitReversibleMatrix; +use p3_matrix::dense::RowMajorMatrix; +use p3_maybe_rayon::prelude::*; +use p3_util::{log2_strict_usize, reverse_bits_len}; + +use crate::traits::{EvaluationDomain, OpenedValues, Pcs, VerifyRounds}; +use crate::types::{CommitmentParameters, FriParameters}; + +/// The p3 FRI PCS plus the two pieces of configuration the adapter needs +/// that p3 keeps internal: the blowup (the quotient-degree budget) and a +/// DFT engine for the quotient commit. +pub struct FriPcs { + inner: TwoAdicFriPcs, + dft: Dft, + /// The construction parameters, retained because p3 keeps them + /// private and downstream consumers (the Aiur verifying-key codec) + /// need to read them back. They are properties of the PCS — blowup, + /// Merkle cap, query count, PoW — so this is where they live. + commitment_parameters: CommitmentParameters, + fri_parameters: FriParameters, + _marker: PhantomData<(EF, Challenger)>, +} + +impl + FriPcs +{ + pub fn new( + inner: TwoAdicFriPcs, + commitment_parameters: CommitmentParameters, + fri_parameters: FriParameters, + ) -> Self { + Self { + inner, + dft: Dft::default(), + commitment_parameters, + fri_parameters, + _marker: PhantomData, + } + } + + pub fn commitment_parameters(&self) -> CommitmentParameters { + self.commitment_parameters + } + + pub fn fri_parameters(&self) -> FriParameters { + self.fri_parameters + } +} + +impl Pcs + for FriPcs +where + F: TwoAdicField + Ord, + EF: TwoAdicField + ExtensionField, + Dft: TwoAdicSubgroupDft, + TwoAdicFriPcs: P3Pcs, + as P3Pcs>::Domain: + PolynomialSpace + EvaluationDomain, +{ + type F = F; + type Challenge = EF; + type Domain = as P3Pcs>::Domain; + type Challenger = Challenger; + type Commitment = + as P3Pcs>::Commitment; + type ProverData = + as P3Pcs>::ProverData; + type Proof = as P3Pcs>::Proof; + type Error = as P3Pcs>::Error; + type Evaluations<'a> = as P3Pcs< + EF, + Challenger, + >>::EvaluationsOnDomain<'a>; + + #[inline] + fn natural_domain_for_degree(&self, degree: usize) -> Self::Domain { + self.inner.natural_domain_for_degree(degree) + } + + #[inline] + fn max_quotient_degree(&self) -> usize { + // The LDE-subsetting economy: trace evaluations on the quotient + // domain come for free only up to the blowup. + 1 << self.commitment_parameters.log_blowup + } + + #[inline] + fn commit( + &self, + evaluations: Vec<(Self::Domain, RowMajorMatrix)>, + ) -> (Self::Commitment, Self::ProverData) { + self.inner.commit(evaluations) + } + + fn commit_quotient( + &self, + quotients: Vec<(Self::Domain, RowMajorMatrix, usize)>, + ) -> (Self::Commitment, Self::ProverData) { + // `commit_ldes` skips the randomization a hiding PCS applies + // inside `commit`; this path targets non-hiding configurations. + assert!( + ! as P3Pcs>::ZK, + "committing the quotient from coefficients bypasses hiding-PCS randomization" + ); + let ldes = quotients + .into_iter() + .map(|(quotient_domain, evaluations, quotient_degree)| { + let sliced = shifted_quotient_slices( + &self.dft, + evaluations, + EvaluationDomain::first_point("ient_domain), + quotient_degree, + ); + lde_from_shifted_coefficients( + &self.dft, + sliced, + self.commitment_parameters.log_blowup, + ) + }) + .collect(); + self.inner.commit_ldes(ldes) + } + + #[inline] + fn get_evaluations_on_domain<'a>( + &self, + data: &'a Self::ProverData, + idx: usize, + domain: Self::Domain, + ) -> Self::Evaluations<'a> { + self.inner.get_evaluations_on_domain(data, idx, domain) + } + + #[inline] + fn open( + &self, + rounds: Vec<(&Self::ProverData, Vec>)>, + challenger: &mut Challenger, + ) -> (OpenedValues, Self::Proof) { + self.inner.open(rounds, challenger) + } + + #[inline] + fn verify( + &self, + rounds: VerifyRounds, + proof: &Self::Proof, + challenger: &mut Challenger, + ) -> Result<(), Self::Error> { + self.inner.verify(rounds, proof, challenger) + } +} + +/// From the quotient's evaluations on its disjoint domain — `q·n` rows of +/// `D` base columns on the coset `shift·H` with `|H| = q·n` — produce the +/// `n`-row, `q·D`-column matrix of slice coefficients with the committed +/// LDE's `GENERATOR` shift already folded in: the input +/// [`lde_from_shifted_coefficients`] expects. +/// +/// Semantically this is three steps: coset iDFT to coefficients, slicing +/// `Q(X) = Σₖ X^{k·n}·cₖ(X)` into rows `[c₀ | … | c_{q−1}]`, and +/// pre-scaling row `r` by `GENERATOR^r`. Executed literally (the library +/// entry points) those steps cost a bit-reversal materialization, a serial +/// row-swap pass, two serial full-matrix scaling passes, and a serial +/// gather. But the composition collapses: with `N = q·n` and `S` the raw +/// bit-reversed storage of the forward DFT, +/// +/// ```text +/// idft(f)ⱼ = N⁻¹ · dft(f)_{(N−j) mod N} = N⁻¹ · S[rev((N−j) mod N)] +/// ``` +/// +/// and the coset-unscale factor `shift^{−j}` at `j = k·n + r` splits into +/// `shift^{−k·n} · shift^{−r}`, whose row-dependent part cancels the LDE +/// pre-scale `GENERATOR^r` exactly, because the disjoint quotient domain's +/// shift IS the generator (asserted below; `create_disjoint_domain` on a +/// natural trace domain guarantees it). What survives is a single parallel +/// gather off the DFT storage with ONE constant weight per slice: +/// `wₖ = N⁻¹ · GENERATOR^{−k·n}`. +fn shifted_quotient_slices( + dft: &impl TwoAdicSubgroupDft, + quotient_evals: RowMajorMatrix, + domain_shift: F, + quotient_degree: usize, +) -> RowMajorMatrix { + assert_eq!( + domain_shift, + F::GENERATOR, + "quotient domain shift must equal the LDE shift for the scalings to cancel" + ); + let ext_degree = quotient_evals.width(); + let big_height = quotient_evals.height(); + let log_big_height = log2_strict_usize(big_height); + debug_assert_eq!(big_height % quotient_degree, 0); + let n = big_height / quotient_degree; + let width = quotient_degree * ext_degree; + // Raw storage of the forward DFT: natural index `k` lives at row + // `rev(k)`, and the unwrap out of the bit-reversed view is copy-free. + let storage = dft + .dft_batch(quotient_evals) + .bit_reverse_rows() + .to_row_major_matrix(); + let n_inv = F::ONE.div_2exp_u64(log_big_height as u64); + let weight_step = F::GENERATOR.exp_u64(n as u64).inverse(); + let weights: Vec = weight_step + .powers() + .take(quotient_degree) + .map(|w| w * n_inv) + .collect(); + let mut values = F::zero_vec(n * width); + values + .par_chunks_mut(width) + .enumerate() + .for_each(|(row, out)| { + for (chunk, weight) in weights.iter().enumerate() { + let j = chunk * n + row; + let src = reverse_bits_len( + big_height.wrapping_sub(j) & (big_height - 1), + log_big_height, + ); + let src = &storage.values[src * ext_degree..(src + 1) * ext_degree]; + for (out, src) in out[chunk * ext_degree..(chunk + 1) * ext_degree] + .iter_mut() + .zip(src) + { + *out = *src * *weight; + } + } + }); + RowMajorMatrix::new(values, width) +} + +/// Low-degree extension of column polynomials given by their COEFFICIENTS +/// with the `GENERATOR` coset shift already folded in (row `j` +/// pre-multiplied by `GENERATOR^j`, which [`shifted_quotient_slices`] +/// produces for free), in the exact layout `Pcs::commit` stores for +/// evaluations on the natural domain: `2^log_blowup` row blocks, where +/// block `b` holds the evaluations on the coset `GENERATOR · w^rev(b) · H` +/// in bit-reversed row order (`H` is the size-`n` subgroup, `w` generates +/// the size-`2^log_blowup · n` subgroup, and `rev` reverses `log_blowup` +/// bits). Globally that is the bit-reversal of the natural order of the +/// whole blown-up coset — i.e. +/// `coset_lde_batch(evals, log_blowup, GENERATOR).bit_reverse_rows()`, +/// which is what `TwoAdicFriPcs::commit` computes. +/// +/// Committing the result via `Pcs::commit_ldes` is therefore bit-identical +/// to `Pcs::commit` on the columns' trace-domain evaluations — field +/// arithmetic is exact, so equal polynomials give equal evaluations no +/// matter which transform produced them — while skipping both that +/// evaluation DFT and the inverse DFT `commit` would open with. +/// +/// The whole extension is ONE size-`2^log_blowup · n` transform: zero-pad +/// the shifted coefficients to the LDE height (which leaves the column +/// polynomials unchanged) and DFT. That spends `log_blowup` more butterfly +/// layers than `2^log_blowup` separate size-`n` coset DFTs would, but one +/// batched transform is what the memory traffic wants: no per-coset matrix +/// clones, no per-coset serial shift-scaling passes inside +/// `coset_dft_batch`, no reassembly copies, and `Radix2DitParallel`'s +/// native output order is already the bit-reversed storage order, so the +/// final unwrap is copy-free. +fn lde_from_shifted_coefficients( + dft: &impl TwoAdicSubgroupDft, + mut coefficients: RowMajorMatrix, + log_blowup: usize, +) -> RowMajorMatrix { + let height = coefficients.height(); + coefficients.pad_to_height(height << log_blowup, F::ZERO); + dft.dft_batch(coefficients) + .bit_reverse_rows() + .to_row_major_matrix() +} + +/// Reference form of [`lde_from_shifted_coefficients`] taking PLAIN +/// coefficients: folds the `GENERATOR` shift in explicitly. Only the +/// pinning tests need it; the prover gets the shift for free inside +/// [`shifted_quotient_slices`]. +#[cfg(test)] +fn lde_from_coefficients( + dft: &impl TwoAdicSubgroupDft, + mut coefficients: RowMajorMatrix, + log_blowup: usize, +) -> RowMajorMatrix { + scale_rows_by_powers(&mut coefficients, F::GENERATOR); + lde_from_shifted_coefficients(dft, coefficients, log_blowup) +} + +/// Multiplies row `j` of `mat` by `base^j`, in parallel: each chunk of rows +/// pays one exponentiation and steps serially from there. +#[cfg(test)] +fn scale_rows_by_powers(mat: &mut RowMajorMatrix, base: F) { + const ROWS_PER_CHUNK: usize = 512; + let width = mat.width(); + mat.values + .par_chunks_mut(ROWS_PER_CHUNK * width) + .enumerate() + .for_each(|(chunk, rows)| { + let mut weight = base.exp_u64((chunk * ROWS_PER_CHUNK) as u64); + for row in rows.chunks_mut(width) { + for value in row { + *value *= weight; + } + weight *= base; + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::Val; + use p3_dft::Radix2DitParallel; + use p3_field::{Field, PrimeCharacteristicRing}; + use rand::{RngExt, SeedableRng, rngs::SmallRng}; + + /// `lde_from_coefficients` must reproduce, value for value, the matrix + /// `TwoAdicFriPcs::commit` stores for the same polynomials given as + /// trace-domain evaluations: `coset_lde_batch` with the generator shift + /// (the natural domain's shift is one), then a bit-reversal. This pins + /// the exact substitution the quotient commit relies on. + #[test] + fn lde_from_coefficients_matches_commit_transform() { + let mut rng = SmallRng::seed_from_u64(0); + let dft = Radix2DitParallel::::default(); + for log_height in [0usize, 1, 2, 5, 8] { + for log_blowup in [1usize, 2, 3] { + for width in [1usize, 2, 7] { + let height = 1 << log_height; + let coefficients = RowMajorMatrix::new( + (0..height * width).map(|_| rng.random()).collect(), + width, + ); + let evaluations = dft + .coset_dft_batch(coefficients.clone(), Val::ONE) + .to_row_major_matrix(); + let expected = dft + .coset_lde_batch(evaluations, log_blowup, Val::GENERATOR) + .bit_reverse_rows() + .to_row_major_matrix(); + let got = lde_from_coefficients(&dft, coefficients, log_blowup); + assert_eq!(got, expected, "h=2^{log_height} B=2^{log_blowup} w={width}"); + } + } + } + } + + /// `shifted_quotient_slices` must reproduce, value for value, the naive + /// composition it replaces: coset iDFT off the quotient domain, slicing + /// the coefficients into `q` chunks per row, and folding the committed + /// LDE's `GENERATOR` shift into the rows. This pins the scaling + /// cancellation the fused gather relies on. + #[test] + fn shifted_quotient_slices_matches_naive_composition() { + let mut rng = SmallRng::seed_from_u64(1); + let dft = Radix2DitParallel::::default(); + for log_n in [0usize, 1, 2, 5, 7] { + for quotient_degree in [1usize, 2, 4] { + for ext_degree in [1usize, 2] { + let n = 1 << log_n; + let big_height = n * quotient_degree; + let evals = RowMajorMatrix::new( + (0..big_height * ext_degree).map(|_| rng.random()).collect(), + ext_degree, + ); + // Naive path: coset iDFT, slice, fold the LDE shift in. + let coefficients = dft.coset_idft_batch(evals.clone(), Val::GENERATOR); + let width = quotient_degree * ext_degree; + let mut sliced = Vec::with_capacity(n * width); + for row in 0..n { + for chunk in 0..quotient_degree { + sliced.extend_from_slice( + &coefficients.values[(chunk * n + row) * ext_degree + ..(chunk * n + row + 1) * ext_degree], + ); + } + } + let mut expected = RowMajorMatrix::new(sliced, width); + scale_rows_by_powers(&mut expected, Val::GENERATOR); + let got = shifted_quotient_slices(&dft, evals, Val::GENERATOR, quotient_degree); + assert_eq!( + got, expected, + "n=2^{log_n} q={quotient_degree} D={ext_degree}" + ); + } + } + } + } +} diff --git a/src/prover.rs b/src/prover.rs index 146a239..f662c3e 100644 --- a/src/prover.rs +++ b/src/prover.rs @@ -9,7 +9,7 @@ //! The proving protocol proceeds in several stages sharing one Fiat-Shamir //! transcript. The challenger starts from a seed binding a domain tag and all //! protocol parameters (see the transcript contract on -//! [`StarkGenericConfig::initialise_challenger`]), and the system shape +//! [`ProofConfig::initialise_challenger`]), and the system shape //! (circuit count, widths, constraint counts, degrees) //! is observed before any commitment. //! @@ -179,24 +179,22 @@ //! the prover's work. use crate::config::{ - Com, Domain, EvaluationsOnDomain, PackedChallenge, PackedVal, PcsProof, StarkGenericConfig, Val, + Com, Domain, EvaluationsOnDomain, PackedChallenge, PackedVal, PcsProof, ProofConfig, Val, }; use crate::eval::VarValues; use crate::lookup::{LookupValues, fingerprint}; use crate::system::{ProverKey, System, SystemWitness}; +use crate::traits::{ + Algebra, EvaluationDomain, ExtensionOf, Field, LagrangeSelectors, OpenedValuesForRound, Packed, + PackedExtension, Pcs, Transcript, TwoAdicField, flatten_to_base, +}; use bincode::config::{Configuration, Fixint, LittleEndian, standard}; use bincode::error::{DecodeError, EncodeError}; use bincode::serde::{decode_from_slice, encode_to_vec}; -use p3_challenger::{CanObserve, FieldChallenger}; -use p3_commit::{LagrangeSelectors, OpenedValuesForRound, Pcs, PolynomialSpace}; -use p3_dft::{Radix2DitParallel, TwoAdicSubgroupDft}; -use p3_field::{ - Algebra, BasedVectorSpace, Field, PackedValue, PrimeCharacteristicRing, TwoAdicField, -}; -use p3_matrix::{Matrix, bitrev::BitReversibleMatrix, dense::RowMajorMatrix}; +use p3_matrix::{Matrix, dense::RowMajorMatrix}; use p3_maybe_rayon::prelude::*; -use p3_util::{log2_strict_usize, reverse_bits_len}; +use p3_util::log2_strict_usize; use serde::{Deserialize, Serialize}; /// Polynomial commitments included in the proof. @@ -213,7 +211,7 @@ pub struct Commitments { /// A STARK proof for a multi-circuit system. #[derive(Serialize, Deserialize)] #[serde(bound = "")] -pub struct Proof { +pub struct Proof { /// Activation bitmap over the system's canonical circuit set: circuit i /// is covered by this proof iff `active[i]`. Inactive circuits (empty /// execution traces) are not committed, opened, accumulated, or @@ -238,7 +236,7 @@ pub struct Proof { pub stage_2_opened_values: OpenedValuesForRound, } -impl Proof { +impl Proof { fn serde_config() -> Configuration { standard().with_little_endian().with_fixed_int_encoding() } @@ -257,7 +255,7 @@ impl Proof { impl System where - SC: StarkGenericConfig, + SC: ProofConfig, // Two-adicity is needed to slice the quotient into coefficient slices // and rebuild their committed LDE from those coefficients; every // FRI-based config is two-adic anyway. @@ -312,7 +310,7 @@ where // the transcript here, before any commitment or challenge. let active: Vec = witness.traces.iter().map(|t| t.height() > 0).collect(); for &is_active in &active { - challenger.observe(Val::::from_bool(is_active)); + challenger.observe_field(Val::::from_bool(is_active)); } // Canonical index of each active circuit, in order; matrix position // within every per-proof commitment == position in this list. @@ -347,40 +345,41 @@ where let trace_domain = pcs.natural_domain_for_degree(degree); log_degrees.push(log_degree); (trace_domain, trace) - }); + }) + .collect(); let (stage_1_trace_commit, stage_1_trace_data) = pcs.commit(evaluations); drop(_g); if let Some(commit) = &self.preprocessed_commit { - challenger.observe(commit.clone()); + challenger.observe_commitment(commit.clone()); } - challenger.observe(stage_1_trace_commit.clone()); + challenger.observe_commitment(stage_1_trace_commit.clone()); // Observe the traces' heights. This binds the proof to specific domain // sizes; the verifier reads these from the (untrusted) proof, so they // must influence every subsequent challenge. for log_degree in &log_degrees { - challenger.observe(Val::::from_usize(*log_degree)); + challenger.observe_field(Val::::from_usize(*log_degree)); } // Observe the claims, length-prefixed so that distinct claim // structures (e.g. [[a, b]] vs [[a], [b]]) yield distinct transcripts. // This has to be done before generating the lookup argument challenge, // otherwise the lookup argument can be attacked. - challenger.observe(Val::::from_usize(claims.len())); + challenger.observe_field(Val::::from_usize(claims.len())); for claim in claims { - challenger.observe(Val::::from_usize(claim.len())); - challenger.observe_slice(claim); + challenger.observe_field(Val::::from_usize(claim.len())); + challenger.observe_field_slice(claim); } // Lookup challenges. - let lookup_argument_challenge: SC::Challenge = challenger.sample_algebra_element(); - challenger.observe_algebra_element(lookup_argument_challenge); - let fingerprint_challenge: SC::Challenge = challenger.sample_algebra_element(); - challenger.observe_algebra_element(fingerprint_challenge); + let lookup_argument_challenge: SC::Challenge = challenger.sample_challenge(); + challenger.observe_challenge(lookup_argument_challenge); + let fingerprint_challenge: SC::Challenge = challenger.sample_challenge(); + challenger.observe_challenge(fingerprint_challenge); // Initial accumulator from the claims. - let mut acc = SC::Challenge::ZERO; + let mut acc = >::ZERO; for claim in claims { let message = lookup_argument_challenge + fingerprint(&fingerprint_challenge, claim.iter().cloned()); @@ -417,24 +416,27 @@ where // Cost: "Stage 2 commit" — LDE + Merkle for flattened extension traces. // FFT work: Σ w2_i · D · (B+1) · n_i · log₂(n_i). let _g = tracing::info_span!("stark/stage2_commit").entered(); - let evaluations = stage_2_traces.into_iter().map(|trace| { - let degree = trace.height(); - let trace_domain = pcs.natural_domain_for_degree(degree); - (trace_domain, trace.flatten_to_base()) - }); + let evaluations = stage_2_traces + .into_iter() + .map(|trace| { + let degree = trace.height(); + let trace_domain = pcs.natural_domain_for_degree(degree); + (trace_domain, flatten_to_base(trace)) + }) + .collect(); let (stage_2_trace_commit, stage_2_trace_data) = pcs.commit(evaluations); drop(_g); - challenger.observe(stage_2_trace_commit.clone()); + challenger.observe_commitment(stage_2_trace_commit.clone()); // Observe the intermediate accumulators. They enter the constraints as // public values, so later challenges (α, ζ) must depend on them // directly rather than only through the quotient commitment. for acc in &intermediate_accumulators { - challenger.observe_algebra_element(*acc); + challenger.observe_challenge(*acc); } // Constraint challenge. - let constraint_challenge: SC::Challenge = challenger.sample_algebra_element(); + let constraint_challenge: SC::Challenge = challenger.sample_challenge(); // Cost: "Quotient computation and commit" — constraint evaluation on // the quotient domain (Σ n_i·q_i·eval_cost(k_i)), the forward DFT of @@ -443,9 +445,7 @@ where let _g = tracing::info_span!("stark/quotient").entered(); debug_assert_eq!(intermediate_accumulators.len(), active_indices.len()); debug_assert_eq!(log_degrees.len(), active_indices.len()); - let dft = Radix2DitParallel::>::default(); - let log_blowup = self.config.log_blowup(); - let quotient_ldes: Vec<_> = active_indices + let quotients: Vec<_> = active_indices .iter() .zip(log_degrees.iter()) .zip(intermediate_accumulators.iter()) @@ -498,39 +498,23 @@ where circuit.constraint_count(), ); let quotient_flat = - RowMajorMatrix::new_col(quotient_values).flatten_to_base::>(); - // The quotient has degree greater than the trace polynomials, - // so for FRI to work it must be split into `quotient_degree` - // sub-polynomials of trace degree. Slice its COEFFICIENTS: - // `Q(X) = Σᵢ X^{i·n}·cᵢ(X)` with each `cᵢ` of degree < n, and - // commit all slices as ONE `q·D`-column matrix on the trace - // domain — instead of one matrix per slice on the split - // cosets — so the opening phase pays its per-matrix costs - // once per circuit rather than once per slice. The committed - // LDE is built straight from these coefficients: evaluating - // them onto the trace domain only for `Pcs::commit` to - // inverse-DFT that evaluation right back would waste two - // size-n transforms per column. The slicing itself is fused - // with the iDFT's scaling passes into one parallel gather - // (see `shifted_quotient_slices`). + flatten_to_base::, _>(RowMajorMatrix::new_col(quotient_values)); + // The quotient has degree greater than the trace + // polynomials, so it is split into `quotient_degree` + // sub-polynomials of trace degree and committed as ONE + // `q·D`-column matrix per circuit on the trace domain (so + // the opening phase pays its per-matrix costs once per + // circuit rather than once per slice). Everything + // representation-specific — coefficient slicing and how the + // slices are committed — is the backend's job: + // `Pcs::commit_quotient` receives the evaluations on the + // disjoint domain plus the quotient degree. acc = *next_acc; - let sliced = shifted_quotient_slices( - &dft, - quotient_flat, - quotient_domain.first_point(), - quotient_degree, - ); - lde_from_shifted_coefficients(&dft, sliced, log_blowup) + (quotient_domain, quotient_flat, quotient_degree) }) .collect(); - // `commit_ldes` skips the randomization a hiding PCS applies inside - // `commit`; this prover targets non-hiding configurations only. - assert!( - !>::ZK, - "committing the quotient from coefficients bypasses hiding-PCS randomization" - ); - let (quotient_commit, quotient_data) = pcs.commit_ldes(quotient_ldes); - challenger.observe(quotient_commit.clone()); + let (quotient_commit, quotient_data) = pcs.commit_quotient(quotients); + challenger.observe_commitment(quotient_commit.clone()); drop(_g); let commitments = Commitments { @@ -542,16 +526,14 @@ where // Cost: "FRI opening" — barycentric interpolation (Σ n_i·B·W_i), // FRI folding (≈ H), and FRI queries (Q·R·log₂ H hash ops). let _g = tracing::info_span!("stark/fri_open").entered(); - let zeta: SC::Challenge = challenger.sample_algebra_element(); + let zeta: SC::Challenge = challenger.sample_challenge(); let mut round0_openings = vec![]; let mut round1_openings = vec![]; let mut round2_openings = vec![]; let mut round3_openings = vec![]; for &log_degree in log_degrees.iter() { let trace_domain = pcs.natural_domain_for_degree(1 << log_degree); - let zeta_next = trace_domain - .next_point(zeta) - .expect("domain has no next point"); + let zeta_next = trace_domain.next_point(zeta); round1_openings.push(vec![zeta, zeta_next]); round2_openings.push(vec![zeta, zeta_next]); // One wide matrix per circuit holds all its quotient slices. @@ -566,9 +548,7 @@ where match pos { Some(pos) => { let trace_domain = pcs.natural_domain_for_degree(1 << log_degrees[pos]); - let zeta_next = trace_domain - .next_point(zeta) - .expect("domain has no next point"); + let zeta_next = trace_domain.next_point(zeta); round0_openings.push(vec![zeta, zeta_next]); } None => round0_openings.push(vec![]), @@ -609,153 +589,6 @@ where } } -/// From the quotient's evaluations on its disjoint domain — `q·n` rows of -/// `D` base columns on the coset `shift·H` with `|H| = q·n` — produce the -/// `n`-row, `q·D`-column matrix of slice coefficients with the committed -/// LDE's `GENERATOR` shift already folded in: the input -/// [`lde_from_shifted_coefficients`] expects. -/// -/// Semantically this is three steps: coset iDFT to coefficients, slicing -/// `Q(X) = Σₖ X^{k·n}·cₖ(X)` into rows `[c₀ | … | c_{q−1}]`, and -/// pre-scaling row `r` by `GENERATOR^r`. Executed literally (the library -/// entry points) those steps cost a bit-reversal materialization, a serial -/// row-swap pass, two serial full-matrix scaling passes, and a serial -/// gather. But the composition collapses: with `N = q·n` and `S` the raw -/// bit-reversed storage of the forward DFT, -/// -/// ```text -/// idft(f)ⱼ = N⁻¹ · dft(f)_{(N−j) mod N} = N⁻¹ · S[rev((N−j) mod N)] -/// ``` -/// -/// and the coset-unscale factor `shift^{−j}` at `j = k·n + r` splits into -/// `shift^{−k·n} · shift^{−r}`, whose row-dependent part cancels the LDE -/// pre-scale `GENERATOR^r` exactly, because the disjoint quotient domain's -/// shift IS the generator (asserted below; `create_disjoint_domain` on a -/// natural trace domain guarantees it). What survives is a single parallel -/// gather off the DFT storage with ONE constant weight per slice: -/// `wₖ = N⁻¹ · GENERATOR^{−k·n}`. -fn shifted_quotient_slices( - dft: &Radix2DitParallel, - quotient_evals: RowMajorMatrix, - domain_shift: F, - quotient_degree: usize, -) -> RowMajorMatrix { - assert_eq!( - domain_shift, - F::GENERATOR, - "quotient domain shift must equal the LDE shift for the scalings to cancel" - ); - let ext_degree = quotient_evals.width(); - let big_height = quotient_evals.height(); - let log_big_height = log2_strict_usize(big_height); - debug_assert_eq!(big_height % quotient_degree, 0); - let n = big_height / quotient_degree; - let width = quotient_degree * ext_degree; - // Raw storage of the forward DFT: natural index `k` lives at row - // `rev(k)`, and the unwrap out of the bit-reversed view is copy-free. - let storage = dft.dft_batch(quotient_evals).bit_reverse_rows(); - let n_inv = F::ONE.div_2exp_u64(log_big_height as u64); - let weight_step = F::GENERATOR.exp_u64(n as u64).inverse(); - let weights: Vec = weight_step - .powers() - .take(quotient_degree) - .map(|w| w * n_inv) - .collect(); - let mut values = F::zero_vec(n * width); - values - .par_chunks_mut(width) - .enumerate() - .for_each(|(row, out)| { - for (chunk, weight) in weights.iter().enumerate() { - let j = chunk * n + row; - let src = reverse_bits_len( - big_height.wrapping_sub(j) & (big_height - 1), - log_big_height, - ); - let src = &storage.values[src * ext_degree..(src + 1) * ext_degree]; - for (out, src) in out[chunk * ext_degree..(chunk + 1) * ext_degree] - .iter_mut() - .zip(src) - { - *out = *src * *weight; - } - } - }); - RowMajorMatrix::new(values, width) -} - -/// Low-degree extension of column polynomials given by their COEFFICIENTS -/// with the `GENERATOR` coset shift already folded in (row `j` -/// pre-multiplied by `GENERATOR^j`, which [`shifted_quotient_slices`] -/// produces for free), in the exact layout `Pcs::commit` stores for -/// evaluations on the natural domain: `2^log_blowup` row blocks, where -/// block `b` holds the evaluations on the coset `GENERATOR · w^rev(b) · H` -/// in bit-reversed row order (`H` is the size-`n` subgroup, `w` generates -/// the size-`2^log_blowup · n` subgroup, and `rev` reverses `log_blowup` -/// bits). Globally that is the bit-reversal of the natural order of the -/// whole blown-up coset — i.e. -/// `coset_lde_batch(evals, log_blowup, GENERATOR).bit_reverse_rows()`, -/// which is what `TwoAdicFriPcs::commit` computes. -/// -/// Committing the result via `Pcs::commit_ldes` is therefore bit-identical -/// to `Pcs::commit` on the columns' trace-domain evaluations — field -/// arithmetic is exact, so equal polynomials give equal evaluations no -/// matter which transform produced them — while skipping both that -/// evaluation DFT and the inverse DFT `commit` would open with. -/// -/// The whole extension is ONE size-`2^log_blowup · n` transform: zero-pad -/// the shifted coefficients to the LDE height (which leaves the column -/// polynomials unchanged) and DFT. That spends `log_blowup` more butterfly -/// layers than `2^log_blowup` separate size-`n` coset DFTs would, but one -/// batched transform is what the memory traffic wants: no per-coset matrix -/// clones, no per-coset serial shift-scaling passes inside -/// `coset_dft_batch`, no reassembly copies, and `Radix2DitParallel`'s -/// native output order is already the bit-reversed storage order, so the -/// final unwrap is copy-free. -fn lde_from_shifted_coefficients( - dft: &Radix2DitParallel, - mut coefficients: RowMajorMatrix, - log_blowup: usize, -) -> RowMajorMatrix { - let height = coefficients.height(); - coefficients.pad_to_height(height << log_blowup, F::ZERO); - dft.dft_batch(coefficients).bit_reverse_rows() -} - -/// Reference form of [`lde_from_shifted_coefficients`] taking PLAIN -/// coefficients: folds the `GENERATOR` shift in explicitly. Only the -/// pinning tests need it; the prover gets the shift for free inside -/// [`shifted_quotient_slices`]. -#[cfg(test)] -fn lde_from_coefficients( - dft: &Radix2DitParallel, - mut coefficients: RowMajorMatrix, - log_blowup: usize, -) -> RowMajorMatrix { - scale_rows_by_powers(&mut coefficients, F::GENERATOR); - lde_from_shifted_coefficients(dft, coefficients, log_blowup) -} - -/// Multiplies row `j` of `mat` by `base^j`, in parallel: each chunk of rows -/// pays one exponentiation and steps serially from there. -#[cfg(test)] -fn scale_rows_by_powers(mat: &mut RowMajorMatrix, base: F) { - const ROWS_PER_CHUNK: usize = 512; - let width = mat.width(); - mat.values - .par_chunks_mut(ROWS_PER_CHUNK * width) - .enumerate() - .for_each(|(chunk, rows)| { - let mut weight = base.exp_u64((chunk * ROWS_PER_CHUNK) as u64); - for row in rows.chunks_mut(width) { - for value in row { - *value *= weight; - } - weight *= base; - } - }); -} - /// Evaluates the folded constraints on the quotient domain and divides by /// the vanishing polynomial, producing the quotient values. #[allow(clippy::too_many_arguments)] @@ -771,7 +604,7 @@ fn quotient_values( constraint_count: usize, ) -> Vec where - SC: StarkGenericConfig, + SC: ProofConfig, Val: TwoAdicField + Ord, { let quotient_size = quotient_domain.size(); @@ -793,25 +626,25 @@ where let next_step = 1 << qdb; for _ in quotient_size..PackedVal::::WIDTH { - sels.is_first_row.push(Val::::default()); - sels.is_last_row.push(Val::::default()); - sels.is_transition.push(Val::::default()); - sels.inv_vanishing.push(Val::::default()); + sels.is_first_row.push(Val::::ZERO); + sels.is_last_row.push(Val::::ZERO); + sels.is_transition.push(Val::::ZERO); + sels.inv_vanishing.push(Val::::ZERO); } // α powers in reverse (constraint i of k weighted by α^{k-1-i}), // decomposed per basis coordinate for the batched base-field fold. - let mut alpha_powers = alpha.powers().collect_n(constraint_count); + let mut alpha_powers: Vec = alpha.powers().take(constraint_count).collect(); alpha_powers.reverse(); - let decomposed_alpha_powers: Vec>> = - (0..>>::DIMENSION) - .map(|i| { - alpha_powers - .iter() - .map(|x| x.as_basis_coefficients_slice()[i]) - .collect() - }) - .collect(); + let decomposed_alpha_powers: Vec>> = (0 + ..>>::D) + .map(|i| { + alpha_powers + .iter() + .map(|x| x.as_basis_coefficients_slice()[i]) + .collect() + }) + .collect(); // Public coordinates broadcast to packed base values. let publics_packed: Vec> = lookup_publics @@ -821,7 +654,7 @@ where // Δ/(n·g) per coordinate, broadcast: the logUp boundary injection with // the last-row selector's normalization constant pre-absorbed. - let ext_d = >>::DIMENSION; + let ext_d = >>::D; let delta_scaled: Vec> = (0..ext_d) .map(|k| { PackedVal::::from( @@ -888,7 +721,7 @@ fn quotient_values_inner( ext_degree: usize, ) -> impl Iterator where - SC: StarkGenericConfig, + SC: ProofConfig, Val: TwoAdicField + Ord, { let i_range = i_start..i_start + PackedVal::::WIDTH; @@ -900,11 +733,11 @@ where // Packed two-row windows of each trace, as base columns. let preprocessed_pair: Option>> = preprocessed_on_quotient_domain .as_ref() - .map(|m| m.vertically_packed_row_pair::>(i_start, next_step)); + .map(|m| PackedVal::::packed_row_pair(m, i_start, next_step)); let stage_1_pair = - stage_1_on_quotient_domain.vertically_packed_row_pair::>(i_start, next_step); + PackedVal::::packed_row_pair(stage_1_on_quotient_domain, i_start, next_step); let stage_2_pair = - stage_2_on_quotient_domain.vertically_packed_row_pair::>(i_start, next_step); + PackedVal::::packed_row_pair(stage_2_on_quotient_domain, i_start, next_step); let (stage_1_cur, stage_1_next) = stage_1_pair.split_at(main_width); let (stage_2_cur, stage_2_next) = stage_2_pair.split_at(stage_2_width); @@ -960,90 +793,9 @@ where (0..quotient_size.min(PackedVal::::WIDTH)).map(move |idx_in_packing| { SC::Challenge::from_basis_coefficients_fn(|coeff_idx| { - as BasedVectorSpace>>::as_basis_coefficients_slice( - "ient, - )[coeff_idx] + PackedExtension::, SC::Challenge>::as_basis_coefficients_slice("ient) + [coeff_idx] .as_slice()[idx_in_packing] }) }) } - -#[cfg(test)] -mod tests { - use super::*; - use crate::types::Val; - use rand::{RngExt, SeedableRng, rngs::SmallRng}; - - /// `lde_from_coefficients` must reproduce, value for value, the matrix - /// `TwoAdicFriPcs::commit` stores for the same polynomials given as - /// trace-domain evaluations: `coset_lde_batch` with the generator shift - /// (the natural domain's shift is one), then a bit-reversal. This pins - /// the exact substitution the quotient commit relies on. - #[test] - fn lde_from_coefficients_matches_commit_transform() { - let mut rng = SmallRng::seed_from_u64(0); - let dft = Radix2DitParallel::::default(); - for log_height in [0usize, 1, 2, 5, 8] { - for log_blowup in [1usize, 2, 3] { - for width in [1usize, 2, 7] { - let height = 1 << log_height; - let coefficients = RowMajorMatrix::new( - (0..height * width).map(|_| rng.random()).collect(), - width, - ); - let evaluations = dft - .coset_dft_batch(coefficients.clone(), Val::ONE) - .to_row_major_matrix(); - let expected = dft - .coset_lde_batch(evaluations, log_blowup, Val::GENERATOR) - .bit_reverse_rows() - .to_row_major_matrix(); - let got = lde_from_coefficients(&dft, coefficients, log_blowup); - assert_eq!(got, expected, "h=2^{log_height} B=2^{log_blowup} w={width}"); - } - } - } - } - - /// `shifted_quotient_slices` must reproduce, value for value, the naive - /// composition it replaces: coset iDFT off the quotient domain, slicing - /// the coefficients into `q` chunks per row, and folding the committed - /// LDE's `GENERATOR` shift into the rows. This pins the scaling - /// cancellation the fused gather relies on. - #[test] - fn shifted_quotient_slices_matches_naive_composition() { - let mut rng = SmallRng::seed_from_u64(1); - let dft = Radix2DitParallel::::default(); - for log_n in [0usize, 1, 2, 5, 7] { - for quotient_degree in [1usize, 2, 4] { - for ext_degree in [1usize, 2] { - let n = 1 << log_n; - let big_height = n * quotient_degree; - let evals = RowMajorMatrix::new( - (0..big_height * ext_degree).map(|_| rng.random()).collect(), - ext_degree, - ); - // Naive path: coset iDFT, slice, fold the LDE shift in. - let coefficients = dft.coset_idft_batch(evals.clone(), Val::GENERATOR); - let width = quotient_degree * ext_degree; - let mut sliced = Vec::with_capacity(n * width); - for row in 0..n { - for chunk in 0..quotient_degree { - sliced.extend_from_slice( - &coefficients.values[(chunk * n + row) * ext_degree - ..(chunk * n + row + 1) * ext_degree], - ); - } - } - let mut expected = RowMajorMatrix::new(sliced, width); - scale_rows_by_powers(&mut expected, Val::GENERATOR); - let got = shifted_quotient_slices(&dft, evals, Val::GENERATOR, quotient_degree); - assert_eq!( - got, expected, - "n=2^{log_n} q={quotient_degree} D={ext_degree}" - ); - } - } - } - } -} diff --git a/src/system.rs b/src/system.rs index 63335d8..46bc774 100644 --- a/src/system.rs +++ b/src/system.rs @@ -9,18 +9,17 @@ //! ([`crate::lookup::logup_constraint_values`]), folding their values after //! the user roots. -use p3_challenger::CanObserve; -use p3_commit::{Pcs, PolynomialSpace}; -use p3_field::{BasedVectorSpace, Field, PrimeCharacteristicRing}; +use crate::traits::{ExtensionOf, Field}; use p3_matrix::{Matrix, dense::RowMajorMatrix}; -use crate::config::{Com, PcsData, StarkGenericConfig, Val}; +use crate::config::{Com, PcsData, ProofConfig, Val}; use crate::lookup::LookupValues; use crate::eval::VarValues; use crate::expr::{CircuitSpec, Expr, ExtExpr}; use crate::graph::{ConstraintGraph, ExtensionParams, compile}; use crate::lookup::{Lookup, logup_constraint_count, logup_max_degree, num_publics, stage2_width}; +use crate::traits::{Pcs, Transcript}; /// User-facing definition of one circuit: main-trace width, optional /// preprocessed trace, base and extension constraints, and lookups. The @@ -101,7 +100,7 @@ impl Circuit { /// A multi-circuit STARK system over compiled constraint circuits. Contains /// all circuits together with their shared preprocessed commitment and the /// protocol configuration. -pub struct System { +pub struct System { pub config: SC, pub circuits: Vec>>, /// Commitment to all preprocessed traces (if any circuit has one). @@ -112,12 +111,12 @@ pub struct System { } /// Prover-side data retained between system setup and proving. -pub struct ProverKey { +pub struct ProverKey { /// PCS prover data for the preprocessed traces. pub preprocessed_data: Option>, } -impl System { +impl System { /// Builds the system from per-circuit inputs. /// /// # Panics @@ -227,9 +226,9 @@ impl System { /// commitment, so that transcripts of systems with different circuit /// shapes never collide. The protocol parameters are bound separately, /// via the challenger seed (see - /// [`StarkGenericConfig::initialise_challenger`]). + /// [`ProofConfig::initialise_challenger`]). pub fn observe_shape(&self, challenger: &mut SC::Challenger) { - let mut observe = |x: usize| challenger.observe(Val::::from_usize(x)); + let mut observe = |x: usize| challenger.observe_field(Val::::from_usize(x)); observe(self.circuits.len()); for circuit in &self.circuits { observe(circuit.constraint_count()); @@ -266,8 +265,8 @@ impl SystemWitness { /// heights must match; the rows would otherwise be silently truncated). pub fn from_stage_1(traces: Vec>, system: &System) -> Self where - SC: StarkGenericConfig, - SC::Pcs: Pcs>, + SC: ProofConfig, + SC::Pcs: Pcs, { assert_eq!( traces.len(), @@ -350,23 +349,13 @@ fn compute_lookup_values( builder.finish() } -/// Extracts the binomial extension parameters of the challenge field -/// generically: the degree is `Challenge::DIMENSION`, and the modulus -/// constant `W` (with `X^D = W`) is recovered by evaluating `X^D` and -/// reading its base coordinate — no dependence on a concrete field type. -pub(crate) fn extension_params() -> ExtensionParams> { - let d = >>::DIMENSION; - let x = >>::ith_basis_element(1) - .expect("challenge field must have extension degree >= 2"); - let x_pow_d = x.powers().nth(d).expect("powers iterator is infinite"); - let coords = x_pow_d.as_basis_coefficients_slice(); - debug_assert!( - coords[1..].iter().all(|c| c.is_zero()), - "challenge field is not a binomial extension: X^D is not a base element" - ); +/// The binomial extension parameters of the challenge field, read off +/// the `ExtensionOf` constants (`X^D = W`; `W` is unused when `D = 1`). +pub(crate) fn extension_params() -> ExtensionParams> { + let d = >>::D; ExtensionParams { degree: d, - w: coords[0], + w: >>::W, karatsuba: d == 2, } } @@ -485,7 +474,8 @@ mod tests { let (system, _key) = System::new(config, [LookupAir::new(Preprocessed, vec![])]); // The main trace has 8 rows but the preprocessed trace has 4. This // must panic instead of silently truncating the lookup rows. - let trace = RowMajorMatrix::new(vec![Val::ZERO; 8], 1); + let trace = + RowMajorMatrix::new(vec![::ZERO; 8], 1); SystemWitness::from_stage_1(vec![trace], &system); } } diff --git a/src/test_circuits/baby_bear_config.rs b/src/test_circuits/baby_bear_config.rs index ca35637..0fdc57a 100644 --- a/src/test_circuits/baby_bear_config.rs +++ b/src/test_circuits/baby_bear_config.rs @@ -1,4 +1,4 @@ -//! A second [`StarkGenericConfig`] instantiation — BabyBear field with a +//! A second [`ProofConfig`] instantiation — BabyBear field with a //! degree-4 binomial extension and Poseidon2 hashing — differing from the //! reference Goldilocks/Keccak config in both the field and the hash axes. //! @@ -6,7 +6,7 @@ //! change compiles only for the reference config, the smoke test here //! catches it. -use crate::config::StarkGenericConfig; +use crate::config::ProofConfig; use crate::lookup::Lookup; use crate::p3_adapter::{LookupAir, SymbolicExpression, var}; use crate::system::{System, SystemWitness}; @@ -35,14 +35,107 @@ type Challenge = BinomialExtensionField; type ChallengeMmcs = ExtensionMmcs; type Challenger = DuplexChallenger; type Dft = Radix2DitParallel; -type Pcs = TwoAdicFriPcs; +type InnerPcs = TwoAdicFriPcs; +type Pcs = crate::p3_adapter::pcs::FriPcs; +type Com = >::Commitment; +type Domain = p3_field::coset::TwoAdicMultiplicativeCoset; + +// Crate field-trait instantiations for the BabyBear stack. +use crate::p3_adapter::field::{impl_extension_via_p3, impl_field_via_p3, impl_two_adic_via_p3}; +impl_field_via_p3!(Val); +impl_two_adic_via_p3!(Val); +impl_field_via_p3!(Challenge); +impl_extension_via_p3!(Val, Challenge, 4); + +impl crate::traits::EvaluationDomain for Domain { + type F = Val; + type Challenge = Challenge; + + #[inline] + fn size(&self) -> usize { + p3_commit::PolynomialSpace::size(self) + } + + #[inline] + fn first_point(&self) -> Val { + p3_commit::PolynomialSpace::first_point(self) + } + + #[inline] + fn next_point(&self, x: Challenge) -> Challenge { + p3_commit::PolynomialSpace::next_point(self, x).expect("two-adic domain has a next point") + } + + #[inline] + fn create_disjoint_domain(&self, min_size: usize) -> Self { + p3_commit::PolynomialSpace::create_disjoint_domain(self, min_size) + } + + #[inline] + fn selectors_at_point(&self, point: Challenge) -> crate::traits::LagrangeSelectors { + let s = p3_commit::PolynomialSpace::selectors_at_point(self, point); + crate::traits::LagrangeSelectors { + is_first_row: s.is_first_row, + is_last_row: s.is_last_row, + is_transition: s.is_transition, + inv_vanishing: s.inv_vanishing, + } + } + + #[inline] + fn selectors_on_coset(&self, coset: Self) -> crate::traits::LagrangeSelectors> { + let s = p3_commit::PolynomialSpace::selectors_on_coset(self, coset); + crate::traits::LagrangeSelectors { + is_first_row: s.is_first_row, + is_last_row: s.is_last_row, + is_transition: s.is_transition, + inv_vanishing: s.inv_vanishing, + } + } +} + +// Second `MsChallenger` instantiation — the whole point of this config: +// prove the crate-owned transcript trait is generic across field/hash +// choices, not shaped around the reference challenger. +impl crate::traits::Transcript for Challenger { + type F = Val; + type Challenge = Challenge; + type Commitment = Com; + + #[inline] + fn observe_field(&mut self, x: Val) { + self.observe(x); + } + + #[inline] + fn observe_field_slice(&mut self, xs: &[Val]) { + self.observe_slice(xs); + } + + #[inline] + fn observe_challenge(&mut self, x: Challenge) { + use p3_challenger::FieldChallenger; + self.observe_algebra_element(x); + } + + #[inline] + fn observe_commitment(&mut self, c: Com) { + self.observe(c); + } + + #[inline] + fn sample_challenge(&mut self) -> Challenge { + use p3_challenger::FieldChallenger; + self.sample_algebra_element() + } +} struct BabyBearPoseidon2Config { pcs: Pcs, perm: Perm, /// Field elements observed into every fresh challenger: a domain tag /// plus a digest of the protocol parameters (see the transcript contract - /// on [`StarkGenericConfig::initialise_challenger`]). + /// on [`ProofConfig::initialise_challenger`]). challenger_seed: Vec, max_log_degree: usize, max_quotient_degree: usize, @@ -66,7 +159,11 @@ impl BabyBearPoseidon2Config { query_proof_of_work_bits: fri_parameters.query_proof_of_work_bits, mmcs: challenge_mmcs, }; - let pcs = Pcs::new(Dft::default(), val_mmcs, inner_parameters); + let pcs = Pcs::new( + InnerPcs::new(Dft::default(), val_mmcs, inner_parameters), + commitment_parameters, + fri_parameters, + ); let mut challenger_seed: Vec = b"multi-stark/v0" .iter() .map(|&byte| Val::from_u8(byte)) @@ -96,7 +193,7 @@ impl BabyBearPoseidon2Config { } } -impl StarkGenericConfig for BabyBearPoseidon2Config { +impl ProofConfig for BabyBearPoseidon2Config { type Pcs = Pcs; type Challenge = Challenge; type Challenger = Challenger; diff --git a/src/traits/domain.rs b/src/traits/domain.rs new file mode 100644 index 0000000..f658d48 --- /dev/null +++ b/src/traits/domain.rs @@ -0,0 +1,48 @@ +//! The evaluation-domain surface the core actually uses. +//! +//! Both backends run over two-adic multiplicative subgroups; what differs +//! is who hands the domains out (the PCS) and how the quotient-sweep +//! coset is realised. Associated types, for the same inference reasons as +//! [`super::Transcript`]: one (base field, challenge field) pair per +//! domain type per configuration. + +/// Lagrange selector values over some evaluation context `T` (a point's +/// worth of challenge-field values, or per-coset-row base-field vectors). +/// Mirrors the classic quadruple: first row, last row, transition, and +/// the inverse vanishing polynomial. +pub struct LagrangeSelectors { + pub is_first_row: T, + pub is_last_row: T, + pub is_transition: T, + pub inv_vanishing: T, +} + +/// A (sub)group evaluation domain of power-of-two size. +pub trait EvaluationDomain: Copy { + /// Base (trace) field. + type F; + /// Challenge field for out-of-domain points. + type Challenge; + + fn size(&self) -> usize; + + /// The domain's first point (its coset shift; the subgroup identity + /// for unshifted domains). + fn first_point(&self) -> Self::F; + + /// `x * g` for the domain's group generator — the "next row" map used + /// to build the (zeta, zeta*g) opening pairs. + fn next_point(&self, x: Self::Challenge) -> Self::Challenge; + + /// A domain of at least `min_size` points disjoint from this one, for + /// the quotient sweep (the vanishing polynomial of `self` must be + /// nonzero on every point of the result). + fn create_disjoint_domain(&self, min_size: usize) -> Self; + + /// Selector values at one out-of-domain point. + fn selectors_at_point(&self, point: Self::Challenge) -> LagrangeSelectors; + + /// Selector values at every point of `coset` (a disjoint domain as + /// produced by [`Self::create_disjoint_domain`]). + fn selectors_on_coset(&self, coset: Self) -> LagrangeSelectors>; +} diff --git a/src/traits/field.rs b/src/traits/field.rs new file mode 100644 index 0000000..86a314d --- /dev/null +++ b/src/traits/field.rs @@ -0,0 +1,226 @@ +//! The field surface the core actually uses. +//! +//! Three layers, measured off the call sites (see docs/pcs-abstraction.md): +//! +//! - [`Field`]: the scalar base/challenge fields — ring ops, constants, +//! inversion, small-integer embeddings, powers, and an associated +//! SIMD packing for the prover's constraint sweep. +//! - [`Algebra`]: the sweep's working types (`W` in `eval.rs`): the base +//! field itself (witness paths), its packing (quotient domain), or the +//! challenge field (verifier at zeta). Ring ops among `W`, embedding +//! from `F`, and scaling by `F`. +//! - [`ExtensionOf`]: the challenge field over the base field, as a +//! BINOMIAL extension `X^D = W` with `D >= 1`. `D = 1` is first-class +//! (the field is its own challenge field — the KZG/BLS12-381 case); +//! the constants replace the old runtime recovery of `W`, which +//! required `D >= 2`. +//! +//! Backends adapt in: `p3_adapter/field.rs` for Plonky3 fields (packings +//! wrapped in a generic newtype so every impl stays constructor-headed), +//! the arkworks adapter with scalar (width-1) packing. + +use core::fmt::Debug; +use core::iter::Iterator; +use core::ops::{Add, AddAssign, Mul, MulAssign, Neg, Sub, SubAssign}; + +use p3_matrix::Matrix; +use serde::Serialize; +use serde::de::DeserializeOwned; + +/// Ring operations shared by every layer, over `Rhs`. +pub trait RingOps: + Sized + + Add + + Sub + + Mul + + AddAssign + + SubAssign + + MulAssign +{ +} +impl RingOps for T where + T: Sized + + Add + + Sub + + Mul + + AddAssign + + SubAssign + + MulAssign +{ +} + +/// A working type for the constraint sweep: closed ring ops, an +/// embedding from the base field, and scaling by base-field constants. +pub trait Algebra: + Copy + Send + Sync + RingOps + Neg + From + Mul +{ + const ZERO: Self; + const ONE: Self; +} + +/// A scalar field. +pub trait Field: + 'static + + Copy + + Send + + Sync + + Eq + + core::hash::Hash + + Debug + + Algebra + + Serialize + + DeserializeOwned +{ + /// SIMD packing for the prover's sweep; scalar (width 1) is a valid + /// implementation. + type Packing: Packed; + + /// Multiplicative inverse; implementations may define `inverse(0)` + /// freely (the protocol never inverts zero on honest paths). + fn inverse(&self) -> Self; + + fn exp_u64(&self, exp: u64) -> Self; + + /// `self^(2^log)` by repeated squaring. + fn exp_power_of_2(&self, log: usize) -> Self { + let mut r = *self; + for _ in 0..log { + r = r * r; + } + r + } + + fn from_bool(b: bool) -> Self; + fn from_u8(x: u8) -> Self; + fn from_u32(x: u32) -> Self; + fn from_u64(x: u64) -> Self; + fn from_usize(x: usize) -> Self; + + fn is_zero(&self) -> bool { + *self == Self::ZERO + } + + /// The infinite iterator `1, x, x^2, ...`. + fn powers(&self) -> Powers { + Powers { + base: *self, + current: Self::ONE, + } + } + + fn zero_vec(len: usize) -> Vec { + vec![Self::ZERO; len] + } +} + +/// `1, x, x^2, ...` — see [`Field::powers`]. +#[derive(Clone, Debug)] +pub struct Powers { + pub base: F, + pub current: F, +} + +impl Iterator for Powers { + type Item = F; + + fn next(&mut self) -> Option { + let result = self.current; + self.current *= self.base; + Some(result) + } +} + +/// A field with a large power-of-two subgroup. +pub trait TwoAdicField: Field { + const TWO_ADICITY: usize; + + /// A generator of the order-`2^bits` subgroup. + fn two_adic_generator(bits: usize) -> Self; +} + +/// SIMD packet of base-field values. Scalar fields implement this for +/// themselves with `WIDTH = 1`. +pub trait Packed: Algebra { + const WIDTH: usize; + + /// Reinterpret `WIDTH` contiguous scalars as one packet (no copy). + fn from_slice(slice: &[F]) -> &Self; + + /// The packet's scalars. + fn as_slice(&self) -> &[F]; + + /// `sum_i coeffs[i] * vecs[i]` — the alpha-fold inner loop. + fn batched_linear_combination(vecs: &[Self], coeffs: &[F]) -> Self { + let mut acc = Self::ZERO; + for (v, c) in vecs.iter().zip(coeffs) { + acc += *v * *c; + } + acc + } + + /// The packed two-row window `[rows i.., rows (i+step)..]` of a + /// matrix, `WIDTH` vertically adjacent rows per packet — the sweep's + /// input gather. Row indices wrap modulo the height. + fn packed_row_pair>(matrix: &M, i: usize, step: usize) -> Vec; +} + +/// The challenge field as a binomial extension `X^D = W` of the base +/// field, `D >= 1`. For `D = 1` the field is its own challenge field: +/// `W` is unused and the basis is the identity. +pub trait ExtensionOf: Field + Algebra { + const D: usize; + /// The binomial modulus constant (`X^D = W`); unused when `D = 1`. + const W: F; + + /// Packed representation: `D` base packings. + type ExtPacking: PackedExtension; + + fn as_basis_coefficients_slice(&self) -> &[F]; + fn from_basis_coefficients_fn(f: impl FnMut(usize) -> F) -> Self; +} + +/// Packed challenge values: `D` base packets, used to reassemble the +/// sweep's folded accumulator and scale it by packed base values. +pub trait PackedExtension: + Copy + Send + Sync + Mul +{ + fn from_basis_coefficients_fn(f: impl FnMut(usize) -> F::Packing) -> Self; + fn as_basis_coefficients_slice(&self) -> &[F::Packing]; +} + +/// Flatten a challenge-valued matrix to base columns (each value becomes +/// its `D` basis coordinates). Identity-shaped when `D = 1`. +pub fn flatten_to_base>( + m: p3_matrix::dense::RowMajorMatrix, +) -> p3_matrix::dense::RowMajorMatrix { + let width = m.width * EF::D; + let values = m + .values + .into_iter() + .flat_map(|e| e.as_basis_coefficients_slice().to_vec()) + .collect(); + p3_matrix::dense::RowMajorMatrix::new(values, width) +} + +/// Batch inversion by Montgomery's trick: 3 multiplications per element +/// and a single field inversion. Zero entries are passed through as zero +/// (matching the reference semantics the lookup witness relies on). +pub fn batch_inverse(values: &[F]) -> Vec { + let mut prefix = Vec::with_capacity(values.len()); + let mut acc = F::ONE; + for &v in values { + prefix.push(acc); + if !v.is_zero() { + acc *= v; + } + } + let mut inv = acc.inverse(); + let mut out = vec![F::ZERO; values.len()]; + for i in (0..values.len()).rev() { + if !values[i].is_zero() { + out[i] = prefix[i] * inv; + inv *= values[i]; + } + } + out +} diff --git a/src/traits/mod.rs b/src/traits/mod.rs new file mode 100644 index 0000000..6f9529f --- /dev/null +++ b/src/traits/mod.rs @@ -0,0 +1,24 @@ +//! Crate-owned trait layer: the abstraction boundary the prover and +//! verifier are written against, so a polynomial commitment backend +//! (Plonky3 FRI today, arkworks KZG next) plugs in via an adapter +//! module instead of leaking its traits through the core. See +//! `docs/pcs-abstraction.md` for the design and migration plan. +//! +//! Names are the natural ones for this crate; where they collide with a +//! backend's (p3 also has a `Pcs`), the backend import is renamed at the +//! adapter (`use p3_commit::Pcs as P3Pcs`), never the other way around. + +mod domain; +mod field; +mod pcs; +mod transcript; + +pub use domain::{EvaluationDomain, LagrangeSelectors}; +pub use field::{ + Algebra, ExtensionOf, Field, Packed, PackedExtension, Powers, RingOps, TwoAdicField, + batch_inverse, flatten_to_base, +}; +pub use pcs::{ + OpenedValues, OpenedValuesForMatrix, OpenedValuesForRound, OpeningRounds, Pcs, VerifyRounds, +}; +pub use transcript::Transcript; diff --git a/src/traits/pcs.rs b/src/traits/pcs.rs new file mode 100644 index 0000000..998f3fc --- /dev/null +++ b/src/traits/pcs.rs @@ -0,0 +1,117 @@ +//! The polynomial-commitment surface the core actually uses. +//! +//! Six operations plus the degree-budget query (see +//! `docs/pcs-abstraction.md`). Associated types for the same reasons as +//! the other crate traits; the challenger is an associated type because +//! `open`/`verify` drive the transcript, and each backend requires its +//! own concrete challenger capabilities internally. + +use core::fmt::Debug; + +use p3_matrix::Matrix; +use p3_matrix::dense::RowMajorMatrix; + +use serde::Serialize; +use serde::de::DeserializeOwned; + +use super::EvaluationDomain; + +/// Opened values for one matrix: per opening point, the row of values. +pub type OpenedValuesForMatrix = Vec>; +/// Opened values for one commitment round: per matrix. +pub type OpenedValuesForRound = Vec>; +/// Opened values for all rounds of one `open` call. +pub type OpenedValues = Vec>; + +/// Opening rounds: per committed round, per matrix, the opening points. +pub type OpeningRounds<'a, Data, EF> = Vec<(&'a Data, Vec>)>; + +/// Verification rounds: per commitment, per matrix its domain and the +/// (point, claimed values) pairs. +pub type VerifyRounds = Vec<(Com, Vec<(Dom, Vec<(EF, Vec)>)>)>; + +/// A batch polynomial commitment scheme over matrices of trace +/// evaluations. +pub trait Pcs { + /// Base (trace) field. The bounds are the container's needs (matrix + /// storage is shared across prover threads), satisfied by any field + /// element type — no proof-system semantics implied. + type F: Clone + Send + Sync + 'static; + /// Challenge field. + type Challenge; + /// Evaluation domains this PCS hands out. + type Domain: EvaluationDomain; + /// The transcript driven by `open`/`verify`. + type Challenger; + /// Commitment type (FRI: one Merkle cap per round; KZG: one G1 per + /// column, so a round is a vector of points). + type Commitment: Clone + Serialize + DeserializeOwned; + /// Prover-side retained data for committed rounds. + type ProverData; + /// Opening proof covering all rounds of one `open` call. + type Proof: Clone + Serialize + DeserializeOwned; + type Error: Debug; + /// Prover-side view of a committed matrix's evaluations on a domain + /// (borrowed from `ProverData`; the constraint sweep reads it). + type Evaluations<'a>: Matrix + 'a; + + /// The canonical domain for traces of `degree` rows. + fn natural_domain_for_degree(&self, degree: usize) -> Self::Domain; + + /// The largest quotient degree (as a multiple of the trace degree) + /// this PCS serves ECONOMICALLY. Advisory: `System::new` validates + /// every circuit against it at build time, and the lookup-grouping + /// policy keys on it. FRI: the blowup (trace evaluations on the + /// quotient domain subset the committed LDE). KZG: the coset-FFT + /// budget. Implementations should fail loudly — not fall back to a + /// silent slow path — if asked to exceed it (see + /// [`Self::get_evaluations_on_domain`]). + fn max_quotient_degree(&self) -> usize; + + /// Commit to matrices of evaluations over their domains, as one + /// round. + fn commit( + &self, + evaluations: Vec<(Self::Domain, RowMajorMatrix)>, + ) -> (Self::Commitment, Self::ProverData); + + /// Commit the per-circuit quotient polynomials, given by their + /// EVALUATIONS on the disjoint quotient domain (flattened to base + /// columns) plus the circuit's quotient degree `q`. The backend owns + /// everything representation-specific from here: slicing + /// `Q(X) = Σₖ X^{k·n}·cₖ(X)` into `q` degree-`< n` coefficient + /// slices and committing them as one `q·D`-column matrix per circuit + /// on the trace domain (FRI: fused shifted gather + zero-padded DFT + /// to the committed LDE; KZG: coset iDFT + per-slice MSM). + fn commit_quotient( + &self, + quotients: Vec<(Self::Domain, RowMajorMatrix, usize)>, + ) -> (Self::Commitment, Self::ProverData); + + /// A committed matrix's evaluations on `domain`. May panic if + /// `domain` exceeds what this PCS serves economically (FRI: the + /// committed LDE) — the build-time [`Self::max_quotient_degree`] + /// check fires first. + fn get_evaluations_on_domain<'a>( + &self, + data: &'a Self::ProverData, + idx: usize, + domain: Self::Domain, + ) -> Self::Evaluations<'a>; + + /// Open all rounds: per round, per matrix, the opening points. + /// Returns the opened values in round order and the opening proof. + fn open( + &self, + rounds: OpeningRounds<'_, Self::ProverData, Self::Challenge>, + challenger: &mut Self::Challenger, + ) -> (OpenedValues, Self::Proof); + + /// Verify claimed opened values against commitments. + fn verify( + &self, + rounds: VerifyRounds, + proof: &Self::Proof, + challenger: &mut Self::Challenger, + ) -> Result<(), Self::Error>; +} diff --git a/src/traits/transcript.rs b/src/traits/transcript.rs new file mode 100644 index 0000000..bf149cb --- /dev/null +++ b/src/traits/transcript.rs @@ -0,0 +1,36 @@ +//! The Fiat-Shamir transcript surface the core actually uses. +//! +//! Associated types (not generics): a transcript serves exactly one +//! (field, challenge, commitment) triple per configuration, so +//! associated types keep every call site inference-unambiguous — +//! `observe_field(x)` needs no turbofish — and sidestep the +//! unconstrained-parameter (E0207) problems a blanket impl over an +//! erased base field would hit. +//! +//! Semantics each implementation must honour (they define the +//! transcript format, which is consensus-critical): +//! - `observe_field`: absorb one base-field element canonically. +//! - `observe_field_slice`: absorb elements in order; MUST equal +//! observing each element individually. +//! - `observe_challenge`: absorb a challenge-field element by its basis +//! coefficients over the base field, in order (degree 1: the element +//! itself). +//! - `observe_commitment`: absorb a commitment by its canonical byte +//! encoding (FRI: Merkle cap digest limbs; KZG: compressed G1 bytes). +//! - `sample_challenge`: squeeze one challenge-field element. + +/// Transcript operations for one proof configuration. +pub trait Transcript { + /// Base (trace) field. + type F; + /// Challenge field (a based vector space over `F`; degree may be 1). + type Challenge; + /// Commitment type absorbed into the transcript. + type Commitment; + + fn observe_field(&mut self, x: Self::F); + fn observe_field_slice(&mut self, xs: &[Self::F]); + fn observe_challenge(&mut self, x: Self::Challenge); + fn observe_commitment(&mut self, c: Self::Commitment); + fn sample_challenge(&mut self) -> Self::Challenge; +} diff --git a/src/types.rs b/src/types.rs index ee42b65..0db626c 100644 --- a/src/types.rs +++ b/src/types.rs @@ -5,7 +5,7 @@ //! [`crate::prover`] and [`crate::verifier`]; this module only provides a //! concrete, batteries-included instantiation. -use crate::config::StarkGenericConfig; +use crate::config::ProofConfig; use p3_blake3::Blake3; use p3_challenger::{ CanObserve, CanSample, CanSampleBits, FieldChallenger, GrindingChallenger, HashChallenger, @@ -82,16 +82,18 @@ impl GrindingChallenger for DeterministicPow { pub type Mmcs = MerkleTreeMmcs, Blake3CompressionFunction, 2, 32>; pub type ExtMmcs = ExtensionMmcs; -pub type Pcs = TwoAdicFriPcs; +pub type InnerPcs = TwoAdicFriPcs; +pub type Pcs = crate::p3_adapter::pcs::FriPcs; -pub type Commitment = >::Commitment; -pub type Domain = >::Domain; -pub type ProverData = >::ProverData; -pub type EvaluationsOnDomain<'a> = >::EvaluationsOnDomain<'a>; -pub type PcsError = >::Error; -pub type PcsProof = >::Proof; +pub type Commitment = >::Commitment; +pub type Domain = >::Domain; +pub type ProverData = >::ProverData; +pub type EvaluationsOnDomain<'a> = + >::EvaluationsOnDomain<'a>; +pub type PcsError = >::Error; +pub type PcsProof = >::Proof; -/// The reference [`StarkGenericConfig`] implementation. +/// The reference [`ProofConfig`] implementation. pub struct GoldilocksBlake3Config { /// The PCS used to commit polynomials and prove opening proofs. pcs: Pcs, @@ -114,7 +116,7 @@ impl GoldilocksBlake3Config { // followed by every protocol parameter. Binding the parameters into // the seed means transcripts produced under different parameters // never collide (see the transcript contract on - // [`StarkGenericConfig::initialise_challenger`]). + // [`ProofConfig::initialise_challenger`]). let mut challenger_seed = b"multi-stark/v0".to_vec(); for parameter in [ commitment_parameters.log_blowup, @@ -140,7 +142,7 @@ impl GoldilocksBlake3Config { } } -impl StarkGenericConfig for GoldilocksBlake3Config { +impl ProofConfig for GoldilocksBlake3Config { type Pcs = Pcs; type Challenge = ExtVal; type Challenger = Challenger; @@ -219,7 +221,11 @@ fn new_pcs(commitment_parameters: CommitmentParameters, fri_parameters: FriParam mmcs, }; let dft = Dft::default(); - Pcs::new(dft, val_mmcs, inner_parameters) + Pcs::new( + InnerPcs::new(dft, val_mmcs, inner_parameters), + commitment_parameters, + fri_parameters, + ) } #[cfg(test)] diff --git a/src/verifier.rs b/src/verifier.rs index 40398a7..bf43563 100644 --- a/src/verifier.rs +++ b/src/verifier.rs @@ -159,16 +159,14 @@ //! 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::{PcsError, ProofConfig, Val}; use crate::ensure_eq; use crate::eval::VarValues; use crate::lookup::fingerprint; use crate::prover::Proof; use crate::system::System; +use crate::traits::{Algebra, EvaluationDomain, ExtensionOf, Field, Pcs, Transcript, TwoAdicField}; -use p3_challenger::{CanObserve, FieldChallenger}; -use p3_commit::{Pcs, PolynomialSpace}; -use p3_field::{BasedVectorSpace, ExtensionField, Field, PrimeCharacteristicRing, TwoAdicField}; use p3_util::log2_strict_usize; /// Errors that can occur during proof verification. @@ -191,7 +189,7 @@ pub enum VerificationError { UnbalancedChannel, } -impl System { +impl System { /// Verifies a STARK proof against a single claim. pub fn verify( &self, @@ -241,7 +239,7 @@ impl System { // ≤ N / |F_ext| (Schwartz-Zippel on the numerator polynomial). ensure_eq!( intermediate_accumulators.last(), - Some(&SC::Challenge::ZERO), + Some(&>::ZERO), VerificationError::UnbalancedChannel ); @@ -261,18 +259,18 @@ impl System { // Bind the activation bitmap — before any commitment or challenge, so // every sample depends on which circuits this proof covers. for &is_active in active { - challenger.observe(Val::::from_bool(is_active)); + challenger.observe_field(Val::::from_bool(is_active)); } // observe preprocessed and stage_1 commitment if let Some(commit) = &self.preprocessed_commit { - challenger.observe(commit.clone()); + challenger.observe_commitment(commit.clone()); } - challenger.observe(commitments.stage_1_trace.clone()); + challenger.observe_commitment(commitments.stage_1_trace.clone()); // Observe trace heights to bind the proof to specific domain sizes. for log_degree in log_degrees { - challenger.observe(Val::::from_u8(*log_degree)); + challenger.observe_field(Val::::from_u8(*log_degree)); } // Soundness: claims must be observed BEFORE lookup challenges are sampled. @@ -280,33 +278,33 @@ impl System { // challenges, breaking the lookup argument's binding property. The claims // are length-prefixed so that distinct claim structures (e.g. [[a, b]] // vs [[a], [b]]) yield distinct transcripts. - challenger.observe(Val::::from_usize(claims.len())); + challenger.observe_field(Val::::from_usize(claims.len())); for claim in claims { - challenger.observe(Val::::from_usize(claim.len())); - challenger.observe_slice(claim); + challenger.observe_field(Val::::from_usize(claim.len())); + challenger.observe_field_slice(claim); } // Soundness: lookup argument. The challenges are random elements of F_ext. // The message m_i = lookup_challenge + fingerprint(fingerprint_challenge, args_i) // is an affine function of the challenges, ensuring that distinct argument // tuples produce distinct messages with probability ≥ 1 - 1/|F_ext|. - let lookup_argument_challenge: SC::Challenge = challenger.sample_algebra_element(); - challenger.observe_algebra_element(lookup_argument_challenge); - let fingerprint_challenge: SC::Challenge = challenger.sample_algebra_element(); - challenger.observe_algebra_element(fingerprint_challenge); + let lookup_argument_challenge: SC::Challenge = challenger.sample_challenge(); + challenger.observe_challenge(lookup_argument_challenge); + let fingerprint_challenge: SC::Challenge = challenger.sample_challenge(); + challenger.observe_challenge(fingerprint_challenge); // observe stage_2 commitment - challenger.observe(commitments.stage_2_trace.clone()); + challenger.observe_commitment(commitments.stage_2_trace.clone()); // Observe the intermediate accumulators. They enter the constraints as // public values, so later challenges (α, ζ) must depend on them // directly rather than only through the quotient commitment. for acc in intermediate_accumulators { - challenger.observe_algebra_element(*acc); + challenger.observe_challenge(*acc); } // construct the accumulator from the claims - let mut acc = SC::Challenge::ZERO; + let mut acc = >::ZERO; for claim in claims { let message = lookup_argument_challenge + fingerprint(&fingerprint_challenge, claim.iter().cloned()); @@ -316,14 +314,14 @@ impl System { // Soundness: constraint folding. All k constraints are combined via powers // of α. The folded sum has degree k-1 in α, so by Schwartz-Zippel a violated // constraint survives folding with probability ≥ 1 - (k-1)/|F_ext|. - let constraint_challenge: SC::Challenge = challenger.sample_algebra_element(); + let constraint_challenge: SC::Challenge = challenger.sample_challenge(); // observe quotient commitment - challenger.observe(commitments.quotient_chunks.clone()); + challenger.observe_commitment(commitments.quotient_chunks.clone()); // Soundness: OOD evaluation. ζ is sampled after all commitments are fixed. // A nonzero polynomial of degree ≤ D vanishes at ζ with probability ≤ D/|F_ext|. - let zeta: SC::Challenge = challenger.sample_algebra_element(); + let zeta: SC::Challenge = challenger.sample_challenge(); // Reconstruct the PCS opening rounds (identical to the prover). let mut stage_1_trace_evaluations = vec![]; @@ -332,9 +330,7 @@ impl System { for pos in 0..active_indices.len() { let log_degree = log_degrees[pos]; let trace_domain = pcs.natural_domain_for_degree(1 << log_degree); - let zeta_next = trace_domain - .next_point(zeta) - .ok_or(VerificationError::InvalidProofShape)?; + let zeta_next = trace_domain.next_point(zeta); stage_1_trace_evaluations.push(( trace_domain, vec![ @@ -370,9 +366,7 @@ impl System { match active_pos[ci] { Some(pos) => { let trace_domain = pcs.natural_domain_for_degree(1 << log_degrees[pos]); - let zeta_next = trace_domain - .next_point(zeta) - .ok_or(VerificationError::InvalidProofShape)?; + let zeta_next = trace_domain.next_point(zeta); let preprocessed_opened_values = preprocessed_opened_values.as_ref().unwrap(); preprocessed_trace_evaluations.push(( @@ -416,7 +410,7 @@ impl System { // use the opened values to compute the composition polynomial for each circuit // and check that the evaluation of the composition polynomial equals the // product of the zerofier with the quotient - let extension_d = >>::DIMENSION; + let extension_d = >>::D; let empty: [SC::Challenge; 0] = []; for (pos, &ci) in active_indices.iter().enumerate() { let circuit = &self.circuits[ci]; @@ -496,11 +490,10 @@ impl System { debug_assert_eq!(constraint_values.len(), circuit.constraint_count()); // Fold with α (Horner): Σ_i α^{k-1-i} · value_i, matching the // prover's reversed α-power weighting. - let composition = constraint_values - .iter() - .fold(SC::Challenge::ZERO, |acc, &v| { - acc * constraint_challenge + v - }); + let composition = constraint_values.iter().fold( + >::ZERO, + |acc, &v| acc * constraint_challenge + v, + ); // Recombine the quotient from its coefficient slices: // `Q(ζ) = Σᵢ ζ^{i·n}·cᵢ(ζ)`, with each slice's value read from @@ -512,7 +505,9 @@ impl System { .chunks_exact(extension_d) .zip(zeta_pow_n.powers()) .map(|(chunk, zeta_pow)| zeta_pow * from_ext_basis::, SC::Challenge>(chunk)) - .sum::(); + .fold(>::ZERO, |a, b| { + a + b + }); // Soundness: OOD check. If any constraint is violated on the trace // domain, the composition polynomial is not divisible by the vanishing @@ -674,7 +669,7 @@ impl System { num_active, VerificationError::InvalidProofShape ); - let extension_d = >>::DIMENSION; + let extension_d = >>::D; for (pos, quotient_degree) in quotient_degrees.iter().enumerate() { ensure_eq!( quotient_opened_values[pos].len(), @@ -697,12 +692,14 @@ impl System { } /// Reassembles an extension element from its base coordinates. -fn from_ext_basis>(coeffs: &[EF]) -> EF { +fn from_ext_basis>(coeffs: &[EF]) -> EF { coeffs .iter() .enumerate() - .map(|(i, c)| *c * >::ith_basis_element(i).unwrap()) - .sum() + .fold(>::ZERO, |acc, (i, c)| { + let basis = EF::from_basis_coefficients_fn(|j| if j == i { F::ONE } else { F::ZERO }); + acc + *c * basis + }) } #[cfg(test)] @@ -826,6 +823,45 @@ mod tests { system.verify_multiple_claims(no_claims, &proof2).unwrap(); } + /// Byte-identity gate for the PCS-abstraction refactor: the serialized + /// proof of a fixed system + witness must not change while the core is + /// ported onto the crate-owned traits (docs/pcs-abstraction.md, Phase 0). + /// Serial builds are deterministic (the `parallel` feature is what + /// introduces run-to-run byte drift), so the pin only runs without it. + /// If a deliberate protocol change moves this hash, bump it in the same + /// commit with the reasoning — never as a refactor side effect. + #[cfg(not(feature = "parallel"))] + #[test] + fn proof_bytes_pin() { + use p3_symmetric::CryptographicHasher; + let (system, key) = system(); + let f = Val::from_u32; + let mut pythagorean_trace = [3, 4, 5].map(f).to_vec(); + let mut complex_trace = [4, 2, 3, 1, 10, 10].map(f).to_vec(); + for _ in 0..4 { + pythagorean_trace.extend(pythagorean_trace.clone()); + complex_trace.extend(complex_trace.clone()); + } + let witness = SystemWitness::from_stage_1( + vec![ + RowMajorMatrix::new(pythagorean_trace, 3), + RowMajorMatrix::new(complex_trace, 6), + ], + &system, + ); + let claim = [f(3), f(4), f(5)]; + let proof = system.prove_multiple_claims(&key, &[&claim], witness); + let bytes = proof.to_bytes().expect("serialize"); + let hash: [u8; 32] = p3_blake3::Blake3.hash_iter(bytes.iter().copied()); + let hex: String = hash.iter().map(|b| format!("{b:02x}")).collect(); + assert_eq!( + hex, + "20e5e4d4ddf9e9c16aa3e47749b3d80e638981e3bb72a4d493697a74736df8d8", + "serialized proof bytes changed ({} bytes)", + bytes.len() + ); + } + // -- Negative / adversarial tests -- /// Helper: creates a small system and valid proof for negative tests. @@ -863,7 +899,7 @@ mod tests { fn test_tampered_stage_1_values_rejected() { let (system, mut proof) = small_system_and_proof(); // Mutate a value in the stage 1 opened values — FRI should catch this. - proof.stage_1_opened_values[0][0][0] += ExtVal::ONE; + proof.stage_1_opened_values[0][0][0] += >::ONE; let no_claims: &[&[Val]] = &[]; let result = system.verify_multiple_claims(no_claims, &proof); assert!(result.is_err()); @@ -874,7 +910,7 @@ mod tests { let (system, mut proof) = small_system_and_proof(); // Set the last intermediate accumulator to non-zero. let last = proof.intermediate_accumulators.len() - 1; - proof.intermediate_accumulators[last] = ExtVal::ONE; + proof.intermediate_accumulators[last] = >::ONE; let no_claims: &[&[Val]] = &[]; let result = system.verify_multiple_claims(no_claims, &proof); assert!(result.is_err());