Skip to the content.

Adding a Circuit to Qloop

One new file is all it takes. Drop qloop/circuits/my_circuit.py in, run pytest, and every pipeline stage runs automatically.


Step 1 — Copy the template

cp templates/circuit_template.py qloop/circuits/my_circuit.py

Step 2 — Fill in the required fields

Open qloop/circuits/my_circuit.py and edit:

Field What to set
name Unique string identifier (lowercase, hyphens ok)
n_qubits Number of qubits build() returns
build(**params) Return a QuantumCircuit with no measurements
budget() Max depth / two-qubit gate count after transpilation

That’s it for the minimum viable plugin.

Step 3 — Run the suite

pytest tests/test_framework/ tests/generic/ -v

You’ll see your circuit appear in every parametrized test automatically.


Optional declarations and what they unlock

Each optional method you add enables one more pipeline stage. Omitting it produces a visible pytest.skip — never a silent pass.

reference_state(**params) → Statevector

Enables Stage 2 exact statevector comparison. Define this when the output state has a known closed form (Bell, GHZ, etc.).

def reference_state(self, **params) -> Statevector:
    inv_sqrt2 = 1.0 / np.sqrt(2)
    return Statevector([inv_sqrt2, 0.0, 0.0, inv_sqrt2])

reference_expectation(**params) → float

Enables exact expectation-value checking in Stage 2 — useful for variational circuits (VQE ground state energy).

invariants() → list[Invariant]

Enables Stage 3 property checks at default parameters. normalized() is always checked automatically.

from qloop.core.invariants import normalized, unitary

def invariants(self):
    return [normalized(), unitary()]

invariants_for(**params) → list[Invariant]

Override instead of invariants() when the correct invariants depend on parameters (e.g. Grover’s marked state changes).

param_space() → ParamSpace

Enables Stage 3 Hypothesis fuzzing — the framework generates random parameter combinations and checks invariants_for across all of them.

from qloop.core.spec import BitstringDomain, FloatDomain, ParamSpace

def param_space(self) -> ParamSpace:
    return ParamSpace(
        marked=BitstringDomain(length=2),
        theta=FloatDomain(0.0, 3.14159),
    )

expected_distribution(**params) → dict[str, float]

Enables Stage 5 noisy simulation. Keys are bitstrings; values are ideal probabilities. The noisy test asserts each observed probability falls within ±7 pp of the ideal.

def expected_distribution(self, **params) -> dict[str, float]:
    return {"00": 0.5, "11": 0.5}

stim_program(**params) → stim.Circuit | None, stim_param_space() → ParamSpace

Enables Stage 3b, the Clifford/Stim tier — for Clifford circuits too large for statevector simulation (n_qubits > MAX_STATEVECTOR_QUBITS, currently 20). Return a stim.Circuit with DETECTOR instructions; the generic test asserts detectors are quiescent absent an injected error and fire when one is injected (by convention, sweep an error_qubit parameter where -1 means “no error”). stim_param_space() is independent from param_space() — they drive different sweeps (Stim error injection vs. statevector-based Hypothesis fuzzing).

def stim_program(self, error_qubit: int = -1, **params):
    circuit = stim.Circuit()
    # ... build a 2-round syndrome-extraction circuit with DETECTOR instructions
    return circuit

def stim_param_space(self) -> ParamSpace:
    return ParamSpace(error_qubit=IntDomain(-1, self.n_qubits - 1))

If your circuit is small enough for statevector simulation, you don’t need this — reference_state() and invariants() are simpler and sufficient. This tier exists specifically for the handful of circuits (bb-code-72, bb-code-144) at 144+ qubits.


Budget tuning

Budget has two pairs of limits:

Budget(
    depth=50,                  # ideal or all-to-all targets
    two_qubit_gates=15,
    depth_limited=80,          # heavy-hex: SWAP overhead inflates depth
    two_qubit_gates_limited=25,
)

If you omit depth_limited, the depth value is used for all targets. Tighten the budget after measuring actual transpiled depth (Stage 4 prints [metrics] lines under pytest -s).


Example: GHZ (minimal plugin)

qloop/circuits/ghz.py is the canonical example:

To re-enable Stage 5 for GHZ at its default n=12, add:

def expected_distribution(self, n: int = DEFAULT_N, **params) -> dict[str, float]:
    return {"0" * n: 0.5, "1" * n: 0.5}

See also qloop/circuits/ghz_tree.py — same target state, a different (log-depth) construction, registered as a separate circuit specifically so both get independent budgets checked by the transpile matrix. That’s the pattern to follow whenever you want to compare two constructions of the same thing: two CircuitSpecs, not one circuit with a “which construction” parameter (budgets aren’t parametrized, so a single circuit can’t get two different budget checks).


What the framework never requires