The ffsim simulation backend¶
Important
The concepts in this guide are currently available only in the Python API. Equivalent functionality will be made available through the C API in a future release.
The getting-started guides simulate fermionic circuits and
Hamiltonians with ffsim – calling ffsim.apply_unitary(), ffsim.linear_operator(), or
ffsim.sample_state_vector() directly on FermionicCircuits and
FermionOperators. This guide explains why that works and what is actually happening
underneath: this package couples deliberately with ffsim’s simulation protocols rather than
building its own simulation API, and it does so in a way that keeps a native (scipy-only)
simulation path available for users who cannot or do not want to install ffsim.
Why couple with ffsim¶
ffsim is a high-performance simulator for fermionic quantum circuits that exploits
particle-number and spin-Z conservation to represent state vectors far more compactly than a
generic \(2^n\)-dimensional qubit statevector. It defines two small protocols that any object
can implement to participate in its simulation machinery (see qiskit_fermions.protocols
for this package’s own protocols, which follow the same design):
ffsim.SupportsApplyUnitary, via a method_apply_unitary_(vec, norb, nelec, copy)applying the object as a unitary to a fixed-particle-number state vector, andffsim.SupportsLinearOperator, via a method_linear_operator_(norb, nelec)returning ascipy.sparse.linalg.LinearOperatorview of the object on that same sector.
Rather than invent a separate simulation interface, every fermionic gate in
qiskit_fermions.circuit.library and FermionOperator implement these exact
protocols. The direct payoff is that ffsim’s own tools work natively on this package’s objects,
with no conversion step: ffsim.apply_unitary() can simulate a FermionicCircuit
end to end, ffsim.linear_operator() (or plain scipy.sparse.linalg.eigsh()) can
diagonalize a FermionOperator, and sampling utilities like
ffsim.sample_state_vector() – used in the SKQD guide to turn a
simulated statevector into measurement counts – work out of the box.
>>> import numpy as np
>>>
>>> from qiskit_fermions.circuit import FermionicCircuit
>>> from qiskit_fermions.circuit.library import Evolution
>>> from qiskit_fermions.operators import FermionOperator, ann, cre
>>> from qiskit_fermions.utils.optionals import HAS_FFSIM
>>>
>>> if HAS_FFSIM:
... import ffsim
>>>
>>> norb, nelec = 2, (1, 1)
>>> hamiltonian = FermionOperator.from_terms([
... ([cre(0), ann(1)], 0.5),
... ([cre(1), ann(0)], 0.5),
... ])
>>>
>>> circuit = FermionicCircuit(2 * norb)
>>> circuit.append(Evolution(2 * norb, hamiltonian, time=1.0), circuit.modes)
>>>
>>> if HAS_FFSIM:
... reference = ffsim.hartree_fock_state(norb, nelec)
... state = ffsim.apply_unitary(reference, circuit, norb=norb, nelec=nelec) # native ffsim call
This is the concrete reason for coupling with ffsim’s protocols rather than, say, only offering a
bespoke simulate() method: it lets ffsim’s existing, actively developed ecosystem of simulation
and sampling utilities apply to this package’s circuits and operators unchanged, and it lets users
already working with ffsim mix in this package’s gates and operators without learning a second
simulation API.
Fermionic simulation lives in a fixed particle-number sector¶
Both _apply_unitary_ and _linear_operator_ take an nelec argument and represent the state
vector over the fixed-particle-number determinant basis for that (norb, nelec) sector –
mirroring ffsim’s (and, transitively, PySCF’s) FCI space setup, rather than the full
\(2^{\text{num\_modes}}\)-dimensional space a general qubit simulator would use. This is a much
smaller space (its size is a product of binomial coefficients rather than a power of two), but it
comes with a hard restriction: only operators and gates that preserve particle number (and, in
the spinful case, each spin species’ particle number individually) can be represented in it. An
operator whose action would move amplitude to a different particle number simply has nowhere to go
in this fixed-sector picture.
The native kernel resolves this by silently dropping any term that would leave the sector – projecting its contribution to zero rather than raising an error, since the kernel’s contract is “matrix-vector product on this sector,” not “validate this operator.” For most callers this dropping would be the wrong thing: applying \(\exp(-i t H)\) for a Hamiltonian \(H\) with a non-particle-conserving term would silently turn a would-be unitary into a non-unitary, physically-meaningless map. So the higher-level entry points that build a unitary out of the kernel add an explicit guard before ever calling it:
EvolutionchecksFermionOperator.conserves_sector()and raises aValueErrorif the operator does not conserve the(norb, nelec)sector.OrbitalRotationchecks that its (embedded) rotation matrix is block-diagonal across the alpha/beta split in the spinful case, and raises aValueErrorif it mixes spin sectors.
>>> from qiskit_fermions.circuit.library import Evolution
>>> from qiskit_fermions.linalg import apply_unitary
>>>
>>> non_conserving = FermionOperator.from_terms([([cre(0), cre(1)], 1.0)]) # creates 2 particles
>>> gate = Evolution(2 * norb, non_conserving, time=1.0)
>>>
>>> if not HAS_FFSIM:
... # on Windows we manually define our reference state vector
... reference = np.asarray([1, 0, 0, 0], dtype=complex)
>>>
>>> try:
... apply_unitary(reference, gate, norb, nelec, copy=True)
... except ValueError as exc:
... print("rejected:", exc)
rejected: Evolution requires an operator that conserves the (norb, nelec) sector: every term must preserve the particle number of each spin species (norb=2, nelec=(1, 1)).
Calling SupportsLinearOperator._linear_operator_() directly on a non-conserving operator bypasses
this guard – it is a lower-level building block, not a validated simulation entry point – and will
return a matrix-vector product that silently zeroes the non-conserving amplitude rather than raising.
The practical consequence is that non-particle-preserving simulation is not possible in fermionic
space at all, by construction of the fixed-sector representation – not as a missing feature, but
as the price of the compact FCI-space representation that makes fermionic simulation tractable in
the first place. If your algorithm genuinely needs particle-number-violating operators (for example,
a qubit-native error channel, or an operator built for a mapped Hamiltonian that does not conserve
particle number term by term), transpile to qubits first and simulate the resulting
QuantumCircuit with a qubit-level simulator instead, which has no such
restriction. Any transpilation route works for this; the
fermionic circuit and transpilation guides go into more detail, but
generate_preset_jw_pass_manager() is a reasonable default
choice to reach for.
The block-spin convention for spinful systems¶
nelec is typed int | tuple[int, int], and its type – not a separate flag – is what selects
between the two supported mode layouts:
an int selects the spinless interpretation: the
norbmodes are treated directly as spinless orbitals, andnelecis simply the total particle count.a pair
(n_alpha, n_beta)selects the spinful interpretation of2 * norbmodes under a fixed block-spin convention: modes0 .. norbare the alpha (spin-up) orbitals and modesnorb .. 2 * norbare the beta (spin-down) orbitals. Each spin species is conserved independently – an alpha-only term can move an electron between alpha modes but never into a beta mode, and vice versa.
This dispatch happens at every ffsim-protocol entry point in this package – gate constructors,
_apply_unitary_placed_ implementations, and the native Rust kernel’s own sector compilation –
and it is exactly the convention used throughout the LUCJ and
SKQD guides (for example,
InitializeModes.from_hartree_fock() fills alpha occupations into modes 0 .. n_alpha and
beta occupations into modes norb .. norb + n_beta). It is worth contrasting this with the
qiskit_fermions.operators module and FermionicCircuit in general, which use
generic mode indices with no inherent spin semantics (see the fermionic circuit guide) – the block-spin meaning is imposed only when a spinful nelec
is supplied to a simulation call, not baked into the operator or circuit representation itself.
>>> from qiskit_fermions.circuit.library import InitializeModes
>>>
>>> norb, nelec = 3, (2, 1)
>>> init = InitializeModes.from_hartree_fock(norb, nelec)
>>> print([bool(occ) for occ in init.occupation]) # alpha modes 0,1; beta mode 3 (= norb + 0)
[True, True, False, True, False, False]
Next steps¶
Walk through the LUCJ guide to see this backend used end to end, including the pure-scipy path (via
ffsim.linear_operator(), itself backed by the same_linear_operator_protocol method described here) for evaluating an ansatz’s energy.Walk through the SKQD guide to see
ffsim.apply_unitary()andffsim.sample_state_vector()used together to sample circuits built from this package’s gates.Read the fermionic circuit guide for the generic, spin-agnostic mode indexing used outside of simulation calls.
Read the transpilation guide for how to leave fermionic space entirely and simulate on qubits, which is required for non-particle-conserving operators.
Browse
qiskit_fermions.protocolsfor the full catalog of protocols this package defines, including the conversion protocols (SupportsFermionOperator,SupportsMajoranaOperator) that are unrelated to ffsim.