Source code for qrisp.algorithms.cold.problems.QUBO

# ********************************************************************************
# * Copyright (c) 2026 the Qrisp authors
# *
# * This program and the accompanying materials are made available under the
# * terms of the Eclipse Public License 2.0 which is available at
# * http://www.eclipse.org/legal/epl-2.0.
# *
# * This Source Code may also be made available under the following Secondary
# * Licenses when the conditions for such availability set forth in the Eclipse
# * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
# * with the GNU Classpath Exception which is
# * available at https://www.gnu.org/software/classpath/license.html.
# *
# * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0
# ********************************************************************************

"""Provides helper functions to build and solve QUBO problems using COLD/LCD counterdiabatic driving."""

import itertools
import warnings

import numpy as np
import sympy as sp

from qrisp.algorithms.cold import DCQOProblem
from qrisp.algorithms.cold.AGP_params import _solve_alpha
from qrisp.core import QuantumVariable
from qrisp.misc.exceptions import QrispDeprecationWarning
from qrisp.operators.qubit import QubitOperator, X, Y, Z

__all__ = ["create_COLD_instance", "create_LCD_instance", "solve_QUBO"]


def _normalize_agp_type(agp_type):
    """Map the deprecated agp_type ``"order1"`` to ``"local"`` with a deprecation warning, reject unknown values."""
    if agp_type == "order1":
        warnings.warn(
            "agp_type='order1' is deprecated and will be removed in version 0.11. Use agp_type='local' instead.",
            QrispDeprecationWarning,
            stacklevel=3,
        )
        return "local"
    if agp_type not in ("local", "nc"):
        raise ValueError(f"{agp_type} is not a valid option as agp_type. Valid options are 'local' and 'nc'.")
    return agp_type


def _local_agp_coeffs(h, J, lam, f=0.0, f_deriv=0.0, *, uniform=True):  # noqa: PLR0913 -- one coefficient formula, three call sites
    r"""First-order AGP coefficients for the ansatz $A_\lambda = \sum_i \alpha_i \sigma^y_i$.

    Minimises the action $S = \mathrm{Tr}[G_\lambda^2]$ with
    $G_\lambda = \partial_\lambda H + i[A_\lambda, H]$ for the Hamiltonian the circuit evolves,

    .. math::
        H(\lambda) = (1-\lambda)\sum_i \sigma^x_i
                    + \lambda\Big(\sum_{i<j} J_{ij}\sigma^z_i\sigma^z_j + \sum_i h_i\sigma^z_i\Big)
                    + f\sum_i \sigma^z_i .

    Writing $b = 1-\lambda$ and $c_i = \lambda h_i + f$, the Pauli strings in $G_\lambda$ are
    trace-orthogonal, so the action is quadratic in the coefficients and

    .. math::
        \alpha_i = -\frac{b\dot c_i - c_i\dot b}
                        {2\left(b^2 + c_i^2 + \lambda^2\sum_{j\neq i} J_{ij}^2\right)},
        \qquad b\dot c_i - c_i\dot b = h_i + f + (1-\lambda)f'.

    The uniform case shares one coefficient across all sites, which sums numerator and
    denominator over $i$. Both forms reproduce an exact matrix minimisation of $S$ to machine
    precision, and the same derivation applied to Eq. (23) of `COLD
    <https://doi.org/10.1103/PRXQuantum.4.010312>`_ reproduces its Eq. (30).

    Parameters
    ----------
    h : np.array
        Onsite energies of the problem Hamiltonian.
    J : np.array
        Coupling energies of the problem Hamiltonian.
    lam : float
        Value of the scheduling function at this timestep.
    f : float, optional
        Control pulse amplitude (COLD). Zero for LCD, which has no control Hamiltonian.
    f_deriv : float, optional
        Derivative of the control pulse with respect to ``lam``. Zero for LCD.
    uniform : bool, optional
        Whether to share a single coefficient across all sites. The default is ``True``.

    Returns
    -------
    alph : list[float]
        The AGP coefficient per site, of length ``len(h)``.

    """
    N = len(h)
    c = lam * h + f
    b = 1 - lam
    nom = h + f + b * f_deriv
    # sum_{j != i} J_ij**2, per site
    J_sq = np.sum(J**2, axis=1) - np.diag(J) ** 2

    if uniform:
        alph = -np.sum(nom) / (2 * (N * b**2 + np.sum(c**2) + lam**2 * np.sum(J_sq)))
        return [alph] * N

    return [-nom[i] / (2 * (b**2 + c[i] ** 2 + lam**2 * J_sq[i])) for i in range(N)]


