Repository: PennyLaneAI/pennylane
Pull request #6962 (2025-02-12 22:27:57)

[FTQC] Add data structures and API for qubit mapping
**Context:** To provide support for MBQC, and eventual QEC protocols, we need to maintain a mapping between physical qubits and logical qubits. This mapping will allow for the expression of operations on logical qubits that can be mapped to operations on physical qubits at the hardware layer.

**Description of the Change:** Adds the basic structure for this mapping, implemented in the `QubitGraph` class, which represents a hierarchical qubit memory model as nested graphs of qubits. A QubitGraph is a qubit that contains a graph of underlying qubits, where each underlying qubit is itself a QubitGraph.

**Example usage:** The example below initializes a qubit as a 3x4 Cartesian grid of "underlying" qubits and uses this qubit in a simple circuit.

```python
import networkx as nx
import pennylane as qml
from pennylane.ftqc import QubitGraph

graph = nx.grid_2d_graph(3, 4)
q0 = QubitGraph(graph, id=0)

dev = qml.device("default.qubit")

@qml.qnode(dev)
def circuit():
    qml.RX(1.618, wires=q0)
    return qml.expval(qml.PauliZ(q0))
``` 

**Future Work (for subsequent PRs):**

- Consolidate graph-generation functions across the FTQC module.
- Propagate transversal operations on a `QubitGraph` down to its underlying qubits.
- Enable tensor-like indexing of `QubitGraph` objects. This is already partially implemented and works for simple, list-like indexing, but more work is required for more complex indexing. For example, if we have some number of "data" qubits and some number of "auxiliary" qubits, indexed as `('data', 0), ('data', 1), ..., ('aux', 0), ('aux', 1), ...`, it may be useful to select all of the data qubits, for instance, using

    ```python
    q = QubitGraph(...)
    q[("data", :)]
    ```

- [Optional] Add support for both networkx and rustworkx graph objects.

-----

