Source code for uniqc.algorithmics.ansatz.qaoa_ansatz

"""QAOA (Quantum Approximate Optimization Algorithm) ansatz.

Constructs the alternating-operator ansatz used in QAOA for solving
combinatorial optimisation problems.
"""

__all__ = ["qaoa_ansatz"]

from typing import List, Optional, Tuple
import numpy as np
from uniqc.circuit_builder import Circuit


def _parse_pauli_string(pauli_string: str) -> List[Tuple[str, int]]:
    """Parse a Pauli string like 'Z0Z1' or 'X0Y1Z2' into [(op, qubit), ...]."""
    terms = []
    current_op = None
    current_idx = ""
    for ch in pauli_string:
        if ch in "XYZI":
            if current_op is not None:
                terms.append((current_op, int(current_idx)))
            current_op = ch
            current_idx = ""
        elif ch.isdigit():
            current_idx += ch
    if current_op is not None:
        terms.append((current_op, int(current_idx)))
    return terms


def _apply_cost_unitary(
    circuit: Circuit,
    hamiltonian_terms: List[Tuple[str, float]],
    gamma: float,
) -> None:
    """Apply the cost-function unitary exp(-i γ H_C).

    For each Pauli string with coefficient h, applies exp(-i γ h P).
    """
    for pauli_str, coeff in hamiltonian_terms:
        angle = 2 * gamma * coeff
        terms = _parse_pauli_string(pauli_str)

        # Filter out identity terms
        non_id = [(op, q) for op, q in terms if op != "I"]
        if not non_id:
            continue

        # Apply basis rotation for non-Z terms, then CNOT cascade, Rz, undo
        # Step 1: Rotate non-Z qubits to Z basis
        # Note: Y-basis rotation uses H·Rz(-π/2) (equivalent to S†·H).
        # When the angle happens to be exactly 0, Circuit.rz omits the
        # parameter, which crashes the OriginIR parser.  We therefore skip
        # the gate entirely when the angle is negligible.
        for op, q in non_id:
            if op == "X":
                circuit.h(q)
            elif op == "Y":
                _angle = -np.pi / 2
                circuit.rz(q, float(_angle))
                circuit.h(q)

        # Step 2: CNOT cascade
        for i in range(len(non_id) - 1):
            circuit.cx(non_id[i][1], non_id[i + 1][1])

        # Step 3: Rz on last qubit
        if abs(angle) > 1e-15:
            circuit.rz(non_id[-1][1], float(angle))

        # Step 4: Undo CNOT cascade
        for i in range(len(non_id) - 2, -1, -1):
            circuit.cx(non_id[i][1], non_id[i + 1][1])

        # Step 5: Undo basis rotations
        for op, q in non_id:
            if op == "X":
                circuit.h(q)
            elif op == "Y":
                _angle = np.pi / 2
                circuit.rz(q, float(_angle))
                circuit.h(q)


def _apply_mixer_unitary(
    circuit: Circuit,
    n_qubits: int,
    qubits: List[int],
    beta: float,
) -> None:
    """Apply the mixer unitary exp(-i β Σ X_i) = Π Rx(2β)."""
    for q in qubits:
        circuit.h(q)
        if abs(2 * beta) > 1e-15:
            circuit.rz(q, float(2 * beta))
        circuit.h(q)


[docs] def qaoa_ansatz( cost_hamiltonian: List[Tuple[str, float]], p: int = 1, qubits: Optional[List[int]] = None, betas: Optional[np.ndarray] = None, gammas: Optional[np.ndarray] = None, ) -> Circuit: """Build a QAOA ansatz circuit. The ansatz alternates between the cost unitary :math:`U_C(\\gamma) = e^{-i\\gamma H_C}` and the mixer unitary :math:`U_M(\\beta) = e^{-i\\beta \\sum X_i}` for *p* layers. Args: cost_hamiltonian: List of ``(pauli_string, coefficient)`` tuples. Pauli strings use the format ``"Z0Z1"``, ``"X0Y1Z2"``, etc. p: Number of QAOA layers. qubits: Qubit indices. ``None`` → auto-detect from hamiltonian. betas: Mixer angles, length *p*. ``None`` → random. gammas: Cost angles, length *p*. ``None`` → random. Returns: A :class:`Circuit` object. Raises: ValueError: Angle arrays have wrong length. Example: >>> from uniqc.algorithmics.ansatz import qaoa_ansatz >>> H = [("Z0Z1", 1.0), ("Z1Z2", 1.0), ("Z0Z2", 0.5)] >>> c = qaoa_ansatz(H, p=2) """ # Determine qubit set all_qubits = set() for pauli_str, _ in cost_hamiltonian: for _, q in _parse_pauli_string(pauli_str): all_qubits.add(q) n_qubits = max(all_qubits) + 1 if all_qubits else 0 if qubits is None: qubits = list(range(n_qubits)) else: qubits = list(qubits) if betas is None: betas = np.random.uniform(0, np.pi, size=p) if gammas is None: gammas = np.random.uniform(0, np.pi, size=p) betas = np.asarray(betas) gammas = np.asarray(gammas) if len(betas) != p: raise ValueError(f"betas length ({len(betas)}) must equal p ({p})") if len(gammas) != p: raise ValueError(f"gammas length ({len(gammas)}) must equal p ({p})") circuit = Circuit() # Initial state: Hadamard on all qubits (uniform superposition) for q in qubits: circuit.h(q) # QAOA layers for layer in range(p): _apply_cost_unitary(circuit, cost_hamiltonian, float(gammas[layer])) _apply_mixer_unitary(circuit, n_qubits, qubits, float(betas[layer])) return circuit