Work with fermionic circuits

Important

The concepts in this guide are currently available only in the Python API. Equivalent functionality will be made available in the C API in a future release.

This guide explains how to use the FermionicCircuit to implement quantum algorithms directly in fermionic space, rather than mapping to qubits first.

Why use fermionic circuits

Consider the traditional workflow for implementing the time evolution of a fermionic Hamiltonian on a qubit-based architecture:

  1. Define the fermionic Hamiltonian (for example, \(H = \sum_{pq} h_{pq} c^\dagger_p c_q\)).

  2. Map it to a qubit operator (for example, by using the Jordan-Wigner mapping).

  3. Implement the time evolution in qubit space (using Trotterization or other methods).

This approach has a fundamental limitation; early mapping to qubits discards problem-aware knowledge about the fermionic structure. Standard optimization passes cannot exploit fermionic symmetries or allowed term reorderings, resulting in suboptimal circuits with unnecessary depths and gate counts.

The FermionicCircuit enables a better workflow:

  1. Define the fermionic Hamiltonian (for example, \(H = \sum_{pq} h_{pq} c^\dagger_p c_q\)).

  2. Implement the time evolution directly in fermionic space using fermionic gates.

  3. Map to qubit space as part of the transpilation process.

This approach addresses the limitation by keeping problem-aware knowledge in fermionic space, where optimization passes can exploit fermionic structure and commutation relations to reduce circuit depth. The fermionic circuit describes what computation to perform, while transpilation handles how to map it to qubits, providing a cleaner separation of concerns.

Generic mode indexing

Both the operators module and the FermionicCircuit use generic mode-based indexing that makes no assumptions about the nature of the modes. A mode is simply an abstract index labeling a fermionic degree of freedom, with no inherent semantics. This design choice mirrors the operators module and ensures maximum flexibility. For example, you can implement time evolution for any operator implementing the OperatorTrait protocol, regardless of its mathematical representation.

Build a fermionic circuit

The example below implements the time evolution described in the previous section. It constructs a FermionicCircuit by specifying the number of fermionic modes, then adds fermionic gates from the qiskit_fermions.circuit.library to implement the time evolution.

The example also demonstrates how to incorporate domain knowledge into an operator by using operator term grouping. By assigning group indices to the Hamiltonian terms, you preserve structural information that optimization passes can exploit throughout the transpilation stack.

>>> from qiskit_fermions.circuit import FermionicCircuit
>>> from qiskit_fermions.circuit.library import Evolution
>>> from qiskit_fermions.operators import FermionOperator, cre, ann
>>>
>>> # Create a circuit with 4 fermionic modes
>>> circuit = FermionicCircuit(4)
>>>
>>> # Define a simple fermionic Hamiltonian
>>> hamiltonian = FermionOperator.from_terms([
...     ([cre(0), ann(2)], 0.5),
...     ([cre(2), ann(0)], 0.5),
...     ([cre(1), ann(3)], 0.5),
...     ([cre(3), ann(1)], 0.5),
... ])
>>> # Add some problem structure by grouping our Hamiltonian terms
>>> hamiltonian.groups = [0, 0, 1, 1]
>>>
>>> # Add an evolution gate to implement exp(-i * t * H)
>>> evolution = Evolution(4, hamiltonian, time=1.0)
>>> circuit.append(evolution, circuit.register)

(png, hires.png, pdf)

A simple `FermionicCircuit` with a single time evolution gate.

(png, hires.png, pdf)

A decomposed `FermionicCircuit` with several time evolution gates.

Notice how the operator term grouping is preserved even in simple operations like decomposition. This demonstrates how structural information flows through the circuit stack. To understand the full transpilation to qubits, refer to the Transpiling fermionic circuits guide.

Keep gates apart with a barrier

The optimization passes fuse neighboring gates where they can. For example, MergeOrbitalRotations combines a run of consecutive OrbitalRotation gates into one, and MergeSlaterDeterminantPreparation folds an InitializeModes into the rotation that follows it.

Use FermionicCircuit.barrier() when you want to prevent that. A barrier carries no unitary effect, so it does not change the state your circuit prepares; it only marks a point that the passes do not fuse across. Call it without arguments to span every mode, or pass the modes to restrict it to:

>>> from qiskit_fermions.circuit.library import OrbitalRotation
>>> import numpy as np
>>>
>>> rotations = FermionicCircuit(4)
>>> rotations.append(OrbitalRotation(np.eye(4, dtype=complex)), rotations.register)
>>> rotations.barrier()
>>> rotations.append(OrbitalRotation(np.eye(4, dtype=complex)), rotations.register)

(png, hires.png, pdf)

A `FermionicCircuit` with a barrier separating two orbital rotations.

Barriers survive the fermion-to-qubit transpilation, so they also constrain the qubit-level optimization that follows. The Transpile fermionic circuits guide covers that stage.

Repeat a circuit

