Repository: PennyLaneAI/pennylane

Issue:
[BUG] Specs does not take into account transforms
### Expected behavior

Transformes applied to a QNode should influence the values output by `qml.specs()`.

### Actual behavior

Very similar to the (now fixed) bug https://github.com/PennyLaneAI/pennylane/issues/5275, this does not seem to happen.

### Additional information

The attached source code tests that the returned specs change when the transform is applied. The code was adopted from https://github.com/PennyLaneAI/pennylane/issues/5275. Contrary to that issue, the problem here also occurs with the default device.

### Source code

```shell
from typing import Callable, Sequence

import pennylane as qml
try:
    from pennylane.transforms import transform
except ImportError: # pennylane < 0.31
    from pennylane.transforms import batch_transform as transform

from pennylane.tape import QuantumTape

from pennylane import Device
from pennylane import numpy as np

class MyDevice(Device):
    author = "Christian Gogolin"
    pennylane_requires = ">=0.29.1"
    name = "MyDevice"
    short_name = "my.device"
    version = "0.1.0"

    def __init__(self, wires, *args, **kwargs):
        if wires != 1:
            raise NotImplementedError()
        self._state = None
        super().__init__(wires, *args, **kwargs)

    def apply(self, operation, wires, par):
        self._state = qml.matrix(getattr(qml, operation))(0) @ self._state

    def expval(self, observable, wires, par):
        return float(np.real(np.dot(self._state.conj().T, qml.matrix(qml.PauliZ)(0).dot(self._state))).item())

    @property
    def observables(self):
        return ["PauliZ"]

    @property
    def operations(self):
        return ["PauliX", "PauliY"]

    def reset(self):
        self._state = np.array([1., 0.])

for dev in [qml.device("default.qubit", wires=1), MyDevice(wires=1)]:

    @qml.qnode(dev)
    def qnode():
        qml.PauliX(0)
        return qml.expval(qml.PauliZ(0))

    @transform
    def prepend_y(tape: QuantumTape) -> (Sequence[QuantumTape], Callable):

        try:
            new_tape = type(tape)([qml.PauliY(0)] + tape.operations, tape.measurements, shots=tape.shots)
        except AttributeError: # pennylane < 0.31
            new_tape = type(tape)([qml.PauliY(0)] + tape.operations, tape.measurements)

        def postprocessing(results):
            return results[0]

        return [new_tape], postprocessing

    print(dev)
    print(qml.draw(qnode)())
    res = qnode()
    print(res, type(res))
    print(qml.draw(prepend_y(qnode))())
    res2 = prepend_y(qnode)()
    print(res2, type(res2))

    print("specs:")
    print(qml.specs(qnode)())
    print(qml.specs(prepend_y(qnode))())

    assert qml.specs(qnode)()["resources"].depth == 1
    assert qml.specs(prepend_y(qnode))()["resources"].depth == 2
```


### Tracebacks

_No response_

### System information

```shell
Name: PennyLane
Version: 0.36.0
Summary: PennyLane is a cross-platform Python library for quantum computing, quantum machine learning, and quantum chemistry. Train a quantum computer the same way as a neural network.
Home-page: https://github.com/PennyLaneAI/pennylane
Author:
Author-email:
License: Apache License 2.0
Location: /fs/home/cvjjm/.conda/envs/hfak/lib/python3.9/site-packages
Requires: appdirs, autograd, autoray, cachetools, networkx, numpy, pennylane-lightning, requests, rustworkx, scipy, semantic-version, toml, typing-extensions
Required-by: PennyLane_Lightning

Platform info:           Linux-4.18.0-372.32.1.el8_6.x86_64-x86_64-with-glibc2.28
Python version:          3.9.0
Numpy version:           1.23.5
Scipy version:           1.13.0
Installed devices:
- default.clifford (PennyLane-0.36.0)
- default.gaussian (PennyLane-0.36.0)
- default.mixed (PennyLane-0.36.0)
- default.qubit (PennyLane-0.36.0)
- default.qubit.autograd (PennyLane-0.36.0)
- default.qubit.jax (PennyLane-0.36.0)
- default.qubit.legacy (PennyLane-0.36.0)
- default.qubit.tf (PennyLane-0.36.0)
- default.qubit.torch (PennyLane-0.36.0)
- default.qutrit (PennyLane-0.36.0)
- default.qutrit.mixed (PennyLane-0.36.0)
- null.qubit (PennyLane-0.36.0)
- lightning.qubit (PennyLane-Lightning-0.36.0)
```


