Repository: Qiskit/qiskit

Issue:
`PauliList.evolve` raises exception when evolving certain Clifford circuits.
### Environment

- **Qiskit Terra version**:  0.21.1
- **Python version**: 3.9
- **Operating system**: MacOS 11.6.7


### What is happening?

There is a bug in the `BasePauli/PauliList/Pauli` classes where the phase vector is sometimes set as dtype `uint8` instead of `int64` leading to errors when calling the `evolve` function for circuits containing gates that modify the pauli phase vector.

Whether this happens or not seems to depend on how the Pauli is initialized (sometimes it will be initialized with uint8 phase vector, other times with int64 phase vector).

### How can we reproduce the issue?

Minimal example

```python
from qiskit import QuantumCircuit
import qiskit.quantum_info as qi

# Create circuit with gate that modifies Pauli phase
qc = QuantumCircuit(2)
qc.cz(0, 1)

# Generate random Pauli
pauli = qi.random_pauli(2, seed=123)

# Try and evolve
pauli.evolve(qc)
```

Give the exception:
```
---------------------------------------------------------------------------
UFuncTypeError                            Traceback (most recent call last)
/var/folders/lp/ftt3j6qs7s90j6h9wszq3jcw0000gn/T/ipykernel_71613/3628800509.py in <module>
      7 pauli = qi.random_pauli(2, seed=123)
      8 #qc = qi.random_clifford(4, seed=123).to_circuit()
----> 9 pauli.evolve(qc)

~/anaconda3/envs/qiskit/lib/python3.9/site-packages/qiskit/quantum_info/operators/symplectic/pauli.py in evolve(self, other, qargs, frame)
    597             other = Pauli(other)
    598 
--> 599         return Pauli(super().evolve(other, qargs=qargs, frame=frame))
    600 
    601     # ---------------------------------------------------------------------

~/anaconda3/envs/qiskit/lib/python3.9/site-packages/qiskit/quantum_info/operators/symplectic/base_pauli.py in evolve(self, other, qargs, frame)
    277         if frame == "s":
    278             return self.copy()._append_circuit(other, qargs=qargs)
--> 279         return self.copy()._append_circuit(other.inverse(), qargs=qargs)
    280 
    281     def _evolve_clifford(self, other, qargs=None, frame="h"):

~/anaconda3/envs/qiskit/lib/python3.9/site-packages/qiskit/quantum_info/operators/symplectic/base_pauli.py in _append_circuit(self, circuit, qargs)
    585             # Get the integer position of the flat register
    586             new_qubits = [qargs[bit_indices[tup]] for tup in instruction.qubits]
--> 587             self._append_circuit(instruction.operation, new_qubits)
    588 
    589         # Since the individual gate evolution functions don't take mod

~/anaconda3/envs/qiskit/lib/python3.9/site-packages/qiskit/quantum_info/operators/symplectic/base_pauli.py in _append_circuit(self, circuit, qargs)
    558             if len(qargs) != 2:
    559                 raise QiskitError("Invalid qubits for 2-qubit gate.")
--> 560             return basis_2q[name](self, qargs[0], qargs[1])
    561 
    562         # If not a Clifford basis gate we try to unroll the gate and

~/anaconda3/envs/qiskit/lib/python3.9/site-packages/qiskit/quantum_info/operators/symplectic/base_pauli.py in _evolve_cz(base_pauli, q1, q2)
    661     base_pauli._z[:, q1] ^= x2
    662     base_pauli._z[:, q2] ^= x1
--> 663     base_pauli._phase += 2 * np.logical_and(x1, x2).T
    664     return base_pauli
    665 

UFuncTypeError: Cannot cast ufunc 'add' output from dtype('int64') to dtype('uint8') with casting rule 'same_kind'
```

### What should happen?

The dtype of the phase vector should always be the same, so that evolution and other methods that attempt to inplace modify the vector don't cause exceptions for mismatched dtypes.

### Any suggestions?

One fix would be to explicitly cast the phase vector when initializing base pauli


Discussion:
I suspect the bug might be due to this line in the `_count_y` function: https://github.com/Qiskit/qiskit-terra/blob/main/qiskit/quantum_info/operators/symplectic/base_pauli.py#L692

```python
if dtype is None:
    dtype = np.min_scalar_type(x.shape[axis])
```
I tried :
if dtype is None:
        dtype = np.int64(x.shape[axis])

No errors with this solution, print(pauli.evolve(qc)) shows ZY. So this solution works but casting could also create bug in itself. In any case I will start an PR and run all the tests to be sure.
@chriseclectic: I think the phase vector should always be representable in a `u8` right?  It's only 2 bits of data.  The `_count_y` dtype handling there looks correct for what the function does, so I suspect the more correct solution is to ensure that whatever's calling that is passing `np.uint8` as the dtype when it's going to bind the result to a `uint8` context, or it manually casts after the function is called.  `uint` arithmetic is guaranteed to wraparound on overflow, so there shouldn't be any issues, since the phase is just about to take the result mod 4 anyway.

@BramDo: `np.int64(val)` produces a value of type `np.int64`, whereas the `dtype` argument should be a dtype instance (e.g. the class `np.int64` itself).  I'm surprised Numpy doesn't error out on that call, but I suppose it must be trying to match the type.  Either way, I don't think this is the correct solution here - I think it's missing the real issue.
I did not realize that the phase vector is only 2 bits of data. In c programming you could manipulate these bits with shift and other operations. But we don't need that because the problem is not in the _count_y handling. It seems that there might be a mismatch with the calling software or even it could be there is not a problem at all. However if you run the realistic example you get the error, and with the (not correct modification ) the pauli.evolve gives you the right answer.  Maybe the real problem could be found by debugging in vscode and set some variables and breakpoints?
Each element of the phase vector is two bits of data, but Paulis on multiple qubits use a vector of these values. The issue with your PR wasn't that the output result wasn't mathematically correct, it's that it changed the data type of the phase vector from what it logically should be (u8) to something much wider (i64). That means more data is used all the time, and it was masking the real bug, which is that something somewhere else in the code was trying to do in place mutation between two values whose types it wasn't properly controlling.
As already discussed it is not necessary to modify the _count_y because dtype handling there looks correct for what the function does. The more correct solution is to ensure that whatever's calling that is passing np.uint8 as the dtype when it's going to bind the result to a uint8 context, or it manually cast. The caller can be found in the exception handling of the example:

```
~/anaconda3/envs/qiskit/lib/python3.9/site-packages/qiskit/quantum_info/operators/symplectic/base_pauli.py in _evolve_cz(base_pauli, q1, q2)
    661     base_pauli._z[:, q1] ^= x2
    662     base_pauli._z[:, q2] ^= x1
--> 663     base_pauli._phase += 2 * np.logical_and(x1, x2).T
    664     return base_pauli
    665 
```

In the example we use an evolve over the phase. This is done by the evolve_cz function. In line 663 the phase vector is set by:
base_pauli._phase += 2 * np.logical_and(x1, x2).T

To cast the phase vector to a uint8 we can do:
base_pauli._phase += np.uint8(2 * (np.logical_and(x1, x2).T))

Summary 
No change in the  _count_y function but modification of the result of the  _evolve_cz function. This works, I got the mathematically correct answer as before and we don't have to change the phase vector from what it logically should be (u8) to something much wider (i64) with the problem of using to much data all the time.

I'll be happy to go further with this PR if this is the way to go.



Resolving patch:
diff --git a/qiskit/quantum_info/operators/symplectic/base_pauli.py b/qiskit/quantum_info/operators/symplectic/base_pauli.py
--- a/qiskit/quantum_info/operators/symplectic/base_pauli.py
+++ b/qiskit/quantum_info/operators/symplectic/base_pauli.py
@@ -131,9 +131,9 @@ def compose(self, other, qargs=None, front=False, inplace=False):
         # Get phase shift
         phase = self._phase + other._phase
         if front:
-            phase += 2 * _count_y(x1, z2)
+            phase += 2 * _count_y(x1, z2, dtype=phase.dtype)
         else:
-            phase += 2 * _count_y(x2, z1)
+            phase += 2 * _count_y(x2, z1, dtype=phase.dtype)
 
         # Update Pauli
         x = np.logical_xor(x1, x2)
@@ -185,7 +185,7 @@ def conjugate(self):
     def transpose(self):
         """Return the transpose of each Pauli in the list."""
         # Transpose sets Y -> -Y. This has effect on changing the phase
-        parity_y = self._count_y() % 2
+        parity_y = self._count_y(dtype=self._phase.dtype) % 2
         if np.all(parity_y == 0):
             return self
         return BasePauli(self._z, self._x, np.mod(self._phase + 2 * parity_y, 4))
@@ -388,7 +388,8 @@ def _from_array(z, x, phase=0):
             raise QiskitError("z and x vectors are different size.")
 
         # Convert group phase convention to internal ZX-phase conversion.
-        base_phase = np.mod(_count_y(base_x, base_z) + phase, 4)
+        dtype = getattr(phase, "dtype", None)
+        base_phase = np.mod(_count_y(base_x, base_z, dtype=dtype) + phase, 4)
         return base_z, base_x, base_phase
 
     @staticmethod
@@ -601,7 +602,7 @@ def _evolve_h(base_pauli, qubit):
     z = base_pauli._z[:, qubit].copy()
     base_pauli._x[:, qubit] = z
     base_pauli._z[:, qubit] = x
