Repository: quantumlib/Cirq

Issue:
Unable to print multi-qubit circuit with asymmetric depolarizing noise
**Description of the issue**
Attempting to print a multi-qubit circuit with asymmetric depolarizing noise results in an error being thrown.

**How to reproduce the issue**
```
q0, q1 = cirq.LineQubit.range(2)
op = cirq.AsymmetricDepolarizingChannel(error_probabilities={'XX': 0.1})(q0, q1)
cirq.Circuit(op)
```

<details>

ValueError: Wanted diagram info from cirq.asymmetric_depolarize(error_probabilities={'XX': 0.1, 'II': 0.9}).on(cirq.LineQubit(0), cirq.LineQubit(1)) for [cirq.LineQubit(0), cirq.LineQubit(1)]) but got cirq.CircuitDiagramInfo(wire_symbols=('A(XX:0.1, II:0.9)',), exponent=1, connected=True, exponent_qubit_index=None, auto_exponent_parens=True)

</details>

**Cirq version**
1.0.0




Discussion:
@tanujkhattar  I think the output of the above snippet should be

```
0: ───A(X:0.1, I:0.9)───M('m')───
      │
1: ───A(X:0.1, I:0.9)────────────
```
What do you think?
I thought I had left a comment on this issue when I added the labels, but looks I never finished the draft. Anyways, here are my thoughts: 

This is a bug and should be fixed. The reason is that the diagram info method returns a single string, instead of returning an `Iterable[str]` with length equal to number of qubits.

https://github.com/quantumlib/Cirq/blob/425bf8d442498741884cb4a7ad885e134e39fdbb/cirq-core/cirq/ops/common_channels.py#L142

https://github.com/quantumlib/Cirq/pull/3262 made the original change to generalize the channel from 1 qubit to multiple qubits. Even though the PR correctly identified that the diagram info would need to be updated, the update that was done was incorrect. This is because for a gate / operation that acts on `N` qubits, we need to return `N` strings that should be drawn on wires corresponding to each qubit (or a `CircuitDiagramInfo(wire_symbols=tuple(symbols_to_draw))`).

One easy fix would be to change the return statement to return `self._num_qubits` copies of the same formatted string.

So, change

https://github.com/quantumlib/Cirq/blob/425bf8d442498741884cb4a7ad885e134e39fdbb/cirq-core/cirq/ops/common_channels.py#L157

to

```python
 return [f"A({', '.join(error_probabilities)})"] * self._num_qubits
```

In this case, the diagram for the above snippet would look like:

```
0: ───A(XX:0.1, II:0.9)───
      │
1: ───A(XX:0.1, II:0.9)───
```


Another alternative would be to return a sequence of strings so that the string corresponding to the gate name along the error probabilities  appears on the 0'th qubit and then from qubits 1...N - 1, we just print an integer specifying the ordering of qubits used to apply the gate to construct an operation. i.e. 

```python
 return [f"A({', '.join(error_probabilities)})"] + [f'({i})' for i in range(1, self._num_qubits)]
```
In this case, the diagram for the above snippet would look like:
```
0: ───A(XX:0.1, II:0.9)───
      │
1: ───(1)─────────────────
```

This would make sure that the diagram has enough information for users to figure out which error channels are applied on which qubits in case the error is asymmetric (eg: `XYZ` error on qubits `q[0], q[1], q[2]`)

We could also do a hybrid where each term is the entire `A()` formatted string + an integer specifying the index of the qubit.

cc @viathor for opinions on different approaches to plot the diagrams. 
cc @tonybruguier since you made the original change to generalize the gate to multiple qubits. 


And while we are at it, I should also point out that `cirq.approx_eq(op1, op2)` also raises if `op1` and `op2` are both multi-qubit asymmetric depolarizing channels. This is because `_approx_eq_` is also implemented assuming that the gate acts only on a single qubit and the implementation should be updated:

https://github.com/quantumlib/Cirq/blob/425bf8d442498741884cb4a7ad885e134e39fdbb/cirq-core/cirq/ops/common_channels.py#L195-L203
From cirq sync:

Let's use do the second approach for diagrams and keep it consistent with other multi-qubit gates like QFT:

https://github.com/quantumlib/Cirq/blob/ec68a55d54cf1372e5394d602575b185bc54396d/cirq-core/cirq/ops/fourier_transform.py#L192

Resolving patch:
diff --git a/cirq-core/cirq/ops/common_channels.py b/cirq-core/cirq/ops/common_channels.py
--- a/cirq-core/cirq/ops/common_channels.py
+++ b/cirq-core/cirq/ops/common_channels.py
@@ -139,7 +139,9 @@ def __repr__(self) -> str:
     def __str__(self) -> str:
         return 'asymmetric_depolarize(' + f"error_probabilities={self._error_probabilities})"
 
-    def _circuit_diagram_info_(self, args: 'protocols.CircuitDiagramInfoArgs') -> str:
+    def _circuit_diagram_info_(
+        self, args: 'protocols.CircuitDiagramInfoArgs'
+    ) -> Union[str, Iterable[str]]:
         if self._num_qubits == 1:
             if args.precision is not None:
                 return (
@@ -154,7 +156,9 @@ def _circuit_diagram_info_(self, args: 'protocols.CircuitDiagramInfoArgs') -> st
             ]
         else:
             error_probabilities = [f"{pauli}:{p}" for pauli, p in self._error_probabilities.items()]