### Existing GitHub issues

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

Discussion:
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/PennyLaneAI/pennylane/pull/5781?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.66%. Comparing base [(`2a36a35`)](https://app.codecov.io/gh/PennyLaneAI/pennylane/commit/2a36a355f212ffba12c8c49d5842eef8a7c6dbdb?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennyLaneAI) to head [(`14696c8`)](https://app.codecov.io/gh/PennyLaneAI/pennylane/commit/14696c8d84c90383490b6d8a2223cafd1cca3df3?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennyLaneAI).
> Report is 1 commits behind head on master.


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


```diff
@@            Coverage Diff             @@
##           master    #5781      +/-   ##
==========================================
- Coverage   99.67%   99.66%   -0.01%     
==========================================
  Files         413      413              
  Lines       39254    38981     -273     
==========================================
- Hits        39126    38852     -274     
- Misses        128      129       +1     
```



</details>

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


Patch:
diff --git a/doc/releases/changelog-dev.md b/doc/releases/changelog-dev.md
index e28571f3d..f76d6850b 100644
--- a/doc/releases/changelog-dev.md
+++ b/doc/releases/changelog-dev.md
@@ -269,6 +269,9 @@
 * The qchem docs are updated with the new qchem improvements.
   [(#5758)](https://github.com/PennyLaneAI/pennylane/pull/5758/)
   [(#5638)](https://github.com/PennyLaneAI/pennylane/pull/5638/)
+  
+* `specs()` can now be requested at any specific point of the transform program through the `level` keyword argument.
+  [(#5781)](https://github.com/PennyLaneAI/pennylane/pull/5781/)
 
 * Device preprocess transforms now happen inside the ml boundary.
   [(#5791)](https://github.com/PennyLaneAI/pennylane/pull/5791)
diff --git a/pennylane/devices/default_qubit.py b/pennylane/devices/default_qubit.py
index 663a78b05..865bedf6b 100644
--- a/pennylane/devices/default_qubit.py
+++ b/pennylane/devices/default_qubit.py
@@ -129,7 +129,7 @@ def all_state_postprocessing(results, measurements, wire_order):
 @qml.transform
 def adjoint_state_measurements(
     tape: QuantumTape, device_vjp=False
-) -> (Tuple[QuantumTape], Callable):
+) -> Tuple[Tuple[QuantumTape], Callable]:
     """Perform adjoint measurement preprocessing.
 
     * Allows a tape with only expectation values through unmodified
diff --git a/pennylane/resource/specs.py b/pennylane/resource/specs.py
index d515ef84d..3de1dd315 100644
--- a/pennylane/resource/specs.py
+++ b/pennylane/resource/specs.py
@@ -13,6 +13,7 @@
 # limitations under the License.
 """Code for resource estimation"""
 import inspect
+import warnings
 
 import pennylane as qml
 
@@ -21,18 +22,41 @@ def _get_absolute_import_path(fn):
     return f"{inspect.getmodule(fn).__name__}.{fn.__name__}"
 
 
-def specs(qnode, max_expansion=None, expansion_strategy=None):
-    """Resource information about a quantum circuit.
+def _determine_spec_level(kwargs, qnode):
+    if "max_expansion" in kwargs:
+        warnings.warn(
+            "'max_expansion' has no effect on the output of 'specs()' and should not be used."
+        )
+
+    sentinel = object()
+
+    level = kwargs.get("level", sentinel)
+    expansion_strategy = kwargs.get("expansion_strategy", sentinel)
+
+    if all(val != sentinel for val in (level, expansion_strategy)):
+        raise ValueError("Either 'level' or 'expansion_strategy' need to be set, but not both.")
+
+    if level == sentinel:
+        if expansion_strategy == sentinel:
+            return qnode.expansion_strategy
+        return expansion_strategy
+    return level
+
+
+def specs(qnode, **kwargs):
+    r"""Resource information about a quantum circuit.
 
     This transform converts a QNode into a callable that provides resource information
-    about the circuit.
+    about the circuit after applying the specified amount of transforms/expansions first.
 
     Args:
         qnode (.QNode): the QNode to calculate the specifications for
 
     Keyword Args:
-        max_expansion (int): The number of times the internal circuit should be expanded when
-            calculating the specification. Defaults to ``qnode.max_expansion``.
+        level (None, str, int, slice): An indication of what transforms to apply before computing the resource information.
+            Check :func:`~.workflow.get_transform_program` for more information on the allowed values and usage details of
+            this argument.
+
         expansion_strategy (str): The strategy to use when circuit expansions or decompositions
             are required.
 
@@ -43,9 +67,25 @@ def specs(qnode, max_expansion=None, expansion_strategy=None):
             - ``device``: The QNode will attempt to decompose the internal circuit
               such that all circuit operations are natively supported by the device.
 
+        max_expansion (int): The number of times the internal circuit should be expanded when
+            calculating the specification. Defaults to ``qnode.max_expansion``.
+
     Returns:
         A function that has the same argument signature as ``qnode``. This function
-        returns a dictionary of information about qnode structure.
+        returns a dictionary (or a list of dictionaries) of information about qnode structure.
+
+    .. note::
+
+        At most, one of ``level`` or ``expansion_strategy`` needs to be provided. If neither is provided,
+        ``qnode.expansion_strategy`` would be used instead. Users are encouraged to predominantly use ``level``,
+        as it allows for the same values as ``expansion_strategy``, and allows for more flexibility choosing
+        the wanted transforms/expansions.
+
+    .. warning::
+
+        ``max_expansion`` and ``qnode.max_expansion`` have no effect on the return of this function and will
+        be ignored.
+
 
     **Example**
 
@@ -81,8 +121,75 @@ def specs(qnode, max_expansion=None, expansion_strategy=None):
     'gradient_fn': 'pennylane.gradients.parameter_shift.param_shift',
     'num_gradient_executions': 2}
 
+    Here you can see how the number of gates and their types change as we apply different amounts of transforms
+    through the ``level`` argument:
+
+    .. code-block:: python3
+
+        @qml.transforms.merge_rotations
+        @qml.transforms.undo_swaps
+        @qml.transforms.cancel_inverses
+        @qml.qnode(qml.device("default.qubit"), diff_method="parameter-shift", shifts=np.pi / 4)
+        def circuit(x):
+            qml.RandomLayers(qml.numpy.array([[1.0, 2.0]]), wires=(0, 1))
+            qml.RX(x, wires=0)
+            qml.RX(-x, wires=0)
+            qml.SWAP((0, 1))
+            qml.X(0)
+            qml.X(0)
+            return qml.expval(qml.X(0) + qml.Y(1))
+
+        return circuit
+
+    First, we can check the resource information of the ``QNode`` without any modifications. Note that ``level=top`` would
+    return the same results:
+
+    >>> qml.specs(circuit, level=0)(0.1)["resources"]
+    Resources(num_wires=2, num_gates=6, gate_types=defaultdict(<class 'int'>, {'RandomLayers': 1, 'RX': 2, 'SWAP': 1, 'PauliX': 2}),
+    gate_sizes=defaultdict(<class 'int'>, {2: 2, 1: 4}), depth=6, shots=Shots(total_shots=None, shot_vector=()))
+
+    We then check the resources after applying all transforms:
+
+    >>> qml.specs(circuit, level=None)(0.1)["resources"]
+    Resources(num_wires=2, num_gates=2, gate_types=defaultdict(<class 'int'>, {'RY': 1, 'RX': 1}),
+    gate_sizes=defaultdict(<class 'int'>, {1: 2}), depth=1, shots=Shots(total_shots=None, shot_vector=()))
+
+    We can also notice that ``SWAP`` and ``PauliX`` are not present in the circuit if we set ``level=2``:
+
+    >>> qml.specs(circuit, level=2)(0.1)["resources"]
+    Resources(num_wires=2, num_gates=3, gate_types=defaultdict(<class 'int'>, {'RandomLayers': 1, 'RX': 2}),
+    gate_sizes=defaultdict(<class 'int'>, {2: 1, 1: 2}), depth=3, shots=Shots(total_shots=None, shot_vector=()))
+
+    If we attempt to only apply the ``merge_rotations`` transform, we would end with only one trainable object, which is in ``RandomLayers``:
+
+    >>> qml.specs(circuit, level=slice(2, 3))(0.1)["num_trainable_params"]
+    1
+
+    However, if we apply all transforms, ``RandomLayers`` would be decomposed to an ``RY`` and an ``RX``, giving us two trainable objects:
+
+    >>> qml.specs(circuit, level=None)(0.1)["num_trainable_params"]
+    2
+
+    If a ``QNode`` with a tape-splitting transform is supplied to the function, with the transform included in the desired transforms, a dictionary
+    would be returned for each resulting tapes:
+
+    .. code-block:: python3
+
+        H = qml.Hamiltonian([0.2, -0.543], [qml.X(0) @ qml.Z(1), qml.Z(0) @ qml.Y(2)])
+
+
+        @qml.transforms.hamiltonian_expand
+        @qml.qnode(qml.device("default.qubit"), diff_method="parameter-shift", shifts=np.pi / 4)
+        def circuit():
+            qml.RandomLayers(qml.numpy.array([[1.0, 2.0]]), wires=(0, 1))
+            return qml.expval(H)
+
+    >>> len(qml.specs(circuit, level="user")())
+    2
     """
 
+    specs_level = _determine_spec_level(kwargs, qnode)
+
     def specs_qnode(*args, **kwargs):
         """Returns information on the structure and makeup of provided QNode.
 
@@ -110,47 +217,41 @@ def specs(qnode, max_expansion=None, expansion_strategy=None):
         Returns:
             dict[str, Union[defaultdict,int]]: dictionaries that contain QNode specifications
         """
-        initial_max_expansion = qnode.max_expansion
-        initial_expansion_strategy = getattr(qnode, "expansion_strategy", None)
-
-        try:
-            qnode.max_expansion = initial_max_expansion if max_expansion is None else max_expansion
-            qnode.expansion_strategy = expansion_strategy or initial_expansion_strategy
-            qnode.construct(args, kwargs)
-        finally:
-            qnode.max_expansion = initial_max_expansion
-            qnode.expansion_strategy = initial_expansion_strategy
-
-        info = qnode.qtape.specs.copy()
-
-        info["num_device_wires"] = (
-            len(qnode.tape.wires)
-            if isinstance(qnode.device, qml.devices.Device)
-            else len(qnode.device.wires)
-        )
-        info["device_name"] = getattr(qnode.device, "short_name", qnode.device.name)
-        info["expansion_strategy"] = qnode.expansion_strategy
-        info["gradient_options"] = qnode.gradient_kwargs
-        info["interface"] = qnode.interface
-        info["diff_method"] = (
-            _get_absolute_import_path(qnode.diff_method)
-            if callable(qnode.diff_method)
-            else qnode.diff_method
-        )
 
-        if isinstance(qnode.gradient_fn, qml.transforms.core.TransformDispatcher):
-            info["gradient_fn"] = _get_absolute_import_path(qnode.gradient_fn)
+        infos = []
+        batch, _ = qml.workflow.construct_batch(qnode, level=specs_level)(*args, **kwargs)
+
+        for tape in batch:
+            info = tape.specs.copy()
+
+            info["num_device_wires"] = len(qnode.device.wires or tape.wires)
+            info["num_tape_wires"] = tape.num_wires
+
+            info["device_name"] = getattr(qnode.device, "short_name", qnode.device.name)
+            info["level"] = specs_level
+            info["gradient_options"] = qnode.gradient_kwargs
+            info["interface"] = qnode.interface
+            info["diff_method"] = (
+                _get_absolute_import_path(qnode.diff_method)
+                if callable(qnode.diff_method)
+                else qnode.diff_method
+            )
+
+            if isinstance(qnode.gradient_fn, qml.transforms.core.TransformDispatcher):
+                info["gradient_fn"] = _get_absolute_import_path(qnode.gradient_fn)
+
+                try:
+                    info["num_gradient_executions"] = len(qnode.gradient_fn(tape)[0])
+                except Exception as e:  # pylint: disable=broad-except
+                    # In the case of a broad exception, we don't want the `qml.specs` transform
+                    # to fail. Instead, we simply indicate that the number of gradient executions
+                    # is not supported for the reason specified.
+                    info["num_gradient_executions"] = f"NotSupported: {str(e)}"
+            else:
+                info["gradient_fn"] = qnode.gradient_fn
 
-            try:
-                info["num_gradient_executions"] = len(qnode.gradient_fn(qnode.qtape)[0])
-            except Exception as e:  # pylint: disable=broad-except
-                # In the case of a broad exception, we don't want the `qml.specs` transform
-                # to fail. Instead, we simply indicate that the number of gradient executions
-                # is not supported for the reason specified.
-                info["num_gradient_executions"] = f"NotSupported: {str(e)}"
-        else:
-            info["gradient_fn"] = qnode.gradient_fn
+            infos.append(info)
 
-        return info
+        return infos[0] if len(infos) == 1 else infos
 
     return specs_qnode
diff --git a/pennylane/workflow/construct_batch.py b/pennylane/workflow/construct_batch.py
index 717fa9d8e..4379ab7ff 100644
--- a/pennylane/workflow/construct_batch.py
+++ b/pennylane/workflow/construct_batch.py
@@ -16,7 +16,7 @@
 """
 import inspect
 from functools import wraps
-from typing import Callable, Tuple, Union
+from typing import Callable, Literal, Optional, Tuple, Union
 
 import pennylane as qml
 
@@ -72,7 +72,7 @@ def get_transform_program(qnode: "QNode", level=None) -> "qml.transforms.core.Tr
 
     Args:
         qnode (QNode): the qnode to get the transform program for.
-        level (None, str, int, slice): And indication of what transforms to use from the full program.
+        level (None, str, int, slice): An indication of what transforms to use from the full program.
 
             * ``None``: use the full transform program
             * ``str``: Acceptable keys are ``"user"``, ``"device"``, ``"top"`` and ``"gradient"``
@@ -208,7 +208,10 @@ def get_transform_program(qnode: "QNode", level=None) -> "qml.transforms.core.Tr
     return resolved_program
 
 
-def construct_batch(qnode: QNode, level: Union[None, str, int, slice] = "user") -> Callable:
+def construct_batch(
+    qnode: QNode,
+    level: Optional[Union[Literal["top", "user", "device", "gradient"], int, slice]] = "user",
+) -> Callable:
     """Construct the batch of tapes and post processing for a designated stage in the transform program.
 
     Args:
@@ -313,6 +316,24 @@ def construct_batch(qnode: QNode, level: Union[None, str, int, slice] = "user")
         else:
             shots = kwargs.pop("shots", _get_device_shots(qnode.device))
 
+        if isinstance(qnode, qml.qnn.KerasLayer):
+            # pylint: disable=import-outside-toplevel
+            import tensorflow as tf
+
+            with tf.GradientTape() as tape:
+                tape.watch(list(qnode.qnode_weights.values()))
+
+                kwargs = {
+                    **{k: 1.0 * w for k, w in qnode.qnode_weights.items()},
+                    **kwargs,
+                }
+
+        if isinstance(qnode, qml.qnn.TorchLayer):
+            x = args[0]
+            kwargs = {
+                **{arg: weight.data.to(x) for arg, weight in qnode.qnode_weights.items()},
+            }
+
         initial_tape = qml.tape.make_qscript(qnode.func, shots=shots)(*args, **kwargs)
 
         qnode._update_gradient_fn(tape=initial_tape)


Test patch:
diff --git a/tests/qnn/test_keras.py b/tests/qnn/test_keras.py
index 964e1cee0..68d3e5a09 100644
--- a/tests/qnn/test_keras.py
+++ b/tests/qnn/test_keras.py
@@ -955,8 +955,9 @@ def test_specs():
 
     assert info["num_observables"] == 2
     assert info["num_diagonalizing_gates"] == 0
-    assert info["num_device_wires"] == 2
-    assert info["num_trainable_params"] == 2
+    assert info["num_device_wires"] == 3
+    assert info["num_tape_wires"] == 2
+    assert info["num_trainable_params"] == 3
     assert info["interface"] == "tf"
     assert info["device_name"] == "default.qubit"
 
diff --git a/tests/qnn/test_qnn_torch.py b/tests/qnn/test_qnn_torch.py
index 4f3866f43..aae611eab 100644
--- a/tests/qnn/test_qnn_torch.py
+++ b/tests/qnn/test_qnn_torch.py
@@ -943,7 +943,8 @@ def test_specs():
 
     assert info["num_observables"] == 2
     assert info["num_diagonalizing_gates"] == 0
-    assert info["num_device_wires"] == 2
-    assert info["num_trainable_params"] == 0
+    assert info["num_device_wires"] == 3
+    assert info["num_tape_wires"] == 2
+    assert info["num_trainable_params"] == 3
     assert info["interface"] == "torch"
     assert info["device_name"] == "default.qubit"
diff --git a/tests/resource/test_specs.py b/tests/resource/test_specs.py
index 81b875d9a..03937622c 100644
--- a/tests/resource/test_specs.py
+++ b/tests/resource/test_specs.py
@@ -25,8 +25,85 @@ from pennylane import numpy as np
 class TestSpecsTransform:
     """Tests for the transform specs using the QNode"""
 
+    def sample_circuit(self):
+        @qml.transforms.merge_rotations
+        @qml.transforms.undo_swaps
+        @qml.transforms.cancel_inverses
+        @qml.qnode(qml.device("default.qubit"), diff_method="parameter-shift", shifts=np.pi / 4)
+        def circuit(x):
+            qml.RandomLayers(qml.numpy.array([[1.0, 2.0]]), wires=(0, 1))
+            qml.RX(x, wires=0)
+            qml.RX(-x, wires=0)
+            qml.SWAP((0, 1))
+            qml.X(0)
+            qml.X(0)
+            return qml.expval(qml.X(0) + qml.Y(1))
+
+        return circuit
+
+    def test_max_expansion_throws_warning(self):
+        dev = qml.device("default.qubit", wires=1)
+
+        @qml.qnode(dev, diff_method="backprop")
+        def circ():
+            return qml.expval(qml.PauliZ(0))
+
+        with pytest.warns(UserWarning, match="'max_expansion' has no effect"):
+            qml.specs(circ, max_expansion=10)()
+
+    def test_only_one_of_level_or_expansion_strategy_passed(self):
+        dev = qml.device("default.qubit", wires=1)
+
+        @qml.qnode(dev, diff_method="backprop")
+        def circ():
+            return qml.expval(qml.PauliZ(0))
+
+        with pytest.raises(ValueError, match="Either 'level' or 'expansion_strategy'"):
+            qml.specs(circ, level="device", expansion_strategy="gradient")
+
+    def test_disallow_pos_args(self):
+        circ = self.sample_circuit()
+        with pytest.raises(TypeError, match="takes 1 positional argument"):
+            qml.specs(circ, "device")(0.1)
+
     @pytest.mark.parametrize(
-        "diff_method, len_info", [("backprop", 12), ("parameter-shift", 13), ("adjoint", 12)]
+        "level,expected_gates,exptected_train_params",
+        [(0, 6, 3), (1, 4, 3), (2, 3, 3), (3, 1, 1), (None, 2, 2)],
+    )
+    def test_int_specs_level(self, level, expected_gates, exptected_train_params):
+        circ = self.sample_circuit()
+        specs = qml.specs(circ, level=level)(0.1)
+
+        assert specs["level"] == level
+        assert specs["resources"].num_gates == expected_gates
+
+        assert specs["num_trainable_params"] == exptected_train_params
+
+    @pytest.mark.parametrize(
+        "level1,level2",
+        [
+            ("top", 0),
+            (0, slice(0, 0)),
+            ("user", 3),
+            ("user", slice(0, 3)),
+            (None, slice(0, None)),
+            (-1, slice(0, -1)),
+            ("device", slice(0, None)),
+        ],
+    )
+    def test_equivalent_levels(self, level1, level2):
+        circ = self.sample_circuit()
+
+        specs1 = qml.specs(circ, level=level1)(0.1)
+        specs2 = qml.specs(circ, level=level2)(0.1)
+
+        del specs1["level"]
+        del specs2["level"]
+
+        assert specs1 == specs2
+
+    @pytest.mark.parametrize(
+        "diff_method, len_info", [("backprop", 13), ("parameter-shift", 14), ("adjoint", 13)]
     )
     def test_empty(self, diff_method, len_info):
         dev = qml.device("default.qubit", wires=1)
@@ -52,13 +129,14 @@ class TestSpecsTransform:
         assert info["diff_method"] == diff_method
         assert info["num_trainable_params"] == 0
         assert info["device_name"] == dev.name
+        assert info["level"] == "gradient"
 
         if diff_method == "parameter-shift":
             assert info["num_gradient_executions"] == 0
             assert info["gradient_fn"] == "pennylane.gradients.parameter_shift.param_shift"
 
     @pytest.mark.parametrize(
-        "diff_method, len_info", [("backprop", 12), ("parameter-shift", 13), ("adjoint", 12)]
+        "diff_method, len_info", [("backprop", 13), ("parameter-shift", 14), ("adjoint", 13)]
     )
     def test_specs(self, diff_method, len_info):
         """Test the specs transforms works in standard situations"""
@@ -77,11 +155,7 @@ class TestSpecsTransform:
         x = np.array([0.05, 0.1, 0.2, 0.3, 0.5], requires_grad=True)
         y = np.array(0.1, requires_grad=False)
 
-        info_func = qml.specs(circuit)
-
-        info = info_func(x, y, add_RY=False)
-
-        circuit(x, y, add_RY=False)
+        info = qml.specs(circuit)(x, y, add_RY=False)
 
         assert len(info) == len_info
 
@@ -94,16 +168,17 @@ class TestSpecsTransform:
 
         assert info["num_observables"] == 2
         assert info["num_diagonalizing_gates"] == 1
-        assert info["num_device_wires"] == 3
+        assert info["num_device_wires"] == 4
         assert info["diff_method"] == diff_method
-        assert info["num_trainable_params"] == 4
+        assert info["num_trainable_params"] == 5
         assert info["device_name"] == dev.name
+        assert info["level"] == "gradient"
 
         if diff_method == "parameter-shift":
             assert info["num_gradient_executions"] == 6
 
     @pytest.mark.parametrize(
-        "diff_method, len_info", [("backprop", 12), ("parameter-shift", 13), ("adjoint", 12)]
+        "diff_method, len_info", [("backprop", 13), ("parameter-shift", 14), ("adjoint", 13)]
     )
     def test_specs_state(self, diff_method, len_info):
         """Test specs works when state returned"""
@@ -114,14 +189,47 @@ class TestSpecsTransform:
         def circuit():
             return qml.state()
 
-        info_func = qml.specs(circuit)
-        info = info_func()
+        info = qml.specs(circuit)()
         assert len(info) == len_info
 
         assert info["resources"] == qml.resource.Resources(gate_types=defaultdict(int))
 
         assert info["num_observables"] == 1
         assert info["num_diagonalizing_gates"] == 0
+        assert info["level"] == "gradient"
+
+    def test_splitting_transforms(self):
+        coeffs = [0.2, -0.543, 0.1]
+        obs = [qml.X(0) @ qml.Z(1), qml.Z(0) @ qml.Y(2), qml.Y(0) @ qml.X(2)]
+        H = qml.Hamiltonian(coeffs, obs)
+
+        @qml.transforms.hamiltonian_expand
+        @qml.transforms.merge_rotations
+        @qml.qnode(qml.device("default.qubit"), diff_method="parameter-shift", shifts=np.pi / 4)
+        def circuit(x):
+            qml.RandomLayers(qml.numpy.array([[1.0, 2.0]]), wires=(0, 1))
+            qml.RX(x, wires=0)
+            qml.RX(-x, wires=0)
+            qml.SWAP((0, 1))
+            qml.X(0)
+            qml.X(0)
+            return qml.expval(H)
+
+        specs_instance = qml.specs(circuit, level=1)(np.array([1.23, -1]))
+
+        assert isinstance(specs_instance, dict)
+
+        specs_list = qml.specs(circuit, level=2)(np.array([1.23, -1]))
+
+        assert len(specs_list) == len(H)
+
+        assert specs_list[0]["num_diagonalizing_gates"] == 1
+        assert specs_list[1]["num_diagonalizing_gates"] == 3
+        assert specs_list[2]["num_diagonalizing_gates"] == 4
+
+        assert specs_list[0]["num_device_wires"] == specs_list[0]["num_tape_wires"] == 2
+        assert specs_list[1]["num_device_wires"] == specs_list[1]["num_tape_wires"] == 3
+        assert specs_list[2]["num_device_wires"] == specs_list[1]["num_tape_wires"] == 3
 
     def make_qnode_and_params(self, initial_expansion_strategy):
         """Generates a qnode and params for use in other tests"""
@@ -174,7 +282,7 @@ class TestSpecsTransform:
         info = qml.specs(circuit, expansion_strategy="device")(params)
         assert circuit.expansion_strategy == "gradient"
 
-        assert len(info) == 12
+        assert len(info) == 13
 
     def test_gradient_transform(self):
         """Test that a gradient transform is properly labelled"""
@@ -194,7 +302,9 @@ class TestSpecsTransform:
         dev = qml.device("default.qubit", wires=2)
 
         @qml.transforms.core.transform
-        def my_transform(tape: qml.tape.QuantumTape) -> (Sequence[qml.tape.QuantumTape], Callable):
+        def my_transform(
+            tape: qml.tape.QuantumTape,
+        ) -> tuple[Sequence[qml.tape.QuantumTape], Callable]:
             return tape, None
 
         @qml.qnode(dev, diff_method=my_transform)
@@ -209,7 +319,7 @@ class TestSpecsTransform:
         "device,num_wires",
         [
             (qml.device("default.qubit"), 1),
-            (qml.device("default.qubit", wires=2), 1),
+            (qml.device("default.qubit", wires=2), 2),
             (qml.device("default.qubit.legacy", wires=2), 2),
         ],
     )
@@ -223,3 +333,25 @@ class TestSpecsTransform:
 
         info = qml.specs(circuit)()
         assert info["num_device_wires"] == num_wires
+
+    def test_no_error_contents_on_device_level(self):
+        coeffs = [0.25, 0.75]
+        ops = [qml.X(0), qml.Z(0)]
+        H = qml.dot(coeffs, ops)
+
+        @qml.qnode(qml.device("default.qubit"))
+        def circuit():
+            qml.Hadamard(0)
+            qml.TrotterProduct(H, time=2.4, order=2)
+
+            return qml.state()
+
+        top_specs = qml.specs(circuit, level="top")()
+        dev_specs = qml.specs(circuit, level="device")()
+
+        assert "SpectralNormError" in top_specs["errors"]
+        assert np.allclose(top_specs["errors"]["SpectralNormError"].error, 13.824)
+
+        # At the device level, approximations don't exist anymore and therefore
+        # we should expect an empty errors dictionary.
+        assert dev_specs["errors"] == {}