-    base_pauli._phase += 2 * np.logical_and(x, z).T
+    base_pauli._phase += 2 * np.logical_and(x, z).T.astype(base_pauli._phase.dtype)
     return base_pauli
 
 
@@ -609,7 +610,7 @@ def _evolve_s(base_pauli, qubit):
     """Update P -> S.P.Sdg"""
     x = base_pauli._x[:, qubit]
     base_pauli._z[:, qubit] ^= x
-    base_pauli._phase += x.T
+    base_pauli._phase += x.T.astype(base_pauli._phase.dtype)
     return base_pauli
 
 
@@ -617,7 +618,7 @@ def _evolve_sdg(base_pauli, qubit):
     """Update P -> Sdg.P.S"""
     x = base_pauli._x[:, qubit]
     base_pauli._z[:, qubit] ^= x
-    base_pauli._phase -= x.T
+    base_pauli._phase -= x.T.astype(base_pauli._phase.dtype)
     return base_pauli
 
 
@@ -629,19 +630,21 @@ def _evolve_i(base_pauli, qubit):
 
 def _evolve_x(base_pauli, qubit):
     """Update P -> X.P.X"""
-    base_pauli._phase += 2 * base_pauli._z[:, qubit].T
+    base_pauli._phase += 2 * base_pauli._z[:, qubit].T.astype(base_pauli._phase.dtype)
     return base_pauli
 
 
 def _evolve_y(base_pauli, qubit):
     """Update P -> Y.P.Y"""
-    base_pauli._phase += 2 * base_pauli._x[:, qubit].T + 2 * base_pauli._z[:, qubit].T
+    xp = base_pauli._x[:, qubit].T.astype(base_pauli._phase.dtype)
+    zp = base_pauli._z[:, qubit].T.astype(base_pauli._phase.dtype)
+    base_pauli._phase += 2 * (xp + zp)
     return base_pauli
 
 
 def _evolve_z(base_pauli, qubit):
     """Update P -> Z.P.Z"""
-    base_pauli._phase += 2 * base_pauli._x[:, qubit].T
+    base_pauli._phase += 2 * base_pauli._x[:, qubit].T.astype(base_pauli._phase.dtype)
     return base_pauli
 
 
@@ -658,7 +661,7 @@ def _evolve_cz(base_pauli, q1, q2):
     x2 = base_pauli._x[:, q2].copy()
     base_pauli._z[:, q1] ^= x2
     base_pauli._z[:, q2] ^= x1
-    base_pauli._phase += 2 * np.logical_and(x1, x2).T
+    base_pauli._phase += 2 * np.logical_and(x1, x2).T.astype(base_pauli._phase.dtype)
     return base_pauli
 
 
@@ -670,7 +673,7 @@ def _evolve_cy(base_pauli, qctrl, qtrgt):
     base_pauli._x[:, qtrgt] ^= x1
     base_pauli._z[:, qtrgt] ^= x1
     base_pauli._z[:, qctrl] ^= np.logical_xor(x2, z2)
-    base_pauli._phase += x1 + 2 * np.logical_and(x1, x2).T
+    base_pauli._phase += x1 + 2 * np.logical_and(x1, x2).T.astype(base_pauli._phase.dtype)
     return base_pauli
 
 
@@ -687,7 +690,4 @@ def _evolve_swap(base_pauli, q1, q2):
 
 def _count_y(x, z, dtype=None):
     """Count the number of I Pauli's"""
-    axis = 1
-    if dtype is None:
-        dtype = np.min_scalar_type(x.shape[axis])
-    return (x & z).sum(axis=axis, dtype=dtype)
+    return (x & z).sum(axis=1, dtype=dtype)
diff --git a/qiskit/quantum_info/operators/symplectic/pauli.py b/qiskit/quantum_info/operators/symplectic/pauli.py
--- a/qiskit/quantum_info/operators/symplectic/pauli.py
+++ b/qiskit/quantum_info/operators/symplectic/pauli.py
@@ -286,12 +286,12 @@ def settings(self) -> Dict:
     def phase(self):
         """Return the group phase exponent for the Pauli."""
         # Convert internal ZX-phase convention of BasePauli to group phase
-        return np.mod(self._phase - self._count_y(), 4)[0]
+        return np.mod(self._phase - self._count_y(dtype=self._phase.dtype), 4)[0]
 
     @phase.setter
     def phase(self, value):
         # Convert group phase convention to internal ZX-phase convention
-        self._phase[:] = np.mod(value + self._count_y(), 4)
+        self._phase[:] = np.mod(value + self._count_y(dtype=self._phase.dtype), 4)
 
     @property
     def x(self):
