Repository: PennyLaneAI/pennylane (python)

Issue:
[BUG] `qml.equal` does not work with `qml.HilbertSchmidt`
### Expected behavior

Equal ops are detected to be equal

### Actual behavior

Equal ops are reported to be different

### Additional information

The culprit is `v_tape` in the hyperparameters.

### Source code

```shell
u_tape = qml.tape.QuantumScript([qml.Hadamard(0)])
v_function = lambda params: qml.RZ(params[0], wires=1)
v_params = np.array([0.6])
op1 = qml.HilbertSchmidt(v_params, v_wires=[1], u_tape=u_tape, v_function=v_function)
op2 = qml.HilbertSchmidt(v_params, v_wires=[1], u_tape=u_tape, v_function=v_function)

qml.equal(op1, op2)
```


### Tracebacks

```shell
False
```


### System information

```shell
pl dev
```


### Existing GitHub issues

- [X] I have searched existing GitHub issues to make sure the issue does not already exist.

Pull request description:
[bugfix] Make `qml.equal` work with `qml.HilbertSchmidt`
**Context:**
`qml.HilbertSchmidt` has `QuantumScript` instances as hyperparameters, which are not compared correctly by the default `Operator` dispatch of `qml.equal`.

**Description of the Change:**
This PR adds a custom dispatch branch for `qml.HilbertSchmidt` to `qml.equal`, comparing the `QuantumScript` hyperparameters with additional `qml.equal` calls.

**Benefits:**
bug fix

**Possible Drawbacks:**

**Related GitHub Issues:**
fixes #5536 


[sc-61589]
[sc-49174]

Patch:
diff --git a/doc/releases/changelog-dev.md b/doc/releases/changelog-dev.md
index 6291af1df..8681fe6af 100644
--- a/doc/releases/changelog-dev.md
+++ b/doc/releases/changelog-dev.md
@@ -390,6 +390,9 @@
 
 <h3>Bug fixes 🐛</h3>
 