def _nc_uniform_agp_coeffs(h, J):
    r"""Build the shared coefficient of the nested-commutator AGP, as a function of the schedule.

    Minimises the action $S = \mathrm{Tr}[G_\lambda^2]$, $G_\lambda = \partial_\lambda H + i[A_\lambda, H]$,
    for the single merged ansatz operator $A_\lambda = \sum_i A_i$ with

    .. math::
        A_i = -2\Big(h_i\sigma^y_i
              + \sum_{j<i} J_{ij}(\sigma^y_i\sigma^z_j + \sigma^z_i\sigma^y_j)\Big),

    against the Hamiltonian the circuit evolves,

    .. math::
        H(\lambda) = (1-\lambda)\sum_i \sigma^x_i
                   + \lambda\Big(\sum_{i<j} J_{ij}\sigma^z_i\sigma^z_j + \sum_i h_i\sigma^z_i\Big)
                   + f\sum_i \sigma^z_i .

    The action is quadratic in the coefficient and every trace evaluates in closed form, giving

    .. math::
        \alpha = \frac{S_{h^2} + 2S_2 + \big(f + (1-\lambda)f'\big)S_h}
                      {4\big[(S_{h^2} + 8S_2)(1-\lambda)^2
                       + \lambda^2 (S_{h^4} + 2S_4 + 6S_{h^2R} + 6S_{adj})
                       + 2\lambda (S_{h^3} + 3S_{hR})f
                       + (S_{h^2} + 2S_2)f^2\big]} .

    The numerator at $f = f' = 0$ reproduces Eq. (S11) of `BF-DCQO
    <https://arxiv.org/abs/2405.13898>`_ for a uniform transverse field. Every aggregate is
    $O(N^2)$ to build and the coefficient is then $O(1)$ per timestep, where an explicit
    minimal-action solve would cost $O(N^4)$.

    Parameters
    ----------
    h : np.array
        Onsite energies of the problem Hamiltonian.
    J : np.array
        Coupling energies of the problem Hamiltonian.

    Returns
    -------
    alpha : callable
        A function ``alpha(lam, f=0.0, f_deriv=0.0)`` returning the coefficient for every site.
        LCD has no control Hamiltonian and calls it with ``f = f_deriv = 0``.

    """
    N = len(h)
    h = np.asarray(h, dtype=float)
    J_sq = np.asarray(J, dtype=float) ** 2
    np.fill_diagonal(J_sq, 0.0)
    h_sq = h**2

    S_h = np.sum(h)
    S_h2 = np.sum(h_sq)
    S_h3 = np.sum(h**3)
    S_h4 = np.sum(h_sq**2)
    # sums over unordered pairs i < j
    S_2 = np.sum(J_sq) / 2
    S_4 = np.sum(J_sq**2) / 2
    # sum_{i<j} J_ij**2 (h_i + h_j) and sum_{i<j} J_ij**2 (h_i**2 + h_j**2)
    S_hR = np.sum(J_sq * h[None, :])
    S_hsqR = np.sum(J_sq * h_sq[None, :])
    # sum over unordered pairs of distinct edges sharing a site
    R_i = np.sum(J_sq, axis=1)
    S_adj = (np.sum(R_i**2) - 2 * S_4) / 2

    def alpha(lam, f=0.0, f_deriv=0.0):
        """AGP coefficient(s) at scheduling value ``lam``."""
        nom = S_h2 + 2 * S_2 + (f + (1 - lam) * f_deriv) * S_h
        denom = 4 * (
            (S_h2 + 8 * S_2) * (1 - lam) ** 2
            + lam**2 * (S_h4 + 2 * S_4 + 6 * S_hsqR + 6 * S_adj)
            + 2 * lam * (S_h3 + 3 * S_hR) * f
            + (S_h2 + 2 * S_2) * f**2
        )
        if denom == 0:
            return [0.0] * N
        return [nom / denom] * N

    return alpha


def _nested_commutator_operators(h, J):
    """A_i = -2 (h_i Y_i + sum_{j<i} J_ij (Y_i Z_j + Z_i Y_j)), the first-order NC ansatz.

    This is the ansatz of Eq. (1) of `BF-DCQO <https://arxiv.org/abs/2405.13898>`_, split into one
    operator per site so that non-uniform coefficients can be attached to it.
    """
    N = len(h)
    return [
        -2 * (h[i] * Y(i) + QubitOperator.sum(J[i][j] * (Y(i) * Z(j) + Z(i) * Y(j)) for j in range(i)))
        for i in range(N)
    ]


