Skip to content

UCCSD (native excitation circuits)

Circuits, not chemistry: this module builds gate sequences for the single/double fermionic excitations a UCCSD ansatz needs. Pairing this with an actual molecular Hamiltonian is VQE's own job.

A UCCSD ansatz applies one unitary per excitation out of a Hartree-Fock reference: exp(theta * (a_p^dagger a_q - a_q^dagger a_p)) for a single excitation, and the four-index equivalent for a double. This module builds those unitaries as real, exact gate sequences directly against the Jordan-Wigner mapping — no PennyLane dependency, no Trotter error.

Step 1. Which excitations exist for a given molecule

from dense_evolution.circuits.uccsd import find_excitations

singles, doubles = find_excitations(electrons=2, n_qubits=4)
singles, doubles
([[0, 2], [1, 3]], [[0, 1, 2, 3]])

find_excitations is pure combinatorics, no quantum computation: with 2 electrons filling qubits 0 and 1 (occupied) and qubits 2, 3 empty (virtual), a single excitation moves one electron from an occupied to a virtual orbital of the same spin; a double moves two at once. This matches qml.qchem.excitations(electrons, n_qubits) exactly, without needing PennyLane installed just to find out which excitations exist.

Step 2. A single excitation, exact for any orbital distance

import dense_evolution as de
from dense_evolution.circuits.uccsd import single_excitation_ops

p, q, theta = 0, 2, 0.4173
ops = single_excitation_ops(p, q, theta)

sim = de.DenseSVSimulator(4)
sim.sv[0] = 0
sim.sv[int('0011', 2)] = 1.0
sim.run_circuit(ops)
sim.get_statevector()
array with nonzero amplitudes at |0011> (0.9142) and |0110> (0.4053)

single_excitation_ops(p, q, theta) builds exp(theta * (a_p^dagger a_q - a_q^dagger a_p)) as CNOT(p,q), a chain of CNOTs folding every in-between qubit's Jordan-Wigner parity onto p, one CRY(2*theta), then undoing the fold — exact for any p < q, not just adjacent orbitals. Starting from the Hartree-Fock reference |0011> (qubits 0,1 occupied), it rotates into a superposition with |0110> — the p=0 -> q=2 excited configuration — matching scipy.linalg.expm of the exact fermionic generator to machine precision (~1e-16), not a Trotter approximation.

Step 3. A double excitation, exact when both pairs are adjacent

from dense_evolution.circuits.uccsd import double_excitation_ops

p, q, r, s, theta = 0, 1, 2, 3, 0.4173
ops = double_excitation_ops(p, q, r, s, theta, ancilla1=4, ancilla2=5)

sim = de.DenseSVSimulator(6)
sim.sv[0] = 0
sim.sv[int('110000', 2)] = 1.0
sim.run_circuit(ops)
sim.get_statevector()
array with nonzero amplitudes at |110000> (0.9142) and |001100> (0.4053)

double_excitation_ops needs two spare ancilla qubits (ancilla1, ancilla2, must be |0> on entry) whenever the occupied pair (p, q) and the virtual pair (r, s) are each adjacent — exactly the case find_excitations produces for a minimal active space like this one. The ancillas come back to |0> on exit (verified above: no leakage outside the logical |110000>/|001100> pair), so the same two ancillas can be reused across every double excitation in an ansatz.

Details

Where this came from

UCCSD's excitation generators were previously only reachable through PennyLane's own FermionicSingleExcitation/FermionicDoubleExcitation decomposition. This module is a direct derivation against dense_evolution.physics.fermions.majorana_pauli_terms (the package's own already-verified Jordan-Wigner mapping) instead — every circuit identity was checked against scipy.linalg.expm of the exact generator matrix, independently reconstructed from raw ladder operators, before being written here (see tests/unit/test_uccsd.py).

When the double-excitation closed form doesn't apply

For an active space where the occupied pair or the virtual pair is itself non-adjacent (3+ occupied or 3+ virtual orbitals, e.g. LiH/BeH2), the closed-form Z-string folding used for single excitations does not carry over: the naive fold leaves genuine leakage, not just an unsimplified circuit. double_excitation_ops falls back automatically to per-term exponentiation of the 8-term Pauli decomposition (via dense_evolution.circuits.trotter.pauli_rotation_ops) whenever no ancillas are given, or the pairs aren't both adjacent.

That fallback is exact too, not a Trotter approximation: verified exhaustively across every computational basis state and several non-adjacent p<q<r<s choices, to floating-point precision (~1e-15) at every theta tested — the 8 terms don't commute as general operators, but the generator only ever couples two basis states for any fixed setting of the untouched qubits, and within that 2-dimensional subspace the per-term exponentials compose without error. Only the gate count differs from the closed form, never correctness.

Gate count

The single-excitation closed form costs 2*(q-p) + 2 CX gates (plus the CRY's own 2 CX). The double-excitation closed form uses two Toffolis (each itself decomposed into CX + single-qubit gates via QuantumTranspiler.decompose_toffoli) around one CRY; the per-term fallback costs one Pauli-rotation gadget per surviving term (up to 8) of the double-excitation generator, via pauli_rotation_ops.

uccsd

Native (PennyLane-free) UCCSD excitation circuits.

UCCSD's single/double fermionic excitation generators (a_p^dagger a_q - h.c., a_p^dagger a_q^dagger a_r a_s - h.c.) were previously only reachable through PennyLane's own FermionicSingleExcitation/ FermionicDoubleExcitation decomposition (see dashboard/core/vqe.py's _uccsd_tape_to_qasm) -- not because a native circuit is theoretically impossible, but because deriving the exact (non-Trotterized) circuit identity requires real derivation, not just wiring PennyLane through.

This module IS that derivation, done directly against dense_evolution.physics.fermions.majorana_pauli_terms (already-verified Jordan-Wigner mapping) rather than against PennyLane's internals, and every circuit identity below was verified against scipy.linalg.expm of the exact generator matrix (independently reconstructed from raw Jordan-Wigner ladder operators, not from PennyLane) before being written here -- see tests/unit/test_uccsd.py.

Single excitation (any orbital distance): exact closed form, always. G_pq = a_p^dagger a_q - a_q^dagger a_p decomposes (via a_p^dagger=(chi_X(p)-ichi_Y(p))/2) into exactly 2 Pauli strings that share the Jordan-Wigner Z-string between p and q and differ only by an X<->Y swap at p and q. This is the standard "Givens rotation" generator (G^2 = 2I): exact circuit is CNOT(p,q), fold each Z-string qubit's parity onto p via CNOT(k,p), CRY(2theta, control=q, target=p), then undo. Verified for adjacent and long-Z-string pairs alike (any p<q).

Double excitation: exact closed form when the occupied pair (p,q) is adjacent (q=p+1) AND the virtual pair (r,s) is adjacent (s=r+1) -- the gap between q and r (HOMO-LUMO gap) can be any length, no restriction. This is the case dashboard/core/vqe.py's UCCSD ansatz always produces for a 2-occupied/2-virtual active space (e.g. H2 in a minimal basis), and generally whenever qml.qchem.excitations pairs consecutive occupied orbitals with consecutive virtual orbitals. G_pqrs (8 Pauli terms) reduces to a clean 2-level coupling between |p=0,q=1,r=1,s=1,...> and its bit-flip, realized via a CNOT fan (mirroring the p,q,r,s pattern onto q) plus a triple-controlled RY (Toffoli-mediated, 2 ancilla qubits, always returned to |0>).

For a double excitation where the occupied pair or the virtual pair is itself non-adjacent (occurs for active spaces with 3+ occupied or 3+ virtual orbitals, e.g. LiH/BeH2), the same closed-form Z-string folding used for single excitations does NOT carry over cleanly (verified directly: the naive fold leaves genuine leakage, not just an unsimplified-but-correct circuit) -- deriving a comparably compact closed form for that case is unresolved, so double_excitation_ops falls back to per-term exponentiation of the 8 Pauli terms via dense_evolution.circuits.trotter.pauli_rotation_ops for that specific case, applied sequentially in a fixed term order.

That fallback turned out to be exact too, not a Trotter approximation: verified exhaustively (every computational basis state, several p<q<r<s choices including non-adjacent pairs, theta up to pi) against scipy.linalg.expm of the exact generator matrix, to floating-point precision (~1e-15) at every theta tested, not just small theta the way a genuine first-order Trotter error would show. The 8 terms don't commute as operators on the full Hilbert space, but the generator only ever couples two basis states for any fixed setting of the qubits it doesn't act on (same structure as the closed-form case), and within that 2-dimensional invariant subspace the per-term exponentials apparently compose without error -- not derived from first principles here, just confirmed by the exhaustive check above. Only the GATE COUNT differs from the closed form, not correctness.

find_excitations

find_excitations(electrons: int, n_qubits: int)

Enumerate spin-conserving single/double excitation index tuples for a Hartree-Fock reference with the given electron count -- pure classical combinatorics (occupied = orbitals [0, electrons), virtual = the rest, even index = spin-up, odd = spin-down under the standard Jordan-Wigner interleaved spin-orbital ordering), no quantum computation involved. Verified to reproduce qml.qchem.excitations(electrons, n_qubits) exactly across several (electrons, n_qubits) pairs -- see tests/unit/test_uccsd.py -- kept here so finding which excitations exist doesn't need PennyLane installed any more than building their circuits does.

Returns (singles, doubles): singles is a list of [p, q] (p occupied, q virtual, same spin); doubles is a list of [p, q, r, s] with p<q both occupied, r<s both virtual, total spin conserved.

Source code in dense_evolution/circuits/uccsd.py
def find_excitations(electrons: int, n_qubits: int):
    """
    Enumerate spin-conserving single/double excitation index tuples for
    a Hartree-Fock reference with the given electron count -- pure
    classical combinatorics (occupied = orbitals [0, electrons), virtual
    = the rest, even index = spin-up, odd = spin-down under the standard
    Jordan-Wigner interleaved spin-orbital ordering), no quantum
    computation involved. Verified to reproduce
    qml.qchem.excitations(electrons, n_qubits) exactly across several
    (electrons, n_qubits) pairs -- see tests/unit/test_uccsd.py -- kept
    here so finding *which* excitations exist doesn't need PennyLane
    installed any more than building their circuits does.

    Returns (singles, doubles): singles is a list of [p, q] (p occupied,
    q virtual, same spin); doubles is a list of [p, q, r, s] with
    p<q both occupied, r<s both virtual, total spin conserved.
    """
    occupied = list(range(electrons))
    virtual = list(range(electrons, n_qubits))
    singles = [[p, q] for p in occupied for q in virtual if p % 2 == q % 2]
    doubles = []
    for i, p in enumerate(occupied):
        for q in occupied[i + 1:]:
            for j, r in enumerate(virtual):
                for s in virtual[j + 1:]:
                    if (p % 2 + q % 2) == (r % 2 + s % 2):
                        doubles.append([p, q, r, s])
    return singles, doubles

single_excitation_ops

single_excitation_ops(
    p: int, q: int, theta: float
) -> List[Tuple]

Exact circuit for exp(theta * (a_p^dagger a_q - a_q^dagger a_p)), the UCCS single-excitation unitary, for ANY p < q (any Jordan-Wigner Z-string length in between) -- no Trotter error, ever.

Gate count: 2(1 + (q-p-1)) CX + 1 CRY (itself 2 CX + 2 RY), so 2(q-p) + 2 CX total.

Source code in dense_evolution/circuits/uccsd.py
def single_excitation_ops(p: int, q: int, theta: float) -> List[Tuple]:
    """
    Exact circuit for exp(theta * (a_p^dagger a_q - a_q^dagger a_p)),
    the UCCS single-excitation unitary, for ANY p < q (any Jordan-Wigner
    Z-string length in between) -- no Trotter error, ever.

    Gate count: 2*(1 + (q-p-1)) CX + 1 CRY (itself 2 CX + 2 RY), so
    2*(q-p) + 2 CX total.
    """
    if not p < q:
        raise ValueError(f"single_excitation_ops requires p < q, got p={p}, q={q}")
    inter = list(range(p + 1, q))
    ops: List[Tuple] = []
    ops.append(('cx', p, q))
    for k in inter:
        ops.append(('cx', k, p))
    ops.extend(_cry_ops(2 * theta, control=q, target=p))
    for k in reversed(inter):
        ops.append(('cx', k, p))
    ops.append(('cx', p, q))
    return ops

double_excitation_ops

double_excitation_ops(
    p: int,
    q: int,
    r: int,
    s: int,
    theta: float,
    ancilla1: int = None,
    ancilla2: int = None,
) -> List[Tuple]

Circuit for exp(theta * (a_p^dagger a_q^dagger a_r a_s - h.c.)), the UCCD double-excitation unitary, for p < q < r < s.

Exact closed form (no Trotter error) when ancilla1/ancilla2 are both given AND the occupied pair is adjacent (q == p+1) AND the virtual pair is adjacent (s == r+1) -- the gap between q and r can be any length. This is what qml.qchem.excitations always produces for a 2-occupied/2-virtual active space, and sometimes for larger ones.

Falls back to per-term exponentiation of the 8-term Pauli decomposition whenever no ancillas are given, or the pairs aren't adjacent -- verified exact (not an approximation), just more gates than the closed form; see the module docstring for why the closed form doesn't generalize to a non-adjacent occupied or virtual pair yet.

ancilla1, ancilla2 : two qubits that MUST be |0> on entry and are guaranteed returned to |0> on exit -- only touched by the closed-form path (never referenced by the per-term fallback), so the same two ancillas can be reused across every double excitation in an ansatz. Omit both (leave as None) to force the ancilla-free per-term path unconditionally -- e.g. when the caller's qubit register has no spare qubits to offer.

Source code in dense_evolution/circuits/uccsd.py
def double_excitation_ops(p: int, q: int, r: int, s: int, theta: float,
                           ancilla1: int = None, ancilla2: int = None) -> List[Tuple]:
    """
    Circuit for exp(theta * (a_p^dagger a_q^dagger a_r a_s - h.c.)), the
    UCCD double-excitation unitary, for p < q < r < s.

    Exact closed form (no Trotter error) when ancilla1/ancilla2 are both
    given AND the occupied pair is adjacent (q == p+1) AND the virtual
    pair is adjacent (s == r+1) -- the gap between q and r can be any
    length. This is what qml.qchem.excitations always produces for a
    2-occupied/2-virtual active space, and sometimes for larger ones.

    Falls back to per-term exponentiation of the 8-term Pauli
    decomposition whenever no ancillas are given, or the pairs aren't
    adjacent -- verified exact (not an approximation), just more gates
    than the closed form; see the module docstring for why the closed
    form doesn't generalize to a non-adjacent occupied or virtual pair
    yet.

    ancilla1, ancilla2 : two qubits that MUST be |0> on entry and are
    guaranteed returned to |0> on exit -- only touched by the
    closed-form path (never referenced by the per-term fallback), so
    the same two ancillas can be reused across every double excitation
    in an ansatz. Omit both (leave as None) to force the ancilla-free
    per-term path unconditionally -- e.g. when the caller's qubit
    register has no spare qubits to offer.
    """
    if not p < q < r < s:
        raise ValueError(f"double_excitation_ops requires p<q<r<s, got {p},{q},{r},{s}")

    have_ancillas = ancilla1 is not None and ancilla2 is not None
    if have_ancillas and q == p + 1 and s == r + 1:
        return _double_excitation_ops_closed_form(p, q, r, s, theta, ancilla1, ancilla2)
    return _double_excitation_ops_per_term(p, q, r, s, theta)