Repository: Qiskit/qiskit — pull request #12884 (python)
Title: Fix setter so that `SparsePauliOp.paulis.phase` stays zero

<!--\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- [] I have added the tests to cover my changes.\r\n- [ ] I have updated the documentation accordingly.\r\n- [ ] I have read the CONTRIBUTING document.\r\n-->\r\n\r\n### Summary\r\nGoal is to fix #12883.\r\n\r\nCurrently several `SPO` methods assume that `SPO.paulis.phase` is all zero. However nonzero phase is allowed by the `paulis()` setter, leading to incorrect results when those other methods are called. \r\n\r\n### Details and comments\r\nIn this small PR we update the `paulis()` setter to absorb the phase into the SPO `coeffs`. \r\n

Resolved issues:
title: several `SparsePauliOp` methods ignore `self.paulis.phase`
body: ### Environment\r\n\r\n- **Qiskit version**: 1.1.1\r\n- **Python version**: 3.11.6\r\n- **Operating system**: macos\r\n\r\n\r\n### What is happening?\r\n\r\nIf I understand it right, `SparsePauliOp.simplify()` does not correctly take into account the `phase` attribute of its underlying `PauliList`.\r\n\r\nThe same also seems true for several `SparsePauliOp` methods under the header "LinearOp Methods", like `conjugate`, and `adjoint`, which update `self.coeffs` but AFAIK do not look at `self.paulis.phase`.\r\n\r\n(I haven't looked exhaustively for other methods that do not handle `paulis.phase`.)\r\n\r\n\r\n### How can we reproduce the issue?\r\n\r\nFor example, define a `SparsePauliOp`, and then set its `paulis` attribute to a new value with nonzero `phase`:\r\n```\r\nspo = qi.SparsePauliOp(['X', 'X'])\r\nspo.paulis = ['X','-X']\r\nprint(spo)\r\n```\r\n```\r\nSparsePauliOp(['X', '-X'],\r\n              coeffs=[1.+0.j, 1.+0.j])\r\n```\r\nSo far so good. But now call simplify:\r\n```\r\nspo.simplify()\r\n```\r\n```\r\nSparsePauliOp(['X'],\r\n              coeffs=[2.+0.j])\r\n```\r\nAFAIK this is incorrect. \r\n\r\n### What should happen?\r\n\r\nThe example above should instead match this result where we construct the SPO differently:\r\n```\r\nspo_2 = qi.SparsePauliOp(['X','-X'])\r\nspo_2.simplify()\r\n```\r\n```\r\nSparsePauliOp(['I'],\r\n              coeffs=[0.+0.j])\r\n```\r\n\r\n### Any suggestions?\r\n\r\nEither we need to fix all the methods to consider `paulis.phase`, or (probably better) we need to more strictly require that the `paulis` attribute never has any phase, e.g. updating the `paulis()` setter to move the phase into the `coeffs` (don't know what else would need to change).\r\n\r\nAnother example:\r\n```\r\nspo_1 = qi.SparsePauliOp(['X'])\r\nspo_1.paulis = qi.PauliList(['-1jX'])\r\nspo_1\r\n```\r\n```\r\nSparsePauliOp(['-iX'],\r\n              coeffs=[1.+0.j])\r\n```\r\nNow try `conjugate()`:\r\n```\r\nspo_1.conjugate()\r\n```\r\n```\r\nSparsePauliOp(['-iX'],\r\n              coeffs=[1.-0.j])\r\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 10d76f8719af..4621019a9d81 100644\n--- a/qiskit/quantum_info/operators/symplectic/sparse_pauli_op.py\n+++ b/qiskit/quantum_info/operators/symplectic/sparse_pauli_op.py\n@@ -256,6 +256,8 @@ def paulis(self, value):\n             raise ValueError(\n                 f"incorrect number of operators: expected {len(self.paulis)}, got {len(value)}"\n             )\n+        self.coeffs *= (-1j) ** value.phase\n+        value.phase = 0\n         self._pauli_list = value\n \n     @property\ndiff --git a/releasenotes/notes/fix-sparsepauliop-phase-bug-2b24f4b775ca564f.yaml b/releasenotes/notes/fix-sparsepauliop-phase-bug-2b24f4b775ca564f.yaml\nnew file mode 100644\nindex 000000000000..ff1cf96dd711\n--- /dev/null\n+++ b/releasenotes/notes/fix-sparsepauliop-phase-bug-2b24f4b775ca564f.yaml\n@@ -0,0 +1,8 @@\n+---\n+fixes:\n+  - |\n+    Fixed a bug when :attr:`.SparsePauliOp.paulis` is set to be a :class:`.PauliList` with nonzero \n+    phase, where subsequent calls to several :class:`.SparsePauliOp` methods would produce \n+    incorrect results. Now when :attr:`.SparsePauliOp.paulis` is set to a :class:`.PauliList` with \n+    nonzero phase, the phase is absorbed into :attr:`.SparsePauliOp.coeffs`, and the phase of the \n+    input :class:`.PauliList` is set to zero.\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 4964b0c6a609..dedd84279a8d 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@@ -1097,6 +1097,25 @@ def test_paulis_setter_rejects_bad_inputs(self):\n         with self.assertRaisesRegex(ValueError, "incorrect number of operators"):\n             op.paulis = PauliList([Pauli("XY"), Pauli("ZX"), Pauli("YZ")])\n \n+    def test_paulis_setter_absorbs_phase(self):\n+        """Test that the setter for `paulis` absorbs `paulis.phase` to `self.coeffs`."""\n+        coeffs_init = np.array([1, 1j])\n+        op = SparsePauliOp(["XY", "ZX"], coeffs=coeffs_init)\n+        paulis_new = PauliList(["-1jXY", "1jZX"])\n+        op.paulis = paulis_new\n+        # Paulis attribute should have no phase:\n+        self.assertEqual(op.paulis, PauliList(["XY", "ZX"]))\n+        # Coeffs attribute should now include that phase:\n+        self.assertTrue(np.allclose(op.coeffs, coeffs_init * np.array([-1j, 1j])))\n+        # The phase of the input array is now zero:\n+        self.assertTrue(np.allclose(paulis_new.phase, np.array([0, 0])))\n+\n+    def test_paulis_setter_absorbs_phase_2(self):\n+        """Test that `paulis` setter followed by `simplify()` handle phase OK."""\n+        spo = SparsePauliOp(["X", "X"])\n+        spo.paulis = ["X", "-X"]\n+        self.assertEqual(spo.simplify(), SparsePauliOp(["I"], coeffs=[0.0 + 0.0j]))\n+\n     def test_apply_layout_with_transpile(self):\n         """Test the apply_layout method with a transpiler layout."""\n         psi = EfficientSU2(4, reps=4, entanglement="circular")\n