+* (In)equality of `qml.HilbertSchmidt` instances is now reported correctly by `qml.equal`.
+  [(#5538)](https://github.com/PennyLaneAI/pennylane/pull/5538)
+
 * `qml.ParticleConservingU1` and `qml.ParticleConservingU2` no longer raise an error when the initial state is not specified but default to the all-zeros state.
   [(#5535)](https://github.com/PennyLaneAI/pennylane/pull/5535)
 
diff --git a/pennylane/ops/functions/equal.py b/pennylane/ops/functions/equal.py
index f7ce6911b..f1bd63ad8 100644
--- a/pennylane/ops/functions/equal.py
+++ b/pennylane/ops/functions/equal.py
@@ -540,3 +540,46 @@ def _equal_basis_rotation(
         ):
             return False
     return True
+
+
+@_equal.register
+def _equal_hilbert_schmidt(
+    op1: qml.HilbertSchmidt,
+    op2: qml.HilbertSchmidt,
+    check_interface=True,
+    check_trainability=True,
+    rtol=1e-5,
+    atol=1e-9,
+):
+    if not all(
+        qml.math.allclose(d1, d2, rtol=rtol, atol=atol) for d1, d2 in zip(op1.data, op2.data)
+    ):
+        return False
+
+    if check_trainability:
+        for params_1, params_2 in zip(op1.data, op2.data):
+            if qml.math.requires_grad(params_1) != qml.math.requires_grad(params_2):
+                return False
+
+    if check_interface:
+        for params_1, params_2 in zip(op1.data, op2.data):
+            if qml.math.get_interface(params_1) != qml.math.get_interface(params_2):
+                return False
+
+    equal_kwargs = {
+        "check_interface": check_interface,
+        "check_trainability": check_trainability,
+        "atol": atol,
+        "rtol": rtol,
+    }
+    # Check hyperparameters using qml.equal rather than == where necessary
+    if op1.hyperparameters["v_wires"] != op2.hyperparameters["v_wires"]:
+        return False
+    if not qml.equal(op1.hyperparameters["u_tape"], op2.hyperparameters["u_tape"], **equal_kwargs):
+        return False
+    if not qml.equal(op1.hyperparameters["v_tape"], op2.hyperparameters["v_tape"], **equal_kwargs):
+        return False
+    if op1.hyperparameters["v_function"] != op2.hyperparameters["v_function"]:
+        return False
+
+    return True


Test patch:
diff --git a/tests/ops/functions/test_equal.py b/tests/ops/functions/test_equal.py
index e0b89c530..1812f3db7 100644
--- a/tests/ops/functions/test_equal.py
+++ b/tests/ops/functions/test_equal.py
@@ -2033,6 +2033,8 @@ class TestQuantumScriptComparisons:
 
 
 class TestBasisRotation:
+    """Test that qml.equal works with qml.BasisRotation."""
+
     rotation_mat = np.array(
         [
             [-0.618452, -0.68369054 - 0.38740723j],
@@ -2071,3 +2073,160 @@ class TestBasisRotation:
         other_op = qml.BasisRotation(wires=range(2), unitary_matrix=rotation_mat_jax)
         assert qml.equal(op, other_op, check_interface=False)
         assert qml.equal(op, other_op) is False
+
+
+class TestHilbertSchmidt:
+    """Test that qml.equal works with qml.HilbertSchmidt."""
+
+    # pylint: disable=no-self-argument
+
+    def v_function1(params):
+        """A quantum function."""
+        qml.RZ(params[0], wires=1)
+
+    def v_function2(params):
+        """Differs from v_function1 by operation type and used parameter."""
+        qml.RX(params[1], wires=1)
+
+    def v_function3(params):
+        """Differs from v_function1 by the used wire."""
+        qml.RZ(params[0], wires=2)
+
+    def v_function4(params):
+        """Differs from v_function1 by the functional parameter dependence, but
+        produces the same tape at params[0]=0.2."""
+        qml.RZ(params[0] * 2 - 0.2, wires=1)
+
+    v_wires1 = [1]
+    v_wires2 = [2]
+
+    u_tape1 = qml.tape.QuantumScript([qml.RX(0.2, 0)])
+    u_tape1_eps = qml.tape.QuantumScript([qml.RX(0.2 + 1e-7, 0)])
+    u_tape1_trainable = qml.tape.QuantumScript([qml.RX(npp.array(0.2, requires_grad=True), 0)])
+    u_tape1_untrainable = qml.tape.QuantumScript([qml.RX(npp.array(0.2, requires_grad=False), 0)])
+    u_tape2 = qml.tape.QuantumScript([qml.Hadamard(2)])
+
+    v_params1 = [0.2, 0.3]
+    v_params2 = [0.1, 0.5]
+    v_params1_eps = [0.2 + 1e-7, 0.3]
+    v_params1_trainable = npp.array(v_params1, requires_grad=True)
+    v_params1_untrainable = npp.array(v_params1, requires_grad=False)
+
+    op1 = qml.HilbertSchmidt(v_params1, v_function=v_function1, v_wires=v_wires1, u_tape=u_tape1)
+    op1_trainable = qml.HilbertSchmidt(
+        v_params1, v_function=v_function1, v_wires=v_wires1, u_tape=u_tape1_trainable
+    )
+    op1_untrainable = qml.HilbertSchmidt(
+        v_params1, v_function=v_function1, v_wires=v_wires1, u_tape=u_tape1_untrainable
+    )
+    op1_eps = qml.HilbertSchmidt(
+        v_params1_eps, v_function=v_function1, v_wires=v_wires1, u_tape=u_tape1
+    )
+    op1_eps_tape = qml.HilbertSchmidt(
+        v_params1, v_function=v_function1, v_wires=v_wires1, u_tape=u_tape1_eps
+    )
+    op2 = qml.HilbertSchmidt(v_params2, v_function=v_function1, v_wires=v_wires1, u_tape=u_tape1)
+    op3_tapediff = qml.HilbertSchmidt(
+        v_params1, v_function=v_function2, v_wires=v_wires1, u_tape=u_tape1
+    )
+    op3_fundiff = qml.HilbertSchmidt(
+        v_params1, v_function=v_function4, v_wires=v_wires1, u_tape=u_tape1
+    )
+    op4 = qml.HilbertSchmidt(v_params1, v_function=v_function1, v_wires=v_wires1, u_tape=u_tape2)
+    op5 = qml.HilbertSchmidt(
+        v_params1_trainable, v_function=v_function1, v_wires=v_wires1, u_tape=u_tape1
+    )
+    op6 = qml.HilbertSchmidt(
+        v_params1_untrainable, v_function=v_function1, v_wires=v_wires1, u_tape=u_tape1
+    )
+    op7 = qml.HilbertSchmidt(v_params1, v_function=v_function3, v_wires=v_wires2, u_tape=u_tape1)
+
+    @pytest.mark.parametrize("op, other_op", [(op1, op1), (op2, op2), (op4, op4)])
+    def test_equality(self, op, other_op):
+        """Test that differing u_tapes are found."""
+        assert qml.equal(op, other_op) is True
+
+    # The second test case (incl op1_eps_tape) ensures that the kwargs of equal are
+    # passed to the tape comparisons correctly
+    @pytest.mark.parametrize("op, other_op", [(op1, op1_eps), (op1, op1_eps_tape)])
+    def test_different_tolerances_comparison(self, op, other_op):
+        """Test that the tolerance parameters are used correctly."""
+        assert qml.equal(op, other_op) is True
+        assert qml.equal(op, other_op, rtol=0) is False
+        assert qml.equal(op, other_op, rtol=0, atol=1e-5) is True
+
+    @pytest.mark.parametrize("op, other_op", [(op1, op2)])
+    def test_non_equal_data(self, op, other_op):
+        """Test that differing parameters are found."""
+        assert qml.equal(op, other_op) is False
+        other_op.data = op.data
+
+        op_tape = op.hyperparameters["v_tape"]
+        new_tape = other_op.hyperparameters["v_tape"].bind_new_parameters(
+            op_tape.get_parameters(), [0]
+        )
+
+        new_other_op = deepcopy(other_op)
+        new_other_op.hyperparameters["v_tape"] = new_tape
+        assert qml.equal(op, new_other_op) is True
+
+    @pytest.mark.parametrize("op, other_op", [(op1, op3_fundiff)])
+    def test_non_equal_v_function(self, op, other_op):
+        """Test that differing v_functions are found."""
+        assert qml.equal(op, other_op) is False
+
+        new_other_op = deepcopy(other_op)
+        new_other_op.hyperparameters["v_function"] = op.hyperparameters["v_function"]
+        assert qml.equal(op, new_other_op) is True
+
+    @pytest.mark.parametrize("op, other_op", [(op1, op4)])
+    def test_non_equal_u_tapes(self, op, other_op):
+        """Test that differing u_tapes are found."""
+        assert qml.equal(op, other_op) is False
+
+        new_other_op = deepcopy(other_op)
+        new_other_op.hyperparameters["u_tape"] = op.hyperparameters["u_tape"]
+        assert qml.equal(op, new_other_op) is True
+
+    @pytest.mark.parametrize("op, other_op", [(op1, op3_tapediff)])
+    def test_non_equal_v_tapes(self, op, other_op):
+        """Test that differing v_tapes are found."""
+        assert qml.equal(op, other_op) is False
+        # The v_function must have been different as well if it produced
+        # a different tape and the parameters and v_wires were fixed.
+        new_other_op = deepcopy(other_op)
+        new_other_op.hyperparameters["v_function"] = op.hyperparameters["v_function"]
+        new_other_op.hyperparameters["v_tape"] = op.hyperparameters["v_tape"]
+        assert qml.equal(op, new_other_op) is True
+
+    @pytest.mark.parametrize("op, other_op", [(op1, op7)])
+    def test_non_equal_v_wires(self, op, other_op):
+        """Test that differing v_wires are found."""
+        assert qml.equal(op, other_op) is False
+        # If the v_wires were different, so must have been the v_function and the
+        # resulting v_tape
+        new_other_op = deepcopy(other_op)
+        new_other_op.hyperparameters["v_function"] = op.hyperparameters["v_function"]
+        new_other_op.hyperparameters["v_tape"] = op.hyperparameters["v_tape"]
+        new_other_op.hyperparameters["v_wires"] = op.hyperparameters["v_wires"]
+        assert qml.equal(op, new_other_op) is True
+
+    @pytest.mark.parametrize("op, other_op", [(op5, op6), (op1_trainable, op1_untrainable)])
+    def test_trainability(self, op, other_op):
+        """Test that differing trainabilities are found."""
+        assert qml.equal(op, other_op) is False
+        assert qml.equal(op, other_op, check_trainability=False) is True
+
+    @pytest.mark.parametrize("op, other_op", [(op1, op6), (op1, op1_untrainable)])
+    def test_interface(self, op, other_op):
+        """Test that differing interfaces are found."""
+        assert qml.equal(op, other_op) is False
+        assert qml.equal(op, other_op, check_interface=False) is True
+
+    @pytest.mark.parametrize("op, other_op", [(op1, op5), (op1, op1_trainable)])
+    def test_interface_and_trainability(self, op, other_op):
+        """Test that simultaneously differing interfaces and trainabilities are found."""
+        assert qml.equal(op, other_op) is False
+        assert qml.equal(op, other_op, check_interface=False) is False
+        assert qml.equal(op, other_op, check_trainability=False) is False
+        assert qml.equal(op, other_op, check_interface=False, check_trainability=False) is True
diff --git a/tests/templates/test_subroutines/test_hilbert_schmidt.py b/tests/templates/test_subroutines/test_hilbert_schmidt.py
index dc9ef4f7f..4f1e35d6e 100644
--- a/tests/templates/test_subroutines/test_hilbert_schmidt.py
+++ b/tests/templates/test_subroutines/test_hilbert_schmidt.py
@@ -24,7 +24,6 @@ def global_v_circuit(params):
 
 
 # pylint: disable=protected-access
-@pytest.mark.xfail  # to be fixed by shortcut story 49174
 @pytest.mark.parametrize("op_type", (qml.HilbertSchmidt, qml.LocalHilbertSchmidt))
 def test_flatten_unflatten_standard_checks(op_type):
     """Test the flatten and unflatten methods."""
diff --git a/tests/transforms/test_hamiltonian_expand.py b/tests/transforms/test_hamiltonian_expand.py
index e48549a9c..b435e1e30 100644
--- a/tests/transforms/test_hamiltonian_expand.py
+++ b/tests/transforms/test_hamiltonian_expand.py
@@ -345,6 +345,7 @@ class TestHamiltonianExpand:
         """Tests that the processing function works with shot vectors, parameter broadcasting,
         and grouping with different number of coefficients in each group"""
 
+        np.random.seed(824)
         dev_with_shot_vector = qml.device("default.qubit", shots=[(8000, 4)])
 
         if grouping: