Repository: BQSKit/bqskit (language: python)

Issue:
Adding Multiplexed Rotation Gates
Pull request creates 2 new multiplexed rotation gates.

These gates have n -1 select qubits and a single target qubit. It has 2^(n-1) parameters corresponding to all of the possible configurations of the n - 1 select qubit. Each configuration of the n - 1 select qubits applies to a rotation gate (parameterized by the respective parameter) to the target qubit. The select qubits are unchanged.

These gates are used extensively in QSD and Block ZXZ Unitary Decomposition.


Discussion / hints:

Fix patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 5fd25af39..43ceec2ac 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -2,7 +2,7 @@ ci:
     skip: [mypy]
 repos:
 -   repo: https://github.com/pre-commit/pre-commit-hooks
-    rev: v4.6.0
+    rev: v5.0.0
     hooks:
     -   id: trailing-whitespace
     -   id: end-of-file-fixer
@@ -20,8 +20,8 @@ repos:
     -   id: fix-byte-order-marker
     -   id: fix-encoding-pragma
         args: ['--remove']
--   repo: https://github.com/PyCQA/docformatter
-    rev: v1.7.5
+-   repo: https://github.com/s-weigand/docformatter
+    rev: 1ec30b7
     hooks:
     -   id: docformatter
         args:
@@ -66,7 +66,7 @@ repos:
       args:
         - --in-place
 -   repo: https://github.com/pre-commit/mirrors-mypy
-    rev: v1.11.1
+    rev: v1.11.2
     hooks:
     - id: mypy
       exclude: tests/qis/test_pauli.py
diff --git a/bqskit/ir/gates/__init__.py b/bqskit/ir/gates/__init__.py
index 183a6426c..fb9affdb1 100644
--- a/bqskit/ir/gates/__init__.py
+++ b/bqskit/ir/gates/__init__.py
@@ -72,6 +72,8 @@
     CRZGate
     CUGate
     FSIMGate
+    MPRYGate
+    MPRZGate
     PauliGate
     PauliZGate
     PhasedXZGate
diff --git a/bqskit/ir/gates/parameterized/__init__.py b/bqskit/ir/gates/parameterized/__init__.py
index 546520b60..34b51aee5 100644
--- a/bqskit/ir/gates/parameterized/__init__.py
+++ b/bqskit/ir/gates/parameterized/__init__.py
@@ -11,6 +11,8 @@
 from bqskit.ir.gates.parameterized.crz import CRZGate
 from bqskit.ir.gates.parameterized.cu import CUGate
 from bqskit.ir.gates.parameterized.fsim import FSIMGate
+from bqskit.ir.gates.parameterized.mpry import MPRYGate
+from bqskit.ir.gates.parameterized.mprz import MPRZGate
 from bqskit.ir.gates.parameterized.pauli import PauliGate
 from bqskit.ir.gates.parameterized.pauliz import PauliZGate
 from bqskit.ir.gates.parameterized.phasedxz import PhasedXZGate
@@ -41,6 +43,8 @@
     'CRZGate',
     'CUGate',
     'FSIMGate',
+    'MPRYGate',
+    'MPRZGate',
     'PauliGate',
     'PauliZGate',
     'PhasedXZGate',
diff --git a/bqskit/ir/gates/parameterized/mpry.py b/bqskit/ir/gates/parameterized/mpry.py
new file mode 100644
index 000000000..19f9e8648
--- /dev/null
+++ b/bqskit/ir/gates/parameterized/mpry.py
@@ -0,0 +1,176 @@
+"""This module implements the MPRYGate."""
+from __future__ import annotations
+
+import numpy as np
+import numpy.typing as npt
+
+from bqskit.ir.gates.qubitgate import QubitGate
+from bqskit.qis.unitary.differentiable import DifferentiableUnitary
+from bqskit.qis.unitary.optimizable import LocallyOptimizableUnitary
+from bqskit.qis.unitary.unitary import RealVector
+from bqskit.qis.unitary.unitarymatrix import UnitaryMatrix
+from bqskit.utils.cachedclass import CachedClass
+
+
+def get_indices(
+    index: int,
+    target_qudit: int,
+    num_qudits: int,
+) -> tuple[int, int]:
+    """Get indices for the matrix based on the target qubit."""
+    shift_qubit = num_qudits - target_qudit - 1
+    shift = 2 ** shift_qubit
+    # Split into two parts around target qubit
+    # 100 | 111
+    left = index // shift
+    right = index % shift
+
+    # Now, shift left by one spot to
+    # make room for the target qubit
+    left *= (shift * 2)
+    # Now add 0 * new_ind and 1 * new_ind to get indices
+    return left + right, left + shift + right
+
+
+class MPRYGate(
+    QubitGate,
+    DifferentiableUnitary,
+    CachedClass,
+    LocallyOptimizableUnitary,
+):
+    """
+    A gate representing a multiplexed Y rotation. A multiplexed Y rotation
+    uses n - 1 qubits as select qubits and applies a Y rotation to the target.
+    If the target qubit is the last qubit, then the unitary is block diagonal.
+    Each block is a 2x2 RY matrix with parameter theta.
+
+    Since there are n - 1 select qubits, there are 2^(n-1) parameters (thetas).
+
+    We allow the target qubit to be specified to any qubit, and the other qubits
+    maintain their order. Qubit 0 is the most significant qubit.
+
+    See this paper: https://arxiv.org/pdf/quant-ph/0406176
+    """
+
+    _qasm_name = 'mpry'
+
+    def __init__(
+        self,
+        num_qudits: int,
+        target_qubit: int = -1,
+    ) -> None:
+        self._num_qudits = num_qudits
+        # 1 param for each configuration of the selec qubits
+        self._num_params = 2 ** (num_qudits - 1)
+        # By default, the controlled qubit is the last qubit
+        if target_qubit == -1:
+            target_qubit = num_qudits - 1
+        self.target_qubit = target_qubit
+        super().__init__()
+
+    def get_unitary(self, params: RealVector = []) -> UnitaryMatrix:
+        """Return the unitary for this gate, see :class:`Unitary` for more."""
+        self.check_parameters(params)
+
+        matrix = np.zeros(
+            (
+                2 ** self.num_qudits,
+                2 ** self.num_qudits,
+            ), dtype=np.complex128,
+        )
+        for i, param in enumerate(params):
+            cos = np.cos(param / 2)
+            sin = np.sin(param / 2)
+
+            # Now, get indices based on target qubit.
+            # i corresponds to the configuration of the
+            # select qubits (e.g 5 = 101). Now, the
+            # target qubit is 0,1 for both the row and col
+            # indices. So, if i = 5 and the target_qubit is 2
+            # Then the rows/cols are 1001 and 1101
+            x1, x2 = get_indices(i, self.target_qubit, self.num_qudits)
+
+            matrix[x1, x1] = cos
+            matrix[x2, x2] = cos
+            matrix[x2, x1] = sin
+            matrix[x1, x2] = -1 * sin
+
+        return UnitaryMatrix(matrix)
+
+    def get_grad(self, params: RealVector = []) -> npt.NDArray[np.complex128]:
+        """
+        Return the gradient for this gate.
+
+        See :class:`DifferentiableUnitary` for more info.
+        """
+        self.check_parameters(params)
+
+        grad = np.zeros(
+            (
+                len(params), 2 ** self.num_qudits,
+                2 ** self.num_qudits,
+            ), dtype=np.complex128,
+        )
+
+        # For each parameter, calculate the derivative
+        # with respect to that parameter
+        for i, param in enumerate(params):
+            dcos = -np.sin(param / 2) / 2
+            dsin = -1j * np.cos(param / 2) / 2
+
+            # Again, get indices based on target qubit.
+            x1, x2 = get_indices(i, self.target_qubit, self.num_qudits)
+
+            grad[i, x1, x1] = dcos
+            grad[i, x2, x2] = dcos
+            grad[i, x2, x1] = dsin
+            grad[i, x1, x2] = -1 * dsin
+
+        return grad
+
+    def optimize(self, env_matrix: npt.NDArray[np.complex128]) -> list[float]:
+        """
+        Return the optimal parameters with respect to an environment matrix.
+
+        See :class:`LocallyOptimizableUnitary` for more info.
+        """
+        self.check_env_matrix(env_matrix)
+        thetas: list[float] = [0] * self.num_params
+
+        for i in range(self.num_params):
+            x1, x2 = get_indices(i, self.target_qubit, self.num_qudits)
+            a = np.real(env_matrix[x1, x1] + env_matrix[x2, x2])
+            b = np.real(env_matrix[x2, x1] - env_matrix[x1, x2])
+            theta = 2 * np.arccos(a / np.sqrt(a ** 2 + b ** 2))
+            theta *= -1 if b > 0 else 1
+            thetas[i] = theta
+
+        return thetas
+
+    @staticmethod
+    def get_decomposition(params: RealVector = []) -> tuple[
+        RealVector,
+        RealVector,
+    ]:
+        """
+        Get the corresponding parameters for one level of decomposition of a
+        multiplexed gate.
+
+        This is used in the decomposition of both the MPRY and MPRZ gates.
+        """
+        new_num_params = len(params) // 2
+        left_params = np.zeros(new_num_params)
+        right_params = np.zeros(new_num_params)
+        for i in range(len(left_params)):
+            left_param = (params[i] + params[i + new_num_params]) / 2
+            right_param = (params[i] - params[i + new_num_params]) / 2
+            left_params[i] = left_param
+            right_params[i] = right_param
+
+        return left_params, right_params
+
+    @property
+    def name(self) -> str:
+        """The name of this gate, with the number of qudits appended."""
+        base_name = getattr(self, '_name', self.__class__.__name__)
+        return f'{base_name}_{self.num_qudits}'
diff --git a/bqskit/ir/gates/parameterized/mprz.py b/bqskit/ir/gates/parameterized/mprz.py
new file mode 100644
index 000000000..f35e8747a
--- /dev/null
+++ b/bqskit/ir/gates/parameterized/mprz.py
@@ -0,0 +1,147 @@
+"""This module implements the MPRZGate."""
+from __future__ import annotations
+
+import numpy as np
+import numpy.typing as npt
+
+from bqskit.ir.gates.parameterized.mpry import get_indices
+from bqskit.ir.gates.qubitgate import QubitGate
+from bqskit.qis.unitary.differentiable import DifferentiableUnitary
+from bqskit.qis.unitary.optimizable import LocallyOptimizableUnitary
+from bqskit.qis.unitary.unitary import RealVector
+from bqskit.qis.unitary.unitarymatrix import UnitaryMatrix
+from bqskit.utils.cachedclass import CachedClass
+
+
+class MPRZGate(
+    QubitGate,
+    DifferentiableUnitary,
+    CachedClass,
+    LocallyOptimizableUnitary,
+):
+    """
+    A gate representing a multiplexed Z rotation. A multiplexed Z rotation
+    uses n - 1 qubits as select qubits and applies a Z rotation to the target.
+    If the target qubit is the last qubit, then the unitary is block diagonal.
+    Each block is a 2x2 RZ matrix with parameter theta.
+
+    Since there are n - 1 select qubits, there are 2^(n-1) parameters (thetas).
+
+    We allow the target qubit to be specified to any qubit, and the other qubits
+    maintain their order. Qubit 0 is the most significant qubit.
+
+
+    Why is 0 the MSB? Typically, in the QSD diagram, we see the block drawn
+    with qubit 0 at the top and qubit n-1 at the bottom. Then, the decomposition
+    slowly moves from the bottom to the top.
+
+    See this paper: https://arxiv.org/pdf/quant-ph/0406176
+    """
+
+    _qasm_name = 'mprz'
+
+    def __init__(
+        self,
+        num_qudits: int,
+        target_qubit: int = -1,
+    ) -> None:
+        """
+        Create a new MPRZGate with `num_qudits` qubits and `target_qubit` as the
+        target qubit. We then have 2^(n-1) parameters for this gate.
+
+        For Example:
+        `num_qudits` = 3, `target_qubit` = 1
+
+        Then, the select qubits are 0 and 2 with 0 as the MSB.
+
+        If the input vector is |0x0> then the selection is 00, and
+        RZ(theta_0) is applied to the target qubit.
+
+        If the input vector is |1x0> then the selection is 01, and
+        RZ(theta_1) is applied to the target qubit.
+        """
+        self._num_qudits = num_qudits
+        # 1 param for each configuration of the selec qubits
+        self._num_params = 2 ** (num_qudits - 1)
+        # By default, the controlled qubit is the last qubit
+        if target_qubit == -1:
+            target_qubit = num_qudits - 1
+        self.target_qubit = target_qubit
+        super().__init__()
+
+    def get_unitary(self, params: RealVector = []) -> UnitaryMatrix:
+        """Return the unitary for this gate, see :class:`Unitary` for more."""
+        self.check_parameters(params)
+        matrix = np.zeros(
+            (
+                2 ** self.num_qudits,
+                2 ** self.num_qudits,
+            ), dtype=np.complex128,
+        )
+        for i, param in enumerate(params):
+            pos = np.exp(1j * param / 2)
+            neg = np.exp(-1j * param / 2)
+
+            # Get correct indices based on target qubit
+            # See :class:`mcry` for more info
+            x1, x2 = get_indices(i, self.target_qubit, self.num_qudits)
+
+            matrix[x1, x1] = neg
+            matrix[x2, x2] = pos
+
+        return UnitaryMatrix(matrix)
+
+    def get_grad(self, params: RealVector = []) -> npt.NDArray[np.complex128]:
+        """
+        Return the gradient for this gate.
+
+        See :class:`DifferentiableUnitary` for more info.
+        """
+        self.check_parameters(params)
+
+        grad = np.zeros(
+            (
+                len(params), 2 ** self.num_qudits,
+                2 ** self.num_qudits,
+            ), dtype=np.complex128,
+        )
+
+        # For each parameter, calculate the derivative
+        # with respect to that parameter
+        for i, param in enumerate(params):
+            dpos = 1j * np.exp(1j * param / 2) / 2
+            dneg = -1j * np.exp(-1j * param / 2) / 2
+
+            # Again, get indices based on target qubit.
+            x1, x2 = get_indices(i, self.target_qubit, self.num_qudits)
+
+            grad[i, x1, x1] = dpos
+            grad[i, x2, x2] = dneg
+
+        return grad
+
+    def optimize(self, env_matrix: npt.NDArray[np.complex128]) -> list[float]:
+        """
+        Return the optimal parameters with respect to an environment matrix.
+
+        See :class:`LocallyOptimizableUnitary` for more info.
+        """
+        self.check_env_matrix(env_matrix)
+        thetas: list[float] = [0] * self.num_params
+
+        for i in range(self.num_params):
+            x1, x2 = get_indices(i, self.target_qubit, self.num_qudits)
+            # Optimize each RZ independently from indices
+            # Taken from QFACTOR repo
+            a = np.angle(env_matrix[x1, x1])
+            b = np.angle(env_matrix[x2, x2])
+            # print(thetas)
+            thetas[i] = a - b
+
+        return thetas
+
+    @property
+    def name(self) -> str:
+        """The name of this gate, with the number of qudits appended."""
+        base_name = getattr(self, '_name', self.__class__.__name__)
+        return f'{base_name}_{self.num_qudits}'


Test patch:
diff --git a/tests/ir/gates/parameterized/test_mpry.py b/tests/ir/gates/parameterized/test_mpry.py
new file mode 100644
index 000000000..8d4b7aa58
--- /dev/null
+++ b/tests/ir/gates/parameterized/test_mpry.py
@@ -0,0 +1,72 @@
+"""This module tests the MPRYGate class."""
+from __future__ import annotations
+
+import numpy as np
+import scipy.linalg as la
+from hypothesis import given
+from hypothesis.strategies import floats
+from hypothesis.strategies import integers
+from hypothesis.strategies import lists
+
+from bqskit.ir.gates.constant import PermutationGate
+from bqskit.ir.gates.parameterized import MPRYGate
+from bqskit.ir.gates.parameterized import RYGate
+
+
+@given(
+    lists(
+        elements=floats(
+            allow_nan=False,
+            allow_infinity=False,
+            width=32,
+        ), min_size=2, max_size=16,
+    ),
+)
+def test_get_unitary(thetas: list[float]) -> None:
+    """
+    Test the get_unitary method of the MPRYGate class.
+
+    Use the default target qubit.
+    """
+    # Ensure that len(thetas) is a power of 2
+    # There are 2 ** (n - 1) parameters
+    num_qudits = int(np.log2(len(thetas))) + 1
+    thetas = thetas[:2 ** (num_qudits - 1)]
+    MPRy = MPRYGate(num_qudits=num_qudits)
+    block_unitaries = [RYGate().get_unitary([theta]) for theta in thetas]
+    blocked_unitary = la.block_diag(*block_unitaries)
+    dist = MPRy.get_unitary(thetas).get_distance_from(blocked_unitary)
+    assert dist < 1e-7
+
+
+@given(integers(min_value=0, max_value=4))
+def test_get_unitary_target_select(target_qubit: int) -> None:
+    """Test the get_unitary method of the MPRYGate class when the target qubit
+    is set."""
+    # Create an MPRY gate with 6 qubits and random parameters
+    num_qudits = 6
+    MPRy = MPRYGate(num_qudits=num_qudits, target_qubit=target_qubit)
+    thetas = list(np.random.rand(2 ** (num_qudits - 1)) * 2 * np.pi)
+
+    # Create the block diagonal matrix
+    block_unitaries = [RYGate().get_unitary([theta]) for theta in thetas]
+    blocked_unitary = la.block_diag(*block_unitaries)
+
+    # Apply a permutation transformation
+    # to the block diagonal matrix
+    # Swap the target qubit with the last qubit
+    # perm = np.arange(num_qudits)
+    perm = list(range(num_qudits))
+    for i in range(target_qubit, num_qudits):
+        perm[i] = i + 1
+    perm[-1] = target_qubit
+
+    perm_gate = PermutationGate(num_qudits, perm)
+
+    full_utry = (
+        perm_gate.get_unitary().conj().T
+        @ blocked_unitary @ perm_gate.get_unitary()
+    )
+
+    dist = MPRy.get_unitary(thetas).get_distance_from(full_utry)
+    assert dist < 1e-7
diff --git a/tests/ir/gates/parameterized/test_mprz.py b/tests/ir/gates/parameterized/test_mprz.py
new file mode 100644
index 000000000..0ba506e30
--- /dev/null
+++ b/tests/ir/gates/parameterized/test_mprz.py
@@ -0,0 +1,72 @@
+"""This module tests the MPRZGate class."""
+from __future__ import annotations
+
+import numpy as np
+import scipy.linalg as la
+from hypothesis import given
+from hypothesis.strategies import floats
+from hypothesis.strategies import integers
+from hypothesis.strategies import lists
+
+from bqskit.ir.gates.constant import PermutationGate
+from bqskit.ir.gates.parameterized import MPRZGate
+from bqskit.ir.gates.parameterized import RZGate
+
+
+@given(
+    lists(
+        elements=floats(
+            allow_nan=False,
+            allow_infinity=False,
+            width=32,
+        ), min_size=2, max_size=16,
+    ),
+)
+def test_get_unitary(thetas: list[float]) -> None:
+    """
+    Test the get_unitary method of the MPRZGate class.
+
+    Use the default target qubit.
+    """
+    # Ensure that len(thetas) is a power of 2
+    # There are 2 ** (n - 1) parameters
+    num_qudits = int(np.log2(len(thetas))) + 1
+    thetas = thetas[:2 ** (num_qudits - 1)]
+    MPRy = MPRZGate(num_qudits=num_qudits)
+    block_unitaries = [RZGate().get_unitary([theta]) for theta in thetas]
+    blocked_unitary = la.block_diag(*block_unitaries)
+    dist = MPRy.get_unitary(thetas).get_distance_from(blocked_unitary)
+    assert dist < 1e-7
+
+
+@given(integers(min_value=0, max_value=4))
+def test_get_unitary_target_select(target_qubit: int) -> None:
+    """Test the get_unitary method of the MPRZGate class when the target qubit
+    is set."""
+    # Create an MPRZ gate with 6 qubits and random parameters
+    num_qudits = 6
+    MPRy = MPRZGate(num_qudits=num_qudits, target_qubit=target_qubit)
+    thetas = list(np.random.rand(2 ** (num_qudits - 1)) * 2 * np.pi)
+
+    # Create the block diagonal matrix
+    block_unitaries = [RZGate().get_unitary([theta]) for theta in thetas]
+    blocked_unitary = la.block_diag(*block_unitaries)
+
+    # Apply a permutation transformation
+    # to the block diagonal matrix
+    # Swap the target qubit with the last qubit
+    # perm = np.arange(num_qudits)
+    perm = list(range(num_qudits))
+    for i in range(target_qubit, num_qudits):
+        perm[i] = i + 1
+    perm[-1] = target_qubit
+
+    perm_gate = PermutationGate(num_qudits, perm)
+
+    full_utry = (
+        perm_gate.get_unitary().conj().T
+        @ blocked_unitary @ perm_gate.get_unitary()
+    )
+
+    dist = MPRy.get_unitary(thetas).get_distance_from(full_utry)
+    assert dist < 1e-7