# SPDX-FileCopyrightText: 2025-2026 Qoro Quantum Ltd <divi@qoroquantum.de>
#
# SPDX-License-Identifier: Apache-2.0
"""Built-in QAOA / VQE ansätze.
Every ``Ansatz.build`` creates and returns a :class:`~qiskit.circuit.QuantumCircuit`.
Chemistry ansätze (``UCCSDAnsatz``, ``HartreeFockAnsatz``,
``QCCAnsatz``) source excitation / Hartree-Fock data from ``pennylane.qchem``
and route the PL gates through the local PL → Qiskit converter; consumers
always see Qiskit instructions.
"""
import inspect
from abc import ABC, abstractmethod
from collections.abc import Callable, Iterable, Mapping, Sequence
from typing import Literal
from warnings import warn
import numpy as np
import pennylane as qp
from qiskit.circuit import Gate, QuantumCircuit
from qiskit.circuit.library import RXGate, RYGate, RZGate
from divi.circuits._conversions import _qscript_to_dag
from divi.hamiltonians._term_ops import _HALF_PI
def _require_trainable_params(n_params: int, ansatz_name: str) -> int:
if n_params <= 0:
raise ValueError(
f"{ansatz_name} must define at least one trainable parameter. "
"Parameter-free circuits are not supported."
)
return n_params
def _pl_ops_to_qc(pl_ops: Sequence, n_qubits: int) -> QuantumCircuit:
"""Translate ``pl_ops`` to Qiskit gates and return a circuit on ``n_qubits`` qubits."""
qc = QuantumCircuit(n_qubits)
if not pl_ops:
return qc
script = qp.tape.QuantumScript(list(pl_ops))
dag, _params, _wire_map = _qscript_to_dag(script)
for node in dag.topological_op_nodes():
qubit_indices = [dag.qubits.index(q) for q in node.qargs]
qc.append(node.op, [qc.qubits[i] for i in qubit_indices])
return qc
[docs]
class Ansatz(ABC):
"""Abstract base class for all VQE ansätze."""
@property
def name(self) -> str:
"""Returns the human-readable name of the ansatz."""
return self.__class__.__name__
[docs]
@staticmethod
@abstractmethod
def n_params_per_layer(n_qubits: int, **kwargs) -> int:
"""Returns the number of parameters required by the ansatz for one layer."""
raise NotImplementedError
[docs]
@abstractmethod
def build(self, params, n_qubits: int, n_layers: int, **kwargs) -> QuantumCircuit:
"""
Builds the ansatz circuit and returns a list of operations.
Args:
params: Parameter array for the ansatz.
n_qubits (int): Number of qubits in the circuit.
n_layers (int): Number of ansatz layers.
**kwargs: Additional arguments specific to the ansatz.
Returns:
QuantumCircuit: The ansatz circuit on ``n_qubits`` qubits.
"""
raise NotImplementedError
# --- Template Ansätze ---
def _gate_n_params(gate_cls: type[Gate]) -> int:
"""Number of free parameters a Qiskit ``Gate`` class takes — i.e. the count
of required positional args of its ``__init__`` (Qiskit encodes rotation
angles as required positionals; see e.g. :class:`RXGate`, :class:`UGate`).
"""
return sum(
1
for name, p in inspect.signature(gate_cls.__init__).parameters.items()
if name != "self"
and p.default is inspect.Parameter.empty
and p.kind
in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD)
)
def _gate_n_qubits(gate_cls: type[Gate]) -> int:
"""Qubit arity of a Qiskit ``Gate`` subclass via a zero-parameter probe."""
probe = gate_cls(*([0.0] * _gate_n_params(gate_cls))) # type: ignore[bad-argument-type]
return probe.num_qubits
def _validate_gate_cls(
cls,
*,
expected_qubits: int,
role: str,
example: str,
expected_params: int | None = None,
) -> None:
"""Reject anything that is not a Qiskit ``Gate`` subclass of the right arity.
If ``expected_params`` is provided, also reject gate classes whose
``__init__`` requires a different number of positional parameters.
"""
if not (isinstance(cls, type) and issubclass(cls, Gate)):
raise TypeError(
f"{role} must be a Qiskit Gate subclass ({example}), got {cls!r}."
)
n_q = _gate_n_qubits(cls)
if n_q != expected_qubits:
raise ValueError(
f"{role} must be a {expected_qubits}-qubit gate; "
f"{cls.__name__} acts on {n_q} qubits."
)
if expected_params is not None:
n_p = _gate_n_params(cls)
if n_p != expected_params:
raise ValueError(
f"{role} must take {expected_params} parameters; "
f"{cls.__name__} takes {n_p}."
)
[docs]
class GenericLayerAnsatz(Ansatz):
"""
A flexible ansatz alternating single-qubit gates with optional entanglers.
"""
_layout_fn: Callable[[int], Iterable[tuple[int, int]]]
def __init__(
self,
gate_sequence: Sequence[type[Gate]],
entangler: type[Gate] | None = None,
entangling_layout: (
Literal["linear", "brick", "circular", "all-to-all"]
| Sequence[tuple[int, int]]
| None
) = None,
):
"""
Args:
gate_sequence: Sequence of one-qubit Qiskit ``Gate`` subclasses
(e.g., ``RYGate``, ``RZGate``).
entangler: Two-qubit Qiskit ``Gate`` subclass (e.g., ``CXGate``,
``CZGate``). If None, no entanglement is applied.
entangling_layout (str): Layout for entangling layer ("linear", "all-to-all", etc.).
"""
for cls in gate_sequence:
_validate_gate_cls(
cls,
expected_qubits=1,
role="gate_sequence entries",
example="e.g. RYGate, RZGate",
)
if entangler is not None:
_validate_gate_cls(
entangler,
expected_qubits=2,
role="entangler",
example="e.g. CXGate, CZGate",
expected_params=0,
)
self.gate_sequence = list(gate_sequence)
self._gate_param_counts = [_gate_n_params(g) for g in self.gate_sequence]
self.entangler = entangler
self.entangling_layout = entangling_layout
if entangler is None and entangling_layout is not None:
warn("`entangling_layout` provided but `entangler` is None.")
match entangling_layout:
case None | "linear":
self.entangling_layout = "linear"
self._layout_fn = lambda n_qubits: zip(
range(n_qubits), range(1, n_qubits)
)
case "brick":
self._layout_fn = lambda n_qubits: [
(i, i + 1) for r in range(2) for i in range(r, n_qubits - 1, 2)
]
case "circular":
self._layout_fn = lambda n_qubits: zip(
range(n_qubits), [(i + 1) % n_qubits for i in range(n_qubits)]
)
case "all-to-all":
self._layout_fn = lambda n_qubits: (
(i, j) for i in range(n_qubits) for j in range(i + 1, n_qubits)
)
case list() | tuple() as custom_layout:
if not all(
isinstance(ent, tuple)
and len(ent) == 2
and isinstance(ent[0], int)
and isinstance(ent[1], int)
for ent in custom_layout
):
raise ValueError(
"entangling_layout must be 'linear', 'circular', "
"'all-to-all', or a Sequence of tuples of integers."
)
self._layout_fn = lambda _: list(custom_layout)
case _:
raise ValueError(
f"Unknown entangling_layout: {entangling_layout!r}. "
"Must be 'linear', 'circular', 'all-to-all', or "
"a Sequence of (int, int) tuples."
)
[docs]
def n_params_per_layer(self, n_qubits: int, **kwargs) -> int:
"""``sum(_gate_n_params(g) for g in gate_sequence) * n_qubits``."""
per_qubit = sum(self._gate_param_counts)
return _require_trainable_params(per_qubit * n_qubits, self.name)
[docs]
def build(self, params, n_qubits: int, n_layers: int, **kwargs) -> QuantumCircuit:
qc = QuantumCircuit(n_qubits)
gate_param_counts = self._gate_param_counts
per_qubit = sum(gate_param_counts)
params = np.asarray(params, dtype=object).reshape(n_layers, n_qubits, per_qubit)
layout = list(self._layout_fn(n_qubits))
for layer_idx in range(n_layers):
layer_params = params[layer_idx]
for q, qubit_params in zip(range(n_qubits), layer_params):
idx = 0
for gate_cls, n_p in zip(self.gate_sequence, gate_param_counts):
args = list(qubit_params[idx : idx + n_p])
qc.append(gate_cls(*args), [q])
idx += n_p
if self.entangler is not None:
for wire_a, wire_b in layout:
qc.append(self.entangler(), [wire_a, wire_b]) # type: ignore[call-arg]
return qc
def _emit_rx(qc: QuantumCircuit, theta, q: int) -> None:
qc.rx(theta, q)
def _emit_ry(qc: QuantumCircuit, theta, q: int) -> None:
qc.ry(theta, q)
def _emit_rz(qc: QuantumCircuit, theta, q: int) -> None:
qc.rz(theta, q)
_QAOA_LOCAL_FIELDS: Mapping[
type[Gate], Callable[[QuantumCircuit, object, int], None]
] = {
RXGate: _emit_rx,
RYGate: _emit_ry,
RZGate: _emit_rz,
}
[docs]
class QAOAAnsatz(Ansatz):
"""QAOA-style ansatz inspired by Killoran et al. (2020).
Each of the ``L`` layers consists of a Hadamard encoding layer followed
by a weight Hamiltonian:
* for ``n_qubits == 1`` — a single local-field rotation;
* for ``n_qubits == 2`` — one ``RZZ`` on the pair, then one local field
per qubit (no wrap-around);
* for ``n_qubits >= 3`` — ``RZZ`` gates on a closed ring (``i ↔ (i+1) %
n``), then one local field per qubit.
A trailing Hadamard layer is applied after the ``L``-th weight
Hamiltonian. The default local field is ``RYGate``.
Args:
local_field: Single-qubit rotation used as the local field. Must be
one of ``RXGate``, ``RYGate``, ``RZGate``. Defaults to ``RYGate``.
"""
def __init__(self, local_field: type[Gate] = RYGate) -> None:
if local_field not in _QAOA_LOCAL_FIELDS:
raise ValueError(
f"local_field must be one of RXGate, RYGate, RZGate; "
f"got {local_field!r}."
)
self.local_field = local_field
self._emit_local_field = _QAOA_LOCAL_FIELDS[local_field]
[docs]
@staticmethod
def n_params_per_layer(n_qubits: int, **kwargs) -> int:
"""Per-layer parameter count.
* ``n_qubits == 1`` → ``1`` (single local-field rotation)
* ``n_qubits == 2`` → ``3`` (``RZZ`` + one local field per qubit)
* ``n_qubits >= 3`` → ``2 * n_qubits`` (ring of ``RZZ`` + per-qubit local field)
"""
if n_qubits == 1:
n_params = 1
elif n_qubits == 2:
n_params = 3
else:
n_params = 2 * n_qubits
return _require_trainable_params(n_params, QAOAAnsatz.__name__)
[docs]
def build(self, params, n_qubits: int, n_layers: int, **kwargs) -> QuantumCircuit:
"""Build the QAOA ansatz circuit.
Args:
params: Flat parameter array of length
``n_layers * n_params_per_layer(n_qubits)``.
n_qubits: Number of qubits.
n_layers: Number of QAOA layers.
**kwargs: Additional unused arguments.
Returns:
QuantumCircuit: Qiskit circuit implementing the QAOA ansatz.
"""
per_layer = self.n_params_per_layer(n_qubits)
layered = np.asarray(params, dtype=object).reshape(n_layers, per_layer)
qc = QuantumCircuit(n_qubits)
for layer in range(n_layers):
# Encoding Hamiltonian: Hadamard on every qubit.
for q in range(n_qubits):
qc.h(q)
# Weight Hamiltonian.
weights = layered[layer]
if n_qubits == 1:
self._emit_local_field(qc, weights[0], 0)
elif n_qubits == 2:
_emit_two_qubit_pauli_rot(qc, "ZZ", weights[0], 0, 1)
self._emit_local_field(qc, weights[1], 0)
self._emit_local_field(qc, weights[2], 1)
else:
for q in range(n_qubits):
_emit_two_qubit_pauli_rot(
qc, "ZZ", weights[q], q, (q + 1) % n_qubits
)
for q in range(n_qubits):
self._emit_local_field(qc, weights[n_qubits + q], q)
# Trailing encoding layer.
for q in range(n_qubits):
qc.h(q)
return qc
# --- Chemistry Ansätze ---
[docs]
class UCCSDAnsatz(Ansatz):
"""
Unitary Coupled Cluster Singles and Doubles (UCCSD) ansatz.
This ansatz is specifically designed for quantum chemistry calculations,
implementing the UCCSD approximation which includes all single and double
electron excitations from a reference state.
"""
[docs]
@staticmethod
def n_params_per_layer(n_qubits: int, **kwargs) -> int:
"""``len(s_wires) + len(d_wires)`` from ``qp.qchem.excitations`` for
the given ``n_electrons`` (required kwarg)."""
n_electrons = kwargs.pop("n_electrons")
singles, doubles = qp.qchem.excitations(n_electrons, n_qubits)
s_wires, d_wires = qp.qchem.excitations_to_wires(singles, doubles)
n_params = len(s_wires) + len(d_wires)
return _require_trainable_params(n_params, UCCSDAnsatz.__name__)
[docs]
def build(self, params, n_qubits: int, n_layers: int, **kwargs) -> QuantumCircuit:
n_electrons = kwargs.pop("n_electrons")
singles, doubles = qp.qchem.excitations(n_electrons, n_qubits)
s_wires, d_wires = qp.qchem.excitations_to_wires(singles, doubles)
hf_state = qp.qchem.hf_state(n_electrons, n_qubits)
params = np.asarray(params, dtype=object).reshape(n_layers, -1)
pl_ops = qp.UCCSD.compute_decomposition(
params,
wires=range(n_qubits),
s_wires=s_wires,
d_wires=d_wires,
init_state=hf_state,
n_repeats=n_layers,
)
return _pl_ops_to_qc(pl_ops, n_qubits)
[docs]
class HartreeFockAnsatz(Ansatz):
"""
Hartree-Fock-based ansatz for quantum chemistry.
This ansatz prepares the Hartree-Fock reference state and applies
parameterized single and double excitation gates. It's a simplified
alternative to UCCSD, often used as a starting point for VQE calculations.
"""
[docs]
@staticmethod
def n_params_per_layer(n_qubits: int, **kwargs) -> int:
"""``len(singles) + len(doubles)`` from ``qp.qchem.excitations`` for
the given ``n_electrons`` (required kwarg)."""
n_electrons = kwargs.pop("n_electrons")
singles, doubles = qp.qchem.excitations(n_electrons, n_qubits)
n_params = len(singles) + len(doubles)
return _require_trainable_params(n_params, HartreeFockAnsatz.__name__)
[docs]
def build(self, params, n_qubits: int, n_layers: int, **kwargs) -> QuantumCircuit:
n_electrons = kwargs.pop("n_electrons")
singles, doubles = qp.qchem.excitations(n_electrons, n_qubits)
hf_state = qp.qchem.hf_state(n_electrons, n_qubits)
params = np.asarray(params, dtype=object).reshape(n_layers, -1)
pl_ops: list = []
for layer_idx, layer_params in enumerate(params):
layer_ops = list(
qp.AllSinglesDoubles.compute_decomposition(
layer_params,
wires=range(n_qubits),
hf_state=hf_state,
singles=singles,
doubles=doubles,
)
)
# Only the first layer should prepare the Hartree-Fock state; reset
# the basis-state init for subsequent layers.
if layer_idx > 0:
layer_ops = [op for op in layer_ops if op.name != "BasisState"]
pl_ops.extend(layer_ops)
return _pl_ops_to_qc(pl_ops, n_qubits)
[docs]
class QCCAnsatz(Ansatz):
"""Qubit Coupled Cluster ansatz.
Hartree-Fock ``X`` flips on occupied orbitals, then per-layer single-qubit
``RY`` rotations followed by Pauli-word exponentials (``XX``, ``YY``,
``ZZ``) on adjacent qubit pairs.
"""
[docs]
@staticmethod
def n_params_per_layer(n_qubits: int, **kwargs) -> int:
"""``n_qubits`` single-qubit ``RY`` rotations plus ``3 * (n_qubits - 1)``
entangler parameters (one ``XX``, ``YY``, ``ZZ`` per adjacent pair)."""
n_params = n_qubits + 3 * (n_qubits - 1)
return _require_trainable_params(n_params, QCCAnsatz.__name__)
[docs]
def build(self, params, n_qubits: int, n_layers: int, **kwargs) -> QuantumCircuit:
n_electrons = kwargs.pop("n_electrons")
hf_state = qp.qchem.hf_state(n_electrons, n_qubits)
params = np.asarray(params, dtype=object).reshape(n_layers, -1)
qc = QuantumCircuit(n_qubits)
# Hartree-Fock prep: ``hf_state`` is a 0/1 vector of length n_qubits.
for q, bit in enumerate(hf_state):
if bit:
qc.x(q)
n_singles = n_qubits
for layer_params in params:
for q in range(n_qubits):
qc.ry(layer_params[q], q)
ent_params = layer_params[n_singles:]
ent_idx = 0
for q in range(n_qubits - 1):
# exp(-i theta/2 * P) on qubits (q, q+1) for P in {XX, YY, ZZ}.
for pauli in ("XX", "YY", "ZZ"):
theta = ent_params[ent_idx]
_emit_two_qubit_pauli_rot(qc, pauli, theta, q, q + 1)
ent_idx += 1
return qc
def _emit_two_qubit_pauli_rot(
qc: QuantumCircuit, pauli: str, theta, q1: int, q2: int
) -> None:
"""Emit ``exp(-i theta/2 * P)`` for ``P ∈ {XX, YY, ZZ}`` onto ``qc`` as a
``H``/``RX(±π/2)`` basis change plus a CX-RZ-CX ladder.
"""
if pauli == "XX":
qc.h(q1)
qc.h(q2)
qc.cx(q1, q2)
qc.rz(theta, q2)
qc.cx(q1, q2)
qc.h(q1)
qc.h(q2)
elif pauli == "YY":
qc.rx(_HALF_PI, q1)
qc.rx(_HALF_PI, q2)
qc.cx(q1, q2)
qc.rz(theta, q2)
qc.cx(q1, q2)
qc.rx(-_HALF_PI, q1)
qc.rx(-_HALF_PI, q2)
elif pauli == "ZZ":
qc.cx(q1, q2)
qc.rz(theta, q2)
qc.cx(q1, q2)
else:
raise ValueError(f"Unsupported two-qubit Pauli {pauli!r}; expected XX/YY/ZZ.")