Repository: PennyLaneAI/pennylane
Pull request #7186 (2025-04-01 21:09:03)

Make string inputs consistent in qchem 
### Before submitting

Please complete the following checklist when submitting a PR:

- [ ] All new features must include a unit test.
      If you've fixed a bug or added code that should be tested, add a test to the
      test directory!

- [ ] All new functions and code must be clearly commented and documented.
      If you do make documentation changes, make sure that the docs build and
      render correctly by running `make docs`.

- [ ] Ensure that the test suite passes, by running `make test`.

- [ ] Add a new entry to the `doc/releases/changelog-dev.md` file, summarizing the
      change, and including a link back to the PR.

- [ ] The PennyLane source code conforms to
      [PEP8 standards](https://www.python.org/dev/peps/pep-0008/).
      We check all of our code against [Pylint](https://www.pylint.org/).
      To lint modified files, simply `pip install pylint`, and then
      run `pylint pennylane/path/to/file.py`.

When all the above are checked, delete everything above the dashed
line and fill in the pull request template.

------------------------------------------------------------------------------------------------------------

**Context:**
The qchem functions that accept a string input have been updated to consistently work with both lower-case and upper-case inputs.

**Description of the Change:**

**Benefits:**

**Possible Drawbacks:**

**Related GitHub Issues:**


github-actions[bot]: Hello. You may have forgotten to update the changelog!
Please edit [`doc/releases/changelog-dev.md`](/PennyLaneAI/pennylane/blob/master/doc/releases/changelog-dev.md) with:
* A one-to-two sentence description of the change. You may include a small working example for new features.
* A link back to this PR.
* Your name (or GitHub username) in the contributors section.
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/PennyLaneAI/pennylane/pull/7186?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennyLaneAI) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 99.62%. Comparing base [(`470c585`)](https://app.codecov.io/gh/PennyLaneAI/pennylane/commit/470c585c859b65e2834215aa4ad32e2014f8b2c5?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennyLaneAI) to head [(`d0111a2`)](https://app.codecov.io/gh/PennyLaneAI/pennylane/commit/d0111a28509af490d0b04237e171b2d63fc6425c?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennyLaneAI).
> Report is 2 commits behind head on master.

<details><summary>Additional details and impacted files</summary>


```diff
@@           Coverage Diff           @@
##           master    #7186   +/-   ##
=======================================
  Coverage   99.62%   99.62%           
=======================================
  Files         509      509           
  Lines       49619    49625    +6     
=======================================
+ Hits        49434    49440    +6     
  Misses        185      185           
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/PennyLaneAI/pennylane/pull/7186?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennyLaneAI).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennyLaneAI).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
</details>

Patch:
diff --git a/doc/releases/changelog-dev.md b/doc/releases/changelog-dev.md
index a06b77bf9..30f0c984f 100644
--- a/doc/releases/changelog-dev.md
+++ b/doc/releases/changelog-dev.md
@@ -117,6 +117,7 @@ With `qml.decompositions.enable_graph()`, the following new features are availab
     rules that the new system may choose if they're the most resource efficient.
   [(#6966)](https://github.com/PennyLaneAI/pennylane/pull/6966)
   [(#7149)](https://github.com/PennyLaneAI/pennylane/pull/7149)
+  [(#7184)](https://github.com/PennyLaneAI/pennylane/pull/7184)
 
   Each keyword argument must be assigned a dictionary that maps operator types to decomposition rules.
   Here is an example of both keyword arguments in use:
@@ -669,6 +670,10 @@ With `qml.decompositions.enable_graph()`, the following new features are availab
 
 <h4>Other improvements</h4>
 
+* The qchem functions that accept a string input have been updated to consistently work with both
+  lower-case and upper-case inputs.
+  [(#7186)](https://github.com/PennyLaneAI/pennylane/pull/7186)
+
 * `PSWAP.matrix()` and `PSWAP.eigvals()` now support parameter broadcasting.
   [(#7179)](https://github.com/PennyLaneAI/pennylane/pull/7179)
 
@@ -1098,6 +1103,7 @@ Pietropaolo Frisoni,
 Marcus Gisslén,
 Diego Guala,
 Austin Huang,
+Soran Jahangiri,
 Korbinian Kottmann,
 Christina Lee,
 Joseph Lee,
diff --git a/pennylane/decomposition/collect_resource_ops.py b/pennylane/decomposition/collect_resource_ops.py
new file mode 100644
index 000000000..d3a26dffb
--- /dev/null
+++ b/pennylane/decomposition/collect_resource_ops.py
@@ -0,0 +1,93 @@
+# Copyright 2025 Xanadu Quantum Technologies Inc.
+
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+
+#     http://www.apache.org/licenses/LICENSE-2.0
+
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Defines an interpreter that extracts a set of resource reps from a plxpr"""
+
+from copy import copy
+
+from pennylane.capture.base_interpreter import FlattenedInterpreter
+from pennylane.capture.primitives import adjoint_transform_prim, cond_prim, ctrl_transform_prim
+
+from .resources import adjoint_resource_rep, controlled_resource_rep, resource_rep
+
+
+class CollectResourceOps(FlattenedInterpreter):
+    """Collects a set of unique resource ops from a plxpr."""
+
+    def __init__(self):
+        super().__init__()
+        self.state = {"ops": set()}
+
+    def interpret_operation(self, op):
+        self.state["ops"].add(resource_rep(type(op), **op.resource_params))
+        return op
+
+
+@CollectResourceOps.register_primitive(adjoint_transform_prim)
+def _(self, *invals, jaxpr, lazy, n_consts):  # pylint: disable=unused-argument
+    """Collect all operations in the base plxpr and create adjoint resource ops with them."""
+    consts = invals[:n_consts]
+    args = invals[n_consts:]
+    child = CollectResourceOps()
+    child.eval(jaxpr, consts, *args)
+    for op in child.state["ops"]:
+        self.state["ops"].add(adjoint_resource_rep(op.op_type, op.params))
+    return []
+
+
+@CollectResourceOps.register_primitive(ctrl_transform_prim)
+def _(self, *invals, n_control, jaxpr, n_consts, **params):
+    """Collect all operations in the target plxpr and create controlled resource ops with them."""
+
+    consts = invals[:n_consts]
+    args = invals[n_consts:-n_control]
+    control = invals[-n_control:]
+    child = CollectResourceOps()
+    child.eval(jaxpr, consts, *args)
+
+    # Extract the resource parameters of this control transform
+    control_values = params.get("control_values")
+    work_wires = params.get("work_wires")
+    num_control_wires = len(control)
+    num_zero_control_values = sum(1 for v in control_values if not v) if control_values else 0
+    num_work_wires = len(work_wires) if work_wires else 0
+
+    # Create resource reps
+    for op in child.state["ops"]:
+        self.state["ops"].add(
+            controlled_resource_rep(
+                op.op_type, op.params, num_control_wires, num_zero_control_values, num_work_wires
+            )
+        )
+
+    return []
+
+
+@CollectResourceOps.register_primitive(cond_prim)
+def explore_all_branches(self, *invals, jaxpr_branches, consts_slices, args_slice):
+    """Handle the cond primitive by a flattened python strategy."""
+    n_branches = len(jaxpr_branches)
+    conditions = invals[:n_branches]
+    args = invals[args_slice]
+    outvals = ()
+    for _, jaxpr, consts_slice in zip(conditions, jaxpr_branches, consts_slices):
+        consts = invals[consts_slice]
+        if jaxpr is not None:
+            dummy = copy(self).eval(jaxpr, consts, *args)
+            # The cond_prim may or may not expect outvals, so we need to check whether
+            # the first branch returns something significant. If so, we use the return
+            # value of the first branch as the outvals of this cond_prim.
+            if dummy and not outvals:
+                outvals = dummy
+    return outvals
diff --git a/pennylane/decomposition/decomposition_graph.py b/pennylane/decomposition/decomposition_graph.py
index 14100f94f..4eb17fa31 100644
--- a/pennylane/decomposition/decomposition_graph.py
+++ b/pennylane/decomposition/decomposition_graph.py
@@ -94,7 +94,7 @@ class DecompositionGraph:  # pylint: disable=too-many-instance-attributes
     allows us to use Dijkstra's algorithm to find the most efficient decomposition.
 
     Args:
-        operations (list[Operator]): The list of operations to find decompositions for.
+        operations (list[Operator or CompressedResourceOp]): The list of operations to decompose.
         target_gate_set (set[str]): The names of the gates in the target gate set.
         fixed_decomps (dict): A dictionary mapping operator names to fixed decompositions.
         alt_decomps (dict): A dictionary mapping operator names to alternative decompositions.
@@ -121,7 +121,7 @@ class DecompositionGraph:  # pylint: disable=too-many-instance-attributes
 
     def __init__(
         self,
-        operations,
+        operations: list[Operator | CompressedResourceOp],
         target_gate_set: set[str],
         fixed_decomps: dict = None,
         alt_decomps: dict = None,
@@ -148,8 +148,9 @@ class DecompositionGraph:  # pylint: disable=too-many-instance-attributes
     def _construct_graph(self):
         """Constructs the decomposition graph."""
         for op in self._original_ops:
-            op_node = resource_rep(type(op), **op.resource_params)
-            idx = self._recursively_add_op_node(op_node)
+            if isinstance(op, Operator):
+                op = resource_rep(type(op), **op.resource_params)
+            idx = self._recursively_add_op_node(op)
             self._original_ops_indices.add(idx)
 
     def _recursively_add_op_node(self, op_node: CompressedResourceOp) -> int:
diff --git a/pennylane/decomposition/resources.py b/pennylane/decomposition/resources.py
index 6bbbe89bf..9188a87d9 100644
--- a/pennylane/decomposition/resources.py
+++ b/pennylane/decomposition/resources.py
@@ -21,6 +21,8 @@ from dataclasses import dataclass, field
 from functools import cached_property
 from typing import Optional, Type
 
+import numpy as np
+
 import pennylane as qml
 from pennylane.operation import Operator
 
@@ -133,8 +135,12 @@ class CompressedResourceOp:
         return f"{self.op_type.__name__}({params})" if self.params else self.op_type.__name__
 
 
-def _make_hashable(d) -> tuple:
-    return tuple((k, _make_hashable(v)) for k, v in d.items()) if isinstance(d, dict) else d
+def _make_hashable(d):
+    if isinstance(d, dict):
+        return tuple(sorted(((k, _make_hashable(v)) for k, v in d.items()), key=lambda x: x[0]))
+    if hasattr(d, "tolist"):
+        return d.tolist()
+    return d
 
 
 def _validate_resource_rep(op_type, params):
@@ -341,6 +347,11 @@ def adjoint_resource_rep(base_class: Type[Operator], base_params: dict = None):
     )
 
 
+def _is_integer(x):
+    """Checks if x is an integer."""
+    return isinstance(x, int) or np.issubdtype(getattr(x, "dtype", None), np.integer)
+
+
 def pow_resource_rep(base_class, base_params, z):
     """Creates a ``CompressedResourceOp`` representation of the power of an operator.
 
@@ -350,7 +361,7 @@ def pow_resource_rep(base_class, base_params, z):
         z (int or float): the power
 
     """
-    if not isinstance(z, int) or z < 0:
+    if (not qml.math.is_abstract(z)) and (not _is_integer(z) or z < 0):
         raise NotImplementedError("Non-integer powers or negative powers are not supported yet.")
     base_resource_rep = resource_rep(base_class, **base_params)
     return CompressedResourceOp(
diff --git a/pennylane/qchem/convert.py b/pennylane/qchem/convert.py
index 9273f151c..bd13de35e 100644
--- a/pennylane/qchem/convert.py
+++ b/pennylane/qchem/convert.py
@@ -330,6 +330,7 @@ def import_operator(qubit_observable, format="openfermion", wires=None, tol=1e01
     >>> print(h_pl)
     (-0.0548 * X(0 @ X(1) @ Y(2) @ Y(3))) + (0.14297 * Z(0 @ Z(1)))
     """
+    format = format.strip().lower()
     if format not in ["openfermion"]:
         raise TypeError(f"Converter does not exist for {format} format.")
 
diff --git a/pennylane/qchem/dipole.py b/pennylane/qchem/dipole.py
index f8a10a186..f036fe0db 100644
--- a/pennylane/qchem/dipole.py
+++ b/pennylane/qchem/dipole.py
@@ -429,9 +429,11 @@ def molecular_dipole(
     )
     """
 
+    method = method.strip().lower()
     if method not in ["dhf", "openfermion"]:
         raise ValueError("Only 'dhf', and 'openfermion' backends are supported.")
 
+    mapping = mapping.strip().lower()
     if mapping.strip().lower() not in ["jordan_wigner", "parity", "bravyi_kitaev"]:
         raise ValueError(
             f"'{mapping}' is not supported."
diff --git a/pennylane/qchem/hamiltonian.py b/pennylane/qchem/hamiltonian.py
index d43140c9b..20c99e432 100644
--- a/pennylane/qchem/hamiltonian.py
+++ b/pennylane/qchem/hamiltonian.py
@@ -503,10 +503,12 @@ def _molecular_hamiltonian(
 ):  # pylint:disable=too-many-arguments, too-many-statements
     r"""Generate the qubit Hamiltonian of a molecule."""
 
+    method = method.strip().lower()
     if method not in ["dhf", "pyscf", "openfermion"]:
         raise ValueError("Only 'dhf', 'pyscf' and 'openfermion' backends are supported.")
 
-    if mapping.strip().lower() not in ["jordan_wigner", "parity", "bravyi_kitaev"]:
+    mapping = mapping.strip().lower()
+    if mapping not in ["jordan_wigner", "parity", "bravyi_kitaev"]:
         raise ValueError(
             f"'{mapping}' is not supported."
             f"Please set the mapping to 'jordan_wigner', 'parity' or 'bravyi_kitaev'."
@@ -580,7 +582,7 @@ def _molecular_hamiltonian(
         )
 
         hf = qml.qchem.fermionic_observable(core_constant, one_mo, two_mo)
-        mapping = mapping.strip().lower()
+
         qubits = len(hf.wires)
 
         if mapping == "jordan_wigner":
diff --git a/pennylane/qchem/observable_hf.py b/pennylane/qchem/observable_hf.py
index 5002168ff..3848e5ccd 100644
--- a/pennylane/qchem/observable_hf.py
+++ b/pennylane/qchem/observable_hf.py
@@ -113,6 +113,7 @@ def qubit_observable(o_ferm, cutoff=1.0e-12, mapping="jordan_wigner"):
     >>> print(qubit_observable(s))
     -0.775j * (Y(0) @ X(1)) + 0.775 * (Y(0) @ Y(1)) + 0.775 * (X(0) @ X(1)) + 0.775j * (X(0) @ Y(1))
     """
+    mapping = mapping.strip().lower()
     if mapping == "jordan_wigner":
         h = qml.jordan_wigner(o_ferm, ps=True, tol=cutoff)
     elif mapping == "parity":
@@ -121,6 +122,11 @@ def qubit_observable(o_ferm, cutoff=1.0e-12, mapping="jordan_wigner"):
     elif mapping == "bravyi_kitaev":
         qubits = len(o_ferm.wires)
         h = qml.bravyi_kitaev(o_ferm, qubits, ps=True, tol=cutoff)
+    else:
+        raise ValueError(
+            f"The '{mapping}' transformation is not available."
+            f"Please set mapping to 'jordan_wigner', 'parity', or 'bravyi_kitaev'"
+        )
 
     if list(h.wires) != sorted(list(h.wires)):
         h = PauliSentence(
diff --git a/pennylane/qchem/openfermion_pyscf.py b/pennylane/qchem/openfermion_pyscf.py
index 980a1ca92..51aa3deff 100644
--- a/pennylane/qchem/openfermion_pyscf.py
+++ b/pennylane/qchem/openfermion_pyscf.py
@@ -135,7 +135,9 @@ def observable(fermion_ops, init_term=0, mapping="jordan_wigner", wires=None):
     """
     openfermion, _ = _import_of()
 
-    if mapping.strip().lower() not in ("jordan_wigner", "parity", "bravyi_kitaev"):
+    mapping = mapping.strip().lower()
+
+    if mapping not in ("jordan_wigner", "parity", "bravyi_kitaev"):
         raise TypeError(
             f"The '{mapping}' transformation is not available. \n "
             f"Please set 'mapping' to 'jordan_wigner', 'parity', or 'bravyi_kitaev'."
@@ -151,12 +153,12 @@ def observable(fermion_ops, init_term=0, mapping="jordan_wigner", wires=None):
         mb_obs += ops
 
     # Map the fermionic operator to a qubit operator
-    if mapping.strip().lower() == "bravyi_kitaev":
+    if mapping == "bravyi_kitaev":
         return qml.qchem.convert.import_operator(
             openfermion.transforms.bravyi_kitaev(mb_obs), wires=wires
         )
 
-    if mapping.strip().lower() == "parity":
+    if mapping == "parity":
         qubits = openfermion.count_qubits(mb_obs)
         if qubits == 0:
             return 0.0 * qml.I(0)
diff --git a/pennylane/qchem/structure.py b/pennylane/qchem/structure.py
index 02d84c959..b48e537e8 100644
--- a/pennylane/qchem/structure.py
+++ b/pennylane/qchem/structure.py
@@ -383,6 +383,8 @@ def hf_state(electrons, orbitals, basis="occupation_number"):
 
     state = np.where(np.arange(orbitals) < electrons, 1, 0)
 
+    basis = basis.strip().lower()
+
     if basis == "parity":
         pi_matrix = np.tril(np.ones((orbitals, orbitals)))
         return (np.matmul(pi_matrix, state) % 2).astype(int)
diff --git a/pennylane/spin/spin_hamiltonian.py b/pennylane/spin/spin_hamiltonian.py
index 762e06f1f..ee6eec392 100644
--- a/pennylane/spin/spin_hamiltonian.py
+++ b/pennylane/spin/spin_hamiltonian.py
@@ -307,6 +307,7 @@ def fermi_hubbard(
 
     hamiltonian = hopping_ham + int_term
 
+    mapping = mapping.strip().lower()
     if mapping not in ["jordan_wigner", "parity", "bravyi_kitaev"]:
         raise ValueError(
             f"The '{mapping}' transformation is not available."
@@ -472,6 +473,7 @@ def emery(
 
     hamiltonian = hopping_term + coulomb_term + intersite_term
 
+    mapping = mapping.strip().lower()
     if mapping not in ["jordan_wigner", "parity", "bravyi_kitaev"]:
         raise ValueError(
             f"The '{mapping}' transformation is not available."
@@ -613,6 +615,7 @@ def haldane(
                     math.exp(-1j * phi_term) * FermiWord({(0, s2): "+", (1, s1): "-"})
                 )
 
+    mapping = mapping.strip().lower()
     if mapping not in ["jordan_wigner", "parity", "bravyi_kitaev"]:
         raise ValueError(
             f"The '{mapping}' transformation is not available."
diff --git a/pennylane/transforms/decompose.py b/pennylane/transforms/decompose.py
index 1efa8dfb1..0a497c54a 100644
--- a/pennylane/transforms/decompose.py
+++ b/pennylane/transforms/decompose.py
@@ -45,7 +45,7 @@ def _get_plxpr_decompose():  # pylint: disable=missing-docstring, too-many-state
         import jax
 
         from pennylane.capture.primitives import ctrl_transform_prim
-        from pennylane.tape.plxpr_conversion import CollectOpsandMeas
+        from pennylane.decomposition.collect_resource_ops import CollectResourceOps
 
     except ImportError:  # pragma: no cover
         return None, None
@@ -261,8 +261,7 @@ def _get_plxpr_decompose():  # pylint: disable=missing-docstring, too-many-state
 
                 with qml.capture.pause():
 
-                    # TODO: replace this with a collector that does not flatten the PLxPR
-                    collector = CollectOpsandMeas()
+                    collector = CollectResourceOps()
                     collector.eval(jaxpr, consts, *args)
                     operations = collector.state["ops"]
 


Test patch:
diff --git a/tests/capture/transforms/test_capture_graph_decompose.py b/tests/capture/transforms/test_capture_graph_decompose.py
index bb8aa4b8a..443313278 100644
--- a/tests/capture/transforms/test_capture_graph_decompose.py
+++ b/tests/capture/transforms/test_capture_graph_decompose.py
@@ -271,3 +271,77 @@ class TestDecomposeInterpreterGraphEnabled:
             qml.RY(qml.math.array(-0.2, like="jax"), wires=[0]),
             qml.RX(qml.math.array(-0.1, like="jax"), wires=[0]),
         ]
+
+    @pytest.mark.integration
+    def test_cond(self):
+        """Tests that a circuit containing conditionals can be decomposed correctly."""
+
+        @DecomposeInterpreter(gate_set={"CNOT", "RX", "RY", "RZ"})
+        def f(x, wires):
+
+            def true_fn():
+                qml.CRX(x, wires=wires)
+
+            def false_fn():
+                qml.CRZ(x, wires=wires)
+
+            qml.cond(x > 0.5, true_fn, false_fn)()
+
+        # The PLxPR is constructed with the true_fn branch
+        jaxpr = jax.make_jaxpr(f)(0.6, [0, 1])
+        collector = CollectOpsandMeas()
+        collector.eval(jaxpr.jaxpr, jaxpr.consts, 0.6, *[0, 1])
+        assert collector.state["ops"] == [
+            qml.RZ(np.pi / 2, wires=[1]),
+            qml.RY(qml.math.array(0.6 / 2, like="jax"), wires=[1]),
+            qml.CNOT(wires=[0, 1]),
+            qml.RY(qml.math.array(-0.6 / 2, like="jax"), wires=[1]),
+            qml.CNOT(wires=[0, 1]),
+            qml.RZ(-np.pi / 2, wires=[1]),
+        ]
+        # Tests that the false_fn branch is also decomposed
+        collector = CollectOpsandMeas()
+        collector.eval(jaxpr.jaxpr, jaxpr.consts, 0.2, *[0, 1])
+        assert collector.state["ops"] == [
+            qml.RZ(qml.math.array(0.1, like="jax"), wires=[1]),
+            qml.CNOT(wires=[0, 1]),
+            qml.RZ(qml.math.array(-0.1, like="jax"), wires=[1]),
+            qml.CNOT(wires=[0, 1]),
+        ]
+
+    @pytest.mark.integration
+    def test_ctrl_cond(self):
+        """Tests that a circuit containing a cond nested in a ctrl is decomposed correctly."""
+
+        @DecomposeInterpreter(gate_set={"CNOT", "RX", "RY", "RZ"})
+        def f(x, wires):
+
+            def true_fn():
+                qml.RX(x, wires=wires[1])
+
+            def false_fn():
+                qml.RZ(x, wires=wires[1])
+
+            qml.ctrl(qml.cond(x > 0.5, true_fn, false_fn), control=wires[0])()
+
+        # The PLxPR is constructed with the true_fn branch
+        jaxpr = jax.make_jaxpr(f)(0.6, [0, 1])
+        collector = CollectOpsandMeas()
+        collector.eval(jaxpr.jaxpr, jaxpr.consts, 0.6, *[0, 1])
+        assert collector.state["ops"] == [
+            qml.RZ(np.pi / 2, wires=[1]),
+            qml.RY(qml.math.array(0.6 / 2, like="jax"), wires=[1]),
+            qml.CNOT(wires=[0, 1]),
+            qml.RY(qml.math.array(-0.6 / 2, like="jax"), wires=[1]),
+            qml.CNOT(wires=[0, 1]),
+            qml.RZ(-np.pi / 2, wires=[1]),
+        ]
+        # Tests that the false_fn branch is also decomposed
+        collector = CollectOpsandMeas()
+        collector.eval(jaxpr.jaxpr, jaxpr.consts, 0.2, *[0, 1])
+        assert collector.state["ops"] == [
+            qml.RZ(qml.math.array(0.1, like="jax"), wires=[1]),
+            qml.CNOT(wires=[0, 1]),
+            qml.RZ(qml.math.array(-0.1, like="jax"), wires=[1]),
+            qml.CNOT(wires=[0, 1]),
+        ]
diff --git a/tests/decomposition/test_collect_resource_ops.py b/tests/decomposition/test_collect_resource_ops.py
new file mode 100644
index 000000000..569868d27
--- /dev/null
+++ b/tests/decomposition/test_collect_resource_ops.py
@@ -0,0 +1,170 @@
+# Copyright 2025 Xanadu Quantum Technologies Inc.
+
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+
+#     http://www.apache.org/licenses/LICENSE-2.0
+
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Tests for the CollectResourceOps interpreter."""
+
+# pylint: disable=wrong-import-position,wrong-import-order
+
+import pytest
+
+import pennylane as qml
+
+pytestmark = [pytest.mark.jax, pytest.mark.usefixtures("enable_disable_plxpr")]
+
+jax = pytest.importorskip("jax")
+
+from pennylane.decomposition.collect_resource_ops import CollectResourceOps
+
+
+class TestCollectResourceOps:
+    """Unit tests for the CollectResourceOps interpreter."""
+
+    @pytest.mark.unit
+    def test_flat_body_fn(self):
+        """Tests a function without classical structure."""
+
+        def f(x, wires):
+            qml.RX(x, wires=wires[0])
+            qml.CNOT(wires=wires[:2])
+            qml.MultiRZ(x * 2, wires=wires[1:])
+            qml.CNOT(wires=wires[:2])
+            qml.RX(x * 2, wires=wires[1])
+            qml.MultiRZ(x * 2, wires=wires[2:])
+
+        jaxpr = jax.make_jaxpr(f)(0.5, [0, 1, 2, 3])
+        collector = CollectResourceOps()
+        collector.eval(jaxpr.jaxpr, jaxpr.consts, 0.5, *[0, 1, 2, 3])
+        ops = collector.state["ops"]
+        assert len(ops) == 4
+        assert ops == {
+            qml.resource_rep(qml.RX),
+            qml.resource_rep(qml.CNOT),
+            qml.resource_rep(qml.MultiRZ, num_wires=2),
+            qml.resource_rep(qml.MultiRZ, num_wires=3),
+        }
+
+    @pytest.mark.unit
+    def test_for_loop(self):
+        """Tests a function with a for loop."""
+
+        class CustomOp(qml.operation.Operator):  # pylint: disable=too-few-public-methods
+
+            resource_keys = {"x"}
+
+            @property
+            def resource_params(self) -> dict:
+                return {"x": self.parameters[0]}
+
+        def f(x, wires):
+
+            wires = qml.math.array(wires, like="jax")
+
+            @qml.for_loop(3)
+            def loop(i):
+                qml.RX(x, wires=wires[i])
+                qml.MultiRZ(x * 2, wires=[wires[i], wires[i + 1], wires[i + 2]])
+                CustomOp(x * i, wires=wires[1])
+
+            loop()
+
+        jaxpr = jax.make_jaxpr(f)(0.5, [0, 1, 2, 3, 4])
+        collector = CollectResourceOps()
+        collector.eval(jaxpr.jaxpr, jaxpr.consts, 0.5, *[0, 1, 2, 3, 4])
+        ops = collector.state["ops"]
+        assert len(ops) == 5
+        assert ops == {
+            qml.resource_rep(qml.RX),
+            qml.resource_rep(qml.MultiRZ, num_wires=3),
+            qml.resource_rep(CustomOp, x=0),
+            qml.resource_rep(CustomOp, x=0.5),
+            qml.resource_rep(CustomOp, x=1),
+        }
+
+    @pytest.mark.unit
+    def test_ctrl(self):
+        """Tests circuits containing control transforms."""
+
+        def circuit(x, wires):
+
+            def f():
+                qml.X(wires[0])
+                qml.RX(x, wires=wires[0])
+                qml.MultiRZ(x * 2, wires=[wires[1], wires[2], wires[3]])
+
+            qml.ctrl(f, control=wires[4])()
+
+        jaxpr = jax.make_jaxpr(circuit)(0.5, [0, 1, 2, 3, 4])
+        collector = CollectResourceOps()
+        collector.eval(jaxpr.jaxpr, jaxpr.consts, 0.5, *[0, 1, 2, 3, 4])
+        ops = collector.state["ops"]
+        assert len(ops) == 3
+        assert ops == {
+            qml.decomposition.controlled_resource_rep(qml.X, {}, 1, 0, 0),
+            qml.decomposition.controlled_resource_rep(qml.RX, {}, 1, 0, 0),
+            qml.decomposition.controlled_resource_rep(qml.MultiRZ, {"num_wires": 3}, 1, 0, 0),
+        }
+
+    @pytest.mark.unit
+    def test_adjoint(self):
+        """Tests circuits containing adjoint transforms."""
+
+        def circuit(x, wires):
+
+            def f():
+                qml.X(wires[0])
+                qml.RX(x, wires=wires[0])
+                qml.MultiRZ(x * 2, wires=[wires[1], wires[2], wires[3]])
+
+            qml.adjoint(f)()
+
+        jaxpr = jax.make_jaxpr(circuit)(0.5, [0, 1, 2, 3, 4])
+        collector = CollectResourceOps()
+        collector.eval(jaxpr.jaxpr, jaxpr.consts, 0.5, *[0, 1, 2, 3, 4])
+        ops = collector.state["ops"]
+        assert len(ops) == 3
+        assert ops == {
+            qml.decomposition.adjoint_resource_rep(qml.X, {}),
+            qml.decomposition.adjoint_resource_rep(qml.RX, {}),
+            qml.decomposition.adjoint_resource_rep(qml.MultiRZ, {"num_wires": 3}),
+        }
+
+    @pytest.mark.unit
+    def test_cond(self):
+        """Tests that all branches of a `cond` are explored."""
+
+        def circuit(x, wires):
+
+            wires = qml.math.array(wires, like="jax")
+
+            def true_fn():
+                qml.CRX(x, wires=wires)
+
+            def false_fn():
+                qml.CRZ(x, wires=wires)
+
+            qml.cond(x > 0.5, true_fn, false_fn)()
+            qml.cond(x > 0.5, qml.RX, qml.RY, elifs=(x > 0.2, qml.RZ))(x, wires=wires[0])
+
+        jaxpr = jax.make_jaxpr(circuit)(0.5, [0, 1])
+        collector = CollectResourceOps()
+        collector.eval(jaxpr.jaxpr, jaxpr.consts, 0.5, *[0, 1])
+        ops = collector.state["ops"]
+        assert len(ops) == 5
+        assert ops == {
+            qml.resource_rep(qml.CRX),
+            qml.resource_rep(qml.CRZ),
+            qml.resource_rep(qml.RX),
+            qml.resource_rep(qml.RY),
+            qml.resource_rep(qml.RZ),
+        }
diff --git a/tests/decomposition/test_resources.py b/tests/decomposition/test_resources.py
index bac8fb492..6bf7209e0 100644
--- a/tests/decomposition/test_resources.py
+++ b/tests/decomposition/test_resources.py
@@ -136,6 +136,13 @@ class TestCompressedResourceOp:
         )
         assert isinstance(hash(op), int)
 
+    def test_same_params_same_hash(self):
+        """Tests that two ops with the same params have the same hash."""
+
+        op1 = CompressedResourceOp(qml.RX, {"a": 1, "b": 2})
+        op2 = CompressedResourceOp(qml.RX, {"b": 2, "a": 1})
+        assert hash(op1) == hash(op2)
+
     def test_empty_params_same_hash(self):
         """Tests that CompressedResourceOp objects initialized with or without empty
         parameters have the same hash."""
diff --git a/tests/qchem/test_observable_hf.py b/tests/qchem/test_observable_hf.py
index f10dd9c9b..2b0a3db72 100644
--- a/tests/qchem/test_observable_hf.py
+++ b/tests/qchem/test_observable_hf.py
@@ -200,3 +200,12 @@ def test_qubit_observable_cutoff(f_observable, cut_off):
     assert np.allclose(
         qml.matrix(h_ref_op, wire_order=[0, 1, 2]), qml.matrix(h_as_op, wire_order=[0, 1, 2])
     )
+
+
+def test_qubit_observable_error():
+    """Test that qubit_observable raises an error for unsupported mapping."""
+
+    f_observable = 0.01 * from_string("0+ 0-")
+
+    with pytest.raises(ValueError, match="transformation is not available"):
+        qchem.qubit_observable(f_observable, mapping="random")