Repository: Qiskit/qiskit

Issue:
Overloading `@` for `Operator` class
### What should we add?

## Proposition

Overloading the `@` operator for class `qiskit.quantum_info.operators.Operator` ([documentation](https://qiskit.org/documentation/stubs/qiskit.quantum_info.Operator.html?highlight=operator#qiskit.quantum_info.Operator)) so it works the same as `Operator.compose()` method. The same behavior is done with class `qiskit.opflow.operator_base.OperatorBase` ([documentation](https://qiskit.org/documentation/stubs/qiskit.opflow.OperatorBase.html#qiskit.opflow.OperatorBase)).

## Motivation

When going through the Open Science Prize 2021 I encountered [problem](https://github.com/qiskit-community/open-science-prize-2021/issues/5). After investigation it turned out that `Operator` class doesn't have `@` overloading, so in order to fix the issue `compose()` or `np.matmul()` methods need to be used. As this is good solution it would be easier to use `@`, especially for reading the code.


Discussion:
You can already use the `&` operator for `compose`, but `@` was specifically deprecated Terra 0.17 (#5834) and removed in Terra 0.19 (#7291) so we could change the order of the operands; `a.__matmul__(b)` was original defined to be `a.compose(b)`, but that's the same as `np.array(b) @ np.array(a)` (i.e. backwards).  We needed to deprecate and remove it, so that people who were already using it were forced to adjust, and we didn't silently change the behaviour of the code underneath them.  We did plan to add `@` back in again with the meaning `a @ b == a.dot(b)`.

I'm somewhat cautious about re-adding the operator with a new meaning after only one release without it (0.19), but with suitable release documentation, it could be ok.  @ikkoham: do you have thoughts?
Yes, that's right. I'm going to add it in 0.20. (maybe in next QAMP)

Resolving patch:
diff --git a/qiskit/quantum_info/operators/mixins/group.py b/qiskit/quantum_info/operators/mixins/group.py
--- a/qiskit/quantum_info/operators/mixins/group.py
+++ b/qiskit/quantum_info/operators/mixins/group.py
@@ -34,6 +34,7 @@ class GroupMixin(ABC):
     And the following operator overloads:
 
         - ``&``, ``__and__`` -> :meth:`compose`
+        - ``@``, ``__matmul__`` -> :meth:`dot`
         - ``^``, ``__xor__`` -> `:meth:`tensor`
         - ``**``, ``__pow__`` -> :meth:`power`
 
@@ -54,6 +55,9 @@ def __pow__(self, n):
     def __xor__(self, other):
         return self.tensor(other)
 
+    def __matmul__(self, other):
+        return self.dot(other)
+
     @abstractmethod
     def tensor(self, other):
         r"""Return the tensor product with another CLASS.
@@ -111,9 +115,9 @@ def compose(self, other, qargs=None, front=False):
 
         .. note::
             Composition (``&``) by default is defined as `left` matrix multiplication for
-            matrix operators, while :meth:`dot` is defined as `right` matrix
+            matrix operators, while ``@`` (equivalent to :meth:`dot`) is defined as `right` matrix
             multiplication. That is that ``A & B == A.compose(B)`` is equivalent to
-            ``B.dot(A)`` when ``A`` and ``B`` are of the same type.
+            ``B @ A == B.dot(A)`` when ``A`` and ``B`` are of the same type.
 
             Setting the ``front=True`` kwarg changes this to `right` matrix
             multiplication and is equivalent to the :meth:`dot` method
@@ -131,6 +135,10 @@ def dot(self, other, qargs=None):
 
         Returns:
             CLASS: The right matrix multiplied CLASS.
+
+        .. note::
+            The dot product can be obtained using the ``@`` binary operator.
+            Hence ``a.dot(b)`` is equivalent to ``a @ b``.
         """
         return self.compose(other, qargs=qargs, front=True)
 


Test patch:
diff --git a/test/python/quantum_info/operators/channel/test_chi.py b/test/python/quantum_info/operators/channel/test_chi.py
--- a/test/python/quantum_info/operators/channel/test_chi.py
+++ b/test/python/quantum_info/operators/channel/test_chi.py
@@ -141,7 +141,11 @@ def test_dot(self):
         chan1 = Chi(chi1, input_dims=2, output_dims=2)
         chan2 = Chi(chi2, input_dims=2, output_dims=2)
         target = rho.evolve(chan1).evolve(chan2)
-        output = rho.evolve(chan2.dot(chan1))
+        chan = chan2.dot(chan1)
+        output = rho.evolve(chan)
+        self.assertEqual(output, target)
+        chan = chan2 @ chan1
+        output = rho.evolve(chan)
         self.assertEqual(output, target)
 
     def test_compose_front(self):
diff --git a/test/python/quantum_info/operators/channel/test_choi.py b/test/python/quantum_info/operators/channel/test_choi.py
--- a/test/python/quantum_info/operators/channel/test_choi.py
+++ b/test/python/quantum_info/operators/channel/test_choi.py
@@ -186,11 +186,13 @@ def test_dot(self):
         chan2 = Choi(self.choiY)
         targ = Choi(self.choiZ)
         self.assertEqual(chan1.dot(chan2), targ)
+        self.assertEqual(chan1 @ chan2, targ)
 
         # 50% depolarizing channel
         chan1 = Choi(self.depol_choi(0.5))
         targ = Choi(self.depol_choi(0.75))
         self.assertEqual(chan1.dot(chan1), targ)
+        self.assertEqual(chan1 @ chan1, targ)
 
         # Measure and rotation
         Zp, Zm = np.diag([1, 0]), np.diag([0, 1])
@@ -200,17 +202,23 @@ def test_dot(self):
         # X-gate second does nothing
         targ = Choi(np.kron(Zp, Xp) + np.kron(Zm, Xm))
         self.assertEqual(chan2.dot(chan1), targ)
+        self.assertEqual(chan2 @ chan1, targ)
         # X-gate first swaps Z states
         targ = Choi(np.kron(Zm, Xp) + np.kron(Zp, Xm))
         self.assertEqual(chan1.dot(chan2), targ)
+        self.assertEqual(chan1 @ chan2, targ)
 
         # Compose different dimensions
         chan1 = Choi(np.eye(8) / 4, input_dims=2, output_dims=4)
         chan2 = Choi(np.eye(8) / 2, input_dims=4, output_dims=2)
         chan = chan1.dot(chan2)
         self.assertEqual(chan.dim, (4, 4))
+        chan = chan1 @ chan2
+        self.assertEqual(chan.dim, (4, 4))
         chan = chan2.dot(chan1)
         self.assertEqual(chan.dim, (2, 2))
+        chan = chan2 @ chan1
+        self.assertEqual(chan.dim, (2, 2))
 
     def test_compose_front(self):
         """Test front compose method."""
diff --git a/test/python/quantum_info/operators/channel/test_kraus.py b/test/python/quantum_info/operators/channel/test_kraus.py
--- a/test/python/quantum_info/operators/channel/test_kraus.py
+++ b/test/python/quantum_info/operators/channel/test_kraus.py
@@ -205,11 +205,13 @@ def test_dot(self):
         chan2 = Kraus(self.UY)
         targ = rho.evolve(Kraus(self.UZ))
         self.assertEqual(rho.evolve(chan1.dot(chan2)), targ)
+        self.assertEqual(rho.evolve(chan1 @ chan2), targ)
 
         # 50% depolarizing channel
         chan1 = Kraus(self.depol_kraus(0.5))
         targ = rho & Kraus(self.depol_kraus(0.75))
         self.assertEqual(rho.evolve(chan1.dot(chan1)), targ)
+        self.assertEqual(rho.evolve(chan1 @ chan1), targ)
 
         # Compose different dimensions
         kraus1, kraus2 = self.rand_kraus(2, 4, 4), self.rand_kraus(4, 2, 4)
@@ -217,6 +219,7 @@ def test_dot(self):
         chan2 = Kraus(kraus2)
         targ = rho & chan1 & chan2
         self.assertEqual(rho.evolve(chan2.dot(chan1)), targ)
+        self.assertEqual(rho.evolve(chan2 @ chan1), targ)
 
     def test_compose_front(self):
         """Test deprecated front compose method."""
diff --git a/test/python/quantum_info/operators/channel/test_ptm.py b/test/python/quantum_info/operators/channel/test_ptm.py
--- a/test/python/quantum_info/operators/channel/test_ptm.py
+++ b/test/python/quantum_info/operators/channel/test_ptm.py
@@ -137,6 +137,7 @@ def test_dot(self):
         chan2 = PTM(self.ptmY)
         rho_targ = rho.evolve(PTM(self.ptmZ))
         self.assertEqual(rho.evolve(chan2.dot(chan1)), rho_targ)
+        self.assertEqual(rho.evolve(chan2 @ chan1), rho_targ)
 
         # Compose random
         ptm1 = self.rand_matrix(4, 4, real=True)
@@ -145,6 +146,7 @@ def test_dot(self):
         chan2 = PTM(ptm2, input_dims=2, output_dims=2)
         rho_targ = rho.evolve(chan1).evolve(chan2)
         self.assertEqual(rho.evolve(chan2.dot(chan1)), rho_targ)
+        self.assertEqual(rho.evolve(chan2 @ chan1), rho_targ)
 
     def test_compose_front(self):
         """Test deprecated front compose method."""
diff --git a/test/python/quantum_info/operators/channel/test_stinespring.py b/test/python/quantum_info/operators/channel/test_stinespring.py
--- a/test/python/quantum_info/operators/channel/test_stinespring.py
+++ b/test/python/quantum_info/operators/channel/test_stinespring.py
@@ -197,11 +197,13 @@ def test_dot(self):
         chan2 = Stinespring(self.UY)
         rho_targ = rho_init.evolve(Stinespring(self.UZ))
         self.assertEqual(rho_init.evolve(chan1.dot(chan2)), rho_targ)
+        self.assertEqual(rho_init.evolve(chan1 @ chan2), rho_targ)
 
         # 50% depolarizing channel
         chan1 = Stinespring(self.depol_stine(0.5))
         rho_targ = rho_init & Stinespring(self.depol_stine(0.75))
         self.assertEqual(rho_init.evolve(chan1.dot(chan1)), rho_targ)
+        self.assertEqual(rho_init.evolve(chan1 @ chan1), rho_targ)
 
         # Compose different dimensions
         stine1, stine2 = self.rand_matrix(16, 2), self.rand_matrix(8, 4)
@@ -209,6 +211,7 @@ def test_dot(self):
         chan2 = Stinespring(stine2, input_dims=4, output_dims=2)
         rho_targ = rho_init & chan1 & chan2
         self.assertEqual(rho_init.evolve(chan2.dot(chan1)), rho_targ)
+        self.assertEqual(rho_init.evolve(chan2 @ chan1), rho_targ)
 
     def test_compose_front(self):
         """Test deprecated front compose method."""
diff --git a/test/python/quantum_info/operators/channel/test_superop.py b/test/python/quantum_info/operators/channel/test_superop.py
--- a/test/python/quantum_info/operators/channel/test_superop.py
+++ b/test/python/quantum_info/operators/channel/test_superop.py
@@ -265,11 +265,13 @@ def test_dot(self):
         chan2 = SuperOp(self.sopY)
         targ = SuperOp(self.sopZ)
         self.assertEqual(chan1.dot(chan2), targ)
+        self.assertEqual(chan1 @ chan2, targ)
 
         # 50% depolarizing channel
         chan1 = SuperOp(self.depol_sop(0.5))
         targ = SuperOp(self.depol_sop(0.75))
         self.assertEqual(chan1.dot(chan1), targ)
+        self.assertEqual(chan1 @ chan1, targ)
 
         # Random superoperator
         mat1 = self.rand_matrix(4, 4)
@@ -285,8 +287,12 @@ def test_dot(self):
         chan2 = SuperOp(self.rand_matrix(4, 16))
         chan = chan1.dot(chan2)
         self.assertEqual(chan.dim, (4, 4))
+        chan = chan1 @ chan2
+        self.assertEqual(chan.dim, (4, 4))
         chan = chan2.dot(chan1)
         self.assertEqual(chan.dim, (2, 2))
+        chan = chan2 @ chan1
+        self.assertEqual(chan.dim, (2, 2))
 
     def test_compose_front(self):
         """Test front compose method."""
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
@@ -258,6 +258,8 @@ def test_dot(self, label1, label2):
         op2 = operator_from_label(label2)
         target = op1.dot(op2)
         self.assertEqual(value, target)
+        target = op1 @ op2
+        self.assertEqual(value, target)
 
     @data(*pauli_group_labels(1))
     def test_dot_qargs(self, label2):
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
--- a/test/python/quantum_info/operators/symplectic/test_sparse_pauli_op.py
+++ b/test/python/quantum_info/operators/symplectic/test_sparse_pauli_op.py
@@ -340,6 +340,11 @@ def test_dot(self, num_qubits):
         self.assertEqual(value, target)
         np.testing.assert_array_equal(op.paulis.phase, np.zeros(op.size))
 
+        op = spp_op1 @ spp_op2
+        value = op.to_operator()
+        self.assertEqual(value, target)
+        np.testing.assert_array_equal(op.paulis.phase, np.zeros(op.size))
+
     @combine(num_qubits=[1, 2, 3])
     def test_qargs_compose(self, num_qubits):
         """Test 3-qubit compose method with {num_qubits}-qubit qargs."""
diff --git a/test/python/quantum_info/operators/test_operator.py b/test/python/quantum_info/operators/test_operator.py
--- a/test/python/quantum_info/operators/test_operator.py
+++ b/test/python/quantum_info/operators/test_operator.py
@@ -336,9 +336,11 @@ def test_dot(self):
 
         targ = Operator(np.dot(self.UY, self.UX))
         self.assertEqual(op1.dot(op2), targ)
+        self.assertEqual(op1 @ op2, targ)
 
         targ = Operator(np.dot(self.UX, self.UY))
         self.assertEqual(op2.dot(op1), targ)
+        self.assertEqual(op2 @ op1, targ)
 
     def test_compose_front(self):
         """Test front compose method."""
diff --git a/test/python/quantum_info/operators/test_scalar_op.py b/test/python/quantum_info/operators/test_scalar_op.py
--- a/test/python/quantum_info/operators/test_scalar_op.py
+++ b/test/python/quantum_info/operators/test_scalar_op.py
@@ -363,6 +363,10 @@ def test_dot_scalar(self, coeff1, coeff2):
         target = coeff1 * coeff2
         self.assertScalarOp(val, dims, target)
 
+        val = op1 @ op2
+        target = coeff1 * coeff2
+        self.assertScalarOp(val, dims, target)
+
     @combine(coeff1=[0, 1, -3.1, 1 + 3j], coeff2=[0, -1, -5.1 - 2j])
     def test_matmul_scalar(self, coeff1, coeff2):
         """Test matmul method with two ScalarOp. ({coeff1}, {coeff2})"""