Repository: PennyLaneAI/pennylane
Pull request #7058: [Decomposition] Decomposition graph supports adjoint operations
Author: login: astralcai
Labels: name: urgent
description: Mark a pull request as high priority
Created: 2025-03-07T16:19:30Z | Merged: 2025-03-21T19:04:20Z

**Context:**

**Description of the Change:**

**Benefits:**

**Possible Drawbacks:**

**Related GitHub Issues:**

[sc-84336]


Diff:
diff --git a/doc/releases/changelog-dev.md b/doc/releases/changelog-dev.md
index f14b2587f6a..9c9ed46705a 100644
--- a/doc/releases/changelog-dev.md
+++ b/doc/releases/changelog-dev.md
@@ -180,6 +180,8 @@
 * A graph-based decomposition solver has been implemented that solves for the optimal decomposition
   rule to use for an operator towards a target gate set.
   [(#6952)](https://github.com/PennyLaneAI/pennylane/pull/6952)
+  [(#7045)](https://github.com/PennyLaneAI/pennylane/pull/7045)
+  [(#7058)](https://github.com/PennyLaneAI/pennylane/pull/7058)
 
 <h3>Improvements 🛠</h3>
   
diff --git a/pennylane/decomposition/controlled_decomposition.py b/pennylane/decomposition/controlled_decomposition.py
index 421b62bd5b5..1d7a2b4ecc9 100644
--- a/pennylane/decomposition/controlled_decomposition.py
+++ b/pennylane/decomposition/controlled_decomposition.py
@@ -231,7 +231,12 @@ def controlled_x_decomp(*_, wires, control_wires, control_values, work_wires, **
 
 @functools.lru_cache(maxsize=1)
 def base_to_custom_ctrl_op():
-    """A dictionary mapping base op types to their custom controlled versions."""
+    """A dictionary mapping base op types to their custom controlled versions.
+
+    This dictionary is used under the assumption that all custom controlled operations do not
+    have resource params (which is why `ControlledQubitUnitary` is not included here).
+
+    """
 
     ops_with_custom_ctrl_ops = {
         (qml.PauliZ, 1): qml.CZ,
diff --git a/pennylane/decomposition/decomposition_graph.py b/pennylane/decomposition/decomposition_graph.py
index 66fce6fe81b..0c7ab8a80bd 100644
--- a/pennylane/decomposition/decomposition_graph.py
+++ b/pennylane/decomposition/decomposition_graph.py
@@ -42,6 +42,13 @@
 )
 from .decomposition_rule import DecompositionRule, list_decomps
 from .resources import CompressedResourceOp, Resources, resource_rep
+from .symbolic_decomposition import (
+    AdjointDecomp,
+    adjoint_adjoint_decomp,
+    adjoint_controlled_decomp,
+    same_type_adjoint_decomp,
+    same_type_adjoint_ops,
+)
 
 
 class DecompositionError(Exception):
@@ -177,6 +184,9 @@ def _recursively_add_op_node(self, op_node: CompressedResourceOp) -> int:
             # This branch only applies to general controlled operators
             return self._add_controlled_decomp_node(op_node, op_node_idx)
 
+        if issubclass(op_node.op_type, qml.ops.Adjoint):
+            return self._add_adjoint_decomp_node(op_node, op_node_idx)
+
         for decomposition in self._get_decompositions(op_node.op_type):
             decomp_resource = decomposition.compute_resources(**op_node.params)
             d_node_idx = self._recursively_add_decomposition_node(decomposition, decomp_resource)
@@ -192,6 +202,35 @@ def _add_special_decomp_rule_to_op(
         d_node_idx = self._recursively_add_decomposition_node(rule, decomp_resource)
         self._graph.add_edge(d_node_idx, op_node_idx, 0)
 
+    def _add_adjoint_decomp_node(self, op_node: CompressedResourceOp, op_node_idx: int) -> int:
+        """Adds an adjoint decomposition node."""
+
+        base_class, base_params = op_node.params["base_class"], op_node.params["base_params"]
+
+        if issubclass(base_class, qml.ops.Adjoint):
+            rule = adjoint_adjoint_decomp
+            self._add_special_decomp_rule_to_op(rule, op_node, op_node_idx)
+            return op_node_idx
+
+        if base_class in same_type_adjoint_ops():
+            rule = same_type_adjoint_decomp
+            self._add_special_decomp_rule_to_op(rule, op_node, op_node_idx)
+            return op_node_idx
+
+        if (
+            issubclass(base_class, qml.ops.Controlled)
+            and base_params["base_class"] in same_type_adjoint_ops()
+        ):
+            rule = adjoint_controlled_decomp
+            self._add_special_decomp_rule_to_op(rule, op_node, op_node_idx)
+            return op_node_idx
+
+        for base_decomposition in self._get_decompositions(base_class):
+            rule = AdjointDecomp(base_decomposition)
+            self._add_special_decomp_rule_to_op(rule, op_node, op_node_idx)
+
+        return op_node_idx
+
     def _add_controlled_decomp_node(self, op_node: CompressedResourceOp, op_node_idx: int) -> int:
         """Adds a controlled decomposition node to the graph."""
 
diff --git a/pennylane/decomposition/resources.py b/pennylane/decomposition/resources.py
index bef76ac5b2a..c9b04fc1af5 100644
--- a/pennylane/decomposition/resources.py
+++ b/pennylane/decomposition/resources.py
@@ -323,7 +323,7 @@ def controlled_resource_rep(
     )
 
 
-def adjoint_resource_rep(base_class: Type[Operator], base_params: dict):
+def adjoint_resource_rep(base_class: Type[Operator], base_params: dict = None):
     """Creates a ``CompressedResourceOp`` representation of the adjoint of an operator.
 
     Args:
@@ -331,6 +331,7 @@ def adjoint_resource_rep(base_class: Type[Operator], base_params: dict):
         base_params (dict): the resource params of the base operator
 
     """
+    base_params = base_params or {}
     base_resource_rep = resource_rep(base_class, **base_params)  # flattens any nested structures
     return CompressedResourceOp(
         qml.ops.Adjoint,
diff --git a/pennylane/decomposition/symbolic_decomposition.py b/pennylane/decomposition/symbolic_decomposition.py
new file mode 100644
index 00000000000..719ca3a1808
--- /dev/null
+++ b/pennylane/decomposition/symbolic_decomposition.py
@@ -0,0 +1,207 @@
+# 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.
+
+"""This module contains special logic of decomposing symbolic operations."""
+
+from __future__ import annotations
+
+import functools
+
+import pennylane as qml
+
+from .controlled_decomposition import base_to_custom_ctrl_op
+from .decomposition_rule import DecompositionRule, register_resources
+from .resources import adjoint_resource_rep, resource_rep
+
+
+class AdjointDecomp(DecompositionRule):  # pylint: disable=too-few-public-methods
+    """The adjoint version of a decomposition rule."""
+
+    def __init__(self, base_decomposition: DecompositionRule):
+        self._base_decomposition = base_decomposition
+        super().__init__(self._get_impl(), self._get_resource_fn())
+
+    def _get_impl(self):
+        """The implementation of the adjoint of a gate."""
+
+        def _impl(*_, base, **__):
+            qml.adjoint(self._base_decomposition._impl)(  # pylint: disable=protected-access
+                *base.parameters, base.wires, **base.hyperparameters
+            )
+
+        return _impl
+
+    def _get_resource_fn(self):
+        """The resource function of the adjoint of a gate."""
+
+        def _resource_fn(base_class, base_params):  # pylint: disable=unused-argument
+            base_resources = self._base_decomposition.compute_resources(**base_params)
+            return {
+                adjoint_resource_rep(decomp_op.op_type, decomp_op.params): count
+                for decomp_op, count in base_resources.gate_counts.items()
+            }
+
+        return _resource_fn
+
+
+def _same_type_adjoint_resource(base_class, base_params):
+    """Resources of the adjoint of a gate whose adjoint is an instance of its own type."""
+    # This assumes that the adjoint of the gate has the same resources as the gate itself.
+    return {resource_rep(base_class, **base_params): 1}
+
+
+@register_resources(_same_type_adjoint_resource)
+def same_type_adjoint_decomp(*_, base, **__):
+    """Decompose the adjoint of a gate whose adjoint is an instance of its own type."""
+    base.adjoint()
+
+
+def _adjoint_adjoint_resource(*_, base_params, **__):
+    """Resources of the adjoint of the adjoint of a gate."""
+    # The base of a nested adjoint is an adjoint, so the base of the base is
+    # the original gate, and the "base_params" of base_params is the parameters
+    # of the original gate.
+    base_class, base_params = base_params["base_class"], base_params["base_params"]
+    return {resource_rep(base_class, **base_params): 1}
+
+
+@register_resources(_adjoint_adjoint_resource)
+def adjoint_adjoint_decomp(*_, wires, base):  # pylint: disable=unused-argument
+    """Decompose the adjoint of the adjoint of a gate."""
+    base.base._unflatten(*base.base._flatten())  # pylint: disable=protected-access
+
+
+def _adjoint_controlled_resource(base_class, base_params):
+    """Resources of the adjoint of a controlled gate whose base has adjoint."""
+
+    num_control_wires = base_params["num_control_wires"]
+    controlled_base_class = base_params["base_class"]
+
+    # Handle controlled-X gates, the adjoint is just themselves
+    if controlled_base_class is qml.X:
+        if num_control_wires == 1:
+            return {resource_rep(qml.CNOT): 1}
+        if num_control_wires == 2:
+            return {resource_rep(qml.Toffoli): 1}
+        return {
+            resource_rep(
+                qml.MultiControlledX,
+                num_control_wires=num_control_wires,
+                num_zero_control_values=base_params["num_zero_control_values"],
+                num_work_wires=base_params["num_work_wires"],
+            ): 1
+        }
+
+    # Handle custom controlled gates. The adjoint of a general controlled operator that
+    # is equivalent to a custom controlled operator should just be the custom controlled
+    # operator given that its base has_adjoint.
+    custom_op_type = base_to_custom_ctrl_op().get((controlled_base_class, num_control_wires))
+    if custom_op_type is not None:
+        # All gates in base_to_custom_ctrl_op do not have resource params.
+        return {resource_rep(custom_op_type): 1}
+
+    # Handle the general case, here we assume that the adjoint of a controlled gate
+    # whose base has an adjoint that is of its own type, should have the same resource
+    # rep as the controlled gate itself. For example, Adjoint(Controlled(O)) should
+    # have the same resources as Controlled(O) if the adjoint of O is another O.
+    return {resource_rep(base_class, **base_params): 1}
+
+
+@register_resources(_adjoint_controlled_resource)
+def adjoint_controlled_decomp(*_, base, **__):
+    """Decompose the adjoint of a controlled gate whose base has adjoint.
+
+    Precondition:
+    - isinstance(base, qml.ops.Controlled) and base.base.has_adjoint
+
+    """
+    qml.ctrl(
+        base.base.adjoint(),
+        control=base.control_wires,
+        control_values=base.control_values,
+        work_wires=base.work_wires,
+    )
+
+
+@functools.lru_cache(maxsize=1)
+def same_type_adjoint_ops():
+    """A set of operators whose adjoint is an instance of its own type."""
+    return frozenset(
+        {
+            # identity
+            qml.Identity,
+            qml.GlobalPhase,
+            # non-parametric gates
+            qml.H,
+            qml.X,
+            qml.Y,
+            qml.Z,
+            qml.SWAP,
+            qml.ECR,
+            # single-qubit parametric gates
+            qml.Rot,
+            qml.U1,
+            qml.U2,
+            qml.U3,
+            qml.RX,
+            qml.RY,
+            qml.RZ,
+            qml.PhaseShift,
+            # multi-qubit parametric gates
+            qml.MultiRZ,
+            qml.PauliRot,
+            qml.PCPhase,
+            qml.IsingXX,
+            qml.IsingYY,
+            qml.IsingZZ,
+            qml.IsingXY,
+            qml.PSWAP,
+            qml.CPhaseShift00,
+            qml.CPhaseShift01,
+            qml.CPhaseShift10,
+            # matrix gates
+            qml.QubitUnitary,
+            qml.DiagonalQubitUnitary,
+            qml.BlockEncode,
+            qml.SpecialUnitary,
+            # custom controlled ops
+            qml.CH,
+            qml.CY,
+            qml.CZ,
+            qml.CNOT,
+            qml.CSWAP,
+            qml.CCZ,
+            qml.Toffoli,
+            qml.MultiControlledX,
+            qml.CRX,
+            qml.CRY,
+            qml.CRZ,
+            qml.CRot,
+            qml.ControlledPhaseShift,
+            # arithmetic ops
+            qml.QubitSum,
+            qml.IntegerComparator,
+            # qchem ops
+            qml.SingleExcitation,
+            qml.SingleExcitationMinus,
+            qml.SingleExcitationPlus,
+            qml.DoubleExcitation,
+            qml.DoubleExcitationPlus,
+            qml.DoubleExcitationMinus,
+            qml.OrbitalRotation,
+            qml.FermionicSWAP,
+            # templates
+            qml.CommutingEvolution,
+        }
+    )
diff --git a/pennylane/ops/op_math/controlled_ops.py b/pennylane/ops/op_math/controlled_ops.py
index 96cae85120e..5d50f0ee6b3 100644
--- a/pennylane/ops/op_math/controlled_ops.py
+++ b/pennylane/ops/op_math/controlled_ops.py
@@ -328,6 +328,9 @@ def __init__(self, wires, id=None):
     def __repr__(self):
         return f"CH(wires={self.wires.tolist()})"
 
+    def adjoint(self):
+        return CH(self.wires)
+
     @property
     def resource_params(self) -> dict:
         return {}
@@ -467,6 +470,9 @@ def __repr__(self):
     def resource_params(self) -> dict:
         return {}
 
+    def adjoint(self):
+        return CY(self.wires)
+
     @staticmethod
     @lru_cache()
     def compute_matrix():  # pylint: disable=arguments-differ
@@ -595,6 +601,9 @@ def __repr__(self):
     def resource_params(self) -> dict:
         return {}
 
+    def adjoint(self):
+        return CZ(self.wires)
+
     @staticmethod
     @lru_cache()
     def compute_matrix():  # pylint: disable=arguments-differ
@@ -715,6 +724,9 @@ def __repr__(self):
     def resource_params(self) -> dict:
         return {}
 
+    def adjoint(self):
+        return CSWAP(self.wires)
+
     @staticmethod
     @lru_cache()
     def compute_matrix():  # pylint: disable=arguments-differ
@@ -863,6 +875,9 @@ def __repr__(self):
     def resource_params(self) -> dict:
         return {}
 
+    def adjoint(self):
+        return CCZ(self.wires)
+
     @staticmethod
     @lru_cache()
     def compute_matrix():  # pylint: disable=arguments-differ
@@ -1041,6 +1056,9 @@ def __init__(self, wires, id=None):
         base = type.__call__(qml.X, wires=wires[1:])
         super().__init__(base, wires[:1], id=id)
 
+    def adjoint(self):
+        return CNOT(self.wires)
+
     @property
     def has_decomposition(self):
         return False
@@ -1182,6 +1200,9 @@ def __repr__(self):
     def resource_params(self) -> dict:
         return {}
 
+    def adjoint(self):
+        return Toffoli(self.wires)
+
     @staticmethod
     @lru_cache()
     def compute_matrix():  # pylint: disable=arguments-differ
@@ -1464,6 +1485,11 @@ def resource_params(self) -> dict:
             "num_work_wires": len(self.work_wires),
         }
 
+    def adjoint(self):
+        return MultiControlledX(
+            wires=self.wires, control_values=self.control_values, work_wires=self.work_wires
+        )
+
     # pylint: disable=unused-argument, arguments-differ
     @staticmethod
     def compute_matrix(control_wires: WiresLike, control_values=None, **kwargs):
@@ -1640,6 +1666,9 @@ def _primitive_bind_call(cls, phi, wires: WiresLike, id=None):
     def resource_params(self) -> dict:
         return {}
 
+    def adjoint(self):
+        return CRX(-self.data[0], wires=self.wires)
+
     @staticmethod
     def compute_matrix(theta):  # pylint: disable=arguments-differ
         r"""Representation of the operator as a canonical matrix in the computational basis (static method).
@@ -1844,6 +1873,9 @@ def _primitive_bind_call(cls, phi, wires, id=None):
     def resource_params(self) -> dict:
         return {}
 
+    def adjoint(self):
+        return CRY(-self.data[0], wires=self.wires)
+
     @staticmethod
     def compute_matrix(theta):  # pylint: disable=arguments-differ
         r"""Representation of the operator as a canonical matrix in the computational basis (static method).
@@ -2023,6 +2055,9 @@ def _primitive_bind_call(cls, phi, wires, id=None):
     def resource_params(self) -> dict:
         return {}
 
+    def adjoint(self):
+        return CRZ(-self.data[0], wires=self.wires)
+
     @staticmethod
     def compute_matrix(theta):  # pylint: disable=arguments-differ
         r"""Representation of the operator as a canonical matrix in the computational basis (static method).
@@ -2240,6 +2275,10 @@ def _primitive_bind_call(
     def resource_params(self) -> dict:
         return {}
 
+    def adjoint(self):
+        phi, theta, omega = self.parameters
+        return CRot(-omega, -theta, -phi, wires=self.wires)
+
     @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).
@@ -2433,6 +2472,9 @@ def _primitive_bind_call(cls, phi, wires, id=None):
     def resource_params(self) -> dict:
         return {}
 
+    def adjoint(self):
+        return ControlledPhaseShift(-self.data[0], wires=self.wires)
+
     @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/tests/decomposition/conftest.py b/tests/decomposition/conftest.py
index 3a9df267563..ce2f764cee5 100644
--- a/tests/decomposition/conftest.py
+++ b/tests/decomposition/conftest.py
@@ -109,3 +109,19 @@ def _x_to_rx(*_, **__):
 
 
 decompositions[qml.X] = [_x_to_rx]
+
+
+@qml.register_resources({qml.PhaseShift: 1})
+def _u1_ps(phi, wires, **__):
+    qml.PhaseShift(phi, wires=wires)
+
+
+decompositions[qml.U1] = [_u1_ps]
+
+
+@qml.register_resources({qml.PhaseShift: 1})
+def _t_ps(wires, **__):
+    raise NotImplementedError
+
+
+decompositions[qml.T] = [_t_ps]
diff --git a/tests/decomposition/test_decomposition_graph.py b/tests/decomposition/test_decomposition_graph.py
index b3d46e8f938..9aba089d9cf 100644
--- a/tests/decomposition/test_decomposition_graph.py
+++ b/tests/decomposition/test_decomposition_graph.py
@@ -341,3 +341,121 @@ def custom_controlled_decomp(wires):
         assert graph.decomposition(qml.ctrl(qml.GlobalPhase(0.5), control=[1]))
         assert graph.decomposition(qml.ctrl(qml.GlobalPhase(0.5), control=[1, 2]))
         assert graph.decomposition(qml.ctrl(CustomOp(wires=[1]), control=[0, 2]))
+
+
+@patch(
+    "pennylane.decomposition.decomposition_graph.list_decomps",
+    side_effect=lambda x: decompositions[x],
+)
+class TestSymbolicDecompositions:
+    """Tests decompositions of symbolic ops."""
+
+    def test_adjoint_adjoint(self, _):
+        """Tests that a nested adjoint operator is flattened properly."""
+
+        op = qml.adjoint(qml.adjoint(qml.RX(0.5, wires=[0])))
+
+        graph = DecompositionGraph(operations=[op], target_gate_set={"RX"})
+        # 2 operator nodes (Adjoint(Adjoint(RX)) and RX), and 1 decomposition node.
+        assert len(graph._graph.nodes()) == 3
+        assert len(graph._graph.edges()) == 2
+
+        graph.solve()
+        with qml.queuing.AnnotatedQueue() as q:
+            graph.decomposition(op)(*op.parameters, wires=op.wires, **op.hyperparameters)
+
+        assert q.queue == [qml.RX(0.5, wires=[0])]
+        assert graph.resource_estimate(op) == to_resources({qml.RX: 1})
+
+    def test_custom_adjoint(self, _):
+        """Tests adjoint of an operator that defines its own adjoint."""
+
+        op = qml.adjoint(qml.RX(0.5, wires=[0]))
+
+        graph = DecompositionGraph(operations=[op], target_gate_set={"RX"})
+        # 2 operator nodes (Adjoint(RX) and RX), and 1 decomposition node.
+        assert len(graph._graph.nodes()) == 3
+        assert len(graph._graph.edges()) == 2
+
+        graph.solve()
+        with qml.queuing.AnnotatedQueue() as q:
+            graph.decomposition(op)(*op.parameters, wires=op.wires, **op.hyperparameters)
+
+        assert q.queue == [qml.RX(-0.5, wires=[0])]
+        assert graph.resource_estimate(op) == to_resources({qml.RX: 1})
+
+    def test_controlled_adjoint(self, _):
+        """Tests that the adjoint of a controlled operator is decomposed properly."""
+
+        op = qml.adjoint(qml.ops.Controlled(qml.RX(0.5, wires=[0]), control_wires=1))
+        op2 = qml.adjoint(qml.ctrl(qml.U1(0.5, wires=0), control=[1]))
+
+        graph = DecompositionGraph(
+            operations=[op, op2], target_gate_set={"ControlledPhaseShift", "CRX"}
+        )
+        # 5 operator nodes: Adjoint(C(RX)), Adjoint(C(U1)), CRX, C(U1), ControlledPhaseShift
+        # 3 decomposition nodes leading into Adjoint(C(RX)), Adjoint(C(U1)), and C(U1)
+        assert len(graph._graph.nodes()) == 8
+        # 3 edges from decompositions to ops and 3 edges from ops to decompositions
+        assert len(graph._graph.edges()) == 6
+
+        graph.solve()
+        with qml.queuing.AnnotatedQueue() as q:
+            graph.decomposition(op)(*op.parameters, wires=op.wires, **op.hyperparameters)
+            graph.decomposition(op2)(*op2.parameters, wires=op2.wires, **op2.hyperparameters)
+
+        assert q.queue == [qml.CRX(-0.5, wires=[1, 0]), qml.ctrl(qml.U1(-0.5, wires=0), control=1)]
+
+        op3 = qml.ctrl(qml.U1(-0.5, wires=0), control=1)
+        with qml.queuing.AnnotatedQueue() as q:
+            graph.decomposition(op3)(*op3.parameters, wires=op3.wires, **op3.hyperparameters)
+
+        assert q.queue == [qml.ControlledPhaseShift(-0.5, wires=[1, 0])]
+        assert graph.resource_estimate(op2) == to_resources({qml.ControlledPhaseShift: 1})
+
+    def test_adjoint_general(self, _):
+        """Tests decomposition of a generalized adjoint operation."""
+
+        class CustomOp(qml.operation.Operation):  # pylint: disable=too-few-public-methods
+            """A custom operation."""
+
+            resource_keys = set()
+
+            @property
+            def resource_params(self):
+                return {}
+
+        @qml.register_resources({qml.H: 1, qml.CNOT: 2, qml.RX: 1, qml.T: 1})
+        def custom_decomp(phi, wires):
+            qml.H(wires[0])
+            qml.CNOT(wires=wires[:2])
+            qml.RX(phi, wires=wires[1])
+            qml.CNOT(wires=wires[1:3])
+            qml.T(wires[2])
+
+        op = qml.adjoint(CustomOp(0.5, wires=[0, 1, 2]))
+        graph = DecompositionGraph(
+            operations=[op],
+            target_gate_set={"Hadamard", "CNOT", "RX", "PhaseShift"},
+            alt_decomps={CustomOp: [custom_decomp]},
+        )
+        # 10 operator nodes: A(CustomOp), A(H), A(CNOT), A(RX), A(T), H, CNOT, RX, A(PhaseShift), PhaseShift
+        # 6 decomposition nodes for: A(CustomOp), A(H), A(CNOT), A(RX), A(T), A(PhaseShift)
+        assert len(graph._graph.nodes()) == 16
+        # 9 edges from ops to decompositions and 6 edges from decompositions to ops.
+        assert len(graph._graph.edges()) == 15
+
+        graph.solve()
+        with qml.queuing.AnnotatedQueue() as q:
+            graph.decomposition(op)(*op.parameters, wires=op.wires, **op.hyperparameters)
+
+        assert q.queue == [
+            qml.adjoint(qml.T(2)),
+            qml.adjoint(qml.CNOT(wires=[1, 2])),
+            qml.adjoint(qml.RX(0.5, wires=1)),
+            qml.adjoint(qml.CNOT(wires=[0, 1])),
+            qml.adjoint(qml.H(wires=0)),
+        ]
+        assert graph.resource_estimate(op) == to_resources(
+            {qml.H: 1, qml.CNOT: 2, qml.RX: 1, qml.PhaseShift: 1}
+        )
diff --git a/tests/decomposition/test_symbolic_decomposition.py b/tests/decomposition/test_symbolic_decomposition.py
new file mode 100644
index 00000000000..78c9ae913da
--- /dev/null
+++ b/tests/decomposition/test_symbolic_decomposition.py
@@ -0,0 +1,184 @@
+# Copyright 2025 Xanadu Quantum Technologies Inc.
+
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+
+#     http://www.apache.org/licenses/LICENSE-2.0
+
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Tests the decomposition rules defined for symbolic operations other than controlled."""
+
+import pytest
+
+import pennylane as qml
+from pennylane.decomposition.resources import Resources
+from pennylane.decomposition.symbolic_decomposition import (
+    AdjointDecomp,
+    adjoint_adjoint_decomp,
+    adjoint_controlled_decomp,
+    same_type_adjoint_decomp,
+)
+from tests.decomposition.conftest import to_resources
+
+
+class TestAdjointDecompositionRules:
+    """Tests the decomposition rules defined for the adjoint of operations."""
+
+    def test_adjoint_adjoint(self):
+        """Tests that the adjoint of an adjoint cancels out."""
+
+        op = qml.adjoint(qml.adjoint(qml.RX(0.5, wires=0)))
+
+        with qml.queuing.AnnotatedQueue() as q:
+            adjoint_adjoint_decomp(*op.parameters, wires=op.wires, **op.hyperparameters)
+
+        assert q.queue == [qml.RX(0.5, wires=0)]
+        assert adjoint_adjoint_decomp.compute_resources(**op.resource_params) == to_resources(
+            {qml.RX: 1}
+        )
+
+    @pytest.mark.jax
+    def test_adjoint_adjoint_capture(self):
+        """Tests that the adjoint of an adjoint works with capture."""
+
+        from pennylane.tape.plxpr_conversion import CollectOpsandMeas
+
+        op = qml.adjoint(qml.adjoint(qml.RX(0.5, wires=0)))
+
+        capture_enabled = qml.capture.enabled()
+        qml.capture.enable()
+
+        def circuit():
+            adjoint_adjoint_decomp(*op.parameters, wires=op.wires, **op.hyperparameters)
+
+        plxpr = qml.capture.make_plxpr(circuit)()
+        collector = CollectOpsandMeas()
+        collector.eval(plxpr.jaxpr, plxpr.consts)
+        assert collector.state["ops"] == [qml.RX(0.5, wires=0)]
+
+        if not capture_enabled:
+            qml.capture.disable()
+
+    def test_adjoint_controlled(self):
+        """Tests that the adjoint of a controlled operation is correctly decomposed."""
+
+        op1 = qml.adjoint(qml.ctrl(qml.U1(0.5, wires=0), control=1))
+        op2 = qml.adjoint(qml.ops.Controlled(qml.RX(0.5, wires=0), control_wires=[1]))
+
+        with qml.queuing.AnnotatedQueue() as q:
+            adjoint_controlled_decomp(*op1.parameters, wires=op1.wires, **op1.hyperparameters)
+            adjoint_controlled_decomp(*op2.parameters, wires=op2.wires, **op2.hyperparameters)
+
+        assert q.queue == [qml.ctrl(qml.U1(-0.5, wires=0), control=1), qml.CRX(-0.5, wires=[1, 0])]
+        assert adjoint_controlled_decomp.compute_resources(**op1.resource_params) == Resources(
+            {
+                qml.decomposition.controlled_resource_rep(
+                    qml.U1, {}, num_control_wires=1, num_zero_control_values=0, num_work_wires=0
+                ): 1,
+            }
+        )
+        assert adjoint_controlled_decomp.compute_resources(**op2.resource_params) == Resources(
+            {qml.resource_rep(qml.CRX): 1}
+        )
+
+    def test_adjoint_controlled_x(self):
+        """Tests the adjoint of controlled X operations are correctly decomposed."""
+
+        op1 = qml.adjoint(qml.ops.Controlled(qml.X(1), control_wires=[0]))
+        op2 = qml.adjoint(qml.ops.Controlled(qml.X(2), control_wires=[0, 1]))
+        op3 = qml.adjoint(qml.ops.Controlled(qml.X(3), control_wires=[0, 1, 2]))
+
+        with qml.queuing.AnnotatedQueue() as q:
+            adjoint_controlled_decomp(*op1.parameters, wires=op1.wires, **op1.hyperparameters)
+            adjoint_controlled_decomp(*op2.parameters, wires=op2.wires, **op2.hyperparameters)
+            adjoint_controlled_decomp(*op3.parameters, wires=op3.wires, **op3.hyperparameters)
+
+        assert q.queue == [
+            qml.CNOT(wires=[0, 1]),
+            qml.Toffoli(wires=[0, 1, 2]),
+            qml.MultiControlledX(wires=[0, 1, 2, 3]),
+        ]
+
+        assert adjoint_controlled_decomp.compute_resources(**op1.resource_params) == Resources(
+            {qml.resource_rep(qml.CNOT): 1}
+        )
+        assert adjoint_controlled_decomp.compute_resources(**op2.resource_params) == Resources(
+            {qml.resource_rep(qml.Toffoli): 1}
+        )
+        assert adjoint_controlled_decomp.compute_resources(**op3.resource_params) == Resources(
+            {
+                qml.resource_rep(
+                    qml.MultiControlledX,
+                    num_control_wires=3,
+                    num_zero_control_values=0,
+                    num_work_wires=0,
+                ): 1
+            }
+        )
+
+    def test_same_type_adjoint(self):
+        """Tests that the adjoint of an operation that has adjoint of same
+        type is correctly decomposed."""
+
+        op1 = qml.adjoint(qml.H(0))
+        op2 = qml.adjoint(qml.RX(0.5, wires=0))
+
+        with qml.queuing.AnnotatedQueue() as q:
+            same_type_adjoint_decomp(*op1.parameters, wires=op1.wires, **op1.hyperparameters)
+            same_type_adjoint_decomp(*op2.parameters, wires=op2.wires, **op2.hyperparameters)
+
+        assert q.queue == [qml.H(0), qml.RX(-0.5, wires=0)]
+        assert same_type_adjoint_decomp.compute_resources(**op1.resource_params) == to_resources(
+            {qml.H: 1}
+        )
+        assert same_type_adjoint_decomp.compute_resources(**op2.resource_params) == to_resources(
+            {qml.RX: 1}
+        )
+
+    def test_adjoint_general(self):
+        """Tests the adjoint of a general operator can be correctly decomposed."""
+
+        class CustomOp(qml.operation.Operator):  # pylint: disable=too-few-public-methods
+
+            resource_keys = set()
+
+            @property
+            def resource_params(self):
+                return {}
+
+        @qml.register_resources({qml.H: 1, qml.CNOT: 2, qml.RX: 1, qml.T: 1})
+        def custom_decomp(phi, wires):
+            qml.H(wires[0])
+            qml.CNOT(wires=wires[:2])
+            qml.RX(phi, wires=wires[1])
+            qml.CNOT(wires=wires[1:3])
+            qml.T(wires[2])
+
+        op = qml.adjoint(CustomOp(0.5, wires=[0, 1, 2]))
+        rule = AdjointDecomp(custom_decomp)
+
+        with qml.queuing.AnnotatedQueue() as q:
+            rule(*op.parameters, wires=op.wires, **op.hyperparameters)
+
+        assert q.queue == [
+            qml.adjoint(qml.T(2)),
+            qml.adjoint(qml.CNOT(wires=[1, 2])),
+            qml.adjoint(qml.RX(0.5, wires=1)),
+            qml.adjoint(qml.CNOT(wires=[0, 1])),
+            qml.adjoint(qml.H(wires=0)),
+        ]
+
+        assert rule.compute_resources(**op.resource_params) == Resources(
+            {
+                qml.decomposition.adjoint_resource_rep(qml.T): 1,
+                qml.decomposition.adjoint_resource_rep(qml.CNOT): 2,
+                qml.decomposition.adjoint_resource_rep(qml.RX): 1,
+                qml.decomposition.adjoint_resource_rep(qml.H): 1,
+            }
+        )


Reviews:
user: login: dwierichs
body: Looks great to me! :100: 
Very consistent structure with #7045 , made it easier for me to review :+1:
I was a bit surprised by the single capture-related test :sweat_smile: 
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-19T10:31:38Z
user: login: astralcai
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-19T15:12:38Z
user: login: astralcai
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-19T15:17:30Z
user: login: astralcai
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-19T15:25:05Z
user: login: astralcai
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-19T15:26:40Z
user: login: dwierichs
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-19T15:35:12Z
user: login: dwierichs
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-19T15:36:49Z
user: login: dwierichs
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-19T15:37:34Z
user: login: PietropaoloFrisoni
body: Looks great!
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-19T16:10:54Z
user: login: astralcai
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-19T20:18:25Z
user: login: astralcai
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-19T20:18:39Z
user: login: astralcai
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-19T20:21:06Z
user: login: astralcai
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-19T20:26:01Z
user: login: astralcai
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-19T20:27:13Z
user: login: dwierichs
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-20T11:53:22Z
user: login: dwierichs
body: LGTM :100: Thanks @astralcai 
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-20T11:53:48Z
user: login: PietropaoloFrisoni
body: 🚀 
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-20T12:50:34Z

Review comments:
diff_hunk: @@ -193,6 +203,32 @@ def _add_special_decomp_rule_to_op(
         self._graph.add_edge(d_node_idx, op_node_idx, 0)
         return op_node_idx
 
+    def _add_adjoint_decomp_node(self, op_node: CompressedResourceOp, op_node_idx: int) -> int:
+        """Adds an adjoint decomposition node."""
+
+        base_class, base_params = op_node.params["base_class"], op_node.params["base_params"]
+
+        if issubclass(base_class, qml.ops.Adjoint):
+            rule = adjoint_adjoint_decomp
+            return self._add_special_decomp_rule_to_op(rule, op_node, op_node_idx)
+
+        if base_class in has_adjoint_ops():
+            rule = has_adjoint_decomp
+            return self._add_special_decomp_rule_to_op(rule, op_node, op_node_idx)
+
+        if (
+            issubclass(base_class, qml.ops.Controlled)
+            and base_params["base_class"] in has_adjoint_ops()
+        ):
+            rule = adjoint_controlled_decomp
+            return self._add_special_decomp_rule_to_op(rule, op_node, op_node_idx)
+
+        for base_decomposition in self._get_decompositions(base_class):
+            rule = AdjointDecomp(base_decomposition)
+            self._add_special_decomp_rule_to_op(rule, op_node, op_node_idx)
+
path: pennylane/decomposition/decomposition_graph.py
user: login: dwierichs
body: Similar to [this suggestion](https://github.com/PennyLaneAI/pennylane/pull/7045#discussion_r2002877843)
```suggestion

        if base_class in has_adjoint_ops():
            rule = has_adjoint_decomp

        if (
            issubclass(base_class, qml.ops.Controlled)
            and base_params["base_class"] in has_adjoint_ops()
        ):
            rule = adjoint_controlled_decomp

        for base_decomposition in self._get_decompositions(base_class):
            rule = AdjointDecomp(base_decomposition)

        self._add_special_decomp_rule_to_op(rule, op_node, op_node_idx)
```
created_at: 2025-03-19T09:57:48Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,202 @@
+# 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.
+
+"""This module contains special logic of decomposing symbolic operations."""
+
+from __future__ import annotations
+
+import functools
+
+import pennylane as qml
+
+from .controlled_decomposition import base_to_custom_ctrl_op
+from .decomposition_rule import DecompositionRule, register_resources
+from .resources import adjoint_resource_rep, resource_rep
+
+
+class AdjointDecomp(DecompositionRule):  # pylint: disable=too-few-public-methods
+    """The adjoint version of a decomposition rule."""
+
+    def __init__(self, base_decomposition: DecompositionRule):
+        self._base_decomposition = base_decomposition
+        super().__init__(self._get_impl(), self._get_resource_fn())
+
+    def _get_impl(self):
+        """The implementation of the adjoint of a gate."""
+
+        def _impl(*_, base, **__):
+            qml.adjoint(self._base_decomposition._impl)(  # pylint: disable=protected-access
+                *base.parameters, base.wires, **base.hyperparameters
path: pennylane/decomposition/symbolic_decomposition.py
user: login: dwierichs
body: Same question as for ctrl regarding parametrization. Leaving this comment for tracking.
created_at: 2025-03-19T09:59:01Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,202 @@
+# 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.
+
+"""This module contains special logic of decomposing symbolic operations."""
+
+from __future__ import annotations
+
+import functools
+
+import pennylane as qml
+
+from .controlled_decomposition import base_to_custom_ctrl_op
+from .decomposition_rule import DecompositionRule, register_resources
+from .resources import adjoint_resource_rep, resource_rep
+
+
+class AdjointDecomp(DecompositionRule):  # pylint: disable=too-few-public-methods
+    """The adjoint version of a decomposition rule."""
+
+    def __init__(self, base_decomposition: DecompositionRule):
+        self._base_decomposition = base_decomposition
+        super().__init__(self._get_impl(), self._get_resource_fn())
+
+    def _get_impl(self):
+        """The implementation of the adjoint of a gate."""
+
+        def _impl(*_, base, **__):
+            qml.adjoint(self._base_decomposition._impl)(  # pylint: disable=protected-access
+                *base.parameters, base.wires, **base.hyperparameters
+            )
+
+        return _impl
+
+    def _get_resource_fn(self):
+        """The resource function of the adjoint of a gate."""
+
+        def _resource_fn(base_class, base_params):  # pylint: disable=unused-argument
+            base_resources = self._base_decomposition.compute_resources(**base_params)
+            return {
+                adjoint_resource_rep(decomp_op.op_type, decomp_op.params): count
+                for decomp_op, count in base_resources.gate_counts.items()
+            }
+
+        return _resource_fn
+
+
+def _same_type_adjoint_resource(base_class, base_params):
+    """Resources of the adjoint of a gate whose adjoint is an instance of its own type."""
+    # This assumes that the adjoint of the gate has the same resources as the gate itself.
+    return {resource_rep(base_class, **base_params): 1}
+
+
+@register_resources(_same_type_adjoint_resource)
+def has_adjoint_decomp(*_, base, **__):
path: pennylane/decomposition/symbolic_decomposition.py
user: login: dwierichs
body: `has_adjoint_decomp` seems to loose information, compared to `same_type_adjoint_decomp`.
created_at: 2025-03-19T10:00:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,202 @@
+# 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.
+
+"""This module contains special logic of decomposing symbolic operations."""
+
+from __future__ import annotations
+
+import functools
+
+import pennylane as qml
+
+from .controlled_decomposition import base_to_custom_ctrl_op
+from .decomposition_rule import DecompositionRule, register_resources
+from .resources import adjoint_resource_rep, resource_rep
+
+
+class AdjointDecomp(DecompositionRule):  # pylint: disable=too-few-public-methods
+    """The adjoint version of a decomposition rule."""
+
+    def __init__(self, base_decomposition: DecompositionRule):
+        self._base_decomposition = base_decomposition
+        super().__init__(self._get_impl(), self._get_resource_fn())
+
+    def _get_impl(self):
+        """The implementation of the adjoint of a gate."""
+
+        def _impl(*_, base, **__):
+            qml.adjoint(self._base_decomposition._impl)(  # pylint: disable=protected-access
+                *base.parameters, base.wires, **base.hyperparameters
+            )
+
+        return _impl
+
+    def _get_resource_fn(self):
+        """The resource function of the adjoint of a gate."""
+
+        def _resource_fn(base_class, base_params):  # pylint: disable=unused-argument
+            base_resources = self._base_decomposition.compute_resources(**base_params)
+            return {
+                adjoint_resource_rep(decomp_op.op_type, decomp_op.params): count
+                for decomp_op, count in base_resources.gate_counts.items()
+            }
+
+        return _resource_fn
+
+
+def _same_type_adjoint_resource(base_class, base_params):
+    """Resources of the adjoint of a gate whose adjoint is an instance of its own type."""
+    # This assumes that the adjoint of the gate has the same resources as the gate itself.
+    return {resource_rep(base_class, **base_params): 1}
+
+
+@register_resources(_same_type_adjoint_resource)
+def has_adjoint_decomp(*_, base, **__):
+    """Decompose the adjoint of a gate whose adjoint is an instance of its own type."""
+    base.adjoint()
+
+
+def _adjoint_adjoint_resource(*_, base_params, **__):
+    """Resources of the adjoint of the adjoint of a gate."""
+    base_class, base_params = base_params["base_class"], base_params["base_params"]
+    return {resource_rep(base_class, **base_params): 1}
path: pennylane/decomposition/symbolic_decomposition.py
user: login: dwierichs
body: This might make things clearer, or less clear :sweat_smile: 
```suggestion
    base_of_base_class, base_of_base_params = base_params["base_class"], base_params["base_params"]
    return {resource_rep(base_of_base_class, **base_of_base_params): 1}
```
created_at: 2025-03-19T10:01:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,202 @@
+# 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.
+
+"""This module contains special logic of decomposing symbolic operations."""
+
+from __future__ import annotations
+
+import functools
+
+import pennylane as qml
+
+from .controlled_decomposition import base_to_custom_ctrl_op
+from .decomposition_rule import DecompositionRule, register_resources
+from .resources import adjoint_resource_rep, resource_rep
+
+
+class AdjointDecomp(DecompositionRule):  # pylint: disable=too-few-public-methods
+    """The adjoint version of a decomposition rule."""
+
+    def __init__(self, base_decomposition: DecompositionRule):
+        self._base_decomposition = base_decomposition
+        super().__init__(self._get_impl(), self._get_resource_fn())
+
+    def _get_impl(self):
+        """The implementation of the adjoint of a gate."""
+
+        def _impl(*_, base, **__):
+            qml.adjoint(self._base_decomposition._impl)(  # pylint: disable=protected-access
+                *base.parameters, base.wires, **base.hyperparameters
+            )
+
+        return _impl
+
+    def _get_resource_fn(self):
+        """The resource function of the adjoint of a gate."""
+
+        def _resource_fn(base_class, base_params):  # pylint: disable=unused-argument
+            base_resources = self._base_decomposition.compute_resources(**base_params)
+            return {
+                adjoint_resource_rep(decomp_op.op_type, decomp_op.params): count
+                for decomp_op, count in base_resources.gate_counts.items()
+            }
+
+        return _resource_fn
+
+
+def _same_type_adjoint_resource(base_class, base_params):
+    """Resources of the adjoint of a gate whose adjoint is an instance of its own type."""
+    # This assumes that the adjoint of the gate has the same resources as the gate itself.
+    return {resource_rep(base_class, **base_params): 1}
+
+
+@register_resources(_same_type_adjoint_resource)
+def has_adjoint_decomp(*_, base, **__):
+    """Decompose the adjoint of a gate whose adjoint is an instance of its own type."""
+    base.adjoint()
+
+
+def _adjoint_adjoint_resource(*_, base_params, **__):
+    """Resources of the adjoint of the adjoint of a gate."""
+    base_class, base_params = base_params["base_class"], base_params["base_params"]
+    return {resource_rep(base_class, **base_params): 1}
+
+
+@register_resources(_adjoint_adjoint_resource)
+def adjoint_adjoint_decomp(*_, wires, base):  # pylint: disable=unused-argument
+    """Decompose the adjoint of the adjoint of a gate."""
+    base.base._unflatten(*base.base._flatten())  # pylint: disable=protected-access
+
+
+def _adjoint_controlled_resource(base_class, base_params):
+    """Resources of the adjoint of a controlled gate whose base has adjoint."""
+
+    num_control_wires = base_params["num_control_wires"]
+    controlled_base_class = base_params["base_class"]
+
+    # Handle controlled-X gates, the adjoint is just themselves
+    if controlled_base_class is qml.X:
+        if num_control_wires == 1:
+            return {resource_rep(qml.CNOT): 1}
+        if num_control_wires == 2:
+            return {resource_rep(qml.Toffoli): 1}
+        return {
+            resource_rep(
+                qml.MultiControlledX,
+                num_control_wires=num_control_wires,
+                num_zero_control_values=base_params["num_zero_control_values"],
+                num_work_wires=base_params["num_work_wires"],
+            ): 1
+        }
+
+    # Handle custom controlled gates. The adjoint of a general controlled operator that
+    # is equivalent to a custom controlled operator should just be the custom controlled
+    # operator given that its base has_adjoint.
+    if (controlled_base_class, num_control_wires) in base_to_custom_ctrl_op():
+        custom_op_type = base_to_custom_ctrl_op()[(controlled_base_class, num_control_wires)]
+        # All gates in base_to_custom_ctrl_op do not have resource params.
+        return {resource_rep(custom_op_type): 1}
+
+    # Handle the general case, here we assume that the adjoint of a controlled gate
+    # whose base has an adjoint that is of its own type, should have the same resource
+    # rep as the controlled gate itself. For example, Adjoint(Controlled(O)) should
+    # have the same resources as Controlled(O) if the adjoint of O is another O.
+    return {resource_rep(base_class, **base_params): 1}
path: pennylane/decomposition/symbolic_decomposition.py
user: login: dwierichs
body: This "just reproduces" the logic of `_same_type_adjoint_resource` for controlled ops, right? 
Is there no way to hide the whole case distinction for controlled ops by just returning the controlled op's resources itself?
created_at: 2025-03-19T10:04:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,202 @@
+# 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.
+
+"""This module contains special logic of decomposing symbolic operations."""
+
+from __future__ import annotations
+
+import functools
+
+import pennylane as qml
+
+from .controlled_decomposition import base_to_custom_ctrl_op
+from .decomposition_rule import DecompositionRule, register_resources
+from .resources import adjoint_resource_rep, resource_rep
+
+
+class AdjointDecomp(DecompositionRule):  # pylint: disable=too-few-public-methods
+    """The adjoint version of a decomposition rule."""
+
+    def __init__(self, base_decomposition: DecompositionRule):
+        self._base_decomposition = base_decomposition
+        super().__init__(self._get_impl(), self._get_resource_fn())
+
+    def _get_impl(self):
+        """The implementation of the adjoint of a gate."""
+
+        def _impl(*_, base, **__):
+            qml.adjoint(self._base_decomposition._impl)(  # pylint: disable=protected-access
+                *base.parameters, base.wires, **base.hyperparameters
+            )
+
+        return _impl
+
+    def _get_resource_fn(self):
+        """The resource function of the adjoint of a gate."""
+
+        def _resource_fn(base_class, base_params):  # pylint: disable=unused-argument
+            base_resources = self._base_decomposition.compute_resources(**base_params)
+            return {
+                adjoint_resource_rep(decomp_op.op_type, decomp_op.params): count
+                for decomp_op, count in base_resources.gate_counts.items()
+            }
+
+        return _resource_fn
+
+
+def _same_type_adjoint_resource(base_class, base_params):
+    """Resources of the adjoint of a gate whose adjoint is an instance of its own type."""
+    # This assumes that the adjoint of the gate has the same resources as the gate itself.
+    return {resource_rep(base_class, **base_params): 1}
+
+
+@register_resources(_same_type_adjoint_resource)
+def has_adjoint_decomp(*_, base, **__):
+    """Decompose the adjoint of a gate whose adjoint is an instance of its own type."""
+    base.adjoint()
+
+
+def _adjoint_adjoint_resource(*_, base_params, **__):
+    """Resources of the adjoint of the adjoint of a gate."""
+    base_class, base_params = base_params["base_class"], base_params["base_params"]
+    return {resource_rep(base_class, **base_params): 1}
+
+
+@register_resources(_adjoint_adjoint_resource)
+def adjoint_adjoint_decomp(*_, wires, base):  # pylint: disable=unused-argument
+    """Decompose the adjoint of the adjoint of a gate."""
+    base.base._unflatten(*base.base._flatten())  # pylint: disable=protected-access
+
+
+def _adjoint_controlled_resource(base_class, base_params):
+    """Resources of the adjoint of a controlled gate whose base has adjoint."""
+
+    num_control_wires = base_params["num_control_wires"]
+    controlled_base_class = base_params["base_class"]
+
+    # Handle controlled-X gates, the adjoint is just themselves
+    if controlled_base_class is qml.X:
+        if num_control_wires == 1:
+            return {resource_rep(qml.CNOT): 1}
+        if num_control_wires == 2:
+            return {resource_rep(qml.Toffoli): 1}
+        return {
+            resource_rep(
+                qml.MultiControlledX,
+                num_control_wires=num_control_wires,
+                num_zero_control_values=base_params["num_zero_control_values"],
+                num_work_wires=base_params["num_work_wires"],
+            ): 1
+        }
+
+    # Handle custom controlled gates. The adjoint of a general controlled operator that
+    # is equivalent to a custom controlled operator should just be the custom controlled
+    # operator given that its base has_adjoint.
+    if (controlled_base_class, num_control_wires) in base_to_custom_ctrl_op():
+        custom_op_type = base_to_custom_ctrl_op()[(controlled_base_class, num_control_wires)]
+        # All gates in base_to_custom_ctrl_op do not have resource params.
+        return {resource_rep(custom_op_type): 1}
+
+    # Handle the general case, here we assume that the adjoint of a controlled gate
+    # whose base has an adjoint that is of its own type, should have the same resource
+    # rep as the controlled gate itself. For example, Adjoint(Controlled(O)) should
+    # have the same resources as Controlled(O) if the adjoint of O is another O.
+    return {resource_rep(base_class, **base_params): 1}
+
+
+@register_resources(_adjoint_controlled_resource)
+def adjoint_controlled_decomp(*_, base, **__):
+    """Decompose the adjoint of a controlled gate whose base has adjoint.
+
+    Precondition:
+    - isinstance(base, qml.ops.Controlled) and base.base.has_adjoint
path: pennylane/decomposition/symbolic_decomposition.py
user: login: dwierichs
body: :100:
created_at: 2025-03-19T10:05:04Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,202 @@
+# 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.
+
+"""This module contains special logic of decomposing symbolic operations."""
+
+from __future__ import annotations
+
+import functools
+
+import pennylane as qml
+
+from .controlled_decomposition import base_to_custom_ctrl_op
+from .decomposition_rule import DecompositionRule, register_resources
+from .resources import adjoint_resource_rep, resource_rep
+
+
+class AdjointDecomp(DecompositionRule):  # pylint: disable=too-few-public-methods
+    """The adjoint version of a decomposition rule."""
+
+    def __init__(self, base_decomposition: DecompositionRule):
+        self._base_decomposition = base_decomposition
+        super().__init__(self._get_impl(), self._get_resource_fn())
+
+    def _get_impl(self):
+        """The implementation of the adjoint of a gate."""
+
+        def _impl(*_, base, **__):
+            qml.adjoint(self._base_decomposition._impl)(  # pylint: disable=protected-access
+                *base.parameters, base.wires, **base.hyperparameters
+            )
+
+        return _impl
+
+    def _get_resource_fn(self):
+        """The resource function of the adjoint of a gate."""
+
+        def _resource_fn(base_class, base_params):  # pylint: disable=unused-argument
+            base_resources = self._base_decomposition.compute_resources(**base_params)
+            return {
+                adjoint_resource_rep(decomp_op.op_type, decomp_op.params): count
+                for decomp_op, count in base_resources.gate_counts.items()
+            }
+
+        return _resource_fn
+
+
+def _same_type_adjoint_resource(base_class, base_params):
+    """Resources of the adjoint of a gate whose adjoint is an instance of its own type."""
+    # This assumes that the adjoint of the gate has the same resources as the gate itself.
+    return {resource_rep(base_class, **base_params): 1}
+
+
+@register_resources(_same_type_adjoint_resource)
+def has_adjoint_decomp(*_, base, **__):
+    """Decompose the adjoint of a gate whose adjoint is an instance of its own type."""
+    base.adjoint()
+
+
+def _adjoint_adjoint_resource(*_, base_params, **__):
+    """Resources of the adjoint of the adjoint of a gate."""
+    base_class, base_params = base_params["base_class"], base_params["base_params"]
+    return {resource_rep(base_class, **base_params): 1}
+
+
+@register_resources(_adjoint_adjoint_resource)
+def adjoint_adjoint_decomp(*_, wires, base):  # pylint: disable=unused-argument
+    """Decompose the adjoint of the adjoint of a gate."""
+    base.base._unflatten(*base.base._flatten())  # pylint: disable=protected-access
+
+
+def _adjoint_controlled_resource(base_class, base_params):
+    """Resources of the adjoint of a controlled gate whose base has adjoint."""
+
+    num_control_wires = base_params["num_control_wires"]
+    controlled_base_class = base_params["base_class"]
+
+    # Handle controlled-X gates, the adjoint is just themselves
+    if controlled_base_class is qml.X:
+        if num_control_wires == 1:
+            return {resource_rep(qml.CNOT): 1}
+        if num_control_wires == 2:
+            return {resource_rep(qml.Toffoli): 1}
+        return {
+            resource_rep(
+                qml.MultiControlledX,
+                num_control_wires=num_control_wires,
+                num_zero_control_values=base_params["num_zero_control_values"],
+                num_work_wires=base_params["num_work_wires"],
+            ): 1
+        }
+
+    # Handle custom controlled gates. The adjoint of a general controlled operator that
+    # is equivalent to a custom controlled operator should just be the custom controlled
+    # operator given that its base has_adjoint.
+    if (controlled_base_class, num_control_wires) in base_to_custom_ctrl_op():
+        custom_op_type = base_to_custom_ctrl_op()[(controlled_base_class, num_control_wires)]
+        # All gates in base_to_custom_ctrl_op do not have resource params.
+        return {resource_rep(custom_op_type): 1}
+
+    # Handle the general case, here we assume that the adjoint of a controlled gate
+    # whose base has an adjoint that is of its own type, should have the same resource
+    # rep as the controlled gate itself. For example, Adjoint(Controlled(O)) should
+    # have the same resources as Controlled(O) if the adjoint of O is another O.
+    return {resource_rep(base_class, **base_params): 1}
+
+
+@register_resources(_adjoint_controlled_resource)
+def adjoint_controlled_decomp(*_, base, **__):
+    """Decompose the adjoint of a controlled gate whose base has adjoint.
+
+    Precondition:
+    - isinstance(base, qml.ops.Controlled) and base.base.has_adjoint
+
+    """
+    qml.ctrl(
+        base.base.adjoint(),
+        control=base.control_wires,
+        control_values=base.control_values,
+        work_wires=base.work_wires,
+    )
+
+
+@functools.lru_cache()
+def has_adjoint_ops():
+    """A set of operators whose adjoint is an instance of its own type."""
+    return {
+        # identity
+        qml.Identity,
+        qml.GlobalPhase,
+        # non-parametric gates
+        qml.H,
+        qml.X,
+        qml.Y,
+        qml.Z,
+        qml.SWAP,
+        qml.ECR,
+        # single-qubit parametric gates
+        qml.Rot,
+        qml.U1,
+        qml.U2,
path: pennylane/decomposition/symbolic_decomposition.py
user: login: dwierichs
body: Is the adjoint of a U2 gate again of the U2 form? :) 
created_at: 2025-03-19T10:06:27Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,202 @@
+# 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.
+
+"""This module contains special logic of decomposing symbolic operations."""
+
+from __future__ import annotations
+
+import functools
+
+import pennylane as qml
+
+from .controlled_decomposition import base_to_custom_ctrl_op
+from .decomposition_rule import DecompositionRule, register_resources
+from .resources import adjoint_resource_rep, resource_rep
+
+
+class AdjointDecomp(DecompositionRule):  # pylint: disable=too-few-public-methods
+    """The adjoint version of a decomposition rule."""
+
+    def __init__(self, base_decomposition: DecompositionRule):
+        self._base_decomposition = base_decomposition
+        super().__init__(self._get_impl(), self._get_resource_fn())
+
+    def _get_impl(self):
+        """The implementation of the adjoint of a gate."""
+
+        def _impl(*_, base, **__):
+            qml.adjoint(self._base_decomposition._impl)(  # pylint: disable=protected-access
+                *base.parameters, base.wires, **base.hyperparameters
+            )
+
+        return _impl
+
+    def _get_resource_fn(self):
+        """The resource function of the adjoint of a gate."""
+
+        def _resource_fn(base_class, base_params):  # pylint: disable=unused-argument
+            base_resources = self._base_decomposition.compute_resources(**base_params)
+            return {
+                adjoint_resource_rep(decomp_op.op_type, decomp_op.params): count
+                for decomp_op, count in base_resources.gate_counts.items()
+            }
+
+        return _resource_fn
+
+
+def _same_type_adjoint_resource(base_class, base_params):
+    """Resources of the adjoint of a gate whose adjoint is an instance of its own type."""
+    # This assumes that the adjoint of the gate has the same resources as the gate itself.
+    return {resource_rep(base_class, **base_params): 1}
+
+
+@register_resources(_same_type_adjoint_resource)
+def has_adjoint_decomp(*_, base, **__):
+    """Decompose the adjoint of a gate whose adjoint is an instance of its own type."""
+    base.adjoint()
+
+
+def _adjoint_adjoint_resource(*_, base_params, **__):
+    """Resources of the adjoint of the adjoint of a gate."""
+    base_class, base_params = base_params["base_class"], base_params["base_params"]
+    return {resource_rep(base_class, **base_params): 1}
+
+
+@register_resources(_adjoint_adjoint_resource)
+def adjoint_adjoint_decomp(*_, wires, base):  # pylint: disable=unused-argument
+    """Decompose the adjoint of the adjoint of a gate."""
+    base.base._unflatten(*base.base._flatten())  # pylint: disable=protected-access
+
+
+def _adjoint_controlled_resource(base_class, base_params):
+    """Resources of the adjoint of a controlled gate whose base has adjoint."""
+
+    num_control_wires = base_params["num_control_wires"]
+    controlled_base_class = base_params["base_class"]
+
+    # Handle controlled-X gates, the adjoint is just themselves
+    if controlled_base_class is qml.X:
+        if num_control_wires == 1:
+            return {resource_rep(qml.CNOT): 1}
+        if num_control_wires == 2:
+            return {resource_rep(qml.Toffoli): 1}
+        return {
+            resource_rep(
+                qml.MultiControlledX,
+                num_control_wires=num_control_wires,
+                num_zero_control_values=base_params["num_zero_control_values"],
+                num_work_wires=base_params["num_work_wires"],
+            ): 1
+        }
+
+    # Handle custom controlled gates. The adjoint of a general controlled operator that
+    # is equivalent to a custom controlled operator should just be the custom controlled
+    # operator given that its base has_adjoint.
+    if (controlled_base_class, num_control_wires) in base_to_custom_ctrl_op():
+        custom_op_type = base_to_custom_ctrl_op()[(controlled_base_class, num_control_wires)]
+        # All gates in base_to_custom_ctrl_op do not have resource params.
+        return {resource_rep(custom_op_type): 1}
+
+    # Handle the general case, here we assume that the adjoint of a controlled gate
+    # whose base has an adjoint that is of its own type, should have the same resource
+    # rep as the controlled gate itself. For example, Adjoint(Controlled(O)) should
+    # have the same resources as Controlled(O) if the adjoint of O is another O.
+    return {resource_rep(base_class, **base_params): 1}
+
+
+@register_resources(_adjoint_controlled_resource)
+def adjoint_controlled_decomp(*_, base, **__):
+    """Decompose the adjoint of a controlled gate whose base has adjoint.
+
+    Precondition:
+    - isinstance(base, qml.ops.Controlled) and base.base.has_adjoint
+
+    """
+    qml.ctrl(
+        base.base.adjoint(),
+        control=base.control_wires,
+        control_values=base.control_values,
+        work_wires=base.work_wires,
+    )
+
+
+@functools.lru_cache()
+def has_adjoint_ops():
+    """A set of operators whose adjoint is an instance of its own type."""
+    return {
+        # identity
+        qml.Identity,
+        qml.GlobalPhase,
+        # non-parametric gates
+        qml.H,
+        qml.X,
+        qml.Y,
+        qml.Z,
+        qml.SWAP,
+        qml.ECR,
+        # single-qubit parametric gates
+        qml.Rot,
+        qml.U1,
+        qml.U2,
+        qml.U3,
+        qml.RX,
+        qml.RY,
+        qml.RZ,
+        qml.PhaseShift,
+        # multi-qubit parametric gates
+        qml.MultiRZ,
+        qml.PauliRot,
+        qml.PCPhase,
+        qml.IsingXX,
+        qml.IsingYY,
+        qml.IsingZZ,
+        qml.IsingXY,
+        qml.PSWAP,
+        qml.CPhaseShift00,
+        qml.CPhaseShift01,
+        qml.CPhaseShift10,
+        # matrix gates
+        qml.QubitUnitary,
+        qml.DiagonalQubitUnitary,
+        qml.BlockEncode,
+        qml.SpecialUnitary,
+        # custom controlled ops
+        qml.CH,
+        qml.CY,
+        qml.CZ,
+        qml.CNOT,
+        qml.CSWAP,
+        qml.CCZ,
+        qml.Toffoli,
+        qml.MultiControlledX,
+        qml.CRX,
+        qml.CRY,
+        qml.CRZ,
+        qml.CRot,
+        qml.ControlledPhaseShift,
+        # arithmetic ops
+        qml.QubitSum,
+        qml.IntegerComparator,
+        # qchem ops
+        qml.SingleExcitation,
+        qml.SingleExcitationMinus,
+        qml.SingleExcitationPlus,
+        qml.DoubleExcitation,
+        qml.DoubleExcitationPlus,
+        qml.DoubleExcitationMinus,
+        qml.OrbitalRotation,
+        qml.FermionicSWAP,
+        # templates
+        qml.CommutingEvolution,
path: pennylane/decomposition/symbolic_decomposition.py
user: login: dwierichs
body: I guess we can always extend this list, is it tracked somewhere that we want to do make it "complete" :thinking: 
Things that could go in there might be `TrotterProduct` or `QDrift`, maybe? :) 
created_at: 2025-03-19T10:13:04Z
author_association: CONTRIBUTOR
diff_hunk: @@ -341,3 +341,121 @@ def custom_controlled_decomp(wires):
         assert graph.decomposition(qml.ctrl(qml.GlobalPhase(0.5), control=[1]))
         assert graph.decomposition(qml.ctrl(qml.GlobalPhase(0.5), control=[1, 2]))
         assert graph.decomposition(qml.ctrl(CustomOp(wires=[1]), control=[0, 2]))
+
+
+@patch(
+    "pennylane.decomposition.decomposition_graph.list_decomps",
+    side_effect=lambda x: decompositions[x],
+)
+class TestSymbolicDecompositions:
+    """Tests decompositions of symbolic ops."""
+
+    def test_adjoint_adjoint(self, _):
+        """Tests that a nested adjoint operator is flattened properly."""
+
+        op = qml.adjoint(qml.adjoint(qml.RX(0.5, wires=[0])))
+
+        graph = DecompositionGraph(operations=[op], target_gate_set={"RX"})
+        # 2 operator nodes (Adjoint(Adjoint(RX)) and RX), and 1 decomposition node.
+        assert len(graph._graph.nodes()) == 3
+        assert len(graph._graph.edges()) == 2
+
+        graph.solve()
+        with qml.queuing.AnnotatedQueue() as q:
+            graph.decomposition(op)(*op.parameters, wires=op.wires, **op.hyperparameters)
+
+        assert q.queue == [qml.RX(0.5, wires=[0])]
+        assert graph.resource_estimate(op) == to_resources({qml.RX: 1})
+
+    def test_custom_adjoint(self, _):
+        """Tests adjoint of an operator that defines its own adjoint."""
+
+        op = qml.adjoint(qml.RX(0.5, wires=[0]))
+
+        graph = DecompositionGraph(operations=[op], target_gate_set={"RX"})
+        # 2 operator nodes (Adjoint(RX) and RX), and 1 decomposition node.
+        assert len(graph._graph.nodes()) == 3
+        assert len(graph._graph.edges()) == 2
+
+        graph.solve()
+        with qml.queuing.AnnotatedQueue() as q:
+            graph.decomposition(op)(*op.parameters, wires=op.wires, **op.hyperparameters)
+
+        assert q.queue == [qml.RX(-0.5, wires=[0])]
+        assert graph.resource_estimate(op) == to_resources({qml.RX: 1})
+
+    def test_controlled_adjoint(self, _):
+        """Tests that the adjoint of a controlled operator is decomposed properly."""
+
+        op = qml.adjoint(qml.ops.Controlled(qml.RX(0.5, wires=[0]), control_wires=1))
+        op2 = qml.adjoint(qml.ctrl(qml.U1(0.5, wires=0), control=[1]))
+
+        graph = DecompositionGraph(
+            operations=[op, op2], target_gate_set={"ControlledPhaseShift", "CRX"}
+        )
+        # 5 operator nodes: Adjoint(C(RX)), Adjoint(C(U1)), CRX, C(U1), ControlledPhaseShift
+        # 3 decomposition nodes leading into Adjoint(C(RX)), Adjoint(C(U1)), and C(U1)
+        assert len(graph._graph.nodes()) == 8
+        # 3 edges from decompositions to ops and 3 edges from ops to decompositions
+        assert len(graph._graph.edges()) == 6
+
+        graph.solve()
+        with qml.queuing.AnnotatedQueue() as q:
+            graph.decomposition(op)(*op.parameters, wires=op.wires, **op.hyperparameters)
+            graph.decomposition(op2)(*op2.parameters, wires=op2.wires, **op2.hyperparameters)
+
+        assert q.queue == [qml.CRX(-0.5, wires=[1, 0]), qml.ctrl(qml.U1(-0.5, wires=0), control=1)]
+
+        op3 = qml.ctrl(qml.U1(-0.5, wires=0), control=1)
+        with qml.queuing.AnnotatedQueue() as q:
+            graph.decomposition(op3)(*op3.parameters, wires=op3.wires, **op3.hyperparameters)
+
+        assert q.queue == [qml.ControlledPhaseShift(-0.5, wires=[1, 0])]
+        assert graph.resource_estimate(op2) == to_resources({qml.ControlledPhaseShift: 1})
path: tests/decomposition/test_decomposition_graph.py
user: login: dwierichs
body: I really liked this test :) 
created_at: 2025-03-19T10:15:15Z
author_association: CONTRIBUTOR
diff_hunk: @@ -341,3 +341,121 @@ def custom_controlled_decomp(wires):
         assert graph.decomposition(qml.ctrl(qml.GlobalPhase(0.5), control=[1]))
         assert graph.decomposition(qml.ctrl(qml.GlobalPhase(0.5), control=[1, 2]))
         assert graph.decomposition(qml.ctrl(CustomOp(wires=[1]), control=[0, 2]))
+
+
+@patch(
+    "pennylane.decomposition.decomposition_graph.list_decomps",
+    side_effect=lambda x: decompositions[x],
+)
+class TestSymbolicDecompositions:
+    """Tests decompositions of symbolic ops."""
+
+    def test_adjoint_adjoint(self, _):
+        """Tests that a nested adjoint operator is flattened properly."""
+
+        op = qml.adjoint(qml.adjoint(qml.RX(0.5, wires=[0])))
+
+        graph = DecompositionGraph(operations=[op], target_gate_set={"RX"})
+        # 2 operator nodes (Adjoint(Adjoint(RX)) and RX), and 1 decomposition node.
+        assert len(graph._graph.nodes()) == 3
+        assert len(graph._graph.edges()) == 2
+
+        graph.solve()
+        with qml.queuing.AnnotatedQueue() as q:
+            graph.decomposition(op)(*op.parameters, wires=op.wires, **op.hyperparameters)
+
+        assert q.queue == [qml.RX(0.5, wires=[0])]
+        assert graph.resource_estimate(op) == to_resources({qml.RX: 1})
+
+    def test_custom_adjoint(self, _):
+        """Tests adjoint of an operator that defines its own adjoint."""
+
+        op = qml.adjoint(qml.RX(0.5, wires=[0]))
+
+        graph = DecompositionGraph(operations=[op], target_gate_set={"RX"})
+        # 2 operator nodes (Adjoint(RX) and RX), and 1 decomposition node.
+        assert len(graph._graph.nodes()) == 3
+        assert len(graph._graph.edges()) == 2
+
+        graph.solve()
+        with qml.queuing.AnnotatedQueue() as q:
+            graph.decomposition(op)(*op.parameters, wires=op.wires, **op.hyperparameters)
+
+        assert q.queue == [qml.RX(-0.5, wires=[0])]
+        assert graph.resource_estimate(op) == to_resources({qml.RX: 1})
+
+    def test_controlled_adjoint(self, _):
+        """Tests that the adjoint of a controlled operator is decomposed properly."""
+
+        op = qml.adjoint(qml.ops.Controlled(qml.RX(0.5, wires=[0]), control_wires=1))
+        op2 = qml.adjoint(qml.ctrl(qml.U1(0.5, wires=0), control=[1]))
+
+        graph = DecompositionGraph(
+            operations=[op, op2], target_gate_set={"ControlledPhaseShift", "CRX"}
+        )
+        # 5 operator nodes: Adjoint(C(RX)), Adjoint(C(U1)), CRX, C(U1), ControlledPhaseShift
+        # 3 decomposition nodes leading into Adjoint(C(RX)), Adjoint(C(U1)), and C(U1)
+        assert len(graph._graph.nodes()) == 8
+        # 3 edges from decompositions to ops and 3 edges from ops to decompositions
+        assert len(graph._graph.edges()) == 6
+
+        graph.solve()
+        with qml.queuing.AnnotatedQueue() as q:
+            graph.decomposition(op)(*op.parameters, wires=op.wires, **op.hyperparameters)
+            graph.decomposition(op2)(*op2.parameters, wires=op2.wires, **op2.hyperparameters)
+
+        assert q.queue == [qml.CRX(-0.5, wires=[1, 0]), qml.ctrl(qml.U1(-0.5, wires=0), control=1)]
+
+        op3 = qml.ctrl(qml.U1(-0.5, wires=0), control=1)
+        with qml.queuing.AnnotatedQueue() as q:
+            graph.decomposition(op3)(*op3.parameters, wires=op3.wires, **op3.hyperparameters)
+
+        assert q.queue == [qml.ControlledPhaseShift(-0.5, wires=[1, 0])]
+        assert graph.resource_estimate(op2) == to_resources({qml.ControlledPhaseShift: 1})
+
+    def test_adjoint_general(self, _):
+        """Tests decomposition of a generalized adjoint operation."""
+
+        class CustomOp(qml.operation.Operation):  # pylint: disable=too-few-public-methods
+            """A custom operation."""
+
+            resource_keys = set()
+
+            @property
+            def resource_params(self):
+                return {}
+
+        @qml.register_resources({qml.H: 1, qml.CNOT: 2, qml.RX: 1, qml.T: 1})
+        def custom_decomp(phi, wires):
+            qml.H(wires[0])
+            qml.CNOT(wires=wires[:2])
+            qml.RX(phi, wires=wires[1])
+            qml.CNOT(wires=wires[1:3])
+            qml.T(wires[2])
+
+        op = qml.adjoint(CustomOp(0.5, wires=[0, 1, 2]))
+        graph = DecompositionGraph(
+            operations=[op],
+            target_gate_set={"Hadamard", "CNOT", "RX", "PhaseShift"},
+            alt_decomps={CustomOp: [custom_decomp]},
+        )
+        # 10 operator nodes: A(CustomOp), A(H), A(CNOT), A(RX), A(T), H, CNOT, RX, A(PhaseShift), PhaseShift
path: tests/decomposition/test_decomposition_graph.py
user: login: dwierichs
body: The decomposition indicated by this series of ops makes me wonder:
It feels  a little inconsistent to not have A(T) and T be the same resource count (we did not add T to the list of ops that have this property), but then once we move to `PhaseShift`, this differentiation is lost.
Now, I think this is fine and one can argue that introducing the rule `T -> PhaseShift` is the problematic ("test user-defined") bit here. But I wanted to bring it up, wondering whether this structure could lead to unwanted simplifications in more complex scenarios.
created_at: 2025-03-19T10:19:18Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,183 @@
+# Copyright 2025 Xanadu Quantum Technologies Inc.
+
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+
+#     http://www.apache.org/licenses/LICENSE-2.0
+
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Tests the decomposition rules defined for symbolic operations other than controlled."""
+
+import pytest
+
+import pennylane as qml
+from pennylane.decomposition.resources import Resources
+from pennylane.decomposition.symbolic_decomposition import (
+    AdjointDecomp,
+    adjoint_adjoint_decomp,
+    adjoint_controlled_decomp,
+    has_adjoint_decomp,
+)
+from tests.decomposition.conftest import to_resources
+
+
+class TestAdjointDecompositionRules:
+    """Tests the decomposition rules defined for the adjoint of operations."""
+
+    def test_adjoint_adjoint(self):
+        """Tests that the adjoint of an adjoint cancels out."""
+
+        op = qml.adjoint(qml.adjoint(qml.RX(0.5, wires=0)))
+
+        with qml.queuing.AnnotatedQueue() as q:
+            adjoint_adjoint_decomp(*op.parameters, wires=op.wires, **op.hyperparameters)
+
+        assert q.queue == [qml.RX(0.5, wires=0)]
+        assert adjoint_adjoint_decomp.compute_resources(**op.resource_params) == to_resources(
+            {qml.RX: 1}
+        )
+
+    @pytest.mark.jax
+    def test_adjoint_adjoint_capture(self):
+        """Tests that the adjoint of an adjoint works with capture."""
+
+        from pennylane.tape.plxpr_conversion import CollectOpsandMeas
+
+        op = qml.adjoint(qml.adjoint(qml.RX(0.5, wires=0)))
+
+        capture_enabled = qml.capture.enabled()
+        qml.capture.enable()
+
+        def circuit():
+            adjoint_adjoint_decomp(*op.parameters, wires=op.wires, **op.hyperparameters)
+
+        plxpr = qml.capture.make_plxpr(circuit)()
+        collector = CollectOpsandMeas()
+        collector.eval(plxpr.jaxpr, plxpr.consts)
+        assert collector.state["ops"] == [qml.RX(0.5, wires=0)]
+
+        if not capture_enabled:
+            qml.capture.disable()
path: tests/decomposition/test_symbolic_decomposition.py
user: login: dwierichs
body: This test surprised me. Should there be more capture-related tests in this line of PRs? :thinking: What makes `adjoint` stand out to require this test?
created_at: 2025-03-19T10:27:46Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,183 @@
+# Copyright 2025 Xanadu Quantum Technologies Inc.
+
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+
+#     http://www.apache.org/licenses/LICENSE-2.0
+
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Tests the decomposition rules defined for symbolic operations other than controlled."""
+
+import pytest
+
+import pennylane as qml
+from pennylane.decomposition.resources import Resources
+from pennylane.decomposition.symbolic_decomposition import (
+    AdjointDecomp,
+    adjoint_adjoint_decomp,
+    adjoint_controlled_decomp,
+    has_adjoint_decomp,
+)
+from tests.decomposition.conftest import to_resources
+
+
+class TestAdjointDecompositionRules:
+    """Tests the decomposition rules defined for the adjoint of operations."""
+
+    def test_adjoint_adjoint(self):
+        """Tests that the adjoint of an adjoint cancels out."""
+
+        op = qml.adjoint(qml.adjoint(qml.RX(0.5, wires=0)))
+
+        with qml.queuing.AnnotatedQueue() as q:
+            adjoint_adjoint_decomp(*op.parameters, wires=op.wires, **op.hyperparameters)
+
+        assert q.queue == [qml.RX(0.5, wires=0)]
+        assert adjoint_adjoint_decomp.compute_resources(**op.resource_params) == to_resources(
+            {qml.RX: 1}
+        )
+
+    @pytest.mark.jax
+    def test_adjoint_adjoint_capture(self):
+        """Tests that the adjoint of an adjoint works with capture."""
+
+        from pennylane.tape.plxpr_conversion import CollectOpsandMeas
+
+        op = qml.adjoint(qml.adjoint(qml.RX(0.5, wires=0)))
+
+        capture_enabled = qml.capture.enabled()
+        qml.capture.enable()
+
+        def circuit():
+            adjoint_adjoint_decomp(*op.parameters, wires=op.wires, **op.hyperparameters)
+
+        plxpr = qml.capture.make_plxpr(circuit)()
+        collector = CollectOpsandMeas()
+        collector.eval(plxpr.jaxpr, plxpr.consts)
+        assert collector.state["ops"] == [qml.RX(0.5, wires=0)]
+
+        if not capture_enabled:
+            qml.capture.disable()
+
+    def test_adjoint_controlled(self):
+        """Tests that the adjoint of a controlled operation is correctly decomposed."""
+
+        op1 = qml.adjoint(qml.ctrl(qml.U1(0.5, wires=0), control=1))
+        op2 = qml.adjoint(qml.ops.Controlled(qml.RX(0.5, wires=0), control_wires=[1]))
+
+        with qml.queuing.AnnotatedQueue() as q:
+            adjoint_controlled_decomp(*op1.parameters, wires=op1.wires, **op1.hyperparameters)
+            adjoint_controlled_decomp(*op2.parameters, wires=op2.wires, **op2.hyperparameters)
+
+        assert q.queue == [qml.ctrl(qml.U1(-0.5, wires=0), control=1), qml.CRX(-0.5, wires=[1, 0])]
+        assert adjoint_controlled_decomp.compute_resources(**op1.resource_params) == Resources(
+            {
+                qml.controlled_resource_rep(
+                    qml.U1, {}, num_control_wires=1, num_zero_control_values=0, num_work_wires=0
+                ): 1,
+            }
+        )
+        assert adjoint_controlled_decomp.compute_resources(**op2.resource_params) == Resources(
+            {qml.resource_rep(qml.CRX): 1}
+        )
+
+    def test_adjoint_controlled_x(self):
+        """Tests the adjoint of controlled X operations are correctly decomposed."""
+
+        op1 = qml.adjoint(qml.ops.Controlled(qml.X(1), control_wires=[0]))
+        op2 = qml.adjoint(qml.ops.Controlled(qml.X(2), control_wires=[0, 1]))
+        op3 = qml.adjoint(qml.ops.Controlled(qml.X(3), control_wires=[0, 1, 2]))
+
+        with qml.queuing.AnnotatedQueue() as q:
+            adjoint_controlled_decomp(*op1.parameters, wires=op1.wires, **op1.hyperparameters)
+            adjoint_controlled_decomp(*op2.parameters, wires=op2.wires, **op2.hyperparameters)
+            adjoint_controlled_decomp(*op3.parameters, wires=op3.wires, **op3.hyperparameters)
+
+        assert q.queue == [
+            qml.CNOT(wires=[0, 1]),
+            qml.Toffoli(wires=[0, 1, 2]),
+            qml.MultiControlledX(wires=[0, 1, 2, 3]),
+        ]
+
+        assert adjoint_controlled_decomp.compute_resources(**op1.resource_params) == Resources(
+            {qml.resource_rep(qml.CNOT): 1}
+        )
+        assert adjoint_controlled_decomp.compute_resources(**op2.resource_params) == Resources(
+            {qml.resource_rep(qml.Toffoli): 1}
+        )
+        assert adjoint_controlled_decomp.compute_resources(**op3.resource_params) == Resources(
+            {
+                qml.resource_rep(
+                    qml.MultiControlledX,
+                    num_control_wires=3,
+                    num_zero_control_values=0,
+                    num_work_wires=0,
+                ): 1
+            }
+        )
+
+    def test_adjoint_custom(self):
+        """Tests that the adjoint of an operation that has adjoint is correctly decomposed."""
path: tests/decomposition/test_symbolic_decomposition.py
user: login: dwierichs
body: maybe?
```suggestion
        """Tests that the adjoint of an operation that has adjoint of same type is correctly decomposed."""
```
created_at: 2025-03-19T10:29:14Z
author_association: CONTRIBUTOR
diff_hunk: @@ -221,7 +221,12 @@ def controlled_x_decomp(*_, wires, control_wires, control_values, work_wires, **
 
 @functools.lru_cache()
 def base_to_custom_ctrl_op():
-    """A dictionary mapping base op types to their custom controlled versions."""
+    """A dictionary mapping base op types to their custom controlled versions.
+
+    This dictionary is used under the assumption that all custom controlled operations do not
+    have resource params (which is why `ControlledQubitUnitary` is not included here).
path: pennylane/decomposition/controlled_decomposition.py
user: login: dwierichs
body: Is there a plan to generalize this? Are there other ops than `ControlledQubitUnitary` affected by this? :thinking: 
created_at: 2025-03-19T10:30:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -341,3 +341,121 @@ def custom_controlled_decomp(wires):
         assert graph.decomposition(qml.ctrl(qml.GlobalPhase(0.5), control=[1]))
         assert graph.decomposition(qml.ctrl(qml.GlobalPhase(0.5), control=[1, 2]))
         assert graph.decomposition(qml.ctrl(CustomOp(wires=[1]), control=[0, 2]))
+
+
+@patch(
+    "pennylane.decomposition.decomposition_graph.list_decomps",
+    side_effect=lambda x: decompositions[x],
+)
+class TestSymbolicDecompositions:
+    """Tests decompositions of symbolic ops."""
+
+    def test_adjoint_adjoint(self, _):
+        """Tests that a nested adjoint operator is flattened properly."""
+
+        op = qml.adjoint(qml.adjoint(qml.RX(0.5, wires=[0])))
+
+        graph = DecompositionGraph(operations=[op], target_gate_set={"RX"})
+        # 2 operator nodes (Adjoint(Adjoint(RX)) and RX), and 1 decomposition node.
+        assert len(graph._graph.nodes()) == 3
+        assert len(graph._graph.edges()) == 2
+
+        graph.solve()
+        with qml.queuing.AnnotatedQueue() as q:
+            graph.decomposition(op)(*op.parameters, wires=op.wires, **op.hyperparameters)
+
+        assert q.queue == [qml.RX(0.5, wires=[0])]
+        assert graph.resource_estimate(op) == to_resources({qml.RX: 1})
+
+    def test_custom_adjoint(self, _):
+        """Tests adjoint of an operator that defines its own adjoint."""
+
+        op = qml.adjoint(qml.RX(0.5, wires=[0]))
+
+        graph = DecompositionGraph(operations=[op], target_gate_set={"RX"})
+        # 2 operator nodes (Adjoint(RX) and RX), and 1 decomposition node.
+        assert len(graph._graph.nodes()) == 3
+        assert len(graph._graph.edges()) == 2
+
+        graph.solve()
+        with qml.queuing.AnnotatedQueue() as q:
+            graph.decomposition(op)(*op.parameters, wires=op.wires, **op.hyperparameters)
+
+        assert q.queue == [qml.RX(-0.5, wires=[0])]
+        assert graph.resource_estimate(op) == to_resources({qml.RX: 1})
+
+    def test_controlled_adjoint(self, _):
+        """Tests that the adjoint of a controlled operator is decomposed properly."""
+
+        op = qml.adjoint(qml.ops.Controlled(qml.RX(0.5, wires=[0]), control_wires=1))
+        op2 = qml.adjoint(qml.ctrl(qml.U1(0.5, wires=0), control=[1]))
+
+        graph = DecompositionGraph(
+            operations=[op, op2], target_gate_set={"ControlledPhaseShift", "CRX"}
+        )
+        # 5 operator nodes: Adjoint(C(RX)), Adjoint(C(U1)), CRX, C(U1), ControlledPhaseShift
+        # 3 decomposition nodes leading into Adjoint(C(RX)), Adjoint(C(U1)), and C(U1)
+        assert len(graph._graph.nodes()) == 8
+        # 3 edges from decompositions to ops and 3 edges from ops to decompositions
+        assert len(graph._graph.edges()) == 6
+
+        graph.solve()
+        with qml.queuing.AnnotatedQueue() as q:
+            graph.decomposition(op)(*op.parameters, wires=op.wires, **op.hyperparameters)
+            graph.decomposition(op2)(*op2.parameters, wires=op2.wires, **op2.hyperparameters)
+
+        assert q.queue == [qml.CRX(-0.5, wires=[1, 0]), qml.ctrl(qml.U1(-0.5, wires=0), control=1)]
+
+        op3 = qml.ctrl(qml.U1(-0.5, wires=0), control=1)
+        with qml.queuing.AnnotatedQueue() as q:
+            graph.decomposition(op3)(*op3.parameters, wires=op3.wires, **op3.hyperparameters)
+
+        assert q.queue == [qml.ControlledPhaseShift(-0.5, wires=[1, 0])]
+        assert graph.resource_estimate(op2) == to_resources({qml.ControlledPhaseShift: 1})
+
+    def test_adjoint_general(self, _):
+        """Tests decomposition of a generalized adjoint operation."""
+
+        class CustomOp(qml.operation.Operation):  # pylint: disable=too-few-public-methods
+            """A custom operation."""
+
+            resource_keys = set()
+
+            @property
+            def resource_params(self):
+                return {}
+
+        @qml.register_resources({qml.H: 1, qml.CNOT: 2, qml.RX: 1, qml.T: 1})
+        def custom_decomp(phi, wires):
+            qml.H(wires[0])
+            qml.CNOT(wires=wires[:2])
+            qml.RX(phi, wires=wires[1])
+            qml.CNOT(wires=wires[1:3])
+            qml.T(wires[2])
+
+        op = qml.adjoint(CustomOp(0.5, wires=[0, 1, 2]))
+        graph = DecompositionGraph(
+            operations=[op],
+            target_gate_set={"Hadamard", "CNOT", "RX", "PhaseShift"},
+            alt_decomps={CustomOp: [custom_decomp]},
+        )
+        # 10 operator nodes: A(CustomOp), A(H), A(CNOT), A(RX), A(T), H, CNOT, RX, A(PhaseShift), PhaseShift
path: tests/decomposition/test_decomposition_graph.py
user: login: astralcai
body: In general `A(Op)` is not the same as `Op`. `A(T)` being equivalent to `T` is in essence a decomposition. I try not to imbed any decomposition logic in the construction of the `CompressedResourceOp`.
created_at: 2025-03-19T15:12:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,183 @@
+# Copyright 2025 Xanadu Quantum Technologies Inc.
+
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+
+#     http://www.apache.org/licenses/LICENSE-2.0
+
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Tests the decomposition rules defined for symbolic operations other than controlled."""
+
+import pytest
+
+import pennylane as qml
+from pennylane.decomposition.resources import Resources
+from pennylane.decomposition.symbolic_decomposition import (
+    AdjointDecomp,
+    adjoint_adjoint_decomp,
+    adjoint_controlled_decomp,
+    has_adjoint_decomp,
+)
+from tests.decomposition.conftest import to_resources
+
+
+class TestAdjointDecompositionRules:
+    """Tests the decomposition rules defined for the adjoint of operations."""
+
+    def test_adjoint_adjoint(self):
+        """Tests that the adjoint of an adjoint cancels out."""
+
+        op = qml.adjoint(qml.adjoint(qml.RX(0.5, wires=0)))
+
+        with qml.queuing.AnnotatedQueue() as q:
+            adjoint_adjoint_decomp(*op.parameters, wires=op.wires, **op.hyperparameters)
+
+        assert q.queue == [qml.RX(0.5, wires=0)]
+        assert adjoint_adjoint_decomp.compute_resources(**op.resource_params) == to_resources(
+            {qml.RX: 1}
+        )
+
+    @pytest.mark.jax
+    def test_adjoint_adjoint_capture(self):
+        """Tests that the adjoint of an adjoint works with capture."""
+
+        from pennylane.tape.plxpr_conversion import CollectOpsandMeas
+
+        op = qml.adjoint(qml.adjoint(qml.RX(0.5, wires=0)))
+
+        capture_enabled = qml.capture.enabled()
+        qml.capture.enable()
+
+        def circuit():
+            adjoint_adjoint_decomp(*op.parameters, wires=op.wires, **op.hyperparameters)
+
+        plxpr = qml.capture.make_plxpr(circuit)()
+        collector = CollectOpsandMeas()
+        collector.eval(plxpr.jaxpr, plxpr.consts)
+        assert collector.state["ops"] == [qml.RX(0.5, wires=0)]
+
+        if not capture_enabled:
+            qml.capture.disable()
path: tests/decomposition/test_symbolic_decomposition.py
user: login: astralcai
body: The decomposition of `op = qml.adjoint(qml.T)` is `op.base`, but since the decomposition is a qfunc, if we write
```
def decomp(...):
    qml.apply(base)
```
it doesn't get captured by program capture. The solution ended up being
```
def decomp(...):
    base._unflatten(*base._flatten())
```
which both queues the op in a queueing context, and gets captured properly. This test is to make sure of that.
created_at: 2025-03-19T15:17:30Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,202 @@
+# 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.
+
+"""This module contains special logic of decomposing symbolic operations."""
+
+from __future__ import annotations
+
+import functools
+
+import pennylane as qml
+
+from .controlled_decomposition import base_to_custom_ctrl_op
+from .decomposition_rule import DecompositionRule, register_resources
+from .resources import adjoint_resource_rep, resource_rep
+
+
+class AdjointDecomp(DecompositionRule):  # pylint: disable=too-few-public-methods
+    """The adjoint version of a decomposition rule."""
+
+    def __init__(self, base_decomposition: DecompositionRule):
+        self._base_decomposition = base_decomposition
+        super().__init__(self._get_impl(), self._get_resource_fn())
+
+    def _get_impl(self):
+        """The implementation of the adjoint of a gate."""
+
+        def _impl(*_, base, **__):
+            qml.adjoint(self._base_decomposition._impl)(  # pylint: disable=protected-access
+                *base.parameters, base.wires, **base.hyperparameters
+            )
+
+        return _impl
+
+    def _get_resource_fn(self):
+        """The resource function of the adjoint of a gate."""
+
+        def _resource_fn(base_class, base_params):  # pylint: disable=unused-argument
+            base_resources = self._base_decomposition.compute_resources(**base_params)
+            return {
+                adjoint_resource_rep(decomp_op.op_type, decomp_op.params): count
+                for decomp_op, count in base_resources.gate_counts.items()
+            }
+
+        return _resource_fn
+
+
+def _same_type_adjoint_resource(base_class, base_params):
+    """Resources of the adjoint of a gate whose adjoint is an instance of its own type."""
+    # This assumes that the adjoint of the gate has the same resources as the gate itself.
+    return {resource_rep(base_class, **base_params): 1}
+
+
+@register_resources(_same_type_adjoint_resource)
+def has_adjoint_decomp(*_, base, **__):
+    """Decompose the adjoint of a gate whose adjoint is an instance of its own type."""
+    base.adjoint()
+
+
+def _adjoint_adjoint_resource(*_, base_params, **__):
+    """Resources of the adjoint of the adjoint of a gate."""
+    base_class, base_params = base_params["base_class"], base_params["base_params"]
+    return {resource_rep(base_class, **base_params): 1}
+
+
+@register_resources(_adjoint_adjoint_resource)
+def adjoint_adjoint_decomp(*_, wires, base):  # pylint: disable=unused-argument
+    """Decompose the adjoint of the adjoint of a gate."""
+    base.base._unflatten(*base.base._flatten())  # pylint: disable=protected-access
+
+
+def _adjoint_controlled_resource(base_class, base_params):
+    """Resources of the adjoint of a controlled gate whose base has adjoint."""
+
+    num_control_wires = base_params["num_control_wires"]
+    controlled_base_class = base_params["base_class"]
+
+    # Handle controlled-X gates, the adjoint is just themselves
+    if controlled_base_class is qml.X:
+        if num_control_wires == 1:
+            return {resource_rep(qml.CNOT): 1}
+        if num_control_wires == 2:
+            return {resource_rep(qml.Toffoli): 1}
+        return {
+            resource_rep(
+                qml.MultiControlledX,
+                num_control_wires=num_control_wires,
+                num_zero_control_values=base_params["num_zero_control_values"],
+                num_work_wires=base_params["num_work_wires"],
+            ): 1
+        }
+
+    # Handle custom controlled gates. The adjoint of a general controlled operator that
+    # is equivalent to a custom controlled operator should just be the custom controlled
+    # operator given that its base has_adjoint.
+    if (controlled_base_class, num_control_wires) in base_to_custom_ctrl_op():
+        custom_op_type = base_to_custom_ctrl_op()[(controlled_base_class, num_control_wires)]
+        # All gates in base_to_custom_ctrl_op do not have resource params.
+        return {resource_rep(custom_op_type): 1}
+
+    # Handle the general case, here we assume that the adjoint of a controlled gate
+    # whose base has an adjoint that is of its own type, should have the same resource
+    # rep as the controlled gate itself. For example, Adjoint(Controlled(O)) should
+    # have the same resources as Controlled(O) if the adjoint of O is another O.
+    return {resource_rep(base_class, **base_params): 1}
+
+
+@register_resources(_adjoint_controlled_resource)
+def adjoint_controlled_decomp(*_, base, **__):
+    """Decompose the adjoint of a controlled gate whose base has adjoint.
+
+    Precondition:
+    - isinstance(base, qml.ops.Controlled) and base.base.has_adjoint
+
+    """
+    qml.ctrl(
+        base.base.adjoint(),
+        control=base.control_wires,
+        control_values=base.control_values,
+        work_wires=base.work_wires,
+    )
+
+
+@functools.lru_cache()
+def has_adjoint_ops():
+    """A set of operators whose adjoint is an instance of its own type."""
+    return {
+        # identity
+        qml.Identity,
+        qml.GlobalPhase,
+        # non-parametric gates
+        qml.H,
+        qml.X,
+        qml.Y,
+        qml.Z,
+        qml.SWAP,
+        qml.ECR,
+        # single-qubit parametric gates
+        qml.Rot,
+        qml.U1,
+        qml.U2,
path: pennylane/decomposition/symbolic_decomposition.py
user: login: astralcai
body: I think so: https://github.com/PennyLaneAI/pennylane/blob/711375b7e526cf976e0de8addde385bbfcfca21d/pennylane/ops/qubit/parametric_ops_single_qubit.py#L994
created_at: 2025-03-19T15:25:05Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,202 @@
+# 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.
+
+"""This module contains special logic of decomposing symbolic operations."""
+
+from __future__ import annotations
+
+import functools
+
+import pennylane as qml
+
+from .controlled_decomposition import base_to_custom_ctrl_op
+from .decomposition_rule import DecompositionRule, register_resources
+from .resources import adjoint_resource_rep, resource_rep
+
+
+class AdjointDecomp(DecompositionRule):  # pylint: disable=too-few-public-methods
+    """The adjoint version of a decomposition rule."""
+
+    def __init__(self, base_decomposition: DecompositionRule):
+        self._base_decomposition = base_decomposition
+        super().__init__(self._get_impl(), self._get_resource_fn())
+
+    def _get_impl(self):
+        """The implementation of the adjoint of a gate."""
+
+        def _impl(*_, base, **__):
+            qml.adjoint(self._base_decomposition._impl)(  # pylint: disable=protected-access
+                *base.parameters, base.wires, **base.hyperparameters
+            )
+
+        return _impl
+
+    def _get_resource_fn(self):
+        """The resource function of the adjoint of a gate."""
+
+        def _resource_fn(base_class, base_params):  # pylint: disable=unused-argument
+            base_resources = self._base_decomposition.compute_resources(**base_params)
+            return {
+                adjoint_resource_rep(decomp_op.op_type, decomp_op.params): count
+                for decomp_op, count in base_resources.gate_counts.items()
+            }
+
+        return _resource_fn
+
+
+def _same_type_adjoint_resource(base_class, base_params):
+    """Resources of the adjoint of a gate whose adjoint is an instance of its own type."""
+    # This assumes that the adjoint of the gate has the same resources as the gate itself.
+    return {resource_rep(base_class, **base_params): 1}
+
+
+@register_resources(_same_type_adjoint_resource)
+def has_adjoint_decomp(*_, base, **__):
+    """Decompose the adjoint of a gate whose adjoint is an instance of its own type."""
+    base.adjoint()
+
+
+def _adjoint_adjoint_resource(*_, base_params, **__):
+    """Resources of the adjoint of the adjoint of a gate."""
+    base_class, base_params = base_params["base_class"], base_params["base_params"]
+    return {resource_rep(base_class, **base_params): 1}
+
+
+@register_resources(_adjoint_adjoint_resource)
+def adjoint_adjoint_decomp(*_, wires, base):  # pylint: disable=unused-argument
+    """Decompose the adjoint of the adjoint of a gate."""
+    base.base._unflatten(*base.base._flatten())  # pylint: disable=protected-access
+
+
+def _adjoint_controlled_resource(base_class, base_params):
+    """Resources of the adjoint of a controlled gate whose base has adjoint."""
+
+    num_control_wires = base_params["num_control_wires"]
+    controlled_base_class = base_params["base_class"]
+
+    # Handle controlled-X gates, the adjoint is just themselves
+    if controlled_base_class is qml.X:
+        if num_control_wires == 1:
+            return {resource_rep(qml.CNOT): 1}
+        if num_control_wires == 2:
+            return {resource_rep(qml.Toffoli): 1}
+        return {
+            resource_rep(
+                qml.MultiControlledX,
+                num_control_wires=num_control_wires,
+                num_zero_control_values=base_params["num_zero_control_values"],
+                num_work_wires=base_params["num_work_wires"],
+            ): 1
+        }
+
+    # Handle custom controlled gates. The adjoint of a general controlled operator that
+    # is equivalent to a custom controlled operator should just be the custom controlled
+    # operator given that its base has_adjoint.
+    if (controlled_base_class, num_control_wires) in base_to_custom_ctrl_op():
+        custom_op_type = base_to_custom_ctrl_op()[(controlled_base_class, num_control_wires)]
+        # All gates in base_to_custom_ctrl_op do not have resource params.
+        return {resource_rep(custom_op_type): 1}
+
+    # Handle the general case, here we assume that the adjoint of a controlled gate
+    # whose base has an adjoint that is of its own type, should have the same resource
+    # rep as the controlled gate itself. For example, Adjoint(Controlled(O)) should
+    # have the same resources as Controlled(O) if the adjoint of O is another O.
+    return {resource_rep(base_class, **base_params): 1}
+
+
+@register_resources(_adjoint_controlled_resource)
+def adjoint_controlled_decomp(*_, base, **__):
+    """Decompose the adjoint of a controlled gate whose base has adjoint.
+
+    Precondition:
+    - isinstance(base, qml.ops.Controlled) and base.base.has_adjoint
+
+    """
+    qml.ctrl(
+        base.base.adjoint(),
+        control=base.control_wires,
+        control_values=base.control_values,
+        work_wires=base.work_wires,
+    )
+
+
+@functools.lru_cache()
+def has_adjoint_ops():
+    """A set of operators whose adjoint is an instance of its own type."""
+    return {
+        # identity
+        qml.Identity,
+        qml.GlobalPhase,
+        # non-parametric gates
+        qml.H,
+        qml.X,
+        qml.Y,
+        qml.Z,
+        qml.SWAP,
+        qml.ECR,
+        # single-qubit parametric gates
+        qml.Rot,
+        qml.U1,
+        qml.U2,
+        qml.U3,
+        qml.RX,
+        qml.RY,
+        qml.RZ,
+        qml.PhaseShift,
+        # multi-qubit parametric gates
+        qml.MultiRZ,
+        qml.PauliRot,
+        qml.PCPhase,
+        qml.IsingXX,
+        qml.IsingYY,
+        qml.IsingZZ,
+        qml.IsingXY,
+        qml.PSWAP,
+        qml.CPhaseShift00,
+        qml.CPhaseShift01,
+        qml.CPhaseShift10,
+        # matrix gates
+        qml.QubitUnitary,
+        qml.DiagonalQubitUnitary,
+        qml.BlockEncode,
+        qml.SpecialUnitary,
+        # custom controlled ops
+        qml.CH,
+        qml.CY,
+        qml.CZ,
+        qml.CNOT,
+        qml.CSWAP,
+        qml.CCZ,
+        qml.Toffoli,
+        qml.MultiControlledX,
+        qml.CRX,
+        qml.CRY,
+        qml.CRZ,
+        qml.CRot,
+        qml.ControlledPhaseShift,
+        # arithmetic ops
+        qml.QubitSum,
+        qml.IntegerComparator,
+        # qchem ops
+        qml.SingleExcitation,
+        qml.SingleExcitationMinus,
+        qml.SingleExcitationPlus,
+        qml.DoubleExcitation,
+        qml.DoubleExcitationPlus,
+        qml.DoubleExcitationMinus,
+        qml.OrbitalRotation,
+        qml.FermionicSWAP,
+        # templates
+        qml.CommutingEvolution,
path: pennylane/decomposition/symbolic_decomposition.py
user: login: astralcai
body: There is a more general story for adding full support for all PL operators.
created_at: 2025-03-19T15:26:40Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,202 @@
+# 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.
+
+"""This module contains special logic of decomposing symbolic operations."""
+
+from __future__ import annotations
+
+import functools
+
+import pennylane as qml
+
+from .controlled_decomposition import base_to_custom_ctrl_op
+from .decomposition_rule import DecompositionRule, register_resources
+from .resources import adjoint_resource_rep, resource_rep
+
+
+class AdjointDecomp(DecompositionRule):  # pylint: disable=too-few-public-methods
+    """The adjoint version of a decomposition rule."""
+
+    def __init__(self, base_decomposition: DecompositionRule):
+        self._base_decomposition = base_decomposition
+        super().__init__(self._get_impl(), self._get_resource_fn())
+
+    def _get_impl(self):
+        """The implementation of the adjoint of a gate."""
+
+        def _impl(*_, base, **__):
+            qml.adjoint(self._base_decomposition._impl)(  # pylint: disable=protected-access
+                *base.parameters, base.wires, **base.hyperparameters
+            )
+
+        return _impl
+
+    def _get_resource_fn(self):
+        """The resource function of the adjoint of a gate."""
+
+        def _resource_fn(base_class, base_params):  # pylint: disable=unused-argument
+            base_resources = self._base_decomposition.compute_resources(**base_params)
+            return {
+                adjoint_resource_rep(decomp_op.op_type, decomp_op.params): count
+                for decomp_op, count in base_resources.gate_counts.items()
+            }
+
+        return _resource_fn
+
+
+def _same_type_adjoint_resource(base_class, base_params):
+    """Resources of the adjoint of a gate whose adjoint is an instance of its own type."""
+    # This assumes that the adjoint of the gate has the same resources as the gate itself.
+    return {resource_rep(base_class, **base_params): 1}
+
+
+@register_resources(_same_type_adjoint_resource)
+def has_adjoint_decomp(*_, base, **__):
+    """Decompose the adjoint of a gate whose adjoint is an instance of its own type."""
+    base.adjoint()
+
+
+def _adjoint_adjoint_resource(*_, base_params, **__):
+    """Resources of the adjoint of the adjoint of a gate."""
+    base_class, base_params = base_params["base_class"], base_params["base_params"]
+    return {resource_rep(base_class, **base_params): 1}
+
+
+@register_resources(_adjoint_adjoint_resource)
+def adjoint_adjoint_decomp(*_, wires, base):  # pylint: disable=unused-argument
+    """Decompose the adjoint of the adjoint of a gate."""
+    base.base._unflatten(*base.base._flatten())  # pylint: disable=protected-access
+
+
+def _adjoint_controlled_resource(base_class, base_params):
+    """Resources of the adjoint of a controlled gate whose base has adjoint."""
+
+    num_control_wires = base_params["num_control_wires"]
+    controlled_base_class = base_params["base_class"]
+
+    # Handle controlled-X gates, the adjoint is just themselves
+    if controlled_base_class is qml.X:
+        if num_control_wires == 1:
+            return {resource_rep(qml.CNOT): 1}
+        if num_control_wires == 2:
+            return {resource_rep(qml.Toffoli): 1}
+        return {
+            resource_rep(
+                qml.MultiControlledX,
+                num_control_wires=num_control_wires,
+                num_zero_control_values=base_params["num_zero_control_values"],
+                num_work_wires=base_params["num_work_wires"],
+            ): 1
+        }
+
+    # Handle custom controlled gates. The adjoint of a general controlled operator that
+    # is equivalent to a custom controlled operator should just be the custom controlled
+    # operator given that its base has_adjoint.
+    if (controlled_base_class, num_control_wires) in base_to_custom_ctrl_op():
+        custom_op_type = base_to_custom_ctrl_op()[(controlled_base_class, num_control_wires)]
+        # All gates in base_to_custom_ctrl_op do not have resource params.
+        return {resource_rep(custom_op_type): 1}
+
+    # Handle the general case, here we assume that the adjoint of a controlled gate
+    # whose base has an adjoint that is of its own type, should have the same resource
+    # rep as the controlled gate itself. For example, Adjoint(Controlled(O)) should
+    # have the same resources as Controlled(O) if the adjoint of O is another O.
+    return {resource_rep(base_class, **base_params): 1}
+
+
+@register_resources(_adjoint_controlled_resource)
+def adjoint_controlled_decomp(*_, base, **__):
+    """Decompose the adjoint of a controlled gate whose base has adjoint.
+
+    Precondition:
+    - isinstance(base, qml.ops.Controlled) and base.base.has_adjoint
+
+    """
+    qml.ctrl(
+        base.base.adjoint(),
+        control=base.control_wires,
+        control_values=base.control_values,
+        work_wires=base.work_wires,
+    )
+
+
+@functools.lru_cache()
+def has_adjoint_ops():
+    """A set of operators whose adjoint is an instance of its own type."""
+    return {
+        # identity
+        qml.Identity,
+        qml.GlobalPhase,
+        # non-parametric gates
+        qml.H,
+        qml.X,
+        qml.Y,
+        qml.Z,
+        qml.SWAP,
+        qml.ECR,
+        # single-qubit parametric gates
+        qml.Rot,
+        qml.U1,
+        qml.U2,
path: pennylane/decomposition/symbolic_decomposition.py
user: login: dwierichs
body: Ah, sorry, should've checked there myself :facepalm: Thanks!
created_at: 2025-03-19T15:35:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -341,3 +341,121 @@ def custom_controlled_decomp(wires):
         assert graph.decomposition(qml.ctrl(qml.GlobalPhase(0.5), control=[1]))
         assert graph.decomposition(qml.ctrl(qml.GlobalPhase(0.5), control=[1, 2]))
         assert graph.decomposition(qml.ctrl(CustomOp(wires=[1]), control=[0, 2]))
+
+
+@patch(
+    "pennylane.decomposition.decomposition_graph.list_decomps",
+    side_effect=lambda x: decompositions[x],
+)
+class TestSymbolicDecompositions:
+    """Tests decompositions of symbolic ops."""
+
+    def test_adjoint_adjoint(self, _):
+        """Tests that a nested adjoint operator is flattened properly."""
+
+        op = qml.adjoint(qml.adjoint(qml.RX(0.5, wires=[0])))
+
+        graph = DecompositionGraph(operations=[op], target_gate_set={"RX"})
+        # 2 operator nodes (Adjoint(Adjoint(RX)) and RX), and 1 decomposition node.
+        assert len(graph._graph.nodes()) == 3
+        assert len(graph._graph.edges()) == 2
+
+        graph.solve()
+        with qml.queuing.AnnotatedQueue() as q:
+            graph.decomposition(op)(*op.parameters, wires=op.wires, **op.hyperparameters)
+
+        assert q.queue == [qml.RX(0.5, wires=[0])]
+        assert graph.resource_estimate(op) == to_resources({qml.RX: 1})
+
+    def test_custom_adjoint(self, _):
+        """Tests adjoint of an operator that defines its own adjoint."""
+
+        op = qml.adjoint(qml.RX(0.5, wires=[0]))
+
+        graph = DecompositionGraph(operations=[op], target_gate_set={"RX"})
+        # 2 operator nodes (Adjoint(RX) and RX), and 1 decomposition node.
+        assert len(graph._graph.nodes()) == 3
+        assert len(graph._graph.edges()) == 2
+
+        graph.solve()
+        with qml.queuing.AnnotatedQueue() as q:
+            graph.decomposition(op)(*op.parameters, wires=op.wires, **op.hyperparameters)
+
+        assert q.queue == [qml.RX(-0.5, wires=[0])]
+        assert graph.resource_estimate(op) == to_resources({qml.RX: 1})
+
+    def test_controlled_adjoint(self, _):
+        """Tests that the adjoint of a controlled operator is decomposed properly."""
+
+        op = qml.adjoint(qml.ops.Controlled(qml.RX(0.5, wires=[0]), control_wires=1))
+        op2 = qml.adjoint(qml.ctrl(qml.U1(0.5, wires=0), control=[1]))
+
+        graph = DecompositionGraph(
+            operations=[op, op2], target_gate_set={"ControlledPhaseShift", "CRX"}
+        )
+        # 5 operator nodes: Adjoint(C(RX)), Adjoint(C(U1)), CRX, C(U1), ControlledPhaseShift
+        # 3 decomposition nodes leading into Adjoint(C(RX)), Adjoint(C(U1)), and C(U1)
+        assert len(graph._graph.nodes()) == 8
+        # 3 edges from decompositions to ops and 3 edges from ops to decompositions
+        assert len(graph._graph.edges()) == 6
+
+        graph.solve()
+        with qml.queuing.AnnotatedQueue() as q:
+            graph.decomposition(op)(*op.parameters, wires=op.wires, **op.hyperparameters)
+            graph.decomposition(op2)(*op2.parameters, wires=op2.wires, **op2.hyperparameters)
+
+        assert q.queue == [qml.CRX(-0.5, wires=[1, 0]), qml.ctrl(qml.U1(-0.5, wires=0), control=1)]
+
+        op3 = qml.ctrl(qml.U1(-0.5, wires=0), control=1)
+        with qml.queuing.AnnotatedQueue() as q:
+            graph.decomposition(op3)(*op3.parameters, wires=op3.wires, **op3.hyperparameters)
+
+        assert q.queue == [qml.ControlledPhaseShift(-0.5, wires=[1, 0])]
+        assert graph.resource_estimate(op2) == to_resources({qml.ControlledPhaseShift: 1})
+
+    def test_adjoint_general(self, _):
+        """Tests decomposition of a generalized adjoint operation."""
+
+        class CustomOp(qml.operation.Operation):  # pylint: disable=too-few-public-methods
+            """A custom operation."""
+
+            resource_keys = set()
+
+            @property
+            def resource_params(self):
+                return {}
+
+        @qml.register_resources({qml.H: 1, qml.CNOT: 2, qml.RX: 1, qml.T: 1})
+        def custom_decomp(phi, wires):
+            qml.H(wires[0])
+            qml.CNOT(wires=wires[:2])
+            qml.RX(phi, wires=wires[1])
+            qml.CNOT(wires=wires[1:3])
+            qml.T(wires[2])
+
+        op = qml.adjoint(CustomOp(0.5, wires=[0, 1, 2]))
+        graph = DecompositionGraph(
+            operations=[op],
+            target_gate_set={"Hadamard", "CNOT", "RX", "PhaseShift"},
+            alt_decomps={CustomOp: [custom_decomp]},
+        )
+        # 10 operator nodes: A(CustomOp), A(H), A(CNOT), A(RX), A(T), H, CNOT, RX, A(PhaseShift), PhaseShift
path: tests/decomposition/test_decomposition_graph.py
user: login: dwierichs
body: Yeah okay, that would be my take as well! So here the effective "A(T)=T" resulting from "A(PhaseShift)=PhaseShift" is considered a user-introduced equality, and it is fine because a user would have to introduce it themselves conciously. Got it :) 
created_at: 2025-03-19T15:36:49Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,183 @@
+# Copyright 2025 Xanadu Quantum Technologies Inc.
+
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+
+#     http://www.apache.org/licenses/LICENSE-2.0
+
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Tests the decomposition rules defined for symbolic operations other than controlled."""
+
+import pytest
+
+import pennylane as qml
+from pennylane.decomposition.resources import Resources
+from pennylane.decomposition.symbolic_decomposition import (
+    AdjointDecomp,
+    adjoint_adjoint_decomp,
+    adjoint_controlled_decomp,
+    has_adjoint_decomp,
+)
+from tests.decomposition.conftest import to_resources
+
+
+class TestAdjointDecompositionRules:
+    """Tests the decomposition rules defined for the adjoint of operations."""
+
+    def test_adjoint_adjoint(self):
+        """Tests that the adjoint of an adjoint cancels out."""
+
+        op = qml.adjoint(qml.adjoint(qml.RX(0.5, wires=0)))
+
+        with qml.queuing.AnnotatedQueue() as q:
+            adjoint_adjoint_decomp(*op.parameters, wires=op.wires, **op.hyperparameters)
+
+        assert q.queue == [qml.RX(0.5, wires=0)]
+        assert adjoint_adjoint_decomp.compute_resources(**op.resource_params) == to_resources(
+            {qml.RX: 1}
+        )
+
+    @pytest.mark.jax
+    def test_adjoint_adjoint_capture(self):
+        """Tests that the adjoint of an adjoint works with capture."""
+
+        from pennylane.tape.plxpr_conversion import CollectOpsandMeas
+
+        op = qml.adjoint(qml.adjoint(qml.RX(0.5, wires=0)))
+
+        capture_enabled = qml.capture.enabled()
+        qml.capture.enable()
+
+        def circuit():
+            adjoint_adjoint_decomp(*op.parameters, wires=op.wires, **op.hyperparameters)
+
+        plxpr = qml.capture.make_plxpr(circuit)()
+        collector = CollectOpsandMeas()
+        collector.eval(plxpr.jaxpr, plxpr.consts)
+        assert collector.state["ops"] == [qml.RX(0.5, wires=0)]
+
+        if not capture_enabled:
+            qml.capture.disable()
path: tests/decomposition/test_symbolic_decomposition.py
user: login: dwierichs
body: Ah, I noticed the function having this form but forgot that it does so because of capture :sweat_smile: Thanks! :+1:
created_at: 2025-03-19T15:37:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,202 @@
+# 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.
+
+"""This module contains special logic of decomposing symbolic operations."""
+
+from __future__ import annotations
+
+import functools
+
+import pennylane as qml
+
+from .controlled_decomposition import base_to_custom_ctrl_op
+from .decomposition_rule import DecompositionRule, register_resources
+from .resources import adjoint_resource_rep, resource_rep
+
+
+class AdjointDecomp(DecompositionRule):  # pylint: disable=too-few-public-methods
+    """The adjoint version of a decomposition rule."""
+
+    def __init__(self, base_decomposition: DecompositionRule):
+        self._base_decomposition = base_decomposition
+        super().__init__(self._get_impl(), self._get_resource_fn())
+
+    def _get_impl(self):
+        """The implementation of the adjoint of a gate."""
+
+        def _impl(*_, base, **__):
+            qml.adjoint(self._base_decomposition._impl)(  # pylint: disable=protected-access
+                *base.parameters, base.wires, **base.hyperparameters
+            )
+
+        return _impl
+
+    def _get_resource_fn(self):
+        """The resource function of the adjoint of a gate."""
+
+        def _resource_fn(base_class, base_params):  # pylint: disable=unused-argument
+            base_resources = self._base_decomposition.compute_resources(**base_params)
+            return {
+                adjoint_resource_rep(decomp_op.op_type, decomp_op.params): count
+                for decomp_op, count in base_resources.gate_counts.items()
+            }
+
+        return _resource_fn
+
+
+def _same_type_adjoint_resource(base_class, base_params):
+    """Resources of the adjoint of a gate whose adjoint is an instance of its own type."""
+    # This assumes that the adjoint of the gate has the same resources as the gate itself.
+    return {resource_rep(base_class, **base_params): 1}
+
+
+@register_resources(_same_type_adjoint_resource)
+def has_adjoint_decomp(*_, base, **__):
+    """Decompose the adjoint of a gate whose adjoint is an instance of its own type."""
+    base.adjoint()
+
+
+def _adjoint_adjoint_resource(*_, base_params, **__):
+    """Resources of the adjoint of the adjoint of a gate."""
+    base_class, base_params = base_params["base_class"], base_params["base_params"]
+    return {resource_rep(base_class, **base_params): 1}
+
+
+@register_resources(_adjoint_adjoint_resource)
+def adjoint_adjoint_decomp(*_, wires, base):  # pylint: disable=unused-argument
+    """Decompose the adjoint of the adjoint of a gate."""
+    base.base._unflatten(*base.base._flatten())  # pylint: disable=protected-access
+
+
+def _adjoint_controlled_resource(base_class, base_params):
+    """Resources of the adjoint of a controlled gate whose base has adjoint."""
+
+    num_control_wires = base_params["num_control_wires"]
+    controlled_base_class = base_params["base_class"]
+
+    # Handle controlled-X gates, the adjoint is just themselves
+    if controlled_base_class is qml.X:
+        if num_control_wires == 1:
+            return {resource_rep(qml.CNOT): 1}
+        if num_control_wires == 2:
+            return {resource_rep(qml.Toffoli): 1}
+        return {
+            resource_rep(
+                qml.MultiControlledX,
+                num_control_wires=num_control_wires,
+                num_zero_control_values=base_params["num_zero_control_values"],
+                num_work_wires=base_params["num_work_wires"],
+            ): 1
+        }
+
+    # Handle custom controlled gates. The adjoint of a general controlled operator that
+    # is equivalent to a custom controlled operator should just be the custom controlled
+    # operator given that its base has_adjoint.
+    if (controlled_base_class, num_control_wires) in base_to_custom_ctrl_op():
+        custom_op_type = base_to_custom_ctrl_op()[(controlled_base_class, num_control_wires)]
path: pennylane/decomposition/symbolic_decomposition.py
user: login: PietropaoloFrisoni
body: ```suggestion
    custom_ctrl_ops = base_to_custom_ctrl_op()
    if (controlled_base_class, num_control_wires) in custom_ctrl_ops:
        custom_op_type = custom_ctrl_ops[(controlled_base_class, num_control_wires)]
```
created_at: 2025-03-19T16:03:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -221,7 +221,12 @@ def controlled_x_decomp(*_, wires, control_wires, control_values, work_wires, **
 
 @functools.lru_cache()
path: pennylane/decomposition/controlled_decomposition.py
user: login: PietropaoloFrisoni
body: ```suggestion
@functools.lru_cache(maxsize=1)
```
created_at: 2025-03-19T16:05:39Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,202 @@
+# 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.
+
+"""This module contains special logic of decomposing symbolic operations."""
+
+from __future__ import annotations
+
+import functools
+
+import pennylane as qml
+
+from .controlled_decomposition import base_to_custom_ctrl_op
+from .decomposition_rule import DecompositionRule, register_resources
+from .resources import adjoint_resource_rep, resource_rep
+
+
+class AdjointDecomp(DecompositionRule):  # pylint: disable=too-few-public-methods
+    """The adjoint version of a decomposition rule."""
+
+    def __init__(self, base_decomposition: DecompositionRule):
+        self._base_decomposition = base_decomposition
+        super().__init__(self._get_impl(), self._get_resource_fn())
+
+    def _get_impl(self):
+        """The implementation of the adjoint of a gate."""
+
+        def _impl(*_, base, **__):
+            qml.adjoint(self._base_decomposition._impl)(  # pylint: disable=protected-access
+                *base.parameters, base.wires, **base.hyperparameters
+            )
+
+        return _impl
+
+    def _get_resource_fn(self):
+        """The resource function of the adjoint of a gate."""
+
+        def _resource_fn(base_class, base_params):  # pylint: disable=unused-argument
+            base_resources = self._base_decomposition.compute_resources(**base_params)
+            return {
+                adjoint_resource_rep(decomp_op.op_type, decomp_op.params): count
+                for decomp_op, count in base_resources.gate_counts.items()
+            }
+
+        return _resource_fn
+
+
+def _same_type_adjoint_resource(base_class, base_params):
+    """Resources of the adjoint of a gate whose adjoint is an instance of its own type."""
+    # This assumes that the adjoint of the gate has the same resources as the gate itself.
+    return {resource_rep(base_class, **base_params): 1}
+
+
+@register_resources(_same_type_adjoint_resource)
+def has_adjoint_decomp(*_, base, **__):
+    """Decompose the adjoint of a gate whose adjoint is an instance of its own type."""
+    base.adjoint()
+
+
+def _adjoint_adjoint_resource(*_, base_params, **__):
+    """Resources of the adjoint of the adjoint of a gate."""
+    base_class, base_params = base_params["base_class"], base_params["base_params"]
+    return {resource_rep(base_class, **base_params): 1}
+
+
+@register_resources(_adjoint_adjoint_resource)
+def adjoint_adjoint_decomp(*_, wires, base):  # pylint: disable=unused-argument
+    """Decompose the adjoint of the adjoint of a gate."""
+    base.base._unflatten(*base.base._flatten())  # pylint: disable=protected-access
+
+
+def _adjoint_controlled_resource(base_class, base_params):
+    """Resources of the adjoint of a controlled gate whose base has adjoint."""
+
+    num_control_wires = base_params["num_control_wires"]
+    controlled_base_class = base_params["base_class"]
+
+    # Handle controlled-X gates, the adjoint is just themselves
+    if controlled_base_class is qml.X:
+        if num_control_wires == 1:
+            return {resource_rep(qml.CNOT): 1}
+        if num_control_wires == 2:
+            return {resource_rep(qml.Toffoli): 1}
+        return {
+            resource_rep(
+                qml.MultiControlledX,
+                num_control_wires=num_control_wires,
+                num_zero_control_values=base_params["num_zero_control_values"],
+                num_work_wires=base_params["num_work_wires"],
+            ): 1
+        }
+
+    # Handle custom controlled gates. The adjoint of a general controlled operator that
+    # is equivalent to a custom controlled operator should just be the custom controlled
+    # operator given that its base has_adjoint.
+    if (controlled_base_class, num_control_wires) in base_to_custom_ctrl_op():
+        custom_op_type = base_to_custom_ctrl_op()[(controlled_base_class, num_control_wires)]
+        # All gates in base_to_custom_ctrl_op do not have resource params.
+        return {resource_rep(custom_op_type): 1}
+
+    # Handle the general case, here we assume that the adjoint of a controlled gate
+    # whose base has an adjoint that is of its own type, should have the same resource
+    # rep as the controlled gate itself. For example, Adjoint(Controlled(O)) should
+    # have the same resources as Controlled(O) if the adjoint of O is another O.
+    return {resource_rep(base_class, **base_params): 1}
+
+
+@register_resources(_adjoint_controlled_resource)
+def adjoint_controlled_decomp(*_, base, **__):
+    """Decompose the adjoint of a controlled gate whose base has adjoint.
+
+    Precondition:
+    - isinstance(base, qml.ops.Controlled) and base.base.has_adjoint
+
+    """
+    qml.ctrl(
+        base.base.adjoint(),
+        control=base.control_wires,
+        control_values=base.control_values,
+        work_wires=base.work_wires,
+    )
+
+
+@functools.lru_cache()
path: pennylane/decomposition/symbolic_decomposition.py
user: login: PietropaoloFrisoni
body: ```suggestion
@functools.lru_cache(maxsize=1)
```
created_at: 2025-03-19T16:05:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,202 @@
+# 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.
+
+"""This module contains special logic of decomposing symbolic operations."""
+
+from __future__ import annotations
+
+import functools
+
+import pennylane as qml
+
+from .controlled_decomposition import base_to_custom_ctrl_op
+from .decomposition_rule import DecompositionRule, register_resources
+from .resources import adjoint_resource_rep, resource_rep
+
+
+class AdjointDecomp(DecompositionRule):  # pylint: disable=too-few-public-methods
+    """The adjoint version of a decomposition rule."""
+
+    def __init__(self, base_decomposition: DecompositionRule):
+        self._base_decomposition = base_decomposition
+        super().__init__(self._get_impl(), self._get_resource_fn())
+
+    def _get_impl(self):
+        """The implementation of the adjoint of a gate."""
+
+        def _impl(*_, base, **__):
+            qml.adjoint(self._base_decomposition._impl)(  # pylint: disable=protected-access
+                *base.parameters, base.wires, **base.hyperparameters
+            )
+
+        return _impl
+
+    def _get_resource_fn(self):
+        """The resource function of the adjoint of a gate."""
+
+        def _resource_fn(base_class, base_params):  # pylint: disable=unused-argument
+            base_resources = self._base_decomposition.compute_resources(**base_params)
+            return {
+                adjoint_resource_rep(decomp_op.op_type, decomp_op.params): count
+                for decomp_op, count in base_resources.gate_counts.items()
+            }
+
+        return _resource_fn
+
+
+def _same_type_adjoint_resource(base_class, base_params):
+    """Resources of the adjoint of a gate whose adjoint is an instance of its own type."""
+    # This assumes that the adjoint of the gate has the same resources as the gate itself.
+    return {resource_rep(base_class, **base_params): 1}
+
+
+@register_resources(_same_type_adjoint_resource)
+def has_adjoint_decomp(*_, base, **__):
+    """Decompose the adjoint of a gate whose adjoint is an instance of its own type."""
+    base.adjoint()
+
+
+def _adjoint_adjoint_resource(*_, base_params, **__):
+    """Resources of the adjoint of the adjoint of a gate."""
+    base_class, base_params = base_params["base_class"], base_params["base_params"]
+    return {resource_rep(base_class, **base_params): 1}
+
+
+@register_resources(_adjoint_adjoint_resource)
+def adjoint_adjoint_decomp(*_, wires, base):  # pylint: disable=unused-argument
+    """Decompose the adjoint of the adjoint of a gate."""
+    base.base._unflatten(*base.base._flatten())  # pylint: disable=protected-access
+
+
+def _adjoint_controlled_resource(base_class, base_params):
+    """Resources of the adjoint of a controlled gate whose base has adjoint."""
+
+    num_control_wires = base_params["num_control_wires"]
+    controlled_base_class = base_params["base_class"]
+
+    # Handle controlled-X gates, the adjoint is just themselves
+    if controlled_base_class is qml.X:
+        if num_control_wires == 1:
+            return {resource_rep(qml.CNOT): 1}
+        if num_control_wires == 2:
+            return {resource_rep(qml.Toffoli): 1}
+        return {
+            resource_rep(
+                qml.MultiControlledX,
+                num_control_wires=num_control_wires,
+                num_zero_control_values=base_params["num_zero_control_values"],
+                num_work_wires=base_params["num_work_wires"],
+            ): 1
+        }
+
+    # Handle custom controlled gates. The adjoint of a general controlled operator that
+    # is equivalent to a custom controlled operator should just be the custom controlled
+    # operator given that its base has_adjoint.
+    if (controlled_base_class, num_control_wires) in base_to_custom_ctrl_op():
+        custom_op_type = base_to_custom_ctrl_op()[(controlled_base_class, num_control_wires)]
+        # All gates in base_to_custom_ctrl_op do not have resource params.
+        return {resource_rep(custom_op_type): 1}
+
+    # Handle the general case, here we assume that the adjoint of a controlled gate
+    # whose base has an adjoint that is of its own type, should have the same resource
+    # rep as the controlled gate itself. For example, Adjoint(Controlled(O)) should
+    # have the same resources as Controlled(O) if the adjoint of O is another O.
+    return {resource_rep(base_class, **base_params): 1}
+
+
+@register_resources(_adjoint_controlled_resource)
+def adjoint_controlled_decomp(*_, base, **__):
+    """Decompose the adjoint of a controlled gate whose base has adjoint.
+
+    Precondition:
+    - isinstance(base, qml.ops.Controlled) and base.base.has_adjoint
+
+    """
+    qml.ctrl(
+        base.base.adjoint(),
+        control=base.control_wires,
+        control_values=base.control_values,
+        work_wires=base.work_wires,
+    )
+
+
+@functools.lru_cache()
+def has_adjoint_ops():
path: pennylane/decomposition/symbolic_decomposition.py
user: login: PietropaoloFrisoni
body: So we are memoizing `has_adjoint_ops()` for efficiency, which returns a mutable data structure. I imagine that if the cached set is accidentally modified elsewhere, it modifies the cached result. Should we return a `frozenset` instead?
created_at: 2025-03-19T16:10:09Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,202 @@
+# 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.
+
+"""This module contains special logic of decomposing symbolic operations."""
+
+from __future__ import annotations
+
+import functools
+
+import pennylane as qml
+
+from .controlled_decomposition import base_to_custom_ctrl_op
+from .decomposition_rule import DecompositionRule, register_resources
+from .resources import adjoint_resource_rep, resource_rep
+
+
+class AdjointDecomp(DecompositionRule):  # pylint: disable=too-few-public-methods
+    """The adjoint version of a decomposition rule."""
+
+    def __init__(self, base_decomposition: DecompositionRule):
+        self._base_decomposition = base_decomposition
+        super().__init__(self._get_impl(), self._get_resource_fn())
+
+    def _get_impl(self):
+        """The implementation of the adjoint of a gate."""
+
+        def _impl(*_, base, **__):
+            qml.adjoint(self._base_decomposition._impl)(  # pylint: disable=protected-access
+                *base.parameters, base.wires, **base.hyperparameters
+            )
+
+        return _impl
+
+    def _get_resource_fn(self):
+        """The resource function of the adjoint of a gate."""
+
+        def _resource_fn(base_class, base_params):  # pylint: disable=unused-argument
+            base_resources = self._base_decomposition.compute_resources(**base_params)
+            return {
+                adjoint_resource_rep(decomp_op.op_type, decomp_op.params): count
+                for decomp_op, count in base_resources.gate_counts.items()
+            }
+
+        return _resource_fn
+
+
+def _same_type_adjoint_resource(base_class, base_params):
+    """Resources of the adjoint of a gate whose adjoint is an instance of its own type."""
+    # This assumes that the adjoint of the gate has the same resources as the gate itself.
+    return {resource_rep(base_class, **base_params): 1}
+
+
+@register_resources(_same_type_adjoint_resource)
+def has_adjoint_decomp(*_, base, **__):
+    """Decompose the adjoint of a gate whose adjoint is an instance of its own type."""
+    base.adjoint()
+
+
+def _adjoint_adjoint_resource(*_, base_params, **__):
+    """Resources of the adjoint of the adjoint of a gate."""
+    base_class, base_params = base_params["base_class"], base_params["base_params"]
+    return {resource_rep(base_class, **base_params): 1}
path: pennylane/decomposition/symbolic_decomposition.py
user: login: astralcai
body: I'll add a comment instead 😅 
created_at: 2025-03-19T20:18:25Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,202 @@
+# 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.
+
+"""This module contains special logic of decomposing symbolic operations."""
+
+from __future__ import annotations
+
+import functools
+
+import pennylane as qml
+
+from .controlled_decomposition import base_to_custom_ctrl_op
+from .decomposition_rule import DecompositionRule, register_resources
+from .resources import adjoint_resource_rep, resource_rep
+
+
+class AdjointDecomp(DecompositionRule):  # pylint: disable=too-few-public-methods
+    """The adjoint version of a decomposition rule."""
+
+    def __init__(self, base_decomposition: DecompositionRule):
+        self._base_decomposition = base_decomposition
+        super().__init__(self._get_impl(), self._get_resource_fn())
+
+    def _get_impl(self):
+        """The implementation of the adjoint of a gate."""
+
+        def _impl(*_, base, **__):
+            qml.adjoint(self._base_decomposition._impl)(  # pylint: disable=protected-access
+                *base.parameters, base.wires, **base.hyperparameters
+            )
+
+        return _impl
+
+    def _get_resource_fn(self):
+        """The resource function of the adjoint of a gate."""
+
+        def _resource_fn(base_class, base_params):  # pylint: disable=unused-argument
+            base_resources = self._base_decomposition.compute_resources(**base_params)
+            return {
+                adjoint_resource_rep(decomp_op.op_type, decomp_op.params): count
+                for decomp_op, count in base_resources.gate_counts.items()
+            }
+
+        return _resource_fn
+
+
+def _same_type_adjoint_resource(base_class, base_params):
+    """Resources of the adjoint of a gate whose adjoint is an instance of its own type."""
+    # This assumes that the adjoint of the gate has the same resources as the gate itself.
+    return {resource_rep(base_class, **base_params): 1}
+
+
+@register_resources(_same_type_adjoint_resource)
+def has_adjoint_decomp(*_, base, **__):
+    """Decompose the adjoint of a gate whose adjoint is an instance of its own type."""
+    base.adjoint()
+
+
+def _adjoint_adjoint_resource(*_, base_params, **__):
+    """Resources of the adjoint of the adjoint of a gate."""
+    base_class, base_params = base_params["base_class"], base_params["base_params"]
+    return {resource_rep(base_class, **base_params): 1}
+
+
+@register_resources(_adjoint_adjoint_resource)
+def adjoint_adjoint_decomp(*_, wires, base):  # pylint: disable=unused-argument
+    """Decompose the adjoint of the adjoint of a gate."""
+    base.base._unflatten(*base.base._flatten())  # pylint: disable=protected-access
+
+
+def _adjoint_controlled_resource(base_class, base_params):
+    """Resources of the adjoint of a controlled gate whose base has adjoint."""
+
+    num_control_wires = base_params["num_control_wires"]
+    controlled_base_class = base_params["base_class"]
+
+    # Handle controlled-X gates, the adjoint is just themselves
+    if controlled_base_class is qml.X:
+        if num_control_wires == 1:
+            return {resource_rep(qml.CNOT): 1}
+        if num_control_wires == 2:
+            return {resource_rep(qml.Toffoli): 1}
+        return {
+            resource_rep(
+                qml.MultiControlledX,
+                num_control_wires=num_control_wires,
+                num_zero_control_values=base_params["num_zero_control_values"],
+                num_work_wires=base_params["num_work_wires"],
+            ): 1
+        }
+
+    # Handle custom controlled gates. The adjoint of a general controlled operator that
+    # is equivalent to a custom controlled operator should just be the custom controlled
+    # operator given that its base has_adjoint.
+    if (controlled_base_class, num_control_wires) in base_to_custom_ctrl_op():
+        custom_op_type = base_to_custom_ctrl_op()[(controlled_base_class, num_control_wires)]
+        # All gates in base_to_custom_ctrl_op do not have resource params.
+        return {resource_rep(custom_op_type): 1}
+
+    # Handle the general case, here we assume that the adjoint of a controlled gate
+    # whose base has an adjoint that is of its own type, should have the same resource
+    # rep as the controlled gate itself. For example, Adjoint(Controlled(O)) should
+    # have the same resources as Controlled(O) if the adjoint of O is another O.
+    return {resource_rep(base_class, **base_params): 1}
path: pennylane/decomposition/symbolic_decomposition.py
user: login: astralcai
body: Good point...
created_at: 2025-03-19T20:18:39Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,202 @@
+# 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.
+
+"""This module contains special logic of decomposing symbolic operations."""
+
+from __future__ import annotations
+
+import functools
+
+import pennylane as qml
+
+from .controlled_decomposition import base_to_custom_ctrl_op
+from .decomposition_rule import DecompositionRule, register_resources
+from .resources import adjoint_resource_rep, resource_rep
+
+
+class AdjointDecomp(DecompositionRule):  # pylint: disable=too-few-public-methods
+    """The adjoint version of a decomposition rule."""
+
+    def __init__(self, base_decomposition: DecompositionRule):
+        self._base_decomposition = base_decomposition
+        super().__init__(self._get_impl(), self._get_resource_fn())
+
+    def _get_impl(self):
+        """The implementation of the adjoint of a gate."""
+
+        def _impl(*_, base, **__):
+            qml.adjoint(self._base_decomposition._impl)(  # pylint: disable=protected-access
+                *base.parameters, base.wires, **base.hyperparameters
+            )
+
+        return _impl
+
+    def _get_resource_fn(self):
+        """The resource function of the adjoint of a gate."""
+
+        def _resource_fn(base_class, base_params):  # pylint: disable=unused-argument
+            base_resources = self._base_decomposition.compute_resources(**base_params)
+            return {
+                adjoint_resource_rep(decomp_op.op_type, decomp_op.params): count
+                for decomp_op, count in base_resources.gate_counts.items()
+            }
+
+        return _resource_fn
+
+
+def _same_type_adjoint_resource(base_class, base_params):
+    """Resources of the adjoint of a gate whose adjoint is an instance of its own type."""
+    # This assumes that the adjoint of the gate has the same resources as the gate itself.
+    return {resource_rep(base_class, **base_params): 1}
+
+
+@register_resources(_same_type_adjoint_resource)
+def has_adjoint_decomp(*_, base, **__):
+    """Decompose the adjoint of a gate whose adjoint is an instance of its own type."""
+    base.adjoint()
+
+
+def _adjoint_adjoint_resource(*_, base_params, **__):
+    """Resources of the adjoint of the adjoint of a gate."""
+    base_class, base_params = base_params["base_class"], base_params["base_params"]
+    return {resource_rep(base_class, **base_params): 1}
+
+
+@register_resources(_adjoint_adjoint_resource)
+def adjoint_adjoint_decomp(*_, wires, base):  # pylint: disable=unused-argument
+    """Decompose the adjoint of the adjoint of a gate."""
+    base.base._unflatten(*base.base._flatten())  # pylint: disable=protected-access
+
+
+def _adjoint_controlled_resource(base_class, base_params):
+    """Resources of the adjoint of a controlled gate whose base has adjoint."""
+
+    num_control_wires = base_params["num_control_wires"]
+    controlled_base_class = base_params["base_class"]
+
+    # Handle controlled-X gates, the adjoint is just themselves
+    if controlled_base_class is qml.X:
+        if num_control_wires == 1:
+            return {resource_rep(qml.CNOT): 1}
+        if num_control_wires == 2:
+            return {resource_rep(qml.Toffoli): 1}
+        return {
+            resource_rep(
+                qml.MultiControlledX,
+                num_control_wires=num_control_wires,
+                num_zero_control_values=base_params["num_zero_control_values"],
+                num_work_wires=base_params["num_work_wires"],
+            ): 1
+        }
+
+    # Handle custom controlled gates. The adjoint of a general controlled operator that
+    # is equivalent to a custom controlled operator should just be the custom controlled
+    # operator given that its base has_adjoint.
+    if (controlled_base_class, num_control_wires) in base_to_custom_ctrl_op():
+        custom_op_type = base_to_custom_ctrl_op()[(controlled_base_class, num_control_wires)]
+        # All gates in base_to_custom_ctrl_op do not have resource params.
+        return {resource_rep(custom_op_type): 1}
+
+    # Handle the general case, here we assume that the adjoint of a controlled gate
+    # whose base has an adjoint that is of its own type, should have the same resource
+    # rep as the controlled gate itself. For example, Adjoint(Controlled(O)) should
+    # have the same resources as Controlled(O) if the adjoint of O is another O.
+    return {resource_rep(base_class, **base_params): 1}
path: pennylane/decomposition/symbolic_decomposition.py
user: login: astralcai
body: Not sure how to go about it though. The corresponding logic is scattered in different places in `controlled_decompositions`.
created_at: 2025-03-19T20:21:06Z
author_association: CONTRIBUTOR
diff_hunk: @@ -221,7 +221,12 @@ def controlled_x_decomp(*_, wires, control_wires, control_values, work_wires, **
 
 @functools.lru_cache()
 def base_to_custom_ctrl_op():
-    """A dictionary mapping base op types to their custom controlled versions."""
+    """A dictionary mapping base op types to their custom controlled versions.
+
+    This dictionary is used under the assumption that all custom controlled operations do not
+    have resource params (which is why `ControlledQubitUnitary` is not included here).
path: pennylane/decomposition/controlled_decomposition.py
user: login: astralcai
body: I think `ControlledQubitUnitary` is the only exception here.
created_at: 2025-03-19T20:26:01Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,202 @@
+# 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.
+
+"""This module contains special logic of decomposing symbolic operations."""
+
+from __future__ import annotations
+
+import functools
+
+import pennylane as qml
+
+from .controlled_decomposition import base_to_custom_ctrl_op
+from .decomposition_rule import DecompositionRule, register_resources
+from .resources import adjoint_resource_rep, resource_rep
+
+
+class AdjointDecomp(DecompositionRule):  # pylint: disable=too-few-public-methods
+    """The adjoint version of a decomposition rule."""
+
+    def __init__(self, base_decomposition: DecompositionRule):
+        self._base_decomposition = base_decomposition
+        super().__init__(self._get_impl(), self._get_resource_fn())
+
+    def _get_impl(self):
+        """The implementation of the adjoint of a gate."""
+
+        def _impl(*_, base, **__):
+            qml.adjoint(self._base_decomposition._impl)(  # pylint: disable=protected-access
+                *base.parameters, base.wires, **base.hyperparameters
+            )
+
+        return _impl
+
+    def _get_resource_fn(self):
+        """The resource function of the adjoint of a gate."""
+
+        def _resource_fn(base_class, base_params):  # pylint: disable=unused-argument
+            base_resources = self._base_decomposition.compute_resources(**base_params)
+            return {
+                adjoint_resource_rep(decomp_op.op_type, decomp_op.params): count
+                for decomp_op, count in base_resources.gate_counts.items()
+            }
+
+        return _resource_fn
+
+
+def _same_type_adjoint_resource(base_class, base_params):
+    """Resources of the adjoint of a gate whose adjoint is an instance of its own type."""
+    # This assumes that the adjoint of the gate has the same resources as the gate itself.
+    return {resource_rep(base_class, **base_params): 1}
+
+
+@register_resources(_same_type_adjoint_resource)
+def has_adjoint_decomp(*_, base, **__):
+    """Decompose the adjoint of a gate whose adjoint is an instance of its own type."""
+    base.adjoint()
+
+
+def _adjoint_adjoint_resource(*_, base_params, **__):
+    """Resources of the adjoint of the adjoint of a gate."""
+    base_class, base_params = base_params["base_class"], base_params["base_params"]
+    return {resource_rep(base_class, **base_params): 1}
+
+
+@register_resources(_adjoint_adjoint_resource)
+def adjoint_adjoint_decomp(*_, wires, base):  # pylint: disable=unused-argument
+    """Decompose the adjoint of the adjoint of a gate."""
+    base.base._unflatten(*base.base._flatten())  # pylint: disable=protected-access
+
+
+def _adjoint_controlled_resource(base_class, base_params):
+    """Resources of the adjoint of a controlled gate whose base has adjoint."""
+
+    num_control_wires = base_params["num_control_wires"]
+    controlled_base_class = base_params["base_class"]
+
+    # Handle controlled-X gates, the adjoint is just themselves
+    if controlled_base_class is qml.X:
+        if num_control_wires == 1:
+            return {resource_rep(qml.CNOT): 1}
+        if num_control_wires == 2:
+            return {resource_rep(qml.Toffoli): 1}
+        return {
+            resource_rep(
+                qml.MultiControlledX,
+                num_control_wires=num_control_wires,
+                num_zero_control_values=base_params["num_zero_control_values"],
+                num_work_wires=base_params["num_work_wires"],
+            ): 1
+        }
+
+    # Handle custom controlled gates. The adjoint of a general controlled operator that
+    # is equivalent to a custom controlled operator should just be the custom controlled
+    # operator given that its base has_adjoint.
+    if (controlled_base_class, num_control_wires) in base_to_custom_ctrl_op():
+        custom_op_type = base_to_custom_ctrl_op()[(controlled_base_class, num_control_wires)]
+        # All gates in base_to_custom_ctrl_op do not have resource params.
+        return {resource_rep(custom_op_type): 1}
+
+    # Handle the general case, here we assume that the adjoint of a controlled gate
+    # whose base has an adjoint that is of its own type, should have the same resource
+    # rep as the controlled gate itself. For example, Adjoint(Controlled(O)) should
+    # have the same resources as Controlled(O) if the adjoint of O is another O.
+    return {resource_rep(base_class, **base_params): 1}
+
+
+@register_resources(_adjoint_controlled_resource)
+def adjoint_controlled_decomp(*_, base, **__):
+    """Decompose the adjoint of a controlled gate whose base has adjoint.
+
+    Precondition:
+    - isinstance(base, qml.ops.Controlled) and base.base.has_adjoint
+
+    """
+    qml.ctrl(
+        base.base.adjoint(),
+        control=base.control_wires,
+        control_values=base.control_values,
+        work_wires=base.work_wires,
+    )
+
+
+@functools.lru_cache()
+def has_adjoint_ops():
path: pennylane/decomposition/symbolic_decomposition.py
user: login: astralcai
body: Done
created_at: 2025-03-19T20:27:13Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,202 @@
+# 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.
+
+"""This module contains special logic of decomposing symbolic operations."""
+
+from __future__ import annotations
+
+import functools
+
+import pennylane as qml
+
+from .controlled_decomposition import base_to_custom_ctrl_op
+from .decomposition_rule import DecompositionRule, register_resources
+from .resources import adjoint_resource_rep, resource_rep
+
+
+class AdjointDecomp(DecompositionRule):  # pylint: disable=too-few-public-methods
+    """The adjoint version of a decomposition rule."""
+
+    def __init__(self, base_decomposition: DecompositionRule):
+        self._base_decomposition = base_decomposition
+        super().__init__(self._get_impl(), self._get_resource_fn())
+
+    def _get_impl(self):
+        """The implementation of the adjoint of a gate."""
+
+        def _impl(*_, base, **__):
+            qml.adjoint(self._base_decomposition._impl)(  # pylint: disable=protected-access
+                *base.parameters, base.wires, **base.hyperparameters
+            )
+
+        return _impl
+
+    def _get_resource_fn(self):
+        """The resource function of the adjoint of a gate."""
+
+        def _resource_fn(base_class, base_params):  # pylint: disable=unused-argument
+            base_resources = self._base_decomposition.compute_resources(**base_params)
+            return {
+                adjoint_resource_rep(decomp_op.op_type, decomp_op.params): count
+                for decomp_op, count in base_resources.gate_counts.items()
+            }
+
+        return _resource_fn
+
+
+def _same_type_adjoint_resource(base_class, base_params):
+    """Resources of the adjoint of a gate whose adjoint is an instance of its own type."""
+    # This assumes that the adjoint of the gate has the same resources as the gate itself.
+    return {resource_rep(base_class, **base_params): 1}
+
+
+@register_resources(_same_type_adjoint_resource)
+def has_adjoint_decomp(*_, base, **__):
+    """Decompose the adjoint of a gate whose adjoint is an instance of its own type."""
+    base.adjoint()
+
+
+def _adjoint_adjoint_resource(*_, base_params, **__):
+    """Resources of the adjoint of the adjoint of a gate."""
+    base_class, base_params = base_params["base_class"], base_params["base_params"]
+    return {resource_rep(base_class, **base_params): 1}
+
+
+@register_resources(_adjoint_adjoint_resource)
+def adjoint_adjoint_decomp(*_, wires, base):  # pylint: disable=unused-argument
+    """Decompose the adjoint of the adjoint of a gate."""
+    base.base._unflatten(*base.base._flatten())  # pylint: disable=protected-access
+
+
+def _adjoint_controlled_resource(base_class, base_params):
+    """Resources of the adjoint of a controlled gate whose base has adjoint."""
+
+    num_control_wires = base_params["num_control_wires"]
+    controlled_base_class = base_params["base_class"]
+
+    # Handle controlled-X gates, the adjoint is just themselves
+    if controlled_base_class is qml.X:
+        if num_control_wires == 1:
+            return {resource_rep(qml.CNOT): 1}
+        if num_control_wires == 2:
+            return {resource_rep(qml.Toffoli): 1}
+        return {
+            resource_rep(
+                qml.MultiControlledX,
+                num_control_wires=num_control_wires,
+                num_zero_control_values=base_params["num_zero_control_values"],
+                num_work_wires=base_params["num_work_wires"],
+            ): 1
+        }
+
+    # Handle custom controlled gates. The adjoint of a general controlled operator that
+    # is equivalent to a custom controlled operator should just be the custom controlled
+    # operator given that its base has_adjoint.
+    if (controlled_base_class, num_control_wires) in base_to_custom_ctrl_op():
+        custom_op_type = base_to_custom_ctrl_op()[(controlled_base_class, num_control_wires)]
+        # All gates in base_to_custom_ctrl_op do not have resource params.
+        return {resource_rep(custom_op_type): 1}
+
+    # Handle the general case, here we assume that the adjoint of a controlled gate
+    # whose base has an adjoint that is of its own type, should have the same resource
+    # rep as the controlled gate itself. For example, Adjoint(Controlled(O)) should
+    # have the same resources as Controlled(O) if the adjoint of O is another O.
+    return {resource_rep(base_class, **base_params): 1}
path: pennylane/decomposition/symbolic_decomposition.py
user: login: dwierichs
body: Hm, yeah. It's not clear to me either. 
created_at: 2025-03-20T11:53:22Z
author_association: CONTRIBUTOR

PR comments:
user: login: github-actions[bot]
created_at: 2025-03-07T16:20:53Z
body: Hello. You may have forgotten to update the changelog!
Please edit [`doc/releases/changelog-dev.md`](/PennyLaneAI/pennylane/blob/master/doc/releases/changelog-dev.md) with:
* A one-to-two sentence description of the change. You may include a small working example for new features.
* A link back to this PR.
* Your name (or GitHub username) in the contributors section.
author_association: CONTRIBUTOR
user: login: codecov[bot]
created_at: 2025-03-13T20:49:45Z
body: ## [Codecov](https://app.codecov.io/gh/PennyLaneAI/pennylane/pull/7058?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennyLaneAI) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 99.62%. Comparing base [(`35f705f`)](https://app.codecov.io/gh/PennyLaneAI/pennylane/commit/35f705f6494425704257d0e5479ee8fbb48a3cc6?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennyLaneAI) to head [(`dd3ff3b`)](https://app.codecov.io/gh/PennyLaneAI/pennylane/commit/dd3ff3b0f7446ba957b044267894d4c8a8cfe8c1?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennyLaneAI).
> Report is 347 commits behind head on master.

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


```diff
@@           Coverage Diff            @@
##           master    #7058    +/-   ##
========================================
  Coverage   99.62%   99.62%            
========================================
  Files         503      505     +2     
  Lines       49024    49254   +230     
========================================
+ Hits        48839    49069   +230     
  Misses        185      185            
```

</details>

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

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

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