Skip to Content
⚠️ This documentation is AI-generated, for personal use only, and is not supported or endorsed by Google.

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::id hash.
  • 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_q telemetry fields on QuadCircuit.

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 field npub_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). Like private_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) and size_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) and size_t sub(size_t op0, size_t op1) — linear combination. sub is implemented as add(a, mul(-1, b)).
  • size_t konst(const Elt& k) — a constant wire.
  • size_t assert0(size_t op) — constrain op to 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 term a*x when x itself was a product k*z*w and rewrites it into (a*k)*z*w on the previous layer. That rewrite may destroy a common subexpression or collapse a layer you wanted to keep. linear() inserts an explicit 1*op multiplication 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_t values returned by input_wire(), mul(), add(), and friends identify nodes in the builder’s DAG. The scheduler renumbers every wire during mkcircuit() as part of canonicalization, so never persist a builder wire ID past the mkcircuit() call — they will not match anything in the resulting Circuit.
  • nc is the number of copies. The proof system proves the same circuit over nc independent input assignments in one shot. Pass nc=1 for a single attestation; pass nc=N if you are batching N TPM quotes or TDX reports under the same circuit.
  • mkcircuit() populates Circuit::id. Internally it calls circuit_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 of Circuit::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 a PdqHash lookup, 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 d is consumed at depth d+k, the scheduler silently inserts 1 * node copy 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

  • Logic — the authoring surface that typically drives the compiler.
  • Sumcheck — consumer of the layered Circuit<Field> that mkcircuit() 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.
Last updated on