Repository: PennyLaneAI/pennylane
Pull request #6950 (2025-02-11 18:45:42)

[Decomposition] Defines the interface for decomposition rules
**Context:**

This PR defines the data structure used to define a decomposition rule and the API for creating a decomposition rule and registering it with an operator. Many features and concepts are adapted from the resource estimation module.

**Description of the Change:**

Created three classes:

- The `DecompositionRule` stores a decomposition rule as a qfunc and a resource estimate.
- The `Resources` is a data structure to represent the resource estimate of a decompositon.
- The `CompressedResourceOp` is a lightweight representation of an operator to be decomposed.

Created functions:
- The `register_resources` decorator can be used to wrap a qfunc in a `DecompositionRule` object.
- A `resource_rep` to create a `CompressedResourceOp` from an operator type and any additional information.

Added methods and attributes to `Operator`:
- Class property `Operator.resource_keys` is a set of strings
- Properties `resource_params` and `resource_rep` that returns a `CompressedReourceOp`

**Related GitHub Issues:**
[sc-84328]

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/PennyLaneAI/pennylane/pull/6950?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennyLaneAI) Report
Attention: Patch coverage is `61.97183%` with `27 lines` in your changes missing coverage. Please review.
> Project coverage is 99.53%. Comparing base [(`95736c0`)](https://app.codecov.io/gh/PennyLaneAI/pennylane/commit/95736c03471e36810579d42a0fc1202efe609743?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennyLaneAI) to head [(`b9d9ba2`)](https://app.codecov.io/gh/PennyLaneAI/pennylane/commit/b9d9ba239fb99d9323a25a54b8b2508ff6ea2416?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennyLaneAI).

| [Files with missing lines](https://app.codecov.io/gh/PennyLaneAI/pennylane/pull/6950?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennyLaneAI) | Patch % | Lines |
|---|---|---|
| [pennylane/decomposition/resources.py](https://app.codecov.io/gh/PennyLaneAI/pennylane/pull/6950?src=pr&el=tree&filepath=pennylane%2Fdecomposition%2Fresources.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennyLaneAI#diff-cGVubnlsYW5lL2RlY29tcG9zaXRpb24vcmVzb3VyY2VzLnB5) | 58.82% | [14 Missing :warning: ](https://app.codecov.io/gh/PennyLaneAI/pennylane/pull/6950?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennyLaneAI) |
| [pennylane/decomposition/decomposition\_rule.py](https://app.codecov.io/gh/PennyLaneAI/pennylane/pull/6950?src=pr&el=tree&filepath=pennylane%2Fdecomposition%2Fdecomposition_rule.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennyLaneAI#diff-cGVubnlsYW5lL2RlY29tcG9zaXRpb24vZGVjb21wb3NpdGlvbl9ydWxlLnB5) | 42.85% | [12 Missing :warning: ](https://app.codecov.io/gh/PennyLaneAI/pennylane/pull/6950?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennyLaneAI) |
| [pennylane/operation.py](https://app.codecov.io/gh/PennyLaneAI/pennylane/pull/6950?src=pr&el=tree&filepath=pennylane%2Foperation.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennyLaneAI#diff-cGVubnlsYW5lL29wZXJhdGlvbi5weQ==) | 92.85% | [1 Missing :warning: ](https://app.codecov.io/gh/PennyLaneAI/pennylane/pull/6950?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennyLaneAI) |

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


```diff
@@            Coverage Diff             @@
##           master    #6950      +/-   ##
==========================================
- Coverage   99.59%   99.53%   -0.06%     
==========================================
  Files         480      483       +3     
  Lines       45651    45722      +71     
==========================================
+ Hits        45466    45510      +44     
- Misses        185      212      +27     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/PennyLaneAI/pennylane/pull/6950?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).

astralcai: > Where is the update for these docstrings? 🤔
> - transforms.decompose
> - devices.preprocess.decompose
> - decompositions.enable/disable_graph

This PR does not contain updates to any of these. See https://github.com/PennyLaneAI/pennylane/pull/6966
maliasadi: > Does your PR add enable_graph and disable_graph?

@astralcai yes! It's added in PR #6966
isaacdevlugt: @astralcai the structure of the module docstring looks great! Thank you for doing that 🙏 

Patch:
diff --git a/doc/code/qml_decomposition.rst b/doc/code/qml_decomposition.rst
new file mode 100644
index 000000000..90ddb2e00
--- /dev/null
+++ b/doc/code/qml_decomposition.rst
@@ -0,0 +1,6 @@
+qml.decomposition
+=================
+
+.. currentmodule:: pennylane.decomposition
+
+.. automodule:: pennylane.decomposition
diff --git a/doc/index.rst b/doc/index.rst
index ae2e22106..6e78fb3e0 100644
--- a/doc/index.rst
+++ b/doc/index.rst
@@ -198,6 +198,7 @@ PennyLane is **free** and **open source**, released under the Apache License, Ve
    code/qml_bose
    code/qml_compiler
    code/qml_data
+   code/qml_decomposition
    code/qml_debugging
    code/qml_drawer
    code/qml_fermi
diff --git a/doc/releases/changelog-dev.md b/doc/releases/changelog-dev.md
index 880446719..1ec4eaf6a 100644
--- a/doc/releases/changelog-dev.md
+++ b/doc/releases/changelog-dev.md
@@ -116,6 +116,64 @@
 * ``qml.structure_constants`` now accepts and outputs matrix inputs using the ``matrix`` keyword.
   [(#6861)](https://github.com/PennyLaneAI/pennylane/pull/6861)
 
+<h4>Gate-set targeted decompositions</h4>
+
+* A new module called `qml.decomposition` that contains PennyLane's new experimental graph-based 
+  gate-set-targeted decomposition system has been added.
+  [(#6950)](https://github.com/PennyLaneAI/pennylane/pull/6950)
+
+  * A decomposition rule in the new system is defined as a quantum function that declares its own
+    resource requirements using `qml.register_resources`.
+    ```python
+    import pennylane as qml
+
+    @qml.register_resources({qml.H: 2, qml.CZ: 1})
+    def my_cnot(wires):
+        qml.H(wires=wires[1])
+        qml.CZ(wires=wires)
+        qml.H(wires=wires[1])
+    ```
+
+  * Operators with dynamic resource requirements must be declared in a resource estimate using `qml.resource_rep`.
+    ```python
+    import pennylane as qml
+
+    def _resource_fn(num_wires):
+        return {
+            qml.resource_rep(qml.MultiRZ, num_wires=num_wires - 1): 1,
+            qml.resource_rep(qml.MultiRZ, num_wires=3): 2
+        }
+    
+    @qml.register_resources(_resource_fn)
+    def my_decomp(thata, wires):
+        qml.MultiRZ(theta, wires=wires[:3])
+        qml.MultiRZ(theta, wires=wires[1:])
+        qml.MultiRZ(theta, wires=wires[:3])
+    ```
+
+  * The new system allows multiple decomposition rules to be registered for the same operator. 
+    Use `qml.add_decomps` to register a decomposition with an operator; use `qml.list_decomps`
+    to inspect all known decompositions for an operator.
+    ```pycon
+    >>> import pennylane as qml
+    >>> qml.list_decomps(qml.CRX)
+    [<pennylane.decomposition.decomposition_rule.DecompositionRule at 0x136da9de0>,
+     <pennylane.decomposition.decomposition_rule.DecompositionRule at 0x136da9db0>,
+     <pennylane.decomposition.decomposition_rule.DecompositionRule at 0x136da9f00>]
+    >>> print(qml.list_decomps(qml.CRX)[0])
+    @register_resources(_crx_to_rx_cz_resources)
+    def _crx_to_rx_cz(phi, wires, **__):
+        qml.RX(phi / 2, wires=wires[1])
+        qml.CZ(wires=wires)
+        qml.RX(-phi / 2, wires=wires[1])
+        qml.CZ(wires=wires)
+    >>> qml.list_decomps(qml.CRX)[0].compute_resources()
+    {qml.RX: 2, qml.CZ: 2}
+    >>> print(qml.draw(qml.list_decomps(qml.CRX)[0])(0.5, wires=[0, 1]))
+    0: ───────────╭●────────────╭●─┤
+    1: ──RX(0.25)─╰Z──RX(-0.25)─╰Z─┤
+    ```
+
 <h3>Improvements 🛠</h3>
   
 * The decompositions of `qml.SX`, `qml.X` and `qml.Y` use `qml.GlobalPhase` instead of `qml.PhaseShift`.
@@ -806,6 +864,7 @@ This release contains contributions from (in alphabetical order):
 Guillermo Alonso,
 Daniela Angulo,
 Utkarsh Azad,
+Astral Cai,
 Joey Carter,
 Henry Chang,
 Yushao Chen,
diff --git a/pennylane/__init__.py b/pennylane/__init__.py
index 9adc13547..1fa0a5f08 100644
--- a/pennylane/__init__.py
+++ b/pennylane/__init__.py
@@ -29,6 +29,13 @@ from pennylane.control_flow import for_loop, while_loop
 import pennylane.kernels
 import pennylane.math
 import pennylane.operation
+import pennylane.decomposition
+from pennylane.decomposition import (
+    register_resources,
+    add_decomps,
+    list_decomps,
+    resource_rep,
+)
 import pennylane.qnn
 import pennylane.templates
 import pennylane.pauli
diff --git a/pennylane/decomposition/__init__.py b/pennylane/decomposition/__init__.py
new file mode 100644
index 000000000..1360cedad
--- /dev/null
+++ b/pennylane/decomposition/__init__.py
@@ -0,0 +1,98 @@
+# 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.
+
+r"""
+.. currentmodule:: pennylane
+
+This module implements the infrastructure for PennyLane's new graph-based decomposition system.
+
+.. warning::
+
+    This module is experimental and is subject to change in the future.
+
+.. TODO::
+    Add section here explaining how to enable and disable the new decomposition system. [sc-83993]
+
+Defining Decomposition Rules
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+.. currentmodule:: pennylane.decomposition
+
+.. autosummary::
+    :toctree: api
+
+    ~register_resources
+    ~resource_rep
+    ~controlled_resource_rep
+    ~adjoint_resource_rep
+    ~DecompositionRule
+    ~Resources
+
+In the new decomposition system, a decomposition rule must be defined as a quantum function that
+accepts ``(*op.parameters, op.wires, **op.hyperparameters)`` as arguments, where ``op`` is an
+instance of the operator type that the decomposition is for. Additionally, a decomposition rule
+must declare its resource requirements using the ``register_resources`` decorator:
+
+.. code-block:: python
+
+    import pennylane as qml
+
+    @qml.register_resources({qml.H: 2, qml.CZ: 1})
+    def my_cnot(wires):
+        qml.H(wires=wires[1])
+        qml.CZ(wires=wires)
+        qml.H(wires=wires[1])
+
+Inspecting and Managing Decomposition Rules
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+.. autosummary::
+    :toctree: api
+
+    ~add_decomps
+    ~list_decomps
+    ~has_decomp
+
+PennyLane maintains a global dictionary of decomposition rules. New decomposition rules can be
+registered under an operator using ``add_decomps``, and ``list_decomps`` can be called to inspect
+a list of known decomposition rules for a given operator.
+
+Graph-based Decomposition Solver
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+.. TODO::
+    Add section here explaining the decomposition graph [sc-84329]
+
+Integration with the Decompose Transform
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+.. TODO::
+    Add section here explaining how this new system integrates with the `decompose` transform. [sc-83993]
+
+"""
+
+from .resources import (
+    Resources,
+    # TODO: add CompressedResourceOp once the conflict with labs is resolved.
+    resource_rep,
+    controlled_resource_rep,
+    adjoint_resource_rep,
+)
+from .decomposition_rule import (
+    register_resources,
+    DecompositionRule,
+    add_decomps,
+    list_decomps,
+    has_decomp,
+)
diff --git a/pennylane/decomposition/decomposition_rule.py b/pennylane/decomposition/decomposition_rule.py
new file mode 100644
index 000000000..3eb2712dc
--- /dev/null
+++ b/pennylane/decomposition/decomposition_rule.py
@@ -0,0 +1,337 @@
+# 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 the ``DecompositionRule`` class to represent a decomposition rule."""
+
+from __future__ import annotations
+
+import inspect
+from collections import defaultdict
+from textwrap import dedent
+from typing import Callable, Optional, Type, overload
+
+from pennylane.operation import Operator
+
+from .resources import CompressedResourceOp, Resources, resource_rep
+
+
+@overload
+def register_resources(resources: Callable | dict) -> Callable[[Callable], DecompositionRule]: ...
+@overload
+def register_resources(resources: Callable | dict, qfunc: Callable) -> DecompositionRule: ...
+def register_resources(resources: Callable | dict, qfunc: Optional[Callable] = None):
+    """Bind a quantum function to its required resources.
+
+    .. note::
+
+        This function is only relevant when the new experimental graph-based decomposition system
+        (introduced in v0.41) is enabled via ``qml.decompositions.enable_graph()``. This new way of
+        doing decompositions is generally more resource efficient and accommodates multiple alternative
+        decomposition rules for an operator. In this new system, custom decomposition rules are
+        defined as quantum functions, and it is currently required that every decomposition rule
+        declares its required resources using ``qml.register_resources``.
+
+    Args:
+        resources (dict or Callable): a dictionary mapping unique operators within the given
+            ``qfunc`` to their number of occurrences therein. If a function is provided instead
+            of a static dictionary, a dictionary must be returned from the function. For more
+            information, consult the Quantum Functions as Decomposition Rules section below.
+        qfunc (Callable): the quantum function that implements the decomposition. If ``None``,
+            returns a decorator for acting on a function.
+
+    Returns:
+        DecompositionRule:
+            a data structure that represents a decomposition rule, which contains a PennyLane
+            quantum function representing the decomposition, and its resource function.
+
+
+    **Example**
+
+    This function can be used as a decorator to bind a quantum function to its required resources
+    so that it can be used as a decomposition rule within the new graph-based decomposition system.
+
+    .. code-block:: python
+
+        import pennylane as qml
+
+        qml.decompositions.enable_graph()
+
+        @qml.register_resources({qml.H: 2, qml.CZ: 1})
+        def my_cnot(wires):
+            qml.H(wires=wires[1])
+            qml.CZ(wires=wires)
+            qml.H(wires=wires[1])
+
+        @partial(qml.transforms.decompose, fixed_decomps={qml.CNOT: my_cnot})
+        @qml.qnode(qml.device("default.qubit"))
+        def circuit():
+            qml.CNOT(wires=[0, 1])
+            return qml.state()
+
+
+    >>> print(qml.draw(circuit, level="device")())
+    0: ────╭●────┤  State
+    1: ──H─╰Z──H─┤  State
+
+    Alternatively, the decomposition rule can be created in-line:
+
+    >>> my_cnot = qml.register_resources({qml.H: 2, qml.CZ: 1}, my_cnot)
+
+    .. details::
+        :title: Quantum Functions as Decomposition Rules
+
+        Quantum functions representing decomposition rules within the new decomposition system
+        are expected to take ``(*op.parameters, op.wires, **op.hyperparameters)`` as arguments,
+        where ``op`` is an instance of the operator type that the decomposition is for.
+
+    .. details::
+        :title: Operators with Dynamic Resource Requirements
+
+        In many cases, the resource requirement of an operator's decomposition is not static; some
+        operators have properties that directly affect the resource estimate of its decompositions,
+        i.e., the types of gates that exist in the decomposition and their number of occurrences.
+
+        For each operator class, the set of parameters that affects the type of gates and their
+        number of occurrences in its decompositions is given by the ``resource_keys`` attribute.
+        For example, the number of gates in the decomposition for ``qml.MultiRZ`` changes based
+        on the number of wires it acts on, in contrast to the decomposition for ``qml.CNOT``:
+
+        >>> qml.CNOT.resource_keys
+        {}
+        >>> qml.MultiRZ.resource_keys
+        {'num_wires'}
+
+        The output of ``resource_keys`` indicates that custom decompositions for the operator
+        should be registered to a resource function (as opposed to a static dictionary) that
+        accepts those exact arguments and returns a dictionary.
+
+        .. code-block:: python
+
+            def _multi_rz_resources(num_wires):
+                return {
+                    qml.CNOT: 2 * (num_wires - 1),
+                    qml.RZ: 1
+                }
+
+            @qml.register_resources(_multi_rz_resources)
+            def multi_rz_decomposition(theta, wires, **__):
+                for w0, w1 in zip(wires[-1:0:-1], wires[-2::-1]):
+                    qml.CNOT(wires=(w0, w1))
+                qml.RZ(theta, wires=wires[0])
+                for w0, w1 in zip(wires[1:], wires[:-1]):
+                    qml.CNOT(wires=(w0, w1))
+
+        Additionally, if a custom decomposition for an operator contains gates that, in turn,
+        have properties that affect their own decompositions, this information must also be
+        included in the resource function. For example, if the decomposition rule produces a
+        ``MultiRZ`` gate, it is not sufficient to declare the existence of a ``MultiRZ`` in the
+        resource function; the number of wires it acts on must also be specified.
+
+        Consider a fictitious operator with the following decomposition:
+
+        .. code-block:: python
+
+            def my_decomp(thata, wires):
+                qml.MultiRZ(theta, wires=wires[:-1])
+                qml.MultiRZ(theta, wires=wires)
+                qml.MultiRZ(theta, wires=wires[1:])
+
+        It contains two ``MultiRZ`` gates acting on ``num_wires - 1`` wires (the first and last
+        ``MultiRZ``) and one ``MultiRZ`` gate acting on exactly ``num_wires`` wires. This
+        distinction must be reflected in the resource function:
+
+        .. code-block:: python
+
+            def my_resources(num_wires):
+                return {
+                    qml.resource_rep(qml.MultiRZ, num_wires=num_wires - 1): 2,
+                    qml.resource_rep(qml.MultiRZ, num_wires=num_wires): 1
+                }
+
+            my_decomp = qml.register_resources(my_resources, my_decomp)
+
+        where ``qml.resource_rep`` is a utility function that wraps an operator type and any
+        additional information relevant to its resource estimate into a compressed data structure.
+        To check what (if any) additional information is required to declare an operator type
+        in a resource function, refer to the ``resource_keys`` attribute of the operator class.
+        Operators with non-empty ``resource_keys`` must be declared using ``qml.resource_rep``,
+        with keyword arguments matching its ``resource_keys`` exactly.
+
+        .. seealso::
+
+            :func:`~pennylane.resource_rep`
+
+    """
+
+    def _decorator(_qfunc) -> DecompositionRule:
+        return DecompositionRule(_qfunc, resources)
+
+    return _decorator(qfunc) if qfunc else _decorator
+
+
+class DecompositionRule:  # pylint: disable=too-few-public-methods
+    """Represents a decomposition rule for an operator."""
+
+    def __init__(self, func: Callable, resources: Callable | dict):
+        self._impl = func
+        self._source = inspect.getsource(func)
+        if isinstance(resources, dict):
+
+            def resource_fn():
+                return resources
+
+            self._compute_resources = resource_fn
+        else:
+            self._compute_resources = resources
+
+    def __call__(self, *args, **kwargs):
+        return self._impl(*args, **kwargs)
+
+    def __str__(self):
+        return dedent(self._source).strip()
+
+    def compute_resources(self, *args, **kwargs) -> Resources:
+        """Computes the resources required to implement this decomposition rule."""
+        if self._compute_resources is None:
+            raise NotImplementedError("No resource estimation found for this decomposition rule.")
+        gate_counts = self._compute_resources(*args, **kwargs)
+        assert isinstance(gate_counts, dict), "Resource function must return a dictionary."
+        gate_counts = {_auto_wrap(op): count for op, count in gate_counts.items() if count > 0}
+        return Resources(gate_counts)
+
+
+def _auto_wrap(op_type):
+    """Conveniently wrap an operator type in a resource representation."""
+    if isinstance(op_type, CompressedResourceOp):
+        return op_type
+    if not issubclass(op_type, Operator):
+        raise TypeError(
+            "The keys of the dictionary returned by the resource function must be a subclass of "
+            "Operator or a CompressedResourceOp constructed with qml.resource_rep"
+        )
+    try:
+        return resource_rep(op_type)
+    except TypeError as e:
+        raise TypeError(
+            f"Operator {op_type.__name__} has non-empty resource_keys. A resource "
+            f"representation must be explicitly constructed using qml.resource_rep"
+        ) from e
+
+
+_decompositions = defaultdict(list)
+"""dict[type, list[DecompositionRule]]: A dictionary mapping operator types to decomposition rules."""
+
+
+def add_decomps(op_type: Type[Operator], *decomps: DecompositionRule) -> None:
+    """Globally registers new decomposition rules with an operator class.
+
+    .. note::
+
+        This function is only relevant when the new experimental graph-based decomposition system
+        (introduced in v0.41) is enabled via ``qml.decompositions.enable_graph()``. This new way of
+        doing decompositions is generally more resource efficient and accommodates multiple alternative
+        decomposition rules for an operator. In this new system, custom decomposition rules are
+        defined as quantum functions, and it is currently required that every decomposition rule
+        declares its required resources using :func:`~pennylane.register_resources`
+
+    In the new system of decompositions, multiple decomposition rules can be registered for the
+    same operator class. The specified decomposition rules in ``add_decomps`` serve as alternative
+    decomposition rules that may be chosen if they lead to a more resource-efficient decomposition.
+
+    Args:
+        op_type: the operator type for which new decomposition rules are specified.
+        decomps (DecompositionRule): new decomposition rules to add to the given ``op_type``.
+            A decomposition is a quantum function registered with a resource estimate using
+            ``qml.register_resources``.
+
+    .. seealso:: :func:`~pennylane.register_resources` and :class:`~pennylane.list_decomps`
+
+    **Example**
+
+    This example demonstrates adding two new decomposition rules to the ``qml.Hadamard`` operator.
+
+    .. code-block:: python
+
+        import pennylane as qml
+        import numpy as np
+
+        @qml.register_resources({qml.RZ: 2, qml.RX: 1, qml.GlobalPhase: 1})
+        def my_hadamard1(wires):
+            qml.RZ(np.pi / 2, wires=wires)
+            qml.RX(np.pi / 2, wires=wires)
+            qml.RZ(np.pi / 2, wires=wires)
+            qml.GlobalPhase(-np.pi / 2, wires=wires)
+
+        @qml.register_resources({qml.RZ: 1, qml.RY: 1, qml.GlobalPhase: 1})
+        def my_hadamard2(wires):
+            qml.RZ(np.pi, wires=wires)
+            qml.RY(np.pi / 2, wires=wires)
+            qml.GlobalPhase(-np.pi / 2)
+
+        qml.add_decomps(qml.Hadamard, my_hadamard1, my_hadamard2)
+
+    These two new decomposition rules for ``qml.Hadamard`` will be subsequently stored within the
+    scope of this program, and they will be taken into account for all circuit decompositions
+    for the duration of the session. To add alternative decompositions for a particular circuit
+    as opposed to globally, use the ``alt_decomps`` argument of the :func:`~pennylane.transforms.decompose` transform.
+
+    .. seealso:: :func:`~pennylane.transforms.decompose`
+
+    """
+    if not all(isinstance(d, DecompositionRule) for d in decomps):
+        raise TypeError(
+            "A decomposition rule must be a qfunc with a resource estimate "
+            "registered using qml.register_resources"
+        )
+    _decompositions[op_type].extend(decomps)
+
+
+def list_decomps(op_type: Type[Operator]) -> list[DecompositionRule]:
+    """Lists all stored decomposition rules for an operator class.
+
+    .. note::
+
+        This function is only relevant when the new experimental graph-based decomposition system
+        (introduced in v0.41) is enabled via ``qml.decompositions.enable_graph()``. This new way of
+        doing decompositions is generally more resource efficient and accommodates multiple alternative
+        decomposition rules for an operator.
+
+    Args:
+        op_type: the operator class to retrieve decomposition rules for.
+
+    Returns:
+        list[DecompositionRule]: a list of decomposition rules registered for the given operator.
+
+    """
+    return _decompositions[op_type][:]
+
+
+def has_decomp(op_type: Type[Operator]) -> bool:
+    """Check whether an operator has decomposition rules defined.
+
+    .. note::
+
+        This function is only relevant when the new experimental graph-based decomposition system
+        (introduced in v0.41) is enabled via ``qml.decompositions.enable_graph()``. This new way of
+        doing decompositions is generally more resource efficient and accommodates multiple alternative
+        decomposition rules for an operator.
+
+    Args:
+        op_type: the operator class to check for decomposition rules.
+
+    Returns:
+        bool: whether decomposition rules are defined for the given operator.
+
+    """
+    return op_type in _decompositions and len(_decompositions[op_type]) > 0
diff --git a/pennylane/decomposition/resources.py b/pennylane/decomposition/resources.py
new file mode 100644
index 000000000..068ebfefc
--- /dev/null
+++ b/pennylane/decomposition/resources.py
@@ -0,0 +1,353 @@
+# 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 the data structure that stores resource estimates for each decomposition."""
+
+from __future__ import annotations
+
+import functools
+from dataclasses import dataclass, field
+from functools import cached_property
+from typing import Optional, Type
+
+import pennylane as qml
+from pennylane.operation import Operator
+
+
+@dataclass(frozen=True)
+class Resources:
+    r"""Stores resource estimates.
+
+    Args:
+        gate_counts (dict): dictionary mapping operator types to their number of occurrences.
+
+    """
+
+    gate_counts: dict[CompressedResourceOp, int] = field(default_factory=dict)
+
+    def __post_init__(self):
+        """Verify that the gate counts and the number of gates are consistent."""
+        assert all(v > 0 for v in self.gate_counts.values())
+
+    @cached_property
+    def num_gates(self) -> int:
+        """The total number of gates."""
+        return sum(self.gate_counts.values())
+
+    def __add__(self, other: Resources):
+        return Resources(
+            _combine_dict(self.gate_counts, other.gate_counts),
+        )
+
+    def __mul__(self, scalar: int):
+        return Resources(_scale_dict(self.gate_counts, scalar))
+
+    __rmul__ = __mul__
+
+    def __repr__(self):
+        return f"<num_gates={self.num_gates}, gate_counts={self.gate_counts}>"
+
+
+def _combine_dict(dict1: dict, dict2: dict):
+    r"""Combines two dictionaries and adds values of common keys."""
+
+    combined_dict = dict1.copy()
+
+    for k, v in dict2.items():
+        combined_dict[k] = combined_dict.get(k, 0) + v
+
+    return combined_dict
+
+
+def _scale_dict(dict1: dict, scalar: int):
+    r"""Scales the values in a dictionary with a scalar."""
+
+    return {key: scalar * value for key, value in dict1.items()}
+
+
+class CompressedResourceOp:
+    """A lightweight representation of an operator to be decomposed.
+
+    .. note::
+
+        This class is only relevant when the new experimental graph-based decomposition system
+        (introduced in v0.41) is enabled via ``qml.decompositions.enable_graph()``. This new way of
+        doing decompositions is generally more resource efficient and accommodates multiple alternative
+        decomposition rules for an operator. In this new system, custom decomposition rules are
+        defined as quantum functions, and it is currently required that every decomposition rule
+        declares its required resources using :func:`~pennylane.register_resources`.
+
+    The ``CompressedResourceOp` is a lightweight data structure that contains an operator type
+    and a set of parameters that affects the resource requirement of this operator. If the
+    decomposition of an operator is independent of its parameters, e.g., ``Rot`` can be decomposed
+    into two ``RZ`` gates and an ``RY`` regardless of the angles, then every occurrence of this
+    operator in the circuit is represented by the same ``CompressedResourceOp`` which only
+    specifies the operator type, i.e., ``Rot``.
+
+    On the other hand, for some operators such as ``MultiRZ``, for which the numbers of ``CNOT``
+    gates in its decomposition depends on the number of wires, the resource representation of
+    a ``MultiRZ`` must include this information. To create a ``CompressedResourceOp`` object for
+    an operator, use the :func:`~pennylane.resource_rep` function.
+
+    Args:
+        op_type: the operator type
+        params (dict): the parameters of the operator relevant to the resource estimation of
+            its decompositions. This should only include parameters that affect the gate counts.
+
+    .. seealso:: :func:`~pennylane.resource_rep`
+
+    """
+
+    def __init__(self, op_type: Type[Operator], params: Optional[dict] = None):
+        if not isinstance(op_type, type):
+            raise TypeError(f"op_type must be an Operator type, got {type(op_type)}")
+        if not issubclass(op_type, qml.operation.Operator):
+            raise TypeError(f"op_type must be a subclass of Operator, got {op_type}")
+        self.op_type = op_type
+        self.params = params or {}
+        self._hashable_params = _make_hashable(params) if params else ()
+
+    def __hash__(self) -> int:
+        return hash((self.op_type, self._hashable_params))
+
+    def __eq__(self, other: CompressedResourceOp) -> bool:
+        return (
+            isinstance(other, CompressedResourceOp)
+            and self.op_type == other.op_type
+            and self.params == other.params
+        )
+
+    def __repr__(self):
+        params = ", ".join(f"{k}={v}" for k, v in self.params.items())
+        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 _validate_resource_rep(op_type, params):
+    """Validates the resource representation of an operator."""
+
+    if not issubclass(op_type, qml.operation.Operator):
+        raise TypeError(f"op_type must be a type of Operator, got {op_type}")
+
+    unexpected_arguments = set(params.keys()) - op_type.resource_keys
+    if unexpected_arguments:
+        raise TypeError(
+            f"Unexpected keyword arguments for resource_rep({op_type.__name__}): "
+            f"{list(unexpected_arguments)}). Expected: {list(op_type.resource_keys)}"
+        )
+
+    missing_arguments = op_type.resource_keys - set(params.keys())
+    if missing_arguments:
+        raise TypeError(
+            f"Missing keyword arguments for resource_rep({op_type.__name__}): "
+            f"{list(missing_arguments)}. Expected: {list(op_type.resource_keys)}"
+        )
+
+
+def resource_rep(op_type: Type[Operator], **params) -> CompressedResourceOp:
+    """Binds an operator type with additional resource parameters.
+
+    .. note::
+
+        This function is only relevant when the new experimental graph-based decomposition system
+        (introduced in v0.41) is enabled via ``qml.decompositions.enable_graph()``. This new way of
+        doing decompositions is generally more resource efficient and accommodates multiple alternative
+        decomposition rules for an operator. In this new system, custom decomposition rules are
+        defined as quantum functions, and it is currently required that every decomposition rule
+        declares its required resources using :func:`~pennylane.register_resources`.
+
+    Args:
+        op_type: the operator class to create a resource representation for.
+        **params: parameters relevant to the resource estimate of the operator's decompositions.
+            This should be consistent with ``op_type.resource_keys``.
+
+    Returns:
+        CompressedResourceOp: a lightweight representation of the operator.
+
+    **Example**
+
+    The resource parameters of an operator are a minimal set of information required to determine
+    the resource estimate of its decompositions. To check the required set of keyword arguments
+    for an operator type, refer to the ``resource_keys`` attribute of the operator class:
+
+    >>> qml.MultiRZ.resource_keys
+    {'num_wires'}
+
+    When calling ``resource_rep`` for ``MultiRZ``, ``num_wires`` must be provided as a keyword argument.
+
+    >>> rep = resource_rep(qml.MultiRZ, num_wires=3)
+    >>> rep
+    MultiRZ, {'num_wires': 3}
+    >>> type(rep)
+    <class 'pennylane.decomposition.resources.CompressedResourceOp'>
+
+    .. seealso:: See how this function is used in the context of defining a decomposition rule using :func:`~pennylane.register_resources`
+
+    .. details::
+        :title: Usage Details
+
+        The same approach applies also to symbolic operators. For example, if the decomposition
+        of an operator contains a controlled operation:
+
+        .. code-block:: python
+
+            def my_decomp(wires):
+                qml.ctrl(
+                    qml.MultiRZ(wires=wires[:3]),
+                    control=wires[3:5],
+                    control_values=[0, 1],
+                    work_wires=wires[5]
+                )
+
+        To declare this controlled operator in the resource function, we find the resource keys
+        of ``qml.ops.Controlled``:
+
+        >>> qml.ops.Controlled.resource_keys
+        {
+            'base_class',
+            'base_params',
+            'num_control_wires',
+            'num_zero_control_values',
+            'num_work_wires'
+        }
+
+        Then the resource representation can be created as follows:
+
+        >>> qml.resource_rep(
+        ...     qml.ops.Controlled,
+        ...     base_class=qml.ops.MultiRZ,
+        ...     base_params={'num_wires': 3},
+        ...     num_control_wires=2,
+        ...     num_zero_control_values=1,
+        ...     num_work_wires=1
+        ... )
+        Controlled, {'base_class': <class 'pennylane.ops.qubit.parametric_ops_multi_qubit.MultiRZ'>, 'base_params': {'num_wires': 3}, 'num_control_wires': 2, 'num_zero_control_values': 1, 'num_work_wires': 1}
+
+        Alternatively, use the utility function :func:`~pennylane.decomposition.controlled_resource_rep`:
+
+        >>> qml.decomposition.controlled_resource_rep(
+        ...     base_class=qml.ops.MultiRZ,
+        ...     base_params={'num_wires': 3},
+        ...     num_control_wires=2,
+        ...     num_zero_control_values=1,
+        ...     num_work_wires=1
+        ... )
+        Controlled, {'base_class': <class 'pennylane.ops.qubit.parametric_ops_multi_qubit.MultiRZ'>, 'base_params': {'num_wires': 3}, 'num_control_wires': 2, 'num_zero_control_values': 1, 'num_work_wires': 1}
+
+        .. seealso:: :func:`~pennylane.decomposition.controlled_resource_rep` and :func:`~pennylane.decomposition.adjoint_resource_rep`
+
+    """
+    _validate_resource_rep(op_type, params)
+    if op_type in (qml.ops.Controlled, qml.ops.ControlledOp):
+        return controlled_resource_rep(**params)
+    if issubclass(op_type, qml.ops.Adjoint):
+        return adjoint_resource_rep(**params)
+    return CompressedResourceOp(op_type, params)
+
+
+def controlled_resource_rep(
+    base_class: Type[Operator],
+    base_params: dict,
+    num_control_wires: int,
+    num_zero_control_values: int,
+    num_work_wires: int,
+):
+    """Creates a ``CompressedResourceOp`` representation of a general controlled operator.
+
+    Args:
+        base_class: the base operator type
+        base_params (dict): the resource params of the base operator
+        num_control_wires (int): the number of control wires
+        num_zero_control_values (int): the number of control values that are 0
+        num_work_wires (int): the number of work wires
+
+    """
+
+    _validate_resource_rep(base_class, base_params)
+
+    # Flatten nested controlled operations
+    if base_class is qml.ops.Controlled or base_class is qml.ops.ControlledOp:
+        base_resource_rep = controlled_resource_rep(**base_params)
+        base_class = base_resource_rep.params["base_class"]
+        base_params = base_resource_rep.params["base_params"]
+        num_control_wires += base_resource_rep.params["num_control_wires"]
+        num_zero_control_values += base_resource_rep.params["num_zero_control_values"]
+        num_work_wires += base_resource_rep.params["num_work_wires"]
+
+    elif base_class in custom_ctrl_op_to_base():
+        num_control_wires += base_class.num_wires - 1
+        base_class = custom_ctrl_op_to_base()[base_class]
+
+    elif base_class is qml.MultiControlledX:
+        base_class = qml.X
+        num_control_wires += base_params["num_control_wires"]
+        num_zero_control_values += base_params["num_zero_control_values"]
+        num_work_wires += base_params["num_work_wires"]
+        base_params = {}
+
+    elif base_class is qml.ControlledQubitUnitary:
+        base_class = qml.QubitUnitary
+        num_control_wires += base_params["num_control_wires"]
+        num_zero_control_values += base_params["num_zero_control_values"]
+        num_work_wires += base_params["num_work_wires"]
+        base_params = base_params["base"].resource_params
+
+    return CompressedResourceOp(
+        qml.ops.Controlled,
+        {
+            "base_class": base_class,
+            "base_params": base_params,
+            "num_control_wires": num_control_wires,
+            "num_zero_control_values": num_zero_control_values,
+            "num_work_wires": num_work_wires,
+        },
+    )
+
+
+def adjoint_resource_rep(base_class: Type[Operator], base_params: dict):
+    """Creates a ``CompressedResourceOp`` representation of the adjoint of an operator.
+
+    Args:
+        base_class: the base operator type
+        base_params (dict): the resource params of the base operator
+
+    """
+    base_resource_rep = resource_rep(base_class, **base_params)  # flattens any nested structures
+    return CompressedResourceOp(
+        qml.ops.Adjoint,
+        {"base_class": base_resource_rep.op_type, "base_params": base_resource_rep.params},
+    )
+
+
+@functools.lru_cache(maxsize=1)
+def custom_ctrl_op_to_base():
+    """The set of custom controlled operations."""
+
+    return {
+        qml.CNOT: qml.X,
+        qml.Toffoli: qml.X,
+        qml.CZ: qml.Z,
+        qml.CCZ: qml.Z,
+        qml.CY: qml.Y,
+        qml.CSWAP: qml.SWAP,
+        qml.CH: qml.H,
+        qml.CRX: qml.RX,
+        qml.CRY: qml.RY,
+        qml.CRZ: qml.RZ,
+        qml.CRot: qml.Rot,
+        qml.ControlledPhaseShift: qml.PhaseShift,
+    }
diff --git a/pennylane/labs/resource_estimation/resource_container.py b/pennylane/labs/resource_estimation/resource_container.py
index 7aaceb769..77bcd73a8 100644
--- a/pennylane/labs/resource_estimation/resource_container.py
+++ b/pennylane/labs/resource_estimation/resource_container.py
@@ -15,10 +15,11 @@ r"""Base classes for resource estimation."""
 import copy
 from collections import defaultdict
 
+from pennylane.decomposition.resources import CompressedResourceOp as _CompressedResourceOp
 from pennylane.labs.resource_estimation import ResourceOperator
 
 
-class CompressedResourceOp:
+class CompressedResourceOp(_CompressedResourceOp):  # pylint: disable=too-few-public-methods
     r"""Instantiate the light weight class corresponding to the operator type and parameters.
 
     Args:
@@ -58,17 +59,9 @@ class CompressedResourceOp:
         if not issubclass(op_type, ResourceOperator):
             raise TypeError(f"op_type must be a subclass of ResourceOperator. Got {op_type}.")
 
-        self.op_type = op_type
-        self.params = params
-        self._hashable_params = _make_hashable(params)
+        super().__init__(op_type, params)
         self._name = name or op_type.tracking_name(**params)
 
-    def __hash__(self) -> int:
-        return hash((self._name, self._hashable_params))
-
-    def __eq__(self, other: object) -> bool:
-        return (self.op_type == other.op_type) and (self.params == other.params)
-
     def __repr__(self) -> str:
         return self._name
 
@@ -350,9 +343,3 @@ def _scale_dict(dict1: defaultdict, scalar: int, in_place=False):
         combined_dict[k] *= scalar
 
     return combined_dict
-
-
-def _make_hashable(d) -> tuple:
-    if isinstance(d, dict):
-        return tuple((name, _make_hashable(value)) for name, value in d.items())
-    return d
diff --git a/pennylane/operation.py b/pennylane/operation.py
index ebe323c44..467aff996 100644
--- a/pennylane/operation.py
+++ b/pennylane/operation.py
@@ -349,7 +349,7 @@ class ClassPropertyDescriptor:  # pragma: no cover
         return self
 
 
-def classproperty(func):
+def classproperty(func) -> ClassPropertyDescriptor:
     """The class property decorator"""
     if not isinstance(func, (classmethod, staticmethod)):
         func = classmethod(func)
@@ -1405,6 +1405,56 @@ class Operator(abc.ABC, metaclass=ABCCaptureMeta):
 
         raise DecompositionUndefinedError
 
+    @classproperty
+    def resource_keys(self) -> Union[set, frozenset]:  # pylint: disable=no-self-use
+        """The set of parameters that affects the resource requirement of the operator.
+
+        All decomposition rules for this operator class are expected to have a resource function
+        that accepts keyword arguments that match these keys exactly. The :func:`~pennylane.resource_rep`
+        function will also expect keyword arguments that match these keys when called with this
+        operator type.
+
+        .. seealso::
+            :meth:`~.Operator.resource_params`
+
+        """
+        return set()
+
+    @property
+    def resource_params(self) -> dict:
+        """A dictionary containing the minimal information needed to compute a
+        resource estimate of the operator's decomposition.
+
+        The keys of this dictionary should match the ``resource_keys`` attribute of the operator
+        class. Two instances of the same operator type should have identical ``resource_params`` iff
+        their decompositions exhibit the same counts for each gate type, even if the individual
+        gate parameters differ.
+
+        **Examples**
+
+        The ``MultiRZ`` has non-empty ``resource_keys``:
+
+        >>> qml.MultiRZ.resource_keys
+        {"num_wires"}
+
+        The ``resource_params`` of an instance of ``MultiRZ`` will contain the number of wires:
+
+        >>> op = qml.MultiRZ(0.5, wires=[0, 1])
+        >>> op.resource_params
+        {"num_wires": 2}
+
+        Note that another ``MultiRZ`` may have different parameters but the same ``resource_params``:
+
+        >>> op2 = qml.MultiRZ(0.7, wires=[1, 2])
+        >>> op2.resource_params
+        {"num_wires": 2}
+
+        """
+        # For most operators, this should just be an empty dictionary, but a default
+        # implementation is intentionally not provided so that each operator class is
+        # forced to explicitly define its resource params.
+        raise NotImplementedError(f"{self.__class__.__name__}.resource_params undefined!")
+
     # pylint: disable=no-self-argument, comparison-with-callable
     @classproperty
     def has_diagonalizing_gates(cls) -> bool:
diff --git a/pennylane/ops/functions/assert_valid.py b/pennylane/ops/functions/assert_valid.py
index ca4537190..9b3b0ddd9 100644
--- a/pennylane/ops/functions/assert_valid.py
+++ b/pennylane/ops/functions/assert_valid.py
@@ -18,12 +18,14 @@ Operator class is correctly defined.
 
 import copy
 import pickle
+from collections import defaultdict
 from string import ascii_lowercase
 
 import numpy as np
 import scipy.sparse
 
 import pennylane as qml
+from pennylane.decomposition import DecompositionRule
 from pennylane.operation import EigvalsUndefinedError
 
 
@@ -94,6 +96,47 @@ def _check_decomposition(op, skip_wire_mapping):
         )(*op.data, wires=op.wires, **op.hyperparameters)
 
 
+def _check_decomposition_new(op):
+    """Checks involving the new system of decompositions."""
+
+    if type(op).resource_params is qml.operation.Operator.resource_params:
+        assert not qml.decomposition.has_decomp(
+            type(op)
+        ), "resource_params must be defined for operators with decompositions"
+        return
+
+    assert set(op.resource_params.keys()) == set(
+        type(op).resource_keys
+    ), "resource_params must have the same keys as specified by resource_keys"
+
+    for rule in qml.list_decomps(type(op)):
+        _test_decomposition_rule(op, rule)
+
+
+def _test_decomposition_rule(op, rule: DecompositionRule):
+    """Tests that a decomposition rule is consistent with the operator."""
+
+    # Test that the resource function is correct
+    resources = rule.compute_resources(**op.resource_params)
+    gate_counts = resources.gate_counts
+
+    with qml.queuing.AnnotatedQueue() as q:
+        rule(*op.data, wires=op.wires, **op.hyperparameters)
+    tape = qml.tape.QuantumScript.from_queue(q)
+    actual_gate_counts = defaultdict(int)
+    for _op in tape.operations:
+        resource_rep = qml.resource_rep(type(_op), **_op.resource_params)
+        actual_gate_counts[resource_rep] += 1
+    assert gate_counts == actual_gate_counts
+
+    # Tests that the decomposition produces the same matrix
+    op_matrix = qml.matrix(op)
+    decomp_matrix = qml.matrix(tape, wire_order=op.wires)
+    assert qml.math.allclose(
+        op_matrix, decomp_matrix
+    ), "decomposition must produce the same matrix as the operator."
+
+
 def _check_matrix(op):
     """Check that if the operation says it has a matrix, it does. Otherwise a ``MatrixUndefinedError`` should be raised."""
     if op.has_matrix:
@@ -420,6 +463,8 @@ def assert_valid(
         _check_pickle(op)
     _check_bind_new_parameters(op)
     _check_decomposition(op, skip_wire_mapping)
+    _check_decomposition_new(op)
+
     _check_matrix(op)
     _check_matrix_matches_decomp(op)
     _check_sparse_matrix(op)
diff --git a/pennylane/ops/op_math/adjoint.py b/pennylane/ops/op_math/adjoint.py
index 174974bd8..fe5bf1d91 100644
--- a/pennylane/ops/op_math/adjoint.py
+++ b/pennylane/ops/op_math/adjoint.py
@@ -323,6 +323,8 @@ class Adjoint(SymbolicOp):
 
     """
 
+    resource_keys = {"base_class", "base_params"}
+
     def _flatten(self):
         return (self.base,), tuple()
 
@@ -364,6 +366,10 @@ class Adjoint(SymbolicOp):
     def __repr__(self):
         return f"Adjoint({self.base})"
 
+    @property
+    def resource_params(self) -> dict:
+        return {"base_class": type(self.base), "base_params": self.base.resource_params}
+
     @property
     def ndim_params(self):
         return self.base.ndim_params
diff --git a/pennylane/ops/op_math/controlled.py b/pennylane/ops/op_math/controlled.py
index f2de47f52..9d500eb0d 100644
--- a/pennylane/ops/op_math/controlled.py
+++ b/pennylane/ops/op_math/controlled.py
@@ -462,6 +462,14 @@ class Controlled(SymbolicOp):
 
     """
 
+    resource_keys = {
+        "base_class",
+        "base_params",
+        "num_control_wires",
+        "num_zero_control_values",
+        "num_work_wires",
+    }
+
     def _flatten(self):
         return (self.base,), (self.control_wires, tuple(self.control_values), self.work_wires)
 
@@ -633,6 +641,18 @@ class Controlled(SymbolicOp):
             work_wires=new_work_wires,
         )
 
+    # Properties for resource estimation ###############
+
+    @property
+    def resource_params(self):
+        return {
+            "base_class": type(self.base),
+            "base_params": self.base.resource_params,
+            "num_control_wires": len(self.control_wires),
+            "num_zero_control_values": len([val for val in self.control_values if not val]),
+            "num_work_wires": len(self.work_wires),
+        }
+
     # Methods ##########################################
 
     def __repr__(self):
diff --git a/pennylane/ops/op_math/controlled_ops.py b/pennylane/ops/op_math/controlled_ops.py
index 66a84e6b2..adbb3591c 100644
--- a/pennylane/ops/op_math/controlled_ops.py
+++ b/pennylane/ops/op_math/controlled_ops.py
@@ -120,6 +120,8 @@ class ControlledQubitUnitary(ControlledOp):
     ndim_params = (2,)
     """tuple[int]: Number of dimensions per trainable parameter that the operator depends on."""
 
+    resource_keys = {"base", "num_control_wires", "num_zero_control_values", "num_work_wires"}
+
     grad_method = None
     """Gradient computation method."""
 
@@ -229,6 +231,15 @@ class ControlledQubitUnitary(ControlledOp):
         )
         self._name = "ControlledQubitUnitary"
 
+    @property
+    def resource_params(self) -> dict:
+        return {
+            "base": self.base,
+            "num_control_wires": len(self.control_wires),
+            "num_zero_control_values": len([val for val in self.control_values if not val]),
+            "num_work_wires": len(self.work_wires),
+        }
+
     def _controlled(self, wire):
         ctrl_wires = wire + self.control_wires
         values = None if self.control_values is None else [True] + self.control_values
@@ -288,6 +299,8 @@ class CH(ControlledOp):
     ndim_params = ()
     """tuple[int]: Number of dimensions per trainable parameter that the operator depends on."""
 
+    resource_keys = set()
+
     name = "CH"
 
     def _flatten(self):
@@ -313,6 +326,10 @@ class CH(ControlledOp):
     def __repr__(self):
         return f"CH(wires={self.wires.tolist()})"
 
+    @property
+    def resource_params(self) -> dict:
+        return {}
+
     @staticmethod
     @lru_cache()
     def compute_matrix():  # pylint: disable=arguments-differ
@@ -406,6 +423,8 @@ class CY(ControlledOp):
     ndim_params = ()
     """tuple[int]: Number of dimensions per trainable parameter that the operator depends on."""
 
+    resource_keys = set()
+
     name = "CY"
 
     def _flatten(self):
@@ -428,6 +447,10 @@ class CY(ControlledOp):
     def __repr__(self):
         return f"CY(wires={self.wires.tolist()})"
 
+    @property
+    def resource_params(self) -> dict:
+        return {}
+
     @staticmethod
     @lru_cache()
     def compute_matrix():  # pylint: disable=arguments-differ
@@ -515,6 +538,8 @@ class CZ(ControlledOp):
     ndim_params = ()
     """tuple[int]: Number of dimensions per trainable parameter that the operator depends on."""
 
+    resource_keys = set()
+
     name = "CZ"
 
     def _flatten(self):
@@ -537,6 +562,10 @@ class CZ(ControlledOp):
     def __repr__(self):
         return f"CZ(wires={self.wires.tolist()})"
 
+    @property
+    def resource_params(self) -> dict:
+        return {}
+
     @staticmethod
     @lru_cache()
     def compute_matrix():  # pylint: disable=arguments-differ
@@ -603,6 +632,8 @@ class CSWAP(ControlledOp):
     ndim_params = ()
     """tuple[int]: Number of dimensions per trainable parameter that the operator depends on."""
 
+    resource_keys = set()
+
     name = "CSWAP"
 
     def _flatten(self):
@@ -628,6 +659,10 @@ class CSWAP(ControlledOp):
     def __repr__(self):
         return f"CSWAP(wires={self.wires.tolist()})"
 
+    @property
+    def resource_params(self) -> dict:
+        return {}
+
     @staticmethod
     @lru_cache()
     def compute_matrix():  # pylint: disable=arguments-differ
@@ -742,6 +777,8 @@ class CCZ(ControlledOp):
     ndim_params = ()
     """tuple[int]: Number of dimensions per trainable parameter that the operator depends on."""
 
+    resource_keys = set()
+
     name = "CCZ"
 
     def __init__(self, wires, id=None):
@@ -756,6 +793,10 @@ class CCZ(ControlledOp):
     def __repr__(self):
         return f"CCZ(wires={self.wires.tolist()})"
 
+    @property
+    def resource_params(self) -> dict:
+        return {}
+
     @staticmethod
     @lru_cache()
     def compute_matrix():  # pylint: disable=arguments-differ
@@ -880,6 +921,8 @@ class CNOT(ControlledOp):
     ndim_params = ()
     """tuple[int]: Number of dimensions per trainable parameter that the operator depends on."""
 
+    resource_keys = set()
+
     name = "CNOT"
 
     def _flatten(self):
@@ -925,6 +968,10 @@ class CNOT(ControlledOp):
         """
         raise qml.operation.DecompositionUndefinedError
 
+    @property
+    def resource_params(self) -> dict:
+        return {}
+
     def __repr__(self):
         return f"CNOT(wires={self.wires.tolist()})"
 
@@ -992,6 +1039,8 @@ class Toffoli(ControlledOp):
     ndim_params = ()
     """tuple[int]: Number of dimensions per trainable parameter that the operator depends on."""
 
+    resource_keys = set()
+
     name = "Toffoli"
 
     def _flatten(self):
@@ -1016,6 +1065,10 @@ class Toffoli(ControlledOp):
     def __repr__(self):
         return f"Toffoli(wires={self.wires.tolist()})"
 
+    @property
+    def resource_params(self) -> dict:
+        return {}
+
     @staticmethod
     @lru_cache()
     def compute_matrix():  # pylint: disable=arguments-differ
@@ -1181,6 +1234,8 @@ class MultiControlledX(ControlledOp):
     ndim_params = ()
     """tuple[int]: Number of dimensions per trainable parameter that the operator depends on."""
 
+    resource_keys = {"num_control_wires", "num_zero_control_values", "num_work_wires"}
+
     name = "MultiControlledX"
 
     def _flatten(self):
@@ -1255,6 +1310,14 @@ class MultiControlledX(ControlledOp):
     def wires(self):
         return self.control_wires + self.target_wires
 
+    @property
+    def resource_params(self) -> dict:
+        return {
+            "num_control_wires": len(self.control_wires),
+            "num_zero_control_values": len([val for val in self.control_values if not val]),
+            "num_work_wires": len(self.work_wires),
+        }
+
     # pylint: disable=unused-argument, arguments-differ
     @staticmethod
     def compute_matrix(control_wires: WiresLike, control_values=None, **kwargs):
@@ -1402,6 +1465,8 @@ class CRX(ControlledOp):
     ndim_params = (0,)
     """tuple[int]: Number of dimensions per trainable parameter that the operator depends on."""
 
+    resource_keys = set()
+
     name = "CRX"
     parameter_frequencies = [(0.5, 1.0)]
 
@@ -1425,6 +1490,10 @@ class CRX(ControlledOp):
     def _primitive_bind_call(cls, phi, wires: WiresLike, id=None):
         return cls._primitive.bind(phi, *wires, n_wires=len(wires))
 
+    @property
+    def resource_params(self) -> dict:
+        return {}
+
     @staticmethod
     def compute_matrix(theta):  # pylint: disable=arguments-differ
         r"""Representation of the operator as a canonical matrix in the computational basis (static method).
@@ -1558,6 +1627,8 @@ class CRY(ControlledOp):
     ndim_params = (0,)
     """tuple[int]: Number of dimensions per trainable parameter that the operator depends on."""
 
+    resource_keys = set()
+
     name = "CRY"
     parameter_frequencies = [(0.5, 1.0)]
 
@@ -1581,6 +1652,10 @@ class CRY(ControlledOp):
     def _primitive_bind_call(cls, phi, wires, id=None):
         return cls._primitive.bind(phi, *wires, n_wires=len(wires))
 
+    @property
+    def resource_params(self) -> dict:
+        return {}
+
     @staticmethod
     def compute_matrix(theta):  # pylint: disable=arguments-differ
         r"""Representation of the operator as a canonical matrix in the computational basis (static method).
@@ -1714,6 +1789,8 @@ class CRZ(ControlledOp):
     ndim_params = (0,)
     """tuple[int]: Number of dimensions per trainable parameter that the operator depends on."""
 
+    resource_keys = set()
+
     name = "CRZ"
     parameter_frequencies = [(0.5, 1.0)]
 
@@ -1737,6 +1814,10 @@ class CRZ(ControlledOp):
     def _primitive_bind_call(cls, phi, wires, id=None):
         return cls._primitive.bind(phi, *wires, n_wires=len(wires))
 
+    @property
+    def resource_params(self) -> dict:
+        return {}
+
     @staticmethod
     def compute_matrix(theta):  # pylint: disable=arguments-differ
         r"""Representation of the operator as a canonical matrix in the computational basis (static method).
@@ -1902,6 +1983,8 @@ class CRot(ControlledOp):
     ndim_params = (0, 0, 0)
     """tuple[int]: Number of dimensions per trainable parameter that the operator depends on."""
 
+    resource_keys = set()
+
     name = "CRot"
     parameter_frequencies = [(0.5, 1.0), (0.5, 1.0), (0.5, 1.0)]
 
@@ -1931,6 +2014,10 @@ class CRot(ControlledOp):
     ):  # pylint: disable=too-many-positional-arguments
         return cls._primitive.bind(phi, theta, omega, *wires, n_wires=len(wires))
 
+    @property
+    def resource_params(self) -> dict:
+        return {}
+
     @staticmethod
     def compute_matrix(phi, theta, omega):  # pylint: disable=arguments-differ
         r"""Representation of the operator as a canonical matrix in the computational basis (static method).
@@ -2075,6 +2162,8 @@ class ControlledPhaseShift(ControlledOp):
     ndim_params = (0,)
     """tuple[int]: Number of dimensions per trainable parameter that the operator depends on."""
 
+    resource_keys = set()
+
     name = "ControlledPhaseShift"
     parameter_frequencies = [(1,)]
 
@@ -2098,6 +2187,10 @@ class ControlledPhaseShift(ControlledOp):
     def _primitive_bind_call(cls, phi, wires, id=None):
         return cls._primitive.bind(phi, *wires, n_wires=len(wires))
 
+    @property
+    def resource_params(self) -> dict:
+        return {}
+
     @staticmethod
     def compute_matrix(phi):  # pylint: disable=arguments-differ
         r"""Representation of the operator as a canonical matrix in the computational basis (static method).
diff --git a/pennylane/ops/qubit/matrix_ops.py b/pennylane/ops/qubit/matrix_ops.py
index 5351006fa..215f336d5 100644
--- a/pennylane/ops/qubit/matrix_ops.py
+++ b/pennylane/ops/qubit/matrix_ops.py
@@ -132,6 +132,8 @@ class QubitUnitary(Operation):
     ndim_params = (2,)
     """tuple[int]: Number of dimensions per trainable parameter that the operator depends on."""
 
+    resource_keys = {"num_wires"}
+
     grad_method = None
     """Gradient computation method."""
 
@@ -182,6 +184,10 @@ class QubitUnitary(Operation):
             atol=1e-6,
         )
 
+    @property
+    def resource_params(self) -> dict:
+        return {"num_wires": len(self.wires)}
+
     @staticmethod
     def compute_matrix(U: TensorLike):  # pylint: disable=arguments-differ
         r"""Representation of the operator as a canonical matrix in the computational basis (static method).
diff --git a/pennylane/ops/qubit/non_parametric_ops.py b/pennylane/ops/qubit/non_parametric_ops.py
index 9ab7aa087..089b2b01d 100644
--- a/pennylane/ops/qubit/non_parametric_ops.py
+++ b/pennylane/ops/qubit/non_parametric_ops.py
@@ -242,6 +242,8 @@ class PauliX(Observable, Operation):
 
     basis = "X"
 
+    resource_keys = set()
+
     batch_size = None
 
     _queue_category = "_ops"
@@ -276,6 +278,10 @@ class PauliX(Observable, Operation):
     def name(self) -> str:
         return "PauliX"
 
+    @property
+    def resource_params(self) -> dict:
+        return {}
+
     @staticmethod
     @lru_cache()
     def compute_matrix() -> np.ndarray:  # pylint: disable=arguments-differ
@@ -629,6 +635,8 @@ class PauliZ(Observable, Operation):
 
     batch_size = None
 
+    resource_keys = set()
+
     _queue_category = "_ops"
 
     @property
@@ -661,6 +669,10 @@ class PauliZ(Observable, Operation):
     def name(self) -> str:
         return "PauliZ"
 
+    @property
+    def resource_params(self) -> dict:
+        return {}
+
     @staticmethod
     @lru_cache()
     def compute_matrix() -> np.ndarray:  # pylint: disable=arguments-differ
diff --git a/pennylane/ops/qubit/parametric_ops_multi_qubit.py b/pennylane/ops/qubit/parametric_ops_multi_qubit.py
index e49bb9d35..2dd708d22 100644
--- a/pennylane/ops/qubit/parametric_ops_multi_qubit.py
+++ b/pennylane/ops/qubit/parametric_ops_multi_qubit.py
@@ -24,6 +24,7 @@ from typing import Optional, Union
 import numpy as np
 
 import pennylane as qml
+from pennylane.decomposition import add_decomps, register_resources
 from pennylane.math import expand_matrix
 from pennylane.operation import AnyWires, FlatPytree, Operation
 from pennylane.typing import TensorLike
@@ -67,6 +68,8 @@ class MultiRZ(Operation):
     ndim_params = (0,)
     """tuple[int]: Number of dimensions per trainable parameter that the operator depends on."""
 
+    resource_keys = {"num_wires"}
+
     grad_method = "A"
     parameter_frequencies = [(1,)]
 
@@ -193,6 +196,10 @@ class MultiRZ(Operation):
 
         return ops
 
+    @property
+    def resource_params(self) -> dict:
+        return {"num_wires": self.hyperparameters["num_wires"]}
+
     def adjoint(self) -> "MultiRZ":
         return MultiRZ(-self.parameters[0], wires=self.wires)
 
@@ -208,6 +215,29 @@ class MultiRZ(Operation):
         return MultiRZ(theta, wires=self.wires)
 
 
+def _multi_rz_decomposition_resources(num_wires):
+    return {qml.RZ: 1, qml.CNOT: 2 * (num_wires - 1)}
+
+
+@register_resources(_multi_rz_decomposition_resources)
+def _multi_rz_decomposition(theta, wires, **__):
+
+    @qml.for_loop(len(wires) - 1, 0, -1)
+    def _pre_cnot(i):
+        qml.CNOT(wires=(wires[i], wires[i - 1]))
+
+    @qml.for_loop(1, len(wires), 1)
+    def _post_cnot(i):
+        qml.CNOT(wires=(wires[i], wires[i - 1]))
+
+    _pre_cnot()  # pylint: disable=no-value-for-parameter
+    qml.RZ(theta, wires=wires[0])
+    _post_cnot()  # pylint: disable=no-value-for-parameter
+
+
+add_decomps(MultiRZ, _multi_rz_decomposition)
+
+
 class PauliRot(Operation):
     r"""
     Arbitrary Pauli word rotation.
diff --git a/pennylane/ops/qubit/parametric_ops_single_qubit.py b/pennylane/ops/qubit/parametric_ops_single_qubit.py
index 633e3d454..e3f4db0f3 100644
--- a/pennylane/ops/qubit/parametric_ops_single_qubit.py
+++ b/pennylane/ops/qubit/parametric_ops_single_qubit.py
@@ -71,6 +71,8 @@ class RX(Operation):
     ndim_params = (0,)
     """tuple[int]: Number of dimensions per trainable parameter that the operator depends on."""
 
+    resource_keys = set()
+
     basis = "X"
     grad_method = "A"
     parameter_frequencies = [(1,)]
@@ -81,6 +83,10 @@ class RX(Operation):
     def __init__(self, phi: TensorLike, wires: WiresLike, id: Optional[str] = None):
         super().__init__(phi, wires=wires, id=id)
 
+    @property
+    def resource_params(self) -> dict:
+        return {}
+
     @staticmethod
     def compute_matrix(theta: TensorLike) -> TensorLike:  # pylint: disable=arguments-differ
         r"""Representation of the operator as a canonical matrix in the computational basis (static method).
@@ -280,6 +286,8 @@ class RZ(Operation):
     ndim_params = (0,)
     """tuple[int]: Number of dimensions per trainable parameter that the operator depends on."""
 
+    resource_keys = set()
+
     basis = "Z"
     grad_method = "A"
     parameter_frequencies = [(1,)]
@@ -373,6 +381,10 @@ class RZ(Operation):
     def adjoint(self) -> "RZ":
         return RZ(-self.data[0], wires=self.wires)
 
+    @property
+    def resource_params(self) -> dict:
+        return {}
+
     def pow(self, z: Union[int, float]) -> list["qml.operation.Operator"]:
         return [RZ(self.data[0] * z, wires=self.wires)]
 
@@ -602,6 +614,8 @@ class Rot(Operation):
     ndim_params = (0, 0, 0)
     """tuple[int]: Number of dimensions per trainable parameter that the operator depends on."""
 
+    resource_keys = set()
+
     grad_method = "A"
     parameter_frequencies = [(1,), (1,), (1,)]
 
@@ -616,6 +630,10 @@ class Rot(Operation):
     ):
         super().__init__(phi, theta, omega, wires=wires, id=id)
 
+    @property
+    def resource_params(self) -> dict:
+        return {}
+
     @staticmethod
     def compute_matrix(
         phi: TensorLike,


Test patch:
diff --git a/pennylane/labs/tests/resource_estimation/test_resource_container.py b/pennylane/labs/tests/resource_estimation/test_resource_container.py
index 0240ba442..4dbe7e516 100644
--- a/pennylane/labs/tests/resource_estimation/test_resource_container.py
+++ b/pennylane/labs/tests/resource_estimation/test_resource_container.py
@@ -33,25 +33,26 @@ from pennylane.labs.resource_estimation.resource_container import (
     substitute,
 )
 from pennylane.labs.resource_estimation.resource_operator import ResourceOperator
+from pennylane.operation import Operator
 
 
-class ResourceDummyX(ResourceOperator):
+class ResourceDummyX(Operator, ResourceOperator):
     """Dummy testing class representing X gate"""
 
 
-class ResourceDummyQFT(ResourceOperator):
+class ResourceDummyQFT(Operator, ResourceOperator):
     """Dummy testing class representing QFT gate"""
 
 
-class ResourceDummyQSVT(ResourceOperator):
+class ResourceDummyQSVT(Operator, ResourceOperator):
     """Dummy testing class representing QSVT gate"""
 
 
-class ResourceDummyTrotterProduct(ResourceOperator):
+class ResourceDummyTrotterProduct(Operator, ResourceOperator):
     """Dummy testing class representing TrotterProduct gate"""
 
 
-class ResourceDummyAdjoint(ResourceOperator):
+class ResourceDummyAdjoint(Operator, ResourceOperator):
     """Dummy testing class representing the Adjoint symbolic operator"""
 
 
diff --git a/tests/decomposition/test_decomposition_rule.py b/tests/decomposition/test_decomposition_rule.py
new file mode 100644
index 000000000..d0c0f1cab
--- /dev/null
+++ b/tests/decomposition/test_decomposition_rule.py
@@ -0,0 +1,226 @@
+# 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.
+
+"""Unit tests for the DecompositionRule class."""
+
+from textwrap import dedent
+
+import pytest
+
+import pennylane as qml
+from pennylane.decomposition.decomposition_rule import (
+    DecompositionRule,
+    _decompositions,
+    register_resources,
+)
+from pennylane.decomposition.resources import CompressedResourceOp, Resources
+
+
+class TestDecompositionRule:
+    """Unit tests for DecompositionRule."""
+
+    def test_create_decomposition_rule(self):
+        """Test that a DecompositionRule object can be created."""
+
+        def multi_rz_decomposition(theta, wires, **__):
+            for w0, w1 in zip(wires[-1:0:-1], wires[-2::-1]):
+                qml.CNOT(wires=(w0, w1))
+            qml.RZ(theta, wires=wires[0])
+            for w0, w1 in zip(wires[1:], wires[:-1]):
+                qml.CNOT(wires=(w0, w1))
+
+        def _multi_rz_resources(num_wires):
+            return {
+                qml.RZ: 1,
+                qml.CNOT: 2 * (num_wires - 1),
+            }
+
+        multi_rz_decomposition = register_resources(_multi_rz_resources, multi_rz_decomposition)
+
+        assert isinstance(multi_rz_decomposition, DecompositionRule)
+
+        with qml.queuing.AnnotatedQueue() as q:
+            multi_rz_decomposition(0.5, wires=[0, 1, 2])
+
+        assert q.queue == [
+            qml.CNOT(wires=[2, 1]),
+            qml.CNOT(wires=[1, 0]),
+            qml.RZ(0.5, wires=[0]),
+            qml.CNOT(wires=[1, 0]),
+            qml.CNOT(wires=[2, 1]),
+        ]
+
+        assert multi_rz_decomposition.compute_resources(num_wires=3) == Resources(
+            gate_counts={CompressedResourceOp(qml.RZ): 1, CompressedResourceOp(qml.CNOT): 4}
+        )
+
+    def test_decomposition_decorator(self):
+        """Tests creating a decomposition rule using the decorator syntax."""
+
+        def _multi_rz_resources(num_wires):
+            return {
+                qml.RZ: 1,
+                qml.CNOT: 2 * (num_wires - 1),
+            }
+
+        @qml.register_resources(_multi_rz_resources)
+        def multi_rz_decomposition(theta, wires, **__):
+            for w0, w1 in zip(wires[-1:0:-1], wires[-2::-1]):
+                qml.CNOT(wires=(w0, w1))
+            qml.RZ(theta, wires=wires[0])
+            for w0, w1 in zip(wires[1:], wires[:-1]):
+                qml.CNOT(wires=(w0, w1))
+
+        assert isinstance(multi_rz_decomposition, DecompositionRule)
+
+        with qml.queuing.AnnotatedQueue() as q:
+            multi_rz_decomposition(0.5, wires=[0, 1, 2])
+
+        assert q.queue == [
+            qml.CNOT(wires=[2, 1]),
+            qml.CNOT(wires=[1, 0]),
+            qml.RZ(0.5, wires=[0]),
+            qml.CNOT(wires=[1, 0]),
+            qml.CNOT(wires=[2, 1]),
+        ]
+
+        assert multi_rz_decomposition.compute_resources(num_wires=3) == Resources(
+            gate_counts={CompressedResourceOp(qml.RZ): 1, CompressedResourceOp(qml.CNOT): 4}
+        )
+
+    def test_inspect_decomposition_rule(self):
+        """Tests that the source code for a decomposition rule can be inspected."""
+
+        @qml.register_resources({qml.H: 2, qml.CNOT: 1})
+        def my_cz(wires):
+            qml.H(wires[0])
+            qml.CNOT(wires=wires)
+            qml.H(wires[0])
+
+        assert (
+            str(my_cz)
+            == dedent(
+                """
+        @qml.register_resources({qml.H: 2, qml.CNOT: 1})
+        def my_cz(wires):
+            qml.H(wires[0])
+            qml.CNOT(wires=wires)
+            qml.H(wires[0])
+        """
+            ).strip()
+        )
+
+    def test_error_raised_with_no_resource_fn(self):
+        """Tests that an error is raised when no resource fn is provided."""
+
+        def multi_rz_decomposition(theta, wires, **__):
+            for w0, w1 in zip(wires[-1:0:-1], wires[-2::-1]):
+                qml.CNOT(wires=(w0, w1))
+            qml.RZ(theta, wires=wires[0])
+            for w0, w1 in zip(wires[1:], wires[:-1]):
+                qml.CNOT(wires=(w0, w1))
+
+        multi_rz_decomposition = qml.register_resources(None, multi_rz_decomposition)
+
+        with pytest.raises(NotImplementedError, match="No resource estimation found"):
+            multi_rz_decomposition.compute_resources(num_wires=3)
+
+    def test_decomposition_dictionary(self):
+        """Tests that decomposition rules can be registered for an operator."""
+
+        class CustomOp(qml.operation.Operation):  # pylint: disable=too-few-public-methods
+            pass
+
+        assert not qml.decomposition.has_decomp(CustomOp)
+
+        @qml.register_resources({qml.RZ: 2, qml.CNOT: 1})
+        def custom_decomp(theta, wires, **__):
+            qml.RZ(theta, wires=wires[0])
+            qml.CNOT(wires=[wires[0], wires[1]])
+            qml.RZ(theta, wires=wires[0])
+
+        @qml.register_resources({qml.RX: 2, qml.CZ: 1})
+        def custom_decomp2(theta, wires, **__):
+            qml.RX(theta, wires=wires[0])
+            qml.CZ(wires=[wires[0], wires[1]])
+            qml.RX(theta, wires=wires[0])
+
+        @qml.register_resources({qml.RY: 2, qml.CNOT: 1})
+        def custom_decomp3(theta, wires, **__):
+            qml.RY(theta, wires=wires[0])
+            qml.CNOT(wires=[wires[0], wires[1]])
+            qml.RY(theta, wires=wires[0])
+
+        qml.add_decomps(CustomOp, custom_decomp)
+        qml.add_decomps(CustomOp, custom_decomp2, custom_decomp3)
+
+        assert qml.decomposition.has_decomp(CustomOp)
+        assert qml.list_decomps(CustomOp) == [custom_decomp, custom_decomp2, custom_decomp3]
+
+        def custom_decomp4(theta, wires, **__):
+            qml.RZ(theta, wires=wires[0])
+            qml.CZ(wires=[wires[0], wires[1]])
+            qml.RZ(theta, wires=wires[0])
+
+        with pytest.raises(TypeError, match="decomposition rule must be a qfunc with a resource"):
+            qml.add_decomps(CustomOp, custom_decomp4)
+
+        _decompositions.pop(CustomOp)  # cleanup
+
+    def test_auto_wrap_in_resource_op(self):
+        """Tests that simply classes can be auto-wrapped in a ``CompressionResourceOp``."""
+
+        class DummyOp(qml.operation.Operator):  # pylint: disable=too-few-public-methods
+
+            resource_keys = set()
+
+        @qml.register_resources({DummyOp: 1})
+        def custom_decomp(*_, **__):
+            raise NotImplementedError
+
+        assert custom_decomp.compute_resources() == Resources(
+            gate_counts={CompressedResourceOp(DummyOp): 1}
+        )
+
+        def custom_decomp_2(*_, **__):
+            raise NotImplementedError
+
+        custom_decomp_2 = qml.register_resources(
+            {CompressedResourceOp(DummyOp): 1}, custom_decomp_2
+        )
+
+        assert custom_decomp_2.compute_resources() == Resources(
+            gate_counts={CompressedResourceOp(DummyOp): 1}
+        )
+
+    def test_auto_wrap_fails(self):
+        """Tests that an op with non-empty resource_keys cannot be auto-wrapped."""
+
+        class DummyOp(qml.operation.Operator):  # pylint: disable=too-few-public-methods
+
+            resource_keys = {"foo"}
+
+        @qml.register_resources({DummyOp: 1})
+        def custom_decomp(*_, **__):
+            raise NotImplementedError
+
+        with pytest.raises(TypeError, match="Operator DummyOp has non-empty resource_keys"):
+            custom_decomp.compute_resources()
+
+        def custom_decomp_2(*_, **__):
+            raise NotImplementedError
+
+        with pytest.raises(TypeError, match="must be a subclass of Operator"):
+            custom_decomp_2 = qml.register_resources({int: 1}, custom_decomp_2)
+            custom_decomp_2.compute_resources()
diff --git a/tests/decomposition/test_resources.py b/tests/decomposition/test_resources.py
new file mode 100644
index 000000000..07141e3bb
--- /dev/null
+++ b/tests/decomposition/test_resources.py
@@ -0,0 +1,404 @@
+# 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.
+
+"""Unit tests for the data structures used for resource estimation in the decomposition module."""
+
+import pytest
+
+import pennylane as qml
+from pennylane.decomposition.resources import (
+    CompressedResourceOp,
+    Resources,
+    controlled_resource_rep,
+    resource_rep,
+)
+
+
+class TestResources:
+    """Unit tests for the Resources data structure."""
+
+    def test_resource_initialize(self):
+        """Tests initializing a Resources object."""
+        resources = Resources()
+        assert resources.num_gates == 0
+        assert resources.gate_counts == {}
+
+    def test_negative_gate_counts(self):
+        """Tests that an error is raised if the gate count is negative."""
+        with pytest.raises(AssertionError):
+            Resources(
+                gate_counts={
+                    CompressedResourceOp(qml.RX, {}): 2,
+                    CompressedResourceOp(qml.RZ, {}): -1,
+                }
+            )
+
+    def test_add_resources(self):
+        """Tests adding two Resources objects."""
+
+        resources1 = Resources(
+            gate_counts={CompressedResourceOp(qml.RX, {}): 2, CompressedResourceOp(qml.RZ, {}): 1}
+        )
+        resources2 = Resources(
+            gate_counts={CompressedResourceOp(qml.RX, {}): 1, CompressedResourceOp(qml.RY, {}): 1}
+        )
+
+        resources = resources1 + resources2
+        assert resources.num_gates == 5
+        assert resources.gate_counts == {
+            CompressedResourceOp(qml.RX, {}): 3,
+            CompressedResourceOp(qml.RZ, {}): 1,
+            CompressedResourceOp(qml.RY, {}): 1,
+        }
+
+    def test_mul_resource_with_scalar(self):
+        """Tests multiplying a Resources object with a scalar."""
+
+        resources = Resources(
+            gate_counts={CompressedResourceOp(qml.RX, {}): 2, CompressedResourceOp(qml.RZ, {}): 1}
+        )
+
+        resources = resources * 2
+        assert resources.num_gates == 6
+        assert resources.gate_counts == {
+            CompressedResourceOp(qml.RX, {}): 4,
+            CompressedResourceOp(qml.RZ, {}): 2,
+        }
+
+    def test_repr(self):
+        """Tests the __repr__ of a Resources object."""
+
+        resources = Resources(
+            {CompressedResourceOp(qml.RX, {}): 2, CompressedResourceOp(qml.RZ, {}): 1}
+        )
+        assert repr(resources) == "<num_gates=3, gate_counts={RX: 2, RZ: 1}>"
+
+
+class DummyOp(qml.operation.Operator):  # pylint: disable=too-few-public-methods
+    resource_keys = {"foo", "bar"}
+
+
+class TestCompressedResourceOp:
+    """Unit tests for the CompressedResourceOp data structure."""
+
+    def test_initialization(self):
+        """Tests creating a CompressedResourceOp object."""
+
+        op = CompressedResourceOp(qml.QFT, {"num_wires": 5})
+        assert op.op_type is qml.QFT
+        assert op.params == {"num_wires": 5}
+
+        op = CompressedResourceOp(qml.RX)
+        assert op.op_type is qml.RX
+        assert op.params == {}
+
+    def test_invalid_op_type(self):
+        """Tests that an error is raised if the op is invalid."""
+
+        with pytest.raises(TypeError, match="op_type must be an Operator type"):
+            CompressedResourceOp("RX", {})
+
+        with pytest.raises(TypeError, match="op_type must be a subclass of Operator"):
+            CompressedResourceOp(int, {})
+
+    def test_hash(self):
+        """Tests that a CompressedResourceOp object is hashable."""
+
+        op = CompressedResourceOp(qml.RX, {})
+        assert isinstance(hash(op), int)
+
+        op = CompressedResourceOp(qml.QFT, {"num_wires": 5})
+        assert isinstance(hash(op), int)
+
+        op = CompressedResourceOp(
+            qml.ops.Controlled,
+            {
+                "base_class": qml.QFT,
+                "base_params": {"num_wires": 5},  # nested dictionary in params
+                "num_control_wires": 1,
+                "num_zero_control_values": 1,
+                "num_work_wires": 1,
+            },
+        )
+        assert isinstance(hash(op), int)
+
+    def test_empty_params_same_hash(self):
+        """Tests that CompressedResourceOp objects initialized with or without empty
+        parameters have the same hash."""
+        op1 = CompressedResourceOp(qml.RX)
+        op2 = CompressedResourceOp(qml.RX, {})
+        assert hash(op1) == hash(op2)
+
+    def test_different_params_different_hash(self):
+        """Tests that CompressedResourceOp objects initialized with different parameters
+        have different hashes."""
+        op1 = CompressedResourceOp(qml.MultiRZ, {"num_wires": 5})
+        op2 = CompressedResourceOp(qml.MultiRZ, {"num_wires": 6})
+        assert hash(op1) != hash(op2)
+
+    def test_equal(self):
+        """Tests comparing two CompressedResourceOp objects."""
+
+        op1 = CompressedResourceOp(qml.RX, {})
+        op2 = CompressedResourceOp(qml.RX, {})
+        assert op1 == op2
+
+        op1 = CompressedResourceOp(qml.RX, {})
+        op2 = CompressedResourceOp(qml.RZ, {})
+        assert op1 != op2
+
+        op1 = CompressedResourceOp(qml.MultiRZ, {"num_wires": 3})
+        op2 = CompressedResourceOp(qml.MultiRZ, {"num_wires": 3})
+        assert op1 == op2
+
+        op1 = CompressedResourceOp(qml.MultiRZ, {"num_wires": 5})
+        op2 = CompressedResourceOp(qml.MultiRZ, {"num_wires": 6})
+        assert op1 != op2
+
+    def test_repr(self):
+        """Tests the repr defined for debugging purposes."""
+
+        op = CompressedResourceOp(qml.RX, {})
+        assert repr(op) == "RX"
+
+        op = CompressedResourceOp(qml.MultiRZ, {"num_wires": 5})
+        assert repr(op) == "MultiRZ(num_wires=5)"
+
+        op = CompressedResourceOp(DummyOp, {"foo": 2, "bar": 1})
+        assert repr(op) == "DummyOp(foo=2, bar=1)"
+
+
+class TestResourceRep:
+    """Tests the resource_rep utility function."""
+
+    def test_resource_rep_fail(self):
+        """Tests that an error is raised if the op is invalid."""
+
+        with pytest.raises(TypeError, match="op_type must be a type of Operator"):
+            resource_rep(int)
+
+    def test_params_mismatch(self):
+        """Tests that an error is raised when parameters are missing."""
+
+        with pytest.raises(TypeError, match="Missing keyword arguments"):
+            resource_rep(DummyOp, foo=2)
+
+        with pytest.raises(TypeError, match="Unexpected keyword arguments"):
+            resource_rep(DummyOp, foo=2, bar=1, hello=3)
+
+    def test_resource_rep(self):
+        """Tests creating a resource rep."""
+
+        assert resource_rep(DummyOp, foo=2, bar=1) == CompressedResourceOp(
+            DummyOp, {"foo": 2, "bar": 1}
+        )
+
+
+class TestControlledResourceRep:
+    """Tests the controlled_resource_rep function."""
+
+    def test_controlled_resource_rep(self):
+        """Tests creating the resource rep of a general controlled operation."""
+
+        rep = controlled_resource_rep(DummyOp, {"foo": 2, "bar": 1}, 2, 1, 1)
+        assert rep == CompressedResourceOp(
+            qml.ops.Controlled,
+            {
+                "base_class": DummyOp,
+                "base_params": {"foo": 2, "bar": 1},
+                "num_control_wires": 2,
+                "num_zero_control_values": 1,
+                "num_work_wires": 1,
+            },
+        )
+
+    def test_controlled_resource_rep_flatten(self):
+        """Tests that nested controlled ops are flattened."""
+
+        rep = controlled_resource_rep(
+            qml.ops.Controlled,
+            {
+                "base_class": qml.CRX,
+                "base_params": {},
+                "num_control_wires": 2,
+                "num_zero_control_values": 1,
+                "num_work_wires": 1,
+            },
+            1,
+            1,
+            1,
+        )
+        assert rep == CompressedResourceOp(
+            qml.ops.Controlled,
+            {
+                "base_class": qml.RX,
+                "base_params": {},
+                "num_control_wires": 4,
+                "num_zero_control_values": 2,
+                "num_work_wires": 2,
+            },
+        )
+
+    def test_resource_rep_dispatch_to_controlled_resource_rep(self, mocker):
+        """Tests that resource_rep dispatches to controlled_resource_rep for Controlled."""
+
+        expected_fn = mocker.patch("pennylane.decomposition.resources.controlled_resource_rep")
+        _ = resource_rep(
+            qml.ops.Controlled,
+            **{
+                "base_class": qml.CRX,
+                "base_params": {},
+                "num_control_wires": 2,
+                "num_zero_control_values": 1,
+                "num_work_wires": 1,
+            },
+        )
+        expected_fn.assert_called_once_with(
+            base_class=qml.CRX,
+            base_params={},
+            num_control_wires=2,
+            num_zero_control_values=1,
+            num_work_wires=1,
+        )
+
+    def test_controlled_resource_op_base_param_mismatch(self):
+        """Tests that an error is raised when base op and base params mismatch."""
+
+        with pytest.raises(TypeError, match="Missing keyword arguments"):
+            controlled_resource_rep(DummyOp, {}, 1, 1, 1)
+
+    def test_controlled_resource_op_flatten_x(self):
+        """Tests that nested X-based controlled ops are flattened."""
+
+        rep = controlled_resource_rep(
+            qml.ops.Controlled,
+            {
+                "base_class": qml.MultiControlledX,
+                "base_params": {
+                    "num_control_wires": 2,
+                    "num_zero_control_values": 1,
+                    "num_work_wires": 1,
+                },
+                "num_control_wires": 1,
+                "num_zero_control_values": 1,
+                "num_work_wires": 1,
+            },
+            1,
+            1,
+            1,
+        )
+        assert rep == CompressedResourceOp(
+            qml.ops.Controlled,
+            {
+                "base_class": qml.X,
+                "base_params": {},
+                "num_control_wires": 4,
+                "num_zero_control_values": 3,
+                "num_work_wires": 3,
+            },
+        )
+
+    def test_nested_controlled_qubit_unitary(self):
+        """Tests that a nested controlled qubit unitary is flattened."""
+
+        U = qml.math.eye(2)
+
+        rep = controlled_resource_rep(
+            qml.ops.Controlled,
+            {
+                "base_class": qml.ControlledQubitUnitary,
+                "base_params": {
+                    "num_control_wires": 2,
+                    "num_zero_control_values": 1,
+                    "num_work_wires": 1,
+                    "base": qml.QubitUnitary(U, wires=[0]),
+                },
+                "num_control_wires": 1,
+                "num_zero_control_values": 1,
+                "num_work_wires": 1,
+            },
+            1,
+            1,
+            1,
+        )
+        assert rep == CompressedResourceOp(
+            qml.ops.Controlled,
+            {
+                "base_class": qml.QubitUnitary,
+                "base_params": {"num_wires": 1},
+                "num_control_wires": 4,
+                "num_zero_control_values": 3,
+                "num_work_wires": 3,
+            },
+        )
+
+
+class TestSymbolicResourceRep:
+    """Tests resource reps of symbolic operators"""
+
+    def test_adjoint_resource_rep(self):
+        """Tests creating the resource rep of the adjoint of an operator."""
+
+        rep = qml.decomposition.adjoint_resource_rep(DummyOp, {"foo": 2, "bar": 1})
+        assert rep == CompressedResourceOp(
+            qml.ops.Adjoint, {"base_class": DummyOp, "base_params": {"foo": 2, "bar": 1}}
+        )
+
+    def test_resource_rep_dispatch_to_adjoint_resource_rep(self, mocker):
+        """Tests that resource_rep dispatches to adjoint_resource_rep for Adjoint."""
+
+        expected_fn = mocker.patch("pennylane.decomposition.resources.adjoint_resource_rep")
+        _ = resource_rep(
+            qml.ops.Adjoint, **{"base_class": DummyOp, "base_params": {"foo": 2, "bar": 1}}
+        )
+        assert expected_fn.called
+
+    def test_adjoint_resource_rep_base_param_mismatch(self):
+        """Tests that an error is raised when base op and base params mismatch."""
+
+        with pytest.raises(TypeError, match="Missing keyword arguments"):
+            qml.decomposition.adjoint_resource_rep(DummyOp, {})
+
+    def test_adjoint_resource_rep_flattens_inner_nested_controlled_op(self):
+        """Tests that the adjoint of a nested controlled op is flattened."""
+
+        rep = qml.decomposition.adjoint_resource_rep(
+            qml.ops.Adjoint,
+            {
+                "base_class": qml.ops.Controlled,
+                "base_params": {
+                    "base_class": qml.CRX,
+                    "base_params": {},
+                    "num_control_wires": 2,
+                    "num_zero_control_values": 1,
+                    "num_work_wires": 1,
+                },
+            },
+        )
+        assert rep == CompressedResourceOp(
+            qml.ops.Adjoint,
+            {
+                "base_class": qml.ops.Adjoint,
+                "base_params": {
+                    "base_class": qml.ops.Controlled,
+                    "base_params": {
+                        "base_class": qml.RX,
+                        "base_params": {},
+                        "num_control_wires": 3,
+                        "num_zero_control_values": 1,
+                        "num_work_wires": 1,
+                    },
+                },
+            },
+        )
diff --git a/tests/ops/op_math/test_composite.py b/tests/ops/op_math/test_composite.py
index 055e440a7..eeadf019c 100644
--- a/tests/ops/op_math/test_composite.py
+++ b/tests/ops/op_math/test_composite.py
@@ -251,7 +251,7 @@ def _assert_method_no_recursion_error(instance, method_name):
         getattr(instance, method_name)()
     except Exception as e:  # pylint: disable=broad-except
         assert not isinstance(e, RecursionError)
-        if isinstance(e, RuntimeError):
+        if isinstance(e, RuntimeError) and not isinstance(e, NotImplementedError):
             assert "This is likely due to nesting too many levels" in str(e)
 
 
@@ -261,7 +261,7 @@ def _assert_property_no_recursion_error(instance, property_name):
         getattr(instance, property_name)
     except Exception as e:  # pylint: disable=broad-except
         assert not isinstance(e, RecursionError)
-        if isinstance(e, RuntimeError):
+        if isinstance(e, RuntimeError) and not isinstance(e, NotImplementedError):
             assert "This is likely due to nesting too many levels" in str(e)
 
 
diff --git a/tests/ops/op_math/test_controlled.py b/tests/ops/op_math/test_controlled.py
index c8d7430a7..c31df9693 100644
--- a/tests/ops/op_math/test_controlled.py
+++ b/tests/ops/op_math/test_controlled.py
@@ -203,6 +203,23 @@ class TestControlledInit:
 class TestControlledProperties:
     """Test the properties of the ``Controlled`` symbolic operator."""
 
+    def test_resource_params(self):
+        """Tests that a controlled op has the correct resource params."""
+
+        op = Controlled(
+            qml.MultiRZ(0.5, wires=[0, 1, 2]),
+            control_wires=[3, 4],
+            control_values=[True, False],
+            work_wires=[5],
+        )
+        assert op.resource_params == {
+            "base_class": qml.MultiRZ,
+            "base_params": {"num_wires": 3},
+            "num_control_wires": 2,
+            "num_zero_control_values": 1,
+            "num_work_wires": 1,
+        }
+
     def test_data(self):
         """Test that the base data can be get and set through Controlled class."""
 
diff --git a/tests/ops/qubit/test_parametric_ops.py b/tests/ops/qubit/test_parametric_ops.py
index b06feb7c7..31966a026 100644
--- a/tests/ops/qubit/test_parametric_ops.py
+++ b/tests/ops/qubit/test_parametric_ops.py
@@ -3179,6 +3179,12 @@ class TestMultiRZ:
         assert decomp_ops[4].name == "CNOT"
         assert decomp_ops[4].wires == Wires([3, 2])
 
+    def test_MultiRZ_assert_valid(self):
+        """Tests that MultiRZ is valid."""
+
+        op = qml.MultiRZ(0.123, wires=[0, 1, 2, 3])
+        qml.ops.functions.assert_valid(op)
+
     @pytest.mark.parametrize("angle", npp.linspace(0, 2 * np.pi, 7, requires_grad=True))
     def test_differentiability(self, angle, tol):
         """Test that differentiation of MultiRZ works."""