_ _ ___ ___ _ __(_) |__ ___ / __|/ __| '__| | '_ \ / _ \ \__ \ (__| | | | |_) | __/ |___/\___|_| |_|_.__/ \___|
Getting Started · Usage · Proven Gadgets · Architecture · Why
Zero-knowledge circuits are notoriously hard to get right: a single under-constrained gate is a soundness bug that no amount of testing reliably catches. scribe turns the soundness of a ZK gadget into a theorem and proves it using a large language model to write the proof and the Lean 4 kernel to check it.
Important
The Lean kernel is the oracle, not the LLM. proof-pilot builds the Lake project and then compiles the exact target file. The real guarantee is an #audit_axioms gate compiled next to every theorem: it runs the kernel's own axiom tracking and fails the build unless the proof's axiom set is a subset of {propext, Classical.choice, Quot.sound}. So sorry, admit, axiom, or native_decide cannot survive — not even reached transitively through a dependency. Fast textual scans in proof-pilot, a pre-commit hook, and CI are a pre-filter; the axiom gate is the source of truth.
Three properties make this trustworthy:
- Sound by construction. Every proof in this repo is checked by the Lean 4 kernel, and an
#audit_axiomsgate next to each theorem runs the engine behind#print axiomsat build time, rejecting anything that depends on more than the three standard axioms. That catches a transitivesorryAx(whatsorry/admitelaborate to) ornative_decide'sofReduceBoolreached through any dependency — which a textual grep cannot. SeeAudit.lean. - Specs audited, not just proofs. A kernel-checked proof of a meaningless statement proves nothing, so a dual-sided verdict engine probes the statements themselves at build time:
#audit_uses/#audit_requires(C1) fail the build on decorative or dropped hypotheses;#audit_falsifiable(C2) requires a kernel-checked refutation of the conclusion at a finite-model instantiation, killing vacuously-true specs;#audit_satisfiable(C3) requires a witness satisfying every constraint, killing impossible-antecedent specs. A theorem passing C1–C3 has demonstrated content: its constraints can hold, its conclusion can fail, and its side-conditions are load-bearing.scribe refute(C4) completes the dual side: an adversarial LLM loop attacks the spec itself, hunting for a kernel-checked counterexample — so a spec is only trusted after surviving the same class of refutation search that catches under-constrained circuits. - Automated. An LLM drives a closed feedback loop, edit the proof, compile, read the errors (or structured LSP goal states), repeat until the kernel accepts or the budget runs out.
- Real circuits. The
halva-bridgeconsumes actual Halva-style halo2 extraction output and proves soundness against a human-written specification.
scribe is a small Rust workspace. The pipeline runs IR → Lean scaffold → LLM proof loop → kernel-accepted .lean:
gadget-ir: a minimal IR for polynomial constraints over a prime field (TOML → struct).lean-emit: reads the IR and emits a Lean 4 file with the theorem statement and asorry.proof-pilot: drives an LLM backend in a loop: edit the proof → compile the target file → read the errors → repeat. Stops when the kernel accepts or the budget is exhausted.halva-bridge: combines a Halva halo2 extraction with a user specification and (optionally) sends the resulting theorem toproof-pilot.scribe-cli: the top-levelscribebinary, withverify,init, anddemosubcommands.
The published image bundles the Rust binaries, the Lean toolchain, and pre-cached Mathlib oleans, so there is no ~30-minute cold Mathlib build.
# five-minute demo: dry-run the range-check gadget proof (no API key needed)
docker run --rm ghcr.io/lucidsamuel/scribe:latest scribe demo
# verify a Halva extraction against a user spec (requires ANTHROPIC_API_KEY)
docker run --rm \
-e ANTHROPIC_API_KEY="$ANTHROPIC_API_KEY" \
-v "$(pwd)":/workspace \
ghcr.io/lucidsamuel/scribe:latest \
scribe verify \
--halva-output /workspace/extracted.lean \
--spec-file /workspace/spec.lean \
--output /workspace/Proof.leanSee the Docker section for tags and local builds.
Note
The Rust workspace builds with a stable toolchain. The Lean side requires elan (the Lean version manager); the pinned toolchain is leanprover/lean4:v4.30.0-rc2.
# Rust workspace
cargo check --workspace
cargo test --workspace
# Lean proofs
curl https://raw.githubusercontent.com/leanprover/elan/master/elan-init.sh -sSf | sh # install elan (mac/linux)
cd lean
lake exe cache get # fetch pre-built Mathlib oleans
lake build # should exit 0 with no warnings — every gadget proof is completeTip
Running scribe, proof-pilot, or halva-bridge against a live model needs a backend. The default backend shells out to the claude CLI; pass --backend openai|anthropic|... plus --api-key / --model to use a hosted API instead. Run any binary with --help for the full flag list.
The single-command path from a Halva extraction (or extractor project) to a kernel-checked proof.
# from a pre-extracted Halva .lean file + a user spec snippet
scribe verify \
--halva-output extracted.lean \
--spec-file spec.lean \
--output lean/ZkGadgets/MyCircuit.lean \
[--backend claude|openai|...] \
[--model MODEL] \
[--max-iters N] \
[--notes NOTES.md] \
[--save-transcript session.json]
# from a Halva extractor project directory (runs the extractor, then verifies)
scribe verify \
--circuit path/to/halva-extractor-project \
--spec-file spec.lean \
--output lean/ZkGadgets/MyCircuit.leanBoth forms run proof-pilot by default and exit 0 only when the Lean kernel accepts the proof. The output .lean file is the artifact, and every generated scaffold carries an #audit_axioms gate next to its theorem — so the file only ever compiles once the proof is real and rests solely on the three standard axioms. Pass --no-prove to emit only the scaffold (which still contains sorry, and therefore fails the build until proven) and skip the proof loop. The gate's command is provided by scribe's own lean/ project; pass --no-audit-gate if the scaffold targets a Lake project without the ZkGadgets library (at the cost of the unproven-scaffold-is-red guarantee).
Note
Scope. scribe verify --circuit operates on a Halva extractor project — you still author the small extractor program that runs against your halo2 circuit. scribe does not read raw halo2 Rust source directly. The chain is: extractor-project output → Lean scaffold → LLM proof loop → kernel-accepted .lean.
The adversarial half of the verdict engine (C4): instead of proving the spec, attack it.
scribe refute --gadget examples/range-check/gadget.toml \
[--prime P] # default: smallest prime satisfying the gadget's `p > N` bounds
[--max-iters N] # refutation attempts (default: 6)
[--scaffold-only] # emit the negated statement and stopThe gadget's soundness statement is emitted at a concrete small prime, negated, and an LLM refuter loop (system prompt prompts/lean-refuter.md) tries to prove the negation — i.e. exhibit witness values that satisfy every constraint while violating the spec. A kernel-accepted refutation is exactly as trustworthy as a kernel-accepted proof, and carries its own #audit_axioms gate. Exit codes: 2 = REFUTED (the circuit is under-constrained or the spec is wrong — the counterexample is in the output file), 0 = SURVIVED (no refutation within budget; evidence, not proof), 1 = infrastructure error.
The verdict engine as one command: prove AND attack, and say which side won.
scribe judge --gadget examples/range-check/gadget.toml \
[--prove-iters N] [--refute-iters M] [--prime P] [--samples-per-iter K]The prover runs first (a kernel-accepted proof settles the question — no counterexample can exist, and sound gadgets usually prove in an iteration or two). Only if proving exhausts its budget does the adversarial refuter run. The verdict is backed by a kernel-checked artifact whenever it is not UNDETERMINED:
| Exit code | Verdict | Meaning |
|---|---|---|
0 |
SOUND | kernel-accepted proof of the spec (path printed) |
1 |
UNDETERMINED | neither proof nor refutation within budget |
2 |
UNSOUND | kernel-checked counterexample (path printed) |
3 |
— | infrastructure error |
The exit codes are stable and documented so third parties can script scribe judge in their own CI.
Best-of-n sampling. --samples-per-iter K (also on verify, refute, and proof-pilot) fires K attempts per iteration and lets the kernel filter: candidates are deduplicated, tried shortest-first, and the first to build wins. Parallel sampling is soundness-free — a wrong candidate costs a build, never a false acceptance — and the transcript records every candidate plus the acceptance index, so --replay determinism is preserved.
Generates the editable Halva extractor project that scribe verify --circuit expects.
scribe init \
--circuit path/to/raw-halo2-circuit-crate \
--output path/to/halva-extractor-project \
[--name MyCircuit] \
[--halva-git https://github.com/<owner>/<repo>.git] \
[--halva-rev REV]The generated project contains Cargo.toml, src/main.rs, and a README. Fill in src/main.rs to instantiate your circuit, call Halva, and print the extracted Lean to stdout — then pass that project to scribe verify --circuit. If --halva-git is omitted, the generated Cargo.toml leaves the Halva dependency as an explicit TODO rather than guessing a repository URL.
The five-minute first-run experience, built around the range-check gadget (legible to non-hash ZK engineers). Three tiers:
| Tier | What it does | Requirements |
|---|---|---|
scribe demo |
Dry-run: walks through the pipeline and shows the gadget description | none |
scribe demo --verify |
Runs lake build on the pre-computed RangeCheck.lean proof |
Lean (no API key) |
scribe demo --live |
Runs the full LLM proof loop on a fresh scaffold | Lean and an API key / backend |
scribe demo # dry-run, no Lean or API key
scribe demo --verify # kernel-check the pre-computed proof
scribe demo --live --backend claude # full LLM proof loop on a fresh scaffold# emit a scaffold from an IR file
cargo run -p lean-emit -- examples/poseidon-sbox/gadget.toml
# run the proof loop on a target file
cargo run -p proof-pilot -- lean/ZkGadgets/AutoProof.lean \
--lake-dir lean \
--max-iters 10 \
--transcript transcript.logproof-pilot calls the model with the file + build errors, extracts the proof from the response, patches the file, and repeats until both the project build and the exact target compilation pass clean. Useful flags:
--notes NOTES.md— write a learning log; each iteration records the tactics tried and cites worked examples fromlean/ZkGadgets/.--save-transcript session.json— save a versioned JSON transcript (iteration history, toolchain string, Mathlib rev).--replay session.json— deterministically replay a transcript; refuses on toolchain mismatch unless--allow-toolchain-mismatch.--lsp— use the Lean language server for structured diagnostics (goal states + hypotheses) instead of rawlake buildtext.--system-prompt <file>— override the default system prompt (prompts/lean-prover.md).
See transcripts/poseidon-sbox.md for a session that closes the Poseidon S-box proof in 2 iterations.
halva-bridge turns a real halo2 extraction into a proven soundness theorem. It parses Halva's meets_constraints output, merges in a user Spec + soundness theorem, and hands the result to proof-pilot.
cargo run -p halva-bridge -- examples/halva-range-check/extracted.lean \
--spec-file examples/halva-range-check/spec.lean \
--output lean/ZkGadgets/HalvaRangeCheck.leanUse --prove to launch proof-pilot after scaffolding. Structured specs can use --spec <expr> with repeatable --extra-import <module> flags; run halva-bridge --help for the backend and proof-loop options.
The same flow scales to harder circuits. examples/halva-fibonacci/ and examples/halva-binary-number/ are real extractions (cargo run --example fib / --example scroll-binary-number in the Halva repo) bridged to proven theorems — exercising copy constraints and multi-gate circuits respectively, neither of which the range-check example touches.
Note
Inherited-model fidelity. Halva's emitted Circuit.isValid preamble defines multiplicative_generator P g := g ^ P = 1, which is mathematically wrong: Fermat's little theorem gives g ^ P = g in ZMod P, so the predicate forces g = 1 and can never hold of an actual generator. This is harmless for the proofs here — they destructure meets_constraints and never load isValid's generator field — but it is a standing example of why "valid circuit" preambles deserve their own scrutiny. The smell is pinned by a kernel-checked witness, RangeCheck.multiplicative_generator_forces_one, so it can neither silently disappear nor be silently relied upon. Worth an upstream report to Halva.
Every theorem below is complete and kernel-checked (lake build is green, and each theorem's #audit_axioms gate confirms it depends only on propext, Classical.choice, and Quot.sound).
| Gadget | Constraints | Soundness claim |
|---|---|---|
| 8-bit range check | 9 (8 boolean + decomposition) | ZMod.val x < 256 (requires p > 256) |
| conditional select | 2 (boolean + mux) | (b = 0 ∧ z = y) ∨ (b = 1 ∧ z = x) |
| poseidon s-box | 3 (squaring chain) | y = x ^ 5 |
| non-zero check | 1 (inverse) | x ≠ 0 |
| edwards addition | 2 (baby jubjub add) | output point on curve (closure — see scope note) |
| Halva polynomial range check | 1 (10-factor polynomial) | ZMod.val advice < 10 on enabled rows (requires P > 10) |
| Halva Fibonacci | 1 add gate + 17 copy constraints | output instance cell = 21·f(0) + 34·f(1) |
| Halva binary number | 4 (2 boolean + recomposition + range) | bits b₀,b₁ with value = 2·b₀ + b₁, never both set |
Note
Scope — know what you proved.
- Edwards addition proves closure, not functional correctness. Inputs on the curve + the gadget constraints imply the output is on the curve. That is necessary but not sufficient for "computes EC addition" — a buggy gadget could emit a wrong on-curve point. Full soundness would conclude
(x₃,y₃) = (x₁,y₁) + (x₂,y₂)(or witness uniqueness); the nonzero-denominator hypotheses are also assumed rather than derived from Baby Jubjub's completeness. Both are documented inEdwardsAddition.leanand tracked as future work. - Lookups and permutation arguments are not yet exercised. Every Halva circuit proven here has
all_lookups = trueandall_shuffles = truestubbed trivially by the extractor — the theorems cover gate and copy constraints only. Lookup/permutation arguments are exactly where real halo2 soundness gets hard; they are the next frontier, not a solved problem.
crates/bench runs the proof loop over a 20-gadget suite (benchmark/suite.toml, tiers 1–3 by difficulty) and reports statistics an eval can be judged on, not just a smoke-test pass count:
cargo run -p zkgadget-eval -- --backend claude --budget 5 \
--samples 3 --modes build,lsp -o results.json--samples k— resample each gadget k times; per gadget the JSON reports the unbiased pass@k estimator (1 − C(n−c,k)/C(n,k)) for everyk ≤ nplus a Wilson 95% interval on the per-sample prove probability, so run-to-run variance is measured instead of hidden.--modes build,lsp— the feedback-mode axis (rawlake buildtext vs. structured LSP goal states) runs inside one invocation as an A/B; the summary is broken down per mode.- Negative gadgets — the suite contains five deliberately buggy circuits whose spec is false (
kind = "negative"), spanning distinct audit-bug classes: a range check missing a boolean constraint, a vacuousx − x = 0constraint, the classic is-zero gadget missing itsx·out = 0half, a swap gate with a dangling output wire, and a one-term constraint typo (x·inv − invforx·inv − 1). Every spec is kernel-checked false (a concrete counterexample refutes it, documented in each TOML), gadget names are neutral so nothing the model sees marks them as traps, and the loop is graded on refusing to prove them. A "proved" negative is a soundness alarm: the run prints it loudly and exits2. Without negatives, a suite cannot distinguish "the loop proves true specs" from "the loop proves anything you hand it".
Honest limitations: the suite exercises algebraic gate constraints only (no lookup or permutation arguments — see the scope note above), and samples are independent reruns of the same nondeterministic loop, not seed-controlled.
Results so far (claude-sonnet-5, two full-suite runs: a 1-sample A/B + a 3-sample variance run): 118/120 positive sessions proved, negatives refused 16/16, zero alarms; no measurable quality difference between build and LSP feedback, and range-check-8 is the suite's only sub-1.0 pass@1 cell — see benchmark/RESULTS.md for tables and caveats.
The ghcr.io/lucidsamuel/scribe image is built on every push to main and on version tags (.github/workflows/docker.yml). It bundles:
- Rust release binaries:
scribe,proof-pilot,halva-bridge - elan + the Lean toolchain pinned to
leanprover/lean4:v4.30.0-rc2 - pre-cached Mathlib oleans (~2.5 GB of the ~3.2 GB image)
docker pull ghcr.io/lucidsamuel/scribe:latest
docker run --rm ghcr.io/lucidsamuel/scribe:latest scribe demo
# build locally (from repo root)
docker build -t scribe:dev .
docker run --rm scribe:dev scribe --helpTags: latest (main branch), sha-<commit> (per-commit), v<semver> (releases).
crates/
gadget-ir/ constraint system IR + TOML deserialization
lean-emit/ IR → Lean 4 scaffold with sorry
proof-pilot/ LLM proof loop (patcher, lean runner, session logging, transcript, replay)
halva-bridge/ Halva extraction + semantic specification bridge
scribe-cli/ top-level `scribe` binary (verify + init + demo)
bench/ ZKGadgetEval benchmark suite
lean/ZkGadgets/ the proven theorems (Field + 8 gadgets)
examples/ IR / extraction + spec inputs for every gadget
transcripts/ example proof-pilot sessions
prompts/ system prompt(s) for the proof loop
docs/ architecture.md, why.md
- architecture.md — data flow, crate descriptions, how the pieces connect.
- why.md — motivation: why formally verify ZK circuits, and why an LLM loop.
- poseidon-sbox transcript — a real session closing a proof in 2 iterations.
Contributions are welcome — please open an issue or PR on GitHub.
Before committing, enable the git hook that rejects any sorry, axiom, or native_decide added to Lean files (a fast pre-filter — the build's #audit_axioms gates are the real check):
git config core.hooksPath .githooksCaution
CI runs cargo check + cargo test --workspace, lake build (which enforces the per-theorem #audit_axioms gates), and a fast sorry/axiom/native_decide pre-filter scan over lean/ZkGadgets. A green local lake build and cargo test are the bar before opening a PR; running cargo fmt and cargo clippy first is good practice.
Licensed under the MIT License.