Source code for sdplab.special.qot._cvxpy
# Copyright 2026 Pavlo Pelikh
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
r"""CVXPY solver for the QOT dual semidefinite program."""
from __future__ import annotations
import numpy as np
import cvxpy as cp
from typing import Any
from spacecore import Context, NumpyOps
from ...problem import SDPProblem
from ._constraint_op import QOTConstraintOp
[docs]
def solve_qot_dual(
qot: SDPProblem,
solver: str = 'MOSEK',
verbose: bool = False,
*args, **kwargs
) -> tuple[Any, Any]:
r"""Solve the QOT dual SDP.
For the QOT constraint operator :math:`\mathcal{A}`, the primal coupling
satisfies
:math:`\Gamma \in \operatorname{dom}(\mathcal{A}) = \operatorname{Herm}(d^N)`
and the marginal data satisfy
:math:`\gamma = (\gamma_0, \ldots, \gamma_{N-1})
\in \operatorname{cod}(\mathcal{A}) = \operatorname{Herm}(d)^N`.
The dual problem is
.. math::
\max_{U \in \operatorname{cod}(\mathcal{A})}\quad
\sum_k \operatorname{Tr}[U_k \gamma_k]
\quad \text{s.t.}\quad
\mathcal{A}^\dagger U \preceq C.
Here :math:`C \in \operatorname{dom}(\mathcal{A})` is the cost matrix and
:math:`U = (U_0, \ldots, U_{N-1})` is the block dual variable. The adjoint
constraint is
.. math::
\mathcal{A}^\dagger U
=
U_0 \oplus \cdots \oplus U_{N-1}
=
\sum_k I \otimes \cdots \otimes U_k \otimes \cdots \otimes I
\preceq C,
equivalently :math:`C - \mathcal{A}^\dagger U \succeq 0`.
Returns:
A pair ``(primal, dual)``. The primal value stores the coupling
:math:`\Gamma`, represented as the positive-semidefinite multiplier
for :math:`C - \mathcal{A}^\dagger U \succeq 0`. The dual value stores
the optimized blocks :math:`U_k` in
:math:`\operatorname{cod}(\mathcal{A})`.
"""
if not isinstance(qot.A, QOTConstraintOp):
raise TypeError("Input problem is not Quantum Optimal Transport.")
np_ctx = Context(ops=NumpyOps(), dtype=qot.ctx.dtype)
problem = qot.convert(np_ctx)
C = cp.Constant(np.asarray(problem.C.to_dense()))
marginals = np.asarray(problem.b)
d = problem.A.d
N = problem.A.N
is_complex = np.iscomplexobj(C) or np.iscomplexobj(marginals)
if is_complex:
U = [cp.Variable((d, d), hermitian=True) for _ in range(N)]
obj = cp.Maximize(cp.real(cp.sum([cp.trace(Uk @ cp.Constant(Rk)) for Uk, Rk in zip(U, marginals)])))
else:
U = [cp.Variable((d, d), symmetric=True) for _ in range(N)]
obj = cp.Maximize(cp.sum([cp.trace(Uk @ cp.Constant(Rk)) for Uk, Rk in zip(U, marginals)]))
# ----------- build K = sum_k I⊗...⊗U_k⊗...⊗I -----------
eye = cp.Constant(np.eye(d, d, dtype=qot.ctx.dtype))
def kron_chain(k: int) -> cp.Expression:
r"""Return the Kronecker embedding of the ``k``-th dual block."""
expr = None
for idx in range(N):
term = U[idx] if idx == k else eye
expr = term if expr is None else cp.kron(expr, term)
return expr
K = sum(kron_chain(k) for k in range(N))
constraints = [C - K >> 0]
prob = cp.Problem(obj, constraints)
prob.solve(solver=solver, verbose=verbose, *args, **kwargs)
if U[0].value is None:
raise ValueError(f'{solver} solver did not return a solution.')
# The dual of the PSD constraint is the coupling itself; cvxpy returns it
# already correctly scaled for a complex Hermitian cone, so it is used as is.
primal = qot.ctx.asarray(np.asarray(constraints[0].dual_value))
dual = qot.ctx.asarray(np.stack([np.asarray(Uk.value) for Uk in U], axis=0))
return primal, dual