Compiler
The compiler turns a circuit described against the Logic layer into a QuadCircuit — a compact, canonical, layered quadratic-arithmetic representation the proof system can consume. It runs common-subexpression elimination and constant folding as you build, schedules the finished DAG into layers of quadratic terms, and produces a 32-byte canonical circuit hash used for prover/verifier negotiation. Attestation workloads like hashing a TPM2 quote with SHA-256 or verifying an ECDSA-P256 signature over a TDX report all flow through this compiler before they ever see a prover.
When to reach for it
- You are authoring a custom circuit and need to emit it for proving, not just evaluate it in the clear.
- You want canonical identity for prover/verifier negotiation — both sides compile the same source and compare the 32-byte
Circuit::idhash. - You need to control layer depth or the number of copies (
nc) for batched proving over many attestation records. - You are debugging circuit size or depth via the
dump_qtelemetry fields onQuadCircuit.
Design overview
You almost never construct gates against QuadCircuit by hand. The idiomatic driver shape is to wrap a QuadCircuit<Field> in a CompilerBackend<Field> and then hand that backend to Logic<Field, CompilerBackend>. CompilerBackend is a tiny adapter — it exposes a small gate vocabulary (add, sub, mul, konst, assert0, input_wire, output_wire) that forwards to the underlying QuadCircuit*, and it declares using V = size_t so that “wire” in the Logic layer is the same size_t wire ID that the compiler uses internally. Every arithmetic operation your gadget performs at the Logic layer emits one node into the compiler’s DAG.
The QuadCircuit applies algebraic simplifiers on every gate call (CSE via an internal PdqHash, constant folding, zero/one peepholes). When you ask for the finished circuit, the Scheduler rewrites the DAG into layers, inserts copy wires where a value is consumed at a deeper layer than it was produced, and hands back a sumcheck::Circuit<Field> that the proof system consumes directly.
API surface
Constructing a QuadCircuit and declaring I/O
QuadCircuit(const Field& f) constructs an empty circuit over the given field. Input wires are created one at a time with size_t input_wire() and are numbered in declaration order. Two markers let you partition those inputs:
void private_input()marks the boundary between public and private inputs. Wires declared before it are public, wires after it are private. It can only be called once, so declare all public inputs first (the fieldnpub_input_records the index of the first private input).void begin_full_field()marks the boundary between subfield and full-field inputs. Wires declared before this call are constrained to live in the subfield (e.g. bit-decomposed bytes of a SHA-256 block); wires after it may take any element of the full field (e.g. a P-256 scalar). Likeprivate_input(), it can only be called once.
void output_wire(size_t n, size_t wire_id) marks node n as an output at position wire_id. You typically do not call this from gadget code — Logic and the higher-level encoders handle output wiring for you.
Gate operations
All gate constructors return a size_t wire ID that you thread into downstream gates:
size_t mul(size_t op0, size_t op1)andsize_t mul(const Elt& k, size_t op0, size_t op1)— quadratic term (with an optional scalar coefficient).size_t add(size_t op0, size_t op1)andsize_t sub(size_t op0, size_t op1)— linear combination.subis implemented asadd(a, mul(-1, b)).size_t konst(const Elt& k)— a constant wire.size_t assert0(size_t op)— constrainopto equal zero; this is how equality and boolean checks are expressed.size_t linear(size_t op0)— a deliberately opaque “multiply by 1” that the simplifier will not fold away. This matters because the algebraic simplifier, left alone, peeks through a linear terma*xwhenxitself was a productk*z*wand rewrites it into(a*k)*z*won the previous layer. That rewrite may destroy a common subexpression or collapse a layer you wanted to keep.linear()inserts an explicit1*opmultiplication that the compiler refuses to optimize away, letting you pin the layered shape you want.
Compilation
std::unique_ptr<Circuit<Field>> mkcircuit(size_t nc) runs the scheduler and returns the finished circuit. Three things to keep in mind:
- Wire IDs are transient. The
size_tvalues returned byinput_wire(),mul(),add(), and friends identify nodes in the builder’s DAG. The scheduler renumbers every wire duringmkcircuit()as part of canonicalization, so never persist a builder wire ID past themkcircuit()call — they will not match anything in the resultingCircuit. ncis the number of copies. The proof system proves the same circuit overncindependent input assignments in one shot. Passnc=1for a single attestation; passnc=Nif you are batchingNTPM quotes or TDX reports under the same circuit.mkcircuit()populatesCircuit::id. Internally it callscircuit_id(c->id, *c, f), which SHA-256-hashes the layered quad representation together with the field descriptor. Prover and verifier can therefore agree they are running the same circuit just by comparing the 32 bytes ofCircuit::id.
Canonicalization
Two differently-written DAGs that compute the same function compile to byte-identical Circuit structures — and therefore produce identical Circuit::id hashes. Three things make this work:
- Every
push_node()goes through aPdqHashlookup, so structurally equal subexpressions are deduplicated on the fly (common-subexpression elimination). Scheduler::assign_wire_ids()renumbers wires in a deterministic lexicographic order over(rlop0, rlop1, k)triples. Term operand pairs are canonicalized so(a,b)and(b,a)sort together.- When a node produced at depth
dis consumed at depthd+k, the scheduler silently inserts1 * nodecopy wires to carry it forward. Copy-wire insertion is not a user-controllable knob; it happens during layering.
See canonicalization_test.cc for the demonstration: two circuits that compute (a*b) * (c*d) via different intermediate orderings (and with extra unused nodes thrown in) both produce the same 32-byte id.
Example
QuadCircuit<Field> Q(F);
const CompilerBackend cbk(&Q);
const Logic<Field, CompilerBackend> L(&cbk, F);
size_t a = Q.input_wire();
size_t b = Q.input_wire();
size_t c = Q.input_wire();
Q.assert0(Q.sub(Q.add(a, b), c)); // assert a + b == c
auto circuit = Q.mkcircuit(/*nc=*/1);
// circuit->id is now the 32-byte canonical hash.See it used
compiler_test.cc— canonical build-and-compile flow, including theassert0(a + b == c)pattern shown above.canonicalization_test.cc— two differently-written DAGs, one canonical circuit hash.
Related
- Logic — the authoring surface that typically drives the compiler.
- Sumcheck — consumer of the layered
Circuit<Field>thatmkcircuit()returns. - ZK (top-level) — the end-to-end composer that takes your compiled circuit and produces a proof.
- Compiling & Caching Circuits — guide on the compile-once-ship-bytes workflow.