Repository: PennyLaneAI/pennylane
Pull request #3516 (2022-12-12 19:49:53)

Extend qml.equal to CompositeOp subclasses
**Context:**
The qml.equal function cannot compare subclasses of `CompositeOp` (`Prod`, `Sum`).

**Description of the Change:**
Add `singledispatch` `_equal` function for `CompositeOp`, based on ordering the operations using the relevant subclass' `_sort` method and comparing the sorted list of operands.


codecov[bot]: # [Codecov](https://codecov.io/gh/PennyLaneAI/pennylane/pull/3516?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennyLaneAI) Report
> Merging [#3516](https://codecov.io/gh/PennyLaneAI/pennylane/pull/3516?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennyLaneAI) (486d14e) into [master](https://codecov.io/gh/PennyLaneAI/pennylane/commit/7b2a4ab811bd4cd61973590129e4491d20591adf?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennyLaneAI) (7b2a4ab) will **increase** coverage by `0.00%`.
> The diff coverage is `100.00%`.

```diff
@@           Coverage Diff           @@
##           master    #3516   +/-   ##
=======================================
  Coverage   99.87%   99.87%           
=======================================
  Files         311      311           
  Lines       27900    27915   +15     
=======================================
+ Hits        27864    27879   +15     
  Misses         36       36           
```


| [Impacted Files](https://codecov.io/gh/PennyLaneAI/pennylane/pull/3516?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennyLaneAI) | Coverage Δ | |
|---|---|---|
| [pennylane/ops/functions/equal.py](https://codecov.io/gh/PennyLaneAI/pennylane/pull/3516/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennyLaneAI#diff-cGVubnlsYW5lL29wcy9mdW5jdGlvbnMvZXF1YWwucHk=) | `99.17% <100.00%> (+0.11%)` | :arrow_up: |

Help us with your feedback. Take ten seconds to tell us [how you rate us](https://about.codecov.io/nps?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennyLaneAI). Have a feature suggestion? [Share it here.](https://app.codecov.io/gh/feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennyLaneAI)


Patch:
diff --git a/doc/releases/changelog-dev.md b/doc/releases/changelog-dev.md
index 3c7840c75..a4d639109 100644
--- a/doc/releases/changelog-dev.md
+++ b/doc/releases/changelog-dev.md
@@ -42,7 +42,11 @@
 
 <h3>Improvements</h3>
 
-* The `qml.generator` function now checks if the generator is hermitian, rather than whether it is a subclass of
+* Extended the `qml.equal` function to compare `Prod` and `Sum` operators.
+  [(#3516)](https://github.com/PennyLaneAI/pennylane/pull/3516)
+
+
+* The `qml.generator` function now checks if the generator is hermitian, rather than whether it is a subclass of 
   `Observable`, allowing it to return valid generators from `SymbolicOp` and `CompositeOp` classes.
  [(#3485)](https://github.com/PennyLaneAI/pennylane/pull/3485)
 
@@ -74,3 +78,4 @@ Lillian M. A. Frederiksen
 Albert Mitjans Coma
 Matthew Silverman
 Antal Száva
+
diff --git a/pennylane/ops/functions/equal.py b/pennylane/ops/functions/equal.py
index 392043b1e..ef2563748 100644
--- a/pennylane/ops/functions/equal.py
+++ b/pennylane/ops/functions/equal.py
@@ -17,14 +17,13 @@ This module contains the qml.equal function.
 # pylint: disable=too-many-arguments,too-many-return-statements
 from functools import singledispatch
 from typing import Union
-
 import pennylane as qml
 from pennylane.measurements import MeasurementProcess
 from pennylane.measurements.classical_shadow import ShadowExpvalMP
 from pennylane.measurements.mutual_info import MutualInfoMP
 from pennylane.measurements.vn_entropy import VnEntropyMP
 from pennylane.operation import Observable, Operator, Tensor
-from pennylane.ops import Hamiltonian, Controlled, Pow, Adjoint, Exp, SProd
+from pennylane.ops import Hamiltonian, Controlled, Pow, Adjoint, Exp, SProd, CompositeOp
 
 
 def equal(
@@ -54,7 +53,8 @@ def equal(
         comparing ``Operation`` objects. Comparisons of ``MeasurementProcess``
         or ``Observable`` objects will use the default value of ``True`` for both, regardless
         of what the user specifies when calling the function. For subclasses of ``SymbolicOp``
-        with an ``Operation`` as a base, the kwargs will be applied to the base comparison.
+        or ``CompositeOp`` with an ``Operation`` as a base, the kwargs will be applied to the base
+        comparison.
 
     Args:
         op1 (.Operator or .MeasurementProcess): First object to compare
@@ -104,9 +104,10 @@ def equal(
         :title: Usage Details
 
         You can use the optional arguments to get more specific results. Additionally, they are
-        applied when comparing the base of ``SymbolicOp`` operators such as ``Controlled``, ``Pow``,
-        ``SProd``, etc., if the base is an ``Operation``. These arguments are, however, not used
-        for comparing ``MeasurementProcess``, ``Hamiltonian`` or ``Tensor`` objects.
+        applied when comparing the base of ``SymbolicOp`` and ``CompositeOp`` operators such as
+        ``Controlled``, ``Pow``, ``SProd``, ``Prod``, etc., if the base is an ``Operation``. These arguments
+        are, however, not used for comparing ``MeasurementProcess``, ``Hamiltonian`` or ``Tensor``
+        objects.
 
         Consider the following comparisons:
 
@@ -208,6 +209,21 @@ def _equal_operators(
     return getattr(op1, "inverse", False) == getattr(op2, "inverse", False)
 
 
+@_equal.register
+# pylint: disable=unused-argument, protected-access
+def _equal_prod_and_sum(op1: CompositeOp, op2: CompositeOp, **kwargs):
+    """Determine whether two Prod or Sum objects are equal"""
+
+    if len(op1.operands) != len(op2.operands):
+        return False
+
+    # organizes by wire indicies while respecting commutation relations
+    sorted_ops1 = op1._sort(op1.operands)
+    sorted_ops2 = op2._sort(op2.operands)
+
+    return all(equal(o1, o2, **kwargs) for o1, o2 in zip(sorted_ops1, sorted_ops2))
+
+
 @_equal.register
 def _equal_controlled(op1: Controlled, op2: Controlled, **kwargs):
     """Determine whether two Controlled or ControlledOp objects are equal"""


Test patch:
diff --git a/tests/ops/functions/test_equal.py b/tests/ops/functions/test_equal.py
index 8509d9dc0..d42d2e72b 100644
--- a/tests/ops/functions/test_equal.py
+++ b/tests/ops/functions/test_equal.py
@@ -23,7 +23,7 @@ import pytest
 import pennylane as qml
 from pennylane import numpy as npp
 from pennylane.measurements import ExpectationMP
-from pennylane.ops.op_math.controlled_class import Controlled
+from pennylane.ops.op_math import SymbolicOp, Controlled
 
 PARAMETRIZED_OPERATIONS_1P_1W = [
     qml.RX,
@@ -1103,19 +1103,22 @@ class TestEqual:
         op2 = qml.prod(op1, qml.RY(0.25, wires=1))
         assert not qml.equal(op1, op2)
 
-    def test_equal_with_nested_operators_raises_error(self):
+    def test_equal_with_unsupported_nested_operators_raises_error(self):
         """Test that the equal method with two operators with the same arithmetic depth (>0) raises
-        an error."""
-        op1 = qml.PauliY(0)
-        op2 = qml.PauliZ(1)
-        prod = qml.prod(op1, op2)
+        an error unless there is a singledispatch function specifically comparing that operator type."""
+
+        op1 = SymbolicOp(qml.PauliY(0))
+        op2 = SymbolicOp(qml.PauliY(0))
+
+        assert op1.arithmetic_depth == op2.arithmetic_depth
+        assert op1.arithmetic_depth > 0
 
         with pytest.raises(
             NotImplementedError,
             match="Comparison of operators with an arithmetic"
             + " depth larger than 0 is not yet implemented.",
         ):
-            qml.equal(prod, prod)
+            qml.equal(op1, op2)
 
     def test_equal_same_inversion(self):
         """Test operations are equal if they are both inverted."""
@@ -1233,7 +1236,7 @@ class TestSymbolicOpComparison:
 
     def test_comparison_of_base_not_implemented_error(self):
         """Test that comparing SymbolicOps of base operators whose comparison is not yet implemented raises an error"""
-        base = qml.prod(qml.RX(1.2, 0), qml.RY(1.3, 1))
+        base = SymbolicOp(qml.RX(1.2, 0))
         op1 = Controlled(base, control_wires=2)
         op2 = Controlled(base, control_wires=2)
 
@@ -1333,3 +1336,169 @@ class TestSymbolicOpComparison:
         op1 = qml.s_prod(param1, base1)
         op2 = qml.s_prod(param2, base2)
         assert qml.equal(op1, op2) == (bases_match and params_match)
+
+
+class TestProdComparisons:
+    """Tests comparisons between Prod operators"""
+
+    SINGLE_WIRE_BASES = [
+        ([qml.PauliX(0), qml.PauliY(1)], [qml.PauliX(0), qml.PauliY(1)], True),
+        ([qml.PauliX(0), qml.PauliY(1)], [qml.PauliY(1), qml.PauliX(0)], True),
+        (
+            [qml.RX(1.23, 0), qml.adjoint(qml.RY(1.23, 1))],
+            [qml.RX(1.23, 0), qml.adjoint(qml.RY(1.23, 1))],
+            True,
+        ),
+        ([qml.PauliX(1), qml.PauliY(0)], [qml.PauliY(1), qml.PauliX(0)], False),
+        (
+            [qml.PauliX(0), qml.PauliY(1), qml.PauliX(0), qml.PauliY(1)],
+            [qml.PauliX(0), qml.PauliX(0), qml.PauliY(1), qml.PauliY(1)],
+            True,
+        ),
+        (
+            [qml.PauliX(0), qml.PauliY(1), qml.PauliZ(0), qml.PauliY(1)],
+            [qml.PauliZ(0), qml.PauliX(0), qml.PauliY(1), qml.PauliY(1)],
+            False,
+        ),
+        ([qml.PauliZ(0), qml.PauliZ(1)], [qml.PauliZ(0), qml.PauliZ(1), qml.PauliY(2)], False),
+        ([qml.RX(1.23, 0), qml.RX(1.23, 1)], [qml.RX(2.34, 0), qml.RX(1.23, 1)], False),
+    ]
+
+    MULTI_WIRE_BASES = [
+        ([qml.CRX(1.23, [0, 1]), qml.PauliX(0)], [qml.CRX(1.23, [0, 1]), qml.PauliX(0)], True),
+        ([qml.CRX(1.23, [0, 1]), qml.PauliX(0)], [qml.PauliX(0), qml.CRX(1.23, [0, 1])], False),
+        ([qml.CRX(1.23, [0, 1]), qml.PauliX(2)], [qml.PauliX(2), qml.CRX(1.23, [0, 1])], True),
+        (
+            [qml.CRX(1.23, [1, 0]), qml.CRY(2.34, [1, 0])],
+            [qml.CRX(1.23, [1, 0]), qml.CRY(2.34, [1, 0])],
+            True,
+        ),
+        (
+            [qml.CRX(1.23, [1, 0]), qml.CRY(2.34, [1, 0])],
+            [qml.CRX(1.23, [1, 0]), qml.CRY(2.34, [0, 1])],
+            False,
+        ),
+        (
+            [qml.CRX(1.34, [1, 0]), qml.CRY(2.34, [1, 0])],
+            [qml.CRX(1.23, [1, 0]), qml.CRY(2.34, [1, 0])],
+            False,
+        ),
+    ]
+
+    def test_non_commuting_order_swap_not_equal(self):
+        """Test that changing the order of non-commuting operators is not equal"""
+        op1 = qml.prod(qml.PauliX(0), qml.PauliY(0))
+        op2 = qml.prod(qml.PauliY(0), qml.PauliX(0))
+        assert not qml.equal(op1, op2)
+
+    def test_commuting_order_swap_equal(self):
+        """Test that changing the order of commuting operators is equal"""
+        op1 = qml.prod(qml.PauliX(0), qml.PauliY(1))
+        op2 = qml.prod(qml.PauliY(1), qml.PauliX(0))
+        assert qml.equal(op1, op2)
+
+    @pytest.mark.all_interfaces
+    def test_prod_kwargs_used_for_base_operator_comparison(self):
+        """Test that setting kwargs check_interface and check_trainability are applied when comparing the bases"""
+        import torch
+        import jax
+
+        base_list1 = [qml.RX(torch.tensor(1.2), wires=0), qml.RX(torch.tensor(2.3), wires=1)]
+        base_list2 = [qml.RX(jax.numpy.array(1.2), wires=0), qml.RX(jax.numpy.array(2.3), wires=1)]
+
+        op1 = qml.prod(*base_list1)
+        op2 = qml.prod(*base_list2)
+
+        assert not qml.equal(op1, op2)
+        assert qml.equal(op1, op2, check_interface=False, check_trainability=False)
+
+    @pytest.mark.parametrize(("base_list1", "base_list2", "res"), SINGLE_WIRE_BASES)
+    def test_prod_comparisons_single_wire_bases(self, base_list1, base_list2, res):
+        """Test comparison of products of operators where all operators have a single wire"""
+        op1 = qml.prod(*base_list1)
+        op2 = qml.prod(*base_list2)
+        assert qml.equal(op1, op2) == res
+
+    @pytest.mark.parametrize(("base_list1", "base_list2", "res"), MULTI_WIRE_BASES)
+    def test_prod_with_multi_wire_bases(self, base_list1, base_list2, res):
+        """Test comparison of products of operators where some operators work on multiple wires"""
+        op1 = qml.prod(*base_list1)
+        op2 = qml.prod(*base_list2)
+        assert qml.equal(op1, op2) == res
+
+
+class TestSumComparisons:
+    """Tests comparisons between Sum operators"""
+
+    SINGLE_WIRE_BASES = [
+        ([qml.PauliX(0), qml.PauliY(1)], [qml.PauliX(0), qml.PauliY(1)], True),
+        ([qml.PauliX(0), qml.PauliY(1)], [qml.PauliY(1), qml.PauliX(0)], True),
+        (
+            [qml.RX(1.23, 0), qml.adjoint(qml.RY(1.23, 1))],
+            [qml.RX(1.23, 0), qml.adjoint(qml.RY(1.23, 1))],
+            True,
+        ),
+        ([qml.PauliX(1), qml.PauliY(0)], [qml.PauliY(1), qml.PauliX(0)], False),
+        (
+            [qml.PauliX(0), qml.PauliY(1), qml.PauliX(0), qml.PauliY(1)],
+            [qml.PauliX(0), qml.PauliX(0), qml.PauliY(1), qml.PauliY(1)],
+            True,
+        ),
+        ([qml.PauliZ(0), qml.PauliZ(1)], [qml.PauliZ(0), qml.PauliZ(1), qml.PauliY(2)], False),
+        ([qml.RX(1.23, 0), qml.RX(1.23, 1)], [qml.RX(2.34, 0), qml.RX(1.23, 1)], False),
+    ]
+
+    MULTI_WIRE_BASES = [
+        ([qml.CRX(1.23, [0, 1]), qml.PauliX(0)], [qml.CRX(1.23, [0, 1]), qml.PauliX(0)], True),
+        ([qml.CRX(1.23, [0, 1]), qml.PauliX(0)], [qml.PauliX(0), qml.CRX(1.23, [0, 1])], True),
+        (
+            [qml.CRX(1.23, [1, 0]), qml.CRY(2.34, [1, 0])],
+            [qml.CRX(1.23, [1, 0]), qml.CRY(2.34, [1, 0])],
+            True,
+        ),
+        (
+            [qml.CRX(1.23, [1, 0]), qml.CRY(2.34, [1, 0])],
+            [qml.CRX(1.23, [1, 0]), qml.CRY(2.34, [0, 1])],
+            False,
+        ),
+        (
+            [qml.CRX(1.34, [1, 0]), qml.CRY(2.34, [1, 0])],
+            [qml.CRX(1.23, [1, 0]), qml.CRY(2.34, [1, 0])],
+            False,
+        ),
+    ]
+
+    def test_sum_different_order_still_equal(self):
+        """Test that changing the order of the terms doesn't affect comparison of sums"""
+        op1 = qml.op_sum(qml.PauliX(0), qml.PauliY(1))
+        op2 = qml.op_sum(qml.PauliY(1), qml.PauliX(0))
+        assert qml.equal(op1, op2)
+
+    @pytest.mark.all_interfaces
+    def test_sum_kwargs_used_for_base_operator_comparison(self):
+        """Test that setting kwargs check_interface and check_trainability are applied when comparing the bases"""
+        import torch
+        import jax
+
+        base_list1 = [qml.RX(torch.tensor(1.2), wires=0), qml.RX(torch.tensor(2.3), wires=1)]
+        base_list2 = [qml.RX(jax.numpy.array(1.2), wires=0), qml.RX(jax.numpy.array(2.3), wires=1)]
+
+        op1 = qml.op_sum(*base_list1)
+        op2 = qml.op_sum(*base_list2)
+
+        assert not qml.equal(op1, op2)
+        assert qml.equal(op1, op2, check_interface=False, check_trainability=False)
+
+    @pytest.mark.parametrize(("base_list1", "base_list2", "res"), SINGLE_WIRE_BASES)
+    def test_sum_comparisons_single_wire_bases(self, base_list1, base_list2, res):
+        """Test comparison of sums of operators where all operators have a single wire"""
+        op1 = qml.op_sum(*base_list1)
+        op2 = qml.op_sum(*base_list2)
+        assert qml.equal(op1, op2) == res
+
+    @pytest.mark.parametrize(("base_list1", "base_list2", "res"), MULTI_WIRE_BASES)
+    def test_sum_with_multi_wire_operations(self, base_list1, base_list2, res):
+        """Test comparison of sums of operators where some operators act on multiple wires"""
+        op1 = qml.op_sum(*base_list1)
+        op2 = qml.op_sum(*base_list2)
+        assert qml.equal(op1, op2) == res