def create_COLD_instance(Q, uniform_AGP_coeffs, agp_type="local"):
    r"""Create the necessary parameters and operators to initialize a DCQO problem instance for COLD.

    Parameters
    ----------
    Q : np.array
        The QUBO Matrix to be encoded in the Hamiltonian.
        Must be symmetric: the cost is read as $y = x^T Q x$ with off-diagonal entries
        counted twice, and the Ising coefficients $J_{ij} = \frac{1}{2} Q_{ij}$,
        $h_i = -\frac{1}{2} \sum_j Q_{ij}$ derived from it are only valid for
        symmetric $Q$. Symmetrize an upper-triangular matrix with
        ``Q = 0.5 * (Q + Q.T)`` before passing it in.
    uniform_AGP_coeffs : bool
        Whether to approximate the AGP with uniform or non-uniform coefficients.
    agp_type : str, optional
        Which approximation of the AGP to use, either ``local`` (a sum of single-qubit Y
        operators) or ``nc`` (first-order nested commutators). The default is ``local``.
        ``nc`` is the better approximation and is recommended for short evolution times, and
        requires ``uniform_AGP_coeffs=True``: the non-uniform coefficients have no closed form and
        their solver cannot take the symbolic control pulse COLD compiles into the circuit. Use
        :func:`create_LCD_instance` for the non-uniform nested-commutator ansatz.

        .. deprecated:: 0.10

            The former name ``order1`` is deprecated and will be removed in version 0.11.
            Use ``local`` instead.

    Returns
    -------
    collected operators : tuple
        Tuple containing the following functions and operators:
        Scheduling function (lam(t)), function for AGP coefficients (alpha), initial and problem
        Hamiltonian (H_init, H_prob), AGP function (A_lam), Coupling and onsite energies of
        problem Hamiltonian (J, h), inverse scheduling function (g(lam)), control Hamiltonian (H_control).

    """
    agp_type = _normalize_agp_type(agp_type)
    N = len(Q[0])
    h = -0.5 * np.sum(Q, axis=1)
    J = 0.5 * Q

    def lam():
        """Symbolic scheduling function lambda(t)."""
        t, T = sp.symbols("t T", real=True)
        lam_expr = sp.sin(sp.pi / 2 * sp.sin(sp.pi * t / (2 * T)) ** 2) ** 2
        return lam_expr

    # AGP coefficients
    if agp_type == "local":

        def alpha(lam, f, f_deriv):
            """AGP coefficient(s) at scheduling value ``lam``."""
            return _local_agp_coeffs(h, J, lam, f, f_deriv, uniform=uniform_AGP_coeffs)

    elif agp_type == "nc" and uniform_AGP_coeffs:
        alpha = _nc_uniform_agp_coeffs(h, J)

    elif agp_type == "nc":
        # Non-uniform nested-commutator coefficients have no closed form; they need the explicit
        # minimal-action solve in _solve_alpha, which works on numpy arrays. COLD's exp_value
        # objective compiles a parametrized circuit and passes sympy Symbols as f and f_deriv,
        # which that solver cannot consume. Refuse rather than fail deep inside the compile.
        raise NotImplementedError(
            "agp_type='nc' with uniform_AGP_coeffs=False is not supported for COLD. Use "
            "uniform_AGP_coeffs=True, which has a closed-form coefficient, or run the "
            "non-uniform nested-commutator ansatz with method='LCD'."
        )

    # Initial Hamiltonian
    H_init = 1 * sum([X(i) for i in range(N)])

    # Problem Hamiltonian
    H_prob = QubitOperator.sum(
        itertools.chain(
            (J[i][j] * Z(i) * Z(j) for i in range(N) for j in range(i, N)),
            (h[i] * Z(i) for i in range(N)),
        )
    )

    # AGP as function of alpha. A single QubitOperator signals uniform coefficients to
    # DCQOProblem, a list of them one coefficient per site.
    if agp_type == "local":
        A_lam = sum([Y(i) for i in range(N)]) if uniform_AGP_coeffs else [Y(i) for i in range(N)]
    else:
        site_operators = _nested_commutator_operators(h, J)
        A_lam = QubitOperator.sum(site_operators) if uniform_AGP_coeffs else site_operators

    # Control Hamiltonian
    H_control = sum([Z(i) for i in range(N)])

    collected_operators = (Q, H_init, H_prob, A_lam, alpha, lam, H_control)

    return collected_operators


