Repository: Qiskit/qiskit — pull request #14217 (python)
Title: Bug fixes in Solovay Kitaev Decomposition

<!--\r\n⚠️ If you do not respect this template, your pull request will be closed.\r\n⚠️ Your pull request title should be short detailed and understandable for all.\r\n⚠️ Also, please add a release note file using reno if the change needs to be\r\n  documented in the release notes.\r\n⚠️ If your pull request fixes an open issue, please link to the issue.\r\n\r\n- [x] I have added the tests to cover my changes.\r\n- [x] I have updated the documentation accordingly.\r\n- [x] I have read the CONTRIBUTING document.\r\n-->\r\n\r\n### Summary\r\n\r\nThis PR fixes a number of bugs we have recently discovered in the Python code for the Solovay-Kitaev decomposition, together with @Cryoris and @anedumla.\r\n\r\nFixes include:\r\n* fixed a problem with the rotation axis computation for 180 degree rotations (thanks to @Cryoris for working out the math)\r\n* fixed a problem where the global phase of the returned circuit might have been off by $\\pi$, due to the underlying algorithm approximating not the original ``SU(2)`` matrix, but its projection onto ``SO(3)``\r\n* in particular fixed #9552.\r\n\r\nLonger-term, we will deprecate the Python code for Solovay-Kitaev decomposition altogether, and switch to the new improved pass written in Rust that also fixes these problems (see #14203), however for backward compatibility we need to temporarily support the Python code as well, and thus it makes sense to fix the problems on the Python side too.\r\n\r\n\r\n### Details and comments\r\n\r\nOther changes:\r\n\r\n* There was a very strange "back door" for returning a circuit with the original unitary matrix if the computed matrix is not close to the identity. This behavior is not consistent with the documented description of the pass and was removed. (Or rather the functions ``to_circuit`` and ``to_dag`` remain for backwards compatibility but are no longer used by the main pass).\r\n* The ``GateSequence`` class used to store both the SO(3) matrix as ``product`` and the SU(2) matrix as ``product_su2``, however the parameter ``product_su2`` was not updated correctly in various places (and it was not even clear what to do within ``GateSequence.from_matrix``). As ``product_su2`` was almost not used anywhere in the algorithm (with the exception of one place where its usage did not seem to be correct anyway), it was simply removed to avoid confusion and unnecessary computations. \r\n

Resolved issues:
title: Global phase on Solovay-Kitaev decomposition seems wrong
body: ### Environment\r\n\r\n- **Qiskit Terra version**: 0.23.1\r\n- **Python version**: 3.10.6\r\n- **Operating system**: Ubuntu Linux\r\n\r\n\r\n### What is happening?\r\n\r\nThe global phase of some circuits generated by `SolovayKitaev` seems wrong.\r\n\r\n### How can we reproduce the issue?\r\n\r\n```python\r\n>>> from qiskit.transpiler.passes.synthesis import SolovayKitaev\r\n>>> from qiskit.quantum_info import Operator\r\n>>> from qiskit import QuantumCircuit, Aer, transpile, assemble\r\n>>> c = QuantumCircuit(1)\r\n>>> c.y(0)\r\n<qiskit.circuit.instructionset.InstructionSet object at 0x7f62280bc910>\r\n>>> skd = SolovayKitaev(recursion_degree=3)\r\n>>> dc = skd(c)\r\n>>> c.draw()\r\n   ┌───┐\r\nq: ┤ Y ├\r\n   └───┘\r\n>>> dc.draw()\r\nglobal phase: 3π/4\r\n   ┌───┐┌───┐┌───┐┌───┐┌─────┐┌─────┐┌───┐┌───┐┌───┐\r\nq: ┤ H ├┤ T ├┤ T ├┤ H ├┤ Tdg ├┤ Tdg ├┤ H ├┤ T ├┤ T ├\r\n   └───┘└───┘└───┘└───┘└─────┘└─────┘└───┘└───┘└───┘\r\n>>> Operator(c)\r\nOperator([[0.+0.j, 0.-1.j],\r\n          [0.+1.j, 0.+0.j]],\r\n         input_dims=(2,), output_dims=(2,))\r\n>>> Operator(dc)\r\nOperator([[-1.11022302e-16+7.85046229e-17j,\r\n            1.05981241e-15+1.00000000e+00j],\r\n          [-9.43689571e-16-1.00000000e+00j,\r\n            0.00000000e+00-3.92523115e-17j]],\r\n         input_dims=(2,), output_dims=(2,))\r\n```\r\n\r\n### What should happen?\r\n\r\nGiven `Operator(dc).data` aprroximately equals to `[[0, 1j], [-1j, 0]]` which equals to `-Operator(c).data`, `dc`'s global phase should be π rather than 3π/4, I believe.\r\n\r\n### Any suggestions?\r\n\r\n_No response_

