Repository: Qiskit/qiskit — pull request #14676 (python)
Title: Fix SparsePauliOp.is_unitary() to respect input tolerance values

Fixes #14107\r\n\r\n### Summary\r\n\r\nThis PR fixes an issue where `SparsePauliOp.is_unitary()` was not properly respecting the input tolerance values when checking if an operator is unitary. The method was not passing the tolerance parameters to the internal [simplify()] call.\r\n\r\n### Details and comments\r\n\r\nThe main changes in this PR are:\r\n\r\n1. Modified `SparsePauliOp.is_unitary()` to pass the atol and rtol parameters to the [simplify()] method\r\n2. Added a test case that verifies the fix works as expected by:\r\n   - Creating a matrix that's approximately unitary but not exactly\r\n   - Verifying it's considered unitary with appropriate tolerances\r\n   - Verifying it's not considered unitary with very tight tolerances\r\n\r\n### Testing\r\n- [x] I have added the tests to cover my changes.\r\n- [x] All tests pass locally with my changes\r\n- [x] I have read the CONTRIBUTING document.\r\n\r\n### Release Notes\r\n\r\nFixed an issue where `SparsePauliOp.is_unitary()` was not respecting the input tolerance values when checking if an operator is unitary. 

Resolved issues:
title: SparsePauliOp.is_unitary() doesn't respect the input tolerance values
body: ### Environment\n\n- **Qiskit version**: the main branch\n- **Python version**: -\n- **Operating system**: - \n\n\n### What is happening?\n\nThe comment of the method said the tolerance is used to check if the values are zero, but the implementation actually only used the tolerance to check if the coefficient of the identity operator is one.\n\nThe tolerance values should be propagated to SparsePauliOp.simplify() to set the coefficients smaller than the tolerance to zero.\n\nhttps://github.com/Qiskit/qiskit/blob/6ce77887008c181f74306887ec494fedd2911a56/qiskit/quantum_info/operators/symplectic/sparse_pauli_op.py#L433-L459\n\n### How can we reproduce the issue?\n\n```python\nimport numpy as np\n\na = np.array(  #\n    [[-9.9801135e-01 + 6.3036762e-02j, 5.6710692e-06 + 8.1099635e-05j],\n     [5.6710610e-06 + 8.1099643e-05j, -9.9707150e-01 + 7.6473624e-02j]])\nprint(a @ (a.conj().T))\n# output:\n# [[ 1.00000029e+00+0.00000000e+00j -2.83078694e-12+2.75849112e-11j]\n#  [-2.83078694e-12-2.75849112e-11j  9.99999798e-01+0.00000000e+00j]]\npauli_operators = quantum_info.SparsePauliOp.from_operator(a)\nprint(pauli_operators.compose(pauli_operators.adjoint()).simplify())\n# output:\n# SparsePauliOp(['I', 'Z'],\n#               coeffs=[1.00000004e+00+3.95107524e-18j, 2.48406162e-07+0.00000000e+00j])\nprint(pauli_operators.is_unitary(atol=1e-5, rtol=1e-3))\n# output: False\n# expect: True\n```\n\n### What should happen?\n\nSee the how to reproduce section.\n\n### Any suggestions?\n\nChange \n\n```python\n val = self.compose(self.adjoint()).simplify() \n```\n\nto \n\n```python\n val = self.compose(self.adjoint()).simplify(atol=atol, rtol=rtol)\n```

Hints:

Fix patch:
diff --git a/qiskit/quantum_info/operators/symplectic/sparse_pauli_op.py b/qiskit/quantum_info/operators/symplectic/sparse_pauli_op.py\nindex 6c0ce6608267..91ab65fdf6f6 100644\n--- a/qiskit/quantum_info/operators/symplectic/sparse_pauli_op.py\n+++ b/qiskit/quantum_info/operators/symplectic/sparse_pauli_op.py\n@@ -433,10 +433,14 @@ def _multiply(self, other):\n     def is_unitary(self, atol: float | None = None, rtol: float | None = None) -> bool:\n         """Return True if operator is a unitary matrix.\n \n+        This method checks whether the operator composed with its adjoint equals\n+        the identity, up to the provided tolerance. The tolerance is used when\n+        simplifying the composed operator and checking if the result is the identity.\n+\n         Args:\n             atol (float): Optional. Absolute tolerance for checking if\n                           coefficients are zero (Default: 1e-8).\n-            rtol (float): Optional. relative tolerance for checking if\n+            rtol (float): Optional. Relative tolerance for checking if\n                           coefficients are zero (Default: 1e-5).\n \n         Returns:\n@@ -449,7 +453,7 @@ def is_unitary(self, atol: float | None = None, rtol: float | None = None) -> bo\n             rtol = self.rtol\n \n         # Compose with adjoint\n-        val = self.compose(self.adjoint()).simplify()\n+        val = self.compose(self.adjoint()).simplify(atol=atol, rtol=rtol)\n         # See if the result is an identity\n         return (\n             val.size == 1\ndiff --git a/releasenotes/notes/fix-sparse-pauli-op-is-unitary-tolerance-28249b2d55f38284.yaml b/releasenotes/notes/fix-sparse-pauli-op-is-unitary-tolerance-28249b2d55f38284.yaml\nnew file mode 100644\nindex 000000000000..c899bb5cbdc0\n--- /dev/null\n+++ b/releasenotes/notes/fix-sparse-pauli-op-is-unitary-tolerance-28249b2d55f38284.yaml\n@@ -0,0 +1,7 @@\n+fixes:\n+  - |\n+    Fixed an issue where :meth:`~qiskit.quantum_info.SparsePauliOp.is_unitary`\n+    was not properly respecting the input tolerance values when checking if an\n+    operator is unitary. The method now correctly uses the provided ``atol`` and\n+    ``rtol`` parameters when simplifying the operator and checking if it equals\n+    the identity. This fixes `#14107 <https://github.com/Qiskit/qiskit/issues/14107>`__.\n

Test patch:
diff --git a/test/python/quantum_info/operators/symplectic/test_sparse_pauli_op.py b/test/python/quantum_info/operators/symplectic/test_sparse_pauli_op.py\nindex 075313fd2d20..6ff669f7d79d 100644\n--- a/test/python/quantum_info/operators/symplectic/test_sparse_pauli_op.py\n+++ b/test/python/quantum_info/operators/symplectic/test_sparse_pauli_op.py\n@@ -1357,6 +1357,39 @@ def test_simplify_sum_below_tolerance(self):\n         res = op.simplify(atol=1e-7)\n         self.assertEqual(SparsePauliOp(["II"], [0j]), res)\n \n+    def test_is_unitary_tolerance(self):\n+        """Test that is_unitary respects the input tolerance values.\n+\n+        This test verifies that the tolerance parameters are properly passed through\n+        to the internal operations. See issue #14107.\n+        """\n+        # Create a matrix that's approximately unitary but not exactly\n+        a = np.array(\n+            [\n+                [-9.9801135e-01 + 6.3036762e-02j, 5.6710692e-06 + 8.1099635e-05j],\n+                [5.6710610e-06 + 8.1099643e-05j, -9.9707150e-01 + 7.6473624e-02j],\n+            ]\n+        )\n+\n+        # Verify the matrix is approximately unitary\n+        identity = a @ a.conj().T\n+        np.testing.assert_allclose(identity, np.eye(2), atol=1e-5, rtol=1e-3)\n+\n+        # Create SparsePauliOp from the matrix\n+        op = SparsePauliOp.from_operator(a)\n+\n+        # Test with tolerance that should pass\n+        self.assertTrue(\n+            op.is_unitary(atol=1e-5, rtol=1e-3),\n+            "Operator should be considered unitary with given tolerance",\n+        )\n+\n+        # Test with very small tolerance that should fail\n+        self.assertFalse(\n+            op.is_unitary(atol=1e-10, rtol=1e-10),\n+            "Operator should not be considered unitary with very small tolerance",\n+        )\n+\n \n if __name__ == "__main__":\n     unittest.main()\n