Repository: quantumlib/Cirq

Issue:
`Xa*Zb*Zb*Zb` works but `Xa*Zb**3` raises error 
**How to reproduce the issue**
```python
In [1]: import cirq
   ...: a, b = cirq.LineQubit.range(2)
   ...: Xa, Zb = cirq.X(a), cirq.Z(b)
   ...: print(Xa * Zb * Zb * Zb) # Works because always stays a `cirq.PauliString`. 
   ...: print(Xa * Zb ** 3) # Fails because Zb ** 3 returns a `cirq.ZPowGate` instead of `cirq._PauliZ`; which doesn't support multiplication with other pauli's.

X(q(0))*Z(q(1))
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
<ipython-input-2-402c68ab82e4> in <module>
      3 Xa, Zb = cirq.X(a), cirq.Z(b)
      4 print(Xa * Zb * Zb * Zb) # Works because always stays a `cirq.PauliString`.
----> 5 print(Xa * Zb ** 3) # Fails because Zb ** 3 returns a `cirq.ZPowGate` instead of `cirq._PauliZ`; which doesn't support multiplication with other pauli's.

TypeError: unsupported operand type(s) for *: 'SingleQubitPauliStringGateOperation' and 'GateOperation'
```

**Cirq version**:
0.16.0.dev



Discussion:
From cirq sync:

Looks like a bug and should be fixed. Should not be categorized as a breaking change. 
Would this be a good first issue to look into. If so could this be assigned to me?
Kinda think this is not possible. Presumably it should work for even exponents too. However to work for even exponents, the gate needs to transform to `cirq.I`. However that ends up causing all sorts of downstream problems (even after making `cirq.I` an `EigenGate`) as can be seen in the linked PR above because `cirq.I` is special cased so much. Additionally it breaks all the `eval(repr(x)) == x` etc. protocol tests. 
Well, it's possible, as I managed to get the linked PR to pass all tests. The changes are probably more breaking than desired though. And we need to add some extra handling and tests since `IdentityGate`, as an `EigenGate`, can now have a phase attached.

It also leaves `Xa*Xa != Xa**2`, which seems wrong. I think to solve this completely we'd need a `_PauliI` (which may be reasonable? Not sure.)
Nvm, different approach worked.
Nvm again. The second approach had an issue with `PauliString(X(q)**2)` turning into `PauliString()` and then losing the qubit and thus changing the commutation properties. To fix that we'd need to make `I` a paulistring option, which then we'd have to get into making a `_PauliI`, and then making `Identity` an `EigenGate` like in the first attempt. Doable, but a big change that needs some back-and-forth design and approvals since its backwards compatibility is questionable.

Would be a good issue though for someone with more time to contribute.
Hi, I would like to try to work on this issue :-)

Resolving patch:
diff --git a/cirq-core/cirq/ops/pauli_string.py b/cirq-core/cirq/ops/pauli_string.py
--- a/cirq-core/cirq/ops/pauli_string.py
+++ b/cirq-core/cirq/ops/pauli_string.py
@@ -1120,6 +1120,23 @@ def _validate_qubit_mapping(
         )
 
 
+def _try_interpret_as_pauli_string(op: Any):
+    """Return a reprepresentation of an operation as a pauli string, if it is possible."""
+    if isinstance(op, gate_operation.GateOperation):
+        gates = {
+            common_gates.XPowGate: pauli_gates.X,
+            common_gates.YPowGate: pauli_gates.Y,
+            common_gates.ZPowGate: pauli_gates.Z,
+        }
+        if (pauli := gates.get(type(op.gate), None)) is not None:
+            exponent = op.gate.exponent  # type: ignore
+            if exponent % 2 == 0:
+                return cirq.PauliString()
+            if exponent % 2 == 1:
+                return pauli.on(op.qubits[0])
+    return None
+
+
 # Ignoring type because mypy believes `with_qubits` methods are incompatible.
 class SingleQubitPauliStringGateOperation(  # type: ignore
     gate_operation.GateOperation, PauliString
@@ -1159,11 +1176,15 @@ def __mul__(self, other):
             return self._as_pauli_string() * other._as_pauli_string()
         if isinstance(other, (PauliString, complex, float, int)):
             return self._as_pauli_string() * other
+        if (as_pauli_string := _try_interpret_as_pauli_string(other)) is not None:
+            return self * as_pauli_string
         return NotImplemented
 
     def __rmul__(self, other):
         if isinstance(other, (PauliString, complex, float, int)):
             return other * self._as_pauli_string()
+        if (as_pauli_string := _try_interpret_as_pauli_string(other)) is not None:
+            return as_pauli_string * self
         return NotImplemented
 
     def __neg__(self):


Test patch:
diff --git a/cirq-core/cirq/ops/pauli_string_test.py b/cirq-core/cirq/ops/pauli_string_test.py
--- a/cirq-core/cirq/ops/pauli_string_test.py
+++ b/cirq-core/cirq/ops/pauli_string_test.py
@@ -211,6 +211,29 @@ def test_list_op_constructor_matches_mapping(pauli):
     assert cirq.PauliString([op]) == cirq.PauliString({q0: pauli})
 
 
+@pytest.mark.parametrize('pauli1', (cirq.X, cirq.Y, cirq.Z))
+@pytest.mark.parametrize('pauli2', (cirq.X, cirq.Y, cirq.Z))
+def test_exponent_mul_consistency(pauli1, pauli2):
+    a, b = cirq.LineQubit.range(2)
+    op_a, op_b = pauli1(a), pauli2(b)
+
+    assert op_a * op_a * op_a == op_a
+    assert op_a * op_a**2 == op_a
+    assert op_a**2 * op_a == op_a
+    assert op_b * op_a * op_a == op_b
+    assert op_b * op_a**2 == op_b
+    assert op_a**2 * op_b == op_b
+    assert op_a * op_a * op_a * op_a == cirq.PauliString()
+    assert op_a * op_a**3 == cirq.PauliString()
+    assert op_b * op_a * op_a * op_a == op_b * op_a
+    assert op_b * op_a**3 == op_b * op_a
+
+    op_a, op_b = pauli1(a), pauli2(a)
+
+    assert op_a * op_b**3 == op_a * op_b * op_b * op_b
+    assert op_b**3 * op_a == op_b * op_b * op_b * op_a
+
+
 def test_constructor_flexibility():
     a, b = cirq.LineQubit.range(2)
     with pytest.raises(TypeError, match='cirq.PAULI_STRING_LIKE'):