Circuit frontend (emp-tool/circuits/frontend/)
June 10, 2026 · View on GitHub
A small, optional layer for writing a pure circuit once and running it on any
context — plaintext, garbled 2PC, ZK, … . You write the circuit in ordinary
typed circuit-value code (Bit_T<Ctx>, UInt_T<Ctx,N>, Int_T<Ctx,N>,
Float_T<Ctx,W>); the frontend lets you call it live, or compile it once into
a reusable, context-free Circuit and run it on whatever context you hold
— with no global backend and no per-bit virtual dispatch.
Everything lives in namespace emp::frontend (so run / compile don't
pollute emp). Header-only over emp-tool; C++20. Pull it in with
#include "emp-tool/circuits/frontend/frontend.h" (or directly
#include "emp-tool/circuits/frontend/circuit_fn.h").
This is the BooleanContext frontend: it compiles and replays over the typed
context-bound values (Bit_T<Ctx> / UInt_T<Ctx,N> / …), with the context
passed explicitly and no global backend.
For the typed values it builds on, read circuits.md; for the
gate-context concept it replays over, read the header of ir/context/context.h;
for the session that owns I/O around it, read ir/session/session.h.
What a circuit is — a pure function over a context
A circuit body takes typed circuit values as arguments and returns a typed
value; it does no I/O of its own (no input/reveal/OT). This is enforced
structurally: a body is recorded through a RecordCtx, which has no I/O.
- No secret input inside — pass secret/party inputs as arguments.
- No
revealinside — reveal the returned value outside, through the session. - Public constants inside are fine —
a.constant(5)(implicit form) orUInt_T<Ctx,N>::constant(ctx, 5)(explicit form) fold to constant gates. A value that may differ across parties or runs must be an argument.
I/O stays the session's job, around the circuit:
ClearSession sess; // session owns the I/O boundary
using Ctx = ClearSession::DirectCtx; // a protocol session (SH2PCSession, AG2PCSession) exposes the same surface
using UInt32 = UInt_T<Ctx, 32>;
auto a = sess.input<UInt32>(ALICE, av); // session feeds inputs
auto b = sess.input<UInt32>(BOB, bv);
auto c = frontend::run(sess.direct_ctx(), circuit, a, b); // pure replay over the context
uint32_t r = sess.reveal<uint32_t>(c, PUBLIC).value(); // session reveals -> std::optional<uint32_t>
Body forms
A body comes in two forms; compile/run detect which is invocable. A body
callable in both is a contract error (disambiguate).
-
implicit context (default) — the typed values carry their own context, so the body needs none; make a constant from an argument with
a.constant(v):auto add = [](auto a, auto b) { return a + b; }; -
explicit context (general) — the body takes
Ctx&first. Required for nullary circuits and for making a constant with no argument to anchor on:auto bias = [](auto& ctx) { using C = std::remove_reference_t<decltype(ctx)>; return UInt_T<C,16>::constant(ctx, 4242); };
The calling contract
One diagnostic site (circuit_fn_traits / circuit_contract in circuit_fn.h):
a body must be callable with prvalue circuit-value arguments and return a
circuit value by value. Arguments are passed by value, so a body cannot mutate
them (a non-const lvalue-reference parameter is rejected). Returning a
reference, returning void, returning a non-circuit value, taking a
non-circuit argument, or being callable in both context forms are all
compile-time errors with a precise message — in compile<ArgVs...> and live
run alike.
A circuit value is anything satisfying the WireValue concept
(ir/wire_value.h): it exposes Wire, context_type, clear_t, width(),
context(), pack_wires / from_wires, encode / decode, and a
rebind<Ctx> that maps the same value family onto another context. The four
built-in families (Bit_T, UInt_T, Int_T, Float_T in circuits/typed.h)
satisfy it at fixed width. The runtime-width forms UInt_T<Ctx,0> / Int_T<Ctx,0>
(width chosen at construction) intentionally do not — they have no static
width() or clear codec — so they stay inside a circuit body and never cross the
input / compile / run boundary; convert to a fixed UInt_T<Ctx,N>
(to_fixed<N>()) first if a runtime-width result must leave.
Recording value types — context-free signatures
compile is parameterized by the circuit value types over the recording
context (RecordCtx). The emp::rec:: aliases (circuits/frontend/rec.h) name those
types without spelling RecordCtx:
| value (per context) | recording alias (emp::rec::) |
|---|---|
Bit_T<Ctx> | rec::Bit (= Bit_T<RecordCtx>) |
UInt_T<Ctx,N> | rec::UInt<N> (= UInt_T<RecordCtx,N>) |
Int_T<Ctx,N> | rec::Int<N> (= Int_T<RecordCtx,N>) |
Float_T<Ctx,W> | rec::Float<W> (= Float_T<RecordCtx,W>) |
BitVec_T<Ctx,N> | rec::BitVec<N> (= BitVec_T<RecordCtx,N>) |
The metadata a compiled signature needs — bit width, host clear type + codec,
and the per-context family map — lives on the value type itself
(width(), clear_t, encode/decode, rebind<Ctx>) and is exposed
uniformly through emp::value_traits<T> (circuits/value_traits.h):
value_traits<T>::width(), value_traits<T>::encode(v),
value_traits<T>::decode(bits), value_traits<T>::rebind<Ctx>. A value's
rebind<Ctx> re-attaches a context (UInt_T<RecordCtx,32>::rebind<ClearCtx> == UInt_T<ClearCtx,32>); run uses it to reconstruct the live result type.
compile / run
#include "emp-tool/circuits/frontend/circuit_fn.h"
#include "emp-tool/circuits/frontend/rec.h"
namespace cf = emp::frontend;
using namespace emp;
auto add = [](auto a, auto b) { return a + b; };
auto circ = cf::compile<rec::UInt<32>, rec::UInt<32>>(add); // record ONCE
ClearCtx cx; // run on any context
auto x = UInt_T<ClearCtx,32>::constant(cx, 7);
auto y = UInt_T<ClearCtx,32>::constant(cx, 5);
auto z = cf::run(cx, circ, x, y); // replay -> UInt_T<ClearCtx,32>
- compiled —
compile<ArgVs...>(body)records the body once through aRecordCtxand returns aCircuit<RetV, ArgVs...>(theArgVsare value types overRecordCtx; use therec::aliases).run(ctx, circ, args...)replays it on the livectx(args by const-ref, rebound to that ctx; the context is explicit — no global backend). The sameCircuitruns identically onClearCtx,SH2PCCtx, etc. — user circuits are as portable as the built-in.empbcfiles. - live —
run(body, args...)invokes the body directly on already-live typed values (it recovers the context from the first argument). Same contract.
compile is host-side and deterministic: all parties compile the identical
program, then replay it in lockstep. (A Float_T body inlines its
fp<W>_*.empbc gates into the recording, so a recorded float circuit is
semantically — not necessarily gate-for-gate — equal to the standalone builtin.)
The compiled circuit object
compile returns a Circuit<RetV, ArgVs...> wrapping a
circuit::CircuitArtifact (the flat BooleanProgram + a CircuitSignature of
argument widths and the return width). The argument/return template parameters
are value types over RecordCtx. The artifact is private and immutable;
the constructor validates the program structurally and that the signature
matches the declared value widths, so a stale or mis-typed loaded artifact is
rejected at construction rather than silently mis-running. Accessors:
circ.program(), circ.signature().
No analyses are baked into the circuit — gate counts, liveness, and the AND-depth
schedule are free functions over the program (ir/passes.h,
ir/context/context.h), computed when wanted.
What's inside (internals)
circuits/typed.h— the typed valuesBit_T/BitVec_T/UInt_T/Int_T/Float_T<Ctx>(each carrieswidth()/clear_t/encode/decode/rebind<Ctx>inline) plus the bare-wire arithmetic kernels inemp::kernel.circuits/value_traits.h—value_traits<T>: the uniform metadata accessor (width, clear codec,rebind<Ctx>) over a circuit value's own static members.circuits/frontend/rec.h—rec::Bit/rec::BitVec<N>/rec::UInt<N>/rec::Int<N>/rec::Float<W>, the value types overRecordCtxused ascompilearguments.ir/wire_value.h— the genericWireValueconcept (the structural value contract).ir/context/context.h— theBooleanContextconcept and the contextsClearCtx(plaintext) andRecordCtx(records aBooleanProgram), plus theCountCtx/DigestCtxanalysis helpers,execute_program(ctx, prog, inputs, ws)(value-return replay over anyBooleanContext), andProgramWorkspace.ir/artifact.h—CircuitArtifact(program + signature) +validate_artifact.circuits/frontend/circuit_fn.h— theRecordValueconcept (refiningWireValue),circuit_fn_traits/circuit_contract,Circuit<RetV,ArgVs...>,compile,run.ir/passes.h— analyses over the IR (count,liveness,schedule,layout) as free functions.
Adding a new backend
Define a type satisfying the BooleanContext concept (a std::regular Wire
plus value-return public_bit/and_gate/xor_gate/not_gate). Every compiled
circuit then replays on it via run(ctx, circ, …) with no frontend changes — the
generic replay walks the gate list issuing the context's gate ops. A
round-sensitive protocol (e.g. GMW) gets efficiency by consuming the program's
AND-depth schedule (a BulkBooleanContext + scheduled_execute_program), not the
scalar replay.
Tests
test/test_circuit_fn.cpp— compile-once / run-on-any-context onClearCtx(incl. both body forms, a nullary circuit,.constant(), fp32), plus the size-optimal 31-AND adder and deterministic recording.test/circuit_fn_contract_probes.cpp— the contract's positive case + the negative cases that must fail to compile with the expected message.emp-sh2pc/test/test_circuit_fn_sh2pc.cpp— the same compiledCircuitrun two-party over the garbledSH2PCCtx(uint32 + fp32).