Repository: PennyLaneAI/pennylane (python)

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.

Pull request description:
`specs` can be requested at a specific stage of the transform program
**Context:**
Currently, `specs()` can only be requested after applying the full transform program, with the exception of the stages provided through `expansion_strategy`.

**Description of the Change:**
Using the new `level` argument from `construct_batch`, the function is adapted to make use of the argument as well, which allows for more flexible requests and ability to pinpoint when exactly to ask for specs. In addition, the function works with transforms that split the tape, returning a list of dictionaries each corresponding to one of the new tapes.

**Benefits:**
More flexible resource estimation.

[[sc-53736](https://app.shortcut.com/xanaduai/story/53736)] Fixes #5706 

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"] == {}