Hints:

Fix patch:
diff --git a/qiskit/synthesis/discrete_basis/commutator_decompose.py b/qiskit/synthesis/discrete_basis/commutator_decompose.py\nindex 67094a7105a4..b9d274b38a3d 100644\n--- a/qiskit/synthesis/discrete_basis/commutator_decompose.py\n+++ b/qiskit/synthesis/discrete_basis/commutator_decompose.py\n@@ -53,16 +53,40 @@ def _compute_rotation_axis(matrix: np.ndarray) -> np.ndarray:\n     """\n     _check_is_so3(matrix)\n \n+    # If theta represents the rotation angle, then trace = 1 + 2cos(theta).\n     trace = _compute_trace_so3(matrix)\n-    theta = math.acos(0.5 * (trace - 1))\n-    if math.sin(theta) > 1e-10:\n-        x = 1 / (2 * math.sin(theta)) * (matrix[2][1] - matrix[1][2])\n-        y = 1 / (2 * math.sin(theta)) * (matrix[0][2] - matrix[2][0])\n-        z = 1 / (2 * math.sin(theta)) * (matrix[1][0] - matrix[0][1])\n-    else:\n+\n+    if trace >= 3 - 1e-10:\n+        # The matrix is the identity (rotation by 0)\n         x = 1.0\n         y = 0.0\n         z = 0.0\n+\n+    elif trace <= -1 + 1e-10:\n+        # The matrix is the 180-degree rotation\n+        squares = (1 + np.diagonal(matrix)) / 2\n+        index_of_max = np.argmax(squares)\n+\n+        if index_of_max == 0:\n+            x = math.sqrt(squares[0])\n+            y = matrix[0][1] / (2 * x)\n+            z = matrix[0][2] / (2 * x)\n+        elif index_of_max == 1:\n+            y = math.sqrt(squares[1])\n+            x = matrix[0][1] / (2 * y)\n+            z = matrix[1][2] / (2 * y)\n+        else:\n+            z = math.sqrt(squares[2])\n+            x = matrix[0][2] / (2 * z)\n+            y = matrix[1][2] / (2 * z)\n+\n+    else:\n+        # The matrix is the rotation by theta with sin(theta)!=0\n+        theta = math.acos(0.5 * (trace - 1))\n+        x = 1 / (2 * math.sin(theta)) * (matrix[2][1] - matrix[1][2])\n+        y = 1 / (2 * math.sin(theta)) * (matrix[0][2] - matrix[2][0])\n+        z = 1 / (2 * math.sin(theta)) * (matrix[1][0] - matrix[0][1])\n+\n     return np.array([x, y, z])\n \n \ndiff --git a/qiskit/synthesis/discrete_basis/gate_sequence.py b/qiskit/synthesis/discrete_basis/gate_sequence.py\nindex 39adff0ab5bc..73a0c631529b 100644\n--- a/qiskit/synthesis/discrete_basis/gate_sequence.py\n+++ b/qiskit/synthesis/discrete_basis/gate_sequence.py\n@@ -55,7 +55,6 @@ def __init__(self, gates: Sequence[Gate] = ()) -> None:\n         self.name = " ".join(self.labels)\n         self.global_phase = global_phase\n         self.product = so3_matrix\n-        self.product_su2 = su2_matrix\n \n     def remove_cancelling_pair(self, indices: Sequence[int]) -> None:\n         """Remove a pair of indices that cancel each other and *do not* change the matrices."""\n@@ -106,6 +105,16 @@ def to_circuit(self):\n \n         return circuit\n \n+    def _to_u2(self):\n+        """Creates the U2 matrix corresponding to the stored sequence of gates\n+        and the global phase.\n+        """\n+        u2 = np.eye(2, dtype=complex)\n+        for mat in self.gates:\n+            u2 = mat.to_matrix().dot(u2)\n+        u2 = np.exp(1j * self.global_phase) * u2\n+        return u2\n+\n     def to_dag(self):\n         """Convert to a :class:`.DAGCircuit`.\n \n@@ -149,7 +158,6 @@ def append(self, gate: Gate) -> "GateSequence":\n         so3 = _convert_su2_to_so3(su2)\n \n         self.product = so3.dot(self.product)\n-        self.product_su2 = su2.dot(self.product_su2)\n         self.global_phase = self.global_phase + phase\n \n         self.gates.append(gate)\n@@ -172,7 +180,6 @@ def adjoint(self) -> "GateSequence":\n         adjoint.labels = [inv.name for inv in adjoint.gates]\n         adjoint.name = " ".join(adjoint.labels)\n         adjoint.product = np.conj(self.product).T\n-        adjoint.product_su2 = np.conj(self.product_su2).T\n         adjoint.global_phase = -self.global_phase\n \n         return adjoint\n@@ -190,7 +197,6 @@ def copy(self) -> "GateSequence":\n         out.matrices = self.matrices.copy()\n         out.global_phase = self.global_phase\n         out.product = self.product.copy()\n-        out.product_su2 = self.product_su2.copy()\n         out.name = self.name\n         out._eulers = self._eulers\n         return out\ndiff --git a/qiskit/synthesis/discrete_basis/generate_basis_approximations.py b/qiskit/synthesis/discrete_basis/generate_basis_approximations.py\nindex 388b5cca061d..a603b89d94e4 100644\n--- a/qiskit/synthesis/discrete_basis/generate_basis_approximations.py\n+++ b/qiskit/synthesis/discrete_basis/generate_basis_approximations.py\n@@ -76,7 +76,7 @@ def _check_candidate_greedy(candidate, existing_sequences, tol=1e-10):\n         return False\n \n     for existing in existing_sequences:\n-        if matrix_equal(existing.product_su2, candidate.product_su2, ignore_phase=True, atol=tol):\n+        if matrix_equal(existing.product, candidate.product, ignore_phase=True, atol=tol):\n             # is the new sequence less or more efficient?\n             return len(candidate.gates) < len(existing.gates)\n     return True\ndiff --git a/qiskit/synthesis/discrete_basis/solovay_kitaev.py b/qiskit/synthesis/discrete_basis/solovay_kitaev.py\nindex f367f6c0f0b5..e87efe2884c3 100644\n--- a/qiskit/synthesis/discrete_basis/solovay_kitaev.py\n+++ b/qiskit/synthesis/discrete_basis/solovay_kitaev.py\n@@ -24,7 +24,7 @@\n class SolovayKitaevDecomposition:\n     """The Solovay Kitaev discrete decomposition algorithm.\n \n-    This class is called recursively by the transpiler pass, which is why it is separeted.\n+    This class is called recursively by the transpiler pass, which is why it is separated.\n     See :class:`qiskit.transpiler.passes.SolovayKitaev` for more information.\n     """\n \n@@ -33,7 +33,7 @@ def __init__(\n     ) -> None:\n         """\n         Args:\n-            basic_approximations: A specification of the basic SU(2) approximations in terms\n+            basic_approximations: A specification of the basic SO(3) approximations in terms\n                 of discrete gates. At each iteration this algorithm, the remaining error is\n                 approximated with the closest sequence of gates in this set.\n                 If a ``str``, this specifies a ``.npy`` filename from which to load the\n@@ -116,23 +116,33 @@ def run(\n         """\n         # make input matrix SU(2) and get the according global phase\n         z = 1 / np.sqrt(np.linalg.det(gate_matrix))\n-        gate_matrix_su2 = GateSequence.from_matrix(z * gate_matrix)\n+\n+        gate_matrix_su2 = z * gate_matrix\n+        gate_matrix_as_sequence = GateSequence.from_matrix(gate_matrix_su2)\n         global_phase = np.arctan2(np.imag(z), np.real(z))\n \n         # get the decomposition as GateSequence type\n-        decomposition = self._recurse(gate_matrix_su2, recursion_degree, check_input=check_input)\n+        decomposition = self._recurse(\n+            gate_matrix_as_sequence, recursion_degree, check_input=check_input\n+        )\n \n         # simplify\n         _remove_identities(decomposition)\n         _remove_inverse_follows_gate(decomposition)\n \n+        # adjust to the correct SU(2) phase\n+        adjust_phase = (\n+            np.pi if _should_adjust_phase(decomposition._to_u2(), gate_matrix_su2) else 0.0\n+        )\n+\n         # convert to a circuit and attach the right phases\n         if return_dag:\n             out = decomposition.to_dag()\n         else:\n             out = decomposition.to_circuit()\n \n-        out.global_phase = decomposition.global_phase - global_phase\n+        out.global_phase += adjust_phase\n+        out.global_phase -= global_phase\n \n         return out\n \n@@ -155,17 +165,20 @@ def _recurse(self, sequence: GateSequence, n: int, check_input: bool = True) ->\n             raise ValueError("Shape of U must be (3, 3) but is", sequence.shape)\n \n         if n == 0:\n-            return self.find_basic_approximation(sequence)\n+            res = self.find_basic_approximation(sequence)\n \n-        u_n1 = self._recurse(sequence, n - 1, check_input=check_input)\n+        else:\n+            u_n1 = self._recurse(sequence, n - 1, check_input=check_input)\n \n-        v_n, w_n = commutator_decompose(\n-            sequence.dot(u_n1.adjoint()).product, check_input=check_input\n-        )\n+            v_n, w_n = commutator_decompose(\n+                sequence.dot(u_n1.adjoint()).product, check_input=check_input\n+            )\n+\n+            v_n1 = self._recurse(v_n, n - 1, check_input=check_input)\n+            w_n1 = self._recurse(w_n, n - 1, check_input=check_input)\n+            res = v_n1.dot(w_n1).dot(v_n1.adjoint()).dot(w_n1.adjoint()).dot(u_n1)\n \n-        v_n1 = self._recurse(v_n, n - 1, check_input=check_input)\n-        w_n1 = self._recurse(w_n, n - 1, check_input=check_input)\n-        return v_n1.dot(w_n1).dot(v_n1.adjoint()).dot(w_n1.adjoint()).dot(u_n1)\n+        return res\n \n     def find_basic_approximation(self, sequence: GateSequence) -> GateSequence:\n         """Find ``GateSequence`` in ``self._basic_approximations`` that approximates ``sequence``.\n@@ -215,3 +228,13 @@ def _remove_identities(sequence):\n             sequence.gates.pop(index)\n         else:\n             index += 1\n+\n+\n+def _should_adjust_phase(computed: np.ndarray, target: np.ndarray) -> bool:\n+    """\n+    The implemented SolovayKitaevDecomposition has a global phase uncertainty of +-1,\n+    due to approximating not the original SU(2) matrix but its projection onto SO(3).\n+    This function returns ``True`` if the global phase of the computed approximation\n+    should be adjusted (by adding pi) to better much the target.\n+    """\n+    return np.linalg.norm(-computed - target) < np.linalg.norm(computed - target)\ndiff --git a/releasenotes/notes/fix-sk-decomposition-23da3ee4b6a10d62.yaml b/releasenotes/notes/fix-sk-decomposition-23da3ee4b6a10d62.yaml\nnew file mode 100644\nindex 000000000000..21a899a7df9e\n--- /dev/null\n+++ b/releasenotes/notes/fix-sk-decomposition-23da3ee4b6a10d62.yaml\n@@ -0,0 +1,12 @@\n+---\n+fixes:\n+  - |\n+    Fixed a problem in the :class:`.SolovayKitaev` transpiler pass where the pass could\n+    crash due to encountering a 180 degree rotation in the internal recursion,\n+    which was not handled correctly.\n+  - |\n+    Fixed a problem in the :class:`.SolovayKitaev` transpiler pass where the generated\n+    approximation could have a phase that differs by :math:`\\pi` from the correct value.\n+    This resulted due to the internal :math:`SO(3)` representation, which requires additional\n+    handling to obtain the correct sign of the qubit gate matrix.\n+    Fixed `#9552 <https://github.com/Qiskit/qiskit-terra/issues/9552>`__\n

