diff --git a/.cargo/config.toml b/.cargo/config.toml
index 92a480263..30f238b3a 100644
--- a/.cargo/config.toml
+++ b/.cargo/config.toml
@@ -6,5 +6,6 @@ docall = ["doc", "--release", "--workspace", "--no-deps"]
[build]
rustdocflags = ["-D", "warnings"]
-[target.'cfg(all())']
+# Not for the RISC-V guests (`guests/`), which inherit this file: no RISC-V target accepts the host's CPU.
+[target.'cfg(not(target_arch = "riscv64"))']
rustflags = ["-C", "target-cpu=native"]
diff --git a/.github/workflows/doc.yml b/.github/workflows/doc.yml
index 300b36174..1da1f73ed 100644
--- a/.github/workflows/doc.yml
+++ b/.github/workflows/doc.yml
@@ -5,15 +5,11 @@ on:
branches: [ "main" ]
paths:
- 'doc/leanvm/**'
- - 'doc/xmss/**'
- - 'doc/sphincs/**'
- 'doc/images/**'
- '.github/workflows/doc.yml'
pull_request:
paths:
- 'doc/leanvm/**'
- - 'doc/xmss/**'
- - 'doc/sphincs/**'
- 'doc/images/**'
- '.github/workflows/doc.yml'
workflow_dispatch:
@@ -38,22 +34,6 @@ jobs:
- name: Fail on an undefined reference or citation
run: |
! grep -qE 'Reference .* undefined|Citation .* undefined|multiply defined' doc/leanvm/.build/main.log
- - name: Compile XMSS specification
- uses: xu-cheng/latex-action@v3
- with:
- working_directory: doc/xmss
- root_file: main.tex
- - name: Fail on an undefined XMSS reference or citation
- run: |
- ! grep -qE 'Reference .* undefined|Citation .* undefined|multiply defined' doc/xmss/.build/main.log
- - name: Compile SPHINCS specification
- uses: xu-cheng/latex-action@v3
- with:
- working_directory: doc/sphincs
- root_file: main.tex
- - name: Fail on an undefined SPHINCS reference or citation
- run: |
- ! grep -qE 'Reference .* undefined|Citation .* undefined|multiply defined' doc/sphincs/.build/main.log
build-pdf:
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
@@ -67,21 +47,9 @@ jobs:
with:
working_directory: doc/leanvm
root_file: main.tex
- - name: Compile XMSS specification
- uses: xu-cheng/latex-action@v3
- with:
- working_directory: doc/xmss
- root_file: main.tex
- - name: Compile SPHINCS specification
- uses: xu-cheng/latex-action@v3
- with:
- working_directory: doc/sphincs
- root_file: main.tex
- name: Name the artifacts
run: |
cp doc/leanvm/.build/main.pdf leanVM.pdf
- cp doc/xmss/.build/main.pdf XMSS.pdf
- cp doc/sphincs/.build/main.pdf SPHINCS.pdf
- name: Publish PDFs as release assets
uses: softprops/action-gh-release@v2
with:
@@ -91,10 +59,6 @@ jobs:
Auto-built on every push to `main`.
`leanVM.pdf` contains the leanVM specification.
- `XMSS.pdf` contains the XMSS specification.
- `SPHINCS.pdf` contains the SPHINCS specification.
make_latest: false
files: |
leanVM.pdf
- XMSS.pdf
- SPHINCS.pdf
diff --git a/.github/workflows/lean.yml b/.github/workflows/lean.yml
deleted file mode 100644
index a7a1e8556..000000000
--- a/.github/workflows/lean.yml
+++ /dev/null
@@ -1,73 +0,0 @@
-name: Lean
-
-on:
- push:
- branches: [ "main" ]
- pull_request:
- workflow_dispatch:
-
-permissions:
- contents: read
-
-concurrency:
- group: lean-${{ github.ref }}
- cancel-in-progress: true
-
-jobs:
- xmss-formalization:
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v4
- # Cheap first pass: the axiom guard in `XmssSecurity.lean` catches a `sorry`
- # or a `native_decide` reaching the root theorem, this catches one parked
- # anywhere in the project.
- - name: Forbid proof escapes
- run: |
- ! grep -rnE '\b(sorry|sorryAx|admit|native_decide|unsafe|implemented_by)\b|#exit' \
- --include='*.lean' --exclude-dir=.lake formal/xmss | grep -vE ':[0-9]+: *(--|/-)'
- # Installs the toolchain from `formal/xmss/lean-toolchain`, fetches the
- # mathlib cache, and runs `lake build` on the default target, which
- # elaborates the root module and with it the `#guard_msgs` check. The
- # checked-in manifest is used as is: no `lake update`.
- - uses: leanprover/lean-action@v1
- with:
- lake-package-directory: formal/xmss
- # The root module guards its own footprint with `#guard_msgs`, which an
- # edit to the expected message would silence. This asks again from
- # outside, against the list written here.
- - name: Check the axiom footprint
- working-directory: formal/xmss
- run: |
- printf 'import XmssSecurity\n#print axioms XmssSecurity.xmss_has_127_bits_of_classical_security\n' \
- > "$RUNNER_TEMP/axioms.lean"
- lake env lean "$RUNNER_TEMP/axioms.lean" | tee "$RUNNER_TEMP/axioms.txt"
- grep -qF "'XmssSecurity.xmss_has_127_bits_of_classical_security' depends on axioms: [propext, Classical.choice, Quot.sound]" \
- "$RUNNER_TEMP/axioms.txt"
- - uses: actions/upload-artifact@v4
- with:
- name: xmss-axioms
- path: ${{ runner.temp }}/axioms.txt
-
- sphincs-formalization:
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v4
- - name: Forbid proof escapes
- run: |
- ! grep -rnE '\b(sorry|sorryAx|admit|native_decide|unsafe|implemented_by)\b|#exit' \
- --include='*.lean' --exclude-dir=.lake formal/sphincs | grep -vE ':[0-9]+: *(--|/-)'
- - uses: leanprover/lean-action@v1
- with:
- lake-package-directory: formal/sphincs
- - name: Check the axiom footprint
- working-directory: formal/sphincs
- run: |
- printf 'import SphincsSecurity\n#print axioms SphincsSecurity.sphincs_has_127_bits_of_classical_security\n' \
- > "$RUNNER_TEMP/axioms.lean"
- lake env lean "$RUNNER_TEMP/axioms.lean" | tee "$RUNNER_TEMP/axioms.txt"
- grep -qF "'SphincsSecurity.sphincs_has_127_bits_of_classical_security' depends on axioms: [propext, Classical.choice, Quot.sound]" \
- "$RUNNER_TEMP/axioms.txt"
- - uses: actions/upload-artifact@v4
- with:
- name: sphincs-axioms
- path: ${{ runner.temp }}/axioms.txt
diff --git a/.github/workflows/riscv.yml b/.github/workflows/riscv.yml
new file mode 100644
index 000000000..9c6a03a96
--- /dev/null
+++ b/.github/workflows/riscv.yml
@@ -0,0 +1,46 @@
+name: RISC-V
+
+# ACT4 (conformance/act4/): the tests are generated from their pinned sources, not checked
+# in. Generation is deterministic, so its output is cached under a hash of every input
+# and the Docker image is only built when one of them changes.
+
+on:
+ push:
+ # Pushes to the branches PRs target save the generated tests where those PRs can
+ # restore them: a PR's own cache is visible to it alone.
+ branches: [ "main", "riscv-exploration" ]
+ pull_request:
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: riscv-${{ github.ref }}
+ cancel-in-progress: true
+
+env:
+ CARGO_TERM_COLOR: always
+ RUST_BACKTRACE: 1
+ # As in rust.yml: the SIMD backend under test must not depend on the runner.
+ RUSTFLAGS: -C target-cpu=haswell
+
+jobs:
+ act4:
+ name: ACT4
+ runs-on: ubuntu-24.04
+ steps:
+ - uses: actions/checkout@v4
+ - name: Restore the generated tests
+ id: elf
+ uses: actions/cache@v4
+ with:
+ path: conformance/act4/elf
+ key: act4-elf-${{ hashFiles('conformance/act4/Dockerfile', 'conformance/act4/*.sh', 'conformance/act4/*.yaml', 'conformance/act4/*.json', 'conformance/act4/*.ld', 'conformance/act4/*.h') }}
+ - name: Generate the tests from the pinned sources
+ if: steps.elf.outputs.cache-hit != 'true'
+ run: conformance/act4/generate.sh
+ - uses: dtolnay/rust-toolchain@stable
+ - uses: Swatinem/rust-cache@v2
+ - name: Run the tests
+ run: cargo test --release -p lean_vm --test verifiers -- --ignored act4
diff --git a/.gitignore b/.gitignore
index 60e0034dd..69de9f663 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,5 @@
.build
target
__pycache__/
+formal/
+conformance/act4/elf/
diff --git a/AGENTS.md b/AGENTS.md
index 4f0e540a5..de68894f2 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -2,19 +2,10 @@
## What this is
-A minimal virtual machine and recursive SNARK for signature aggregation and blob encoding. Proofs are not zero knowledge.
+A RISC-V (rv64im) virtual machine and the SNARK that proves its execution. Proofs are not zero knowledge. Every rv64im instruction is proven, plus one custom instruction, the BLAKE2s compression (`blake2s rs1, rs2`). A program is a guest's ELF file (`guests/`, Rust built for `riscv64im-unknown-none-elf`, loaded by `rv::Guest::from_elf`) or a text assembled by hand with `lean_vm::rv::asm`; a run is proven on a public input (four words, RAM's first) and an advice (a region of memory the prover fills), and its statement is the program's digest, the input and the output (`a0..a3` at `exit`). One run is one proof: a run whose witness exceeds one commitment (`pcs::MAX_MU`) is refused up front with `Trap::TooLong`, continuations being unimplemented.
-- `doc/leanvm/` is the LaTeX project describing the machine ISA and the snark that proves it. Its root is `doc/leanvm/main.tex`; build it with `cd doc/leanvm && latexmk -pdf main.tex`, which writes to the gitignored `doc/leanvm/.build/`. Sections live in `doc/leanvm/body/`, numbered `01`..`10` plus the lettered annexes `a` (ring switching), `b` (the PCS), `c` (Flock), and `d` (novel basis and additive NTT), and every symbol is defined once in `doc/leanvm/preamble/macros.tex`. If latexmk fails oddly (a bibtex error, or a missing `main.log`) right after inputs are renamed or `refs.bib` is edited, remove `doc/leanvm/.build` and rerun; it has not reproduced on unchanged inputs. **Drafting one section:** each section file carries a `% !TeX root` comment pointing at its generated driver in `doc/leanvm/drafts/`, so the LaTeX build key (`F5`, or the extension's `cmd+alt+b`) compiles only that section, numbered as in the full document and with cross-references and citations resolved against `.build/main.aux`; in `main.tex` the same key builds everything. Run `doc/leanvm/make-drafts.sh` after adding, renaming or renumbering a section.
-- `doc/xmss/` is the standalone XMSS specification; `crates/xmss` implements its hash inputs and signature verification.
-- `doc/sphincs/` is the standalone specification of the concrete SPHINCS+ instance used where statelessness matters; its root is `doc/sphincs/main.tex`, built the same way as `doc/xmss`, and implemented by `crates/sphincs`. It uses the same BLAKE2s primitive and target-sum encoding shape as XMSS, with its own tweak layout, target sum, and signing search.
-- `formal/xmss/` and `formal/sphincs/` are Lean 4 proofs (over VCVio) of the ideal schemes' classical random-oracle security, `xmss_has_127_bits_of_classical_security` and `sphincs_has_127_bits_of_classical_security`; `formal/sphincs/` also proves correctness and completeness, `sphincs_is_correct` and `sphincs_is_complete`, stated in `SphincsSecurity/Completeness.lean`. Each project's `Scheme.lean`, under `XmssSecurity/` or `SphincsSecurity/`, contains the concrete parameters, the byte layout of every hash input, and the three algorithms; `Statement.lean` imports it and defines the SUF-CMA game, hash-query budget, and security claim. `lake exe cache get` once, then `lake build`.
-- The one hash function is BLAKE2s, in `primitives::hash`: scalar, streaming, and a lane-transposed batched form for the PCS Merkle tree. The VM proves one compression per opcode, and BLAKE2s takes the byte counter and final-block flag as ordinary compression inputs, so repeated opcodes hash arbitrary byte strings by carrying the chaining value and setting the counter and final flag for each block.
-- `crates/lean_compiler/zkDSL.md` documents the (pythonic) zkDSL (that compiles to the ISA that our VM runs, and that our snark proves).
-
-Primary uses:
-
-- Aggregate XMSS claims grouped by epoch and message, SPHINCS claims carrying individual messages, and LeanDA blob encoding claims.
-- Recursively aggregate child proofs, proving that every published signature claim and DA root is supported by a raw input or a verified child.
+- `doc/leanvm/` is the LaTeX project describing the machine ISA and the snark that proves it. Its root is `doc/leanvm/main.tex`; build it with `cd doc/leanvm && latexmk -pdf main.tex`, which writes to the gitignored `doc/leanvm/.build/`. Sections live in `doc/leanvm/body/`, numbered `01`..`08` plus the lettered annexes `a` (ring switching), `b` (the PCS), `c` (Flock), and `d` (novel basis and additive NTT), and every symbol is defined once in `doc/leanvm/preamble/macros.tex`. If latexmk fails oddly (a bibtex error, or a missing `main.log`) right after inputs are renamed or `refs.bib` is edited, remove `doc/leanvm/.build` and rerun; it has not reproduced on unchanged inputs. **Drafting one section:** each section file carries a `% !TeX root` comment pointing at its generated driver in `doc/leanvm/drafts/`, so the LaTeX build key (`F5`, or the extension's `cmd+alt+b`) compiles only that section, numbered as in the full document and with cross-references and citations resolved against `.build/main.aux`; in `main.tex` the same key builds everything. Run `doc/leanvm/make-drafts.sh` after adding, renaming or renumbering a section.
+- The one hash function is BLAKE2s, in `primitives::hash`: scalar, streaming, and a lane-transposed batched form for the PCS Merkle tree. The VM's compression instruction is the generic gate-list circuit `rv::circuits::blake2s`, proven like every other class; `flock::hash` is the hand-optimized circuit of the same function with its own witness kernels, kept as flock's throughput benchmark and used by nothing else. Moving the precompile onto it is the known speed-up if hashing ever dominates a workload.
## Layout
@@ -27,21 +18,20 @@ Dependency order, leaves first:
| `primitives` | field kernels (NEON/AVX), bit transposes, multilinear helpers, streaming stores, `bench` |
| `fiat_shamir` | VM-native `FiatShamirState` + prover/verifier transcript |
| `pcs` | additive NTT, Merkle, ring switch, stacked WHIR |
-| `flock` | batched R1CS over GF(2) for BLAKE2s: zerocheck + lincheck |
-| `lean_vm` | arithmetization: tables, bus, constraints, `cpu::prove`/`verify` |
-| `lean_compiler` | zkDSL (Python subset) → ISA |
-| `xmss` | XMSS over BLAKE2s; an independent leaf, consumed only by `rec_aggregation` |
-| `sphincs` | the stateless SPHINCS+ instance of `doc/sphincs`; an independent leaf, consumed only by `rec_aggregation` |
-| `lean_da` | additive Reed-Solomon blob encoding, commitments, and membership vectors |
-| `rec_aggregation` | recursive signature and DA aggregation: the guest, public entry points, and benchmarks |
+| `flock` | batched R1CS over GF(2): zerocheck + lincheck; gate-list circuits over word ports (`circuit`), the u64 adder and multiplier in them (`arith`), the BLAKE2s circuit (`hash`) |
+| `lean_vm` | the RISC-V machine (`rv`: decoder, class semantics and circuits, interpreter, assembler, ELF loader) and its arithmetization: tables, bus, constraints, `cpu::prove`/`verify` |
+
+`src/lib.rs` is the public API and the only thing a user imports: every crate above is `publish = false`, so a new user-facing item is a re-export there. `src/main.rs` is the CLI (`fibonacci`, the benchmark, and `guest --input a,b,c,d --advice ...`), `tests/api.rs` the end-to-end use of the API.
-`src/lib.rs` is the public API and the only thing a user imports: every crate above is `publish = false`, so a new user-facing item is a re-export there. `src/main.rs` is the benchmark CLI, `tests/api.rs` the end-to-end use of the API; guests are zkDSL under `crates/rec_aggregation/guests/`.
+`guests/` is a separate cargo workspace (its own toolchain file, nightly with `rust-src`, and `.cargo/config.toml` targeting `riscv64im-unknown-none-elf` with `-Zbuild-std=core`): the runtime crate `rt` (`_start`, `input()`, `advice()`, `output()`, a `Blake2s` hasher over the custom instruction through `.insn r`, a panic that is `unimp`), the guests, `link.ld` fixing the memory map, and `build.sh`, which refreshes the checked-in ELF fixtures in `guests/elf/` that `lean_vm/tests/verifiers/guests.rs` loads. **Rerun `build.sh` after touching a guest or the runtime**: nothing rebuilds the fixtures, so CI would keep testing the old ELF and say nothing. The nightly channel floats, so the fixtures are not reproducible byte for byte across toolchain updates, which is why they are not diffed in CI. The root `.cargo/config.toml` scopes `target-cpu=native` to `cfg(not(target_arch = "riscv64"))` because the guests inherit it. Atomics are why the target is `im` and not `imac`: the builtin target has no compare-and-swap, so a dependency needing one does not compile.
+
+`conformance/act4/` holds the official RISC-V architectural tests ([ACT4](https://github.com/riscv/riscv-arch-test), riscv-arch-test 4.1.0 at `6e8a4512`), their `I` and `M` suites, 64 tests, for an `rv64im` profile with no misaligned access. ACT4 runs each test on the Sail reference model and builds it again with Sail's results inside, so that the ELF file checks itself. The configuration is `test_config.yaml`, `leanvm-rv64im.yaml` (for the unified database, UDB, which defines `MXLEN` through `Sm` and so needs `Zicsr` claimed too: `rvmodel_macros.h` leaves `STANDARD_SM_SUPPORTED` undefined, so no test touches a CSR), `sail.json` (Sail's regions: leanVM's text and RAM), `link.ld` (the code in the text, everything else in RAM past the input words, RAM the smallest power of two holding the image) and `rvmodel_macros.h` (a test ends in `exit` with the output zero on a pass, `a0 = 1` and `a1` the caller when the framework halts on a failure). A failing check's handler reads the check back from the text, which leanVM cannot read, so it traps there, and `lean_vm/tests/verifiers/act4.rs` reports the check's description, the value computed and Sail's. `generate.sh` builds the Docker image of `Dockerfile` (Ubuntu 24.04 by digest, riscv-gnu-toolchain 2025.08.08 with GCC 15.1, Sail 0.13.1 and mise 2026.9.17 by checksum, the Ruby and Python tools locked by riscv-arch-test itself) and generates `elf/I` and `elf/M` byte for byte: it strips the symbol naming the compiler's temporary object file, which is random, and clears the compressed-instructions flag that ACT4's alignment directives set though nothing is compressed. **The ELF files are not checked in** (`elf/` is ignored), so `act4.rs`'s two tests are `#[ignore]`d: run `conformance/act4/generate.sh` once (Docker), then `cargo test --release -p lean_vm --test verifiers -- --ignored act4`. CI (`.github/workflows/riscv.yml`) runs them on every PR, caching the generated files under a hash of every file in `conformance/act4/`, so the image is only built when one of them changes. Left out by design: `Misalign` (a misaligned access traps, and the configuration says so), `Zicsr` (no CSRs), `Zifencei` (no self-modifying code), and `Zmmul`, whose tests are M's multiplication tests again. `act4.rs` runs every test on the interpreter, proves each and checks the proof with the Rust verifier, and with the Python verifier the first test to reach each table.
## Building / Testing / Formatting
- `.cargo/config.toml` pins `-C target-cpu=native` and `-D warnings` for rustdoc
-- always run in `--release` mode any test or benchmark touching the VM (the zkDSL compiler stack-overflows in `debug` mode)
-- **One test binary per crate, not one per file:** new `lean_compiler` integration tests go in `tests/suite/main.rs`, one linked executable instead of seventeen. Exception: a test opening an arena phase (`lean_vm::init_prover`) needs its own binary. Phases are process-global, so two in one process reclaim each other's `ArenaVec`s and the symptom is a proof that stops verifying, never a crash (`rec_aggregation/tests/arena_prove.rs`).
+- always run in `--release` mode any test or benchmark touching the VM
+- **One test binary per crate, not one per file** (`lean_vm/tests/verifiers/main.rs`). Exception: a test opening an arena phase (`lean_vm::init_prover`) needs its own binary. Phases are process-global, so two in one process reclaim each other's `ArenaVec`s and the symptom is a proof that stops verifying, never a crash (`tests/api.rs` is that binary, and `tests/no_arena.rs` the one that must never enable the arena).
An x86-only arm never compiles on an Apple dev machine, so a typo in one ships. Type-check the other target before pushing anything `cfg`-gated:
@@ -60,17 +50,54 @@ cargo fmt --all # max_width = 120
ruff format --line-length 150 python-verifier/verifier.py # and `ruff check` it
```
-Heavy benches and measurement harnesses are `#[ignore]`d; run by name with `-- --ignored --nocapture`: `hash_batch_prove_verify`, `pcs_throughput`, `aggregate_three_levels`, `aggregate_statement_binds`, `aggregate_hints_bind`, `aggregate_rejects_a_bad_signature`, `print_whir_query_counts`, `encoding_grinding_bits`.
+Heavy benches and measurement harnesses are `#[ignore]`d; run by name with `-- --ignored --nocapture`: `hash_batch_prove_verify`, `add_wrapping_prove_verify`, `mul_wrapping_prove_verify`, `mul_widening_prove_verify`, `pcs_throughput`, `multithreaded_throughput`, `print_whir_query_counts`, `print_whir_query_table`.
## Benchmarking
The benchmarks we care about:
-- `cargo run --release -- aggregate --xmss 900 --log-inv-rate 1 --repeat 3`
-- `cargo run --release -- aggregate --sphincs 220 --log-inv-rate 1 --repeat 3`
-- `cargo run --release -- recursion --n 2 --xmss-per-leaf 900 --log-inv-rate 2 --repeat 3`
+- `cargo run --release -- fibonacci --n 2000000 --log-inv-rate 1 --repeat 3` (Fibonacci mod 2^64, on registers)
+- `cargo run --release -- guest guests/elf/hash.elf --input 50000 --repeat 3` (the precompile, from a Rust guest)
+- `BENCH_REPEAT=3 FLOCK_N_LOG=18 cargo test --release -p flock --test batch_proving_hashes -- hash_batch_prove_verify --exact --nocapture --include-ignored` (flock alone, on its hand-optimized circuit)
+
+## Read-write arrays
+
+The registers, RAM and the advice are read-write, by timestamped offline memory checking (`doc/leanvm` §sec:memchan). What to keep in mind before touching it:
+
+- **The clock rides the state tuple**, `(pc, ts)`, and advances by the row's stride (`ClassSpec::stride`: 4, or 18 for a hash row): a row's access in slot `k` carries the timestamp `g^k·ts`, which is what lets one row touch the same cell twice (`add a0, a0, a0`). Slots are `rs1` at 0, `rs2` at 1, the RAM access at 2, `rd` at 3; a hash row has no `rd` write and its sixteen block words take slots 2 to 17. The run starts at cycle 1, because a seed is stamped `g^0` and an access must be strictly later than the one before.
+- **Strictness is the soundness.** An access pulls `(addr, prev, old)` and pushes `(addr, g^k·ts, new)`, with `prev·lo = g^k·ts·hi`, where `lo` and `hi` are read off two uncommitted range arrays (`{g^(j+1)}` and `{g^(-2^16·j)}`, 2^16 entries each). The `+1` in the low array is the strict `<`: with a gap of zero a read pulls the tuple it pushes and returns anything.
+- **Padding rows have clock zero**, and zero is no power of `g`: their state tuples close around a fill block (`0·g^s = 0`), their accesses are forced to `prev = 0` and cancel themselves, and nothing they flush can meet a tuple of the run. They are written out by `cpu::execute`, not executed, and touch no memory. Any new table has to keep this true: every memory tuple's timestamp must be `g^k·ts` or the committed `prev`, nothing else. **The verifier's notion of a padding row is `ts = 0` and nothing else**, so a prover may close its zero-clock rows around any cycle of the program's own control flow rather than a fill block; that is inert too, and the fill blocks exist to make the fill exact, not to make it safe. What makes `prev = 0` is the gap check against the range arrays, whose entries are all nonzero, so a range array that ever held a zero would break this.
+- **Two committed columns per array**: what it holds after the run, and each cell's last timestamp. What it holds before is public for the registers (zero) and RAM (`Coord::Sparse`: the input, the image, zeros, evaluated in time proportional to the image), and a third committed column for the advice (`ADV_INIT`), which is the prover's. RAM and the advice share `SEP_MEM`; they never share an address, every region's base being a multiple of its largest size (`rv::TEXT_BASE`, `rv::ADVICE_BASE`, `rv::RAM_BASE`), so word `z` of a region sits at `base ^ (z << 3)` and the seed block's address is the free `Coord::IntIndex`.
+- **A padding row rewrites what it writes**, its `old` column set to its `new` (the register write's `vd_old`, the hash's `out_old`), which is why a row can never update a cell in place: the hash reads `h` and writes `out` in different words, since no chaining value is a fixed point of the compression.
+- **A gap is below 2^32**, and a cell's first access is measured from zero, so a run is capped near 2^30 cycles. The executor asserts it.
+- `a_stale_read_unbalances_the_bus` is the soundness regression test (a forged run that serves an overwritten register), `a_forged_load_unbalances_the_bus` its RAM counterpart, and `leaf::unmatched_leaves` (test-only) names the tuples a forged run leaves unmatched, which says more than a failing proof. `lean_vm/tests/verifiers/programs.rs` holds the hand-assembled programs checked by both verifiers, `guests.rs` the Rust guests.
+
+## The RISC-V machine
+
+`lean_vm::rv` is the machine, `lean_vm::tables` and `lean_vm::cpu` prove it. What to keep in mind:
-`aggregate` takes a count per scheme, both defaulting to zero, so either alone or a mix of the two is one command; `recursion --sphincs-per-leaf` likewise puts both schemes in one tree. One SPHINCS verification uses 531 compressions against XMSS's 144; use the benchmark output to compare complete VM cycle counts. `aggregate --blobs` adds LeanDA blobs, and `recursion --blobs-per-leaf` includes them in each child.
+- **The program is public, so decoding is free.** `rv::decode` turns each word into an `Entry` once: an instruction class, a `flags` word selecting what the class's one function does, the three register cells the row touches, the immediate already sign-extended, the branch target, and the `link` and `jalr` selectors. `LUI`, `AUIPC` and `JAL` fold to constants, `ECALL` is a jump to the halt slot, and everything rv64im does not define (reserved shift encodings, `EBREAK`, CSRs) is an illegal entry. The bytecode lookup returns those fields; no table ever decomposes an instruction word. An entry whose class has no table has tag zero, which no row can read.
+- **The statement is about the decoded table**, so the rules that make it RISC-V are checked where a table enters, on both sides (`rv::Entry::is_well_formed` in `Program::new`, `check_bytecode` in Python): two registers below 32 are read, the cell written is in `1..=32`, the successor is `pc + 4`, the flags are ones the class defines. The proof system itself is sound for any table.
+- **`x0` is hardwired by the decoder.** Every row reads two registers and writes one. An instruction with fewer reads `x0`; one with no destination, or with `rd = x0`, writes `SINK` (cell 32), which nothing reads. So cell 0 is never written, and its seed is zero.
+- **Registers are a read-write array of their own**, under their own separator `REG`, so that loads and stores cannot reach them: 64 cells, a public zero seed, committed final values and timestamps, the same clock, gap check and range arrays as memory. A register's number comes straight from the bytecode, so an access needs no address arithmetic.
+- **No integer addition happens on the bus.** A load's or a store's address is its circuit's word, with the misalignment bits ORed back in (`semantics::bus_address`), so a misaligned or out-of-range access names no seeded cell and the bus does not balance; a hash row's block words are at `v1 ^ 8k`, which is `Coord::Sum(Col(v1), Const(8k))`, the XOR being the sum in `K`. The `EXP` lookup of the leanISA days is gone.
+- **State is `(pc, ts)`**, `pc` the real byte address. Instruction `z` sits at `TEXT_BASE ^ (z << 2)` and the bytecode block's address coordinate is `Coord::IntIndex { base, shift }`, whose MLE is linear. Everything sits inside one 2 GiB window and below `0x7FFF_F800` for the code models' sake. A computed or misaligned jump target needs no check: the next row's bytecode read finds no entry.
+- **A trap is the absence of a proof** (`rv::Trap`): an illegal or unmapped `pc`, a misaligned or unmapped access, an `ecall` that is not `exit`, the cycle cap, and `TooLong`, a run that would not fit one proof. An illegal word follows the text, so a run falling off it traps instead of sliding into the padding blocks or the halt slot.
+- **The halt is an exit.** The run ends on the last slot of the padded text, which is never executed. The verifier claims `a7 = 93` and `a0..a3 = output` on the committed final registers at the Boolean points naming them; the output seeds the transcript with the program's digest, which covers the decoded table, the entry and halt `pc`, RAM's and the advice's sizes and the image. Nothing the program fixes is read from the prover; the Python verifier gets the same things through `public.bin`.
+- **The precompile is a class like the others** (`Class::Hash`, table `HASH`): `blake2s rs1, rs2` (custom-0 opcode `0x0b`, `funct3 = 1` on the final block, `rd = funct7 = 0`) compresses the 128-byte block at `rs1` (`h` in words 0..4, the result written to 4..8, the message in 8..16, `rv::hash`), with the counter in `rs2` and the finalization word in the bytecode's flags; a base that is no word address traps, an unaligned one permutes the words deterministically (a guest bug, not a forgery). Its row has no `rd`, `imm` or `out` columns (constants `SINK` and 0 in its bytecode tuple), eighteen accesses and a stride of 18.
+- **The interpreter is the reference** (`rv::Machine`), tested against an executor written from the specification on byte-addressed memory that shares no code with it, and against the RISC-V architectural tests' `I` and `M` suites (ACT4, `conformance/act4/`). `cpu::execute` is that interpreter plus the memory argument's bookkeeping.
+
+## Instruction classes and their circuits
+
+Addition with carries, comparisons, shifts, AND/OR, multiplication and division are Boolean relations no degree-2 identity over `K` expresses, so each instruction class is a Boolean circuit proven by flock (`doc/leanvm` Annex C), and a table only does plumbing:
+
+- **One generic table, specialized by a `tables::ClassSpec`**: the state step, the bytecode read, two register reads, one register write (unless `Ram::Block`), and the class's RAM accesses (`Ram::None`, one cell `Read` or `Write`n at the circuit's address, or the hash's `Block`), with `npc = pc4 + taken·dt + jalr·(out + pc4)` and `rd <- out + link·(out + pc4)` as degree-2 bus forms for a class with control flow. A new class is a spec, a circuit in `rv::circuits`, its reference function in `rv::semantics`, a no-op word in `cpu::filler`, its decoding, and the same in Python (`Table(...)` in `TABLES`, its gate list, its flags in `check_bytecode`).
+- **Every word the circuit reads or writes is a virtual column** of the table, living in the class's packed witness (`Q_BASE + t`, one committed column per table, instance `j` being row `j`): `flags` and `imm` from the bytecode tuple, `v1` and `v2` from the register tuples, a load's `address` and `cell`, a hash's block words, `out`, `taken`. The bus is the whole binding. `class_flock::Prepared::build` asserts that what the circuit computed is what the interpreter did. A hint (`DIV`'s quotient and remainder) is a port in no column, and what a circuit asserts (`Word::Bad`) rides bytecode slot 13, where the program is zero.
+- **Circuits are gate lists over word ports** (`flock::circuit`): inputs, outputs, the constant, then products in the order they are made. A port bit with no gate is an empty row, hence zero, which is what makes a one-bit output such as `taken` a 0 or 1 field element: give such an output a word to itself and never put a free wire on its spare bits. What Rust and Python must agree on is the port layout and the ORDER PRODUCTS ARE MADE IN; XOR order is free. `alu_is_its_reference` pins a circuit to its reference function, and the end-to-end tests pin the Python mirror.
+- **One reduction per class, one opening for all**: zerocheck plus lincheck per table, in table order after the exit claims, each leaving a claim on its own witness; `pcs::stack_open` takes one ring-switched region per witness, all under one map challenge.
+- **Batch floors.** Flock needs eight instances and a zerocheck cube of `2^13` bits (`class_flock::n_blocks_log`); padding rows supply them, as honest instances on zero registers.
+- **Witness generation is the generic walk of the gate list, bit by bit**, and is the prover's largest single stage. A word-arithmetic or bit-sliced generator per circuit is a known follow-up, the hash's `flock::hash` kernels being the model.
+- **Circuit sizes are structure, not measurements**: `k_log` per class is pinned in its `ClassSpec` and asserted against the built circuit; product counts are in `doc/leanvm` Annex C.
## The proving arena (`zk_alloc`)
@@ -90,20 +117,12 @@ No rayon. Every parallel site is "N independent items, each writing its own disj
`LEANVM_NUM_THREADS` sets the **performance**-worker count, leaving E-workers in place. `1` = strictly sequential.
-## Three verifiers, one protocol
+## Two verifiers, one protocol
-The same verification algorithm is written out three times, in three languages. Any change to snark protocol has to land in all three.
+The same verification algorithm is written out twice, in two languages. Any change to the snark protocol has to land in both.
1. **Rust**, `lean_vm::cpu::verify`. The native verifier.
-2. **Python**, `python-verifier/verifier.py` (no dependencies), for readability and simplicity. Pinned by `lean_vm/tests/verifiers/python_verifier.rs`.
-3. **Recursive verifier**, `crates/rec_aggregation/guests/lean_ethereum.py`. Its zkDSL compiles to the ISA; proving its execution gives a proof of child proofs.
-
-Understand the third before changing the verifier. `guests/lean_ethereum.py` is zkDSL, not runnable Python. `lean_compiler` lowers it to the six-opcode, write-once-memory VM, so the prover proves every verifier step. Its size and instruction mix are what the recursion benchmark reports first. It verifies raw signatures of both schemes: a node's coverage table has one contiguous region per XMSS `(epoch, message)` group and separate regions for SPHINCS and DA roots, so the one range check a write already needs also keeps a signature off another group's declared keys, of either scheme, and the statement's signer lists say which scheme verified which key against which `(epoch, message)`. The XMSS signers are grouped by `(epoch, message)`, so one epoch signed at under several messages is one group per message, a runtime number of groups (at most `MAX_EPOCHS`) bound through the signer-set digest, which is plain BLAKE2s of a byte string (each list's own digest folded into it, likewise plain BLAKE2s): a run-time length rides the byte counter because the counter is a memory operand, split as `doc/leanvm` §sec:prog-byte-counter describes. A child's groups need not equal its parent's, a hinted map tying each child group to a parent group with the same epoch and message. A SPHINCS signer's message rides its own four-cell slot, so that list is `(key, message)` pairs; both lists count claims rather than distinct signers, an XMSS key claiming once per `(epoch, message)` it signed. Both schemes' tweaks are built in-circuit: XMSS's from the epochs the statement carries, derived once per group that verifies raw XMSS signatures and skipped by one that verifies none, SPHINCS's per signature from the index its message digest picks. Two consequences:
-
-- The guest is **self-referential**: it verifies proofs of itself, so `unified_guest` compiles it to a fixed point on its own log size. The digest needs no fixed point, riding the statement instead of the code, which is also what lets one bytecode serve any inner size and PCS rate.
-- It does not verify *quite* everything in-circuit. Three claims on fixed polynomials (stacked bytecode, flock's A0/B0) are deferred. Each node batches its children's carried claims with the fresh ones its verifications raise, `2n` per polynomial down to one; only the root's are discharged natively, by `EthereumProof::verify` (explained in `doc/leanvm/`).
-
-`aggregate_two_to_one` is the fast end-to-end check; `aggregate_statement_binds` and `aggregate_hints_bind` are the adversarial ones, tampering the wire object and the witness respectively. A child must commit at least `2^MU_MIN` or the guest has no opening arm for it, so `aggregate` sets `Program::min_log_committed` and a smaller run grows its `SET` table through the fill blocks until it clears the floor.
+2. **Python**, `python-verifier/verifier.py` (no dependencies), for readability and simplicity. Pinned by `lean_vm/tests/verifiers/python_verifier.rs`, which feeds it the raw proof `cpu::verify_to_raw` returns.
## Conventions that bite
@@ -118,33 +137,26 @@ Understand the third before changing the verifier. `guests/lean_ethereum.py` is
- Simpler is better.
- **Fiat-Shamir:** `add_scalar`/`next_scalar` bind into the Fiat-Shamir state as a side effect. The public statement seeds the transcript at construction; the transport exposes no separate observe operation. Never re-observe data that rode the stream, which silently desynchronizes the two sides.
- **Prover and verifier derive the layout identically** from announced sizes. Changes to `placements_of` or the schema land on both sides. `col_kappas` is derived from `col_kappa_sources` rather than written out twice, so the two can no longer drift; keep it that way.
-- **The L0 lane fold binds the committed witness's TOP `INITIAL_FOLDING_FACTOR` variables**, because lane `l` of the interleaved commitment is the stack block `q[l·2^(μ-k) ..)`. That makes the witness's zero tail whole lanes, so `whir::commit` encodes only `StackShape::n_lanes` of them, and the opening's dense weight, its first `k` sumcheck rounds and the stack allocation shrink with it. **A leaf image is still `2^k` words**, the absent lanes contributing their codeword's zeros, but those zeros LEAD it (codeword lane `t` is stack block `n_lanes-1-t`): their whole 64-byte blocks are then one shared chaining value (`hash::zero_prefix_state`) the committer hashes once rather than per leaf, and only the image's tail rides the proof, so `PrunedMerklePaths` stores `n_lanes` words per L0 row while `RawMerklePath` (what the guest and the Python verifier read) carries the full image. The Rust and Python verifiers therefore derive `n_lanes` from the announced layout to read a row; the guest never needs it, its hints being full images. Since `mu = log2_ceil(placed)`, `n_lanes` is always in `[2^(k-1)+1, 2^k]`: the encode saving caps near half, the hashing saving is quantized to whole blocks of 8 lanes, and both are ~0 just above a power of two. The cost is that fold challenges arrive in round order while every transparent weight is written in witness coordinates, so all three verifiers rotate the terminal point left by `k` before evaluating it (`whir.rs` before `eval_b_at`, `verifier.py` before `evaluate_basis`, `open_stacked` in the guest). Anything else that reads the opening's point (per-level induced weights, the residual) stays in round order.
-- **A failed guest `assert` surfaces as a write-once memory conflict**, not an assertion message, but it names the function and source line: `write-once conflict at cell 34: had ..., new ... at pc ... (in verify_sub (line 2906))`, as does every other `ExecError` (a failed range check, a wild `DEREF`). A conflict that `had 0:0:0` can instead be an ordering bug: an instruction read the cell, unwritten, before this write. Parse and lowering errors carry a line too. Reach for `DBG_DISASM` only when the line is not enough, or when the pc lands in a fill block, which has no source line by construction.
-- Guests are single-file; the compiler skips `from snark_lib import *`, which exists only so editors accept the file as Python.
-- **One symbol, one meaning, across the whole leanVM document.** All notation is defined in `doc/leanvm/preamble/macros.tex`: define a new macro there rather than inline, and check the letter is free first. Annex B's "Symbols" table maps its letters back to WHIR/Ligerito/BCHKS25, so read it before renaming one. A sumcheck round challenge is `\fc` everywhere, which is what keeps `\rho` free for the rate; `r` is the point a claim is made at, not a challenge. **A rename in the document is a rename in the implementations**: the Rust prover and verifier, `python-verifier/verifier.py`, and `guests/lean_ethereum.py` name their variables after the document's symbols, so the four have to move together.
+- **The L0 lane fold binds the committed witness's TOP `INITIAL_FOLDING_FACTOR` variables**, because lane `l` of the interleaved commitment is the stack block `q[l·2^(μ-k) ..)`. That makes the witness's zero tail whole lanes, so `whir::commit` encodes only `StackShape::n_lanes` of them, and the opening's dense weight, its first `k` sumcheck rounds and the stack allocation shrink with it. **A leaf image is still `2^k` words**, the absent lanes contributing their codeword's zeros, but those zeros LEAD it (codeword lane `t` is stack block `n_lanes-1-t`): their whole 64-byte blocks are then one shared chaining value (`hash::zero_prefix_state`) the committer hashes once rather than per leaf, and only the image's tail rides the proof, so `PrunedMerklePaths` stores `n_lanes` words per L0 row while `RawMerklePath` (what the Python verifier reads) carries the full image. Both verifiers therefore derive `n_lanes` from the announced layout to read a row. Since `mu = log2_ceil(placed)`, `n_lanes` is always in `[2^(k-1)+1, 2^k]`: the encode saving caps near half, the hashing saving is quantized to whole blocks of 8 lanes, and both are ~0 just above a power of two. The cost is that fold challenges arrive in round order while every transparent weight is written in witness coordinates, so both verifiers rotate the terminal point left by `k` before evaluating it (`whir.rs` before `eval_b_at`, `verifier.py` before `evaluate_basis`). Anything else that reads the opening's point (per-level induced weights, the residual) stays in round order.
+- **One symbol, one meaning, across the whole leanVM document.** All notation is defined in `doc/leanvm/preamble/macros.tex`: define a new macro there rather than inline, and check the letter is free first. Annex B's "Symbols" table maps its letters back to WHIR/Ligerito/BCHKS25, so read it before renaming one. A sumcheck round challenge is `\fc` everywhere, which is what keeps `\rho` free for the rate; `r` is the point a claim is made at, not a challenge. **A rename in the document is a rename in the implementations**: the Rust prover and verifier and `python-verifier/verifier.py` name their variables after the document's symbols, so the three have to move together.
- **Doc labels are an API.** `crates/pcs` cites `thm:rbr` and `thm:mca-johnson` by name and several crates cite `doc/leanvm/main.tex` sections, so renaming a label breaks those pointers with nothing to catch it. `doc/leanvm/body/NN-*.tex` prefixes match section numbers, so inserting a section renumbers the rest.
- **No em-dashes or en-dashes in prose**, anywhere a human reads it: docs, LaTeX, comments, commit messages. Restructure with a comma, colon, parentheses, or two sentences.
- **Never hard-wrap prose in Markdown or LaTeX.** One paragraph is one line; let the editor wrap it. Artificial line breaks make every later edit a reflow, so diffs show rewrapped lines instead of changed words. Applies to `.md` and `.tex` alike; code blocks, tables and list items keep their own line.
-## Soundness
-
-- In the recursion program, the prover transmits advice to the verifier, called "hints". Hints are untrusted witness data and must be checked by the verifier; a malicious prover must not be able to prove an invalid witness.
-
## Env knobs
| var | effect |
| ------------------------------------------------------------------------------------------------------- | ------------------------------------------------ |
| `LEANVM_NUM_THREADS` | performance-worker count; `1` = sequential |
| `LEANVM_PROFILE` | per-stage prover timings |
+| `LEANVM_ACT4` | directory of generated ACT4 ELF files (`I/`, `M/`) to test instead of `conformance/act4/elf/` |
| `ZK_ALLOC_STATS` | arena peak/phase, high water, overflow |
| `ZK_ALLOC_POISON` | fill released arena blocks, to catch use-after-free |
| `BENCH_REPEAT`, `BENCH_COOLDOWN` | `--repeat`/`--cooldown` for `#[ignore]`d benches |
-| `LEANVM_XMSS_N`, `LEANVM_HASH_N`, `LEANVM_HASH_UNROLL` | workload sizes in tests |
| `FLOCK_N_LOG`, `FLOCK_PROVE_TRACE`, `FLOCK_ZC_TIMING`, `LINCHECK_TRACE` | flock batch size, stage traces |
-| `PCS_LOG_N`, `PCS_LOG_INV_RATE`, `PCS_MIN_MU`, `PCS_SAMPLES` | PCS throughput bench |
+| `PCS_LOG_N`, `PCS_LOG_INV_RATE`, `PCS_SAMPLES` | PCS throughput bench |
| `WHIR_TRACE`, `WHIR_NUM_VARS`, `WHIR_LOG_INV_RATE` | WHIR NTT/Merkle split |
-| `DBG_PROF{,_DUMP}`, `DBG_LOOPS`, `DBG_DISASM`, `DBG_LOWER`, `DBG_PLACEHOLDERS` | compiler / guest-cycle attribution |
## Side notes
-- Grinding chooses the smallest valid nonce, including in parallel. Randomized signature inputs can still make proofs differ between runs.
+- Grinding chooses the smallest valid nonce, including in parallel.
diff --git a/Cargo.lock b/Cargo.lock
index 42d9dc829..f9803310c 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -11,54 +11,6 @@ dependencies = [
"memchr",
]
-[[package]]
-name = "alloy-primitives"
-version = "1.7.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ce7b00f0cb42c66ec353076ded1dff1fbf818f6e0e26c40c8a8456c04483fca4"
-dependencies = [
- "alloy-rlp",
- "bytes",
- "cfg-if",
- "const-hex",
- "derive_more",
- "fixed-cache",
- "foldhash",
- "hashbrown 0.17.1",
- "indexmap 2.14.1",
- "itoa",
- "k256",
- "keccak-asm",
- "paste",
- "proptest",
- "rand 0.9.4",
- "rapidhash",
- "ruint",
- "rustc-hash",
- "secp256k1",
- "serde",
- "sha3",
-]
-
-[[package]]
-name = "alloy-rlp"
-version = "0.3.16"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "24671b1f62edcf0f9b62994c7bf72cd621a04a4b99f5020ece1a647b40e2f103"
-dependencies = [
- "arrayvec",
- "bytes",
-]
-
-[[package]]
-name = "android_system_properties"
-version = "0.1.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc"
-dependencies = [
- "libc",
-]
-
[[package]]
name = "ansi_term"
version = "0.12.1"
@@ -119,2087 +71,353 @@ dependencies = [
]
[[package]]
-name = "ark-ff"
-version = "0.3.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6b3235cc41ee7a12aaaf2c575a2ad7b46713a8a50bda2fc3b003a04845c05dd6"
-dependencies = [
- "ark-ff-asm 0.3.0",
- "ark-ff-macros 0.3.0",
- "ark-serialize 0.3.0",
- "ark-std 0.3.0",
- "derivative",
- "num-bigint",
- "num-traits",
- "paste",
- "rustc_version 0.3.3",
- "zeroize",
-]
-
-[[package]]
-name = "ark-ff"
-version = "0.4.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ec847af850f44ad29048935519032c33da8aa03340876d351dfab5660d2966ba"
-dependencies = [
- "ark-ff-asm 0.4.2",
- "ark-ff-macros 0.4.2",
- "ark-serialize 0.4.2",
- "ark-std 0.4.0",
- "derivative",
- "digest 0.10.7",
- "itertools 0.10.5",
- "num-bigint",
- "num-traits",
- "paste",
- "rustc_version 0.4.1",
- "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 0.5.0",
- "ark-ff-macros 0.5.0",
- "ark-serialize 0.5.0",
- "ark-std 0.5.0",
- "arrayvec",
- "digest 0.10.7",
- "educe",
- "itertools 0.13.0",
- "num-bigint",
- "num-traits",
- "paste",
- "zeroize",
-]
-
-[[package]]
-name = "ark-ff"
-version = "0.6.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f7a806ac6c8307b929df4645776290a50ee2aac754ad09d8bdf73391309e43af"
-dependencies = [
- "ark-ff-asm 0.6.0",
- "ark-ff-macros 0.6.0",
- "ark-serialize 0.6.0",
- "ark-std 0.6.0",
- "digest 0.10.7",
- "educe",
- "num-bigint",
- "num-traits",
- "zeroize",
-]
-
-[[package]]
-name = "ark-ff-asm"
-version = "0.3.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "db02d390bf6643fb404d3d22d31aee1c4bc4459600aef9113833d17e786c6e44"
-dependencies = [
- "quote",
- "syn 1.0.109",
-]
-
-[[package]]
-name = "ark-ff-asm"
-version = "0.4.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3ed4aa4fe255d0bc6d79373f7e31d2ea147bcf486cba1be5ba7ea85abdb92348"
-dependencies = [
- "quote",
- "syn 1.0.109",
-]
-
-[[package]]
-name = "ark-ff-asm"
-version = "0.5.0"
+name = "bincode"
+version = "1.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "62945a2f7e6de02a31fe400aa489f0e0f5b2502e69f95f853adb82a96c7a6b60"
+checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad"
dependencies = [
- "quote",
- "syn 2.0.118",
+ "serde",
]
[[package]]
-name = "ark-ff-asm"
-version = "0.6.0"
+name = "cfg-if"
+version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1479009684adc073dff49a1025d3a7065b317a9ead25aaaca38cdc70058ba8a2"
-dependencies = [
- "quote",
- "syn 2.0.118",
-]
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
-name = "ark-ff-macros"
-version = "0.3.0"
+name = "clap"
+version = "4.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "db2fd794a08ccb318058009eefdf15bcaaaaf6f8161eb3345f907222bac38b20"
+checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51"
dependencies = [
- "num-bigint",
- "num-traits",
- "quote",
- "syn 1.0.109",
+ "clap_builder",
+ "clap_derive",
]
[[package]]
-name = "ark-ff-macros"
-version = "0.4.2"
+name = "clap_builder"
+version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7abe79b0e4288889c4574159ab790824d0033b9fdcb2a112a3182fac2e514565"
+checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
dependencies = [
- "num-bigint",
- "num-traits",
- "proc-macro2",
- "quote",
- "syn 1.0.109",
+ "anstream",
+ "anstyle",
+ "clap_lex",
+ "strsim",
]
[[package]]
-name = "ark-ff-macros"
-version = "0.5.0"
+name = "clap_derive"
+version = "4.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "09be120733ee33f7693ceaa202ca41accd5653b779563608f1234f78ae07c4b3"
+checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9"
dependencies = [
- "num-bigint",
- "num-traits",
+ "heck",
"proc-macro2",
"quote",
- "syn 2.0.118",
+ "syn",
]
[[package]]
-name = "ark-ff-macros"
-version = "0.6.0"
+name = "clap_lex"
+version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4a0691ed21ef00ef89c1e9bda832eba493dda3ec2f8d892fb25b705f73f06bb8"
-dependencies = [
- "num-bigint",
- "num-traits",
- "proc-macro2",
- "quote",
- "syn 2.0.118",
-]
+checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
-name = "ark-serialize"
-version = "0.3.0"
+name = "colorchoice"
+version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1d6c2b318ee6e10f8c2853e73a83adc0ccb88995aa978d8a3408d492ab2ee671"
-dependencies = [
- "ark-std 0.3.0",
- "digest 0.9.0",
-]
+checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
-name = "ark-serialize"
-version = "0.4.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "adb7b85a02b83d2f22f89bd5cac66c9c89474240cb6207cb1efc16d098e822a5"
+name = "fiat_shamir"
+version = "0.1.0"
dependencies = [
- "ark-std 0.4.0",
- "digest 0.10.7",
- "num-bigint",
+ "parallel",
+ "primitives",
+ "serde",
]
[[package]]
-name = "ark-serialize"
-version = "0.5.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3f4d068aaf107ebcd7dfb52bc748f8030e0fc930ac8e360146ca54c1203088f7"
+name = "flock"
+version = "0.1.0"
dependencies = [
- "ark-std 0.5.0",
- "arrayvec",
- "digest 0.10.7",
- "num-bigint",
+ "fiat_shamir",
+ "parallel",
+ "pcs",
+ "primitives",
+ "zk_alloc",
]
[[package]]
-name = "ark-serialize"
-version = "0.6.0"
+name = "getrandom"
+version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a74dd304fd536fb95d0a328e72be759209cc496a9da094c5bc56e5fea4f9e86b"
+checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
- "ark-serialize-derive",
- "ark-std 0.6.0",
- "digest 0.10.7",
- "num-bigint",
- "serde_with",
+ "cfg-if",
+ "libc",
+ "r-efi",
+ "wasip2",
]
[[package]]
-name = "ark-serialize-derive"
-version = "0.6.0"
+name = "heck"
+version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4f153690697a2b91e5e1251ff98411ee5371500a111a0fd317a70e588eb300f9"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn 2.0.118",
-]
+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
-name = "ark-std"
-version = "0.3.0"
+name = "is_terminal_polyfill"
+version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1df2c09229cbc5a028b1d70e00fdb2acee28b1055dfb5ca73eea49c5a25c4e7c"
-dependencies = [
- "num-traits",
- "rand 0.8.8",
-]
+checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
-name = "ark-std"
-version = "0.4.0"
+name = "lazy_static"
+version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "94893f1e0c6eeab764ade8dc4c0db24caf4fe7cbbaafc0eba0a9030f447b5185"
-dependencies = [
- "num-traits",
- "rand 0.8.8",
-]
+checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]]
-name = "ark-std"
-version = "0.5.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "246a225cc6131e9ee4f24619af0f19d67761fff15d7ccc22e42b80846e69449a"
+name = "lean_vm"
+version = "0.1.0"
dependencies = [
- "num-traits",
- "rand 0.8.8",
+ "bincode",
+ "fiat_shamir",
+ "flock",
+ "parallel",
+ "pcs",
+ "primitives",
+ "tracing",
+ "zk_alloc",
]
[[package]]
-name = "ark-std"
-version = "0.6.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "367c9c827ed431bff6868b7aa926e05b16eb46603cc8b6e768e4a5553fa1d155"
+name = "leanvm"
+version = "0.1.0"
dependencies = [
- "num-traits",
- "rand 0.8.8",
+ "bincode",
+ "clap",
+ "lean_vm",
+ "primitives",
+ "tracing",
+ "zk_alloc",
]
[[package]]
-name = "arrayvec"
-version = "0.7.8"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
-
-[[package]]
-name = "auto_impl"
-version = "1.3.0"
+name = "libc"
+version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ffdcb70bdbc4d478427380519163274ac86e52916e10f0a8889adf0f96d3fee7"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn 2.0.118",
-]
+checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
-name = "autocfg"
-version = "1.5.1"
+name = "log"
+version = "0.4.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
+checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
-name = "base16ct"
+name = "matchers"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
-
-[[package]]
-name = "base64"
-version = "0.22.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
-
-[[package]]
-name = "base64ct"
-version = "1.8.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
-
-[[package]]
-name = "bincode"
-version = "1.3.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad"
-dependencies = [
- "serde",
-]
-
-[[package]]
-name = "bitcoin-consensus-encoding"
-version = "1.2.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6712f9c6fd6785b3b270884e57c441c403dc5d7e19ca45368c97c7a1de3000ec"
+checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
dependencies = [
- "bitcoin-internals",
- "hex-conservative 1.2.0",
- "serde",
+ "regex-automata",
]
[[package]]
-name = "bitcoin-internals"
-version = "0.6.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d573f4cf32996a8dce612e4348cece65a241f1882ed594047c9ba348e8869fa5"
-
-[[package]]
-name = "bitcoin-io"
-version = "0.1.101"
+name = "memchr"
+version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bb5de036369d1ac59d3c1819ebc4d850f89466f5401c571a285b6ed564a4cb78"
-dependencies = [
- "bitcoin-consensus-encoding",
-]
+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
-name = "bitcoin_hashes"
-version = "0.14.101"
+name = "nu-ansi-term"
+version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bca4c7abb40c8817d77403c880988cfd484f23ab2365726afb2f798363e2c4a2"
+checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
- "bitcoin-io",
- "hex-conservative 0.2.3",
+ "windows-sys",
]
[[package]]
-name = "bitflags"
-version = "1.3.2"
+name = "once_cell"
+version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
-name = "bitflags"
-version = "2.13.1"
+name = "once_cell_polyfill"
+version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
+checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
-name = "bitvec"
-version = "1.1.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837"
+name = "parallel"
+version = "0.1.0"
dependencies = [
- "funty",
- "radium",
- "tap",
- "wyz",
+ "libc",
]
[[package]]
-name = "block-buffer"
-version = "0.10.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
+name = "pcs"
+version = "0.1.0"
dependencies = [
- "generic-array",
+ "bincode",
+ "fiat_shamir",
+ "parallel",
+ "primitives",
+ "tracing",
+ "zk_alloc",
]
[[package]]
-name = "block-buffer"
-version = "0.12.1"
+name = "pin-project-lite"
+version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
-dependencies = [
- "hybrid-array",
-]
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
-name = "bs58"
-version = "0.5.1"
+name = "ppv-lite86"
+version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4"
+checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
- "tinyvec",
+ "zerocopy",
]
[[package]]
-name = "bumpalo"
-version = "3.20.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
-
-[[package]]
-name = "byte-slice-cast"
-version = "1.2.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7575182f7272186991736b70173b0ea045398f984bf5ebbb3804736ce1330c9d"
-
-[[package]]
-name = "byteorder"
-version = "1.5.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
-
-[[package]]
-name = "bytes"
-version = "1.12.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
+name = "primitives"
+version = "0.1.0"
dependencies = [
+ "bincode",
+ "libc",
+ "parallel",
+ "primitives",
+ "rand",
"serde",
+ "tracing-forest",
+ "tracing-subscriber",
+ "zk_alloc",
]
[[package]]
-name = "cc"
-version = "1.4.4"
+name = "proc-macro2"
+version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273"
-dependencies = [
- "find-msvc-tools",
- "shlex",
-]
-
-[[package]]
-name = "cfg-if"
-version = "1.0.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
-
-[[package]]
-name = "chrono"
-version = "0.4.45"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
-dependencies = [
- "iana-time-zone",
- "num-traits",
- "serde",
- "windows-link",
-]
-
-[[package]]
-name = "clap"
-version = "4.6.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51"
-dependencies = [
- "clap_builder",
- "clap_derive",
-]
-
-[[package]]
-name = "clap_builder"
-version = "4.6.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
-dependencies = [
- "anstream",
- "anstyle",
- "clap_lex",
- "strsim",
-]
-
-[[package]]
-name = "clap_derive"
-version = "4.6.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9"
-dependencies = [
- "heck",
- "proc-macro2",
- "quote",
- "syn 2.0.118",
-]
-
-[[package]]
-name = "clap_lex"
-version = "1.1.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
-
-[[package]]
-name = "colorchoice"
-version = "1.0.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
-
-[[package]]
-name = "const-hex"
-version = "1.19.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "33e2a781ebdf4467d1428dc4593067825fb646f6871475098d8577421af73558"
-dependencies = [
- "cfg-if",
- "cpufeatures 0.2.17",
- "proptest",
- "serde_core",
-]
-
-[[package]]
-name = "const-oid"
-version = "0.9.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
-
-[[package]]
-name = "const_format"
-version = "0.2.36"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4481a617ad9a412be3b97c5d403fef8ed023103368908b9c50af598ff467cc1e"
-dependencies = [
- "const_format_proc_macros",
- "konst",
-]
-
-[[package]]
-name = "const_format_proc_macros"
-version = "0.2.34"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744"
-dependencies = [
- "proc-macro2",
- "quote",
- "unicode-xid",
-]
-
-[[package]]
-name = "convert_case"
-version = "0.10.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9"
-dependencies = [
- "unicode-segmentation",
-]
-
-[[package]]
-name = "core-foundation-sys"
-version = "0.8.7"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
-
-[[package]]
-name = "cpufeatures"
-version = "0.2.17"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
-dependencies = [
- "libc",
-]
-
-[[package]]
-name = "cpufeatures"
-version = "0.3.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
-dependencies = [
- "libc",
-]
-
-[[package]]
-name = "crunchy"
-version = "0.2.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
-
-[[package]]
-name = "crypto-bigint"
-version = "0.5.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
-dependencies = [
- "generic-array",
- "rand_core 0.6.4",
- "subtle",
- "zeroize",
-]
-
-[[package]]
-name = "crypto-common"
-version = "0.1.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3"
-dependencies = [
- "generic-array",
- "typenum",
-]
-
-[[package]]
-name = "crypto-common"
-version = "0.2.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
-dependencies = [
- "hybrid-array",
-]
-
-[[package]]
-name = "defmt"
-version = "1.1.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1"
-dependencies = [
- "bitflags 1.3.2",
- "defmt-macros",
-]
-
-[[package]]
-name = "defmt-macros"
-version = "1.1.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8"
-dependencies = [
- "defmt-parser",
- "proc-macro2",
- "quote",
- "syn 2.0.118",
-]
-
-[[package]]
-name = "defmt-parser"
-version = "1.0.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e"
-dependencies = [
- "thiserror",
-]
-
-[[package]]
-name = "der"
-version = "0.7.10"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
-dependencies = [
- "const-oid",
- "zeroize",
-]
-
-[[package]]
-name = "deranged"
-version = "0.5.8"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
-dependencies = [
- "serde_core",
-]
-
-[[package]]
-name = "derivative"
-version = "2.2.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn 1.0.109",
-]
-
-[[package]]
-name = "derive_more"
-version = "2.1.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134"
-dependencies = [
- "derive_more-impl",
-]
-
-[[package]]
-name = "derive_more-impl"
-version = "2.1.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb"
-dependencies = [
- "convert_case",
- "proc-macro2",
- "quote",
- "rustc_version 0.4.1",
- "syn 2.0.118",
- "unicode-xid",
-]
-
-[[package]]
-name = "digest"
-version = "0.9.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066"
-dependencies = [
- "generic-array",
-]
-
-[[package]]
-name = "digest"
-version = "0.10.7"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
-dependencies = [
- "block-buffer 0.10.4",
- "const-oid",
- "crypto-common 0.1.6",
- "subtle",
-]
-
-[[package]]
-name = "digest"
-version = "0.11.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
-dependencies = [
- "block-buffer 0.12.1",
- "crypto-common 0.2.2",
-]
-
-[[package]]
-name = "dyn-clone"
-version = "1.0.20"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
-
-[[package]]
-name = "ecdsa"
-version = "0.16.9"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca"
-dependencies = [
- "der",
- "digest 0.10.7",
- "elliptic-curve",
- "rfc6979",
- "signature",
- "spki",
-]
-
-[[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.118",
-]
-
-[[package]]
-name = "either"
-version = "1.18.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34"
-
-[[package]]
-name = "elliptic-curve"
-version = "0.13.8"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47"
-dependencies = [
- "base16ct",
- "crypto-bigint",
- "digest 0.10.7",
- "ff",
- "generic-array",
- "group",
- "pkcs8",
- "rand_core 0.6.4",
- "sec1",
- "subtle",
- "zeroize",
-]
-
-[[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.4",
-]
-
-[[package]]
-name = "equivalent"
-version = "1.0.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
-
-[[package]]
-name = "ethereum_serde_utils"
-version = "0.8.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "38df44a7a271ab43835678f9215b53cc2523e4714a215da6643d83dc110245da"
-dependencies = [
- "alloy-primitives",
- "hex",
- "serde",
- "serde_derive",
- "serde_json",
-]
-
-[[package]]
-name = "ethereum_ssz"
-version = "0.10.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e462875ad8693755ea8913d6e905715c76ea4836e2254e18c9cf0f7a8f8c2a13"
-dependencies = [
- "alloy-primitives",
- "ethereum_serde_utils",
- "itertools 0.14.0",
- "serde",
- "serde_derive",
- "smallvec",
- "typenum",
-]
-
-[[package]]
-name = "fastrlp"
-version = "0.3.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "139834ddba373bbdd213dffe02c8d110508dcf1726c2be27e8d1f7d7e1856418"
-dependencies = [
- "arrayvec",
- "auto_impl",
- "bytes",
-]
-
-[[package]]
-name = "fastrlp"
-version = "0.4.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ce8dba4714ef14b8274c371879b175aa55b16b30f269663f19d576f380018dc4"
-dependencies = [
- "arrayvec",
- "auto_impl",
- "bytes",
-]
-
-[[package]]
-name = "ff"
-version = "0.13.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393"
-dependencies = [
- "rand_core 0.6.4",
- "subtle",
-]
-
-[[package]]
-name = "fiat_shamir"
-version = "0.1.0"
-dependencies = [
- "parallel",
- "primitives",
- "serde",
-]
-
-[[package]]
-name = "find-msvc-tools"
-version = "0.1.11"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890"
-
-[[package]]
-name = "fixed-cache"
-version = "0.1.10"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2fe63500644ef0269fe6b744e7e5dc5c20b5eebf3d881bc2be53f194636f6583"
-dependencies = [
- "equivalent",
- "rapidhash",
-]
-
-[[package]]
-name = "fixed-hash"
-version = "0.8.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "835c052cb0c08c1acf6ffd71c022172e18723949c8282f2b9f27efbc51e64534"
-dependencies = [
- "byteorder",
- "rand 0.8.8",
- "rustc-hex",
- "static_assertions",
-]
-
-[[package]]
-name = "flock"
-version = "0.1.0"
-dependencies = [
- "fiat_shamir",
- "parallel",
- "pcs",
- "primitives",
- "zk_alloc",
-]
-
-[[package]]
-name = "foldhash"
-version = "0.2.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
-
-[[package]]
-name = "funty"
-version = "2.0.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
-
-[[package]]
-name = "futures-core"
-version = "0.3.34"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
-
-[[package]]
-name = "futures-task"
-version = "0.3.34"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
-
-[[package]]
-name = "futures-util"
-version = "0.3.34"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
-dependencies = [
- "futures-core",
- "futures-task",
- "pin-project-lite",
- "slab",
-]
-
-[[package]]
-name = "generic-array"
-version = "0.14.9"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2"
-dependencies = [
- "typenum",
- "version_check",
- "zeroize",
-]
-
-[[package]]
-name = "getrandom"
-version = "0.2.17"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
-dependencies = [
- "cfg-if",
- "libc",
- "wasi",
-]
-
-[[package]]
-name = "getrandom"
-version = "0.3.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
-dependencies = [
- "cfg-if",
- "libc",
- "r-efi",
- "wasip2",
-]
-
-[[package]]
-name = "group"
-version = "0.13.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63"
-dependencies = [
- "ff",
- "rand_core 0.6.4",
- "subtle",
-]
-
-[[package]]
-name = "hashbrown"
-version = "0.12.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
-
-[[package]]
-name = "hashbrown"
-version = "0.17.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
-dependencies = [
- "foldhash",
- "serde",
- "serde_core",
-]
-
-[[package]]
-name = "heck"
-version = "0.5.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
-
-[[package]]
-name = "hex"
-version = "0.4.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
-
-[[package]]
-name = "hex-conservative"
-version = "0.2.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "db3fef046dca3ca91ee1408a8c1b80ab777e80a4d308d1bf4e7adb3fcb047e08"
-dependencies = [
- "arrayvec",
-]
-
-[[package]]
-name = "hex-conservative"
-version = "1.2.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "35431185f361ccf3ffc58254628af5f1f5d5f28531da2e02e5d6c82bbc282a10"
-dependencies = [
- "arrayvec",
-]
-
-[[package]]
-name = "hmac"
-version = "0.12.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
-dependencies = [
- "digest 0.10.7",
-]
-
-[[package]]
-name = "hybrid-array"
-version = "0.4.14"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b"
-dependencies = [
- "typenum",
-]
-
-[[package]]
-name = "iana-time-zone"
-version = "0.1.65"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
-dependencies = [
- "android_system_properties",
- "core-foundation-sys",
- "iana-time-zone-haiku",
- "js-sys",
- "log",
- "wasm-bindgen",
- "windows-core",
-]
-
-[[package]]
-name = "iana-time-zone-haiku"
-version = "0.1.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
-dependencies = [
- "cc",
-]
-
-[[package]]
-name = "impl-codec"
-version = "0.6.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ba6a270039626615617f3f36d15fc827041df3b78c439da2cadfa47455a77f2f"
-dependencies = [
- "parity-scale-codec",
-]
-
-[[package]]
-name = "impl-trait-for-tuples"
-version = "0.2.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a0eb5a3343abf848c0984fe4604b2b105da9539376e24fc0a3b0007411ae4fd9"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn 2.0.118",
-]
-
-[[package]]
-name = "indexmap"
-version = "1.9.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99"
-dependencies = [
- "autocfg",
- "hashbrown 0.12.3",
- "serde",
-]
-
-[[package]]
-name = "indexmap"
-version = "2.14.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "07aa2048142242915a31d35844fb311e0e53fcca590c3a0a40dcf1b841fa09eb"
-dependencies = [
- "equivalent",
- "hashbrown 0.17.1",
- "serde",
- "serde_core",
-]
-
-[[package]]
-name = "is_terminal_polyfill"
-version = "1.70.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
-
-[[package]]
-name = "itertools"
-version = "0.10.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473"
-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"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
-dependencies = [
- "either",
-]
-
-[[package]]
-name = "itoa"
-version = "1.0.18"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
-
-[[package]]
-name = "jiff"
-version = "0.2.35"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc"
-dependencies = [
- "defmt",
- "jiff-core",
- "jiff-static",
- "jiff-tzdb-platform",
- "log",
- "portable-atomic",
- "portable-atomic-util",
- "serde_core",
- "windows-link",
-]
-
-[[package]]
-name = "jiff-core"
-version = "0.1.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09"
-dependencies = [
- "defmt",
-]
-
-[[package]]
-name = "jiff-static"
-version = "0.2.35"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204"
-dependencies = [
- "jiff-core",
- "proc-macro2",
- "quote",
- "syn 2.0.118",
-]
-
-[[package]]
-name = "jiff-tzdb"
-version = "0.1.8"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e"
-
-[[package]]
-name = "jiff-tzdb-platform"
-version = "0.1.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8"
-dependencies = [
- "jiff-tzdb",
-]
-
-[[package]]
-name = "js-sys"
-version = "0.3.104"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a"
-dependencies = [
- "cfg-if",
- "futures-util",
- "wasm-bindgen",
-]
-
-[[package]]
-name = "k256"
-version = "0.13.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b"
-dependencies = [
- "cfg-if",
- "ecdsa",
- "elliptic-curve",
- "once_cell",
- "sha2",
-]
-
-[[package]]
-name = "keccak"
-version = "0.2.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d8f198d1db720e4940b5a493201d199d9f24f568f8f746bd13706243a2f71598"
-dependencies = [
- "cfg-if",
- "cpufeatures 0.3.1",
-]
-
-[[package]]
-name = "keccak-asm"
-version = "0.1.8"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dd5dc2c0d691cbf7595cde551ced329cca99c2387c2cbc97754c5d0cd045d3ee"
-dependencies = [
- "digest 0.10.7",
- "sha3-asm",
-]
-
-[[package]]
-name = "konst"
-version = "0.2.20"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "128133ed7824fcd73d6e7b17957c5eb7bacb885649bd8c69708b2331a10bcefb"
-dependencies = [
- "konst_macro_rules",
-]
-
-[[package]]
-name = "konst_macro_rules"
-version = "0.2.19"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a4933f3f57a8e9d9da04db23fb153356ecaf00cbd14aee46279c33dc80925c37"
-
-[[package]]
-name = "lazy_static"
-version = "1.5.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
-
-[[package]]
-name = "lean_compiler"
-version = "0.1.0"
-dependencies = [
- "bincode",
- "lean_vm",
- "primitives",
- "rand 0.9.4",
-]
-
-[[package]]
-name = "lean_da"
-version = "0.1.0"
-dependencies = [
- "fiat_shamir",
- "parallel",
- "pcs",
- "primitives",
- "rand 0.9.4",
- "serde",
- "tracing",
-]
-
-[[package]]
-name = "lean_vm"
-version = "0.1.0"
-dependencies = [
- "bincode",
- "fiat_shamir",
- "flock",
- "lean_compiler",
- "parallel",
- "pcs",
- "primitives",
- "tracing",
- "zk_alloc",
-]
-
-[[package]]
-name = "leanvm"
-version = "0.1.0"
-dependencies = [
- "clap",
- "lean_da",
- "lean_vm",
- "primitives",
- "rand 0.9.4",
- "rec_aggregation",
- "sphincs",
- "xmss",
- "zk_alloc",
-]
-
-[[package]]
-name = "libc"
-version = "0.2.186"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
-
-[[package]]
-name = "libm"
-version = "0.2.16"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
-
-[[package]]
-name = "log"
-version = "0.4.33"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
-
-[[package]]
-name = "matchers"
-version = "0.2.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
-dependencies = [
- "regex-automata",
-]
-
-[[package]]
-name = "memchr"
-version = "2.8.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
-
-[[package]]
-name = "nu-ansi-term"
-version = "0.50.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
-dependencies = [
- "windows-sys",
-]
-
-[[package]]
-name = "num-bigint"
-version = "0.4.8"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367"
-dependencies = [
- "num-integer",
- "num-traits",
-]
-
-[[package]]
-name = "num-conv"
-version = "0.2.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
-
-[[package]]
-name = "num-integer"
-version = "0.1.47"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b"
-dependencies = [
- "num-traits",
-]
-
-[[package]]
-name = "num-traits"
-version = "0.2.19"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
-dependencies = [
- "autocfg",
- "libm",
-]
-
-[[package]]
-name = "once_cell"
-version = "1.21.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
-
-[[package]]
-name = "once_cell_polyfill"
-version = "1.70.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
-
-[[package]]
-name = "parallel"
-version = "0.1.0"
-dependencies = [
- "libc",
-]
-
-[[package]]
-name = "parity-scale-codec"
-version = "3.7.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "799781ae679d79a948e13d4824a40970bfa500058d245760dd857301059810fa"
-dependencies = [
- "arrayvec",
- "bitvec",
- "byte-slice-cast",
- "const_format",
- "impl-trait-for-tuples",
- "parity-scale-codec-derive",
- "rustversion",
- "serde",
-]
-
-[[package]]
-name = "parity-scale-codec-derive"
-version = "3.7.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "34b4653168b563151153c9e4c08ebed57fb8262bebfa79711552fa983c623e7a"
-dependencies = [
- "proc-macro-crate",
- "proc-macro2",
- "quote",
- "syn 2.0.118",
-]
-
-[[package]]
-name = "paste"
-version = "1.0.15"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
-
-[[package]]
-name = "pcs"
-version = "0.1.0"
-dependencies = [
- "bincode",
- "fiat_shamir",
- "parallel",
- "primitives",
- "tracing",
- "zk_alloc",
-]
-
-[[package]]
-name = "pest"
-version = "2.9.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5a07a60cc7a4d00c91f95c685609d1d2f79050e6804b70ebedd7650f0b839bcf"
-dependencies = [
- "memchr",
- "ucd-trie",
-]
-
-[[package]]
-name = "pin-project-lite"
-version = "0.2.17"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
-
-[[package]]
-name = "pkcs8"
-version = "0.10.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
-dependencies = [
- "der",
- "spki",
-]
-
-[[package]]
-name = "portable-atomic"
-version = "1.15.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85"
-
-[[package]]
-name = "portable-atomic-util"
-version = "0.2.7"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618"
-dependencies = [
- "portable-atomic",
-]
-
-[[package]]
-name = "powerfmt"
-version = "0.2.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
-
-[[package]]
-name = "ppv-lite86"
-version = "0.2.21"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
-dependencies = [
- "zerocopy",
-]
-
-[[package]]
-name = "primitive-types"
-version = "0.12.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0b34d9fd68ae0b74a41b21c03c2f62847aa0ffea044eee893b4c140b37e244e2"
-dependencies = [
- "fixed-hash",
- "impl-codec",
- "uint",
-]
-
-[[package]]
-name = "primitives"
-version = "0.1.0"
-dependencies = [
- "bincode",
- "libc",
- "parallel",
- "primitives",
- "rand 0.9.4",
- "serde",
- "tracing-forest",
- "tracing-subscriber",
- "zk_alloc",
-]
-
-[[package]]
-name = "proc-macro-crate"
-version = "3.5.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
-dependencies = [
- "toml_edit",
-]
-
-[[package]]
-name = "proc-macro2"
-version = "1.0.106"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
+checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
-name = "proptest"
-version = "1.11.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744"
-dependencies = [
- "bitflags 2.13.1",
- "num-traits",
- "rand 0.9.4",
- "rand_chacha 0.9.0",
- "rand_xorshift",
- "regex-syntax",
- "unarray",
-]
-
-[[package]]
-name = "quote"
-version = "1.0.46"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
-dependencies = [
- "proc-macro2",
-]
-
-[[package]]
-name = "r-efi"
-version = "5.3.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
-
-[[package]]
-name = "radium"
-version = "0.7.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09"
-
-[[package]]
-name = "rand"
-version = "0.8.8"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c"
-dependencies = [
- "libc",
- "rand_chacha 0.3.1",
- "rand_core 0.6.4",
-]
-
-[[package]]
-name = "rand"
-version = "0.9.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
-dependencies = [
- "rand_chacha 0.9.0",
- "rand_core 0.9.5",
- "serde",
-]
-
-[[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_chacha"
-version = "0.9.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
-dependencies = [
- "ppv-lite86",
- "rand_core 0.9.5",
-]
-
-[[package]]
-name = "rand_core"
-version = "0.6.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
-dependencies = [
- "getrandom 0.2.17",
-]
-
-[[package]]
-name = "rand_core"
-version = "0.9.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
-dependencies = [
- "getrandom 0.3.4",
- "serde",
-]
-
-[[package]]
-name = "rand_xorshift"
-version = "0.4.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a"
-dependencies = [
- "rand_core 0.9.5",
-]
-
-[[package]]
-name = "rapidhash"
-version = "4.5.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5da7e78a036ce858e8d55b7e7dc8ba3a88b78350fd2155d3591bbd966b58589e"
-dependencies = [
- "rustversion",
-]
-
-[[package]]
-name = "rec_aggregation"
-version = "0.1.0"
-dependencies = [
- "bincode",
- "flock",
- "lean_compiler",
- "lean_da",
- "lean_vm",
- "parallel",
- "pcs",
- "primitives",
- "rand 0.9.4",
- "serde",
- "sphincs",
- "tracing",
- "xmss",
- "zk_alloc",
-]
-
-[[package]]
-name = "ref-cast"
-version = "1.0.27"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3"
-dependencies = [
- "ref-cast-impl",
-]
-
-[[package]]
-name = "ref-cast-impl"
-version = "1.0.27"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn 3.0.4",
-]
-
-[[package]]
-name = "regex-automata"
-version = "0.4.16"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad"
-dependencies = [
- "aho-corasick",
- "memchr",
- "regex-syntax",
-]
-
-[[package]]
-name = "regex-syntax"
-version = "0.8.11"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
-
-[[package]]
-name = "rfc6979"
-version = "0.4.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2"
-dependencies = [
- "hmac",
- "subtle",
-]
-
-[[package]]
-name = "rlp"
-version = "0.5.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bb919243f34364b6bd2fc10ef797edbfa75f33c252e7998527479c6d6b47e1ec"
-dependencies = [
- "bytes",
- "rustc-hex",
-]
-
-[[package]]
-name = "ruint"
-version = "1.20.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f5e99bff0393163bb25029a6af25d3d8d202ba5b5438a74d1bd8789f5c822970"
-dependencies = [
- "alloy-rlp",
- "ark-ff 0.3.0",
- "ark-ff 0.4.2",
- "ark-ff 0.5.0",
- "ark-ff 0.6.0",
- "bytes",
- "fastrlp 0.3.1",
- "fastrlp 0.4.0",
- "num-bigint",
- "num-integer",
- "num-traits",
- "parity-scale-codec",
- "primitive-types",
- "proptest",
- "rand 0.8.8",
- "rand 0.9.4",
- "rlp",
- "ruint-macro",
- "serde_core",
- "valuable",
- "zeroize",
-]
-
-[[package]]
-name = "ruint-macro"
-version = "1.2.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "48fd7bd8a6377e15ad9d42a8ec25371b94ddc67abe7c8b9127bec79bebaaae18"
-
-[[package]]
-name = "rustc-hash"
-version = "2.1.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
-
-[[package]]
-name = "rustc-hex"
-version = "2.1.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3e75f6a532d0fd9f7f13144f392b6ad56a32696bfcd9c78f797f16bbb6f072d6"
-
-[[package]]
-name = "rustc_version"
-version = "0.3.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f0dfe2087c51c460008730de8b57e6a320782fbfb312e1f4d520e6c6fae155ee"
-dependencies = [
- "semver 0.11.0",
-]
-
-[[package]]
-name = "rustc_version"
-version = "0.4.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
-dependencies = [
- "semver 1.0.28",
-]
-
-[[package]]
-name = "rustversion"
-version = "1.0.23"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
-
-[[package]]
-name = "schemars"
-version = "0.9.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f"
-dependencies = [
- "dyn-clone",
- "ref-cast",
- "serde",
- "serde_json",
-]
-
-[[package]]
-name = "schemars"
-version = "1.2.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a"
-dependencies = [
- "dyn-clone",
- "ref-cast",
- "serde",
- "serde_json",
-]
-
-[[package]]
-name = "sec1"
-version = "0.7.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc"
-dependencies = [
- "base16ct",
- "der",
- "generic-array",
- "pkcs8",
- "subtle",
- "zeroize",
-]
-
-[[package]]
-name = "secp256k1"
-version = "0.31.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2c3c81b43dc2d8877c216a3fccf76677ee1ebccd429566d3e67447290d0c42b2"
-dependencies = [
- "bitcoin_hashes",
- "rand 0.9.4",
- "secp256k1-sys",
-]
-
-[[package]]
-name = "secp256k1-sys"
-version = "0.11.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dcb913707158fadaf0d8702c2db0e857de66eb003ccfdda5924b5f5ac98efb38"
-dependencies = [
- "cc",
-]
-
-[[package]]
-name = "semver"
-version = "0.11.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f301af10236f6df4160f7c3f04eec6dbc70ace82d23326abad5edee88801c6b6"
-dependencies = [
- "semver-parser",
-]
-
-[[package]]
-name = "semver"
-version = "1.0.28"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
-
-[[package]]
-name = "semver-parser"
-version = "0.10.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9900206b54a3527fdc7b8a938bffd94a568bac4f4aa8113b209df75a09c0dec2"
-dependencies = [
- "pest",
-]
-
-[[package]]
-name = "serde"
-version = "1.0.228"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
-dependencies = [
- "serde_core",
- "serde_derive",
-]
-
-[[package]]
-name = "serde_core"
-version = "1.0.228"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
-dependencies = [
- "serde_derive",
-]
-
-[[package]]
-name = "serde_derive"
-version = "1.0.228"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn 2.0.118",
-]
-
-[[package]]
-name = "serde_json"
-version = "1.0.151"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
-dependencies = [
- "itoa",
- "memchr",
- "serde",
- "serde_core",
- "zmij",
-]
-
-[[package]]
-name = "serde_with"
-version = "3.22.0"
+name = "quote"
+version = "1.0.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ee78f1fbe43ac4a0e47aadb3dbd357b69eb0d3793e948624cd03dd2750ab1c0a"
+checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
dependencies = [
- "base64",
- "bs58",
- "chrono",
- "hex",
- "indexmap 1.9.3",
- "indexmap 2.14.1",
- "jiff",
- "schemars 0.9.0",
- "schemars 1.2.2",
- "serde_core",
- "serde_json",
- "time",
+ "proc-macro2",
]
[[package]]
-name = "sha2"
-version = "0.10.9"
+name = "r-efi"
+version = "5.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
+
+[[package]]
+name = "rand"
+version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
+checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
dependencies = [
- "cfg-if",
- "cpufeatures 0.2.17",
- "digest 0.10.7",
+ "rand_chacha",
+ "rand_core",
]
[[package]]
-name = "sha3"
-version = "0.11.0"
+name = "rand_chacha"
+version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "be176f1a57ce4e3d31c1a166222d9768de5954f811601fb7ca06fc8203905ce1"
+checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
- "digest 0.11.3",
- "keccak",
+ "ppv-lite86",
+ "rand_core",
]
[[package]]
-name = "sha3-asm"
-version = "0.1.8"
+name = "rand_core"
+version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a6287fd675f713484342a89cbf0a386abef5f15919cfad607e5e1f19e1e15331"
+checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
dependencies = [
- "cc",
- "cfg-if",
+ "getrandom",
]
[[package]]
-name = "sharded-slab"
-version = "0.1.7"
+name = "regex-automata"
+version = "0.4.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
+checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad"
dependencies = [
- "lazy_static",
+ "aho-corasick",
+ "memchr",
+ "regex-syntax",
]
[[package]]
-name = "shlex"
-version = "2.0.1"
+name = "regex-syntax"
+version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
+checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]]
-name = "signature"
-version = "2.2.0"
+name = "serde"
+version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
- "digest 0.10.7",
- "rand_core 0.6.4",
+ "serde_core",
+ "serde_derive",
]
[[package]]
-name = "slab"
-version = "0.4.12"
+name = "serde_core"
+version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
+dependencies = [
+ "serde_derive",
+]
[[package]]
-name = "smallvec"
-version = "1.15.2"
+name = "serde_derive"
+version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
-
-[[package]]
-name = "sphincs"
-version = "0.1.0"
+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
- "parallel",
- "primitives",
- "rand 0.9.4",
- "serde",
+ "proc-macro2",
+ "quote",
+ "syn",
]
[[package]]
-name = "spki"
-version = "0.7.3"
+name = "sharded-slab"
+version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
+checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
dependencies = [
- "base64ct",
- "der",
+ "lazy_static",
]
[[package]]
-name = "static_assertions"
-version = "1.1.0"
+name = "smallvec"
+version = "1.15.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
+checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
[[package]]
name = "strsim"
@@ -2207,23 +425,6 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
-[[package]]
-name = "subtle"
-version = "2.6.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
-
-[[package]]
-name = "syn"
-version = "1.0.109"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
-dependencies = [
- "proc-macro2",
- "quote",
- "unicode-ident",
-]
-
[[package]]
name = "syn"
version = "2.0.118"
@@ -2235,23 +436,6 @@ dependencies = [
"unicode-ident",
]
-[[package]]
-name = "syn"
-version = "3.0.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f"
-dependencies = [
- "proc-macro2",
- "quote",
- "unicode-ident",
-]
-
-[[package]]
-name = "tap"
-version = "1.0.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
-
[[package]]
name = "thiserror"
version = "2.0.18"
@@ -2269,7 +453,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.118",
+ "syn",
]
[[package]]
@@ -2281,81 +465,6 @@ dependencies = [
"cfg-if",
]
-[[package]]
-name = "time"
-version = "0.3.55"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
-dependencies = [
- "deranged",
- "num-conv",
- "powerfmt",
- "serde_core",
- "time-core",
- "time-macros",
-]
-
-[[package]]
-name = "time-core"
-version = "0.1.9"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
-
-[[package]]
-name = "time-macros"
-version = "0.2.32"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85"
-dependencies = [
- "num-conv",
- "time-core",
-]
-
-[[package]]
-name = "tinyvec"
-version = "1.12.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f"
-dependencies = [
- "tinyvec_macros",
-]
-
-[[package]]
-name = "tinyvec_macros"
-version = "0.1.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
-
-[[package]]
-name = "toml_datetime"
-version = "1.1.1+spec-1.1.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
-dependencies = [
- "serde_core",
-]
-
-[[package]]
-name = "toml_edit"
-version = "0.25.13+spec-1.1.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b"
-dependencies = [
- "indexmap 2.14.1",
- "toml_datetime",
- "toml_parser",
- "winnow",
-]
-
-[[package]]
-name = "toml_parser"
-version = "1.1.3+spec-1.1.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56"
-dependencies = [
- "winnow",
-]
-
[[package]]
name = "tracing"
version = "0.1.44"
@@ -2375,7 +484,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.118",
+ "syn",
]
[[package]]
@@ -2430,54 +539,12 @@ dependencies = [
"tracing-log",
]
-[[package]]
-name = "typenum"
-version = "1.20.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
-
-[[package]]
-name = "ucd-trie"
-version = "0.1.7"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971"
-
-[[package]]
-name = "uint"
-version = "0.9.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "76f64bba2c53b04fcab63c01a7d7427eadc821e3bc48c34dc9ba29c501164b52"
-dependencies = [
- "byteorder",
- "crunchy",
- "hex",
- "static_assertions",
-]
-
-[[package]]
-name = "unarray"
-version = "0.1.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94"
-
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
-[[package]]
-name = "unicode-segmentation"
-version = "1.13.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
-
-[[package]]
-name = "unicode-xid"
-version = "0.2.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
-
[[package]]
name = "utf8parse"
version = "0.2.2"
@@ -2490,18 +557,6 @@ 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 = "wasi"
-version = "0.11.1+wasi-snapshot-preview1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
-
[[package]]
name = "wasip2"
version = "1.0.4+wasi-0.2.12"
@@ -2511,51 +566,6 @@ dependencies = [
"wit-bindgen",
]
-[[package]]
-name = "wasm-bindgen"
-version = "0.2.127"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70"
-dependencies = [
- "cfg-if",
- "once_cell",
- "rustversion",
- "wasm-bindgen-macro",
- "wasm-bindgen-shared",
-]
-
-[[package]]
-name = "wasm-bindgen-macro"
-version = "0.2.127"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1"
-dependencies = [
- "quote",
- "wasm-bindgen-macro-support",
-]
-
-[[package]]
-name = "wasm-bindgen-macro-support"
-version = "0.2.127"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284"
-dependencies = [
- "bumpalo",
- "proc-macro2",
- "quote",
- "syn 2.0.118",
- "wasm-bindgen-shared",
-]
-
-[[package]]
-name = "wasm-bindgen-shared"
-version = "0.2.127"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf"
-dependencies = [
- "unicode-ident",
-]
-
[[package]]
name = "winapi"
version = "0.3.9"
@@ -2578,65 +588,12 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
-[[package]]
-name = "windows-core"
-version = "0.62.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
-dependencies = [
- "windows-implement",
- "windows-interface",
- "windows-link",
- "windows-result",
- "windows-strings",
-]
-
-[[package]]
-name = "windows-implement"
-version = "0.60.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn 2.0.118",
-]
-
-[[package]]
-name = "windows-interface"
-version = "0.59.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn 2.0.118",
-]
-
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
-[[package]]
-name = "windows-result"
-version = "0.4.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
-dependencies = [
- "windows-link",
-]
-
-[[package]]
-name = "windows-strings"
-version = "0.5.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
-dependencies = [
- "windows-link",
-]
-
[[package]]
name = "windows-sys"
version = "0.61.2"
@@ -2646,42 +603,12 @@ dependencies = [
"windows-link",
]
-[[package]]
-name = "winnow"
-version = "1.0.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
-dependencies = [
- "memchr",
-]
-
[[package]]
name = "wit-bindgen"
version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
-[[package]]
-name = "wyz"
-version = "0.5.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed"
-dependencies = [
- "tap",
-]
-
-[[package]]
-name = "xmss"
-version = "0.1.0"
-dependencies = [
- "bincode",
- "ethereum_ssz",
- "parallel",
- "primitives",
- "rand 0.9.4",
- "serde",
-]
-
[[package]]
name = "zerocopy"
version = "0.8.52"
@@ -2699,27 +626,7 @@ checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.118",
-]
-
-[[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.118",
+ "syn",
]
[[package]]
@@ -2728,9 +635,3 @@ version = "0.1.0"
dependencies = [
"libc",
]
-
-[[package]]
-name = "zmij"
-version = "1.0.23"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
diff --git a/Cargo.toml b/Cargo.toml
index cb60b7e46..e2d84e385 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -12,13 +12,10 @@ workspace = true
[dependencies]
primitives.workspace = true
-rec_aggregation.workspace = true
lean_vm.workspace = true
-lean_da.workspace = true
-xmss.workspace = true
-sphincs.workspace = true
zk_alloc.workspace = true
-rand.workspace = true
+bincode.workspace = true
+tracing.workspace = true
clap = { version = "4", features = ["derive"] }
[workspace.package]
@@ -39,17 +36,11 @@ fiat_shamir = { path = "crates/fiat_shamir" }
pcs = { path = "crates/pcs" }
flock = { path = "crates/flock" }
lean_vm = { path = "crates/lean_vm" }
-lean_compiler = { path = "crates/lean_compiler" }
-lean_da = { path = "crates/lean_da" }
-rec_aggregation = { path = "crates/rec_aggregation" }
-xmss = { path = "crates/xmss" }
-sphincs = { path = "crates/sphincs" }
zk_alloc = { path = "crates/zk_alloc" }
parallel = { path = "crates/parallel" }
libc = "0.2"
serde = { version = "1", features = ["derive"] }
bincode = "1"
-ethereum_ssz = "0.10"
rand = "0.9"
tracing = "0.1.26"
tracing-forest = { version = "0.3.0", features = ["ansi", "smallvec"] }
diff --git a/README.md b/README.md
index a6070c30b..d424508f1 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,7 @@
-minimal hash-based zkVM, for post-quantum Ethereum
+minimal hash-based zkVM
@@ -12,31 +12,13 @@
-
@@ -56,82 +38,76 @@ leanVM is designed for security:
Expect leanVM to change significantly:
* **hash**: BLAKE2s is a placeholder. SHA2, SHA3, BLAKE3 are actively considered.
-* **ISA**: A migration from leanISA to RISC-V (rv64im) is planned.
+* **ISA**: leanVM proves RISC-V (rv64im) plus one custom instruction, the BLAKE2s compression. A run is one proof; continuations, for runs whose witness exceeds one commitment, are planned.
* **zk**: Support for zero-knowledge is planned.
**note**: Prior to binary fields leanVM used [KoalaBear](https://crates.io/crates/p3-koala-bear) and [Poseidon](https://eprint.iacr.org/2019/458). The historical design is in [this branch](https://github.com/leanEthereum/leanVM/tree/koalabear).
+## guests
+
+A guest is a `no_std` Rust program built for `riscv64im-unknown-none-elf` against the runtime crate in [`guests/rt`](./guests/rt/src/lib.rs), which gives it its public input (four words), its advice (a region of memory the prover fills, which the statement says nothing about), its output (four words) and a BLAKE2s hasher over the custom instruction. The linker script fixes the memory map. Build them with `guests/build.sh` (a nightly toolchain, for `-Zbuild-std`), then prove and verify a run:
+
+```bash
+cargo run --release -- guest guests/elf/preimage.elf --advice 5,0x6f6c6c6568
+```
+
+The statement a proof makes is the program (an ELF file), the four input words and the four output words; everything a guest reads from its advice it has to check itself, which is what makes a proof a proof of knowledge (`preimage` outputs the digest of a message only the prover has).
+
## benchmarks
**machine**: M4 Max MacBook Pro (12 performance cores, 4 efficiency cores, 48GB RAM)
**note**: The Metal GPU was not used.
-### XMSS aggregation
-
-The XMSS parameters are specified in [XMSS.pdf](https://github.com/leanEthereum/leanVM/releases/download/doc-latest/XMSS.pdf), with a [(ROM) security proof in Lean 4](https://github.com/leanEthereum/leanMultisig/blob/main/formal/xmss/XmssSecurity/Statement.lean).
+### Fibonacci
```bash
-cargo run --release -- aggregate --xmss 900 --log-inv-rate 1 --repeat 3
+cargo run --release -- fibonacci --n 2000000 --log-inv-rate 1 --repeat 3
```
```
-aggregation, 900 XMSS signatures
- cycles (VM steps) : 995,578 = 2^19.925
- details : DEREF 2^17.878 (24.2%) MUL 2^17.689 (21.2%) SET 2^17.425 (17.7%) BLAKE2S 2^16.989 (13.1%) XOR 2^16.979 (13.0%) JUMP 2^16.722 (10.9%) MEMORY 2^20.674 BYTECODE 2^17.737 TOTAL_COMMITTED 2^25.801
- proof size : 317.4 KiB
- proving time : 0.545 s ± 1.5% peak memory 7.385 GiB
- per signature : 1,651.687 signatures/s
- verifying : 3.977 ms
+Fibonacci (modulo 2^64), N = 2,000,000
+ cycles (VM steps) : 2,097,208
+ details : ALU 2^20.934 (100.0%) TOTAL_COMMITTED 2^26.395
+ proof size : 337.5 KiB
+ proving : 1.281 s ± 1.2% 1,636,564 cycles/s peak memory 11.6 GiB
+ verifying : 6.186 ms
```
-### SPHINCS aggregation
+### BLAKE2s in plain Rust
-The SPHINCS parameters are specified in [SPHINCS.pdf](https://github.com/leanEthereum/leanVM/releases/download/doc-latest/SPHINCS.pdf), with a [(ROM) security proof in Lean 4](https://github.com/leanEthereum/leanMultisig/blob/main/formal/sphincs/SphincsSecurity/Statement.lean).
+The `blake2s` guest is the hash function written in ordinary Rust, compiled by `rustc` for `riscv64im-unknown-none-elf` (`guests/blake2s`): 10,000 bytes, 157 compressions, a mix of arithmetic, shifts, loads and stores.
```bash
-cargo run --release -- aggregate --sphincs 245 --log-inv-rate 1 --repeat 3
+cargo run --release -- guest guests/elf/blake2s.elf --input 10000 --repeat 3 --cooldown 2
```
```
-aggregation, 245 SPHINCS signatures
- cycles (VM steps) : 2,131,611 = 2^21.024
- details : XOR 2^18.951 (23.8%) MUL 2^18.93 (23.4%) SET 2^18.845 (22.1%) DEREF 2^18.711 (20.1%) BLAKE2S 2^16.992 (6.1%) JUMP 2^16.543 (4.5%) MEMORY 2^21.46 BYTECODE 2^17.737 TOTAL_COMMITTED 2^26.301
- proof size : 299.9 KiB
- proving time : 0.831 s ± 3.6% peak memory 9.275 GiB
- per signature : 294.783 signatures/s
- verifying : 3.644 ms
+guests/elf/blake2s.elf
+ input : [2710, 0, 0, 0]
+ output : [8f9fc3d71d84c0cc, 515c979fa65679e8, 9ffc0e1e022efcc7, cef54d0c06836e56]
+ cycles (VM steps) : 1,015,824
+ details : ALU 2^18.47 (55.2%) SHIFT 2^17.238 (23.5%) LOAD 2^16.356 (12.8%) STORE 2^15.139 (5.5%) MUL 2^13.288 (1.5%) MULH 2^13.288 (1.5%) TOTAL_COMMITTED 2^25.435
+ proof size : 328.6 KiB
+ proving : 0.698 s ± 1.2% 1,454,472 cycles/s peak memory 5.18 GiB
+ verifying : 7.185 ms
```
-### data availability
+### BLAKE2s through the precompile
-```bash
-cargo run --release -- aggregate --blobs 16 --log-inv-rate 1 --repeat 3
-```
-
-```
-aggregation, 16 blobs
- cycles (VM steps) : 2,989,506 = 2^21.511
- details : MUL 2^19.899 (32.7%) XOR 2^19.809 (30.7%) DEREF 2^19.138 (19.3%) JUMP 2^18.299 (10.8%) SET 2^16.787 (3.8%) BLAKE2S 2^16.295 (2.7%) MEMORY 2^21.695 BYTECODE 2^17.737 TOTAL_COMMITTED 2^26.695
- proof size : 324.0 KiB
- proving time : 0.986 s ± 11.1% peak memory 12.534 GiB
- blob throughput : 16.235 blobs/s, 2.029 MiB/s
- verifying : 6.573 ms
-```
-
-### recursion
+The `hash` guest hashes 50,000 bytes through the compression instruction, 782 compressions; most of its cycles generate the message.
```bash
-cargo run --release -- recursion --n 2 --xmss-per-leaf 900 --log-inv-rate 2 --repeat 3
+cargo run --release -- guest guests/elf/hash.elf --input 50000 --repeat 3
```
```
-recursion 2→1, over leaves of 900 XMSS signatures
- cycles (VM steps) : 562,737 = 2^19.102
- details : MUL 2^17.823 (41.2%) DEREF 2^16.964 (22.7%) XOR 2^16.728 (19.3%) SET 2^15.77 (9.9%) JUMP 2^14.486 (4.1%) BLAKE2S 2^13.932 (2.8%) MEMORY 2^19.481 BYTECODE 2^17.737 TOTAL_COMMITTED 2^24.086
- proof size : 190.2 KiB
- proving time : 0.272 s ± 5.3% peak memory 8.388 GiB
- verifying : 3.457 ms
+guests/elf/hash.elf
+ cycles (VM steps) : 869,384
+ details : ALU 2^18.641 (59.8%) SHIFT 2^16.61 (14.6%) STORE 2^15.915 (9.0%) MULH 2^15.61 (7.3%) MUL 2^15.61 (7.3%) LOAD 2^13.618 (1.8%) HASH 2^9.611 (0.1%) TOTAL_COMMITTED 2^25.49
+ proof size : 331.0 KiB
+ proving : 0.816 s ± 0.9% 1,065,522 cycles/s peak memory 5.238 GiB
+ verifying : 7.796 ms
```
### hashing
@@ -143,32 +119,16 @@ BENCH_REPEAT=3 BENCH_COOLDOWN=2 FLOCK_N_LOG=18 cargo test --release --package fl
```
Flock BLAKE2s batch proving, 262,144 compressions (2^18 slots)
setup (preprocessing, excluded) : 0.0 ms
- witness-gen : 34.8 ms ± 26.8% 6.1%
- commit : 102.2 ms ± 1.3% 17.8%
- zerocheck : 244.9 ms ± 7.5% 42.6%
- lincheck : 20.2 ms ± 3.4% 3.5%
- pcs opening : 172.3 ms ± 1.3% 30.0%
+ witness-gen : 64.6 ms ± 7.8% 10.6%
+ commit : 101.2 ms ± 0.4% 16.6%
+ zerocheck : 238.3 ms ± 3.9% 39.0%
+ lincheck : 20.3 ms ± 12.2% 3.3%
+ pcs opening : 186.0 ms ± 2.9% 30.5%
other : 0.0 ms 0.0%
------------------------------------------
- prove TOTAL (witness excluded) : 539.7 ms ± 3.3% 93.9%
- verify : 2.0 ms
- throughput : 485,765 compressions/s ± 3.3%
- (~3327.2 XMSS/s equivalent at 146 compressions/signature)
-```
-
-### Fibonacci
-
-```bash
-cargo run --release -- fibonacci --n 2000000 --log-inv-rate 1 --repeat 3
-```
-
-```
-Fibonacci (in the exponent, i.e. modulo 2^64 - 1), N = 2,000,000
- cycles (VM steps) : 2,127,880
- details : MUL 2^20.944 (98.9%) SET 2^13.288 (0.5%) DEREF 2^12.967 (0.4%) JUMP 2^10.968 (0.1%) XOR 2^10.966 (0.1%) MEMORY 2^20.96 BYTECODE 2^11.352 TOTAL_COMMITTED 2^25.26
- proof size : 286.0 KiB
- proving : 0.344 s ± 4.6% 6,181,087 cycles/s peak memory 5.199 GiB
- verifying : 2.218 ms
+ prove TOTAL (witness excluded) : 545.8 ms ± 1.1% 89.4%
+ verify : 1.9 ms
+ throughput : 480,319 compressions/s ± 1.1%
```
## SNARK machinery
diff --git a/conformance/act4/Dockerfile b/conformance/act4/Dockerfile
new file mode 100644
index 000000000..130f9069b
--- /dev/null
+++ b/conformance/act4/Dockerfile
@@ -0,0 +1,54 @@
+# The environment that generates the ACT4 ELF files (generate.sh builds and runs it):
+# riscv-arch-test, its RISC-V GCC, the Sail reference model and the tools of its
+# framework, every one pinned by version and checksum. The configuration is mounted
+# at run time, so a change to it does not rebuild the image.
+FROM ubuntu:24.04@sha256:008173c23f95b170204355c12626cb5a965d779a7e1283b09e9cffbb1bf33ca3
+
+ARG DEBIAN_FRONTEND=noninteractive
+# build-essential builds the native gems of the unified database (UDB).
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ build-essential ca-certificates curl git xz-utils \
+ && rm -rf /var/lib/apt/lists/*
+
+# GCC 15.1 and binutils 2.45: ACT4 4.1.0 needs GCC 15 or later.
+ARG TOOLCHAIN=2025.08.08
+ARG TOOLCHAIN_SHA256=2aaa09d5eb768d4874b85cba152b994f49605b13035fc8d6a71f14c51db5276e
+RUN curl -fsSL -o /tmp/toolchain.tar.xz \
+ "https://github.com/riscv-collab/riscv-gnu-toolchain/releases/download/${TOOLCHAIN}/riscv64-elf-ubuntu-24.04-gcc-nightly-${TOOLCHAIN}-nightly.tar.xz" \
+ && echo "${TOOLCHAIN_SHA256} /tmp/toolchain.tar.xz" | sha256sum -c - \
+ && tar -xJf /tmp/toolchain.tar.xz -C /opt \
+ && rm /tmp/toolchain.tar.xz
+
+# ACT4 4.1.0 requires exactly Sail 0.13.1.
+ARG SAIL=0.13.1
+ARG SAIL_SHA256=ee052f64494a2f5f071afd9c2cb4aa5eaae4ba84753e4f77e442b4f83f2e9469
+RUN curl -fsSL -o /tmp/sail.tar.gz \
+ "https://github.com/riscv/sail-riscv/releases/download/${SAIL}/sail-riscv-Linux-x86_64.tar.gz" \
+ && echo "${SAIL_SHA256} /tmp/sail.tar.gz" | sha256sum -c - \
+ && mkdir /opt/sail \
+ && tar -xzf /tmp/sail.tar.gz -C /opt/sail --strip-components=1 \
+ && rm /tmp/sail.tar.gz
+
+# mise installs the uv, Ruby and Bundler versions riscv-arch-test's .mise.toml pins.
+ARG MISE=2026.9.17
+ARG MISE_SHA256=63049bc35fb9065e8dc35ac8b25fdae53e9bd6f1885a843aedeba398e046a1ee
+RUN curl -fsSL -o /usr/local/bin/mise \
+ "https://github.com/jdx/mise/releases/download/v${MISE}/mise-v${MISE}-linux-x64" \
+ && echo "${MISE_SHA256} /usr/local/bin/mise" | sha256sum -c - \
+ && chmod +x /usr/local/bin/mise
+
+ENV PATH="/opt/riscv/bin:/opt/sail/bin:/root/.local/share/mise/shims:${PATH}" \
+ MISE_YES=1
+
+# riscv-arch-test 4.1.0, and its Ruby and Python dependencies (locked by its
+# Gemfile.lock and uv.lock), so that generating needs no network.
+ARG ARCH_TEST=6e8a45123f14cebfb3df151a0e7b849b4389b33b
+WORKDIR /act4
+RUN git init -q . \
+ && git fetch -q --depth 1 https://github.com/riscv/riscv-arch-test.git "${ARCH_TEST}" \
+ && git checkout -q FETCH_HEAD \
+ && mise trust \
+ && mise install ruby gem:bundler uv \
+ && BUNDLE_GEMFILE=/act4/framework/src/act/data/Gemfile bundle install \
+ && uv sync --frozen \
+ && riscv64-unknown-elf-gcc --version && sail_riscv_sim --version
diff --git a/conformance/act4/generate.sh b/conformance/act4/generate.sh
new file mode 100755
index 000000000..7bdb2e449
--- /dev/null
+++ b/conformance/act4/generate.sh
@@ -0,0 +1,18 @@
+#!/bin/sh
+# Generate ACT4's self-checking I and M tests for leanVM into elf/ (elf/I, elf/M; not
+# checked in), which lean_vm/tests/verifiers/act4.rs loads. `generate.sh DIR` writes them to DIR
+# instead. Needs Docker, and network access to build the image (Dockerfile) that
+# pins every tool; ACT4_IMAGE names an image already built from it instead.
+set -eu
+here=$(cd "$(dirname "$0")" && pwd)
+out=${1:-$here/elf}
+image=${ACT4_IMAGE:-}
+if [ -z "$image" ]; then
+ image=leanvm-act4
+ docker build -t "$image" - <"$here/Dockerfile"
+fi
+
+mkdir -p "$out"
+out=$(cd "$out" && pwd)
+rm -rf "$out/I" "$out/M"
+docker run --rm -v "$here:/config:ro" -v "$out:/out" -e OWNER="$(id -u):$(id -g)" "$image" sh /config/in-docker.sh
diff --git a/conformance/act4/in-docker.sh b/conformance/act4/in-docker.sh
new file mode 100755
index 000000000..29061192c
--- /dev/null
+++ b/conformance/act4/in-docker.sh
@@ -0,0 +1,18 @@
+#!/bin/sh
+# generate.sh's work inside the image: ACT4 builds the tests (Sail running each one
+# to record the expected values the self-checking build embeds) into /out.
+set -eu
+# Of the other tests ACT4 would select for this configuration, Zicsr's need CSRs, which
+# leanVM does not have (the configuration claims Zicsr only for UDB to define MXLEN),
+# and Zmmul's are M's multiplication tests again.
+/act4/.venv/bin/act /config/test_config.yaml --workdir /act4/work --test-dir tests --extensions I,M --fast
+cp -r /act4/work/leanvm-rv64im/elfs/rv64i/I /act4/work/leanvm-rv64im/elfs/rv64i/M /out/
+for elf in /out/I/*.elf /out/M/*.elf; do
+ # The symbol naming the compiler's temporary object file, whose name is random.
+ riscv64-unknown-elf-objcopy --wildcard --strip-symbol='cc*.o' "$elf"
+ # ACT4 switches compressed instructions on around its alignment padding, which marks
+ # the file as using them (e_flags = EF_RISCV_RVC) though no instruction is compressed.
+ test "$(od -An -tu4 -j48 -N4 "$elf" | tr -d ' ')" = 1
+ printf '\000' | dd of="$elf" bs=1 seek=48 conv=notrunc status=none
+done
+chown -R "$OWNER" /out/I /out/M
diff --git a/conformance/act4/leanvm-rv64im.yaml b/conformance/act4/leanvm-rv64im.yaml
new file mode 100644
index 000000000..439696369
--- /dev/null
+++ b/conformance/act4/leanvm-rv64im.yaml
@@ -0,0 +1,55 @@
+# yaml-language-server: $schema=https://raw.githubusercontent.com/riscv/riscv-unified-db/main/spec/schemas/config_schema.json
+---
+$schema: config_schema.json#
+kind: architecture configuration
+type: fully configured
+name: leanvm-rv64im
+description: leanVM, RV64IM with no misaligned accesses
+
+implemented_extensions:
+ - { name: I, version: "= 2.1" }
+ - { name: M, version: "= 2.0" }
+ - { name: Zmmul, version: "= 1.0.0" }
+ # UDB defines MXLEN through Sm, which needs Zicsr. leanVM has neither: rvmodel_macros.h
+ # leaves STANDARD_SM_SUPPORTED undefined, so no test code touches a CSR.
+ - { name: Zicsr, version: "= 2.0" }
+ - { name: Sm, version: "= 1.12.0" }
+
+params:
+ MUTABLE_MISA_M: false
+ MXLEN: 64
+ PRECISE_SYNCHRONOUS_EXCEPTIONS: true
+ TRAP_ON_ECALL_FROM_M: true
+ TRAP_ON_EBREAK: true
+ MARCHID_IMPLEMENTED: false
+ MIMPID_IMPLEMENTED: false
+ VENDOR_ID_BANK: 0x0
+ VENDOR_ID_OFFSET: 0x0
+ MISALIGNED_LDST: false
+ MISALIGNED_LDST_EXCEPTION_PRIORITY: high
+ TRAP_ON_ILLEGAL_WLRL: false
+ TRAP_ON_UNIMPLEMENTED_INSTRUCTION: true
+ TRAP_ON_RESERVED_INSTRUCTION: true
+ TRAP_ON_UNIMPLEMENTED_CSR: true
+ REPORT_VA_IN_MTVAL_ON_BREAKPOINT: false
+ REPORT_VA_IN_MTVAL_ON_LOAD_MISALIGNED: false
+ REPORT_VA_IN_MTVAL_ON_STORE_AMO_MISALIGNED: false
+ REPORT_VA_IN_MTVAL_ON_INSTRUCTION_MISALIGNED: false
+ REPORT_VA_IN_MTVAL_ON_LOAD_ACCESS_FAULT: false
+ REPORT_VA_IN_MTVAL_ON_STORE_AMO_ACCESS_FAULT: false
+ REPORT_VA_IN_MTVAL_ON_INSTRUCTION_ACCESS_FAULT: false
+ REPORT_ENCODING_IN_MTVAL_ON_ILLEGAL_INSTRUCTION: false
+ MTVAL_WIDTH: 64
+ CONFIG_PTR_ADDRESS: 0
+ PMA_GRANULARITY: 3
+ PHYS_ADDR_WIDTH: 64
+ M_MODE_ENDIANNESS: little
+ MISA_CSR_IMPLEMENTED: false
+ MTVEC_ACCESS: rw
+ MTVEC_MODES: [0]
+ MTVEC_BASE_ALIGNMENT_DIRECT: 4
+ MTVEC_ILLEGAL_WRITE_BEHAVIOR: retain
+ NUM_PMP_ENTRIES: 0
+ MCOUNTINHIBIT_IMPLEMENTED: false
+ HPM_COUNTER_EN: [false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false]
+ MCOUNTENABLE_EN: [false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false]
diff --git a/conformance/act4/link.ld b/conformance/act4/link.ld
new file mode 100644
index 000000000..0061687f6
--- /dev/null
+++ b/conformance/act4/link.ld
@@ -0,0 +1,46 @@
+/* ACT4's test layout on leanVM's memory map (guests/link.ld): the code in the text,
+ every other section in RAM past the input words, and one word of advice. Sail runs
+ the same file on the regions sail.json describes, so the two agree on every address.
+ RAM is the smallest power of two holding the image, the loader reading its size off
+ __stack_top: the tests use no stack. */
+OUTPUT_ARCH("riscv")
+ENTRY(rvtest_entry_point)
+
+MEMORY {
+ TEXT (rx) : ORIGIN = 0x10000000, LENGTH = 256M
+ ADVICE (rw) : ORIGIN = 0x20000000, LENGTH = 8
+ RAM (rw) : ORIGIN = 0x40000000, LENGTH = 1M
+}
+
+PROVIDE(__stack_size = 0);
+PROVIDE(__num_harts = 1);
+
+SECTIONS {
+ /* Separate output sections, so that a test's alignment does not move the entry. The
+ model's code comes last: it differs between the self-checking build and Sail's. */
+ .text.init ORIGIN(TEXT) : { *(.text.init) } > TEXT
+ .text.rvtest : { *(.text.rvtest) *(.text.rvtest.*) } > TEXT
+ .text.rvmodel : { *(.text.rvmodel) *(.text.rvmodel.*) *(.text) *(.text.*) } > TEXT
+
+ /* RAM's first four words are the run's public input: nothing is loaded there, and a
+ section reserving them would make GNU ld write zeros there into the data segment. */
+ .data ORIGIN(RAM) + 32 : {
+ *(.rodata) *(.rodata.*) *(.srodata) *(.srodata.*)
+ *(.data) *(.data.*) *(.sdata) *(.sdata.*)
+ } > RAM
+ /* Sail's tohost, which the self-checking build does not have. */
+ .tohost : { *(.tohost) } > RAM
+ .bss (NOLOAD) : {
+ __bss_start = .;
+ *(.sbss) *(.sbss.*) *(.bss) *(.bss.*) *(COMMON)
+ __bss_end = .;
+ } > RAM
+
+ __stack_bottom = .;
+ _end = .;
+ __stack_top = ORIGIN(RAM) + (1 << LOG2CEIL(_end - ORIGIN(RAM)));
+ __advice_top = ORIGIN(ADVICE) + LENGTH(ADVICE);
+ ASSERT(__stack_top <= ORIGIN(RAM) + LENGTH(RAM), "the test does not fit RAM: grow RAM here and in sail.json")
+
+ /DISCARD/ : { *(.comment) }
+}
diff --git a/conformance/act4/rvmodel_macros.h b/conformance/act4/rvmodel_macros.h
new file mode 100644
index 000000000..51cfecd01
--- /dev/null
+++ b/conformance/act4/rvmodel_macros.h
@@ -0,0 +1,47 @@
+// ACT4's model interface for leanVM. The machine has no CSRs, no traps, no console
+// and no tohost: a test ends in the one ecall it knows, exit (a7 = 93), with the
+// output a0..a3. A pass is all zeros. A failure that reaches the model's halt is
+// a0 = 1 and a1 = the return address of the call to it, which names the check.
+// STANDARD_SM_SUPPORTED stays undefined, so ACT4 emits no CSR code.
+// SPDX-License-Identifier: BSD-3-Clause
+
+#ifndef _RVMODEL_MACROS_H
+#define _RVMODEL_MACROS_H
+
+// Only Sail's build has a tohost, which sail_macros.h defines.
+#define RVMODEL_DATA_SECTION
+
+// Nothing traps, so the trap signature region, 15000 entries by default, holds nothing.
+#define TRAP_SIGUPD_COUNT 0
+
+#define RVMODEL_HALT_EXIT \
+ li a2, 0 ;\
+ li a3, 0 ;\
+ li a7, 93 ;\
+ ecall ;
+
+#define RVMODEL_HALT_PASS \
+ li a0, 0 ;\
+ li a1, 0 ;\
+ RVMODEL_HALT_EXIT
+
+#define RVMODEL_HALT_FAIL \
+ li a0, 1 ;\
+ mv a1, ra ;\
+ RVMODEL_HALT_EXIT
+
+#define RVMODEL_IO_WRITE_STR(_R1, _R2, _R3, _STR_PTR)
+
+// Required by check_defines.h, expanded only with STANDARD_SM_SUPPORTED.
+#define RVMODEL_INTERRUPT_LATENCY 10
+#define RVMODEL_TIMER_INT_SOON_DELAY 100
+#define RVMODEL_SET_MEXT_INT(_R1, _R2)
+#define RVMODEL_CLR_MEXT_INT(_R1, _R2)
+#define RVMODEL_SET_MSW_INT(_R1, _R2)
+#define RVMODEL_CLR_MSW_INT(_R1, _R2)
+#define RVMODEL_SET_SEXT_INT(_R1, _R2)
+#define RVMODEL_CLR_SEXT_INT(_R1, _R2)
+#define RVMODEL_SET_SSW_INT(_R1, _R2)
+#define RVMODEL_CLR_SSW_INT(_R1, _R2)
+
+#endif // _RVMODEL_MACROS_H
diff --git a/conformance/act4/sail.json b/conformance/act4/sail.json
new file mode 100644
index 000000000..2b94d9ddf
--- /dev/null
+++ b/conformance/act4/sail.json
@@ -0,0 +1,595 @@
+{
+ "$schema": "/opt/sail/share/sail-riscv/sail_riscv_config_schema.json",
+ "base": {
+ "xlen": 64,
+ "E": false,
+ "writable_misa": false,
+ "writable_fiom": false,
+ "writable_hpm_counters": {
+ "len": 32,
+ "value": "0x0"
+ },
+ "scounteren_writable_bits": {
+ "len": 32,
+ "value": "0x0"
+ },
+ "mcounteren_writable_bits": {
+ "len": 32,
+ "value": "0x0"
+ },
+ "mtvec": {
+ "direct": {
+ "supported": true,
+ "base_alignment": 2
+ },
+ "vectored": {
+ "supported": false,
+ "base_alignment": 2
+ }
+ },
+ "stvec": {
+ "direct": {
+ "supported": false
+ },
+ "vectored": {
+ "supported": false,
+ "base_alignment": 2
+ }
+ },
+ "medeleg": {
+ "delegatable_bits": {
+ "len": 64,
+ "value": "0x0000_0000_000c_b3FF"
+ }
+ },
+ "mideleg": {
+ "delegatable_bits": {
+ "len": "xlen",
+ "value": "0x0000_0000_0000_2222"
+ }
+ },
+ "xtval_nonzero": {
+ "illegal_instruction": false,
+ "software_breakpoint": false,
+ "hardware_breakpoint": false,
+ "load_address_misaligned": false,
+ "load_access_fault": false,
+ "load_page_fault": false,
+ "samo_address_misaligned": false,
+ "samo_access_fault": false,
+ "samo_page_fault": false,
+ "fetch_address_misaligned": false,
+ "fetch_access_fault": false,
+ "fetch_page_fault": false,
+ "software_check": false,
+ "reserved_exceptions": false
+ },
+ "reserved_behavior": {
+ "amocas_odd_register": "AMOCAS_Illegal",
+ "fcsr_rm": "Fcsr_RM_Illegal",
+ "pmpcfg_write_only": "PMP_ClearPermissions",
+ "xenvcfg_cbie": "Xenvcfg_ClearPermissions",
+ "xtvec_mode": "Xtvec_Ignore",
+ "rv32zdinx_odd_register": "Zdinx_Illegal"
+ },
+ "mstatus": {
+ "fs_legal_states": "ExtContext_Off",
+ "vs_legal_states": "ExtContext_Off"
+ },
+ "privileged_isa_version": "Privileged_ISA_1_12"
+ },
+ "memory": {
+ "physaddr_bits": 64,
+ "pmp": {
+ "grain": 0,
+ "count": 0,
+ "usable_count": 0,
+ "tor_supported": false,
+ "na4_supported": false,
+ "napot_supported": false
+ },
+ "misaligned": {
+ "exceptions": {
+ "load_store": {
+ "Some": "AlignmentException"
+ },
+ "vector": {
+ "None": null
+ },
+ "amo": {
+ "Some": "AccessFault"
+ },
+ "lrsc": "AccessFault"
+ },
+ "order_decreasing": false,
+ "default_allowed_within_exp": 0,
+ "byte_by_byte": true
+ },
+ "dtb_address": {
+ "len": 64,
+ "value": "0x0"
+ },
+ "regions": [
+ {
+ "base": {
+ "len": 64,
+ "value": "0x2000000"
+ },
+ "size": {
+ "len": 64,
+ "value": "0x100000"
+ },
+ "attributes": {
+ "mem_type": "IOMemory",
+ "cacheable": false,
+ "coherent": true,
+ "executable": false,
+ "readable": true,
+ "writable": true,
+ "read_idempotent": false,
+ "write_idempotent": false,
+ "misaligned_exceptions": {
+ "load_store": {
+ "Some": "AlignmentException"
+ },
+ "vector": {
+ "None": null
+ },
+ "amo": "AccessFault"
+ },
+ "atomic_support": "AMONone",
+ "misaligned_atomicity_granule_size_exp": 0,
+ "vector_misaligned_atomicity_granule_size_exp": 0,
+ "reservability": "RsrvNone",
+ "supports_cbo_zero": false,
+ "supports_pte_read": false,
+ "supports_pte_write": false
+ },
+ "include_in_device_tree": false
+ },
+ {
+ "base": {
+ "len": 64,
+ "value": "0x10000000"
+ },
+ "size": {
+ "len": 64,
+ "value": "0x10000000"
+ },
+ "attributes": {
+ "mem_type": "MainMemory",
+ "cacheable": true,
+ "coherent": true,
+ "executable": true,
+ "readable": true,
+ "writable": true,
+ "read_idempotent": true,
+ "write_idempotent": true,
+ "misaligned_exceptions": {
+ "load_store": {
+ "Some": "AlignmentException"
+ },
+ "vector": {
+ "None": null
+ },
+ "amo": "AccessFault"
+ },
+ "atomic_support": "AMONone",
+ "misaligned_atomicity_granule_size_exp": 0,
+ "vector_misaligned_atomicity_granule_size_exp": 0,
+ "reservability": "RsrvNone",
+ "supports_cbo_zero": false,
+ "supports_pte_read": false,
+ "supports_pte_write": false
+ },
+ "include_in_device_tree": false
+ },
+ {
+ "base": {
+ "len": 64,
+ "value": "0x40000000"
+ },
+ "size": {
+ "len": 64,
+ "value": "0x100000"
+ },
+ "attributes": {
+ "mem_type": "MainMemory",
+ "cacheable": true,
+ "coherent": true,
+ "executable": false,
+ "readable": true,
+ "writable": true,
+ "read_idempotent": true,
+ "write_idempotent": true,
+ "misaligned_exceptions": {
+ "load_store": {
+ "Some": "AlignmentException"
+ },
+ "vector": {
+ "None": null
+ },
+ "amo": "AccessFault"
+ },
+ "atomic_support": "AMONone",
+ "misaligned_atomicity_granule_size_exp": 0,
+ "vector_misaligned_atomicity_granule_size_exp": 0,
+ "reservability": "RsrvNone",
+ "supports_cbo_zero": false,
+ "supports_pte_read": false,
+ "supports_pte_write": false
+ },
+ "include_in_device_tree": false
+ }
+ ]
+ },
+ "platform": {
+ "vendorid": 0,
+ "archid": 0,
+ "impid": 0,
+ "hartid": 0,
+ "cache_block_size_exp": 6,
+ "reservation": {
+ "reservation_set_size_exp": 3,
+ "require_exact_reservation_addr": false,
+ "invalidate_on_same_hart_store": false
+ },
+ "clint": {
+ "supported": true,
+ "base": 33554432,
+ "size": 786432
+ },
+ "simple_interrupt_generator": {
+ "supported": true,
+ "base": 34340864
+ },
+ "clock_frequency": 1000000000,
+ "instructions_per_tick": 2,
+ "wfi_is_nop": true,
+ "wfi_available_to_user_mode": false,
+ "max_time_to_wait": 200
+ },
+ "extensions": {
+ "M": {
+ "supported": true
+ },
+ "A": {
+ "supported": false
+ },
+ "F": {
+ "supported": false,
+ "fflags_dirty_policy": "Fflags_Dirty_Precise"
+ },
+ "D": {
+ "supported": false
+ },
+ "V": {
+ "support_level": "Disabled",
+ "vlen_exp": 8,
+ "elen_exp": 6,
+ "reserved_behavior": {
+ "illegal_vtype": "IllegalVtype_SetVill",
+ "vstart_out_of_bounds": "Vstart_Illegal"
+ },
+ "vl_use_ceil": false,
+ "max_index_eew_exp": 6,
+ "vstart": {
+ "zero_required": {
+ "arith": true,
+ "scalar_move": true
+ }
+ }
+ },
+ "B": {
+ "supported": false
+ },
+ "S": {
+ "supported": false
+ },
+ "U": {
+ "supported": false
+ },
+ "Zibi": {
+ "supported": false
+ },
+ "Zic64b": {
+ "supported": false
+ },
+ "Zicbom": {
+ "supported": false
+ },
+ "Zicbop": {
+ "supported": false
+ },
+ "Zicboz": {
+ "supported": false
+ },
+ "Ziccamoa": {
+ "supported": false
+ },
+ "Ziccamoc": {
+ "supported": false
+ },
+ "Ziccif": {
+ "supported": false
+ },
+ "Zicclsm": {
+ "supported": false
+ },
+ "Ziccrse": {
+ "supported": false
+ },
+ "Zicfilp": {
+ "supported": false
+ },
+ "Zicfiss": {
+ "supported": false
+ },
+ "Zicond": {
+ "supported": false
+ },
+ "Zicntr": {
+ "supported": false
+ },
+ "Zicsr": {
+ "supported": true
+ },
+ "Zifencei": {
+ "supported": false
+ },
+ "Zihintntl": {
+ "supported": false
+ },
+ "Zihintpause": {
+ "supported": false
+ },
+ "Zihpm": {
+ "supported": false
+ },
+ "Zimop": {
+ "supported": false
+ },
+ "Zmmul": {
+ "supported": true
+ },
+ "Zaamo": {
+ "supported": false
+ },
+ "Zabha": {
+ "supported": false
+ },
+ "Zacas": {
+ "supported": false
+ },
+ "Zalrsc": {
+ "supported": false
+ },
+ "Zama16b": {
+ "supported": false
+ },
+ "Zawrs": {
+ "supported": false,
+ "nto": {
+ "is_nop": false
+ },
+ "sto": {
+ "is_nop": false
+ }
+ },
+ "Zfa": {
+ "supported": false
+ },
+ "Zfbfmin": {
+ "supported": false
+ },
+ "Zfh": {
+ "supported": false
+ },
+ "Zfhmin": {
+ "supported": false
+ },
+ "Zfinx": {
+ "supported": false
+ },
+ "Zdinx": {
+ "supported": false
+ },
+ "Zca": {
+ "supported": false
+ },
+ "Zcf": {
+ "supported": false
+ },
+ "Zcd": {
+ "supported": false
+ },
+ "Zcb": {
+ "supported": false
+ },
+ "Zcmop": {
+ "supported": false
+ },
+ "Zba": {
+ "supported": false
+ },
+ "Zbb": {
+ "supported": false
+ },
+ "Zbs": {
+ "supported": false
+ },
+ "Zbc": {
+ "supported": false
+ },
+ "Zbkb": {
+ "supported": false
+ },
+ "Zbkc": {
+ "supported": false
+ },
+ "Zbkx": {
+ "supported": false
+ },
+ "Zknd": {
+ "supported": false
+ },
+ "Zkne": {
+ "supported": false
+ },
+ "Zknh": {
+ "supported": false
+ },
+ "Zkr": {
+ "supported": false,
+ "sseed_reset_value": false,
+ "useed_reset_value": false,
+ "sseed_read_only_zero": false,
+ "useed_read_only_zero": false
+ },
+ "Zksed": {
+ "supported": false
+ },
+ "Zksh": {
+ "supported": false
+ },
+ "Zkt": {
+ "supported": false
+ },
+ "Zhinx": {
+ "supported": false
+ },
+ "Zhinxmin": {
+ "supported": false
+ },
+ "Zvabd": {
+ "supported": false
+ },
+ "Zvfbfmin": {
+ "supported": false
+ },
+ "Zvfbfwma": {
+ "supported": false
+ },
+ "Zvfh": {
+ "supported": false
+ },
+ "Zvfhmin": {
+ "supported": false
+ },
+ "Zvbb": {
+ "supported": false
+ },
+ "Zvbc": {
+ "supported": false
+ },
+ "Zvkb": {
+ "supported": false
+ },
+ "Zvkg": {
+ "supported": false
+ },
+ "Zvkned": {
+ "supported": false
+ },
+ "Zvknha": {
+ "supported": false
+ },
+ "Zvknhb": {
+ "supported": false
+ },
+ "Zvksed": {
+ "supported": false
+ },
+ "Zvksh": {
+ "supported": false
+ },
+ "Zvkt": {
+ "supported": false
+ },
+ "Ssccptr": {
+ "supported": false
+ },
+ "Sscofpmf": {
+ "supported": false
+ },
+ "Sscounterenw": {
+ "supported": false
+ },
+ "Sstc": {
+ "supported": false
+ },
+ "Sstvala": {
+ "supported": false
+ },
+ "Svade": {
+ "supported": false
+ },
+ "Svadu": {
+ "supported": false
+ },
+ "Svinval": {
+ "supported": false
+ },
+ "Svrsw60t59b": {
+ "supported": false
+ },
+ "Svnapot": {
+ "supported": false
+ },
+ "Ssnpm": {
+ "supported": false,
+ "supported_pmlen_7": true,
+ "supported_pmlen_16": true
+ },
+ "Smnpm": {
+ "supported": false,
+ "supported_pmlen_7": true,
+ "supported_pmlen_16": true
+ },
+ "Smmpm": {
+ "supported": false,
+ "supported_pmlen_7": true,
+ "supported_pmlen_16": true
+ },
+ "Smcntrpmf": {
+ "supported": false
+ },
+ "Svbare": {
+ "supported": false,
+ "sfence_vma_illegal_if_svbare_only": true
+ },
+ "Sv32": {
+ "supported": false
+ },
+ "Sv39": {
+ "supported": false
+ },
+ "Sv48": {
+ "supported": false
+ },
+ "Sv57": {
+ "supported": false
+ },
+ "Stateen": {
+ "Smstateen": {
+ "supported": false
+ },
+ "Ssstateen": {
+ "supported": false
+ },
+ "C_readonly_zero": true,
+ "SE0_readonly_zero": false
+ },
+ "Ssqosid": {
+ "supported": false,
+ "rcid_length": 12,
+ "mcid_length": 12
+ },
+ "Svpbmt": {
+ "supported": false
+ },
+ "Svvptc": {
+ "supported": false
+ }
+ }
+}
diff --git a/conformance/act4/test_config.yaml b/conformance/act4/test_config.yaml
new file mode 100644
index 000000000..ad86091e4
--- /dev/null
+++ b/conformance/act4/test_config.yaml
@@ -0,0 +1,8 @@
+name: leanvm-rv64im
+compiler_exe: riscv64-unknown-elf-gcc
+objdump_exe: riscv64-unknown-elf-objdump
+ref_model_exe: sail_riscv_sim
+udb_config: leanvm-rv64im.yaml
+linker_script: link.ld
+dut_include_dir: .
+include_priv_tests: false
diff --git a/crates/fiat_shamir/src/lib.rs b/crates/fiat_shamir/src/lib.rs
index 32a31618b..08ef8d963 100644
--- a/crates/fiat_shamir/src/lib.rs
+++ b/crates/fiat_shamir/src/lib.rs
@@ -9,8 +9,7 @@ use primitives::field::{F64, F192};
/// `f(a, b) = BLAKE2s(a‖b)` on two 256-bit halves laid out little-endian into
/// 64 bytes, *exactly* the VM's `Blake2s` opcode: 64 input bytes → 32-byte
/// digest, split back into four field words. THE primitive; the chain is a
-/// chain of these, so a zkDSL program replays it with one `blake2s(...)` per
-/// step.
+/// chain of these, so a VM program replays it with one `BLAKE2S` row per step.
///
/// A 64-byte input is one compression, so this is `compress(PARAM_IV, m,
/// t = 64, last = true)` and nothing about the byte-level padding rules can
@@ -40,9 +39,7 @@ const DS_POW_NONCE: F64 = F64(4);
/// `compress(base, (nonce.c0, nonce.c1, nonce.c2, DS_POW_NONCE))` has its low `bits`
/// bits zero: the grinding predicate over the VM compression. A CONTIGUOUS
-/// low-bit window (rather than byte-wise leading zeros) so a recursive verifier
-/// re-checks it with a single loop over the bit decomposition of the digest word
-/// (`grind_check` in `guests/lean_ethereum.py`). `bits` is always `< 64`.
+/// low-bit window rather than byte-wise leading zeros. `bits` is always `< 64`.
#[inline]
fn pow_bits_ok(base: [F64; 4], nonce: F192, bits: u32) -> bool {
debug_assert!(bits < 64, "grinding deficit fits the digest's low word");
diff --git a/crates/fiat_shamir/src/merkle.rs b/crates/fiat_shamir/src/merkle.rs
index bdab343e7..bdd9a111e 100644
--- a/crates/fiat_shamir/src/merkle.rs
+++ b/crates/fiat_shamir/src/merkle.rs
@@ -8,8 +8,8 @@ pub type Hash = [u8; 32];
/// Encode a Merkle hash as the two field words transcripts carry it in: two
/// 128-bit halves, each a K pair with a spare top lane. Every digest in the
-/// protocol uses this one split (the commitment root, the public input, the
-/// guest's MD state), so the VM sees one shape everywhere.
+/// protocol uses this one split (the commitment root, the public input), so the
+/// VM sees one shape everywhere.
#[inline]
pub fn hash_to_scalars(hash: &Hash) -> [F192; 2] {
let word_at = |offset: usize| u64::from_le_bytes(hash[offset..offset + 8].try_into().unwrap());
@@ -241,12 +241,12 @@ impl PrunedMerklePaths {
///
/// The redundant form. Several queries of one phase repeat whatever siblings
/// they share, which is exactly what makes it simple to consume: recomputing
-/// the root is a walk up one path, with no dedup bookkeeping. Recursive witness
-/// construction and the Python verifier consume this; the wire format
-/// ([`PrunedMerklePaths`]) sends each shared sibling once.
+/// the root is a walk up one path, with no dedup bookkeeping. The Python
+/// verifier consumes this; the wire format ([`PrunedMerklePaths`]) sends each
+/// shared sibling once.
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct RawMerklePath {
- /// Transcript-derived position, retained for recursive witness construction.
+ /// Transcript-derived position.
pub leaf_index: usize,
pub leaf_data: Vec,
pub path: Vec,
diff --git a/crates/fiat_shamir/src/transcript.rs b/crates/fiat_shamir/src/transcript.rs
index fefba8478..8fc900c5c 100644
--- a/crates/fiat_shamir/src/transcript.rs
+++ b/crates/fiat_shamir/src/transcript.rs
@@ -11,11 +11,10 @@ pub struct Proof {
pub merkle: Vec,
}
-/// The proof the recursion guest and the Python verifier consume: [`Proof`] with
-/// every query's Merkle path written out, which is the one thing they would
-/// otherwise have to reconstruct. A verifier run yields it as a by-product
-/// ([`VerifierState::into_raw_proof`]), so that expansion is written once, in
-/// Rust, instead of three times in three languages.
+/// The proof the Python verifier consumes: [`Proof`] with every query's Merkle
+/// path written out, which is the one thing it would otherwise have to
+/// reconstruct. A verifier run yields it as a by-product
+/// ([`VerifierState::into_raw_proof`]), so that expansion is written once, in Rust.
pub type RawProof = Proof;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -44,8 +43,7 @@ pub trait Transmitter: Challenger {
fn add_scalars(&mut self, xs: &[F192]);
fn grind(&mut self, bits: u32);
- /// Transmit a root as its two scalars, not as a byte string, so the recursion guest replays
- /// one shape for every digest. Sending it is what binds it: no verifier absorbs a root
+ /// Transmit a root as its two scalars, not as a byte string. Sending it is what binds it: no verifier absorbs a root
/// separately (see [`Receiver::next_root`], its mirror).
fn add_root(&mut self, root: &Hash) {
self.add_scalars(&hash_to_scalars(root));
@@ -200,13 +198,6 @@ impl<'a> VerifierState<'a> {
}
}
- /// How many scalars have been read so far: the cursor into the stream a
- /// caller needs to locate a sub-protocol's scalars without counting back
- /// from the tail.
- pub fn stream_offset(&self) -> usize {
- self.offset
- }
-
/// Assert the whole proof was consumed (no trailing/extra data).
pub fn finish(&self) -> Result<(), Error> {
if self.offset == self.stream.len() && self.phase == self.merkle.len() {
diff --git a/crates/flock/src/arith.rs b/crates/flock/src/arith.rs
new file mode 100644
index 000000000..aa3fbac92
--- /dev/null
+++ b/crates/flock/src/arith.rs
@@ -0,0 +1,270 @@
+//! u64 arithmetic as Flock R1CS circuits, one operation per block: wrapping
+//! addition ([`add`]), and multiplication ([`mul`]) wrapping or widening.
+//!
+//! Each is a [`crate::circuit`] gate list over the ports `a`, `b` and the result, in
+//! that order ([`A_BASE`], [`B_BASE`], [`OUT_BASE`]), built from [`add::Adder`] and
+//! [`mul::Multiplier`], which take wires and return wires and so compose into
+//! larger circuits. Here the witness is not the generic walk of the gate list but
+//! word arithmetic on the structure the list is built from, one instance at a time.
+
+pub mod add;
+pub mod mul;
+
+use crate::circuit::{Builder, Circuit};
+use crate::reduction::Block;
+use zk_alloc::ArenaVec;
+
+pub const A_BASE: usize = 0;
+pub const B_BASE: usize = 64;
+pub const OUT_BASE: usize = 128;
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum U64Op {
+ /// `a + b mod 2^64`.
+ WrappingAdd,
+ /// `a·b mod 2^64`.
+ WrappingMul,
+ /// `a·b` as a u128.
+ WideningMul,
+}
+
+impl U64Op {
+ /// Bits of the committed result.
+ pub const fn out_bits(self) -> usize {
+ match self {
+ Self::WrappingAdd | Self::WrappingMul => 64,
+ Self::WideningMul => 128,
+ }
+ }
+}
+
+/// One instance's words of `z`, `A·z` and `B·z`.
+struct Instance<'a> {
+ z: &'a mut [u64],
+ az: &'a mut [u64],
+ bz: &'a mut [u64],
+}
+
+impl Instance<'_> {
+ /// `width` rows from `slot` whose B side is the constant, with `A·z = z = v`.
+ fn unit_rows(&mut self, slot: usize, v: u128, width: usize) {
+ or_bits(self.z, slot, v);
+ or_bits(self.az, slot, v);
+ or_bits(self.bz, slot, u128::MAX >> (128 - width));
+ }
+
+ /// Product rows from `slot`, one per position of `mask`, with `A·z = left`,
+ /// `B·z = right` and `z = left·right`.
+ fn products(&mut self, slot: usize, mask: u128, left: u128, right: u128) {
+ if mask != 0 {
+ let shift = mask.trailing_zeros();
+ or_bits(self.z, slot, (left & right & mask) >> shift);
+ or_bits(self.az, slot, (left & mask) >> shift);
+ or_bits(self.bz, slot, (right & mask) >> shift);
+ }
+ }
+}
+
+/// OR `v` into `buf` from bit `at`.
+#[inline(always)]
+fn or_bits(buf: &mut [u64], at: usize, v: u128) {
+ let s = at % 64;
+ let words = [
+ (v << s) as u64,
+ ((v >> 1) >> (63 - s)) as u64,
+ ((v >> 1) >> (127 - s)) as u64,
+ ];
+ for (w, x) in buf[at / 64..].iter_mut().zip(words) {
+ *w |= x;
+ }
+}
+
+/// What an operation's witness is computed from, besides its inputs.
+enum Plan {
+ Add(add::Adder),
+ Mul(mul::Multiplier),
+}
+
+pub struct U64Circuit {
+ op: U64Op,
+ circuit: Circuit,
+ plan: Plan,
+}
+
+impl U64Circuit {
+ pub fn new(op: U64Op) -> Self {
+ let n = op.out_bits();
+ let mut c = Builder::new(&[64, 64], &[n]);
+ let (a, b) = (c.input(0), c.input(1));
+ let (out, plan) = match op {
+ U64Op::WrappingAdd => {
+ let (out, adder) = add::Adder::build(&mut c, &a, &b);
+ (out, Plan::Add(adder))
+ }
+ U64Op::WrappingMul | U64Op::WideningMul => {
+ let (out, multiplier) = mul::Multiplier::build(&mut c, &a, &b, n);
+ (out, Plan::Mul(multiplier))
+ }
+ };
+ for (i, wire) in out.into_iter().enumerate() {
+ c.output(0, i, wire);
+ }
+ let circuit = c.finish();
+ assert_eq!(circuit.const_pos(), OUT_BASE + n);
+ Self { op, circuit, plan }
+ }
+
+ pub fn circuit(&self) -> &Circuit {
+ &self.circuit
+ }
+
+ pub fn k_log(&self) -> usize {
+ self.circuit.k_log()
+ }
+
+ pub fn useful_bits(&self) -> usize {
+ self.circuit.useful_bits()
+ }
+
+ pub fn block(&self) -> Block<'_> {
+ self.circuit.block()
+ }
+
+ /// `(z, a, b, z_lincheck)` for `pairs` padded with `(0, 0)` to
+ /// `2^n_blocks_log` instances: the bit-packed `z`, `A·z` and `B·z`
+ /// (`2^k_log / 64` words per instance), and lincheck's byte stripes.
+ pub fn generate_witness(
+ &self,
+ pairs: &[(u64, u64)],
+ n_blocks_log: usize,
+ ) -> (ArenaVec, ArenaVec, ArenaVec, ArenaVec) {
+ let n = self.op.out_bits();
+ self.circuit
+ .generate_witness_with(pairs, &(0, 0), n_blocks_log, |&(a, b), z, az, bz| {
+ let mut witness = Instance { z, az, bz };
+ let out = match &self.plan {
+ Plan::Add(adder) => adder.witness(a, b, &mut witness),
+ Plan::Mul(multiplier) => multiplier.witness(a, b, &mut witness),
+ };
+ witness.unit_rows(A_BASE, a as u128, 64);
+ witness.unit_rows(B_BASE, b as u128, 64);
+ witness.unit_rows(OUT_BASE, out, n);
+ witness.unit_rows(self.circuit.const_pos(), 1, 1);
+ })
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::lincheck::LincheckCircuit;
+ use fiat_shamir::transcript::{ProverState, VerifierState};
+ use primitives::field::F192;
+ use primitives::test_rng::Rng;
+
+ const OPS: [U64Op; 3] = [U64Op::WrappingAdd, U64Op::WrappingMul, U64Op::WideningMul];
+
+ fn native(op: U64Op, a: u64, b: u64) -> u128 {
+ let (a, b) = (a as u128, b as u128);
+ match op {
+ U64Op::WrappingAdd => (a + b) as u64 as u128,
+ U64Op::WrappingMul => (a * b) as u64 as u128,
+ U64Op::WideningMul => a * b,
+ }
+ }
+
+ /// Every pairing of the carry-heavy edge values, then random pairs.
+ fn pairs(n: usize, seed: u64) -> Vec<(u64, u64)> {
+ const EDGES: [u64; 6] = [0, 1, 2, 1 << 63, u64::MAX - 1, u64::MAX];
+ let mut rng = Rng::new(seed);
+ EDGES
+ .iter()
+ .flat_map(|&x| EDGES.iter().map(move |&y| (x, y)))
+ .chain(std::iter::repeat_with(|| (rng.next_u64(), rng.next_u64())))
+ .take(n)
+ .collect()
+ }
+
+ /// The committed result is the native one and every row holds, which ties
+ /// the word-level witness to the gate list the walks read.
+ #[test]
+ fn witness_is_the_result_and_satisfies_r1cs() {
+ let n_log = 6;
+ for op in OPS {
+ let circuit = U64Circuit::new(op);
+ let k = circuit.circuit.n_cols();
+ let pairs = pairs(1 << n_log, 0x3A11);
+ let (z, _, _, _) = circuit.generate_witness(&pairs, n_log);
+ for (t, &(x, y)) in pairs.iter().enumerate() {
+ let word = |w: usize| z[t * (k / 64) + w];
+ let out = (word(2) as u128 | (word(3) as u128) << 64) & (u128::MAX >> (128 - op.out_bits()));
+ assert_eq!((word(0), word(1), out), (x, y, native(op, x, y)), "{op:?}");
+ let block: Vec = (0..k)
+ .map(|i| {
+ if (z[(t * k + i) / 64] >> (i % 64)) & 1 == 1 {
+ F192::ONE
+ } else {
+ F192::ZERO
+ }
+ })
+ .collect();
+ let (ra, rb) = circuit.circuit.row_values(&block);
+ assert!((0..k).all(|i| ra[i] * rb[i] == block[i]), "{op:?} ({x}, {y})");
+ }
+ }
+ }
+
+ /// The generic walk of the gate list writes the very tables the word arithmetic does.
+ #[test]
+ fn generic_witness_is_the_word_arithmetic() {
+ let n_log = 4;
+ for op in OPS {
+ let circuit = U64Circuit::new(op);
+ let pairs = pairs(1 << n_log, 0x3A13);
+ let rows: Vec<[u64; 2]> = pairs.iter().map(|&(a, b)| [a, b]).collect();
+ let fast = circuit.generate_witness(&pairs, n_log);
+ let generic = circuit.circuit.generate_witness(&rows, n_log);
+ assert!(fast.0[..] == generic.0[..], "{op:?}: z");
+ assert!(fast.1[..] == generic.1[..], "{op:?}: A·z");
+ assert!(fast.2[..] == generic.2[..], "{op:?}: B·z");
+ assert!(fast.3[..] == generic.3[..], "{op:?}: stripes");
+ }
+ }
+
+ /// The reduction verifies an honest batch, which is also what ties the
+ /// prover's backward walk and the `A·z`, `B·z` tables to the verifier's
+ /// forward walk, and rejects one flipped witness bit.
+ #[test]
+ fn reduction_roundtrip_rejects_tampering() {
+ const LABEL: &[u8] = b"flock-arith-reduction-test";
+ // The zerocheck needs a cube of at least 2^13 bits.
+ let n_log = 5;
+ for op in OPS {
+ let circuit = U64Circuit::new(op);
+ let block = circuit.block();
+ let pairs = pairs(1 << n_log, 0x3A12);
+ let run = |tamper: Option| {
+ let (mut z, a, b, mut z_lincheck) = circuit.generate_witness(&pairs, n_log);
+ if let Some(bit) = tamper {
+ z[bit / 64] ^= 1 << (bit % 64);
+ z_lincheck[bit] ^= 1;
+ }
+ let mut ps = ProverState::from_label(LABEL);
+ let stage = block.prove_zerocheck(n_log, &z, &a, &b, &mut ps);
+ let claim = block.prove_lincheck(n_log, stage, &z_lincheck, &mut ps);
+ let proof = ps.into_proof();
+ let mut vs = VerifierState::from_label(LABEL, &proof);
+ block.verify(n_log, &mut vs).is_ok_and(|r| r.claim == claim) && vs.finish().is_ok()
+ };
+ assert!(run(None), "{op:?}");
+ for bit in [
+ A_BASE + 3,
+ OUT_BASE + 5,
+ circuit.circuit.const_pos(),
+ circuit.useful_bits() - 1,
+ ] {
+ assert!(!run(Some(bit)), "{op:?}: flipping bit {bit} must reject");
+ }
+ }
+ }
+}
diff --git a/crates/flock/src/arith/add.rs b/crates/flock/src/arith/add.rs
new file mode 100644
index 000000000..1744d2bc8
--- /dev/null
+++ b/crates/flock/src/arith/add.rs
@@ -0,0 +1,43 @@
+//! Wrapping addition, as a ripple-carry adder. The carry into position `i + 1`
+//! is `maj(a_i, b_i, c_i) = (a_i ⊕ c_i)(b_i ⊕ c_i) ⊕ c_i`, one product, and every
+//! other wire is affine, so the circuit pays 63 products: the carry out of bit
+//! 63 falls off the modulus. The witness is the native sum, whose carries are
+//! `(a + b) ⊕ a ⊕ b`.
+
+use super::Instance;
+use crate::circuit::{Builder, Wire};
+
+/// The carries into bits 1 to 63.
+const CARRIES: u128 = (u64::MAX >> 1) as u128;
+
+pub struct Adder {
+ /// The first of the carries' 63 product slots.
+ slot: usize,
+}
+
+impl Adder {
+ /// `a + b mod 2^64`, as wires.
+ pub fn build(c: &mut Builder, a: &[Wire], b: &[Wire]) -> (Vec, Self) {
+ let slot = c.next_slot();
+ let mut carry = None;
+ let mut sum = Vec::with_capacity(64);
+ for i in 0..64 {
+ let ac = c.xor(a[i], carry);
+ let bc = c.xor(b[i], carry);
+ sum.push(c.xor(ac, b[i]));
+ if i < 63 {
+ let maj = c.and(ac, bc);
+ carry = c.xor(maj, carry);
+ }
+ }
+ (sum, Self { slot })
+ }
+
+ /// Writes the carries' rows and returns the result.
+ pub(super) fn witness(&self, a: u64, b: u64, witness: &mut Instance) -> u128 {
+ let sum = a.wrapping_add(b);
+ let carry_in = sum ^ a ^ b;
+ witness.products(self.slot, CARRIES, (a ^ carry_in) as u128, (b ^ carry_in) as u128);
+ sum as u128
+ }
+}
diff --git a/crates/flock/src/arith/mul.rs b/crates/flock/src/arith/mul.rs
new file mode 100644
index 000000000..4d126c7f8
--- /dev/null
+++ b/crates/flock/src/arith/mul.rs
@@ -0,0 +1,241 @@
+//! Multiplication.
+//!
+//! ## Partial products are free
+//!
+//! A schoolbook multiplier pays one product per partial product `a_i·b_j`, then
+//! one per carry to sum them. Over GF(2) the first half is avoidable: with
+//! `e_ij = ¬(a_i ⊕ b_j)`, `2·a_i·b_j = a_i + b_j − 1 + e_ij`. Summed, with
+//! `M = 2^64 − 1` and `¬a = M − a` the 64-bit complement,
+//!
+//! ```text
+//! 2ab = Σ_i (a_i ? b : ¬b)·2^i + ¬a + ¬b + (a + b)·2^64 + 1 − 2^128
+//! ```
+//!
+//! Every row there is affine in the inputs. Its column 0,
+//! `¬(a_0 ⊕ b_0) + ¬a_0 + ¬b_0 + 1`, is `2 + 2g` with `g = ¬a_0·¬b_0`, so after
+//! that one product the identity halves: `a·b mod 2^N` is `1 + g` plus the other
+//! columns shifted down a place. That is 66 rows of affine bits, with `1` and
+//! `g` in the empty low bits of two of them.
+//!
+//! ## Compression
+//!
+//! A carry-save step turns three rows into their XOR and their majority shifted
+//! up a place. The majority `(x ⊕ z)(y ⊕ z) ⊕ z` is one product at each position
+//! where at least two rows have a bit, except where exactly two do and the carry
+//! row is still free there: one of the two bits moves into it instead. Taking
+//! the three rows that end lowest each time, the 64 steps cost as few products
+//! as summing column by column, and a ripple-carry addition finishes the last
+//! two rows. The top position's majority would carry out of the modulus, so it
+//! is never a product.
+//!
+//! Every step is word arithmetic on `u128` rows and its products are one run of
+//! slots, so an instance's witness is a few shifts and masks per step.
+
+use super::Instance;
+use crate::circuit::{Builder, Wire};
+
+/// The rows `(a_i ? b : ¬b)` for `i < 64`, then the `a` and `b` rows.
+const N_ROWS: usize = 66;
+const A_ROW: usize = 64;
+const B_ROW: usize = 65;
+
+/// One carry-save step: rows `x`, `y`, `z` become the sum row, stored in `x`,
+/// and the carry row, stored in `y`.
+#[derive(Clone, Copy)]
+struct Csa {
+ x: usize,
+ y: usize,
+ z: usize,
+ /// Positions whose majority is a product, one run of slots from `slot`.
+ products: u128,
+ /// Positions where the pair's `y` (or `z`) bit moves to the carry row.
+ move_y: u128,
+ move_z: u128,
+ slot: usize,
+}
+
+/// A row's `(highest, lowest)` position.
+fn ends(row: u128) -> (u32, u32) {
+ (127 - row.leading_zeros(), row.trailing_zeros())
+}
+
+fn is_run(mask: u128) -> bool {
+ let run = mask.checked_shr(mask.trailing_zeros()).unwrap_or(0);
+ run & run.wrapping_add(1) == 0
+}
+
+pub struct Multiplier {
+ g_slot: usize,
+ steps: Vec,
+ /// The two rows the steps leave, and where adding them makes a product.
+ last: (usize, usize),
+ carries: u128,
+ carry_slot: usize,
+ /// Positions below `N`.
+ width: u128,
+}
+
+impl Multiplier {
+ /// The low `n` bits of `a·b`, as wires.
+ pub fn build(c: &mut Builder, a: &[Wire], b: &[Wire], n: usize) -> (Vec, Self) {
+ let width = u128::MAX >> (128 - n);
+ let one = c.one();
+ let not_a: [Wire; 64] = std::array::from_fn(|i| c.xor(a[i], one));
+ let not_b: [Wire; 64] = std::array::from_fn(|i| c.xor(b[i], one));
+ let g_slot = c.next_slot();
+ let g = c.and(not_a[0], not_b[0]);
+
+ // Each row's wire per position, all shifted down a place: row 0's bit 0
+ // is what `g` and the constant 1 replace.
+ let mut rows = vec![vec![None; n]; N_ROWS];
+ for i in 0..64usize {
+ for j in 0..64 {
+ if let Some(p) = (i + j).checked_sub(1).filter(|&p| p < n) {
+ rows[i][p] = c.xor(b[j], not_a[i]);
+ }
+ }
+ }
+ // `(¬a ≫ 1) + a·2^63`, and the same for `b`.
+ for (row, low, high) in [(A_ROW, ¬_a, a), (B_ROW, ¬_b, b)] {
+ let len = 64.min(n - 63);
+ rows[row][..63].copy_from_slice(&low[1..]);
+ rows[row][63..63 + len].copy_from_slice(&high[..len]);
+ }
+ rows[2][0] = one;
+ rows[3][0] = g;
+ // Half of the constant `2^128`, which survives only mod `2^128`.
+ if n == 128 {
+ rows[A_ROW][127] = one;
+ }
+ let mut present: Vec = rows
+ .iter()
+ .map(|row| (0..n).filter(|&p| row[p].is_some()).fold(0, |m, p| m | (1 << p)))
+ .collect();
+
+ let mut live: Vec = (0..N_ROWS).collect();
+ let mut steps = Vec::new();
+ while live.len() > 2 {
+ let mut order: Vec = (0..live.len()).collect();
+ order.sort_by_key(|&t| ends(present[live[t]]));
+ let [x, y, z] = [live[order[0]], live[order[1]], live[order[2]]];
+ live.retain(|r| ![x, y, z].contains(r));
+ live.extend([x, y]);
+
+ let (px, py, pz) = (present[x], present[y], present[z]);
+ let pairs = ((px & py) | (px & pz) | (py & pz)) & (width >> 1);
+ let triples = px & py & pz;
+ let (mut products, mut moves) = (0u128, 0u128);
+ for p in (0..n - 1).filter(|&p| (pairs >> p) & 1 == 1) {
+ // The carry row is free at `p` unless `p − 1` has a product.
+ if (triples >> p) & 1 == 0 && (products << 1) >> p & 1 == 0 {
+ moves |= 1 << p;
+ } else {
+ products |= 1 << p;
+ }
+ }
+ assert!(is_run(products), "a step's products must be one run of slots");
+ let step = Csa {
+ x,
+ y,
+ z,
+ products,
+ move_y: moves & !pz,
+ move_z: moves & pz,
+ slot: c.next_slot(),
+ };
+
+ let (mut sum, mut carry) = (vec![None; n], vec![None; n]);
+ for p in 0..n {
+ let (wx, wy, wz) = (rows[x][p], rows[y][p], rows[z][p]);
+ if (products >> p) & 1 == 1 {
+ let xz = c.xor(wx, wz);
+ let yz = c.xor(wy, wz);
+ let maj = c.and(xz, yz);
+ carry[p + 1] = c.xor(maj, wz);
+ sum[p] = c.xor(xz, wy);
+ } else if (step.move_z >> p) & 1 == 1 {
+ carry[p] = wz;
+ sum[p] = c.xor(wx, wy);
+ } else if (step.move_y >> p) & 1 == 1 {
+ carry[p] = wy;
+ sum[p] = wx;
+ } else {
+ let xz = c.xor(wx, wz);
+ sum[p] = c.xor(xz, wy);
+ }
+ }
+ rows[x] = sum;
+ rows[y] = carry;
+ present[x] = px | py | pz;
+ present[y] = (products << 1) | moves;
+ steps.push(step);
+ }
+
+ let &[x, y] = live.as_slice() else {
+ unreachable!("the steps stop at two rows")
+ };
+ let carry_slot = c.next_slot();
+ let mut carries = 0u128;
+ let mut carry = None;
+ let mut product = Vec::with_capacity(n);
+ for p in 0..n {
+ let (wx, wy) = (rows[x][p], rows[y][p]);
+ let out = if p + 1 < n && [wx, wy, carry].iter().flatten().count() >= 2 {
+ carries |= 1 << p;
+ let xc = c.xor(wx, carry);
+ let yc = c.xor(wy, carry);
+ let maj = c.and(xc, yc);
+ carry = c.xor(maj, carry);
+ c.xor(xc, wy)
+ } else {
+ let xy = c.xor(wx, wy);
+ c.xor(xy, carry.take())
+ };
+ product.push(out);
+ }
+ assert!(is_run(carries), "the final carries must be one run of slots");
+
+ let multiplier = Self {
+ g_slot,
+ steps,
+ last: (x, y),
+ carries,
+ carry_slot,
+ width,
+ };
+ (product, multiplier)
+ }
+
+ /// Writes `g`'s row and every step's products, and returns the result.
+ pub(super) fn witness(&self, a: u64, b: u64, witness: &mut Instance) -> u128 {
+ let (na, nb) = (!a, !b);
+ let mut rows = [0u128; N_ROWS];
+ for (i, row) in rows[..64].iter_mut().enumerate() {
+ let v = (b ^ ((a >> i) & 1).wrapping_sub(1)) as u128;
+ *row = if i == 0 { v >> 1 } else { v << (i - 1) };
+ }
+ rows[A_ROW] = ((na >> 1) as u128) | ((a as u128) << 63) | (1 << 127);
+ rows[B_ROW] = ((nb >> 1) as u128) | ((b as u128) << 63);
+ rows[2] |= 1;
+ rows[3] |= (na & nb & 1) as u128;
+ for row in &mut rows {
+ *row &= self.width;
+ }
+ witness.products(self.g_slot, 1, na as u128, nb as u128);
+
+ for s in &self.steps {
+ let (rx, ry, rz) = (rows[s.x], rows[s.y], rows[s.z]);
+ let (xz, yz) = (rx ^ rz, ry ^ rz);
+ let moved = (ry & s.move_y) | (rz & s.move_z);
+ rows[s.x] = rx ^ ry ^ rz ^ moved;
+ rows[s.y] = ((((xz & yz) ^ rz) & s.products) << 1) | moved;
+ witness.products(s.slot, s.products, xz, yz);
+ }
+
+ let (rx, ry) = (rows[self.last.0], rows[self.last.1]);
+ let sum = rx.wrapping_add(ry) & self.width;
+ let carry_in = sum ^ rx ^ ry;
+ witness.products(self.carry_slot, self.carries, rx ^ carry_in, ry ^ carry_in);
+ sum
+ }
+}
diff --git a/crates/flock/src/circuit.rs b/crates/flock/src/circuit.rs
new file mode 100644
index 000000000..bdc71744a
--- /dev/null
+++ b/crates/flock/src/circuit.rs
@@ -0,0 +1,365 @@
+//! Boolean circuits as gate lists over word ports: what [`crate::arith`] and the
+//! VM's instruction classes are written in.
+//!
+//! ## Witness layout per block
+//!
+//! ```text
+//! z[0 .. 64·P) = the ports, a whole number of 64-bit words each:
+//! inputs (free), then outputs (committed copies)
+//! z[64·P] = 1 (constant wire)
+//! z[64·P + 1 .. useful) = the circuit's products, in the order they are made
+//! z[useful .. 2^k_log) = padding (forced to 0 by empty rows)
+//! ```
+//!
+//! A port bit with no gate is an empty row too, hence zero: an output narrower than
+//! its words, a single bit say, is that value as a 64-bit word. A caller that binds
+//! ports to something outside (memory words, in the VM) relies on exactly this.
+//!
+//! A circuit is one gate list, where a wire is the gate driving it and each
+//! committed wire is a row. As in [`crate::hash`], no matrix is ever built: the
+//! verifier walks the list forwards and the prover backwards (doc/leanvm, Annex
+//! C "Evaluating the matrices"). Across implementations what has to agree is the
+//! port layout and the order products are made in, which fixes their slots; the
+//! order of the free XORs is nobody's business.
+
+use crate::lincheck::LincheckCircuit;
+use crate::reduction::Block;
+use crate::witness::drive_witness_packed_and_lincheck;
+use primitives::field::F192;
+use zk_alloc::ArenaVec;
+
+/// The gate driving a wire, or `None` for a structural zero.
+pub type Wire = Option;
+
+#[derive(Clone, Copy)]
+enum Gate {
+ /// The committed free wire at `slot`: an input bit, or the constant. Row `z[slot]·1 = z[slot]`.
+ Free(u32),
+ /// `w_x ⊕ w_y`, uncommitted.
+ Xor(u32, u32),
+ /// Row `w_x · w_y = z[slot]`.
+ And(u32, u32, u32),
+ /// Row `w_x · 1 = z[slot]`: an affine wire committed, which is how a result leaves the circuit.
+ Copy(u32, u32),
+}
+
+/// A gate list under construction.
+pub struct Builder {
+ gates: Vec,
+ next_slot: usize,
+ one: Wire,
+ inputs: Vec>,
+ /// Each output port's first bit and width.
+ outputs: Vec<(usize, usize)>,
+ n_input_words: usize,
+}
+
+impl Builder {
+ /// Ports of the given widths in bits, each rounded up to whole words: the inputs,
+ /// whose bits are free wires, then the outputs.
+ pub fn new(input_bits: &[usize], output_bits: &[usize]) -> Self {
+ let words = |bits: &[usize]| bits.iter().map(|b| b.div_ceil(64)).sum::();
+ let n_input_words = words(input_bits);
+ let const_pos = 64 * (n_input_words + words(output_bits));
+ let mut c = Self {
+ gates: Vec::new(),
+ next_slot: const_pos + 1,
+ one: None,
+ inputs: Vec::new(),
+ outputs: Vec::new(),
+ n_input_words,
+ };
+ c.one = Some(c.push(Gate::Free(const_pos as u32)));
+ let mut base = 0;
+ for &bits in input_bits {
+ let wires = (0..bits).map(|i| Some(c.push(Gate::Free((base + i) as u32)))).collect();
+ c.inputs.push(wires);
+ base += 64 * bits.div_ceil(64);
+ }
+ for &bits in output_bits {
+ c.outputs.push((base, bits));
+ base += 64 * bits.div_ceil(64);
+ }
+ c
+ }
+
+ fn push(&mut self, gate: Gate) -> u32 {
+ self.gates.push(gate);
+ (self.gates.len() - 1) as u32
+ }
+
+ /// The constant 1.
+ pub fn one(&self) -> Wire {
+ self.one
+ }
+
+ /// Input port `port`'s bits, low first.
+ pub fn input(&self, port: usize) -> Vec {
+ self.inputs[port].clone()
+ }
+
+ /// The slot the next product takes.
+ pub fn next_slot(&self) -> usize {
+ self.next_slot
+ }
+
+ pub fn xor(&mut self, x: Wire, y: Wire) -> Wire {
+ match (x, y) {
+ (Some(x), Some(y)) => Some(self.push(Gate::Xor(x, y))),
+ _ => x.or(y),
+ }
+ }
+
+ pub fn not(&mut self, x: Wire) -> Wire {
+ self.xor(x, self.one)
+ }
+
+ /// One product, and one slot, unless an operand is a structural zero.
+ pub fn and(&mut self, x: Wire, y: Wire) -> Wire {
+ let (x, y) = (x?, y?);
+ let slot = self.next_slot as u32;
+ self.next_slot += 1;
+ Some(self.push(Gate::And(x, y, slot)))
+ }
+
+ pub fn or(&mut self, x: Wire, y: Wire) -> Wire {
+ let both = self.and(x, y);
+ let either = self.xor(x, y);
+ self.xor(either, both)
+ }
+
+ /// `if s { x } else { y }`, one product.
+ pub fn mux(&mut self, s: Wire, x: Wire, y: Wire) -> Wire {
+ let d = self.xor(x, y);
+ let picked = self.and(s, d);
+ self.xor(picked, y)
+ }
+
+ /// Commits `wire` as bit `bit` of output port `port`. A structural zero needs no
+ /// gate: the empty row is what forces the bit to zero.
+ pub fn output(&mut self, port: usize, bit: usize, wire: Wire) {
+ let (base, bits) = self.outputs[port];
+ assert!(bit < bits, "output port {port} has {bits} bits");
+ if let Some(wire) = wire {
+ self.push(Gate::Copy(wire, (base + bit) as u32));
+ }
+ }
+
+ pub fn finish(self) -> Circuit {
+ let useful_bits = self.next_slot;
+ Circuit {
+ const_pos: 64 * (self.n_input_words + self.outputs.iter().map(|o| o.1.div_ceil(64)).sum::()),
+ k_log: useful_bits.next_power_of_two().trailing_zeros() as usize,
+ useful_bits,
+ n_input_words: self.n_input_words,
+ gates: self.gates,
+ }
+ }
+}
+
+pub struct Circuit {
+ gates: Vec,
+ const_pos: usize,
+ k_log: usize,
+ useful_bits: usize,
+ n_input_words: usize,
+}
+
+impl Circuit {
+ /// `log2` of the bits one instance occupies.
+ pub fn k_log(&self) -> usize {
+ self.k_log
+ }
+
+ pub fn useful_bits(&self) -> usize {
+ self.useful_bits
+ }
+
+ /// The constant wire's position.
+ pub fn const_pos(&self) -> usize {
+ self.const_pos
+ }
+
+ /// Products, which is what an instance pays beyond its ports.
+ pub fn n_products(&self) -> usize {
+ self.useful_bits - self.const_pos - 1
+ }
+
+ pub fn n_input_words(&self) -> usize {
+ self.n_input_words
+ }
+
+ pub fn block(&self) -> Block<'_> {
+ Block {
+ k_log: self.k_log,
+ useful_bits: self.useful_bits,
+ circuit: self,
+ }
+ }
+
+ /// One instance's `z`, `A·z` and `B·z`, by one walk of the gate list on bits.
+ /// `inputs` are the input ports' words; the buffers come zeroed.
+ pub fn witness_instance(&self, inputs: &[u64], z: &mut [u64], az: &mut [u64], bz: &mut [u64]) {
+ assert_eq!(inputs.len(), self.n_input_words);
+ let set = |buf: &mut [u64], slot: u32, v: bool| buf[slot as usize / 64] |= (v as u64) << (slot % 64);
+ let mut wires: Vec = Vec::with_capacity(self.gates.len());
+ for &gate in &self.gates {
+ let v = match gate {
+ Gate::Free(s) => {
+ let v = s as usize == self.const_pos || (inputs[s as usize / 64] >> (s % 64)) & 1 == 1;
+ set(z, s, v);
+ set(az, s, v);
+ set(bz, s, true);
+ v
+ }
+ Gate::Xor(x, y) => wires[x as usize] ^ wires[y as usize],
+ Gate::And(x, y, s) => {
+ let (x, y) = (wires[x as usize], wires[y as usize]);
+ set(z, s, x & y);
+ set(az, s, x);
+ set(bz, s, y);
+ x & y
+ }
+ Gate::Copy(x, s) => {
+ let v = wires[x as usize];
+ set(z, s, v);
+ set(az, s, v);
+ set(bz, s, true);
+ v
+ }
+ };
+ wires.push(v);
+ }
+ }
+
+ /// `(z, a, b, z_lincheck)` for `rows` of input words, padded with all-zero inputs
+ /// to `2^n_blocks_log` instances: the bit-packed `z`, `A·z` and `B·z`
+ /// (`2^k_log / 64` words per instance), and lincheck's byte stripes.
+ pub fn generate_witness(
+ &self,
+ rows: &[[u64; N]],
+ n_blocks_log: usize,
+ ) -> (ArenaVec, ArenaVec, ArenaVec, ArenaVec) {
+ assert_eq!(N, self.n_input_words);
+ self.generate_witness_with(rows, &[0; N], n_blocks_log, |row, z, az, bz| {
+ self.witness_instance(row, z, az, bz)
+ })
+ }
+
+ /// [`Self::generate_witness`] over the caller's own rows, `inputs` writing a row's
+ /// input words. `rows` fill the batch, or `padding` does.
+ pub fn generate_witness_by(
+ &self,
+ rows: &[S],
+ padding: &S,
+ n_blocks_log: usize,
+ inputs: impl Fn(&S, &mut [u64]) + Sync,
+ ) -> (ArenaVec, ArenaVec, ArenaVec, ArenaVec) {
+ const MAX_INPUT_WORDS: usize = 16;
+ assert!(self.n_input_words <= MAX_INPUT_WORDS);
+ self.generate_witness_with(rows, padding, n_blocks_log, |row, z, az, bz| {
+ let mut words = [0u64; MAX_INPUT_WORDS];
+ inputs(row, &mut words[..self.n_input_words]);
+ self.witness_instance(&words[..self.n_input_words], z, az, bz)
+ })
+ }
+
+ /// [`Self::generate_witness`] with the caller's own rows, padding row and way to
+ /// fill an instance, for a circuit whose witness is cheaper as word arithmetic.
+ pub(crate) fn generate_witness_with(
+ &self,
+ rows: &[S],
+ padding: &S,
+ n_blocks_log: usize,
+ instance: impl Fn(&S, &mut [u64], &mut [u64], &mut [u64]) + Sync,
+ ) -> (ArenaVec, ArenaVec, ArenaVec, ArenaVec) {
+ drive_witness_packed_and_lincheck(rows, Some(padding), n_blocks_log, self.k_log, instance)
+ }
+
+ /// The matrix-vector products `(A_0 w, B_0 w)`, by one forward walk.
+ pub(crate) fn row_values(&self, w: &[F192]) -> (Vec, Vec) {
+ let k = self.n_cols();
+ assert_eq!(w.len(), k);
+ let wc = w[self.const_pos];
+ let mut ra = vec![F192::ZERO; k];
+ let mut rb = vec![F192::ZERO; k];
+ let mut wires: Vec = Vec::with_capacity(self.gates.len());
+ for &gate in &self.gates {
+ let v = match gate {
+ Gate::Free(s) => {
+ let s = s as usize;
+ (ra[s], rb[s]) = (w[s], wc);
+ w[s]
+ }
+ Gate::Xor(x, y) => wires[x as usize] + wires[y as usize],
+ Gate::And(x, y, s) => {
+ let s = s as usize;
+ (ra[s], rb[s]) = (wires[x as usize], wires[y as usize]);
+ w[s]
+ }
+ Gate::Copy(x, s) => {
+ let s = s as usize;
+ (ra[s], rb[s]) = (wires[x as usize], wc);
+ w[s]
+ }
+ };
+ wires.push(v);
+ }
+ (ra, rb)
+ }
+}
+
+impl LincheckCircuit for Circuit {
+ fn n_cols(&self) -> usize {
+ 1 << self.k_log
+ }
+
+ fn const_pin_col(&self) -> usize {
+ self.const_pos
+ }
+
+ /// `(A_0 + α B_0)ᵀ u`, by one backward walk: every gate, in reverse, hands
+ /// its wire's adjoint to its operands or deposits it on its slot.
+ fn fold_alpha_batched(&self, alpha: F192, u: &[F192]) -> Vec {
+ assert_eq!(u.len(), self.n_cols());
+ let c = self.const_pos;
+ let mut m = vec![F192::ZERO; u.len()];
+ let mut adj = vec![F192::ZERO; self.gates.len()];
+ for (i, &gate) in self.gates.iter().enumerate().rev() {
+ let g = adj[i];
+ match gate {
+ Gate::Free(s) => {
+ let s = s as usize;
+ m[s] += g + u[s];
+ m[c] += alpha * u[s];
+ }
+ Gate::Xor(x, y) => {
+ adj[x as usize] += g;
+ adj[y as usize] += g;
+ }
+ Gate::And(x, y, s) => {
+ let s = s as usize;
+ m[s] += g;
+ adj[x as usize] += u[s];
+ adj[y as usize] += alpha * u[s];
+ }
+ Gate::Copy(x, s) => {
+ let s = s as usize;
+ m[s] += g;
+ adj[x as usize] += u[s];
+ m[c] += alpha * u[s];
+ }
+ }
+ }
+ m
+ }
+
+ fn bilinear_form(&self, alpha: F192, u: &[F192], w: &[F192]) -> Option {
+ let (ra, rb) = self.row_values(w);
+ Some(
+ u.iter()
+ .zip(ra.iter().zip(&rb))
+ .fold(F192::ZERO, |acc, (&u, (&a, &b))| acc + u * (a + alpha * b)),
+ )
+ }
+}
diff --git a/crates/flock/src/hash.rs b/crates/flock/src/hash.rs
index 3a1bea461..67189e50b 100644
--- a/crates/flock/src/hash.rs
+++ b/crates/flock/src/hash.rs
@@ -86,17 +86,19 @@ use crate::gf2::{
ADD3_BITS, CARRY_BITS_PER_ADD, MatrixSide, WireWord, back_add, back_add3_fused, walk_add, walk_add3_fused,
wire_from_const, wire_from_slot_base, wire_rotl, wire_rotr, wire_xor,
};
+use crate::reduction::{self, Block};
use crate::verifier;
-use crate::witness::packed_bytes;
use crate::witness::{
BitRecord, add_carry_parts, add3_fused_parts, drive_witness_packed_and_lincheck, or_bit_at,
write_lin_word_ab_packed,
};
-use pcs::pack::{LOG_PACKING, PACKING_WIDTH};
-use pcs::stack_open::{RingSwitchClaim, RingSwitchOpen, RingSwitchVerify, RingSwitchVerifyClaim};
+use pcs::pack::LOG_PACKING;
+use pcs::stack_open::{RingSwitchOpen, RingSwitchVerify};
use primitives::field::F192;
use zk_alloc::ArenaVec;
+pub use crate::reduction::{ReductionReplay, SliceClaim, ZerocheckStage, min_n_blocks_log};
+
// ---------------------------------------------------------------------------
// Public constants
// ---------------------------------------------------------------------------
@@ -108,13 +110,6 @@ pub const K: usize = 1 << K_LOG;
/// Univariate-skip dim, must match [`crate::zerocheck::K_SKIP`].
pub const K_SKIP: usize = 6;
-// A claim's `2^K_SKIP` slices are a ring-switch claim on `q_flock` only if that
-// matches the packing width; otherwise `ring_claim` fails at run time.
-const _: () = assert!(
- K_SKIP == LOG_PACKING,
- "the univariate skip must match the PCS packing width"
-);
-
/// Number of BLAKE2s rounds.
pub const N_ROUNDS: usize = primitives::hash::ROUNDS;
/// Number of G calls per round (4 column + 4 diagonal).
@@ -271,20 +266,12 @@ pub fn padding_block() -> Compression {
/// commit that still had `build_matrices` and run `r1cs_digest_matches_baked`.
///
/// The value is mirrored in `python-verifier/verifier.py`, which never could
-/// rebuild the matrices, and in the recursion guest, so a deliberate circuit
-/// change means bumping all three by hand.
+/// rebuild the matrices, so a deliberate circuit change means bumping both by hand.
pub const R1CS_DIGEST: [u8; 32] = [
0x53, 0x7a, 0xd2, 0x07, 0x90, 0x30, 0x8f, 0x8e, 0xb8, 0xc0, 0xe8, 0xbd, 0x3e, 0x6c, 0x58, 0xee, 0x64, 0x57, 0x33,
0x71, 0xe3, 0xd5, 0x3c, 0x30, 0x61, 0x3d, 0xd0, 0x4d, 0x87, 0xc0, 0xb7, 0xea,
];
-/// Minimum `n_blocks_log` needed to prove `n_blocks` compressions, subject to
-/// the lincheck floor of `n_blocks_log ≥ 3` (`n_outer ≥ 8`).
-pub fn min_n_blocks_log(n_blocks: usize) -> usize {
- assert!(n_blocks >= 1, "n_blocks must be ≥ 1");
- n_blocks.max(8).next_power_of_two().trailing_zeros() as usize
-}
-
// ---------------------------------------------------------------------------
// Circuit-walk evaluation: `(uᵀ A_0 w, uᵀ B_0 w)` in O(circuit) field ops,
// over matrices that are never materialized. The row assignment these walks
@@ -719,19 +706,12 @@ impl Blake2sSetup {
// The zerocheck, lincheck, and ring-switch scalars use the shared transcript;
// the caller carries the WHIR opening.
-/// The one claim on the committed witness `q_flock` left by the Flock BLAKE2s
-/// zerocheck + lincheck reduction, for the PCS to discharge: the `2^k_skip`
-/// bit-slice values of `z` at `suffix_point`, transmitted and pinned inside the
-/// reduction by lincheck's terminal identity (which batches A, B, the
-/// constant-wire pin and C), so the PCS only has to bind them to the
-/// commitment.
-///
-/// This is the clean seam between Flock's reduction and the PCS.
-#[derive(Clone, Debug, PartialEq, Eq)]
-pub struct SliceClaim {
- pub suffix_point: Vec,
- pub s_hat_v: Vec,
-}
+/// The BLAKE2s circuit as the reduction sees it.
+const BLOCK: Block<'static> = Block {
+ k_log: K_LOG,
+ useful_bits: USEFUL_BITS,
+ circuit: &WalkLincheckCircuit,
+};
/// The variable count (`log2` length) of the committed `q_flock` column for
/// `n_blocks` executed compressions: `K_LOG + min_n_blocks_log − LOG_PACKING`.
@@ -741,103 +721,15 @@ pub fn qflock_kappa(n_blocks: usize) -> usize {
K_LOG + min_n_blocks_log(n_blocks.max(1)) - LOG_PACKING
}
-/// One reduction claim as a tower [`RingSwitchClaim`]: the `2^k_skip` slices and
-/// the suffix point they live at, which is the WHOLE multilinear tail of the
-/// quirky point (`q_flock` has `2^qflock_vars` words, and the packing prefix is
-/// exactly the skipped coordinates, so nothing is split off into it).
-fn ring_claim(claim: &SliceClaim, qflock_vars: usize) -> RingSwitchClaim {
- assert_eq!(
- claim.suffix_point.len(),
- qflock_vars,
- "ring-switch suffix must span the q_flock cube"
- );
- assert_eq!(claim.s_hat_v.len(), PACKING_WIDTH);
- RingSwitchClaim {
- suffix_point: claim.suffix_point.clone(),
- s_hat_v: Some(claim.s_hat_v.clone()),
- }
-}
-
-/// Package the prover's reduction claim as a [`RingSwitchOpen`], so the PCS
-/// discharges flock's validity in the same opening as the embedder's own point
-/// claims. `offset` is `q_flock`'s slot in the committed stack; the opener
-/// slices `q_flock` from there.
+/// [`reduction::ring_switch_open`] for `n_blocks` compressions, `offset` being
+/// `q_flock`'s slot in the committed stack.
pub fn ring_switch_open(n_blocks: usize, offset: usize, reduced: &SliceClaim) -> RingSwitchOpen {
- let qflock_vars = qflock_kappa(n_blocks);
- RingSwitchOpen {
- offset,
- qflock_vars,
- claims: vec![ring_claim(reduced, qflock_vars)],
- }
+ reduction::ring_switch_open(qflock_kappa(n_blocks), offset, reduced)
}
-/// Verifier counterpart of [`ring_switch_open`]: package the recovered claim as
-/// a [`RingSwitchVerify`], the same statement data. The transmitted opening
-/// travels separately.
+/// [`reduction::ring_switch_verify`] for `n_blocks` compressions.
pub fn ring_switch_verify(n_blocks: usize, offset: usize, claim: &SliceClaim) -> RingSwitchVerify<'_> {
- let qflock_vars = qflock_kappa(n_blocks);
- assert_eq!(
- claim.suffix_point.len(),
- qflock_vars,
- "ring-switch suffix must span the q_flock cube"
- );
- RingSwitchVerify {
- offset,
- qflock_vars,
- claims: vec![RingSwitchVerifyClaim {
- suffix_point: &claim.suffix_point,
- s_hat_v: claim.s_hat_v.as_slice().try_into().expect("ring-switch has 64 slices"),
- }],
- }
-}
-
-/// Everything [`Blake2sSetup::verify_reduction`] recovers: the z-claim for the
-/// PCS and the zerocheck / lincheck claims.
-#[derive(Clone, Debug)]
-pub struct ReductionReplay {
- pub claim: SliceClaim,
- pub zc_claim: crate::zerocheck::ZerocheckClaim,
- pub lc_claim: crate::lincheck::LincheckClaim,
-}
-
-/// The lincheck input point carried over from the zerocheck claim: the
-/// univariate-skip coordinate, then the multilinear challenges split at
-/// `inner_rest_len` into the inner-rest and outer halves.
-fn x_ab_of(zc: &crate::zerocheck::ZerocheckClaim, inner_rest_len: usize) -> crate::lincheck::QuirkyPoint {
- crate::lincheck::QuirkyPoint {
- z_skip: zc.z,
- x_inner_rest: zc.mlv_challenges[..inner_rest_len].to_vec(),
- x_outer: zc.mlv_challenges[inner_rest_len..].to_vec(),
- }
-}
-
-/// The claim the reduction leaves for the PCS: lincheck's output point, whose
-/// 64 slice values are `lc.s_hat_v`. Prover and verifier must derive it
-/// identically, so they share this one derivation.
-fn reduction_claim(lc: &crate::lincheck::LincheckClaim, x_outer: &[F192]) -> SliceClaim {
- let mut suffix_point = lc.r_inner_rest.clone();
- suffix_point.extend_from_slice(x_outer);
- SliceClaim {
- suffix_point,
- s_hat_v: lc.s_hat_v.clone(),
- }
-}
-
-/// What the zerocheck stage hands the lincheck stage: the zerocheck claim and
-/// the quirky point lincheck runs at. Opaque; the two stages of
-/// [`Blake2sSetup::prove_reduction_precomputed`] are split only so a caller can
-/// time or profile them apart.
-#[derive(Clone, Debug)]
-pub struct ZerocheckStage {
- x_ab: crate::lincheck::QuirkyPoint,
-}
-
-/// One `FLOCK_PROVE_TRACE` line. `label` carries its own colon so the stages
-/// line up.
-fn trace_stage(label: &str, t: std::time::Instant) {
- if std::env::var_os("FLOCK_PROVE_TRACE").is_some() {
- eprintln!("[flock prove] {label:<11}{:8.2} ms", t.elapsed().as_secs_f64() * 1e3);
- }
+ reduction::ring_switch_verify(qflock_kappa(n_blocks), offset, claim)
}
impl Blake2sSetup {
@@ -877,35 +769,7 @@ impl Blake2sSetup {
b_packed_words: &[u64],
ps: &mut fiat_shamir::transcript::ProverState,
) -> ZerocheckStage {
- let t_zerocheck = std::time::Instant::now();
-
- // The fused generator packs 64 Boolean coordinates per word.
- let packed_len = 1usize << (self.m() - 6);
- assert_eq!(z_packed.len(), packed_len, "wrong packed witness length");
- assert_eq!(a_packed_words.len(), packed_len, "wrong packed A·z length");
- assert_eq!(b_packed_words.len(), packed_len, "wrong packed B·z length");
-
- // No bind_statement here: the embedding protocol (leanVM) seeds its
- // transcript with the R1CS digest and binds the instance
- // count and commitment root before any challenge, so the statement is
- // already fully transcript-bound.
-
- let padding = crate::zerocheck::PaddingSpec {
- k_log: K_LOG,
- useful_bits_per_block: USEFUL_BITS,
- };
- let zc_claim = crate::zerocheck::prove_packed_padded(
- packed_bytes(a_packed_words),
- packed_bytes(b_packed_words),
- packed_bytes(z_packed), // C = I, so c == z
- self.m(),
- &padding,
- ps,
- );
-
- let x_ab = x_ab_of(&zc_claim, K_LOG - K_SKIP);
- trace_stage("zerocheck:", t_zerocheck);
- ZerocheckStage { x_ab }
+ BLOCK.prove_zerocheck(self.n_blocks_log, z_packed, a_packed_words, b_packed_words, ps)
}
/// **Flock reduction, second stage (prover): the lincheck.** Reduces the
@@ -917,25 +781,7 @@ impl Blake2sSetup {
z_packed_lincheck: &[u8],
ps: &mut fiat_shamir::transcript::ProverState,
) -> SliceClaim {
- let t_lincheck = std::time::Instant::now();
- let packed_len = 1usize << (self.m() - 6);
- assert_eq!(z_packed_lincheck.len(), packed_len * 8, "wrong lincheck stripe length");
-
- let ZerocheckStage { x_ab } = stage;
- let lc_claim = crate::lincheck::prove_padded_capture_s_hat_v(
- z_packed_lincheck,
- self.m(),
- K_LOG,
- K_SKIP,
- USEFUL_BITS,
- &WalkLincheckCircuit,
- &x_ab,
- ps,
- );
-
- let claim = reduction_claim(&lc_claim, &x_ab.x_outer);
- trace_stage("lincheck:", t_lincheck);
- claim
+ BLOCK.prove_lincheck(self.n_blocks_log, stage, z_packed_lincheck, ps)
}
/// **Flock reduction (verifier).** Replay the BLAKE2s zerocheck and
@@ -946,35 +792,7 @@ impl Blake2sSetup {
&self,
vs: &mut fiat_shamir::transcript::VerifierState<'_>,
) -> Result {
- // Mirror of prove_reduction_precomputed: the statement is bound by the embedding
- // protocol's seed (R1CS digest) + announced count + commitment root.
-
- let zc_claim = crate::zerocheck::verify(self.m(), vs).map_err(verifier::VerifyError::Zerocheck)?;
-
- let inner_rest_len = K_LOG - K_SKIP;
- let x_ab = x_ab_of(&zc_claim, inner_rest_len);
- // Walk-capable circuit: the verifier's lincheck consistency check is
- // one circuit walk (O(circuit) field ops) instead of the ∝ NNZ CSC
- // marginal fold. Same transcript, same accept/reject.
- let lc_claim = crate::lincheck::verify(
- self.m(),
- K_LOG,
- K_SKIP,
- &WalkLincheckCircuit,
- &x_ab,
- zc_claim.a_eval,
- zc_claim.b_eval,
- zc_claim.c_eval,
- vs,
- )
- .map_err(verifier::VerifyError::Lincheck)?;
-
- let claim = reduction_claim(&lc_claim, &x_ab.x_outer);
- Ok(ReductionReplay {
- claim,
- zc_claim,
- lc_claim,
- })
+ BLOCK.verify(self.n_blocks_log, vs)
}
}
diff --git a/crates/flock/src/lib.rs b/crates/flock/src/lib.rs
index c5b111036..15e8c615a 100644
--- a/crates/flock/src/lib.rs
+++ b/crates/flock/src/lib.rs
@@ -12,12 +12,15 @@
//! 4. The PCS binds that family of slices ([`hash::SliceClaim`]) to the
//! commitment.
//!
-//! [`hash`] is the one circuit: the BLAKE2s compression as a per-block R1CS,
-//! plus its witness generation and the leanVM-facing reduction entry points
-//! (`Blake2sSetup::{prove_reduction_precomputed, verify_reduction, …}`). Steps 2 to 4 above
-//! are circuit-agnostic: they take the block shape as plain numbers and reach
-//! the matrices only through [`lincheck::LincheckCircuit`], whose one live impl
-//! walks the circuit rather than reading any matrix.
+//! [`hash`] is the protocol's circuit: the BLAKE2s compression as a per-block
+//! R1CS, plus its witness generation and the leanVM-facing reduction entry
+//! points (`Blake2sSetup::{prove_reduction_precomputed, verify_reduction, …}`). [`circuit`]
+//! is the gate-list vocabulary every other circuit is written in, and [`arith`]
+//! holds u64 addition and multiplication in it.
+//! Steps 2 to 4 above are
+//! circuit-agnostic ([`reduction`]): they take the block shape as plain numbers
+//! and reach the matrices only through [`lincheck::LincheckCircuit`], whose
+//! impls walk the circuit rather than reading any matrix.
//!
//! BLAKE2s is a 32-bit ARX round whose XORs and rotations are free over GF(2),
//! so its only nonlinear constraints are the product bits of the modular ADDs.
@@ -25,9 +28,12 @@
//! gadgets, forwards and transposed, kept separate because the fused
//! three-operand adder's bit boundaries are the subtlest thing here.
+pub mod arith;
+pub mod circuit;
mod gf2;
pub mod hash;
pub mod lincheck;
+pub mod reduction;
/// The circuit driven through the whole reduction. A `src` module rather than
/// its own test binary so it shares the process, and so the slow
/// [`hash::matrices`] build, with the unit tests.
diff --git a/crates/flock/src/lincheck.rs b/crates/flock/src/lincheck.rs
index be77eef39..10052f110 100644
--- a/crates/flock/src/lincheck.rs
+++ b/crates/flock/src/lincheck.rs
@@ -1320,8 +1320,7 @@ pub fn verify(
// The c term's `⟨eq_inner, w_col⟩`, by the tensor structure of both sides:
// `eq_inner = eq(x_inner_rest) ⊗ λ(z_skip)` and `w_col = eq(r_inner_rest) ⊗
// z_partial`, so it is 8 eq factors times a 64-term Lagrange combination
- // instead of a length-k inner product. That is the form the recursive
- // verifier can afford.
+ // instead of a length-k inner product.
let lambda_skip = lagrange_weights_naive(k_skip, x_ab.z_skip);
let c_slice_value = lambda_skip
.iter()
diff --git a/crates/flock/src/reduction.rs b/crates/flock/src/reduction.rs
new file mode 100644
index 000000000..3bda3cc47
--- /dev/null
+++ b/crates/flock/src/reduction.rs
@@ -0,0 +1,253 @@
+//! The circuit-agnostic half of Flock: zerocheck then lincheck over a batch of
+//! `2^k_log`-bit blocks, reducing R1CS validity to ONE claim on the packed
+//! witness, packaged for ring switching. A circuit supplies only its [`Block`]:
+//! the shape, and the walks behind its [`LincheckCircuit`].
+
+use crate::lincheck::{self, LincheckCircuit, LincheckClaim, QuirkyPoint};
+use crate::verifier::VerifyError;
+use crate::witness::packed_bytes;
+use crate::zerocheck::{self, K_SKIP, PaddingSpec, ZerocheckClaim};
+use fiat_shamir::transcript::{ProverState, VerifierState};
+use pcs::pack::{LOG_PACKING, PACKING_WIDTH};
+use pcs::stack_open::{RingSwitchClaim, RingSwitchOpen, RingSwitchVerify, RingSwitchVerifyClaim};
+use primitives::field::F192;
+
+// A claim's `2^K_SKIP` slices are a ring-switch claim on `q_flock` only if that
+// matches the packing width; otherwise `ring_claim` fails at run time.
+const _: () = assert!(
+ K_SKIP == LOG_PACKING,
+ "the univariate skip must match the PCS packing width"
+);
+
+/// Minimum `n_blocks_log` needed to prove `n_blocks` instances, subject to the
+/// lincheck floor of `n_blocks_log ≥ 3` (`n_outer ≥ 8`).
+pub fn min_n_blocks_log(n_blocks: usize) -> usize {
+ assert!(n_blocks >= 1, "n_blocks must be ≥ 1");
+ n_blocks.max(8).next_power_of_two().trailing_zeros() as usize
+}
+
+/// A circuit as the reduction sees it: `2^k_log` witness bits per instance, of
+/// which `[useful_bits, 2^k_log)` are zero padding the prover skips.
+#[derive(Clone, Copy)]
+pub struct Block<'a> {
+ pub k_log: usize,
+ pub useful_bits: usize,
+ pub circuit: &'a dyn LincheckCircuit,
+}
+
+/// The one claim on the committed witness `q_flock` left by the zerocheck +
+/// lincheck reduction, for the PCS to discharge: the `2^k_skip` bit-slice
+/// values of `z` at `suffix_point`, transmitted and pinned inside the reduction
+/// by lincheck's terminal identity (which batches A, B, the constant-wire pin
+/// and C), so the PCS only has to bind them to the commitment.
+///
+/// This is the clean seam between Flock's reduction and the PCS.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct SliceClaim {
+ pub suffix_point: Vec,
+ pub s_hat_v: Vec,
+}
+
+/// Everything [`Block::verify`] recovers: the z-claim for the PCS and the
+/// zerocheck / lincheck claims.
+#[derive(Clone, Debug)]
+pub struct ReductionReplay {
+ pub claim: SliceClaim,
+ pub zc_claim: ZerocheckClaim,
+ pub lc_claim: LincheckClaim,
+}
+
+/// What the zerocheck stage hands the lincheck stage: the quirky point lincheck
+/// runs at. Opaque; the two stages are split only so a caller can time or
+/// profile them apart.
+#[derive(Clone, Debug)]
+pub struct ZerocheckStage {
+ x_ab: QuirkyPoint,
+}
+
+/// One `FLOCK_PROVE_TRACE` line. `label` carries its own colon so the stages
+/// line up.
+pub(crate) fn trace_stage(label: &str, t: std::time::Instant) {
+ if std::env::var_os("FLOCK_PROVE_TRACE").is_some() {
+ eprintln!("[flock prove] {label:<11}{:8.2} ms", t.elapsed().as_secs_f64() * 1e3);
+ }
+}
+
+/// The lincheck input point carried over from the zerocheck claim: the
+/// univariate-skip coordinate, then the multilinear challenges split at
+/// `inner_rest_len` into the inner-rest and outer halves.
+fn x_ab_of(zc: &ZerocheckClaim, inner_rest_len: usize) -> QuirkyPoint {
+ QuirkyPoint {
+ z_skip: zc.z,
+ x_inner_rest: zc.mlv_challenges[..inner_rest_len].to_vec(),
+ x_outer: zc.mlv_challenges[inner_rest_len..].to_vec(),
+ }
+}
+
+/// The claim the reduction leaves for the PCS: lincheck's output point, whose
+/// 64 slice values are `lc.s_hat_v`. Prover and verifier must derive it
+/// identically, so they share this one derivation.
+fn reduction_claim(lc: &LincheckClaim, x_outer: &[F192]) -> SliceClaim {
+ let mut suffix_point = lc.r_inner_rest.clone();
+ suffix_point.extend_from_slice(x_outer);
+ SliceClaim {
+ suffix_point,
+ s_hat_v: lc.s_hat_v.clone(),
+ }
+}
+
+impl Block<'_> {
+ /// **First stage (prover): the zerocheck.** Reduces `a·b ⊕ c = 0` over the
+ /// cube of `2^n_blocks_log` blocks to evaluation claims on `(â, b̂, ĉ)`, all
+ /// three at one point.
+ pub fn prove_zerocheck(
+ &self,
+ n_blocks_log: usize,
+ z_packed: &[u64],
+ a_packed_words: &[u64],
+ b_packed_words: &[u64],
+ ps: &mut ProverState,
+ ) -> ZerocheckStage {
+ let t_zerocheck = std::time::Instant::now();
+ let m = self.k_log + n_blocks_log;
+
+ // The fused generator packs 64 Boolean coordinates per word.
+ let packed_len = 1usize << (m - 6);
+ assert_eq!(z_packed.len(), packed_len, "wrong packed witness length");
+ assert_eq!(a_packed_words.len(), packed_len, "wrong packed A·z length");
+ assert_eq!(b_packed_words.len(), packed_len, "wrong packed B·z length");
+
+ // No bind_statement here: the embedding protocol binds the circuit, the
+ // instance count and the commitment root before any challenge, so the
+ // statement is already fully transcript-bound.
+
+ let padding = PaddingSpec {
+ k_log: self.k_log,
+ useful_bits_per_block: self.useful_bits,
+ };
+ let zc_claim = zerocheck::prove_packed_padded(
+ packed_bytes(a_packed_words),
+ packed_bytes(b_packed_words),
+ packed_bytes(z_packed), // C = I, so c == z
+ m,
+ &padding,
+ ps,
+ );
+
+ let x_ab = x_ab_of(&zc_claim, self.k_log - K_SKIP);
+ trace_stage("zerocheck:", t_zerocheck);
+ ZerocheckStage { x_ab }
+ }
+
+ /// **Second stage (prover): the lincheck.** Reduces the zerocheck's
+ /// `(â, b̂, ĉ)` claims to the `2^k_skip` bit slices of `z` at one point,
+ /// against the per-block matrices.
+ pub fn prove_lincheck(
+ &self,
+ n_blocks_log: usize,
+ stage: ZerocheckStage,
+ z_packed_lincheck: &[u8],
+ ps: &mut ProverState,
+ ) -> SliceClaim {
+ let t_lincheck = std::time::Instant::now();
+ let m = self.k_log + n_blocks_log;
+ assert_eq!(
+ z_packed_lincheck.len(),
+ (1usize << m) / 8,
+ "wrong lincheck stripe length"
+ );
+
+ let ZerocheckStage { x_ab } = stage;
+ let lc_claim = lincheck::prove_padded_capture_s_hat_v(
+ z_packed_lincheck,
+ m,
+ self.k_log,
+ K_SKIP,
+ self.useful_bits,
+ self.circuit,
+ &x_ab,
+ ps,
+ );
+
+ let claim = reduction_claim(&lc_claim, &x_ab.x_outer);
+ trace_stage("lincheck:", t_lincheck);
+ claim
+ }
+
+ /// **Verifier.** Replay the zerocheck and lincheck straight off the shared
+ /// transcript stream, recovering the one evaluation claim on the committed
+ /// witness `q_flock`. The PCS then discharges the returned claim.
+ pub fn verify(&self, n_blocks_log: usize, vs: &mut VerifierState<'_>) -> Result {
+ let m = self.k_log + n_blocks_log;
+ let zc_claim = zerocheck::verify(m, vs).map_err(VerifyError::Zerocheck)?;
+
+ let x_ab = x_ab_of(&zc_claim, self.k_log - K_SKIP);
+ let lc_claim = lincheck::verify(
+ m,
+ self.k_log,
+ K_SKIP,
+ self.circuit,
+ &x_ab,
+ zc_claim.a_eval,
+ zc_claim.b_eval,
+ zc_claim.c_eval,
+ vs,
+ )
+ .map_err(VerifyError::Lincheck)?;
+
+ let claim = reduction_claim(&lc_claim, &x_ab.x_outer);
+ Ok(ReductionReplay {
+ claim,
+ zc_claim,
+ lc_claim,
+ })
+ }
+}
+
+/// One reduction claim as a tower [`RingSwitchClaim`]: the `2^k_skip` slices and
+/// the suffix point they live at, which is the WHOLE multilinear tail of the
+/// quirky point (`q_flock` has `2^qflock_vars` words, and the packing prefix is
+/// exactly the skipped coordinates, so nothing is split off into it).
+fn ring_claim(claim: &SliceClaim, qflock_vars: usize) -> RingSwitchClaim {
+ assert_eq!(
+ claim.suffix_point.len(),
+ qflock_vars,
+ "ring-switch suffix must span the q_flock cube"
+ );
+ assert_eq!(claim.s_hat_v.len(), PACKING_WIDTH);
+ RingSwitchClaim {
+ suffix_point: claim.suffix_point.clone(),
+ s_hat_v: Some(claim.s_hat_v.clone()),
+ }
+}
+
+/// Package the prover's reduction claim as a [`RingSwitchOpen`], so the PCS
+/// discharges flock's validity in the same opening as the embedder's own point
+/// claims. `q_flock` is the `2^qflock_vars`-word slice of the committed stack at
+/// `offset`.
+pub fn ring_switch_open(qflock_vars: usize, offset: usize, reduced: &SliceClaim) -> RingSwitchOpen {
+ RingSwitchOpen {
+ offset,
+ qflock_vars,
+ claims: vec![ring_claim(reduced, qflock_vars)],
+ }
+}
+
+/// Verifier counterpart of [`ring_switch_open`]: package the recovered claim as
+/// a [`RingSwitchVerify`], the same statement data. The transmitted opening
+/// travels separately.
+pub fn ring_switch_verify(qflock_vars: usize, offset: usize, claim: &SliceClaim) -> RingSwitchVerify<'_> {
+ assert_eq!(
+ claim.suffix_point.len(),
+ qflock_vars,
+ "ring-switch suffix must span the q_flock cube"
+ );
+ RingSwitchVerify {
+ offset,
+ qflock_vars,
+ claims: vec![RingSwitchVerifyClaim {
+ suffix_point: &claim.suffix_point,
+ s_hat_v: claim.s_hat_v.as_slice().try_into().expect("ring-switch has 64 slices"),
+ }],
+ }
+}
diff --git a/crates/flock/tests/batch_proving_arithmetic.rs b/crates/flock/tests/batch_proving_arithmetic.rs
new file mode 100644
index 000000000..66f85ef2b
--- /dev/null
+++ b/crates/flock/tests/batch_proving_arithmetic.rs
@@ -0,0 +1,204 @@
+//! Standalone batch u64 arithmetic proving, isolated from the VM: wrapping
+//! addition, and multiplication wrapping (a `u64` result) or widening (a `u128`).
+//!
+//! ```text
+//! BENCH_REPEAT=3 BENCH_COOLDOWN=2 FLOCK_N_LOG=20 cargo test --release --package flock --test batch_proving_arithmetic -- mul_wrapping_prove_verify --exact --nocapture --include-ignored
+//! ```
+
+use std::sync::Mutex;
+use std::time::Instant;
+
+use fiat_shamir::transcript::{ProverState, Receiver, Transmitter, VerifierState};
+use flock::arith::{U64Circuit, U64Op};
+use flock::reduction::{min_n_blocks_log, ring_switch_open, ring_switch_verify};
+use pcs::pack::LOG_PACKING;
+use pcs::stack_open::{open_batch_mixed_whir_stacked, verify_opening_batch_mixed_whir_stacked};
+use pcs::whir::{INITIAL_FOLDING_FACTOR, LOG_INV_RATE_0};
+use pcs::whir::{commit, config_for_rate};
+use primitives::bench::{Plan, Timing};
+use primitives::{field::F64, pretty_integer, test_rng::Rng};
+
+/// Arena phases are process-global, so the benchmarks must not overlap.
+static ONE_AT_A_TIME: Mutex<()> = Mutex::new(());
+
+#[test]
+#[ignore = "manual release benchmark; needs substantial memory"]
+fn add_wrapping_prove_verify() {
+ bench(U64Op::WrappingAdd);
+}
+
+#[test]
+#[ignore = "manual release benchmark; needs substantial memory"]
+fn mul_wrapping_prove_verify() {
+ bench(U64Op::WrappingMul);
+}
+
+#[test]
+#[ignore = "manual release benchmark; needs substantial memory"]
+fn mul_widening_prove_verify() {
+ bench(U64Op::WideningMul);
+}
+
+fn bench(op: U64Op) {
+ let _serial = ONE_AT_A_TIME.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
+ let (title, unit) = match op {
+ U64Op::WrappingAdd => ("Wrapping u64 addition", "sums"),
+ U64Op::WrappingMul => ("Wrapping u64 multiplication", "products"),
+ U64Op::WideningMul => ("Widening u64 multiplication", "products"),
+ };
+ let requested_n_log: usize = std::env::var("FLOCK_N_LOG")
+ .ok()
+ .map(|s| s.parse().expect("FLOCK_N_LOG must be an integer"))
+ .unwrap_or(16);
+ let n = 1usize
+ .checked_shl(requested_n_log as u32)
+ .expect("FLOCK_N_LOG exceeds the platform usize width");
+ let n_log = min_n_blocks_log(n);
+
+ let t = Instant::now();
+ let circuit = U64Circuit::new(op);
+ let setup_ms = t.elapsed().as_secs_f64() * 1e3;
+ let block = circuit.block();
+ let mu = circuit.k_log() + n_log - LOG_PACKING;
+ assert!(
+ mu >= 15,
+ "FLOCK_N_LOG too small: need a committed witness with mu >= 15"
+ );
+
+ let mut rng = Rng::new(0x9E37_79B9_7F4A_7C15 ^ n as u64);
+ let pairs: Vec<(u64, u64)> = (0..n).map(|_| (rng.next_u64(), rng.next_u64())).collect();
+ let config = config_for_rate(mu, LOG_INV_RATE_0).expect("WHIR configuration");
+ let label = format!("flock-{op:?}-batch").into_bytes();
+
+ // One full prove pass from the raw pairs, one arena phase, as in
+ // `batch_proving_hashes`.
+ zk_alloc::enable_arena();
+ let prove_pass = || {
+ let _phase = zk_alloc::enter_phase();
+ let t_pass = Instant::now();
+ let t = Instant::now();
+ let (z_packed, a_packed, b_packed, z_lincheck) = circuit.generate_witness(&pairs, n_log);
+ // SAFETY: `F64` is `repr(transparent)` over `u64`.
+ let q_flock: &[F64] = unsafe { std::slice::from_raw_parts(z_packed.as_ptr().cast(), z_packed.len()) };
+ let witness_s = t.elapsed().as_secs_f64();
+ assert_eq!(q_flock.len(), 1 << mu);
+
+ let mut ps = ProverState::from_label(&label);
+
+ let t = Instant::now();
+ let (commitment, prover_data) = commit(q_flock, mu, INITIAL_FOLDING_FACTOR, LOG_INV_RATE_0);
+ ps.add_root(&commitment.root);
+ let commit_s = t.elapsed().as_secs_f64();
+
+ let t = Instant::now();
+ let stage = block.prove_zerocheck(n_log, &z_packed, &a_packed, &b_packed, &mut ps);
+ let zerocheck_s = t.elapsed().as_secs_f64();
+
+ let t = Instant::now();
+ let reduced = block.prove_lincheck(n_log, stage, &z_lincheck, &mut ps);
+ let lincheck_s = t.elapsed().as_secs_f64();
+ drop((a_packed, b_packed, z_lincheck));
+
+ let t = Instant::now();
+ let ring = ring_switch_open(mu, 0, &reduced);
+ open_batch_mixed_whir_stacked(
+ &mut ps,
+ mu,
+ q_flock,
+ &prover_data,
+ &config,
+ &[],
+ std::slice::from_ref(&ring),
+ );
+ let open_s = t.elapsed().as_secs_f64();
+
+ let proof = ps.into_proof();
+ let pass_s = t_pass.elapsed().as_secs_f64();
+ (proof, [witness_s, commit_s, zerocheck_s, lincheck_s, open_s, pass_s])
+ };
+
+ let env = |key: &str, default: usize| {
+ std::env::var(key).map_or(default, |s| {
+ s.parse().unwrap_or_else(|_| panic!("{key} must be an integer"))
+ })
+ };
+ let plan = Plan::new(env("BENCH_REPEAT", 1), env("BENCH_COOLDOWN", 2) as u64);
+ let mut stages: [Timing; 6] = std::array::from_fn(|_| Timing::default());
+ let (transcript, _) = plan.warm_then_measure(|_final_pass| {
+ let (out, secs) = prove_pass();
+ for (timing, s) in stages.iter_mut().zip(secs) {
+ timing.push(s);
+ }
+ out
+ });
+ // The warmup pass also pushed a sample; drop the leading one per stage.
+ let [witness, commit_stage, zerocheck, lincheck, open, pass] = stages.map(|t| {
+ let mut kept = Timing::default();
+ for &s in &t.samples()[1..] {
+ kept.push(s);
+ }
+ kept
+ });
+
+ let (_, verify_time) = Plan::new(plan.repeat, 0).measure_quiet(|_final_pass| {
+ let mut vs = VerifierState::from_label(&label, &transcript);
+ let root = vs.next_root().expect("commitment root");
+ let replay = block.verify(n_log, &mut vs).expect("Flock reduction verifies");
+ let ring = ring_switch_verify(mu, 0, &replay.claim);
+ assert!(
+ verify_opening_batch_mixed_whir_stacked(
+ &mut vs,
+ &config,
+ mu,
+ 1 << INITIAL_FOLDING_FACTOR,
+ &root,
+ &[],
+ std::slice::from_ref(&ring)
+ )
+ .is_ok(),
+ "stacked PCS opening verifies"
+ );
+ vs.finish().expect("transcript fully consumed");
+ });
+
+ let pass_s = pass.mean();
+ let share = |s: f64| format!("{:>5.1}%", 100.0 * s / pass_s);
+ let ms = |t: &Timing| format!("{:>8.1} ms{:<9}{}", t.mean() * 1e3, t.spread(), share(t.mean()));
+ let named = witness.mean() + commit_stage.mean() + zerocheck.mean() + lincheck.mean() + open.mean();
+ println!(
+ "\nFlock {title} batch proving, {} {unit} (2^{n_log} slots)",
+ pretty_integer(n)
+ );
+ println!(
+ " block : 2^{} bits, {} constrained",
+ circuit.k_log(),
+ pretty_integer(circuit.useful_bits())
+ );
+ println!(" setup (circuit, excluded) : {setup_ms:>8.1} ms");
+ println!(" witness-gen : {}", ms(&witness));
+ println!(" commit : {}", ms(&commit_stage));
+ println!(" zerocheck : {}", ms(&zerocheck));
+ println!(" lincheck : {}", ms(&lincheck));
+ println!(" pcs opening : {}", ms(&open));
+ println!(
+ " other : {:>8.1} ms{:<9}{}",
+ (pass_s - named) * 1e3,
+ "",
+ share(pass_s - named)
+ );
+ println!(" ------------------------------------------");
+ println!(
+ " prove TOTAL (witness included) : {:>8.1} ms{}",
+ pass_s * 1e3,
+ pass.spread()
+ );
+ println!(
+ " verify : {:>8.1} ms",
+ verify_time.mean() * 1e3
+ );
+ println!(
+ " throughput : {:>14} {unit}/s{}",
+ pretty_integer((n as f64 / pass_s).round() as u64),
+ pass.spread()
+ );
+}
diff --git a/crates/flock/tests/batch_proving_hashes.rs b/crates/flock/tests/batch_proving_hashes.rs
index 8d77d26af..e183dfdaa 100644
--- a/crates/flock/tests/batch_proving_hashes.rs
+++ b/crates/flock/tests/batch_proving_hashes.rs
@@ -21,7 +21,6 @@ use primitives::{field::F64, pretty_integer, test_rng::Rng};
#[test]
#[ignore = "manual release benchmark; needs a large-stack worker and substantial memory"]
fn hash_batch_prove_verify() {
- // The XMSS n=820 workload executes about 2^17 BLAKE2s compressions.
let requested_n_log: usize = std::env::var("FLOCK_N_LOG")
.ok()
.map(|s| s.parse().expect("FLOCK_N_LOG must be an integer"))
@@ -86,7 +85,15 @@ fn hash_batch_prove_verify() {
let t = Instant::now();
let ring = ring_switch_open(n, 0, &reduced);
- open_batch_mixed_whir_stacked(&mut ps, mu, q_flock, &prover_data, &config, &[], &ring);
+ open_batch_mixed_whir_stacked(
+ &mut ps,
+ mu,
+ q_flock,
+ &prover_data,
+ &config,
+ &[],
+ std::slice::from_ref(&ring),
+ );
let open_s = t.elapsed().as_secs_f64();
let prove_s = t_prove.elapsed().as_secs_f64();
@@ -138,7 +145,7 @@ fn hash_batch_prove_verify() {
1 << INITIAL_FOLDING_FACTOR,
&root,
&[],
- &ring
+ std::slice::from_ref(&ring)
)
.is_ok(),
"stacked PCS opening verifies"
@@ -181,8 +188,4 @@ fn hash_batch_prove_verify() {
pretty_integer(compressions_per_second),
prove.spread()
);
- println!(
- " (~{:.1} XMSS/s equivalent at 146 compressions/signature)",
- n as f64 / prove_s / 146.0
- );
}
diff --git a/crates/lean_compiler/Cargo.toml b/crates/lean_compiler/Cargo.toml
deleted file mode 100644
index 0de4a791b..000000000
--- a/crates/lean_compiler/Cargo.toml
+++ /dev/null
@@ -1,16 +0,0 @@
-[package]
-name = "lean_compiler"
-version.workspace = true
-edition.workspace = true
-publish = false
-
-[lints]
-workspace = true
-
-[dependencies]
-primitives.workspace = true
-lean_vm.workspace = true
-
-[dev-dependencies]
-rand.workspace = true
-bincode.workspace = true
diff --git a/crates/lean_compiler/snark_lib.py b/crates/lean_compiler/snark_lib.py
deleted file mode 100644
index 9715f64a1..000000000
--- a/crates/lean_compiler/snark_lib.py
+++ /dev/null
@@ -1,236 +0,0 @@
-# Import this in zkDSL .py files (`from snark_lib import *`) so editors and
-# linters resolve the builtins. The compiler skips the import; it does not
-# include other source files (single-file programs only). Resolved through
-# `extraPaths` in the root pyrightconfig.json; see zkDSL.md. A guest is not a
-# runnable Python file: its `*_PLACEHOLDER` names are filled in at compile time,
-# so `import`ing one raises NameError.
-
-from typing import Any, Optional
-
-Const = Any
-"""Parameter annotation: `def f(k: Const, x):`, where `k` is a compile-time
-argument; the compiler specializes the function per distinct constant."""
-
-
-class _Elt:
- """A 192-bit machine word in E = GF(2^192), represented as a cubic tower
- over K = GF(2^64). Indices and addresses are K-valued powers of GEN, i.e.
- "in the exponent": `GEN ** k` is the k-th index and `x * GEN` its successor.
- A heap pointer is K-valued too; `buf[i]` is the write-once cell at `buf * i`."""
-
- def __add__(self, other): # field addition = XOR
- _ = other
- return _Elt()
-
- __radd__ = __add__
-
- def __mul__(self, other): # tower-field product
- _ = other
- return _Elt()
-
- __rmul__ = __mul__
-
- def __truediv__(self, other): # field division a / b = a · b⁻¹ (single slash)
- _ = other
- return _Elt()
-
- __rtruediv__ = __truediv__
-
- def __pow__(self, k: int):
- _ = k
- return _Elt()
-
- def __getitem__(self, idx): # heap read m[self · idx]
- _ = idx
- return _Elt()
-
- def __setitem__(self, idx, value): # heap store m[self · idx] (write-once)
- _ = idx, value
-
-
-def f192(c0: int, c1: int, c2: int) -> _Elt:
- """Construct a field constant from its three little-endian GF(2^64) limbs."""
- _ = c0, c1, c2
- return _Elt()
-
-
-GEN = _Elt()
-"""The fixed generator g = x of K^× = GF(2^64)^× (order 2^64 - 1)."""
-
-
-def hint_decompose_bits(bits, value, nbits: int) -> None:
- """Computed advice: the prover writes the `nbits` bits of `value` into the
- `bits` buffer. UNCONSTRAINED: the caller must check booleanity and that the
- bits reconstruct `value` (a range check that `value < 2^nbits`)."""
- _ = bits, value, nbits
-
-
-def hint_decompose_bits_exponent(bits, x, nbits: int) -> None:
- """Computed advice: the prover writes the `nbits` bits of n, where x = g^n
- (recovered by a bounded discrete log at witness generation), into `bits`.
- UNCONSTRAINED: the caller checks booleanity and Π g^(bit_j 2^j) == x."""
- _ = bits, x, nbits
-
-
-def hint_log2_ceil(bits, nbits: int, floor: int) -> _Elt:
- """Computed advice: returns `g^max(log2_ceil(v), floor)`, where `v` is the
- integer the `nbits`-cell `bits` buffer decodes to. The prover fills it at
- witness-generation; it is UNCONSTRAINED, so the caller must verify it (see the
- log2_ceil_in_the_exponent wrapper in the recursion guest). log2 = base-2 log of the integer, NOT the
- discrete log base g that `log(...)` means."""
- _ = bits, nbits, floor
- return _Elt()
-
-
-def const(e):
- """`if const(a == b):` asks for the branch to be decided while compiling, and
- `const(e)` in a value position asks for `e` itself to be read that way.
-
- Two things follow. The condition must be decidable then (both sides
- compile-time integers), and it is read with INTEGER arithmetic, which is the
- regime a compile-time constant lives in. Without the wrapper, a condition
- whose integer and field readings disagree is rejected rather than silently
- decided one way, since `+` is XOR in a value. A folded branch is
- straight-line code, so unlike a runtime branch its bindings outlive it."""
- return e
-
-
-def log(x) -> int:
- """The discrete log base GEN: `x = GEN ** log(x)`. Only meaningful inside
- a range-check assert (`assert log(x) < log(GEN ** k)`, equivalently
- `assert log(x) < k`, proves `x ∈ {GEN**0, …, GEN**(k-1)}` in 3 cycles),
- or as the scrutinee of `match`."""
- _ = x
- return 0
-
-# @inline decorator (does nothing in Python execution)
-def inline(fn):
- return fn
-
-
-def match(value: int, *args):
- """A `match` with generated arms: `match(log(x), range(a, b),
- lambda i: …, …)` expands to one arm per integer of the contiguous ranges
- (which must start at 0), the lambda applied to the concrete value; the
- results bind to the assignment targets. In Python execution, finds the
- matching range and calls its lambda."""
- for i in range(0, len(args), 2):
- rng, fn = args[i], args[i + 1]
- if value in rng:
- return fn(value)
- raise AssertionError(f"value {value} not in any range")
-
-
-def mul_range(start, stop) -> list:
- """The loop counter walked in the exponent: from element `start` to `stop`
- (exclusive), ×GEN each iteration. `start` is a compile-time power of GEN
- (`1`, `GEN`, or `GEN ** k`); `stop` is one too, or else a runtime g-power
- element the walk must be able to reach."""
- _ = start, stop
- return []
-
-
-def unroll(a: int, b: int) -> range:
- """Compile-time unrolling: the body is replicated for i = a, …, b-1, the
- counter substituted as an integer literal (usable as a stack index, slice
- bound, or `Const` argument). Bounds are compile-time integers, including
- `Const` parameters."""
- return range(a, b)
-
-
-def HeapBuf(n) -> _Elt:
- """Allocate a fresh, disjoint heap buffer; evaluates to its pointer (a
- fresh g-power). `n` is either an integer literal (compile-time size), or a
- runtime value carrying the cell count *in the exponent*: `g^k` allocates
- `k` cells, so a size derived from a g-power count is plain field arithmetic
- (`HeapBuf(cnt * cnt)` is `2·log(cnt)` cells). Allocation is a prover
- convenience, so an under-size only trips write-once."""
- _ = n
- return _Elt()
-
-
-def StackBuf(n: int) -> _Elt:
- """Allocate `n` consecutive frame (stack) cells. A size-2 StackBuf holds a
- 256-bit value and is a valid `blake2s` operand."""
- _ = n
- return _Elt()
-
-
-def addr(buf) -> _Elt:
- """The g-address of a StackBuf's first cell, so a frame run can be pointed
- at: `p = addr(sb)` binds a pointer whose `p[i]` / `p * GEN ** k` behave like
- a HeapBuf's, while `sb[k]` itself stays a direct frame cell. Costs one
- materialization of `fp` per function (2 DEREFs, amortized; free in `main`).
- Only valid as the whole right-hand side of an assignment."""
- _ = buf
- return _Elt()
-
-
-def hint_witness(dest, name: Optional[str] = None) -> Any:
- """Take the next ENTRY (a slice of values) of the named prover witness
- stream.
-
- Two forms. As a statement, `hint_witness(dest, "name")` fills `dest` (a
- StackBuf, or a StackBuf/HeapBuf slice of any length). As an expression,
- `x = hint_witness("name")` binds ONE value and needs no destination, the
- entry then having to hold exactly one value.
-
- The same symbol may be hinted many times, each call popping the next entry
- (`Program::set_witness`; test programs declare one `# witness name: v1, …`
- line per entry). Zero cycles either way, and the values are completely
- UNCONSTRAINED: the program must constrain them itself (asserts, range
- checks, hashes)."""
- _ = dest, name
- return _Elt()
-
-
-def assert_in_k(a, b) -> None:
- """Prove that both machine words are GF(2^64)-valued. This is the sole
- packing-related intrinsic and lowers to one untaken JUMP."""
- _ = a, b
-
-
-def hint_f192_limbs(dest, value) -> None:
- """Computed advice: write the first `len(dest)` GF(2^64) coordinate limbs
- of `value` into a 1-to-3-cell StackBuf. UNCONSTRAINED; callers bind the
- result with `assert_in_k` and field reconstruction."""
- _ = dest, value
-
-
-def blake2s(
- a,
- b,
- out,
- *,
- cv=None,
- counter: Optional[int] = None,
- final: Optional[int] = None,
- last_node: int = 0,
- md=None,
-) -> None:
- """One standard BLAKE2s compression of the two 256-bit message operands
- `a`, `b`, written into the 2-cell run `out` (write-once: if `out` was
- already written, this asserts it equals the chaining value).
-
- With no keywords this hashes exactly 64 bytes: the parameterized BLAKE2s-256
- initial chaining value, byte counter 64, final-block flag set. That is
- `blake2s(a || b)`, the form every Fiat-Shamir step and Merkle node uses.
-
- For a longer message, drive the blocks yourself. `counter` is the CUMULATIVE
- byte count through this block (`64 * whole_blocks_before + bytes_in_this_block`)
- and `final=1` marks the last block; `cv` carries the previous block's output
- and requires one of `counter`, `final`, `last_node` or `md`. Setting any of
- `counter`, `final` or `last_node` makes `final` default
- to 0, so a single short block needs `counter=, final=1`. Bytes past the
- block's real length must be zero-filled by the program. `last_node` is
- BLAKE2s's tree-mode `f1` and is 0 everywhere here.
-
- `md` is the whole 128-bit metadata word as a value the program computed, for
- a hash whose block count is only known at run time. It replaces `counter`,
- `final` and `last_node` (giving both is an error), and it must not name a
- cell of `out`.
-
- Message, chaining-value, and output operands are size-2 StackBufs or
- 2-cell slices `buf[lo:hi]` of larger StackBufs or HeapBufs (heap inputs are
- bridged through the stack, one DEREF per cell)."""
- _ = a, b, out, cv, counter, final, last_node, md
diff --git a/crates/lean_compiler/src/ast.rs b/crates/lean_compiler/src/ast.rs
deleted file mode 100644
index 73e75f3e2..000000000
--- a/crates/lean_compiler/src/ast.rs
+++ /dev/null
@@ -1,464 +0,0 @@
-//! The surface AST produced by the parser: expressions, statements, functions.
-
-use primitives::field::F192;
-
-/// An expression. Arithmetic is the field's own: `+` is `XOR`, `*` is `MUL`.
-#[derive(Clone, Debug, PartialEq)]
-pub enum Expr {
- /// Integer / field literal: the source syntax provides a raw 128-bit value,
- /// embedded into the low two limbs of the 192-bit tower element (`c2 = 0`).
- Lit(u128),
- /// The generator `g`, written `GEN`. A logical index `i` rides the exponent
- /// as `gⁱ`, so `GEN` is the unit step.
- Gen,
- /// The field constant `g^k`. The exponent is a `u128`, so an index can be a
- /// large logical value.
- GPow(u128),
- /// `GEN ** e` for a compile-time integer *expression* `e` (an `unroll`
- /// variable, a constant, index arithmetic of those), resolved to a concrete
- /// `g^k` at lowering. Lets `buf[GEN ** i]` name cell `i` with no cursor.
- GenPow(Box),
- /// `base ** e` with a non-`GEN` base and a compile-time exponent, by
- /// square-and-multiply at lowering: integer arithmetic in an index or bound
- /// position, field arithmetic in a value. The base may be runtime.
- Pow(Box, Box),
- /// A variable in scope.
- Var(String),
- Add(Box, Box),
- Mul(Box, Box),
- /// Integer subtraction, **compile-time only**: field subtraction is `+`
- /// (XOR), so `-` means something only in index space. Using one as a runtime
- /// field value is an error.
- Sub(Box, Box),
- /// Integer floor-division `a // b` and remainder `a % b`, **compile-time
- /// only** (the field has no integer division). Valid where an index /
- /// slice bound / `Const` argument is expected, or as a folded `if`
- /// condition; using one as a runtime field value is an error.
- Div(Box, Box),
- Mod(Box, Box),
- /// Field division `a / b` (single slash): a **runtime** field operation,
- /// `a · b⁻¹`. Lowered to one `MUL` whose quotient operand is unset, so the
- /// write-once back-solve fills it with `a · b⁻¹` and the `MUL` constraint
- /// pins `quotient · b == a` (§range-check trick). No hint: the inverse is
- /// nondeterministic but the constraint binds it. `b == 0` is rejected,
- /// including `0 / 0`; `1 / b` therefore also enforces `b != 0`. Distinct
- /// from the compile-time `//` ([`Expr::Div`]).
- FieldDiv(Box, Box),
- /// Single-return function call in expression position.
- Call(String, Vec),
- /// `HeapBuf(n)`: allocate a heap buffer of `n` cells; evaluates to its pointer.
- HeapBuf(u64),
- /// `HeapBuf(size)` with a *runtime* size carried **in the exponent**: the
- /// buffer holds `k` cells where `size = g^k` (so a size derived from a
- /// g-power count `n` is plain field arithmetic: `HeapBuf(n * n * GEN**2)`
- /// is `2·log(n) + 2` cells). The allocation is a prover convenience (like
- /// every base pointer), so an under-size only hurts the prover:
- /// overlapping regions trip write-once. Evaluates to the pointer.
- HeapBufDyn(Box),
- /// `StackBuf(n)`: allocate `n` *consecutive* frame (stack) cells, bound as a
- /// stack value. Its cells `sa[0..n]` are written/read directly (no heap deref),
- /// and a size-2 `StackBuf` is a valid `blake2s` operand (the four 64-bit hash
- /// words live as two lanes in each of two consecutive 128-bit cells).
- StackBuf(u64),
- /// `arr[idx]`: read a cell. For a heap `arr` (a pointer), `m[arr·idx]` (idx a
- /// g-power). For a [`Expr::StackBuf`]: the frame cell `base + idx` (idx a
- /// compile-time integer), read directly.
- Index(Box, Box),
- /// `buf[lo:hi]`: a run of cells of a [`Expr::StackBuf`] (frame cells
- /// `base+lo..base+hi`) or of a [`Expr::HeapBuf`] (heap cells
- /// `ptr·g^lo..ptr·g^hi`), with compile-time integer bounds (`hi`
- /// exclusive). Only meaningful as a `blake2s` operand, where it must span
- /// exactly 2 cells (one 256-bit value).
- Slice(Box, Box, Box),
- /// `[a, b, …]`: an initialized [`Expr::StackBuf`], so `x = [a, b]` allocates
- /// a StackBuf of the element count and writes each element in place, sugar
- /// for the alloc-then-store idiom. Only meaningful as the RHS of a plain
- /// assignment (inside a function; a *top-level* `NAME = […]` is a constant
- /// array, see [`Ast::const_arrays`]).
- ListLit(Vec),
-}
-
-/// A statement, with the source line it came from. The line is what every
-/// lowering diagnostic names and what the pc-to-line table is built from: the
-/// AST is the only place that still knows it, since lowering works in frame
-/// cells and program counters.
-#[derive(Clone, Debug)]
-pub struct Stmt {
- pub line: u32,
- pub kind: StmtKind,
-}
-
-impl Stmt {
- pub fn new(line: u32, kind: StmtKind) -> Self {
- Self { line, kind }
- }
-
- /// A statement the compiler synthesized (a loop helper's body, a desugared
- /// tail call): it inherits the line of whatever it was generated for.
- pub fn at(&self, kind: StmtKind) -> Self {
- Self { line: self.line, kind }
- }
-}
-
-/// What a statement does.
-#[derive(Clone, Debug)]
-pub enum StmtKind {
- /// `x = expr` (immutable binding).
- Let(String, Expr),
- /// `x, y, … = f(args)`: call with multiple returns.
- LetTuple(Vec, String, Vec),
- /// `assert a == b`: a proof-enforced equality.
- AssertEq(Expr, Expr),
- /// `assert a != b`. Lowers to `x = a + b`, a hinted `inv = x⁻¹`, `p = x·inv`
- /// and `SET p = 1`: `x = 0` forces `p = 0` whatever the hint, so the
- /// write-once conflict is the assertion. See `FnLower::lower_assert_ne`.
- AssertNe(Expr, Expr),
- /// `assert log X < log Y`, or `assert log X < k`: a range check in the
- /// exponent, proving `x < k` for `X = g^x`. See `FnLower::lower_assert_lt`.
- AssertLt(Expr, LtBound),
- /// `f(args)` as a statement (returns discarded).
- Call(String, Vec),
- /// `hint_witness(dest, "name")`: fill `dest` from the next entry of the named
- /// witness stream (`Program::set_witness`), whose length must match. The same
- /// name may be hinted many times, each call popping the next entry. Zero
- /// cycles and completely unconstrained, so the program must constrain the
- /// values itself.
- HintWitness { dest: Expr, name: String },
- /// `x = hint_witness("stream")`: one hinted value bound to a name, as
- /// unconstrained as any other hint. The run form above needs a destination
- /// that already exists, so a lone scalar otherwise costs a one-cell
- /// `StackBuf`, a slice of it, and a read back out.
- LetHintWitness { name: String, stream: String },
- /// `print("label", expr)`: a prover-side debug print, witness generation only.
- Print { label: String, value: Expr },
- /// `if lhs == rhs:` (`eq`) / `if lhs != rhs:`, with an optional `else` (an
- /// `elif` parses as an `else` holding a nested `if`). One conditional `JUMP`
- /// on the XOR of the sides. Bindings inside a branch are local to it, and the
- /// branches communicate through write-once memory, only one of them running.
- /// See `FnLower::lower_if`.
- If {
- eq: bool,
- lhs: Expr,
- rhs: Expr,
- then: Vec,
- els: Vec,
- /// Written `if const(a == b):`. The author asks for the branch to be
- /// decided while compiling, so a condition that cannot be decided then is
- /// an error rather than a runtime test, and one whose integer and field
- /// readings disagree is an error rather than a silent choice.
- force_const: bool,
- },
- /// `targets = match(log(x), range(a, b), lambda i: expr, …)`: the one dispatch
- /// construct. Arm `j` is the lambda body with the parameter replaced by the
- /// literal `j`, expanded at parse time, and `x = g^j` runs arm `j` through a
- /// trampoline table in the bytecode. Every arm writes the same cells, exactly
- /// one of them running, so a target may be a name bound at the join or a
- /// `StackBuf` element written in place. See `FnLower::lower_match`.
- Match {
- targets: Vec,
- x: Expr,
- arms: Vec,
- },
- /// `arr[idx] = value`: store into a heap cell (write-once).
- Store(Expr, Expr, Expr),
- /// `for i in mul_range(GEN ** lo, stop)`: the counter rides the exponent as
- /// `gⁱ`, advancing by `×g` from `g^lo` until it reaches `stop`, which is not
- /// itself executed. There is no step knob, the bounds being field elements
- /// (`mul_range(1, GEN ** 10)` runs ten times). A runtime `stop` must be known
- /// reachable: range-check its log, or the walk never terminates.
- For {
- var: String,
- lo: u64,
- hi: ForBound,
- body: Vec,
- },
- /// `for i in unroll(a, b)`: the body emitted `b − a` times with `i`
- /// substituted by each literal, so `i` is usable wherever a literal is. No
- /// call, no frame, no counter, at the price of code size. The bounds are
- /// compile-time integer expressions evaluated at lowering, after
- /// `Const`-parameter specialization, so `unroll(0, n)` with `n: Const` works.
- Unroll {
- var: String,
- lo: Expr,
- hi: Expr,
- body: Vec,
- },
- /// `return e, …` (a bare `return` is the empty vector).
- Return(Vec),
- /// Internal, from loop lowering: `if lhs != rhs: callee(args)` in tail
- /// position, dispatched by `JUMP`'s nonzero test.
- CallIfNe(Expr, Expr, String, Vec),
-}
-
-/// A `mul_range` stop bound: a compile-time `GEN ** k`, or a runtime g-power
-/// element (evaluated once in the enclosing scope and threaded through the
-/// loop helper as a parameter).
-#[derive(Clone, Debug)]
-pub enum ForBound {
- Const(u64),
- Runtime(Expr),
-}
-
-/// A range-check bound (`assert log X < …`): a compile-time exponent, or a
-/// runtime `g^n`. Same gadget either way, only the cell holding `g^{k-1}`
-/// differing.
-///
-/// A runtime bound loses the `k ≤ 2^MIN_LOG_MEM` cap the compiler would check,
-/// so the PROGRAM owes it: range-check the bound itself first, or `log X < log n`
-/// bounds `X` only by the prover-announced memory size.
-#[derive(Clone, Debug)]
-pub enum LtBound {
- Const(u64),
- Runtime(Expr),
-}
-
-/// Compile-time representation of a runtime parameter or return value.
-#[derive(Clone, Copy, Debug, PartialEq, Eq)]
-pub enum Shape {
- /// One ordinary field element or address cell. Heap buffers use this shape:
- /// allocation happens in the callee and only their pointer crosses.
- Scalar,
- /// A compile-time-sized run of consecutive frame cells.
- StackBuf(u32),
-}
-
-impl Shape {
- /// Number of physical call-frame cells occupied by this value.
- pub(crate) fn cells(self) -> u32 {
- match self {
- Self::StackBuf(n) => n,
- Self::Scalar => 1,
- }
- }
-}
-
-/// A function parameter and its calling convention.
-#[derive(Clone, Debug)]
-pub struct Param {
- pub name: String,
- pub kind: ParamKind,
-}
-
-#[derive(Clone, Copy, Debug, PartialEq, Eq)]
-pub enum ParamKind {
- /// Substituted at the call site, with no runtime argument cell.
- Const,
- Runtime(Shape),
-}
-
-impl Param {
- pub(crate) fn shape(&self) -> Shape {
- match self.kind {
- ParamKind::Const => Shape::Scalar,
- ParamKind::Runtime(shape) => shape,
- }
- }
-}
-
-/// A function definition. `main` is the entry point.
-#[derive(Clone, Debug)]
-pub struct Func {
- pub name: String,
- /// A function with a `Const` parameter is a template, specialized per
- /// distinct constant tuple before lowering.
- pub params: Vec,
- /// Compile-time shape of each source-level return value. Stack buffers use
- /// multiple physical ABI cells; everything else uses one cell.
- pub return_shapes: Vec,
- pub body: Vec,
- /// `@inline`: expand at each call site instead of emitting a call, so the
- /// frame and the argument and return plumbing vanish. The body must be a
- /// single tail `return`, and is never lowered standalone.
- pub inline: bool,
-}
-
-impl Func {
- pub(crate) fn has_const_params(&self) -> bool {
- self.params.iter().any(|p| p.kind == ParamKind::Const)
- }
-
- pub(crate) fn param_shapes(&self) -> impl Iterator- + '_ {
- self.params.iter().map(Param::shape)
- }
-}
-
-/// A whole program: a set of functions including `main`.
-#[derive(Clone, Debug)]
-pub struct Ast {
- pub funcs: Vec,
- /// Top-level constant arrays `NAME = [a, b, c]`, in declaration order.
- /// Indexed `NAME[i]` and measured `len(NAME)` at compile time only, `i` being
- /// a literal, a constant or an `unroll` variable. Unlike a scalar constant
- /// these are not textually substituted, but resolved at lowering.
- pub const_arrays: Vec<(String, Vec)>,
-}
-
-// Free-variable analysis: pure AST, no lowering state. Its one consumer is the
-// `for` desugaring, which needs the names a loop body reads from outside itself.
-
-use std::collections::HashSet;
-
-/// Collect variable references in `e` into `refs` (in source order).
-fn free_vars_expr<'a>(e: &'a Expr, refs: &mut Vec<&'a str>) {
- match e {
- Expr::Var(v) => refs.push(v.as_str()),
- Expr::Add(a, b)
- | Expr::Mul(a, b)
- | Expr::Sub(a, b)
- | Expr::Div(a, b)
- | Expr::FieldDiv(a, b)
- | Expr::Mod(a, b)
- | Expr::Index(a, b)
- | Expr::Pow(a, b) => {
- free_vars_expr(a, refs);
- free_vars_expr(b, refs);
- }
- Expr::Slice(a, lo, hi) => {
- free_vars_expr(a, refs);
- free_vars_expr(lo, refs);
- free_vars_expr(hi, refs);
- }
- Expr::Call(_, args) | Expr::ListLit(args) => args.iter().for_each(|a| free_vars_expr(a, refs)),
- Expr::HeapBufDyn(sz) | Expr::GenPow(sz) => free_vars_expr(sz, refs),
- Expr::Lit(_) | Expr::Gen | Expr::GPow(_) | Expr::HeapBuf(_) | Expr::StackBuf(_) => {}
- }
-}
-
-/// Collect references in `s` into `refs` and names it binds into `bound`.
-/// Every name the block binds, ignoring scope. [`free_vars_stmt`] deliberately
-/// does not answer this: its `bound` set is scoped, so an arm-local binding is
-/// discarded with the arm.
-pub(crate) fn binds_anywhere<'a>(body: &'a [Stmt], out: &mut HashSet<&'a str>) {
- for s in body {
- match &s.kind {
- StmtKind::Let(n, _) | StmtKind::LetHintWitness { name: n, .. } => {
- out.insert(n.as_str());
- }
- StmtKind::LetTuple(ns, ..) => ns.iter().for_each(|n| {
- out.insert(n.as_str());
- }),
- StmtKind::Match { targets, .. } => targets.iter().for_each(|t| {
- if let Expr::Var(n) = t {
- out.insert(n.as_str());
- }
- }),
- StmtKind::If { then, els, .. } => {
- binds_anywhere(then, out);
- binds_anywhere(els, out);
- }
- StmtKind::For { var, body, .. } | StmtKind::Unroll { var, body, .. } => {
- out.insert(var.as_str());
- binds_anywhere(body, out);
- }
- _ => {}
- }
- }
-}
-
-/// Collect references from a block whose bindings do not escape.
-fn scoped_vars<'a>(body: &'a [Stmt], refs: &mut Vec<&'a str>) {
- let mut inner = HashSet::new();
- for s in body {
- free_vars_stmt(s, refs, &mut inner);
- }
-}
-
-pub(crate) fn free_vars_stmt<'a>(s: &'a Stmt, refs: &mut Vec<&'a str>, bound: &mut HashSet<&'a str>) {
- match &s.kind {
- StmtKind::Let(n, e) => {
- free_vars_expr(e, refs);
- bound.insert(n.as_str());
- }
- StmtKind::LetTuple(ns, _, args) => {
- args.iter().for_each(|a| free_vars_expr(a, refs));
- ns.iter().for_each(|n| {
- bound.insert(n.as_str());
- });
- }
- StmtKind::AssertEq(a, b) | StmtKind::AssertNe(a, b) => {
- free_vars_expr(a, refs);
- free_vars_expr(b, refs);
- }
- StmtKind::AssertLt(e, bound) => {
- free_vars_expr(e, refs);
- if let LtBound::Runtime(b) = bound {
- free_vars_expr(b, refs);
- }
- }
- StmtKind::HintWitness { dest, .. } => free_vars_expr(dest, refs),
- StmtKind::LetHintWitness { name, .. } => {
- bound.insert(name.as_str());
- }
- StmtKind::Print { value, .. } => free_vars_expr(value, refs),
- StmtKind::If {
- lhs,
- rhs,
- then,
- els,
- force_const,
- ..
- } => {
- free_vars_expr(lhs, refs);
- free_vars_expr(rhs, refs);
- // An arm's bindings are local to it (`zkDSL.md` §Control flow), so each
- // gets its own scope. Sharing one made a name rebound in ONE arm count
- // as loop-local everywhere, so the OUTER binding the other arm reads
- // was never captured and a legal program failed with `unbound
- // variable`. Over-collecting into `refs` is harmless: a capture naming
- // nothing in the enclosing scope is dropped.
- // `lower_if` FOLDS a compile-time condition and runs the taken branch
- // without `scoped`, so its bindings persist exactly like an `unroll`
- // body's. Modelling that as scoped over-captured, and the loop's own
- // self-call then read a name the folded arm had rebound to a
- // `StackBuf`. Both sides literal is the syntactic half of that test.
- if *force_const || matches!((lhs, rhs), (Expr::Lit(_), Expr::Lit(_))) {
- then.iter().for_each(|s| free_vars_stmt(s, refs, bound));
- els.iter().for_each(|s| free_vars_stmt(s, refs, bound));
- } else {
- scoped_vars(then, refs);
- scoped_vars(els, refs);
- }
- }
- StmtKind::Match { targets, x, arms } => {
- free_vars_expr(x, refs);
- arms.iter().for_each(|a| free_vars_expr(a, refs));
- for t in targets {
- match t {
- Expr::Var(n) => {
- bound.insert(n.as_str());
- }
- // A store target is READ, not bound: `sb[i], e = …` needs `sb`.
- other => free_vars_expr(other, refs),
- }
- }
- }
- StmtKind::CallIfNe(a, b, _, args) => {
- free_vars_expr(a, refs);
- free_vars_expr(b, refs);
- args.iter().for_each(|e| free_vars_expr(e, refs));
- }
- StmtKind::Call(_, args) => args.iter().for_each(|a| free_vars_expr(a, refs)),
- StmtKind::Store(arr, idx, val) => {
- free_vars_expr(arr, refs);
- free_vars_expr(idx, refs);
- free_vars_expr(val, refs);
- }
- StmtKind::Return(es) => es.iter().for_each(|e| free_vars_expr(e, refs)),
- StmtKind::For { hi, body, .. } => {
- if let ForBound::Runtime(b) = hi {
- free_vars_expr(b, refs);
- }
- // A nested loop's body becomes its own function, so neither its
- // counter nor its bindings exist out here. `unroll` below is the
- // opposite: it replicates straight-line code into THIS scope, so its
- // bindings really do persist and it keeps the shared set.
- scoped_vars(body, refs);
- }
- StmtKind::Unroll { var, lo, hi, body } => {
- free_vars_expr(lo, refs);
- free_vars_expr(hi, refs);
- bound.insert(var.as_str());
- body.iter().for_each(|s| free_vars_stmt(s, refs, bound));
- }
- }
-}
diff --git a/crates/lean_compiler/src/filler.rs b/crates/lean_compiler/src/filler.rs
deleted file mode 100644
index 5c4f1c66f..000000000
--- a/crates/lean_compiler/src/filler.rs
+++ /dev/null
@@ -1,58 +0,0 @@
-//! Fill blocks: extra rows so every table's height is a power of two.
-//!
-//! A table is proven over a power-of-two number of rows, so a table whose execution
-//! needs fewer has to make up the difference. The alternative to this module is padding
-//! rows, which are not real rows: they put default tuples on the bus that nothing
-//! matches, so the verifier has to be told each table's real row count and divide those
-//! tuples back out. That correction was the most delicate part of the bus argument, and
-//! it existed only for the instruction tables; unread memory cells and unexecuted
-//! program entries already need nothing, their seed and finalize tuples cancelling.
-//!
-//! So run the difference off instead. Every program carries, past `main`'s halt, one
-//! block per table per size in `lean_vm::cpu::filler::SIZES`: that many dummy
-//! instructions of the table's opcode, then a `JUMP` back to the block's own first
-//! instruction. Each block is therefore a cycle, and no program code enters one: on the
-//! bus its state tuples cancel against each other rather than against the program's
-//! chain, so it can be traversed any number of times, and the interpreter walks the
-//! blocks itself once the program has halted
-//! (`lean_vm::cpu::Program::execute`).
-//!
-//! Nothing in a block counts, tests, or allocates: a traversal of the size-`s` block
-//! costs exactly `s + 1` rows, `s` of its table and one `JUMP`. That is where the sizes
-//! earn their keep: powers of two make any fill reachable exactly, while the bulk rides
-//! the largest block at one jump per 128 rows. A table already on a power of two is
-//! never entered.
-
-/// What a table's dummy instruction is: the cheapest instruction of that opcode that can
-/// be executed any number of times in one frame, given write-once memory. All but
-/// `Blake2s` name a single scratch cell as every operand, so the value they write there is
-/// the value already there (`FnLower::lower_filler_blocks` fixes the frame offsets).
-#[derive(Clone, Copy, Debug, PartialEq, Eq)]
-pub enum FillerOp {
- /// `XOR s, s -> s`: pins the scratch cell to `m[s] + m[s] = 0`.
- Xor,
- /// `MUL s, s -> s`: pins it to `m[s]^2`, so to `0` given the above.
- Mul,
- /// `SET s = 0`.
- Set,
- /// `DEREF` through the frame's pointer cell, which the interpreter sets to `g^0`, so
- /// the address is memory cell `0` and the value read is the public input's.
- Deref,
- /// `JUMP` on a cell nothing ever writes: a zero condition falls through, so the row
- /// fills the table without closing the block's cycle.
- Jump,
- /// One compression of message and chaining-value cells nothing ever writes, its
- /// digest placed clear of them, so every traversal compresses the same input.
- Blake2s,
-}
-
-/// The tables, in `lean_vm::cpu::Stats::TABLES` order, which is how the solver indexes
-/// them.
-pub const TABLES: [(u8, FillerOp); 6] = [
- (0, FillerOp::Xor),
- (1, FillerOp::Mul),
- (2, FillerOp::Set),
- (3, FillerOp::Deref),
- (4, FillerOp::Jump),
- (5, FillerOp::Blake2s),
-];
diff --git a/crates/lean_compiler/src/ir.rs b/crates/lean_compiler/src/ir.rs
deleted file mode 100644
index 29d5a8ec3..000000000
--- a/crates/lean_compiler/src/ir.rs
+++ /dev/null
@@ -1,139 +0,0 @@
-//! Lowered intermediate instructions and hints, between the AST and final assembly.
-
-use super::*;
-
-pub(crate) type Off = u32;
-
-/// A `SET` immediate: a field constant, or a function entry address resolved
-/// once entry program counters are fixed.
-#[derive(Clone, Debug)]
-pub(crate) enum KVal {
- /// The stride to the next frame in a reserved loop run.
- FrameSize,
- /// A 192-bit machine-word constant. Source literals fill only c0/c1, while
- /// compiler-generated constants may use the full field.
- Const(F192),
- Entry(String),
- /// The halt sentinel pc `g^{B-1}` (last bytecode slot), fixed once the
- /// padded bytecode size `B` is known. `main` jumps here to terminate.
- EndSentinel,
- /// An intra-function jump target: the `i`-th instruction of the function
- /// this `SET` belongs to, resolved to `g^{entry + i}` once entry pcs are
- /// fixed. Emitted with a placeholder by the `if`/`else` lowering and
- /// backpatched ([`FnLower::patch_local`]).
- Local(u32),
-}
-
-#[derive(Clone, Debug)]
-pub(crate) struct LInstr {
- pub(crate) op: LOp,
- /// Source line of the statement that emitted this instruction. Becomes
- /// `Program::src_lines`.
- pub(crate) line: u32,
- /// Prover hints applied (in order) *before* this instruction during witness
- /// generation.
- pub(crate) hints: Vec,
-}
-
-#[derive(Clone, Debug)]
-pub(crate) enum LOp {
- /// A copy into the next loop frame, outside this frame's own allocation.
- MulNextFrame {
- a: Off,
- b: Off,
- c: Off,
- },
- Set {
- o: Off,
- k: KVal,
- },
- Xor {
- a: Off,
- b: Off,
- c: Off,
- },
- Mul {
- a: Off,
- b: Off,
- c: Off,
- },
- Deref {
- o1: Off,
- o2: Off,
- o3: Off,
- mode: DerefMode,
- },
- Jump {
- oc: Off,
- od: Off,
- of: Off,
- },
- /// `BLAKE2s`: the four 128-bit input chunks `ins` are addressed independently,
- /// one frame cell each. The 32-byte output occupies the two consecutive
- /// 128-bit cells `c, c+1`; `md` is the cell holding the byte counter and the
- /// two flags.
- Blake2s {
- ins: [Off; 4],
- cv: Off,
- c: Off,
- md: Off,
- },
-}
-
-/// A prover hint attached to an instruction. Most are already the runtime's own
-/// [`RHint`] and pass straight through; the allocation ones are compiler-side,
-/// since their size is only known once every function's frame is laid out.
-#[derive(Clone, Debug)]
-pub(crate) enum Hint {
- /// Index the next frame address without allocating it again.
- NextFrameAddress,
- /// Reserve the loop run, including the final untaken call's argument frame.
- AllocLoopFrames { ptr: Off, callee: String, count: LoopCount },
- /// `m[fp·g^ptr] = g^{fresh base}`: a fresh, disjoint frame for `callee`.
- AllocFrame { ptr: Off, callee: String },
- /// `AllocFrame` sized to the **largest** of several callees, a shared frame
- /// for a dispatched call (all `callees` share the arg/return layout; only
- /// their local count, hence frame size, differs). See [`FnLower::lower_dispatched_call`].
- AllocFrameMax { ptr: Off, callees: Vec },
- /// `m[fp·g^ptr] = g^{fresh base}`: a fresh, disjoint heap region of `size`
- /// cells (a `HeapBuf(size)`), addressed by g-power offsets from the pointer.
- AllocBuffer { ptr: Off, size: u32 },
- /// `AllocBuffer` with a *runtime* size in the exponent: the cell count is
- /// the g-power exponent of `m[fp·g^size]` (a `HeapBuf(size_expr)`).
- AllocBufferDyn { ptr: Off, size: Off },
- /// A hint that needs nothing from the layout: witness fills, computed
- /// advice, debug prints.
- Resolved(RHint),
-}
-
-pub(crate) struct Lowered {
- pub(crate) name: String,
- pub(crate) code: Vec,
- pub(crate) frame_size: u32,
- /// The fill blocks this function carries, with `code`-relative pcs; only `main` has
- /// any ([`crate::lower::FnLower::lower_filler_blocks`]).
- pub(crate) filler: Vec,
-}
-
-/// A resolved run of consecutive cells ([`crate::lower::FnLower::cell_run`]): a
-/// frame (stack) run, used in place, or a heap slice (the buffer pointer's cell
-/// plus the first g-power offset), which a `blake2s` operand must bridge through
-/// the stack since `BLAKE2s` addresses only frame cells.
-pub(crate) enum CellRun {
- Stack { base: Off, len: u32 },
- Heap { ptr: Off, lo: u32, len: u32 },
-}
-
-impl CellRun {
- pub(crate) fn cells(&self) -> u32 {
- match *self {
- CellRun::Stack { len, .. } | CellRun::Heap { len, .. } => len,
- }
- }
-}
-
-#[derive(Clone, Debug)]
-pub(crate) enum LoopCount {
- Constant(u64),
- Bound { cell: Off, start: u64 },
-}
diff --git a/crates/lean_compiler/src/lib.rs b/crates/lean_compiler/src/lib.rs
deleted file mode 100644
index f00e3ab4b..000000000
--- a/crates/lean_compiler/src/lib.rs
+++ /dev/null
@@ -1,318 +0,0 @@
-//! A compiler from a Python-like zkDSL (see `zkDSL.md`) to the ISA (`cpu::Op`).
-//! Produces a [`lean_vm::cpu::Program`]: bytecode plus the prover's allocation hints.
-//!
-//! ## Calling convention
-//!
-//! A frame is fp-relative; operand `gᵏ` names cell `m[fp·gᵏ]`. Layout:
-//!
-//! | offset | contents |
-//! |--------|----------|
-//! | 0 | `retpc`, the return program counter |
-//! | 1 | `retfp`, the caller frame pointer |
-//! | 2 .. 2+nargs | arguments |
-//! | 2+nargs .. 2+nargs+nretcells | flattened return cells |
-//! | rest | locals / temporaries / frame-pointer hints |
-//!
-//! A scalar or `HeapBuf` pointer occupies one return cell. A returned
-//! `StackBuf(n)` occupies `n` consecutive cells and is copied into a consecutive
-//! run in the caller; source-level tuple arity therefore differs from physical
-//! return-cell count when a tuple contains a stack buffer.
-//!
-//! A **call** is `DEREF`-then-`JUMP`: the callee frame pointer is a fresh
-//! prover-hinted cell; the args and `retfp` are stored with `DEREF`(`Cell`/`Fp`),
-//! then `DEREF`(`Pc`) stores the return address `g²·pc` (the resume point after the
-//! call `JUMP`). The callee returns with one `JUMP[one, 0, 1]`. A **`mul_range`
-//! loop** lowers to a recursive helper that tests `i == g^hi` and, while not done,
-//! runs the body and recurses on `i·g`.
-
-use std::collections::HashMap;
-use std::fmt::Write;
-
-use lean_vm::cpu::hints::{BitsDest, RHint};
-use lean_vm::cpu::{DerefMode, Op, Program};
-use primitives::{
- field::{F64, F192, g_pow},
- pretty_integer,
-};
-
-mod ast;
-pub mod filler;
-mod ir;
-mod lower;
-mod parser;
-pub use ast::*;
-pub(crate) use ir::*;
-use lower::lower_func;
-pub(crate) use parser::subst_stmts;
-pub use parser::{parse, parse_const, parse_with_replacements};
-
-/// Compile an [`Ast`] to a provable [`Program`]. Panics on a malformed program
-/// (unbound variable, missing `main`, address overflow).
-pub fn compile(ast: &Ast) -> Program {
- // Every program carries, past `main`'s halt, the fill blocks that bring each table's
- // row count up to a power of two, so that no table needs padding rows (see `filler`).
- compile_inner(ast, true)
-}
-
-/// [`compile`] without the fill blocks, so the program's own instruction mix is what
-/// runs. Used by tests of instruction selection and execution.
-pub fn compile_without_filler(ast: &Ast) -> Program {
- compile_inner(ast, false)
-}
-
-fn compile_inner(ast: &Ast, with_filler: bool) -> Program {
- // Lower main first (entry pc 0), then the rest, expanding loop helpers.
- let mut queue: Vec = Vec::new();
- let main = ast
- .funcs
- .iter()
- .find(|f| f.name == "main")
- .expect("program needs a `main`");
- assert!(!main.has_const_params(), "main cannot take Const parameters");
- assert!(!main.inline, "main cannot be `@inline`");
- queue.push(main.clone());
- for f in &ast.funcs {
- if f.name != "main" {
- queue.push(f.clone());
- }
- }
- // Definitions by name, for Const-parameter specialization at call sites.
- let defs: HashMap<&str, &Func> = ast.funcs.iter().map(|f| (f.name.as_str(), f)).collect();
- // Constant arrays by name, resolved at lowering (`NAME[i]`, `len(NAME)`).
- let const_arrays: HashMap<&str, &[F192]> = ast
- .const_arrays
- .iter()
- .map(|(name, values)| (name.as_str(), values.as_slice()))
- .collect();
- let dbg_lower = std::env::var("DBG_LOWER").is_ok();
-
- let mut loop_bounds = HashMap::new();
- let mut loop_ctr = 0usize;
- let mut lowered: Vec = Vec::new();
- let mut i = 0;
- while i < queue.len() {
- let f = &queue[i];
- i += 1;
- // A function with Const parameters is a template (only its call-site
- // specializations are lowered); an `@inline` function is expanded at
- // each call site ([`FnLower::try_inline`]), never lowered standalone.
- if f.has_const_params() || f.inline {
- continue;
- }
- let f = f.clone();
- let low = lower_func(
- &f,
- &mut queue,
- &mut loop_ctr,
- &defs,
- &const_arrays,
- with_filler,
- &mut loop_bounds,
- );
- if dbg_lower {
- eprintln!("== fn {} (frame {}) ==", low.name, pretty_integer(low.frame_size));
- for (i, ins) in low.code.iter().enumerate() {
- let index = pretty_integer(i);
- eprintln!(" {index:>5}: {:?}", ins.op);
- }
- }
- lowered.push(low);
- }
-
- // Assign entry program counters and frame sizes.
- let mut entry = HashMap::new();
- let mut frame_size = HashMap::new();
- let mut pc = 0u32;
- for l in &lowered {
- entry.insert(l.name.clone(), pc);
- frame_size.insert(l.name.clone(), l.frame_size);
- pc += l.code.len() as u32;
- }
- // The padded bytecode size `B` is fixed by the lowered length, so the halt
- // sentinel pc `g^{B-1}` (last slot) is known before resolving: `main`'s
- // `EndSentinel` jump dest resolves to it, and the program halts there.
- let total: usize = lowered.iter().map(|l| l.code.len()).sum();
- // The sentinel needs a slot of its own PAST all real code: pad from
- // total + 1, so a program of exactly 2^k instructions doesn't collide
- // its last instruction with the halt pc.
- let bytecode_size = (total + 1).next_power_of_two();
- let sentinel = (bytecode_size - 1) as u32;
-
- // Resolve to bytecode + a hint map keyed by global pc.
- let mut prog: Vec = Vec::new();
- // Source line per pc, so a run-time failure can name a line instead of a pc.
- let mut src_lines: Vec = Vec::new();
- let mut hints: HashMap> = HashMap::new();
- for l in &mut lowered {
- let base = entry[&l.name];
- for ins in &mut l.code {
- let here = prog.len() as u32;
- if !ins.hints.is_empty() {
- let rhs = ins
- .hints
- .drain(..)
- .map(|h| match h {
- Hint::NextFrameAddress => RHint::FrameAddress { offset: l.frame_size },
- Hint::AllocLoopFrames { ptr, callee, count } => {
- let size = frame_size[&callee];
- match count {
- LoopCount::Constant(n) => RHint::Alloc {
- ptr,
- size: size
- .checked_mul(u32::try_from(n).expect("loop range too large"))
- .expect("loop frames overflow"),
- },
- LoopCount::Bound { cell, start } => RHint::AllocFrames {
- ptr,
- size,
- end: cell,
- start_inverse: g_pow_u128(u128::from(start)).inv(),
- },
- }
- }
- Hint::AllocFrame { ptr, callee } => RHint::Alloc {
- ptr,
- size: frame_size[&callee],
- },
- Hint::AllocFrameMax { ptr, callees } => RHint::Alloc {
- ptr,
- size: callees.iter().map(|c| frame_size[c]).max().unwrap(),
- },
- Hint::AllocBuffer { ptr, size } => RHint::Alloc { ptr, size },
- Hint::AllocBufferDyn { ptr, size } => RHint::AllocDyn { ptr, size },
- Hint::Resolved(r) => r,
- })
- .collect();
- hints.insert(here, rhs);
- }
- src_lines.push(ins.line);
- prog.push(resolve(&ins.op, &entry, sentinel, base, l.frame_size));
- }
- }
-
- // Pad the bytecode to `B` (the sentinel slot g^{B-1} must exist for execution).
- prog.resize(bytecode_size, Op::Set { o: 0, k: F192::ZERO });
- let mut program = Program::assemble(prog, hints, frame_size["main"]);
- program.src_lines = src_lines;
- program.fn_ranges = lowered
- .iter()
- .map(|l| (l.name.clone(), entry[&l.name], l.code.len() as u32))
- .collect();
- // The blocks are `main`'s, and `main` is lowered first, so its entry pc is 0 and the
- // block pcs are already the global ones.
- program.filler = std::mem::take(&mut lowered[0].filler);
- program
-}
-
-/// Render compiled bytecode as a human-readable disassembly. `fp[k]` is the cell
-/// `m[fp·gᵏ]` (frame offset `k`); `*(p·gᵝ)` is the dereferenced cell. `SET`
-/// constants that are small g-powers (code addresses, indices) show as `gʲ`.
-pub fn disassemble(prog: &[Op]) -> String {
- // Reverse index for small g-powers, to pretty-print code addresses/indices.
- let mut gmap: HashMap = HashMap::new();
- let mut acc = F64::ONE;
- for j in 0..(prog.len() + 512) {
- gmap.entry(acc).or_insert(j);
- acc *= primitives::field::G;
- }
- // A machine word is 192-bit; K-valued immediates (both high limbs zero) may be small
- // g-powers (code addresses, indices), shown as `gʲ`.
- let kfmt = |k: F192| match (k.c1 == 0 && k.c2 == 0).then(|| gmap.get(&F64(k.c0))).flatten() {
- Some(j) => format!("g^{j}"),
- None if k.c1 == 0 && k.c2 == 0 => format!("0x{:016x}", k.c0),
- None => format!("0x{:016x}{:016x}{:016x}", k.c2, k.c1, k.c0),
- };
-
- let mut out = String::new();
- for (pc, op) in prog.iter().enumerate() {
- let line = match op {
- Op::Set { o, k } => format!("SET fp[{o}] = {}", kfmt(*k)),
- Op::Xor { a, b, c } => format!("XOR fp[{c}] = fp[{a}] ^ fp[{b}]"),
- Op::Mul { a, b, c } => format!("MUL fp[{c}] = fp[{a}] * fp[{b}]"),
- Op::Deref { o1, o2, o3, mode } => {
- let src = match mode {
- DerefMode::Cell => format!("fp[{o3}]"),
- DerefMode::Pc => "g²·pc".to_string(),
- DerefMode::Fp => "fp".to_string(),
- };
- format!("DEREF *(fp[{o1}]·g^{o2}) = {src} [{mode:?}]")
- }
- Op::Jump { oc, od, of } => {
- format!("JUMP if fp[{oc}]≠0: pc=fp[{od}], fp=fp[{of}]")
- }
- Op::Blake2s { ins, cv, out, md } => {
- format!(
- "BLAKE2S fp[{out}..]= compress(cv=fp[{cv}..], m=fp[{}],fp[{}],fp[{}],fp[{}], meta=fp[{md}])",
- ins[0], ins[1], ins[2], ins[3]
- )
- }
- };
- writeln!(out, "{:>6} {line}", pretty_integer(pc)).unwrap();
- }
- out
-}
-
-/// Embed a `u128` source literal into the low 128 bits of a 192-bit machine word.
-pub(crate) fn lit_field(n: u128) -> F192 {
- F192::new(n as u64, (n >> 64) as u64, 0)
-}
-
-/// `g^e` for a `u128` exponent (square-and-multiply). `field::g_pow` only takes
-/// a `usize`; an index carried in the exponent (a Fibonacci number, say) can
-/// exceed 64 bits (`ord(g) = 2^64 − 1`, so the exponent wraps mod that).
-fn g_pow_u128(mut e: u128) -> F64 {
- let mut result = F64::ONE;
- let mut base = primitives::field::G;
- while e > 0 {
- if e & 1 == 1 {
- result *= base;
- }
- base = base * base;
- e >>= 1;
- }
- result
-}
-
-fn resolve(op: &LOp, entry: &HashMap, sentinel: u32, base: u32, frame_size: u32) -> Op {
- let resolve_kval = |kv: &KVal| -> F192 {
- match kv {
- KVal::FrameSize => g_pow(frame_size as usize).into(),
- KVal::Const(c) => *c,
- // Address / entry / sentinel constants are K-valued g-powers;
- // embed them canonically as (c0, 0, 0).
- KVal::Entry(name) => g_pow(entry[name] as usize).into(),
- KVal::EndSentinel => g_pow(sentinel as usize).into(),
- KVal::Local(i) => g_pow((base + i) as usize).into(),
- }
- };
- match op {
- LOp::MulNextFrame { a, b, c } => Op::Mul {
- a: *a,
- b: *b,
- c: frame_size.checked_add(*c).expect("frame offset overflow"),
- },
- LOp::Set { o, k: kv } => Op::Set {
- o: *o,
- k: resolve_kval(kv),
- },
- LOp::Xor { a, b, c } => Op::Xor { a: *a, b: *b, c: *c },
- LOp::Mul { a, b, c } => Op::Mul { a: *a, b: *b, c: *c },
- LOp::Deref { o1, o2, o3, mode } => Op::Deref {
- o1: *o1,
- o2: *o2,
- o3: *o3,
- mode: *mode,
- },
- LOp::Jump { oc, od, of } => Op::Jump {
- oc: *oc,
- od: *od,
- of: *of,
- },
- LOp::Blake2s { ins, cv, c, md } => Op::Blake2s {
- ins: *ins,
- cv: *cv,
- out: *c,
- md: *md,
- },
- }
-}
diff --git a/crates/lean_compiler/src/lower.rs b/crates/lean_compiler/src/lower.rs
deleted file mode 100644
index 2149e4c47..000000000
--- a/crates/lean_compiler/src/lower.rs
+++ /dev/null
@@ -1,1671 +0,0 @@
-//! Lowering: each function AST is compiled to a sequence of intermediate
-//! [`LOp`] instructions (fp-relative offsets, backpatched jump targets).
-//!
-//! This file holds the walk itself, control flow, instruction emission, and
-//! [`Scope`]. The rest is split by the question it answers, because each of the
-//! four has an invariant worth stating once rather than rediscovering:
-//!
-//! - [`mod@eval`] asks what an expression is worth before anything runs. Every
-//! function there takes `&self` and emits nothing, which is what lets a caller
-//! ask without paying for the answer.
-//! - [`mod@mem`] asks which cell a name means, and what writing to it costs.
-//! Every index is bounds-checked, in every position, and every store emits: the
-//! machine's write-once memory is what separates an assertion from a definition.
-//! - [`mod@call`] is the call boundary. Caller and callee must agree on the
-//! arity, because they place the return area from their own idea of it.
-//! - [`mod@builtins`] is the precompile and the hints: the two places a value
-//! arrives without an instruction computing it.
-//!
-//! [`Scope`] is what a name means HERE, and it reverts at a branch join, so a
-//! cell whose `SET` sits inside a branch is never trusted outside it.
-
-use super::*;
-use crate::filler::FillerOp;
-use lean_vm::cpu::filler::Block;
-
-mod builtins;
-mod call;
-mod eval;
-mod mem;
-use call::ret_binding;
-use eval::field_pow;
-
-/// A value equal to `pointer(base)·g^exp`, or the pure constant `g^exp` when
-/// `base` is `None`. Heap-address arithmetic (`ptr·gᵏ`, and constant g-power
-/// cursors such as a tweak-table index) is tracked symbolically so a later
-/// access folds the whole offset into `DEREF`'s `β` immediate rather than
-/// emitting a `SET`+`MUL` per step. A cursor read only as an index thus costs
-/// nothing; one used as a value is materialized on demand ([`FnLower::materialize`]).
-#[derive(Clone, Copy)]
-struct GAddr {
- base: Option,
- exp: u128,
- /// For a pointer minted by `addr(sb)`, the frame run it names, as
- /// `(first cell, length)`. `base` is then the shared `fp` cell and `exp` the
- /// absolute frame offset, so the run cannot be recovered from those two
- /// alone: carrying it here is what lets [`FnLower::check_heap_bound`] hold a
- /// frame pointer to the same bound a `HeapBuf` pointer gets. `None` for a
- /// heap pointer (bounded through `heap_sizes`) and for a pure g-power.
- run: Option<(Off, u32)>,
-}
-
-/// The fixed prefix of every frame: the caller's return pc and frame pointer,
-/// then the arguments, then the flattened return area (a `StackBuf(n)` return
-/// occupies `n` consecutive cells). One place derives every offset in it, so a
-/// caller writing into a callee's frame and the callee reading its own cannot
-/// drift apart, and `2 + n_args + n_ret_cells` is not spelled out at each site.
-struct Abi;
-
-impl Abi {
- /// Where the caller leaves the return pc and the return frame pointer.
- const RET_PC: Off = 0;
- const RET_FP: Off = 1;
- /// Argument `i`, after the two return slots and the preceding arguments.
- fn arg(shapes: impl Iterator
- , i: usize) -> Off {
- 2 + shapes.take(i).map(Shape::cells).sum::()
- }
- /// Total width of the argument area.
- fn arg_cells(shapes: impl Iterator
- ) -> u32 {
- shapes.map(Shape::cells).sum()
- }
- /// Return cell `i` of a callee whose arguments occupy `arg_cells` cells.
- fn ret(arg_cells: u32, i: u32) -> Off {
- 2 + arg_cells + i
- }
- /// One past the last cell the CALLER touches, so the first local cell.
- fn end(arg_cells: u32, n_ret_cells: u32) -> Off {
- Self::ret(arg_cells, n_ret_cells)
- }
-}
-
-/// Cap on a `β`-folded exponent, inclusive: the operand g-power table is sized to
-/// the largest immediate, so beyond this a huge constant index falls back to a
-/// materialized pointer instead of inflating that table. The one cap: every site
-/// that folds an exponent into `β` measures it against this.
-const FOLD_MAX: u128 = 1 << lean_vm::cpu::MIN_LOG_MEM;
-
-/// The two pure operations worth interning. Both are commutative, so operands
-/// are stored sorted.
-#[derive(Clone, Copy, PartialEq, Eq, Hash)]
-enum PureOp {
- Xor,
- Mul,
-}
-
-/// How an inlined `@inline` tail-return value binds into the caller
-/// ([`FnLower::inline_stack_ret`]): a `StackBuf` hands over its cell run and a
-/// folded g-address hands over its symbolic pointer, both aliased at zero
-/// copies (so `cvb = obs(cvb, x)` and a fused `fs, x, cur = fs_next(fs, cur)`
-/// stay free); a scalar was already copied into its dst cell.
-#[derive(Clone, Copy)]
-enum RetBind {
- Stack(Off, u32),
- Gaddr(GAddr),
- Scalar,
-}
-
-/// What a name is bound to. The kinds are mutually exclusive: a name has exactly
-/// one of them, which is why they are one enum and not four maps.
-#[derive(Clone, Copy)]
-enum Binding {
- Scalar(Off),
- Stack(Off, u32),
- Gaddr(GAddr),
- FConst(F192),
-}
-
-/// What one name means: its value binding, plus an OPTIONAL compile-time integer
-/// reading of the same expression. The two genuinely coexist (`x = 2` names the
-/// field element 2 AND the index 2, while `n = len(A) - 1` has no g-power reading
-/// at all), so `int` rides beside `val` rather than being another variant. One
-/// entry per name is what makes a rebind atomic.
-#[derive(Clone, Copy)]
-struct Bound {
- val: Binding,
- int: Option,
-}
-
-/// Everything a runtime branch may not have executed: the name bindings, plus the
-/// lazily materialized cells whose `SET` sits wherever it was first needed.
-/// [`FnLower::scoped`] restores one of these at the join, so a cell written on one
-/// path is never trusted on another.
-#[derive(Clone, Default)]
-struct Scope {
- names: HashMap,
- /// The cell holding this function's own `fp`, materialized lazily
- /// ([`FnLower::self_fp`]): local (`if`/`else`) jumps reload the frame
- /// pointer on the taken branch.
- self_fp_off: Option,
- /// Results of pure operations: `(op, sorted operands)` → the cell holding it.
- ///
- /// Reverting at a branch join (with `const_cells`, below) is the whole
- /// invalidation: a cached cell must dominate every later use, and nothing
- /// clears this at a label target. That is sound only because every backward
- /// edge crosses a function boundary (a loop body is its own `Func` with a
- /// fresh `Scope`) and every `patch_local` target is a forward jump, so a
- /// cached cell's defining instruction always precedes its reuse. A new
- /// backward edge, or a `patch_local` that jumps backwards, would need this
- /// cleared at the target.
- pure_cells: HashMap<(PureOp, Off, Off), Off>,
- /// Dominating memory equalities. Reads reuse the frame cell; stores still
- /// emit their equality checks. Branch joins restore this with the scope.
- load_cells: HashMap<(Off, u32), Off>,
- /// Every lazily-`SET` constant cell: field value (as bits) → the frame cell
- /// holding it. Cells are write-once and read-many, so one `SET` serves every
- /// use in scope. A `SET` first emitted inside a branch must not be named from
- /// outside it, where the other path leaves the cell unwritten and therefore
- /// prover-chosen, which is why this reverts at a join with the bindings.
- const_cells: HashMap<[u64; 3], Off>,
- /// Two consecutive frame cells holding the standard BLAKE2s IV, emitted
- /// lazily at the first dominating default-IV compression in this
- /// control-flow scope.
- blake2s_iv: Option,
-}
-
-impl Scope {
- fn bound(&self, n: &str) -> Option {
- self.names.get(n).copied()
- }
- fn stack(&self, n: &str) -> Option<(Off, u32)> {
- match self.bound(n)?.val {
- Binding::Stack(base, size) => Some((base, size)),
- _ => None,
- }
- }
- /// The compile-time integer reading of `n`, when it has one.
- fn int(&self, n: &str) -> Option {
- self.bound(n)?.int
- }
- /// Attach the integer reading to the binding just made for `n`.
- fn set_int(&mut self, n: &str, k: u128) {
- if let Some(b) = self.names.get_mut(n) {
- b.int = Some(k);
- }
- }
-}
-
-struct FnLower<'a> {
- scope: Scope,
- next: Off,
- /// Physical width of this function's argument area, which is not its
- /// parameter COUNT once a parameter can be a run of cells.
- arg_cells: u32,
- /// Source-level return shapes for this function. Their physical cell widths
- /// determine the reserved return area immediately after the arguments.
- return_shapes: &'a [Shape],
- is_main: bool,
- code: Vec,
- /// Declared size of each `HeapBuf`, keyed by its pointer cell. Shifted
- /// aliases resolve to the same base cell through their gaddr, so a
- /// compile-time index checks against the ORIGINAL buffer's bound.
- heap_sizes: HashMap,
- /// While inlining an `@inline` call ([`Self::try_inline`]), the destination
- /// cells its tail `return` binds into instead of emitting a return jump.
- /// `None` outside an inlined body.
- inline_ret: Option>,
- /// Set by an inlined tail `return`, one [`RetBind`] per returned value,
- /// telling the caller's `let`/tuple how to bind each (alias a `StackBuf` run
- /// or a folded g-address, or take the scalar dst cell). `None` outside an
- /// inlined return.
- inline_stack_ret: Option>,
- /// Source line of the statement being lowered, for diagnostics and for the
- /// pc-to-line table. Zero for a synthesized statement with no source.
- cur_line: u32,
- /// Hints queued to attach to the next emitted instruction.
- pending: Vec,
- /// Active `@inline` expansion stack. Nested inline helpers are allowed,
- /// but direct or indirect recursion would otherwise recurse forever in
- /// the compiler.
- inline_calls: Vec,
- /// Set by [`lower_func`] just before lowering a statement that sits in tail
- /// position; consumed by the next [`Self::stmt`] call, so nested lowering
- /// never inherits it.
- tail_call: bool,
- /// Generated loops that can reserve their complete run of frames.
- loop_bounds: &'a mut HashMap)>,
- queue: &'a mut Vec,
- loop_ctr: &'a mut usize,
- /// Function name for diagnostics and generated-loop handling.
- fn_name: &'a str,
- /// The program's function definitions by name, for `Const`-parameter
- /// specialization at call sites ([`Self::specialize`]).
- defs: &'a HashMap<&'a str, &'a Func>,
- /// Top-level constant arrays, resolved at compile time: `NAME[i]` yields the
- /// element (a field value or an index), `len(NAME)` its length.
- const_arrays: &'a HashMap<&'a str, &'a [F192]>,
-}
-
-impl FnLower<'_> {
- /// Abort with a diagnostic naming the source line being lowered. Every
- /// deliberate user-facing error goes through here; a bare `assert!` left in
- /// the file is an internal invariant, i.e. a compiler bug rather than a
- /// program one, and deliberately does NOT get a line.
- fn fail(&self, msg: impl std::fmt::Display) -> ! {
- // The line alone is not the site when the function being lowered is one
- // the author never wrote: `hash_pair__L1` exists because of a `Const`
- // call site, `__loop3` because of a `for` header, and an `@inline` body
- // is lowered through the CALLER, so its statements report the caller's
- // line. Naming the function, and the inline chain when there is one, is
- // what turns "line 19" back into somewhere to look.
- let site = match (self.cur_line, self.fn_name) {
- (0, "main") => String::new(),
- (0, f) => format!("in {f}: "),
- (n, "main") => format!("line {n}: "),
- (n, f) => format!("line {n} in {f}: "),
- };
- match self.inline_calls.as_slice() {
- [] => panic!("{site}{msg}"),
- chain => panic!("{site}{msg} (inlined through {})", chain.join(" -> ")),
- }
- }
-
- fn fresh(&mut self) -> Off {
- let o = self.next;
- self.next += 1;
- o
- }
-
- fn emit(&mut self, op: LOp) {
- let hints = std::mem::take(&mut self.pending);
- self.code.push(LInstr {
- op,
- line: self.cur_line,
- hints,
- });
- }
-
- fn set(&mut self, o: Off, k: KVal) {
- self.emit(LOp::Set { o, k });
- }
-
- fn set_const(&mut self, o: Off, v: F192) {
- self.set(o, KVal::Const(v));
- }
-
- fn deref(&mut self, o1: Off, o2: u32, o3: Off, mode: DerefMode) {
- self.emit(LOp::Deref { o1, o2, o3, mode });
- if mode == DerefMode::Cell {
- self.scope.load_cells.entry((o1, o2)).or_insert(o3);
- }
- }
-
- /// A no-op instruction to hang a pending hint on, so it fires exactly here
- /// instead of drifting onto whatever is emitted next (which may sit past a
- /// branch join, or on a path this hint does not belong to).
- fn anchor(&mut self) {
- let o = self.fresh();
- self.set_const(o, F192::ZERO);
- }
-
- /// A top-level constant name is reserved (`zkDSL.md` §Global constants). A
- /// scalar one enforces that by construction, its value being substituted
- /// textually so a shadowing binding becomes a literal and fails loudly. A
- /// constant ARRAY is carried to lowering instead and resolved without
- /// consulting the scope, so a colliding local would have its
- /// compile-time-indexed reads folded to baked literals, including reads of a
- /// `hint_witness` destination whose asserts would then run on the constant.
- /// Reject the collision rather than pick a winner.
- fn check_not_reserved(&self, name: &str) {
- if self.const_arrays.contains_key(name) {
- self.fail(format!(
- "`{name}` is a top-level constant array, so the name is reserved: rename the local \
- or parameter (zkDSL.md §Global constants)"
- ))
- };
- }
-
- /// Replace the binding, clearing its previous compile-time integer reading.
- fn rebind(&mut self, name: &str, b: Binding) {
- self.check_not_reserved(name);
- self.scope.names.insert(name.to_string(), Bound { val: b, int: None });
- }
-
- /// Bind each of `names` to the join cell holding its value, after a `match`
- /// dispatch: whichever arm ran wrote them, so they are plain scalars now.
- fn bind_targets(&mut self, binds: &[(&str, Off)]) {
- for (name, cell) in binds {
- self.rebind(name, Binding::Scalar(*cell));
- }
- }
-
- /// The cell holding `a op b`, computed only if this scope has not already.
- ///
- /// **Route an operation here only when no later write to its result cell
- /// could be the assertion.** A fresh cell is not sufficient: `assert a != b`
- /// mints one for `x·inv` and writes it again with `SET p = 1`, and THAT write
- /// is the assertion, so sharing the cell would let the next `assert a != b`
- /// skip its `MUL` and assert nothing. A second writer is fine where the value
- /// is already pinned, as in `q = x ** k / w`, whose division writes into the
- /// cell the squaring chain already determined.
- ///
- /// So these stay out: the zero cell an `assert a == b` XORs into, the
- /// `g^{k-1}` a range check multiplies into, `assert a != b`'s product, a
- /// division's back-solve, and `expr_into`'s caller-chosen destination.
- fn pure(&mut self, op: PureOp, a: Off, b: Off) -> Off {
- let key = (op, a.min(b), a.max(b));
- if let Some(&o) = self.scope.pure_cells.get(&key) {
- return o;
- }
- let o = self.fresh();
- match op {
- PureOp::Xor => self.emit(LOp::Xor { a, b, c: o }),
- PureOp::Mul => self.emit(LOp::Mul { a, b, c: o }),
- }
- self.scope.pure_cells.insert(key, o);
- o
- }
-
- /// The frame cell `arr[idx]` names, bounds-checked, or `None` when `arr` is
- /// not a `StackBuf` and the caller should take its heap path.
- ///
- /// Emits nothing, so each caller keeps its own evaluation order. In
- /// `hb[sb[0]] = f(sb, …)`, the address may read a cell the value writes.
- /// The heap bound lives where the address is formed ([`Self::heap_addr`]).
- fn frame_cell(&mut self, arr: &Expr, idx: &Expr) -> Option {
- let (base, size) = self.stack_of(arr)?;
- let k = self.const_index(idx);
- if k >= size {
- self.fail(format!("index {k} out of bounds (StackBuf size {size})"))
- };
- Some(base + k)
- }
-
- /// A frame cell holding `1` (always-taken `JUMP` condition).
- fn one(&mut self) -> Off {
- self.const_cell(F192::ONE)
- }
-
- /// A frame cell holding `v`, shared by every dominated use in the current
- /// scope: the one cache for every lazily-`SET` constant.
- ///
- /// The `SET` is emitted where the cell is allocated, before anything can name
- /// it, which is what makes every later write a write-once equality against a
- /// bytecode constant rather than a chance to choose the value. It reverts at
- /// a branch join with the rest of [`Scope`], since a `SET` first emitted
- /// inside a branch must not be named outside it, where the other path leaves
- /// the cell unwritten and so prover-chosen. Several call sites hoist
- /// [`Self::one`] above a branch on purpose; the revert is what makes that an
- /// optimization rather than the thing holding the invariant up.
- fn const_cell(&mut self, v: F192) -> Off {
- let key = [v.c0, v.c1, v.c2];
- if let Some(&o) = self.scope.const_cells.get(&key) {
- return o;
- }
- let o = self.fresh();
- self.set_const(o, v);
- self.scope.const_cells.insert(key, o);
- o
- }
-
- /// A frame cell holding `0`, set lazily once: the source for forwarded zero
- /// words (a `BLAKE2s` padding half), and the destination every `assert a == b`
- /// in this scope XORs into.
- fn zero(&mut self) -> Off {
- self.const_cell(F192::ZERO)
- }
-
- /// Terminate `main`: jump to the halt sentinel `g^{B-1}` with `fp = g^0`.
- /// The cell holding `1` doubles as the (nonzero) jump condition and the new
- /// frame pointer `g^0`; the dest cell holds `g^{B-1}` (doc §sec:e2e, final state).
- fn halt(&mut self) {
- let one = self.one();
- let dest = self.fresh();
- self.set(dest, KVal::EndSentinel);
- self.emit(LOp::Jump {
- oc: one,
- od: dest,
- of: one,
- });
- }
-
- /// Emit the fill blocks: per table and per size in `lean_vm::cpu::filler::SIZES`,
- /// that many dummy instructions of the table's opcode, then a `JUMP` back to the
- /// block's own first instruction, in the same frame.
- ///
- /// A block is a cycle and nothing jumps into one: they sit past `main`'s halt
- /// and the interpreter enters them itself once the program has stopped. The
- /// state tuples a traversal pushes are the ones it pulls, so the cycle
- /// balances for any number of traversals (`lean_vm::cpu::filler`).
- ///
- /// The closing jump is always taken (its destination is a g-power, so
- /// nonzero) and reads its destination and frame from cells the interpreter
- /// writes, so a traversal costs the block's rows plus that jump. A dummy uses
- /// one scratch cell as each operand, writing the value already there, so a
- /// block costs one cell whatever its size.
- fn lower_filler_blocks(&mut self) -> Vec {
- use lean_vm::cpu::filler::{SIZES, frame as fr};
-
- // No statement wrote these, so they get the "unknown" line rather than
- // whatever `main` happened to end on.
- self.cur_line = 0;
-
- // A block runs in a frame the interpreter carves out, so the cells it reads are at
- // fixed offsets in *that* frame rather than allocated from this function's
- // counter, and nothing here touches `main`'s frame at all.
- let mut blocks = Vec::new();
- for (table, op) in crate::filler::TABLES {
- for size in SIZES {
- blocks.push(Block {
- pc: self.code.len() as u32,
- size: size as u32,
- table,
- });
- for _ in 0..size {
- self.emit(match op {
- FillerOp::Xor => LOp::Xor {
- a: fr::SCRATCH,
- b: fr::SCRATCH,
- c: fr::SCRATCH,
- },
- FillerOp::Mul => LOp::Mul {
- a: fr::SCRATCH,
- b: fr::SCRATCH,
- c: fr::SCRATCH,
- },
- FillerOp::Set => LOp::Set {
- o: fr::SCRATCH,
- k: KVal::Const(F192::ZERO),
- },
- FillerOp::Deref => LOp::Deref {
- o1: fr::PTR,
- o2: 0,
- o3: fr::SCRATCH,
- mode: DerefMode::Cell,
- },
- // Its condition is a cell nothing ever writes, so it reads as
- // zero: the dummy is not taken and falls through to the next
- // instruction of the block instead of closing the cycle early.
- FillerOp::Jump => LOp::Jump {
- oc: fr::ZERO,
- od: fr::ZERO,
- of: fr::ZERO,
- },
- // Its metadata cell is one no instruction writes, like its
- // message cells: the interpreter leaves those zero, and a
- // prover choosing otherwise only picks which compression the
- // dummy proves, which nothing reads (`lean_vm::cpu::filler`).
- FillerOp::Blake2s => LOp::Blake2s {
- ins: [fr::DIGEST + 2, fr::DIGEST + 3, fr::DIGEST + 4, fr::DIGEST + 5],
- cv: fr::SCRATCH,
- c: fr::DIGEST,
- md: fr::ZERO,
- },
- });
- }
- // Back to the top, closing the cycle. For the `JUMP` table this is one
- // more row of its own, which is why the solver decomposes that table over
- // `size + 1`.
- self.emit(LOp::Jump {
- oc: fr::DEST,
- od: fr::DEST,
- of: fr::NEXT_FP,
- });
- }
- }
- blocks
- }
-
- /// `dst = src` (no MOV: multiply by `1`).
- fn copy(&mut self, src: Off, dst: Off) {
- let one = self.one();
- self.emit(LOp::Mul { a: src, b: one, c: dst });
- }
-
- /// A frame cell holding this function's own `fp` (the g-power element),
- /// materialized lazily once: a taken `JUMP` reloads the frame pointer
- /// from a cell, so local (`if`/`else`) jumps must name it. The ISA has no
- /// fp-read, so bounce it through a fresh 1-cell heap buffer: a
- /// `DEREF`-fp writes it there and a `DEREF`-cell copies it back. In `main`, `fp = g^0 = 1`, which is
- /// the [`Self::one`] cell.
- fn self_fp(&mut self) -> Off {
- if self.is_main {
- return self.one();
- }
- if let Some(o) = self.scope.self_fp_off {
- return o;
- }
- let q = self.fresh();
- self.pending.push(Hint::AllocBuffer { ptr: q, size: 1 });
- self.deref(q, 0, 0, DerefMode::Fp); // m[q] := fp
- let o = self.fresh();
- self.deref(q, 0, o, DerefMode::Cell); // m[fp·g^o] := m[q]
- self.scope.self_fp_off = Some(o);
- o
- }
-
- /// Backpatch a [`KVal::Local`] `SET` (emitted with a placeholder) to name
- /// the instruction at index `target` of this function's code.
- fn patch_local(&mut self, set_idx: usize, target: usize) {
- match &mut self.code[set_idx].op {
- LOp::Set { k: KVal::Local(t), .. } => *t = target as u32,
- other => unreachable!("patch_local on {other:?}"),
- }
- }
-
- /// Run `f` with branch-local scope: bindings AND the lazily cached cells
- /// (`one`, `self_fp`, range-check bounds, default BLAKE2s IV) revert
- /// afterwards, since a cell whose `SET` sits inside a conditionally-executed
- /// region must not be trusted outside it.
- fn scoped(&mut self, f: impl FnOnce(&mut Self)) {
- let saved_scope = self.scope.clone();
- f(self);
- // A hint pending at the end of a branch (e.g. a trailing
- // `hint_witness`) must not attach to whatever instruction follows the
- // join, which would fire it unconditionally.
- if !self.pending.is_empty() {
- self.anchor();
- }
- self.scope = saved_scope;
- }
-
- /// Lower a branch body with branch-local scope ([`Self::scoped`]).
- fn branch(&mut self, body: &[Stmt]) {
- self.scoped(|s| {
- for st in body {
- s.stmt(st);
- }
- });
- }
-
- /// The cell each multi-return target names, and the names still to bind.
- ///
- /// A plain name takes a fresh cell. A `StackBuf` element uses its existing
- /// cell, so [`Self::call_into`] returns directly into the destination.
- fn ret_targets<'a>(&mut self, targets: &'a [Expr]) -> (Vec, Vec<(&'a str, Off)>) {
- let mut cells = Vec::with_capacity(targets.len());
- let mut binds = Vec::new();
- for t in targets {
- match t {
- // Only a frame cell can be a return slot. Rejecting here rather
- // than after forming the address keeps a pointer `MUL` out of the
- // program and reports the actual problem: routing a heap target
- // through the heap path lectured about g-powers instead, and told a
- // scalar it was a HeapBuf.
- Expr::Index(arr, idx) => match self.frame_cell(arr, idx) {
- Some(c) => cells.push(c),
- None => self.fail(format!(
- "a multi-value target must be a name or a StackBuf element, got `{t:?}`"
- )),
- },
- Expr::Var(n) => {
- let c = self.fresh();
- cells.push(c);
- binds.push((n.as_str(), c));
- }
- other => self.fail(format!(
- "a multi-value target must be a name or a StackBuf element, got `{other:?}`"
- )),
- }
- }
- (cells, binds)
- }
-
- /// `targets = match(log(x), …)`: dispatch through the trampoline table
- /// ([`Self::lower_match_dispatch`]), arm `j` evaluating the lambda body at
- /// `i = j` into cells every arm shares. Write-once makes that sound, exactly
- /// one arm running, and [`Self::ret_targets`] says which cells those are.
- fn lower_match(&mut self, targets: &[Expr], x: &Expr, arms: &[Expr]) {
- for arm in arms {
- if let Expr::Call(f, _) = arm
- && self
- .defs
- .get(f.as_str())
- .is_some_and(|d| !d.inline && d.return_shapes.iter().any(|s| matches!(s, Shape::StackBuf(_))))
- {
- self.fail("a normal function's StackBuf return cannot cross a match join; bind it with `let`");
- }
- }
- // Calls with identical runtime args share one callee frame and a
- // two-instruction trampoline per arm. Const args select specializations;
- // see `lower_dispatched_call` for the shared argument/return layout checks.
- // `@inline` arms instead expand into this frame, below.
- let inline_arm =
- |s: &Self, a: &Expr| matches!(a, Expr::Call(f, _) if s.defs.get(f.as_str()).is_some_and(|d| d.inline));
- if arms.iter().all(|a| matches!(a, Expr::Call(..)) && !inline_arm(self, a)) {
- let specialized: Vec<(String, Vec<&Expr>)> = arms
- .iter()
- .map(|a| {
- let Expr::Call(f, cargs) = a else { unreachable!() };
- self.specialize(f, cargs)
- })
- .collect();
- let rt0 = &specialized[0].1;
- if specialized.iter().all(|(_, rt)| rt == rt0) {
- let callees: Vec = specialized.iter().map(|(c, _)| c.clone()).collect();
- self.lower_dispatched_call(targets, x, &callees, rt0);
- return;
- }
- // Not uniform: fall through (the specializations queued above are
- // re-requested idempotently by `call_into`).
- }
- let xo = self.expr(x);
- let (rcells, binds) = self.ret_targets(targets);
- self.lower_match_dispatch(xo, arms.len(), |s, j| {
- s.scoped(|s| {
- if let [rcell] = rcells.as_slice() {
- s.expr_into(&arms[j], *rcell);
- } else {
- let Expr::Call(f, cargs) = &arms[j] else {
- s.fail(format!(
- "a multi-target match arm must be a function call, got `{:?}`",
- arms[j]
- ));
- };
- s.inline_stack_ret = None;
- s.call_into(f, cargs, &rcells);
- // An @inline arm's aliased returns materialize into the
- // shared join cells (a real call wrote them directly).
- if let Some(binds) = s.inline_stack_ret.take() {
- for (b, &rc) in binds.iter().zip(&rcells) {
- match *b {
- RetBind::Gaddr(ga) => {
- let c = s.materialize(ga);
- s.copy(c, rc);
- }
- RetBind::Stack(base, size) => {
- if size != 1 {
- s.fail("a multi-cell StackBuf return cannot cross a match join")
- }
- // `copy` reads the run's first cell, which is where
- // the arm's single returned value sits.
- let src = base;
- s.copy(src, rc);
- }
- RetBind::Scalar => {}
- }
- }
- }
- }
- });
- });
- self.bind_targets(&binds);
- }
-
- /// The two-jump dispatch itself: `d = g^T · x²` names slot `x` of the
- /// two-instruction trampoline table at bytecode base `T`. Returns the index
- /// of the `SET` holding `T`, for the caller to patch once the table's
- /// position is known.
- fn emit_dispatch(&mut self, xo: Off, one: Off, of: Off) -> usize {
- let kcell = self.fresh();
- let kset = self.code.len();
- self.set(kcell, KVal::Local(0)); // patched: table base T
- let x2 = self.pure(PureOp::Mul, xo, xo);
- let d = self.fresh();
- self.emit(LOp::Mul { a: kcell, b: x2, c: d });
- self.emit(LOp::Jump { oc: one, od: d, of });
- kset
- }
-
- /// The `n` trampoline slots themselves, each `SET c = k(j); JUMP c`, sharing
- /// `c` since one slot runs. Returns the table's start; slot `j` has its
- /// `SET` at `start + 2*j`.
- fn emit_slots(&mut self, n: usize, one: Off, of: Off, k: impl Fn(usize) -> KVal) -> usize {
- let start = self.code.len();
- let c = self.fresh();
- for j in 0..n {
- self.set(c, k(j));
- self.emit(LOp::Jump { oc: one, od: c, of });
- }
- start
- }
-
- /// The trampoline dispatch every `match` lowers through: jump to
- /// `d = g^T · x²` (slot `j` of the two-instruction table at bytecode base
- /// `T`), then to `body(j)`'s code; every non-final body exits to the
- /// join. `body` lowers arm `j`, with its own branch-local scope.
- ///
- /// One arm runs, so the arms allocate their locals over the same cells.
- fn lower_match_dispatch(&mut self, xo: Off, n: usize, mut body: impl FnMut(&mut Self, usize)) {
- // Hoisted on purpose: these SETs must dominate the join.
- let sfp = self.self_fp();
- let one = self.one();
- let join = self.fresh();
- let jset = self.code.len();
- self.set(join, KVal::Local(0)); // patched: the join
- // Slot j (two instructions) sits at T + 2j.
- let kset = self.emit_dispatch(xo, one, sfp);
- // The trampoline table.
- self.patch_local(kset, self.code.len());
- let start = self.emit_slots(n, one, sfp, |_| KVal::Local(0));
- // The arm blocks, each exiting to the join (the last falls through).
- let arms_base = self.next;
- let mut arms_end = arms_base;
- for j in 0..n {
- self.next = arms_base;
- self.patch_local(start + 2 * j, self.code.len());
- body(self, j);
- if j + 1 != n {
- self.emit(LOp::Jump {
- oc: one,
- od: join,
- of: sfp,
- });
- }
- arms_end = arms_end.max(self.next);
- // The next arm may reuse these cells for something other than a `HeapBuf`.
- self.heap_sizes.retain(|&o, _| o < arms_base);
- }
- self.next = arms_end;
- self.patch_local(jset, self.code.len());
- }
-
- /// Lower `if` / `else`, arranging the blocks so the nonzero `XOR` result jumps to the correct arm.
- fn lower_if(&mut self, eq: bool, lhs: &Expr, rhs: &Expr, then: &[Stmt], els: &[Stmt], force_const: bool) {
- // A folded condition emits no test and no jump, so the taken arm is
- // straight-line code and its bindings persist, unlike a runtime branch's.
- //
- // The fold reads INTEGERS while the runtime lowering below tests a field
- // XOR, and the two disagree whenever a side's readings do. Neither can
- // simply win, so an ambiguous condition is REJECTED and `const(...)` is
- // how the author names the regime (`zkDSL.md`, "`if const(...)`").
- let (a, b) = (self.eval(lhs), self.eval(rhs));
- if let (Some(ai), Some(bi)) = (
- a.int.and_then(|n| u32::try_from(n).ok()),
- b.int.and_then(|n| u32::try_from(n).ok()),
- ) {
- // Checked per SIDE, not by comparing the two verdicts. If each side's
- // own readings agree then integer equality and field equality say the
- // same thing, so a side that disagrees with ITSELF is the whole of the
- // ambiguity. Comparing verdicts instead needs a field reading for both
- // sides, and `try_field_const` has no arm for `-`, `//` or `%`, so
- // `n == 3 - 1` slipped through and folded on the integer reading while
- // `n == 2`, the same condition, was rejected.
- if !force_const {
- for (e, known) in [(lhs, a), (rhs, b)] {
- if let Some((n, f)) = known.diverging_readings() {
- self.fail(format!(
- "`{e:?}` reads as the integer {n} where a condition folds, and as the field \
- element {:#x}:{:#x} where a value is wanted, so this branch would be decided \
- by one reading and its body run under the other. Write `if const(...)` to \
- decide it with integer arithmetic, or spell the operand so the two agree.",
- f.c1, f.c0
- ))
- }
- }
- }
- for st in if (ai == bi) == eq { then } else { els } {
- self.stmt(st);
- }
- return;
- }
- // `const(...)` also decides a condition only the field can read (`GEN ** 3`,
- // or anything past `u32`), which has no integer reading to be ambiguous
- // against. A plain `if` must NOT: folding it would rescope the arm, whose
- // bindings then outlive it.
- if force_const {
- if let (Some(fa), Some(fb)) = (a.field, b.field) {
- for st in if (fa == fb) == eq { then } else { els } {
- self.stmt(st);
- }
- return;
- }
- self.fail("`if const(...)` asks for a compile-time decision, but this condition is not one: both sides must be compile-time constants")
- }
- // `x != 0` needs no XOR: the cell itself is the JUMP's nonzero test.
- let x = if self.try_lit(rhs) == Some(0) {
- self.expr(lhs)
- } else if self.try_lit(lhs) == Some(0) {
- self.expr(rhs)
- } else {
- let (la, lb) = (self.expr(lhs), self.expr(rhs));
- // x = lhs + rhs: nonzero ⇔ !=
- self.pure(PureOp::Xor, la, lb)
- };
- // Hoisted on purpose: these SETs must dominate the join.
- let sfp = self.self_fp();
- let one = self.one();
- let (a_block, b_block) = if eq { (then, els) } else { (els, then) };
- let bdest = self.fresh();
- let bset = self.code.len();
- self.set(bdest, KVal::Local(0)); // patched: start of B
- self.emit(LOp::Jump {
- oc: x,
- od: bdest,
- of: sfp,
- });
- self.branch(a_block);
- if b_block.is_empty() {
- self.patch_local(bset, self.code.len());
- } else {
- let edest = self.fresh();
- let eset = self.code.len();
- self.set(edest, KVal::Local(0)); // patched: the join
- self.emit(LOp::Jump {
- oc: one,
- od: edest,
- of: sfp,
- });
- self.patch_local(bset, self.code.len());
- self.branch(b_block);
- self.patch_local(eset, self.code.len());
- }
- }
-
- /// `assert a != b`: `XOR` for `x = a + b`, a hinted `inv = x⁻¹`, then
- /// `MUL p = x·inv` and `SET p = 1`, the write-once conflict being the
- /// assertion (as for `assert a == b`). Sound because `x = 0` forces `p = 0`
- /// whatever the hint, and `p` cannot then be `1`.
- /// A compile-time-equal pair is a hard compile error.
- fn lower_assert_ne(&mut self, a: &Expr, b: &Expr) {
- // Compile-time literals (e.g. after `Const`-arg substitution): a
- // trivially-true pair emits nothing, an equal pair is a hard error.
- // Restricted to plain literals so a field value is never confused with a
- // g-power index (unlike stack-index folding).
- if let (Expr::Lit(x), Expr::Lit(y)) = (a, b) {
- if x == y {
- self.fail(format!("assert a != b: sides are the compile-time-equal literal {x}"))
- };
- return;
- }
- let (la, lb) = (self.expr(a), self.expr(b));
- // x = a + b: nonzero ⇔ a != b
- let x = self.pure(PureOp::Xor, la, lb);
- let inv = self.fresh();
- self.pending.push(Hint::Resolved(RHint::Inverse { value: x, dst: inv }));
- let p = self.fresh();
- self.emit(LOp::Mul { a: x, b: inv, c: p });
- self.set_const(p, F192::ONE);
- }
-
- /// The frame cell holding `g^{k-1}`, the range-check product target, shared
- /// by every check of that bound.
- ///
- /// An ordinary [`Self::const_cell`], so it is shared with any plain use of
- /// the same constant: at `k = 1` the target is `g^0 = 1`, the very cell
- /// [`Self::one`] hands out, which in `main` is also `self_fp`. Sound, since
- /// the `SET` precedes every use and each later write is the write-once
- /// equality, but a second WRITER on any of those paths would land on all.
- fn bound_cell(&mut self, k: u64) -> Off {
- self.const_cell(g_pow_u128((k - 1) as u128).into())
- }
-
- /// `assert log x < log GEN ** k`: the 3-cycle range check in the exponent
- /// (`doc/leanvm/body/09-isa-programming.tex` §sec:prog-range-checks). With
- /// `x = g^e`:
- ///
- /// 1. `DEREF` through `x`, so the bus proves `x = g^e` with `e < 2^h`;
- /// 2. `MUL x·y` into the write-once cell holding `g^{k-1}`. The complement
- /// `y = g^{k-1-e}` needs no hint, the result cell being already written,
- /// so the runner back-solves the one unknown operand;
- /// 3. `DEREF` through `y`, proving `y = g^f` with `f < 2^h`.
- ///
- /// Then `e + f ≡ k-1 (mod 2^64-1)` with `e, f < 2^h`, and a negative `k-1-e`
- /// wraps to `≈ 2^64 ≫ 2^h`, so `e ≤ k-1` for ANY announced memory size,
- /// provided `k ≤ 2^MIN_LOG_MEM`. Both `DEREF` destinations are unconstrained
- /// touches, back-filled at the end of execution unless a cached read needs
- /// their value sooner. Only the ADDRESS matters to the range check itself.
- ///
- /// A [`LtBound::Runtime`] bound reaches the same gadget through one extra
- /// `MUL` for `g^{k-1} = Y·g^{-1}`, still back-solved rather than hinted, and
- /// the `k ≤ 2^MIN_LOG_MEM` obligation moves to the program.
- fn lower_assert_lt(&mut self, e: &Expr, bound: &LtBound) {
- let kcell = match bound {
- LtBound::Const(k) => {
- if *k < 1 {
- self.fail("range-check bound GEN ** 0 names the empty set")
- };
- if *k > 1 << lean_vm::cpu::MIN_LOG_MEM {
- self.fail(format!(
- "range-check bound GEN ** {k} exceeds 2^{} (the minimum memory size)",
- lean_vm::cpu::MIN_LOG_MEM
- ))
- };
- self.bound_cell(*k)
- }
- LtBound::Runtime(b) => {
- // A bound that folds only after substitution (`GEN ** i` inside an
- // `unroll`) reaches here rather than the arm above, and would then
- // skip the `k <= 2^MIN_LOG_MEM` cap entirely. Reject it: the author
- // wrote a compile-time bound and should get the compile-time check.
- if self.try_field_const(b).is_some() {
- self.fail(format!(
- "a compile-time range-check bound must be written as `log GEN ** k` or an \
- integer, so that the 2^{} cap applies",
- lean_vm::cpu::MIN_LOG_MEM
- ))
- };
- let bcell = self.expr(b);
- let inv = self.const_cell(F192::new(primitives::field::G.inv().0, 0, 0));
- let c = self.fresh();
- self.emit(LOp::Mul { a: bcell, b: inv, c });
- c
- }
- };
- let x = self.expr(e);
- let y = self.fresh(); // the complement g^{k-1-e}, back-solved by the MUL
- let t1 = self.fresh(); // DEREF targets: unconstrained touch cells
- let t2 = self.fresh();
- self.deref(x, 0, t1, DerefMode::Cell);
- self.emit(LOp::Mul { a: x, b: y, c: kcell });
- self.deref(y, 0, t2, DerefMode::Cell);
- }
-
- fn expr(&mut self, e: &Expr) -> Off {
- // A wholly compile-time expression, whatever its shape, is one pooled
- // `SET` ([`Self::const_cell`]): folded here once rather than arm by arm.
- if let Some(v) = self.try_field_const(e) {
- return self.const_cell(v);
- }
- match e {
- Expr::Lit(_) | Expr::Gen | Expr::GPow(_) => unreachable!("a literal folds above"),
- // Not folded above, so its exponent is not a compile-time integer,
- // which `gpow_exp` reports.
- Expr::GenPow(e) => {
- let k = self.gpow_exp(e);
- self.const_cell(g_pow_u128(k).into())
- }
- Expr::Pow(b, e) => self.pow_expr(b, e),
- Expr::Var(v) => match self.scope.bound(v).map(|b| b.val) {
- Some(Binding::Stack(..)) => {
- self.fail(format!("StackBuf `{v}` used as a scalar; index it (`{v}[k]`) or pass it to blake2s"));
- }
- Some(Binding::Gaddr(ga)) => self.materialize(ga),
- Some(Binding::Scalar(o)) => o,
- _ => {
- // A `for` body that ASSIGNS to an enclosing name reads it before
- // it binds it, and the capture set drops every name the body
- // binds, so the read arrives here with nothing behind it. That is
- // the loop-carry limitation rather than a typo, and it deserves
- // the same courtesy the `StackBuf` case already gets: the
- // tail-recursive helper threads its captures IN, never out, so an
- // accumulator cannot come back.
- if self.fn_name.starts_with("__loop") {
- self.fail(format!(
- "unbound variable `{v}` in a `for` loop body. If `{v}` names a value from \
- outside the loop that this body also assigns to, the loop cannot carry it: \
- the helper threads its captures in, not out. Assign to a new name inside \
- the body, or carry state through a `HeapBuf`."
- ))
- }
- self.fail(format!("unbound variable `{v}`"))
- }
- },
- Expr::Add(a, b) => {
- if let Some(x) = self.add_identity(a, b) {
- return self.expr(x);
- }
- let (la, lb) = (self.expr(a), self.expr(b));
- self.pure(PureOp::Xor, la, lb)
- }
- Expr::Mul(a, b) => {
- if let Some(x) = self.mul_identity(a, b) {
- return self.expr(x);
- }
- let (la, lb) = (self.expr(a), self.expr(b));
- self.pure(PureOp::Mul, la, lb)
- }
- Expr::FieldDiv(a, b) => {
- // q = a / b via the MUL write-once back-solve: emit `a = q * b`
- // with the quotient `q` the unset operand. Witness-gen fills
- // q = a·b⁻¹, and the MUL constraint pins q·b == a (so b == 0 is
- // rejected unless a == 0). One MUL, no hint. The dividend cell
- // `a` must already be written, which `self.expr(a)` guarantees.
- let (la, lb) = (self.expr(a), self.expr(b));
- let q = self.fresh();
- self.emit(LOp::Mul { a: q, b: lb, c: la });
- q
- }
- // A well-formed one folds above, so this is a malformed call.
- Expr::Call(f, _) if f == "f192" => self.fail("f192 needs three literal u64 limbs"),
- // Folded above when it is what it claims to be, so reaching here means
- // it is not: name that, rather than reporting an unknown function.
- Expr::Call(f, args) if f == "const" => {
- if args.len() != 1 {
- self.fail(format!("const(...) takes one expression, got {}", args.len()))
- };
- self.fail(format!(
- "const(...) asks for a compile-time integer, and `{:?}` is not one",
- args[0]
- ))
- }
- Expr::Call(f, args) if f == "addr" => {
- let ga = self.stack_addr(args);
- self.materialize(ga)
- }
- Expr::Call(f, args) if f == "hint_log2_ceil" => {
- // Computed advice: the prover fills g^log2_ceil (base-2 ceil-log) of the value in
- // `bits` (a `nbits`-bit buffer), floored at `floor`. Returned
- // UNCONSTRAINED, so the caller (log2_ceil) re-verifies it. Same
- // "prover computes, circuit checks" pattern as `/`.
- if args.len() != 3 {
- self.fail(format!(
- "hint_log2_ceil takes three arguments, `(bits, nbits, floor)`, got {}",
- args.len()
- ))
-};
- let nbits = self.const_index(&args[1]);
- let floor = self.const_index(&args[2]);
- let bits = self.bits_dest(&args[0], nbits, "hint_log2_ceil");
- let dst = self.fresh();
- self.pending.push(Hint::Resolved(RHint::Log2Ceil {
- bits,
- dst,
- nbits,
- floor,
- }));
- dst
- }
- Expr::Call(f, args) => {
- let d = self.call(f, args, 1)[0];
- self.take_inline_ret_cell(d)
- }
- Expr::HeapBuf(n) => {
- let arr = self.fresh();
- self.heap_sizes.insert(arr, *n as u128);
- // Allocate before the next instruction reads the pointer.
- self.pending.push(Hint::AllocBuffer {
- ptr: arr,
- size: *n as u32,
- });
- arr
- }
- Expr::HeapBufDyn(e) => {
- // Evaluate the size first (its cell must be written when the
- // alloc hint fires), then allocate before the pointer is read.
- let size = self.expr(e);
- let arr = self.fresh();
- self.pending.push(Hint::AllocBufferDyn { ptr: arr, size });
- arr
- }
- Expr::StackBuf(_) => {
- self.fail("StackBuf(n) must be bound to a name: `x = StackBuf(n)`")
- }
- // A frame cell IS the answer; a heap cell needs a `DEREF` to read.
- Expr::Index(arr, idx) => {
- if let Some(c) = self.frame_cell(arr, idx) {
- return c;
- }
- let (ptr, o2) = self.heap_addr(arr, idx);
- if let Some(&cell) = self.scope.load_cells.get(&(ptr, o2)) {
- return cell;
- }
- let dst = self.fresh();
- self.deref(ptr, o2, dst, DerefMode::Cell);
- dst
- }
- Expr::Sub(..) | Expr::Div(..) | Expr::Mod(..) => {
- self.fail(format!(
- "`-`, `//`, `%` are compile-time only (field subtraction is `+`); use them in an index, a bound, or a `Const` argument, got `{e:?}`"
- ))
- }
- Expr::Slice(..) => self.fail("a slice is not a scalar; it is only a blake2s operand"),
- Expr::ListLit(..) => self.fail("a list literal must be bound to a name: `x = [a, b]`"),
- }
- }
-
- /// `base ** e` (non-`GEN` base, compile-time exponent `e`): a fully-constant
- /// base folds to one `SET`; a runtime base is raised by square-and-multiply.
- fn pow_expr(&mut self, b: &Expr, e: &Expr) -> Off {
- let k = self
- .try_const_index(e)
- .unwrap_or_else(|| self.fail(format!("`**` exponent must be a compile-time integer, got `{e:?}`")));
- // Fully constant: evaluate in the field and emit a single `SET`.
- if let Some(bc) = self.try_field_const(b) {
- return self.const_cell(field_pow(bc, k));
- }
- if k == 0 {
- let o = self.fresh();
- self.set_const(o, F192::ONE);
- return o;
- }
- // Runtime base: square-and-multiply over the compile-time exponent bits.
- let base = self.expr(b);
- let hi = 31 - k.leading_zeros(); // top set bit (k >= 1)
- let mut acc = base;
- for bit in (0..hi).rev() {
- acc = self.pure(PureOp::Mul, acc, acc);
- if (k >> bit) & 1 == 1 {
- acc = self.pure(PureOp::Mul, acc, base);
- }
- }
- acc
- }
-
- /// Evaluate `e` into `dst`, writing constants, arithmetic, heap reads and call results directly.
- /// Writing an existing cell enforces equality under write-once memory.
- fn expr_into(&mut self, e: &Expr, dst: Off) {
- // A wholly compile-time expression (a literal, a constant-array element,
- // constant arithmetic) is one `SET` into `dst`, not a heap read.
- if let Some(v) = self.try_field_const(e) {
- self.set_const(dst, v);
- return;
- }
- match e {
- // Heap read straight into dst (a stack read falls through to the copy).
- // Through the same resolver as every other index, so the frame/heap
- // split is written once: a heap read `DEREF`s straight into `dst`, and a
- // frame read is the cell, copied.
- Expr::Index(arr, idx) => match self.frame_cell(arr, idx) {
- Some(c) => self.copy(c, dst),
- None => {
- let (ptr, o2) = self.heap_addr(arr, idx);
- // A DEREF links two unwritten sides; a MUL copy would read the
- // source, writing zero to it.
- self.deref(ptr, o2, dst, DerefMode::Cell);
- }
- },
- Expr::Pow(b, e) => {
- let v = self.pow_expr(b, e);
- self.copy(v, dst);
- }
- Expr::Add(a, b) => {
- if let Some(x) = self.add_identity(a, b) {
- self.expr_into(x, dst);
- } else {
- // Not `pure`: `dst` is the caller's, so it may be written
- // again and the second write be the assertion. See its doc.
- let (la, lb) = (self.expr(a), self.expr(b));
- self.emit(LOp::Xor { a: la, b: lb, c: dst });
- }
- }
- Expr::Mul(a, b) => {
- if let Some(x) = self.mul_identity(a, b) {
- self.expr_into(x, dst);
- } else {
- let (la, lb) = (self.expr(a), self.expr(b));
- self.emit(LOp::Mul { a: la, b: lb, c: dst });
- }
- }
- // A call writes its single return value straight into `dst` (an
- // aliased inline return materializes, then copies into `dst`).
- Expr::Call(f, args) => {
- self.inline_stack_ret = None;
- self.call_into(f, args, &[dst]);
- let v = self.take_inline_ret_cell(dst);
- if v != dst {
- self.copy(v, dst);
- }
- }
- _ => {
- let v = self.expr(e);
- self.copy(v, dst);
- }
- }
- }
-
- fn stmt(&mut self, s: &Stmt) {
- let tail = std::mem::take(&mut self.tail_call);
- // Every diagnostic raised while lowering this statement, and every
- // instruction it emits, is attributed to this line.
- self.cur_line = s.line;
- match &s.kind {
- StmtKind::Let(name, e) => match e {
- // `x = StackBuf(n)`: bind a run of `n` consecutive frame cells.
- Expr::StackBuf(n) => {
- let base = self.alloc_stack(*n as u32);
- self.rebind(name, Binding::Stack(base, *n as u32));
- }
- // `x = [a, b, …]`: an initialized StackBuf. Allocate the run and
- // write each element in place, through the ordinary stack-store
- // path. Elements are lowered before `name` rebinds, so they may
- // read its old binding (`fs = [fs[1], fs[0]]`).
- Expr::ListLit(es) => {
- let base = self.alloc_stack(es.len() as u32);
- for (k, el) in es.iter().enumerate() {
- self.expr_into(el, base + k as u32);
- }
- self.rebind(name, Binding::Stack(base, es.len() as u32));
- }
- // `p = addr(sb)` binds the address itself, so the offset folds
- // into every later access; in any other position `expr` has to
- // materialize it into a cell instead.
- Expr::Call(f, cargs) if f == "addr" => {
- let ga = self.stack_addr(cargs);
- self.rebind(name, Binding::Gaddr(ga));
- }
- // `x = other_stackbuf`: a compile-time alias of the same cell
- // run (zero instructions), the chaining-state idiom `st = sn`
- // of an MD loop.
- Expr::Var(v) if self.scope.stack(v).is_some() => {
- let (base, size) = self.scope.stack(v).expect("guarded above");
- self.rebind(name, Binding::Stack(base, size));
- }
- _ => {
- // NOTE: `name`'s old binding stays visible while the RHS is
- // lowered (the MD-chain idiom `cvb = obs(cvb, x)` reads it);
- // each terminal path below unbinds/rebinds afterwards.
- // A compile-time integer binding (a literal, or an expression
- // that folds: `FOLDBASE[lvl] + j`, `n // 2`, `len(A) - 1`) is
- // usable as a compile-time index / bound / exponent, and that
- // role survives the value binding chosen below.
- let known = self.eval(e);
- // A symbolic g-address (a constant g-power or a shifted
- // pointer) or a compile-time field constant stays virtual:
- // no instruction here, folded / materialized only on demand.
- if let Some(ga) = known.addr {
- self.rebind(name, Binding::Gaddr(ga));
- } else if let Some(c) = known.field {
- self.rebind(name, Binding::FConst(c));
- } else if let Some(k) = known.int {
- // Integer-only fold (`//`, `-`, `%` of constants): a
- // compile-time value too, and as a scalar it is the field
- // element with those 128 bits, materialized on demand.
- self.rebind(name, Binding::FConst(lit_field(k)));
- } else if let Expr::Call(cf, cargs) = e
- && self.defs.contains_key(cf.as_str())
- {
- // A bare `name = call(...)` of a user function: bind per
- // the inlined return's RetBind, aliasing its StackBuf run
- // or folded g-address at zero copies (the `cvb = obs(...)`
- // / advanced-cursor idiom), else (a plain scalar, or a
- // real call) bind the dst cell. Embedded calls do NOT
- // take this path: `expr` materializes theirs
- // ([`Self::take_inline_ret_cell`]).
- let o = self.call(cf, cargs, 1)[0];
- let b = ret_binding(self.inline_stack_ret.take().and_then(|b| b.into_iter().next()), o);
- self.rebind(name, b);
- } else {
- let o = self.expr(e);
- self.rebind(name, Binding::Scalar(o));
- }
- // The integer reading of the SAME expression, if it has one,
- // rides alongside whichever value binding was chosen above.
- // It is attached after, because a rebind clears it, and the
- // RHS above still had to see `name`'s old reading.
- if let Some(k) = known.int {
- self.scope.set_int(name, k);
- }
- }
- },
- StmtKind::LetTuple(names, f, args) => {
- let dsts = self.call(f, args, names.len());
- // Each returned value binds per its RetBind (alias a StackBuf run
- // or folded g-address, else take the scalar dst cell); a real call
- // leaves the field None, so every name binds its scalar dst.
- let binds = self.inline_stack_ret.take();
- for (i, (n, d)) in names.iter().zip(&dsts).enumerate() {
- let b = ret_binding(binds.as_ref().and_then(|b| b.get(i).copied()), *d);
- self.rebind(n, b);
- }
- }
- // `a + b` into the frame's zero cell: the double write IS the
- // assertion, so the `SET .. = 0` a fresh destination needed is gone
- // and no cell is burned. Not through `pure`, for the same reason:
- // sharing this cell would drop the assertion.
- StmtKind::AssertEq(a, b) => {
- let (la, lb) = (self.expr(a), self.expr(b));
- let z = self.zero();
- self.emit(LOp::Xor { a: la, b: lb, c: z });
- }
- StmtKind::AssertNe(a, b) => self.lower_assert_ne(a, b),
- StmtKind::AssertLt(e, bound) => self.lower_assert_lt(e, bound),
- StmtKind::HintWitness { dest, name } => self.lower_hint_witness(dest, name),
- // One hinted value into one fresh cell, bound to `name`. The run form
- // names its destination's physical cells, and a scalar's cell is never
- // a store target at all, being reachable only through a name.
- StmtKind::LetHintWitness { name, stream } => {
- let dst = self.fresh();
- self.pending.push(Hint::Resolved(RHint::WitnessStack {
- name: stream.clone(),
- base: dst,
- len: 1,
- }));
- self.rebind(name, Binding::Scalar(dst));
- }
- StmtKind::Print { label, value } => {
- // Prover-side debug print: evaluate the value into a cell, hang
- // a Print hint on a no-op anchor so it fires exactly here (and
- // only on this path), at witness generation. No constraints.
- let cell = self.expr(value);
- self.pending.push(Hint::Resolved(RHint::Print {
- label: label.clone(),
- cell,
- }));
- self.anchor();
- }
- StmtKind::If {
- eq,
- lhs,
- rhs,
- then,
- els,
- force_const,
- } => self.lower_if(*eq, lhs, rhs, then, els, *force_const),
- StmtKind::Match { targets, x, arms } => self.lower_match(targets, x, arms),
- StmtKind::Call(f, args) => {
- if !self.lower_builtin(f, args) {
- self.call(f, args, 0);
- }
- }
- StmtKind::Store(arr, idx, val) => {
- // A frame write places the value in the cell; a heap write is the
- // `DEREF` asserting `m[arr·idx] == val` (write-once). The VALUE is
- // lowered first on the heap path, since the address may read a cell
- // the value writes (`hb[sb[0]] = f(sb, …)`), and Python's own
- // evaluation order for `a[i] = v` is the same.
- if let Some(c) = self.frame_cell(arr, idx) {
- self.expr_into(val, c);
- } else {
- let v = self.expr(val);
- let (ptr, o2) = self.heap_addr(arr, idx);
- self.deref(ptr, o2, v, DerefMode::Cell);
- }
- }
- StmtKind::Return(es) => self.lower_return(es),
- StmtKind::CallIfNe(lhs, rhs, callee, args) => {
- // A conditional call: the frame setup runs either way, and the
- // `JUMP`'s nonzero test decides whether the callee is entered,
- // so the not-taken path continues straight after it. In tail
- // position the callee inherits THIS frame's `retpc`/`retfp`, so
- // a `mul_range` loop builds no unwind chain: only the final
- // iteration returns, straight to the loop's original caller.
- let (la, lb) = (self.expr(lhs), self.expr(rhs));
- // x = lhs + rhs; x != 0 ⇔ lhs != rhs
- let x = self.pure(PureOp::Xor, la, lb);
- self.lower_call(callee, args, Some(x), &[], tail);
- }
- StmtKind::For { var, lo, hi, body } => self.lower_for(var, *lo, hi, body),
- // Compile-time unrolling: emit the body per integer, the counter
- // substituted as its literal. Every copy executes (this is
- // straight-line code, not a branch), so bindings simply rebind (a
- // fresh binding per iteration) and lazy caches persist.
- StmtKind::Unroll { var, lo, hi, body } => {
- let bound = |s: &Self, e: &Expr| {
- s.try_const_index(e).unwrap_or_else(|| {
- self.fail(format!("unroll bounds must be compile-time integers, got `{e:?}`"))
- })
- };
- let (lo, hi) = (bound(self, lo), bound(self, hi));
- if lo > hi {
- self.fail(format!("unroll(a, b) needs a <= b, got ({lo}, {hi})"))
- };
- for j in lo..hi {
- for s in subst_stmts(body, var, &Expr::Lit(j as u128)) {
- self.stmt(&s);
- }
- }
- }
- }
- }
-
- /// `for i in mul_range(GEN**lo, GEN**hi)` → a single tail-recursive helper, with the
- /// exit test folded into the recursion's condition (no separate branch, no
- /// is-zero gadget):
- /// ```text
- /// loop(i):
- ///
- /// j = i·g
- /// if j != g^hi: loop(j) // JUMP's nonzero test on (j − g^hi)
- /// return
- /// caller: if lo != hi: loop(g^lo) // resolved at compile time
- /// ```
- /// Free variables of the body that are bound in the enclosing scope are
- /// captured by value as extra helper parameters (e.g. a `HeapBuf` pointer
- /// threaded through the loop).
- fn lower_for(&mut self, var: &str, lo: u64, hi: &ForBound, body: &[Stmt]) {
- let id = *self.loop_ctr;
- *self.loop_ctr += 1;
- let loop_name = format!("__loop{id}");
- let mut shadowed = std::collections::HashSet::new();
- binds_anywhere(body, &mut shadowed);
- // Returns and counter rebinding can change the number of iterations.
- if !contains_return(body) && !shadowed.contains(var) {
- self.loop_bounds.insert(
- loop_name.clone(),
- (
- lo,
- match hi {
- ForBound::Const(end) => Some(*end),
- ForBound::Runtime(_) => None,
- },
- ),
- );
- }
- if std::env::var("DBG_LOOPS").is_ok() {
- let bound = match hi {
- ForBound::Const(h) => format!("g^{lo}..g^{h}"),
- ForBound::Runtime(e) => format!("g^{lo}..{e:?}"),
- };
- eprintln!("DBG_LOOPS {loop_name} in {} for {var} in {bound}", self.fn_name);
- }
- // A runtime stop bound is evaluated once here and threaded through the
- // helper as an extra leading parameter (the exit test compares the
- // advanced counter against it each iteration).
- let bound_var = format!("__bound{id}");
- let (exit, entry_bound): (Expr, Expr) = match hi {
- ForBound::Const(hi) => (Expr::GPow(*hi as u128), Expr::GPow(*hi as u128)),
- ForBound::Runtime(e) => (Expr::Var(bound_var.clone()), e.clone()),
- };
-
- // Determine captures: referenced − locally-bound − the counter, kept if
- // they exist in the enclosing scope (deterministic order).
- let mut referenced = Vec::new();
- let mut bound = std::collections::HashSet::new();
- bound.insert(var);
- for s in body {
- free_vars_stmt(s, &mut referenced, &mut bound);
- }
- // Everything the body binds ANYWHERE, branch-local or not. `bound` above
- // is scoped, which is what makes the capture set right; this flat one
- // only answers "does the body have an `r` of its own?", which is what the
- // StackBuf rejection below needs: a body that merely SHADOWS an enclosing
- // `StackBuf` never touches it, so rejecting it names a capture that is
- // not happening.
- let mut captures = Vec::new();
- let mut seen = std::collections::HashSet::new();
- for r in &referenced {
- if bound.contains(r) {
- continue;
- }
- // A StackBuf is a run of cells, not a single scalar arg, and the
- // tail-recursive loop helper can't thread one across iterations, so a
- // StackBuf from the enclosing scope can't be captured. Reject with a
- // clear error (not the misleading "unbound variable" the capture drop
- // would otherwise trigger). Keep it inside the loop body, or carry
- // state through a `HeapBuf`.
- if self.scope.stack(r).is_some() && !shadowed.contains(r) {
- self.fail(format!(
- "StackBuf `{r}` cannot be captured into a `for` loop; \
- define it inside the loop body or carry state via a `HeapBuf`"
- ));
- }
- // A compile-time field constant is capturable too: the body becomes
- // its own function, so the constant is not in scope there, and the
- // helper takes it as a parameter that the call site materializes
- // with one `SET`. Dropping it made `c = 5` followed by a loop that
- // reads `c` fail as "unbound variable", which named neither the
- // cause nor a fix.
- if matches!(
- self.scope.bound(r).map(|b| b.val),
- Some(Binding::Scalar(_) | Binding::Gaddr(_) | Binding::FConst(_))
- ) && seen.insert(*r)
- {
- captures.push((*r).to_string());
- }
- }
-
- // The helper takes the counter, the runtime bound (if any), then the
- // captures. `cap_args` builds an argument list (a leading expression,
- // the bound, then the captures by name).
- let runtime = matches!(hi, ForBound::Runtime(_));
- let mut params = vec![var.to_string()];
- if runtime {
- params.push(bound_var.clone());
- }
- params.extend(captures.iter().cloned());
- let cap_args = |first: Expr, bound: Expr| {
- let mut a = vec![first];
- if runtime {
- a.push(bound);
- }
- a.extend(captures.iter().map(|c| Expr::Var(c.clone())));
- a
- };
-
- // loop(i, [bound,] caps): run the body, advance to j = i·g, and
- // tail-recurse while j != stop. The exit test is the recursive call's
- // own condition (`JUMP`'s nonzero check on j − stop): no is-zero
- // gadget, no inverse hint, and no extra call beyond the one a loop
- // iteration already makes.
- let next_var = format!("__next{id}");
- let next = Expr::Mul(Box::new(Expr::Var(var.to_string())), Box::new(Expr::Gen));
- let mut loop_body: Vec = body.to_vec();
- // The counter advance and the self-call belong to the `for` header.
- let at = |kind| Stmt::new(self.cur_line, kind);
- loop_body.push(at(StmtKind::Let(next_var.clone(), next)));
- loop_body.push(at(StmtKind::CallIfNe(
- Expr::Var(next_var.clone()),
- exit,
- loop_name.clone(),
- cap_args(Expr::Var(next_var), Expr::Var(bound_var.clone())),
- )));
- loop_body.push(at(StmtKind::Return(vec![])));
- self.queue.push(Func {
- name: loop_name.clone(),
- params: params
- .into_iter()
- .map(|name| Param {
- name,
- kind: ParamKind::Runtime(Shape::Scalar),
- })
- .collect(),
- return_shapes: vec![],
- body: loop_body,
- inline: false,
- });
-
- // Enter the loop iff it runs at least once: compile-time for constant
- // bounds (an empty range compiles to nothing), a conditional call on
- // `g^lo != stop` for runtime ones.
- match hi {
- ForBound::Const(hi) => {
- if lo != *hi {
- self.call(
- &loop_name,
- &cap_args(Expr::GPow(lo as u128), Expr::GPow(*hi as u128)),
- 0,
- );
- }
- }
- ForBound::Runtime(_) => {
- let stmt = Stmt::new(
- self.cur_line,
- StmtKind::CallIfNe(
- Expr::GPow(lo as u128),
- entry_bound.clone(),
- loop_name,
- cap_args(Expr::GPow(lo as u128), entry_bound),
- ),
- );
- self.stmt(&stmt);
- }
- }
- }
-}
-
-fn contains_return(body: &[Stmt]) -> bool {
- body.iter().any(|stmt| match &stmt.kind {
- StmtKind::Return(_) => true,
- StmtKind::If { then, els, .. } => contains_return(then) || contains_return(els),
- StmtKind::For { body, .. } | StmtKind::Unroll { body, .. } => contains_return(body),
- _ => false,
- })
-}
-
-/// The literal `k` when `hi` is syntactically `lo + k` (either operand order):
-/// the shape of a runtime slice, whose bounds cannot be evaluated at compile
-/// time.
-fn plus_k(lo: &Expr, hi: &Expr) -> Option {
- match hi {
- Expr::Add(a, b) => match (a.as_ref(), b.as_ref()) {
- (Expr::Lit(k), other) | (other, Expr::Lit(k)) if other == lo => Some(*k),
- _ => None,
- },
- _ => None,
- }
-}
-
-/// Lower one function to its instruction list and frame size.
-pub(crate) fn lower_func(
- f: &Func,
- queue: &mut Vec,
- loop_ctr: &mut usize,
- defs: &HashMap<&str, &Func>,
- const_arrays: &HashMap<&str, &[F192]>,
- with_filler: bool,
- loop_bounds: &mut HashMap)>,
-) -> Lowered {
- let mut names: HashMap = HashMap::new();
- for (i, p) in f.params.iter().enumerate() {
- assert!(
- !const_arrays.contains_key(p.name.as_str()),
- "`{}`: parameter `{}` collides with a top-level constant array, whose name is \
- reserved (zkDSL.md §Global constants)",
- f.name,
- p.name
- );
- // A `StackBuf(n)` parameter binds the run the caller wrote, exactly as a
- // local `StackBuf(n)` binds one it allocated.
- let off = Abi::arg(f.param_shapes(), i);
- let val = match p.shape() {
- Shape::StackBuf(n) => Binding::Stack(off, n),
- Shape::Scalar => Binding::Scalar(off),
- };
- names.insert(p.name.clone(), Bound { val, int: None });
- }
- // Reserve [0,1] retpc/retfp, params, then the flattened return area, then
- // locals. A StackBuf(n) return occupies n consecutive physical slots.
- let n_ret_cells: u32 = f.return_shapes.iter().map(|s| s.cells()).sum();
- let arg_cells = Abi::arg_cells(f.param_shapes());
- let abi_end = Abi::end(arg_cells, n_ret_cells);
- // Loop callers write the callee's own fp just past its arguments. That
- // equality is tied to the JUMP target, so the loop can use it directly.
- let loop_frame = loop_bounds.contains_key(&f.name);
- let mut lowerer = FnLower {
- scope: Scope {
- self_fp_off: loop_frame.then_some(abi_end),
- names,
- ..Default::default()
- },
- next: abi_end + u32::from(loop_frame),
- arg_cells,
- return_shapes: &f.return_shapes,
- is_main: f.name == "main",
- fn_name: &f.name,
- tail_call: false,
- code: Vec::new(),
- cur_line: 0,
- heap_sizes: HashMap::new(),
- inline_ret: None,
- inline_stack_ret: None,
-
- pending: Vec::new(),
- inline_calls: Vec::new(),
- loop_bounds,
- queue,
- loop_ctr,
- defs,
- const_arrays,
- };
- for (i, s) in f.body.iter().enumerate() {
- // Tail position: a conditional call whose only successor is a bare
- // `return`, in a function that returns nothing. The `mul_range` helper
- // ends exactly like this, so its self-call stops building an unwind
- // chain.
- lowerer.tail_call = !lowerer.is_main
- && f.return_shapes.is_empty()
- && matches!(s.kind, StmtKind::CallIfNe(..))
- && matches!(f.body.get(i + 1).map(|n| &n.kind), Some(StmtKind::Return(r)) if r.is_empty());
- lowerer.stmt(s);
- }
- let mut filler = Vec::new();
- if lowerer.is_main {
- lowerer.halt(); // main terminates at the sentinel pc, not by falling off
- if with_filler {
- // Past the halt, so no program code reaches them: the fill blocks are cycles
- // the interpreter enters on its own ([`FnLower::lower_filler_blocks`]).
- filler = lowerer.lower_filler_blocks();
- }
- } else if !matches!(f.body.last().map(|s| &s.kind), Some(StmtKind::Return(_))) {
- // A function must never fall off its end into whatever code the
- // layout placed next: append the implicit bare return.
- let last = f.body.last().map_or(0, |s| s.line);
- lowerer.stmt(&Stmt::new(last, StmtKind::Return(vec![])));
- }
- Lowered {
- name: f.name.clone(),
- code: lowerer.code,
- frame_size: lowerer.next,
- filler,
- }
-}
diff --git a/crates/lean_compiler/src/lower/builtins.rs b/crates/lean_compiler/src/lower/builtins.rs
deleted file mode 100644
index 68e39b635..000000000
--- a/crates/lean_compiler/src/lower/builtins.rs
+++ /dev/null
@@ -1,328 +0,0 @@
-//! The precompile and the hints: the two places a value arrives without an
-//! instruction computing it.
-//!
-//! `blake2s` is a STATEMENT, not an expression: it writes its digest into a
-//! two-cell run the caller names, so a pre-written destination checks the digest
-//! instead of computing it, by the same write-once rule as any store.
-//!
-//! A hint writes values the prover chose and the circuit did not, so **the
-//! program must constrain them**. A hint names its destination's PHYSICAL cells,
-//! and since every store emits there is nothing for the compiler to prepare: a
-//! later `s[k] = ` is a second write of that cell, which is the
-//! assertion that pins the hinted value.
-
-use super::*;
-
-impl FnLower<'_> {
- /// `blake2s(a, b, out)`: the digest of the two 256-bit operands lands in the
- /// existing 2-cell run `out` (write-once: if `out` was already written, this
- /// asserts the digest equals it). A heap `out` slice takes the digest via a
- /// fresh stack pair and two `DEREF`s after the hash, the store direction
- /// being the same instruction as the load (write-once fills the unset side).
- /// Keyword arguments set the metadata: `counter=` / `final=` / `last_node=`
- /// build it at compile time, `md=` takes the whole word from a value the
- /// program computed.
- fn lower_blake2s(&mut self, args: &[Expr]) {
- let first_kw = args
- .iter()
- .position(|a| matches!(a, Expr::Call(name, _) if name.starts_with("__kw_")))
- .unwrap_or(args.len());
- if first_kw != 3 {
- self.fail("blake2s takes three positional arguments: (a, b, out)")
- };
- if !(args[first_kw..]
- .iter()
- .all(|a| matches!(a, Expr::Call(name, v) if name.starts_with("__kw_") && v.len() == 1)))
- {
- self.fail("keyword arguments must follow the three positional blake2s arguments")
- };
- let mut kwargs: HashMap<&str, &Expr> = HashMap::new();
- for kw in &args[first_kw..] {
- let Expr::Call(name, value) = kw else { unreachable!() };
- let key = name.strip_prefix("__kw_").unwrap();
- if kwargs.insert(key, &value[0]).is_some() {
- self.fail(format!("duplicate blake2s keyword `{key}`"))
- };
- }
- let allowed = ["cv", "counter", "final", "last_node", "md"];
- if !(kwargs.keys().all(|k| allowed.contains(k))) {
- // Sorted: a `HashMap`'s order would make the same mistake report
- // differently between builds.
- let mut bad: Vec<&&str> = kwargs.keys().filter(|k| !allowed.contains(k)).collect();
- bad.sort_unstable();
- self.fail(format!("unknown blake2s keyword {bad:?}; the keywords are {allowed:?}"))
- };
- let customized = kwargs.keys().any(|k| matches!(*k, "counter" | "final" | "last_node"));
- // `md=` hands over the whole metadata word as a runtime value, so it
- // replaces the three keywords that would otherwise build it.
- let runtime_md = kwargs.get("md").copied();
- if runtime_md.is_some() && customized {
- self.fail("blake2s md= is the whole metadata word, so counter=, final= and last_node= cannot come with it")
- };
- if kwargs.contains_key("cv") && !customized && runtime_md.is_none() {
- self.fail(
- "blake2s with cv= requires one of counter=, final=, last_node= or md=, since a chained \
- block is not the default one-block hash",
- )
- };
-
- let a = self.blake2s_input(&args[0]);
- let b = self.blake2s_input(&args[1]);
- let (c, heap_out) = match self.blake2s_operand(&args[2]) {
- CellRun::Stack { base, .. } => (base, None),
- CellRun::Heap { ptr, lo, .. } => (self.alloc_stack(2), Some((ptr, lo))),
- };
- let cv = if let Some(value) = kwargs.get("cv") {
- self.blake2s_cv(value)
- } else {
- self.default_blake2s_cv()
- };
- let md = match runtime_md {
- // A metadata word the program computes, which is what lets a hash whose
- // block count is only known at run time carry the byte counter the
- // standard asks for (doc §sec:prog-byte-counter). It owes the same
- // canonical embedding as every other operand: the memory interaction
- // carries a literal zero above its two low limbs.
- //
- // Aliasing the digest destination is the one case write-once does not
- // catch: the runner reads the metadata before storing the digest, while
- // the witness reads the finished memory image, so the two disagree and
- // the proof fails its opening rather than saying why.
- Some(expr) => {
- let md = self.expr(expr);
- if md == c || md == c + 1 {
- self.fail("blake2s md= must not name a cell of the digest destination")
- };
- md
- }
- None => {
- let const_kw = |this: &Self, name: &str, default: u128| -> u128 {
- kwargs
- .get(name)
- .map(|e| {
- this.try_const_int(e).unwrap_or_else(|| {
- self.fail(format!(
- "BLAKE2s `{name}` must be a compile-time integer, got `{e:?}`; \
- a metadata word computed at run time goes through md="
- ))
- })
- })
- .unwrap_or(default)
- };
- // BLAKE2s metadata is just the cumulative byte counter and two flags, so
- // a multi-block hash is `counter = 64 * blocks_before + bytes_in_this_block`
- // and `final = 1` on the last block. The default is the one-block hash of
- // a full 64-byte input, which is what `vmhash::compress` and every Merkle
- // node use.
- let counter = const_kw(self, "counter", 64);
- let counter = u64::try_from(counter)
- .unwrap_or_else(|_| self.fail(format!("blake2s counter= {counter} does not fit in u64")));
- let f0 = if const_kw(self, "final", if customized { 0 } else { 1 }) != 0 {
- lean_vm::hash_flock::FINAL_FLAG
- } else {
- 0
- };
- let f1 = if const_kw(self, "last_node", 0) != 0 {
- u32::MAX
- } else {
- 0
- };
- // A compile-time metadata is a pooled `SET`: one per distinct value
- // per frame, however many compressions read it.
- self.const_cell(lean_vm::hash_flock::metadata(counter, f0, f1))
- }
- };
- // Each operand is two 128-bit chunk cells; the flexible opcode addresses
- // the four input cells independently (`blake2s_input` forwards the real
- // chunk sources where it can). The digest occupies the two consecutive
- // output cells `c, g·c`.
- self.emit(LOp::Blake2s {
- ins: [a[0], a[1], b[0], b[1]],
- cv,
- c,
- md,
- });
- if let Some((ptr, lo)) = heap_out {
- for k in 0..2 {
- self.deref(ptr, lo + k, c + k, DerefMode::Cell);
- }
- }
- }
-
- /// The statement-position builtins, `true` if `f` was one of them (else the
- /// caller emits an ordinary call). The `hint_*` ones queue prover-side
- /// advice, re-checked in-circuit by their caller: `hint_decompose_bits`
- /// writes a value's bits into a buffer, `hint_decompose_bits_exponent` the
- /// bits of `n` where the value is `g^n` (a bounded dlog at witness
- /// generation), `hint_f192_limbs` a value's coordinate limbs.
- pub(super) fn lower_builtin(&mut self, f: &str, args: &[Expr]) -> bool {
- match f {
- "hint_decompose_bits" | "hint_decompose_bits_exponent" => {
- if args.len() != 3 {
- self.fail(format!(
- "{f} takes three arguments, `(bits, value, nbits)`, got {}",
- args.len()
- ))
- };
- let nbits = self.const_index(&args[2]);
- let bits = self.bits_dest(&args[0], nbits, f);
- let value = self.expr(&args[1]);
- self.pending.push(Hint::Resolved(if f == "hint_decompose_bits" {
- RHint::BitDecompose { value, bits, nbits }
- } else {
- RHint::BitDecomposeExp { value, bits, nbits }
- }));
- }
- "blake2s" => self.lower_blake2s(args),
- "assert_in_k" => {
- if args.len() != 2 {
- self.fail("assert_in_k(a, b) takes two scalar cells")
- };
- let a = self.expr(&args[0]);
- let b = self.expr(&args[1]);
- let zero = self.zero();
- self.emit(LOp::Jump { oc: zero, od: a, of: b });
- }
- "hint_f192_limbs" => {
- if args.len() != 2 {
- self.fail(format!(
- "hint_f192_limbs takes two arguments, `(dest, value)`, got {}",
- args.len()
- ))
- };
- let (base, len) = self.stack_of(&args[0]).unwrap_or_else(|| {
- self.fail(format!(
- "hint_f192_limbs writes 1..=3 frame cells, so its destination must be a \
- StackBuf, got `{:?}`",
- args[0]
- ))
- });
- if !((1..=3).contains(&len)) {
- self.fail("hint_f192_limbs destination must have 1..=3 cells")
- };
- let value = self.expr(&args[1]);
- // Names the physical cells, as the two consumers above do: whatever
- // the program stores into them afterwards is a second write, and so
- // the assertion that pins these limbs.
- self.pending
- .push(Hint::Resolved(RHint::FieldLimbs { value, base, len }));
- }
- _ => return false,
- }
- true
- }
-
- /// Resolve a `blake2s` operand: a [`Self::cell_run`] pinned to exactly 2
- /// cells, a 256-bit value being two 128-bit cells. Stack operands are used
- /// in place; heap operands must be bridged through the stack, since
- /// `BLAKE2s` addresses only frame cells (see [`Self::blake2s_input`]).
- fn blake2s_operand(&mut self, e: &Expr) -> CellRun {
- let run = self.cell_run(e);
- if run.cells() != 2 {
- self.fail("a blake2s operand must span exactly 2 cells (two 128-bit words); slice a larger buffer: `buf[lo:lo + 2]`")
- };
- run
- }
-
- /// A `blake2s` *input* operand as its two independently-addressed 128-bit
- /// chunk bases (each chunk is ONE 128-bit cell): stack runs in place; a heap
- /// slice is pulled into a fresh stack pair first, one `DEREF` per cell
- /// (`m[ptr·g^{lo+k}] == m[fp+t+k]`, the `β` immediate doing the pointer
- /// offset). The heap cells must already be written.
- ///
- /// A LIST LITERAL names its two words directly and allocates nothing. The
- /// opcode addresses its four input chunks independently, so an operand
- /// assembled out of values living elsewhere never has to be gathered into a
- /// consecutive run: `blake2s([a, b], …)` is the spelling that says so.
- pub(super) fn blake2s_input(&mut self, e: &Expr) -> [Off; 2] {
- if let Expr::ListLit(words) = e {
- if words.len() != 2 {
- self.fail(format!(
- "a blake2s operand written as a list needs exactly 2 words, got {}",
- words.len()
- ))
- };
- return [self.expr(&words[0]), self.expr(&words[1])];
- }
- match self.blake2s_operand(e) {
- CellRun::Stack { base, .. } => [base, base + 1],
- CellRun::Heap { ptr, lo, .. } => {
- let t = self.alloc_stack(2);
- for k in 0..2 {
- self.deref(ptr, lo + k, t + k, DerefMode::Cell);
- }
- [t, t + 1]
- }
- }
- }
-
- fn default_blake2s_cv(&mut self) -> Off {
- if let Some(o) = self.scope.blake2s_iv {
- return o;
- }
- let o = self.alloc_stack(2);
- for (k, value) in lean_vm::hash_flock::IV_CELLS.into_iter().enumerate() {
- self.set_const(o + k as u32, value);
- self.scope
- .const_cells
- .entry([value.c0, value.c1, value.c2])
- .or_insert(o + k as u32);
- }
- self.scope.blake2s_iv = Some(o);
- o
- }
-
- /// A computed-advice bit buffer's destination ([`BitsDest`]). Not
- /// [`Self::cell_run`]: these builtins take a bare `HeapBuf` and carry the
- /// length in `nbits`, where a cell run would demand a slice.
- pub(super) fn bits_dest(&mut self, e: &Expr, nbits: u32, what: &str) -> BitsDest {
- match self.stack_of(e) {
- Some((base, len)) => {
- if len < nbits {
- self.fail(format!(
- "{what} needs {nbits} cells, its StackBuf destination has {len}"
- ))
- };
- // The hint names the physical cells, as `hint_f192_limbs` does.
- BitsDest::Stack(base)
- }
- None => {
- // Bounds-checked like the `StackBuf` arm above, and like every
- // other heap consumer. Without this a `HeapBuf` destination wrote
- // `nbits` cells with nothing checking the buffer held them, so the
- // bits ran on into the next buffer while the same call with a
- // `StackBuf` destination was rejected.
- self.check_heap_bound(e, 0, u128::from(nbits));
- BitsDest::Heap(self.expr(e))
- }
- }
- }
-
- /// `hint_witness(dest, "name")`: resolve `dest` to a run of cells and
- /// queue the witness-fill hint (no instructions: the values are written
- /// by the runner before the next instruction executes, unconstrained).
- pub(super) fn lower_hint_witness(&mut self, dest: &Expr, name: &str) {
- let name = name.to_string();
- let hint = match self.cell_run(dest) {
- CellRun::Stack { base, len } => RHint::WitnessStack { name, base, len },
- CellRun::Heap { ptr, lo, len } => RHint::WitnessHeap { name, ptr, lo, len },
- };
- self.pending.push(Hint::Resolved(hint));
- }
- /// A BLAKE2s chaining value must occupy two consecutive frame cells because
- /// the opcode carries one base offset for both words. Preserve a genuine
- /// consecutive pair, including a heap pair already bridged by
- /// [`Self::blake2s_input`]. A `cv` written as a two-word LIST exposes two
- /// sources that need not be adjacent, so those are copied into a fresh
- /// consecutive pair.
- fn blake2s_cv(&mut self, e: &Expr) -> Off {
- let pair = self.blake2s_input(e);
- if pair[1] == pair[0] + 1 {
- return pair[0];
- }
- let cv = self.alloc_stack(2);
- self.copy(pair[0], cv);
- self.copy(pair[1], cv + 1);
- cv
- }
-}
diff --git a/crates/lean_compiler/src/lower/call.rs b/crates/lean_compiler/src/lower/call.rs
deleted file mode 100644
index 6ce9ff744..000000000
--- a/crates/lean_compiler/src/lower/call.rs
+++ /dev/null
@@ -1,629 +0,0 @@
-//! The call boundary: arguments in, return values out, and the two ways a
-//! callee can disappear into its caller.
-//!
-//! Every frame is laid out by [`Abi`], and CALLER AND CALLEE MUST AGREE ON THE
-//! ARITY: each places the return area from its own idea of the argument count,
-//! so a missing argument leaves the callee's cell unwritten and therefore
-//! prover-chosen, and a surplus one overwrites the callee's first return slot.
-//! Two paths need the check, the ordinary one and the fused `match`
-//! dispatch.
-//!
-//! A callee vanishes into its caller two ways. `Const` specialization
-//! monomorphises it per constant tuple; `@inline` expands the body into the
-//! caller's own frame, so the caller's `one`, `self_fp` and constant cells stay
-//! valid across it and only the name bindings reset.
-
-use super::*;
-use std::borrow::Cow;
-
-/// How an inlined tail return binds in the caller: a `StackBuf` run and a folded
-/// g-address alias at zero copies, while anything else (a plain scalar, or a real
-/// call, which records no [`RetBind`]) takes the destination cell it wrote.
-pub(super) fn ret_binding(b: Option, dst: Off) -> Binding {
- match b {
- Some(RetBind::Stack(base, size)) => Binding::Stack(base, size),
- Some(RetBind::Gaddr(ga)) => Binding::Gaddr(ga),
- _ => Binding::Scalar(dst),
- }
-}
-
-fn substitute_body<'a>(body: &'a [Stmt], substs: &[(&str, Expr)]) -> Cow<'a, [Stmt]> {
- substs.iter().fold(Cow::Borrowed(body), |body, (name, value)| {
- Cow::Owned(subst_stmts(&body, name, value))
- })
-}
-
-/// A single tail return, preceded by statements that can be expanded in the caller's frame.
-fn body_inlinable(body: &[Stmt]) -> bool {
- matches!(body.split_last(), Some((last, rest)) if matches!(last.kind, StmtKind::Return(_))
- && rest.iter().all(stmt_inline_safe))
-}
-
-fn stmt_inline_safe(s: &Stmt) -> bool {
- match &s.kind {
- StmtKind::Let(..)
- | StmtKind::Store(..)
- | StmtKind::HintWitness { .. }
- | StmtKind::LetHintWitness { .. }
- | StmtKind::Print { .. }
- | StmtKind::AssertEq(..)
- | StmtKind::AssertNe(..)
- | StmtKind::AssertLt(..)
- // Ordinary calls allocate their own frames.
- | StmtKind::Call(..) => true,
- StmtKind::If { then, els, .. } => then.iter().all(stmt_inline_safe) && els.iter().all(stmt_inline_safe),
- StmtKind::Unroll { body, .. } => body.iter().all(stmt_inline_safe),
- _ => false,
- }
-}
-
-impl FnLower<'_> {
- /// Lower a call into the caller's return cells. Distinct `match` arms may
- /// share these write-once destinations.
- pub(super) fn lower_call(&mut self, callee: &str, args: &[Expr], cond: Option, dsts: &[Off], tail: bool) {
- // Every parameter must be supplied. A missing argument leaves the
- // callee's argument cell unwritten, hence prover-chosen, so an `assert`
- // reading it is vacuous; a surplus one lands on the callee's first
- // return slot, because caller and callee place the return area from
- // their own idea of the argument count. Only `specialize` checked this,
- // and only for a callee declaring `Const` parameters.
- match self.callee_def(callee).map(|f| f.params.len()) {
- Some(want) if want != args.len() => {
- let plural = if want == 1 { "argument" } else { "arguments" };
- self.fail(format!("`{callee}` takes {want} {plural}, got {}", args.len()))
- }
- // Nothing by that name is going to be lowered, so the entry pc it
- // needs will not exist. Caught here, where there is a line: a typo, a
- // statement-only builtin used as a value (`x = assert_in_k(a, b)`),
- // or an `@inline` callee reached where inlining did not happen, all
- // used to die later in `resolve` as a bare `no entry found for key`.
- None => self.fail(format!(
- "no function named `{callee}`. A builtin that writes into a destination \
- (`blake2s`, `assert_in_k`, a `hint_*`) is a statement and returns nothing, so it \
- cannot be called for a value"
- )),
- _ => {}
- }
- let (callee, args) = self.specialize(callee, args);
- let (callee, args) = (callee.as_str(), args.as_slice());
- // Each argument goes where its SHAPE puts it: a `StackBuf(n)` parameter
- // takes n consecutive cells, exactly as a `StackBuf(n)` return value
- // does. Resolved before the frame pointer is allocated, as before.
- let shapes = self
- .callee_def(callee)
- .map(|f| f.param_shapes().collect::>())
- .unwrap_or_else(|| vec![Shape::Scalar; args.len()]);
- let mut arg_offs: Vec<(Off, Off)> = Vec::new();
- for (i, a) in args.iter().enumerate() {
- let base = Abi::arg(shapes.iter().copied(), i);
- match shapes.get(i).copied().unwrap_or(Shape::Scalar) {
- Shape::StackBuf(n) => {
- let (src, len) = self.stack_of(a).unwrap_or_else(|| {
- self.fail(format!(
- "`{callee}` parameter {i} is a StackBuf({n}); pass one, got `{a:?}`"
- ))
- });
- if len != n {
- self.fail(format!(
- "`{callee}` parameter {i} is a StackBuf({n}), got a StackBuf({len})"
- ))
- }
- for k in 0..n {
- let cell = src + k;
- arg_offs.push((base + k, cell));
- }
- }
- Shape::Scalar => {
- let cell = self.expr(a);
- arg_offs.push((base, cell));
- }
- }
- }
- let callee_arg_cells = Abi::arg_cells(shapes.iter().copied());
- if tail && callee == self.fn_name && self.loop_bounds.contains_key(callee) {
- let own_fp = self.self_fp();
- let scale = self.fresh();
- self.set(scale, KVal::FrameSize);
- self.pending.push(Hint::NextFrameAddress);
- let nfp = self.pure(PureOp::Mul, own_fp, scale);
- let entry = self.fresh();
- let oc = cond.unwrap_or_else(|| self.one());
- let one = self.one();
- self.set(entry, KVal::Entry(callee.to_string()));
- for &(off, ao) in &arg_offs {
- self.emit(LOp::MulNextFrame { a: ao, b: one, c: off });
- }
- self.emit(LOp::MulNextFrame {
- a: nfp,
- b: one,
- c: Abi::end(callee_arg_cells, 0),
- });
- self.emit(LOp::MulNextFrame {
- a: 1,
- b: one,
- c: Abi::RET_FP,
- });
- self.emit(LOp::MulNextFrame {
- a: 0,
- b: one,
- c: Abi::RET_PC,
- });
- self.emit(LOp::Jump { oc, od: entry, of: nfp });
- return;
- }
- let nfp = self.fresh();
- let entry = self.fresh();
- // Resolve the jump condition up front: `self.one()` may emit a `SET`, and
- // nothing may sit between the retpc `DEREF` and the `JUMP` (the `g²·pc`
- // return target assumes the `JUMP` is exactly one instruction later).
- let oc = cond.unwrap_or_else(|| self.one());
- self.set(entry, KVal::Entry(callee.to_string()));
-
- // The frame-pointer hint fires before the first DEREF that reads `nfp`.
- if let Some(&(start, end)) = self.loop_bounds.get(callee) {
- let count = match end {
- Some(end) => LoopCount::Constant(end - start + 1),
- None => LoopCount::Bound {
- cell: arg_offs[1].1,
- start,
- },
- };
- self.pending.push(Hint::AllocLoopFrames {
- ptr: nfp,
- callee: callee.to_string(),
- count,
- });
- } else {
- self.pending.push(Hint::AllocFrame {
- ptr: nfp,
- callee: callee.to_string(),
- });
- }
- for &(off, ao) in &arg_offs {
- self.deref(nfp, off, ao, DerefMode::Cell);
- }
- if self.loop_bounds.contains_key(callee) {
- self.deref(nfp, Abi::end(callee_arg_cells, 0), nfp, DerefMode::Cell);
- }
- if tail {
- // Tail call: hand the callee OUR return target, so it returns to our
- // caller and we are never resumed. Cells 0/1 of this frame already
- // hold that target (written by whoever called us).
- self.deref(nfp, 1, 1, DerefMode::Cell); // retfp := our retfp
- self.deref(nfp, 0, 0, DerefMode::Cell); // retpc := our retpc
- } else {
- self.deref(nfp, 1, 0, DerefMode::Fp); // retfp
- self.deref(nfp, 0, 0, DerefMode::Pc); // retpc = g²·pc
- }
- self.emit(LOp::Jump { oc, od: entry, of: nfp });
-
- for (i, &d) in dsts.iter().enumerate() {
- self.deref(nfp, Abi::ret(callee_arg_cells, i as u32), d, DerefMode::Cell);
- }
- }
-
- /// `names = match(log(x), …, lambda k: f(args, k))` fused: the arms all
- /// call one of `callees` (specializations sharing the arg/return layout) with
- /// the same runtime `args`, so build the callee frame **once** and let the
- /// dispatch jump straight into the selected entry, which returns to the join.
- /// Each taken arm is then just the trampoline's `SET entry; JUMP`: no
- /// per-arm frame setup, call, or return jump.
- pub(super) fn lower_dispatched_call(&mut self, targets: &[Expr], x: &Expr, callees: &[String], rt_args: &[&Expr]) {
- // The arms share ONE frame, so they must share one argument layout too:
- // a `StackBuf` parameter in one callee and a scalar in another at the
- // same position would put the return area in two places. The arity check
- // below is the count; this is the widths.
- if let Some(shared) = callees.iter().find_map(|c| self.callee_def(c)) {
- for c in callees {
- if let Some(def) = self.callee_def(c)
- && !def.param_shapes().eq(shared.param_shapes())
- {
- self.fail(format!(
- "`{c}` does not take the same parameter shapes as the other arms of this dispatch"
- ))
- }
- }
- // Fused dispatch writes one cell per argument; only scalar parameters fit.
- if let Some(i) = shared.param_shapes().position(|s| s != Shape::Scalar) {
- self.fail(format!(
- "a `match` arm cannot pass a `StackBuf` parameter (parameter {i} of `{}`): the \
- fused dispatch writes one cell per argument. Give the arms `Const` arguments so each \
- specializes into its own call instead of fusing",
- callees.first().map(String::as_str).unwrap_or("?")
- ))
- }
- }
- let n_args = rt_args.len() as u32;
- // The join below reads one return cell per bound name, so every callee has
- // to declare exactly that many. Unchecked, a name past a callee's arity
- // `DEREF`s a frame offset nothing on that path writes, and since the shared
- // frame is sized to the LARGEST callee the offset exists: the surplus name
- // binds a prover-chosen word. The non-fused path enforces this
- // ([`Self::call_into`]), so leaving it out here means one source is rejected
- // by one lowering of `match` and silently miscompiled by the other.
- for callee in callees {
- // Arguments for the same reason as returns below: the shared frame
- // is sized to the largest callee, so a callee expecting more than
- // the arms supply reads a cell that exists and nothing writes.
- let Some(def) = self.callee_def(callee) else {
- continue;
- };
- let want = def.params.len();
- if want != rt_args.len() {
- let plural = if want == 1 { "argument" } else { "arguments" };
- self.fail(format!(
- "`{callee}` takes {want} {plural}, dispatched call passes {}",
- rt_args.len()
- ))
- }
- let shapes = &def.return_shapes;
- if shapes.len() != targets.len() {
- self.fail(format!(
- "`{callee}` returns {} values, dispatched call binds {}",
- shapes.len(),
- targets.len()
- ))
- };
- if shapes.iter().any(|s| *s != Shape::Scalar) {
- self.fail(format!(
- "`{callee}`: a multi-cell StackBuf return cannot cross a dispatched join"
- ))
- };
- }
- let (rcells, binds) = self.ret_targets(targets);
-
- // Shared callee frame: args, retfp, and retpc = the join (so the callee
- // returns straight past the dispatch). Evaluated once.
- let arg_offs: Vec = rt_args.iter().map(|a| self.expr(a)).collect();
- let xo = self.expr(x);
- let one = self.one();
- let sfp = self.self_fp();
-
- let nfp = self.fresh();
- self.pending.push(Hint::AllocFrameMax {
- ptr: nfp,
- callees: callees.to_vec(),
- });
- for (i, &ao) in arg_offs.iter().enumerate() {
- self.deref(
- nfp,
- Abi::arg(std::iter::repeat_n(Shape::Scalar, rt_args.len()), i),
- ao,
- DerefMode::Cell,
- );
- }
- self.deref(nfp, Abi::RET_FP, 0, DerefMode::Fp);
- let join_cell = self.fresh();
- let join_set = self.code.len();
- self.set(join_cell, KVal::Local(0)); // patched: the join pc
- self.deref(nfp, Abi::RET_PC, join_cell, DerefMode::Cell); // retpc = join
-
- let kset = self.emit_dispatch(xo, one, sfp);
-
- // Trampoline: slot j enters `callees[j]` with fp = nfp; the callee's own
- // `return` jumps to retpc (the join) in the caller frame.
- self.patch_local(kset, self.code.len());
- self.emit_slots(callees.len(), one, nfp, |j| KVal::Entry(callees[j].clone()));
-
- // Join: read the return values (written by whichever callee ran).
- self.patch_local(join_set, self.code.len());
- for (i, &r) in rcells.iter().enumerate() {
- self.deref(nfp, Abi::ret(n_args, i as u32), r, DerefMode::Cell);
- }
-
- self.bind_targets(&binds);
- }
-
- /// Inline an `@inline` `callee(args)` into the current frame, binding its
- /// return values straight into `dsts`: no frame setup, no argument/return
- /// plumbing, no call/return jumps. Returns `false` for a non-`@inline`
- /// callee (the caller emits a real call). Panics if an `@inline` function
- /// isn't inlinable ([`body_inlinable`]) or its `Const` args don't resolve.
- pub(super) fn try_inline(&mut self, callee: &str, args: &[Expr], dsts: &[Off]) -> bool {
- let Some(def) = self.defs.get(callee).copied().filter(|d| d.inline) else {
- return false;
- };
- let substs = self
- .const_substs(def, args)
- .unwrap_or_else(|_| self.fail(format!("`@inline {callee}`: bad arity or unresolved Const argument")));
- let body = substitute_body(&def.body, &substs);
- let n_ret = def.return_shapes.len();
- if n_ret != dsts.len() {
- self.fail(format!(
- "`@inline {callee}` returns {n_ret} values, call binds {}",
- dsts.len()
- ))
- };
- if !body_inlinable(&body) {
- self.fail(format!("`@inline {callee}` requires one tail `return`, with no nested returns, tuple assignments, mul_range loops, or match"))
- };
- if self.inline_calls.iter().any(|f| f == callee) {
- self.fail(format!(
- "recursive @inline expansion is not supported: {} -> {callee}",
- self.inline_calls.join(" -> ")
- ))
- };
- // Bind the params from the caller-scope arguments (symbolically where we
- // can, so a shifted-pointer arg keeps folding into `β`; a `StackBuf` arg
- // aliases its cell run), then lower the body in a fresh variable
- // environment, since a function sees only its params. The frame, `one`,
- // `self_fp`, and range-check bounds stay the caller's: the inlined code
- // runs in the caller's frame, so they fit.
- let mut binds: Vec<(String, Binding)> = Vec::new();
- for (p, a) in def.params.iter().zip(args) {
- if p.kind == ParamKind::Const {
- continue;
- }
- let b = if let Some((base, size)) = self.stack_of(a) {
- Binding::Stack(base, size)
- } else if let Some(ga) = self.gaddr_of(a) {
- Binding::Gaddr(ga)
- } else {
- Binding::Scalar(self.expr(a))
- };
- binds.push((p.name.clone(), b));
- }
- // Only the name bindings reset: the inlined body runs in the caller's
- // frame, so the caller's `one`, `self_fp`, constant and bound cells all
- // still name valid cells and stay live.
- let saved = std::mem::take(&mut self.scope.names);
- for (p, b) in binds {
- self.check_not_reserved(&p);
- self.scope.names.insert(p, Bound { val: b, int: None });
- }
- let saved_ret = self.inline_ret.replace(dsts.to_vec());
- // The body lowers through the CALLER's `FnLower`, so its statements move
- // `cur_line` into the callee. Restoring it is what keeps the rest of the
- // caller's expression attributed to the call site rather than to whatever
- // line the callee happened to end on.
- let saved_line = self.cur_line;
- self.inline_calls.push(callee.to_string());
- for s in body.iter() {
- self.stmt(s);
- }
- let popped = self.inline_calls.pop();
- debug_assert_eq!(popped.as_deref(), Some(callee));
- self.inline_ret = saved_ret;
- self.cur_line = saved_line;
- self.scope.names = saved;
- true
- }
-
- pub(super) fn lower_return(&mut self, exprs: &[Expr]) {
- // Inlined (`@inline`): bind the return values into the caller's cells
- // and fall through: this is the body's tail return, so no jump is needed.
- if let Some(dsts) = self.inline_ret.take() {
- // Each returned value is bound into the caller independently, exactly
- // as a `let name = ` would: a `StackBuf` or a folded
- // g-address hands over its run/pointer (alias, not copies: allocated
- // in the caller's frame, so it outlives the inline scope), a scalar is
- // copied into its dst cell. The per-slot record lets the caller's
- // `let`/tuple pick the right binding, so a fused
- // `fs, x, cur = fs_next(fs, cur)` returns a StackBuf, a scalar, and an
- // advanced cursor together.
- let mut binds = Vec::with_capacity(dsts.len());
- for (e, &d) in exprs.iter().zip(&dsts) {
- binds.push(if let Some((base, size)) = self.stack_of(e) {
- RetBind::Stack(base, size)
- } else if let Some(ga) = self.gaddr_of(e) {
- RetBind::Gaddr(ga)
- } else {
- self.expr_into(e, d);
- RetBind::Scalar
- });
- }
- self.inline_stack_ret = Some(binds);
- return;
- }
- if self.is_main {
- return; // a `return` in main is a no-op; main halts via the trailing sentinel jump (lower_func).
- }
- let ret_base = Abi::ret(self.arg_cells, 0);
- if exprs.len() != self.return_shapes.len() {
- self.fail(format!(
- "function returns {} values here, but its ABI declares {}",
- exprs.len(),
- self.return_shapes.len()
- ))
- };
- // Each logical value lands straight in its flattened return area. A
- // StackBuf is copied cell-by-cell because its callee-frame offsets are
- // not meaningful after control returns to the caller.
- let mut ret = ret_base;
- for (e, &shape) in exprs.iter().zip(self.return_shapes) {
- match shape {
- Shape::Scalar => self.expr_into(e, ret),
- Shape::StackBuf(size) => {
- let (base, actual) = self
- .stack_of(e)
- .unwrap_or_else(|| self.fail(format!("expected a StackBuf({size}) return, got `{e:?}`")));
- if actual != size {
- self.fail(format!("returned StackBuf has size {actual}, expected {size}"))
- };
- for k in 0..size {
- let src = base + k;
- self.copy(src, ret + k);
- }
- }
- }
- ret += shape.cells();
- }
- let one = self.one();
- self.emit(LOp::Jump { oc: one, od: 0, of: 1 });
- }
-
- /// If `callee` declares `Const` parameters, monomorphize: the constant
- /// arguments (literals, `GEN ** k`, or literal-bound names) substitute into a
- /// copy of the callee, queued once per distinct constant tuple and named
- /// `callee__L5_G3`-style, and only the runtime arguments remain.
- pub(super) fn specialize<'a>(&mut self, callee: &str, args: &'a [Expr]) -> (String, Vec<&'a Expr>) {
- let Some(def) = self.defs.get(callee).copied() else {
- return (callee.to_string(), args.iter().collect()); // loop helpers, unknown names
- };
- if !def.has_const_params() {
- return (callee.to_string(), args.iter().collect());
- }
- let substs = self.const_substs(def, args).unwrap_or_else(|e| self.fail(e));
- let mut name = format!("{callee}_");
- for (_, c) in &substs {
- match c {
- Expr::Lit(n) => write!(name, "_L{n}"),
- Expr::GPow(k) => write!(name, "_G{k}"),
- _ => unreachable!(),
- }
- .unwrap();
- }
- let runtime = def.params.iter().zip(args).filter(|(p, _)| p.kind != ParamKind::Const);
- if !self.queue.iter().any(|f| f.name == name) {
- if self.queue.len() >= 10_000 {
- self.fail("Const specialization explosion (recursive constants?)")
- };
- self.queue.push(Func {
- name: name.clone(),
- params: runtime.clone().map(|(p, _)| p.clone()).collect(),
- return_shapes: def.return_shapes.clone(),
- body: substitute_body(&def.body, &substs).into_owned(),
- inline: false,
- });
- }
- (name, runtime.map(|(_, a)| a).collect())
- }
-
- /// Lower a call; returns one caller offset per source-level return value.
- /// A real-call StackBuf return is flattened into consecutive ABI cells and
- /// copied into a fresh consecutive run in the caller. `inline_stack_ret`
- /// describes those logical bindings to the surrounding let/tuple lowering.
- pub(super) fn call(&mut self, callee: &str, args: &[Expr], n_ret: usize) -> Vec {
- if callee == "blake2s" {
- self.fail("blake2s is a statement: `blake2s(a, b, out)` writes the digest into the 2-cell stack run `out`")
- };
- self.inline_stack_ret = None;
- if self.defs.get(callee).is_some_and(|d| d.inline) {
- let dsts: Vec = (0..n_ret).map(|_| self.fresh()).collect();
- self.call_into(callee, args, &dsts);
- return dsts;
- }
-
- let def = self.defs.get(callee).copied();
- if let Some(def) = def
- && def.return_shapes.len() != n_ret
- {
- self.fail(format!(
- "`{callee}` returns {} values, call binds {n_ret}",
- def.return_shapes.len()
- ))
- };
- let mut logical = Vec::with_capacity(n_ret);
- let mut physical = Vec::new();
- let mut binds = Vec::with_capacity(n_ret);
- for i in 0..n_ret {
- match def.map_or(Shape::Scalar, |d| d.return_shapes[i]) {
- Shape::Scalar => {
- let dst = self.fresh();
- logical.push(dst);
- physical.push(dst);
- binds.push(RetBind::Scalar);
- }
- Shape::StackBuf(size) => {
- if size == 0 {
- self.fail("a returned StackBuf must not be empty")
- };
- let base = self.alloc_stack(size);
- logical.push(base);
- physical.extend(base..base + size);
- binds.push(RetBind::Stack(base, size));
- }
- }
- }
- self.lower_call(callee, args, None, &physical, false);
- self.inline_stack_ret = Some(binds);
- logical
- }
-
- /// Evaluate `callee(args)` into `dsts`, inlining the callee when it is
- /// `@inline` ([`Self::try_inline`]), else a real call.
- pub(super) fn call_into(&mut self, callee: &str, args: &[Expr], dsts: &[Off]) {
- if callee == "blake2s" {
- self.fail("blake2s is a statement, not a value-returning call")
- };
- if !self.try_inline(callee, args, dsts) {
- if let Some(def) = self.defs.get(callee) {
- if def.return_shapes.len() != dsts.len() {
- self.fail(format!(
- "`{callee}` returns {} values, call binds {}",
- def.return_shapes.len(),
- dsts.len()
- ))
- };
- if def.return_shapes.iter().any(|s| *s != Shape::Scalar) {
- self.fail("a normal function's multi-cell StackBuf return needs a `let` binding")
- };
- }
- self.lower_call(callee, args, None, dsts, false);
- }
- }
-
- fn const_substs<'a>(&self, def: &'a Func, args: &[Expr]) -> Result, String> {
- if args.len() != def.params.len() {
- return Err(format!("call to `{}`: wrong arity", def.name));
- }
- let mut substs = Vec::new();
- for (p, a) in def.params.iter().zip(args) {
- if p.kind == ParamKind::Const {
- let c = self.const_arg(a).ok_or_else(|| {
- format!(
- "argument for Const parameter `{}` of `{}` must be a compile-time \
- constant, got `{a:?}`",
- p.name, def.name
- )
- })?;
- substs.push((p.name.as_str(), c));
- }
- }
- Ok(substs)
- }
-
- /// Original definitions take precedence over generated functions in the queue.
- fn callee_def(&self, callee: &str) -> Option<&Func> {
- self.defs
- .get(callee)
- .copied()
- .or_else(|| self.queue.iter().find(|f| f.name == callee))
- }
-
- /// Consume the [`RetBind`] a single-value inlined tail return recorded,
- /// for a call in EXPRESSION position (embedded in arithmetic, a store
- /// RHS, a single-target match arm): there is no name to alias-bind, so an
- /// aliased return materializes into a plain cell (free for a var / an
- /// exp-0 g-address; one `MUL` for a shifted pointer). `dst` is the call's
- /// destination cell, already written by a real call or a plain-scalar
- /// return, so it is the fallback.
- pub(super) fn take_inline_ret_cell(&mut self, dst: Off) -> Off {
- match self.inline_stack_ret.take().and_then(|b| b.into_iter().next()) {
- Some(RetBind::Gaddr(ga)) => self.materialize(ga),
- Some(RetBind::Stack(base, size)) => {
- if size != 1 {
- self.fail("a multi-cell StackBuf return needs a `let` binding, not an expression use")
- };
- // The run's first cell: a single returned value sits there, and a
- // `let` consumer reaches the same one by binding the run
- // ([`ret_binding`]).
- base
- }
- _ => dst,
- }
- }
- /// The value a `Const` parameter takes, as the literal that substitutes for
- /// it: a `GEN ** k` argument stays a g-power, everything else must fold to a
- /// compile-time integer (a bound name, a const-array element `DEPTH[lvl]`,
- /// `len(...)`, index arithmetic over other `Const` params). `None` when it
- /// does not fold.
- fn const_arg(&self, a: &Expr) -> Option {
- Some(match a {
- Expr::Lit(n) => Expr::Lit(*n),
- Expr::Gen => Expr::GPow(1),
- Expr::GPow(k) => Expr::GPow(*k),
- other => Expr::Lit(self.try_const_index(other)? as u128),
- })
- }
-}
diff --git a/crates/lean_compiler/src/lower/eval.rs b/crates/lean_compiler/src/lower/eval.rs
deleted file mode 100644
index bd9cd6222..000000000
--- a/crates/lean_compiler/src/lower/eval.rs
+++ /dev/null
@@ -1,395 +0,0 @@
-//! Compile-time evaluation: what an expression is worth before anything runs.
-//!
-//! Queries emit no instructions and leave compiler state unchanged.
-//!
-//! There are two answers and the POSITION of a use picks one:
-//! [`FnLower::try_const_int`] for a size, an index, a bound or an exponent,
-//! [`FnLower::try_field_const`] for a value, where `+` is XOR. They disagree on
-//! a sum of overlapping integers, and `const(...)` is how an author says which
-//! was meant.
-
-use super::*;
-
-/// The readings of one expression: as many as its shape has. Produced by
-/// [`FnLower::eval`], which is the only walk that computes them.
-#[derive(Clone, Copy, Default)]
-pub(super) struct Known {
- /// The compile-time INTEGER, wanted by a size, an index, a bound, an exponent.
- pub(super) int: Option,
- /// The FIELD element a value position sees, where `+` is XOR.
- pub(super) field: Option,
- /// The ADDRESS the compiler tracks: a base cell times `g^exp`.
- pub(super) addr: Option,
-}
-
-impl Known {
- /// The integer and field readings when both exist and disagree.
- pub(super) fn diverging_readings(self) -> Option<(u128, F192)> {
- let (n, f) = (self.int?, self.field?);
- (f != lit_field(n)).then_some((n, f))
- }
-}
-
-/// `a·b` in the [`GAddr`] representation: exponents add, and at most one factor
-/// may carry a runtime base (two pointers can't be multiplied symbolically).
-fn gmul(a: GAddr, b: GAddr) -> Option {
- let base = match (a.base, b.base) {
- (None, x) | (x, None) => x,
- (Some(_), Some(_)) => return None,
- };
- Some(GAddr {
- base,
- exp: a.exp.checked_add(b.exp)?,
- // Only a based address carries a run, and at most one side is based, so
- // the shift keeps its origin: `addr(sb) * GEN ** k` stays bounded by `sb`.
- run: a.run.or(b.run),
- })
-}
-
-/// `b^k` by square-and-multiply, in logarithmically many field operations.
-pub(super) fn field_pow(b: F192, mut k: u32) -> F192 {
- let (mut acc, mut sq) = (F192::ONE, b);
- while k > 0 {
- if k & 1 == 1 {
- acc *= sq;
- }
- k >>= 1;
- if k > 0 {
- sq *= sq;
- }
- }
- acc
-}
-
-impl FnLower<'_> {
- /// Everything `e` is worth before anything runs, from ONE walk.
- ///
- /// An expression genuinely has more than one reading, and which is wanted
- /// depends on the POSITION of the use: `x = 2` names the integer 2, the field
- /// element 2, and the address `g^1`, all three at once. So the evaluator
- /// computes every reading a shape has and the caller takes the one its
- /// position means.
- ///
- /// Keeping the readings together lets each use reject an ambiguous value
- /// by comparing them, without evaluating the expression again.
- pub(super) fn eval(&self, e: &Expr) -> Known {
- // Deliberately NO address: only a LITERAL reads as one, and only under the
- // guard below. Attaching it here gave `const(2^k)` and `len(A)` an address
- // that `Expr::Lit` alone used to have, which slipped them past
- // `heap_addr`'s ambiguity guard: `buf[const(8)]` on a `HeapBuf(4)`
- // compiled and aliased cell 3, while the bare `buf[8]` it means was
- // rejected. One spelling naming two different cells is exactly what that
- // guard exists to stop.
- let int = |n: u128| Known {
- int: Some(n),
- field: Some(lit_field(n)),
- addr: None,
- };
- let gpow = |exp: u128| Known {
- int: None,
- field: Some(g_pow_u128(exp).into()),
- addr: Some(GAddr {
- base: None,
- exp,
- run: None,
- }),
- };
- match e {
- // `g = x`, so the literal `2^k` IS `g^k`, but ONLY while `k < 64`: at
- // and above it the modulus folds the monomial back into the low limb
- // while the literal's bit `k` lands in the next limb, the tower
- // coefficient of `y`. Without the guard the guest's own `Y_TOWER =
- // 2^64` would read as `g^64` in a pointer position.
- Expr::Lit(n) => Known {
- addr: (n.is_power_of_two() && *n < (1 << 64)).then(|| GAddr {
- base: None,
- exp: n.trailing_zeros() as u128,
- run: None,
- }),
- ..int(*n)
- },
- Expr::Gen => gpow(1),
- Expr::GPow(k) => gpow(*k),
- Expr::GenPow(x) => match self.eval(x).int.and_then(|n| u32::try_from(n).ok()) {
- Some(k) => gpow(u128::from(k)),
- None => Known::default(),
- },
- Expr::Var(v) => {
- let Some(b) = self.scope.bound(v) else {
- return Known::default();
- };
- let int = b.int;
- match b.val {
- Binding::FConst(c) => Known {
- int,
- field: Some(c),
- addr: None,
- },
- Binding::Gaddr(ga) => Known {
- int,
- // A constant g-power also reads as that field element.
- field: (ga.base.is_none()).then(|| g_pow_u128(ga.exp).into()),
- addr: Some(ga),
- },
- // A plain scalar is its own base, unshifted.
- Binding::Scalar(c) => Known {
- int,
- field: None,
- addr: Some(GAddr {
- base: Some(c),
- exp: 0,
- run: None,
- }),
- },
- Binding::Stack(..) => Known {
- int,
- ..Known::default()
- },
- }
- }
- // Each operand is evaluated ONCE: a reading per arm would re-walk the
- // subtree, which is exponential in the nesting depth.
- //
- // `+` is integer addition in an index and XOR in a value, so it has
- // both; `-`, `//` and `%` have no field meaning, so only the integer.
- Expr::Add(a, b) | Expr::Sub(a, b) | Expr::Mul(a, b) | Expr::Div(a, b) | Expr::Mod(a, b) => {
- let (x, y) = (self.eval(a), self.eval(b));
- if matches!(e, Expr::Div(..) | Expr::Mod(..)) && y.int == Some(0) {
- self.fail(match e {
- Expr::Div(..) => "compile-time division by zero",
- _ => "compile-time modulo by zero",
- })
- };
- let int = || {
- let (n, m) = (x.int?, y.int?);
- match e {
- Expr::Add(..) => n.checked_add(m),
- Expr::Sub(..) => n.checked_sub(m),
- Expr::Mul(..) => n.checked_mul(m),
- Expr::Div(..) => Some(n / m),
- _ => Some(n % m),
- }
- };
- Known {
- int: int(),
- field: match e {
- Expr::Add(..) => x.field.and_then(|f| Some(f + y.field?)),
- Expr::Mul(..) => x.field.and_then(|f| Some(f * y.field?)),
- _ => None,
- },
- addr: match e {
- Expr::Mul(..) => x.addr.and_then(|p| gmul(p, y.addr?)),
- _ => None,
- },
- }
- }
- Expr::Pow(b, x) => {
- let (base, exp) = (self.eval(b), self.eval(x).int.and_then(|n| u32::try_from(n).ok()));
- Known {
- int: base.int.and_then(|n| n.checked_pow(exp?)),
- field: base.field.and_then(|f| Some(field_pow(f, exp?))),
- addr: None,
- }
- }
- // A constant-array element, as a field value or as the integer those
- // bits spell.
- Expr::Index(..) => match self.const_array_elem(e) {
- Some(v) => Known {
- int: (v.c2 == 0).then_some(v.c0 as u128 | ((v.c1 as u128) << 64)),
- field: Some(v),
- addr: None,
- },
- None => Known::default(),
- },
- Expr::Call(f, args) if f == "f192" && args.len() == 3 => {
- let limb = |i: usize| match &args[i] {
- Expr::Lit(n) => u64::try_from(*n).ok(),
- _ => None,
- };
- Known {
- field: (|| Some(F192::new(limb(0)?, limb(1)?, limb(2)?)))(),
- ..Known::default()
- }
- }
- // `const(e)`: the one construct that asks for the INTEGER reading in a
- // position that would otherwise take the field one. It reinterprets the
- // OPERATORS, so its leaves must mean the same thing either way.
- Expr::Call(f, args) if f == "const" && args.len() == 1 => {
- self.check_const_leaves(&args[0]);
- match self.eval(&args[0]).int {
- Some(n) => int(n),
- None => Known::default(),
- }
- }
- Expr::Call(..) => match self.const_len(e) {
- Some(n) => int(n as u128),
- None => Known::default(),
- },
- _ => Known::default(),
- }
- }
-
- /// A compile-time integer. `None` means a runtime value, or arithmetic outside
- /// the source language's `u128` literal domain.
- pub(super) fn try_const_int(&self, e: &Expr) -> Option {
- self.eval(e).int
- }
-
- /// The address the compiler tracks for `e`: a base cell times `g^exp`.
- pub(super) fn gaddr_of(&self, e: &Expr) -> Option {
- self.eval(e).addr
- }
-
- /// `e` as a compile-time FIELD constant, where `+` is XOR. `None` for a
- /// runtime value or for arithmetic the field has no meaning for (`-`, `//`,
- /// `%`).
- pub(super) fn try_field_const(&self, e: &Expr) -> Option {
- self.eval(e).field
- }
-
- /// A compile-time integer index. The general integer evaluator is narrowed
- /// here so stack offsets, bounds, and immediate exponents remain `u32`.
- pub(super) fn try_const_index(&self, idx: &Expr) -> Option {
- u32::try_from(self.try_const_int(idx)?).ok()
- }
-
- /// A stack index or compile-time slice bound: [`Self::try_const_index`],
- /// required to succeed.
- pub(super) fn const_index(&self, idx: &Expr) -> u32 {
- let k = self.try_const_int(idx).unwrap_or_else(|| {
- self.fail(format!(
- "a StackBuf index must be a compile-time integer, got `{idx:?}`"
- ))
- });
- u32::try_from(k).unwrap_or_else(|_| self.fail(format!("stack index {k} does not fit in u32")))
- }
-
- /// The exponent of `GEN ** e`: a compile-time integer, required to succeed.
- pub(super) fn gpow_exp(&self, e: &Expr) -> u128 {
- self.try_const_index(e)
- .unwrap_or_else(|| self.fail(format!("`GEN ** e` needs a compile-time integer exponent, got `{e:?}`")))
- as u128
- }
-
- /// If `e` is `NAME[i]` for a top-level constant array `NAME` with a
- /// compile-time index `i`, its element (a raw `u128`).
- fn const_array_elem(&self, e: &Expr) -> Option {
- if let Expr::Index(arr, idx) = e
- && let Expr::Var(v) = arr.as_ref()
- && let Some(a) = self.const_arrays.get(v.as_str())
- {
- let i = self.try_const_index(idx)? as usize;
- return Some(
- *a.get(i).unwrap_or_else(|| {
- self.fail(format!("const array `{v}` index {i} out of bounds (len {})", a.len()))
- }),
- );
- }
- None
- }
-
- /// If `e` is `len(NAME)` for a top-level constant array `NAME`, its length.
- fn const_len(&self, e: &Expr) -> Option {
- if let Expr::Call(f, args) = e
- && f == "len"
- && args.len() == 1
- && let Expr::Var(v) = &args[0]
- {
- return self.const_arrays.get(v.as_str()).map(|a| a.len());
- }
- None
- }
-
- /// The surviving operand of `a + b` when the other is a compile-time zero,
- /// which contributes nothing and (being a constant) has no side effect to
- /// preserve. So `x + 0` lowers to just `x`: no cell, no XOR. Kills the
- /// `acc = 0; acc = acc + t` accumulator seed and similar.
- pub(super) fn add_identity<'e>(&self, a: &'e Expr, b: &'e Expr) -> Option<&'e Expr> {
- if self.try_field_const(a) == Some(F192::ZERO) {
- return Some(b);
- }
- (self.try_field_const(b) == Some(F192::ZERO)).then_some(a)
- }
-
- /// The surviving operand of `a * b` when the other is a compile-time one, a
- /// no-op multiply. Kills the `acc = GEN ** 0` (= 1) accumulator seed's first
- /// `1 * f` in every product loop.
- pub(super) fn mul_identity<'e>(&self, a: &'e Expr, b: &'e Expr) -> Option<&'e Expr> {
- if self.try_field_const(a) == Some(F192::ONE) {
- return Some(b);
- }
- (self.try_field_const(b) == Some(F192::ONE)).then_some(a)
- }
-
- /// The field value of `e` when it is a trivial compile-time constant (a
- /// literal, a literal-bound name, or `GEN ** 0`), for the `x*1`/`x+0`
- /// arithmetic identities and the `== 0` test of [`Self::lower_if`].
- pub(super) fn try_lit(&self, e: &Expr) -> Option {
- match e {
- Expr::Lit(n) => u64::try_from(*n).ok(),
- Expr::Var(v) => self.scope.int(v).and_then(|n| u64::try_from(n).ok()),
- Expr::GPow(0) => Some(1),
- _ => None,
- }
- }
-
- /// Check the LEAVES of a `const(...)`. The wrapper reinterprets the
- /// OPERATORS as integer arithmetic, which is its whole purpose, so their two
- /// readings are expected to diverge (`3 + 1` is the integer 4 and the value
- /// 2). A LEAF is different: `const(...)` cannot change what a name already
- /// stands for, so a leaf whose own two readings disagree would have the
- /// wrapper hand back a value that leaf never had.
- ///
- /// `n = 2 + 3` is the case. The cell holds `2 XOR 3` = 1 while the name's
- /// integer reading is 5, so `assert n == 1` and `assert const(n) == 5` both
- /// passed, in one program. This is the ambiguity `if const(...)` already
- /// rejects per side, one level up, and the rule is the same one.
- fn check_const_leaves(&self, e: &Expr) {
- match e {
- Expr::Add(a, b)
- | Expr::Sub(a, b)
- | Expr::Mul(a, b)
- | Expr::Div(a, b)
- | Expr::Mod(a, b)
- | Expr::Pow(a, b) => {
- self.check_const_leaves(a);
- self.check_const_leaves(b);
- }
- Expr::Call(f, args) if f == "const" && args.len() == 1 => self.check_const_leaves(&args[0]),
- leaf => {
- if let Some((n, f)) = self.eval(leaf).diverging_readings() {
- self.fail(format!(
- "const(...) reads its operators as integer arithmetic, but it cannot reinterpret \
- `{leaf:?}`, which is the integer {n} and the value {:#x}:{:#x}: two different \
- numbers. Bind it in one regime and name that one",
- f.c1, f.c0
- ))
- }
- }
- }
- }
-
- /// The exponent of `e` when it is a *constant* g-power small enough to ride a
- /// `DEREF` `β` immediate, for the constant factor of a product index.
- ///
- /// Folds `e` only where the readings agree: either the compiler tracks it as an
- /// address, or `e` is the integer `2^j` AND its field value is `g^j`, in
- /// which case both readings name cell `j` and folding decides nothing.
- pub(super) fn const_gpow(&self, e: &Expr) -> Option {
- let k = self.eval(e);
- if let Some(GAddr { base: None, exp, .. }) = k.addr
- && exp <= FOLD_MAX
- {
- return Some(exp as u32);
- }
- // `g = x`, so the integer `2^j` reads as `g^j`, but ONLY for `j < 64`:
- // at and above that the modulus folds the monomial back into the low
- // limb while the literal's bit lands in the tower coefficient of `y`.
- let n = k.int?;
- if !n.is_power_of_two() || n >= (1 << 64) {
- return None;
- }
- let j = n.trailing_zeros();
- (u128::from(j) <= FOLD_MAX && k.field? == g_pow_u128(u128::from(j)).into()).then_some(j)
- }
-}
diff --git a/crates/lean_compiler/src/lower/mem.rs b/crates/lean_compiler/src/lower/mem.rs
deleted file mode 100644
index a2f8875f3..000000000
--- a/crates/lean_compiler/src/lower/mem.rs
+++ /dev/null
@@ -1,294 +0,0 @@
-//! Addressing, and the store path: which cell a name means, and what a write to
-//! it costs. Two rules, both of which have been broken here before.
-//!
-//! **Every index is bounds-checked, in every position.** A store's right-hand
-//! side is an index as much as an expression is, and a slice checks its whole
-//! SPAN rather than its first cell.
-//!
-//! **A store always emits, and the machine decides what it means.** If the cell
-//! already holds a value the store is the write-once equality ASSERTION, which is
-//! what makes `s[k] = ` pin a hint; if it does not, the store is
-//! what gives the cell its value. The compiler tracks nothing to tell those
-//! apart, so there is no state here that could disagree with the machine.
-
-use super::*;
-
-impl FnLower<'_> {
- /// Resolve an expression naming a run of consecutive cells: a whole
- /// `StackBuf`, a `StackBuf` slice, a `HeapBuf` slice with compile-time
- /// bounds, or a runtime-start heap slice `buf[i:i + k]` (whose length is
- /// the only thing its bounds reveal). Heap runs fold the buffer's symbolic
- /// shift and the slice start into the pointer offset.
- pub(super) fn cell_run(&mut self, e: &Expr) -> CellRun {
- match e {
- Expr::Var(_) => {
- let (base, len) = self.stack_of(e).unwrap_or_else(|| {
- self.fail(format!(
- "only a StackBuf names a run of cells unsliced, got `{e:?}`; slice a \
- HeapBuf instead: `buf[lo:lo + k]`"
- ))
- });
- CellRun::Stack { base, len }
- }
- Expr::Slice(arr, lo, hi) => match (self.try_const_index(lo), self.try_const_index(hi)) {
- // Compile-time bounds: integer cell indexes `lo..hi` (frame
- // offsets for a stack, g-power exponents for the heap).
- (Some(lo), Some(hi)) => {
- if lo >= hi {
- self.fail(format!("empty slice {lo}:{hi}"))
- };
- let len = hi - lo;
- if let Some((base, size)) = self.stack_of(arr) {
- if hi > size {
- self.fail(format!("slice {lo}:{hi} out of bounds (StackBuf size {size})"))
- };
- CellRun::Stack { base: base + lo, len }
- } else {
- self.check_heap_bound(arr, lo as u128, len as u128);
- let (ptr, lo) = self.heap_base(arr, lo as u128);
- CellRun::Heap { ptr, lo, len }
- }
- }
- // Runtime start (heap only): `buf[i:i + k]` with a runtime
- // g-power index `i` names the cells `buf·i·g^j`, j < k. The
- // `hi` bound cannot be evaluated, only shape-checked against
- // `lo`. One MUL folds `i` into the pointer.
- _ => {
- if self.stack_of(arr).is_some() {
- self.fail(
- "a StackBuf slice needs compile-time bounds (frame offsets are baked into the bytecode)",
- )
- };
- let k = plus_k(lo, hi).unwrap_or_else(|| {
- self.fail(format!("a runtime slice must be `buf[i:i + k]`, got `{lo:?}:{hi:?}`"))
- });
- let len =
- u32::try_from(k).unwrap_or_else(|_| self.fail(format!("slice length {k} does not fit in u32")));
- if len == 0 {
- self.fail(format!(
- "a runtime slice `{lo:?}:{hi:?}` has length 0, so it names no cell"
- ))
- };
- // `heap_addr` bounds-checks ONE cell. A start that folds
- // (`GEN ** k`, or a name bound to one) reaches this arm because
- // it is not an INTEGER, yet its offset IS known, so the run's
- // length has to be checked here or it never is.
- if let Some(GAddr { base: None, exp, .. }) = self.gaddr_of(lo) {
- self.check_heap_bound(arr, exp, u128::from(len));
- }
- let (ptr, lo) = self.heap_addr(arr, lo);
- CellRun::Heap { ptr, lo, len }
- }
- },
- other => self.fail(format!(
- "expected a StackBuf, a StackBuf slice, or a HeapBuf slice, got `{other:?}`"
- )),
- }
- }
-
- /// Address `arr[idx]` as `(base_cell, β)`. A constant g-power `idx` folds
- /// into `β` ([`Self::heap_base`]); a runtime index materializes the pointer.
- pub(super) fn heap_addr(&mut self, arr: &Expr, idx: &Expr) -> (Off, u32) {
- // A compile-time index that is a plain field constant but NOT a
- // g-power (`buf[0]`, `buf[2]`, an integer unroll var) can never name
- // a heap cell (cell k lives at `buf · g^k`) and would deref a wild
- // address at proving time. Reject it here, where the source is known.
- // A BARE literal index is rejected even now that `gaddr_of` reads `2^k`
- // as `g^k` in a pointer: `hb[2]` would silently mean cell 1, while slice
- // bounds stayed integer (`hb[2:4]` starts at cell 2), so one spelling
- // would name two different cells. An index built from `GEN` is fine, and
- // `1` is `g^0` either way.
- let bare_int = matches!(self.try_lit(idx), Some(n) if n != 1);
- let known = self.eval(idx);
- if (bare_int || known.addr.is_none())
- && let Some(c) = known.field
- {
- // Two reasons reach here and they read differently. A bare integer
- // index may well BE a g-power (4 is g²), and is rejected for being
- // ambiguous against slice syntax rather than for naming nothing.
- let why = match self.const_gpow(idx) {
- Some(k) => format!(
- "is a plain integer naming cell {k}, while the slice `buf[n:n + 1]` reads the \
- same number as cell n. Write `buf[GEN ** {k}]` and say which you mean"
- ),
- None => format!(
- "folds to the field constant {:#x}:{:#x}, which is not a g-power, so it names \
- no heap cell (did an integer index leak in from a StackBuf conversion?)",
- c.c1, c.c0
- ),
- };
- self.fail(format!("heap index {why}"));
- }
- match known.addr {
- Some(GAddr { base: None, exp, .. }) => return self.heap_base(arr, exp),
- // A runtime-base index carrying a constant g-power shift
- // (`buf[cursor * GEN ** k]`): fold the whole constant part (the
- // index's shift plus `arr`'s own symbolic shift) into `β`, and
- // emit ONE pointer multiply instead of materializing g^k.
- Some(GAddr {
- base: Some(ib), exp, ..
- }) => {
- if let Some(ga) = self.gaddr_of(arr)
- && let (Some(ab), Some(total)) = (ga.base, ga.exp.checked_add(exp))
- && total <= FOLD_MAX
- {
- let ptr = self.pure(PureOp::Mul, ab, ib);
- return (ptr, total as u32);
- }
- }
- None => {}
- }
- // Fall back to the constant-g-power-factor fold (a runtime index still
- // materializes the pointer `MUL`, with any constant factor in `β`).
- self.array_ptr(arr, idx)
- }
-
- /// Compile-time bounds check: when `arr` resolves to a sized `HeapBuf`
- /// (directly or through shifted aliases) and the whole index is the
- /// compile-time exponent `exp`, reject `exp + span > size`. Runtime
- /// indices are not checked (their value is not known here).
- pub(super) fn check_heap_bound(&self, arr: &Expr, extra: u128, span: u128) {
- let Some(ga) = self.gaddr_of(arr) else { return };
- let (Some(base), Some(exp)) = (ga.base, ga.exp.checked_add(extra)) else {
- return;
- };
- // A frame pointer from `addr(sb)`: `exp` is an absolute frame offset and
- // `base` is the shared `fp` cell, so the run comes from the address's own
- // provenance rather than from `heap_sizes`.
- if let Some((start, len)) = ga.run {
- let (start, len) = (start as u128, len as u128);
- if exp < start || exp + span > start + len {
- let off = exp.saturating_sub(start); // `exp < start` is unreachable: gmul only adds
- let what = if span == 1 {
- format!("index {off}")
- } else {
- format!("slice {off}:{}", off + span)
- };
- self.fail(format!(
- "frame {what} out of bounds for the StackBuf({len}) named by `addr`"
- ));
- }
- return;
- }
- let Some(&size) = self.heap_sizes.get(&base) else {
- return;
- };
- if exp + span > size {
- // Several names can share a cell, so pick the first alphabetically
- // rather than the first the map happens to yield: the same program
- // must blame the same name on every build.
- let name = self
- .scope
- .names
- .iter()
- .filter(|(_, b)| matches!(b.val, Binding::Scalar(c) if c == base))
- .map(|(n, _)| n.as_str())
- .min()
- .unwrap_or("?");
- if span == 1 {
- self.fail(format!(
- "heap index {exp} out of bounds for `{name}` (HeapBuf size {size})"
- ));
- }
- self.fail(format!(
- "heap slice {exp}:{} out of bounds for `{name}` (HeapBuf size {size})",
- exp + span
- ));
- }
- }
-
- /// `addr(sb)`: the g-address `fp·g^base` of a `StackBuf`'s first cell, so a
- /// frame run can be pointed at (indexed at runtime, or handed to a callee)
- /// while its own accesses stay direct frame cells. Materializing `fp` is the
- /// ISA's one cost here, amortized per function ([`Self::self_fp`]); the
- /// address is a folded [`GAddr`], so `addr(sb) * GEN ** k` stays virtual.
- pub(super) fn stack_addr(&mut self, args: &[Expr]) -> GAddr {
- if args.len() != 1 {
- self.fail("addr(buf) takes one StackBuf")
- };
- let (base, len) = self.stack_of(&args[0]).unwrap_or_else(|| {
- self.fail(format!(
- "addr() names a frame run, so it takes a StackBuf, got `{:?}`",
- args[0]
- ))
- });
- GAddr {
- base: Some(self.self_fp()),
- exp: base as u128,
- run: Some((base, len)),
- }
- }
-
- /// Address `arr·g^extra` as `(base_cell, β)`, folding `arr`'s symbolic shift
- /// and the constant `extra` into `β`. Falls back to a materialized pointer
- /// (`β = 0`) when there is no runtime base or the offset exceeds [`FOLD_MAX`].
- fn heap_base(&mut self, arr: &Expr, extra: u128) -> (Off, u32) {
- self.check_heap_bound(arr, extra, 1);
- if let Some(ga) = self.gaddr_of(arr)
- && let (Some(base), Some(exp)) = (ga.base, ga.exp.checked_add(extra))
- && exp <= FOLD_MAX
- {
- return (base, exp as u32);
- }
- let a = self.expr(arr);
- if extra == 0 {
- return (a, 0);
- }
- let k = self.const_cell(g_pow_u128(extra).into());
- (self.pure(PureOp::Mul, a, k), 0)
- }
-
- /// Resolve a heap access `arr[idx]` to a `DEREF`-ready pair: a cell
- /// holding a pointer `p` and a compile-time exponent `o2`, the accessed
- /// cell being `m[p·g^o2]` (heap addressing in the exponent: cell `g^k`
- /// of the buffer sits at `arr·g^k`). The fallback of [`Self::heap_addr`],
- /// which has already folded away a wholly constant index: here a constant
- /// g-power *factor* still goes into the `o2` immediate, so only the
- /// runtime factor costs a pointer `MUL`.
- fn array_ptr(&mut self, arr: &Expr, idx: &Expr) -> (Off, u32) {
- // `buf[r * GEN ** k]` (either factor order): o2 takes the constant,
- // the pointer MUL takes only the runtime factor `r`.
- if let Expr::Mul(a, b) = idx {
- for (c, r) in [(a, b), (b, a)] {
- if let Some(k) = self.const_gpow(c) {
- let (la, lr) = (self.expr(arr), self.expr(r));
- return (self.pure(PureOp::Mul, la, lr), k);
- }
- }
- }
- let (la, li) = (self.expr(arr), self.expr(idx));
- (self.pure(PureOp::Mul, la, li), 0)
- }
-
- /// Realize a [`GAddr`] into a frame cell holding its value: a constant is one
- /// `SET`; a base with no shift is already that cell; a shifted base is a
- /// `SET`+`MUL`.
- pub(super) fn materialize(&mut self, ga: GAddr) -> Off {
- match ga {
- GAddr {
- base: Some(c), exp: 0, ..
- } => c,
- GAddr { base, exp, .. } => {
- let k = self.const_cell(g_pow_u128(exp).into());
- let Some(c) = base else { return k };
- self.pure(PureOp::Mul, c, k)
- }
- }
- }
-
- /// If `e` names a `StackBuf` variable, its `(base, size)`.
- pub(super) fn stack_of(&self, e: &Expr) -> Option<(Off, u32)> {
- match e {
- Expr::Var(v) => self.scope.stack(v),
- _ => None,
- }
- }
-
- /// Allocate `n` *consecutive* fresh frame cells (a stack run), returning the
- /// base. Nothing else may `fresh()` between them, so they stay adjacent.
- pub(super) fn alloc_stack(&mut self, n: u32) -> Off {
- let base = self.next;
- self.next += n;
- base
- }
-}
diff --git a/crates/lean_compiler/src/parser.rs b/crates/lean_compiler/src/parser.rs
deleted file mode 100644
index 892498938..000000000
--- a/crates/lean_compiler/src/parser.rs
+++ /dev/null
@@ -1,907 +0,0 @@
-//! Parser: a minimal indentation-based Python-like surface syntax → [`Ast`].
-//!
-//! This file holds the line-oriented part: the indentation structure, the
-//! statement forms, and the top level's global constants. The rest is split by
-//! the question it answers:
-//!
-//! - [`mod@expr`] reads structure out of a line, under one rule: a string
-//! literal is one opaque token, and every scan goes through `depth0`.
-//! - [`mod@consts`] evaluates a constant at parse time, which five syntactic
-//! positions demand before lowering ever runs. It reads a constant as an
-//! INTEGER, deliberately, which is what makes a derived size right.
-//! - [`mod@subst`] substitutes an expression for a name through a statement
-//! tree, which is how `unroll` and `Const` bind their variable.
-
-use super::*;
-use std::collections::BTreeMap;
-
-mod consts;
-mod expr;
-mod subst;
-pub use consts::parse_const;
-use consts::{apply_replacements, const_int_expr, eval_const_int, gpow_bound, parse_f192_const, parse_gpow_bound};
-use expr::{
- binding_name, call_args, is_ident, parse_expr, split_assign, split_aug, split_once_top, split_top, string_lit,
- strip_comment, strip_const_wrapper, top_level_cmp,
-};
-pub(crate) use subst::subst_stmts;
-use subst::subst_var;
-/// Parse zkDSL source (the Python-shaped surface syntax of `zkDSL.md`) into an
-/// [`Ast`]. The `snark_lib` import is skipped; any other import is an error,
-/// since a program is a single file.
-pub fn parse(src: &str) -> Result {
- parse_with_replacements(src, &BTreeMap::new())
-}
-
-/// Like [`parse`], but first substitutes compile-time **placeholders**: an
-/// identifier that is a key of `replacements` becomes its value everywhere it
-/// appears, which is how a host bakes sizes and flags into a program without
-/// editing it. The top level then peels off the **global constants**, each
-/// evaluated as a compile-time integer (or an `f192` literal / constant array)
-/// and substituted into the `def`s below. See the "Placeholders" and "Global
-/// constants" sections of `zkDSL.md`.
-pub fn parse_with_replacements(src: &str, replacements: &BTreeMap) -> Result {
- // Substitution runs over the RAW source, before lines are split, so a
- // replacement carrying a newline would shift every line after it and make
- // every diagnostic below name the wrong one. It would also inject statements
- // at whatever indentation it landed on. Reject it instead.
- // A `#` truncates the rest of the line just as silently, and changes the
- // compiled program with no diagnostic at all.
- // A `"` reshapes the line just as a `#` does, now that a string literal is one
- // opaque token: an odd number of them swallows the rest of the line.
- if let Some((k, _)) = replacements
- .iter()
- .find(|(_, v)| v.contains('\n') || v.contains('#') || v.contains('"'))
- {
- return Err(format!(
- "placeholder `{k}` contains a newline, `#` or `\"`, which would reshape the line"
- ));
- }
- let src = apply_replacements(src, replacements);
- // (source line, indent, content) for each significant line. The source line
- // is what every diagnostic below names; blanks, comments and imports are
- // skipped, so the index into this vector is NOT it.
- let mut lines: Vec = Vec::new();
- for (src_line, raw) in src.lines().enumerate() {
- let no_comment = strip_comment(raw);
- if no_comment.trim().is_empty() {
- continue;
- }
- let t = no_comment.trim();
- if let Some(rest) = t.strip_prefix("import ").or_else(|| t.strip_prefix("from ")) {
- let module = rest.split_whitespace().next().unwrap_or("");
- if module != "snark_lib" {
- return Err(locate(
- src_line + 1,
- format!("file imports are not supported (only the `snark_lib` stub): `{t}`"),
- ));
- }
- continue; // the stub is for Python tooling; the compiler skips it
- }
- let indent = no_comment.len() - no_comment.trim_start().len();
- lines.push(Line {
- src: src_line + 1,
- indent,
- text: no_comment.trim().to_string(),
- });
- }
- // Peel off the leading top-level constant declarations (before any `def`),
- // each evaluated to a field value and rendered as a single decimal literal.
- // Building a `name → literal` map lets later constants and the functions
- // reference them by plain text substitution, so a constant works even in
- // positions that demand a parse-time literal (`StackBuf`, `**`, `assert log
- // _ < _`).
- let mut consts: BTreeMap = BTreeMap::new();
- let mut const_arrays: Vec<(String, Vec)> = Vec::new();
- let mut start = 0;
- while start < lines.len() {
- let Line {
- src,
- indent,
- text: line,
- } = &lines[start];
- let at = |e: String| locate(*src, e);
- if *indent == 0 && (line.starts_with("def ") || line.starts_with('@')) {
- break;
- }
- if *indent != 0 {
- return Err(at(format!("unexpected indentation at top level: `{line}`")));
- }
- let (lhs, rhs) = split_assign(line).ok_or_else(|| {
- at(format!(
- "top level: expected `def`, a global constant `NAME = value`, or the `snark_lib` import, got `{line}`"
- ))
- })?;
- let name = lhs.trim().to_string();
- if !is_ident(&name) {
- return Err(at(format!(
- "global constant name must be a plain identifier: `{}`",
- lhs.trim()
- )));
- }
- if consts.contains_key(&name) || const_arrays.iter().any(|(n, _)| n == &name) {
- return Err(at(format!("global constant `{name}` is declared twice")));
- }
- // A scalar constant is substituted textually, so one named after a builtin
- // rewrites the builtin's own call sites: `match = 4` turned
- // `v = match(log(x), …)` into `4(log(x), …)`, whose diagnostic names
- // neither the constant nor `match`.
- if BUILTINS.contains(&name.as_str()) {
- return Err(at(format!(
- "`{name}` is a builtin, so a global constant of that name would be substituted \
- into its own call sites. Rename it"
- )));
- }
- // Resolve earlier scalar constants inside the value first.
- let rhs = apply_replacements(rhs.trim(), &consts);
- let rhs = rhs.trim();
- if let Some(inner) = rhs.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
- // A constant array `NAME = [a, b, c]`: each element a compile-time
- // integer / field value. Not textually substituted, but carried to
- // lowering, indexed/measured there.
- let mut elems = Vec::new();
- for part in split_top(inner, ',') {
- let p = part.trim();
- if p.is_empty() {
- continue; // tolerate a trailing comma
- }
- let elem = if let Some(v) = parse_f192_const(p) {
- v.map_err(|e| at(format!("global constant array `{name}`: {e}")))?
- } else {
- let n = eval_const_int(p).map_err(|e| at(format!("global constant array `{name}`: {e}")))?;
- F192::new(n as u64, (n >> 64) as u64, 0)
- };
- elems.push(elem);
- }
- const_arrays.push((name, elems));
- } else {
- // A scalar constant: an `f192` literal, else a compile-time integer,
- // else a field-valued expression.
- if let Some(value) = parse_f192_const(rhs) {
- let v = value.map_err(|e| at(format!("global constant `{name}`: {e}")))?;
- consts.insert(name, format!("f192({},{},{})", v.c0, v.c1, v.c2));
- } else if let Ok(value) = eval_const_int(rhs) {
- consts.insert(name, value.to_string());
- } else {
- // `GEN ** 2` and friends. The ISA is written in g-powers, so this
- // is the natural spelling for a constant one, and it is not an
- // integer expression. Rendered as a decimal wherever the value
- // fits the low two limbs, so the constant still works in the
- // positions that demand a literal rather than only as a value.
- let v = parse_const(rhs).map_err(|e| at(format!("global constant `{name}`: {e}")))?;
- consts.insert(
- name,
- if v.c2 == 0 {
- (v.c0 as u128 | ((v.c1 as u128) << 64)).to_string()
- } else {
- format!("f192({},{},{})", v.c0, v.c1, v.c2)
- },
- );
- }
- }
- start += 1;
- }
- // Substitute the constants into every remaining (function) line, then parse.
- let func_lines: Vec = lines[start..]
- .iter()
- .map(|l| Line {
- src: l.src,
- indent: l.indent,
- text: apply_replacements(&l.text, &consts),
- })
- .collect();
- let mut p = Parser {
- lines: &func_lines,
- i: 0,
- };
- let mut funcs = Vec::new();
- while p.i < p.lines.len() {
- funcs.push(p.func()?);
- }
- // Two names that used to be accepted and then silently picked a winner.
- // A repeated `def` lowered both bodies and kept the last, and a name
- // beginning with `__` can collide with a compiler-generated one: a loop
- // helper is `__loopN`, and a `Const` specialization of `f` is `f__L1`, so a
- // user function called `f__L1` took the specialization's place and the call
- // ran the wrong body.
- for (i, f) in funcs.iter().enumerate() {
- if funcs[..i].iter().any(|g| g.name == f.name) {
- return Err(format!("function `{}` is defined twice", f.name));
- }
- if f.name.contains("__") {
- return Err(format!(
- "function name `{}` may not contain `__`, which is reserved for the names the \
- compiler generates (a loop helper, a `Const` specialization)",
- f.name
- ));
- }
- // A builtin wins at the call site, so a function with a builtin's name is
- // never called and its body, constraints included, silently disappears.
- // `def const(x): assert x == 99` was skipped outright by `v = const(4)`,
- // and by whether the ARGUMENT folded, so one call site had two meanings.
- if BUILTINS.contains(&f.name.as_str()) {
- return Err(format!(
- "`{}` is a builtin, so a function of that name could never be called: \
- the builtin takes every call site. Rename it",
- f.name
- ));
- }
- }
- infer_return_shapes(&mut funcs)?;
- Ok(Ast { funcs, const_arrays })
-}
-
-/// Every name the lowerer resolves before it looks for a user function. A `def`
-/// may not take one of these, since the builtin would win and the body would be
-/// dead code that still looked live.
-const BUILTINS: &[&str] = &[
- "addr",
- "assert_in_k",
- "blake2s",
- "const",
- "f192",
- "hint_decompose_bits",
- "hint_decompose_bits_exponent",
- "hint_f192_limbs",
- "hint_log2_ceil",
- "hint_witness",
- "len",
- "match",
- "HeapBuf",
- "StackBuf",
-];
-
-/// Infer the compile-time representation of each tail-return value: a `StackBuf`
-/// carries its static size, a `HeapBuf` stays a one-cell pointer (its allocation
-/// hint ran in the creating function). Iterated to a fixed point, so a wrapper
-/// may return a buffer produced by a function declared later.
-fn infer_return_shapes(funcs: &mut [Func]) -> Result<(), String> {
- fn expr_shape(
- e: &Expr,
- locals: &HashMap<&str, Shape>,
- known: &HashMap>,
- ) -> Result {
- let fits = |n: u64| u32::try_from(n).map_err(|_| format!("StackBuf size {n} does not fit in u32"));
- Ok(match e {
- Expr::Var(v) => locals.get(v.as_str()).copied().unwrap_or(Shape::Scalar),
- Expr::StackBuf(n) => Shape::StackBuf(fits(*n)?),
- Expr::ListLit(es) => Shape::StackBuf(fits(es.len() as u64)?),
- Expr::Call(f, _) => known
- .get(f)
- .filter(|r| r.len() == 1)
- .and_then(|r| r.first())
- .copied()
- .unwrap_or(Shape::Scalar),
- _ => Shape::Scalar,
- })
- }
-
- fn scan(
- body: &[Stmt],
- params: &[Param],
- known: &HashMap>,
- n_ret: usize,
- ) -> Result, String> {
- // Seeded from the DECLARED shapes: a `s: StackBuf(n)` parameter is a run
- // here as much as a local one is, so `return s` returns the run rather
- // than reporting it used as a scalar.
- let mut locals: HashMap<&str, Shape> = params.iter().map(|p| (p.name.as_str(), p.shape())).collect();
- let mut returns = vec![Shape::Scalar; n_ret];
- for stmt in body {
- match &stmt.kind {
- StmtKind::Let(name, e) => {
- let shape = expr_shape(e, &locals, known)?;
- locals.insert(name.as_str(), shape);
- }
- StmtKind::LetTuple(names, f, _) => {
- let shapes = known.get(f);
- for (i, name) in names.iter().enumerate() {
- let shape = shapes.and_then(|s| s.get(i)).copied().unwrap_or(Shape::Scalar);
- locals.insert(name.as_str(), shape);
- }
- }
- // `unroll` is straight-line expansion, so a binding in its last
- // copy remains visible afterward. One symbolic scan is enough
- // for representation shapes (the iteration value is scalar).
- StmtKind::Unroll { var, body, .. } => {
- locals.insert(var.as_str(), Shape::Scalar);
- for inner in body {
- if let StmtKind::Let(name, e) = &inner.kind {
- let shape = expr_shape(e, &locals, known)?;
- locals.insert(name.as_str(), shape);
- }
- }
- }
- StmtKind::LetHintWitness { name, .. } => {
- locals.insert(name.as_str(), Shape::Scalar);
- }
- StmtKind::Return(es) => {
- returns = es
- .iter()
- .map(|e| expr_shape(e, &locals, known))
- .collect::>()?;
- }
- _ => {}
- }
- }
- Ok(returns)
- }
-
- let mut known: HashMap> = funcs
- .iter()
- .map(|f| (f.name.clone(), f.return_shapes.clone()))
- .collect();
- // A shape can only move from Scalar to one of the finite constructor
- // shapes (or acquire one through a call), so `funcs.len() + 1` rounds are
- // sufficient for the longest acyclic wrapper chain.
- for _ in 0..=funcs.len() {
- let next: HashMap> = funcs
- .iter()
- .map(|f| Ok((f.name.clone(), scan(&f.body, &f.params, &known, f.return_shapes.len())?)))
- .collect::>()?;
- if next == known {
- break;
- }
- known = next;
- }
- for f in funcs {
- f.return_shapes = known
- .remove(&f.name)
- .expect("every function has inferred return shapes");
- }
- Ok(())
-}
-
-/// One significant source line: its 1-based position in the ORIGINAL file, its
-/// indentation, and its text with comments stripped and constants substituted.
-/// Prefix a diagnostic with the source line it came from, unless an inner frame
-/// already named a more specific one.
-/// Does the leading call span the WHOLE of `s`?
-///
-/// `call_args` strips the first `(` and the LAST `)`, so it also matches a line
-/// where the call is only the first factor: `hint_witness("a") * f("b")` came
-/// back with the stream name `a") * f("b`, and the rest of the line vanished.
-/// A `)` that closes below depth zero means the call ended before the line did.
-fn whole_call(s: &str) -> bool {
- let Some(inner) = s.find('(').map(|i| &s[i + 1..]) else {
- return false;
- };
- let (mut depth, mut in_str) = (0i32, false);
- for (i, c) in inner.bytes().enumerate() {
- if in_str {
- in_str = c != b'"';
- continue;
- }
- match c {
- b'"' => in_str = true,
- b'(' | b'[' => depth += 1,
- b')' | b']' => {
- depth -= 1;
- // The close that matches the call's own `(`. It has to be the
- // last thing on the line, or something followed the call.
- if depth < 0 {
- return i + 1 == inner.len();
- }
- }
- _ => {}
- }
- }
- false
-}
-
-fn locate(line: usize, e: String) -> String {
- if e.starts_with("line ") {
- e
- } else {
- format!("line {line}: {e}")
- }
-}
-
-struct Line {
- src: usize,
- indent: usize,
- text: String,
-}
-
-struct Parser<'a> {
- lines: &'a [Line],
- i: usize,
-}
-
-impl Parser<'_> {
- /// The source line the cursor is on, for a diagnostic raised where no
- /// `func`/`stmt` frame is open: those two stamp the line they were ENTERED
- /// on, which is an enclosing header, not the line that is actually wrong.
- fn here(&self) -> usize {
- self.lines
- .get(self.i)
- .or_else(|| self.lines.last())
- .map_or(0, |l| l.src)
- }
-
- fn func(&mut self) -> Result {
- let here = self.lines.get(self.i).map_or(0, |l| l.src);
- self.func_inner().map_err(|e| locate(here, e))
- }
-
- fn func_inner(&mut self) -> Result {
- let mut line = &self.lines[self.i];
- // Optional `@inline` decorator on its own line before `def`.
- let inline = if let Some(dec) = line.text.strip_prefix('@') {
- if dec.trim() != "inline" {
- return Err(format!("unknown decorator `@{}` (only `@inline`)", dec.trim()));
- }
- self.i += 1;
- line = self.lines.get(self.i).ok_or("`@inline` must precede a `def`")?;
- true
- } else {
- false
- };
- // Re-stamped here: the decorator path advanced past its own line, so
- // `func`'s frame would name the `@inline` while quoting the `def`.
- let at_def = self.here();
- self.func_header(inline, line.indent, &line.text)
- .map_err(|e| locate(at_def, e))
- }
-
- fn func_header(&mut self, inline: bool, indent: usize, line: &str) -> Result {
- let header = line
- .strip_prefix("def ")
- .ok_or_else(|| format!("expected `def`, got `{line}`"))?;
- let header = header.strip_suffix(':').ok_or("function header needs `:`")?;
- let open = header.find('(').ok_or("function header needs `(`")?;
- let name = header[..open].trim().to_string();
- let params_str = header[open + 1..header.rfind(')').ok_or("missing `)`")?].trim();
- let mut params = Vec::new();
- if !params_str.is_empty() {
- for part in params_str.split(',') {
- if part.trim().is_empty() {
- return Err(format!("`def {name}`: empty parameter (a trailing comma?)"));
- }
- // `x`, `x: Const` (compile-time, specialized), or
- // `x: StackBuf(n)` (a run of n cells, passed whole).
- let (param_name, annotation) = part.split_once(':').map_or((part, None), |(n, a)| (n, Some(a.trim())));
- let kind = match annotation {
- None => ParamKind::Runtime(Shape::Scalar),
- Some("Const") => ParamKind::Const,
- Some(ann) => {
- let size = ann.strip_prefix("StackBuf(").and_then(|r| r.strip_suffix(')')).ok_or_else(|| {
- format!("unsupported parameter annotation `{ann}` (`Const`, or `StackBuf(n)` to pass a run of cells)")
- })?;
- let k =
- eval_const_int(size).map_err(|e| format!("`def {name}`: StackBuf parameter size: {e}"))?;
- let k = u32::try_from(k).map_err(|_| format!("`def {name}`: StackBuf({k}) is too large"))?;
- if k == 0 {
- return Err(format!("`def {name}`: a StackBuf parameter needs at least one cell"));
- }
- ParamKind::Runtime(Shape::StackBuf(k))
- }
- };
- params.push(Param {
- name: binding_name(param_name, "parameter name")?,
- kind,
- });
- }
- }
- if let Some(dup) = params
- .iter()
- .enumerate()
- .find_map(|(i, p)| params[..i].iter().any(|q| q.name == p.name).then_some(&p.name))
- {
- return Err(format!("parameter `{dup}` is declared twice"));
- }
- self.i += 1;
- let body = self.block(indent)?;
- let n_ret = body
- .iter()
- .filter_map(|s| {
- if let StmtKind::Return(es) = &s.kind {
- Some(es.len())
- } else {
- None
- }
- })
- .max()
- .unwrap_or(0);
- Ok(Func {
- name,
- params,
- return_shapes: vec![Shape::Scalar; n_ret],
- body,
- inline,
- })
- }
-
- /// Parse a block: all statements indented strictly more than `parent`.
- fn block(&mut self, parent: usize) -> Result, String> {
- let mut stmts = Vec::new();
- let block_indent = match self.lines.get(self.i) {
- Some(Line { indent: ind, .. }) if *ind > parent => *ind,
- _ => return Err(locate(self.here(), "expected an indented block".into())),
- };
- while let Some(Line { indent: ind, .. }) = self.lines.get(self.i) {
- if *ind != block_indent {
- if *ind > parent && *ind > block_indent {
- return Err(locate(self.here(), "inconsistent indentation".into()));
- }
- break;
- }
- stmts.push(self.stmt(block_indent)?);
- }
- Ok(stmts)
- }
-
- fn stmt(&mut self, indent: usize) -> Result {
- let here = self.lines.get(self.i).map_or(0, |l| l.src);
- self.stmt_inner(indent)
- .map(|kind| Stmt::new(here as u32, kind))
- .map_err(|e| locate(here, e))
- }
-
- fn stmt_inner(&mut self, indent: usize) -> Result {
- let line = self.lines[self.i].text.as_str();
- if let Some(rest) = line.strip_prefix("for ") {
- // for VAR in mul_range(START, STOP): the counter walks gᵏ from START
- // to STOP, ×g each iteration (STOP is exclusive). Bounds are field
- // elements (powers of GEN), so the multiplicative walk is explicit.
- let rest = rest.strip_suffix(':').ok_or("`for` needs `:`")?;
- let (var, iter) = rest.split_once(" in ").ok_or("`for` needs `in`")?;
- // `for i in unroll(a, b):` is compile-time replication; the bounds
- // are integer expressions, evaluated at lowering (so a `Const`
- // parameter works as a bound).
- if let Some(parts) = call_args(iter, "unroll") {
- if parts.len() != 2 {
- return Err("unroll needs `a, b` (compile-time integers)".into());
- }
- let (lo, hi) = (parse_expr(parts[0])?, parse_expr(parts[1])?);
- self.i += 1;
- let body = self.block(indent)?;
- return Ok(StmtKind::Unroll {
- var: binding_name(var, "loop counter")?,
- lo,
- hi,
- body,
- });
- }
- let parts = call_args(iter, "mul_range").ok_or("`for` needs `mul_range(start, stop)` or `unroll(a, b)`")?;
- if parts.len() != 2 {
- return Err("mul_range needs `start, stop`".into());
- }
- let lo = parse_gpow_bound(parts[0])?;
- // The stop bound: a compile-time power of GEN, or any expression,
- // a runtime g-power element the walk must be able to reach.
- let hi = match parse_gpow_bound(parts[1]) {
- Ok(hi) => {
- if lo > hi {
- return Err(format!("mul_range: start GEN**{lo} must not exceed stop GEN**{hi}"));
- }
- ForBound::Const(hi)
- }
- Err(_) => {
- let stop = parse_expr(parts[1])?;
- // A compile-time value that is not a power of GEN can never be
- // REACHED: the counter walks by multiplication and exits on
- // equality, so the loop runs forever at witness generation with
- // no diagnostic. The `lo` side has always been checked, which
- // made `mul_range(0, GEN ** 3)` a clean parse error while
- // `mul_range(1, 10)` was a hang.
- // A power of two reached `gpow_bound` above, so a literal here
- // is one the multiplicative walk can never hit. This catches a
- // bare `10` and a constant that substitutes to one; a value
- // built by arithmetic (`5 * 2`) still slips through to the
- // runtime path and still hangs, which wants a field-level
- // constant folder the parser does not have.
- if let Expr::Lit(n) = stop {
- return Err(format!(
- "mul_range stop bound `{n}` is not a power of GEN, so the multiplicative walk \
- never reaches it: write `GEN ** k`"
- ));
- }
- ForBound::Runtime(stop)
- }
- };
- self.i += 1;
- let body = self.block(indent)?;
- return Ok(StmtKind::For {
- var: binding_name(var, "loop counter")?,
- lo,
- hi,
- body,
- });
- }
- if let Some(rest) = line.strip_prefix("if ") {
- return self.if_stmt(rest, indent);
- }
- self.i += 1;
- if line == "return" {
- return Ok(StmtKind::Return(vec![]));
- }
- if let Some(rest) = line.strip_prefix("return ") {
- return Ok(StmtKind::Return(
- split_top(rest, ',')
- .iter()
- .map(|e| parse_expr(e))
- .collect::>()?,
- ));
- }
- // `print(expr)` / `print("label", expr)`: prover-side debug print;
- // the label defaults to the argument's source text.
- if let Some(parts) = call_args(line, "print") {
- let (label, value) = match parts.as_slice() {
- [l, v] if l.trim().starts_with('"') => {
- let l = string_lit(l).ok_or("print's label is a string literal: print(\"label\", expr)")?;
- (l.to_string(), v.trim())
- }
- [v] => (v.trim().to_string(), v.trim()),
- _ => return Err("print takes `print(expr)` or `print(\"label\", expr)`".into()),
- };
- return Ok(StmtKind::Print {
- label,
- value: parse_expr(value)?,
- });
- }
- // `hint_witness(dest, "name")`: the string literal is not an
- // expression; parsed here. `whole_call` for the same reason as the
- // scalar form: the string arg is what lets a trailing `* f("b")`
- // vanish into the stream name instead of failing to parse.
- if let Some(parts) = call_args(line, "hint_witness").filter(|_| whole_call(line)) {
- let [dest, name] = parts.as_slice() else {
- return Err("hint_witness(dest, \"name\") takes two arguments".into());
- };
- let name = string_lit(name).ok_or("hint_witness's second argument is a string literal: \"name\"")?;
- return Ok(StmtKind::HintWitness {
- dest: parse_expr(dest)?,
- name: name.to_string(),
- });
- }
- if let Some(rest) = line.strip_prefix("assert ") {
- if let Some((a, b)) = split_once_top(rest, "==") {
- return Ok(StmtKind::AssertEq(parse_expr(a)?, parse_expr(b)?));
- }
- if let Some((a, b)) = split_once_top(rest, "!=") {
- return Ok(StmtKind::AssertNe(parse_expr(a)?, parse_expr(b)?));
- }
- // `assert log X < log Y` (`Y` a compile-time g-power, or any runtime
- // g-power) or `assert log X < k` (`k` an integer exponent) is a
- // range check in the exponent: proves `log_g(X) < k`.
- if let Some((a, b)) = split_once_top(rest, "<") {
- let x =
- strip_log(a).ok_or("a `<` assert compares logs: `assert log X < log Y` or `assert log X < k`")?;
- let bound = match strip_log(b) {
- // `log GEN ** k = k` when the bound folds to a power of GEN;
- // otherwise it is a runtime g-power and the gadget derives
- // `g^{k-1}` from it. A bound that folds to something else is a
- // mistake rather than a runtime bound: `log 8` names the field
- // element 8, whose g-log is nothing in particular, and taking it
- // for a bound would fail only at witness generation.
- Some(y) => {
- let y = parse_expr(y)?;
- match gpow_bound(&y) {
- Ok(k) => LtBound::Const(k),
- Err(e) if const_int_expr(&y).is_some() => return Err(e),
- Err(_) => LtBound::Runtime(y),
- }
- }
- // An integer bound folds like any parse-time size (`CAP + 1`).
- None => match const_int_expr(&parse_expr(b)?) {
- Some(k) => {
- LtBound::Const(u64::try_from(k).map_err(|_| format!("log bound {k} does not fit in u64"))?)
- }
- None => {
- return Err(format!(
- "a log bound must be `log _` or a parse-time integer, got `{b}`"
- ));
- }
- },
- };
- return Ok(StmtKind::AssertLt(parse_expr(x)?, bound));
- }
- return Err("`assert` needs `==`, `!=`, or `log _ < _`".into());
- }
- // Augmented assignment `x OP= rhs` (Python `*=`, `+=`, `//=`, `%=`,
- // `-=`) desugars to `x = x OP (rhs)`.
- let expanded = split_aug(line)?.map(|(lhs, op, rhs)| format!("{lhs} = {lhs} {op} ({rhs})"));
- let line = expanded.as_deref().unwrap_or(line);
- // Assignment or bare call.
- if let Some((lhs, rhs)) = split_assign(line) {
- // `names = match(…)` carries lambdas, which `parse_expr`
- // does not speak, so it gets its own parser.
- if rhs.trim_start().starts_with("match(") {
- return parse_match(lhs, rhs);
- }
- // `x = hint_witness("stream")`: one hinted value, no buffer. The
- // string is not an expression, so like the run form it is parsed
- // here rather than by `parse_expr`.
- if let Some(parts) = call_args(rhs.trim(), "hint_witness").filter(|_| whole_call(rhs.trim())) {
- let [stream] = parts.as_slice() else {
- return Err(
- "`x = hint_witness(\"stream\")` takes one argument; to fill a run, write \
- `hint_witness(dest, \"stream\")` as a statement"
- .into(),
- );
- };
- let stream = string_lit(stream).ok_or("hint_witness's argument is a string literal: \"stream\"")?;
- return Ok(StmtKind::LetHintWitness {
- name: binding_name(lhs, "binding name")?,
- stream: stream.to_string(),
- });
- }
- let rhs_expr = parse_expr(rhs)?;
- // Indexed LHS `arr[idx] = value` is a heap store.
- if lhs.trim_end().ends_with(']') {
- let lhs = lhs.trim();
- let open = lhs.find('[').ok_or("malformed store target")?;
- let arr = parse_expr(&lhs[..open])?;
- let idx = parse_expr(&lhs[open + 1..lhs.len() - 1])?;
- return Ok(StmtKind::Store(arr, idx, rhs_expr));
- }
- let targets = split_top(lhs, ',');
- if targets.len() == 1 {
- return Ok(StmtKind::Let(binding_name(targets[0], "binding name")?, rhs_expr));
- }
- // Tuple assignment: RHS must be a call.
- if let Expr::Call(f, args) = rhs_expr {
- let names = targets
- .iter()
- .map(|t| binding_name(t, "binding name"))
- .collect::, _>>()?;
- return Ok(StmtKind::LetTuple(names, f, args));
- }
- return Err("tuple assignment requires a call on the right".into());
- }
- // A bare comparison is not a statement, and `split_assign` used to split
- // one on its `=` into a binding named `x !`. In a verifier that is an
- // `assert` compiled to nothing, so name the fix rather than letting the
- // expression parser fail on an operator it does not have.
- // `while`/`elif`/`else` reach here as unknown keywords, not comparisons, so
- // suggesting `assert while x < y:` would be worse than the generic error.
- let keyword = ["while ", "elif ", "else", "for ", "def "]
- .iter()
- .any(|k| line.starts_with(k));
- if let Some(op) = top_level_cmp(line).filter(|_| !keyword) {
- let fix = if matches!(op, "==" | "!=") {
- format!("write `assert {line}`")
- } else {
- format!("`{op}` is not a predicate: order facts come from `assert log x < k`")
- };
- return Err(format!("`{line}` is a comparison, not a statement: {fix}"));
- }
- // Bare call statement.
- if let Expr::Call(f, args) = parse_expr(line)? {
- return Ok(StmtKind::Call(f, args));
- }
- Err(format!("statement has no effect: `{line}`"))
- }
-
- /// `if a == b:` / `if a != b:` (the current line, its `if `/`elif `
- /// prefix already stripped into `header`), with an optional `elif`/`else`
- /// tail at the same indent (an `elif` is sugar for an `else` holding a
- /// nested `if`).
- fn if_stmt(&mut self, header: &str, indent: usize) -> Result {
- let cond = header.strip_suffix(':').ok_or("`if` needs `:`")?;
- let (cond, force_const) = match strip_const_wrapper(cond) {
- Some(inner) => (inner, true),
- // A near miss (`const (a == b)`, an unbalanced one) otherwise falls
- // through to an ordinary parse error that never says the word, so name
- // it here. Two things are NOT near misses: `const == 4`, a variable
- // that happens to be called `const`, since nothing follows the name but
- // the comparison; and a condition with a comparison of its own, since
- // `const(...)` is a value expression too, so `if const(k) == n:` is an
- // ordinary runtime test of a folded literal and rejecting it made the
- // two operand orders behave differently.
- None if top_level_cmp(cond).is_none()
- && cond
- .trim_start()
- .strip_prefix("const")
- .is_some_and(|r| r.trim_start().starts_with('(')) =>
- {
- return Err(
- "`const(...)` must wrap the WHOLE condition and balance its brackets: `if const(a == b):`".into(),
- );
- }
- None => (cond, false),
- };
- let (eq, l, r) = if let Some((l, r)) = split_once_top(cond, "==") {
- (true, l, r)
- } else if let Some((l, r)) = split_once_top(cond, "!=") {
- (false, l, r)
- } else {
- return Err("an `if` condition must be `a == b` or `a != b`".into());
- };
- let (lhs, rhs) = (parse_expr(l)?, parse_expr(r)?);
- self.i += 1;
- let then = self.block(indent)?;
- let mut els = Vec::new();
- if let Some(Line {
- indent: ind,
- text: line,
- ..
- }) = self.lines.get(self.i)
- && *ind == indent
- {
- if line == "else:" {
- self.i += 1;
- els = self.block(indent)?;
- } else if let Some(rest) = line.strip_prefix("elif ") {
- // `if_stmt` recurses into ITSELF for an `elif`, so no `stmt`
- // frame opens and the whole chain would report the first `if`.
- let at_elif = self.here();
- els = vec![Stmt::new(
- at_elif as u32,
- self.if_stmt(rest, indent).map_err(|e| locate(at_elif, e))?,
- )];
- }
- }
- Ok(StmtKind::If {
- eq,
- lhs,
- rhs,
- then,
- els,
- force_const,
- })
- }
-}
-
-type Aug<'a> = Option<(&'a str, &'static str, &'a str)>;
-
-/// Strip a leading `log` token (`log x`, `log(x)`), if present. The token must
-/// end at a boundary, so a variable named `logx` is not a log of `x`.
-fn strip_log(s: &str) -> Option<&str> {
- let r = s.trim_start().strip_prefix("log")?;
- r.starts_with([' ', '(']).then_some(r)
-}
-
-/// `names = match(log(x), range(a, b), lambda i: expr, …)`: leanVM's
-/// `match`, expanded at parse time: one arm per integer of the
-/// contiguous `(range, lambda)` pairs, arm `j` being the lambda body with the
-/// parameter substituted by the literal `j`. The union of the ranges must be
-/// gapless and start at 0 (this compiler's `match` rule). Everything sits on
-/// one line, since there is no line continuation.
-fn parse_match(lhs: &str, rhs: &str) -> Result {
- // A target is a name, or a `StackBuf` element, which the arms then write
- // into directly: the ABI already returns into cells the caller picks, so
- // `sb[i], e = match(…)` costs no copy where a name plus a store did.
- let targets = split_top(lhs, ',')
- .iter()
- .map(|t| match parse_expr(t)? {
- e @ (Expr::Var(_) | Expr::Index(..)) => Ok(e),
- other => Err(format!(
- "a `match` target must be a name or a StackBuf element, got `{other:?}`"
- )),
- })
- .collect::, _>>()?;
- let chunks = call_args(rhs, "match").ok_or("malformed `match(…)`")?;
- let (first, pairs) = chunks.split_first().ok_or("match needs arguments")?;
- let x = strip_log(first).ok_or("`match` matches logs: `match(log(x), …)`")?;
- let x = parse_expr(x)?;
- if pairs.is_empty() || !pairs.len().is_multiple_of(2) {
- return Err("match needs `range(a, b), lambda i: …` pairs after the scrutinee".into());
- }
- let mut arms = Vec::new();
- for pair in pairs.chunks(2) {
- let (lo, hi) = match parse_expr(pair[0])? {
- Expr::Call(f, args) if f == "range" => match args.as_slice() {
- [Expr::Lit(a), Expr::Lit(b)] if a < b => (*a, *b),
- _ => return Err("match needs `range(a, b)` with integer literals, a < b".into()),
- },
- other => return Err(format!("expected `range(a, b)`, got `{other:?}`")),
- };
- if lo != arms.len() as u128 {
- return Err(format!(
- "match ranges must be contiguous from 0: expected a range starting at {}, got {lo}",
- arms.len()
- ));
- }
- let lam = pair[1]
- .trim()
- .strip_prefix("lambda ")
- .ok_or("expected `lambda i: …` after each range")?;
- let (param, body) = split_once_top(lam, ":").ok_or("`lambda` needs `:`")?;
- let body = parse_expr(body)?;
- for j in lo..hi {
- arms.push(subst_var(&body, param.trim(), &Expr::Lit(j)));
- }
- }
- Ok(StmtKind::Match { targets, x, arms })
-}
diff --git a/crates/lean_compiler/src/parser/consts.rs b/crates/lean_compiler/src/parser/consts.rs
deleted file mode 100644
index ebaa3d046..000000000
--- a/crates/lean_compiler/src/parser/consts.rs
+++ /dev/null
@@ -1,148 +0,0 @@
-//! Evaluating a constant at parse time, and the one substitution that still
-//! happens on text.
-//!
-//! Five syntactic positions demand a literal before lowering ever runs: a buffer
-//! size, a `mul_range` bound, a range-check bound, a `match` range, and a
-//! `case`. That is why a global constant is substituted rather than bound, and
-//! it is the whole reason this module exists.
-//!
-//! A constant is read as a compile-time INTEGER, which is deliberate and
-//! load-bearing: it is what makes a derived size come out right. So the same
-//! text means different things here and in a value position, where it would fold
-//! in the field with `+` as XOR. Neither reading is wrong, and `const(...)` is
-//! how an author says which was meant (`zkDSL.md`, "`const(...)` in a value
-//! position"). It is transparent here, a constant having only this reading.
-
-use super::*;
-
-/// Evaluate a compile-time **integer** constant expression: decimal literals
-/// combined with `+ - * / // % **` and parentheses. This is ordinary integer
-/// arithmetic (a global constant is a count, a size, an exponent), deliberately
-/// distinct from runtime field arithmetic, so a derived size like `2 + (W - 1) *
-/// V + LOG_LIFETIME` comes out right; a single `/` divides like `//` here.
-/// References to earlier constants are already substituted to their decimal
-/// values, so the input is pure arithmetic. Overflow, division by zero, and a
-/// negative intermediate are errors.
-pub(super) fn eval_const_int(s: &str) -> Result {
- parse_expr(s)
- .ok()
- .and_then(|e| const_int_expr(&e))
- .ok_or_else(|| format!("not a compile-time integer constant expression: `{}`", s.trim()))
-}
-
-/// Fold a compile-time INTEGER expression (literals combined with the usual
-/// operators) to its value; `None` if any leaf is not a literal. Placeholders
-/// are substituted before parsing, so `GEN ** (K_SKIP + 1)`-style exponents
-/// fold here.
-pub(super) fn const_int_expr(e: &Expr) -> Option {
- match e {
- Expr::Lit(k) => Some(*k),
- // `const(e)` asks for the integer reading, which is the only reading a
- // parse-time position has, so it is transparent rather than redundant. It
- // used to be a parse error in a `StackBuf` size and a `log` bound while
- // being accepted in a `HeapBuf` size, an `unroll` count and a `GEN **`
- // exponent, which is one construct with two meanings depending on where it
- // stood.
- Expr::Call(f, args) if f == "const" && args.len() == 1 => const_int_expr(&args[0]),
- Expr::Add(a, b) => const_int_expr(a)?.checked_add(const_int_expr(b)?),
- Expr::Sub(a, b) => const_int_expr(a)?.checked_sub(const_int_expr(b)?),
- Expr::Mul(a, b) => const_int_expr(a)?.checked_mul(const_int_expr(b)?),
- // A single `/` between compile-time integers is integer division: in a
- // constant (a count, a size), there is no field to divide in.
- Expr::Div(a, b) | Expr::FieldDiv(a, b) => match const_int_expr(b)? {
- 0 => None,
- d => Some(const_int_expr(a)? / d),
- },
- Expr::Mod(a, b) => match const_int_expr(b)? {
- 0 => None,
- d => Some(const_int_expr(a)? % d),
- },
- Expr::Pow(a, b) => const_int_expr(a)?.checked_pow(u32::try_from(const_int_expr(b)?).ok()?),
- _ => None,
- }
-}
-
-/// Evaluate a compile-time constant expression (integer literals, `GEN`,
-/// `GEN ** k`, and `+`/`*` combinations of those) to its field element.
-/// Used for the `# public_input: , ` annotation of `.py` test
-/// programs (see `tests/py_source.rs`).
-pub fn parse_const(s: &str) -> Result {
- fn eval(e: &Expr) -> Result {
- match e {
- // An integer literal is the raw 128-bit bit pattern of a machine word.
- Expr::Lit(n) => Ok(F192::new(*n as u64, (*n >> 64) as u64, 0)),
- Expr::Gen => Ok(g_pow(1).into()),
- Expr::GPow(k) => Ok(g_pow_u128(*k).into()),
- Expr::Add(a, b) => Ok(eval(a)? + eval(b)?),
- Expr::Mul(a, b) => Ok(eval(a)? * eval(b)?),
- other => Err(format!("not a constant expression: `{other:?}`")),
- }
- }
- eval(&parse_expr(s)?)
-}
-
-pub(super) fn parse_f192_const(s: &str) -> Option> {
- let inner = s.trim().strip_prefix("f192(")?.strip_suffix(')')?;
- let parts = split_top(inner, ',');
- Some((|| {
- if parts.len() != 3 {
- return Err("f192 needs exactly three limbs".into());
- }
- let mut limbs = [0u64; 3];
- for (i, p) in parts.iter().enumerate() {
- limbs[i] =
- u64::try_from(eval_const_int(p.trim())?).map_err(|_| "an f192 limb does not fit in u64".to_string())?;
- }
- Ok(F192::new(limbs[0], limbs[1], limbs[2]))
- })())
-}
-
-/// A range bound (`mul_range` bounds and `assert log _ < log _` bounds): a
-/// compile-time power of the generator (`1` = `g^0`, `GEN` = `g^1`, or
-/// `GEN ** k`), returning the exponent `k`. Both uses walk/compare exponents,
-/// so the bound must name `g^k` explicitly (an element that is not a known
-/// power of `g` has no usable exponent).
-pub(super) fn gpow_bound(e: &Expr) -> Result {
- match e {
- // `g` is `x`, so the literal `2^k` IS `g^k`, and `1` is `g^0`. Rejecting
- // these used to make `mul_range(1, 8)` an error although it runs exactly
- // like `mul_range(1, GEN ** 3)`, and `mul_range(2, GEN ** 5)` an error
- // although `2 == GEN`. It also contradicted `gaddr_of`, which reads the
- // same literal as the same element.
- Expr::Lit(n) if (n.is_power_of_two() && *n < (1 << 64)) || *n == 1 => Ok(n.trailing_zeros() as u64),
- Expr::Gen => Ok(1),
- Expr::GPow(k) => u64::try_from(*k).map_err(|_| format!("bound exponent {k} does not fit in u64")),
- other => Err(format!(
- "a range bound must be a power of GEN (`1`, `GEN`, or `GEN ** k`), got `{other:?}`"
- )),
- }
-}
-
-pub(super) fn parse_gpow_bound(s: &str) -> Result {
- gpow_bound(&parse_expr(s)?)
-}
-
-/// Apply identifier-level **placeholder** replacements to source text before
-/// parsing: each maximal run of alphanumeric characters and underscores that
-/// equals a key of `replacements` is replaced by its value; other text,
-/// including substrings of longer identifiers, is untouched. An empty map
-/// returns the source unchanged.
-pub(super) fn apply_replacements(src: &str, replacements: &BTreeMap) -> String {
- if replacements.is_empty() {
- return src.to_string();
- }
- let mut out = String::with_capacity(src.len());
- let mut start = 0;
- let flush = |out: &mut String, word: &str| {
- out.push_str(replacements.get(word).map_or(word, String::as_str));
- };
- for (i, c) in src.char_indices() {
- if !(c.is_alphanumeric() || c == '_') {
- flush(&mut out, &src[start..i]);
- out.push(c);
- start = i + c.len_utf8();
- }
- }
- flush(&mut out, &src[start..]);
- out
-}
diff --git a/crates/lean_compiler/src/parser/expr.rs b/crates/lean_compiler/src/parser/expr.rs
deleted file mode 100644
index d46edce86..000000000
--- a/crates/lean_compiler/src/parser/expr.rs
+++ /dev/null
@@ -1,447 +0,0 @@
-//! Reading structure out of a line: where a line ends, where an operator sits,
-//! and what an expression means.
-//!
-//! One rule governs all of it. **A string literal is one opaque token**, and
-//! everything here that scans a line goes through [`depth0`], which skips both
-//! bracketed and quoted text. Before it did, a `,` or a `]` spelled inside a
-//! hint name moved an argument boundary, and [`strip_comment`]'s `#` truncated
-//! the line from inside a string, in both cases producing a DIFFERENT program
-//! that still parsed.
-//!
-//! Precedence is the usual one and is correct as it stands: `+` and `-` split
-//! first, so they bind loosest; then `*`, `/`, `//` and `%`; then `**`, which
-//! splits at its FIRST occurrence and recurses right, so it is
-//! right-associative as in Python. There is no unary minus, since field
-//! subtraction is `+`.
-
-use super::*;
-
-/// Parse an expression with `+` (lowest) then `*`, atoms being integer literals,
-/// variables, calls `f(args)`, and parenthesised sub-expressions.
-pub(super) fn parse_expr(s: &str) -> Result {
- let s = s.trim();
- // `+` / `-` at top level (lowest precedence), left-associative. `-` is
- // compile-time integer subtraction (field subtraction is `+` = XOR).
- let (segs, ops) = split_add(s);
- if !ops.is_empty() {
- return fold_ops(&segs, &ops, |op, l, r| match op {
- b'+' => Expr::Add(l, r),
- _ => Expr::Sub(l, r),
- });
- }
- // `*`, `/`, `//`, `%` (bind tighter than `+`), skipping the two-char `**`.
- let (segs, ops) = split_mul(s);
- if !ops.is_empty() {
- return fold_ops(&segs, &ops, |op, l, r| match op {
- b'*' => Expr::Mul(l, r),
- b'/' => Expr::Div(l, r),
- b'd' => Expr::FieldDiv(l, r),
- _ => Expr::Mod(l, r),
- });
- }
- // `**` (compile-time power), tightest binding: `base ** k` with `k` an
- // integer literal (possibly large), or a parenthesised compile-time
- // integer expression like `GEN ** (2 * s + 1)`, evaluated at lowering,
- // so it can reference `unroll` counters and constants.
- if let Some((base, exp)) = split_once_top(s, "**") {
- let base = parse_expr(base)?;
- let exp_e = parse_expr(exp)?;
- return match base {
- // `GEN ** k`: a compile-time integer exponent (a literal or a
- // constant expression like `K_SKIP + 1`) folds to `g^k`; a runtime
- // expression (e.g. an `unroll` var) becomes `GenPow`, resolved at
- // lowering.
- Expr::Gen => match const_int_expr(&exp_e) {
- Some(k) => Ok(Expr::GPow(k)),
- None => Ok(Expr::GenPow(Box::new(exp_e))),
- },
- // Any other base with a compile-time exponent: square-and-multiply.
- _ => Ok(Expr::Pow(Box::new(base), Box::new(exp_e))),
- };
- }
- // Atom.
- if s.starts_with('(') && s.ends_with(')') {
- return parse_expr(&s[1..s.len() - 1]);
- }
- if s == "GEN" {
- return Ok(Expr::Gen);
- }
- if let Ok(n) = s.parse::() {
- return Ok(Expr::Lit(n));
- }
- // List literal `[a, b, …]`: an initialized StackBuf (only meaningful as
- // the RHS of an assignment; top-level constant arrays are parsed earlier).
- if s.starts_with('[') && s.ends_with(']') {
- let inner = s[1..s.len() - 1].trim();
- if inner.is_empty() {
- return Err("a list literal needs at least one element".into());
- }
- return Ok(Expr::ListLit(
- split_top(inner, ',')
- .iter()
- .map(|e| parse_expr(e))
- .collect::>()?,
- ));
- }
- // Index `base[idx]` or slice `base[lo:hi]` (binds tightest, like a call).
- if s.ends_with(']') {
- let open = s.find('[').ok_or_else(|| format!("unbalanced `]` in `{s}`"))?;
- let base = parse_expr(&s[..open])?;
- let inner = &s[open + 1..s.len() - 1];
- if let Some((lo, hi)) = split_once_top(inner, ":") {
- return Ok(Expr::Slice(
- Box::new(base),
- Box::new(parse_expr(lo)?),
- Box::new(parse_expr(hi)?),
- ));
- }
- let idx = parse_expr(inner)?;
- return Ok(Expr::Index(Box::new(base), Box::new(idx)));
- }
- if let Some(open) = s.find('(')
- && s.ends_with(')')
- {
- let name = s[..open].trim().to_string();
- let args_str = s[open + 1..s.len() - 1].trim();
- let mut args = if args_str.is_empty() {
- vec![]
- } else {
- split_top(args_str, ',')
- .iter()
- .map(|a| {
- if let Some((key, value)) = split_once_top(a, "=") {
- let key = key.trim();
- if key.is_empty() || !key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
- return Err(format!("invalid keyword argument `{key}`"));
- }
- Ok(Expr::Call(format!("__kw_{key}"), vec![parse_expr(value)?]))
- } else {
- parse_expr(a)
- }
- })
- .collect::>()?
- };
- // `HeapBuf(n)` / `StackBuf(n)` are allocations, not ordinary calls. A size
- // that folds as parse-time integer arithmetic (`MAXQ + 1`, constants
- // already substituted) is a static size like a bare literal.
- // A cell count is a frame/heap size: reject one that does not fit rather
- // than wrapping it into a plausible small buffer.
- let cells = |n: u128| u64::try_from(n).map_err(|_| format!("{name} size {n} does not fit in u64"));
- if name == "HeapBuf" || name == "StackBuf" {
- let size = match args.as_slice() {
- [arg] => const_int_expr(arg),
- _ => None,
- };
- return match (name.as_str(), size) {
- ("HeapBuf", Some(n)) => Ok(Expr::HeapBuf(cells(n)?)),
- ("StackBuf", Some(n)) => Ok(Expr::StackBuf(cells(n)?)),
- ("HeapBuf", None) if args.len() == 1 => Ok(Expr::HeapBufDyn(Box::new(args.pop().unwrap()))),
- ("HeapBuf", None) => Err("HeapBuf(size) takes one argument".into()),
- _ => Err("StackBuf(n) needs a parse-time integer size".into()),
- };
- }
- return Ok(Expr::Call(name, args));
- }
- if s.chars().all(|c| c.is_alphanumeric() || c == '_') && !s.is_empty() {
- return Ok(Expr::Var(s.to_string()));
- }
- if s.is_empty() {
- return Err("expected an expression".into());
- }
- Err(format!("cannot parse expression `{s}`"))
-}
-
-/// Combine one tier's operands left-associatively: `node` builds the AST node
-/// for each operator (as [`split_add`] / [`split_mul`] tag it).
-fn fold_ops(segs: &[&str], ops: &[u8], node: impl Fn(u8, Box, Box) -> Expr) -> Result {
- // An empty operand is an operator missing a side: a leading `-`, a trailing
- // operator, or two in a row. Naming it beats letting `parse_expr("")` report
- // an empty backtick, which is what every one of these used to say.
- if let Some(i) = segs.iter().position(|seg| seg.trim().is_empty()) {
- let (op, side) = if i == 0 {
- (ops[0], "left")
- } else {
- (ops[i - 1], "right")
- };
- // `split_mul` encodes the two divisions, so spell them back out.
- let shown = match op {
- b'/' => "//".to_string(),
- b'd' => "/".to_string(),
- c => (c as char).to_string(),
- };
- let hint = if op == b'-' && i == 0 {
- ": there is no unary minus, and field subtraction is `+`"
- } else {
- ""
- };
- return Err(format!("`{shown}` has no {side} operand{hint}"));
- }
- let mut acc = parse_expr(segs[0])?;
- for (&op, seg) in ops.iter().zip(&segs[1..]) {
- let rhs = Box::new(parse_expr(seg)?);
- acc = node(op, Box::new(acc), rhs);
- }
- Ok(acc)
-}
-
-/// The bytes of `s` that sit outside every `(…)` / `[…]` group, with their
-/// index: the one scanner behind all the top-level splits below. Brackets
-/// themselves are never yielded, so a separator that is a bracket never splits.
-pub(super) fn depth0(s: &str) -> impl Iterator
- + '_ {
- let mut depth = 0i32;
- let mut in_str = false;
- s.as_bytes().iter().enumerate().filter_map(move |(i, &c)| {
- // A string literal is one opaque token. Without this every splitter
- // below reads the brackets and operators SPELLED INSIDE a stream name as
- // structure: `hint_witness(b, "a,b")` split into three arguments.
- if in_str {
- in_str = c != b'"';
- return None;
- }
- match c {
- b'"' => {
- in_str = true;
- None
- }
- b'(' | b'[' => {
- depth += 1;
- None
- }
- b')' | b']' => {
- depth -= 1;
- None
- }
- _ => (depth == 0).then_some((i, c)),
- }
- })
-}
-
-/// Split `s` at the top-level additive tier: operands and the `+` / `-`
-/// operators between them. Left-associative; parenthesised/bracketed sub-terms
-/// are left intact.
-fn split_add(s: &str) -> (Vec<&str>, Vec) {
- let (mut segs, mut ops) = (Vec::new(), Vec::new());
- let mut start = 0usize;
- for (i, c) in depth0(s) {
- if c == b'+' || c == b'-' {
- segs.push(&s[start..i]);
- ops.push(c);
- start = i + 1;
- }
- }
- segs.push(&s[start..]);
- (segs, ops)
-}
-
-/// Split `s` at the top-level multiplicative tier: the operands and the
-/// operators between them (`*`, `//` for floor-division, `/` for runtime field
-/// division, `%` for remainder). A `**` power is left intact (bound tighter).
-/// Left-associative.
-fn split_mul(s: &str) -> (Vec<&str>, Vec) {
- let b = s.as_bytes();
- let (mut segs, mut ops) = (Vec::new(), Vec::new());
- let (mut start, mut next) = (0usize, 0usize);
- for (i, c) in depth0(s) {
- if i < next {
- continue; // the second `/` of a `//`, already consumed
- }
- let (op, len) = match c {
- b'*' if b.get(i + 1) == Some(&b'*') || (i > 0 && b[i - 1] == b'*') => continue, // `**`
- b'*' => (b'*', 1),
- b'/' if b.get(i + 1) == Some(&b'/') => (b'/', 2), // `//` compile-time floor-division
- b'/' => (b'd', 1), // `/` runtime field division
- b'%' => (b'%', 1),
- _ => continue,
- };
- segs.push(&s[start..i]);
- ops.push(op);
- next = i + len;
- start = next;
- }
- segs.push(&s[start..]);
- (segs, ops)
-}
-
-/// Split `s` once on a top-level multi-char operator `op`.
-pub(super) fn split_once_top<'a>(s: &'a str, op: &str) -> Option<(&'a str, &'a str)> {
- let b = s.as_bytes();
- for (i, _) in depth0(s) {
- if b[i..].starts_with(op.as_bytes()) {
- return Some((&s[..i], &s[i + op.len()..]));
- }
- }
- None
-}
-
-/// Split `s` on every top-level occurrence of the ASCII char `sep`.
-pub(super) fn split_top(s: &str, sep: char) -> Vec<&str> {
- let sep = sep as u8;
- let mut parts = Vec::new();
- let mut start = 0;
- for (i, c) in depth0(s) {
- if c == sep {
- parts.push(&s[start..i]);
- start = i + 1;
- }
- }
- parts.push(&s[start..]);
- parts
-}
-
-/// Split on a top-level BARE `=`: not part of `==`, not the tail of a
-/// comparison (`!=`, `<=`, `>=`), and not the tail of a compound assignment
-/// ([`split_aug`] owns those, and rejects the ones this language lacks).
-/// Without the last two exclusions a bare `x != y` split into a binding named
-/// `x !`, which in a verifier is an `assert` that compiled to nothing.
-pub(super) fn split_assign(s: &str) -> Option<(&str, &str)> {
- let b = s.as_bytes();
- for (i, c) in depth0(s) {
- if c != b'=' || b.get(i + 1) == Some(&b'=') {
- continue;
- }
- if i > 0 && matches!(b[i - 1], b'=' | b'<' | b'>' | b'!' | b'+' | b'-' | b'*' | b'/' | b'%') {
- continue;
- }
- return Some((&s[..i], &s[i + 1..]));
- }
- None
-}
-
-/// A top-level augmented assignment `lhs OP= rhs` -> `(lhs, "OP", rhs)`, for
-/// OP in `+ - * // %`. `Ok(None)` for a plain `=` or a comparison (`==`, `!=`,
-/// `<=`, `>=`); an `Err` for a compound spelling this language does not have.
-/// The operator's `=` must sit at depth 0 and be immediately preceded by
-/// exactly the operator characters.
-///
-/// The unsupported spellings must be REJECTED rather than declined: falling
-/// through left [`split_assign`] to split the bare `=`, so `x /= 2` became a
-/// binding named `x /` and the program silently kept the old `x`.
-pub(super) fn split_aug(s: &str) -> Result, String> {
- let b = s.as_bytes();
- for (i, c) in depth0(s) {
- if c != b'=' {
- continue;
- }
- // Nothing to the left is no assignment at all; `split_assign` and the
- // binding-name check below give that its error.
- if i == 0 {
- return Ok(None);
- }
- // not `==` and not a comparison tail (`<=`, `>=`, `!=`)
- if b.get(i + 1) == Some(&b'=') || matches!(b[i - 1], b'=' | b'<' | b'>' | b'!') {
- return Ok(None);
- }
- let prev2 = b.get(i.wrapping_sub(2)).copied();
- let (op, plen): (&str, usize) = match b[i - 1] {
- b'+' => ("+", 1),
- b'-' => ("-", 1),
- b'%' => ("%", 1),
- b'*' if prev2 == Some(b'*') => {
- return Err("`**=` is not supported; write `x = x ** k`".into());
- }
- b'*' => ("*", 1),
- b'/' if prev2 == Some(b'/') => ("//", 2),
- b'/' => {
- return Err(
- "`/=` is not supported; `/` is runtime field division, so write `x = x / y` (or `//=` for the \
- compile-time floor division)"
- .into(),
- );
- }
- _ => return Ok(None),
- };
- return Ok(Some((s[..i - plen].trim(), op, s[i + 1..].trim())));
- }
- Ok(None)
-}
-
-/// The top-level arguments of a `name(a, b, …)` call, or `None` when `line` is
-/// not one. Zero arguments come back as one empty string, as [`split_top`]
-/// gives them.
-pub(super) fn call_args<'a>(line: &'a str, name: &str) -> Option> {
- let inner = line.trim().strip_prefix(name)?.strip_prefix('(')?.strip_suffix(')')?;
- Some(split_top(inner, ','))
-}
-
-/// The contents of a `"…"` string literal.
-pub(super) fn string_lit(s: &str) -> Option<&str> {
- s.trim().strip_prefix('"')?.strip_suffix('"')
-}
-
-/// `raw` without its trailing comment. A `#` inside a string literal is part of
-/// the string: truncating there dropped the rest of the line, and the shortened
-/// line usually still parsed.
-pub(super) fn strip_comment(raw: &str) -> &str {
- let mut in_str = false;
- for (i, c) in raw.char_indices() {
- match c {
- '"' => in_str = !in_str,
- '#' if !in_str => return &raw[..i],
- _ => {}
- }
- }
- raw
-}
-
-/// The first top-level comparison operator in `s`. Used only on a line that
-/// reached the bare-call fallback, so an `assert` or an assignment never gets
-/// here and a comparison nested in a call sits at depth > 0.
-pub(super) fn top_level_cmp(s: &str) -> Option<&'static str> {
- let b = s.as_bytes();
- for (i, c) in depth0(s) {
- let eq = b.get(i + 1) == Some(&b'=');
- match c {
- b'=' if eq => return Some("=="),
- b'!' if eq => return Some("!="),
- b'<' => return Some(if eq { "<=" } else { "<" }),
- b'>' => return Some(if eq { ">=" } else { ">" }),
- _ => {}
- }
- }
- None
-}
-
-/// A plain identifier: non-empty, starts with a letter or `_`, all
-/// `[A-Za-z0-9_]` (no operators, brackets, or commas).
-pub(super) fn is_ident(s: &str) -> bool {
- let mut cs = s.chars();
- matches!(cs.next(), Some(c) if c.is_alphabetic() || c == '_') && s.chars().all(|c| c.is_alphanumeric() || c == '_')
-}
-
-/// A binding or parameter name, validated. Anything else here is a mis-split
-/// (`x /`, `x !`) or a top-level constant substituted into a binding position:
-/// constants are replaced textually, so `V = 8` with `def scale(V)` arrives as a
-/// parameter literally named `8` while the body's `V` reads the constant.
-/// `zkDSL.md` §Global constants reserves the name; this enforces it.
-pub(super) fn binding_name(raw: &str, what: &str) -> Result {
- let n = raw.trim();
- if is_ident(n) {
- return Ok(n.to_string());
- }
- Err(format!(
- "`{n}` is not a valid {what}: a name must be a plain identifier. A top-level constant's name is \
- reserved, and is substituted before parsing, so a parameter or local may not reuse one."
- ))
-}
-
-/// The inside of a `const(...)` wrapping the WHOLE of `s`, or `None`.
-///
-/// `const(a) == b` is not one: its first `)` closes before the end, so the
-/// wrapper is a subterm and the condition as a whole is an ordinary one.
-pub(super) fn strip_const_wrapper(s: &str) -> Option<&str> {
- let inner = s.trim().strip_prefix("const(")?.strip_suffix(')')?;
- let mut depth = 0i32;
- for c in inner.bytes() {
- match c {
- b'(' | b'[' => depth += 1,
- b')' | b']' => {
- depth -= 1;
- if depth < 0 {
- return None;
- }
- }
- _ => {}
- }
- }
- Some(inner)
-}
diff --git a/crates/lean_compiler/src/parser/subst.rs b/crates/lean_compiler/src/parser/subst.rs
deleted file mode 100644
index 0affd3ed9..000000000
--- a/crates/lean_compiler/src/parser/subst.rs
+++ /dev/null
@@ -1,181 +0,0 @@
-//! Substituting an expression for a name, throughout a statement tree.
-//!
-//! This is how the two compile-time replications bind their variable: an
-//! `unroll` body is re-emitted per integer with the counter substituted as a
-//! literal, and a `Const` parameter is substituted into the monomorphised copy.
-//! Both happen BEFORE lowering, so the result is ordinary source that no longer
-//! mentions the name.
-//!
-//! Every arm has to be exhaustive over [`StmtKind`] and carry its fields
-//! through: a field silently dropped here is a construct that loses its meaning
-//! only inside an unrolled loop or a specialisation, which is the hardest place
-//! to notice it.
-
-use super::*;
-
-/// Substitute `Var(name)` → `to` through a statement list, stopping at a
-/// statement that rebinds `name` (later uses refer to the new binding).
-/// Nested blocks recurse independently, since their bindings are branch-local,
-/// matching the lowering's scoping. Used by `Const`-parameter specialization.
-pub(crate) fn subst_stmts(stmts: &[Stmt], name: &str, to: &Expr) -> Vec {
- let mut out = Vec::with_capacity(stmts.len());
- let mut active = true;
- for s in stmts {
- if !active {
- out.push(s.clone());
- continue;
- }
- let (kind, rebinds) = subst_kind(&s.kind, name, to);
- out.push(s.at(kind));
- active = !rebinds;
- }
- out
-}
-
-/// Substitute one statement kind; the flag says whether it rebinds `name`.
-fn subst_kind(s: &StmtKind, name: &str, to: &Expr) -> (StmtKind, bool) {
- let e = |x: &Expr| subst_var(x, name, to);
- match s {
- StmtKind::Let(n, x) => (StmtKind::Let(n.clone(), e(x)), n == name),
- StmtKind::LetTuple(ns, f, args) => (
- StmtKind::LetTuple(ns.clone(), f.clone(), args.iter().map(e).collect()),
- ns.iter().any(|n| n == name),
- ),
- StmtKind::AssertEq(a, b) => (StmtKind::AssertEq(e(a), e(b)), false),
- StmtKind::AssertNe(a, b) => (StmtKind::AssertNe(e(a), e(b)), false),
- StmtKind::AssertLt(a, bound) => (
- StmtKind::AssertLt(
- e(a),
- match bound {
- LtBound::Const(k) => LtBound::Const(*k),
- LtBound::Runtime(b) => LtBound::Runtime(e(b)),
- },
- ),
- false,
- ),
- StmtKind::Call(f, args) => (StmtKind::Call(f.clone(), args.iter().map(e).collect()), false),
- StmtKind::Print { label, value } => (
- StmtKind::Print {
- label: label.clone(),
- value: e(value),
- },
- false,
- ),
- // Binds `name` and mentions no expression, so a substitution stops at it
- // exactly as it does at any other binder.
- StmtKind::LetHintWitness { name: n, stream } => (
- StmtKind::LetHintWitness {
- name: n.clone(),
- stream: stream.clone(),
- },
- n == name,
- ),
- StmtKind::HintWitness { dest, name: n } => (
- StmtKind::HintWitness {
- dest: e(dest),
- name: n.clone(),
- },
- false,
- ),
- StmtKind::Store(a, i, v) => (StmtKind::Store(e(a), e(i), e(v)), false),
- StmtKind::Return(es) => (StmtKind::Return(es.iter().map(e).collect()), false),
- StmtKind::CallIfNe(a, b, f, args) => (
- StmtKind::CallIfNe(e(a), e(b), f.clone(), args.iter().map(e).collect()),
- false,
- ),
- StmtKind::For { var, lo, hi, body } => {
- let hi = match hi {
- ForBound::Const(k) => ForBound::Const(*k),
- ForBound::Runtime(b) => ForBound::Runtime(e(b)),
- };
- // The counter shadows `name` inside the body only.
- let body = if var == name {
- body.clone()
- } else {
- subst_stmts(body, name, to)
- };
- (
- StmtKind::For {
- var: var.clone(),
- lo: *lo,
- hi,
- body,
- },
- false,
- )
- }
- StmtKind::Unroll { var, lo, hi, body } => {
- let body = if var == name {
- body.clone()
- } else {
- subst_stmts(body, name, to)
- };
- (
- StmtKind::Unroll {
- var: var.clone(),
- lo: e(lo),
- hi: e(hi),
- body,
- },
- false,
- )
- }
- StmtKind::If {
- eq,
- lhs,
- rhs,
- then,
- els,
- force_const,
- } => (
- StmtKind::If {
- eq: *eq,
- lhs: e(lhs),
- rhs: e(rhs),
- then: subst_stmts(then, name, to),
- els: subst_stmts(els, name, to),
- force_const: *force_const,
- },
- false,
- ),
- StmtKind::Match { targets, x, arms } => (
- StmtKind::Match {
- // A name target is a BINDER, so it is never substituted: the
- // `shadow` flag below already stops substitution past this
- // statement, and rewriting the binder itself turned `k, e = …`
- // under a `Const k` into a literal target. An INDEX target is a
- // use (`sb[k]` needs `k`), so it is substituted.
- targets: targets
- .iter()
- .map(|t| if matches!(t, Expr::Var(_)) { t.clone() } else { e(t) })
- .collect(),
- x: e(x),
- arms: arms.iter().map(e).collect(),
- },
- targets.iter().any(|t| matches!(t, Expr::Var(n) if n == name)),
- ),
- }
-}
-
-/// `e` with every `Var(name)` replaced by `to`: the `match` arm
-/// expansion, where the lambda parameter becomes the arm's integer literal.
-pub(super) fn subst_var(e: &Expr, name: &str, to: &Expr) -> Expr {
- let s = |b: &Expr| Box::new(subst_var(b, name, to));
- match e {
- Expr::Var(v) if v == name => to.clone(),
- Expr::Add(a, b) => Expr::Add(s(a), s(b)),
- Expr::Mul(a, b) => Expr::Mul(s(a), s(b)),
- Expr::Sub(a, b) => Expr::Sub(s(a), s(b)),
- Expr::Div(a, b) => Expr::Div(s(a), s(b)),
- Expr::FieldDiv(a, b) => Expr::FieldDiv(s(a), s(b)),
- Expr::Mod(a, b) => Expr::Mod(s(a), s(b)),
- Expr::Index(a, b) => Expr::Index(s(a), s(b)),
- Expr::Slice(a, lo, hi) => Expr::Slice(s(a), s(lo), s(hi)),
- Expr::GenPow(e) => Expr::GenPow(s(e)),
- Expr::Pow(a, b) => Expr::Pow(s(a), s(b)),
- Expr::HeapBufDyn(sz) => Expr::HeapBufDyn(s(sz)),
- Expr::ListLit(es) => Expr::ListLit(es.iter().map(|a| subst_var(a, name, to)).collect()),
- Expr::Call(f, args) => Expr::Call(f.clone(), args.iter().map(|a| subst_var(a, name, to)).collect()),
- other => other.clone(),
- }
-}
diff --git a/crates/lean_compiler/tests/programs/conditionals.py b/crates/lean_compiler/tests/programs/conditionals.py
deleted file mode 100644
index c7394440b..000000000
--- a/crates/lean_compiler/tests/programs/conditionals.py
+++ /dev/null
@@ -1,29 +0,0 @@
-# `if` / `elif` / `else` on field equality (`==` / `!=`): one XOR and one
-# conditional JUMP. Bindings made inside a branch are branch-local; branches
-# communicate through write-once memory: only one branch executes, so both
-# may write the same cell. The loop body's `if` runs in a helper frame (its
-# own fp cell). Published: (5, 13 + 17) = (5, 28): `+` is XOR.
-# public_input: 5, 28
-from snark_lib import *
-
-
-def main():
- r = HeapBuf(4)
- x = GEN ** 3
- if x == GEN ** 3:
- r[1] = 5
- else:
- r[1] = 7
- if x == GEN:
- r[GEN] = 11
- elif x == GEN ** 3:
- r[GEN] = 13
- else:
- r[GEN] = 15
- for i in mul_range(1, GEN ** 4):
- if i == GEN ** 2:
- r[GEN ** 2] = 17
- p = GEN ** 0
- p[1] = r[1]
- p[GEN] = r[GEN] + r[GEN ** 2]
- return
diff --git a/crates/lean_compiler/tests/programs/const_params.py b/crates/lean_compiler/tests/programs/const_params.py
deleted file mode 100644
index 81a1e833c..000000000
--- a/crates/lean_compiler/tests/programs/const_params.py
+++ /dev/null
@@ -1,30 +0,0 @@
-# `Const` parameters: `def hash_pair(buf, k: Const)` is a template: each call
-# site passes a compile-time constant and gets a monomorphized copy with `k`
-# substituted as the integer literal, usable in compile-time positions (the
-# slice bounds below). The direct call and match arm 0 share the k=0
-# specialization. A 256-bit BLAKE2s value occupies two canonical cells.
-# Published: the two 128-bit digest cells of H(quad0, quad0) XOR H(quad1, quad1)
-#: the direct k=0 digest XORed with the arm the runtime x = GEN selects (k=1).
-# public_input: 252517949230448393340326710819579834691, 263897057969456650752475895236275386743
-from snark_lib import *
-
-
-def main():
- buf = HeapBuf(4)
- buf[1] = 5
- buf[GEN] = 7
- buf[GEN ** 2] = 11
- buf[GEN ** 3] = 13
- a0, a1 = hash_pair(buf, 0)
- x = GEN
- b0, b1 = match(log(x), range(0, 2), lambda i: hash_pair(buf, i))
- p = GEN ** 0
- p[1] = a0 + b0
- p[GEN] = a1 + b1
- return
-
-
-def hash_pair(buf, k: Const):
- h = StackBuf(2)
- blake2s(buf[k * 2:k * 2 + 2], buf[k * 2:k * 2 + 2], h)
- return h[0], h[1]
diff --git a/crates/lean_compiler/tests/programs/fibonacci.py b/crates/lean_compiler/tests/programs/fibonacci.py
deleted file mode 100644
index b672e93d3..000000000
--- a/crates/lean_compiler/tests/programs/fibonacci.py
+++ /dev/null
@@ -1,20 +0,0 @@
-# Fibonacci in the exponent: cell fib[g^k] holds GEN ** F_k, and the field
-# product adds exponents: one MUL per Fibonacci step. The evolving state is
-# carried through a HeapBuf (a mul_range body cannot capture a StackBuf).
-# public_input: GEN ** 89, GEN ** 89
-from snark_lib import *
-
-
-def main():
- fib = HeapBuf(12)
- fib[1] = GEN ** 0 # F_0 = 0
- fib[GEN] = GEN # F_1 = 1
- for i in mul_range(1, GEN ** 10):
- fib[i * GEN * GEN] = fib[i] * fib[i * GEN]
- out = fib[GEN ** 11]
- assert out == GEN ** 89 # F_11 = 89
- assert log(out) < log(GEN ** 128)
- p = GEN ** 0
- p[1] = out
- p[GEN] = out
- return
diff --git a/crates/lean_compiler/tests/programs/hash_heap_chain.py b/crates/lean_compiler/tests/programs/hash_heap_chain.py
deleted file mode 100644
index 6a6950b16..000000000
--- a/crates/lean_compiler/tests/programs/hash_heap_chain.py
+++ /dev/null
@@ -1,21 +0,0 @@
-# Runtime slices: `buf[i:i + 2]` with a runtime g-power index `i` names the
-# heap cells `buf·i·g^k`, k < 2 (one MUL folds `i` into the pointer). A BLAKE2s
-# chain over heap pairs (256-bit BLAKE2s value = two canonical cells),
-# addressed by the loop counter: value k sits at cells g^{2k}..g^{2k+1}, and
-# value k+1 = H(value k, value k). Published: the two 128-bit digest cells of
-# H^3(5, 7).
-# public_input: 64347157528245356000384183465036755063, 163839818445703091465558660402169004232
-from snark_lib import *
-
-
-def main():
- buf = HeapBuf(8)
- buf[1] = 5
- buf[GEN] = 7
- for i in mul_range(1, GEN ** 3):
- b = i * i # value k at cells g^{2k}..g^{2k+1}
- blake2s(buf[b:b + 2], buf[b:b + 2], buf[b * GEN ** 2:b * GEN ** 2 + 2])
- p = GEN ** 0
- p[1] = buf[GEN ** 6]
- p[GEN] = buf[GEN ** 7]
- return
diff --git a/crates/lean_compiler/tests/programs/hash_slices.py b/crates/lean_compiler/tests/programs/hash_slices.py
deleted file mode 100644
index 5fdfd1184..000000000
--- a/crates/lean_compiler/tests/programs/hash_slices.py
+++ /dev/null
@@ -1,27 +0,0 @@
-# BLAKE2s over slices: `buf[lo:hi]` (2 cells) is a 256-bit operand under 128-bit
-# machine words, with compile-time bounds: literals, literal-bound names, and
-# their integer arithmetic (`x:x + 2`). Slices work on a large StackBuf (in
-# place) and on a HeapBuf (bridged through the stack, one DEREF per cell), as
-# inputs and as the output. Published: the two 128-bit digest cells of
-# H(H(a[0:2], hb[0:2]), a[0:2]) read back from the heap.
-# public_input: 249862442812096632729038560305983163980, 150628675827268462743577983046613573776
-from snark_lib import *
-
-
-def main():
- a = StackBuf(4)
- a[0] = 5
- a[1] = 7
- a[2] = 0
- a[3] = 0
- hb = HeapBuf(4)
- hb[1] = 11 # heap cell g^0
- hb[GEN] = 13 # heap cell g^1
- x = 0
- h = StackBuf(2)
- blake2s(a[x:x + 2], hb[0:2], h) # stack slice + heap input slice
- blake2s(h, a[0:2], hb[2:4]) # digest lands in heap cells g^2, g^3
- p = GEN ** 0
- p[1] = hb[GEN ** 2]
- p[GEN] = hb[GEN ** 3]
- return
diff --git a/crates/lean_compiler/tests/programs/heapbuf_dyn.py b/crates/lean_compiler/tests/programs/heapbuf_dyn.py
deleted file mode 100644
index ad6dc5a4e..000000000
--- a/crates/lean_compiler/tests/programs/heapbuf_dyn.py
+++ /dev/null
@@ -1,22 +0,0 @@
-# Runtime-sized HeapBuf: the cell count is carried *in the exponent*: the
-# buffer holds k cells where the size value is g^k. So a size derived from a
-# runtime g-power is plain field arithmetic. Here a hinted count m = g^2 gives
-# a buffer of m·m = g^4 = 4 cells; the four cells are filled from a witness
-# stream and XOR-summed (`+` is XOR): 1^2^4^8 = 15. Published: (15, GEN ** 3).
-# public_input: 15, GEN ** 3
-# witness m: GEN ** 2
-# witness vals: 1, 2, 4, 8
-from snark_lib import *
-
-
-def main():
- mb = StackBuf(1)
- hint_witness(mb[0:1], "m")
- m = mb[0]
- buf = HeapBuf(m * m) # runtime size in the exponent: g^2 · g^2 = g^4 = 4 cells
- hint_witness(buf[0:4], "vals")
- s = buf[1] + buf[GEN] + buf[GEN ** 2] + buf[GEN ** 3]
- p = GEN ** 0
- p[1] = s
- p[GEN] = GEN ** 3
- return
diff --git a/crates/lean_compiler/tests/programs/hint.py b/crates/lean_compiler/tests/programs/hint.py
deleted file mode 100644
index ae5d22c9e..000000000
--- a/crates/lean_compiler/tests/programs/hint.py
+++ /dev/null
@@ -1,28 +0,0 @@
-# `hint_witness(dest, "name")` pops the next *entry* (a slice of values) of a
-# named prover stream into a StackBuf or a StackBuf/HeapBuf slice: zero
-# cycles, and completely unconstrained: every hinted value below is pinned
-# down by the program itself (a range check, equality asserts, an XOR
-# relation). The same symbol may be hinted many times: each `# witness` line
-# is one entry, and the two pops of "r" consume its two entries in order.
-# Published: (GEN ** 5, 6).
-# public_input: GEN ** 5, 6
-# witness r: GEN ** 5, 12
-# witness r: 9
-# witness h: 3, 5, 6
-from snark_lib import *
-
-
-def main():
- sb = StackBuf(2)
- hint_witness(sb, "r") # first "r" entry: (GEN ** 5, 12)
- assert log(sb[0]) < 8 # constrain the hinted g-power
- assert sb[1] == 12
- hb = HeapBuf(4)
- hint_witness(hb[0:3], "h") # heap slice: the (3, 5, 6) entry
- assert hb[1] + hb[GEN] == hb[GEN ** 2] # constrain: 3 + 5 = 6 (XOR)
- hint_witness(hb[3:4], "r") # second "r" entry: (9)
- assert hb[GEN ** 3] == 9
- p = GEN ** 0
- p[1] = sb[0]
- p[GEN] = hb[GEN ** 2]
- return
diff --git a/crates/lean_compiler/tests/programs/identities.py b/crates/lean_compiler/tests/programs/identities.py
deleted file mode 100644
index b77563651..000000000
--- a/crates/lean_compiler/tests/programs/identities.py
+++ /dev/null
@@ -1,13 +0,0 @@
-# Field identities, checked entirely in-program: no `# public_input:`
-# annotation, so the harness runs it with the empty public input (two zero
-# field elements) and nothing is published.
-from snark_lib import *
-
-
-def main():
- x = GEN * GEN
- assert x == GEN ** 2
- assert log(x) < 3
- y = x + x # + is XOR: anything plus itself vanishes
- assert y == 0
- return
diff --git a/crates/lean_compiler/tests/programs/match.py b/crates/lean_compiler/tests/programs/match.py
deleted file mode 100644
index 1aec4271f..000000000
--- a/crates/lean_compiler/tests/programs/match.py
+++ /dev/null
@@ -1,24 +0,0 @@
-# `match(log(x), range(a, b), lambda j: …)`: x = GEN ** j runs arm j. Dispatch is
-# two jumps through a trampoline table in the bytecode, landing on the j-th
-# two-instruction slot (SET the block address; JUMP to it). Arms must cover
-# consecutive integers from 0, and a hinted scrutinee must be range-checked
-# first. Published: (21, 5 + 7 + 9) = (21, 11): `+` is XOR.
-# public_input: 21, 11
-from snark_lib import *
-
-FIRST = [11, 17, 21, 27, 31, 37]
-SECOND = [5, 7, 9]
-
-
-def main():
- r = HeapBuf(4)
- x = GEN ** 2
- v = match(log(x), range(0, 6), lambda j: FIRST[j])
- r[1] = v
- for i in mul_range(1, GEN ** 3):
- w = match(log(i), range(0, 3), lambda j: SECOND[j])
- r[i * GEN] = w
- p = GEN ** 0
- p[1] = r[1]
- p[GEN] = r[GEN] + r[GEN ** 2] + r[GEN ** 3]
- return
diff --git a/crates/lean_compiler/tests/programs/match_arms.py b/crates/lean_compiler/tests/programs/match_arms.py
deleted file mode 100644
index c4205f3cf..000000000
--- a/crates/lean_compiler/tests/programs/match_arms.py
+++ /dev/null
@@ -1,27 +0,0 @@
-# `match(log(x), range(a, b), lambda i: …, …)`: a match with generated
-# arms: arm j is the lambda body with i replaced by the integer literal j, and
-# every arm writes its results into the same fresh cells (write-once: exactly
-# one arm executes), bound to the assignment targets. Ranges are contiguous
-# from 0. With x = GEN ** 3: shift(3) = 3·g = 6, and the second pair's
-# two(3) = (3, 3·g) = (3, 6), so a + b = 3 + 6 = 5 (`+` is XOR).
-# public_input: 6, 5
-from snark_lib import *
-
-
-def main():
- x = GEN ** 3
- r = match(log(x), range(0, 6), lambda i: shift(i))
- assert r == 6
- a, b = match(log(x), range(0, 2), lambda i: two(1), range(2, 6), lambda i: two(i))
- p = GEN ** 0
- p[1] = r
- p[GEN] = a + b
- return
-
-
-def shift(v):
- return v * GEN
-
-
-def two(v):
- return v, v * GEN
diff --git a/crates/lean_compiler/tests/programs/nested.py b/crates/lean_compiler/tests/programs/nested.py
deleted file mode 100644
index 0f7fe153a..000000000
--- a/crates/lean_compiler/tests/programs/nested.py
+++ /dev/null
@@ -1,37 +0,0 @@
-# Deep nesting across frames: a mul_range loop whose helper body range-checks the
-# counter, dispatches on it, calls a recursive function from one arm (five frames
-# deep, with its base-case `return` inside an `if` branch), and carries a runtime
-# branch in the SAME frame as the dispatch, so self_fp and the hoisted caches are
-# shared between the two. The branch is TAKEN, so the published values come from
-# its fall-through path and a lowering that ignored the condition would show up
-# here; `conditionals.py` covers the other polarity.
-# geom(1) = 1 + g + g² + g³ + g⁴ = 31. Published: (31 + 5, 9) = (26, 9).
-# public_input: 26, 9
-from snark_lib import *
-
-TAIL = [0, 5, 9]
-
-
-def main():
- acc = HeapBuf(6)
- for i in mul_range(1, GEN ** 3):
- assert log(i) < 3
- v = match(log(i), range(0, 1), lambda j: geom(1), range(1, 3), lambda j: TAIL[j])
- # TAKEN on every iteration, so the published values ride the fall-through
- # path: an `if` whose condition is never true rides the jump instead and
- # stops detecting a lowering that ignores the condition.
- if i == i * GEN ** 0:
- acc[i] = v
- else:
- acc[i] = 0
- p = GEN ** 0
- p[1] = acc[1] + acc[GEN]
- p[GEN] = acc[GEN ** 2]
- return
-
-
-def geom(x):
- if x == GEN ** 4:
- return x # early return from inside the branch
- y = geom(x * GEN)
- return x + y
diff --git a/crates/lean_compiler/tests/programs/runtime_loop.py b/crates/lean_compiler/tests/programs/runtime_loop.py
deleted file mode 100644
index de70d5811..000000000
--- a/crates/lean_compiler/tests/programs/runtime_loop.py
+++ /dev/null
@@ -1,32 +0,0 @@
-# A runtime mul_range stop bound: the loop walks ×GEN from the start element
-# until it reaches a *runtime* g-power: here a hinted count, range-checked
-# first (an unreachable bound would never terminate, so bounding the log is
-# the program's duty). Repeated squaring: buf[g^k] holds g^{2^k}, so after
-# n = 5 iterations buf[n] = g^32. The second loop's hinted bound equals its
-# start: zero iterations, its impossible assert never runs.
-# Published: (g^32, g^5).
-# public_input: GEN ** 32, GEN ** 5
-# witness n: GEN ** 5
-# witness m: 1
-from snark_lib import *
-
-
-def main():
- nb = StackBuf(1)
- hint_witness(nb[0:1], "n")
- n = nb[0]
- assert log(n) < 16
- buf = HeapBuf(40)
- buf[1] = GEN
- for i in mul_range(1, n):
- buf[i * GEN] = buf[i] * buf[i]
- mb = StackBuf(1)
- hint_witness(mb[0:1], "m")
- m = mb[0]
- assert log(m) < 16
- for j in mul_range(1, m):
- assert 1 == 0 # empty runtime range: never entered
- p = GEN ** 0
- p[1] = buf[n]
- p[GEN] = n
- return
diff --git a/crates/lean_compiler/tests/programs/scoping.py b/crates/lean_compiler/tests/programs/scoping.py
deleted file mode 100644
index 053a82e98..000000000
--- a/crates/lean_compiler/tests/programs/scoping.py
+++ /dev/null
@@ -1,33 +0,0 @@
-# Pins the scoping semantics: bindings (and compile-time index constants)
-# made inside a branch are local to it, and the lazily-cached range-check
-# constant cells revert at the join. The not-taken branch below materializes
-# a bound-16 cell that must NOT leak to the check after the join: a leak
-# would read an unwritten cell and fail witness generation loudly.
-# Published: (9, 6).
-# public_input: 9, 6
-from snark_lib import *
-
-
-def main():
- x = GEN ** 3
- assert log(x) < 8 # bound-8 cell cached in main
- v = 5
- k = 2
- if x == GEN ** 3:
- v = 7 # branch-local rebinding
- assert v == 7
- assert log(x) < 8 # reuses the pre-branch bound-8 cell
- assert v == 5 # the outer binding is untouched at the join
- if x != GEN ** 3:
- k = x # (not taken) kills k's const-ness: locally only
- assert log(x) < 16 # (not taken) caches bound-16 inside the branch
- sb = StackBuf(4)
- sb[k] = 9 # k is still the compile-time 2
- assert log(x) < 16 # must re-materialize its bound cell after the join
- y = 3
- y = y * GEN # rebinding reads the old binding: 3·g = 6
- assert y == 6
- p = GEN ** 0
- p[1] = sb[2]
- p[GEN] = y
- return
diff --git a/crates/lean_compiler/tests/programs/unroll.py b/crates/lean_compiler/tests/programs/unroll.py
deleted file mode 100644
index 84c622964..000000000
--- a/crates/lean_compiler/tests/programs/unroll.py
+++ /dev/null
@@ -1,31 +0,0 @@
-# `for i in unroll(a, b)` replicates the body at compile time, i substituted
-# as the integer literal of each iteration: zero loop overhead (no call, no
-# frame, no counter). Bounds are compile-time integers, including Const
-# parameters: `chain(buf, 3)` specializes and unrolls three BLAKE2s steps over
-# heap slices indexed by `i` (a 256-bit BLAKE2s value is two canonical cells).
-# Published: the two 128-bit digest cells of H^3(5, 7): same chain as
-# hash_heap_chain.py, unrolled instead of looped.
-# public_input: 64347157528245356000384183465036755063, 163839818445703091465558660402169004232
-from snark_lib import *
-
-
-def main():
- sb = StackBuf(8)
- sb[0] = 1
- for i in unroll(0, 7):
- sb[i + 1] = sb[i] * GEN # sb[k] = g^k
- assert sb[7] == GEN ** 7
- buf = HeapBuf(8)
- buf[1] = 5
- buf[GEN] = 7
- chain(buf, 3)
- p = GEN ** 0
- p[1] = buf[GEN ** 6]
- p[GEN] = buf[GEN ** 7]
- return
-
-
-def chain(buf, n: Const):
- for i in unroll(0, n):
- blake2s(buf[i * 2:i * 2 + 2], buf[i * 2:i * 2 + 2], buf[i * 2 + 2:i * 2 + 4])
- return
diff --git a/crates/lean_compiler/tests/programs/wots_walk.py b/crates/lean_compiler/tests/programs/wots_walk.py
deleted file mode 100644
index 5558b2aa7..000000000
--- a/crates/lean_compiler/tests/programs/wots_walk.py
+++ /dev/null
@@ -1,38 +0,0 @@
-# A miniature WOTS-style chain walk bundling the DSL's moving parts: a
-# runtime digit is range-checked (dispatch soundness), then match
-# dispatches it to a Const-specialized walker whose BLAKE2s chain is unrolled
-# over heap slices (a 256-bit BLAKE2s value occupies two canonical cells);
-# the walker also builds g^{2n} at runtime (unrolled MULs) to read its final
-# pair back through g-power indexing. The recomputation at the end lands on an
-# already-written StackBuf pair, so write-once turns the hash into a digest
-# assertion; the dead `if` branch holds an impossible assert that must never
-# execute. Published: the two 128-bit digest cells of H^2(5, 7).
-# public_input: 218111983282286173876109193675516368367, 307986319416510097496844621979881208676
-from snark_lib import *
-
-
-def main():
- buf = HeapBuf(16)
- buf[1] = 5
- buf[GEN] = 7
- d = GEN ** 2 # the runtime digit
- assert log(d) < 4 # bound the scrutinee before dispatching on it
- t0, t1 = match(log(d), range(0, 4), lambda i: walk(buf, i))
- if d != GEN ** 2:
- assert 1 == 0 # dead branch: never executes
- v = StackBuf(2)
- v[0] = t0
- v[1] = t1
- blake2s(buf[2:4], buf[2:4], v) # recompute H(value1, value1): asserts v[0:2] == (t0, t1)
- p = GEN ** 0
- p[1] = t0
- p[GEN] = t1
- return
-
-
-def walk(buf, n: Const):
- p = 1
- for i in unroll(0, n):
- blake2s(buf[i * 2:i * 2 + 2], buf[i * 2:i * 2 + 2], buf[i * 2 + 2:i * 2 + 4])
- p = p * GEN * GEN
- return buf[p], buf[p * GEN]
diff --git a/crates/lean_compiler/tests/suite/assert_ne.rs b/crates/lean_compiler/tests/suite/assert_ne.rs
deleted file mode 100644
index 16a036b43..000000000
--- a/crates/lean_compiler/tests/suite/assert_ne.rs
+++ /dev/null
@@ -1,200 +0,0 @@
-//! `assert a != b`: a proof-enforced inequality. It lowers to `XOR x = a + b`,
-//! a hinted `inv = x⁻¹`, `MUL p = x·inv` and `SET p = 1`, the write-once
-//! conflict on `p` being the assertion. Sound whatever the hint: `x = 0` forces
-//! `p = 0`, which cannot then be set to `1`. Three rows and no `JUMP`.
-
-use lean_compiler::{compile, parse};
-use lean_vm::cpu::{Op, prove, verify};
-use primitives::field::{F64, F192, g_pow};
-
-/// Honest inequality over runtime values: prove + verify pass, and corrupting
-/// the public output is still caught (the assert does not disturb the trace).
-#[test]
-fn assert_ne_end_to_end() {
- let src = "\
-def main():
- x = GEN ** 5
- y = GEN ** 7
- z = x * y
- assert z != x
- assert z != y
- p = 1
- p[1] = z
- p[GEN] = x
- return
-";
- let program = compile(&parse(src).expect("parse"));
- let want = [F192::from(g_pow(12)), F192::from(g_pow(5))];
- let (proof, _) = prove(&program, want, lean_vm::pcs::TEST_LOG_INV_RATE).unwrap();
- verify(&program, &want, &proof).expect("inequality program verifies");
-
- let bad = [F192::from(g_pow(11)), F192::from(g_pow(5))];
- assert!(
- verify(&program, &bad, &proof).is_err(),
- "wrong public input must be rejected"
- );
-}
-
-/// The adversarial case: two hinted cells the prover sets *equal*, asserted
-/// unequal. Honest witness (distinct) verifies; the equal witness leaves
-/// `p = 0·inv = 0`, so `SET p = 1` conflicts and no valid proof continues. No
-/// inverse hint can rescue it, which is the whole soundness argument.
-#[test]
-fn assert_ne_runtime_equal_rejected() {
- let src = "\
-def main():
- v = StackBuf(2)
- hint_witness(v[0:2], \"vals\")
- assert v[0] != v[1]
- p = 1
- p[1] = v[0]
- p[GEN] = v[1]
- return
-";
- let run = |a: F64, b: F64| -> bool {
- let mut program = compile(&parse(src).expect("parse"));
- program.set_witness("vals", vec![vec![F192::from(a), F192::from(b)]]);
- let pi = [F192::from(a), F192::from(b)];
- prove(&program, pi, lean_vm::pcs::TEST_LOG_INV_RATE)
- .is_ok_and(|(proof, _)| verify(&program, &pi, &proof).is_ok())
- };
- assert!(run(g_pow(3), g_pow(5)), "distinct hints must verify");
- assert!(!run(g_pow(3), g_pow(3)), "equal hints must be rejected by `assert !=`");
-}
-
-/// `assert a != b` inside a `mul_range` body: the check is emitted once per
-/// compiled body and runs on every iteration, each of which differs from the
-/// fixed value, so the honest loop verifies.
-#[test]
-fn assert_ne_in_loop() {
- let src = "\
-def main():
- c = GEN ** 9
- for i in mul_range(1, GEN ** 6):
- assert i != c
- p = 1
- p[1] = 5
- p[GEN] = 7
- return
-";
- let program = compile(&parse(src).expect("parse"));
- let want = [F192::from(F64(5)), F192::from(F64(7))];
- let (proof, _) = prove(&program, want, lean_vm::pcs::TEST_LOG_INV_RATE).unwrap();
- verify(&program, &want, &proof).expect("loop inequality verifies");
-}
-
-/// A compile-time-equal literal pair (e.g. after `Const`-arg substitution) is a
-/// hard compile error: the assertion could never hold, so it is caught early.
-#[test]
-#[should_panic(expected = "compile-time-equal")]
-fn assert_ne_compile_time_equal_rejected() {
- let src = "def main():\n assert 5 != 5\n return\n";
- let _ = compile(&parse(src).expect("parse"));
-}
-
-/// Opcode counts of a body with and without one `assert !=`.
-fn opcode_delta(with: &str, without: &str) -> (i64, i64, i64) {
- let count = |src: &str| {
- let p = compile(&parse(src).expect("parse"));
- let (mut xor, mut mul, mut jump) = (0i64, 0i64, 0i64);
- for op in &p.prog {
- match op {
- Op::Xor { .. } => xor += 1,
- Op::Mul { .. } => mul += 1,
- Op::Jump { .. } => jump += 1,
- _ => {}
- }
- }
- (xor, mul, jump)
- };
- let (a, b) = (count(with), count(without));
- (a.0 - b.0, a.1 - b.1, a.2 - b.2)
-}
-
-/// The check costs one `XOR`, one `MUL` and, above all, no `JUMP`: a
-/// branch-based lowering would put an `E`-valued condition back on the one table
-/// that carries constraints. The `SET` that closes the check is not counted,
-/// constant materialisation elsewhere moving with the frame layout.
-#[test]
-fn assert_ne_emits_no_jump() {
- let body = |extra: &str| {
- format!(
- "\
-def main():
- x = GEN ** 5
- y = GEN ** 7
-{extra} p = 1
- p[1] = x
- p[GEN] = y
- return
-"
- )
- };
- let delta = opcode_delta(&body(" assert x != y\n"), &body(""));
- assert_eq!(delta, (1, 1, 0), "one XOR, one MUL, no JUMP");
-}
-
-/// The check survives cell sharing. `SET p = 1` writes a constant another cell
-/// may already hold, and `MUL p = x·inv` a product that could otherwise be
-/// shared; both are kept because `p` is written twice, which is what makes the
-/// write-once conflict the assertion. Dropping either would delete the check
-/// silently, so this pins it: the same product exists elsewhere in the frame,
-/// and a `1` is already live.
-#[test]
-fn assert_ne_survives_cell_sharing() {
- let src = "\
-def main():
- one = 1
- v = StackBuf(2)
- hint_witness(v[0:2], \"vals\")
- d = v[0] + v[1]
- spare = d * one
- assert v[0] != v[1]
- p = 1
- p[1] = spare
- p[GEN] = one
- return
-";
- let run = |a: F64, b: F64| -> bool {
- let mut program = compile(&parse(src).expect("parse"));
- program.set_witness("vals", vec![vec![F192::from(a), F192::from(b)]]);
- let pi = [F192::from(a) + F192::from(b), F192::ONE];
- prove(&program, pi, lean_vm::pcs::TEST_LOG_INV_RATE)
- .is_ok_and(|(proof, _)| verify(&program, &pi, &proof).is_ok())
- };
- assert!(run(g_pow(3), g_pow(5)), "distinct hints must verify");
- assert!(
- !run(g_pow(4), g_pow(4)),
- "equal hints must be rejected even with a live `1` and a shareable product"
- );
-}
-
-/// The inverse is prover advice, so the guest-level idiom must reject a wrong
-/// one. Written out by hand here, the way a guest would if it hinted its own
-/// inverse: only `inv = (a+b)⁻¹` makes the product `1`.
-#[test]
-fn assert_ne_wrong_inverse_hint_rejected() {
- let src = "\
-def main():
- v = StackBuf(3)
- hint_witness(v[0:3], \"vals\")
- d = v[0] + v[1]
- prod = d * v[2]
- assert prod == 1
- p = 1
- p[1] = v[0]
- p[GEN] = v[1]
- return
-";
- let run = |a: F192, b: F192, inv: F192| -> bool {
- let mut program = compile(&parse(src).expect("parse"));
- program.set_witness("vals", vec![vec![a, b, inv]]);
- prove(&program, [a, b], lean_vm::pcs::TEST_LOG_INV_RATE)
- .is_ok_and(|(proof, _)| verify(&program, &[a, b], &proof).is_ok())
- };
- let (a, b) = (F192::from(g_pow(3)), F192::from(g_pow(5)));
- let d = a + b;
- assert!(run(a, b, d.inv()), "the true inverse verifies");
- assert!(!run(a, b, d.inv() + F192::ONE), "a wrong inverse must be rejected");
- assert!(!run(a, a, F192::ONE), "equal sides admit no inverse at all");
-}
diff --git a/crates/lean_compiler/tests/suite/common/mod.rs b/crates/lean_compiler/tests/suite/common/mod.rs
deleted file mode 100644
index ee558f2b9..000000000
--- a/crates/lean_compiler/tests/suite/common/mod.rs
+++ /dev/null
@@ -1,32 +0,0 @@
-//! Helpers shared by the integration tests, reached as `crate::common::…`.
-#![allow(dead_code)]
-
-use lean_compiler::{compile_without_filler, parse};
-use primitives::field::F192;
-
-/// The program's own instruction mix: a build without the fill blocks, executed but not
-/// proven. Proving needs them, since a table's height has to be a power of two with no
-/// padding rows, but their dummy rows would drown out exactly what these counts are
-/// measuring.
-pub fn mix(src: &str, pi: [F192; 2]) -> [usize; lean_vm::cpu::Stats::TABLES.len()] {
- compile_without_filler(&parse(src).expect("parse"))
- .execute(pi)
- .unwrap()
- .base_counts
-}
-
-/// An AST's shape with source lines stripped. Two spellings of the same program
-/// (a constant against its substituted value, a placeholder against the filled
-/// text) are the same program but rarely occupy the same lines, so comparing
-/// them by `Debug` has to ignore that field.
-pub fn without_lines(ast: &lean_compiler::Ast) -> String {
- let d = format!("{ast:?}");
- let (mut out, mut rest) = (String::with_capacity(d.len()), d.as_str());
- while let Some(i) = rest.find("line: ") {
- out.push_str(&rest[..i]);
- let after = &rest[i + "line: ".len()..];
- rest = &after[after.find(", ").expect("`line` is followed by another field") + 2..];
- }
- out.push_str(rest);
- out
-}
diff --git a/crates/lean_compiler/tests/suite/const_placeholder.rs b/crates/lean_compiler/tests/suite/const_placeholder.rs
deleted file mode 100644
index df7e0c19c..000000000
--- a/crates/lean_compiler/tests/suite/const_placeholder.rs
+++ /dev/null
@@ -1,322 +0,0 @@
-//! Global constants and compile-time placeholders in the zkDSL.
-//!
-//! A top-level `NAME = ` is a **global constant**: it is evaluated
-//! to its field value and substituted (as one literal) everywhere its name
-//! appears below: so a constant is usable in every position a literal is,
-//! including `StackBuf`/`HeapBuf` sizes, `**` exponents, and `assert log _ < _`
-//! bounds. A **placeholder** is any identifier text-replaced before parsing via
-//! [`parse_with_replacements`]; the idiom is a placeholder feeding a constant
-//! (`V = V_PLACEHOLDER` with `"V_PLACEHOLDER" ↦ "128"`), as in leanVM.
-
-use std::collections::BTreeMap;
-
-use lean_compiler::{compile, parse, parse_with_replacements};
-use lean_vm::cpu::{prove, verify};
-use primitives::field::g_pow;
-
-/// A global constant substitutes exactly like writing its value inline: even
-/// in a `StackBuf` size, which demands a parse-time literal. The two programs
-/// produce identical ASTs.
-#[test]
-fn const_inlines_like_literal() {
- let with_const = "\
-N = 5
-
-def main():
- a = StackBuf(N)
- a[0] = N
- a[1] = N + 2
- assert a[0] == 5
- return
-";
- let inlined = "\
-def main():
- a = StackBuf(5)
- a[0] = 5
- a[1] = 5 + 2
- assert a[0] == 5
- return
-";
- let ac = parse(with_const).expect("const program parses");
- let ai = parse(inlined).expect("inlined program parses");
- assert_eq!(
- crate::common::without_lines(&ac),
- crate::common::without_lines(&ai),
- "constant must inline to its value"
- );
- let _ = compile(&ac); // and it lowers to a real program
-}
-
-/// A constant may be used as a `**` exponent and an `assert log _ < _` bound -
-/// positions that previously required a bare integer literal.
-#[test]
-fn const_in_literal_only_positions() {
- let src = "\
-LEN = 3
-BOUND = 8
-
-def main():
- x = GEN ** LEN
- assert log x < BOUND
- return
-";
- let inlined = "\
-def main():
- x = GEN ** 3
- assert log x < 8
- return
-";
- assert_eq!(
- crate::common::without_lines(&parse(src).unwrap()),
- crate::common::without_lines(&parse(inlined).unwrap()),
- );
- let _ = compile(&parse(src).unwrap());
-}
-
-/// A global constant may be a g-power, which is how the ISA writes every
-/// address and index.
-///
-/// The scalar path tried an `f192` literal, then an integer expression, and
-/// stopped, so `GEN ** 2` was rejected as "not a compile-time integer constant
-/// expression" while `f192(4, 0, 0)` naming the same element was accepted. It
-/// now falls back to the field evaluator and renders the value as a decimal
-/// wherever it fits the low two limbs, so the constant still works in the
-/// positions that demand a literal rather than only as a value.
-#[test]
-fn a_global_constant_may_be_a_g_power() {
- for (decl, exp) in [("GEN ** 2", 2usize), ("GEN * GEN", 2), ("GEN ** 70", 70)] {
- let src = format!(
- "STEP = {decl}
-
-def main():
- p = GEN ** 0
- p[1] = STEP
- p[GEN] = GEN ** 0
- return
-"
- );
- let program = compile(&parse(&src).unwrap_or_else(|e| panic!("`{decl}`: {e}")));
- let want = [g_pow(exp).into(), g_pow(0).into()];
- let (proof, _) = prove(&program, want, lean_vm::pcs::TEST_LOG_INV_RATE).unwrap();
- verify(&program, &want, &proof).unwrap_or_else(|e| panic!("`{decl}` is not g^{exp}: {e:?}"));
- }
-}
-
-/// A constant may reference an earlier constant (chaining). `B = A` gives `B`
-/// the value of `A`; both are usable as sizes.
-#[test]
-fn const_chains() {
- let src = "\
-A = 4
-B = A
-
-def main():
- p = StackBuf(A)
- q = StackBuf(B)
- return
-";
- let inlined = "\
-def main():
- p = StackBuf(4)
- q = StackBuf(4)
- return
-";
- assert_eq!(
- crate::common::without_lines(&parse(src).unwrap()),
- crate::common::without_lines(&parse(inlined).unwrap()),
- );
- let _ = compile(&parse(src).unwrap());
-}
-
-/// Constant expressions use **integer** arithmetic (`+ - * / **`), not runtime
-/// field arithmetic, so derived sizes/counts come out right. Filled
-/// via placeholders, the whole set of derivations resolves to plain literals.
-#[test]
-fn const_integer_arithmetic_derivations() {
- let templated = "\
-V = V_PLACEHOLDER
-W = W_PLACEHOLDER
-LOG_LIFETIME = LOG_LIFETIME_PLACEHOLDER
-CHAIN_STEPS = W - 1
-N_TWEAK_WORDS = 2 + CHAIN_STEPS * V + LOG_LIFETIME
-N_TWEAK_BLOCKS = N_TWEAK_WORDS / 2
-FIXED_BLOCKS = 1 + N_TWEAK_BLOCKS + LOG_LIFETIME / 2
-FIXED_BYTES = FIXED_BLOCKS * 32
-N_SIGS_BOUND = 2 ** 16
-
-def main():
- a = StackBuf(N_TWEAK_WORDS)
- x = GEN ** FIXED_BYTES
- assert log x < N_SIGS_BOUND
- for i in unroll(0, N_TWEAK_BLOCKS):
- assert a[0] == W
- return
-";
- // V = 42, W = 8, LOG_LIFETIME = 32 → the standard XMSS instance.
- let mut repl = BTreeMap::new();
- repl.insert("V_PLACEHOLDER".to_string(), "42".to_string());
- repl.insert("W_PLACEHOLDER".to_string(), "8".to_string());
- repl.insert("LOG_LIFETIME_PLACEHOLDER".to_string(), "32".to_string());
- let filled = parse_with_replacements(templated, &repl).expect("derivations resolve");
-
- // N_TWEAK_WORDS = 2 + 7*42 + 32 = 328, N_TWEAK_BLOCKS = 164,
- // FIXED_BLOCKS = 1 + 164 + 16 = 181, FIXED_BYTES = 5792, N_SIGS_BOUND = 65536.
- let concrete = "\
-def main():
- a = StackBuf(328)
- x = GEN ** 5792
- assert log x < 65536
- for i in unroll(0, 164):
- assert a[0] == 8
- return
-";
- assert_eq!(
- crate::common::without_lines(&filled),
- crate::common::without_lines(&parse(concrete).unwrap())
- );
- let _ = compile(&filled);
-}
-
-/// `const(...)` is TRANSPARENT in a parse-time position, and the test is that
-/// the wrapped and bare spellings parse to the same AST.
-///
-/// The wrapper means "read this with integer arithmetic". A size, a count, an
-/// exponent, a bound and a stack index have no other reading, so it changes
-/// nothing there. It was a parse error in a `StackBuf` size, a `log` bound and a
-/// top-level constant while being accepted in a `HeapBuf` size, an `unroll` count
-/// and a `GEN **` exponent, which made one construct mean two things depending on
-/// where it stood.
-#[test]
-fn const_is_transparent_where_the_reading_is_already_integer() {
- for (wrapped, bare) in [
- ("s = StackBuf(const(2 + 2))", "s = StackBuf(4)"),
- ("h = HeapBuf(const(2 + 2))", "h = HeapBuf(4)"),
- ("x = GEN ** const(1 + 1)", "x = GEN ** 2"),
- ] {
- let src = |b: &str| format!("def main():\n {b}\n return\n");
- assert_eq!(
- crate::common::without_lines(&parse(&src(wrapped)).unwrap_or_else(|e| panic!("{wrapped}: {e}"))),
- crate::common::without_lines(&parse(&src(bare)).expect("bare")),
- "`{wrapped}` must parse as `{bare}`"
- );
- }
- // A `log` bound and an `unroll` count, which are their own parse paths.
- let bound = |b: &str| format!("def main():\n v = GEN ** 2\n assert log v < {b}\n return\n");
- assert_eq!(
- crate::common::without_lines(&parse(&bound("const(4 + 4)")).expect("wrapped bound")),
- crate::common::without_lines(&parse(&bound("8")).expect("bare bound")),
- );
- // An `unroll` count keeps its expression for the lowerer to fold, in either
- // spelling, so the baseline is the unwrapped expression rather than a literal.
- let count = |b: &str| format!("def main():\n for i in unroll(0, {b}):\n v = 1\n return\n");
- let unrolled = |b: &str| compile(&parse(&count(b)).unwrap_or_else(|e| panic!("{b}: {e}"))).code_len();
- assert_eq!(
- unrolled("const(1 + 1)"),
- unrolled("1 + 1"),
- "the count must fold the same"
- );
- assert_eq!(unrolled("const(1 + 1)"), unrolled("2"));
- // And a global constant, where the whole declaration is already integer.
- assert_eq!(
- crate::common::without_lines(
- &parse("N = const(3 + 1)\n\ndef main():\n s = StackBuf(N)\n return\n").expect("wrapped")
- ),
- crate::common::without_lines(&parse("N = 4\n\ndef main():\n s = StackBuf(N)\n return\n").expect("bare")),
- );
- // Transparent means transparent: an illegal value is still illegal, so the
- // wrapper is no route past a bound the bare spelling would fail.
- for b in ["0", "const(0)"] {
- let ast = parse(&bound(b)).expect("parses");
- let Err(err) = std::panic::catch_unwind(|| compile(&ast)) else {
- panic!("`{b}` was accepted as a bound");
- };
- let msg = err.downcast_ref::().map(String::as_str).unwrap_or("");
- assert!(msg.contains("empty set"), "{b}: got `{msg}`");
- }
-}
-
-/// A placeholder is text-replaced before parsing; feeding a constant is the
-/// idiom. The filled program equals the one written with the value inline.
-#[test]
-fn placeholder_fills_constant() {
- let templated = "\
-V = V_PLACEHOLDER
-
-def main():
- a = StackBuf(V)
- a[0] = V
- assert a[0] == 7
- return
-";
- let mut repl = BTreeMap::new();
- repl.insert("V_PLACEHOLDER".to_string(), "7".to_string());
- let filled = parse_with_replacements(templated, &repl).expect("placeholder fills");
-
- let concrete = "\
-def main():
- a = StackBuf(7)
- a[0] = 7
- assert a[0] == 7
- return
-";
- assert_eq!(
- crate::common::without_lines(&filled),
- crate::common::without_lines(&parse(concrete).unwrap())
- );
- let _ = compile(&filled);
-}
-
-/// Replacement is identifier-bounded: a key does not match a substring of a
-/// longer identifier.
-#[test]
-fn placeholder_is_identifier_bounded() {
- let src = "\
-def main():
- FOOBAR = 1
- x = FOOBAR
- assert x == 1
- return
-";
- let mut repl = BTreeMap::new();
- repl.insert("FOO".to_string(), "999".to_string());
- // `FOO` must NOT rewrite the `FOO` inside `FOOBAR`.
- assert_eq!(
- crate::common::without_lines(&parse_with_replacements(src, &repl).unwrap()),
- crate::common::without_lines(&parse(src).unwrap()),
- );
-}
-
-/// An unfilled placeholder (or an undeclared constant) is a clear error, and a
-/// constant may not be declared twice.
-#[test]
-fn errors() {
- let unfilled = "\
-V = V_PLACEHOLDER
-
-def main():
- return
-";
- let err = parse(unfilled).expect_err("an unfilled placeholder must fail");
- assert!(
- err.contains("V_PLACEHOLDER"),
- "error should name the placeholder: {err}"
- );
-
- let dup = "\
-N = 1
-N = 2
-
-def main():
- return
-";
- assert!(parse(dup).is_err(), "a constant declared twice must fail");
-
- // A top-level line that is neither a `def` nor a `NAME = value` is rejected.
- let junk = "\
-1 + 1
-
-def main():
- return
-";
- assert!(parse(junk).is_err(), "malformed top-level line must fail");
-}
diff --git a/crates/lean_compiler/tests/suite/determinism.rs b/crates/lean_compiler/tests/suite/determinism.rs
deleted file mode 100644
index 68ed44ef5..000000000
--- a/crates/lean_compiler/tests/suite/determinism.rs
+++ /dev/null
@@ -1,107 +0,0 @@
-//! One source compiles to one program, always, and the same program it compiled
-//! to yesterday.
-//!
-//! The bytecode digest leads the Fiat--Shamir transcript, so two builds of one
-//! source that disagree are two incompatible proof systems, and the symptom is a
-//! proof that stops verifying rather than a crash.
-//!
-//! Two different properties, and only the first is about determinism:
-//!
-//! * *Within a process*, compiling twice is a real perturbation rather than a
-//! repeat, since `RandomState` bumps its seed once per map, so the second
-//! compilation hashes with different keys than the first.
-//! * *Across commits*, `GOLDEN` is a SNAPSHOT of the compiler's output. It does
-//! not prove determinism (nothing iterating a hash container reaches the
-//! bytecode today, and deliberately reversing the branch-output order at a join
-//! moves no digest). It earns its place a different way: a codegen change that
-//! was not intended shows up here and nowhere else, and every entry that moved
-//! this far was a change someone then had to justify.
-//!
-//! So a moved digest is a question, not a chore: update `GOLDEN` in the same
-//! commit and say in the message which change moved it.
-
-use std::collections::BTreeMap;
-use std::fs;
-
-use lean_compiler::{compile, parse};
-use lean_vm::cpu::Program;
-
-/// `tests/programs/.py` against the digest of the bytecode it compiles to.
-/// The list is closed: a new program must be added here, so one cannot be added
-/// without a digest.
-#[rustfmt::skip]
-const GOLDEN: &[(&str, &str)] = &[
- ("conditionals", "e8df2b807d3eed366d83843e207f0a9441c1e2014ee0add958a399af562a761c"),
- ("const_params", "a746b339afc434c52c4c04388e0af25054eef20405a9c99a6a6d59987a37d0de"),
- ("fibonacci", "1419063250d0c54fa808499fdca691748dfb91f84f1007c6f0e939d5ef6779b6"),
- ("hash_heap_chain", "9d8735c8393dd6cde7e07a71277a52435ca903186c6a984161b2b8821d6b552d"),
- ("hash_slices", "99b3de0af47b797b51b8f38f37b5ccb7ff64d3f5acd9ba23bdd1ff4cfc47e9c3"),
- ("heapbuf_dyn", "9e570732cf9258379eec6caf1efb2bd2d8ed484b5aa8c7b7da5360d2a79103ef"),
- ("hint", "d9601df070184a3f1a5702d769e3897013c17a264889161df8ae7efe0daecc13"),
- ("identities", "49a2bbd6bf785786f2ce8bf8f63a57a21bb6c547eae0c0f245f20a5222ac1c7a"),
- ("match", "6a7265d2ed56e512c939f76024afea98e5cd707aee4776df09c2e4a863208354"),
- ("match_arms", "c1b74b466538ca9387ae43db9b86c621decf70bf3ae92e73e6634b37b5afb616"),
- ("nested", "2d5c743a2692704207e1c45ea2518a504da1f3a18743a204600bc6ed04bdc87d"),
- ("runtime_loop", "f33c6b5b82ed8ade198fb8978ba443554fc8714082a98dc784dbabdcee92e0fe"),
- ("scoping", "c466babcc1af1deba56dda628e730d815d2fdffe065d695f9118167e0bb8669f"),
- ("unroll", "08ebe1f4b51d862c6335b90694cf60d2fd2841d3a9913f6400cb53322117d309"),
- ("wots_walk", "82f5dc859eec827ec2862c423fc20a2f83c687081f7fe6e37a84a414bf3ae720"),
-];
-
-fn digest(p: &Program) -> String {
- primitives::hash::hash(format!("{:?}", p.prog).as_bytes())
- .iter()
- .map(|b| format!("{b:02x}"))
- .collect()
-}
-
-/// Every program in `tests/programs/`, compiled twice.
-#[test]
-fn bytecode_is_reproducible() {
- let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/programs");
- let mut paths: Vec<_> = fs::read_dir(dir)
- .expect("tests/programs")
- .map(|e| e.expect("dir entry").path())
- .filter(|p| p.extension().is_some_and(|x| x == "py"))
- .collect();
- paths.sort();
- assert!(!paths.is_empty(), "no .py programs found");
-
- let mut actual: Vec<(String, String)> = Vec::new();
- for path in &paths {
- let name = path.file_stem().expect("file stem").to_string_lossy().into_owned();
- let src = fs::read_to_string(path).unwrap_or_else(|e| panic!("{name}: read: {e}"));
- let one = compile(&parse(&src).unwrap_or_else(|e| panic!("{name}: parse: {e}")));
- let two = compile(&parse(&src).unwrap_or_else(|e| panic!("{name}: parse: {e}")));
- assert_eq!(
- digest(&one),
- digest(&two),
- "{name}: two compilations of one source produced different bytecode, \
- so the compiler is reading a hash seed"
- );
- actual.push((name, digest(&one)));
- }
-
- let want: BTreeMap<&str, &str> = GOLDEN.iter().copied().collect();
- let moved: Vec<&str> = actual
- .iter()
- .filter(|(n, d)| want.get(n.as_str()) != Some(&d.as_str()))
- .map(|(n, _)| n.as_str())
- .collect();
- let dropped: Vec<&str> = want
- .keys()
- .copied()
- .filter(|n| !actual.iter().any(|(a, _)| a == n))
- .collect();
- assert!(
- dropped.is_empty(),
- "GOLDEN names a program that no longer exists: {dropped:?}"
- );
- if !moved.is_empty() {
- let table: String = actual
- .iter()
- .map(|(n, d)| format!(" (\"{n}\", \"{d}\"),\n"))
- .collect();
- panic!("bytecode changed for {moved:?}\n\nif that was intended, GOLDEN is now:\n{table}");
- }
-}
diff --git a/crates/lean_compiler/tests/suite/disassemble.rs b/crates/lean_compiler/tests/suite/disassemble.rs
deleted file mode 100644
index 462f55490..000000000
--- a/crates/lean_compiler/tests/suite/disassemble.rs
+++ /dev/null
@@ -1,47 +0,0 @@
-//! `disassemble` must render every one of the six opcodes without panicking,
-//! so it stays usable for the `DBG_DISASM` workflow (a failed guest `assert`
-//! surfaces as a write-once conflict, and the pc is all you get).
-
-use lean_compiler::{compile, disassemble, parse};
-use primitives::pretty_integer;
-
-#[test]
-fn disassemble_covers_every_opcode() {
- let src = "\
-@inline
-def pack64x2(a, b):
- assert_in_k(a, b)
- return a + f192(0, 1, 0) * b
-
-def main():
- buff = HeapBuf(6)
- buff[1] = 1
- buff[GEN] = GEN
- for i in mul_range(1, GEN ** 4):
- buff[i * GEN ** 2] = buff[i] * buff[i * GEN]
- h = StackBuf(2)
- h[0] = 5
- h[1] = 7
- d = StackBuf(2)
- blake2s(h, h, d)
- packed = pack64x2(5, 7)
- p = 1
- p[1] = buff[GEN ** 4] + packed
- p[GEN] = d[0]
- return
-";
-
- let program = compile(&parse(src).expect("parse"));
-
- println!("\n=== zkDSL source ===\n{src}");
- println!(
- "=== compiled ISA ({} instructions) ===",
- pretty_integer(program.prog.len())
- );
- let text = disassemble(&program.prog);
- print!("{text}");
-
- for mnemonic in ["SET", "XOR", "MUL", "DEREF", "JUMP", "BLAKE2S"] {
- assert!(text.contains(mnemonic), "disassembly is missing {mnemonic}");
- }
-}
diff --git a/crates/lean_compiler/tests/suite/field_div.rs b/crates/lean_compiler/tests/suite/field_div.rs
deleted file mode 100644
index f2166b8dd..000000000
--- a/crates/lean_compiler/tests/suite/field_div.rs
+++ /dev/null
@@ -1,85 +0,0 @@
-//! Field division `a / b` (single slash): a runtime `a · b⁻¹`, distinct from
-//! the compile-time floor-division `//`. It lowers to a single `MUL` whose
-//! quotient operand is left unset: the write-once back-solve fills it with
-//! `a · b⁻¹` and the `MUL` constraint `quotient · b == a` binds it, with no
-//! prover hint (the same back-solve the range-check gadget already uses). A
-//! zero divisor is rejected (the back-solve cannot invert 0).
-
-use lean_compiler::{compile, parse};
-use lean_vm::cpu::{prove, verify};
-use primitives::field::{F64, F192, g_pow};
-
-/// `a / b` and `1 / b` over runtime values: the quotient satisfies `q·b == a`,
-/// checked by publishing it and reproducing the dividend.
-#[test]
-fn field_div_end_to_end() {
- let src = "\
-def main():
- a = GEN ** 20
- b = GEN ** 7
- q = a / b
- r = 1 / b
- p = 1
- p[1] = q * b
- p[GEN] = r * b
- return
-";
- let program = compile(&parse(src).expect("parse"));
- // q·b must reproduce a = g^20; r·b must be 1.
- let want = [F192::from(g_pow(20)), F192::from(F64::ONE)];
- let (proof, _) = prove(&program, want, lean_vm::pcs::TEST_LOG_INV_RATE).unwrap();
- verify(&program, &want, &proof).expect("division program verifies");
-
- let bad = [F192::from(g_pow(21)), F192::from(F64::ONE)];
- assert!(
- verify(&program, &bad, &proof).is_err(),
- "wrong quotient product rejected"
- );
-}
-
-/// `//` stays compile-time floor division (an index), `/` is the runtime field
-/// op: the two must not collide. Here `8 // 2 == 4` picks a stack slot while
-/// `x / y` is a field quotient.
-#[test]
-fn field_div_vs_floordiv() {
- let src = "\
-def main():
- x = GEN ** 6
- q = x / (GEN ** 2)
- z = GEN ** (6 // 2)
- p = 1
- p[1] = q
- p[GEN] = z
- return
-";
- let program = compile(&parse(src).expect("parse"));
- // q = g^6 / g^2 = g^4 (runtime `/`); z = g^(6//2) = g^3 (compile-time `//`).
- let want = [F192::from(g_pow(4)), F192::from(g_pow(3))];
- let (proof, _) = prove(&program, want, lean_vm::pcs::TEST_LOG_INV_RATE).unwrap();
- verify(&program, &want, &proof).expect("mixed //-and-/ program verifies");
-}
-
-/// A zero divisor: the prover hints `b = 0`, and `1 / b` cannot be back-solved
-/// (`1 = q·0` has no solution), so witness generation / verification rejects.
-#[test]
-fn field_div_by_zero_rejected() {
- let src = "\
-def main():
- v = StackBuf(1)
- hint_witness(v[0:1], \"den\")
- r = 1 / v[0]
- p = 1
- p[1] = r * v[0]
- p[GEN] = 1
- return
-";
- let run = |den: F64| -> bool {
- let mut program = compile(&parse(src).expect("parse"));
- program.set_witness("den", vec![vec![F192::from(den)]]);
- let pi = [F192::from(F64::ONE), F192::from(F64::ONE)];
- prove(&program, pi, lean_vm::pcs::TEST_LOG_INV_RATE)
- .is_ok_and(|(proof, _)| verify(&program, &pi, &proof).is_ok())
- };
- assert!(run(g_pow(4)), "nonzero divisor must verify");
- assert!(!run(F64::ZERO), "zero divisor must be rejected");
-}
diff --git a/crates/lean_compiler/tests/suite/field_towers.rs b/crates/lean_compiler/tests/suite/field_towers.rs
deleted file mode 100644
index 2988ed2e3..000000000
--- a/crates/lean_compiler/tests/suite/field_towers.rs
+++ /dev/null
@@ -1,35 +0,0 @@
-//! Random cross-check of F192 = GF((2^64)^3) against its portable reference,
-//! through the same `primitives` re-export path the VM uses. `primitives`
-//! itself only pins the dispatched `Mul` against `software::mul` on a handful
-//! of fixed Python-generated vectors (plus 10k random inputs on aarch64), so on
-//! a pclmulqdq x86 host this is the random-input check on that dispatch.
-
-use primitives::field::{F192, F192Unreduced};
-use rand::Rng;
-
-fn rand_f192(rng: &mut impl Rng) -> F192 {
- F192::new(rng.random(), rng.random(), rng.random())
-}
-
-#[test]
-fn f192_field_behaviour() {
- let mut rng = rand::rng();
- for _ in 0..500 {
- let (a, b, c) = (rand_f192(&mut rng), rand_f192(&mut rng), rand_f192(&mut rng));
- // ring axioms + agreement with the portable reference
- assert_eq!(a * b, primitives::field::gf2_64x3::software::mul(a, b));
- assert_eq!(a * b, b * a);
- assert_eq!((a * b) * c, a * (b * c));
- assert_eq!(a * (b + c), a * b + a * c);
- assert_eq!(a.square(), a * a);
- if !a.is_zero() {
- assert_eq!(a * a.inv(), F192::ONE);
- }
- let mut acc = F192Unreduced::ZERO;
- acc ^= a.mul_unreduced(b);
- acc ^= a.mul_unreduced(c);
- assert_eq!(acc.reduce(), a * b + a * c);
- }
- // y^3 = y + 1 (the defining relation)
- assert_eq!(F192::Y * F192::Y * F192::Y, F192::Y + F192::ONE);
-}
diff --git a/crates/lean_compiler/tests/suite/filler.rs b/crates/lean_compiler/tests/suite/filler.rs
deleted file mode 100644
index c67139c40..000000000
--- a/crates/lean_compiler/tests/suite/filler.rs
+++ /dev/null
@@ -1,58 +0,0 @@
-//! Fill blocks (`lean_compiler::filler`): extra real rows so a table's height needs no
-//! padding.
-//!
-//! What has to hold, and is checked here end to end:
-//!
-//! - every table comes out an exact power of two, whatever the program's row mix, so
-//! nothing is ever padded;
-//! - the fill costs exactly what the solver says, a traversal being its block's rows
-//! plus one jump and nothing else;
-//! - the filled trace proves and verifies.
-//!
-//! The second is what lets the interpreter solve once the chain has halted, in one
-//! interpretation of the program, so it is worth pinning rather than assuming.
-
-use lean_compiler::{compile, parse};
-use lean_vm::cpu::filler;
-use lean_vm::cpu::{prove, verify};
-use primitives::field::F192;
-
-const PROGRAMS: [&str; 5] = [
- // Folds to nothing, so the fill is all there is.
- "def main():\n x = GEN ** 5\n y = x * x\n return\n",
- "def main():\n b = HeapBuf(4)\n b[1] = GEN\n y = b[1] * b[1]\n return\n",
- "def main():\n for i in mul_range(1, GEN ** 20):\n z = i * i\n return\n",
- // A compression, so BLAKE2s is non-empty too.
- "def main():\n a = StackBuf(2)\n a[0] = 5\n a[1] = 7\n c = StackBuf(2)\n blake2s(a, a, c)\n return\n",
- "def main():\n for i in mul_range(1, GEN ** 300):\n z = i + GEN\n return\n",
-];
-
-#[test]
-fn every_table_lands_on_a_power_of_two() {
- for src in PROGRAMS {
- let program = compile(&parse(src).expect("parse"));
- let pi = [F192::ZERO, F192::ZERO];
- let (proof, stats) = prove(&program, pi, lean_vm::pcs::TEST_LOG_INV_RATE).unwrap();
- assert!(filler::is_filled(stats.counts), "{:?} for {src:?}", stats.counts);
- verify(&program, &pi, &proof).expect("a filled program verifies");
- }
-}
-
-/// The solver's cost model is the whole reason one pass suffices, so check it against
-/// what the machine did: from the program's own rows, the plan the interpreter solved
-/// must predict the proven counts exactly.
-#[test]
-fn the_cost_model_is_exact() {
- for src in PROGRAMS {
- let program = compile(&parse(src).expect("parse"));
- let stats = prove(&program, [F192::ZERO, F192::ZERO], lean_vm::pcs::TEST_LOG_INV_RATE)
- .unwrap()
- .1;
- let plan = filler::solve(stats.base_counts, filler::NO_FLOORS).expect("solvable");
- assert_eq!(
- filler::filled(stats.base_counts, &plan),
- stats.counts,
- "predicted against actual for {src:?}"
- );
- }
-}
diff --git a/crates/lean_compiler/tests/suite/hint_log2_ceil.rs b/crates/lean_compiler/tests/suite/hint_log2_ceil.rs
deleted file mode 100644
index 4d66a8c67..000000000
--- a/crates/lean_compiler/tests/suite/hint_log2_ceil.rs
+++ /dev/null
@@ -1,68 +0,0 @@
-//! `hint_log2_ceil(bits, nbits, floor)`: computed advice returning
-//! `g^max(log2_ceil(v), floor)`, where `v` is the integer the `bits` buffer
-//! decodes to. The prover fills it at witness-generation (the runtime has `v`
-//! concretely); it is unconstrained on its own: `log2_ceil` re-verifies it -
-//! so this test checks only that the advice computes the right value.
-
-use lean_compiler::{compile, parse};
-use lean_vm::cpu::{prove, verify};
-use primitives::field::{F64, F192, g_pow};
-
-fn log2_ceil_of(v: u128) -> usize {
- if v <= 1 {
- 0
- } else {
- (128 - (v - 1).leading_zeros()) as usize
- }
-}
-
-#[test]
-fn log2_ceil_advice_computes_the_log() {
- let src = "\
-def main():
- bits = HeapBuf(GEN ** 8)
- hint_witness(bits[0:8], \"bits\")
- g_mu = hint_log2_ceil(bits, 8, 0)
- p = 1
- p[1] = g_mu
- p[GEN] = 1
- return
-";
- for v in [1u128, 2, 3, 4, 5, 7, 8, 200] {
- let mut program = compile(&parse(src).expect("parse"));
- let bits: Vec = (0..8).map(|j| F192::from(F64(((v >> j) & 1) as u64))).collect();
- program.set_witness("bits", vec![bits]);
- let want = [F192::from(g_pow(log2_ceil_of(v))), F192::from(F64::ONE)];
- let (proof, _) = prove(&program, want, lean_vm::pcs::TEST_LOG_INV_RATE).unwrap();
- verify(&program, &want, &proof).unwrap_or_else(|_| panic!("v={v}: log2_ceil advice must verify"));
- let bad = [F192::from(g_pow(log2_ceil_of(v) + 1)), F192::from(F64::ONE)];
- assert!(
- verify(&program, &bad, &proof).is_err(),
- "v={v}: wrong g_mu must be rejected"
- );
- }
-}
-
-/// The `floor` argument: `max(log2_ceil(v), floor)`. With floor = 5, small
-/// values are lifted to g^5.
-#[test]
-fn log2_ceil_advice_floor() {
- let src = "\
-def main():
- bits = HeapBuf(GEN ** 8)
- hint_witness(bits[0:8], \"bits\")
- g_mu = hint_log2_ceil(bits, 8, 5)
- p = 1
- p[1] = g_mu
- p[GEN] = 1
- return
-";
- for (v, mu) in [(2u128, 5usize), (4, 5), (64, 6), (200, 8)] {
- let mut program = compile(&parse(src).expect("parse"));
- let bits: Vec = (0..8).map(|j| F192::from(F64(((v >> j) & 1) as u64))).collect();
- program.set_witness("bits", vec![bits]);
- let want = [F192::from(g_pow(mu)), F192::from(F64::ONE)];
- let (proof, _) = prove(&program, want, lean_vm::pcs::TEST_LOG_INV_RATE).unwrap();
- verify(&program, &want, &proof).unwrap_or_else(|_| panic!("v={v}: floored log2_ceil must verify"));
- }
-}
diff --git a/crates/lean_compiler/tests/suite/inline_expr.rs b/crates/lean_compiler/tests/suite/inline_expr.rs
deleted file mode 100644
index 3a0bd768b..000000000
--- a/crates/lean_compiler/tests/suite/inline_expr.rs
+++ /dev/null
@@ -1,62 +0,0 @@
-//! `@inline` calls in EXPRESSION position: embedded in arithmetic, as a heap
-//! store's RHS, or under further ops: must produce the same values as the
-//! statement-position form. Regression test for the dropped-RetBind bug: an
-//! inlined tail return of a plain var records a g-address alias, which only
-//! `let`/tuple bindings used to consume; expression positions read the
-//! never-written dst cell (zeros) and left the stale bind to corrupt the next
-//! `let`.
-
-use lean_compiler::{compile, parse};
-use lean_vm::cpu::{prove, verify};
-use primitives::field::{F64, F192};
-
-#[test]
-fn inline_call_in_expression_positions() {
- let src = "\
-@inline
-def wprod(ch, n: Const, idx: Const):
- # eq-tensor weight of compile-time idx over ch[0..n)
- w = GEN ** 0
- for c in unroll(0, n):
- cv = ch[GEN ** c]
- if (idx // (2 ** c)) % 2 == 1:
- w *= cv
- else:
- w *= (1 + cv)
- return w
-
-def main():
- b = HeapBuf(4)
- b[1] = 3
- b[GEN] = 5
- x = wprod(b, 2, 2)
- y = 7 * wprod(b, 2, 1)
- out = HeapBuf(2)
- out[1] = wprod(b, 2, 3)
- p = 1
- p[1] = x
- p[GEN] = y + out[1]
- return
-";
- let program = compile(&parse(src).expect("parse"));
-
- let (f3, f5, f7) = (F64(3), F64(5), F64(7));
- let one = F64::ONE;
- // statement position: idx 2 -> (1+3)·5
- let x = (one + f3) * f5;
- // embedded in a product: idx 1 -> 7·(3·(1+5))
- let y = f7 * (f3 * (one + f5));
- // heap-store RHS: idx 3 -> 3·5
- let o = f3 * f5;
- let want = [F192::from(x), F192::from(y + o)];
-
- let (proof, _) = prove(&program, want, lean_vm::pcs::TEST_LOG_INV_RATE).unwrap();
- verify(&program, &want, &proof).expect("expression-position inline calls compute correctly");
-
- let mut bad = want;
- bad[0] += F192::ONE;
- assert!(
- verify(&program, &bad, &proof).is_err(),
- "wrong published value must be rejected"
- );
-}
diff --git a/crates/lean_compiler/tests/suite/loop_frames.rs b/crates/lean_compiler/tests/suite/loop_frames.rs
deleted file mode 100644
index f098094f0..000000000
--- a/crates/lean_compiler/tests/suite/loop_frames.rs
+++ /dev/null
@@ -1,130 +0,0 @@
-use lean_compiler::{compile, compile_without_filler, parse};
-use lean_vm::cpu::{prove, verify};
-use primitives::field::{F64, F192, g_pow};
-
-#[test]
-fn loop_frames_preserve_escaped_cells_and_nested_allocations() {
- lean_vm::init_prover_pool();
- let source = r#"
-def make_heap(x):
- h = HeapBuf(2)
- h[1] = x
- h[GEN] = x * x
- return h
-
-def run(stop):
- saved = HeapBuf(GEN ** 12)
- heaps = HeapBuf(GEN ** 12)
- nested = HeapBuf(GEN ** 36)
- sums = HeapBuf(GEN ** 13)
- sums[GEN ** 2] = 0
- for x in mul_range(GEN ** 2, stop):
- pair = [x, x * x]
- saved[x] = addr(pair)
- heaps[x] = make_heap(x)
- for y in mul_range(1, GEN ** 3):
- local = [x, y]
- nested[x ** 3 * y] = addr(local)
- sums[x * GEN] = sums[x] + x
- for x in mul_range(GEN ** 2, stop):
- p = saved[x]
- h = heaps[x]
- assert p[1] == x
- assert p[GEN] == x * x
- assert h[1] == x
- assert h[GEN] == x * x
- for y in mul_range(1, GEN ** 3):
- q = nested[x ** 3 * y]
- assert q[1] == x
- assert q[GEN] == y
- return sums[stop]
-
-def main():
- public = 1
- result = run(public[GEN])
- assert result == public[1]
- return
-"#;
- let program = compile(&parse(source).unwrap());
- for end in [2, 3, 9] {
- let sum = (2..end).fold(F64::ZERO, |sum, i| sum + g_pow(i));
- let public = [F192::from(sum), F192::from(g_pow(end))];
- assert!(program.execute(public).unwrap().unconstrained_reads.is_empty());
- if end == 9 {
- let (proof, _) = prove(&program, public, lean_vm::pcs::TEST_LOG_INV_RATE).unwrap();
- verify(&program, &public, &proof).unwrap();
- }
- }
-}
-
-#[test]
-fn early_return_does_not_reserve_the_unused_range() {
- let source = r#"
-def main():
- out = HeapBuf(1)
- for x in mul_range(1, GEN ** 4294967296):
- if x == 1:
- out[1] = 7
- return
- public = 1
- assert public[1] == out[1]
- return
-"#;
- let program = compile_without_filler(&parse(source).unwrap());
- let execution = program.execute([F192::from(F64(7)), F192::ZERO]).unwrap();
- assert!(execution.unconstrained_reads.is_empty());
-}
-
-#[test]
-fn runtime_frame_count_uses_the_distance_from_the_start() {
- let source = r#"
-def main():
- public = 1
- out = HeapBuf(2)
- for x in mul_range(GEN ** 65535, public[GEN]):
- if x == GEN ** 65535:
- out[1] = x
- else:
- out[GEN] = x
- assert out[GEN] == public[1]
- return
-"#;
- let program = compile_without_filler(&parse(source).unwrap());
- assert!(
- program
- .execute([g_pow(65536).into(), g_pow(65537).into()])
- .unwrap()
- .unconstrained_reads
- .is_empty()
- );
-}
-
-#[test]
-fn rebound_counter_keeps_incremental_frames() {
- lean_vm::init_prover_pool();
- let source = r#"
-def bump(x):
- if x == 1:
- return GEN ** 5
- if x == GEN ** 6:
- return 1
- return x
-
-def main():
- public = 1
- seen = HeapBuf(7)
- for x in mul_range(1, STOP):
- seen[x] = x
- x = bump(x)
- assert seen[GEN ** 6] == GEN ** 6
- assert seen[GEN] == GEN
- return
-"#;
- let public = [F192::ZERO, g_pow(2).into()];
- for bound in ["GEN ** 2", "public[GEN]"] {
- let program = compile(&parse(&source.replace("STOP", bound)).unwrap());
- assert!(program.execute(public).unwrap().unconstrained_reads.is_empty());
- let (proof, _) = prove(&program, public, lean_vm::pcs::TEST_LOG_INV_RATE).unwrap();
- verify(&program, &public, &proof).unwrap();
- }
-}
diff --git a/crates/lean_compiler/tests/suite/main.rs b/crates/lean_compiler/tests/suite/main.rs
deleted file mode 100644
index f7d070318..000000000
--- a/crates/lean_compiler/tests/suite/main.rs
+++ /dev/null
@@ -1,28 +0,0 @@
-//! Compiler integration tests share one binary and its initialization caches.
-//!
-//! These tests leave the proving arena disabled. A test that enables it needs
-//! its own process (see `rec_aggregation`'s `arena_prove`).
-
-mod common;
-
-mod assert_ne;
-mod const_placeholder;
-mod determinism;
-mod disassemble;
-mod field_div;
-mod field_towers;
-mod filler;
-mod hint_log2_ceil;
-mod inline_expr;
-mod loop_frames;
-mod pack64x2;
-mod print_debug;
-mod py_source;
-mod range_check;
-mod sharing;
-mod soundness;
-mod stack_bits;
-mod stack_buf;
-mod statements;
-mod transcript_helpers;
-mod vm_proofs;
diff --git a/crates/lean_compiler/tests/suite/pack64x2.rs b/crates/lean_compiler/tests/suite/pack64x2.rs
deleted file mode 100644
index eb082c492..000000000
--- a/crates/lean_compiler/tests/suite/pack64x2.rs
+++ /dev/null
@@ -1,69 +0,0 @@
-use lean_compiler::{compile, parse};
-use lean_vm::cpu::{Fault, prove, verify};
-use primitives::field::{F64, F192};
-
-use crate::common::mix;
-
-#[test]
-fn pack64x2_proves_and_verifies() {
- let src = "\
-@inline
-def pack64x2(a, b):
- assert_in_k(a, b)
- return a + f192(0, 1, 0) * b
-
-def main():
- a = 5
- b = 7
- packed = pack64x2(a, b)
- p = 1
- p[1] = packed
- p[GEN] = packed
- return
-";
- let program = compile(&parse(src).expect("parse"));
- let want = [F192::new(5, 7, 0), F192::new(5, 7, 0)];
- let (proof, _) = prove(&program, want, lean_vm::pcs::TEST_LOG_INV_RATE).unwrap();
- let counts = mix(src, want);
- assert_eq!(
- (counts[0], counts[1], counts[4]),
- (1, 2, 2),
- "XOR, MUL and JUMP lowering"
- );
- verify(&program, &want, &proof).expect("pack64x2 program verifies");
-}
-
-#[test]
-fn pack64x2_rejects_extension_field_source() {
- let src = "\
-@inline
-def pack64x2(a, b):
- assert_in_k(a, b)
- return a + f192(0, 1, 0) * b
-
-def main():
- a = StackBuf(1)
- hint_witness(a[0:1], \"a\")
- packed = pack64x2(a[0], 7)
- p = 1
- p[1] = packed
- p[GEN] = packed
- return
-";
- let mut program = compile(&parse(src).expect("parse"));
- program.set_witness("a", vec![vec![F192::new(5, 1, 0)]]);
- let err = program
- .execute([F192::from(F64::ONE), F192::from(F64::ONE)])
- .err()
- .expect("the run must fail");
- assert!(
- matches!(
- err.fault,
- Fault::NotInK {
- what: "JUMP target",
- ..
- }
- ),
- "{err}"
- );
-}
diff --git a/crates/lean_compiler/tests/suite/print_debug.rs b/crates/lean_compiler/tests/suite/print_debug.rs
deleted file mode 100644
index 33b77eeba..000000000
--- a/crates/lean_compiler/tests/suite/print_debug.rs
+++ /dev/null
@@ -1,28 +0,0 @@
-//! `print(...)`: a prover-side debug print: must compile, execute during
-//! witness generation, and leave proving/verification untouched.
-
-use lean_compiler::{compile, parse};
-use lean_vm::cpu::{prove, verify};
-use primitives::field::{F64, F192};
-
-#[test]
-fn print_is_constraint_free() {
- let src = "\
-def main():
- x = 5
- y = x * GEN
- print(y)
- print(\"the product\", y * y)
- b = HeapBuf(2)
- b[1] = 3
- print(b[1])
- p = 1
- p[1] = y
- p[GEN] = b[1]
- return
-";
- let program = compile(&parse(src).expect("parse"));
- let want = [F192::from(F64(5) * primitives::field::g_pow(1)), F192::from(F64(3))];
- let (proof, _) = prove(&program, want, lean_vm::pcs::TEST_LOG_INV_RATE).unwrap();
- verify(&program, &want, &proof).expect("prints must not disturb proving");
-}
diff --git a/crates/lean_compiler/tests/suite/py_source.rs b/crates/lean_compiler/tests/suite/py_source.rs
deleted file mode 100644
index d3f3e6b8d..000000000
--- a/crates/lean_compiler/tests/suite/py_source.rs
+++ /dev/null
@@ -1,98 +0,0 @@
-//! zkDSL sources as `.py` files (as in leanVM's `test_data`): the `snark_lib`
-//! stub import makes them valid Python for editors/linters, and the compiler
-//! skips it (single-file programs only: importing anything else is an error).
-//!
-//! The harness is generic: every `tests/programs/*.py` is parsed, compiled,
-//! proven, and verified. A program declares the public input it expects with a
-//! top-of-file annotation of two constant field elements,
-//!
-//! ```text
-//! # public_input: GEN ** 89, 101229015297003380629709256178361811305
-//! ```
-//!
-//! or omits it to run with the empty public input (two zeros).
-
-use std::fs;
-
-use lean_compiler::{compile, parse, parse_const};
-use lean_vm::cpu::{prove, verify};
-use primitives::field::F192;
-
-/// The `# public_input: , ` annotation, or `[0, 0]` if absent.
-fn public_input(src: &str) -> [F192; 2] {
- for line in src.lines() {
- if let Some(rest) = line.trim().strip_prefix("# public_input:") {
- let parts: Vec<&str> = rest.split(',').collect();
- assert_eq!(
- parts.len(),
- 2,
- "`# public_input:` needs two field elements, got `{rest}`"
- );
- let elt = |s: &str| parse_const(s).unwrap_or_else(|e| panic!("bad public_input: {e}"));
- return [elt(parts[0]), elt(parts[1])];
- }
- }
- [F192::ZERO; 2]
-}
-
-/// The `# witness : , …` annotations: one line per *entry*
-/// (repeated lines with the same name are the stream's successive entries,
-/// popped by successive `hint_witness` calls).
-fn witness(src: &str) -> std::collections::HashMap>> {
- let mut streams: std::collections::HashMap>> = Default::default();
- for rest in src.lines().filter_map(|l| l.trim().strip_prefix("# witness ")) {
- let (name, vals) = rest.split_once(':').expect("`# witness` needs `name: values`");
- let entry = vals
- .split(',')
- .map(|s| parse_const(s).unwrap_or_else(|e| panic!("bad witness value: {e}")))
- .collect();
- streams.entry(name.trim().to_string()).or_default().push(entry);
- }
- streams
-}
-
-/// Every program in `tests/programs/`, end to end.
-#[test]
-fn all_py_programs() {
- let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/programs");
- let mut paths: Vec<_> = fs::read_dir(dir)
- .expect("tests/programs")
- .map(|e| e.expect("dir entry").path())
- .filter(|p| p.extension().is_some_and(|x| x == "py"))
- .collect();
- paths.sort();
- assert!(!paths.is_empty(), "no .py programs found");
-
- for path in paths {
- let name = path.file_name().unwrap().to_string_lossy().into_owned();
- let src = fs::read_to_string(&path).unwrap_or_else(|e| panic!("{name}: read: {e}"));
- let want = public_input(&src);
- let ast = parse(&src).unwrap_or_else(|e| panic!("{name}: parse: {e}"));
- let mut program = compile(&ast);
- for (stream, entries) in witness(&src) {
- program.set_witness(stream, entries);
- }
- let (proof, _) = prove(&program, want, lean_vm::pcs::TEST_LOG_INV_RATE).unwrap();
- verify(&program, &want, &proof).unwrap_or_else(|e| panic!("{name}: verify: {e:?}"));
- println!("{name}: ok");
- }
-}
-
-/// Both import spellings are tolerated (and skipped).
-#[test]
-fn snark_lib_import_forms() {
- for import in ["import snark_lib", "from snark_lib import *"] {
- let src = format!("{import}\ndef main():\n return\n");
- parse(&src).expect("snark_lib import is skipped");
- }
-}
-
-/// Importing anything else is a parse error: no multi-file programs (yet).
-#[test]
-fn other_imports_rejected() {
- for import in ["import math", "from utils import *"] {
- let src = format!("{import}\ndef main():\n return\n");
- let err = parse(&src).expect_err("non-snark_lib import must be rejected");
- assert!(err.contains("file imports are not supported"), "{err}");
- }
-}
diff --git a/crates/lean_compiler/tests/suite/range_check.rs b/crates/lean_compiler/tests/suite/range_check.rs
deleted file mode 100644
index 533adca00..000000000
--- a/crates/lean_compiler/tests/suite/range_check.rs
+++ /dev/null
@@ -1,223 +0,0 @@
-//! Range checks *in the exponent*: `assert log x < log GEN ** k` (or
-//! `assert log x < k`) proves `log_g(x) < k`, i.e. `x ∈ {g^0, g^1, …, g^{k-1}}`,
-//! in 3 cycles: `DEREF x` bounds `log(x)` by the memory size, a `MUL` into the
-//! write-once constant cell `g^{k-1}` back-solves and binds the complement
-//! `y = g^{k-1-log(x)}`, and `DEREF y` bounds the complement. leanVM's DEREF
-//! range-check trick, transported to g-powers; the only nondeterminism is the
-//! end-of-run resolution of the two touched cells.
-
-use lean_compiler::{compile, parse};
-use lean_vm::cpu::{Fault, prove, verify};
-use primitives::field::{F64, F192, g_pow};
-
-use crate::common::mix;
-
-/// Both bound forms (`log GEN ** k` and a plain integer exponent) with the
-/// boundary elements (`g^{k-1}`, `1 = g^0`), end-to-end: prove + verify, and a
-/// wrong public input is rejected. Also pins the gadget's cost: 2 DEREFs per
-/// check.
-#[test]
-fn range_check_end_to_end() {
- let src = "\
-def main():
- x = GEN ** 5
- assert log x < log GEN ** 8
- y = GEN ** 7
- assert log y < 8
- assert log 1 < log GEN ** 8
- z = x * y
- assert log z < 13
- p = 1
- p[1] = z
- p[GEN] = x
- return
-";
- let program = compile(&parse(src).expect("parse"));
- let want = [F192::from(g_pow(12)), F192::from(g_pow(5))];
- let (proof, _) = prove(&program, want, lean_vm::pcs::TEST_LOG_INV_RATE).unwrap();
- // 2 DEREFs per range check (4 checks) + 2 publishing stores.
- assert_eq!(mix(src, want)[3], 10, "DEREF count");
- verify(&program, &want, &proof).expect("range-checked program verifies");
-
- let bad = [F192::from(g_pow(12)), F192::from(g_pow(6))];
- assert!(
- verify(&program, &bad, &proof).is_err(),
- "wrong public input must be rejected"
- );
-}
-
-/// A check whose two touched cells (`m[300]` and the complement's `m[99]`) are
-/// never written by the program: their `DEREF`s only link them to fresh cells,
-/// all of which stay ZERO, and the bus still balances.
-#[test]
-fn range_check_unwritten_cells() {
- let src = "\
-def main():
- x = GEN ** 300
- assert log x < 400
- p = 1
- p[1] = x
- p[GEN] = x
- return
-";
- let program = compile(&parse(src).expect("parse"));
- let want = [F192::from(g_pow(300)), F192::from(g_pow(300))];
- let (proof, _) = prove(&program, want, lean_vm::pcs::TEST_LOG_INV_RATE).unwrap();
- verify(&program, &want, &proof).expect("unwritten touches verify");
-}
-
-/// The largest allowed bound, `2^16` = the minimum prover memory, end to end:
-/// the complement cell is `g^65535`, the last cell of that memory, so an
-/// off-by-one in the bound check or in the memory size shows up here.
-#[test]
-fn range_check_max_bound() {
- let src = "\
-def main():
- x = GEN ** 5
- assert log x < 65536
- p = 1
- p[1] = x
- p[GEN] = x
- return
-";
- let program = compile(&parse(src).expect("parse"));
- let want = [F192::from(g_pow(5)); 2];
- let (proof, _) = prove(&program, want, lean_vm::pcs::TEST_LOG_INV_RATE).unwrap();
- verify(&program, &want, &proof).expect("max-bound range check verifies");
-}
-
-/// Range checks inside a `mul_range` body: the check runs once per iteration in
-/// a fresh helper frame (its own `g^{k-1}` constant cell each time), and the
-/// touched low cells mix already-written ones (`m[0]`, `m[1]`: the public
-/// input) with unwritten ones.
-#[test]
-fn range_check_in_loop() {
- let src = "\
-def main():
- for i in mul_range(1, GEN ** 6):
- assert log i < log GEN ** 6
- p = 1
- p[1] = 5
- p[GEN] = 7
- return
-";
- let program = compile(&parse(src).expect("parse"));
- let want = [F192::from(F64(5)), F192::from(F64(7))];
- let (proof, _) = prove(&program, want, lean_vm::pcs::TEST_LOG_INV_RATE).unwrap();
- // 6 iterations × 2 range-check DEREFs, plus call/publish plumbing.
- assert!(mix(src, want)[3] >= 12, "at least the 12 range-check DEREFs");
- verify(&program, &want, &proof).expect("loop range checks verify");
-}
-
-/// `log(g^8) < 8` is false: the complement back-solves to a huge-exponent
-/// element, and its DEREF fails witness generation: the honest-execution
-/// surface of a failing range check.
-#[test]
-fn range_check_at_bound_rejected() {
- let src = "def main():\n x = GEN ** 8\n assert log x < 8\n return\n";
- let program = compile(&parse(src).expect("parse"));
- let err = program
- .execute([F192::ZERO, F192::ZERO])
- .err()
- .expect("the run must fail");
- assert!(matches!(err.fault, Fault::WildPointer { .. }), "{err}");
-}
-
-/// A value that is no small g-power at all (5 = x^2 + 1) fails at the first
-/// DEREF, the same way.
-#[test]
-fn range_check_non_g_power_rejected() {
- let src = "def main():\n x = 5\n assert log x < 8\n return\n";
- let program = compile(&parse(src).expect("parse"));
- let err = program
- .execute([F192::ZERO, F192::ZERO])
- .err()
- .expect("the run must fail");
- assert!(matches!(err.fault, Fault::WildPointer { .. }), "{err}");
-}
-
-/// Bound 0 names the empty set: rejected at compile time.
-#[test]
-#[should_panic(expected = "names the empty set")]
-fn range_check_empty_bound_rejected() {
- let src = "def main():\n x = 1\n assert log x < 0\n return\n";
- let _ = compile(&parse(src).expect("parse"));
-}
-
-/// Bounds beyond `2^16` (the minimum memory size) would not be sound for every
-/// prover memory choice: rejected at compile time.
-#[test]
-#[should_panic(expected = "exceeds 2^16")]
-fn range_check_bound_too_big_rejected() {
- let src = "def main():\n x = 1\n assert log x < 65537\n return\n";
- let _ = compile(&parse(src).expect("parse"));
-}
-
-/// A `<` assert without `log` is rejected: field elements have no order, only
-/// their logs do.
-#[test]
-#[should_panic(expected = "compares logs")]
-fn range_check_without_log_rejected() {
- let src = "def main():\n x = 1\n assert x < 8\n return\n";
- let _ = parse(src).map_err(|e| panic!("{e}"));
-}
-
-/// A *runtime* bound, `assert log x < log n`: the same gadget with `g^{k-1}`
-/// derived as `n·g^{-1}` instead of pooled from a constant. The bound rides a
-/// hint here, as it does in the aggregation guest, where the signer count is
-/// prover-announced.
-#[test]
-fn range_check_runtime_bound() {
- let src = "\
-def main():
- nb = StackBuf(1)
- hint_witness(nb[0:1], \"n\")
- n = nb[0]
- assert log n < 64
- x = GEN ** 5
- assert log x < log n
- assert log 1 < log n
- p = 1
- p[1] = x
- p[GEN] = n
- return
-";
- let mut program = compile(&parse(src).expect("parse"));
- program.set_witness("n", vec![vec![F192::from(g_pow(6))]]);
- let want = [F192::from(g_pow(5)), F192::from(g_pow(6))];
- let (proof, _) = prove(&program, want, lean_vm::pcs::TEST_LOG_INV_RATE).unwrap();
- verify(&program, &want, &proof).expect("runtime-bound range check verifies");
-}
-
-/// The runtime bound binds: `log(g^5) < log(g^5)` is false, and the complement
-/// back-solves to a huge-exponent element whose DEREF fails, exactly as for a
-/// compile-time bound at its boundary.
-#[test]
-fn range_check_runtime_bound_at_bound_rejected() {
- let src = "\
-def main():
- nb = StackBuf(1)
- hint_witness(nb[0:1], \"n\")
- x = GEN ** 5
- assert log x < log nb[0]
- return
-";
- let mut program = compile(&parse(src).expect("parse"));
- program.set_witness("n", vec![vec![F192::from(g_pow(5))]]);
- let err = program
- .execute([F192::ZERO, F192::ZERO])
- .err()
- .expect("the run must fail");
- assert!(matches!(err.fault, Fault::WildPointer { .. }), "{err}");
-}
-
-/// A bound that folds at parse time but is not a power of `GEN` stays a parse
-/// error rather than quietly becoming a runtime bound. `log 8` names the field
-/// element 8, whose g-log is nothing in particular, so a program meaning `< 8`
-/// must not compile into a check that can only fail at witness generation.
-#[test]
-#[should_panic(expected = "must be a power of GEN")]
-fn range_check_folded_non_gpower_bound_rejected() {
- let src = "def main():\n x = GEN ** 3\n assert log x < log 5\n return\n";
- let _ = parse(src).map_err(|e| panic!("{e}"));
-}
diff --git a/crates/lean_compiler/tests/suite/sharing.rs b/crates/lean_compiler/tests/suite/sharing.rs
deleted file mode 100644
index 4626ae6ad..000000000
--- a/crates/lean_compiler/tests/suite/sharing.rs
+++ /dev/null
@@ -1,365 +0,0 @@
-//! The lowerer shares one cell between identical pure operations (`FnLower::pure`).
-//! These programs pin the cases where a "duplicate" is NOT dead, so sharing it
-//! would drop a constraint. They were written against a value-numbering pass that
-//! ran after lowering, and they outlived it: the hazard belongs to the sharing,
-//! not to where it happens.
-
-use lean_compiler::{compile, parse};
-use lean_vm::cpu::{Fault, ProveError, prove, verify};
-use primitives::field::{F64, F192, g_pow};
-
-/// A returned value that repeats a constant computed earlier in the same
-/// function. The return slot lives in the callee frame and is read by the
-/// CALLER, so eliminating that write leaves the caller reading an unwritten
-/// (prover-chosen) cell: `walk` in the XMSS guest returned a flag exactly this
-/// way, and folding it produced a proof whose caller-side assert failed.
-#[test]
-fn duplicate_constant_in_a_return_slot_survives() {
- let src = "\
-def tag(x):
- # `marker` is the same constant the flag below returns, and it is computed
- # first, so the flag's `SET` is a textual duplicate of it.
- marker = 7
- return x * marker, 7
-
-def main():
- v, flag = tag(GEN ** 3)
- p = 1
- p[1] = v
- p[GEN] = flag
- return
-";
- let program = compile(&parse(src).expect("parse"));
- let want = [F192::from(g_pow(3)) * F192::from(F64(7)), F192::from(F64(7))];
- let (proof, _) = prove(&program, want, lean_vm::pcs::TEST_LOG_INV_RATE).unwrap();
- verify(&program, &want, &proof).expect("returned duplicate constant is preserved");
-}
-
-/// An argument slot written with a value that already exists in the caller: the
-/// callee reads its arguments out of its own frame, so the store must stay.
-#[test]
-fn duplicate_argument_value_survives() {
- let src = "\
-def add_both(a, b):
- return a + b
-
-def main():
- k = GEN ** 5
- # Both arguments are the same expression, and the sum is computed here too,
- # so every operand the call needs has a duplicate in this frame.
- local = k + k
- s = add_both(k, k)
- p = 1
- p[1] = s + local
- return
-";
- let program = compile(&parse(src).expect("parse"));
- // (k + k) + (k + k) == 0 in characteristic two.
- let want = [F192::ZERO, F192::ZERO];
- let (proof, _) = prove(&program, want, lean_vm::pcs::TEST_LOG_INV_RATE).unwrap();
- verify(&program, &want, &proof).expect("duplicated call arguments are preserved");
-}
-
-/// A duplicate on one side of a branch must not be shared with the other side's
-/// computation: the cache reverts at a join, so each arm recomputes what it
-/// needs. Shared, the arm that runs would read a cell only the untaken arm
-/// writes, leaving it unwritten and so prover-chosen.
-///
-/// The condition is FALSE on purpose, so the arm that runs is the SECOND one
-/// emitted. Written the other way the taken arm is the one that mints the cell,
-/// any sharing can only redirect the untaken arm, and the test cannot fail: it
-/// passed with both caches leaking past the join and with the scope revert
-/// deleted outright.
-#[test]
-fn duplicates_are_not_folded_across_a_branch() {
- let src = "\
-def main():
- x = GEN ** 3
- r = HeapBuf(2)
- # The same constant in both arms, and the else arm is the one that runs.
- if x == GEN ** 5:
- r[1] = GEN ** 4
- else:
- r[1] = GEN ** 4
- p = 1
- p[1] = r[1]
- p[GEN] = x
- return
-";
- let run = |pi: [F192; 2]| -> bool {
- let program = compile(&parse(src).expect("parse"));
- prove(&program, pi, lean_vm::pcs::TEST_LOG_INV_RATE)
- .is_ok_and(|(proof, _)| verify(&program, &pi, &proof).is_ok())
- };
- assert!(
- run([F192::from(g_pow(4)), F192::from(g_pow(3))]),
- "the arm that runs keeps its own constant"
- );
- // The wrong value is the half that bites: a cell only the untaken arm writes
- // is the prover's to choose, and the honest claim would verify anyway.
- assert!(
- !run([F192::from(g_pow(7)), F192::from(g_pow(3))]),
- "the stored constant is pinned by the arm that ran"
- );
-}
-
-/// The assert idiom is `XOR fp[t] = a ^ b` into the pooled zero cell, whose
-/// second write IS the assertion, so that cell must never be shared and the
-/// `XOR` must never be skipped in favour of one computed earlier.
-///
-/// The operands are HEAP READS on purpose. Written `a = GEN ** 9`, both sides
-/// fold and `diff = a + b` emits no `XOR` at all, so the duplicate the test
-/// names does not exist and the test cannot fail: skipping the assert on a cache
-/// hit then passed the whole suite. Read from a `HeapBuf` the two values are
-/// runtime cells, `diff` really does emit `XOR fp[t] = a ^ b`, and the assert's
-/// own `XOR` has a genuine duplicate to be folded into.
-fn duplicated_comparison(second: u32) -> String {
- format!(
- "\
-def main():
- hb = HeapBuf(2)
- hb[1] = GEN ** 9
- hb[GEN] = GEN ** {second}
- a = hb[1]
- b = hb[GEN]
- # The same XOR the assert needs, as a live value.
- diff = a + b
- assert a == b
- p = 1
- p[1] = diff
- p[GEN] = a
- return
-"
- )
-}
-
-#[test]
-fn assert_survives_a_duplicated_comparison() {
- let program = compile(&parse(&duplicated_comparison(9)).expect("parse"));
- let want = [F192::ZERO, F192::from(g_pow(9))];
- let (proof, _) = prove(&program, want, lean_vm::pcs::TEST_LOG_INV_RATE).unwrap();
- verify(&program, &want, &proof).expect("passing assert still verifies");
-}
-
-/// The same shape with the assert failing: it must still fail, which is what
-/// says the assertion is really there.
-#[test]
-fn failing_assert_still_conflicts() {
- let program = compile(&parse(&duplicated_comparison(10)).expect("parse"));
- let want = [F192::ZERO, F192::from(g_pow(9))];
- let Err(ProveError::Execution(err)) = prove(&program, want, lean_vm::pcs::TEST_LOG_INV_RATE) else {
- panic!("a failing assert makes no proof")
- };
- assert!(matches!(err.fault, Fault::Conflict { .. }), "{err}");
-}
-
-/// A hint at the end of a runtime branch is attached to a no-op anchor by the
-/// lowerer. Even when that anchor repeats an earlier pure instruction, it has to
-/// be emitted: moving the hint to the next textual instruction would move it to
-/// the join and execute it when the branch is not taken.
-#[test]
-fn trailing_branch_hint_stays_in_its_branch() {
- let src = "\
-def main():
- flag = StackBuf(1)
- hint_witness(flag, \"flag\")
- data = StackBuf(1)
- if flag[0] == 1:
- print(\"anchor\", flag[0])
- hint_witness(data, \"data\")
- p = 1
- p[1] = flag[0]
- p[GEN] = 0
- return
-";
- let mut program = compile(&parse(src).expect("parse"));
- program.set_witness("flag", vec![vec![F192::ZERO]]);
- let want = [F192::ZERO, F192::ZERO];
- let (proof, _) = prove(&program, want, lean_vm::pcs::TEST_LOG_INV_RATE).unwrap();
- verify(&program, &want, &proof).expect("untaken branch must not consume its witness");
-}
-
-/// A BLAKE2s chaining value names a CONSECUTIVE PAIR, so neither half may be
-/// folded into a canonical elsewhere and the base may not be rewritten: a
-/// substitution speaks for one cell, and redirecting the base silently redirects
-/// the second word too. `rewrite_reads` used to map `cv` like any single-cell
-/// read, so when the first of the two assembling copies duplicated an earlier
-/// copy of the same source, the compression absorbed the OTHER pair's second
-/// word. Silent, and a soundness break in a transcript.
-///
-/// The two compressions here differ in nothing but their chaining value, and
-/// their two `cv` pairs share a first word, which is what made the first copy a
-/// duplicate. If either pair is rewritten or dropped, the digests coincide and
-/// the inequality fails at witness generation.
-#[test]
-fn a_chaining_value_pair_is_neither_rewritten_nor_dropped() {
- let src = "\
-def main():
- hb = HeapBuf(4)
- hb[1] = GEN ** 11
- hb[GEN] = GEN ** 22
- hb[GEN ** 2] = GEN ** 33
- hb[GEN ** 3] = GEN ** 44
- x = hb[1]
- y = hb[GEN]
- z = hb[GEN ** 2]
- w = hb[GEN ** 3]
- msg = StackBuf(4)
- msg[0] = y
- msg[1] = y
- msg[2] = y
- msg[3] = y
- t = StackBuf(2)
- t[0] = x
- t[1] = z
- o1 = StackBuf(2)
- blake2s(msg[0:2], msg[2:4], o1, cv=t, counter=64, final=1)
- s = StackBuf(2)
- s[0] = x
- s[1] = w
- o2 = StackBuf(2)
- blake2s(msg[0:2], msg[2:4], o2, cv=s, counter=64, final=1)
- assert o1[0] != o2[0]
- p = 1
- p[1] = x
- p[GEN] = y
- return
-";
- let program = compile(&parse(src).expect("parse"));
- let want = [F192::from(g_pow(11)), F192::from(g_pow(22))];
- let (proof, _) = prove(&program, want, lean_vm::pcs::TEST_LOG_INV_RATE).unwrap();
- verify(&program, &want, &proof).expect("each compression absorbs its own chaining value");
-}
-
-#[test]
-fn cached_loads_do_not_cross_branches_or_erase_stores() {
- let source = r#"
-def main():
- h = HeapBuf(3)
- hint_witness(h[0:3], "values")
- public = 1
- if public[1] == 0:
- a = h[1] + h[GEN]
- else:
- b = h[1] + h[GEN]
- h[GEN ** 2] = h[1]
- assert h[GEN ** 2] == h[1]
- h[1] = public[GEN]
- return
-"#;
- let value = F192::new(17, 31, 43);
- let mut program = compile(&parse(source).unwrap());
- program.set_witness("values", vec![vec![value, F192::ONE, value]]);
- for branch in [F192::ZERO, F192::ONE] {
- assert!(program.execute([branch, value]).unwrap().unconstrained_reads.is_empty());
- assert!(program.execute([branch, value + F192::ONE]).is_err());
- }
-}
-
-#[test]
-fn cached_loads_see_linked_equalities_before_use() {
- lean_vm::init_prover_pool();
- let source = r#"
-def fill(h):
- value = hint_witness("value")
- h[1] = value
- return
-
-def main():
- h = HeapBuf(1)
- TOUCH
- FILL
- out = StackBuf(2)
- out[0] = h[1] * h[1]
- out[1] = h[1]
- public = 1
- assert public[1] == out[0]
- assert public[GEN] == out[1]
- return
-"#;
- let value = F192::from(F64(7));
- let public = [value * value, value];
- for touch in ["assert log(h) < 1024", "early = StackBuf(1)\n early[0] = h[1]"] {
- for fill in ["hint_witness(h[0:1], \"value\")", "fill(h)"] {
- let source = source.replace("TOUCH", touch).replace("FILL", fill);
- let mut program = compile(&parse(&source).unwrap());
- program.set_witness("value", vec![vec![value]]);
- assert!(program.execute(public).unwrap().unconstrained_reads.is_empty());
- let (proof, _) = prove(&program, public, lean_vm::pcs::TEST_LOG_INV_RATE).unwrap();
- verify(&program, &public, &proof).unwrap();
- assert!(program.execute([public[0] + F192::ONE, value]).is_err());
- }
- }
-}
-
-/// A load that runs before its store takes the stored value, as write-once memory
-/// says, even where the loaded name is used directly rather than loaded again.
-#[test]
-fn a_load_before_its_store_sees_the_store() {
- lean_vm::init_prover_pool();
- let source = "\
-def main():
- h = HeapBuf(1)
- x = h[1]
- h[1] = GEN ** 3
- public = 1
- assert public[1] == x * GEN
- return
-";
- let program = compile(&parse(source).unwrap());
- let public = [F192::from(g_pow(4)), F192::ZERO];
- let (proof, _) = prove(&program, public, lean_vm::pcs::TEST_LOG_INV_RATE).unwrap();
- verify(&program, &public, &proof).unwrap();
-}
-
-/// A heap cell nothing stores to is prover-chosen, so using a value loaded from it
-/// is an unconstrained read, exactly as for a stack cell.
-#[test]
-fn a_load_of_an_unwritten_heap_cell_is_an_unconstrained_read() {
- let source = "\
-def main():
- h = HeapBuf(2)
- x = h[1]
- public = 1
- public[1] = x * GEN
- return
-";
- let exec = compile(&parse(source).unwrap()).execute([F192::ZERO; 2]).unwrap();
- assert!(!exec.unconstrained_reads.is_empty());
-}
-
-#[test]
-fn cached_copies_see_later_stores() {
- lean_vm::init_prover_pool();
- let source = r#"
-def square(h):
- return h[GEN] * h[GEN]
-
-def main():
- h = HeapBuf(2)
- early = StackBuf(1)
- other = StackBuf(1)
- early[0] = h[GEN]
- value = hint_witness("value")
- FILL_DEST
- DEST[0] = h[GEN]
- result = square(h)
- public = 1
- assert public[1] == result
- return
-"#;
- let value = F192::from(F64(7));
- let public = [value * value, F192::ZERO];
- for (dest, fill) in [
- ("early", "early[0] = value"),
- ("other", "other[0] = value"),
- ("other", "other[0] = h[GEN]\n h[GEN] = value"),
- ] {
- let source = source.replace("FILL_DEST", fill).replace("DEST", dest);
- let mut program = compile(&parse(&source).unwrap());
- program.set_witness("value", vec![vec![value]]);
- assert!(program.execute(public).unwrap().unconstrained_reads.is_empty());
- let (proof, _) = prove(&program, public, lean_vm::pcs::TEST_LOG_INV_RATE).unwrap();
- verify(&program, &public, &proof).unwrap();
- }
-}
diff --git a/crates/lean_compiler/tests/suite/soundness/cases.rs b/crates/lean_compiler/tests/suite/soundness/cases.rs
deleted file mode 100644
index c2a2f30fc..000000000
--- a/crates/lean_compiler/tests/suite/soundness/cases.rs
+++ /dev/null
@@ -1,558 +0,0 @@
-//! Layer 1: perturbation. Each case is one program with one valid trial and a
-//! table of single-cell pokes that must break it.
-//!
-//! Coverage is by *lowering*, not by feature list: every case exercises a
-//! construct whose lowering could plausibly drop the check it stands for, and
-//! every poke names one constraint. A poke that is accepted says which one is
-//! missing.
-//!
-//! The pokes lean on witness streams rather than the public input, because two
-//! public words is all there is and because the streams are where a real guest's
-//! untrusted data actually enters.
-
-use super::{Case, Trial, check_case, g, k, pi, wit};
-use primitives::field::F192;
-
-/// `XOR`/`MUL` relations, both assert forms, and the division back-solve. The
-/// quotient cell is written by nothing but the back-solve, so this case also
-/// pins the one legitimate way a cell may be read before any instruction writes
-/// it.
-#[test]
-fn arithmetic_and_asserts() {
- check_case(&Case {
- name: "arithmetic_and_asserts",
- src: "\
-def main():
- v = StackBuf(3)
- hint_witness(v, \"w\")
- assert v[0] * v[1] == v[2]
- assert v[0] != v[1]
- q = v[2] / v[0]
- assert q == v[1]
- p = GEN ** 0
- p[1] = v[2]
- p[GEN] = v[0] + v[1]
- return
-",
- valid: Trial::new([g(8), g(3) + g(5)]).stream("w", vec![vec![g(3), g(5), g(8)]]),
- pokes: vec![
- // Each of the three hinted cells breaks the product relation.
- wit("w", 0, g(4)),
- wit("w", 1, g(6)),
- wit("w", 2, g(9)),
- // Equal operands: the product relation would still need v[2] = g^10,
- // but this is the poke that `assert !=` exists for.
- wit("w", 0, g(5)),
- // Both published words.
- pi(0, g(9)),
- pi(1, g(3) + g(6)),
- ],
- });
-}
-
-/// The exponent range check and `match` dispatch. The dispatch is only
-/// sound because the matched value was range-checked first (doc §Match
-/// statements), so a poke past the bound must be caught by the check rather than
-/// land at an attacker-chosen arm.
-#[test]
-fn range_check_and_dispatch() {
- check_case(&Case {
- name: "range_check_and_dispatch",
- src: "\
-def main():
- v = StackBuf(2)
- hint_witness(v, \"w\")
- assert log(v[0]) < 8
- r = match(log(v[0]), range(0, 8), lambda i: sq(i))
- assert r == v[1]
- p = GEN ** 0
- p[1] = v[0]
- p[GEN] = r
- return
-
-
-def sq(x):
- return x * x
-",
- // Arm 3 runs: sq(3) = 3·3 in K = (x+1)^2 = x^2+1 = 5.
- valid: Trial::new([g(3), k(5)]).stream("w", vec![vec![g(3), k(5)]]),
- pokes: vec![
- // Past the bound: the range check's complement DEREF must catch it.
- wit("w", 0, g(8)),
- wit("w", 0, g(63)),
- // A different arm runs, so the claimed square is wrong.
- wit("w", 0, g(4)),
- // The claimed square itself.
- wit("w", 1, k(6)),
- pi(0, g(4)),
- pi(1, k(6)),
- ],
- });
-}
-
-/// An `@inline` arm runs in the dispatching frame, so it writes the caller's
-/// `StackBuf` directly and returns its own `Const`. A poke that selects another
-/// arm or changes the written value must be caught.
-#[test]
-fn inline_arms_write_the_callers_buffer() {
- check_case(&Case {
- name: "inline_arms_write_the_callers_buffer",
- src: "\
-def main():
- v = StackBuf(2)
- hint_witness(v, \"w\")
- assert log(v[0]) < 4
- out = StackBuf(1)
- e = match(log(v[0]), range(0, 4), lambda i: put(out, v[1], i))
- p = GEN ** 0
- p[1] = out[0]
- p[GEN] = e
- return
-
-
-@inline
-def put(out, x, n: Const):
- out[0] = x * GEN ** n
- return const(n + 1)
-",
- // Arm 2: out = g^5·g^2, e = 3.
- valid: Trial::new([g(7), k(3)]).stream("w", vec![vec![g(2), g(5)]]),
- pokes: vec![
- wit("w", 0, g(4)),
- wit("w", 0, g(1)),
- wit("w", 1, g(6)),
- pi(0, g(8)),
- pi(1, k(2)),
- ],
- });
-}
-
-/// `if`/`else` communicating through a write-once heap cell: only one arm runs,
-/// so both may write it and the join reads it back. A lowering that lets the
-/// join read anything other than the taken arm's value shows up as a poke that
-/// selects the other arm and is still accepted.
-#[test]
-fn branch_join() {
- check_case(&Case {
- name: "branch_join",
- src: "\
-def main():
- v = StackBuf(2)
- hint_witness(v, \"w\")
- assert log(v[0]) < 4
- r = HeapBuf(1)
- if v[0] == GEN ** 2:
- r[1] = v[1] * GEN
- else:
- r[1] = v[1] * GEN ** 3
- p = GEN ** 0
- p[1] = r[1]
- p[GEN] = v[0]
- return
-",
- valid: Trial::new([g(6), g(2)]).stream("w", vec![vec![g(2), g(5)]]),
- pokes: vec![
- // Takes the else arm, which multiplies by g^3 instead of g.
- wit("w", 0, g(1)),
- wit("w", 0, g(3)),
- // Past the bound.
- wit("w", 0, g(4)),
- // The value the taken arm shifts.
- wit("w", 1, g(4)),
- pi(0, g(7)),
- pi(1, g(3)),
- ],
- });
-}
-
-/// A `mul_range` loop with a runtime bound and heap-carried state. The bound is
-/// hinted, so the loop terminates only because its log was checked first; the
-/// pokes cover both a bound that changes the trip count and one past the check.
-#[test]
-fn loop_with_runtime_bound() {
- check_case(&Case {
- name: "loop_with_runtime_bound",
- src: "\
-def main():
- v = StackBuf(1)
- hint_witness(v, \"n\")
- assert log(v[0]) < 8
- acc = HeapBuf(16)
- acc[1] = GEN ** 0
- for i in mul_range(1, v[0]):
- acc[i * GEN] = acc[i] * GEN ** 2
- p = GEN ** 0
- p[1] = acc[v[0]]
- p[GEN] = v[0]
- return
-",
- // n = g^5: five iterations, acc[j] = g^{2j}, so acc[5] = g^10.
- valid: Trial::new([g(10), g(5)]).stream("n", vec![vec![g(5)]]),
- pokes: vec![
- // Fewer and more iterations: acc[n] is then g^8 and g^12.
- wit("n", 0, g(4)),
- wit("n", 0, g(6)),
- // Past the bound.
- wit("n", 0, g(8)),
- pi(0, g(11)),
- pi(1, g(4)),
- ],
- });
-}
-
-/// `pack64x2`'s range assertion: both sources must lie in K. Its untaken JUMP
-/// puts them in the destination and frame slots, whose memory reads have
-/// literal-zero upper limbs.
-#[test]
-fn pack64x2_range_assertion() {
- check_case(&Case {
- name: "pack64x2_range_assertion",
- src: "\
-@inline
-def pack64x2(a, b):
- assert_in_k(a, b)
- return a + f192(0, 1, 0) * b
-
-def main():
- v = StackBuf(2)
- hint_witness(v, \"w\")
- c = pack64x2(v[0], v[1])
- p = GEN ** 0
- p[1] = c
- p[GEN] = v[0]
- return
-",
- valid: Trial::new([F192::new(5, 7, 0), k(5)]).stream("w", vec![vec![k(5), k(7)]]),
- pokes: vec![
- // Either source outside K.
- wit("w", 0, F192::new(5, 1, 0)),
- wit("w", 0, F192::new(5, 0, 1)),
- wit("w", 1, F192::new(7, 1, 0)),
- // In K, but not the packing that was published.
- wit("w", 0, k(6)),
- wit("w", 1, k(8)),
- pi(0, F192::new(5, 8, 0)),
- pi(1, k(6)),
- ],
- });
-}
-
-/// The digest-as-verification idiom: a hinted preimage, hashed, and the result
-/// pinned against a hinted digest through a heap store. This is the shape a
-/// signature verifier has, so it is the one that most needs a regression test.
-///
-/// The digest constant comes from [`print_blake2s_digest`], not from a hand
-/// computation: what the case tests is that a *wrong* digest is rejected, and
-/// for that the honest value only has to be honest.
-#[test]
-fn digest_pins_its_preimage() {
- check_case(&Case {
- name: "digest_pins_its_preimage",
- src: BLAKE2S_PIN_SRC,
- valid: Trial::new([k(5), k(7)])
- .stream("msg", vec![vec![k(5), k(7), F192::ZERO, F192::ZERO]])
- .stream("dig", vec![vec![DIGEST_5_7[0], DIGEST_5_7[1]]]),
- pokes: vec![
- // A different preimage hashes to something else.
- wit("msg", 0, k(6)),
- wit("msg", 1, k(8)),
- wit("msg", 2, k(1)),
- wit("msg", 3, k(1)),
- // A wrong digest is what the write-once store has to catch.
- wit("dig", 0, F192::ZERO),
- wit("dig", 1, F192::ZERO),
- wit("dig", 0, DIGEST_5_7[0] + F192::ONE),
- wit("dig", 1, DIGEST_5_7[1] + F192::ONE),
- // The published preimage words.
- pi(0, k(6)),
- pi(1, k(8)),
- ],
- });
-}
-
-const BLAKE2S_PIN_SRC: &str = "\
-def main():
- m = StackBuf(4)
- hint_witness(m, \"msg\")
- d = StackBuf(2)
- blake2s(m[0:2], m[2:4], d)
- e = HeapBuf(2)
- hint_witness(e[0:2], \"dig\")
- e[1] = d[0]
- e[GEN] = d[1]
- p = GEN ** 0
- p[1] = m[0]
- p[GEN] = m[1]
- return
-";
-
-/// BLAKE2s of the 64-byte block whose four canonical cells are `(5, 7, 0, 0)`.
-pub const DIGEST_5_7: [F192; 2] = [
- F192::new(0xbbc8_c175_8cb7_7642, 0xf299_5d40_1fad_f4ff, 0),
- F192::new(0x83ea_6ade_289a_53c8, 0x57e6_e523_12ec_734b, 0),
-];
-
-/// Regenerate [`DIGEST_5_7`]: `cargo test --release -p lean_compiler
-/// print_blake2s_digest -- --ignored --nocapture`. Kept so the constant above is
-/// reproducible rather than folklore.
-#[test]
-#[ignore = "prints a constant; not a check"]
-fn print_blake2s_digest() {
- let src = "\
-def main():
- m = StackBuf(4)
- hint_witness(m, \"msg\")
- d = StackBuf(2)
- blake2s(m[0:2], m[2:4], d)
- print(d[0])
- print(d[1])
- return
-";
- let mut p = super::build(src);
- p.set_witness("msg", vec![vec![k(5), k(7), F192::ZERO, F192::ZERO]]);
- p.execute([F192::ZERO, F192::ZERO]).unwrap();
-}
-
-/// The fused `match` path must reject a call that binds more names than
-/// the callee returns, exactly as the non-fused path does. Before this check the
-/// surplus name `DEREF`ed a callee-frame offset nothing on the taken path wrote,
-/// and since the shared frame is sized to the largest callee that offset exists,
-/// so the name bound a prover-chosen word.
-///
-/// Fusion needs every arm to be a call to the same function with identical
-/// runtime arguments, so the two programs below are the fused shape: one over
-/// mixed-arity callees, one over a single over-bound callee.
-#[test]
-#[should_panic(expected = "dispatched call binds")]
-fn dispatched_call_rejects_a_mixed_arity_arm() {
- super::build(
- "\
-def main():
- x = GEN ** 2
- a, b, c = match(log(x), range(0, 2), lambda i: three(x, i), range(2, 4), lambda i: one(x, i))
- p = GEN ** 0
- p[1] = b
- p[GEN] = c
- return
-
-
-def three(v, k: Const):
- q = v * GEN ** k
- return q, q * q, q * q * q
-
-
-def one(v, k: Const):
- return v * GEN ** k
-",
- );
-}
-
-#[test]
-#[should_panic(expected = "dispatched call binds")]
-fn dispatched_call_rejects_an_over_bound_callee() {
- super::build(
- "\
-def main():
- x = GEN ** 1
- a, b = match(log(x), range(0, 4), lambda i: one(x, i))
- p = GEN ** 0
- p[1] = a
- p[GEN] = b
- return
-
-
-def one(v, k: Const):
- return v * GEN ** k
-",
- );
-}
-
-/// A local whose name collides with a top-level constant array must be rejected.
-/// `zkDSL.md` §Global constants reserves the name; a scalar constant enforces that
-/// by construction (the parser substitutes its value, so the shadowing binding
-/// becomes a literal and fails loudly), but a constant array was carried to
-/// lowering, where `const_array_elem` resolved `NAME[i]` against it without
-/// consulting the scope and `expr` folded it before the local could be seen.
-///
-/// The consequence was the catastrophic direction for a hint: the range check
-/// below ran against the baked constant `g^3` and passed, while the actual witness
-/// `g^40` was never bounded and never read.
-#[test]
-#[should_panic(expected = "reserved")]
-fn a_local_may_not_shadow_a_constant_array() {
- super::build(
- "\
-Q = [8, 32]
-
-
-def main():
- Q = StackBuf(2)
- hint_witness(Q, \"w\")
- assert log(Q[0]) < 8
- p = GEN ** 0
- p[1] = Q[0]
- p[GEN] = Q[1]
- return
-",
- );
-}
-
-/// Same rule for a parameter, which is the other half of what the doc reserves.
-#[test]
-#[should_panic(expected = "reserved")]
-fn a_parameter_may_not_shadow_a_constant_array() {
- super::build(
- "\
-Q = [8, 32]
-
-
-def main():
- r = pick(GEN ** 2)
- p = GEN ** 0
- p[1] = r
- p[GEN] = r
- return
-
-
-def pick(Q):
- return Q * GEN
-",
- );
-}
-
-/// A `StackBuf` target's index is bounds-checked. The arms write their return
-/// straight into that cell, so an unchecked index puts a callee's return into
-/// whatever buffer follows: with the check removed, a program that never assigns
-/// `b[0]` publishes a value from the arms and PROVES IT, which is the shape
-/// `copy_alias` had before its own bounds check went in.
-#[test]
-#[should_panic(expected = "out of bounds")]
-fn a_stackbuf_target_index_is_bounds_checked() {
- super::build(
- "\
-def main():
- a = StackBuf(2)
- b = StackBuf(2)
- a[0] = 0
- a[1] = 0
- x = GEN ** 1
- a[2], e = match(log(x), range(0, 2), lambda i: two(x, i))
- p = GEN ** 0
- p[1] = b[0]
- p[GEN] = e
- return
-
-
-def two(v, k: Const):
- return v * GEN ** k, GEN ** k
-",
- );
-}
-
-/// A `blake2s` input operand written as a list is the same hash as gathering the
-/// words into a buffer, so hashing one way and the other must agree.
-///
-/// Self-comparing on purpose: an equivalence pair cannot check this, because a
-/// trial that must be ACCEPTED has to name the digest and no test should carry a
-/// hash constant. Asserting the two digests equal needs no constant, and the two
-/// operands are DIFFERENT words so that reordering within a list is visible: with
-/// both operands equal the swap would cancel out.
-#[test]
-fn a_blake2s_word_list_hashes_like_the_buffer_it_replaces() {
- check_case(&Case {
- name: "a_blake2s_word_list_hashes_like_the_buffer_it_replaces",
- src: "\
-def main():
- v = StackBuf(2)
- hint_witness(v, \"w\")
- named = StackBuf(2)
- blake2s([v[0], v[1]], [v[1], v[0]], named)
- l = StackBuf(2)
- l[0] = v[0]
- l[1] = v[1]
- r = StackBuf(2)
- r[0] = v[1]
- r[1] = v[0]
- gathered = StackBuf(2)
- blake2s(l, r, gathered)
- assert named[0] == gathered[0]
- assert named[1] == gathered[1]
- p = GEN ** 0
- p[1] = v[0]
- p[GEN] = v[1]
- return
-",
- valid: Trial::new([k(11), k(22)]).stream("w", vec![vec![k(11), k(22)]]),
- pokes: vec![wit("w", 0, k(12)), wit("w", 1, k(23))],
- });
-}
-
-/// A frame STORE's index is bounds-checked, like a read's and a target's.
-///
-/// The three callers of `frame_cell` each need their own case: with the check
-/// removed at the store site alone, all 148 tests still passed, and `a[2] = …` on
-/// a `StackBuf(2)` wrote the next buffer's first cell, published it, and the proof
-/// VERIFIED. That is the `copy_alias` bug reappearing at a different caller.
-#[test]
-#[should_panic(expected = "out of bounds")]
-fn a_frame_store_index_is_bounds_checked() {
- super::build(
- "\
-def main():
- a = StackBuf(2)
- b = StackBuf(2)
- a[0] = 0
- a[1] = 0
- b[1] = 0
- a[2] = GEN ** 7
- p = GEN ** 0
- p[1] = b[0]
- p[GEN] = b[1]
- return
-",
- );
-}
-
-/// One spelling of a heap index must not name two different cells.
-///
-/// A heap index is a g-power: `buf[GEN ** k]` is cell `k`, and a plain integer is
-/// rejected because `buf[4]` reads as cell 2 (`4 = g^2`) while the slice
-/// `buf[4:4+2]` reads as cells 4 and 5. Only a LITERAL carries that g-power
-/// reading, and briefly `const(...)` and `len(...)` carried it too, so
-/// `buf[const(8)]` on a `HeapBuf(4)` compiled and aliased cell 3 while the bare
-/// `buf[8]` it means was rejected. The golden digests cannot see this: the guest's
-/// only `const(...)` uses are blake2s operands, not indexes.
-#[test]
-fn an_integer_heap_index_is_rejected_however_it_is_spelled() {
- for idx in ["8", "const(8)", "const(4 + 4)", "len(EIGHT)", "GEN * const(4)"] {
- let src = format!(
- "EIGHT = [0, 0, 0, 0, 0, 0, 0, 0]\n\ndef main():\n buf = HeapBuf(4)\n buf[{idx}] = 9\n p = GEN ** 0\n p[1] = GEN ** 0\n p[GEN] = GEN ** 0\n return\n"
- );
- let Err(err) = std::panic::catch_unwind(|| super::build(&src)) else {
- panic!("`buf[{idx}]` was accepted as a heap index");
- };
- let msg = err.downcast_ref::().map(String::as_str).unwrap_or("");
- assert!(
- msg.contains("plain integer naming cell") || msg.contains("not a g-power"),
- "`{idx}`: wanted the ambiguity guard, got `{msg}`"
- );
- }
-}
-
-/// A large `**` exponent costs its LOG, not its value.
-///
-/// The field reading of `b ** k` is computed whether or not the caller wants it,
-/// and `field_pow` multiplied `k` times, so this program (which compiles: the
-/// index is `1`) took 39 seconds. Square-and-multiply makes it under a
-/// millisecond, and a test that would otherwise hang is the way to keep it so.
-#[test]
-fn a_large_exponent_does_not_cost_its_value() {
- let src = "def main():\n sa = StackBuf(2)\n sa[1 ** 4294967295] = 7\n p = GEN ** 0\n p[1] = sa[0]\n p[GEN] = GEN ** 0\n return\n";
- let started = std::time::Instant::now();
- let _ = super::build(src);
- let took = started.elapsed();
- assert!(
- took < std::time::Duration::from_secs(2),
- "a u32::MAX exponent took {took:?}: field_pow is multiplying k times again"
- );
-}
diff --git a/crates/lean_compiler/tests/suite/soundness/mod.rs b/crates/lean_compiler/tests/suite/soundness/mod.rs
deleted file mode 100644
index 48220a10f..000000000
--- a/crates/lean_compiler/tests/suite/soundness/mod.rs
+++ /dev/null
@@ -1,272 +0,0 @@
-//! Compiler-soundness harness: does the emitted bytecode still carry every
-//! constraint the source asked for?
-//!
-//! A dropped constraint is invisible to ordinary tests. The happy path passes
-//! either way, the compiler emits no diagnostic, and the symptom only appears as
-//! a proof that accepts something it should not. So the three checks below all
-//! attack the *absence* of a constraint rather than the presence of a value.
-//!
-//! 1. [`check_case`], **perturbation**: one valid trial that must run, and a
-//! table of single-cell pokes at the public input or a witness stream, each of
-//! which must make the run fail. A dropped assertion shows up as a poke that
-//! is accepted. (This is the shape of `leanVM`'s own soundness suite.)
-//! 2. [`check_pair`], **equivalence**: two spellings the language documents as
-//! interchangeable must accept exactly the same trials. Every dropped-constraint
-//! bug found so far is an *asymmetry*: an assertion that survives one spelling
-//! and vanishes in the other, so comparing the two finds it without anyone
-//! having to guess which side is wrong.
-//! 3. [`Execution::unconstrained_reads`], **unconstrained reads**, asserted on
-//! every accepting run of both layers above. A cell an instruction read that
-//! nothing ever wrote is a live value from outside the constraint system.
-//!
-//! The three are complementary, and a fix should land with whichever one catches
-//! it. Layer 3 sees a dropped store whose cell is then *read* (the value came from
-//! nowhere); layer 2 sees a dropped store whose cell is then *ignored* (the value
-//! came from the alias instead, and the physical write is orphaned), and layer 3 is
-//! blind to that one, because nothing reads the orphan. Layer 1 needs a program
-//! whose assertion the poke can violate, and in exchange it needs no second
-//! spelling to compare against.
-
-#![allow(dead_code)]
-
-use lean_compiler::{compile_without_filler, parse};
-use lean_vm::cpu::Program;
-use primitives::field::{F64, F192, g_pow};
-
-mod cases;
-mod pairs;
-
-/// `g^k` as a machine word, the way every index, address and counter is written.
-pub fn g(k: usize) -> F192 {
- F192::from(g_pow(k))
-}
-
-/// A K-valued literal in the low lane.
-pub fn k(x: u64) -> F192 {
- F192::from(F64(x))
-}
-
-/// One `hint_witness` stream: the name, then one entry per call naming it.
-pub type Stream = (&'static str, Vec>);
-
-/// Everything a run consumes: the public statement and the prover's advice.
-#[derive(Clone)]
-pub struct Trial {
- pub pi: [F192; 2],
- pub streams: Vec,
-}
-
-impl Trial {
- pub fn new(pi: [F192; 2]) -> Self {
- Self {
- pi,
- streams: Vec::new(),
- }
- }
-
- /// Add a stream whose every call takes one entry of `cells`.
- pub fn stream(mut self, name: &'static str, entries: Vec>) -> Self {
- self.streams.push((name, entries));
- self
- }
-
- fn poke(&self, p: &Poke) -> Self {
- let mut t = self.clone();
- match *p {
- Poke::Pi { slot, to } => t.pi[slot] = to,
- Poke::Wit { name, entry, cell, to } => {
- let s = t
- .streams
- .iter_mut()
- .find(|(n, _)| *n == name)
- .unwrap_or_else(|| panic!("no stream `{name}` in this trial"));
- s.1[entry][cell] = to;
- }
- }
- t
- }
-}
-
-/// A single-cell mutation of a trial. One cell, so a poke that is accepted names
-/// exactly the constraint that is missing.
-#[derive(Clone, Copy)]
-pub enum Poke {
- /// Public-input word 0 or 1.
- Pi { slot: usize, to: F192 },
- /// Cell `cell` of entry `entry` of witness stream `name`.
- Wit {
- name: &'static str,
- entry: usize,
- cell: usize,
- to: F192,
- },
-}
-
-impl Poke {
- fn label(&self) -> String {
- match self {
- Poke::Pi { slot, to } => format!("pi[{slot}] := {:x}:{:x}:{:x}", to.c2, to.c1, to.c0),
- Poke::Wit { name, entry, cell, to } => {
- format!("{name}[{entry}][{cell}] := {:x}:{:x}:{:x}", to.c2, to.c1, to.c0)
- }
- }
- }
-}
-
-/// Poke a public-input word.
-pub fn pi(slot: usize, to: F192) -> Poke {
- Poke::Pi { slot, to }
-}
-
-/// Poke cell `cell` of the first entry of stream `name`.
-pub fn wit(name: &'static str, cell: usize, to: F192) -> Poke {
- Poke::Wit {
- name,
- entry: 0,
- cell,
- to,
- }
-}
-
-/// Poke cell `cell` of entry `entry` of stream `name`.
-pub fn wit_at(name: &'static str, entry: usize, cell: usize, to: F192) -> Poke {
- Poke::Wit { name, entry, cell, to }
-}
-
-/// What an honest run of the emitted bytecode did.
-pub enum Ran {
- /// It completed. Carries the cells it read that nothing ever wrote, which
- /// must be empty for the program to mean what its source says.
- Ok { unconstrained: Vec },
- /// It failed: a write-once conflict (which is how every `assert` fails), a
- /// wild dereference, or any other [`lean_vm::cpu::Fault`].
- Rejected,
-}
-
-impl Ran {
- pub fn accepted(&self) -> bool {
- matches!(self, Ran::Ok { .. })
- }
- fn verb(&self) -> &'static str {
- if self.accepted() { "ACCEPTED" } else { "rejected" }
- }
-}
-
-/// Compile once. Kept out of [`run`] so a compiler panic is a loud test failure
-/// rather than a silent "rejected".
-pub fn build(src: &str) -> Program {
- compile_without_filler(&parse(src).expect("parse"))
-}
-
-/// Run `program` on `t`. The fill blocks are irrelevant to what the program
-/// asserts, so this executes the unfilled build.
-pub fn run(program: &Program, t: &Trial) -> Ran {
- let mut p = program.clone();
- for (name, entries) in &t.streams {
- p.set_witness(*name, entries.clone());
- }
- let pi = t.pi;
- match p.execute(pi) {
- Ok(exec) => Ran::Ok {
- unconstrained: exec.unconstrained_reads,
- },
- Err(_) => Ran::Rejected,
- }
-}
-
-// ---------------------------------------------------------------------------
-// Layer 1: perturbation
-// ---------------------------------------------------------------------------
-
-/// One program, one valid trial, and the pokes that must break it.
-pub struct Case {
- pub name: &'static str,
- pub src: &'static str,
- pub valid: Trial,
- pub pokes: Vec,
-}
-
-pub fn check_case(c: &Case) {
- let program = build(c.src);
- match run(&program, &c.valid) {
- Ran::Ok { unconstrained } => assert!(
- unconstrained.is_empty(),
- "{}: the valid trial reads cells nothing writes: {unconstrained:?}. \
- A live value came from outside the constraint system, so the lowering \
- dropped a store the source asked for.",
- c.name
- ),
- Ran::Rejected => panic!("{}: the valid trial must run, and did not", c.name),
- }
- assert!(!c.pokes.is_empty(), "{}: a case with no pokes checks nothing", c.name);
- for p in &c.pokes {
- assert!(
- !run(&program, &c.valid.poke(p)).accepted(),
- "{}: poke `{}` was ACCEPTED. The constraint that should have caught it \
- is not in the emitted bytecode.",
- c.name,
- p.label()
- );
- }
-}
-
-// ---------------------------------------------------------------------------
-// Layer 2: equivalence
-// ---------------------------------------------------------------------------
-
-/// Two spellings the language documents as interchangeable, and the trials that
-/// have to agree. `why` names the guarantee, so a failure reads as a broken
-/// promise rather than as a diff.
-pub struct Pair<'a> {
- pub name: &'static str,
- pub why: &'static str,
- pub a: &'a str,
- pub b: &'a str,
- pub trials: Vec,
-}
-
-pub fn check_pair(p: &Pair<'_>) {
- let (pa, pb) = (build(p.a), build(p.b));
- assert!(!p.trials.is_empty(), "{}: a pair with no trials checks nothing", p.name);
- let (mut agreed_reject, mut agreed_accept) = (false, false);
- for (i, t) in p.trials.iter().enumerate() {
- let (ra, rb) = (run(&pa, t), run(&pb, t));
- assert_eq!(
- ra.accepted(),
- rb.accepted(),
- "{}: trial {i}: spelling A {} but spelling B {}.\n {}\n\
- One of the two dropped a constraint; the more permissive side is the buggy one.",
- p.name,
- ra.verb(),
- rb.verb(),
- p.why
- );
- for (which, r) in [("A", &ra), ("B", &rb)] {
- if let Ran::Ok { unconstrained } = r {
- assert!(
- unconstrained.is_empty(),
- "{}: trial {i}: spelling {which} reads cells nothing writes: {unconstrained:?}",
- p.name
- );
- }
- }
- agreed_reject |= !ra.accepted();
- agreed_accept |= ra.accepted();
- }
- // A pair needs a trial of each verdict. All-accepted would pass if both
- // spellings dropped everything; all-REJECTED would pass if both spellings
- // were nonsense, which is the easier mistake to make, since an accepting
- // trial has to name the right answer and a rejecting one does not.
- assert!(
- agreed_reject,
- "{}: every trial was accepted by both spellings, so this pair would pass even \
- if both sides dropped the constraint. Add a trial that must be rejected.",
- p.name
- );
- assert!(
- agreed_accept,
- "{}: no trial was accepted, so this pair compares two programs that always fail \
- and would pass however either was broken. Add a trial that must be accepted.",
- p.name
- );
-}
diff --git a/crates/lean_compiler/tests/suite/soundness/pairs.rs b/crates/lean_compiler/tests/suite/soundness/pairs.rs
deleted file mode 100644
index 92841635b..000000000
--- a/crates/lean_compiler/tests/suite/soundness/pairs.rs
+++ /dev/null
@@ -1,540 +0,0 @@
-//! Layer 2: equivalence. Two spellings the language documents as interchangeable
-//! must accept exactly the same trials.
-//!
-//! This is the layer that finds dropped constraints without anyone having to
-//! guess where they went. A dropped store is invisible on its own: the program
-//! still runs, and its one honest witness still passes. It becomes visible the
-//! moment you have a second spelling of the same intent that *kept* the store,
-//! because then one side rejects a witness the other accepts, and the more
-//! permissive side is the buggy one.
-//!
-//! Every pair here is a promise `zkDSL.md` makes. When one fails, quote the
-//! promise in the bug report; the `why` field is there to be quoted.
-
-use super::{Pair, Trial, check_pair, g, k};
-use primitives::field::F192;
-
-/// One hinted pair of cells, published so the trial's public input pins them.
-fn two(a: F192, b: F192) -> Trial {
- Trial::new([a, b]).stream("w", vec![vec![a, b]])
-}
-
-/// `@inline` is documented as a pure call-site expansion: "the body is inlined at
-/// each call site" with the same semantics as the call. So a function's
-/// observable behaviour cannot depend on whether it carries the decorator.
-#[test]
-fn inline_and_plain_calls_agree() {
- let body = "\
-def main():
- v = StackBuf(2)
- hint_witness(v, \"w\")
- assert shift(v[0]) == v[1]
- p = GEN ** 0
- p[1] = v[0]
- p[GEN] = v[1]
- return
-
-
-@INLINE
-def shift(x):
- return x * GEN
-";
- check_pair(&Pair {
- name: "inline_and_plain_calls_agree",
- why: "zkDSL.md §`@inline`: inlining is a call-site expansion, not a change of meaning.",
- a: &body.replace("@INLINE\n", "@inline\n"),
- b: &body.replace("@INLINE\n", ""),
- trials: vec![
- two(g(3), g(4)), // shift(g^3) = g^4
- two(g(3), g(5)), // rejected by both
- two(g(0), g(1)),
- two(g(7), g(7)),
- ],
- });
-}
-
-/// Write-once memory is the assertion mechanism, so `assert a == b` and two
-/// stores of `a` and `b` into one heap cell are the same statement. `zkDSL.md`
-/// §Memory: "a second write of the same value is a no-op, of a different value a
-/// proof failure. This turns stores into equality assertions".
-#[test]
-fn assert_eq_and_double_heap_store_agree() {
- check_pair(&Pair {
- name: "assert_eq_and_double_heap_store_agree",
- why: "zkDSL.md §Memory: a store into an already-written cell IS an equality assertion.",
- a: "\
-def main():
- v = StackBuf(2)
- hint_witness(v, \"w\")
- assert v[0] == v[1]
- p = GEN ** 0
- p[1] = v[0]
- p[GEN] = v[1]
- return
-",
- b: "\
-def main():
- v = StackBuf(2)
- hint_witness(v, \"w\")
- h = HeapBuf(1)
- h[1] = v[0]
- h[1] = v[1]
- p = GEN ** 0
- p[1] = v[0]
- p[GEN] = v[1]
- return
-",
- trials: vec![
- two(g(3), g(3)),
- two(g(3), g(4)),
- two(F192::ZERO, F192::ZERO),
- two(F192::ZERO, k(1)),
- ],
- });
-}
-
-/// `zkDSL.md` §field: "`/` is runtime field division … the compiler leaves the
-/// quotient cell unset and emits the checked relation `quotient · b == a`". So
-/// dividing and then comparing must equal comparing the product, wherever the
-/// divisor is nonzero (division by zero is documented undefined, so no trial
-/// takes it there).
-#[test]
-fn division_and_checked_product_agree() {
- check_pair(&Pair {
- name: "division_and_checked_product_agree",
- why: "zkDSL.md §field: `a / b` emits exactly the relation `quotient · b == a`.",
- a: "\
-def main():
- v = StackBuf(3)
- hint_witness(v, \"w\")
- q = v[1] / v[0]
- assert q == v[2]
- p = GEN ** 0
- p[1] = v[0]
- p[GEN] = v[2]
- return
-",
- b: "\
-def main():
- v = StackBuf(3)
- hint_witness(v, \"w\")
- assert v[2] * v[0] == v[1]
- p = GEN ** 0
- p[1] = v[0]
- p[GEN] = v[2]
- return
-",
- trials: vec![
- three(g(3), g(8), g(5)), // g^8 / g^3 = g^5
- three(g(3), g(8), g(6)), // rejected by both
- three(k(1), g(9), g(9)),
- three(g(2), g(2), g(0)),
- ],
- });
-}
-
-fn three(a: F192, b: F192, c: F192) -> Trial {
- Trial::new([a, c]).stream("w", vec![vec![a, b, c]])
-}
-
-/// `unroll` is documented as compile-time unrolling, so a loop and its expansion
-/// are the same program. A structural pair: it pins the loop machinery itself
-/// rather than any one assertion, which is what catches a lowering that
-/// mis-addresses one iteration.
-#[test]
-fn unroll_and_expansion_agree() {
- check_pair(&Pair {
- name: "unroll_and_expansion_agree",
- why: "zkDSL.md §unroll: the loop is expanded at compile time, so it IS the expansion.",
- a: "\
-def main():
- v = StackBuf(1)
- hint_witness(v, \"w\")
- a = HeapBuf(4)
- a[1] = v[0]
- for i in unroll(0, 3):
- a[GEN ** (i + 1)] = a[GEN ** i] * GEN
- p = GEN ** 0
- p[1] = a[GEN ** 3]
- p[GEN] = v[0]
- return
-",
- b: "\
-def main():
- v = StackBuf(1)
- hint_witness(v, \"w\")
- a = HeapBuf(4)
- a[1] = v[0]
- a[GEN] = a[1] * GEN
- a[GEN ** 2] = a[GEN] * GEN
- a[GEN ** 3] = a[GEN ** 2] * GEN
- p = GEN ** 0
- p[1] = a[GEN ** 3]
- p[GEN] = v[0]
- return
-",
- trials: vec![
- one(g(3), g(0)), // a[3] = g^0·g^3
- one(g(4), g(0)), // rejected by both
- one(g(8), g(5)),
- one(g(5), g(5)),
- ],
- });
-}
-
-/// A published pair whose first word is the claim and whose second is the hint.
-fn one(published: F192, hint: F192) -> Trial {
- Trial::new([published, hint]).stream("w", vec![vec![hint]])
-}
-
-/// An `@inline` function returning a one-cell `StackBuf`, used in expression
-/// position, must hold the value its body stored. `zkDSL.md` §`@inline` makes the
-/// decorator a call-site expansion, and §StackBuf makes `s[0]` the cell the body
-/// wrote, so binding the call with `let` and using it inline are the same program.
-///
-/// Regression test: `take_inline_ret_cell` used to hand back the raw frame cell
-/// rather than following the deferred-copy alias, so the caller read a cell no
-/// instruction ever wrote. The `assert` then compared that cell instead of the
-/// value, which made it vacuous, and the honest runner back-solved the cell to
-/// whatever the public statement demanded.
-#[test]
-fn inline_stackbuf_return_in_expression_position() {
- let body = "\
-def main():
- v = StackBuf(2)
- hint_witness(v, \"w\")
- ASSERTION
- p = GEN ** 0
- p[1] = v[0]
- p[GEN] = v[1]
- return
-
-
-@inline
-def pick(x):
- s = StackBuf(1)
- s[0] = x
- return s
-";
- check_pair(&Pair {
- name: "inline_stackbuf_return_in_expression_position",
- why: "zkDSL.md §`@inline` + §StackBuf: `pick(x)[0]` is the cell the body stored `x` into, \
- whether the caller binds the call or writes it inline.",
- a: &body.replace("ASSERTION", "assert pick(v[0]) != v[1]"),
- b: &body.replace("ASSERTION", "r = pick(v[0])\n assert r[0] != v[1]"),
- trials: vec![
- two(g(3), g(5)), // distinct: accepted by both
- two(g(3), g(3)), // equal and nonzero: the inequality must fail for both
- two(k(1), k(1)),
- two(g(7), g(2)),
- ],
- });
-}
-
-/// A store into a cell something already gave a value to is the write-once
-/// equality assertion of `zkDSL.md` §Memory ("a second write ... of a different
-/// value a proof failure. This turns stores into equality assertions"), whether
-/// the cell is a `StackBuf` cell or a `HeapBuf` cell.
-///
-/// Regression test: `stack_store` deferred a copy-or-constant RHS as an alias
-/// unconditionally, so a `StackBuf` store never pinned a hint. `hint_witness`
-/// named the raw cells while every read forwarded past them, and the check the
-/// author wrote was applied to nothing.
-#[test]
-fn stack_store_pins_a_hint_like_a_heap_store() {
- check_pair(&Pair {
- name: "stack_store_pins_a_hint_like_a_heap_store",
- why: "zkDSL.md §Memory: a store into an already-written cell IS an equality assertion, \
- and §Hints: `s[k] = ` is how a program pins prover advice.",
- a: "\
-def main():
- s = StackBuf(2)
- hint_witness(s, \"w\")
- s[0] = GEN ** 3
- p = GEN ** 0
- p[1] = s[0]
- p[GEN] = s[1]
- return
-",
- b: "\
-def main():
- s = StackBuf(2)
- hint_witness(s, \"w\")
- h = HeapBuf(1)
- h[1] = s[0]
- h[1] = GEN ** 3
- p = GEN ** 0
- p[1] = s[0]
- p[GEN] = s[1]
- return
-",
- trials: vec![
- pinned(g(3), g(9)), // the hint agrees with the pin
- pinned(g(4), g(9)), // it does not: both must reject
- pinned(F192::ZERO, g(1)),
- pinned(g(2), g(3)),
- ],
- });
-}
-
-/// A trial for the pinning pair above: the public input carries the PIN, not the
-/// hint. Publishing the hint would hide a dropped pin, since the publication then
-/// forwards through the very alias that dropped it and both spellings agree by
-/// accident. Publishing the pin makes a dropped pin visible as a program that
-/// accepts every hint.
-fn pinned(hint0: F192, hint1: F192) -> Trial {
- Trial::new([g(3), hint1]).stream("w", vec![vec![hint0, hint1]])
-}
-
-/// `zkDSL.md` §BLAKE2s: "If `out` was already written, the statement *asserts*
-/// the digest equals it, write-once turning the hash into a verification, which
-/// is exactly what a signature verifier wants." That has to hold for a `StackBuf`
-/// `out` as much as for a `HeapBuf` one, since the doc recommends the idiom
-/// without qualifying which.
-///
-/// Regression test: the `BLAKE2s` output arm named the raw run, so a `StackBuf`
-/// `out` whose cells had been pre-written by copies or constants had its digest
-/// written where nothing read it. The "verification" checked nothing, and the
-/// prover could put any message under the hash.
-#[test]
-fn prewritten_blake2s_out_asserts_the_digest() {
- check_pair(&Pair {
- name: "prewritten_blake2s_out_asserts_the_digest",
- why: "zkDSL.md §BLAKE2s: a pre-written `out` turns the hash into a verification.",
- a: "\
-def main():
- v = StackBuf(2)
- hint_witness(v, \"w\")
- m = StackBuf(4)
- m[0] = 5
- m[1] = 7
- m[2] = 0
- m[3] = 0
- d = StackBuf(2)
- d[0] = v[0]
- d[1] = v[1]
- blake2s(m[0:2], m[2:4], d)
- p = GEN ** 0
- p[1] = v[0]
- p[GEN] = v[1]
- return
-",
- b: "\
-def main():
- v = StackBuf(2)
- hint_witness(v, \"w\")
- m = StackBuf(4)
- m[0] = 5
- m[1] = 7
- m[2] = 0
- m[3] = 0
- d = HeapBuf(2)
- d[1] = v[0]
- d[GEN] = v[1]
- blake2s(m[0:2], m[2:4], d[0:2])
- p = GEN ** 0
- p[1] = v[0]
- p[GEN] = v[1]
- return
-",
- trials: vec![
- // The real digest of the block whose cells are (5, 7, 0, 0).
- two(super::cases::DIGEST_5_7[0], super::cases::DIGEST_5_7[1]),
- // Anything else must be rejected by both spellings.
- two(F192::ZERO, F192::ZERO),
- two(super::cases::DIGEST_5_7[0], F192::ZERO),
- two(g(3), g(5)),
- ],
- });
-}
-
-/// Two stores of different values into one cell is the write-once equality
-/// assertion of `zkDSL.md` §Memory, on a `StackBuf` cell as much as on a `HeapBuf`
-/// cell. The doc draws no distinction, and the whole "stores are assertions"
-/// promise rests on there being none.
-#[test]
-fn two_stack_stores_to_one_cell_assert_equality() {
- check_pair(&Pair {
- name: "two_stack_stores_to_one_cell_assert_equality",
- why: "zkDSL.md §Memory: a second write of a different value is a proof failure.",
- a: "\
-def main():
- v = StackBuf(2)
- hint_witness(v, \"w\")
- s = StackBuf(1)
- s[0] = v[0]
- s[0] = v[1]
- p = GEN ** 0
- p[1] = v[0]
- p[GEN] = v[1]
- return
-",
- b: "\
-def main():
- v = StackBuf(2)
- hint_witness(v, \"w\")
- h = HeapBuf(1)
- h[1] = v[0]
- h[1] = v[1]
- p = GEN ** 0
- p[1] = v[0]
- p[GEN] = v[1]
- return
-",
- trials: vec![two(g(3), g(3)), two(g(3), g(4)), two(g(0), g(0)), two(g(5), g(9))],
- });
-}
-
-/// A store made inside a runtime branch into a cell that already carried a value
-/// from before the branch is the same assertion whether the cell is a `StackBuf`
-/// cell or a `HeapBuf` cell. `zkDSL.md` §`if`: "branches communicate through
-/// write-once cells: only one branch executes, so both may write the *same* cell",
-/// and §Memory makes a second write of a different value a failure.
-///
-/// Regression test: `scoped` materialized the branch's value into the cell and
-/// then restored the pre-branch alias over it, so post-join reads forwarded to the
-/// pre-branch source on every path and the materialized write was orphaned. The
-/// published value was the pre-branch one whichever arm ran.
-#[test]
-fn store_inside_a_branch_asserts_against_the_pre_branch_value() {
- check_pair(&Pair {
- name: "store_inside_a_branch_asserts_against_the_pre_branch_value",
- why: "zkDSL.md §`if` + §Memory: both arms may write one cell, and a second write \
- of a different value is a proof failure.",
- a: "\
-def main():
- v = StackBuf(3)
- hint_witness(v, \"w\")
- s = StackBuf(1)
- s[0] = v[0]
- if v[1] == v[2]:
- s[0] = v[1]
- else:
- s[0] = v[2]
- p = GEN ** 0
- p[1] = s[0]
- p[GEN] = v[0]
- return
-",
- b: "\
-def main():
- v = StackBuf(3)
- hint_witness(v, \"w\")
- h = HeapBuf(1)
- h[1] = v[0]
- if v[1] == v[2]:
- h[1] = v[1]
- else:
- h[1] = v[2]
- p = GEN ** 0
- p[1] = h[1]
- p[GEN] = v[0]
- return
-",
- trials: vec![
- // else arm, and v[0] != v[2]: the assertion must fail for both.
- branch3(g(1), g(2), g(3)),
- // else arm, and v[0] == v[2]: accepted by both.
- branch3(g(1), g(2), g(1)),
- // then arm, and v[0] == v[1]: accepted by both.
- branch3(g(1), g(1), g(1)),
- // then arm, and v[0] != v[1] (v[1] == v[2] picks it): must fail.
- branch3(g(1), g(4), g(4)),
- ],
- });
-}
-
-/// A trial for the branch pair: publishes `s[0]` and `v[0]`, which the assertion
-/// makes equal on every accepting path.
-fn branch3(a: F192, b: F192, c: F192) -> Trial {
- Trial::new([a, a]).stream("w", vec![vec![a, b, c]])
-}
-
-/// A multi-value target may be a `StackBuf` element, and `zkDSL.md` §match
-/// says the arms then write straight into it. That is only a saved copy, so it
-/// must accept exactly what the name-plus-store spelling accepts. The bug this
-/// guards is the arms writing PAST the buffer: the target cell is the callee's
-/// return slot now, so an unchecked index puts a return into the next buffer,
-/// which is the shape `copy_alias` had before its bounds check was added.
-#[test]
-fn a_stackbuf_target_and_a_name_plus_store_agree() {
- let body = "\
-def pick(x, i: Const):
- return x * GEN ** i, GEN ** i
-
-def main():
- v = StackBuf(2)
- hint_witness(v, \"w\")
- sb = StackBuf(2)
- sb[1] = 0
- TARGET
- p = GEN ** 0
- p[1] = sb[0]
- p[GEN] = e
- return
-";
- check_pair(&Pair {
- name: "a_stackbuf_target_and_a_name_plus_store_agree",
- why: "zkDSL.md §match: a target may be a name or a StackBuf element; the element form \
- only saves the copy.",
- a: &body.replace(
- "TARGET",
- "sb[0], e = match(log(v[0]), range(0, 2), lambda i: pick(v[1], i))",
- ),
- b: &body.replace(
- "TARGET",
- "t, e = match(log(v[0]), range(0, 2), lambda i: pick(v[1], i))\n sb[0] = t",
- ),
- trials: vec![
- Trial::new([g(4), g(0)]).stream("w", vec![vec![g(0), g(4)]]),
- Trial::new([g(5), g(1)]).stream("w", vec![vec![g(1), g(4)]]),
- Trial::new([g(4), g(1)]).stream("w", vec![vec![g(1), g(4)]]),
- Trial::new([g(9), g(0)]).stream("w", vec![vec![g(0), g(4)]]),
- ],
- });
-}
-
-/// An `@inline` arm expands into the dispatching frame, its locals over cells the
-/// other arms share, where a plain one fuses into a real call. Both must accept
-/// the same trials. Arm `n` allocates `n` locals and the join allocates after
-/// them, so a cell the arms or the join wrongly share shows up here.
-#[test]
-fn an_inline_match_arm_and_a_called_one_agree() {
- let body = "\
-def main():
- v = StackBuf(3)
- hint_witness(v, \"w\")
- assert log(v[0]) < 4
- r, e = match(log(v[0]), range(0, 4), lambda i: pw(v[1], i))
- t = r * e
- assert t == v[2]
- p = GEN ** 0
- p[1] = v[0]
- p[GEN] = t
- return
-
-
-@INLINE
-def pw(x, n: Const):
- y = x
- for j in unroll(0, n):
- y = y * x
- return y, GEN ** n
-";
- // Arm n publishes t = x^(n+1)·g^n.
- let arm = |n: usize, x: usize, t: usize| Trial::new([g(n), g(t)]).stream("w", vec![vec![g(n), g(x), g(t)]]);
- check_pair(&Pair {
- name: "an_inline_match_arm_and_a_called_one_agree",
- why: "zkDSL.md §`@inline`: an inlined arm is a call-site expansion, not a change of meaning.",
- a: &body.replace("@INLINE\n", "@inline\n"),
- b: &body.replace("@INLINE\n", ""),
- trials: vec![
- arm(2, 1, 5),
- arm(0, 3, 3),
- arm(3, 2, 11),
- arm(2, 1, 6),
- arm(1, 1, 5),
- arm(4, 1, 9),
- ],
- });
-}
diff --git a/crates/lean_compiler/tests/suite/stack_bits.rs b/crates/lean_compiler/tests/suite/stack_bits.rs
deleted file mode 100644
index 579784750..000000000
--- a/crates/lean_compiler/tests/suite/stack_bits.rs
+++ /dev/null
@@ -1,222 +0,0 @@
-//! Computed-advice bit buffers in the frame: `hint_decompose_bits` into a
-//! `StackBuf`, and `addr()` naming the run so it can still be indexed through a
-//! pointer (at a runtime index, or from a callee).
-
-use lean_compiler::{compile, parse};
-use lean_vm::cpu::{Fault, Stats, prove, verify};
-use primitives::field::{F64, F192};
-
-const V: u64 = 0b1011_0110;
-
-fn deref_index() -> usize {
- Stats::TABLES.iter().position(|&t| t == "DEREF").expect("a DEREF table")
-}
-
-/// The same eight-bit decomposition, once through a `HeapBuf` and once through a
-/// `StackBuf`. Every index is compile-time, so the frame run needs no `DEREF` at
-/// all where the heap one needs two per bit (the read and the booleanity pin).
-#[test]
-fn a_frame_bit_buffer_costs_no_deref() {
- let src = |decl: &str, idx: &str| {
- format!(
- "\
-def main():
- bits = {decl}
- hint_decompose_bits(bits, {V}, 8)
- acc = 0
- for i in unroll(0, 8):
- b = bits[{idx}]
- bits[{idx}] = b * b
- acc += b * (2 ** i)
- assert acc == {V}
- p = 1
- p[1] = acc
- p[GEN] = 1
- return
-"
- )
- };
- let want = [F192::from(F64(V)), F192::from(F64::ONE)];
- let deref = |s: &str| crate::common::mix(s, want)[deref_index()];
- assert_eq!(
- deref(&src("HeapBuf(GEN ** 8)", "GEN ** i")) - deref(&src("StackBuf(8)", "i")),
- 16,
- "a frame bit buffer must drop both DEREFs per bit"
- );
-}
-
-/// A stack bit run is addressed by CONTIGUITY, so no cell of one may be given
-/// away to a duplicate elsewhere: `hint_log2_ceil` reads `fp+base+k` whatever the
-/// lowerer decided, so a dropped store would leave it holding nothing. The
-/// duplicate `MUL` here comes FIRST, which is the order that would make the store
-/// the one dropped.
-#[test]
-fn a_stack_bit_run_survives_cell_sharing() {
- let src = "\
-def main():
- src = StackBuf(4)
- hint_witness(src, \"bits\")
- bits = StackBuf(4)
- for i in unroll(0, 4):
- dup = src[i] * src[i]
- bits[i] = src[i] * src[i]
- assert dup == bits[i]
- g_mu = hint_log2_ceil(bits, 4, 0)
- p = 1
- p[1] = g_mu
- p[GEN] = 1
- return
-";
- // bits 1011 = 11, whose ceil-log is 4. Executing is enough: a folded-away
- // store leaves its cell unwritten, and the advice then computed off the hole
- // collides with the published public input.
- let mut program = compile(&parse(src).expect("parse"));
- let bits: Vec = [1u64, 1, 0, 1].iter().map(|&b| F192::from(F64(b))).collect();
- program.set_witness("bits", vec![bits]);
- let want = [F192::from(primitives::field::g_pow(4)), F192::from(F64::ONE)];
- let exec = program.execute(want).unwrap();
- assert!(
- exec.unconstrained_reads.is_empty(),
- "every cell of the run must still be written"
- );
-}
-
-/// `addr(sb)` in a non-`main` function, where naming `fp` costs the two-`DEREF`
-/// bounce: the pointer reads the very cells the direct indices wrote, at a
-/// compile-time offset and at a runtime one.
-#[test]
-fn addr_names_the_frame_run() {
- let src = "\
-def main():
- w = StackBuf(1)
- hint_witness(w, \"v\")
- out = probe(w[0])
- p = 1
- p[1] = out
- p[GEN] = 1
- return
-
-def probe(v):
- bits = StackBuf(8)
- hint_decompose_bits(bits, v, 8)
- ptr = addr(bits)
- acc = 0
- for i in unroll(0, 8):
- b = bits[i]
- bits[i] = b * b
- acc += b * (2 ** i)
- assert acc == v
- total = 0
- for i in unroll(0, 8):
- total += ptr[GEN ** i] * (2 ** i)
- assert total == v
- for x in mul_range(1, GEN ** 8):
- chk = ptr[x]
- assert chk * chk == chk
- assert addr(bits) == ptr
- return total
-";
- let mut program = compile(&parse(src).expect("parse"));
- program.set_witness("v", vec![vec![F192::from(F64(V))]]);
- let want = [F192::from(F64(V)), F192::from(F64::ONE)];
- let (proof, _) = prove(&program, want, lean_vm::pcs::TEST_LOG_INV_RATE).unwrap();
- verify(&program, &want, &proof).expect("the pointer reads the frame run");
- let bad = [F192::from(F64(V + 1)), F192::from(F64::ONE)];
- assert!(verify(&program, &bad, &proof).is_err(), "a wrong value is rejected");
-}
-
-/// A store into a cell the program has ALREADY written through an `addr()`
-/// pointer is the write-once equality assertion of `zkDSL.md` §Memory, not an
-/// assembly copy. `addr()` hands out an ordinary `GAddr`, so a write through it
-/// is a `DEREF` whose only `phys`-recorded cell is its source; the run's own
-/// cells looked untouched, the store deferred as an alias and emitted nothing,
-/// and the program went on to "prove" that one cell held two different values.
-#[test]
-fn a_store_after_a_write_through_addr_still_asserts() {
- let src = "\
-def main():
- b = StackBuf(1)
- p = addr(b)
- v = GEN ** 7
- p[1] = v
- b[0] = GEN ** 9
- return
-";
- let err = compile(&parse(src).expect("parse"))
- .execute([F192::ZERO; 2])
- .err()
- .expect("the run must fail");
- assert!(matches!(err.fault, Fault::Conflict { .. }), "{err}");
-}
-
-/// The same hazard for a run declared AFTER the escape, which the test above
-/// does not reach: `unsealed_runs` is already `None` by then, so `alloc_stack`
-/// has to seal the run on the spot. Left transparent, `b[0] = GEN ** 9` defers
-/// as a constant alias, the assert folds to `const == const`, and the program
-/// proves that a cell holding `g^7` holds `g^9`.
-#[test]
-fn a_run_declared_after_the_escape_is_sealed_too() {
- let src = "\
-def poke(q):
- q[GEN ** 3] = GEN ** 7
- return 0
-
-def main():
- a = StackBuf(2)
- pa = addr(a)
- b = StackBuf(1)
- z = poke(pa)
- b[0] = GEN ** 9
- assert b[0] == GEN ** 9
- return
-";
- let err = compile(&parse(src).expect("parse"))
- .execute([F192::ZERO; 2])
- .err()
- .expect("the run must fail");
- assert!(matches!(err.fault, Fault::Conflict { .. }), "{err}");
-}
-
-/// A frame pointer carries the same compile-time bound a `HeapBuf` pointer gets.
-/// `check_heap_bound` keys `heap_sizes` by the pointer's own cell, but every
-/// `addr()` in a function shares the `fp` cell as its base, so the run has to
-/// come from the address's own provenance instead.
-#[test]
-#[should_panic(expected = "out of bounds")]
-fn addr_pointers_are_bounds_checked() {
- let src = "\
-def main():
- b = StackBuf(2)
- p = addr(b)
- p[GEN ** 40] = GEN ** 3
- return
-";
- compile(&parse(src).expect("parse"));
-}
-
-/// The pointer `addr()` hands out addresses the WHOLE frame, not the run it was
-/// taken from, so sealing only that run leaves the next `StackBuf` transparent:
-/// one off-by-one in a callee writes it, and its later store then defers as an
-/// alias and drops the write-once assertion, exactly as before the fix. An
-/// escaped frame address therefore seals every run in the function.
-#[test]
-fn an_escaped_frame_address_seals_every_run() {
- let src = "\
-def poke(q):
- q[GEN ** 2] = GEN ** 7
- return 0
-
-def main():
- a = StackBuf(2)
- b = StackBuf(1)
- z = poke(addr(a))
- b[0] = GEN ** 9
- assert b[0] == GEN ** 9
- return
-";
- let err = compile(&parse(src).expect("parse"))
- .execute([F192::ZERO; 2])
- .err()
- .expect("the run must fail");
- assert!(matches!(err.fault, Fault::Conflict { .. }), "{err}");
-}
diff --git a/crates/lean_compiler/tests/suite/stack_buf.rs b/crates/lean_compiler/tests/suite/stack_buf.rs
deleted file mode 100644
index 3a2405e3a..000000000
--- a/crates/lean_compiler/tests/suite/stack_buf.rs
+++ /dev/null
@@ -1,813 +0,0 @@
-//! `StackBuf`: a run of consecutive frame (stack) cells in the zkDSL. Indexed
-//! reads/writes go straight to `base+k` (no heap deref), and a size-2 `StackBuf`
-//! is a `blake2s` operand: its two canonical 128-bit cells hold the 256-bit value, so
-//! `blake2s(a, b, out)` reads them in place with no copies (a self-hash
-//! `blake2s(h, h, out)` aliases one pair into both input operands) and writes
-//! the digest into the pre-allocated pair `out`.
-//!
-//! Since these DSL scalars are K-embedded F192 cells, a `StackBuf(2)` written
-//! cell-by-cell holds the flock words `[v0, 0, v1, 0]`
-//!: the reference `compress` is fed that lane layout.
-
-use lean_compiler::{compile, parse};
-use lean_vm::cpu::{Op, prove, verify};
-use lean_vm::hash_flock::{compression, digest, metadata, unpack_metadata};
-use lean_vm::vmhash::compress;
-use primitives::field::{F64, F192, g_pow};
-
-use crate::common::mix;
-
-/// The two 128-bit digest cells of `compress(a, b)` as `F192`s (lo = word 0/2,
-/// hi = word 1/3): what a `blake2s(...)` output `StackBuf(2)` holds cell-by-cell.
-fn digest_cells(a: [F64; 4], b: [F64; 4]) -> [F192; 2] {
- let d = compress(a, b);
- [F192::new(d[0].0, d[1].0, 0), F192::new(d[2].0, d[3].0, 0)]
-}
-
-/// A size-2 `StackBuf` fed to `blake2s` as a self-hash `blake2s(h, h)`, then the
-/// digest's two 128-bit cells published to `m[0], m[1]`. Proves and verifies, and
-/// a wrong published digest is rejected: so the whole path (StackBuf load →
-/// aliased blake2s → stack read → publish) is exercised end-to-end.
-#[test]
-fn stack_buf_blake2s_self_hash() {
- let src = "\
-def main():
- a = StackBuf(2)
- a[0] = 5
- a[1] = 7
- c = StackBuf(2)
- blake2s(a, a, c)
- p = 1
- p[1] = c[0]
- p[GEN] = c[1]
- return
-";
- let program = compile(&parse(src).expect("parse"));
-
- // Each cell holds one scalar in its low lane, so the hashed words are [5,0,7,0].
- let h = [F64(5), F64(0), F64(7), F64(0)];
- let want = digest_cells(h, h);
-
- let (proof, _) = prove(&program, want, lean_vm::pcs::TEST_LOG_INV_RATE).unwrap();
- assert_eq!(mix(src, want)[5], 1, "one BLAKE2s instruction");
- verify(&program, &want, &proof).expect("StackBuf self-hash verifies");
-
- let mut bad = want;
- bad[0] += F192::ONE;
- assert!(verify(&program, &bad, &proof).is_err(), "wrong digest must be rejected");
-}
-
-/// Optional BLAKE2s metadata and a memory-supplied chaining value reproduce a
-/// standard two-block (80-byte) BLAKE2s hash.
-#[test]
-fn blake2s_keywords_standard_multiblock() {
- let src = "\
-def main():
- block0 = [1, 2, 3, 4]
- tail = [5, 0, 0, 0]
- cv = StackBuf(2)
- blake2s(block0[0:2], block0[2:4], cv, counter=64, final=0)
- out = StackBuf(2)
- blake2s(tail[0:2], tail[2:4], out, cv=cv, counter=80, final=1)
- p = 1
- p[1] = out[0]
- p[GEN] = out[1]
- return
-";
- let program = compile(&parse(src).expect("parse"));
- let mut input = Vec::new();
- for value in 1u64..=5 {
- input.extend_from_slice(&value.to_le_bytes());
- input.extend_from_slice(&0u64.to_le_bytes());
- }
- let d = primitives::hash::hash(&input);
- let word = |o: usize| u64::from_le_bytes(d[o..o + 8].try_into().unwrap());
- let want = [F192::new(word(0), word(8), 0), F192::new(word(16), word(24), 0)];
- let (proof, _) = prove(&program, want, lean_vm::pcs::TEST_LOG_INV_RATE).unwrap();
- assert_eq!(mix(src, want)[5], 2);
- verify(&program, &want, &proof).expect("standard two-block BLAKE2s verifies");
-}
-
-/// The same 80-byte hash with its second block's metadata computed at run time,
-/// the shape a hash of runtime length needs: the counter's high part is a word
-/// the program produced and its low part a compile-time constant, and their set
-/// bits are disjoint, so one `XOR` is their integer sum (doc
-/// §sec:prog-byte-counter). The hint stands in for the high part a real absorb
-/// loop derives from its own counter.
-#[test]
-fn blake2s_runtime_metadata_matches_the_standard_hash() {
- let src = "\
-def main():
- block0 = [1, 2, 3, 4]
- tail = [5, 0, 0, 0]
- cv = StackBuf(2)
- blake2s(block0[0:2], block0[2:4], cv, counter=64, final=0)
- high = hint_witness(\"high\")
- assert high == 64
- out = StackBuf(2)
- blake2s(tail[0:2], tail[2:4], out, cv=cv, md=high + f192(16, 4294967295, 0))
- p = 1
- p[1] = out[0]
- p[GEN] = out[1]
- return
-";
- let mut program = compile(&parse(src).expect("parse"));
- program.set_witness("high", vec![vec![F192::new(64, 0, 0)]]);
- let mut input = Vec::new();
- for value in 1u64..=5 {
- input.extend_from_slice(&value.to_le_bytes());
- input.extend_from_slice(&0u64.to_le_bytes());
- }
- let d = primitives::hash::hash(&input);
- let word = |o: usize| u64::from_le_bytes(d[o..o + 8].try_into().unwrap());
- let want = [F192::new(word(0), word(8), 0), F192::new(word(16), word(24), 0)];
- let (proof, _) = prove(&program, want, lean_vm::pcs::TEST_LOG_INV_RATE).unwrap();
- verify(&program, &want, &proof).expect("a runtime metadata word hashes to the standard digest");
-}
-
-#[test]
-fn blake2s_counter_accepts_full_u64_range() {
- let src = "\
-def main():
- block = [1, 2, 3, 4]
- out = StackBuf(2)
- counter = 18446744073709551615 // 1
- blake2s(block[0:2], block[2:4], out, counter=counter, final=1)
- return
-";
- let program = compile(&parse(src).expect("parse"));
- // The metadata is a memory operand, so what carries the counter is the `SET`
- // immediate that wrote the cell the instruction reads.
- let md = program
- .prog
- .iter()
- .find_map(|op| match op {
- Op::Blake2s { md, .. } => Some(*md),
- _ => None,
- })
- .expect("BLAKE2s instruction");
- let metadata = program
- .prog
- .iter()
- .find_map(|op| match op {
- Op::Set { o, k } if *o == md => Some(*k),
- _ => None,
- })
- .expect("the metadata cell's SET");
- assert_eq!(unpack_metadata(metadata), (u64::MAX, u32::MAX, 0));
-}
-
-#[test]
-#[should_panic(expected = "counter= 18446744073709551616 does not fit in u64")]
-fn blake2s_counter_rejects_values_above_u64() {
- let src = "\
-def main():
- block = [1, 2, 3, 4]
- out = StackBuf(2)
- blake2s(block[0:2], block[2:4], out, counter=18446744073709551616, final=1)
- return
-";
- compile(&parse(src).expect("parse"));
-}
-
-/// A default IV first materialized in an untaken runtime branch must not leak
-/// into the post-join lowering state. Both executions must initialize the IV
-/// on the path that reaches the second hash.
-#[test]
-fn blake2s_default_iv_after_runtime_branch() {
- let src = "\
-def main():
- flag = StackBuf(1)
- hint_witness(flag, \"flag\")
- a = [1, 2, 3, 4]
- if flag[0] == 1:
- ignored = StackBuf(2)
- blake2s(a[0:2], a[2:4], ignored)
- out = StackBuf(2)
- blake2s(a[0:2], a[2:4], out)
- p = 1
- p[1] = out[0]
- p[GEN] = out[1]
- return
-";
- let want = digest_cells([F64(1), F64(0), F64(2), F64(0)], [F64(3), F64(0), F64(4), F64(0)]);
- for flag in [0, 1] {
- let mut program = compile(&parse(src).expect("parse"));
- program.set_witness("flag", vec![vec![F192::new(flag, 0, 0)]]);
- let (proof, _) = prove(&program, want, lean_vm::pcs::TEST_LOG_INV_RATE).unwrap();
- verify(&program, &want, &proof).expect("post-join default IV is initialized on both paths");
- }
-}
-
-/// Each mutually exclusive branch gets a path-local IV initialization when no
-/// dominating default-IV hash exists before the branch.
-#[test]
-fn blake2s_default_iv_in_both_runtime_branches() {
- let src = "\
-def main():
- flag = StackBuf(1)
- hint_witness(flag, \"flag\")
- a = [1, 2, 3, 4]
- out = StackBuf(2)
- if flag[0] == 1:
- blake2s(a[0:2], a[2:4], out)
- else:
- blake2s(a[0:2], a[2:4], out)
- p = 1
- p[1] = out[0]
- p[GEN] = out[1]
- return
-";
- let want = digest_cells([F64(1), F64(0), F64(2), F64(0)], [F64(3), F64(0), F64(4), F64(0)]);
- for flag in [0, 1] {
- let mut program = compile(&parse(src).expect("parse"));
- program.set_witness("flag", vec![vec![F192::new(flag, 0, 0)]]);
- let (proof, _) = prove(&program, want, lean_vm::pcs::TEST_LOG_INV_RATE).unwrap();
- verify(&program, &want, &proof).expect("each branch initializes its default IV");
- }
-}
-
-/// Deferred aliases may expose non-adjacent source words for a syntactically
-/// consecutive CV StackBuf. The compiler must materialize that pair because
-/// the BLAKE2s opcode carries only one CV base offset.
-#[test]
-fn blake2s_materializes_aliased_cv_pair() {
- let src = "\
-def main():
- msg = [1, 2, 3, 4]
- sources = [5, 99, 6]
- cv = [sources[0], sources[2]]
- out = StackBuf(2)
- blake2s(msg[0:2], msg[2:4], out, cv=cv, counter=128)
- p = 1
- p[1] = out[0]
- p[GEN] = out[1]
- return
-";
- let program = compile(&parse(src).expect("parse"));
- let block = compression(
- [F64(1), F64(0), F64(2), F64(0)],
- [F64(3), F64(0), F64(4), F64(0)],
- [F64(5), F64(0), F64(6), F64(0)],
- metadata(128, 0, 0),
- );
- let d = digest(&block);
- let want = [F192::new(d[0].0, d[1].0, 0), F192::new(d[2].0, d[3].0, 0)];
- let (proof, _) = prove(&program, want, lean_vm::pcs::TEST_LOG_INV_RATE).unwrap();
- verify(&program, &want, &proof).expect("materialized custom CV verifies");
-}
-
-/// A custom CV with the default one-block metadata is not a chained block.
-/// A metadata cell inside the digest destination would be read before the digest
-/// is stored and re-read from the finished image by the witness, so the two would
-/// disagree and the proof would fail its opening with nothing to point at. Every
-/// other overlap is a write-once conflict, which does say where it happened.
-#[test]
-#[should_panic(expected = "md= must not name a cell of the digest destination")]
-fn blake2s_metadata_inside_the_destination_is_rejected() {
- let src = "\
-def main():
- msg = [1, 2, 3, 4]
- out = StackBuf(2)
- out[0] = 7
- blake2s(msg[0:2], msg[2:4], out, md=out[0])
- return
-";
- let _ = compile(&parse(src).expect("parse"));
-}
-
-/// Require the caller to state the byte counter explicitly.
-#[test]
-#[should_panic(expected = "blake2s with cv= requires")]
-fn blake2s_cv_alone_is_rejected() {
- let src = "\
-def main():
- msg = [1, 2, 3, 4]
- cv = [5, 6]
- out = StackBuf(2)
- blake2s(msg[0:2], msg[2:4], out, cv=cv)
- return
-";
- let _ = compile(&parse(src).expect("parse"));
-}
-
-/// A general (non-blake2s) `StackBuf(3)`: indexed writes, an indexed read feeding
-/// an arithmetic write into another slot, then two slots published. Confirms the
-/// stack cells are plain consecutive frame cells addressable by index.
-#[test]
-fn stack_buf_indexing() {
- let src = "\
-def main():
- sa = StackBuf(3)
- sa[0] = 3
- sa[1] = 4
- sa[2] = sa[0] + sa[1]
- p = 1
- p[1] = sa[2]
- p[GEN] = sa[1]
- return
-";
- let program = compile(&parse(src).expect("parse"));
- // `+` is XOR: 3 ^ 4 = 7. Published: (sa[2], sa[1]) = (7, 4).
- let want = [F192::from(F64(7)), F192::from(F64(4))];
- let (proof, _) = prove(&program, want, lean_vm::pcs::TEST_LOG_INV_RATE).unwrap();
- assert_eq!(mix(src, want)[5], 0, "no BLAKE2s here");
- verify(&program, &want, &proof).expect("StackBuf indexing verifies");
-}
-
-/// A normal (non-`@inline`) function may return a StackBuf. Its cells cross the
-/// call boundary through consecutive return slots and bind as a StackBuf in the
-/// caller, including through another normal wrapper function.
-#[test]
-fn normal_function_returns_stackbuf() {
- let src = "\
-def main():
- out = forward(5)
- p = 1
- p[1] = out[0] + out[1]
- p[GEN] = out[2]
- return
-
-def forward(v):
- out = make(v)
- return out
-
-def make(v):
- out = StackBuf(3)
- out[0] = v
- out[1] = v + 3
- out[2] = 11
- return out
-";
- let program = compile(&parse(src).expect("parse"));
- // Field addition is XOR: 5 ^ (5 ^ 3) == 3.
- program.execute([F192::from(F64(3)), F192::from(F64(11))]).unwrap();
-}
-
-/// Tuple returns retain their source-level arity even though a StackBuf member
-/// occupies several physical return cells.
-#[test]
-fn normal_function_returns_stackbuf_and_scalar() {
- let src = "\
-def main():
- out, x = make(9)
- p = 1
- p[1] = out[0] + out[1]
- p[GEN] = x
- return
-
-def make(v):
- out = [v, 6]
- return out, v + 1
-";
- let program = compile(&parse(src).expect("parse"));
- program.execute([F192::from(F64(15)), F192::from(F64(8))]).unwrap();
-}
-
-/// HeapBuf already crosses a normal call as its one-cell pointer. Allocation
-/// happened in the callee, so the caller needs no size metadata to dereference
-/// and use the returned buffer.
-#[test]
-fn normal_function_returns_heapbuf_pointer() {
- let src = "\
-def main():
- out = make()
- p = 1
- p[1] = out[1]
- p[GEN] = out[GEN]
- return
-
-def make():
- out = HeapBuf(2)
- out[1] = 17
- out[GEN] = 23
- return out
-";
- let program = compile(&parse(src).expect("parse"));
- program.execute([F192::from(F64(17)), F192::from(F64(23))]).unwrap();
-}
-
-/// A StackBuf index literal that does not fit `u32` is rejected at compile time,
-/// not silently truncated modulo 2^32 (which would resolve `sa[2^32]` to `sa[0]`).
-#[test]
-#[should_panic(expected = "does not fit in u32")]
-fn stack_buf_index_overflow_rejected() {
- let src = "def main():\n sa = StackBuf(2)\n x = sa[4294967296]\n return\n";
- let _ = compile(&parse(src).expect("parse"));
-}
-
-/// Rebinding a StackBuf name to a scalar clears the stack binding, so the name
-/// is a plain scalar afterward (the old bug left a stale `stacks` entry that made
-/// `x` still look like a StackBuf, panicking on scalar use).
-#[test]
-fn stack_buf_rebind_to_scalar() {
- let src = "def main():\n x = StackBuf(2)\n x = 5\n p = 1\n p[1] = x\n p[GEN] = x\n return\n";
- let program = compile(&parse(src).expect("parse"));
- let want = [F192::from(F64(5)), F192::from(F64(5))];
- let (proof, _) = prove(&program, want, lean_vm::pcs::TEST_LOG_INV_RATE).unwrap();
- verify(&program, &want, &proof).expect("rebound-scalar program verifies");
-}
-
-/// A StackBuf from the enclosing scope referenced inside a `for` loop cannot be
-/// captured; the compiler rejects it with a clear message (not a misleading
-/// "unbound variable" from the capture being silently dropped).
-#[test]
-#[should_panic(expected = "cannot be captured into a `for` loop")]
-fn stack_buf_loop_capture_rejected() {
- let src = "def main():\n h = StackBuf(2)\n h[0] = 1\n h[1] = 2\n for i in mul_range(1, GEN ** 4):\n x = h[0]\n return\n";
- let _ = compile(&parse(src).expect("parse"));
-}
-
-/// An `@inline` may return a `StackBuf` *and* a scalar together (a tuple bind):
-/// the `StackBuf` slot aliases its cell run into the caller (zero copies, usable
-/// as a StackBuf downstream: here fed straight back into a second call, the
-/// MD-chain idiom), while the scalar slot binds a value cell. This is the fused
-/// `state, x = read_obs(state, cursor)` shape the recursion guest relies on.
-#[test]
-fn inline_returns_stackbuf_and_scalar() {
- let src = "\
-def main():
- s = StackBuf(2)
- s[0] = 5
- s[1] = 7
- s, x = step(s, 9)
- s, y = step(s, x)
- p = 1
- p[1] = s[0]
- p[GEN] = s[1]
- return
-
-@inline
-def step(state, v):
- tg = StackBuf(2)
- tg[0] = v
- tg[1] = 3
- nb = StackBuf(2)
- blake2s(state, tg, nb)
- return nb, v
-";
- let program = compile(&parse(src).expect("parse"));
-
- // Each cell = one scalar in its low lane, so a StackBuf(2) hashes words
- // [c0, 0, c1, 0]. x == v == 9 (the scalar return), so both steps use tag 9.
- let tag = [F64(9), F64(0), F64(3), F64(0)];
- let s1 = compress([F64(5), F64(0), F64(7), F64(0)], tag);
- let s2 = compress(s1, tag); // the returned StackBuf (holding s1's words) fed back in
- let want = [F192::new(s2[0].0, s2[1].0, 0), F192::new(s2[2].0, s2[3].0, 0)];
-
- let (proof, _) = prove(&program, want, lean_vm::pcs::TEST_LOG_INV_RATE).unwrap();
- assert_eq!(mix(src, want)[5], 2, "two BLAKE2s instructions (one per inlined step)");
- verify(&program, &want, &proof).expect("inline StackBuf+scalar tuple return verifies");
-
- let mut bad = want;
- bad[1] += F192::ONE;
- assert!(
- verify(&program, &bad, &proof).is_err(),
- "wrong published state must be rejected"
- );
-}
-
-/// Deferred stores made by a runtime branch must initialize buffers allocated
-/// by the surrounding inline call, including when its tuple result is rebound
-/// inside an unrolled loop.
-#[test]
-fn branch_writes_survive_unrolled_tuple_return() {
- let src = "\
-def main():
- public = GEN ** 0
- flag = public[1]
- a = [5, 7]
- b = [13, 17]
- for i in unroll(0, 1):
- a, b = select_pair(flag, a, b)
- assert a[0] == 13
- assert a[1] == 17
- assert b[0] == 5
- assert b[1] == 7
- return
-
-@inline
-def select_pair(flag, a, b):
- first = StackBuf(2)
- second = StackBuf(2)
- if flag == 0:
- first[0] = a[0]
- first[1] = a[1]
- second[0] = b[0]
- second[1] = b[1]
- else:
- first[0] = b[0]
- first[1] = b[1]
- second[0] = a[0]
- second[1] = a[1]
- return first, second
-";
- let program = compile(&parse(src).expect("parse"));
- program.execute([F192::ONE, F192::ZERO]).unwrap();
-}
-
-/// An `@inline` may also alias-return a folded **g-address** among its values:
-/// `fs, x, cur = step(fs, cur)` hands back the Fiat-Shamir state (StackBuf), the
-/// consumed word (scalar), and the ADVANCED cursor (`cursor * GEN`) as a
-/// zero-cost folded pointer, so the caller keeps reading through it with no
-/// manual `cur *= GEN`. This is the shape `fs_next` uses to walk the stream.
-#[test]
-fn inline_returns_advanced_cursor() {
- let src = "\
-def main():
- hb = HeapBuf(4)
- hb[1] = 10
- hb[GEN] = 20
- hb[GEN ** 2] = 30
- fs = StackBuf(2)
- fs[0] = 1
- fs[1] = 2
- cur = hb
- fs, a, cur = step(fs, cur)
- fs, b, cur = step(fs, cur)
- v = cur[GEN ** 0]
- p = 1
- p[1] = a + b
- p[GEN] = v
- return
-
-@inline
-def step(state, cursor):
- x = cursor[GEN ** 0]
- tg = StackBuf(2)
- tg[0] = x
- tg[1] = 3
- nb = StackBuf(2)
- blake2s(state, tg, nb)
- return nb, x, cursor * GEN
-";
- let program = compile(&parse(src).expect("parse"));
- // a = hb[0] = 10, b = hb[1] = 20, v = hb[2] = 30 read through the cursor
- // returned twice-advanced. a + b is XOR: 10 ^ 20 = 30.
- let want = [F192::from(F64(30)), F192::from(F64(30))];
- let (proof, _) = prove(&program, want, lean_vm::pcs::TEST_LOG_INV_RATE).unwrap();
- verify(&program, &want, &proof).expect("inline advanced-cursor return verifies");
-}
-
-/// `x = [a, b, c, d]`: the list-literal StackBuf initializer: allocates the run
-/// and writes the elements in place, sugar for alloc-then-store. The test mixes a
-/// runtime value, a constant, and an expression; feeds the result to blake2s; and
-/// swaps a buffer through itself (`s = [s[1], s[0], …]` reads the OLD binding,
-/// per the let-rebind rule).
-#[test]
-fn stack_buf_list_literal() {
- let src = "\
-def main():
- s = [5, 7]
- s = [s[1], s[0]]
- t = [s[0] + s[1], 3]
- out = StackBuf(2)
- blake2s(s, t, out)
- p = 1
- p[1] = out[0]
- p[GEN] = out[1]
- return
-";
- let program = compile(&parse(src).expect("parse"));
- // s = [7, 5] after the swap → words [7,0,5,0]; t = [7 ^ 5, 3] = [2, 3] → [2,0,3,0].
- let want = digest_cells([F64(7), F64(0), F64(5), F64(0)], [F64(2), F64(0), F64(3), F64(0)]);
- let (proof, _) = prove(&program, want, lean_vm::pcs::TEST_LOG_INV_RATE).unwrap();
- assert_eq!(mix(src, want)[5], 1, "one BLAKE2s instruction");
- verify(&program, &want, &proof).expect("list-literal StackBuf verifies");
-}
-
-/// A list literal anywhere but the RHS of an assignment is rejected with a
-/// clear message, not lowered as a phantom scalar.
-#[test]
-#[should_panic(expected = "a list literal must be bound to a name")]
-fn stack_buf_list_literal_as_value_rejected() {
- let src = "def main():\n x = 1 + [2, 3]\n assert x == x\n return\n";
- let _ = compile(&parse(src).expect("parse"));
-}
-
-/// A compile-time heap index past the buffer's declared size is a compile
-/// error, not a runtime wild deref.
-#[test]
-#[should_panic(expected = "heap index 8 out of bounds for `hb` (HeapBuf size 8)")]
-fn heap_index_oob_rejected() {
- let src = "def main():\n hb = HeapBuf(8)\n x = hb[GEN ** 8]\n assert x == x\n return\n";
- let _ = compile(&parse(src).expect("parse"));
-}
-
-/// The bound follows shifted aliases back to the original buffer: a pointer
-/// alias `row = hb * GEN ** k` checks `row[GEN ** j]` against size − k.
-#[test]
-#[should_panic(expected = "heap index 9 out of bounds for `hb` (HeapBuf size 8)")]
-fn heap_alias_index_oob_rejected() {
- let src = "def main():\n hb = HeapBuf(8)\n row = hb * GEN ** 6\n x = row[GEN ** 3]\n assert x == x\n return\n";
- let _ = compile(&parse(src).expect("parse"));
-}
-
-/// A hint slice whose end exceeds the buffer is rejected at compile time.
-#[test]
-#[should_panic(expected = "heap slice 0:9 out of bounds for `hb` (HeapBuf size 8)")]
-fn heap_hint_slice_oob_rejected() {
- let src = "def main():\n hb = HeapBuf(8)\n hint_witness(hb[0:9], \"w\")\n x = hb[GEN ** 0]\n assert x == x\n return\n";
- let _ = compile(&parse(src).expect("parse"));
-}
-
-/// A blake2s heap slice straddling the buffer end is rejected. The 256-bit
-/// operand `hb[7:9]` is two 128-bit cells, so the bound check trips at
-/// `7 + 2 = 9 > 8`.
-#[test]
-#[should_panic(expected = "heap slice 7:9 out of bounds for `hb` (HeapBuf size 8)")]
-fn heap_blake2s_slice_oob_rejected() {
- let src = "def main():\n hb = HeapBuf(8)\n hb[GEN ** 7] = 5\n out = StackBuf(2)\n blake2s(hb[7:9], hb[7:9], out)\n return\n";
- let _ = compile(&parse(src).expect("parse"));
-}
-
-/// A heap index that folds to a non-g-power field constant (an integer loop
-/// var leaking in from a StackBuf conversion) can never name a heap cell (cell
-/// k lives at `buf · g^k`) and used to survive to proving time as a
-/// wild-pointer DEREF. It must be a compile-time error.
-#[test]
-#[should_panic(expected = "not a g-power")]
-fn integer_heap_index_is_rejected() {
- let src = "\
-def main():
- b = HeapBuf(4)
- b[1] = 3
- b[GEN] = 5
- x = 0
- for k in unroll(0, 2):
- p = 1
- p[GEN ** k] = b[k]
- return
-";
- compile(&parse(src).expect("parse"));
-}
-
-/// The last in-bounds index still compiles and runs.
-#[test]
-fn heap_index_boundary_ok() {
- let src = "def main():\n hb = HeapBuf(8)\n hb[GEN ** 7] = 5\n row = hb * GEN ** 4\n y = row[GEN ** 3]\n assert y == 5\n return\n";
- let program = compile(&parse(src).expect("parse"));
- let pi = [F192::from(F64(3)), F192::from(F64(4))];
- let (proof, _) = prove(&program, pi, lean_vm::pcs::TEST_LOG_INV_RATE).unwrap();
- verify(&program, &pi, &proof).expect("boundary access verifies");
-}
-
-/// A store into a run PARAMETER is the write-once assertion, not a fresh store.
-///
-/// A run parameter's cells are already written, by the caller, before the
-/// callee's first instruction; a local `StackBuf`'s are not. That is the whole
-/// difference, and missing it dropped the assertion: `s[k] = ` inside a
-/// callee recorded a deferred alias and emitted nothing, so the idiom that pins
-/// an unconstrained hint pinned nothing and the prover kept its own values.
-#[test]
-fn a_store_into_a_run_parameter_asserts() {
- let pin = "\
-def pin(s: StackBuf(2)):
- s[0] = GEN ** 5
- s[1] = GEN ** 6
- return GEN ** 0
-
-def main():
- b = StackBuf(2)
- hint_witness(b, \"adv\")
- z = pin(b)
- p = GEN ** 0
- p[1] = b[0]
- p[GEN] = b[1]
- return
-";
- let ast = parse(pin).expect("parse");
- // The honest prover hints what the callee asserts, and it verifies.
- let mut program = compile(&ast);
- program.set_witness("adv", vec![vec![g_pow(5).into(), g_pow(6).into()]]);
- let want = [g_pow(5).into(), g_pow(6).into()];
- let (proof, _) = prove(&program, want, lean_vm::pcs::TEST_LOG_INV_RATE).unwrap();
- verify(&program, &want, &proof).expect("the honest hint matches the pin");
-
- // A prover hinting anything else must be rejected: that is what the pin is.
- let mut bad = compile(&ast);
- bad.set_witness("adv", vec![vec![g_pow(13).into(), g_pow(14).into()]]);
- let dishonest = [g_pow(13).into(), g_pow(14).into()];
- assert!(
- bad.execute(dishonest).is_err(),
- "the pin must reject a hint it does not match"
- );
-
- // The same rule with no hint involved: one cell cannot hold two values.
- let two = "\
-def f(s: StackBuf(2)):
- s[0] = s[1]
- return s[0]
-
-def main():
- b = StackBuf(2)
- b[0] = GEN ** 9
- b[1] = GEN ** 3
- r = f(b)
- p = GEN ** 0
- p[1] = r
- p[GEN] = GEN ** 0
- return
-";
- let program = compile(&parse(two).expect("parse"));
- let want = [g_pow(3).into(), g_pow(0).into()];
- assert!(
- program.execute(want).is_err(),
- "`s[0] = s[1]` asserts that they are equal"
- );
-}
-
-/// A multi-cell value can cross a call in BOTH directions.
-///
-/// It could always be returned as a run of cells and never passed as one, so a
-/// two-cell digest went in through a pointer or an `@inline` expansion while
-/// coming back out whole. A `s: StackBuf(n)` parameter takes the same n
-/// consecutive cells a `StackBuf(n)` return value occupies, placed by the same
-/// `Abi`, which is why the argument area is now a WIDTH rather than a count.
-#[test]
-fn a_stack_buf_can_be_passed_as_well_as_returned() {
- let src = "\
-def swap(s: StackBuf(2)):
- t = StackBuf(2)
- t[0] = s[1]
- t[1] = s[0]
- return t
-
-def main():
- b = StackBuf(2)
- b[0] = GEN ** 1
- b[1] = GEN ** 2
- r = swap(b)
- p = GEN ** 0
- p[1] = r[0]
- p[GEN] = r[1]
- return
-";
- let program = compile(&parse(src).expect("parse"));
- let want = [g_pow(2).into(), g_pow(1).into()];
- let (proof, _) = prove(&program, want, lean_vm::pcs::TEST_LOG_INV_RATE).unwrap();
- verify(&program, &want, &proof).expect("the run went in and the swapped run came back");
-
- // The shape is checked at the call, in both directions of mismatch.
- for (arg, want) in [
- (
- "b = StackBuf(3)\n b[0] = GEN ** 1\n r = f(b)",
- "got a StackBuf(3)",
- ),
- ("r = f(GEN ** 1)", "pass one"),
- ] {
- let src = format!(
- "def f(s: StackBuf(2)):\n return s[0]\n\ndef main():\n {arg}\n p = GEN ** 0\n p[1] = r\n p[GEN] = GEN ** 0\n return\n"
- );
- let ast = parse(&src).expect("parses");
- let Err(err) = std::panic::catch_unwind(|| compile(&ast)) else {
- panic!("accepted: {arg}");
- };
- let msg = err.downcast_ref::().map(String::as_str).unwrap_or("");
- assert!(msg.contains(want), "got `{msg}`");
- }
-}
-
-/// `g` is `x`, so the literal `2^k` IS `g^k`. `try_gpow_index` always knew that
-/// and `gaddr_of` did not, so one field element had three answers: `hb[GEN * 2]`
-/// was rejected as "not a g-power" while `hb[GEN * GEN]` compiled, and
-/// `hb[r * 2]` compiled again as soon as `r` was runtime. All three name cell 2.
-/// A BARE literal index stays rejected: see `integer_heap_index_is_rejected`.
-#[test]
-fn a_literal_power_of_two_is_a_g_power() {
- let src = "\
-def main():
- hb = HeapBuf(8)
- hb[GEN * GEN] = GEN ** 5
- p = GEN ** 0
- p[1] = hb[GEN * 2]
- p[GEN] = hb[GEN ** 2]
- return
-";
- let program = compile(&parse(src).expect("parse"));
- let want = [F192::from(g_pow(5)); 2];
- let (proof, _) = prove(&program, want, lean_vm::pcs::TEST_LOG_INV_RATE).unwrap();
- verify(&program, &want, &proof).expect("three spellings of cell 2 agree");
-}
-
-/// A `HeapBuf` sized at run time is bounded by the address space alone, not by
-/// how far the g-power table happens to reach.
-#[test]
-fn heap_buf_runtime_size_beyond_the_g_power_table() {
- let src = "\
-def main():
- public = 1
- n = public[1]
- i = public[GEN]
- buf = HeapBuf(n)
- buf[i] = 7
- return
-";
- let program = compile(&parse(src).expect("parse"));
- let size = (1 << 20) + 1;
- let exec = program
- .execute([F192::from(g_pow(size)), F192::from(g_pow(size - 1))])
- .unwrap();
- assert!(exec.unconstrained_reads.is_empty());
- assert!(exec.mem_used > size);
-}
diff --git a/crates/lean_compiler/tests/suite/statements.rs b/crates/lean_compiler/tests/suite/statements.rs
deleted file mode 100644
index be9f76073..000000000
--- a/crates/lean_compiler/tests/suite/statements.rs
+++ /dev/null
@@ -1,1058 +0,0 @@
-//! Statement forms that used to be accepted and mean something else. Each of
-//! these compiled clean before, so the pin is that they are now REJECTED: a
-//! diagnostic is the whole fix.
-
-use lean_compiler::{compile, parse};
-use lean_vm::cpu::{prove, verify};
-use primitives::field::{F192, g_pow};
-
-/// A name is a plain identifier. Two different mistakes arrive here, and both
-/// used to compile: a mis-split statement (`x /= 2` became a binding named
-/// `x /`) and a top-level constant reused as a parameter or local. Constants
-/// are substituted textually before parsing, so `V = 8` followed by
-/// `def scale(V)` gave a parameter literally named `8` and a body reading the
-/// constant: `scale(3)` returned 16.
-#[test]
-fn a_constant_may_not_be_reused_as_a_parameter() {
- let src = "\
-V = 8
-
-def scale(V):
- return V * 2
-
-def main():
- p = GEN ** 0
- p[1] = scale(3)
- p[GEN] = scale(3)
- return
-";
- let err = parse(src).expect_err("a parameter may not reuse a constant's name");
- assert!(err.contains("not a valid parameter name"), "{err}");
-}
-
-/// `/=` and `**=` are not compound assignments here. `split_aug` declined them
-/// and `split_assign` then split the bare `=`, leaving a dead binding and the
-/// old value in place. `/` being a real runtime field operation is what made
-/// `x /= y` look legal.
-#[test]
-fn an_unsupported_compound_assignment_is_rejected() {
- let body = |stmt: &str| {
- format!(
- "\
-def main():
- x = 4
- {stmt}
- p = GEN ** 0
- p[1] = x
- p[GEN] = x
- return
-"
- )
- };
- for (stmt, want) in [
- ("x /= 2", "`/=` is not supported"),
- ("x **= 2", "`**=` is not supported"),
- ] {
- let err = parse(&body(stmt)).expect_err(stmt);
- assert!(err.contains(want), "{stmt}: {err}");
- }
- // The five that ARE supported still desugar.
- let ok = body("x += 1\n x *= 2\n x //= 2\n x %= 3\n x -= 1");
- compile(&parse(&ok).expect("the supported compound assignments parse"));
-}
-
-/// A bare comparison is not a statement. `split_assign` used to split `x != y`
-/// on its `=` into a binding named `x !`, so an `assert` that lost its keyword
-/// to an edit compiled to nothing at all: in a verifier, a deleted check with
-/// no diagnostic and no cycle to notice.
-#[test]
-fn a_bare_comparison_is_rejected() {
- let body = |stmt: &str| {
- format!(
- "\
-def main():
- x = 4
- y = 5
- {stmt}
- p = GEN ** 0
- p[1] = x
- p[GEN] = x
- return
-"
- )
- };
- for stmt in ["x != y", "x == y", "x <= y", "x >= y", "x < y", "x > y"] {
- let err = parse(&body(stmt)).expect_err(stmt);
- assert!(err.contains("is a comparison, not a statement"), "{stmt}: {err}");
- }
- // The equality forms name the fix; the order forms say they are not predicates.
- assert!(parse(&body("x != y")).unwrap_err().contains("write `assert x != y`"));
- assert!(parse(&body("x < y")).unwrap_err().contains("order facts come from"));
-}
-
-/// A stack store whose value IS its own destination recorded `alias[dst] = dst`,
-/// and `word_src` chased that forever: a compiler that never returns, with no
-/// output at all. Two stores could close the same loop in two steps. Both are
-/// now no-ops (write-once makes a second write of the same value one), and the
-/// documented swap must keep working.
-#[test]
-fn a_self_referential_stack_store_terminates() {
- let cases = [
- // one statement
- " s[0] = s[0]\n",
- // two, closing the cycle
- " s[0] = s[1]\n s[1] = s[0]\n",
- ];
- for tail in cases {
- let src = format!(
- "\
-def main():
- s = StackBuf(2)
-{tail} p = 1
- p[1] = s[0]
- p[GEN] = s[0]
- return
-"
- );
- compile(&parse(&src).expect("parse")).execute([F192::ZERO; 2]).unwrap();
- }
- // `s = [s[1], s[0]]` rebinds to a fresh run and must still swap.
- let swap = "\
-def main():
- s = StackBuf(2)
- s[0] = 5
- s[1] = 7
- s = [s[1], s[0]]
- p = 1
- p[1] = s[0]
- p[GEN] = s[1]
- return
-";
- let want = [
- F192::from(primitives::field::F64(7)),
- F192::from(primitives::field::F64(5)),
- ];
- compile(&parse(swap).expect("parse")).execute(want).unwrap();
-}
-
-/// A `mul_range` stop bound that is a compile-time value but not a power of GEN
-/// can never be REACHED: the counter walks by multiplication and exits on
-/// equality, so the loop ran forever at witness generation with no diagnostic.
-/// The `lo` side was always checked, which made `mul_range(0, GEN ** 3)` a clean
-/// parse error while `mul_range(1, 10)` was a hang.
-#[test]
-fn a_loop_bound_must_be_reachable() {
- let src = "\
-def main():
- hb = HeapBuf(8)
- hb[1] = 1
- for i in mul_range(1, 10):
- hb[i * GEN] = hb[i]
- return
-";
- let err = parse(src).expect_err("an unreachable stop bound must be rejected");
- assert!(err.contains("is not a power of GEN"), "{err}");
-
- // A power-of-two literal IS a power of GEN and the walk does reach it, so it
- // must be accepted, and as a compile-time bound rather than a runtime one.
- let ok = src.replace("mul_range(1, 10)", "mul_range(1, 16)");
- compile(&parse(&ok).expect("`16` is `g^4`"));
-}
-
-/// A binding made inside one arm of an `if` is local to that arm, so the other
-/// arm reads the OUTER binding and the loop must capture it. `free_vars_stmt`
-/// threaded one flat set through both arms, so a name rebound anywhere in the
-/// body counted as loop-local everywhere and the outer binding was never
-/// captured: this legal program failed with `unbound variable`.
-#[test]
-fn a_branch_local_rebinding_does_not_hide_the_outer_binding() {
- let src = "\
-def main():
- hb = HeapBuf(8)
- w = GEN ** 3
- for i in mul_range(1, GEN ** 4):
- if i == GEN:
- w = GEN
- hb[i * GEN] = w
- else:
- hb[i * GEN] = w
- p = GEN ** 0
- p[1] = hb[GEN ** 2]
- p[GEN] = hb[GEN ** 3]
- return
-";
- // Cell 2 is written on the taken arm (the branch-local `w = GEN`); cell 3 on
- // the other, from the outer `w`.
- let program = compile(&parse(src).expect("parse"));
- let want = [F192::from(g_pow(1)), F192::from(g_pow(3))];
- let (proof, _) = prove(&program, want, lean_vm::pcs::TEST_LOG_INV_RATE).unwrap();
- verify(&program, &want, &proof).expect("the else arm reads the outer binding");
-}
-
-/// `g` is `x`, so a literal `2^k` is `g^k` ONLY while `k < 64`: at and above
-/// that the modulus folds the monomial back into the low limb while the
-/// literal's bit `k` lands in the next one, the tower coefficient of `y`. The
-/// guest's own `Y_TOWER` is exactly `2^64`, machine-generated by `dsl_u128`, so
-/// a g-power recognizer without that guard gives one literal two values
-/// depending on whether it went through a binding.
-#[test]
-fn a_power_of_two_literal_is_a_g_power_only_below_two_to_the_64() {
- let src = "\
-Y_TOWER = 18446744073709551616
-
-def main():
- yt = Y_TOWER
- a = yt * GEN
- b = Y_TOWER * GEN
- assert a == b
- p = GEN ** 0
- p[1] = a
- p[GEN] = b
- return
-";
- let program = compile(&parse(src).expect("parse"));
- // y·x, i.e. the tower coefficient shifted, NOT g^65.
- let want = [F192::new(0, 2, 0); 2];
- let (proof, _) = prove(&program, want, lean_vm::pcs::TEST_LOG_INV_RATE).unwrap();
- verify(&program, &want, &proof).expect("2^64 is the tower element y, not g^64");
-}
-
-/// An operator missing a side says which operator and which side.
-///
-/// Every one of these used to report `cannot parse expression \`\``: the empty
-/// operand was handed to the expression parser, which had nothing to name. A
-/// leading `-` is the common one, since the language has no unary minus.
-#[test]
-fn an_operator_missing_an_operand_says_so() {
- for (src, want) in [
- ("-3 + 5", "`-` has no left operand"),
- ("1 +", "`+` has no right operand"),
- ("* 2", "`*` has no left operand"),
- ("4 // ", "`//` has no right operand"),
- ] {
- let err = lean_compiler::parse_const(src).expect_err(src);
- assert!(err.contains(want), "{src}: got `{err}`, wanted `{want}`");
- }
-}
-
-/// One hinted value needs no buffer, and costs exactly what the buffer did.
-///
-/// `hint_witness` fills a destination that already exists, so a single hinted
-/// scalar cost a one-cell `StackBuf`, a slice of it, and a read back out. The
-/// guest declared thirty such buffers, twenty-eight of them for nothing else.
-#[test]
-fn one_hinted_value_needs_no_buffer() {
- let body = |bind: &str| {
- format!(
- "def main():
-{bind} assert log m < 8
- p = GEN ** 0
- p[1] = m
- p[GEN] = GEN ** 0
- return
-"
- )
- };
- let old = body(" mb = StackBuf(1)\n hint_witness(mb[0:1], \"m\")\n m = mb[0]\n");
- let new = body(" m = hint_witness(\"m\")\n");
- let run = |src: &str| {
- let mut program = compile(&parse(src).expect("parse"));
- program.set_witness("m", vec![vec![g_pow(5).into()]]);
- let want = [g_pow(5).into(), g_pow(0).into()];
- let (proof, _) = prove(&program, want, lean_vm::pcs::TEST_LOG_INV_RATE).unwrap();
- verify(&program, &want, &proof).expect("verifies");
- program.execute(want).unwrap().base_counts.iter().sum::()
- };
- assert_eq!(run(&old), run(&new), "the sugar must cost what it replaces");
-
- // It binds inside a loop body too, where substitution walks the statement.
- let looped = "\
-def main():
- hb = HeapBuf(4)
- for i in mul_range(1, 8):
- w = hint_witness(\"w\")
- hb[i] = w
- p = GEN ** 0
- p[1] = hb[GEN]
- p[GEN] = GEN ** 0
- return
-";
- let mut program = compile(&parse(looped).expect("parse"));
- program.set_witness(
- "w",
- vec![vec![g_pow(1).into()], vec![g_pow(2).into()], vec![g_pow(3).into()]],
- );
- let want = [g_pow(2).into(), g_pow(0).into()];
- let (proof, _) = prove(&program, want, lean_vm::pcs::TEST_LOG_INV_RATE).unwrap();
- // `mul_range(1, 8)` is g^0..g^3, so THREE iterations and all three entries
- // are popped. With `mul_range(1, 4)` the third would sit unread and mask an
- // off-by-one in that direction.
- verify(&program, &want, &proof).expect("one hint per iteration");
-}
-
-/// A trailing factor after a `hint_witness` call may not vanish into the
-/// stream name. `call_args` strips the line's LAST `)`, and the string
-/// argument then absorbs anything between the call's own `)` and there:
-/// `hint_witness(rb[0:1], "a") * f("b")` parsed as a hint for the stream
-/// `a") * f("b`, and the rest of the line was gone. Both forms are guarded
-/// by `whole_call`; the line then falls through to `parse_expr`, which
-/// refuses the string literal, with the line.
-#[test]
-fn a_hint_call_spans_its_whole_line() {
- let stmt = "\
-def main():
- rb = StackBuf(1)
- hint_witness(rb[0:1], \"a\") * f(\"b\")
- return
-";
- let expr = "\
-def main():
- m = hint_witness(\"a\") * f(\"b\")
- return
-";
- for src in [stmt, expr] {
- let err = parse(src).expect_err("a trailing factor must not parse");
- assert!(err.contains("cannot parse expression"), "{err}");
- }
-}
-
-/// The scalar hint binds like any other statement that binds.
-///
-/// Three `StmtKind` walkers have a catch-all arm, and each silently swallowed
-/// the new variant: `@inline` rejected a body that is a single tail return,
-/// the `for` capture check raised a `StackBuf` false positive, and return-shape
-/// inference lost the binding. All three fail closed, so the cost was a
-/// diagnostic naming the wrong cause rather than a miscompile, and all three
-/// made the sugar not a drop-in for the idiom it replaces.
-#[test]
-fn the_scalar_hint_binds_like_any_other_binder() {
- let tail = " p = GEN ** 0\n p[1] = r\n p[GEN] = GEN ** 0\n return\n";
- // `@inline` accepts it: the body IS a single tail return.
- let inlined = format!(
- "@inline\ndef pick():\n m = hint_witness(\"m\")\n return m\n\ndef main():\n r = pick()\n{tail}"
- );
- let mut program = compile(&parse(&inlined).expect("an @inline body may bind a hint"));
- program.set_witness("m", vec![vec![g_pow(5).into()]]);
- let want = [g_pow(5).into(), g_pow(0).into()];
- let (proof, _) = prove(&program, want, lean_vm::pcs::TEST_LOG_INV_RATE).unwrap();
- verify(&program, &want, &proof).expect("the inlined binding fires");
-
- // A `for` body that shadows an enclosing StackBuf's name from inside an arm
- // is not capturing it, which is what `binds_anywhere` exists to notice.
- let shadowed = "\
-def main():
- sb = StackBuf(2)
- sb[0] = GEN ** 1
- sb[1] = GEN ** 2
- hb = HeapBuf(4)
- for i in mul_range(1, 4):
- if i == i:
- sb = hint_witness(\"w\")
- hb[i] = sb
- p = GEN ** 0
- p[1] = sb[0]
- p[GEN] = GEN ** 0
- return
-";
- let mut program = compile(&parse(shadowed).expect("shadowing is not capturing"));
- program.set_witness("w", vec![vec![g_pow(7).into()], vec![g_pow(8).into()]]);
- let want = [g_pow(1).into(), g_pow(0).into()];
- let (proof, _) = prove(&program, want, lean_vm::pcs::TEST_LOG_INV_RATE).unwrap();
- verify(&program, &want, &proof).expect("the outer StackBuf is untouched");
-
- // Return-shape inference, the third walker: rebinding a `StackBuf` name to a
- // scalar hint has to REPLACE the shape, or the callee is inferred to return a
- // run of cells and the caller dies on "StackBuf used as a scalar". Only a
- // rebinding reaches it, which is why the other two cases above do not.
- let rebound = format!(
- "def pick():\n s = StackBuf(2)\n s[0] = GEN ** 1\n s[1] = GEN ** 2\n s = hint_witness(\"m\")\n return s\n\ndef main():\n r = pick()\n{tail}"
- );
- let mut program = compile(&parse(&rebound).expect("a hint may rebind a StackBuf name"));
- program.set_witness("m", vec![vec![g_pow(6).into()]]);
- let want = [g_pow(6).into(), g_pow(0).into()];
- let (proof, _) = prove(&program, want, lean_vm::pcs::TEST_LOG_INV_RATE).unwrap();
- verify(&program, &want, &proof).expect("the rebound name returns as a scalar");
-}
-
-/// A bit-decomposition hint checks its heap destination, like its stack one.
-///
-/// `bits_dest`'s `StackBuf` arm rejected a destination too small for `nbits`;
-/// its `HeapBuf` arm checked nothing, so the bits ran on into the next buffer.
-/// The same shape as three earlier bugs: one omission beside a checked
-/// counterpart. The guest uses the shifted-alias form, so that is checked here
-/// too.
-#[test]
-fn a_bit_decomposition_cannot_overrun_its_heap_destination() {
- let prog = |decl: &str, dest: &str| {
- format!(
- "def main():
- hb = HeapBuf({decl})
- v = GEN ** 5
- hint_decompose_bits_exponent({dest}, v, 8)
- p = GEN ** 0
- p[1] = hb[1]
- p[GEN] = GEN ** 0
- return
-"
- )
- };
- for (decl, dest, want) in [("2", "hb", "0:8"), ("8", "hb * GEN ** 4", "4:12")] {
- let ast = parse(&prog(decl, dest)).expect("parses");
- let Err(err) = std::panic::catch_unwind(|| compile(&ast)) else {
- panic!("HeapBuf({decl}) accepted 8 bits at `{dest}`");
- };
- let msg = err.downcast_ref::().map(String::as_str).unwrap_or("");
- assert!(msg.contains(want) && msg.contains("out of bounds"), "got `{msg}`");
- }
- // Both forms still compile where the buffer really does hold the bits.
- compile(&parse(&prog("8", "hb")).expect("parses"));
- compile(&parse(&prog("16", "hb * GEN ** 4")).expect("parses"));
-}
-
-/// Four names and calls that used to pick a winner or die without a line.
-#[test]
-fn a_program_names_what_it_means() {
- let tail = " p = GEN ** 0\n p[1] = GEN ** 0\n p[GEN] = GEN ** 0\n return\n";
- // Both bodies used to be lowered, and the last definition won.
- let dup = format!("def f(a):\n return a\n\ndef f(a):\n return a\n\ndef main():\n{tail}");
- assert!(parse(&dup).expect_err("duplicate def").contains("defined twice"));
- // `f__L1` is what a `Const` specialization of `f` is called, and `__loopN`
- // what a loop helper is called, so a user function of that name took its
- // place and the call ran the wrong body.
- let reserved = format!("def f__L1(a):\n return a\n\ndef main():\n{tail}");
- assert!(parse(&reserved).expect_err("reserved name").contains("reserved"));
-
- // A call to something that will never be lowered died in the assembler as a
- // bare `no entry found for key`, with no line.
- for callee in ["nosuchfn(1)", "assert_in_k(GEN ** 1, GEN ** 1)"] {
- let src = format!("def main():\n x = {callee}\n{tail}");
- let ast = parse(&src).expect("parses");
- let Err(err) = std::panic::catch_unwind(|| compile(&ast)) else {
- panic!("`{callee}` was accepted");
- };
- let msg = err.downcast_ref::().map(String::as_str).unwrap_or("");
- assert!(msg.contains("no function named"), "{callee}: got `{msg}`");
- }
-
- // A compile-time constant is capturable into a `for` body: the body becomes
- // its own function, so the constant is not in scope there. Dropping it made
- // this "unbound variable", which named neither the cause nor a fix.
- let captured = "\
-def main():
- c = 5
- hb = HeapBuf(4)
- for i in mul_range(1, 4):
- hb[i] = c
- p = GEN ** 0
- p[1] = hb[GEN]
- p[GEN] = GEN ** 0
- return
-";
- let program = compile(&parse(captured).expect("a constant may be captured"));
- let want = [F192::new(5, 0, 0), g_pow(0).into()];
- let (proof, _) = prove(&program, want, lean_vm::pcs::TEST_LOG_INV_RATE).unwrap();
- verify(&program, &want, &proof).expect("the captured constant reaches the body");
-}
-
-/// Three ways a program could read a frame or heap cell it does not own.
-///
-/// Each compiled clean and left a cell that nothing writes, which the prover
-/// then chooses, so an `assert` reading it proved nothing. The first also read
-/// the NEXT buffer and its assert passed.
-///
-/// They are one omission each, in three places that each had a checked
-/// counterpart: the store path's `copy_alias` did not bounds-check its stack
-/// index although the identical read in expression position did; `lower_call`
-/// did not compare a call's argument count against the callee's parameters
-/// although `try_inline` did; and a runtime-start heap slice bounds-checked one
-/// cell rather than its length although the compile-time-bounds arm beside it
-/// checked the whole span.
-#[test]
-fn a_program_cannot_reach_a_cell_it_does_not_own() {
- let rejected = |src: &str, want: &str| {
- let ast = parse(src).unwrap_or_else(|e| panic!("should parse: {e}"));
- let Err(err) = std::panic::catch_unwind(|| compile(&ast)) else {
- panic!("accepted:\n{src}");
- };
- let msg = err.downcast_ref::().map(String::as_str).unwrap_or("");
- assert!(msg.contains(want), "got `{msg}`, wanted `{want}`");
- };
- let tail = " p = GEN ** 0\n p[1] = GEN ** 0\n p[GEN] = GEN ** 0\n return\n";
-
- // A store's RHS, and a list literal element, each index one past the end.
- rejected(
- &format!("def main():\n a = StackBuf(2)\n b = StackBuf(2)\n c = StackBuf(2)\n c[0] = a[2]\n{tail}"),
- "index 2 out of bounds (StackBuf size 2)",
- );
- rejected(
- &format!("def main():\n a = StackBuf(2)\n b = StackBuf(2)\n lst = [a[2], a[3]]\n{tail}"),
- "index 2 out of bounds (StackBuf size 2)",
- );
- // A call supplying too few arguments, and too many.
- rejected(
- &format!("def check(a, b):\n assert a == b\n return\n\ndef main():\n check(0)\n{tail}"),
- "`check` takes 2 arguments, got 1",
- );
- rejected(
- &format!("def one(a):\n return a\n\ndef main():\n r = one(GEN ** 1, GEN ** 2)\n{tail}"),
- "`one` takes 1 argument, got 2",
- );
- // A runtime-start slice whose start folds: the SPAN leaves the buffer.
- rejected(
- &format!(
- "def main():\n hb = HeapBuf(2)\n nxt = HeapBuf(2)\n hint_witness(hb[GEN ** 1:GEN ** 1 + 2], \"w\")\n{tail}"
- ),
- "heap slice 1:3 out of bounds",
- );
-
- // The in-bounds run of the same shape still compiles and proves.
- let ok = "\
-def main():
- hb = HeapBuf(4)
- hint_witness(hb[GEN ** 1:GEN ** 1 + 2], \"w\")
- p = GEN ** 0
- p[1] = hb[GEN]
- p[GEN] = GEN ** 0
- return
-";
- let mut program = compile(&parse(ok).expect("parse"));
- program.set_witness("w", vec![vec![F192::new(1234, 0, 0), F192::new(5678, 0, 0)]]);
- let want = [F192::new(1234, 0, 0), g_pow(0).into()];
- let (proof, _) = prove(&program, want, lean_vm::pcs::TEST_LOG_INV_RATE).unwrap();
- verify(&program, &want, &proof).expect("an in-bounds runtime-start slice still works");
-}
-
-/// A `for` body that assigns to an enclosing name says why that cannot work.
-///
-/// The capture set drops every name the body binds, so a body that ASSIGNS to
-/// an enclosing name also loses the read that precedes the assignment, and the
-/// read arrived at lowering as a bare "unbound variable". That is the loop-carry
-/// limitation, not a typo: the tail-recursive helper threads its captures in and
-/// never out, so an accumulator cannot come back. The `StackBuf` form of the
-/// same limitation already said so; the scalar form did not.
-#[test]
-fn a_loop_that_cannot_carry_a_value_says_so() {
- let accumulator = "\
-def main():
- s = GEN ** 0
- for i in mul_range(1, 8):
- s = s * GEN
- p = GEN ** 0
- p[1] = s
- p[GEN] = GEN ** 0
- return
-";
- let ast = parse(accumulator).expect("parses");
- let Err(err) = std::panic::catch_unwind(|| compile(&ast)) else {
- panic!("a loop-carried accumulator was accepted");
- };
- let msg = err.downcast_ref::().map(String::as_str).unwrap_or("");
- assert!(msg.contains("the loop cannot carry it"), "got `{msg}`");
-
- // A real typo, outside any loop, still gets the plain message.
- let typo = "def main():\n p = GEN ** 0\n p[1] = nosuch\n p[GEN] = GEN ** 0\n return\n";
- let ast = parse(typo).expect("parses");
- let Err(err) = std::panic::catch_unwind(|| compile(&ast)) else {
- panic!("a typo was accepted");
- };
- let msg = err.downcast_ref::().map(String::as_str).unwrap_or("");
- assert!(
- msg.contains("unbound variable `nosuch`") && !msg.contains("loop"),
- "got `{msg}`"
- );
-}
-
-/// A `match` target is a BINDER, and every field of the statement is walked.
-///
-/// Four facts, none of which was pinned by anything in the crate: a name target
-/// is not substituted (a `Const k` used to rewrite the binder into a literal, and
-/// the target was then rejected); an INDEX target is, since `sb[k]` needs `k`; the
-/// scrutinee and the arms are; and the statement REBINDS its name targets, so
-/// substitution stops after it. The last is the dangerous one, being the only
-/// mutation of the four that produced a wrong published value rather than a
-/// diagnostic.
-#[test]
-fn a_match_target_binds_and_every_field_is_walked() {
- let two = "def two(i: Const):\n return GEN ** i, GEN ** i\n\n";
- let publish = " p = GEN ** 0\n p[1] = r\n p[GEN] = GEN ** 0\n return\n";
- // Arm 1 returns (g, g), so the two returns XOR to zero wherever they are read.
- let want = [F192::ZERO, g_pow(0).into()];
- let verifies = |src: &str, why: &str| {
- let program = compile(&parse(src).unwrap_or_else(|e| panic!("{why}: {e}")));
- let (proof, _) = prove(&program, want, lean_vm::pcs::TEST_LOG_INV_RATE).unwrap();
- verify(&program, &want, &proof).unwrap_or_else(|e| panic!("{why}: {e:?}"));
- };
-
- // A `Const` parameter whose name is also a target: the binder survives, and
- // what is read after the statement is the DISPATCH's output, not the constant.
- verifies(
- &format!(
- "{two}def pick(x, k: Const):\n k, e = match(log(x), range(0, 2), lambda i: two(i))\n return k + e\n\ndef main():\n r = pick(GEN ** 1, 7)\n{publish}"
- ),
- "a Const name may also be a target",
- );
-
- // The scrutinee and the arms ARE substituted: both mention the constant, and a
- // missed field shows up as a `Var` reaching a position that needs an integer.
- verifies(
- &format!(
- "{two}def pick(k: Const):\n a, b = match(log(GEN ** k), range(0, 2), lambda i: two(i + k))\n return a + b\n\ndef main():\n r = pick(1)\n{publish}"
- ),
- "the scrutinee and the arms see the constant",
- );
-
- // An INDEX target is substituted, so a `Const` index names a cell.
- verifies(
- &format!(
- "{two}def pick(x, k: Const):\n sb = StackBuf(2)\n sb[0] = 0\n sb[k], e = match(log(x), range(0, 2), lambda i: two(i))\n return sb[1] + e\n\ndef main():\n r = pick(GEN ** 1, 1)\n{publish}"
- ),
- "a Const index target resolves to its cell",
- );
-
- // An `unroll` counter as a target name obeys the same rule.
- let counter = format!(
- "{two}def main():\n for j in unroll(0, 2):\n j, e = match(log(GEN ** 1), range(0, 2), lambda i: two(i))\n r = j + e\n{publish}"
- );
- let _ = compile(&parse(&counter).expect("an unroll counter may also be a target"));
-}
-
-/// The same construct in a VALUE position, for the same reason. `+` there is
-/// XOR, so `lvl + 1` with `lvl = 3` is 2 and not 4, silently: the SPHINCS guest
-/// could not write a Merkle level into a tweak and carried a generated table of
-/// one literal per level to get the integer reading instead.
-///
-/// `const(e)` reads `e` with integer arithmetic and emits the literal, so one
-/// construct means one thing in both positions. The two readings must really
-/// differ here, or the test proves nothing.
-#[test]
-fn a_value_may_ask_for_the_integer_regime() {
- // Parse and compile OUTSIDE the catch, so a negative arm can only fail on the
- // assert. Inside, `!accepted` would also hold if the program stopped parsing.
- let accepted = |expr: &str, want: u64| {
- let src = format!(
- "def main():\n for i in unroll(3, 4):\n v = {expr}\n assert v == {want}\n return\n"
- );
- let program = compile(&parse(&src).expect("parse"));
- program.execute([F192::ZERO, F192::ZERO]).is_ok()
- };
-
- // i = 3: the integer reading is 4, the field reading `3 XOR 1` is 2.
- assert!(accepted("const(i + 1)", 4), "const(...) is the integer reading");
- assert!(!accepted("const(i + 1)", 2), "and not the field one");
- assert!(accepted("i + 1", 2), "undeclared, `+` in a value position stays XOR");
- assert!(!accepted("i + 1", 4));
-
- // Subtraction has no field meaning at all, so it is only reachable this way.
- assert!(accepted("const(i - 1)", 2));
-
- let rejected = |src: &str, want: &str| {
- let ast = parse(src).expect("parses");
- let Err(err) = std::panic::catch_unwind(|| compile(&ast)) else {
- panic!("accepted: {src}");
- };
- let msg = err.downcast_ref::().map(String::as_str).unwrap_or("");
- assert!(msg.contains(want), "wanted `{want}`, got `{msg}`");
- };
- // A `const(...)` that is not a compile-time integer names itself.
- rejected(
- "def main():\n w = GEN ** 0\n v = const(w + 1)\n return\n",
- "compile-time integer",
- );
- // The wrapper reinterprets its OPERATORS, and cannot reinterpret a leaf, so a
- // leaf whose own two readings diverge is rejected rather than silently read one
- // way: `n = 2 + 3` holds `2 XOR 3` = 1 while its integer reading is 5, and
- // `assert n == 1` and `assert const(n) == 5` both used to pass in one program.
- rejected(
- "def main():\n n = 2 + 3\n v = const(n)\n return\n",
- "cannot reinterpret",
- );
-}
-
-/// A `def` may not take a builtin's name. The builtin wins at the call site, so
-/// the body is never reached and its constraints silently disappear: `def const(x)`
-/// with an `assert` in it was skipped outright by `v = const(4)`, and skipped or
-/// not depending on whether the ARGUMENT folded, so one call site had two
-/// meanings. True of `f192` before `const` existed, so this is the class, not one
-/// name.
-#[test]
-fn a_function_may_not_shadow_a_builtin() {
- for name in ["const", "f192", "addr", "blake2s", "len", "hint_witness", "StackBuf"] {
- let src = format!("def {name}(x):\n assert x == 99\n return x\n\ndef main():\n return\n");
- let err = parse(&src).expect_err(&format!("`def {name}` must be rejected"));
- assert!(err.contains("is a builtin"), "got `{err}`");
- }
- // A global CONSTANT of that name is rejected too, and for a sharper reason: a
- // scalar constant is substituted textually, so `match = 4` rewrites
- // `v = match(log(x), …)` into `4(log(x), …)`.
- for name in ["match", "blake2s", "len"] {
- let src = format!("{name} = 4\n\ndef main():\n return\n");
- let err = parse(&src).expect_err(&format!("`{name} = 4` must be rejected"));
- assert!(err.contains("is a builtin"), "got `{err}`");
- }
- // An ordinary name still works, including one that contains a builtin's.
- for name in ["helper", "constant", "addr_of"] {
- let src = format!("def {name}(x):\n return x\n\ndef main():\n return\n");
- parse(&src).unwrap_or_else(|e| panic!("`def {name}` must be accepted: {e}"));
- }
-}
-
-/// A compile-time branch is decided by a regime the author names.
-///
-/// The fold decides on the integer reading while the runtime test of the same
-/// condition compares field values, so the two contradict each other whenever a
-/// side's readings do. `3 + 1` is the integer 4 and the field element
-/// `3 XOR 1` = 2, and `if 3 + 1 == 4` used to fold into an arm whose own
-/// condition is false as a value; `if K == 4: assert K == 4` compiled clean and
-/// died at witness generation.
-///
-/// Neither reading can win: deciding in the field breaks `if 1 + 1 == 2` and
-/// every `if i + 1 == n` in an `unroll`, and cannot read `-`, `//` or `%` at
-/// all. So an ambiguous condition is rejected, and `const(...)` is how the
-/// author says the integer regime was meant.
-#[test]
-fn an_ambiguous_compile_time_branch_must_be_declared() {
- let prog = |cond: &str| {
- format!(
- "def main():
- hb = HeapBuf(4)
- if {cond}:
- hb[GEN ** 0] = 5
- else:
- hb[GEN ** 0] = 7
- p = GEN ** 0
- p[1] = hb[GEN ** 0]
- p[GEN] = GEN ** 0
- return
-"
- )
- };
- let fold = |cond: &str| {
- let program = compile(&parse(&prog(cond)).unwrap_or_else(|e| panic!("{cond}: {e}")));
- let want = [F192::new(5, 0, 0), g_pow(0).into()];
- let (proof, _) = prove(&program, want, lean_vm::pcs::TEST_LOG_INV_RATE).unwrap();
- verify(&program, &want, &proof).unwrap_or_else(|e| panic!("`{cond}` did not take the then arm: {e:?}"));
- };
- // Declared, so it folds with integer arithmetic and the arm runs.
- fold("const(3 + 1 == 4)");
- fold("const(1 + 1 == 2)");
- fold("const(1 + 1 == 3 - 1)");
- // Only the field can read these, and `const(...)` decides them too. A plain
- // `if` must not: folding one would rescope its arm.
- fold("const(GEN ** 3 == GEN ** 3)");
- fold("const(2 ** 40 == 2 ** 40)");
- // Undeclared but unambiguous (6 either way), so it folds as it always did.
- fold("2 * 3 == 6");
-
- // Undeclared and ambiguous: rejected rather than silently decided. The
- // last three are the same condition as the first with the OTHER side
- // written using an operator the field cannot read, which is how the first
- // version of this check let them through: it compared the two sides'
- // verdicts, and `try_field_const` has no arm for `-`, `//` or `%`, so one
- // missing reading disabled the whole guard. The check is per side now.
- for cond in [
- "3 + 1 == 4",
- "1 + 1 == 2",
- "1 + 1 == 3 - 1",
- "1 + 1 == 8 // 4",
- "1 + 1 == 9 % 7",
- ] {
- let src = prog(cond);
- let ast = parse(&src).expect("parses");
- let Err(err) = std::panic::catch_unwind(|| compile(&ast)) else {
- panic!("`{cond}` was accepted");
- };
- let msg = err.downcast_ref::().map(String::as_str).unwrap_or("");
- assert!(msg.contains("where a value is wanted"), "{cond}: got `{msg}`");
- }
- // A near miss of the wrapper says the word, where the ordinary parse error
- // for a malformed condition never would. A condition carrying a comparison of
- // its own is NOT a near miss, since `const(...)` is a value expression too.
- for cond in ["const (a == b)", "const(a == b"] {
- let err = parse(&prog(cond)).expect_err(cond);
- assert!(err.contains("must wrap the WHOLE condition"), "{cond}: got `{err}`");
- }
- // So one side of an ordinary comparison may be a `const(...)`, in either
- // order. Rejecting these made the two operand orders behave differently.
- fold("const(1 + 1) == 2");
- fold("2 == const(1 + 1)");
- // A variable that merely starts with `const` is not a near miss.
- let plain = "def main():\n const = 4\n hb = HeapBuf(4)\n if const == 4:\n hb[GEN ** 0] = 5\n else:\n hb[GEN ** 0] = 7\n p = GEN ** 0\n p[1] = hb[GEN ** 0]\n p[GEN] = GEN ** 0\n return\n";
- let program = compile(&parse(plain).expect("a name beginning with `const` is an ordinary name"));
- let want = [F192::new(5, 0, 0), g_pow(0).into()];
- let (proof, _) = prove(&program, want, lean_vm::pcs::TEST_LOG_INV_RATE).unwrap();
- verify(&program, &want, &proof).expect("`const == 4` is a comparison, not a wrapper");
-
- // Declared, but not actually decidable while compiling.
- let src = prog("const(hb == 4)");
- let ast = parse(&src).expect("parses");
- let Err(err) = std::panic::catch_unwind(|| compile(&ast)) else {
- panic!("a runtime `if const(...)` was accepted");
- };
- let msg = err.downcast_ref::().map(String::as_str).unwrap_or("");
- assert!(msg.contains("asks for a compile-time decision"), "got `{msg}`");
-}
-
-/// A string literal is one opaque token.
-///
-/// Two passes used to read structure out of the middle of one. Comments were
-/// stripped with `raw.split('#')`, so a `#` in a stream name truncated the line,
-/// and the shortened line often still parsed. Bracket depth was counted without
-/// any notion of a string, so every top-level splitter (arguments, `+`/`-`,
-/// `*`//`/`%`, `**`, augmented assignment, comparisons) read a `,` or a `]`
-/// spelled inside the name as structure: this call split into three arguments.
-#[test]
-fn a_string_literal_is_not_scanned_for_syntax() {
- let src = "\
-def main():
- rb = StackBuf(1)
- hint_witness(rb[0:1], \"x,y#z]w\")
- p = GEN ** 0
- p[1] = rb[0]
- p[GEN] = GEN ** 0
- return
-";
- let mut program = compile(&parse(src).expect("a `,`, `#` or `]` inside a string is part of the name"));
- program.set_witness("x,y#z]w", vec![vec![F192::new(7, 0, 0)]]);
- let want = [F192::new(7, 0, 0), g_pow(0).into()];
- let (proof, _) = prove(&program, want, lean_vm::pcs::TEST_LOG_INV_RATE).unwrap();
- verify(&program, &want, &proof).expect("the stream name survived parsing intact");
-}
-
-/// Naming a constant may not change what it means.
-///
-/// `K = 3 + 1` folds two ways at once. In the field, where a value expression's
-/// constants live, it is `3 XOR 1` = 2 = `g^1`. As a compile-time integer, which
-/// is what an index position wants, it is 4. A second g-power recognizer used to
-/// match `Expr::Var` against the integer binding and read those bits as an
-/// exponent, so `K` was `g^1` as a value and `g^2` as an index: one name, two
-/// meanings, in one function. Spelling the constant inline was unaffected, since
-/// the integer view only ever reached a *name*.
-///
-/// The buffer holds a distinct value per cell, so the proof pins which cell the
-/// index named rather than merely that it compiled.
-#[test]
-fn naming_a_constant_does_not_change_which_heap_cell_it_names() {
- let src = "\
-def main():
- rb = StackBuf(1)
- hint_witness(rb[0:1], \"r\")
- r = rb[0]
- hb = HeapBuf(16)
- hint_witness(hb[0:4], \"vals\")
- K = 3 + 1
- x = hb[(r * r) * K]
- p = GEN ** 0
- p[1] = x
- p[GEN] = GEN ** 0
- return
-";
- let mut program = compile(&parse(src).expect("parse"));
- program.set_witness("r", vec![vec![g_pow(0).into()]]);
- program.set_witness("vals", vec![(10u64..14).map(|v| F192::new(v, 0, 0)).collect()]);
- // `r` is 1, so the index is `K` itself: cell 1, holding 11. Reading the
- // integer view instead would name cell 2, holding 12.
- let want = [F192::new(11, 0, 0), g_pow(0).into()];
- let (proof, _) = prove(&program, want, lean_vm::pcs::TEST_LOG_INV_RATE).unwrap();
- verify(&program, &want, &proof).expect("the index means what the value means");
-}
-
-/// A loop body that merely SHADOWS an enclosing `StackBuf` never touches it, so
-/// rejecting the program names a capture that is not happening. Scoping the
-/// arms took the arm-local binding out of the set the rejection consults, which
-/// needs the flat "does the body bind this at all" answer instead.
-#[test]
-fn a_shadowed_stack_buf_is_not_a_capture() {
- let src = "\
-def main():
- sa = StackBuf(2)
- sa[0] = GEN
- sa[1] = GEN ** 2
- hb = HeapBuf(8)
- for i in mul_range(1, GEN ** 3):
- if i == GEN:
- sa = StackBuf(2)
- sa[0] = GEN ** 5
- assert sa[0] == GEN ** 5
- else:
- hb[i] = GEN ** 7
- assert sa[0] == GEN
- return
-";
- let exec = compile(&parse(src).expect("parse")).execute([F192::ZERO; 2]).unwrap();
- assert!(exec.unconstrained_reads.is_empty(), "no prover-chosen read");
-}
-
-/// `lower_if` FOLDS a compile-time condition and runs the taken branch without a
-/// scope, so its bindings persist exactly like an `unroll` body's. Modelling it
-/// as scoped over-captured, and the loop's own self-call then evaluated a name
-/// the folded arm had rebound to a `StackBuf`.
-#[test]
-fn a_folded_branch_keeps_its_bindings() {
- let src = "\
-def main():
- hb = HeapBuf(64)
- w = HeapBuf(16)
- for i in mul_range(1, GEN ** 3):
- if 1 == 1:
- w = StackBuf(2)
- w[0] = i
- w[1] = i * GEN
- hb[i] = w[1]
- assert hb[1] == GEN
- return
-";
- let exec = compile(&parse(src).expect("parse")).execute([F192::ZERO; 2]).unwrap();
- assert!(exec.unconstrained_reads.is_empty(), "no prover-chosen read");
-}
-
-/// Every diagnostic names its source line. The line vector used to carry the
-/// INDENT, not the source index, and the collection loop skips blanks, comments
-/// and imports without counting, so nothing could name one: against a
-/// 3,274-line guest an error read `inconsistent indentation` and no more.
-#[test]
-fn a_parse_error_names_its_source_line() {
- // Counting from 1: blank, comment, constant, blank, blank, def, let, error.
- let src = "\n# a comment\nV = 8\n\n\ndef main():\n x = 4\n x != y\n return\n";
- let err = parse(src).unwrap_err();
- assert!(err.starts_with("line 8:"), "{err}");
-
- // A constant declaration, above the `def`s and parsed by a different loop.
- let konst = "\nN = 1\n\nM = 1 +\n\ndef main():\n return\n";
- assert!(parse(konst).unwrap_err().starts_with("line 4:"), "{konst}");
-
- // Inside a block, past a blank line.
- let block =
- "\n\ndef main():\n a = StackBuf(2)\n\n for i in mul_range(1, 10):\n a[0] = 1\n return\n";
- assert!(parse(block).unwrap_err().starts_with("line 6:"));
-}
-
-/// The four diagnostics raised where no `func`/`stmt` frame is open: those two
-/// stamp the line they were ENTERED on, so an enclosing header would be named
-/// instead of the line that is wrong. `inconsistent indentation` is the one the
-/// whole change exists for, and stamping it from the enclosing frame put it
-/// 1,074 lines away from the fault in the real guest.
-#[test]
-fn an_error_raised_between_frames_still_names_its_line() {
- // 1 blank, 2 def, 3 let, 4 if, 5 body, 6 stray deeper indent.
- let indent = "\ndef main():\n x = 4\n if x == 4:\n x = 5\n x = 6\n return\n";
- assert!(parse(indent).unwrap_err().starts_with("line 6:"), "{:?}", parse(indent));
-
- // 1 blank, 2 @inline, 3 the broken def.
- let deco = "\n@inline\nde f(x):\n return x\n";
- assert!(parse(deco).unwrap_err().starts_with("line 3:"), "{:?}", parse(deco));
-
- // 1 blank, 2 def, 3 let, 4 if, 5 body, 6 the broken elif.
- let elif = "\ndef main():\n x = 4\n if x == 4:\n x = 5\n elif x ~~ 5:\n x = 6\n return\n";
- assert!(parse(elif).unwrap_err().starts_with("line 6:"), "{:?}", parse(elif));
-
- // 1 blank, 2 def, 3 let, 4 the match whose ranges are not contiguous.
- let arms = "\ndef main():\n x = GEN ** 0\n v = match(log(x), range(0, 1), lambda j: 5, range(2, 3), lambda j: 6)\n return\n";
- assert!(parse(arms).unwrap_err().starts_with("line 4:"), "{:?}", parse(arms));
-}
-
-/// A replacement carrying a newline shifts every later line, so the numbers
-/// above would be wrong and the injected text would land at whatever
-/// indentation it fell on. A `#` is the same shape of hazard: it truncates the
-/// rest of the line and changes the compiled program with no diagnostic. All
-/// 134 of the guest's placeholders are clean; this keeps it that way.
-#[test]
-fn a_multi_line_placeholder_is_rejected() {
- let mut reps = std::collections::BTreeMap::new();
- let src = "FOO = 1\n\ndef main():\n return\n";
- for bad in ["1\nz = 9", "1 #"] {
- reps.insert("FOO".to_string(), bad.to_string());
- let err = lean_compiler::parse_with_replacements(src, &reps).unwrap_err();
- assert!(err.contains("would reshape the line"), "{bad}: {err}");
- }
-}
-
-/// A lowering error names its line too, not just a parse error. `lower.rs` works
-/// in frame cells and program counters, so the statement's line is the last
-/// place that knows where the program said it: 30 diagnostics used to print a
-/// Rust `Debug` dump of an AST node and nothing else.
-#[test]
-fn a_lowering_error_names_its_source_line() {
- // 1 blank, 2 def, 3 let, 4 store, 5 blank, 6 let, 7 the unbound read.
- let src = "\ndef main():\n hb = HeapBuf(4)\n hb[GEN] = 7\n\n p = GEN ** 0\n p[1] = nope\n return\n";
- let err = std::panic::catch_unwind(|| {
- compile(&parse(src).expect("parse"));
- })
- .expect_err("an unbound variable must abort");
- let msg = err
- .downcast_ref::()
- .cloned()
- .unwrap_or_else(|| err.downcast_ref::<&str>().map(|s| s.to_string()).unwrap_or_default());
- assert!(msg.starts_with("line 7:"), "{msg}");
-}
-
-/// And a check that fails at witness generation names it, which is the one that
-/// matters day to day: a failed guest `assert` surfaces as a write-once
-/// conflict, and `AGENTS.md` used to say to disassemble around the reported pc.
-#[test]
-fn a_failed_assert_names_its_source_line() {
- // 1 blank, 2 def, 3 let, 4 blank, 5 the assert that cannot hold.
- let src = "\ndef main():\n x = GEN ** 3\n\n assert x == GEN ** 4\n return\n";
- let program = compile(&parse(src).expect("parse"));
- let err = program.execute([F192::ZERO; 2]).err().expect("the assert cannot hold");
- assert!(err.site.contains("line 5"), "{err}");
-}
-
-/// An `@inline` body lowers through the CALLER's `FnLower`, so its statements
-/// move `cur_line`. Without restoring it, every instruction the caller emitted
-/// after the call was blamed on whatever line the callee ended on: a line that
-/// cannot fail, reported with confidence.
-#[test]
-fn an_inline_call_does_not_steal_the_call_site_line() {
- // 1 blank, 2 @inline, 3 def, 4 let, 5 return, 6-7 blank, 8 def main, ... 12 the assert.
- let src = "\n@inline\ndef idf(x):\n y = x * x\n return y\n\n\ndef main():\n hb = HeapBuf(4)\n hb[GEN] = GEN ** 3\n a = hb[GEN]\n assert idf(a) == GEN\n return\n";
- let program = compile(&parse(src).expect("parse"));
- let err = program.execute([F192::ZERO; 2]).err().expect("the assert cannot hold");
- assert!(
- err.site.contains("line 12"),
- "the call site, not the callee's line 5: {err}"
- );
-}
-
-/// The fill blocks are not source code, so they carry the unknown line rather
-/// than whatever `main` happened to end on. They are most of a small program's
-/// instructions, so blaming them on a real line makes the table mostly wrong.
-#[test]
-fn fill_blocks_carry_no_source_line() {
- let src = "\ndef main():\n hb = HeapBuf(4)\n hb[GEN] = 7\n return\n";
- let program = compile(&parse(src).expect("parse"));
- let start = program.filler.first().map(|b| b.pc).expect("main carries fill blocks") as usize;
- assert!(
- program.src_lines[start..].iter().all(|&l| l == 0),
- "a fill block must not be attributed to a source line"
- );
- assert!(
- program.src_lines[..start].iter().any(|&l| l != 0),
- "real code keeps its lines"
- );
-}
-
-/// A `match` join reads one cell per bound name, so a multi-cell `StackBuf`
-/// return would bind only the run's FIRST cell and leave the rest where nothing
-/// reads them. The guard must check every return, not all of them at once:
-/// testing `all(is not scalar)` rather than `any(is not scalar)` fired only when
-/// EVERY return was a buffer, so a `(scalar, StackBuf)` pair walked through.
-#[test]
-#[should_panic(expected = "StackBuf return cannot cross a match join")]
-fn a_mixed_stack_buf_return_cannot_cross_a_match_join() {
- let src = "\
-@inline
-def f(k: Const):
- s = StackBuf(2)
- s[0] = GEN ** 7
- s[1] = GEN ** 9
- return GEN ** k, s
-
-def main():
- x = GEN
- a, b = match(log(x), range(0, 2), lambda i: f(i))
- assert a == a
- assert b == GEN ** 7
- return
-";
- compile(&parse(src).expect("parse"));
-}
diff --git a/crates/lean_compiler/tests/suite/transcript_helpers.rs b/crates/lean_compiler/tests/suite/transcript_helpers.rs
deleted file mode 100644
index a6f128acc..000000000
--- a/crates/lean_compiler/tests/suite/transcript_helpers.rs
+++ /dev/null
@@ -1,57 +0,0 @@
-use lean_compiler::{compile, parse};
-use primitives::field::F192;
-
-#[test]
-fn transcript_helpers_are_ordinary_nested_inline_zkdsl() {
- let src = r#"
-from snark_lib import *
-
-Y = f192(0, 1, 0)
-
-@inline
-def pack64x2(a, b):
- assert_in_k(a, b)
- return a + Y * b
-
-@inline
-def challenge_from_state(state):
- lo = StackBuf(2)
- hi = StackBuf(2)
- hint_f192_limbs(lo, state[0])
- hint_f192_limbs(hi, state[1])
- state[0] = pack64x2(lo[0], lo[1])
- state[1] = pack64x2(hi[0], hi[1])
- return lo[0] + Y * (lo[1] + Y * hi[0])
-
-@inline
-def fs_compress(state, scalar, tail, out):
- limbs = StackBuf(3)
- hint_f192_limbs(limbs, scalar)
- block = StackBuf(2)
- block[0] = pack64x2(limbs[0], limbs[1])
- block[1] = pack64x2(limbs[2], tail)
- assert scalar == limbs[0] + Y * (limbs[1] + Y * limbs[2])
- blake2s(state, block, out)
- return
-
-@inline
-def observe(state, scalar):
- out = StackBuf(2)
- fs_compress(state, scalar, 13, out)
- return out
-
-def main():
- state = StackBuf(2)
- state[0] = f192(1, 2, 0)
- state[1] = f192(3, 4, 0)
- out = observe(state, f192(5, 6, 7))
- challenge = challenge_from_state(out)
- assert challenge == challenge
- return
-"#;
- // `assert challenge == challenge` is the zkDSL keep-alive idiom: it forces
- // the value to be materialized. The assertion under test is that `execute`
- // runs the lowered helpers without a write-once memory conflict.
- let program = compile(&parse(src).expect("parse transcript helpers"));
- program.execute([F192::ZERO; 2]).unwrap();
-}
diff --git a/crates/lean_compiler/tests/suite/vm_proofs.rs b/crates/lean_compiler/tests/suite/vm_proofs.rs
deleted file mode 100644
index bc2f85af7..000000000
--- a/crates/lean_compiler/tests/suite/vm_proofs.rs
+++ /dev/null
@@ -1,144 +0,0 @@
-//! Proof-level properties of the VM: what a proof is bound to, that its channels
-//! carry everything, and that tampering with either channel is rejected.
-//!
-//! These live here rather than beside the prover because a provable program's tables
-//! must all be powers of two, which is the compiler's fill blocks' job
-//! (`lean_compiler::filler`). A hand-written bytecode program would have to fill itself,
-//! duplicating their knowledge of what a dummy row looks like.
-
-use lean_compiler::{compile, parse};
-use lean_vm::cpu::{CpuError, Proof, ProveError, prove, verify};
-use lean_vm::vmhash::compress;
-use primitives::field::{F64, F192};
-
-/// A program that hashes one block and publishes the digest, so its proof carries
-/// flock's sub-proof over a real compression.
-const HASHING: &str = "\
-def main():
- a = StackBuf(2)
- a[0] = 5
- a[1] = 7
- c = StackBuf(2)
- blake2s(a, a, c)
- p = 1
- p[1] = c[0]
- p[GEN] = c[1]
- return
-";
-
-/// The public input `HASHING` publishes.
-fn hashing_pi() -> [F192; 2] {
- let h = [F64(5), F64(0), F64(7), F64(0)];
- let d = compress(h, h);
- [F192::new(d[0].0, d[1].0, 0), F192::new(d[2].0, d[3].0, 0)]
-}
-
-fn hashing_proof() -> (lean_vm::cpu::Program, [F192; 2], Proof) {
- let program = compile(&parse(HASHING).expect("parse"));
- let pi = hashing_pi();
- let (proof, _) = prove(&program, pi, lean_vm::pcs::TEST_LOG_INV_RATE).unwrap();
- verify(&program, &pi, &proof).expect("honest proof verifies");
- (program, pi, proof)
-}
-
-/// The stacked WHIR opening rides the proof's Merkle phases. Nothing there is
-/// bound by the transcript (only by the Merkle structure), so tampering an opened
-/// row must still be rejected.
-#[test]
-fn a_tampered_opening_is_rejected() {
- let (program, pi, mut proof) = hashing_proof();
- let phase = proof.merkle.first_mut().expect("stacked WHIR opening");
- phase.leaf_data[0][0].0 ^= 1;
- assert!(
- verify(&program, &pi, &proof).is_err(),
- "a tampered validity proof must be rejected"
- );
-}
-
-/// flock's reduction sub-proof (zerocheck, lincheck, ring switch) rides the scalar
-/// stream as raw transport, but its values re-enter the transcript through the verifier's
-/// replay, so a flipped transport word diverges the recovered flock claim.
-#[test]
-fn a_tampered_reduction_word_is_rejected() {
- let (program, pi, proof) = hashing_proof();
- let mut tampered = proof;
- let n = tampered.stream.len();
- // The second-to-last word is always meaningful bytes; only the final one may be
- // zero-padded.
- tampered.stream[n - 2] += F192::ONE;
- assert!(
- verify(&program, &pi, &tampered).is_err(),
- "a tampered reduction transport word must be rejected"
- );
-}
-
-/// A proof is bound to its exact program. The two programs here have the same shape,
-/// so the same layout and announced sizes, and differ in one constant; the program
-/// digest seeds the transcript, so it diverges at the first squeeze. This is
-/// the adaptive-statement forgery that the bytecode bus's single-point check does not,
-/// on its own, prevent.
-#[test]
-fn a_proof_does_not_verify_against_another_program() {
- // The differing constant has to reach the bytecode: an unused one folds away at
- // compile time and the two programs come out byte-identical. Hashing it does the
- // job, and publishing nothing keeps the public input the same for both.
- let src = |k: u32| {
- format!(
- "def main():\n a = StackBuf(2)\n a[0] = {k}\n a[1] = 7\n \
- c = StackBuf(2)\n blake2s(a, a, c)\n return\n"
- )
- };
- let program = compile(&parse(&src(5)).expect("parse"));
- let other = compile(&parse(&src(6)).expect("parse"));
- let pi = [F192::ZERO, F192::ZERO];
- let (proof, _) = prove(&program, pi, lean_vm::pcs::TEST_LOG_INV_RATE).unwrap();
- verify(&program, &pi, &proof).expect("honest proof verifies");
- assert!(
- verify(&other, &pi, &proof).is_err(),
- "a proof must not verify against a different program"
- );
-}
-
-/// Out-of-process verification: everything travels in the two channels, so a proof
-/// serializes, crosses a process boundary and verifies, and a flipped announced size
-/// is caught before any reduction runs.
-#[test]
-fn a_proof_roundtrips_through_bytes() {
- let (program, pi, proof) = hashing_proof();
- let bytes = bincode::serialize(&proof).expect("proof serializes");
- let decoded: Proof = bincode::deserialize(&bytes).expect("proof deserializes");
- verify(&program, &pi, &decoded).expect("a deserialized proof verifies");
-
- // The announced sizes lead the stream: memory log, the table log heights, then
- // the PCS rate.
- let mut bad_rate = decoded.clone();
- bad_rate.stream[1 + lean_vm::cpu::Stats::TABLES.len()] = F192::new(5, 0, 0);
- assert!(
- matches!(verify(&program, &pi, &bad_rate), Err(CpuError::PublicInput)),
- "the announced PCS rate must be in 1..=4"
- );
-
- // A BLAKE2s height below flock's instance floor describes a layout the
- // arithmetization cannot express, and all three verifiers reject it there.
- let blake2s = lean_vm::cpu::Stats::TABLES
- .iter()
- .position(|&t| t == "BLAKE2S")
- .unwrap();
- let mut sub_floor = decoded;
- sub_floor.stream[1 + blake2s] = F192::new(2, 0, 0);
- assert!(
- matches!(verify(&program, &pi, &sub_floor), Err(CpuError::PublicInput)),
- "the announced BLAKE2s height must reach flock's instance floor"
- );
-}
-
-/// An unsupported rate is an error, not a panic.
-#[test]
-fn an_unsupported_rate_is_an_error() {
- let program = compile(&parse(HASHING).expect("parse"));
- let rate = lean_vm::pcs::MAX_LOG_INV_RATE + 1;
- assert_eq!(
- prove(&program, hashing_pi(), rate).err(),
- Some(ProveError::InvalidRate { log_inv_rate: rate })
- );
-}
diff --git a/crates/lean_compiler/zkDSL.md b/crates/lean_compiler/zkDSL.md
deleted file mode 100644
index cc8b13199..000000000
--- a/crates/lean_compiler/zkDSL.md
+++ /dev/null
@@ -1,551 +0,0 @@
-# zkDSL Language Reference (leanVM)
-
-The zkDSL is a Python-syntax language that compiles to the leanVM ISA: six instructions (`XOR`, `MUL`, `SET`, `DEREF`, `JUMP`, `BLAKE2s`) over the binary field GF(2^192), with write-once memory and all indices carried "in the exponent" as powers of a fixed generator. For the underlying VM and proving system, see [`doc/leanvm/main.tex`](../../doc/leanvm/main.tex).
-
-Source files use the `.py` extension and are **Python-shaped**: they import the [`snark_lib`](snark_lib.py) stub, which defines `GEN`, `log`, `mul_range`, `HeapBuf`, `StackBuf`, `assert_in_k`, and `blake2s`, so editors and linters resolve the intrinsic names. The compiler skips the import. Ordinary helpers such as `pack64x2` are defined in the single-file guest. A program that uses placeholders is not a runnable Python file: its `*_PLACEHOLDER` identifiers are undefined until the host fills them in, so importing it raises `NameError`.
-
-Entry points: `lean_compiler::parse` / `parse_with_replacements` → `lean_compiler::compile` → `lean_vm::cpu::prove` / `verify`.
-
-## Dev experience
-
-The repo ships a root [`pyrightconfig.json`](../../pyrightconfig.json) with `"extraPaths": ["crates/lean_compiler"]`, so any `.py` program anywhere in the repo resolves `snark_lib` when the repo root is opened in the editor. A placeholder-free program also runs as plain Python (`PYTHONPATH=crates/lean_compiler python3 crates/lean_compiler/tests/programs/foo.py`); the stubs are no-ops, so this only checks that the file is well-formed.
-
-## The field, and indices in the exponent
-
-The fields are
-
-`K = GF(2)[x]/(x^64 + x^4 + x^3 + x + 1)` and `E = K[y]/(y^3 + y + 1) = GF(2^192)`.
-
-Machine **words** (the contents of a memory cell, an immediate, a hashed value, the `JUMP` condition) are elements of `E`. **Addresses**, the program counter, the frame pointer, read counters, operands, opcodes, and domain separators live in the 64-bit subfield `K = GF(2^64)`. There are no runtime integers.
-
-- `+` is field addition = bitwise **XOR** (192-bit on words, so `x + x == 0`),
-- `*` is multiplication in `E`; for g-powers and addresses it stays within `K`,
-- `/` is runtime field division, `a / b = a · b⁻¹`. It costs one `MUL`: the compiler leaves the quotient cell unset and emits the checked relation `quotient · b == a`, which witness generation back-solves. Division by zero is undefined. This is distinct from `//`, compile-time integer floor division in sizes and indices,
-- an integer literal `n` supplies up to 128 raw bits and is embedded as `F192(c0, c1, 0)`. This is a source-syntax limit, not the machine-word width: words have three 64-bit limbs. Thus `5` is `1 + x^2`, not the integer five, and the literal `18446744073709551616` is the tower element `y`. Written `2 ** 64` in a value position it is not: `**` there is a field power, so it is `x^64` reduced. `const(2 ** 64)` is the literal. Full-width constants use `f192(c0, c1, c2)`, with each limb an unsigned 64-bit compile-time integer,
-- `GEN` is the fixed generator `g = x` of the 64-bit subfield `K^×` (multiplicative order `2^64 − 1`),
-- `GEN ** e` is the compile-time constant `g^e ∈ K` (`**` takes base `GEN` and a compile-time integer exponent: a literal, a constant, an `unroll` variable, `len(...)`, or index arithmetic of those). So `buf[GEN ** i]` names heap cell `i` directly inside an `unroll` loop, with no running-pointer cursor.
-- constant arithmetic means different things in the two positions, and this is a silent trap: `a + b` on two constants is **integer** addition in an index, a bound or a keyword (`buf[GEN ** (i + 1)]`, `unroll(0, n + 1)`, `counter=64 * (q + 1)`), and **XOR** in a value, where `1 + 1` is `0`. So a literal built in a value position must not add overlapping integers: `tweak = base + (level + 1) * SHIFT` drops the whole term on odd levels. Products are safe (an integer times a power of two is that shift, as long as the top bit stays inside the limb); to add, name the regime with `const(...)`: `tweak = base + const((level + 1) * SHIFT)`.
-- a **global constant** and a **`Const` parameter** are integer-arithmetic throughout, which is deliberate (it is what makes a derived size right) but means the same text means different things in the two places: `STEP = 3 + 1` is the integer `4` everywhere it appears, while the identical `x = 3 + 1` written inside a function is the field element `2`. Neither is wrong; they are two regimes, and a name crossing between them is where the trap bites.
-- a **compile-time `if`** must say which regime it means when the two disagree. The fold decides on the integer reading while the runtime test of the same condition compares field values, so `if 3 + 1 == 4` is true one way and false the other. Such a condition is **rejected**; write `if const(3 + 1 == 4):` to decide it with integer arithmetic, or spell the operands so the two readings agree (a product or a shift rather than a sum of overlapping integers). A condition whose readings already agree needs no wrapper.
-- `base ** e` with a **non-`GEN`** base and a compile-time exponent `e` is square-and-multiply: integer arithmetic in an index/bound position (`2 ** c`), or field arithmetic in a value position (`x ** k`, e.g. a loop counter `g^i` raised to a stride to reach cell `i·stride`). The base may be runtime.
-
-A logical **index** `i` is carried as `g^i` in the 64-bit subfield (order `2^64 − 1`): incrementing is one multiplication by `GEN`, and memory/bytecode addresses are g-powers. This is the design idiom of the whole VM: loops, heap addressing, and range checks below all live in the exponent, in `K`.
-
-## Program shape
-
-A program is a **single** `.py` file:
-
-```python
-from snark_lib import * # for Python tooling; skipped by the compiler
-
-
-def main(): # required entry point
- ...
- return
-
-def helper(a, b): # other functions
- ...
- return a * b
-```
-
-`import snark_lib` / `from snark_lib import *` are the only imports accepted; anything else is a compile error (no multi-file programs yet). Comments (`#`) and blank lines are free. Indentation is block structure, as in Python.
-
-Ordinary functions may return scalars, `HeapBuf` pointers, and `StackBuf` values, including mixtures in a tuple return. A returned `StackBuf(n)` has a compile-time-known size: its `n` cells are copied through `n` consecutive return slots and the caller binds the result as a new `StackBuf(n)`. A `HeapBuf` return is just its one-cell pointer; the allocation hint already ran where the buffer was created, so no size metadata needs to cross the call.
-
-## Public input
-
-Memory cells `m[0]` and `m[1]` hold the two public-input words, each an F192 machine word. A program *publishes* results by asserting them against those cells through the write-once heap store (the pointer `g^0` addresses absolute memory):
-
-```python
-p = GEN ** 0
-p[1] = result_a # m[p·1] = m[0], an equality assert against the public input
-p[GEN] = result_b # m[p·g] = m[1]
-```
-
-Test programs under `tests/programs/` declare the public input they expect with a top-of-file annotation of two constant elements (or omit it to run with two zeros); the generic harness `tests/py_source.rs` proves and verifies every program in the directory:
-
-```python
-# public_input: GEN ** 89, 101229015297003380629709256178361811305
-```
-
-## Global constants and placeholders
-
-Above the functions (after the optional `snark_lib` import) a program may declare **global constants**, top-level `NAME = `:
-
-```python
-from snark_lib import *
-
-N = 8 # an integer size / value
-STEP = GEN ** 2 # a g-power constant (index carried in the exponent)
-WIDE = N + 1 # compile-time INTEGER arithmetic (`+ - * / **`);
- # references to *earlier* constants are allowed
-
-def main():
- buf = StackBuf(N) # a constant is a plain literal: usable as a size,
- x = GEN ** N # a `**` exponent, a stack/slice index, an operand,
- assert log x < N # an `assert log _ < _` bound, or a `Const` argument
- return
-```
-
-Each constant is **evaluated as a compile-time integer expression** (or an `f192` literal, or a field-valued one such as `GEN ** 2`) and substituted as a single literal everywhere its name appears below, so unlike a `Const` parameter it needs no call site and works in every literal position. Integer arithmetic is the point: it is what makes a derived size come out right, as in `N_TWEAK_WORDS = 2 + CHAIN_STEPS * V + LOG_LIFETIME`. Constants must precede the `def`s and are resolved *before* variables, so a constant name is **reserved**: do not reuse it as a parameter or local name. (Syntactically, `N = 8` is just a Python module global.)
-
-**Placeholders** let a host fill values at compile time without editing the source. Any identifier may be mapped to replacement text before parsing (`parse_with_replacements`, taking a `BTreeMap`); the replacement is identifier-bounded (`FOO` does not touch `FOOBAR`). The idiom is a placeholder feeding a constant:
-
-```python
-V = V_PLACEHOLDER # with replacement "V_PLACEHOLDER" ↦ "128"
-LOG_LIFETIME = LOG_LIFETIME_PLACEHOLDER
-
-def main():
- ... # V is the constant 128 throughout
-```
-
-so one source template compiles at many sizes. An unfilled placeholder (no replacement, no matching constant) is a compile error, not a silent variable.
-
-### Constant arrays
-
-A global constant may be a **list literal**, `NAME = [a, b, c]`, of compile-time values (integers or field values, each a ``). Unlike a scalar constant it is **not** textually substituted; it is carried to lowering and consumed at compile time:
-
-```python
-QUERIES = [290, 177, 145] # or QUERIES = QUERIES_PLACEHOLDER, filled "[290, 177, 145]"
-Z = [Z0_PLACEHOLDER, Z1_PLACEHOLDER, Z2_PLACEHOLDER] # arbitrary field values
-
-def main():
- for lvl in unroll(0, len(QUERIES)): # len(NAME) is a compile-time count
- n = QUERIES[lvl] # NAME[i] with a compile-time index i
- row = buf[GEN ** QUERIES[lvl]] # (i a literal / constant / unroll var)
- ...
-```
-
-`NAME[i]` yields the element (as a field value in value position, or as an integer where an index / slice bound / `unroll` count / `**` exponent is expected), and `len(NAME)` its length. The index `i` must be compile-time (a literal, a constant, or an `unroll` variable). This is what lets one source file adapt to a per-level config vector (query counts, fold factors, sizes) without Rust-side code generation. Nested lists are not (yet) supported: flatten a 2-D table into one array plus an offsets array.
-
-## Functions
-
-```python
-def f(a, b):
- return a + b, a * b # multiple returns
-
-x, y = f(p, q) # tuple assignment
-z = f(p, q) # expression position: first return
-f(p, q) # statement: returns discarded
-```
-
-Functions may recurse. Each call gets a **fresh frame**: the frame pointer is prover-hinted (write-once memory makes an unconstrained cell prover-chosen), arguments and the return address/frame are stored with `DEREF`s, and control transfers with one `JUMP`. Cost: about `n_args + n_returns + 4` instructions per call. Every non-`main` function must end in an explicit `return`; in `main`, `return` is a no-op (main halts at a sentinel automatically).
-
-### `StackBuf` parameters
-
-```python
-def compress(cv: StackBuf(2), block: StackBuf(2)):
- out = StackBuf(2)
- blake2s(cv, block, out)
- return out
-```
-
-`s: StackBuf(n)` marks a parameter as a **run of n cells**, passed whole. The caller must pass a `StackBuf` of exactly that size (a whole named one: a slice is not yet accepted), and the run is copied into the callee's frame.
-
-Those cells arrive **already written**, unlike a local `StackBuf`'s, so a store into one is the write-once equality *assertion* rather than a fresh store. That is what makes a callee able to pin its caller's values: `s[k] = ` inside the callee asserts that the caller's cell already held it. It also means a run parameter initializes nothing, so passing a partly-written buffer passes its unwritten cells, which the prover then chooses, exactly as anywhere else.
-
-A fused `match` arm cannot pass one: the fused dispatch writes one cell per argument, so give such arms `Const` arguments and let each specialize instead, or make the callee `@inline`. This is otherwise the same mechanism a `StackBuf` **return** already used, in the other direction: the argument area is a width rather than a count, and a run occupies the cells its size asks for. Without it a two-cell value could come out of a function whole but only go in through a `HeapBuf` pointer or an `@inline` expansion, which grows the caller's frame at every call site.
-
-### `Const` parameters
-
-```python
-def hash_pair(buf, k: Const):
- h = StackBuf(2)
- blake2s(buf[k * 2:k * 2 + 2], buf[k * 2:k * 2 + 2], h)
- return h[0], h[1]
-```
-
-`k: Const` marks a **compile-time parameter**: the call site must pass a constant (an integer literal, `GEN ** k`, or a literal-bound name), and the compiler *specializes* the function per distinct constant tuple, a monomorphized copy (`hash_pair__L1`) with the parameter substituted as its literal, shared by every call with the same constants; only the runtime arguments are passed. Inside the body the parameter *is* the literal, so it works in compile-time positions: stack indexes, slice bounds. A function with a `Const` parameter is a template: it is never lowered itself. The idiomatic pairing dispatches a runtime index to a const-indexed helper:
-
-```python
-r = match(log(x), range(0, 4), lambda i: hash_pair(buf, i))
-```
-
-### `@inline`: inline a function at its call sites
-
-```python
-@inline
-def combine(a, b, k: Const):
- s = StackBuf(2)
- if k % 2 == 0: # a folded `if` (see below): baked per Const value
- s[0] = a
- else:
- s[0] = b
- s[1] = a + b
- return s[k % 2]
-```
-
-An `@inline` function is **expanded at each call site** instead of emitting a real call: no frame, no argument/return `DEREF`s, no call/return `JUMP`s. Its body must end with one top-level `return`. Builtins, ordinary calls, nested inline calls, `if`, and `unroll` are allowed; `mul_range` loops, `match`, tuple assignments within the body, and nested/early returns are rejected. Ordinary calls retain their own frames; nested inline calls expand recursively, with direct or indirect recursive expansion rejected.
-
-An `@inline` function may also **return a `StackBuf`**: the caller's binding aliases the returned cell run (zero copies), and `StackBuf` arguments alias likewise.
-
-An `@inline` call may also sit in **expression position**: embedded in arithmetic, as a store's RHS, or as a single-target `match` arm. An aliased return (a folded g-address) then materializes into a plain cell (free for a var; one `MUL` for a shifted pointer); a multi-cell `StackBuf` return still needs a `let` binding, since only a name can alias a cell run.
-
-An `@inline` `match` arm expands into the dispatching frame like any other call site, specialized on its `Const` arguments, so it can take a `StackBuf` and write the caller's cells directly. The arms of one `match` share their local cells, one of them running.
-
-Because the body runs in the *caller's* frame, a `Const` parameter whose `if`s fold (below) bakes straight-line, per-case code, the idiom for a `match` arm that must specialize on the arm value. The trade-off is frame cells: each call site gets its own copy, so `@inline` pays off for small, hot callees; inlining a large body at many sites grows the committed witness (more data memory), so it is opt-in, not automatic.
-
-## Variables
-
-Bindings are **immutable**: `x = e` names a fresh cell. Re-binding a name is allowed (it's a new cell; the old value is unaffected), but there is no mutation. Compound assignment (`+=`, `-=`, `*=`, `//=`, `%=`) is sugar for a re-binding: `x += e` desugars to `x = x + e`.
-
-A name bound to an integer literal (`x = 2`) additionally acts as a **compile-time index constant**, usable in stack indexes and slice bounds (see below). Any other re-binding clears that role.
-
-Two families of binding are folded and carried **virtually**, costing no instruction until used as a value:
-
-- **g-powers and shifted pointers**: a cursor like `s = s * GEN` or a pointer view `p = buf * GEN ** k`. The offset folds into the `DEREF` address of each access; only a scalar use materializes it.
-- **field constants**: a value built from literals / `GEN ** k` by field `+` and `*`, e.g. a running weight `w = w * CHAIN_LENGTH` in an unrolled loop. The arithmetic that advances it is compile-time (zero instructions); each use is one `SET` of the folded constant.
-A store into a stack cell is NOT virtual: `sa[k] = other` always emits. If the cell already holds a value the store is the write-once equality *assertion* below, which is what makes `s[k] = ` pin a hint and a pre-written `blake2s` output verify a digest; if it does not, the store is what gives the cell its value. The compiler tracks nothing to tell those apart, the machine's write-once memory being what distinguishes them.
-
-## Debugging
-
-`print(expr)` / `print("label", expr)` displays a value at witness generation (prover side only, with no constraints and nothing entering the transcript). The label defaults to the argument's source text; output goes to stderr as `[print] label = ...`, showing the decimal reading for small integers, `g^k` when the value is a small g-power (both when they overlap: `8 (g^3)`), or `c2:c1:c0` hex otherwise, from the most significant limb to the least significant. Each print costs one anchor instruction, so the witness differs from a print-free build: strip prints before benchmarking.
-
-## Memory
-
-All memory is **write-once**: a cell is set once; a second write of the same value is a no-op, of a different value a proof failure. This turns stores into equality assertions and is used throughout (publishing, `blake2s` outputs). Reading a cell nobody ever writes yields an unconstrained value (zero in witness generation): don't. Nor use a value before the store that gives it has run: witness generation runs forward, so the use sees zero and the run is rejected. Loading it early is fine: `x = hb[i]` before `hb[i] = v` makes `x` equal to `v`, provided `x` is used after the store.
-
-### `HeapBuf(n)`: heap buffers, indexed in the exponent
-
-```python
-buf = HeapBuf(4) # fresh, disjoint region; `buf` is its pointer (a g-power)
-buf[1] = 5 # m[buf·1] is cell g^0
-buf[GEN] = 7 # m[buf·g] is cell g^1
-v = buf[i] # m[buf·i], i any runtime g-power (e.g. a loop counter)
-buf[i * GEN] = v # the next cell along
-```
-
-The index is a field element; cell `k` of the buffer lives at address `buf · g^k`. A read or store is one `DEREF`. A **runtime** index costs one extra `MUL` for the `buf·i` pointer, but a **compile-time g-power** offset (`buf[1]`, `buf[GEN ** k]`, or a cursor advanced by `× GEN ** m`) folds into the `DEREF`'s address immediate for free: no `MUL`, no `SET`, and the cursor arithmetic itself vanishes (so a `× GEN` walk over consecutive cells is zero instructions).
-
-**Compile-time indices are bounds-checked.** When the whole index is a compile-time exponent and the pointer resolves to a declared `HeapBuf` (directly, or through shifted aliases like `row = buf * GEN ** k`), the compiler rejects `index >= size`, and the same for the spans of `hint_witness` and `blake2s` slices. **Runtime** indices are not checked (their value is unknown at compile time): there the buffer remains a region convention, and a stray access surfaces at proving time as a write-once conflict or wild deref.
-
-### `StackBuf(n)`: frame-cell runs, indexed by compile-time integers
-
-```python
-sa = StackBuf(3) # n consecutive cells of the current frame
-sa[0] = 3 # direct frame cell: no DEREF, but the store is an instruction
-sa[2] = sa[0] + sa[1]
-x = 1
-v = sa[x + 1] # indexes: literals, literal-bound names, and + * // % of those
-tg = [v, 7] # list literal: an initialized StackBuf, one cell per element
-```
-
-A **list literal** `x = [a, b, …]` is an initialized `StackBuf`: it allocates one cell per element and writes each element in place, exactly the alloc-then-store idiom above, in one line. Elements are arbitrary runtime expressions; each write goes through the same stack-store path. It exists only as the RHS of a plain assignment inside a function; a *top-level* `NAME = [...]` is a constant array (see "Constant arrays"). The elements are lowered before the name rebinds, so `s = [s[1], s[0]]` swaps through the old binding.
-
-Stack indexes and slice bounds are **compile-time integers**, and index arithmetic (`+ * // %`) is *integer* arithmetic (`x + 1` above is 2, `k // 2` floor-divides, `k % 2` is a remainder: index space, not the field, where XOR is what `+` means and `//`/`%` have no meaning at all: using one as a runtime field value is a compile error). Bounds are checked at compile time. A `StackBuf` name is a run of cells, not a scalar: using it as one is an error, and it cannot be captured into a `for` loop body (carry state through a `HeapBuf` instead).
-
-`p = addr(sb)` names the run's first cell as a **pointer** (`GEN ** k` times the frame pointer), so `p[i]` reads the same cells at a runtime index, `p` can be passed to a callee or stored, and `sb[k]` stays a direct frame cell throughout. Only valid as a whole right-hand side. It costs one materialization of `fp` per function (2 `DEREF`s, amortized with `if`'s; free in `main` and in loops with reserved frames), which is the price of the ISA having no fp-read. This is what lets a bit buffer live in the frame and still be walked by a `mul_range` loop.
-
-A runtime index through such a pointer is unchecked, as on the heap, but it fails more quietly: every frame cell is a real cell, so `p[i]` with a hinted `i` reaches any of them and usually neither faults nor conflicts. The program owes the range check itself (`assert log i < n`) wherever `i` is not a loop counter the compiler produced.
-
-### Slices: `buf[lo:hi]`
-
-`buf[lo:hi]` names a run of cells (`hi` exclusive). BLAKE2s operands must span exactly two cells; `hint_witness` accepts any supported literal length. Two forms:
-
-- **compile-time bounds** (integers, as for stack indexes): frame cells `base+lo .. base+hi` of a `StackBuf`, or heap cells `ptr·g^lo .. ptr·g^hi` of a `HeapBuf`, so `hb[2:4]` is the pair `g^2, g^3`;
-- **runtime start, heap only**: `buf[i:i + k]` with a runtime g-power index `i` (e.g. a loop counter) and literal length `k` names the cells `buf·i`, `buf·i·g`, and so on; one `MUL` folds `i` into the pointer. The `hi` bound cannot be evaluated, only shape-checked: it must be syntactically `lo + k` (`buf[b * GEN ** 2 : b * GEN ** 2 + 2]` is fine). A `StackBuf` slice cannot have a runtime start: frame offsets are baked into the bytecode operands.
-
-Note the two index spaces, consistent with plain indexing: compile-time bounds are integer exponents (`hb[2:4]` ≡ `hb[GEN ** 2 : GEN ** 2 + 2]`), runtime starts are g-power elements.
-
-## Control flow
-
-### `for i in mul_range(start, stop)`: loops in the exponent
-
-```python
-for i in mul_range(1, GEN ** 10): # i = g^0, g^1, …, g^9
- buf[i * GEN * GEN] = buf[i] * buf[i * GEN]
-```
-
-The counter walks multiplicatively: it starts at `start`, advances by `×GEN` each iteration, and stops on reaching `stop` (exclusive). The start is a compile-time power of `GEN` (`1`, `GEN`, or `GEN ** k`); the stop is either compile-time too (an empty range compiles to nothing) or a **runtime** g-power element, e.g. a hinted count:
-
-```python
-hint_witness(nb[0:1], "n_blocks")
-n = nb[0]
-assert log(n) < 16 # the walk terminates only by REACHING the bound:
-for j in mul_range(1, n): # bound its log first, or it never does
- ...
-```
-
-A runtime bound is evaluated once at entry and threaded through the loop as an extra parameter (+1 argument per iteration call); entry itself is the same `!=` test, so a bound equal to the start runs zero iterations.
-
-Lowering: the body becomes a tail-recursive helper function whose exit test is folded into the recursion's `JUMP` condition: one call per iteration, no separate is-zero gadget. Free variables of the body are captured **by value** as extra parameters; a `HeapBuf` pointer threads through fine, a `StackBuf` does not (compile error).
-
-The compiler reserves consecutive frames for a loop that has no early return and does not rebind its counter. Its back edge advances by the frame size and copies arguments directly into the next frame, while ordinary function calls and heap allocations remain disjoint from the reserved run. Each iteration still owns fresh write-once cells, so a pointer to an earlier iteration remains valid. Loops with an early return or counter rebinding keep incremental allocation.
-
-### `for i in unroll(a, b)`: compile-time unrolling
-
-```python
-for i in unroll(0, 7):
- sb[i + 1] = sb[i] * GEN # i is the integer literal of each copy
-
-def chain(buf, n: Const):
- for i in unroll(0, n): # a Const parameter as a bound
- blake2s(buf[i * 2:i * 2 + 2], buf[i * 2:i * 2 + 2], buf[i * 2 + 2:i * 2 + 4])
- return
-```
-
-The body is replicated `b − a` times with `i` substituted by each integer literal in turn, usable anywhere a literal is (stack indexes, slice bounds, `Const` arguments). Zero loop overhead: no call, no frame, no counter; the price is code size. Bounds are compile-time integer expressions, evaluated after `Const` specialization, so `unroll(0, n)` with `n: Const` works (unlike `mul_range`, whose bounds are parse-time literals). Every copy executes (this is straight-line code, not a branch), so bindings simply rebind, a fresh binding per iteration.
-
-### `if` / `elif` / `else`
-
-```python
-if x == GEN ** 3:
- r[1] = 5
-elif x != y:
- r[1] = 7
-else:
- r[1] = 9
-```
-
-Conditions are field-equality tests: `a == b` or `a != b` (there are no other predicates: order facts come from range-check asserts). The lowering is one `XOR` plus one conditional `JUMP` on it; the taken jump goes to whichever block the test doesn't fall into, so no negation gadget is needed. An `elif` is sugar for an `else` holding a nested `if`.
-
-When **both sides are compile-time integers** (e.g. after a `Const` parameter is substituted, `if k % 2 == 0:`), the condition is known at compile time and the `if` **folds** to just the taken branch: no `XOR`, no `JUMP`, no `self-fp`. This is what lets an `@inline` function bake different straight-line code per `Const` value. A side whose integer reading and field reading disagree (`3 + 1` is the integer 4 and the field element 2) is **rejected** rather than folded either way, since the fold and a runtime test of the same condition would answer differently; write `if const(...)` below to decide it with integer arithmetic. Note that the rejection is per side, so it fires however the OTHER side is spelled.
-
-Two write-once-flavored rules:
-
-- **bindings made inside a branch are local to it**: the compile-time scope reverts at the join. Branches communicate through memory: only one branch executes, so both may write the *same* cell (`r[1]` above), and the join reads it.
-- a cell nobody wrote (e.g. skipped-branch territory) stays unconstrained, the same rule as everywhere else in write-once memory.
-
-Local jumps must carry the frame pointer, which the ISA cannot read directly; each branching function materializes its own `fp` once (2 `DEREF`s through a 1-cell heap bounce; free in `main`, where `fp = g^0 = 1`).
-
-### `match`
-
-```python
-r = match(log(x), range(0, 6), lambda j: f(j))
-a, b = match(log(x), range(0, 2), lambda j: g(1), range(2, 6), lambda j: g(j))
-```
-
-The one dispatch construct. It matches the **log** of a g-power scrutinee against integer arms, which must cover consecutive integers from 0 (the dispatch table is dense; there is no default arm). Arm `j` is the lambda body with the parameter replaced by the **integer literal** `j`, usable as a field constant or a compile-time index, expanded at parse time over the contiguous `(range, lambda)` pairs. The whole call sits on one line, there being no line continuation.
-
-Arms produce VALUES: every arm writes its results into the same cells, which is sound under write-once because exactly one arm runs. A target may be a name, bound after the join, or a **`StackBuf` element**, which the arms write into directly and which costs one instruction less than a name plus a store. The ABI returns into cells the CALLER picks, the same reason `sb[i] = f(x)` never needed a temporary, so reach for the element form wherever a returned value's home is a buffer slot. A target index must be a compile-time integer inside the buffer, both errors naming the line; a `HeapBuf` element is not a target, its cells not being frame cells. Multiple targets take a multi-return call as the arm body.
-
-A branch body with statements in it goes in a function, and the arm calls it. A plain function fuses (below), so each taken arm still pays the shared frame's argument and return plumbing, and cannot take a `StackBuf`. An `@inline` one expands into the dispatching frame instead: no frame, no plumbing, and its `StackBuf` arguments alias, so an arm can hash or hint straight into the caller's buffers (the XMSS chain walk in the recursion guest, `lambda k: walk(chain_start, chain_tweaks, pp, md, tips, i, k)`). The price is code size, a copy of every arm at each `match`.
-
-**Lowering** is two jumps through a *trampoline table* in the bytecode: the dispatch jumps to `g^T · x²`, the j-th two-instruction slot (`SET` the arm's address, `JUMP` to it) of a table at base `T`, and the slot jumps to the arm, which can sit anywhere, unaligned and of any length. Cost is about 7 cycles, independent of the arm count.
-
-(Why not leanVM's single-jump `pc = a + b·x`: that affine address needs integer *scaling* by the common block size `b`, which in the exponent becomes `x^b`, log₂ b squarings, plus padding every block to the longest; the trampoline collapses the aligned region to 2-instruction slots, so the scaling is the single squaring `x²`. Other layouts exist, e.g. a memory-resident address table dispatched with a single jump, worthwhile for many repeated small matches, but only the trampoline is implemented.)
-
-**Soundness**: nothing in the dispatch bounds `x`, so a scrutinee outside `[0, n)` jumps to an arbitrary pc. A hinted value must be range-checked first (`assert log(x) < n`, 3 cycles), as in leanVM.
-
-**Dispatched-call fusion.** When *every* arm is a call to the same function with identical runtime arguments (the common `lambda k: f(a, b, k)`, where only a `Const` argument varies), the compiler builds the callee frame **once** and the dispatch jumps straight into the selected specialization's entry, which returns past the join. Each taken arm is then just the trampoline's two instructions (`SET entry; JUMP`) instead of a full call: no per-arm frame setup, call jump, or return jump. An `@inline` callee does not fuse: it expands, as above.
-
-Statements without effect are rejected.
-
-### `if const(...)`: a branch decided while compiling
-
-```python
-if const(level + 1 == DEPTH): # decided now, with integer arithmetic
- tail = 0
-```
-
-Wrapping a condition in `const(...)` asks for the branch to be decided while compiling. Two things follow. The condition must be decidable then, so both sides must be compile-time integers, and a runtime one is an error rather than a silent fallback to a runtime test. And it is read with **integer** arithmetic, the regime a compile-time constant lives in, which is what makes `const(...)` the answer when a condition's two readings disagree (see "The field, and indices in the exponent").
-
-A folded branch emits no test and no jump, and its body is straight-line code, so **its bindings outlive it** where a runtime branch's are branch-local. That is the other reason to reach for the wrapper: it states that the arm's bindings are meant to escape.
-
-A plain `if` still folds on its own when both sides are compile-time integers and neither side's two readings disagree, so the wrapper is needed only where one does, where the condition is decidable only in the field (`GEN ** 3 == GEN ** 3`, which a plain `if` lowers to a real runtime branch), or where you want the compiler to insist.
-
-### `const(...)` in a value position
-
-```python
-tweak = TW_NODE + const((level + 1) * P_MUL) + tau # (level+1)*P_MUL as integers
-```
-
-The same wrapper, the same meaning: read this with **integer** arithmetic and emit the literal. It is needed because `+` in a value position is XOR, so `level + 1` with `level = 3` is 2 rather than 4, and silently: the value is well-formed, just not the one the arithmetic reads like. `-`, `//` and `%` have no field meaning at all, so `const(...)` is the only way to write them in a value position.
-
-The inner expression must be a compile-time integer (a literal, a global constant, a `Const` parameter, an `unroll` counter, a name bound to one, a constant-array element, and `+ - * // % **` of those), and one that is not says so rather than falling back to a runtime computation. The result is one pooled `SET`, so a repeat costs nothing.
-
-In a position that is ALREADY integer arithmetic (a size, a count, an exponent, a bound, a stack index, a global constant) the wrapper is transparent: it asks for the only reading there is, so it changes nothing and is allowed rather than redundant. Where it earns its keep is a value, a condition, and anywhere `-`, `//` or `%` has to appear.
-
-The wrapper reinterprets the **operators**, not the leaves, and that is the whole of its meaning. Two consequences. A leaf whose own two readings disagree is rejected rather than silently read one way, so `n = 2 + 3` (the cell holds `2 XOR 3` = 1, the name's integer reading is 5) may not appear inside one: bind it in one regime and name that one. And the arithmetic runs on a leaf's **bit pattern**, so an element of a field-valued constant array is read as the integer those bits spell, which is not what field arithmetic on it would give: `const(TABLE[i] * 2)` doubles the bit pattern where `TABLE[i] * 2` is a field product.
-
-## Assertions
-
-### `assert a == b`
-
-A proof-enforced equality: 1 cycle (`XOR` into the frame's zero cell, whose write-once double write is the assert).
-
-### `assert a != b`
-
-A proof-enforced inequality, in **3 instructions and no branch**: `XOR` for `x = a + b`, a prover-hinted `inv = x⁻¹`, then `MUL p = x·inv` and `SET p = 1`, where the write-once conflict is the assertion, exactly as for `assert a == b`. It is sound because `x = 0` forces `p = 0` whatever the prover hints, and `p` cannot then also be `1`; the hint needs no checking of its own, which is why an unconstrained value is safe here. Since there is no `JUMP` there is no self-frame or branch setup to amortize either. A compile-time assertion such as `assert 5 != 5` is rejected while compiling.
-
-### Range checks: `assert log x < log Y` and `assert log x < k`
-
-The *range check in the exponent*: proves `x ∈ {g^0, g^1, …, g^{k-1}}`, i.e. `log_g(x) < k`. A compile-time bound is either `log GEN ** k` or a plain integer exponent `k`, with `1 ≤ k ≤ 2^16` (the minimum memory size, which keeps the gadget provable at every memory size the prover may announce). `log x` and `log(x)` both parse; the parenthesized form is the valid-Python spelling. A bare `assert x < y` is rejected: field elements have no order, only their logs do.
-
-```python
-assert log(x) < log(GEN ** 8)
-assert log(x) < 8 # the same check
-assert log(x) < log(n) # n = g^k runtime: same gadget, +1 cycle
-```
-
-A **runtime** bound costs one extra `MUL` for `g^{k-1} = n·g⁻¹` and is otherwise identical, except that the `k ≤ 2^16` cap becomes the program's to enforce: range-check the bound itself first, with `assert log n < 2^16`. That check is not optional: without it the gadget is unsound.
-
-Cost: **3 cycles** (leanVM's DEREF range-check trick, in the exponent) plus one amortized `SET` per distinct bound per frame:
-
-1. `DEREF` through `x`: the dereferenced address must be one of the memory's `2^h` g-power addresses, so the memory bus itself proves `x = g^e`, `e < 2^h`;
-2. `MUL x·y` into the write-once cell holding `g^{k-1}`: the runner back-solves the complement `y = g^{k-1-e}` (the one unknown operand of a known product), and the double-write asserts `x·y = g^{k-1}`;
-3. `DEREF` through `y`: bounds the complement; a "negative" `k-1-e` would wrap to `≈ 2^64`, far beyond any memory size, so together `e ≤ k-1`.
-
-The two `DEREF` target cells are unconstrained touches, back-filled at the end of execution. A failing check surfaces at witness generation as the complement's `DEREF` panic ("not a small g-power … a failed range check").
-
-## K membership and packing
-
-```python
-assert_in_k(lo, hi)
-```
-
-`assert_in_k(a, b)` is the sole packing-related compiler intrinsic. It proves that both source memory words are in the base field GF(2^64) with one untaken `JUMP`: its condition is a known zero, while its destination and frame operands are `a` and `b`. Although neither value affects the successor state, both memory reads carry literal-zero upper limbs, so a source outside GF(2^64) cannot balance the memory permutation.
-
-Packing is ordinary zkDSL built on that assertion:
-
-```python
-@inline
-def pack64x2(a, b):
- assert_in_k(a, b)
- return a + f192(0, 1, 0) * b
-```
-
-The inline helper takes three cycles, one `JUMP`, one `MUL` and one `XOR`, and returns the canonical 128-bit packing `(a.c0, b.c0, 0)`. Assignment-target lowering writes its return directly into the destination, including an already-written cell whose second write is an equality assertion. A caller needing only membership uses `assert_in_k` directly and pays no packing arithmetic.
-
-The recursion transcript uses `challenge_from_state(state)` to reinterpret the first three 64-bit lanes of a canonical two-cell BLAKE2s digest as one extension field challenge. For `state = [s0, s1]`, it lowers exactly as follows (the limb hints cost no cycles, but are not trusted):
-
-```python
-d2 = StackBuf(1)
-hint_f192_limbs(d2, state[1])
-d3 = (state[1] + d2[0]) * Y_INV
-assert_in_k(d2[0], d3)
-challenge = state[0] + d2[0] * f192(0, 0, 1)
-```
-
-Both state words are BLAKE2s outputs, so their top limbs are already zero. Only `d2` must be exposed separately; deriving `d3 = (s1+d2)/Y` and proving both values lie in GF(2^64) binds the one hinted limb by uniqueness of the tower representation. The challenge is `s0+d2·Y² = d0+d1·Y+d2·Y²`, while `d3` is checked but deliberately discarded. `challenge_from_state` is not a compiler intrinsic: this is the complete `@inline` helper used by the recursion guest.
-
-## BLAKE2s
-
-```python
-h = StackBuf(2)
-blake2s(a, b, h) # digest of (a, b) written into h
-blake2s(t[0:2], t[x:x + 2], t[4:6]) # slices of one large StackBuf
-blake2s(h, hb[0:2], hb[2:4]) # HeapBuf slices, input and output
-blake2s(hb[i:i + 2], h, hb[j:j + 2]) # runtime-indexed heap slices (i, j g-powers)
-
-# A standard 80-byte hash as two blocks. Keyword values are compile-time.
-block0 = [1, 2, 3, 4] # 64 bytes
-tail = [5, 0, 0, 0] # 16 more, the rest of the block zero-filled
-blake2s(block0[0:2], block0[2:4], cv, counter=64, final=0)
-blake2s(tail[0:2], tail[2:4], out, cv=cv, counter=80, final=1)
-
-# The same, with the second block's metadata computed at run time.
-blake2s(tail[0:2], tail[2:4], out, cv=cv, md=high + f192(16, 4294967295, 0))
-```
-
-The three positional arguments form a **statement**: one standard BLAKE2s compression consumes the two 256-bit message operands `a`, `b` (64 bytes) and writes its 32-byte result into the 2-cell run `out`. With no keywords it computes the standard hash of exactly 64 bytes: the parameterized BLAKE2s-256 initial chaining value (digest length 32, unkeyed, fanout and depth 1), byte counter 64, final-block flag `f0` set. That is `blake2s(a || b)`, the form every Fiat-Shamir step and Merkle node uses.
-
-Every compression also has a 256-bit chaining value and a 128-bit metadata word. The optional keywords are:
-
-- `cv=`: a consecutive 2-cell chaining value, the previous block's output; omitting it selects the parameterized IV above. On each runtime path, a function emits two `SET`s at its first such hash only and reuses those cells thereafter. Supplying `cv=` also requires one of the four below, since a chained block is never the default one-block hash;
-- `counter=`: BLAKE2s's byte counter `t`, **cumulative** through this block, so `64 * whole_blocks_before + bytes_in_this_block`. Defaults to 64;
-- `final=<0|1>`: BLAKE2s's final-block flag `f0`. It defaults to 1 for the bare three-argument call, but to **0** as soon as `counter=` or `last_node=` appears, so a chained hash must set `final=1` on its last block and a single short block needs `counter=, final=1`. Any compile-time expression works, nonzero meaning set, which is what lets the guests write a predicate like `final=(q + 1) // BLOCKS_PER_HASH`;
-- `last_node=<0|1>`: BLAKE2s's tree-mode flag `f1`. Defaults to 0, and nothing here uses tree mode;
-- `md=`: the whole 128-bit metadata word, as a value the program computed, for a hash whose block count is only known at run time. It replaces the three keywords above (giving both is an error) and it owes the same canonical embedding as every other operand, its top limb being read as a literal zero. The cheap way to build one is the disjoint-bit split of `doc/leanvm` §Byte counters for a hash of runtime length: XOR a runtime high part against a compile-time `metadata(64·j, f0, f1)` constant, one instruction per block.
-
-The metadata is packed as `counter:u64 | f0:u32 | f1:u32`, little-endian, into one memory cell the instruction reads, like every other operand. With compile-time keywords that cell is one pooled `SET`: a frame emits it once per distinct metadata value, however many compressions read it, and the immediate that wrote it is public bytecode. There is no block-length field: the counter is what states how many of the 64 bytes are message, so only the last block may be partial and the program must zero-fill the bytes past its real length, which the compression circuit does not enforce. A multi-block hash therefore feeds each result back with `cv=`, advances `counter=` by the bytes actually absorbed, and sets `final=1` on the last block.
-
-Operands are size-2 `StackBuf`s or 2-cell slices:
-
-- an **input operand written as a list**, `blake2s([a, b], [c, d], out)`, names its two words directly and allocates nothing: the opcode addresses its four input chunks independently, so an operand whose words live in different places never has to be gathered into a consecutive run. This is the spelling to reach for instead of `p = StackBuf(2); p[0] = a; p[1] = b`;
-- **stack operands** are read in place, at zero copies; a self-hash `blake2s(h, h, out)` names one 2-cell pair as both inputs;
-- the instruction addresses its **four canonical 128-bit message chunks independently** (each is a full F192 memory cell constrained at this use to the BLAKE2s subspace `c2 = 0`), so an operand gathered into a buffer (`p = StackBuf(2); p[0] = t0; p[1] = t1; blake2s(p, …)`) costs one instruction per assembling store, which the list form above avoids entirely;
-- the chaining value has only one opcode offset and therefore must be consecutive. If a 2-cell `cv` was assembled from non-adjacent copied cells, the compiler materializes those two cells into a fresh consecutive run;
-- **heap slices** are still bridged through the stack for the *input pull* (the operand's words come from the heap): +1 `DEREF` per heap cell, and the output, if a heap slice, is stored after: write-once memory fills whichever side is unset.
-
-If `out` was already written, the statement *asserts* the digest equals it, write-once turning the hash into a verification, which is exactly what a signature verifier wants.
-
-The compression, including its chaining value and metadata, is proven by the flock-derived BLAKE2s R1CS (`crates/flock`, see `doc.pdf` §BLAKE2s); one instruction is one 64-byte-block compression.
-
-## Hints: `hint_witness(dest, "name")`
-
-```python
-sb = StackBuf(2)
-hint_witness(sb, "r") # fill the whole StackBuf
-hint_witness(hb[0:3], "h") # or any StackBuf/HeapBuf slice (any length)
-assert log(sb[0]) < 8 # hinted values are UNCONSTRAINED: pin them down
-```
-
-A single hinted value needs no destination at all:
-
-```python
-m = hint_witness("m") # one value, bound to a name
-assert log m < 8 # still unconstrained: pin it
-```
-
-which is the one-line form of allocating a `StackBuf(1)`, filling a slice of it, and reading the cell back out, and costs exactly the same (nothing). Everything below about a stream's entries applies to it: each such binding pops one entry, whose length must be 1.
-
-Prover-supplied data (leanVM's `hint_witness`): a stream is a sequence of **entries**, one slice of values per `hint_witness` call, and the same symbol may be hinted many times. Each call pops the stream's next entry (whose length must match the destination run) and writes it into `dest` through the hint mechanism, at **zero cycles**. The values are completely unconstrained; the program must constrain them itself (asserts, range checks, hashes): an unconstrained hint consumed by anything security-relevant is a critical vulnerability. Runtime-start heap slices (`buf[i:i + k]`, `k` a literal) work too.
-
-The prover supplies streams with `program.set_witness("name", entries)` (`Vec>`); test programs declare them as annotations, one line per entry, and repeated lines with the same name are its successive entries:
-
-```python
-# witness r: GEN ** 5, 12
-# witness r: 9
-```
-
-### Computed-advice hints
-
-Three builtins have the prover compute the values at witness generation instead of popping a stream entry. Like `hint_witness`, the results are completely unconstrained: the program must re-verify them in-circuit.
-
-- `hint_decompose_bits(bits, value, nbits)`: writes the low `nbits` bits of `value` into the buffer `bits`, one field element (`0`/`1`) per bit.
-- `hint_decompose_bits_exponent(bits, x, nbits)`: writes the `nbits` bits of the exponent `n` where `x = GEN ** n` into `bits` (a bounded dlog at witness generation).
-- `g = hint_log2_ceil(bits, nbits, floor)`: returns `GEN ** log2_ceil(v)` for the value `v` held bitwise in the `nbits`-bit buffer `bits`, floored at `floor`.
-
-`bits` is a `HeapBuf` or a `StackBuf` (of at least `nbits` cells). Prefer the `StackBuf`: a frame cell is addressed directly, so `bits[i]` at a compile-time index is free where a heap read is a `DEREF`, and the booleanity pin `bits[i] = b * b` is then one `MUL` rather than a `MUL` and a `DEREF`. Use `addr` below where the run must also be indexed at runtime or reached from elsewhere.
-
-## Cost cheat sheet
-
-| construct | instructions |
-|---|---|
-| `x = ` / `GEN ** k` | 1 `SET` |
-| `a + b` | 1 `XOR` |
-| `a * b` | 1 `MUL` |
-| `a / b` | 1 `MUL` (write-once back-solve; division by zero is undefined) |
-| heap read / store `buf[i]` | 1 `DEREF`; +1 `MUL` for a *runtime* index (a compile-time g-power offset folds into the `DEREF`, for free) |
-| stack read `sa[k]` | 0 (direct cell addressing); a *store* is 1, like any other write |
-| `assert a == b` | 1 (+ 1 `SET` amortized per frame for the zero cell) |
-| `assert a != b` | 3 (`XOR`, `MUL`, `SET`), no branch, one hinted inverse |
-| `assert log x < k` | 3 (+1 `SET` amortized per bound per frame; a runtime bound costs 1 `MUL` instead) |
-| `if a == b: …` | 3 (+2 to skip a non-empty `else`; +2 amortized `self-fp` per branching function); **0 if the condition is compile-time** |
-| `… = match(log(x), …)` | ≈ 7 for the dispatch + the arm; results written into the targets directly. Uniform-call arms (`lambda k: f(a, b, k)`) **fuse**: one shared frame + dispatch to entry, each arm just `SET`+`JUMP`; `@inline` arms run in place, with no frame |
-| function call | ≈ `n_args + n_returns + 4` (0 when the callee is `@inline`) |
-| `mul_range` iteration | body + ≈ 1 `MUL` + 1 `XOR` + call overhead |
-| `unroll` iteration | body only (compile-time replication) |
-| `blake2s(a, b, out, ...)` | 1; plus one `SET` once per frame per distinct metadata value (nothing with `md=`, which costs whatever building the word costs), and two more when `cv` is omitted; message/CV words are read in place, +1 `DEREF` per heap input or CV word, +1 `MUL` per runtime slice start |
-| `hint_witness(dest, "name")` | 0 (+1 `MUL` for a runtime slice start) |
-
-Every cost above is the FIRST occurrence. Two identical pure operations in one function share one cell and the second is free, so `hb[i]` twice, or `row[i]` where `row = hb * GEN ** 2`, costs one pointer `MUL` between them. The sharing stops at a branch: a cell whose instruction sits inside an `if` is not reused after the join, because the other path leaves it unwritten and therefore prover-chosen.
-
-## Example
-
-Fibonacci in the exponent (`tests/programs/fibonacci.py`): `fib[g^k]` holds `GEN ** F_k`, so one field `MUL` is one Fibonacci step.
-
-```python
-# public_input: GEN ** 89, GEN ** 89
-from snark_lib import *
-
-
-def main():
- fib = HeapBuf(12)
- fib[1] = GEN ** 0 # F_0 = 0
- fib[GEN] = GEN # F_1 = 1
- for i in mul_range(1, GEN ** 10):
- fib[i * GEN * GEN] = fib[i] * fib[i * GEN]
- out = fib[GEN ** 11]
- assert out == GEN ** 89 # F_11 = 89
- assert log(out) < log(GEN ** 128)
- p = GEN ** 0
- p[1] = out
- p[GEN] = out
- return
-```
-
-## Not (yet) supported
-
-Mutable variables; conditions other than field (in)equality; `match` default and non-contiguous arms; multi-file imports; `Const` parameters as `mul_range` or range-check bounds (a substituted literal is a bit-pattern element, not the g-power a bound needs); runtime slice starts on a `StackBuf`; precompiles beyond `BLAKE2s`.
diff --git a/crates/lean_da/Cargo.toml b/crates/lean_da/Cargo.toml
deleted file mode 100644
index 79c118ad7..000000000
--- a/crates/lean_da/Cargo.toml
+++ /dev/null
@@ -1,19 +0,0 @@
-[package]
-name = "lean_da"
-version.workspace = true
-edition.workspace = true
-publish = false
-
-[lints]
-workspace = true
-
-[dependencies]
-primitives.workspace = true
-parallel.workspace = true
-pcs.workspace = true
-fiat_shamir.workspace = true
-serde.workspace = true
-tracing.workspace = true
-
-[dev-dependencies]
-rand.workspace = true
diff --git a/crates/lean_da/src/commit.rs b/crates/lean_da/src/commit.rs
deleted file mode 100644
index 8e8d594f8..000000000
--- a/crates/lean_da/src/commit.rs
+++ /dev/null
@@ -1,223 +0,0 @@
-//! Two commitment branches over the same cell digests.
-//!
-//! A row digest hashes its first `k/c` cell digests, covering the systematic
-//! payload. A Merkle tree over these digests gives `root_row`.
-//! A second tree has all cell digests as leaves, in column-major order; its
-//! intermediate column roots authenticate samples, and its root is `root_col`.
-//! The final commitment is `H(root_row, root_col)`.
-
-use fiat_shamir::merkle::{Hash, hash_pair};
-use primitives::hash::{OUT_LEN, hash, hash_many_dyn};
-
-use crate::{CELL_SYMBOLS, CELLS_PER_ROW, CODEWORD_SYMBOLS, PAYLOAD_CELLS, encode_rows, row_count};
-
-/// What the builder publishes.
-#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
-pub struct DaCommitment {
- /// `H(root_row, root_col)`, binding the matrix including its zero padding.
- pub root: Hash,
- pub root_row: Hash,
- pub root_col: Hash,
-}
-
-/// What the builder keeps, to answer samples and to feed the proof.
-pub struct DaWitness {
- /// `n_rows · m` symbols, row-major. Padding rows are zero and not stored.
- pub codewords: Vec,
- /// `n_rows · ℓ` cell digests, row-major.
- pub cell_digests: Vec,
- /// The flat tree over the column-major cell digests; see the module docs.
- pub column_tree: Vec,
- /// The flat tree over the `n_padded` row digests.
- pub row_tree: Vec,
-}
-
-impl DaWitness {
- /// The `ℓ` column roots `C_j`, the level at height `log n_padded`.
- pub fn column_roots(&self) -> &[Hash] {
- let (n_pad, cells) = (
- row_count(self.codewords.len(), CODEWORD_SYMBOLS).next_power_of_two(),
- CELLS_PER_ROW,
- );
- let offset = 2 * n_pad * cells - 2 * cells;
- &self.column_tree[offset..offset + cells]
- }
-}
-
-/// Commit to payload rows of little-endian 64-bit symbols and their systematic encoding.
-#[tracing::instrument(name = "Commit", skip_all)]
-pub fn commit(rows: &[u64]) -> (DaCommitment, DaWitness) {
- let codewords = encode_rows(rows);
- commit_codewords(codewords)
-}
-
-/// [`commit`] over rows that are already encoded.
-pub fn commit_codewords(codewords: Vec) -> (DaCommitment, DaWitness) {
- let n_rows = row_count(codewords.len(), CODEWORD_SYMBOLS);
- let (cells, n_pad, t) = (CELLS_PER_ROW, n_rows.next_power_of_two(), PAYLOAD_CELLS);
- let cell_digests = hash_cells(&codewords);
- let (padding_cell, padding_row) = padding_digests();
-
- // Row branch: hash each payload's cell digests.
- let mut prefixes = vec![Hash::default(); n_rows * t];
- parallel::chunks_mut(&mut prefixes, t, |i, prefix| {
- prefix.copy_from_slice(&cell_digests[i * cells..i * cells + t]);
- });
- let mut row_digests = vec![padding_row; n_pad];
- hash_many_dyn(
- prefixes.as_flattened(),
- t * OUT_LEN,
- row_digests[..n_rows].as_flattened_mut(),
- );
- drop(prefixes);
- let row_tree = tree_from_leaves(row_digests);
-
- // Column branch: the same digests, column-major, one tree carrying both levels.
- let mut column_major = vec![Hash::default(); cells * n_pad];
- parallel::chunks_mut(&mut column_major, n_pad, |j, column| {
- for (i, slot) in column.iter_mut().enumerate() {
- *slot = if i < n_rows {
- cell_digests[i * cells + j]
- } else {
- padding_cell
- };
- }
- });
- let column_tree = tree_from_leaves(column_major);
-
- let (root_row, root_col) = (*row_tree.last().unwrap(), *column_tree.last().unwrap());
- let commitment = DaCommitment {
- root: hash_pair(&root_row, &root_col),
- root_row,
- root_col,
- };
- let witness = DaWitness {
- codewords,
- cell_digests,
- column_tree,
- row_tree,
- };
- (commitment, witness)
-}
-
-/// Shape-dependent padding digests: the zero cell and its repeated digest for a row.
-pub fn padding_digests() -> (Hash, Hash) {
- let cell = hash(&[0u8; CELL_SYMBOLS * size_of::()]);
- (cell, hash([cell; PAYLOAD_CELLS].as_flattened()))
-}
-
-/// `e_{i,j} = H(W_{i,j})` for every cell of every real row, row-major.
-#[tracing::instrument(name = "Hashing cells", skip_all)]
-fn hash_cells(codewords: &[u64]) -> Vec {
- let (cells, m) = (CELLS_PER_ROW, CODEWORD_SYMBOLS);
- let n_rows = codewords.len() / m;
- let mut digests = vec![Hash::default(); n_rows * cells];
- parallel::chunks_mut(&mut digests, cells, |i, row| {
- hash_many_dyn(
- as_bytes(&codewords[i * m..(i + 1) * m]),
- CELL_SYMBOLS * size_of::(),
- row.as_flattened_mut(),
- );
- });
- digests
-}
-
-/// The flat Merkle tree over leaves that are already digests: `tree[..n]` is the
-/// leaves, then each level in turn, the root last. Unlike [`pcs::merkle`] the
-/// leaves are not re-hashed, since a cell digest is already the leaf.
-fn tree_from_leaves(mut tree: Vec