Many algorithms apply the same block of gates many times over. Time evolution is the common case: a Trotter step is built once, then repeated to reach the target time. Use repeat() to build the repeated circuit, and compose() to join it to a state preparation prefix.

The example below builds a second-order Trotter step for a Fermi-Hubbard model. The step is symmetric: it splits the hopping term into two half-duration OrbitalRotation gates surrounding the on-site interaction.

>>> import scipy.linalg
>>> from qiskit_fermions.circuit.library import InitializeModes, OrbitalRotation
>>>
>>> norb, reps, time = 2, 3, 1.0
>>> num_modes = 2 * norb
>>> dt = time / reps
>>>
>>> hopping = np.zeros((norb, norb))
>>> hopping[0, 1] = hopping[1, 0] = -1.0
>>> interaction = FermionOperator.from_dict(
...     {(cre(p), ann(p), cre(p + norb), ann(p + norb)): 2.0 for p in range(norb)}
... )
>>>
>>> def rotation(duration):
...     propagator = scipy.linalg.expm(-1j * duration * hopping)
...     # the same rotation acts on the alpha and beta halves of the register
...     return scipy.linalg.block_diag(propagator, propagator)
>>>
>>> trotter_step = FermionicCircuit(num_modes)
>>> trotter_step.append(OrbitalRotation(rotation(dt / 2)), trotter_step.modes)
>>> trotter_step.append(
...     Evolution(num_modes, interaction, dt, atomic=True), trotter_step.modes
... )
>>> trotter_step.append(OrbitalRotation(rotation(dt / 2)), trotter_step.modes)

Repeating the step and composing it onto a Hartree-Fock reference gives the full evolution:

>>> evolution = FermionicCircuit(num_modes)
>>> evolution.append(InitializeModes([True, False, True, False]), evolution.modes)
>>> evolution.compose(trotter_step.repeat(reps), inplace=True)
>>> evolution.count_ops()["OrbitalRotation"]
6

(png, hires.png, pdf)

A `FermionicCircuit` with repeated steps.

The repetitions are flattened into the returned circuit rather than wrapped in a single gate, so count_ops() counts two rotations per step. That flattening is what makes the redundancy at the step boundaries visible to the transpiler: the trailing half-duration rotation of one step and the leading one of the next are adjacent, so MergeOrbitalRotations combines them into a single rotation.

>>> from qiskit_fermions.transpiler import FermionicPassManager
>>> from qiskit_fermions.transpiler.passes import MergeOrbitalRotations
>>>
>>> merged = FermionicPassManager([MergeOrbitalRotations()]).run(evolution)
>>> merged.count_ops()["OrbitalRotation"]
4

(png, hires.png, pdf)

A `FermionicCircuit` with repeated steps and merged orbital rotations.

The result costs one rotation per step plus one, which is what you get by fusing those half-steps by hand. Writing the step in its natural symmetric form and repeating it produces the same circuit, so the fusion is left to the optimization stage.

When you need the repetitions kept apart, pass insert_barriers=True to place a barrier() between them. The optimization passes do not fuse gates across a barrier, so every rotation survives:

>>> separated = trotter_step.repeat(reps, insert_barriers=True)
>>> FermionicPassManager([MergeOrbitalRotations()]).run(separated).count_ops()[
...     "OrbitalRotation"
... ]
6

(png, hires.png, pdf)

A `FermionicCircuit` with repeated steps separated by barriers.

Note

The reps argument of FermionicSuzukiTrotter is a different concept. It subdivides a single Evolution gate into more, shorter factors, whereas repeat() duplicates whole circuits. Repeating a circuit that holds one Evolution only multiplies its total evolution time.

Measure fermionic modes

measure_all() reads every mode out into a classical bit of its own, pairing mode i with bit i. Use measure() with your own ClassicalRegister to read out only some of them.

>>> measured = trotter_step.measure_all(inplace=False)
>>> measured.count_ops()["measure"]
4

Passing inplace=False returns a measured copy and leaves the original alone, which matters because a measurement is not unitary: a circuit carrying one can no longer be applied to a state vector, so keep an unmeasured circuit around if you also want to simulate it.

Measuring here, rather than on the transpiled QuantumCircuit, is what keeps the sampled bitstrings easy to read. A fermionic measurement names the mode it reads, so every stage that moves modes around (RelabelModes at the fermionic level, layout and routing at the qubit level) carries the measurement along with it, and the bitstring stays indexed by the mode you asked about.

Note

Unlike measure_all(), no barrier() is inserted before the measurements. Call barrier() yourself first if you want one there.

Whether a measured bit means what you expect depends on the fermion-to-qubit encoding, which is chosen during transpilation rather than here. Under an occupation-basis encoding such as Jordan-Wigner each mode is carried by one qubit and the bit is that mode’s occupation; see FermionicMeasure.

Transpile fermionic circuits

To implement the quantum algorithm represented by your FermionicCircuit it must be transpiled to a QuantumCircuit. You can learn how to do this in the Transpile fermionic circuits guide.

Next steps