Test patch:
diff --git a/test/python/transpiler/test_solovay_kitaev.py b/test/python/transpiler/test_solovay_kitaev.py\nindex bb39f8db8e81..da3a0840e2b8 100644\n--- a/test/python/transpiler/test_solovay_kitaev.py\n+++ b/test/python/transpiler/test_solovay_kitaev.py\n@@ -99,9 +99,7 @@ def test_unitary_synthesis(self):\n         passes = PassManager([_1q, _cons, _synth])\n         compiled = passes.run(circuit)\n \n-        diff = np.linalg.norm(Operator(compiled) - Operator(circuit))\n-        self.assertLess(diff, 1)\n-        self.assertEqual(set(compiled.count_ops().keys()), {"h", "s", "cx"})\n+        self.assertLessEqual(set(compiled.count_ops().keys()), {"h", "s", "sdg", "cx"})\n \n     def test_plugin(self):\n         """Test calling the plugin directly."""\n@@ -113,12 +111,7 @@ def test_plugin(self):\n         plugin = SolovayKitaevSynthesis()\n         out = plugin.run(unitary, basis_gates=["h", "s"])\n \n-        reference = QuantumCircuit(1, global_phase=3 * np.pi / 4)\n-        reference.h(0)\n-        reference.s(0)\n-        reference.h(0)\n-\n-        self.assertEqual(dag_to_circuit(out), reference)\n+        self.assertLessEqual(set(out.count_ops().keys()), {"h", "s", "sdg", "cx"})\n \n     def test_generating_default_approximation(self):\n         """Test the approximation set is generated by default."""\n@@ -164,11 +157,14 @@ def test_str_basis_gates(self):\n         dag = circuit_to_dag(circuit)\n         discretized = dag_to_circuit(synth.run(dag))\n \n-        reference = QuantumCircuit(1, global_phase=7 * np.pi / 8)\n+        reference = QuantumCircuit(1, global_phase=15 * np.pi / 8)\n         reference.h(0)\n         reference.t(0)\n         reference.h(0)\n \n+        # Make sure that the discretized circuit gives a valid approximation\n+        diff = _trace_distance(circuit, discretized)\n+        self.assertLess(diff, 0.01)\n         self.assertEqual(discretized, reference)\n \n     def test_approximation_on_qft(self):\n@@ -209,14 +205,14 @@ def test_u_gates_work(self):\n         circuit.u(np.pi / 2, 0, 15 * np.pi / 16, 0)\n \n         depth = 4\n-        basis_gates = ["h", "t", "tdg", "s", "z"]\n+        basis_gates = ["h", "t", "tdg", "s", "sdg", "z"]\n         gate_approx_library = generate_basic_approximations(basis_gates=basis_gates, depth=depth)\n \n         skd = SolovayKitaev(recursion_degree=2, basic_approximations=gate_approx_library)\n         discretized = skd(circuit)\n \n         included_gates = set(discretized.count_ops().keys())\n-        self.assertEqual(set(basis_gates), included_gates)\n+        self.assertLessEqual(included_gates, set(basis_gates))\n \n     def test_load_from_file(self):\n         """Test loading basic approximations from a file works.\n@@ -229,22 +225,21 @@ def test_load_from_file(self):\n             fullpath = os.path.join(tmp_dir, filename)\n \n             # dump approximations to file\n-            generate_basic_approximations(basis_gates=["h", "s", "sdg"], depth=3, filename=fullpath)\n+            gate_approx_library = generate_basic_approximations(\n+                basis_gates=["h", "s", "sdg"], depth=3, filename=fullpath\n+            )\n \n             # circuit to decompose and reference decomp\n             circuit = QuantumCircuit(1)\n             circuit.rx(0.8, 0)\n \n-            reference = QuantumCircuit(1, global_phase=3 * np.pi / 4)\n-            reference.h(0)\n-            reference.s(0)\n-            reference.h(0)\n+            # Run SK pass using gate_approx_library\n+            reference = SolovayKitaev(basic_approximations=gate_approx_library)(circuit)\n \n-            # load the decomp and compare to reference\n-            skd = SolovayKitaev(basic_approximations=fullpath)\n-            # skd = SolovayKitaev(basic_approximations=filename)\n-            discretized = skd(circuit)\n+            # Run SK pass using stored basis_approximations\n+            discretized = SolovayKitaev(basic_approximations=fullpath)(circuit)\n \n+        # Check that both flows produce the same result\n         self.assertEqual(discretized, reference)\n \n     def test_measure(self):\n@@ -297,6 +292,15 @@ def test_no_to_matrix(self):\n         transpiled = SolovayKitaev()(qc)\n         self.assertEqual(set(transpiled.count_ops()), {"initialize"})\n \n+    def test_y_gate(self):\n+        """Test the Solovay-Kitaev decomposition on the circuit with a Y-gate (see issue #9552)."""\n+        circuit = QuantumCircuit(1)\n+        circuit.y(0)\n+\n+        transpiled = SolovayKitaev()(circuit)\n+        diff = _trace_distance(circuit, transpiled)\n+        self.assertLess(diff, 1e-6)\n+\n \n @ddt\n class TestGateSequence(QiskitTestCase):\n