def create_LCD_instance(Q, agp_type, uniform_AGP_coeffs=True):
    r"""Create the necessary parameters and operators to initialize a DCQO problem instance for LCD.

    Parameters
    ----------
    Q : np.array
        The QUBO Matrix to be encoded in the Hamiltonian.
        Must be symmetric: the cost is read as $y = x^T Q x$ with off-diagonal entries
        counted twice, and the Ising coefficients $J_{ij} = \frac{1}{2} Q_{ij}$,
        $h_i = -\frac{1}{2} \sum_j Q_{ij}$ derived from it are only valid for
        symmetric $Q$. Symmetrize an upper-triangular matrix with
        ``Q = 0.5 * (Q + Q.T)`` before passing it in.
    agp_type : str
        Which approximation of the AGP to use. You can choose between ``local`` and
        ``nc`` (nested commutators up to first order).
    uniform_AGP_coeffs : bool
        Whether to approximate the AGP with uniform or non-uniform coefficients.

    Returns
    -------
    collected operators : tuple
        Tuple containing the following functions and operators:
        Scheduling function (lam(t)), function for AGP coefficients (alpha), initial and problem
        Hamiltonian (H_init, H_prob), AGP function (A_lam), Coupling and onsite energies of
        problem Hamiltonian (J, h), inverse scheduling function (g(lam)), control Hamiltonian (H_control).

    """
    agp_type = _normalize_agp_type(agp_type)

    def build_agp(agp_type, J, h):
        """Return the AGP operators for the given ``agp_type``."""

        def local():
            """Local AGP ansatz: one single-qubit Y operator per qubit."""
            A_lam = [Y(i) for i in range(N)]
            return A_lam

        def nested_commutators(J, h):
            """Nested-commutator AGP ansatz operators."""
            return _nested_commutator_operators(h, J)

        builders = {"local": local, "nc": lambda: nested_commutators(J, h)}

        return builders[agp_type]()

    def build_coeffs(agp_type, uniform_AGP_coeffs, J, h):
        """Return the AGP coefficient function for the given ``agp_type`` and uniformity."""

        def local_uniform(J, h):
            """Coefficient function for the uniform local AGP."""

            # LCD has no control Hamiltonian, so f = f_deriv = 0.
            def alpha(lam):
                """AGP coefficient(s) at scheduling value ``lam``."""
                return _local_agp_coeffs(h, J, lam, uniform=True)

            return alpha

        def local_nonuniform(J, h):
            """Coefficient function for the per-qubit local AGP."""

            def alpha(lam):
                """AGP coefficient(s) at scheduling value ``lam``."""
                return _local_agp_coeffs(h, J, lam, uniform=False)

            return alpha

        def nc_uniform(J, h):
            """Coefficient function for the uniform nested-commutator AGP."""
            # LCD has no control Hamiltonian, so the coefficient is evaluated at f = f_deriv = 0.
            return _nc_uniform_agp_coeffs(h, J)

        def nc_nonuniform(J, h):
            """Coefficient function for the per-site nested-commutator AGP."""

            def alpha(lam):
                """AGP coefficient(s) at scheduling value ``lam``."""
                alph = _solve_alpha(h, J, lam)
                return alph

            return alpha

        builders = {
            ("local", True): local_uniform,
            ("local", False): local_nonuniform,
            ("nc", True): nc_uniform,
            ("nc", False): nc_nonuniform,
        }

        return builders[(agp_type, uniform_AGP_coeffs)](J, h)

    N = len(Q[0])
    h = -0.5 * np.sum(Q, axis=1)
    J = 0.5 * Q

    def lam():
        """Symbolic scheduling function lambda(t)."""
        t, T = sp.symbols("t T", real=True)
        lam_expr = sp.sin(sp.pi / 2 * sp.sin(sp.pi * t / (2 * T)) ** 2) ** 2
        return lam_expr

    # AGP coefficients
    coeff_func = build_coeffs(agp_type, uniform_AGP_coeffs, J, h)

    # Initial Hamiltonian
    H_init = 1 * sum([X(i) for i in range(N)])

    # Problem Hamiltonian
    # H_prob = sum(
    #    [sum([J[i][j] * Z(i) * Z(j) for j in range(i)]) for i in range(N)]
    # ) + sum([h[i] * Z(i) for i in range(N)])

    H_prob = QubitOperator.sum(
        itertools.chain(
            (J[i][j] * Z(i) * Z(j) for i in range(N) for j in range(i, N)),
            (h[i] * Z(i) for i in range(N)),
        )
    )

    # AGP
    A_lam = build_agp(agp_type, J, h)

    return Q, H_init, H_prob, A_lam, coeff_func, lam


