# SPDX-FileCopyrightText: 2025-2026 Qoro Quantum Ltd <divi@qoroquantum.de>
#
# SPDX-License-Identifier: Apache-2.0
"""QAOA mixer and driver Hamiltonian builders backed by ``SparsePauliOp``."""
import itertools
from collections.abc import Iterable, Sequence
import networkx as nx
from qiskit.quantum_info import SparsePauliOp
# ---------------------------------------------------------------------------
# Big-endian Pauli-label builders for ``SparsePauliOp.from_list``.
# Position ``n_qubits - 1 - qubit`` in the label corresponds to qubit ``qubit``.
# ---------------------------------------------------------------------------
def single_pauli_label(n_qubits: int, qubit: int, pauli: str) -> str:
"""Big-endian label with ``pauli`` at ``qubit`` and ``I`` elsewhere."""
label = ["I"] * n_qubits
label[n_qubits - 1 - qubit] = pauli
return "".join(label)
def two_pauli_label(n_qubits: int, left: int, right: int, pauli: str) -> str:
"""Big-endian label with ``pauli`` at both ``left`` and ``right`` qubits."""
label = ["I"] * n_qubits
label[n_qubits - 1 - left] = pauli
label[n_qubits - 1 - right] = pauli
return "".join(label)
def multi_pauli_label(n_qubits: int, qubit_paulis: Sequence[tuple[int, str]]) -> str:
"""Big-endian label with the given ``(qubit, pauli)`` assignments."""
label = ["I"] * n_qubits
for qubit, pauli in qubit_paulis:
label[n_qubits - 1 - qubit] = pauli
return "".join(label)
def _validate_int_nodes(nodes: Iterable[int], builder: str) -> None:
"""Raise if any node is not a non-negative integer."""
for node in nodes:
if not isinstance(node, int):
raise TypeError(f"{builder} requires integer qubit nodes.")
if node < 0:
raise ValueError(f"{builder} requires non-negative qubit nodes.")
[docs]
def x_mixer(n_qubits: int) -> SparsePauliOp:
"""Return the standard QAOA X mixer ``sum_i X_i``."""
if n_qubits < 0:
raise ValueError("n_qubits must be non-negative.")
if n_qubits == 0:
return SparsePauliOp.from_list([("", 0.0)])
return SparsePauliOp.from_list(
[(single_pauli_label(n_qubits, qubit, "X"), 1.0) for qubit in range(n_qubits)]
)
[docs]
def xy_mixer(
graph: nx.Graph | Iterable[tuple[int, int]], *, n_qubits: int | None = None
) -> SparsePauliOp:
"""Return the XY mixer ``0.5 * sum_(i,j) (X_i X_j + Y_i Y_j)``.
The graph nodes must be integer qubit indices. ``n_qubits`` may be
provided to preserve isolated trailing qubits.
"""
nodes: set[int]
if isinstance(graph, nx.Graph):
edges = list(graph.edges())
nodes = set(graph.nodes()) # type: ignore[bad-assignment]
else:
edges = list(graph)
nodes = {n for edge in edges for n in edge}
_validate_int_nodes(nodes, "xy_mixer")
inferred = max(nodes, default=-1) + 1
n_qubits = inferred if n_qubits is None else n_qubits
if n_qubits < inferred:
raise ValueError("n_qubits is smaller than the largest graph node.")
if n_qubits == 0:
return SparsePauliOp.from_list([("", 0.0)])
terms: list[tuple[str, float]] = []
for left, right in edges:
terms.append((two_pauli_label(n_qubits, left, right, "X"), 0.5))
terms.append((two_pauli_label(n_qubits, left, right, "Y"), 0.5))
if not terms:
return SparsePauliOp.from_list([("I" * n_qubits, 0.0)])
return SparsePauliOp.from_list(terms)
[docs]
def bit_driver(n_qubits: int, b: int) -> SparsePauliOp:
"""Bit-driver Hamiltonian ``(-1)^(b+1) * sum_i Z_i``.
``b=1`` returns ``+sum_i Z_i`` (rewards bitstrings with majority 1s);
``b=0`` returns ``-sum_i Z_i`` (rewards bitstrings with majority 0s).
"""
if b not in (0, 1):
raise ValueError(f"'b' must be either 0 or 1, got {b}")
if n_qubits < 0:
raise ValueError("n_qubits must be non-negative.")
if n_qubits == 0:
return SparsePauliOp.from_list([("", 0.0)])
coeff = 1.0 if b == 1 else -1.0
return SparsePauliOp.from_list(
[(single_pauli_label(n_qubits, qubit, "Z"), coeff) for qubit in range(n_qubits)]
)
[docs]
def edge_driver(
graph: nx.Graph | Iterable[tuple[int, int]],
reward: list[str],
*,
n_qubits: int | None = None,
) -> SparsePauliOp:
"""Edge-driver Hamiltonian rewarding edges whose endpoints match ``reward``.
Mirrors the formulations in
`pennylane.qaoa.cost.edge_driver
<https://docs.pennylane.ai/en/stable/code/api/pennylane.qaoa.cost.edge_driver.html>`__:
* ``reward`` ⊆ ``{"00","01","10","11"}``; ``"01"`` and ``"10"`` are
treated as the undirected pair (must appear together or not at all).
* ``len(reward) ∈ {0, 4}`` reduces to a constant identity term.
* Otherwise the difference between rewarded and penalised energies is
always ``1``.
The graph nodes must be integer qubit indices. ``n_qubits`` may be
provided to preserve isolated trailing qubits.
"""
allowed = {"00", "01", "10", "11"}
bad = [e for e in reward if e not in allowed]
if bad:
raise ValueError(
f"Encountered invalid entry in 'reward', expected 2-bit "
f"bitstrings; got {bad}."
)
if ("01" in reward) ^ ("10" in reward):
raise ValueError(
"'reward' cannot contain either '10' or '01' alone; must contain "
"neither or both."
)
nodes: set[int]
if isinstance(graph, nx.Graph):
edges = list(graph.edges())
nodes = set(graph.nodes()) # type: ignore[bad-assignment]
else:
edges = list(graph)
nodes = {n for edge in edges for n in edge}
_validate_int_nodes(nodes, "edge_driver")
inferred = max(nodes, default=-1) + 1
n_qubits = inferred if n_qubits is None else n_qubits
if n_qubits < inferred:
raise ValueError("n_qubits is smaller than the largest graph node.")
if n_qubits == 0:
return SparsePauliOp.from_list([("", 0.0)])
# Constant Hamiltonian (no preference).
if len(reward) == 0 or len(reward) == 4:
return SparsePauliOp.from_list(
[(single_pauli_label(n_qubits, qubit, "I"), 1.0) for qubit in nodes]
or [("I" * n_qubits, 0.0)]
)
# Collapse undirected edges by removing "01" duplicate.
canonical = list(set(reward) - {"01"})
sign = -1.0
if len(canonical) == 2:
canonical = list({"00", "10", "11"} - set(canonical))
sign = 1.0
selector = canonical[0]
terms: list[tuple[str, float]] = []
for left, right in edges:
zz = two_pauli_label(n_qubits, left, right, "Z")
zl = single_pauli_label(n_qubits, left, "Z")
zr = single_pauli_label(n_qubits, right, "Z")
if selector == "00":
terms.extend([(zz, 0.25 * sign), (zl, 0.25 * sign), (zr, 0.25 * sign)])
elif selector == "10":
terms.append((zz, -0.5 * sign))
elif selector == "11":
terms.extend([(zz, 0.25 * sign), (zl, -0.25 * sign), (zr, -0.25 * sign)])
if not terms:
return SparsePauliOp.from_list([("I" * n_qubits, 0.0)])
return SparsePauliOp.from_list(terms)
[docs]
def bit_flip_mixer(graph: nx.Graph, b: int) -> SparsePauliOp:
"""Bit-flip mixer over ``graph`` (per Hadfield et al. 2019).
For each vertex ``v`` with neighbour set ``N(v)`` of degree ``d(v)``:
.. math::
\\frac{1}{2^{d(v)}} X_v \\prod_{w \\in N(v)} (I + (-1)^b Z_w)
expanded into ``2^{d(v)}`` Pauli terms per vertex.
The graph nodes must be non-negative integer qubit indices.
"""
if b not in (0, 1):
raise ValueError(f"'b' must be either 0 or 1, got {b}")
if not isinstance(graph, nx.Graph):
raise TypeError("bit_flip_mixer requires a networkx.Graph.")
nodes = list(graph.nodes())
_validate_int_nodes(nodes, "bit_flip_mixer")
n_qubits = (max(nodes) + 1) if nodes else 0
if n_qubits == 0:
return SparsePauliOp.from_list([("", 0.0)])
sign = 1.0 if b == 0 else -1.0
terms: list[tuple[str, float]] = []
for vertex in nodes:
neighbours = list(graph.neighbors(vertex))
degree = len(neighbours)
scale = 0.5**degree
# Each neighbour contributes either I or sign*Z.
for choice in itertools.product([(None, 1.0), ("Z", sign)], repeat=degree):
qubit_paulis: list[tuple[int, str]] = [(vertex, "X")]
coeff = scale
for neighbour, (op, sgn) in zip(neighbours, choice):
coeff *= sgn
if op is not None:
qubit_paulis.append((neighbour, op))
terms.append((multi_pauli_label(n_qubits, qubit_paulis), coeff))
if not terms:
return SparsePauliOp.from_list([("I" * n_qubits, 0.0)])
return SparsePauliOp.from_list(terms)