[[sc-84109](https://app.shortcut.com/xanaduai/story/84109/implement-the-qubitgraph-data-structure-in-pennylane)]

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/PennyLaneAI/pennylane/pull/6962?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.59%. Comparing base [(`005a29f`)](https://app.codecov.io/gh/PennyLaneAI/pennylane/commit/005a29f6dceb114f736c169cfeff567c3be88a5a?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennyLaneAI) to head [(`c4c709e`)](https://app.codecov.io/gh/PennyLaneAI/pennylane/commit/c4c709ea8ff1001e18526806740d8539f092ca1c?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennyLaneAI).

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


```diff
@@           Coverage Diff            @@
##           master    #6962    +/-   ##
========================================
  Coverage   99.59%   99.59%            
========================================
  Files         481      482     +1     
  Lines       45790    45899   +109     
========================================
+ Hits        45605    45714   +109     
  Misses        185      185            
```

</details>

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

joeycarter: Thanks @lillian542!

> Final suggestion is to consider doing a find-replace to add backticks to turn QubitGraph and networkx in the docstring into `QubitGraph` and `networkx`, but I'll approve it now :)

Leaving backticks off of "QubitGraph" was intentional, I've seen this done in other package, e.g. [`pandas.DataFrame`](https://pandas.pydata.org/docs/reference/api/pandas.DataFrame.html) where they write the name of common objects in plain text rather than monospace. Since the word "QubitGraph" comes up so often, I tend to prefer this approach, since I think it makes it easier on the eyes. Similarly for networkx, I've changed this to "NetworkX" to keep it in line with how they write it in [their documentation](https://networkx.org/documentation/stable/).
mlxd: > Thanks @lillian542!
> 
> > Final suggestion is to consider doing a find-replace to add backticks to turn QubitGraph and networkx in the docstring into `QubitGraph` and `networkx`, but I'll approve it now :)
> 
> Leaving backticks off of "QubitGraph" was intentional, I've seen this done in other package, e.g. [`pandas.DataFrame`](https://pandas.pydata.org/docs/reference/api/pandas.DataFrame.html) where they write the name of common objects in plain text rather than monospace. Since the word "QubitGraph" comes up so often, I tend to prefer this approach, since I think it makes it easier on the eyes. Similarly for networkx, I've changed this to "NetworkX" to keep it in line with how they write it in [their documentation](https://networkx.org/documentation/stable/).

If we do backticks as convention in PennyLane documentation elsewhere, let's follow-suit as it creates a unified doc across the ecosystem. If not, feel free to use this until we standardize.
josh146: The convention I tried to set early on in the PL docs is:

- Backticks to refer to code objects
- Plain text for concepts (E.g., To create a QNode, you can use the `QNode` decorator).
  - of course, this is very nuanced, and the analogy here would be 'To create a qubit graph, use `QubitGraph`' I suppose.
- Plain text for package names (NumPy, Python, NetworkX).
joeycarter: Thanks @mlxd and @josh146 for the context and suggestions.

I use "QubitGraph" to refer to the concept of "the data structure that represents the hierarchical qubit memory-addressing model" in most cases, although the line does get a little blurry when talking about, for instance, the "structure of a QubitGraph object".

I propose to keep it in plain text within the `QubitGraph` class documentation; I think the fact the we often use "QNode" (in plain text) throughout PennyLane sets a good precedent to do it this way (e.g. [here](https://docs.pennylane.ai/en/stable/introduction/circuits.html)). Then, when referring to QubitGraph objects in other FTQC/PennyLane classes, we can use monospace text, or Sphinx cross-referencing where appropriate.

Patch:
diff --git a/doc/releases/changelog-dev.md b/doc/releases/changelog-dev.md
index a97d67375..a991b8859 100644
--- a/doc/releases/changelog-dev.md
+++ b/doc/releases/changelog-dev.md
@@ -254,6 +254,12 @@
 * `null.qubit` can now execute jaxpr.
   [(#6924)](https://github.com/PennyLaneAI/pennylane/pull/6924)
 
+* A new class, `qml.ftqc.QubitGraph`, is now available for representing a qubit memory-addressing
+  model for mappings between logical and physical qubits. This representation allows for nesting of
+  lower-level qubits with arbitrary depth to allow easy insertion of arbitrarily many levels of
+  abstractions between logical qubits and physical qubits.
+  [(#6962)](https://github.com/PennyLaneAI/pennylane/pull/6962)
+
 <h4>Capturing and representing hybrid programs</h4>
 
 * Traditional tape transforms in PennyLane can be automatically converted to work with program capture enabled.
@@ -633,6 +639,7 @@ This release contains contributions from (in alphabetical order):
 
 Guillermo Alonso,
 Utkarsh Azad,
+Joey Carter,
 Henry Chang,
 Yushao Chen,
 Isaac De Vlugt,
diff --git a/pennylane/ftqc/__init__.py b/pennylane/ftqc/__init__.py
index d33458cd7..87779692f 100644
--- a/pennylane/ftqc/__init__.py
+++ b/pennylane/ftqc/__init__.py
@@ -37,9 +37,11 @@ from .parametric_midmeasure import (
     diagonalize_mcms,
 )
 from .lattice import Lattice, generate_lattice
+from .qubit_graph import QubitGraph
 
 __all__ = [
     "Lattice",
+    "QubitGraph",
     "ParametricMidMeasureMP",
     "XMidMeasureMP",
     "YMidMeasureMP",
diff --git a/pennylane/ftqc/qubit_graph.py b/pennylane/ftqc/qubit_graph.py
new file mode 100644
index 000000000..78e71d592
--- /dev/null
+++ b/pennylane/ftqc/qubit_graph.py
@@ -0,0 +1,896 @@
+# Copyright 2025 Xanadu Quantum Technologies Inc.
+
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+
+#     http://www.apache.org/licenses/LICENSE-2.0
+
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""This module contains the data structures to represent qubits using a hierarchical memory model
+and defines the API to access and manipulate these data structures.
+"""
+
+import uuid
+import warnings
+from typing import Any, Optional, Union
+
+import networkx as nx
+
+# Fail-safe for algorithms that traverse the nested graph structure
+# In other words, we should never expect more than this many layers of nested QubitGraphs
+MAX_TRAVERSAL_DEPTH = 100
+
+
+class QubitGraph:
+    """A class to represent a hierarchical qubit memory model as nested graphs of qubits.
+
+    A QubitGraph is a qubit that contains a graph of underlying qubits, where each underlying qubit
+    is itself a QubitGraph. This representation allows for nesting of lower-level qubits with
+    arbitrary depth to allow easy insertion of arbitrarily many levels of abstractions between
+    logical qubits and physical qubits.
+
+    Args:
+        graph (graph-like, optional): The graph structure to use for the QubitGraph's underlying
+            qubits. The graph must be "flat" (unnested). An object is considered "graph-like" if it
+            has both a 'nodes' and an 'edges' attribute. Defaults to None, which leaves the
+            QubitGraph in an uninitialized state.
+        id (Any, optional): An identifier for this QubitGraph object. The identifier is generally an
+            integer, string, or a tuple of integers and strings, but it may be any object. Inputting
+            None (the default), assigns a random universally unique identifier (uuid) to ``id``.
+
+    .. note::
+        The input graph defines the structure of the underlying qubit graph only; only the set of
+        nodes and the set of edges defining the connectivity between nodes are used to construct the
+        underlying qubit graph. Any data contained within the input graph's nodes, including nested
+        graphs, are ignored.
+
+    .. note::
+        QubitGraph expects an undirected graph as input, specifically an instance of the
+        ``networkx.Graph`` class, although other NetworkX graphs and graph-like types are also
+        permitted.
+
+    **Examples**
+
+    Create a QubitGraph (with id=0) using a 2x2 Cartesian grid to define the structure of its
+    underlying qubits:
+
+    >>> import networkx as nx
+    >>> from pennylane.ftqc import QubitGraph
+    >>> q = QubitGraph(nx.grid_graph((2,2)), id=0)
+    >>> q
+    QubitGraph<id=0, loc=[]>
+    >>> for child in q.children:
+    ...     print(child)
+    QubitGraph<id=(0, 0), loc=[0]>
+    QubitGraph<id=(0, 1), loc=[0]>
+    QubitGraph<id=(1, 0), loc=[0]>
+    QubitGraph<id=(1, 1), loc=[0]>
+
+    Using the QubitGraph object defined above as a starting point, it's possible to nest other
+    QubitGraph objects in any of the nodes of its underlying qubit graph:
+
+    >>> q[(0,0)].init_graph(nx.grid_graph((2,)))
+    >>> q[(0,0)]
+    QubitGraph<id=(0, 0), loc=[0]>
+    >>> for child in q[(0,0)].children:
+    ...     print(child)
+    QubitGraph<id=0, loc=[(0, 0), 0]>
+    QubitGraph<id=1, loc=[(0, 0), 0]>
+
+    Notice that you do not have to provide an ID when constructing the QubitGraph; when a
+    QubitGraph object is assigned to the node of another QubitGraph, its ID takes on the label
+    of the node it was assigned to.
+
+    .. details::
+        :title: How to Read the QubitGraph String Representation
+
+        The examples above showed that any QubitGraph in the hierarchical structure can be displayed
+        with information on its ID and its location in the hierarchy. For example, the string
+        representation
+
+        ::
+
+            QubitGraph<id=1, loc=[(0, 0), 0]>
+
+        indicates that this QubitGraph has an ID of ``1``, and it is nested within a parent node
+        labelled ``(0, 0)``, which is nested within a parent node labelled ``0``, which is a root
+        node. If a QubitGraph is itself a root node (meaning it is not nested within a parent
+        QubitGraph), its location is displayed as an empty list, for example:
+
+        ::
+
+            QubitGraph<id=0, loc=[]>
+
+        If no ID parameter was given upon construction of the QubitGraph, you will notice that a
+        random UUID has been assigned to it:
+
+        >>> q = QubitGraph()
+        >>> q
+        QubitGraph<id=7491161c, loc=[]>
+
+        This ID is truncated for brevity; the full ID is a 32-digit hexadecimal number:
+
+        >>> q.id
+        '7491161c-ca7e-42cc-af3e-8ca6250a370e'
+
+    .. details::
+        :title: Initializing a QubitGraph
+
+        The examples above showed how to initialize the graph structure of a QubitGraph by passing
+        in a NetworkX graph. It is also possible to construct a QubitGraph object with no graph
+        structure:
+
+        >>> q = QubitGraph(id=0)
+        >>> q
+        QubitGraph<id=0, loc=[]>
+
+        In this case, the QubitGraph is still valid for use, but it is in an *uninitialized* state.
+        You can check the initialization state of a QubitGraph with its
+        :attr:`~QubitGraph.is_initialized` attribute:
+
+        >>> q.is_initialized
+        False
+
+        You may not want to initialize a graph of underlying qubits when representing physical
+        qubits at the hardware layer, for example.
+
+        An uninitialized QubitGraph can always be initialized later using one of its
+        graph-initialization methods. The most general of these is :meth:`~QubitGraph.init_graph`,
+        which accepts an arbitrary NetworkX graph as input:
+
+        >>> graph = nx.grid_graph((2,))
+        >>> q.init_graph(graph)
+        >>> q.is_initialized
+        True
+
+        The :meth:`~QubitGraph.init_graph` method behaves in the same way as passing the graph input
+        directly to the QubitGraph constructor, and the same requirements and caveats listed above
+        apply here as well (the input must be graph-like and any data annotated on the nodes are
+        ignored).
+
+        Other graph-initialization methods that automatically construct common graph structures are
+        also available. For example, :meth:`~QubitGraph.init_graph_nd_grid` initializes a
+        QubitGraph's underlying qubits as an n-dimensional Cartesian grid:
+
+        >>> q_3d_grid = QubitGraph(id=0)
+        >>> q_3d_grid.init_graph_nd_grid((3, 4, 5))
+
+    .. details::
+        :title: Traversing the Hierarchical Graph Structure
+
+        The graph hierarchy is structured as a tree data type, with "root" nodes representing the
+        highest-level qubits (for example, the logical qubits in a quantum circuit), down to "leaf"
+        nodes representing the lowest-level qubits (for example, the physical qubits at the hardware
+        level). The :attr:`~QubitGraph.is_root` and :attr:`~QubitGraph.is_leaf` attributes are
+        available to quickly determine if a QubitGraph is a root or leaf node, respectively.
+        Consider the following example with a three-layer nesting structure:
+
+        >>> single_node_graph = nx.grid_graph((1,))
+        >>> q = QubitGraph(single_node_graph, id=0)
+        >>> q[0].init_graph(single_node_graph)
+        >>> print(f"is root? {q.is_root}, is leaf? {q.is_leaf}")
+        is root? True, is leaf? False
+        >>> print(f"is root? {q[0].is_root}, is leaf? {q[0].is_leaf}")
+        is root? False, is leaf? False
+        >>> print(f"is root? {q[0][0].is_root}, is leaf? {q[0][0].is_leaf}")
+        is root? False, is leaf? True
+
+        The :attr:`~QubitGraph.parent` and :attr:`~QubitGraph.children` attributes are also
+        available to access the parent of a given QubitGraph and its set of children, respectively:
+
+        >>> q[0].parent is q
+        True
+        >>> q[0][0].parent is q[0]
+        True
+        >>> list(q[0].children)
+        [QubitGraph<id=0, loc=[0, 0]>]
+
+    ..  TODO:
+
+        - How to represent operations on qubits?
+            - We should be able to broadcast operations to underlying qubits, assuming operations
+              are transversal.
+            - Recall that a *transversal operation* is defined as a logical operator that is formed
+              by applying the individual physical operators to each qubit in a QEC code block.
+        - Implement tensor-like indexing and slicing.
+    """
+
+    def __init__(self, graph: Optional[nx.Graph] = None, id: Optional[Any] = None):
+        # The identifier for this QubitGraph, e.g. a number, string, tuple, etc.
+        # Generate a random uuid if the input is None
+        self._id = uuid.uuid4() if id is None else id
+
+        graph_copy = None
+        if graph is not None:
+            self._check_graph_type_supported_and_raise_or_warn(graph)
+            graph_copy = self._copy_graph_structure(graph)
+
+        # The graph structure of the qubits underlying (nested within) the current qubit
+        self._graph = graph_copy
+
+        # The parent QubitGraph object under which this QubitGraph is nested
+        self._parent = None
+
+        # Initialize each node in the graph to store an empty QubitGraph object
+        if self._graph is not None:
+            self._initialize_all_nodes_as_qubit_graph()
+
+    def __getitem__(self, key: Any) -> "QubitGraph":
+        """QubitGraph subscript operator for read access.
+
+        Currently only basic, linear indexing and slicing is supported.
+
+        Args:
+            key (Any): Node label in the underlying qubit graph.
+
+        ..  TODO:
+
+            - Allow for more advanced tensor-like indexing and slicing.
+        """
+        if not self.is_initialized:
+            self._warn_uninitialized()
+            return None
+
+        if isinstance(key, slice):
+            start, stop, step = key.indices(len(self._graph.nodes))
+            return [self._graph.nodes[node]["qubits"] for node in range(start, stop, step)]
+
+        return self._graph.nodes[key]["qubits"]
+
+    def __setitem__(self, key: Any, value: "QubitGraph"):
+        """QubitGraph subscript operator for assignment.
+
+        Currently only basic, linear indexing is supported. Slicing is not supported.
+
+        The QubitGraph assignment operator transfers ownership of the new QubitGraph object passed
+        as the parameter ``value`` to the parent QubitGraph object. It does so by updating two of
+        the new object's attributes:
+
+            1. It updates the new object's ``id`` to be equal to the label of the node to which it
+               has been assigned, as given by the ``key`` parameter.
+            2. It updates the new object's ``parent`` attribute to be the current QubitGraph object.
+
+        Args:
+            key (Any): Node label in the underlying qubit graph.
+            value (QubitGraph): The QubitGraph object to assign to the node with the given key.
+
+        **Example**
+
+        >>> graph = nx.Graph()
+        >>> graph.add_node(0)
+        >>> q_top = QubitGraph(graph, id="top")
+        >>> print(f"{q_top}; {q_top.node_labels}")
+        QubitGraph<id=top, loc=[]>; [0]
+        >>> q_new = QubitGraph(graph, "new")
+        print(f"{q_new}; {q_new.node_labels}")
+        QubitGraph<id=new, loc=[]>; [0]
+        >>> q_top[0] = q_new
+        >>> print(f"{q_top[0]}; {q_top[0].node_labels}")
+        QubitGraph<id=0, loc=[top]>; [0]
+
+        ..  TODO:
+
+            - Allow for more advanced tensor-like indexing and slicing.
+            - Explicitly disallow assigning a QubitGraph object that would make the nesting
+              structure cyclic.
+        """
+        if not isinstance(value, QubitGraph):
+            raise TypeError(
+                f"'QubitGraph' item assignment type must also be a QubitGraph, but got type "
+                f"'{type(value).__name__}'"
+            )
+
+        if not self.is_initialized:
+            self._warn_uninitialized()
+            return
+
+        value._id = key
+        value._parent = self
+
+        self._graph.nodes[key]["qubits"] = value
+
+    def __iter__(self):
+        """Dummy QubitGraph iterator method that yields itself.
+
+        This method ensures that no implicit iteration takes place over the nodes in the underlying
+        qubit graph, which is possible since we have defined ``__getitem__()``.
+
+        Consider the example where we wrap a QubitGraph object in a tuple. With this dummy
+        ``__iter__()`` method defined, the resulting tuple contains the top-level QubitGraph object:
+
+        >>> q = QubitGraph(id=0)
+        >>> q.init_graph_nd_grid((2,))
+        >>> tuple(q)
+        (QubitGraph<id=0, loc=[]>,)
+
+        Without this dummy method defined, the ``tuple()`` constructor attempts to iterate over the
+        values that the ``__getitem__()`` method yield, which creates a tuple of the QubitGraph
+        objects contained in the underlying qubit graph:
+
+        >>> # Without QubitGraph.__iter__() defined
+        >>> q = QubitGraph(id=0)
+        >>> q.init_graph_nd_grid((2,))
+        >>> tuple(q)
+        (QubitGraph<id=0, loc=[0]>, QubitGraph<id=1, loc=[0]>)
+
+        Making QubitGraph non-iterable is especially important when using it as input to the
+        :class:`~.Wires` class, which checks if the input is iterable by wrapping it in a tuple.
+        """
+        yield self
+
+    def __repr__(self) -> str:
+        """Representation of a QubitGraph object.
+
+        This representation displays the full index hierarchy of a nested QubitGraph object.
+
+        **Examples**
+
+        >>> QubitGraph(id=0)
+        QubitGraph<id=0, loc=[]>
+
+        >>> q = QubitGraph(id=0)
+        >>> q.init_graph_nd_grid((2, 2))
+        >>> q
+        QubitGraph<id=0, loc=[]>
+        >>> q[(0, 1)]
+        QubitGraph<id=(0, 1), loc=[0]>
+        >>> graph = nx.Graph()
+        >>> graph.add_node("aux")
+        >>> q[(0, 1)].init_graph(graph)
+        >>> q[(0, 1)]["aux"]
+        QubitGraph<id=aux, loc=[(0, 1), 0]>
+        """
+        # Truncate the ID representation if it is a UUID
+        id_repr = self._truncate_id_if_uuid(self._id)
+
+        if self.is_root:
+            return f"QubitGraph<id={id_repr}, loc=[]>"
+
+        if self.has_cycle():
+            # NOTE: For now, we check if the QubitGraph nesting structure is cyclic and break early,
+            # rather than iterating up through the parents until we reach the max-traversal depth.
+            # Eventually, it would be better to explicitly disallow constructing a cyclically nested
+            # QubitGraph.
+            self._warn_cyclically_nested_graph()
+            return f"QubitGraph<id={id_repr}; cyclic>"
+
+        depth_counter = 1
+
+        ids = []
+        parent = self.parent
+        while parent is not None:
+            # To prevent extremely long string representations, we break early once we reach the
+            # max-traversal depth. Generally, we should never expect more than this many layers of
+            # nested QubitGraphs, so emit a warning.
+            if depth_counter >= MAX_TRAVERSAL_DEPTH:
+                self._warn_max_traversal_depth_reached("__repr__")
+                ids.append("...")
+                break
+
+            ids.append(self._truncate_id_if_uuid(parent._id))
+            parent = parent.parent
+
+            depth_counter += 1
+
+        ids_str = ", ".join(str(id) for id in ids)
+        return f"QubitGraph<id={id_repr}, loc=[{ids_str}]>"
+
+    @property
+    def id(self):
+        """Gets the QubitGraph ID."""
+        return str(self._id) if isinstance(self._id, uuid.UUID) else self._id
+
+    @property
+    def graph(self):
+        """Gets the underlying qubit graph."""
+        return self._graph
+
+    @property
+    def parent(self) -> "QubitGraph":
+        """Gets the parent QubitGraph of this QubitGraph object.
+
+        Returns:
+            QubitGraph: The parent QubitGraph object.
+        """
+        return self._parent
+
+    @property
+    def is_initialized(self) -> bool:
+        """Checks if the underlying qubits have been initialized.
+
+        The underlying qubit graph is considered uninitialized if and only if it is NoneType. A
+        QubitGraph consisting of a null graph (one with zero nodes) is considered initialized. A
+        QubitGraph may be uninitialized if it is a leaf node in the hierarchical graph structure.
+
+        Returns:
+            bool: Returns True if the underlying qubits have been initialized, False otherwise.
+        """
+        return self._graph is not None
+
+    @property
+    def is_leaf(self) -> bool:
+        """Checks if this QubitGraph object is a leaf node in the hierarchical graph structure.
+
+        A QubitGraph node is a leaf when it has no underlying qubit graph, either if the underlying
+        qubit graph has not been initialized (i.e. it is NoneType) or if the underlying qubits graph
+        has been initialized but is a null graph (one with zero nodes).
+
+        Returns:
+            bool: Returns True if this QubitGraph object is a leaf node.
+        """
+        return (not self.is_initialized) or (len(self._graph.nodes) == 0)
+
+    @property
+    def is_root(self) -> bool:
+        """Checks if this QubitGraph object is a root node in the hierarchical graph structure.
+
+        A QubitGraph node is a root when it has no parent QubitGraph object.
+
+        Returns:
+            bool: Returns True if this QubitGraph object is a root node.
+        """
+        return self._parent is None
+
+    @property
+    def node_labels(self):
+        """Gets the set of node labels in the underlying qubit graph.
+
+        If the underlying qubit graph has not been initialized, emit a UserWarning and return None.
+
+        Accessing ``QubitGraph.node_labels`` is equivalent to accessing the ``nodes`` attribute of
+        the NetworkX graph:
+
+        >>> g = nx.grid_graph((2,))
+        >>> g.nodes
+        NodeView((0, 1))
+        >>> q = QubitGraph(g, id=0)
+        >>> q.node_labels
+        NodeView((0, 1))
+
+        To access the underlying QubitGraph *objects*, rather than their labels, use the
+        :attr:`~QubitGraph.children` attribute.
+
+        Returns:
+            networkx.NodeView: A view of the set of nodes, with native support for operations such
+            as ``len(g.nodes)``, ``n in g.nodes``, ``g.nodes & h.nodes``, etc. See the NetworkX
+            documentation for more information.
+        """
+        if not self.is_initialized:
+            self._warn_uninitialized()
+            return None
+
+        return self._graph.nodes
+
+    @property
+    def edge_labels(self):
+        """Gets the set of edge labels in the underlying qubit graph.
+
+        If the underlying qubit graph has not been initialized, emit a UserWarning and return None.
+
+        Accessing ``QubitGraph.edges_labels`` is equivalent to accessing the ``edges`` attribute of
+        the NetworkX graph:
+
+        >>> g = nx.grid_graph((2,))
+        >>> g.edges
+        EdgeView([(0, 1)])
+        >>> q = QubitGraph(g, id=0)
+        >>> q.edge_labels
+        EdgeView([(0, 1)])
+
+        Returns:
+            networkx.EdgeView: The set of edges, with native support for operations such as
+            ``len(g.edges)``, ``e in g.edges``, ``g.edges & h.edges``, etc. See the NetworkX
+            documentation for more information.
+        """
+        if not self.is_initialized:
+            self._warn_uninitialized()
+            return None
+
+        return self._graph.edges
+
+    @property
+    def children(self):
+        """Gets an iterator over the set of children QubitGraph objects.
+
+        To access the node labels of the underlying qubit graph, rather than the QubitGraph objects
+        themselves, use the :attr:`~QubitGraph.node_labels` attribute.
+
+        Yields:
+            QubitGraph: The next QubitGraph object in the set of children QubitGraphs.
+
+        **Example**
+
+        >>> q = QubitGraph(nx.grid_graph((2,)), id=0)
+        >>> set(q.node_labels)
+        {0, 1}
+        >>> list(q.children)
+        [QubitGraph<id=0, loc=[0]>, QubitGraph<id=1, loc=[0]>]
+        """
+        if not self.is_initialized:
+            self._warn_uninitialized()
+            return
+
+        for node in self.node_labels:
+            yield self[node]
+
+    @property
+    def neighbors(self):
+        """Gets an iterator over all of the QubitGraph objects connected to this QubitGraph (its
+        *neighbors*).
+
+        A QubitGraph does not have to be initialized for it to have neighbors. Similarly, a
+        root-level QubitGraph does not have any neighboring qubits, by construction.
+
+        Yields:
+            QubitGraph: The next QubitGraph object in the set of neighboring QubitGraphs.
+
+        **Example**
+
+        >>> q = QubitGraph(nx.grid_graph((2, 2)), id=0)
+        >>> list(q[(0,0)].neighbors)
+        [QubitGraph<id=(1, 0), loc=[0]>, QubitGraph<id=(0, 1), loc=[0]>]
+        """
+        if self.is_root:
+            return
+
+        for neighbor_id in self._parent.graph.neighbors(self._id):
+            yield self._parent[neighbor_id]
+
+    def clear(self):
+        """Clears the graph of underlying qubits."""
+        self._graph = None
+
+    def has_cycle(self) -> bool:
+        """Checks if the QubitGraph contains a cycle in its nesting structure.
+
+        This method uses Floyd's cycle-finding algorithm (also known as Floyd's tortoise and hare
+        algorithm) by iterating up through the QubitGraphs' parents.
+
+        Returns:
+            bool: Returns True if this QubitGraph has a cycle in its nesting structure.
+        """
+        if self._parent is None:
+            return False
+
+        slow = self._parent
+        fast = self._parent
+
+        while (fast is not None) and (fast.parent is not None):
+            slow = slow.parent
+            fast = fast.parent.parent
+
+            if slow == fast:
+                return True
+
+        return False
+
+    def init_graph(self, graph: nx.Graph):
+        """Initialize the QubitGraph's underlying qubits with the given graph.
+
+        Args:
+            graph (networkx.Graph): The undirected graph to use as the QubitGraph's underlying
+                qubits. This object must not be None.
+
+        **Example**
+
+        This example creates a NetworkX graph with two nodes, labelled 0 and 1, and one edge
+        between them, and uses this graph to initialize the graph structure of a QubitGraph:
+
+        >>> import networkx as nx
+        >>> graph = nx.Graph()
+        >>> graph.add_edge(0, 1)
+        >>> q = QubitGraph(id=0)
+        >>> q.init_graph(graph)
+        >>> list(q.children)
+        [QubitGraph<id=0, loc=[0]>, QubitGraph<id=1, loc=[0]>]
+        """
+        if graph is None:
+            raise TypeError("QubitGraph requires a graph-like input, got NoneType.")
+
+        self._check_graph_type_supported_and_raise_or_warn(graph)
+
+        if self.is_initialized:
+            self._warn_reinitialization()
+            return
+
+        self._graph = self._copy_graph_structure(graph)
+        self._initialize_all_nodes_as_qubit_graph()
+
+    def init_graph_2d_grid(self, m: int, n: int):
+        """Initialize the QubitGraph's underlying qubits as a 2-dimensional Cartesian grid of other
+        QubitGraphs.
+
+        Args:
+            m (int): The number of rows in the grid.
+            n (int): The number of columns in the grid.
+
+        **Example**
+
+        This example initializes the underlying qubits as a 2x3 2-dimensional Cartesian grid
+        with graph structure and qubit indexing below:
+
+        ::
+
+            (0,0) --- (0,1) --- (0,2)
+              |         |         |
+            (1,0) --- (1,1) --- (1,2)
+
+        >>> q = QubitGraph(id=0)
+        >>> q.init_graph_2d_grid(2, 3)
+        >>> list(q.children)
+        [QubitGraph<id=(0, 0), loc=[0]>, QubitGraph<id=(0, 1), loc=[0]>,
+        QubitGraph<id=(0, 2), loc=[0]>, QubitGraph<id=(1, 0), loc=[0]>,
+        QubitGraph<id=(1, 1), loc=[0]>, QubitGraph<id=(1, 2), loc=[0]>]
+        """
+        if self.is_initialized:
+            self._warn_reinitialization()
+            return
+
+        self._graph = nx.grid_2d_graph(m, n)
+        self._initialize_all_nodes_as_qubit_graph()
+
+    def init_graph_nd_grid(self, dim: Union[list[int], tuple[int]]):
+        """Initialize the QubitGraph's underlying qubits as an n-dimensional Cartesian grid of other
+        QubitGraphs.
+
+        Args:
+            dim (list or tuple of ints): The size of each dimension.
+
+        **Example**
+
+        This example initializes the underlying qubits as a 2x2x3 3-dimensional Cartesian grid
+        with graph structure and qubit indexing below:
+
+        ::
+
+                  (2,0,0) ------------- (2,0,1)
+                 /|                    /|
+               (1,0,0) ------------- (1,0,1)
+              /|  |                 /|  |
+            (0,0,0) ------------- (0,0,1)
+            |  |  |               |  |  |
+            |  |  (2,1,0) --------|--|- (2,1,1)
+            |  | /                |  | /
+            |  (1,1,0) -----------|- (1,1,1)
+            | /                   | /
+            (0,1,0) ------------- (0,1,1)
+
+        >>> q = QubitGraph(id=0)
+        >>> q.init_graph_nd_grid((2, 2, 3))
+        >>> list(q.children)
+        [QubitGraph<id=(0, 0, 0), loc=[0]>, QubitGraph<id=(0, 0, 1), loc=[0]>,
+        QubitGraph<id=(0, 1, 0), loc=[0]>, QubitGraph<id=(0, 1, 1), loc=[0]>,
+        QubitGraph<id=(1, 0, 0), loc=[0]>, QubitGraph<id=(1, 0, 1), loc=[0]>,
+        QubitGraph<id=(1, 1, 0), loc=[0]>, QubitGraph<id=(1, 1, 1), loc=[0]>,
+        QubitGraph<id=(2, 0, 0), loc=[0]>, QubitGraph<id=(2, 0, 1), loc=[0]>,
+        QubitGraph<id=(2, 1, 0), loc=[0]>, QubitGraph<id=(2, 1, 1), loc=[0]>]
+        """
+        if self.is_initialized:
+            self._warn_reinitialization()
+            return
+
+        self._graph = nx.grid_graph(dim)
+        self._initialize_all_nodes_as_qubit_graph()
+
+    def init_graph_surface_code_17(self):
+        r"""Initialize the QubitGraph's underlying qubits as the 17-qubit surface code graph from
+
+            Y. Tomita & K. Svore, 2014, *Low-distance Surface Codes under Realistic Quantum Noise*.
+            `arXiv:1404.3747 <https://arxiv.org/abs/1404.3747>`_.
+
+        This graph structure is commonly referred to as the "ninja star" surface code given its
+        shape.
+
+        The nodes are indexed as follows, where 'd' refers to data qubits and 'a' to auxiliary
+        qubits:
+
+        ::
+
+                          a9
+                         /   \
+                d0     d1     d2
+               /  \   /  \   /
+            a10    a11    a12
+               \  /   \  /   \
+                d3     d4     d5
+                  \   /  \   /  \
+                   a13    a14    a15
+                  /   \  /   \  /
+                d6     d7     d8
+                  \   /
+                   a16
+        """
+        if self.is_initialized:
+            self._warn_reinitialization()
+            return
+
+        data_qubits = [("data", i) for i in range(9)]  # 9 data qubits, indexed 0, 1, ..., 8
+        aux_qubits = [("aux", i) for i in range(9, 17)]  # 8 aux qubits, indexed 9, 10, ..., 16
+
+        self._graph = nx.Graph()
+        self._graph.add_nodes_from(data_qubits)
+        self._graph.add_nodes_from(aux_qubits)
+
+        # Adjacency list showing the connectivity of each auxiliary qubit to its neighbouring data qubits
+        aux_adjacency_list = {
+            9: [1, 2],
+            10: [0, 3],
+            11: [0, 1, 3, 4],
+            12: [1, 2, 4, 5],
+            13: [3, 4, 6, 7],
+            14: [4, 5, 7, 8],
+            15: [5, 8],
+            16: [6, 7],
+        }
+
+        for aux_node, data_nodes in aux_adjacency_list.items():
+            for data_node in data_nodes:
+                self._graph.add_edge(("aux", aux_node), ("data", data_node))
+
+        self._initialize_all_nodes_as_qubit_graph()
+
+    def _initialize_all_nodes_as_qubit_graph(self):
+        """Helper function to initialize all nodes in the underlying qubit graph as uninitialized
+        QubitGraph objects. This function also sets the ``parent`` attribute appropriately.
+        """
+        assert self._graph is not None, "Underlying qubit graph object must not be None"
+        assert hasattr(
+            self._graph, "nodes"
+        ), "Underlying qubit graph object must have 'nodes' attribute"
+
+        for node in self._graph.nodes:
+            q = QubitGraph(id=node)
+            q._parent = self  # pylint: disable=protected-access
+            self[node] = q
+
+    @staticmethod
+    def _copy_graph_structure(graph: nx.Graph):
+        """Creates a copy of a NetworkX graph, but only the graph structure (nodes and edges), without
+        copying the node data.
+
+        Args:
+            graph (networkx.Graph): The graph to copy. Must not be None.
+
+        Returns:
+            networkx.Graph: A new graph with the same structure but empty node attributes.
+        """
+        assert graph is not None, "Graph object for copying must not be None"
+
+        if isinstance(graph, nx.Graph):
+            # This allows support for other networkx graph types, which all inherit from nx.Graph
+            graph_type = type(graph)
+            new_graph = graph_type()
+        else:
+            new_graph = nx.Graph()
+
+        new_graph.add_nodes_from(graph.nodes)
+        new_graph.add_edges_from(graph.edges)
+
+        return new_graph
+
+    @staticmethod
+    def _truncate_id_if_uuid(id: Any):
+        """Truncate an ID if it is a UUID, otherwise return the input unmodified.
+
+        Args:
+            id (Any): The ID to truncate.
+
+        Returns:
+            str: The truncated UUID.
+            type(id): If the id is not a UUID, return the unmodified input.
+
+        **Examples**
+
+        >>> id = uuid.uuid4()
+        >>> id
+        UUID('2b03b2f5-5d36-4dfe-997e-01d2b01556c8')
+        >>> QubitGraph._truncate_id_if_uuid(id)
+        '2b03b2f5'
+
+        >>> QubitGraph._truncate_id_if_uuid("abcdefghijkl")
+        'abcdefghijkl'
+        """
+        if isinstance(id, uuid.UUID):
+            return str(id).split("-", maxsplit=1)[0]
+
+        return id
+
+    @staticmethod
+    def _warn_uninitialized():
+        """Emit a UserWarning when attempting to access an uninitialized graph."""
+        warnings.warn("Attempting to access an uninitialized QubitGraph.", UserWarning)
+
+    @staticmethod
+    def _warn_reinitialization():
+        """Emit a UserWarning when attempting to initialize an already-initialized graph."""
+        warnings.warn(
+            "Attempting to re-initialize a QubitGraph. If you wish to initialize the underlying "
+            "qubits with a new graph structure, you must first call QubitGraph.clear() and then "
+            "call the initialization method.",
+            UserWarning,
+        )
+
+    @staticmethod
+    def _warn_max_traversal_depth_reached(algo_name: str):
+        """Emit a UserWarning when an algorithm traversing through the layers of a nested QubitGraph
+        surpasses the maximum traversal depth.
+
+        Args:
+            algo_name (str): The name of the algorithm that triggered the max traversal-depth
+                warning.
+        """
+        warnings.warn(
+            f"Maximum traversal depth reached in '{algo_name}' "
+            f"(traversal depth > {MAX_TRAVERSAL_DEPTH})",
+            UserWarning,
+        )
+
+    @staticmethod
+    def _warn_cyclically_nested_graph():
+        """Emit a UserWarning when a cyclically nested graph structure was detected in a QubitGraph
+        object.
+        """
+        warnings.warn(
+            "A cyclically nested graph structure was detected in a QubitGraph object. QubitGraph "
+            "objects should generally not be cyclically nested since such structures have neither "
+            "well-defined root nor leaf nodes.",
+            UserWarning,
+        )
+
+    @classmethod
+    def _check_graph_type_supported_and_raise_or_warn(cls, candidate: Any):
+        """Check that the input type is graph-like and raise a TypeError if not, and then check that
+        the graph type is one that is supported and emit a UserWarning if not.
+
+        The input is considered "graph-like" according to the definition in the
+        ``QubitGraph._is_graph_like()`` method.
+
+        Currently, QubitGraph only supports NetworkX graphs; specifically, graph objects that are
+        instances of the `networkx.Graph
+        <https://networkx.org/documentation/stable/reference/classes/graph.html>` class, or
+        subclasses thereof.
+
+        Note that other NetworkX graph types, including ``DiGraph``, ``MultiGraph`` and
+        ``MultiDiGraph`` are all subclasses of the ``Graph`` class and are therefore permitted,
+        although their usage is discouraged since they store additional information that is not used
+        by QubitGraph.
+
+        Args:
+            candidate: The candidate graph object used for type-checking. This object must not be
+                None.
+        """
+        assert candidate is not None, "Graph object used for type-checking must not be None"
+
+        if not cls._is_graph_like(candidate):
+            raise TypeError(
+                "QubitGraph requires a graph-like input, i.e. an object having both a 'nodes' and "
+                "an 'edges' attribute."
+            )
+
+        if not isinstance(candidate, nx.Graph):
+            warnings.warn(
+                f"QubitGraph expects an input graph of type 'networkx.Graph', but got "
+                f"'{type(candidate).__name__}'. Using a graph of another type may result in "
+                f"unexpected behaviour.",
+                UserWarning,
+            )
+
+    @classmethod
+    def _is_graph_like(cls, candidate: Any) -> bool:
+        """Check if the input is a graph-like object.
+
+        An object is considered "graph-like" if it has both a 'nodes' and an 'edges' attribute.
+
+        Args:
+            candidate: The candidate graph object used for type-checking. This object must not be
+                None.
+        """
+        assert candidate is not None, "Graph object used for type-checking must not be None"
+
+        return hasattr(candidate, "nodes") and hasattr(candidate, "edges")


Test patch:
diff --git a/tests/ftqc/test_qubit_graph.py b/tests/ftqc/test_qubit_graph.py
new file mode 100644
index 000000000..3e1a38cd6
--- /dev/null
+++ b/tests/ftqc/test_qubit_graph.py
@@ -0,0 +1,766 @@
+# Copyright 2025 Xanadu Quantum Technologies Inc.
+
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+
+#     http://www.apache.org/licenses/LICENSE-2.0
+
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Unit tests for the qubit_graph module"""
+
+import re
+import uuid
+
+import networkx as nx
+import pytest
+import rustworkx as rx
+
+import pennylane as qml
+from pennylane.ftqc import QubitGraph
+from pennylane.ftqc.qubit_graph import MAX_TRAVERSAL_DEPTH
+
+# pylint: disable=too-few-public-methods
+
+
+class TestQubitGraphsInitialization:
+    """Tests for basic initialization of QubitGraphs."""
+
+    def test_initialization_trivial(self):
+        """Trivial case: test that we can initialize a QubitGraph object. Also test that the
+        underlying qubit graph is uninitialized."""
+        qubit = QubitGraph()
+        assert not qubit.is_initialized
+
+    @pytest.mark.parametrize(
+        "graph, id",
+        [(nx.hexagonal_lattice_graph(3, 2), None), (nx.hexagonal_lattice_graph(3, 2), 0)],
+    )
+    def test_initialization_constructor(self, graph, id):
+        """Test that we can initialize a QubitGraph with a user-defined graph of underlying qubits
+        using the QubitGraph constructor."""
+        qubit = QubitGraph(graph, id)
+
+        assert set(qubit.node_labels) == set(graph.nodes)
+        assert set(qubit.edge_labels) == set(graph.edges)
+
+        if id is None:
+            assert isinstance(qubit._id, uuid.UUID)  # pylint: disable=protected-access
+            assert isinstance(qubit.id, str)
+        else:
+            assert qubit.id == id
+
+        for child in qubit.children:
+            assert isinstance(child, QubitGraph)
+            assert child.parent is qubit
+
+    def test_init_graph(self):
+        """Test that we can initialize a QubitGraph with a user-defined graph of underlying qubits."""
+        g = nx.hexagonal_lattice_graph(3, 2)
+        qubit = QubitGraph()
+        qubit.init_graph(g)
+
+        assert set(qubit.node_labels) == set(g.nodes)
+        assert set(qubit.edge_labels) == set(g.edges)
+
+        for child in qubit.children:
+            assert isinstance(child, QubitGraph)
+            assert child.parent is qubit
+
+    def test_init_graph_2d_grid(self):
+        """Test that we can initialize a QubitGraph with a 2D Cartesian grid of underlying qubits.
+
+        For example, for a 2 x 3 grid, we expect a graph with the following structure:
+
+            (0,0) ---- (0,1) ---- (0,2)
+              |          |          |
+              |          |          |
+            (1,0) ---- (1,1) ---- (1,2)
+        """
+        qubit = QubitGraph()
+        m, n = 2, 3
+        qubit.init_graph_2d_grid(m, n)
+
+        expected_graph = nx.grid_2d_graph(m, n)
+        assert set(qubit.node_labels) == set(expected_graph.nodes)
+        assert set(qubit.edge_labels) == set(expected_graph.edges)
+
+        for child in qubit.children:
+            assert isinstance(child, QubitGraph)
+            assert child.parent is qubit
+
+    def test_init_graph_2d_grid_nested_two_layers(self):
+        """Test that we can initialize a QubitGraph with two layers, where each layer is a 2D grid
+        of underlying qubits.
+
+        For example, consider a 2 x 3 grid at the first layer, where each underlying qubit at the
+        second layer is a 1 x 2 grid. The structure of this graph is:
+
+            (0,0) --------------- (0,1) --------------- (0,2)
+              -> (0,0) -- (0,1)     -> (0,0) -- (0,1)     -> (0,0) -- (0,1)
+              |                     |                     |
+              |                     |                     |
+            (1,0) --------------- (1,1) --------------- (1,2)
+              -> (0,0) -- (0,1)     -> (0,0) -- (0,1)     -> (0,0) -- (0,1)
+        """
+        m0, n0 = 2, 3
+        m1, n1 = 1, 2
+
+        # Initialize top-layer qubit (layer 0)
+        qubit0 = QubitGraph()
+        qubit0.init_graph_2d_grid(m0, n0)
+
+        for node in qubit0.node_labels:
+            # Initialize each next-to-top-layer qubit (layer 1)
+            qubit1 = QubitGraph()
+            qubit1.init_graph_2d_grid(m1, n1)
+
+            qubit0[node] = qubit1
+
+        expected_graph0 = nx.grid_2d_graph(m0, n0)
+        assert set(qubit0.node_labels) == set(expected_graph0.nodes)
+        assert set(qubit0.edge_labels) == set(expected_graph0.edges)
+
+        expected_graph1 = nx.grid_2d_graph(m1, n1)
+        expected_graph1_nodes_set = set(expected_graph1.nodes)
+        expected_graph1_edges_set = set(expected_graph1.edges)
+
+        for child in qubit0.children:
+            assert set(child.node_labels) == expected_graph1_nodes_set
+            assert set(child.edge_labels) == expected_graph1_edges_set
+            assert child.parent is qubit0
+
+    def test_init_graph_3d_grid(self):
+        """Test that we can initialize a QubitGraph with a 3D Cartesian grid of underlying qubits."""
+        qubit = QubitGraph()
+        n0, n1, n2 = 2, 3, 4
+        qubit.init_graph_nd_grid((n0, n1, n2))
+
+        expected_graph = nx.grid_graph((n0, n1, n2))
+        assert set(qubit.node_labels) == set(expected_graph.nodes)
+        assert set(qubit.edge_labels) == set(expected_graph.edges)
+
+        for child in qubit.children:
+            assert isinstance(child, QubitGraph)
+            assert child.parent is qubit
+
+    def test_init_graph_surface_code_17(self):
+        """Test that we can initialize a QubitGraph with the underlying qubits following the
+        structure of the 17-qubit surface code.
+        """
+        qubit = QubitGraph()
+        qubit.init_graph_surface_code_17()
+
+        # Create the expected graph structure for Surface Code 17
+        # This is essentially duplicated from the QubitGraph implementation, but it ensures that
+        # accidental changes to the production code will result in a test failure
+        data_qubits = [("data", i) for i in range(9)]  # 9 data qubits, indexed 0, 1, ..., 8
+        aux_qubits = [
+            ("aux", i) for i in range(9, 17)
+        ]  # 8 auxiliary qubits, indexed 9, 10, ..., 16
+
+        expected_graph = nx.Graph()
+        expected_graph.add_nodes_from(data_qubits)
+        expected_graph.add_nodes_from(aux_qubits)
+
+        # Adjacency list for connectivity of each auxiliary qubit to its neighbouring data qubits
+        aux_adjacency_list = {
+            9: [1, 2],
+            10: [0, 3],
+            11: [0, 1, 3, 4],
+            12: [1, 2, 4, 5],
+            13: [3, 4, 6, 7],
+            14: [4, 5, 7, 8],
+            15: [5, 8],
+            16: [6, 7],
+        }
+
+        for aux_node, data_nodes in aux_adjacency_list.items():
+            for data_node in data_nodes:
+                expected_graph.add_edge(("aux", aux_node), ("data", data_node))
+
+        assert set(qubit.node_labels) == set(expected_graph.nodes)
+        assert set(qubit.edge_labels) == set(expected_graph.edges)
+
+        for child in qubit.children:
+            assert isinstance(child, QubitGraph)
+            assert child.parent is qubit
+
+    def test_initialization_with_reused_graph(self):
+        """Test that we can properly initialize a QubitGraph when reusing a single networkx Graph
+        that defines the graph structure of the underlying qubits.
+        """
+        # Initialize qubit0 by passing graph to constructor and qubit1 using `init_graph` to cover
+        # both graph-initialization methods
+        g = nx.grid_graph((2, 2))
+        qubit0 = QubitGraph(g, id=0)
+        qubit1 = QubitGraph(id=1)
+        qubit1.init_graph(g)
+
+        # The underlying graphs should be distinct objects, but have the same structure
+        assert qubit0.graph is not qubit1.graph
+
+        assert set(qubit0.node_labels) == set(g.nodes)
+        assert set(qubit0.edge_labels) == set(g.edges)
+        assert set(qubit1.node_labels) == set(g.nodes)
+        assert set(qubit1.edge_labels) == set(g.edges)
+
+        # Altering the original graph object should not affect the QubitGraphs
+        g.add_node("aux")
+        assert set(qubit0.node_labels) == set(qubit1.node_labels)
+        assert set(qubit0.node_labels) != set(g.nodes)
+
+        # Test nesting
+        qubit0[(0, 0)] = QubitGraph(g)
+        qubit0_00 = qubit0[(0, 0)]
+        assert set(qubit0_00.node_labels) == set(g.nodes)
+        assert set(qubit0_00.edge_labels) == set(g.edges)
+        assert qubit0_00.parent is qubit0
+
+        qubit0[(0, 0)][(0, 0)] = QubitGraph(g)
+        qubit0_00_00 = qubit0[(0, 0)][(0, 0)]
+        assert set(qubit0_00_00.node_labels) == set(g.nodes)
+        assert set(qubit0_00_00.edge_labels) == set(g.edges)
+        assert qubit0_00_00.parent is qubit0_00
+        assert qubit0_00_00.parent.parent is qubit0
+
+    @pytest.mark.xfail(reason="QubitGraph does not yet support rustworkx graphs")
+    def test_initialization_with_rustworkx_graph(self):
+        """Test that we can initialize a QubitGraph using a rustworkx graph."""
+        g = rx.PyGraph()
+        g.add_nodes_from([0, 1])
+        g.add_edge(0, 1, None)
+
+        with pytest.warns(
+            UserWarning,
+            match="QubitGraph expects an input graph of type 'networkx.Graph', but got 'PyGraph'",
+        ):
+            qubit = QubitGraph(g)
+
+            assert set(qubit.node_labels) == set(g.nodes())
+            assert set(qubit.edge_labels) == set(g.edges())
+
+            for child in qubit.children:
+                assert isinstance(child, QubitGraph)
+                assert child.parent is qubit
+
+    def test_init_graph_with_invalid_type_raises_type_error(self):
+        """Test that attempting to initialize a graph with an invalid graph type raises a TypeError."""
+        # pylint: disable=missing-class-docstring
+
+        class NotAGraph:
+            pass
+
+        class SomethingWithOnlyNodes:
+            def __init__(self):
+                self.nodes = []
+
+        class SomethingWithOnlyEdges:
+            def __init__(self):
+                self.edges = []
+
+        # Test initialization with constructor
+        with pytest.raises(TypeError, match="QubitGraph requires a graph-like input"):
+            invalid_graph = NotAGraph()
+            _ = QubitGraph(invalid_graph)
+
+        with pytest.raises(TypeError, match="QubitGraph requires a graph-like input"):
+            invalid_graph = SomethingWithOnlyNodes()
+            _ = QubitGraph(invalid_graph)
+
+        with pytest.raises(TypeError, match="QubitGraph requires a graph-like input"):
+            invalid_graph = SomethingWithOnlyEdges()
+            _ = QubitGraph(invalid_graph)
+
+        # Test initialization with `init_graph` method
+        with pytest.raises(TypeError, match="QubitGraph requires a graph-like input"):
+            invalid_graph = NotAGraph()
+            q = QubitGraph()
+            q.init_graph(invalid_graph)
+
+        with pytest.raises(TypeError, match="QubitGraph requires a graph-like input"):
+            invalid_graph = SomethingWithOnlyNodes()
+            q = QubitGraph()
+            q.init_graph(invalid_graph)
+
+        with pytest.raises(TypeError, match="QubitGraph requires a graph-like input"):
+            invalid_graph = SomethingWithOnlyEdges()
+            q = QubitGraph()
+            q.init_graph(invalid_graph)
+
+        with pytest.raises(TypeError, match="QubitGraph requires a graph-like input, got NoneType"):
+            q = QubitGraph()
+            q.init_graph(None)
+
+
+class TestQubitGraphConnectivityAttributes:
+    """Test for QubitGraph attributes relating to their connectivity across a single layer in the
+    nesting hierarchy.
+    """
+
+    def test_neighbors(self):
+        """Test basic usage of the ``QubitGraph.neighbors`` attribute."""
+        q = QubitGraph(nx.grid_2d_graph(2, 2))
+
+        assert set(q.neighbors) == set()
+
+        assert set(q[(0, 0)].neighbors) == set([q[(0, 1)], q[(1, 0)]])
+        assert set(q[(0, 1)].neighbors) == set([q[(0, 0)], q[(1, 1)]])
+        assert set(q[(1, 0)].neighbors) == set([q[(0, 0)], q[(1, 1)]])
+        assert set(q[(1, 1)].neighbors) == set([q[(0, 1)], q[(1, 0)]])
+
+
+class TestQubitGraphOperations:
+    """Tests for operations on a QubitGraph."""
+
+    def test_clear(self):
+        """Test basic usage of the ``QubitGraph.clear`` method."""
+        q = QubitGraph()
+        assert q.graph is None
+
+        q.init_graph(nx.grid_2d_graph(2, 1))
+        assert q.graph is not None
+
+        q.clear()
+        assert q.graph is None
+
+    def test_has_cycle(self):
+        """Test basic usage of the ``QubitGraph.has_cycle`` method to detect cyclically nested
+        graph structures.
+        """
+        # 1 layer; no cycle
+        q = QubitGraph()
+        assert not q.has_cycle()
+
+        # 1 layer; self cycle
+        q._parent = q  # pylint: disable=protected-access
+        assert q.has_cycle()
+
+        # 2 layers; no cycle
+        q = QubitGraph()
+        q.init_graph_nd_grid((1,))
+        assert not q.has_cycle()
+        assert not q[0].has_cycle()
+
+        # 2 layers; cyclic
+        q[0] = q
+        assert q.has_cycle()
+        assert q[0].has_cycle()
+
+        # 3 layers; no cycle
+        q = QubitGraph()
+        q.init_graph_nd_grid((1,))
+        q[0].init_graph_nd_grid((1,))
+        assert not q.has_cycle()
+        assert not q[0].has_cycle()
+        assert not q[0][0].has_cycle()
+
+        # 3 layers; cyclic
+        q[0][0] = q
+        assert q.has_cycle()
+        assert q[0].has_cycle()
+        assert q[0][0].has_cycle()
+
+
+class TestQubitGraphIterationMethods:
+    """Tests for iteration method on a QubitGraph."""
+
+    def test_iterate_nodes(self):
+        """Test that we can iterate over the nodes of a QubitGraph."""
+        q = QubitGraph()
+        q.init_graph_nd_grid((2,))
+
+        for i, node in enumerate(q.node_labels):
+            assert node == i
+
+    def test_iterate_edges(self):
+        """Test that we can iterate over the edges of a QubitGraph."""
+        q = QubitGraph()
+        q.init_graph_nd_grid((2,))
+
+        for i, edge in enumerate(q.edge_labels):
+            assert edge == (i, i + 1)
+
+    def test_wrapping_in_container(self):
+        """Test that wrapping a QubitGraph in a sequential container does not implicitly iterate
+        over the underlying qubit graph.
+        """
+        q = QubitGraph()
+        q.init_graph_nd_grid((2,))
+
+        q_tuple = tuple(q)
+        assert len(q_tuple) == 1
+        assert q_tuple[0] is q
+
+
+class TestQubitGraphIndexing:
+    """Tests for indexing operations on a QubitGraph."""
+
+    def test_linear_indexing(self):
+        """Test basic linear indexing."""
+        qubit = QubitGraph()
+
+        n = 3
+        qubit.init_graph_nd_grid((n,))
+
+        for i in range(n):
+            q = qubit[i]
+            assert isinstance(q, QubitGraph)
+
+    def test_linear_indexing_nested(self):
+        """Test basic linear indexing in a nested QubitGraph."""
+        qubit0 = QubitGraph()
+
+        n0, n1 = 3, 2
+        qubit0.init_graph_nd_grid((n0,))
+
+        for node in qubit0.node_labels:
+            q1 = QubitGraph()
+            q1.init_graph_nd_grid((n1,))
+
+            qubit0[node] = q1
+
+        for i in range(n0):
+            for j in range(n1):
+                q = qubit0[i][j]
+                assert isinstance(q, QubitGraph)
+
+    def test_linear_indexing_slice(self):
+        """Test basic linear indexing using slices."""
+        qubit = QubitGraph()
+        qubit.init_graph_nd_grid((4,))
+
+        qubit_slice_02 = qubit[0:2]
+        assert len(qubit_slice_02) == 2
+        assert qubit_slice_02[0] is qubit[0]
+        assert qubit_slice_02[1] is qubit[1]
+
+        qubit_slice_13 = qubit[1:3]
+        assert len(qubit_slice_13) == 2
+        assert qubit_slice_13[0] is qubit[1]
+        assert qubit_slice_13[1] is qubit[2]
+
+        qubit_slice_042 = qubit[0:4:2]
+        assert len(qubit_slice_042) == 2
+        assert qubit_slice_042[0] is qubit[0]
+        assert qubit_slice_042[1] is qubit[2]
+
+        qubit_slice_20m1 = qubit[2:0:-1]
+        assert len(qubit_slice_20m1) == 2
+        assert qubit_slice_20m1[0] is qubit[2]
+        assert qubit_slice_20m1[1] is qubit[1]
+
+    def test_assignment(self):
+        """Test assignment of a new QubitGraph object at a given index."""
+        qubit = QubitGraph(id=0)
+        qubit.init_graph_nd_grid((2,))
+
+        new_qubit = QubitGraph(id=1)
+        new_qubit.init_graph_nd_grid((2, 2))
+
+        qubit[0] = new_qubit
+        assert qubit[0] is new_qubit
+        assert qubit[0].is_initialized
+        assert qubit[0].node_labels is not None
+        assert not qubit[1].is_initialized
+
+    def test_attributes_after_assignment(self):
+        """Test that the relevant attributes of a new QubitGraph object have been updated correctly
+        after an assignment operation.
+        """
+        qubit = QubitGraph(id=0)
+        qubit.init_graph_nd_grid((2,))
+
+        new_qubit = QubitGraph(id=1)
+        new_qubit.init_graph_nd_grid((2, 2))
+
+        # Checks before assignment
+        assert qubit.id == 0
+        assert qubit.parent is None
+
+        assert new_qubit.id == 1
+        assert new_qubit.parent is None
+
+        # Assign `new_qubit` to node in `qubit` with label 0
+        qubit[0] = new_qubit
+
+        # The parent attributes should not have changed after assignment
+        assert qubit.id == 0
+        assert qubit.parent is None
+
+        # Checks update attributes of new qubit
+        assert qubit[0] is new_qubit
+        assert qubit[0].id == 0
+        assert qubit[0].parent is qubit
+
+    def test_invalid_index_raises_keyerror(self):
+        """Test that accessing a QubitGraph with an invalid index raises a KeyError."""
+        qubit = QubitGraph()
+        qubit.init_graph_nd_grid((2,))
+
+        invalid_index = 4
+        with pytest.raises(KeyError, match=f"{invalid_index}"):
+            _ = qubit[4]
+
+    def test_invalid_assignment_raises_typeerror(self):
+        """Test that attempting to assign a value that is not a QubitGraph to a node in the graph
+        raises a TypeError."""
+        qubit = QubitGraph()
+        qubit.init_graph_nd_grid((2,))
+
+        with pytest.raises(TypeError, match="item assignment type must also be a QubitGraph"):
+            qubit[0] = 42
+
+        with pytest.raises(TypeError, match="item assignment type must also be a QubitGraph"):
+            qubit[0] = nx.Graph()
+
+
+class TestQubitGraphNesting:
+    """Tests relating to the nesting of QubitGraphs."""
+
+    @staticmethod
+    def _generate_single_node_graph():
+        """Return a graph containing a single node with label 0."""
+        graph = nx.Graph()
+        graph.add_node(0)
+        return graph
+
+    def test_is_leaf(self):
+        """Test the is_leaf() method on each layer in a nested QubitGraph."""
+        qubit = QubitGraph(self._generate_single_node_graph())
+        qubit[0] = QubitGraph(self._generate_single_node_graph())
+
+        assert not qubit.is_leaf
+        assert not qubit[0].is_leaf
+        assert qubit[0][0].is_leaf
+
+    def test_is_leaf_vs_initialized(self):
+        """Test that is_leaf and is_initialized are interpreted correctly."""
+        # Case 1: Fully initialized but lowest layer is null graph
+        #  -> In this case, lowest layer is both a leaf node and initialized
+        qubit1 = QubitGraph(self._generate_single_node_graph())
+        qubit1[0] = QubitGraph(nx.null_graph())
+
+        assert not qubit1.is_leaf
+        assert qubit1[0].is_leaf
+        assert qubit1[0].is_initialized
+
+        # Case 2: Lowest layer is not initialized
+        #  -> In this case, lowest layer is a leaf node but NOT initialized
+        qubit2 = QubitGraph(self._generate_single_node_graph())
+        qubit2[0] = QubitGraph()
+
+        assert not qubit2.is_leaf
+        assert qubit2[0].is_leaf
+        assert not qubit2[0].is_initialized
+
+    def test_is_root(self):
+        """Test the is_leaf() method on each layer in a nested QubitGraph."""
+        qubit = QubitGraph(self._generate_single_node_graph())
+        qubit[0] = QubitGraph(self._generate_single_node_graph())
+
+        assert qubit.is_root
+        assert not qubit[0].is_root
+        assert not qubit[0][0].is_root
+
+    def test_parent_structure(self):
+        """Test that the parent property of a nested QubitGraph references the correct objects."""
+        qubit = QubitGraph(self._generate_single_node_graph())
+        qubit[0] = QubitGraph(self._generate_single_node_graph())
+
+        assert qubit.parent is None
+        assert qubit[0].parent is qubit
+        assert qubit[0][0].parent is qubit[0]
+
+        # Also test cascaded parent operations
+        assert qubit[0][0].parent.parent is qubit
+
+
+class TestQubitGraphRepresentation:
+    """Tests for representing a QubitGraph as a string."""
+
+    def test_representation(self):
+        """Test basic conversion of a QubitGraph to its string representation."""
+        q = QubitGraph(id=0)
+        assert str(q) == "QubitGraph<id=0, loc=[]>"
+
+        q = QubitGraph(id="0")
+        assert str(q) == "QubitGraph<id=0, loc=[]>"
+
+        q = QubitGraph(id=(0, 0))
+        assert str(q) == "QubitGraph<id=(0, 0), loc=[]>"
+
+        q = QubitGraph(id=("aux", 0))
+        assert str(q) == "QubitGraph<id=('aux', 0), loc=[]>"
+
+        q = QubitGraph()  # ID is initialized as UUID if not given
+        hex_pattern_8char = r"[0-9a-fA-F]{8}"  # Expect string repr to contain 8-character hex code
+        assert re.match(rf"QubitGraph<id={hex_pattern_8char}, loc=\[\]>", str(q))
+
+    def test_representation_nested(self):
+        """Test conversion of a nested QubitGraph to its string representation."""
+        graph = nx.Graph()
+        graph.add_node(1)
+        q = QubitGraph(graph, id=0)
+        assert str(q) == "QubitGraph<id=0, loc=[]>"
+        assert str(q[1]) == "QubitGraph<id=1, loc=[0]>"
+
+        graph = nx.Graph()
+        graph.add_node("1")
+        q = QubitGraph(graph, id=0)
+        assert str(q) == "QubitGraph<id=0, loc=[]>"
+        assert str(q["1"]) == "QubitGraph<id=1, loc=[0]>"
+
+        graph = nx.Graph()
+        graph.add_node((0, 0))
+        q = QubitGraph(graph, id=0)
+        assert str(q) == "QubitGraph<id=0, loc=[]>"
+        assert str(q[(0, 0)]) == "QubitGraph<id=(0, 0), loc=[0]>"
+
+        graph = nx.Graph()
+        graph.add_node(("aux", 0))
+        q = QubitGraph(graph, id=0)
+        assert str(q) == "QubitGraph<id=0, loc=[]>"
+        assert str(q[("aux", 0)]) == "QubitGraph<id=('aux', 0), loc=[0]>"
+
+    def test_representation_cyclically_nested_graph(self):
+        """This test currently checks that attempting to represent a cyclically nested graph
+        structure emits a warning stating that a cycle was detected.
+
+        In the future, it would be better to explicitly disallow constructing a cyclically nested
+        QubitGraph.
+        """
+        # Create a cyclically nested graph
+        q = QubitGraph()
+        q.init_graph_nd_grid((1,))
+        q[0] = q
+
+        with pytest.warns(UserWarning, match="A cyclically nested graph structure was detected"):
+            assert str(q) == "QubitGraph<id=0; cyclic>"
+
+    def test_representation_deeply_nested_graph(self):
+        """Test that attempting to represent a deeply nested QubitGraph object emits a 'Maximum
+        traversal depth reached' warning, and that the resulting string representation begins with
+        leading ellipses, i.e. 'QubitGraph<...,'."""
+        q = QubitGraph()
+        q.init_graph_nd_grid((1,))
+        q_next = q
+        for _ in range(MAX_TRAVERSAL_DEPTH):
+            q_next = q_next[0]
+            q_next.init_graph_nd_grid((1,))
+
+        with pytest.warns(UserWarning, match="Maximum traversal depth reached"):
+            q_next_str_repr = str(q_next)
+            assert q_next_str_repr.startswith("QubitGraph<id=0, loc=[")
+            assert q_next_str_repr.endswith("...]>")
+
+
+class TestQubitGraphWorkflows:
+    """Tests of QubitGraph workflows."""
+
+    def test_execution(self):
+        """Test execution of a simple circuit using QubitGraph objects as wires."""
+        q0 = QubitGraph(id=0)
+        q1 = QubitGraph(id=1)
+        q0.init_graph_nd_grid((2,))
+        q1.init_graph_nd_grid((2,))
+
+        dev = qml.device("default.qubit")
+
+        @qml.qnode(dev)
+        def circuit():
+            qml.RZ(1.618, wires=q0)
+            qml.CNOT(wires=[q0, q1])
+            qml.RY(1.618, wires=q1)
+            return qml.expval(qml.PauliZ(q1))
+
+        circuit()
+        assert True  # Only check that we can execute the circuit without failure
+
+
+class TestQubitGraphsWarnings:
+    """Tests for QubitGraph warning messages."""
+
+    def test_access_uninitialized_nodes_warning(self):
+        """Test that accessing the nodes property of an uninitialized graph emits a UserWarning."""
+        q = QubitGraph()
+        with pytest.warns(UserWarning, match="Attempting to access an uninitialized QubitGraph"):
+            _ = q.node_labels
+
+    def test_access_uninitialized_edges_warning(self):
+        """Test that accessing the edges property of an uninitialized graph emits a UserWarning."""
+        q = QubitGraph()
+        with pytest.warns(UserWarning, match="Attempting to access an uninitialized QubitGraph"):
+            _ = q.edge_labels
+
+    def test_access_uninitialized_children_warning(self):
+        """Test that accessing the children property of an uninitialized graph emits a UserWarning."""
+        q = QubitGraph()
+        with pytest.warns(UserWarning, match="Attempting to access an uninitialized QubitGraph"):
+            for child in q.children:
+                _ = child
+
+    def test_access_uninitialized_subscript_warning(self):
+        """Test that accessing an element of a qubit with the subscript operator with an
+        uninitialized graph emits a UserWarning.
+        """
+        q = QubitGraph()
+        with pytest.warns(UserWarning, match="Attempting to access an uninitialized QubitGraph"):
+            _ = q[0]
+
+    def test_assignment_uninitialized_subscript_warning(self):
+        """Test that assigning an element of a qubit with an uninitialized graph emits a
+        UserWarning.
+        """
+        q = QubitGraph()
+        with pytest.warns(UserWarning, match="Attempting to access an uninitialized QubitGraph"):
+            q[0] = QubitGraph()
+
+    def test_reinitialization_warning(self):
+        """Test that re-initializing an already-initialized graph emits a UserWarning."""
+        q = QubitGraph()
+        q.init_graph_2d_grid(2, 2)
+
+        with pytest.warns(UserWarning, match="Attempting to re-initialize a QubitGraph"):
+            g = nx.Graph()
+            q.init_graph(g)
+
+        with pytest.warns(UserWarning, match="Attempting to re-initialize a QubitGraph"):
+            q.init_graph_2d_grid(2, 3)
+
+        with pytest.warns(UserWarning, match="Attempting to re-initialize a QubitGraph"):
+            q.init_graph_nd_grid((3,))
+
+        with pytest.warns(UserWarning, match="Attempting to re-initialize a QubitGraph"):
+            q.init_graph_surface_code_17()
+
+    def test_unsupported_graph_type_warning(self):
+        """Test that initializing a QubitGraph with a graph-like object that is not a networkx.Graph
+        object emits a UserWarning.
+        """
+        # pylint: disable=missing-class-docstring
+
+        class CustomGraph:
+            def __init__(self):
+                self.nodes = []
+                self.edges = []
+
+        g = CustomGraph()
+
+        # Test that constructor raises warning
+        with pytest.warns(
+            UserWarning, match="QubitGraph expects an input graph of type 'networkx.Graph'"
+        ):
+            _ = QubitGraph(g)
+
+        # Test that `init_graph` method raises warning
+        q = QubitGraph()
+        with pytest.warns(
+            UserWarning, match="QubitGraph expects an input graph of type 'networkx.Graph'"
+        ):
+            q.init_graph(g)