@@ -639,7 +639,9 @@ def _from_scalar_op(cls, op):
             raise QiskitError(f"{op} is not an N-qubit identity")
         base_z = np.zeros((1, op.num_qubits), dtype=bool)
         base_x = np.zeros((1, op.num_qubits), dtype=bool)
-        base_phase = np.mod(cls._phase_from_complex(op.coeff) + _count_y(base_x, base_z), 4)
+        base_phase = np.mod(
+            cls._phase_from_complex(op.coeff) + _count_y(base_x, base_z), 4, dtype=int
+        )
         return base_z, base_x, base_phase
 
     @classmethod
diff --git a/qiskit/quantum_info/operators/symplectic/pauli_list.py b/qiskit/quantum_info/operators/symplectic/pauli_list.py
--- a/qiskit/quantum_info/operators/symplectic/pauli_list.py
+++ b/qiskit/quantum_info/operators/symplectic/pauli_list.py
@@ -237,12 +237,12 @@ def equiv(self, other):
     def phase(self):
         """Return the phase exponent of the PauliList."""
         # Convert internal ZX-phase convention to group phase convention
-        return np.mod(self._phase - self._count_y(), 4)
+        return np.mod(self._phase - self._count_y(dtype=self._phase.dtype), 4)
 
     @phase.setter
     def phase(self, value):
         # Convert group phase convetion to internal ZX-phase convention
-        self._phase[:] = np.mod(value + self._count_y(), 4)
+        self._phase[:] = np.mod(value + self._count_y(dtype=self._phase.dtype), 4)
 
     @property
     def x(self):


Test patch:
diff --git a/test/python/quantum_info/operators/symplectic/test_pauli.py b/test/python/quantum_info/operators/symplectic/test_pauli.py
--- a/test/python/quantum_info/operators/symplectic/test_pauli.py
+++ b/test/python/quantum_info/operators/symplectic/test_pauli.py
@@ -404,6 +404,34 @@ def test_evolve_clifford2(self, gate, label):
         self.assertEqual(value, value_h)
         self.assertEqual(value_inv, value_s)
 
+    @data(
+        *it.product(
+            (
+                IGate(),
+                XGate(),
+                YGate(),
+                ZGate(),
+                HGate(),
+                SGate(),
+                SdgGate(),
+                CXGate(),
+                CYGate(),
+                CZGate(),
+                SwapGate(),
+            ),
+            [int, np.int8, np.uint8, np.int16, np.uint16, np.int32, np.uint32, np.int64, np.uint64],
+        )
+    )
+    @unpack
+    def test_phase_dtype_evolve_clifford(self, gate, dtype):
+        """Test phase dtype for evolve method for Clifford gates."""
+        z = np.ones(gate.num_qubits, dtype=bool)
+        x = np.ones(gate.num_qubits, dtype=bool)
+        phase = (np.sum(z & x) % 4).astype(dtype)
+        paulis = Pauli((z, x, phase))
+        evo = paulis.evolve(gate)
+        self.assertEqual(evo.phase.dtype, dtype)
+
     def test_evolve_clifford_qargs(self):
         """Test evolve method for random Clifford"""
         cliff = random_clifford(3, seed=10)
diff --git a/test/python/quantum_info/operators/symplectic/test_pauli_list.py b/test/python/quantum_info/operators/symplectic/test_pauli_list.py
--- a/test/python/quantum_info/operators/symplectic/test_pauli_list.py
+++ b/test/python/quantum_info/operators/symplectic/test_pauli_list.py
@@ -2034,6 +2034,40 @@ def test_evolve_clifford2(self, gate):
         self.assertListEqual(value, value_h)
         self.assertListEqual(value_inv, value_s)
 
+    def test_phase_dtype_evolve_clifford(self):
+        """Test phase dtype during evolve method for Clifford gates."""
+        gates = (
+            IGate(),
+            XGate(),
+            YGate(),
+            ZGate(),
+            HGate(),
+            SGate(),
+            SdgGate(),
+            CXGate(),
+            CYGate(),
+            CZGate(),
+            SwapGate(),
+        )
+        dtypes = [
+            int,
+            np.int8,
+            np.uint8,
+            np.int16,
+            np.uint16,
+            np.int32,
+            np.uint32,
+            np.int64,
+            np.uint64,
+        ]
+        for gate, dtype in itertools.product(gates, dtypes):
+            z = np.ones(gate.num_qubits, dtype=bool)
+            x = np.ones(gate.num_qubits, dtype=bool)
+            phase = (np.sum(z & x) % 4).astype(dtype)
+            paulis = Pauli((z, x, phase))
+            evo = paulis.evolve(gate)
+            self.assertEqual(evo.phase.dtype, dtype)
+
     @combine(phase=(True, False))
     def test_evolve_clifford_qargs(self, phase):
         """Test evolve method for random Clifford"""