-        return f"A({', '.join(error_probabilities)})"
+        return [f"A({', '.join(error_probabilities)})"] + [
+            f'({i})' for i in range(1, self._num_qubits)
+        ]
 
     @property
     def p_i(self) -> float:
@@ -193,13 +197,9 @@ def _json_dict_(self) -> Dict[str, Any]:
         return protocols.obj_to_dict_helper(self, ['error_probabilities'])
 
     def _approx_eq_(self, other: Any, atol: float) -> bool:
-        return (
-            self._num_qubits == other._num_qubits
-            and np.isclose(self.p_i, other.p_i, atol=atol).item()
-            and np.isclose(self.p_x, other.p_x, atol=atol).item()
-            and np.isclose(self.p_y, other.p_y, atol=atol).item()
-            and np.isclose(self.p_z, other.p_z, atol=atol).item()
-        )
+        self_keys, self_values = zip(*sorted(self.error_probabilities.items()))
+        other_keys, other_values = zip(*sorted(other.error_probabilities.items()))
+        return self_keys == other_keys and protocols.approx_eq(self_values, other_values, atol=atol)
 
 
 def asymmetric_depolarize(


Test patch:
diff --git a/cirq-core/cirq/ops/common_channels_test.py b/cirq-core/cirq/ops/common_channels_test.py
--- a/cirq-core/cirq/ops/common_channels_test.py
+++ b/cirq-core/cirq/ops/common_channels_test.py
@@ -805,6 +805,38 @@ def test_multi_asymmetric_depolarizing_channel_repr():
     )
 
 
+def test_multi_asymmetric_depolarizing_eq():
+    a = cirq.asymmetric_depolarize(error_probabilities={'I': 0.8, 'X': 0.2})
+    b = cirq.asymmetric_depolarize(error_probabilities={'II': 0.8, 'XX': 0.2})
+
+    assert not cirq.approx_eq(a, b)
+
+    a = cirq.asymmetric_depolarize(error_probabilities={'II': 0.8, 'XX': 0.2})
+    b = cirq.asymmetric_depolarize(error_probabilities={'II': 2 / 3, 'XX': 1 / 3})
+
+    assert not cirq.approx_eq(a, b)
+
+    a = cirq.asymmetric_depolarize(error_probabilities={'II': 2 / 3, 'ZZ': 1 / 3})
+    b = cirq.asymmetric_depolarize(error_probabilities={'II': 2 / 3, 'XX': 1 / 3})
+
+    assert not cirq.approx_eq(a, b)
+
+    a = cirq.asymmetric_depolarize(0.1, 0.2)
+    b = cirq.asymmetric_depolarize(error_probabilities={'II': 2 / 3, 'XX': 1 / 3})
+
+    assert not cirq.approx_eq(a, b)
+
+    a = cirq.asymmetric_depolarize(error_probabilities={'II': 0.667, 'XX': 0.333})
+    b = cirq.asymmetric_depolarize(error_probabilities={'II': 2 / 3, 'XX': 1 / 3})
+
+    assert cirq.approx_eq(a, b, atol=1e-3)
+
+    a = cirq.asymmetric_depolarize(error_probabilities={'II': 0.667, 'XX': 0.333})
+    b = cirq.asymmetric_depolarize(error_probabilities={'XX': 1 / 3, 'II': 2 / 3})
+
+    assert cirq.approx_eq(a, b, atol=1e-3)
+
+
 def test_multi_asymmetric_depolarizing_channel_str():
     assert str(cirq.asymmetric_depolarize(error_probabilities={'II': 0.8, 'XX': 0.2})) == (
         "asymmetric_depolarize(error_probabilities={'II': 0.8, 'XX': 0.2})"
@@ -814,16 +846,16 @@ def test_multi_asymmetric_depolarizing_channel_str():
 def test_multi_asymmetric_depolarizing_channel_text_diagram():
     a = cirq.asymmetric_depolarize(error_probabilities={'II': 2 / 3, 'XX': 1 / 3})
     assert cirq.circuit_diagram_info(a, args=no_precision) == cirq.CircuitDiagramInfo(
-        wire_symbols=('A(II:0.6666666666666666, XX:0.3333333333333333)',)
+        wire_symbols=('A(II:0.6666666666666666, XX:0.3333333333333333)', '(1)')
     )
     assert cirq.circuit_diagram_info(a, args=round_to_6_prec) == cirq.CircuitDiagramInfo(
-        wire_symbols=('A(II:0.666667, XX:0.333333)',)
+        wire_symbols=('A(II:0.666667, XX:0.333333)', '(1)')
     )
     assert cirq.circuit_diagram_info(a, args=round_to_2_prec) == cirq.CircuitDiagramInfo(
-        wire_symbols=('A(II:0.67, XX:0.33)',)
+        wire_symbols=('A(II:0.67, XX:0.33)', '(1)')
     )
     assert cirq.circuit_diagram_info(a, args=no_precision) == cirq.CircuitDiagramInfo(
-        wire_symbols=('A(II:0.6666666666666666, XX:0.3333333333333333)',)
+        wire_symbols=('A(II:0.6666666666666666, XX:0.3333333333333333)', '(1)')
     )