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:
P.S.: The problem with the documentation mention here has not been fixed: https://github.com/PennyLaneAI/pennylane/issues/5275#issuecomment-1982534435
Hi @cvjjm, 

Thanks for finding this bug, I will look into it and open a bug fix soon! 

I had hoped that I could use `qml.transforms.make_tape()` to make a tape and then get the specs from there, but this did not turn out well either. The take returned by `make_tape()` does not contain the PauliX operation and it's specs are also wrong, as can be seen by executing the following extended example:


```
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))())


    tape = qml.transforms.make_tape(qnode)()
    prepend_y_tape = qml.transforms.make_tape(prepend_y(qnode))()

    print("tape.specs:", tape.specs)
    print(tape.draw())
    print("prepend_y_tape.specs:", prepend_y_tape.specs)
    print(prepend_y_tape.draw())

    assert tape.specs["resources"].gate_types["PauliX"] == 1
    assert tape.specs["resources"].depth == 1
    assert prepend_y_tape.specs["resources"].gate_types["PauliX"] == 1
    assert prepend_y_tape.specs["resources"].gate_types["PauliY"] == 1
    assert prepend_y_tape.specs["resources"].depth == 2

    assert qml.specs(qnode)()["resources"].depth == 1
    assert qml.specs(prepend_y(qnode))()["resources"].depth == 2

```

P.S.: Also in the [docs ](https://docs.pennylane.ai/en/stable/code/api/pennylane.transforms.make_tape.html?highlight=make_tape) of `make_tape()` there is an inconsistency: the text mentions a function called "make_qscript()"
Hi @cvjjm, 

Yes indeed, actually this isn't a bug as much as it's a feature under development. The resource and specs pipelines are not integrated with transforms as of yet. I looked for a quick fix to give you the expected behaviour but as you pointed out, it's not as simple as extracting a transformed tape. 

It is on our radar to integrate these two pieces together, @trbromley should have more information about when this is planned to be implemented. 

Thanks for your patience! 
Both transforms and spec have been around for quite some time and the combination of the two are essential for doing resource estimates for compiled circuits. Very much looking forward to this feature!
Yes we have a PR open for allowing `draw` and `draw_mpl` to take the transform program into account and allow users to set when to do the drawing: https://github.com/PennyLaneAI/pennylane/pull/5139

@Shiro-Raven might be starting on the `specs` equivalent on this in the next few weeks.

Thanks @cvjjm for the feedback for us to prioritize this. We might check in with you when the `specs` PR is open to see if it works for what you need.
The level feature of draw looks _very_ useful. Please mirror that in spec. Thanks!

Resolving patch:
diff --git a/pennylane/devices/default_qubit.py b/pennylane/devices/default_qubit.py
--- 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
--- 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 circuit(x, add_ry=True):
     '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(*args, **kwargs):
         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
--- 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 circuit():
     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 batch_constructor(*args, **kwargs) -> Tuple[Tuple["qml.tape.QuantumTape", Ca
         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
--- a/tests/qnn/test_keras.py
+++ b/tests/qnn/test_keras.py
@@ -955,8 +955,9 @@ def circuit(inputs, w1, w2):
 
     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
--- a/tests/qnn/test_qnn_torch.py
+++ b/tests/qnn/test_qnn_torch.py
@@ -943,7 +943,8 @@ def circuit(inputs, w1, w2):
 
     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
--- a/tests/resource/test_specs.py
+++ b/tests/resource/test_specs.py
@@ -25,8 +25,85 @@
 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 @@ def circ():
         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 @@ def circuit(x, y, add_RY=True):
         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 @@ def circuit(x, y, add_RY=True):
 
         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 @@ def test_specs_state(self, diff_method, len_info):
         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 @@ def test_expansion_strategy(self):
         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 @@ def test_custom_gradient_transform(self):
         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 @@ def circuit():
         "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 @@ def circuit():
 
         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"] == {}