Repository: PennyLaneAI/pennylane
Pull request #5031 (2024-01-09 20:52:40)

composite ops auto-upgrade tensors and hamiltonians
**Context:**
We can get some spooky-looking Franken-Operators today, and it often happens by accident. For example:
```pycon
>>> qml.prod(qml.PauliX(0), 3 * qml.PauliZ(1))
PauliX(wires=[0]) @   (3) [Z1]
```
The second operand became a Hamiltonian instead of an SProd! This makes sense if you think about the order of python execution and how we implemented things, but users shouldn't have to think about that. Moreover, calling `simplify()` on that op won't work so well because things are all mixed up.

**Description of the Change:**
Update `CompositeOp.__init__` to replace any `Tensor` or `Hamiltonian` operands with `Prod`, `Sum`, or `SProd` operands as required.

**Benefits:**
User expectations are met, Operator simplification works

**Possible Drawbacks:**
I suspect this will bring far more benefit than drawback, but it's possible that a user is expecting some weird hybrid operator and is disappointed by this standardization

[sc-53356]


Patch:
diff --git a/doc/releases/changelog-dev.md b/doc/releases/changelog-dev.md
index 689729d55..902d494cb 100644
--- a/doc/releases/changelog-dev.md
+++ b/doc/releases/changelog-dev.md
@@ -8,9 +8,14 @@
 
 * Update `tests/ops/functions/conftest.py` to ensure all operator types are tested for validity.
   [(#4978)](https://github.com/PennyLaneAI/pennylane/pull/4978)
-  
+
 * A new `pennylane.workflow` module is added. This module now contains `qnode.py`, `execution.py`, `set_shots.py`, `jacobian_products.py`, and the submodule `interfaces`.
 
+* Composite operations (eg. those made with `qml.prod` and `qml.sum`) convert `Hamiltonian` and
+  `Tensor` operands to `Sum` and `Prod` types, respectively. This helps avoid the mixing of
+  incompatible operator types.
+  [(#5031)](https://github.com/PennyLaneAI/pennylane/pull/5031)
+
 <h4>Community contributions 🥳</h4>
 
 * The transform `split_non_commuting` now accepts measurements of type `probs`, `sample` and `counts` which accept both wires and observables. 
diff --git a/pennylane/operation.py b/pennylane/operation.py
index 462d227cc..430604338 100644
--- a/pennylane/operation.py
+++ b/pennylane/operation.py
@@ -191,6 +191,7 @@ return newer arithmetic operators, the ``operation`` module provides the followi
     ~enable_new_opmath
     ~disable_new_opmath
     ~active_new_opmath
+    ~convert_to_opmath
 
 Other
 ~~~~~
@@ -2227,6 +2228,9 @@ class Tensor(Observable):
         elif isinstance(other, Observable):
             self.obs.append(other)
 
+        elif isinstance(other, Operator):
+            return qml.prod(*self.obs, other)
+
         else:
             return NotImplemented
 
@@ -3011,6 +3015,27 @@ def active_new_opmath():
     return __use_new_opmath
 
 
+def convert_to_opmath(op):
+    """
+    Converts :class:`~pennylane.Hamiltonian` and :class:`.Tensor` instances
+    into arithmetic operators. Objects of any other type are returned directly.
+
+    Arithmetic operators include :class:`~pennylane.ops.op_math.Prod`,
+    :class:`~pennylane.ops.op_math.Sum` and :class:`~pennylane.ops.op_math.SProd`.
+
+    Args:
+        op (Operator): The operator instance to convert
+
+    Returns:
+        Operator: An operator using the new arithmetic operations, if relevant
+    """
+    if isinstance(op, qml.Hamiltonian):
+        return qml.dot(*op.terms())
+    if isinstance(op, Tensor):
+        return qml.prod(*op.obs)
+    return op
+
+
 def __getattr__(name):
     """To facilitate StatePrep rename"""
     if name == "StatePrep":
diff --git a/pennylane/ops/op_math/composite.py b/pennylane/ops/op_math/composite.py
index f38bc930a..bd4939270 100644
--- a/pennylane/ops/op_math/composite.py
+++ b/pennylane/ops/op_math/composite.py
@@ -23,7 +23,7 @@ import numpy as np
 
 import pennylane as qml
 from pennylane import math
-from pennylane.operation import Operator, _UNSET_BATCH_SIZE
+from pennylane.operation import Operator, convert_to_opmath, _UNSET_BATCH_SIZE
 from pennylane.wires import Wires
 
 # pylint: disable=too-many-instance-attributes
@@ -60,7 +60,7 @@ class CompositeOp(Operator):
         if len(operands) < 2:
             raise ValueError(f"Require at least two operators to combine; got {len(operands)}")
 
-        self.operands = operands
+        self.operands = tuple(convert_to_opmath(op) for op in operands)
         self._wires = qml.wires.Wires.all_wires([op.wires for op in operands])
         self._hash = None
         self._has_overlapping_wires = None


Test patch:
diff --git a/tests/ops/op_math/test_composite.py b/tests/ops/op_math/test_composite.py
index 0b07a82db..23cb5778f 100644
--- a/tests/ops/op_math/test_composite.py
+++ b/tests/ops/op_math/test_composite.py
@@ -22,7 +22,7 @@ import pytest
 
 import pennylane as qml
 from pennylane.operation import DecompositionUndefinedError
-from pennylane.ops.op_math import CompositeOp
+from pennylane.ops.op_math import CompositeOp, Prod, Sum, SProd
 from pennylane.wires import Wires
 
 ops = (
@@ -208,6 +208,27 @@ class TestConstruction:
         op = ValidOp(*self.simple_operands)
         assert op._build_pauli_rep() == qml.pauli.PauliSentence({})
 
+    def test_tensor_and_hamiltonian_converted(self):
+        """Test that Tensor and Hamiltonian instances get converted to Prod and Sum."""
+        operands = [
+            qml.Hamiltonian(
+                [1.1, 2.2], [qml.PauliZ(0), qml.operation.Tensor(qml.PauliX(0), qml.PauliZ(1))]
+            ),
+            qml.prod(qml.PauliX(0), qml.PauliZ(1)),
+            qml.operation.Tensor(qml.PauliX(2), qml.PauliZ(3)),
+        ]
+        op = ValidOp(*operands)
+        assert isinstance(op[0], Sum)
+        assert isinstance(op[0][1], SProd)
+        assert isinstance(op[0][1].base, Prod)
+        assert op[1] is operands[1]
+        assert isinstance(op[2], Prod)
+        assert op.operands == (
+            qml.dot([1.1, 2.2], [qml.PauliZ(0), qml.prod(qml.PauliX(0), qml.PauliZ(1))]),
+            operands[1],
+            qml.prod(qml.PauliX(2), qml.PauliZ(3)),
+        )
+
 
 class TestMscMethods:
     """Test dunder and other visualizing methods."""
diff --git a/tests/test_operation.py b/tests/test_operation.py
index bf4c7561e..246f84ac1 100644
--- a/tests/test_operation.py
+++ b/tests/test_operation.py
@@ -1607,19 +1607,22 @@ class TestTensor:
 
     def test_operation_multiply_invalid(self):
         """Test that an exception is raised if an observable
-        is multiplied by an operation"""
+        is matrix-multiplied by a scalar"""
         X = qml.PauliX(0)
-        Y = qml.CNOT(wires=[0, 1])
         Z = qml.PauliZ(1)
 
-        with pytest.raises(TypeError, match="unsupported operand type"):
-            T = X @ Z
-            _ = T @ Y
-
         with pytest.raises(TypeError, match="unsupported operand type"):
             T = X @ Z
             _ = 4 @ T
 
+    def test_tensor_matmul_op_is_prod(self):
+        """Test that Tensor @ non-observable returns a Prod."""
+        tensor = qml.PauliX(0) @ qml.PauliY(1)
+        assert isinstance(tensor, Tensor)
+        prod = tensor @ qml.S(0)
+        assert isinstance(prod, qml.ops.Prod)
+        assert prod.operands == (qml.PauliX(0), qml.PauliY(1), qml.S(0))
+
     def test_eigvals(self):
         """Test that the correct eigenvalues are returned for the Tensor"""
         X = qml.PauliX(0)