[docs] def solve_QUBO(Q: np.array, problem_args: dict, run_args: dict): r"""Solves a QUBO Matrix using counterdiabatic driving. This method uses the pre-defined COLD/LCD operators (hamiltonian, scheduling function, AGP parameters) as described in the tutorial. To define your own operators, create a DCQO instance and use the ``run`` method. Parameters ---------- Q : np.array QUBO Matrix to solve. Must be symmetric: the cost is read as $y = x^T Q x$ with off-diagonal entries counted twice, and the Ising coefficients $J_{ij} = \frac{1}{2} Q_{ij}$, $h_i = -\frac{1}{2} \sum_j Q_{ij}$ derived from it are only valid for symmetric $Q$. Symmetrize an upper-triangular matrix with ``Q = 0.5 * (Q + Q.T)`` before passing it in. problem_args : dict Holds arguments for DCQO problem creation: * ``method`` : str -- "COLD" or "LCD". * ``uniform`` : bool. * ``agp_type`` : str, optional -- "local" (default) or "nc". Applies to both methods. ``nc`` approximates the AGP with first-order nested commutators and is the better approximation, especially at short evolution times. For COLD, ``nc`` requires ``"uniform": True``. run_args : dict Holds arguments for running the DCQO instance (``N_steps``, ``T``, ``N_opt``, ``CRAB``, ``objective``, ``precision``, ``backend``, ``exp_value_backend``). All options are also listed here: :meth:`DCQOProblem.run`. Returns ------- result : dict The dictionary holding the QUBO vector results, with their probabilitites and cost. They are ordered from most to least likely and the dictionary entries are {"state": [prob, cost]}. Examples -------- :: import numpy as np from qrisp.algorithms.cold import solve_QUBO Q = np.array([[-0.6, 0.2, -0.5, -0.4, -0.6, 0.0], [ 0.2, -1.0, 0.0, 0.0, 0.0, 0.0], [-0.5, 0.0, -1.2, 0.5, -0.5, 0.0], [-0.4, 0.0, 0.5, -0.8, 0.0, 0.1], [-0.6, 0.0, -0.5, 0.0, -1.2, 0.0], [ 0.0, 0.0, 0.0, 0.1, 0.0, 0.3]]) problem_args = {"method": "COLD", "uniform": False} run_args = {"N_steps": 20, "T": 1, "N_opt": 1, "CRAB": False, "bounds": (-3, 3)} result = solve_QUBO(Q, problem_args, run_args) print(result) :: { '111110': [0.9816, np.float64(-7.3999999999999995)], '111111': [0.0058, np.float64(-6.8999999999999995)], '111100': [0.0048, np.float64(-4.0)], '011110': [0.0032, np.float64(-4.2)], '110110': [0.0022, np.float64(-5.199999999999999)], '111010': [0.0012, np.float64(-6.8)], '001110': [0.0004, np.float64(-3.2)], '011100': [0.0002, np.float64(-2.0)], '110010': [0.0002, np.float64(-3.5999999999999996)], '011010': [0.0002, np.float64(-4.4)], '101110': [0.0002, np.float64(-6.8)] } """ method = problem_args["method"] # Both methods accept the AGP type; the local AGP is the default. # Normalize here so a deprecation warning points at the solve_QUBO caller, not into Qrisp agp_type = _normalize_agp_type(problem_args.get("agp_type", "local")) if method == "LCD": problem_operators = create_LCD_instance(Q, agp_type=agp_type, uniform_AGP_coeffs=problem_args["uniform"]) elif method == "COLD": problem_operators = create_COLD_instance(Q, uniform_AGP_coeffs=problem_args["uniform"], agp_type=agp_type) else: raise ValueError(f"{method} is not a valid option as method. Valid options are 'LCD' and 'COLD'.") # Create qarg and problem instrance qarg = QuantumVariable(Q.shape[0]) prob = DCQOProblem(*problem_operators) # Run problem result = prob.run(qarg, method=method, **run_args) return result