Repository: scipy/scipy

Issue:
BUG: using LinearOperator as RHS operand of @ causes a NumPy error
### Describe your issue.

Using a `LinearOperator` on the right hand side of a matmul (through the `@` operator) leads to a NumPy error (see attached).

It does work as a left hand side operand. Moreover, `A @ B`, where `A` is a NumPy array and `B` is a `LinearOperator`, can be implemented using this expression as a workaround:
```python
(B.T @ A.T).T
```
A derived `LinearOperator` is thus forced to the left hand side.

It is not clear whether `A @ B` should be expected to work, so I'm creating this issue to start a discussion. See https://github.com/scikit-learn/scikit-learn/issues/12794#issuecomment-1287827481 for more context.

Fundamentally `A @ B` is equivalent to `A.__matmul__(B)`, so the implementation of `A` controls execution. To my knowledge as part of the general `ufunc` execution flow NumPy will try to coerce the `ufunc` arguments to `PyObjectArray`'s which are expected to have explicit data arrays. However as an abstraction of a 2D array, `LinearOperator`'s do not necessarily have any data. At the moment they end up being converted to zero-dimensional scalars, which leads to the error in question.

One could always materialize the data by letting the `LinearOperator` act on an identity matrix, but that is typically undesirable in matrix-free methods where this class is used.

As @ogrisel [notes](https://github.com/scikit-learn/scikit-learn/pull/18689#issuecomment-1287897926) though, it would be good to at least have a more user-friendly message.

### Reproducing Code Example

```python
import numpy as np
from scipy.sparse.linalg import LinearOperator

def linear_operator_from_array(A):
     return LinearOperator(
        shape=A.shape,
        dtype=A.dtype,
        matvec=lambda x: A @ x,
        rmatvec=lambda x: A.T @ x,
        matmat=lambda X: A @ X,
        rmatmat=lambda X: A.T @ X,
    )

A = np.empty((2, 3))
B = np.empty((3, 4))
B = linear_operator_from_array(B)
A @ B
```


### Error message

```shell
Traceback (most recent call last):
  File "/home/andrey/Projects/scikit-learn/linear-operator-matmul-fail.py", line 17, in <module>
    A @ B
ValueError: matmul: Input operand 1 does not have enough dimensions (has 0, gufunc core with signature (n?,k),(k,m?)->(n?,m?) requires 1)
```


### SciPy/NumPy/Python version information

1.9.1 1.23.4 sys.version_info(major=3, minor=10, micro=7, releaselevel='final', serial=0)


Discussion:
`np.array([]) @ None` leads to the same error. Maybe NumPy could be more conservative during `ufunc` argument conversion and reject certain inputs early.

Resolving patch:
diff --git a/scipy/sparse/linalg/_interface.py b/scipy/sparse/linalg/_interface.py
--- a/scipy/sparse/linalg/_interface.py
+++ b/scipy/sparse/linalg/_interface.py
@@ -138,6 +138,8 @@ class LinearOperator:
     """
 
     ndim = 2
+    # Necessary for right matmul with numpy arrays.
+    __array_ufunc__ = None
 
     def __new__(cls, *args, **kwargs):
         if cls is LinearOperator:
@@ -438,7 +440,42 @@ def __rmul__(self, x):
         if np.isscalar(x):
             return _ScaledLinearOperator(self, x)
         else:
-            return NotImplemented
+            return self._rdot(x)
+
+    def _rdot(self, x):
+        """Matrix-matrix or matrix-vector multiplication from the right.
+
+        Parameters
+        ----------
+        x : array_like
+            1-d or 2-d array, representing a vector or matrix.
+
+        Returns
+        -------
+        xA : array
+            1-d or 2-d array (depending on the shape of x) that represents
+            the result of applying this linear operator on x from the right.
+
+        Notes
+        -----
+        This is copied from dot to implement right multiplication.
+        """
+        if isinstance(x, LinearOperator):
+            return _ProductLinearOperator(x, self)
+        elif np.isscalar(x):
+            return _ScaledLinearOperator(self, x)
+        else:
+            x = np.asarray(x)
+
+            # We use transpose instead of rmatvec/rmatmat to avoid
+            # unnecessary complex conjugation if possible.
+            if x.ndim == 1 or x.ndim == 2 and x.shape[0] == 1:
+                return self.T.matvec(x.T).T
+            elif x.ndim == 2:
+                return self.T.matmat(x.T).T
+            else:
+                raise ValueError('expected 1-d or 2-d array or matrix, got %r'
+                                 % x)
 
     def __pow__(self, p):
         if np.isscalar(p):


Test patch:
diff --git a/scipy/sparse/linalg/tests/test_interface.py b/scipy/sparse/linalg/tests/test_interface.py
--- a/scipy/sparse/linalg/tests/test_interface.py
+++ b/scipy/sparse/linalg/tests/test_interface.py
@@ -174,13 +174,17 @@ def test_matmul(self):
              'rmatmat': lambda x: np.dot(self.A.T.conj(), x),
              'matmat': lambda x: np.dot(self.A, x)}
         A = interface.LinearOperator(**D)
-        B = np.array([[1, 2, 3],
+        B = np.array([[1 + 1j, 2, 3],
                       [4, 5, 6],
                       [7, 8, 9]])
         b = B[0]
 
         assert_equal(operator.matmul(A, b), A * b)
+        assert_equal(operator.matmul(A, b.reshape(-1, 1)), A * b.reshape(-1, 1))
         assert_equal(operator.matmul(A, B), A * B)
+        assert_equal(operator.matmul(b, A.H), b * A.H)
+        assert_equal(operator.matmul(b.reshape(1, -1), A.H), b.reshape(1, -1) * A.H)
+        assert_equal(operator.matmul(B, A.H), B * A.H)
         assert_raises(ValueError, operator.matmul, A, 2)
         assert_raises(ValueError, operator.matmul, 2, A)