Repository: PennyLaneAI/pennylane
Pull request #6962: [FTQC] Add data structures and API for qubit mapping
Author: login: joeycarter
Created: 2025-02-12T22:27:57Z | Merged: 2025-03-10T20:16:08Z

**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)]


Diff:
diff --git a/doc/releases/changelog-dev.md b/doc/releases/changelog-dev.md
index a97d6737516..a991b8859ca 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 d33458cd71a..87779692ffa 100644
--- a/pennylane/ftqc/__init__.py
+++ b/pennylane/ftqc/__init__.py
@@ -37,9 +37,11 @@
     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 00000000000..78e71d5920c
--- /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")
diff --git a/tests/ftqc/test_qubit_graph.py b/tests/ftqc/test_qubit_graph.py
new file mode 100644
index 00000000000..3e1a38cd615
--- /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)


Reviews:
user: login: joeycarter
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-19T21:15:12Z
user: login: mlxd
body: Thanks @joeycarter 
A few thoughts from my side
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-02-19T21:32:42Z
user: login: joeycarter
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-20T18:29:23Z
user: login: joeycarter
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-20T18:30:37Z
user: login: joeycarter
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-20T18:31:05Z
user: login: joeycarter
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-20T18:34:10Z
user: login: joeycarter
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-20T18:34:44Z
user: login: joeycarter
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-24T15:50:49Z
user: login: joeycarter
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-24T16:59:02Z
user: login: joeycarter
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-26T20:42:28Z
user: login: joeycarter
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-26T20:53:58Z
user: login: mlxd
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-02-26T21:20:22Z
user: login: mlxd
body: Thanks @joeycarter 
Looks good to me --- a few minor queries, but happy to approve once they are resolved.
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-02-26T22:08:04Z
user: login: joeycarter
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-27T16:26:01Z
user: login: joeycarter
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-27T16:29:07Z
user: login: joeycarter
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-27T18:33:23Z
user: login: joeycarter
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-27T18:35:14Z
user: login: joeycarter
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-27T18:39:31Z
user: login: joeycarter
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-27T18:39:57Z
user: login: joeycarter
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-27T18:52:00Z
user: login: joeycarter
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-27T19:30:41Z
user: login: joeycarter
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-27T19:31:30Z
user: login: joeycarter
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-27T20:00:04Z
user: login: mlxd
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-02-27T21:40:20Z
user: login: mlxd
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-02-27T21:42:21Z
user: login: mlxd
body: Thanks a bunch @joeycarter 
No further additions from my side for now
state: APPROVED
author_association: MEMBER
submitted_at: 2025-02-27T21:43:30Z
user: login: lillian542
body: Looking good! I played around with this in a notebook for a while and found a few things a bit unintuitive to work with - I think we can make this more natural to use with a combination of some small adjustments to the behaviour, and some additions to the text and examples in the docstring. I left a few suggestions, see what you think :)
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-27T22:39:27Z
user: login: lillian542
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-27T22:53:57Z
user: login: lillian542
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-27T23:03:24Z
user: login: multiphaseCFD
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-03T14:12:15Z
user: login: multiphaseCFD
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-03T14:23:49Z
user: login: joeycarter
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-04T21:36:29Z
user: login: joeycarter
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-04T22:04:36Z
user: login: joeycarter
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-04T22:13:15Z
user: login: joeycarter
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-04T22:29:34Z
user: login: joeycarter
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-05T16:31:38Z
user: login: joeycarter
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-05T19:31:01Z
user: login: joeycarter
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-05T19:38:19Z
user: login: joeycarter
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-05T19:51:54Z
user: login: joeycarter
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-05T20:44:18Z
user: login: joeycarter
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-06T14:38:24Z
user: login: joeycarter
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-06T20:30:05Z
user: login: joeycarter
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-06T21:32:30Z
user: login: lillian542
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-07T16:12:50Z
user: login: lillian542
body: Looking really good 🚀 Love the section on reading the string representation, it's very clear. I don't have any more comments on the implementation or testing, but I left a few suggestions for the documentation, see what you think!


state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-07T18:11:56Z
user: login: joeycarter
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-07T18:48:53Z
user: login: joeycarter
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-07T19:29:52Z
user: login: joeycarter
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-07T19:52:53Z
user: login: lillian542
body: Looks great! 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 :)
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-07T21:31:32Z

Review comments:
diff_hunk: @@ -0,0 +1,401 @@
+# 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 the qubit memory model in the FTQC/MBQC
+framework, as well as the API to access and manipulate these data structures.
+"""
path: pennylane/ftqc/qubit_graph.py
user: login: mlxd
body: ```suggestion
"""This module contains the data structures to represent the qubits using a hierarchical memory model, as well as defining the API to access and manipulate these data structures.
"""
```

I think we can likely define this without the MBQC/FTQC points. We can likely use these structures independent of FTQC too (e.g. replacemenbt blocks for applying algorithms, etc/)
created_at: 2025-02-19T21:10:29Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,401 @@
+# 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 the qubit memory model in the FTQC/MBQC
+framework, as well as the API to access and manipulate these data structures.
+"""
+
+import warnings
+from typing import Union
+
+import networkx as nx
+
+
+class QubitGraph:
+    """A class to represent nested graphs of qubits in the FTQC/MBQC framework.
path: pennylane/ftqc/qubit_graph.py
user: login: mlxd
body: Same comment here as above.
created_at: 2025-02-19T21:10:56Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,401 @@
+# 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 the qubit memory model in the FTQC/MBQC
+framework, as well as the API to access and manipulate these data structures.
+"""
+
+import warnings
+from typing import Union
+
+import networkx as nx
+
+
+class QubitGraph:
+    """A class to represent nested graphs of qubits in the FTQC/MBQC framework.
+
+    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 to use as the QubitGraph's underlying qubits.
+            Inputting None (the default), leaves the underlying qubit graph in an uninitialized
+            state, in which case one of the graph-initialization methods may be used to define the
+            structure of the underlying qubit graph. 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. An object is considered "graph-like" if
+            it has both a 'nodes' and an 'edges' attribute.
+
+    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.
+        * Improve string representation of QubitGraph objects.
+    """
+
+    def __init__(self, graph: nx.Graph = None):
path: pennylane/ftqc/qubit_graph.py
user: login: mlxd
body: ```suggestion
    def __init__(self, graph: Optional[nx.Graph] = None):
```
If we expect this to also be `None` we can declare the type as [optional](https://docs.python.org/3/library/typing.html#typing.Optional)
created_at: 2025-02-19T21:13:59Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,401 @@
+# 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 the qubit memory model in the FTQC/MBQC
+framework, as well as the API to access and manipulate these data structures.
+"""
+
+import warnings
+from typing import Union
+
+import networkx as nx
+
+
+class QubitGraph:
+    """A class to represent nested graphs of qubits in the FTQC/MBQC framework.
+
+    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 to use as the QubitGraph's underlying qubits.
+            Inputting None (the default), leaves the underlying qubit graph in an uninitialized
+            state, in which case one of the graph-initialization methods may be used to define the
+            structure of the underlying qubit graph. 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. An object is considered "graph-like" if
+            it has both a 'nodes' and an 'edges' attribute.
+
+    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.
+        * Improve string representation of QubitGraph objects.
+    """
+
+    def __init__(self, graph: nx.Graph = None):
+        if graph is not None:
+            self._check_graph_type_supported_and_raise_or_warn(graph)
+
+        self._graph_qubits = graph  # The qubits underlying (nested within) the current qubit
+
+        # Initialize each node in the graph to store an empty QubitGraph object
+        if self._graph_qubits is not None:
+            for node in self._graph_qubits.nodes:
+                self[node] = QubitGraph()
+
+    def __getitem__(self, key):
+        """QubitGraph subscript operator for read access.
+
+        Currently only basic, linear indexing and slicing is supported.
+
+        Args:
+            key: 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_qubits.nodes))
+            return [self._graph_qubits.nodes[node]["qubits"] for node in range(start, stop, step)]
+
+        return self._graph_qubits.nodes[key]["qubits"]
+
+    def __setitem__(self, key, value: "QubitGraph"):
+        """QubitGraph subscript operator for assignment.
+
+        Currently only basic, linear indexing is supported. Slicing is not supported.
+
+        Args:
+            key: Node label in the underlying qubit graph.
+            value (QubitGraph): The QubitGraph object to assign to the node with the given key.
+
+        TODO: Allow for more advanced tensor-like indexing and slicing.
+        """
+        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
+
+        self._graph_qubits.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()
+            >>> q.init_graph_nd_grid((2,))
+            >>> tuple(q)
+            (<QubitGraph object>,)
+
+        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:
+
+            >>> q = QubitGraph()
+            >>> q.init_graph_nd_grid((2,))
+            >>> tuple(q)
+            >>> (<QubitGraph object>, <QubitGraph object>)
+
+        Making QubitGraph non-iterable is especially important when using it as input to the Wires
+        class, which checks if the input is iterable by wrapping it in a tuple.
+        """
+        yield self
+
+    def __repr__(self):
+        """Representation of a QubitGraph object.
+
+        TODO:
+            Improve this representation.
+        """
+        return "QubitGraph"
+
+    @property
+    def graph(self):
+        """Gets the underlying qubit graph."""
+        return self._graph_qubits
+
+    @property
+    def nodes(self):
+        """Gets the set of nodes in the underlying qubit graph.
+
+        If the underlying qubit graph has not been initialized, emit a UserWarning and return None.
+
+        Returns:
+            networkx.NodeView: 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 self._graph_qubits is None:
+            self._warn_uninitialized()
+            return None
+
+        return self._graph_qubits.nodes
+
+    @property
+    def edges(self):
+        """Gets the set of edges in the underlying qubit graph.
+
+        If the underlying qubit graph has not been initialized, emit a UserWarning and return None.
+
+        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 self._graph_qubits is None:
+            self._warn_uninitialized()
+            return None
+
+        return self._graph_qubits.edges
+
+    @property
+    def is_initialized(self):
+        """Checks if the underlying qubits have been initialized.
+
+        Returns:
+            bool: Returns True if the underlying qubits have been initialized, False otherwise.
+        """
+        return self._graph_qubits is not None
+
+    def clear(self):
+        """Clears the graph of underlying qubits."""
+        self._graph_qubits = None
+
+    def connected_qubits(self, node):
+        """Returns an iterator over all of the qubits connected to the qubit with label ``node``.
+
+        Args:
+            node (node like): The label of a node in the qubit graph.
+
+        Returns:
+            iterator: An iterator over all QubitGraph objects connected to the qubit with label
+                ``node``.
+        """
+        if not self.is_initialized:
+            self._warn_uninitialized()
+            return
+
+        for neighbor in self._graph_qubits.neighbors(node):
+            yield self[neighbor]
+
+    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:
+
+            >>> graph = networkx.hexagonal_lattice_graph(3, 2)
+            >>> q = QubitGraph(graph)
+        """
+        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_qubits = graph
+        for node in self._graph_qubits.nodes:
+            self[node] = QubitGraph()
+
+    def init_graph_2d_grid(self, m: int, n: int):
path: pennylane/ftqc/qubit_graph.py
user: login: joeycarter
body: We may want to consider moving this and the following `init_*` methods to @multiphaseCFD's `generate_lattice` functions, or delete them here if there are duplicates. 
created_at: 2025-02-19T21:15:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,401 @@
+# 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 the qubit memory model in the FTQC/MBQC
+framework, as well as the API to access and manipulate these data structures.
+"""
+
+import warnings
+from typing import Union
+
+import networkx as nx
+
+
+class QubitGraph:
+    """A class to represent nested graphs of qubits in the FTQC/MBQC framework.
+
+    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 to use as the QubitGraph's underlying qubits.
+            Inputting None (the default), leaves the underlying qubit graph in an uninitialized
+            state, in which case one of the graph-initialization methods may be used to define the
+            structure of the underlying qubit graph. 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. An object is considered "graph-like" if
+            it has both a 'nodes' and an 'edges' attribute.
+
+    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.
+        * Improve string representation of QubitGraph objects.
+    """
+
+    def __init__(self, graph: nx.Graph = None):
+        if graph is not None:
+            self._check_graph_type_supported_and_raise_or_warn(graph)
+
+        self._graph_qubits = graph  # The qubits underlying (nested within) the current qubit
+
+        # Initialize each node in the graph to store an empty QubitGraph object
+        if self._graph_qubits is not None:
+            for node in self._graph_qubits.nodes:
+                self[node] = QubitGraph()
+
+    def __getitem__(self, key):
+        """QubitGraph subscript operator for read access.
+
+        Currently only basic, linear indexing and slicing is supported.
+
+        Args:
+            key: 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_qubits.nodes))
+            return [self._graph_qubits.nodes[node]["qubits"] for node in range(start, stop, step)]
+
+        return self._graph_qubits.nodes[key]["qubits"]
+
+    def __setitem__(self, key, value: "QubitGraph"):
+        """QubitGraph subscript operator for assignment.
+
+        Currently only basic, linear indexing is supported. Slicing is not supported.
+
+        Args:
+            key: Node label in the underlying qubit graph.
+            value (QubitGraph): The QubitGraph object to assign to the node with the given key.
+
+        TODO: Allow for more advanced tensor-like indexing and slicing.
+        """
+        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
+
+        self._graph_qubits.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()
+            >>> q.init_graph_nd_grid((2,))
+            >>> tuple(q)
+            (<QubitGraph object>,)
+
+        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:
+
+            >>> q = QubitGraph()
+            >>> q.init_graph_nd_grid((2,))
+            >>> tuple(q)
+            >>> (<QubitGraph object>, <QubitGraph object>)
+
+        Making QubitGraph non-iterable is especially important when using it as input to the Wires
+        class, which checks if the input is iterable by wrapping it in a tuple.
+        """
+        yield self
+
+    def __repr__(self):
+        """Representation of a QubitGraph object.
+
+        TODO:
+            Improve this representation.
+        """
+        return f"QubitGraph"
path: pennylane/ftqc/qubit_graph.py
user: login: mlxd
body: Do we want to make it easy for now with something like `f"QubitGraph<{self._graph_qubits}>"`, or is there more to it than this?
created_at: 2025-02-19T21:17:24Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,401 @@
+# 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 the qubit memory model in the FTQC/MBQC
+framework, as well as the API to access and manipulate these data structures.
+"""
+
+import warnings
+from typing import Union
+
+import networkx as nx
+
+
+class QubitGraph:
+    """A class to represent nested graphs of qubits in the FTQC/MBQC framework.
+
+    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 to use as the QubitGraph's underlying qubits.
+            Inputting None (the default), leaves the underlying qubit graph in an uninitialized
+            state, in which case one of the graph-initialization methods may be used to define the
+            structure of the underlying qubit graph. 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. An object is considered "graph-like" if
+            it has both a 'nodes' and an 'edges' attribute.
+
+    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.
+        * Improve string representation of QubitGraph objects.
+    """
+
+    def __init__(self, graph: nx.Graph = None):
+        if graph is not None:
+            self._check_graph_type_supported_and_raise_or_warn(graph)
+
+        self._graph_qubits = graph  # The qubits underlying (nested within) the current qubit
+
+        # Initialize each node in the graph to store an empty QubitGraph object
+        if self._graph_qubits is not None:
+            for node in self._graph_qubits.nodes:
+                self[node] = QubitGraph()
+
+    def __getitem__(self, key):
+        """QubitGraph subscript operator for read access.
+
+        Currently only basic, linear indexing and slicing is supported.
+
+        Args:
+            key: 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_qubits.nodes))
+            return [self._graph_qubits.nodes[node]["qubits"] for node in range(start, stop, step)]
+
+        return self._graph_qubits.nodes[key]["qubits"]
+
+    def __setitem__(self, key, value: "QubitGraph"):
+        """QubitGraph subscript operator for assignment.
+
+        Currently only basic, linear indexing is supported. Slicing is not supported.
+
+        Args:
+            key: Node label in the underlying qubit graph.
+            value (QubitGraph): The QubitGraph object to assign to the node with the given key.
+
+        TODO: Allow for more advanced tensor-like indexing and slicing.
+        """
+        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
+
+        self._graph_qubits.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()
+            >>> q.init_graph_nd_grid((2,))
+            >>> tuple(q)
+            (<QubitGraph object>,)
+
+        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:
+
+            >>> q = QubitGraph()
+            >>> q.init_graph_nd_grid((2,))
+            >>> tuple(q)
+            >>> (<QubitGraph object>, <QubitGraph object>)
+
+        Making QubitGraph non-iterable is especially important when using it as input to the Wires
+        class, which checks if the input is iterable by wrapping it in a tuple.
+        """
+        yield self
+
+    def __repr__(self):
+        """Representation of a QubitGraph object.
+
+        TODO:
+            Improve this representation.
+        """
+        return f"QubitGraph"
+
+    @property
+    def graph(self):
+        """Gets the underlying qubit graph."""
+        return self._graph_qubits
+
+    @property
+    def nodes(self):
+        """Gets the set of nodes in the underlying qubit graph.
+
+        If the underlying qubit graph has not been initialized, emit a UserWarning and return None.
+
+        Returns:
+            networkx.NodeView: 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 self._graph_qubits is None:
+            self._warn_uninitialized()
+            return None
+
+        return self._graph_qubits.nodes
+
+    @property
+    def edges(self):
+        """Gets the set of edges in the underlying qubit graph.
+
+        If the underlying qubit graph has not been initialized, emit a UserWarning and return None.
+
+        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 self._graph_qubits is None:
+            self._warn_uninitialized()
+            return None
+
+        return self._graph_qubits.edges
+
+    @property
+    def is_initialized(self):
+        """Checks if the underlying qubits have been initialized.
+
+        Returns:
+            bool: Returns True if the underlying qubits have been initialized, False otherwise.
+        """
+        return self._graph_qubits is not None
+
+    def clear(self):
+        """Clears the graph of underlying qubits."""
+        self._graph_qubits = None
+
+    def connected_qubits(self, node):
+        """Returns an iterator over all of the qubits connected to the qubit with label ``node``.
+
+        Args:
+            node (node like): The label of a node in the qubit graph.
+
+        Returns:
+            iterator: An iterator over all QubitGraph objects connected to the qubit with label
+                ``node``.
+        """
+        if not self.is_initialized:
+            self._warn_uninitialized()
+            return
+
+        for neighbor in self._graph_qubits.neighbors(node):
+            yield self[neighbor]
+
+    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:
+
+            >>> graph = networkx.hexagonal_lattice_graph(3, 2)
+            >>> q = QubitGraph(graph)
+        """
+        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_qubits = graph
+        for node in self._graph_qubits.nodes:
+            self[node] = QubitGraph()
+
+    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, n (int): The number of rows, m, and columns, n, in the grid.
+
+        Example:
+
+            >>> q = QubitGraph()
+            >>> q.init_graph_2d_grid(2, 3)
+
+            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)
+        """
+        if self.is_initialized:
+            self._warn_reinitialization()
+            return
+
+        self._graph_qubits = nx.grid_2d_graph(m, n)
+        for node in self._graph_qubits.nodes:
+            self[node] = QubitGraph()
+
+    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:
+
+            >>> q = QubitGraph()
+            >>> q.init_graph_2d_grid(2, 2, 3)
+
+            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)
+        """
+        if self.is_initialized:
+            self._warn_reinitialization()
+            return
+
+        self._graph_qubits = nx.grid_graph(dim)
+        for node in self._graph_qubits.nodes:
+            self[node] = QubitGraph()
+
+    def init_graph_surface_code_17(self):
path: pennylane/ftqc/qubit_graph.py
user: login: mlxd
body: Fine to live here for now, but maybe later we can move this into a generator/factory object that builds the structures and returns the created graph entity.
created_at: 2025-02-19T21:20:27Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,401 @@
+# 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 the qubit memory model in the FTQC/MBQC
+framework, as well as the API to access and manipulate these data structures.
+"""
+
+import warnings
+from typing import Union
+
+import networkx as nx
+
+
+class QubitGraph:
+    """A class to represent nested graphs of qubits in the FTQC/MBQC framework.
+
+    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 to use as the QubitGraph's underlying qubits.
+            Inputting None (the default), leaves the underlying qubit graph in an uninitialized
+            state, in which case one of the graph-initialization methods may be used to define the
+            structure of the underlying qubit graph. 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. An object is considered "graph-like" if
+            it has both a 'nodes' and an 'edges' attribute.
+
+    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.
+        * Improve string representation of QubitGraph objects.
+    """
+
+    def __init__(self, graph: nx.Graph = None):
+        if graph is not None:
+            self._check_graph_type_supported_and_raise_or_warn(graph)
+
+        self._graph_qubits = graph  # The qubits underlying (nested within) the current qubit
+
+        # Initialize each node in the graph to store an empty QubitGraph object
+        if self._graph_qubits is not None:
+            for node in self._graph_qubits.nodes:
+                self[node] = QubitGraph()
+
+    def __getitem__(self, key):
+        """QubitGraph subscript operator for read access.
+
+        Currently only basic, linear indexing and slicing is supported.
+
+        Args:
+            key: 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_qubits.nodes))
+            return [self._graph_qubits.nodes[node]["qubits"] for node in range(start, stop, step)]
+
+        return self._graph_qubits.nodes[key]["qubits"]
+
+    def __setitem__(self, key, value: "QubitGraph"):
+        """QubitGraph subscript operator for assignment.
+
+        Currently only basic, linear indexing is supported. Slicing is not supported.
+
+        Args:
+            key: Node label in the underlying qubit graph.
+            value (QubitGraph): The QubitGraph object to assign to the node with the given key.
+
+        TODO: Allow for more advanced tensor-like indexing and slicing.
+        """
+        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
+
+        self._graph_qubits.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()
+            >>> q.init_graph_nd_grid((2,))
+            >>> tuple(q)
+            (<QubitGraph object>,)
+
+        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:
+
+            >>> q = QubitGraph()
+            >>> q.init_graph_nd_grid((2,))
+            >>> tuple(q)
+            >>> (<QubitGraph object>, <QubitGraph object>)
+
+        Making QubitGraph non-iterable is especially important when using it as input to the Wires
+        class, which checks if the input is iterable by wrapping it in a tuple.
+        """
+        yield self
+
+    def __repr__(self):
+        """Representation of a QubitGraph object.
+
+        TODO:
+            Improve this representation.
+        """
+        return f"QubitGraph"
+
+    @property
+    def graph(self):
+        """Gets the underlying qubit graph."""
+        return self._graph_qubits
+
+    @property
+    def nodes(self):
+        """Gets the set of nodes in the underlying qubit graph.
+
+        If the underlying qubit graph has not been initialized, emit a UserWarning and return None.
+
+        Returns:
+            networkx.NodeView: 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 self._graph_qubits is None:
+            self._warn_uninitialized()
+            return None
+
+        return self._graph_qubits.nodes
+
+    @property
+    def edges(self):
+        """Gets the set of edges in the underlying qubit graph.
+
+        If the underlying qubit graph has not been initialized, emit a UserWarning and return None.
+
+        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 self._graph_qubits is None:
+            self._warn_uninitialized()
+            return None
+
+        return self._graph_qubits.edges
+
path: pennylane/ftqc/qubit_graph.py
user: login: mlxd
body: Do we want an `is_leaf` method, or something similar to have an entry point to determine whether a given qubit is bottom of the tree? Fine if not.
created_at: 2025-02-19T21:21:38Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,484 @@
+# 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 networkx as nx
+import pytest
+
+import pennylane as qml
+from pennylane.ftqc.qubit_graph import QubitGraph
+
+# 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."""
+        qubit = QubitGraph()
+        assert qubit
path: tests/ftqc/test_qubit_graph.py
user: login: mlxd
body: What's the assertion here? That the object is not `None` ?
Should we be more explicit?
created_at: 2025-02-19T21:22:42Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,484 @@
+# 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 networkx as nx
+import pytest
+
+import pennylane as qml
+from pennylane.ftqc.qubit_graph import QubitGraph
+
+# 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."""
+        qubit = QubitGraph()
+        assert qubit
+
+    def test_initialization_constructor(self):
+        """Test that we can initialize a QubitGraph with a user-defined graph of underlying qubits
+        using the QubitGraph constructor."""
+        g = nx.hexagonal_lattice_graph(3, 2)
+        qubit = QubitGraph(g)
+
+        assert set(qubit.nodes) == set(g.nodes)
+        assert set(qubit.edges) == set(g.edges)
+
+        for node in qubit.nodes:
+            assert isinstance(qubit[node], QubitGraph)
+
+    def test_init_graph(self):
path: tests/ftqc/test_qubit_graph.py
user: login: mlxd
body: Should we also test re initialization, and include the warning capture?
created_at: 2025-02-19T21:25:27Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,484 @@
+# 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 networkx as nx
+import pytest
+
+import pennylane as qml
+from pennylane.ftqc.qubit_graph import QubitGraph
+
+# 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."""
+        qubit = QubitGraph()
+        assert qubit
+
+    def test_initialization_constructor(self):
+        """Test that we can initialize a QubitGraph with a user-defined graph of underlying qubits
+        using the QubitGraph constructor."""
+        g = nx.hexagonal_lattice_graph(3, 2)
+        qubit = QubitGraph(g)
+
+        assert set(qubit.nodes) == set(g.nodes)
+        assert set(qubit.edges) == set(g.edges)
+
+        for node in qubit.nodes:
+            assert isinstance(qubit[node], QubitGraph)
+
+    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.nodes) == set(g.nodes)
+        assert set(qubit.edges) == set(g.edges)
+
+        for node in qubit.nodes:
+            assert isinstance(qubit[node], QubitGraph)
+
+    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.nodes) == set(expected_graph.nodes)
+        assert set(qubit.edges) == set(expected_graph.edges)
+
+        for node in qubit.nodes:
+            assert isinstance(qubit[node], QubitGraph)
+
+    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.nodes:
+            # 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.nodes) == set(expected_graph0.nodes)
+        assert set(qubit0.edges) == 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 node in qubit0.nodes:
+            qubit1 = qubit0[node]
+            assert set(qubit1.nodes) == expected_graph1_nodes_set
+            assert set(qubit1.edges) == expected_graph1_edges_set
+
+    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.nodes) == set(expected_graph.nodes)
+        assert set(qubit.edges) == set(expected_graph.edges)
+
+        for node in qubit.nodes:
+            assert isinstance(qubit[node], QubitGraph)
+
+    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.nodes) == set(expected_graph.nodes)
+        assert set(qubit.edges) == set(expected_graph.edges)
+
+        for node in qubit.nodes:
+            assert isinstance(qubit[node], QubitGraph)
+
+    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."""
path: tests/ftqc/test_qubit_graph.py
user: login: mlxd
body: I like this graph-like idea, nice work.
created_at: 2025-02-19T21:27:22Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,484 @@
+# 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 networkx as nx
+import pytest
+
+import pennylane as qml
+from pennylane.ftqc.qubit_graph import QubitGraph
+
+# 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."""
+        qubit = QubitGraph()
+        assert qubit
+
+    def test_initialization_constructor(self):
+        """Test that we can initialize a QubitGraph with a user-defined graph of underlying qubits
+        using the QubitGraph constructor."""
+        g = nx.hexagonal_lattice_graph(3, 2)
+        qubit = QubitGraph(g)
+
+        assert set(qubit.nodes) == set(g.nodes)
+        assert set(qubit.edges) == set(g.edges)
+
+        for node in qubit.nodes:
+            assert isinstance(qubit[node], QubitGraph)
+
+    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.nodes) == set(g.nodes)
+        assert set(qubit.edges) == set(g.edges)
+
+        for node in qubit.nodes:
+            assert isinstance(qubit[node], QubitGraph)
+
+    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.nodes) == set(expected_graph.nodes)
+        assert set(qubit.edges) == set(expected_graph.edges)
+
+        for node in qubit.nodes:
+            assert isinstance(qubit[node], QubitGraph)
+
+    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.nodes:
+            # 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.nodes) == set(expected_graph0.nodes)
+        assert set(qubit0.edges) == 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 node in qubit0.nodes:
+            qubit1 = qubit0[node]
+            assert set(qubit1.nodes) == expected_graph1_nodes_set
+            assert set(qubit1.edges) == expected_graph1_edges_set
+
+    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.nodes) == set(expected_graph.nodes)
+        assert set(qubit.edges) == set(expected_graph.edges)
+
+        for node in qubit.nodes:
+            assert isinstance(qubit[node], QubitGraph)
+
+    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.nodes) == set(expected_graph.nodes)
+        assert set(qubit.edges) == set(expected_graph.edges)
+
+        for node in qubit.nodes:
+            assert isinstance(qubit[node], QubitGraph)
+
+    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."""
+
+        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 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_connected_qubits(self):
+        """Test basic usage of the ``QubitGraph.connected_qubits`` method."""
+        q = QubitGraph()
+        q.init_graph(nx.grid_2d_graph(2, 2))
+
+        assert set(q.connected_qubits((0, 0))) == set([q[(0, 1)], q[(1, 0)]])
+        assert set(q.connected_qubits((0, 1))) == set([q[(0, 0)], q[(1, 1)]])
+        assert set(q.connected_qubits((1, 0))) == set([q[(0, 0)], q[(1, 1)]])
+        assert set(q.connected_qubits((1, 1))) == set([q[(0, 1)], q[(1, 0)]])
+
+
+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.nodes):
+            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.edges):
+            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.nodes:
+            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()
+        qubit.init_graph_nd_grid((2,))
+
+        new_qubit = QubitGraph()
+        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].nodes is not None
+        assert not qubit[1].is_initialized
+
+    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 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()
+        assert str(q) == "QubitGraph"
+
+
+class TestQubitGraphWorkflows:
+    """Tests of QubitGraph workflows."""
+
+    def test_execution(self):
+        """Test execution of a simple circuit using QubitGraph objects as wires."""
+        q0 = QubitGraph()
+        q1 = QubitGraph()
+        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
path: tests/ftqc/test_qubit_graph.py
user: login: mlxd
body: So this just treats the given grabs as single qubit wire labels?
created_at: 2025-02-19T21:31:45Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,484 @@
+# 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 networkx as nx
+import pytest
+
+import pennylane as qml
+from pennylane.ftqc.qubit_graph import QubitGraph
+
+# 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."""
+        qubit = QubitGraph()
+        assert qubit
+
+    def test_initialization_constructor(self):
+        """Test that we can initialize a QubitGraph with a user-defined graph of underlying qubits
+        using the QubitGraph constructor."""
+        g = nx.hexagonal_lattice_graph(3, 2)
+        qubit = QubitGraph(g)
+
+        assert set(qubit.nodes) == set(g.nodes)
+        assert set(qubit.edges) == set(g.edges)
+
+        for node in qubit.nodes:
+            assert isinstance(qubit[node], QubitGraph)
+
+    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.nodes) == set(g.nodes)
+        assert set(qubit.edges) == set(g.edges)
+
+        for node in qubit.nodes:
+            assert isinstance(qubit[node], QubitGraph)
+
+    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.nodes) == set(expected_graph.nodes)
+        assert set(qubit.edges) == set(expected_graph.edges)
+
+        for node in qubit.nodes:
+            assert isinstance(qubit[node], QubitGraph)
+
+    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.nodes:
+            # 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.nodes) == set(expected_graph0.nodes)
+        assert set(qubit0.edges) == 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 node in qubit0.nodes:
+            qubit1 = qubit0[node]
+            assert set(qubit1.nodes) == expected_graph1_nodes_set
+            assert set(qubit1.edges) == expected_graph1_edges_set
+
+    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.nodes) == set(expected_graph.nodes)
+        assert set(qubit.edges) == set(expected_graph.edges)
+
+        for node in qubit.nodes:
+            assert isinstance(qubit[node], QubitGraph)
+
+    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.nodes) == set(expected_graph.nodes)
+        assert set(qubit.edges) == set(expected_graph.edges)
+
+        for node in qubit.nodes:
+            assert isinstance(qubit[node], QubitGraph)
+
+    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."""
+
+        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 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_connected_qubits(self):
+        """Test basic usage of the ``QubitGraph.connected_qubits`` method."""
+        q = QubitGraph()
+        q.init_graph(nx.grid_2d_graph(2, 2))
+
+        assert set(q.connected_qubits((0, 0))) == set([q[(0, 1)], q[(1, 0)]])
+        assert set(q.connected_qubits((0, 1))) == set([q[(0, 0)], q[(1, 1)]])
+        assert set(q.connected_qubits((1, 0))) == set([q[(0, 0)], q[(1, 1)]])
+        assert set(q.connected_qubits((1, 1))) == set([q[(0, 1)], q[(1, 0)]])
+
+
+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.nodes):
+            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.edges):
+            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.nodes:
+            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()
+        qubit.init_graph_nd_grid((2,))
+
+        new_qubit = QubitGraph()
+        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].nodes is not None
+        assert not qubit[1].is_initialized
+
+    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 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()
+        assert str(q) == "QubitGraph"
+
+
+class TestQubitGraphWorkflows:
+    """Tests of QubitGraph workflows."""
+
+    def test_execution(self):
+        """Test execution of a simple circuit using QubitGraph objects as wires."""
+        q0 = QubitGraph()
+        q1 = QubitGraph()
+        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.nodes
+
+    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.edges
+
+    def test_access_uninitialized_connected_qubits_warning(self):
+        """Test that accessing the connected qubits of a qubit with an uninitialized graph emits a
+        UserWarning.
+        """
+        q = QubitGraph()
+        with pytest.warns(UserWarning, match="Attempting to access an uninitialized QubitGraph"):
+            for connected_q in q.connected_qubits(0):
+                _ = connected_q
+
+    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"):
path: tests/ftqc/test_qubit_graph.py
user: login: mlxd
body: Ah, this check was here. Ignore the earlier comment
created_at: 2025-02-19T21:32:16Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,401 @@
+# 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 the qubit memory model in the FTQC/MBQC
+framework, as well as the API to access and manipulate these data structures.
+"""
+
+import warnings
+from typing import Union
+
+import networkx as nx
+
+
+class QubitGraph:
+    """A class to represent nested graphs of qubits in the FTQC/MBQC framework.
+
+    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 to use as the QubitGraph's underlying qubits.
+            Inputting None (the default), leaves the underlying qubit graph in an uninitialized
+            state, in which case one of the graph-initialization methods may be used to define the
+            structure of the underlying qubit graph. 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. An object is considered "graph-like" if
+            it has both a 'nodes' and an 'edges' attribute.
+
+    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.
+        * Improve string representation of QubitGraph objects.
+    """
+
+    def __init__(self, graph: nx.Graph = None):
+        if graph is not None:
+            self._check_graph_type_supported_and_raise_or_warn(graph)
+
+        self._graph_qubits = graph  # The qubits underlying (nested within) the current qubit
+
+        # Initialize each node in the graph to store an empty QubitGraph object
+        if self._graph_qubits is not None:
+            for node in self._graph_qubits.nodes:
+                self[node] = QubitGraph()
+
+    def __getitem__(self, key):
+        """QubitGraph subscript operator for read access.
+
+        Currently only basic, linear indexing and slicing is supported.
+
+        Args:
+            key: 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_qubits.nodes))
+            return [self._graph_qubits.nodes[node]["qubits"] for node in range(start, stop, step)]
+
+        return self._graph_qubits.nodes[key]["qubits"]
+
+    def __setitem__(self, key, value: "QubitGraph"):
+        """QubitGraph subscript operator for assignment.
+
+        Currently only basic, linear indexing is supported. Slicing is not supported.
+
+        Args:
+            key: Node label in the underlying qubit graph.
+            value (QubitGraph): The QubitGraph object to assign to the node with the given key.
+
+        TODO: Allow for more advanced tensor-like indexing and slicing.
+        """
+        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
+
+        self._graph_qubits.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()
+            >>> q.init_graph_nd_grid((2,))
+            >>> tuple(q)
+            (<QubitGraph object>,)
+
+        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:
+
+            >>> q = QubitGraph()
+            >>> q.init_graph_nd_grid((2,))
+            >>> tuple(q)
+            >>> (<QubitGraph object>, <QubitGraph object>)
+
+        Making QubitGraph non-iterable is especially important when using it as input to the Wires
+        class, which checks if the input is iterable by wrapping it in a tuple.
+        """
+        yield self
+
+    def __repr__(self):
+        """Representation of a QubitGraph object.
+
+        TODO:
+            Improve this representation.
+        """
+        return f"QubitGraph"
path: pennylane/ftqc/qubit_graph.py
user: login: joeycarter
body: My thinking was if its string representation should connect to the node in the graph it belongs to. For example, if we have a 2x2 grid with node labels `(0,0)`, `(0,1)`, `(1,0)`, `(1,1)`, should its representation be something like

```pycon
>>> graph = nx.grid_2d_graph(2, 2)
>>> q = QubitGraph(graph)
>>> print(q[(0,0)])
QubitGraph<(0,0)>
```

Moreover, should the graph nesting be captured in the string representation, e.g.

```pycon
>>> print(q[(0,0)][1][2])
QubitGraph<(0,0),1,2>
```

That would require a nested QubitGraph keeping track of its parent QubitGraph, which would add some complexity.

The issue with something like `f"QubitGraph<{self._graph_qubits}>"` is that you would easily end up with many different QubitGraph objects with the same string representation. Although maybe that's okay for now?

An alternative is to give every QubitGraph a name, or a unique ID, when constructing it, and display that in its string representation. Happy to discuss more.
created_at: 2025-02-20T18:29:23Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,401 @@
+# 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 the qubit memory model in the FTQC/MBQC
+framework, as well as the API to access and manipulate these data structures.
+"""
+
+import warnings
+from typing import Union
+
+import networkx as nx
+
+
+class QubitGraph:
+    """A class to represent nested graphs of qubits in the FTQC/MBQC framework.
+
+    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 to use as the QubitGraph's underlying qubits.
+            Inputting None (the default), leaves the underlying qubit graph in an uninitialized
+            state, in which case one of the graph-initialization methods may be used to define the
+            structure of the underlying qubit graph. 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. An object is considered "graph-like" if
+            it has both a 'nodes' and an 'edges' attribute.
+
+    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.
+        * Improve string representation of QubitGraph objects.
+    """
+
+    def __init__(self, graph: nx.Graph = None):
+        if graph is not None:
+            self._check_graph_type_supported_and_raise_or_warn(graph)
+
+        self._graph_qubits = graph  # The qubits underlying (nested within) the current qubit
+
+        # Initialize each node in the graph to store an empty QubitGraph object
+        if self._graph_qubits is not None:
+            for node in self._graph_qubits.nodes:
+                self[node] = QubitGraph()
+
+    def __getitem__(self, key):
+        """QubitGraph subscript operator for read access.
+
+        Currently only basic, linear indexing and slicing is supported.
+
+        Args:
+            key: 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_qubits.nodes))
+            return [self._graph_qubits.nodes[node]["qubits"] for node in range(start, stop, step)]
+
+        return self._graph_qubits.nodes[key]["qubits"]
+
+    def __setitem__(self, key, value: "QubitGraph"):
+        """QubitGraph subscript operator for assignment.
+
+        Currently only basic, linear indexing is supported. Slicing is not supported.
+
+        Args:
+            key: Node label in the underlying qubit graph.
+            value (QubitGraph): The QubitGraph object to assign to the node with the given key.
+
+        TODO: Allow for more advanced tensor-like indexing and slicing.
+        """
+        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
+
+        self._graph_qubits.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()
+            >>> q.init_graph_nd_grid((2,))
+            >>> tuple(q)
+            (<QubitGraph object>,)
+
+        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:
+
+            >>> q = QubitGraph()
+            >>> q.init_graph_nd_grid((2,))
+            >>> tuple(q)
+            >>> (<QubitGraph object>, <QubitGraph object>)
+
+        Making QubitGraph non-iterable is especially important when using it as input to the Wires
+        class, which checks if the input is iterable by wrapping it in a tuple.
+        """
+        yield self
+
+    def __repr__(self):
+        """Representation of a QubitGraph object.
+
+        TODO:
+            Improve this representation.
+        """
+        return f"QubitGraph"
+
+    @property
+    def graph(self):
+        """Gets the underlying qubit graph."""
+        return self._graph_qubits
+
+    @property
+    def nodes(self):
+        """Gets the set of nodes in the underlying qubit graph.
+
+        If the underlying qubit graph has not been initialized, emit a UserWarning and return None.
+
+        Returns:
+            networkx.NodeView: 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 self._graph_qubits is None:
+            self._warn_uninitialized()
+            return None
+
+        return self._graph_qubits.nodes
+
+    @property
+    def edges(self):
+        """Gets the set of edges in the underlying qubit graph.
+
+        If the underlying qubit graph has not been initialized, emit a UserWarning and return None.
+
+        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 self._graph_qubits is None:
+            self._warn_uninitialized()
+            return None
+
+        return self._graph_qubits.edges
+
+    @property
+    def is_initialized(self):
+        """Checks if the underlying qubits have been initialized.
+
+        Returns:
+            bool: Returns True if the underlying qubits have been initialized, False otherwise.
+        """
+        return self._graph_qubits is not None
+
+    def clear(self):
+        """Clears the graph of underlying qubits."""
+        self._graph_qubits = None
+
+    def connected_qubits(self, node):
+        """Returns an iterator over all of the qubits connected to the qubit with label ``node``.
+
+        Args:
+            node (node like): The label of a node in the qubit graph.
+
+        Returns:
+            iterator: An iterator over all QubitGraph objects connected to the qubit with label
+                ``node``.
+        """
+        if not self.is_initialized:
+            self._warn_uninitialized()
+            return
+
+        for neighbor in self._graph_qubits.neighbors(node):
+            yield self[neighbor]
+
+    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:
+
+            >>> graph = networkx.hexagonal_lattice_graph(3, 2)
+            >>> q = QubitGraph(graph)
+        """
+        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_qubits = graph
+        for node in self._graph_qubits.nodes:
+            self[node] = QubitGraph()
+
+    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, n (int): The number of rows, m, and columns, n, in the grid.
+
+        Example:
+
+            >>> q = QubitGraph()
+            >>> q.init_graph_2d_grid(2, 3)
+
+            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)
+        """
+        if self.is_initialized:
+            self._warn_reinitialization()
+            return
+
+        self._graph_qubits = nx.grid_2d_graph(m, n)
+        for node in self._graph_qubits.nodes:
+            self[node] = QubitGraph()
+
+    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:
+
+            >>> q = QubitGraph()
+            >>> q.init_graph_2d_grid(2, 2, 3)
+
+            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)
+        """
+        if self.is_initialized:
+            self._warn_reinitialization()
+            return
+
+        self._graph_qubits = nx.grid_graph(dim)
+        for node in self._graph_qubits.nodes:
+            self[node] = QubitGraph()
+
+    def init_graph_surface_code_17(self):
path: pennylane/ftqc/qubit_graph.py
user: login: joeycarter
body: I was thinking the same thing, I put a note above to coordinate with @multiphaseCFD on where these methods should live.
created_at: 2025-02-20T18:30:37Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,401 @@
+# 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 the qubit memory model in the FTQC/MBQC
+framework, as well as the API to access and manipulate these data structures.
+"""
+
+import warnings
+from typing import Union
+
+import networkx as nx
+
+
+class QubitGraph:
+    """A class to represent nested graphs of qubits in the FTQC/MBQC framework.
+
+    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 to use as the QubitGraph's underlying qubits.
+            Inputting None (the default), leaves the underlying qubit graph in an uninitialized
+            state, in which case one of the graph-initialization methods may be used to define the
+            structure of the underlying qubit graph. 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. An object is considered "graph-like" if
+            it has both a 'nodes' and an 'edges' attribute.
+
+    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.
+        * Improve string representation of QubitGraph objects.
+    """
+
+    def __init__(self, graph: nx.Graph = None):
+        if graph is not None:
+            self._check_graph_type_supported_and_raise_or_warn(graph)
+
+        self._graph_qubits = graph  # The qubits underlying (nested within) the current qubit
+
+        # Initialize each node in the graph to store an empty QubitGraph object
+        if self._graph_qubits is not None:
+            for node in self._graph_qubits.nodes:
+                self[node] = QubitGraph()
+
+    def __getitem__(self, key):
+        """QubitGraph subscript operator for read access.
+
+        Currently only basic, linear indexing and slicing is supported.
+
+        Args:
+            key: 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_qubits.nodes))
+            return [self._graph_qubits.nodes[node]["qubits"] for node in range(start, stop, step)]
+
+        return self._graph_qubits.nodes[key]["qubits"]
+
+    def __setitem__(self, key, value: "QubitGraph"):
+        """QubitGraph subscript operator for assignment.
+
+        Currently only basic, linear indexing is supported. Slicing is not supported.
+
+        Args:
+            key: Node label in the underlying qubit graph.
+            value (QubitGraph): The QubitGraph object to assign to the node with the given key.
+
+        TODO: Allow for more advanced tensor-like indexing and slicing.
+        """
+        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
+
+        self._graph_qubits.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()
+            >>> q.init_graph_nd_grid((2,))
+            >>> tuple(q)
+            (<QubitGraph object>,)
+
+        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:
+
+            >>> q = QubitGraph()
+            >>> q.init_graph_nd_grid((2,))
+            >>> tuple(q)
+            >>> (<QubitGraph object>, <QubitGraph object>)
+
+        Making QubitGraph non-iterable is especially important when using it as input to the Wires
+        class, which checks if the input is iterable by wrapping it in a tuple.
+        """
+        yield self
+
+    def __repr__(self):
+        """Representation of a QubitGraph object.
+
+        TODO:
+            Improve this representation.
+        """
+        return f"QubitGraph"
+
+    @property
+    def graph(self):
+        """Gets the underlying qubit graph."""
+        return self._graph_qubits
+
+    @property
+    def nodes(self):
+        """Gets the set of nodes in the underlying qubit graph.
+
+        If the underlying qubit graph has not been initialized, emit a UserWarning and return None.
+
+        Returns:
+            networkx.NodeView: 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 self._graph_qubits is None:
+            self._warn_uninitialized()
+            return None
+
+        return self._graph_qubits.nodes
+
+    @property
+    def edges(self):
+        """Gets the set of edges in the underlying qubit graph.
+
+        If the underlying qubit graph has not been initialized, emit a UserWarning and return None.
+
+        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 self._graph_qubits is None:
+            self._warn_uninitialized()
+            return None
+
+        return self._graph_qubits.edges
+
path: pennylane/ftqc/qubit_graph.py
user: login: joeycarter
body: I think that's a good idea, and shouldn't be too difficult to implement. I'll add it.
created_at: 2025-02-20T18:31:05Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,484 @@
+# 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 networkx as nx
+import pytest
+
+import pennylane as qml
+from pennylane.ftqc.qubit_graph import QubitGraph
+
+# 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."""
+        qubit = QubitGraph()
+        assert qubit
path: tests/ftqc/test_qubit_graph.py
user: login: joeycarter
body: This was an early test to check for any errors in the `QubitGraph` constructor. However, every code branch in the constructor is covered in other tests now so I could remove this one.
created_at: 2025-02-20T18:34:10Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,484 @@
+# 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 networkx as nx
+import pytest
+
+import pennylane as qml
+from pennylane.ftqc.qubit_graph import QubitGraph
+
+# 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."""
+        qubit = QubitGraph()
+        assert qubit
+
+    def test_initialization_constructor(self):
+        """Test that we can initialize a QubitGraph with a user-defined graph of underlying qubits
+        using the QubitGraph constructor."""
+        g = nx.hexagonal_lattice_graph(3, 2)
+        qubit = QubitGraph(g)
+
+        assert set(qubit.nodes) == set(g.nodes)
+        assert set(qubit.edges) == set(g.edges)
+
+        for node in qubit.nodes:
+            assert isinstance(qubit[node], QubitGraph)
+
+    def test_init_graph(self):
path: tests/ftqc/test_qubit_graph.py
user: login: joeycarter
body: Tested below :slightly_smiling_face: 
created_at: 2025-02-20T18:34:44Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,484 @@
+# 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 networkx as nx
+import pytest
+
+import pennylane as qml
+from pennylane.ftqc.qubit_graph import QubitGraph
+
+# 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."""
+        qubit = QubitGraph()
+        assert qubit
path: tests/ftqc/test_qubit_graph.py
user: login: joeycarter
body: I opted to keep this test but check that the underlying qubit graph is in the "uninitialized" state after constructing the `QubitGraph` object with no arguments:

```python
qubit = QubitGraph()
assert not qubit.is_initialized
```

81a195e91
created_at: 2025-02-24T15:50:49Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,401 @@
+# 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 the qubit memory model in the FTQC/MBQC
+framework, as well as the API to access and manipulate these data structures.
+"""
+
+import warnings
+from typing import Union
+
+import networkx as nx
+
+
+class QubitGraph:
+    """A class to represent nested graphs of qubits in the FTQC/MBQC framework.
+
+    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 to use as the QubitGraph's underlying qubits.
+            Inputting None (the default), leaves the underlying qubit graph in an uninitialized
+            state, in which case one of the graph-initialization methods may be used to define the
+            structure of the underlying qubit graph. 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. An object is considered "graph-like" if
+            it has both a 'nodes' and an 'edges' attribute.
+
+    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.
+        * Improve string representation of QubitGraph objects.
+    """
+
+    def __init__(self, graph: nx.Graph = None):
+        if graph is not None:
+            self._check_graph_type_supported_and_raise_or_warn(graph)
+
+        self._graph_qubits = graph  # The qubits underlying (nested within) the current qubit
+
+        # Initialize each node in the graph to store an empty QubitGraph object
+        if self._graph_qubits is not None:
+            for node in self._graph_qubits.nodes:
+                self[node] = QubitGraph()
+
+    def __getitem__(self, key):
+        """QubitGraph subscript operator for read access.
+
+        Currently only basic, linear indexing and slicing is supported.
+
+        Args:
+            key: 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_qubits.nodes))
+            return [self._graph_qubits.nodes[node]["qubits"] for node in range(start, stop, step)]
+
+        return self._graph_qubits.nodes[key]["qubits"]
+
+    def __setitem__(self, key, value: "QubitGraph"):
+        """QubitGraph subscript operator for assignment.
+
+        Currently only basic, linear indexing is supported. Slicing is not supported.
+
+        Args:
+            key: Node label in the underlying qubit graph.
+            value (QubitGraph): The QubitGraph object to assign to the node with the given key.
+
+        TODO: Allow for more advanced tensor-like indexing and slicing.
+        """
+        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
+
+        self._graph_qubits.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()
+            >>> q.init_graph_nd_grid((2,))
+            >>> tuple(q)
+            (<QubitGraph object>,)
+
+        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:
+
+            >>> q = QubitGraph()
+            >>> q.init_graph_nd_grid((2,))
+            >>> tuple(q)
+            >>> (<QubitGraph object>, <QubitGraph object>)
+
+        Making QubitGraph non-iterable is especially important when using it as input to the Wires
+        class, which checks if the input is iterable by wrapping it in a tuple.
+        """
+        yield self
+
+    def __repr__(self):
+        """Representation of a QubitGraph object.
+
+        TODO:
+            Improve this representation.
+        """
+        return f"QubitGraph"
+
+    @property
+    def graph(self):
+        """Gets the underlying qubit graph."""
+        return self._graph_qubits
+
+    @property
+    def nodes(self):
+        """Gets the set of nodes in the underlying qubit graph.
+
+        If the underlying qubit graph has not been initialized, emit a UserWarning and return None.
+
+        Returns:
+            networkx.NodeView: 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 self._graph_qubits is None:
+            self._warn_uninitialized()
+            return None
+
+        return self._graph_qubits.nodes
+
+    @property
+    def edges(self):
+        """Gets the set of edges in the underlying qubit graph.
+
+        If the underlying qubit graph has not been initialized, emit a UserWarning and return None.
+
+        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 self._graph_qubits is None:
+            self._warn_uninitialized()
+            return None
+
+        return self._graph_qubits.edges
+
path: pennylane/ftqc/qubit_graph.py
user: login: joeycarter
body: Added in 423ffdf59.
created_at: 2025-02-24T16:59:02Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,401 @@
+# 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 the qubit memory model in the FTQC/MBQC
+framework, as well as the API to access and manipulate these data structures.
+"""
+
+import warnings
+from typing import Union
+
+import networkx as nx
+
+
+class QubitGraph:
+    """A class to represent nested graphs of qubits in the FTQC/MBQC framework.
+
+    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 to use as the QubitGraph's underlying qubits.
+            Inputting None (the default), leaves the underlying qubit graph in an uninitialized
+            state, in which case one of the graph-initialization methods may be used to define the
+            structure of the underlying qubit graph. 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. An object is considered "graph-like" if
+            it has both a 'nodes' and an 'edges' attribute.
+
+    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.
+        * Improve string representation of QubitGraph objects.
+    """
+
+    def __init__(self, graph: nx.Graph = None):
+        if graph is not None:
+            self._check_graph_type_supported_and_raise_or_warn(graph)
+
+        self._graph_qubits = graph  # The qubits underlying (nested within) the current qubit
+
+        # Initialize each node in the graph to store an empty QubitGraph object
+        if self._graph_qubits is not None:
+            for node in self._graph_qubits.nodes:
+                self[node] = QubitGraph()
+
+    def __getitem__(self, key):
+        """QubitGraph subscript operator for read access.
+
+        Currently only basic, linear indexing and slicing is supported.
+
+        Args:
+            key: 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_qubits.nodes))
+            return [self._graph_qubits.nodes[node]["qubits"] for node in range(start, stop, step)]
+
+        return self._graph_qubits.nodes[key]["qubits"]
+
+    def __setitem__(self, key, value: "QubitGraph"):
+        """QubitGraph subscript operator for assignment.
+
+        Currently only basic, linear indexing is supported. Slicing is not supported.
+
+        Args:
+            key: Node label in the underlying qubit graph.
+            value (QubitGraph): The QubitGraph object to assign to the node with the given key.
+
+        TODO: Allow for more advanced tensor-like indexing and slicing.
+        """
+        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
+
+        self._graph_qubits.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()
+            >>> q.init_graph_nd_grid((2,))
+            >>> tuple(q)
+            (<QubitGraph object>,)
+
+        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:
+
+            >>> q = QubitGraph()
+            >>> q.init_graph_nd_grid((2,))
+            >>> tuple(q)
+            >>> (<QubitGraph object>, <QubitGraph object>)
+
+        Making QubitGraph non-iterable is especially important when using it as input to the Wires
+        class, which checks if the input is iterable by wrapping it in a tuple.
+        """
+        yield self
+
+    def __repr__(self):
+        """Representation of a QubitGraph object.
+
+        TODO:
+            Improve this representation.
+        """
+        return f"QubitGraph"
path: pennylane/ftqc/qubit_graph.py
user: login: joeycarter
body: This is now resolved, I opted to keep track of the parent `QubitGraph` object in the class to make it possible to walk up the nesting hierarchy and construct the full index label accordingly.
created_at: 2025-02-26T20:42:28Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,484 @@
+# 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 networkx as nx
+import pytest
+
+import pennylane as qml
+from pennylane.ftqc.qubit_graph import QubitGraph
+
+# 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."""
+        qubit = QubitGraph()
+        assert qubit
+
+    def test_initialization_constructor(self):
+        """Test that we can initialize a QubitGraph with a user-defined graph of underlying qubits
+        using the QubitGraph constructor."""
+        g = nx.hexagonal_lattice_graph(3, 2)
+        qubit = QubitGraph(g)
+
+        assert set(qubit.nodes) == set(g.nodes)
+        assert set(qubit.edges) == set(g.edges)
+
+        for node in qubit.nodes:
+            assert isinstance(qubit[node], QubitGraph)
+
+    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.nodes) == set(g.nodes)
+        assert set(qubit.edges) == set(g.edges)
+
+        for node in qubit.nodes:
+            assert isinstance(qubit[node], QubitGraph)
+
+    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.nodes) == set(expected_graph.nodes)
+        assert set(qubit.edges) == set(expected_graph.edges)
+
+        for node in qubit.nodes:
+            assert isinstance(qubit[node], QubitGraph)
+
+    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.nodes:
+            # 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.nodes) == set(expected_graph0.nodes)
+        assert set(qubit0.edges) == 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 node in qubit0.nodes:
+            qubit1 = qubit0[node]
+            assert set(qubit1.nodes) == expected_graph1_nodes_set
+            assert set(qubit1.edges) == expected_graph1_edges_set
+
+    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.nodes) == set(expected_graph.nodes)
+        assert set(qubit.edges) == set(expected_graph.edges)
+
+        for node in qubit.nodes:
+            assert isinstance(qubit[node], QubitGraph)
+
+    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.nodes) == set(expected_graph.nodes)
+        assert set(qubit.edges) == set(expected_graph.edges)
+
+        for node in qubit.nodes:
+            assert isinstance(qubit[node], QubitGraph)
+
+    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."""
+
+        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 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_connected_qubits(self):
+        """Test basic usage of the ``QubitGraph.connected_qubits`` method."""
+        q = QubitGraph()
+        q.init_graph(nx.grid_2d_graph(2, 2))
+
+        assert set(q.connected_qubits((0, 0))) == set([q[(0, 1)], q[(1, 0)]])
+        assert set(q.connected_qubits((0, 1))) == set([q[(0, 0)], q[(1, 1)]])
+        assert set(q.connected_qubits((1, 0))) == set([q[(0, 0)], q[(1, 1)]])
+        assert set(q.connected_qubits((1, 1))) == set([q[(0, 1)], q[(1, 0)]])
+
+
+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.nodes):
+            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.edges):
+            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.nodes:
+            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()
+        qubit.init_graph_nd_grid((2,))
+
+        new_qubit = QubitGraph()
+        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].nodes is not None
+        assert not qubit[1].is_initialized
+
+    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 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()
+        assert str(q) == "QubitGraph"
+
+
+class TestQubitGraphWorkflows:
+    """Tests of QubitGraph workflows."""
+
+    def test_execution(self):
+        """Test execution of a simple circuit using QubitGraph objects as wires."""
+        q0 = QubitGraph()
+        q1 = QubitGraph()
+        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
path: tests/ftqc/test_qubit_graph.py
user: login: joeycarter
body: For now, yes. I'm starting to think about how to propagate operations down to the underlying qubits, although this work may come in a future PR.
created_at: 2025-02-26T20:53:58Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,484 @@
+# 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 networkx as nx
+import pytest
+
+import pennylane as qml
+from pennylane.ftqc.qubit_graph import QubitGraph
+
+# 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."""
+        qubit = QubitGraph()
+        assert qubit
+
+    def test_initialization_constructor(self):
+        """Test that we can initialize a QubitGraph with a user-defined graph of underlying qubits
+        using the QubitGraph constructor."""
+        g = nx.hexagonal_lattice_graph(3, 2)
+        qubit = QubitGraph(g)
+
+        assert set(qubit.nodes) == set(g.nodes)
+        assert set(qubit.edges) == set(g.edges)
+
+        for node in qubit.nodes:
+            assert isinstance(qubit[node], QubitGraph)
+
+    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.nodes) == set(g.nodes)
+        assert set(qubit.edges) == set(g.edges)
+
+        for node in qubit.nodes:
+            assert isinstance(qubit[node], QubitGraph)
+
+    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.nodes) == set(expected_graph.nodes)
+        assert set(qubit.edges) == set(expected_graph.edges)
+
+        for node in qubit.nodes:
+            assert isinstance(qubit[node], QubitGraph)
+
+    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.nodes:
+            # 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.nodes) == set(expected_graph0.nodes)
+        assert set(qubit0.edges) == 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 node in qubit0.nodes:
+            qubit1 = qubit0[node]
+            assert set(qubit1.nodes) == expected_graph1_nodes_set
+            assert set(qubit1.edges) == expected_graph1_edges_set
+
+    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.nodes) == set(expected_graph.nodes)
+        assert set(qubit.edges) == set(expected_graph.edges)
+
+        for node in qubit.nodes:
+            assert isinstance(qubit[node], QubitGraph)
+
+    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.nodes) == set(expected_graph.nodes)
+        assert set(qubit.edges) == set(expected_graph.edges)
+
+        for node in qubit.nodes:
+            assert isinstance(qubit[node], QubitGraph)
+
+    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."""
+
+        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 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_connected_qubits(self):
+        """Test basic usage of the ``QubitGraph.connected_qubits`` method."""
+        q = QubitGraph()
+        q.init_graph(nx.grid_2d_graph(2, 2))
+
+        assert set(q.connected_qubits((0, 0))) == set([q[(0, 1)], q[(1, 0)]])
+        assert set(q.connected_qubits((0, 1))) == set([q[(0, 0)], q[(1, 1)]])
+        assert set(q.connected_qubits((1, 0))) == set([q[(0, 0)], q[(1, 1)]])
+        assert set(q.connected_qubits((1, 1))) == set([q[(0, 1)], q[(1, 0)]])
+
+
+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.nodes):
+            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.edges):
+            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.nodes:
+            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()
+        qubit.init_graph_nd_grid((2,))
+
+        new_qubit = QubitGraph()
+        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].nodes is not None
+        assert not qubit[1].is_initialized
+
+    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 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()
+        assert str(q) == "QubitGraph"
+
+
+class TestQubitGraphWorkflows:
+    """Tests of QubitGraph workflows."""
+
+    def test_execution(self):
+        """Test execution of a simple circuit using QubitGraph objects as wires."""
+        q0 = QubitGraph()
+        q1 = QubitGraph()
+        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
path: tests/ftqc/test_qubit_graph.py
user: login: mlxd
body: Sounds good for now then, thanks :+1: 
created_at: 2025-02-26T21:20:22Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,591 @@
+# 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 warnings
+from typing import 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
path: pennylane/ftqc/qubit_graph.py
user: login: mlxd
body: Do we want to set a lower limit here?
Or maybe add a cycle-checker?
What do you think?
created_at: 2025-02-26T21:37:53Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,591 @@
+# 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 warnings
+from typing import 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:
+        id (Any): An identifier for this QubitGraph object.
+        graph (graph-like, optional): The graph to use as the QubitGraph's underlying qubits.
+            Inputting None (the default), leaves the underlying qubit graph in an uninitialized
+            state, in which case one of the graph-initialization methods may be used to define the
+            structure of the underlying qubit graph. 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. An object is considered "graph-like" if
+            it has both a 'nodes' and an 'edges' attribute.
+
+    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, id, graph: Optional[nx.Graph] = None):
path: pennylane/ftqc/qubit_graph.py
user: login: mlxd
body: Not requesting a change, but just wondering: should we define the id type as `Any` for clarity, or is it implicitly that if we don't say so?
created_at: 2025-02-26T21:50:09Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,591 @@
+# 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 warnings
+from typing import 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:
+        id (Any): An identifier for this QubitGraph object.
+        graph (graph-like, optional): The graph to use as the QubitGraph's underlying qubits.
+            Inputting None (the default), leaves the underlying qubit graph in an uninitialized
+            state, in which case one of the graph-initialization methods may be used to define the
+            structure of the underlying qubit graph. 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. An object is considered "graph-like" if
+            it has both a 'nodes' and an 'edges' attribute.
+
+    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, id, graph: Optional[nx.Graph] = None):
+        if id is None:
+            raise TypeError("'None' is not a valid QubitGraph ID.")
+
+        self._id = id  # The identifier for this QubitGraph, e.g. a number, string, tuple, etc.
+
+        if graph is not None:
+            self._check_graph_type_supported_and_raise_or_warn(graph)
+
+        self._graph_qubits = graph  # The qubits underlying (nested within) the current qubit
+        self._parent = None  # The parent QubitGraph object under which this QubitGraph is nested
+
+        # Initialize each node in the graph to store an empty QubitGraph object
+        if self._graph_qubits is not None:
+            self._initialize_all_nodes_as_qubit_graph()
+
+    def __getitem__(self, key) -> "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_qubits.nodes))
+            return [self._graph_qubits.nodes[node]["qubits"] for node in range(start, stop, step)]
+
+        return self._graph_qubits.nodes[key]["qubits"]
+
+    def __setitem__(self, key, 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("top", graph)
+            >>> print(f"{q_top}; {q_top.nodes}")
+            QubitGraph<top>; [0]
+            >>> q_new = QubitGraph("new", graph)
+            >>> print(f"{q_new}; {q_new.nodes}")
+            QubitGraph<new>; [0]
+            >>> q_top[0] = q_new
+            >>> print(f"{q_top[0]}; {q_top[0].nodes}")
+            QubitGraph<top, 0>; [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_qubits.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(0)
+            >>> q.init_graph_nd_grid((2,))
+            >>> tuple(q)
+            (<QubitGraph object>,)
+
+        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:
+
+            >>> q = QubitGraph(0)
+            >>> q.init_graph_nd_grid((2,))
+            >>> tuple(q)
+            >>> (<QubitGraph object>, <QubitGraph object>)
+
+        Making QubitGraph non-iterable is especially important when using it as input to the Wires
+        class, which checks if the input is iterable by wrapping it in a tuple.
path: pennylane/ftqc/qubit_graph.py
user: login: mlxd
body: This is a good find --- nice one. 
Do we need to consider making any changes to Wires here in the long-run?
created_at: 2025-02-26T21:53:23Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,591 @@
+# 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 warnings
+from typing import 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:
+        id (Any): An identifier for this QubitGraph object.
+        graph (graph-like, optional): The graph to use as the QubitGraph's underlying qubits.
+            Inputting None (the default), leaves the underlying qubit graph in an uninitialized
+            state, in which case one of the graph-initialization methods may be used to define the
+            structure of the underlying qubit graph. 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. An object is considered "graph-like" if
+            it has both a 'nodes' and an 'edges' attribute.
+
+    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, id, graph: Optional[nx.Graph] = None):
+        if id is None:
+            raise TypeError("'None' is not a valid QubitGraph ID.")
+
+        self._id = id  # The identifier for this QubitGraph, e.g. a number, string, tuple, etc.
+
+        if graph is not None:
+            self._check_graph_type_supported_and_raise_or_warn(graph)
+
+        self._graph_qubits = graph  # The qubits underlying (nested within) the current qubit
+        self._parent = None  # The parent QubitGraph object under which this QubitGraph is nested
+
+        # Initialize each node in the graph to store an empty QubitGraph object
+        if self._graph_qubits is not None:
+            self._initialize_all_nodes_as_qubit_graph()
+
+    def __getitem__(self, key) -> "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_qubits.nodes))
+            return [self._graph_qubits.nodes[node]["qubits"] for node in range(start, stop, step)]
+
+        return self._graph_qubits.nodes[key]["qubits"]
+
+    def __setitem__(self, key, 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("top", graph)
+            >>> print(f"{q_top}; {q_top.nodes}")
+            QubitGraph<top>; [0]
+            >>> q_new = QubitGraph("new", graph)
+            >>> print(f"{q_new}; {q_new.nodes}")
+            QubitGraph<new>; [0]
+            >>> q_top[0] = q_new
+            >>> print(f"{q_top[0]}; {q_top[0].nodes}")
+            QubitGraph<top, 0>; [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_qubits.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(0)
+            >>> q.init_graph_nd_grid((2,))
+            >>> tuple(q)
+            (<QubitGraph object>,)
+
+        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:
+
+            >>> q = QubitGraph(0)
+            >>> q.init_graph_nd_grid((2,))
+            >>> tuple(q)
+            >>> (<QubitGraph object>, <QubitGraph object>)
+
+        Making QubitGraph non-iterable is especially important when using it as input to the Wires
+        class, which checks if the input is iterable by wrapping it in a tuple.
+        """
+        yield self
+
+    def __repr__(self) -> str:
path: pennylane/ftqc/qubit_graph.py
user: login: mlxd
body: All good for now, but maybe we can spend time defining a nice representation for the hierarchical output to a terminal --- we may be able to opt into mirroring existing tree-based outputs
created_at: 2025-02-26T21:58:38Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,591 @@
+# 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 warnings
+from typing import 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:
+        id (Any): An identifier for this QubitGraph object.
+        graph (graph-like, optional): The graph to use as the QubitGraph's underlying qubits.
+            Inputting None (the default), leaves the underlying qubit graph in an uninitialized
+            state, in which case one of the graph-initialization methods may be used to define the
+            structure of the underlying qubit graph. 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. An object is considered "graph-like" if
+            it has both a 'nodes' and an 'edges' attribute.
+
+    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, id, graph: Optional[nx.Graph] = None):
+        if id is None:
+            raise TypeError("'None' is not a valid QubitGraph ID.")
+
+        self._id = id  # The identifier for this QubitGraph, e.g. a number, string, tuple, etc.
+
+        if graph is not None:
+            self._check_graph_type_supported_and_raise_or_warn(graph)
+
+        self._graph_qubits = graph  # The qubits underlying (nested within) the current qubit
+        self._parent = None  # The parent QubitGraph object under which this QubitGraph is nested
+
+        # Initialize each node in the graph to store an empty QubitGraph object
+        if self._graph_qubits is not None:
+            self._initialize_all_nodes_as_qubit_graph()
+
+    def __getitem__(self, key) -> "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_qubits.nodes))
+            return [self._graph_qubits.nodes[node]["qubits"] for node in range(start, stop, step)]
+
+        return self._graph_qubits.nodes[key]["qubits"]
+
+    def __setitem__(self, key, 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("top", graph)
+            >>> print(f"{q_top}; {q_top.nodes}")
+            QubitGraph<top>; [0]
+            >>> q_new = QubitGraph("new", graph)
+            >>> print(f"{q_new}; {q_new.nodes}")
+            QubitGraph<new>; [0]
+            >>> q_top[0] = q_new
+            >>> print(f"{q_top[0]}; {q_top[0].nodes}")
+            QubitGraph<top, 0>; [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_qubits.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(0)
+            >>> q.init_graph_nd_grid((2,))
+            >>> tuple(q)
+            (<QubitGraph object>,)
+
+        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:
+
+            >>> q = QubitGraph(0)
+            >>> q.init_graph_nd_grid((2,))
+            >>> tuple(q)
+            >>> (<QubitGraph object>, <QubitGraph object>)
+
+        Making QubitGraph non-iterable is especially important when using it as input to the 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(0)
+            QubitGraph<0>
+
+            >>> q = QubitGraph(0)
+            >>> q.init_graph_nd_grid((2, 2))
+            >>> q
+            QubitGraph<0>
+            >>> q[(0, 1)]
+            QubitGraph<0, (0, 1)>
+            >>> graph = nx.Graph()
+            >>> graph.add_node("aux")
+            >>> q[(0, 1)].init_graph(graph)
+            >>> q[(0, 1)]["aux"]
+            QubitGraph<0, (0, 1), aux>
+        """
+        if self.is_root:
+            return f"QubitGraph<{self._id}>"
+
+        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<{self._id}; cyclic>"
+
+        depth_counter = 1
+
+        ids = [self.id]
+        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(parent.id)
+            parent = parent.parent
+
+            depth_counter += 1
+
+        ids_str = ", ".join(str(id) for id in ids[::-1])
+        return f"QubitGraph<{ids_str}>"
+
+    @property
+    def id(self):
+        """Gets the QubitGraph ID."""
+        return self._id
+
+    @property
+    def graph(self):
+        """Gets the underlying qubit graph."""
+        return self._graph_qubits
+
+    @property
+    def nodes(self):
+        """Gets the set of nodes in the underlying qubit graph.
+
+        If the underlying qubit graph has not been initialized, emit a UserWarning and return None.
+
+        Returns:
+            networkx.NodeView: 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 self._graph_qubits is None:
+            self._warn_uninitialized()
+            return None
+
+        return self._graph_qubits.nodes
+
+    @property
+    def edges(self):
+        """Gets the set of edges in the underlying qubit graph.
+
+        If the underlying qubit graph has not been initialized, emit a UserWarning and return None.
+
+        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 self._graph_qubits is None:
+            self._warn_uninitialized()
+            return None
+
+        return self._graph_qubits.edges
+
+    @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_qubits 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 (self._graph_qubits is None) or (len(self.nodes) == 0)
+
+    @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_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
+
+    def clear(self):
+        """Clears the graph of underlying qubits."""
+        self._graph_qubits = None
+
+    def connected_qubits(self, node):
+        """Returns an iterator over all of the qubits connected to the qubit with label ``node``.
+
+        Args:
+            node (node-like): The label of a node in the qubit graph.
+
+        Returns:
+            iterator: An iterator over all QubitGraph objects connected to the qubit with label
+                ``node``.
+        """
+        if not self.is_initialized:
+            self._warn_uninitialized()
+            return
+
+        for neighbor in self._graph_qubits.neighbors(node):
+            yield self[neighbor]
+
+    def has_cycle(self) -> bool:
path: pennylane/ftqc/qubit_graph.py
user: login: mlxd
body: My favourite algorithm, given the simplicity of implementation and the utility it offers. 
created_at: 2025-02-26T22:00:17Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,401 @@
+# 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 the qubit memory model in the FTQC/MBQC
+framework, as well as the API to access and manipulate these data structures.
+"""
+
+import warnings
+from typing import Union
+
+import networkx as nx
+
+
+class QubitGraph:
+    """A class to represent nested graphs of qubits in the FTQC/MBQC framework.
+
+    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 to use as the QubitGraph's underlying qubits.
+            Inputting None (the default), leaves the underlying qubit graph in an uninitialized
+            state, in which case one of the graph-initialization methods may be used to define the
+            structure of the underlying qubit graph. 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. An object is considered "graph-like" if
+            it has both a 'nodes' and an 'edges' attribute.
+
+    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.
+        * Improve string representation of QubitGraph objects.
+    """
+
+    def __init__(self, graph: nx.Graph = None):
+        if graph is not None:
+            self._check_graph_type_supported_and_raise_or_warn(graph)
+
+        self._graph_qubits = graph  # The qubits underlying (nested within) the current qubit
+
+        # Initialize each node in the graph to store an empty QubitGraph object
+        if self._graph_qubits is not None:
+            for node in self._graph_qubits.nodes:
+                self[node] = QubitGraph()
+
+    def __getitem__(self, key):
+        """QubitGraph subscript operator for read access.
+
+        Currently only basic, linear indexing and slicing is supported.
+
+        Args:
+            key: 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_qubits.nodes))
+            return [self._graph_qubits.nodes[node]["qubits"] for node in range(start, stop, step)]
+
+        return self._graph_qubits.nodes[key]["qubits"]
+
+    def __setitem__(self, key, value: "QubitGraph"):
+        """QubitGraph subscript operator for assignment.
+
+        Currently only basic, linear indexing is supported. Slicing is not supported.
+
+        Args:
+            key: Node label in the underlying qubit graph.
+            value (QubitGraph): The QubitGraph object to assign to the node with the given key.
+
+        TODO: Allow for more advanced tensor-like indexing and slicing.
+        """
+        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
+
+        self._graph_qubits.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()
+            >>> q.init_graph_nd_grid((2,))
+            >>> tuple(q)
+            (<QubitGraph object>,)
+
+        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:
+
+            >>> q = QubitGraph()
+            >>> q.init_graph_nd_grid((2,))
+            >>> tuple(q)
+            >>> (<QubitGraph object>, <QubitGraph object>)
+
+        Making QubitGraph non-iterable is especially important when using it as input to the Wires
+        class, which checks if the input is iterable by wrapping it in a tuple.
+        """
+        yield self
+
+    def __repr__(self):
+        """Representation of a QubitGraph object.
+
+        TODO:
+            Improve this representation.
+        """
+        return "QubitGraph"
+
+    @property
+    def graph(self):
+        """Gets the underlying qubit graph."""
+        return self._graph_qubits
+
+    @property
+    def nodes(self):
+        """Gets the set of nodes in the underlying qubit graph.
+
+        If the underlying qubit graph has not been initialized, emit a UserWarning and return None.
+
+        Returns:
+            networkx.NodeView: 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 self._graph_qubits is None:
+            self._warn_uninitialized()
+            return None
+
+        return self._graph_qubits.nodes
+
+    @property
+    def edges(self):
+        """Gets the set of edges in the underlying qubit graph.
+
+        If the underlying qubit graph has not been initialized, emit a UserWarning and return None.
+
+        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 self._graph_qubits is None:
+            self._warn_uninitialized()
+            return None
+
+        return self._graph_qubits.edges
+
+    @property
+    def is_initialized(self):
+        """Checks if the underlying qubits have been initialized.
+
+        Returns:
+            bool: Returns True if the underlying qubits have been initialized, False otherwise.
+        """
+        return self._graph_qubits is not None
+
+    def clear(self):
+        """Clears the graph of underlying qubits."""
+        self._graph_qubits = None
+
+    def connected_qubits(self, node):
+        """Returns an iterator over all of the qubits connected to the qubit with label ``node``.
+
+        Args:
+            node (node like): The label of a node in the qubit graph.
+
+        Returns:
+            iterator: An iterator over all QubitGraph objects connected to the qubit with label
+                ``node``.
+        """
+        if not self.is_initialized:
+            self._warn_uninitialized()
+            return
+
+        for neighbor in self._graph_qubits.neighbors(node):
+            yield self[neighbor]
+
+    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:
+
+            >>> graph = networkx.hexagonal_lattice_graph(3, 2)
+            >>> q = QubitGraph(graph)
+        """
+        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_qubits = graph
+        for node in self._graph_qubits.nodes:
+            self[node] = QubitGraph()
+
+    def init_graph_2d_grid(self, m: int, n: int):
path: pennylane/ftqc/qubit_graph.py
user: login: mlxd
body: Any thoughts on following up on this?
created_at: 2025-02-26T22:00:34Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,401 @@
+# 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 the qubit memory model in the FTQC/MBQC
+framework, as well as the API to access and manipulate these data structures.
+"""
+
+import warnings
+from typing import Union
+
+import networkx as nx
+
+
+class QubitGraph:
+    """A class to represent nested graphs of qubits in the FTQC/MBQC framework.
+
+    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 to use as the QubitGraph's underlying qubits.
+            Inputting None (the default), leaves the underlying qubit graph in an uninitialized
+            state, in which case one of the graph-initialization methods may be used to define the
+            structure of the underlying qubit graph. 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. An object is considered "graph-like" if
+            it has both a 'nodes' and an 'edges' attribute.
+
+    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.
+        * Improve string representation of QubitGraph objects.
+    """
+
+    def __init__(self, graph: nx.Graph = None):
+        if graph is not None:
+            self._check_graph_type_supported_and_raise_or_warn(graph)
+
+        self._graph_qubits = graph  # The qubits underlying (nested within) the current qubit
+
+        # Initialize each node in the graph to store an empty QubitGraph object
+        if self._graph_qubits is not None:
+            for node in self._graph_qubits.nodes:
+                self[node] = QubitGraph()
+
+    def __getitem__(self, key):
+        """QubitGraph subscript operator for read access.
+
+        Currently only basic, linear indexing and slicing is supported.
+
+        Args:
+            key: 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_qubits.nodes))
+            return [self._graph_qubits.nodes[node]["qubits"] for node in range(start, stop, step)]
+
+        return self._graph_qubits.nodes[key]["qubits"]
+
+    def __setitem__(self, key, value: "QubitGraph"):
+        """QubitGraph subscript operator for assignment.
+
+        Currently only basic, linear indexing is supported. Slicing is not supported.
+
+        Args:
+            key: Node label in the underlying qubit graph.
+            value (QubitGraph): The QubitGraph object to assign to the node with the given key.
+
+        TODO: Allow for more advanced tensor-like indexing and slicing.
+        """
+        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
+
+        self._graph_qubits.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()
+            >>> q.init_graph_nd_grid((2,))
+            >>> tuple(q)
+            (<QubitGraph object>,)
+
+        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:
+
+            >>> q = QubitGraph()
+            >>> q.init_graph_nd_grid((2,))
+            >>> tuple(q)
+            >>> (<QubitGraph object>, <QubitGraph object>)
+
+        Making QubitGraph non-iterable is especially important when using it as input to the Wires
+        class, which checks if the input is iterable by wrapping it in a tuple.
+        """
+        yield self
+
+    def __repr__(self):
+        """Representation of a QubitGraph object.
+
+        TODO:
+            Improve this representation.
+        """
+        return f"QubitGraph"
+
+    @property
+    def graph(self):
+        """Gets the underlying qubit graph."""
+        return self._graph_qubits
+
+    @property
+    def nodes(self):
+        """Gets the set of nodes in the underlying qubit graph.
+
+        If the underlying qubit graph has not been initialized, emit a UserWarning and return None.
+
+        Returns:
+            networkx.NodeView: 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 self._graph_qubits is None:
+            self._warn_uninitialized()
+            return None
+
+        return self._graph_qubits.nodes
+
+    @property
+    def edges(self):
+        """Gets the set of edges in the underlying qubit graph.
+
+        If the underlying qubit graph has not been initialized, emit a UserWarning and return None.
+
+        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 self._graph_qubits is None:
+            self._warn_uninitialized()
+            return None
+
+        return self._graph_qubits.edges
+
+    @property
+    def is_initialized(self):
+        """Checks if the underlying qubits have been initialized.
+
+        Returns:
+            bool: Returns True if the underlying qubits have been initialized, False otherwise.
+        """
+        return self._graph_qubits is not None
+
+    def clear(self):
+        """Clears the graph of underlying qubits."""
+        self._graph_qubits = None
+
+    def connected_qubits(self, node):
+        """Returns an iterator over all of the qubits connected to the qubit with label ``node``.
+
+        Args:
+            node (node like): The label of a node in the qubit graph.
+
+        Returns:
+            iterator: An iterator over all QubitGraph objects connected to the qubit with label
+                ``node``.
+        """
+        if not self.is_initialized:
+            self._warn_uninitialized()
+            return
+
+        for neighbor in self._graph_qubits.neighbors(node):
+            yield self[neighbor]
+
+    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:
+
+            >>> graph = networkx.hexagonal_lattice_graph(3, 2)
+            >>> q = QubitGraph(graph)
+        """
+        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_qubits = graph
+        for node in self._graph_qubits.nodes:
+            self[node] = QubitGraph()
+
+    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, n (int): The number of rows, m, and columns, n, in the grid.
+
+        Example:
+
+            >>> q = QubitGraph()
+            >>> q.init_graph_2d_grid(2, 3)
+
+            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)
+        """
+        if self.is_initialized:
+            self._warn_reinitialization()
+            return
+
+        self._graph_qubits = nx.grid_2d_graph(m, n)
+        for node in self._graph_qubits.nodes:
+            self[node] = QubitGraph()
+
+    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:
+
+            >>> q = QubitGraph()
+            >>> q.init_graph_2d_grid(2, 2, 3)
+
+            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)
+        """
+        if self.is_initialized:
+            self._warn_reinitialization()
+            return
+
+        self._graph_qubits = nx.grid_graph(dim)
+        for node in self._graph_qubits.nodes:
+            self[node] = QubitGraph()
+
+    def init_graph_surface_code_17(self):
path: pennylane/ftqc/qubit_graph.py
user: login: mlxd
body: Just a quick follow-up on this --- I assume we can come back to it later then?
created_at: 2025-02-26T22:01:11Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,591 @@
+# 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 warnings
+from typing import 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:
+        id (Any): An identifier for this QubitGraph object.
+        graph (graph-like, optional): The graph to use as the QubitGraph's underlying qubits.
+            Inputting None (the default), leaves the underlying qubit graph in an uninitialized
+            state, in which case one of the graph-initialization methods may be used to define the
+            structure of the underlying qubit graph. 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. An object is considered "graph-like" if
+            it has both a 'nodes' and an 'edges' attribute.
+
+    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, id, graph: Optional[nx.Graph] = None):
+        if id is None:
+            raise TypeError("'None' is not a valid QubitGraph ID.")
+
+        self._id = id  # The identifier for this QubitGraph, e.g. a number, string, tuple, etc.
+
+        if graph is not None:
+            self._check_graph_type_supported_and_raise_or_warn(graph)
+
+        self._graph_qubits = graph  # The qubits underlying (nested within) the current qubit
+        self._parent = None  # The parent QubitGraph object under which this QubitGraph is nested
+
+        # Initialize each node in the graph to store an empty QubitGraph object
+        if self._graph_qubits is not None:
+            self._initialize_all_nodes_as_qubit_graph()
+
+    def __getitem__(self, key) -> "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_qubits.nodes))
+            return [self._graph_qubits.nodes[node]["qubits"] for node in range(start, stop, step)]
+
+        return self._graph_qubits.nodes[key]["qubits"]
+
+    def __setitem__(self, key, 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("top", graph)
+            >>> print(f"{q_top}; {q_top.nodes}")
+            QubitGraph<top>; [0]
+            >>> q_new = QubitGraph("new", graph)
+            >>> print(f"{q_new}; {q_new.nodes}")
+            QubitGraph<new>; [0]
+            >>> q_top[0] = q_new
+            >>> print(f"{q_top[0]}; {q_top[0].nodes}")
+            QubitGraph<top, 0>; [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_qubits.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(0)
+            >>> q.init_graph_nd_grid((2,))
+            >>> tuple(q)
+            (<QubitGraph object>,)
+
+        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:
+
+            >>> q = QubitGraph(0)
+            >>> q.init_graph_nd_grid((2,))
+            >>> tuple(q)
+            >>> (<QubitGraph object>, <QubitGraph object>)
+
+        Making QubitGraph non-iterable is especially important when using it as input to the 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(0)
+            QubitGraph<0>
+
+            >>> q = QubitGraph(0)
+            >>> q.init_graph_nd_grid((2, 2))
+            >>> q
+            QubitGraph<0>
+            >>> q[(0, 1)]
+            QubitGraph<0, (0, 1)>
+            >>> graph = nx.Graph()
+            >>> graph.add_node("aux")
+            >>> q[(0, 1)].init_graph(graph)
+            >>> q[(0, 1)]["aux"]
+            QubitGraph<0, (0, 1), aux>
+        """
+        if self.is_root:
+            return f"QubitGraph<{self._id}>"
+
+        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<{self._id}; cyclic>"
+
+        depth_counter = 1
+
+        ids = [self.id]
+        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(parent.id)
+            parent = parent.parent
+
+            depth_counter += 1
+
+        ids_str = ", ".join(str(id) for id in ids[::-1])
+        return f"QubitGraph<{ids_str}>"
+
+    @property
+    def id(self):
+        """Gets the QubitGraph ID."""
+        return self._id
+
+    @property
+    def graph(self):
+        """Gets the underlying qubit graph."""
+        return self._graph_qubits
+
+    @property
+    def nodes(self):
+        """Gets the set of nodes in the underlying qubit graph.
+
+        If the underlying qubit graph has not been initialized, emit a UserWarning and return None.
+
+        Returns:
+            networkx.NodeView: 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 self._graph_qubits is None:
+            self._warn_uninitialized()
+            return None
+
+        return self._graph_qubits.nodes
+
+    @property
+    def edges(self):
+        """Gets the set of edges in the underlying qubit graph.
+
+        If the underlying qubit graph has not been initialized, emit a UserWarning and return None.
+
+        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 self._graph_qubits is None:
+            self._warn_uninitialized()
+            return None
+
+        return self._graph_qubits.edges
+
+    @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_qubits 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 (self._graph_qubits is None) or (len(self.nodes) == 0)
+
+    @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_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
+
+    def clear(self):
+        """Clears the graph of underlying qubits."""
+        self._graph_qubits = None
+
+    def connected_qubits(self, node):
+        """Returns an iterator over all of the qubits connected to the qubit with label ``node``.
+
+        Args:
+            node (node-like): The label of a node in the qubit graph.
+
+        Returns:
+            iterator: An iterator over all QubitGraph objects connected to the qubit with label
+                ``node``.
+        """
+        if not self.is_initialized:
+            self._warn_uninitialized()
+            return
+
+        for neighbor in self._graph_qubits.neighbors(node):
+            yield self[neighbor]
+
+    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 QubitGraph's parent.
+
+        Returns:
+            bool: Returns True if this QubitGraph has a cycle in its nesting structure.s
+        """
+        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:
+
+            >>> graph = networkx.hexagonal_lattice_graph(3, 2)
+            >>> q = QubitGraph(0, graph)
+        """
+        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_qubits = 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, n (int): The number of rows, m, and columns, n, in the grid.
+
+        Example:
+
+            >>> q = QubitGraph(0)
+            >>> q.init_graph_2d_grid(2, 3)
+
+            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)
+        """
+        if self.is_initialized:
+            self._warn_reinitialization()
+            return
+
+        self._graph_qubits = 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:
+
+            >>> q = QubitGraph(0)
+            >>> q.init_graph_2d_grid(2, 2, 3)
+
+            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)
+        """
+        if self.is_initialized:
+            self._warn_reinitialization()
+            return
+
+        self._graph_qubits = 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
+
+            Tomita & Svore, 2014, Low-distance Surface Codes under Realistic Quantum Noise.
+                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_qubits = nx.Graph()
+        self._graph_qubits.add_nodes_from(data_qubits)
+        self._graph_qubits.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_qubits.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 functions also sets the _parent attribute appropriately.
path: pennylane/ftqc/qubit_graph.py
user: login: mlxd
body: ```suggestion
        QubitGraph objects. This function also sets the _parent attribute appropriately.
```
created_at: 2025-02-26T22:01:35Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,591 @@
+# 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 warnings
+from typing import 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:
+        id (Any): An identifier for this QubitGraph object.
+        graph (graph-like, optional): The graph to use as the QubitGraph's underlying qubits.
+            Inputting None (the default), leaves the underlying qubit graph in an uninitialized
+            state, in which case one of the graph-initialization methods may be used to define the
+            structure of the underlying qubit graph. 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. An object is considered "graph-like" if
+            it has both a 'nodes' and an 'edges' attribute.
+
+    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, id, graph: Optional[nx.Graph] = None):
+        if id is None:
+            raise TypeError("'None' is not a valid QubitGraph ID.")
+
+        self._id = id  # The identifier for this QubitGraph, e.g. a number, string, tuple, etc.
+
+        if graph is not None:
+            self._check_graph_type_supported_and_raise_or_warn(graph)
+
+        self._graph_qubits = graph  # The qubits underlying (nested within) the current qubit
+        self._parent = None  # The parent QubitGraph object under which this QubitGraph is nested
+
+        # Initialize each node in the graph to store an empty QubitGraph object
+        if self._graph_qubits is not None:
+            self._initialize_all_nodes_as_qubit_graph()
+
+    def __getitem__(self, key) -> "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_qubits.nodes))
+            return [self._graph_qubits.nodes[node]["qubits"] for node in range(start, stop, step)]
+
+        return self._graph_qubits.nodes[key]["qubits"]
+
+    def __setitem__(self, key, 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("top", graph)
+            >>> print(f"{q_top}; {q_top.nodes}")
+            QubitGraph<top>; [0]
+            >>> q_new = QubitGraph("new", graph)
+            >>> print(f"{q_new}; {q_new.nodes}")
+            QubitGraph<new>; [0]
+            >>> q_top[0] = q_new
+            >>> print(f"{q_top[0]}; {q_top[0].nodes}")
+            QubitGraph<top, 0>; [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_qubits.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(0)
+            >>> q.init_graph_nd_grid((2,))
+            >>> tuple(q)
+            (<QubitGraph object>,)
+
+        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:
+
+            >>> q = QubitGraph(0)
+            >>> q.init_graph_nd_grid((2,))
+            >>> tuple(q)
+            >>> (<QubitGraph object>, <QubitGraph object>)
+
+        Making QubitGraph non-iterable is especially important when using it as input to the 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(0)
+            QubitGraph<0>
+
+            >>> q = QubitGraph(0)
+            >>> q.init_graph_nd_grid((2, 2))
+            >>> q
+            QubitGraph<0>
+            >>> q[(0, 1)]
+            QubitGraph<0, (0, 1)>
+            >>> graph = nx.Graph()
+            >>> graph.add_node("aux")
+            >>> q[(0, 1)].init_graph(graph)
+            >>> q[(0, 1)]["aux"]
+            QubitGraph<0, (0, 1), aux>
+        """
+        if self.is_root:
+            return f"QubitGraph<{self._id}>"
+
+        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<{self._id}; cyclic>"
+
+        depth_counter = 1
+
+        ids = [self.id]
+        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(parent.id)
+            parent = parent.parent
+
+            depth_counter += 1
+
+        ids_str = ", ".join(str(id) for id in ids[::-1])
+        return f"QubitGraph<{ids_str}>"
+
+    @property
+    def id(self):
+        """Gets the QubitGraph ID."""
+        return self._id
+
+    @property
+    def graph(self):
+        """Gets the underlying qubit graph."""
+        return self._graph_qubits
+
+    @property
+    def nodes(self):
+        """Gets the set of nodes in the underlying qubit graph.
+
+        If the underlying qubit graph has not been initialized, emit a UserWarning and return None.
+
+        Returns:
+            networkx.NodeView: 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 self._graph_qubits is None:
+            self._warn_uninitialized()
+            return None
+
+        return self._graph_qubits.nodes
+
+    @property
+    def edges(self):
+        """Gets the set of edges in the underlying qubit graph.
+
+        If the underlying qubit graph has not been initialized, emit a UserWarning and return None.
+
+        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 self._graph_qubits is None:
+            self._warn_uninitialized()
+            return None
+
+        return self._graph_qubits.edges
+
+    @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_qubits 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 (self._graph_qubits is None) or (len(self.nodes) == 0)
+
+    @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_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
+
+    def clear(self):
+        """Clears the graph of underlying qubits."""
+        self._graph_qubits = None
+
+    def connected_qubits(self, node):
+        """Returns an iterator over all of the qubits connected to the qubit with label ``node``.
+
+        Args:
+            node (node-like): The label of a node in the qubit graph.
+
+        Returns:
+            iterator: An iterator over all QubitGraph objects connected to the qubit with label
+                ``node``.
+        """
+        if not self.is_initialized:
+            self._warn_uninitialized()
+            return
+
+        for neighbor in self._graph_qubits.neighbors(node):
+            yield self[neighbor]
+
+    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 QubitGraph's parent.
+
+        Returns:
+            bool: Returns True if this QubitGraph has a cycle in its nesting structure.s
+        """
+        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:
+
+            >>> graph = networkx.hexagonal_lattice_graph(3, 2)
+            >>> q = QubitGraph(0, graph)
+        """
+        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_qubits = 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, n (int): The number of rows, m, and columns, n, in the grid.
+
+        Example:
+
+            >>> q = QubitGraph(0)
+            >>> q.init_graph_2d_grid(2, 3)
+
+            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)
+        """
+        if self.is_initialized:
+            self._warn_reinitialization()
+            return
+
+        self._graph_qubits = 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:
+
+            >>> q = QubitGraph(0)
+            >>> q.init_graph_2d_grid(2, 2, 3)
+
+            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)
+        """
+        if self.is_initialized:
+            self._warn_reinitialization()
+            return
+
+        self._graph_qubits = 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
+
+            Tomita & Svore, 2014, Low-distance Surface Codes under Realistic Quantum Noise.
+                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_qubits = nx.Graph()
+        self._graph_qubits.add_nodes_from(data_qubits)
+        self._graph_qubits.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_qubits.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 functions also sets the _parent attribute appropriately.
+        """
+        assert self._graph_qubits is not None, "Underlying qubit graph object must not be None"
+        assert hasattr(
+            self._graph_qubits, "nodes"
+        ), "Underlying qubit graph object must have 'nodes' attribute"
+
+        for node in self._graph_qubits.nodes:
+            q = QubitGraph(id=node)
+            q._parent = self  # pylint: disable=protected-access
+            self[node] = q
+
+    @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,
+        )
+
+    @staticmethod
+    def _check_graph_type_supported_and_raise_or_warn(graph):
+        """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" if it has both a 'nodes' and an 'edges' attribute.
+
+        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
path: pennylane/ftqc/qubit_graph.py
user: login: mlxd
body: Nothing to check just yet, but I guess we get rustworkx graph support for free here too (I believe their PyGraph object matches our requirements).

Since we are defining graph-like entities, I wonder if this is a good use-case for Python [protocols](https://peps.python.org/pep-0544/). Nothing to be done here yet, but I see benefit in having this to allow interoperability. 
created_at: 2025-02-26T22:05:30Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,690 @@
+# 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 networkx as nx
+import pytest
+
+import pennylane as qml
+from pennylane.ftqc import QubitGraph
+from pennylane.ftqc.qubit_graph import MAX_TRAVERSAL_DEPTH
+
+# pylint: disable=too-few-public-methods
path: tests/ftqc/test_qubit_graph.py
user: login: mlxd
body: Do we still need this top-level?
Fine if so.
created_at: 2025-02-26T22:05:59Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,591 @@
+# 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 warnings
+from typing import 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:
+        id (Any): An identifier for this QubitGraph object.
+        graph (graph-like, optional): The graph to use as the QubitGraph's underlying qubits.
+            Inputting None (the default), leaves the underlying qubit graph in an uninitialized
+            state, in which case one of the graph-initialization methods may be used to define the
+            structure of the underlying qubit graph. 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. An object is considered "graph-like" if
+            it has both a 'nodes' and an 'edges' attribute.
+
+    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, id, graph: Optional[nx.Graph] = None):
path: pennylane/ftqc/qubit_graph.py
user: login: joeycarter
body: Ah, I had but `id (Any): ...` in the docstring but forgot to add the type hint in the call signature. Fixed in 1e6c31933.
created_at: 2025-02-27T16:26:01Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,591 @@
+# 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 warnings
+from typing import 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:
+        id (Any): An identifier for this QubitGraph object.
+        graph (graph-like, optional): The graph to use as the QubitGraph's underlying qubits.
+            Inputting None (the default), leaves the underlying qubit graph in an uninitialized
+            state, in which case one of the graph-initialization methods may be used to define the
+            structure of the underlying qubit graph. 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. An object is considered "graph-like" if
+            it has both a 'nodes' and an 'edges' attribute.
+
+    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, id, graph: Optional[nx.Graph] = None):
+        if id is None:
+            raise TypeError("'None' is not a valid QubitGraph ID.")
+
+        self._id = id  # The identifier for this QubitGraph, e.g. a number, string, tuple, etc.
+
+        if graph is not None:
+            self._check_graph_type_supported_and_raise_or_warn(graph)
+
+        self._graph_qubits = graph  # The qubits underlying (nested within) the current qubit
+        self._parent = None  # The parent QubitGraph object under which this QubitGraph is nested
+
+        # Initialize each node in the graph to store an empty QubitGraph object
+        if self._graph_qubits is not None:
+            self._initialize_all_nodes_as_qubit_graph()
+
+    def __getitem__(self, key) -> "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_qubits.nodes))
+            return [self._graph_qubits.nodes[node]["qubits"] for node in range(start, stop, step)]
+
+        return self._graph_qubits.nodes[key]["qubits"]
+
+    def __setitem__(self, key, 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("top", graph)
+            >>> print(f"{q_top}; {q_top.nodes}")
+            QubitGraph<top>; [0]
+            >>> q_new = QubitGraph("new", graph)
+            >>> print(f"{q_new}; {q_new.nodes}")
+            QubitGraph<new>; [0]
+            >>> q_top[0] = q_new
+            >>> print(f"{q_top[0]}; {q_top[0].nodes}")
+            QubitGraph<top, 0>; [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_qubits.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(0)
+            >>> q.init_graph_nd_grid((2,))
+            >>> tuple(q)
+            (<QubitGraph object>,)
+
+        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:
+
+            >>> q = QubitGraph(0)
+            >>> q.init_graph_nd_grid((2,))
+            >>> tuple(q)
+            >>> (<QubitGraph object>, <QubitGraph object>)
+
+        Making QubitGraph non-iterable is especially important when using it as input to the 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(0)
+            QubitGraph<0>
+
+            >>> q = QubitGraph(0)
+            >>> q.init_graph_nd_grid((2, 2))
+            >>> q
+            QubitGraph<0>
+            >>> q[(0, 1)]
+            QubitGraph<0, (0, 1)>
+            >>> graph = nx.Graph()
+            >>> graph.add_node("aux")
+            >>> q[(0, 1)].init_graph(graph)
+            >>> q[(0, 1)]["aux"]
+            QubitGraph<0, (0, 1), aux>
+        """
+        if self.is_root:
+            return f"QubitGraph<{self._id}>"
+
+        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<{self._id}; cyclic>"
+
+        depth_counter = 1
+
+        ids = [self.id]
+        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(parent.id)
+            parent = parent.parent
+
+            depth_counter += 1
+
+        ids_str = ", ".join(str(id) for id in ids[::-1])
+        return f"QubitGraph<{ids_str}>"
+
+    @property
+    def id(self):
+        """Gets the QubitGraph ID."""
+        return self._id
+
+    @property
+    def graph(self):
+        """Gets the underlying qubit graph."""
+        return self._graph_qubits
+
+    @property
+    def nodes(self):
+        """Gets the set of nodes in the underlying qubit graph.
+
+        If the underlying qubit graph has not been initialized, emit a UserWarning and return None.
+
+        Returns:
+            networkx.NodeView: 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 self._graph_qubits is None:
+            self._warn_uninitialized()
+            return None
+
+        return self._graph_qubits.nodes
+
+    @property
+    def edges(self):
+        """Gets the set of edges in the underlying qubit graph.
+
+        If the underlying qubit graph has not been initialized, emit a UserWarning and return None.
+
+        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 self._graph_qubits is None:
+            self._warn_uninitialized()
+            return None
+
+        return self._graph_qubits.edges
+
+    @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_qubits 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 (self._graph_qubits is None) or (len(self.nodes) == 0)
+
+    @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_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
+
+    def clear(self):
+        """Clears the graph of underlying qubits."""
+        self._graph_qubits = None
+
+    def connected_qubits(self, node):
+        """Returns an iterator over all of the qubits connected to the qubit with label ``node``.
+
+        Args:
+            node (node-like): The label of a node in the qubit graph.
+
+        Returns:
+            iterator: An iterator over all QubitGraph objects connected to the qubit with label
+                ``node``.
+        """
+        if not self.is_initialized:
+            self._warn_uninitialized()
+            return
+
+        for neighbor in self._graph_qubits.neighbors(node):
+            yield self[neighbor]
+
+    def has_cycle(self) -> bool:
path: pennylane/ftqc/qubit_graph.py
user: login: joeycarter
body: One of the greats, for sure! :turtle: :rabbit: 
created_at: 2025-02-27T16:29:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,591 @@
+# 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 warnings
+from typing import 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
path: pennylane/ftqc/qubit_graph.py
user: login: joeycarter
body: I take it you wrote this comment before seeing the `has_cycle()` method below? :slightly_smiling_face:

The choice of 100 was arbitrary, maybe a more realistic limit would be ~10. The general idea is that if a user hits this limit then we emit a warning, since they probably shouldn't be nesting QubitGraphs this deep, but a user should still be allowed to nest arbitrarily deep. The cycle checker should prevent infinite loops.

Currently `MAX_TRAVERSAL_DEPTH` is only used in the string representation method, which for the moment is the only algorithm that traverses through the nested graphs. So for now, its only purpose is to truncate the string representation. 
created_at: 2025-02-27T18:33:23Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,591 @@
+# 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 warnings
+from typing import 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:
+        id (Any): An identifier for this QubitGraph object.
+        graph (graph-like, optional): The graph to use as the QubitGraph's underlying qubits.
+            Inputting None (the default), leaves the underlying qubit graph in an uninitialized
+            state, in which case one of the graph-initialization methods may be used to define the
+            structure of the underlying qubit graph. 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. An object is considered "graph-like" if
+            it has both a 'nodes' and an 'edges' attribute.
+
+    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, id, graph: Optional[nx.Graph] = None):
+        if id is None:
+            raise TypeError("'None' is not a valid QubitGraph ID.")
+
+        self._id = id  # The identifier for this QubitGraph, e.g. a number, string, tuple, etc.
+
+        if graph is not None:
+            self._check_graph_type_supported_and_raise_or_warn(graph)
+
+        self._graph_qubits = graph  # The qubits underlying (nested within) the current qubit
+        self._parent = None  # The parent QubitGraph object under which this QubitGraph is nested
+
+        # Initialize each node in the graph to store an empty QubitGraph object
+        if self._graph_qubits is not None:
+            self._initialize_all_nodes_as_qubit_graph()
+
+    def __getitem__(self, key) -> "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_qubits.nodes))
+            return [self._graph_qubits.nodes[node]["qubits"] for node in range(start, stop, step)]
+
+        return self._graph_qubits.nodes[key]["qubits"]
+
+    def __setitem__(self, key, 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("top", graph)
+            >>> print(f"{q_top}; {q_top.nodes}")
+            QubitGraph<top>; [0]
+            >>> q_new = QubitGraph("new", graph)
+            >>> print(f"{q_new}; {q_new.nodes}")
+            QubitGraph<new>; [0]
+            >>> q_top[0] = q_new
+            >>> print(f"{q_top[0]}; {q_top[0].nodes}")
+            QubitGraph<top, 0>; [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_qubits.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(0)
+            >>> q.init_graph_nd_grid((2,))
+            >>> tuple(q)
+            (<QubitGraph object>,)
+
+        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:
+
+            >>> q = QubitGraph(0)
+            >>> q.init_graph_nd_grid((2,))
+            >>> tuple(q)
+            >>> (<QubitGraph object>, <QubitGraph object>)
+
+        Making QubitGraph non-iterable is especially important when using it as input to the Wires
+        class, which checks if the input is iterable by wrapping it in a tuple.
path: pennylane/ftqc/qubit_graph.py
user: login: joeycarter
body: Thanks! I suspect we won't have to make any changes to `Wires`, I think it should be possible to define `QubitGraph` in such a way that it conforms to what `Wires` expects.
created_at: 2025-02-27T18:35:14Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,591 @@
+# 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 warnings
+from typing import 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:
+        id (Any): An identifier for this QubitGraph object.
+        graph (graph-like, optional): The graph to use as the QubitGraph's underlying qubits.
+            Inputting None (the default), leaves the underlying qubit graph in an uninitialized
+            state, in which case one of the graph-initialization methods may be used to define the
+            structure of the underlying qubit graph. 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. An object is considered "graph-like" if
+            it has both a 'nodes' and an 'edges' attribute.
+
+    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, id, graph: Optional[nx.Graph] = None):
+        if id is None:
+            raise TypeError("'None' is not a valid QubitGraph ID.")
+
+        self._id = id  # The identifier for this QubitGraph, e.g. a number, string, tuple, etc.
+
+        if graph is not None:
+            self._check_graph_type_supported_and_raise_or_warn(graph)
+
+        self._graph_qubits = graph  # The qubits underlying (nested within) the current qubit
+        self._parent = None  # The parent QubitGraph object under which this QubitGraph is nested
+
+        # Initialize each node in the graph to store an empty QubitGraph object
+        if self._graph_qubits is not None:
+            self._initialize_all_nodes_as_qubit_graph()
+
+    def __getitem__(self, key) -> "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_qubits.nodes))
+            return [self._graph_qubits.nodes[node]["qubits"] for node in range(start, stop, step)]
+
+        return self._graph_qubits.nodes[key]["qubits"]
+
+    def __setitem__(self, key, 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("top", graph)
+            >>> print(f"{q_top}; {q_top.nodes}")
+            QubitGraph<top>; [0]
+            >>> q_new = QubitGraph("new", graph)
+            >>> print(f"{q_new}; {q_new.nodes}")
+            QubitGraph<new>; [0]
+            >>> q_top[0] = q_new
+            >>> print(f"{q_top[0]}; {q_top[0].nodes}")
+            QubitGraph<top, 0>; [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_qubits.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(0)
+            >>> q.init_graph_nd_grid((2,))
+            >>> tuple(q)
+            (<QubitGraph object>,)
+
+        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:
+
+            >>> q = QubitGraph(0)
+            >>> q.init_graph_nd_grid((2,))
+            >>> tuple(q)
+            >>> (<QubitGraph object>, <QubitGraph object>)
+
+        Making QubitGraph non-iterable is especially important when using it as input to the Wires
+        class, which checks if the input is iterable by wrapping it in a tuple.
+        """
+        yield self
+
+    def __repr__(self) -> str:
path: pennylane/ftqc/qubit_graph.py
user: login: joeycarter
body: Yes that would be a nice feature to have. Drawing the hierarchy from a leaf node up to its root should be fairly straightforward (in fact that's essentially what `__repr__()` is doing now), although drawing all the children of a root node could be more challenging, given that the number of nodes to draw could grow exponentially. Something to consider.
created_at: 2025-02-27T18:39:31Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,591 @@
+# 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 warnings
+from typing import 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:
+        id (Any): An identifier for this QubitGraph object.
+        graph (graph-like, optional): The graph to use as the QubitGraph's underlying qubits.
+            Inputting None (the default), leaves the underlying qubit graph in an uninitialized
+            state, in which case one of the graph-initialization methods may be used to define the
+            structure of the underlying qubit graph. 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. An object is considered "graph-like" if
+            it has both a 'nodes' and an 'edges' attribute.
+
+    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, id, graph: Optional[nx.Graph] = None):
+        if id is None:
+            raise TypeError("'None' is not a valid QubitGraph ID.")
+
+        self._id = id  # The identifier for this QubitGraph, e.g. a number, string, tuple, etc.
+
+        if graph is not None:
+            self._check_graph_type_supported_and_raise_or_warn(graph)
+
+        self._graph_qubits = graph  # The qubits underlying (nested within) the current qubit
+        self._parent = None  # The parent QubitGraph object under which this QubitGraph is nested
+
+        # Initialize each node in the graph to store an empty QubitGraph object
+        if self._graph_qubits is not None:
+            self._initialize_all_nodes_as_qubit_graph()
+
+    def __getitem__(self, key) -> "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_qubits.nodes))
+            return [self._graph_qubits.nodes[node]["qubits"] for node in range(start, stop, step)]
+
+        return self._graph_qubits.nodes[key]["qubits"]
+
+    def __setitem__(self, key, 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("top", graph)
+            >>> print(f"{q_top}; {q_top.nodes}")
+            QubitGraph<top>; [0]
+            >>> q_new = QubitGraph("new", graph)
+            >>> print(f"{q_new}; {q_new.nodes}")
+            QubitGraph<new>; [0]
+            >>> q_top[0] = q_new
+            >>> print(f"{q_top[0]}; {q_top[0].nodes}")
+            QubitGraph<top, 0>; [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_qubits.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(0)
+            >>> q.init_graph_nd_grid((2,))
+            >>> tuple(q)
+            (<QubitGraph object>,)
+
+        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:
+
+            >>> q = QubitGraph(0)
+            >>> q.init_graph_nd_grid((2,))
+            >>> tuple(q)
+            >>> (<QubitGraph object>, <QubitGraph object>)
+
+        Making QubitGraph non-iterable is especially important when using it as input to the 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(0)
+            QubitGraph<0>
+
+            >>> q = QubitGraph(0)
+            >>> q.init_graph_nd_grid((2, 2))
+            >>> q
+            QubitGraph<0>
+            >>> q[(0, 1)]
+            QubitGraph<0, (0, 1)>
+            >>> graph = nx.Graph()
+            >>> graph.add_node("aux")
+            >>> q[(0, 1)].init_graph(graph)
+            >>> q[(0, 1)]["aux"]
+            QubitGraph<0, (0, 1), aux>
+        """
+        if self.is_root:
+            return f"QubitGraph<{self._id}>"
+
+        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<{self._id}; cyclic>"
+
+        depth_counter = 1
+
+        ids = [self.id]
+        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(parent.id)
+            parent = parent.parent
+
+            depth_counter += 1
+
+        ids_str = ", ".join(str(id) for id in ids[::-1])
+        return f"QubitGraph<{ids_str}>"
+
+    @property
+    def id(self):
+        """Gets the QubitGraph ID."""
+        return self._id
+
+    @property
+    def graph(self):
+        """Gets the underlying qubit graph."""
+        return self._graph_qubits
+
+    @property
+    def nodes(self):
+        """Gets the set of nodes in the underlying qubit graph.
+
+        If the underlying qubit graph has not been initialized, emit a UserWarning and return None.
+
+        Returns:
+            networkx.NodeView: 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 self._graph_qubits is None:
+            self._warn_uninitialized()
+            return None
+
+        return self._graph_qubits.nodes
+
+    @property
+    def edges(self):
+        """Gets the set of edges in the underlying qubit graph.
+
+        If the underlying qubit graph has not been initialized, emit a UserWarning and return None.
+
+        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 self._graph_qubits is None:
+            self._warn_uninitialized()
+            return None
+
+        return self._graph_qubits.edges
+
+    @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_qubits 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 (self._graph_qubits is None) or (len(self.nodes) == 0)
+
+    @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_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
+
+    def clear(self):
+        """Clears the graph of underlying qubits."""
+        self._graph_qubits = None
+
+    def connected_qubits(self, node):
+        """Returns an iterator over all of the qubits connected to the qubit with label ``node``.
+
+        Args:
+            node (node-like): The label of a node in the qubit graph.
+
+        Returns:
+            iterator: An iterator over all QubitGraph objects connected to the qubit with label
+                ``node``.
+        """
+        if not self.is_initialized:
+            self._warn_uninitialized()
+            return
+
+        for neighbor in self._graph_qubits.neighbors(node):
+            yield self[neighbor]
+
+    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 QubitGraph's parent.
+
+        Returns:
+            bool: Returns True if this QubitGraph has a cycle in its nesting structure.s
+        """
+        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:
+
+            >>> graph = networkx.hexagonal_lattice_graph(3, 2)
+            >>> q = QubitGraph(0, graph)
+        """
+        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_qubits = 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, n (int): The number of rows, m, and columns, n, in the grid.
+
+        Example:
+
+            >>> q = QubitGraph(0)
+            >>> q.init_graph_2d_grid(2, 3)
+
+            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)
+        """
+        if self.is_initialized:
+            self._warn_reinitialization()
+            return
+
+        self._graph_qubits = 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:
+
+            >>> q = QubitGraph(0)
+            >>> q.init_graph_2d_grid(2, 2, 3)
+
+            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)
+        """
+        if self.is_initialized:
+            self._warn_reinitialization()
+            return
+
+        self._graph_qubits = 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
+
+            Tomita & Svore, 2014, Low-distance Surface Codes under Realistic Quantum Noise.
+                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_qubits = nx.Graph()
+        self._graph_qubits.add_nodes_from(data_qubits)
+        self._graph_qubits.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_qubits.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 functions also sets the _parent attribute appropriately.
path: pennylane/ftqc/qubit_graph.py
user: login: joeycarter
body: b558036b1
created_at: 2025-02-27T18:39:57Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,690 @@
+# 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 networkx as nx
+import pytest
+
+import pennylane as qml
+from pennylane.ftqc import QubitGraph
+from pennylane.ftqc.qubit_graph import MAX_TRAVERSAL_DEPTH
+
+# pylint: disable=too-few-public-methods
path: tests/ftqc/test_qubit_graph.py
user: login: joeycarter
body: I'd like to keep it at top level, I define a number of "dummy" classes to test some type-checking and it's a pain to copy this disable pragma every time. It's a good linting check to have in production code but not for unit tests IMHO :slightly_smiling_face: 
created_at: 2025-02-27T18:51:59Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,401 @@
+# 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 the qubit memory model in the FTQC/MBQC
+framework, as well as the API to access and manipulate these data structures.
+"""
+
+import warnings
+from typing import Union
+
+import networkx as nx
+
+
+class QubitGraph:
+    """A class to represent nested graphs of qubits in the FTQC/MBQC framework.
+
+    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 to use as the QubitGraph's underlying qubits.
+            Inputting None (the default), leaves the underlying qubit graph in an uninitialized
+            state, in which case one of the graph-initialization methods may be used to define the
+            structure of the underlying qubit graph. 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. An object is considered "graph-like" if
+            it has both a 'nodes' and an 'edges' attribute.
+
+    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.
+        * Improve string representation of QubitGraph objects.
+    """
+
+    def __init__(self, graph: nx.Graph = None):
+        if graph is not None:
+            self._check_graph_type_supported_and_raise_or_warn(graph)
+
+        self._graph_qubits = graph  # The qubits underlying (nested within) the current qubit
+
+        # Initialize each node in the graph to store an empty QubitGraph object
+        if self._graph_qubits is not None:
+            for node in self._graph_qubits.nodes:
+                self[node] = QubitGraph()
+
+    def __getitem__(self, key):
+        """QubitGraph subscript operator for read access.
+
+        Currently only basic, linear indexing and slicing is supported.
+
+        Args:
+            key: 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_qubits.nodes))
+            return [self._graph_qubits.nodes[node]["qubits"] for node in range(start, stop, step)]
+
+        return self._graph_qubits.nodes[key]["qubits"]
+
+    def __setitem__(self, key, value: "QubitGraph"):
+        """QubitGraph subscript operator for assignment.
+
+        Currently only basic, linear indexing is supported. Slicing is not supported.
+
+        Args:
+            key: Node label in the underlying qubit graph.
+            value (QubitGraph): The QubitGraph object to assign to the node with the given key.
+
+        TODO: Allow for more advanced tensor-like indexing and slicing.
+        """
+        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
+
+        self._graph_qubits.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()
+            >>> q.init_graph_nd_grid((2,))
+            >>> tuple(q)
+            (<QubitGraph object>,)
+
+        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:
+
+            >>> q = QubitGraph()
+            >>> q.init_graph_nd_grid((2,))
+            >>> tuple(q)
+            >>> (<QubitGraph object>, <QubitGraph object>)
+
+        Making QubitGraph non-iterable is especially important when using it as input to the Wires
+        class, which checks if the input is iterable by wrapping it in a tuple.
+        """
+        yield self
+
+    def __repr__(self):
+        """Representation of a QubitGraph object.
+
+        TODO:
+            Improve this representation.
+        """
+        return "QubitGraph"
+
+    @property
+    def graph(self):
+        """Gets the underlying qubit graph."""
+        return self._graph_qubits
+
+    @property
+    def nodes(self):
+        """Gets the set of nodes in the underlying qubit graph.
+
+        If the underlying qubit graph has not been initialized, emit a UserWarning and return None.
+
+        Returns:
+            networkx.NodeView: 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 self._graph_qubits is None:
+            self._warn_uninitialized()
+            return None
+
+        return self._graph_qubits.nodes
+
+    @property
+    def edges(self):
+        """Gets the set of edges in the underlying qubit graph.
+
+        If the underlying qubit graph has not been initialized, emit a UserWarning and return None.
+
+        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 self._graph_qubits is None:
+            self._warn_uninitialized()
+            return None
+
+        return self._graph_qubits.edges
+
+    @property
+    def is_initialized(self):
+        """Checks if the underlying qubits have been initialized.
+
+        Returns:
+            bool: Returns True if the underlying qubits have been initialized, False otherwise.
+        """
+        return self._graph_qubits is not None
+
+    def clear(self):
+        """Clears the graph of underlying qubits."""
+        self._graph_qubits = None
+
+    def connected_qubits(self, node):
+        """Returns an iterator over all of the qubits connected to the qubit with label ``node``.
+
+        Args:
+            node (node like): The label of a node in the qubit graph.
+
+        Returns:
+            iterator: An iterator over all QubitGraph objects connected to the qubit with label
+                ``node``.
+        """
+        if not self.is_initialized:
+            self._warn_uninitialized()
+            return
+
+        for neighbor in self._graph_qubits.neighbors(node):
+            yield self[neighbor]
+
+    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:
+
+            >>> graph = networkx.hexagonal_lattice_graph(3, 2)
+            >>> q = QubitGraph(graph)
+        """
+        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_qubits = graph
+        for node in self._graph_qubits.nodes:
+            self[node] = QubitGraph()
+
+    def init_graph_2d_grid(self, m: int, n: int):
path: pennylane/ftqc/qubit_graph.py
user: login: joeycarter
body: Discussed offline, we'll keep these `init_*` methods here in this PR for now since they're convenient tools to have for testing QubitGraph. We can refactor the `ftqc` module in a future PR to consolidate all of the graph-generation methods.
created_at: 2025-02-27T19:30:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,401 @@
+# 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 the qubit memory model in the FTQC/MBQC
+framework, as well as the API to access and manipulate these data structures.
+"""
+
+import warnings
+from typing import Union
+
+import networkx as nx
+
+
+class QubitGraph:
+    """A class to represent nested graphs of qubits in the FTQC/MBQC framework.
+
+    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 to use as the QubitGraph's underlying qubits.
+            Inputting None (the default), leaves the underlying qubit graph in an uninitialized
+            state, in which case one of the graph-initialization methods may be used to define the
+            structure of the underlying qubit graph. 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. An object is considered "graph-like" if
+            it has both a 'nodes' and an 'edges' attribute.
+
+    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.
+        * Improve string representation of QubitGraph objects.
+    """
+
+    def __init__(self, graph: nx.Graph = None):
+        if graph is not None:
+            self._check_graph_type_supported_and_raise_or_warn(graph)
+
+        self._graph_qubits = graph  # The qubits underlying (nested within) the current qubit
+
+        # Initialize each node in the graph to store an empty QubitGraph object
+        if self._graph_qubits is not None:
+            for node in self._graph_qubits.nodes:
+                self[node] = QubitGraph()
+
+    def __getitem__(self, key):
+        """QubitGraph subscript operator for read access.
+
+        Currently only basic, linear indexing and slicing is supported.
+
+        Args:
+            key: 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_qubits.nodes))
+            return [self._graph_qubits.nodes[node]["qubits"] for node in range(start, stop, step)]
+
+        return self._graph_qubits.nodes[key]["qubits"]
+
+    def __setitem__(self, key, value: "QubitGraph"):
+        """QubitGraph subscript operator for assignment.
+
+        Currently only basic, linear indexing is supported. Slicing is not supported.
+
+        Args:
+            key: Node label in the underlying qubit graph.
+            value (QubitGraph): The QubitGraph object to assign to the node with the given key.
+
+        TODO: Allow for more advanced tensor-like indexing and slicing.
+        """
+        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
+
+        self._graph_qubits.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()
+            >>> q.init_graph_nd_grid((2,))
+            >>> tuple(q)
+            (<QubitGraph object>,)
+
+        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:
+
+            >>> q = QubitGraph()
+            >>> q.init_graph_nd_grid((2,))
+            >>> tuple(q)
+            >>> (<QubitGraph object>, <QubitGraph object>)
+
+        Making QubitGraph non-iterable is especially important when using it as input to the Wires
+        class, which checks if the input is iterable by wrapping it in a tuple.
+        """
+        yield self
+
+    def __repr__(self):
+        """Representation of a QubitGraph object.
+
+        TODO:
+            Improve this representation.
+        """
+        return f"QubitGraph"
+
+    @property
+    def graph(self):
+        """Gets the underlying qubit graph."""
+        return self._graph_qubits
+
+    @property
+    def nodes(self):
+        """Gets the set of nodes in the underlying qubit graph.
+
+        If the underlying qubit graph has not been initialized, emit a UserWarning and return None.
+
+        Returns:
+            networkx.NodeView: 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 self._graph_qubits is None:
+            self._warn_uninitialized()
+            return None
+
+        return self._graph_qubits.nodes
+
+    @property
+    def edges(self):
+        """Gets the set of edges in the underlying qubit graph.
+
+        If the underlying qubit graph has not been initialized, emit a UserWarning and return None.
+
+        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 self._graph_qubits is None:
+            self._warn_uninitialized()
+            return None
+
+        return self._graph_qubits.edges
+
+    @property
+    def is_initialized(self):
+        """Checks if the underlying qubits have been initialized.
+
+        Returns:
+            bool: Returns True if the underlying qubits have been initialized, False otherwise.
+        """
+        return self._graph_qubits is not None
+
+    def clear(self):
+        """Clears the graph of underlying qubits."""
+        self._graph_qubits = None
+
+    def connected_qubits(self, node):
+        """Returns an iterator over all of the qubits connected to the qubit with label ``node``.
+
+        Args:
+            node (node like): The label of a node in the qubit graph.
+
+        Returns:
+            iterator: An iterator over all QubitGraph objects connected to the qubit with label
+                ``node``.
+        """
+        if not self.is_initialized:
+            self._warn_uninitialized()
+            return
+
+        for neighbor in self._graph_qubits.neighbors(node):
+            yield self[neighbor]
+
+    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:
+
+            >>> graph = networkx.hexagonal_lattice_graph(3, 2)
+            >>> q = QubitGraph(graph)
+        """
+        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_qubits = graph
+        for node in self._graph_qubits.nodes:
+            self[node] = QubitGraph()
+
+    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, n (int): The number of rows, m, and columns, n, in the grid.
+
+        Example:
+
+            >>> q = QubitGraph()
+            >>> q.init_graph_2d_grid(2, 3)
+
+            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)
+        """
+        if self.is_initialized:
+            self._warn_reinitialization()
+            return
+
+        self._graph_qubits = nx.grid_2d_graph(m, n)
+        for node in self._graph_qubits.nodes:
+            self[node] = QubitGraph()
+
+    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:
+
+            >>> q = QubitGraph()
+            >>> q.init_graph_2d_grid(2, 2, 3)
+
+            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)
+        """
+        if self.is_initialized:
+            self._warn_reinitialization()
+            return
+
+        self._graph_qubits = nx.grid_graph(dim)
+        for node in self._graph_qubits.nodes:
+            self[node] = QubitGraph()
+
+    def init_graph_surface_code_17(self):
path: pennylane/ftqc/qubit_graph.py
user: login: joeycarter
body: Yes we'll come back to this later (see https://github.com/PennyLaneAI/pennylane/pull/6962#discussion_r1974220040).
created_at: 2025-02-27T19:31:30Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,591 @@
+# 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 warnings
+from typing import 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:
+        id (Any): An identifier for this QubitGraph object.
+        graph (graph-like, optional): The graph to use as the QubitGraph's underlying qubits.
+            Inputting None (the default), leaves the underlying qubit graph in an uninitialized
+            state, in which case one of the graph-initialization methods may be used to define the
+            structure of the underlying qubit graph. 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. An object is considered "graph-like" if
+            it has both a 'nodes' and an 'edges' attribute.
+
+    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, id, graph: Optional[nx.Graph] = None):
+        if id is None:
+            raise TypeError("'None' is not a valid QubitGraph ID.")
+
+        self._id = id  # The identifier for this QubitGraph, e.g. a number, string, tuple, etc.
+
+        if graph is not None:
+            self._check_graph_type_supported_and_raise_or_warn(graph)
+
+        self._graph_qubits = graph  # The qubits underlying (nested within) the current qubit
+        self._parent = None  # The parent QubitGraph object under which this QubitGraph is nested
+
+        # Initialize each node in the graph to store an empty QubitGraph object
+        if self._graph_qubits is not None:
+            self._initialize_all_nodes_as_qubit_graph()
+
+    def __getitem__(self, key) -> "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_qubits.nodes))
+            return [self._graph_qubits.nodes[node]["qubits"] for node in range(start, stop, step)]
+
+        return self._graph_qubits.nodes[key]["qubits"]
+
+    def __setitem__(self, key, 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("top", graph)
+            >>> print(f"{q_top}; {q_top.nodes}")
+            QubitGraph<top>; [0]
+            >>> q_new = QubitGraph("new", graph)
+            >>> print(f"{q_new}; {q_new.nodes}")
+            QubitGraph<new>; [0]
+            >>> q_top[0] = q_new
+            >>> print(f"{q_top[0]}; {q_top[0].nodes}")
+            QubitGraph<top, 0>; [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_qubits.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(0)
+            >>> q.init_graph_nd_grid((2,))
+            >>> tuple(q)
+            (<QubitGraph object>,)
+
+        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:
+
+            >>> q = QubitGraph(0)
+            >>> q.init_graph_nd_grid((2,))
+            >>> tuple(q)
+            >>> (<QubitGraph object>, <QubitGraph object>)
+
+        Making QubitGraph non-iterable is especially important when using it as input to the 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(0)
+            QubitGraph<0>
+
+            >>> q = QubitGraph(0)
+            >>> q.init_graph_nd_grid((2, 2))
+            >>> q
+            QubitGraph<0>
+            >>> q[(0, 1)]
+            QubitGraph<0, (0, 1)>
+            >>> graph = nx.Graph()
+            >>> graph.add_node("aux")
+            >>> q[(0, 1)].init_graph(graph)
+            >>> q[(0, 1)]["aux"]
+            QubitGraph<0, (0, 1), aux>
+        """
+        if self.is_root:
+            return f"QubitGraph<{self._id}>"
+
+        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<{self._id}; cyclic>"
+
+        depth_counter = 1
+
+        ids = [self.id]
+        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(parent.id)
+            parent = parent.parent
+
+            depth_counter += 1
+
+        ids_str = ", ".join(str(id) for id in ids[::-1])
+        return f"QubitGraph<{ids_str}>"
+
+    @property
+    def id(self):
+        """Gets the QubitGraph ID."""
+        return self._id
+
+    @property
+    def graph(self):
+        """Gets the underlying qubit graph."""
+        return self._graph_qubits
+
+    @property
+    def nodes(self):
+        """Gets the set of nodes in the underlying qubit graph.
+
+        If the underlying qubit graph has not been initialized, emit a UserWarning and return None.
+
+        Returns:
+            networkx.NodeView: 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 self._graph_qubits is None:
+            self._warn_uninitialized()
+            return None
+
+        return self._graph_qubits.nodes
+
+    @property
+    def edges(self):
+        """Gets the set of edges in the underlying qubit graph.
+
+        If the underlying qubit graph has not been initialized, emit a UserWarning and return None.
+
+        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 self._graph_qubits is None:
+            self._warn_uninitialized()
+            return None
+
+        return self._graph_qubits.edges
+
+    @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_qubits 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 (self._graph_qubits is None) or (len(self.nodes) == 0)
+
+    @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_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
+
+    def clear(self):
+        """Clears the graph of underlying qubits."""
+        self._graph_qubits = None
+
+    def connected_qubits(self, node):
+        """Returns an iterator over all of the qubits connected to the qubit with label ``node``.
+
+        Args:
+            node (node-like): The label of a node in the qubit graph.
+
+        Returns:
+            iterator: An iterator over all QubitGraph objects connected to the qubit with label
+                ``node``.
+        """
+        if not self.is_initialized:
+            self._warn_uninitialized()
+            return
+
+        for neighbor in self._graph_qubits.neighbors(node):
+            yield self[neighbor]
+
+    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 QubitGraph's parent.
+
+        Returns:
+            bool: Returns True if this QubitGraph has a cycle in its nesting structure.s
+        """
+        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:
+
+            >>> graph = networkx.hexagonal_lattice_graph(3, 2)
+            >>> q = QubitGraph(0, graph)
+        """
+        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_qubits = 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, n (int): The number of rows, m, and columns, n, in the grid.
+
+        Example:
+
+            >>> q = QubitGraph(0)
+            >>> q.init_graph_2d_grid(2, 3)
+
+            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)
+        """
+        if self.is_initialized:
+            self._warn_reinitialization()
+            return
+
+        self._graph_qubits = 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:
+
+            >>> q = QubitGraph(0)
+            >>> q.init_graph_2d_grid(2, 2, 3)
+
+            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)
+        """
+        if self.is_initialized:
+            self._warn_reinitialization()
+            return
+
+        self._graph_qubits = 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
+
+            Tomita & Svore, 2014, Low-distance Surface Codes under Realistic Quantum Noise.
+                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_qubits = nx.Graph()
+        self._graph_qubits.add_nodes_from(data_qubits)
+        self._graph_qubits.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_qubits.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 functions also sets the _parent attribute appropriately.
+        """
+        assert self._graph_qubits is not None, "Underlying qubit graph object must not be None"
+        assert hasattr(
+            self._graph_qubits, "nodes"
+        ), "Underlying qubit graph object must have 'nodes' attribute"
+
+        for node in self._graph_qubits.nodes:
+            q = QubitGraph(id=node)
+            q._parent = self  # pylint: disable=protected-access
+            self[node] = q
+
+    @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,
+        )
+
+    @staticmethod
+    def _check_graph_type_supported_and_raise_or_warn(graph):
+        """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" if it has both a 'nodes' and an 'edges' attribute.
+
+        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
path: pennylane/ftqc/qubit_graph.py
user: login: joeycarter
body: The rustworkx API is slightly different than networkx, as I discovered, so we don't get support for free unfortunately. I don't think it should be too much effort to make the appropriate changes in QubitGraph to support rustworkx, but at the same time I don't want to sink too much time into it in case it becomes non-trivial. I've added an xfail unit test in cc8843675 for now, as a reminder that it would be nice to have this support.
created_at: 2025-02-27T20:00:04Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,601 @@
+# 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 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:
+        id (Any): An identifier for this QubitGraph object.
+        graph (graph-like, optional): The graph to use as the QubitGraph's underlying qubits.
+            Inputting None (the default), leaves the underlying qubit graph in an uninitialized
+            state, in which case one of the graph-initialization methods may be used to define the
+            structure of the underlying qubit graph. 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. An object is considered "graph-like" if
+            it has both a 'nodes' and an 'edges' attribute.
+
+    ..  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, id: Any, graph: Optional[nx.Graph] = None):
path: pennylane/ftqc/qubit_graph.py
user: login: lillian542
body: How would you feel about making `id` an argument that defaults to `None`, and uses `self._id = id or str(uuid.uuid4())`? As far as I know it's not enshrined in any explicit decision, I think de facto it's general practice in PennyLane, so it would be a bit more consistent with other behaviour. There's also generally an assumption that people are usually happy with some generated `id`, so it usually the final argument. 

I ask because my intuition is that `QubitGraph(nx_graph)` should work - so much so that even though I read through your PR, when I opened it in a notebook, that's the first thing I did, and then I got an uninitialized graph with my graph as its ID, and then I was pretty confused for a minute when I tried to do things with it and it told me it wasn't initialized 😅 
created_at: 2025-02-27T21:02:50Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,601 @@
+# 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 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:
+        id (Any): An identifier for this QubitGraph object.
+        graph (graph-like, optional): The graph to use as the QubitGraph's underlying qubits.
+            Inputting None (the default), leaves the underlying qubit graph in an uninitialized
+            state, in which case one of the graph-initialization methods may be used to define the
+            structure of the underlying qubit graph. 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. An object is considered "graph-like" if
+            it has both a 'nodes' and an 'edges' attribute.
+
+    ..  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, id: Any, graph: Optional[nx.Graph] = None):
+        if id is None:
+            raise TypeError("'None' is not a valid QubitGraph ID.")
+
+        self._id = id  # The identifier for this QubitGraph, e.g. a number, string, tuple, etc.
+
+        if graph is not None:
+            self._check_graph_type_supported_and_raise_or_warn(graph)
+
+        self._graph_qubits = graph  # The qubits underlying (nested within) the current qubit
+        self._parent = None  # The parent QubitGraph object under which this QubitGraph is nested
+
+        # Initialize each node in the graph to store an empty QubitGraph object
+        if self._graph_qubits 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_qubits.nodes))
+            return [self._graph_qubits.nodes[node]["qubits"] for node in range(start, stop, step)]
+
+        return self._graph_qubits.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("top", graph)
+            >>> print(f"{q_top}; {q_top.nodes}")
+            QubitGraph<top>; [0]
+            >>> q_new = QubitGraph("new", graph)
+            >>> print(f"{q_new}; {q_new.nodes}")
+            QubitGraph<new>; [0]
+            >>> q_top[0] = q_new
+            >>> print(f"{q_top[0]}; {q_top[0].nodes}")
+            QubitGraph<top, 0>; [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_qubits.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(0)
+            >>> q.init_graph_nd_grid((2,))
+            >>> tuple(q)
+            (QubitGraph<0>,)
+
+        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(0)
+            >>> q.init_graph_nd_grid((2,))
+            >>> tuple(q)
+            (QubitGraph<0, 0>, QubitGraph<0, 1>)
+
+        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(0)
+            QubitGraph<0>
+
+            >>> q = QubitGraph(0)
+            >>> q.init_graph_nd_grid((2, 2))
+            >>> q
+            QubitGraph<0>
+            >>> q[(0, 1)]
+            QubitGraph<0, (0, 1)>
+            >>> graph = nx.Graph()
+            >>> graph.add_node("aux")
+            >>> q[(0, 1)].init_graph(graph)
+            >>> q[(0, 1)]["aux"]
+            QubitGraph<0, (0, 1), aux>
+        """
+        if self.is_root:
+            return f"QubitGraph<{self._id}>"
+
+        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<{self._id}; cyclic>"
+
+        depth_counter = 1
+
+        ids = [self.id]
+        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(parent.id)
+            parent = parent.parent
+
+            depth_counter += 1
+
+        ids_str = ", ".join(str(id) for id in ids[::-1])
+        return f"QubitGraph<{ids_str}>"
+
+    @property
+    def id(self):
+        """Gets the QubitGraph ID."""
+        return self._id
+
+    @property
+    def graph(self):
+        """Gets the underlying qubit graph."""
+        return self._graph_qubits
+
+    @property
+    def nodes(self):
+        """Gets the set of nodes in the underlying qubit graph.
+
+        If the underlying qubit graph has not been initialized, emit a ``UserWarning`` and return
+        None.
+
+        Returns:
+            networkx.NodeView: 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.
path: pennylane/ftqc/qubit_graph.py
user: login: lillian542
body: As I'm modifying and manipulating a `QubitGraph` in a notebook, I find myself wanting to access a list of the nodes _as qubit graphs_. This returns the nodes of the networkx graph, which are essentially the labels of the nodes of the `QubitGraph`, as I understand it. Could this or another method return `[self[i] for i in self._graph_qubits.nodes]`?
created_at: 2025-02-27T21:29:44Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,601 @@
+# 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 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:
+        id (Any): An identifier for this QubitGraph object.
+        graph (graph-like, optional): The graph to use as the QubitGraph's underlying qubits.
+            Inputting None (the default), leaves the underlying qubit graph in an uninitialized
+            state, in which case one of the graph-initialization methods may be used to define the
+            structure of the underlying qubit graph. 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. An object is considered "graph-like" if
+            it has both a 'nodes' and an 'edges' attribute.
+
+    ..  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, id: Any, graph: Optional[nx.Graph] = None):
+        if id is None:
+            raise TypeError("'None' is not a valid QubitGraph ID.")
+
+        self._id = id  # The identifier for this QubitGraph, e.g. a number, string, tuple, etc.
+
+        if graph is not None:
+            self._check_graph_type_supported_and_raise_or_warn(graph)
+
+        self._graph_qubits = graph  # The qubits underlying (nested within) the current qubit
+        self._parent = None  # The parent QubitGraph object under which this QubitGraph is nested
+
+        # Initialize each node in the graph to store an empty QubitGraph object
+        if self._graph_qubits 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_qubits.nodes))
+            return [self._graph_qubits.nodes[node]["qubits"] for node in range(start, stop, step)]
+
+        return self._graph_qubits.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("top", graph)
+            >>> print(f"{q_top}; {q_top.nodes}")
+            QubitGraph<top>; [0]
+            >>> q_new = QubitGraph("new", graph)
+            >>> print(f"{q_new}; {q_new.nodes}")
+            QubitGraph<new>; [0]
+            >>> q_top[0] = q_new
+            >>> print(f"{q_top[0]}; {q_top[0].nodes}")
+            QubitGraph<top, 0>; [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_qubits.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(0)
+            >>> q.init_graph_nd_grid((2,))
+            >>> tuple(q)
+            (QubitGraph<0>,)
+
+        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(0)
+            >>> q.init_graph_nd_grid((2,))
+            >>> tuple(q)
+            (QubitGraph<0, 0>, QubitGraph<0, 1>)
+
+        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.
path: pennylane/ftqc/qubit_graph.py
user: login: lillian542
body: I definitely see the utility of this if we want to address a nested stack of qubits from the top-down, but more immediately I imagine we may also want to address them from the bottom up, specifying what individual physical qubits do. Could we have a method that returns an iterable of all the leaves in the graph?

I imagine that as we implement something clearer for how to determine how application of operations on a QubitGraph node maps to the underlying nodes, this will become less relevant, but I think it would be nice to have short term. For instance, I think that would be useful for our initial plan to implement a simple, explicitly defined, bottom up example, where we will be individually addressing the leaves in the graph. 
created_at: 2025-02-27T21:37:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,591 @@
+# 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 warnings
+from typing import 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
path: pennylane/ftqc/qubit_graph.py
user: login: mlxd
body: Yep, wrote it before seeing --- all good!
created_at: 2025-02-27T21:40:20Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,591 @@
+# 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 warnings
+from typing import 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:
+        id (Any): An identifier for this QubitGraph object.
+        graph (graph-like, optional): The graph to use as the QubitGraph's underlying qubits.
+            Inputting None (the default), leaves the underlying qubit graph in an uninitialized
+            state, in which case one of the graph-initialization methods may be used to define the
+            structure of the underlying qubit graph. 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. An object is considered "graph-like" if
+            it has both a 'nodes' and an 'edges' attribute.
+
+    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, id, graph: Optional[nx.Graph] = None):
+        if id is None:
+            raise TypeError("'None' is not a valid QubitGraph ID.")
+
+        self._id = id  # The identifier for this QubitGraph, e.g. a number, string, tuple, etc.
+
+        if graph is not None:
+            self._check_graph_type_supported_and_raise_or_warn(graph)
+
+        self._graph_qubits = graph  # The qubits underlying (nested within) the current qubit
+        self._parent = None  # The parent QubitGraph object under which this QubitGraph is nested
+
+        # Initialize each node in the graph to store an empty QubitGraph object
+        if self._graph_qubits is not None:
+            self._initialize_all_nodes_as_qubit_graph()
+
+    def __getitem__(self, key) -> "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_qubits.nodes))
+            return [self._graph_qubits.nodes[node]["qubits"] for node in range(start, stop, step)]
+
+        return self._graph_qubits.nodes[key]["qubits"]
+
+    def __setitem__(self, key, 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("top", graph)
+            >>> print(f"{q_top}; {q_top.nodes}")
+            QubitGraph<top>; [0]
+            >>> q_new = QubitGraph("new", graph)
+            >>> print(f"{q_new}; {q_new.nodes}")
+            QubitGraph<new>; [0]
+            >>> q_top[0] = q_new
+            >>> print(f"{q_top[0]}; {q_top[0].nodes}")
+            QubitGraph<top, 0>; [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_qubits.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(0)
+            >>> q.init_graph_nd_grid((2,))
+            >>> tuple(q)
+            (<QubitGraph object>,)
+
+        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:
+
+            >>> q = QubitGraph(0)
+            >>> q.init_graph_nd_grid((2,))
+            >>> tuple(q)
+            >>> (<QubitGraph object>, <QubitGraph object>)
+
+        Making QubitGraph non-iterable is especially important when using it as input to the 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(0)
+            QubitGraph<0>
+
+            >>> q = QubitGraph(0)
+            >>> q.init_graph_nd_grid((2, 2))
+            >>> q
+            QubitGraph<0>
+            >>> q[(0, 1)]
+            QubitGraph<0, (0, 1)>
+            >>> graph = nx.Graph()
+            >>> graph.add_node("aux")
+            >>> q[(0, 1)].init_graph(graph)
+            >>> q[(0, 1)]["aux"]
+            QubitGraph<0, (0, 1), aux>
+        """
+        if self.is_root:
+            return f"QubitGraph<{self._id}>"
+
+        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<{self._id}; cyclic>"
+
+        depth_counter = 1
+
+        ids = [self.id]
+        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(parent.id)
+            parent = parent.parent
+
+            depth_counter += 1
+
+        ids_str = ", ".join(str(id) for id in ids[::-1])
+        return f"QubitGraph<{ids_str}>"
+
+    @property
+    def id(self):
+        """Gets the QubitGraph ID."""
+        return self._id
+
+    @property
+    def graph(self):
+        """Gets the underlying qubit graph."""
+        return self._graph_qubits
+
+    @property
+    def nodes(self):
+        """Gets the set of nodes in the underlying qubit graph.
+
+        If the underlying qubit graph has not been initialized, emit a UserWarning and return None.
+
+        Returns:
+            networkx.NodeView: 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 self._graph_qubits is None:
+            self._warn_uninitialized()
+            return None
+
+        return self._graph_qubits.nodes
+
+    @property
+    def edges(self):
+        """Gets the set of edges in the underlying qubit graph.
+
+        If the underlying qubit graph has not been initialized, emit a UserWarning and return None.
+
+        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 self._graph_qubits is None:
+            self._warn_uninitialized()
+            return None
+
+        return self._graph_qubits.edges
+
+    @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_qubits 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 (self._graph_qubits is None) or (len(self.nodes) == 0)
+
+    @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_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
+
+    def clear(self):
+        """Clears the graph of underlying qubits."""
+        self._graph_qubits = None
+
+    def connected_qubits(self, node):
+        """Returns an iterator over all of the qubits connected to the qubit with label ``node``.
+
+        Args:
+            node (node-like): The label of a node in the qubit graph.
+
+        Returns:
+            iterator: An iterator over all QubitGraph objects connected to the qubit with label
+                ``node``.
+        """
+        if not self.is_initialized:
+            self._warn_uninitialized()
+            return
+
+        for neighbor in self._graph_qubits.neighbors(node):
+            yield self[neighbor]
+
+    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 QubitGraph's parent.
+
+        Returns:
+            bool: Returns True if this QubitGraph has a cycle in its nesting structure.s
+        """
+        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:
+
+            >>> graph = networkx.hexagonal_lattice_graph(3, 2)
+            >>> q = QubitGraph(0, graph)
+        """
+        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_qubits = 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, n (int): The number of rows, m, and columns, n, in the grid.
+
+        Example:
+
+            >>> q = QubitGraph(0)
+            >>> q.init_graph_2d_grid(2, 3)
+
+            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)
+        """
+        if self.is_initialized:
+            self._warn_reinitialization()
+            return
+
+        self._graph_qubits = 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:
+
+            >>> q = QubitGraph(0)
+            >>> q.init_graph_2d_grid(2, 2, 3)
+
+            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)
+        """
+        if self.is_initialized:
+            self._warn_reinitialization()
+            return
+
+        self._graph_qubits = 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
+
+            Tomita & Svore, 2014, Low-distance Surface Codes under Realistic Quantum Noise.
+                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_qubits = nx.Graph()
+        self._graph_qubits.add_nodes_from(data_qubits)
+        self._graph_qubits.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_qubits.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 functions also sets the _parent attribute appropriately.
+        """
+        assert self._graph_qubits is not None, "Underlying qubit graph object must not be None"
+        assert hasattr(
+            self._graph_qubits, "nodes"
+        ), "Underlying qubit graph object must have 'nodes' attribute"
+
+        for node in self._graph_qubits.nodes:
+            q = QubitGraph(id=node)
+            q._parent = self  # pylint: disable=protected-access
+            self[node] = q
+
+    @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,
+        )
+
+    @staticmethod
+    def _check_graph_type_supported_and_raise_or_warn(graph):
+        """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" if it has both a 'nodes' and an 'edges' attribute.
+
+        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
path: pennylane/ftqc/qubit_graph.py
user: login: mlxd
body: Awesome, thanks
created_at: 2025-02-27T21:42:21Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,601 @@
+# 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 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:
+        id (Any): An identifier for this QubitGraph object.
+        graph (graph-like, optional): The graph to use as the QubitGraph's underlying qubits.
+            Inputting None (the default), leaves the underlying qubit graph in an uninitialized
+            state, in which case one of the graph-initialization methods may be used to define the
+            structure of the underlying qubit graph. 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. An object is considered "graph-like" if
+            it has both a 'nodes' and an 'edges' attribute.
+
+    ..  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, id: Any, graph: Optional[nx.Graph] = None):
+        if id is None:
+            raise TypeError("'None' is not a valid QubitGraph ID.")
+
+        self._id = id  # The identifier for this QubitGraph, e.g. a number, string, tuple, etc.
+
+        if graph is not None:
+            self._check_graph_type_supported_and_raise_or_warn(graph)
+
+        self._graph_qubits = graph  # The qubits underlying (nested within) the current qubit
+        self._parent = None  # The parent QubitGraph object under which this QubitGraph is nested
+
+        # Initialize each node in the graph to store an empty QubitGraph object
+        if self._graph_qubits 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_qubits.nodes))
+            return [self._graph_qubits.nodes[node]["qubits"] for node in range(start, stop, step)]
+
+        return self._graph_qubits.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("top", graph)
+            >>> print(f"{q_top}; {q_top.nodes}")
+            QubitGraph<top>; [0]
+            >>> q_new = QubitGraph("new", graph)
+            >>> print(f"{q_new}; {q_new.nodes}")
+            QubitGraph<new>; [0]
+            >>> q_top[0] = q_new
+            >>> print(f"{q_top[0]}; {q_top[0].nodes}")
+            QubitGraph<top, 0>; [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_qubits.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(0)
+            >>> q.init_graph_nd_grid((2,))
+            >>> tuple(q)
+            (QubitGraph<0>,)
+
+        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(0)
+            >>> q.init_graph_nd_grid((2,))
+            >>> tuple(q)
+            (QubitGraph<0, 0>, QubitGraph<0, 1>)
+
+        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(0)
+            QubitGraph<0>
+
+            >>> q = QubitGraph(0)
+            >>> q.init_graph_nd_grid((2, 2))
+            >>> q
+            QubitGraph<0>
+            >>> q[(0, 1)]
+            QubitGraph<0, (0, 1)>
+            >>> graph = nx.Graph()
+            >>> graph.add_node("aux")
+            >>> q[(0, 1)].init_graph(graph)
+            >>> q[(0, 1)]["aux"]
+            QubitGraph<0, (0, 1), aux>
path: pennylane/ftqc/qubit_graph.py
user: login: lillian542
body: When I was playing around with this, I found that it would help me if the format were something like:

```suggestion
            QubitGraph<id=0, node_location=[(0, 1), aux]>
```

I don't know if the name `node_location` is a very good idea, its just the first thing that came to mind, but some way of separating these to make it clearer when I want to index into the object, the very first thing is _not_ an index, but the overall "name".
created_at: 2025-02-27T22:26:20Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,601 @@
+# 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 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:
+        id (Any): An identifier for this QubitGraph object.
+        graph (graph-like, optional): The graph to use as the QubitGraph's underlying qubits.
path: pennylane/ftqc/qubit_graph.py
user: login: lillian542
body: Initially when trying to interact with this, I made a nested `networkx` graph and passed it in, which sort of looks like it works initially, but actually uses the sub-graphs as labels and leaves the nodes that "should" contain them uninitialized.

I don't think we should iterate over this whole thing and add a check, because that sounds expensive, but I do think we should note in the docstring that this input graph can't be nested, and include an example in the docstring of the correct procedure for creating a nested graph (by repeatedly setting nodes to be single-layer graphs at various levels).
created_at: 2025-02-27T22:36:17Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,601 @@
+# 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 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:
+        id (Any): An identifier for this QubitGraph object.
+        graph (graph-like, optional): The graph to use as the QubitGraph's underlying qubits.
+            Inputting None (the default), leaves the underlying qubit graph in an uninitialized
+            state, in which case one of the graph-initialization methods may be used to define the
+            structure of the underlying qubit graph. 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. An object is considered "graph-like" if
+            it has both a 'nodes' and an 'edges' attribute.
+
+    ..  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, id: Any, graph: Optional[nx.Graph] = None):
+        if id is None:
+            raise TypeError("'None' is not a valid QubitGraph ID.")
+
+        self._id = id  # The identifier for this QubitGraph, e.g. a number, string, tuple, etc.
+
+        if graph is not None:
+            self._check_graph_type_supported_and_raise_or_warn(graph)
+
+        self._graph_qubits = graph  # The qubits underlying (nested within) the current qubit
+        self._parent = None  # The parent QubitGraph object under which this QubitGraph is nested
+
+        # Initialize each node in the graph to store an empty QubitGraph object
+        if self._graph_qubits 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_qubits.nodes))
+            return [self._graph_qubits.nodes[node]["qubits"] for node in range(start, stop, step)]
+
+        return self._graph_qubits.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("top", graph)
+            >>> print(f"{q_top}; {q_top.nodes}")
+            QubitGraph<top>; [0]
+            >>> q_new = QubitGraph("new", graph)
+            >>> print(f"{q_new}; {q_new.nodes}")
+            QubitGraph<new>; [0]
+            >>> q_top[0] = q_new
+            >>> print(f"{q_top[0]}; {q_top[0].nodes}")
+            QubitGraph<top, 0>; [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_qubits.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(0)
+            >>> q.init_graph_nd_grid((2,))
+            >>> tuple(q)
+            (QubitGraph<0>,)
+
+        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(0)
+            >>> q.init_graph_nd_grid((2,))
+            >>> tuple(q)
+            (QubitGraph<0, 0>, QubitGraph<0, 1>)
+
+        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(0)
+            QubitGraph<0>
+
+            >>> q = QubitGraph(0)
+            >>> q.init_graph_nd_grid((2, 2))
+            >>> q
+            QubitGraph<0>
+            >>> q[(0, 1)]
+            QubitGraph<0, (0, 1)>
+            >>> graph = nx.Graph()
+            >>> graph.add_node("aux")
+            >>> q[(0, 1)].init_graph(graph)
+            >>> q[(0, 1)]["aux"]
+            QubitGraph<0, (0, 1), aux>
+        """
+        if self.is_root:
+            return f"QubitGraph<{self._id}>"
+
+        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<{self._id}; cyclic>"
+
+        depth_counter = 1
+
+        ids = [self.id]
+        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(parent.id)
+            parent = parent.parent
+
+            depth_counter += 1
+
+        ids_str = ", ".join(str(id) for id in ids[::-1])
+        return f"QubitGraph<{ids_str}>"
+
+    @property
+    def id(self):
+        """Gets the QubitGraph ID."""
+        return self._id
+
+    @property
+    def graph(self):
+        """Gets the underlying qubit graph."""
+        return self._graph_qubits
+
+    @property
+    def nodes(self):
+        """Gets the set of nodes in the underlying qubit graph.
+
+        If the underlying qubit graph has not been initialized, emit a ``UserWarning`` and return
+        None.
+
+        Returns:
+            networkx.NodeView: 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 self._graph_qubits is None:
+            self._warn_uninitialized()
+            return None
+
+        return self._graph_qubits.nodes
+
+    @property
+    def edges(self):
+        """Gets the set of edges in the underlying qubit graph.
+
+        If the underlying qubit graph has not been initialized, emit a UserWarning and return None.
+
+        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 self._graph_qubits is None:
+            self._warn_uninitialized()
+            return None
+
+        return self._graph_qubits.edges
+
+    @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_qubits 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 (self._graph_qubits is None) or (len(self.nodes) == 0)
+
+    @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_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
+
+    def clear(self):
+        """Clears the graph of underlying qubits."""
+        self._graph_qubits = None
+
+    def connected_qubits(self, node):
+        """Returns an iterator over all of the qubits connected to the qubit with label ``node``.
path: pennylane/ftqc/qubit_graph.py
user: login: lillian542
body: Can we add an example here? Preferably an example where we have a multi-level graph, and then we get the connected qubits for some subqubit, i.e. something like

```
graph = nx.grid_graph([5])
qubit0 = QubitGraph(0, graph)
for node_name in qubit0.nodes:
    qubit0[node_name] = QubitGraph(node_name, nx.grid_graph([3, 3]))

[i for i in qubit0[0].connected_qubits((0, 1))]
```

to make it clear that we first need to index into the level that is relevant, and then use the label at that level.
created_at: 2025-02-27T22:53:57Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,601 @@
+# 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 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:
+        id (Any): An identifier for this QubitGraph object.
+        graph (graph-like, optional): The graph to use as the QubitGraph's underlying qubits.
+            Inputting None (the default), leaves the underlying qubit graph in an uninitialized
+            state, in which case one of the graph-initialization methods may be used to define the
+            structure of the underlying qubit graph. 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. An object is considered "graph-like" if
+            it has both a 'nodes' and an 'edges' attribute.
+
+    ..  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, id: Any, graph: Optional[nx.Graph] = None):
+        if id is None:
+            raise TypeError("'None' is not a valid QubitGraph ID.")
+
+        self._id = id  # The identifier for this QubitGraph, e.g. a number, string, tuple, etc.
+
+        if graph is not None:
+            self._check_graph_type_supported_and_raise_or_warn(graph)
+
+        self._graph_qubits = graph  # The qubits underlying (nested within) the current qubit
+        self._parent = None  # The parent QubitGraph object under which this QubitGraph is nested
+
+        # Initialize each node in the graph to store an empty QubitGraph object
+        if self._graph_qubits 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_qubits.nodes))
+            return [self._graph_qubits.nodes[node]["qubits"] for node in range(start, stop, step)]
+
+        return self._graph_qubits.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("top", graph)
+            >>> print(f"{q_top}; {q_top.nodes}")
+            QubitGraph<top>; [0]
+            >>> q_new = QubitGraph("new", graph)
+            >>> print(f"{q_new}; {q_new.nodes}")
+            QubitGraph<new>; [0]
+            >>> q_top[0] = q_new
+            >>> print(f"{q_top[0]}; {q_top[0].nodes}")
+            QubitGraph<top, 0>; [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_qubits.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(0)
+            >>> q.init_graph_nd_grid((2,))
+            >>> tuple(q)
+            (QubitGraph<0>,)
+
+        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(0)
+            >>> q.init_graph_nd_grid((2,))
+            >>> tuple(q)
+            (QubitGraph<0, 0>, QubitGraph<0, 1>)
+
+        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(0)
+            QubitGraph<0>
+
+            >>> q = QubitGraph(0)
+            >>> q.init_graph_nd_grid((2, 2))
+            >>> q
+            QubitGraph<0>
+            >>> q[(0, 1)]
+            QubitGraph<0, (0, 1)>
+            >>> graph = nx.Graph()
+            >>> graph.add_node("aux")
+            >>> q[(0, 1)].init_graph(graph)
+            >>> q[(0, 1)]["aux"]
+            QubitGraph<0, (0, 1), aux>
+        """
+        if self.is_root:
+            return f"QubitGraph<{self._id}>"
+
+        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<{self._id}; cyclic>"
+
+        depth_counter = 1
+
+        ids = [self.id]
+        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(parent.id)
+            parent = parent.parent
+
+            depth_counter += 1
+
+        ids_str = ", ".join(str(id) for id in ids[::-1])
+        return f"QubitGraph<{ids_str}>"
+
+    @property
+    def id(self):
+        """Gets the QubitGraph ID."""
+        return self._id
+
+    @property
+    def graph(self):
+        """Gets the underlying qubit graph."""
+        return self._graph_qubits
+
+    @property
+    def nodes(self):
+        """Gets the set of nodes in the underlying qubit graph.
+
+        If the underlying qubit graph has not been initialized, emit a ``UserWarning`` and return
+        None.
+
+        Returns:
+            networkx.NodeView: 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 self._graph_qubits is None:
+            self._warn_uninitialized()
+            return None
+
+        return self._graph_qubits.nodes
+
+    @property
+    def edges(self):
+        """Gets the set of edges in the underlying qubit graph.
+
+        If the underlying qubit graph has not been initialized, emit a UserWarning and return None.
+
+        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 self._graph_qubits is None:
+            self._warn_uninitialized()
+            return None
+
+        return self._graph_qubits.edges
+
+    @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_qubits 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 (self._graph_qubits is None) or (len(self.nodes) == 0)
+
+    @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_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
+
+    def clear(self):
+        """Clears the graph of underlying qubits."""
+        self._graph_qubits = None
+
+    def connected_qubits(self, node):
+        """Returns an iterator over all of the qubits connected to the qubit with label ``node``.
path: pennylane/ftqc/qubit_graph.py
user: login: lillian542
body: Actually playing around with it more, I'm wondering if instead of a label, this might be more user-friendly if it had no arguments and was a property of the current `QubitGraph`. In the example above, it might be clearer to have:

```
# graph generation

node = qubit0[2]

# ... I do whatever is needed with this qubit, and then I want its neighbors

for i in node.connected_qubits:
    ...
```

instead of needing to do:

```
for i in node.parent.connected_qubits(node.id):
    ...
```
created_at: 2025-02-27T23:03:24Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,601 @@
+# 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 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:
+        id (Any): An identifier for this QubitGraph object.
+        graph (graph-like, optional): The graph to use as the QubitGraph's underlying qubits.
+            Inputting None (the default), leaves the underlying qubit graph in an uninitialized
+            state, in which case one of the graph-initialization methods may be used to define the
+            structure of the underlying qubit graph. 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. An object is considered "graph-like" if
+            it has both a 'nodes' and an 'edges' attribute.
+
+    ..  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, id: Any, graph: Optional[nx.Graph] = None):
+        if id is None:
+            raise TypeError("'None' is not a valid QubitGraph ID.")
+
+        self._id = id  # The identifier for this QubitGraph, e.g. a number, string, tuple, etc.
+
+        if graph is not None:
+            self._check_graph_type_supported_and_raise_or_warn(graph)
+
+        self._graph_qubits = graph  # The qubits underlying (nested within) the current qubit
+        self._parent = None  # The parent QubitGraph object under which this QubitGraph is nested
+
+        # Initialize each node in the graph to store an empty QubitGraph object
+        if self._graph_qubits 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_qubits.nodes))
+            return [self._graph_qubits.nodes[node]["qubits"] for node in range(start, stop, step)]
+
+        return self._graph_qubits.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("top", graph)
+            >>> print(f"{q_top}; {q_top.nodes}")
+            QubitGraph<top>; [0]
+            >>> q_new = QubitGraph("new", graph)
+            >>> print(f"{q_new}; {q_new.nodes}")
+            QubitGraph<new>; [0]
+            >>> q_top[0] = q_new
+            >>> print(f"{q_top[0]}; {q_top[0].nodes}")
+            QubitGraph<top, 0>; [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_qubits.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(0)
+            >>> q.init_graph_nd_grid((2,))
+            >>> tuple(q)
+            (QubitGraph<0>,)
+
+        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(0)
+            >>> q.init_graph_nd_grid((2,))
+            >>> tuple(q)
+            (QubitGraph<0, 0>, QubitGraph<0, 1>)
+
+        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(0)
+            QubitGraph<0>
+
+            >>> q = QubitGraph(0)
+            >>> q.init_graph_nd_grid((2, 2))
+            >>> q
+            QubitGraph<0>
+            >>> q[(0, 1)]
+            QubitGraph<0, (0, 1)>
+            >>> graph = nx.Graph()
+            >>> graph.add_node("aux")
+            >>> q[(0, 1)].init_graph(graph)
+            >>> q[(0, 1)]["aux"]
+            QubitGraph<0, (0, 1), aux>
+        """
+        if self.is_root:
+            return f"QubitGraph<{self._id}>"
+
+        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<{self._id}; cyclic>"
+
+        depth_counter = 1
+
+        ids = [self.id]
+        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(parent.id)
+            parent = parent.parent
+
+            depth_counter += 1
+
+        ids_str = ", ".join(str(id) for id in ids[::-1])
+        return f"QubitGraph<{ids_str}>"
+
+    @property
+    def id(self):
+        """Gets the QubitGraph ID."""
+        return self._id
+
+    @property
+    def graph(self):
+        """Gets the underlying qubit graph."""
+        return self._graph_qubits
+
+    @property
+    def nodes(self):
+        """Gets the set of nodes in the underlying qubit graph.
+
+        If the underlying qubit graph has not been initialized, emit a ``UserWarning`` and return
+        None.
+
+        Returns:
+            networkx.NodeView: 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 self._graph_qubits is None:
+            self._warn_uninitialized()
+            return None
+
+        return self._graph_qubits.nodes
+
+    @property
+    def edges(self):
+        """Gets the set of edges in the underlying qubit graph.
+
+        If the underlying qubit graph has not been initialized, emit a UserWarning and return None.
+
+        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 self._graph_qubits is None:
+            self._warn_uninitialized()
+            return None
+
+        return self._graph_qubits.edges
+
+    @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_qubits 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 (self._graph_qubits is None) or (len(self.nodes) == 0)
+
+    @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_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
+
+    def clear(self):
+        """Clears the graph of underlying qubits."""
+        self._graph_qubits = None
path: pennylane/ftqc/qubit_graph.py
user: login: multiphaseCFD
body: Do we need to clear out the `self._parent` as well?
created_at: 2025-03-03T14:12:13Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,601 @@
+# 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 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:
+        id (Any): An identifier for this QubitGraph object.
+        graph (graph-like, optional): The graph to use as the QubitGraph's underlying qubits.
+            Inputting None (the default), leaves the underlying qubit graph in an uninitialized
+            state, in which case one of the graph-initialization methods may be used to define the
+            structure of the underlying qubit graph. 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. An object is considered "graph-like" if
+            it has both a 'nodes' and an 'edges' attribute.
+
+    ..  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.
path: pennylane/ftqc/qubit_graph.py
user: login: multiphaseCFD
body: Per TODOs, is it worth to support dynamic node removal and addition?
created_at: 2025-03-03T14:23:48Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,601 @@
+# 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 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:
+        id (Any): An identifier for this QubitGraph object.
+        graph (graph-like, optional): The graph to use as the QubitGraph's underlying qubits.
+            Inputting None (the default), leaves the underlying qubit graph in an uninitialized
+            state, in which case one of the graph-initialization methods may be used to define the
+            structure of the underlying qubit graph. 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. An object is considered "graph-like" if
+            it has both a 'nodes' and an 'edges' attribute.
+
+    ..  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, id: Any, graph: Optional[nx.Graph] = None):
+        if id is None:
+            raise TypeError("'None' is not a valid QubitGraph ID.")
+
+        self._id = id  # The identifier for this QubitGraph, e.g. a number, string, tuple, etc.
+
+        if graph is not None:
+            self._check_graph_type_supported_and_raise_or_warn(graph)
+
+        self._graph_qubits = graph  # The qubits underlying (nested within) the current qubit
+        self._parent = None  # The parent QubitGraph object under which this QubitGraph is nested
+
+        # Initialize each node in the graph to store an empty QubitGraph object
+        if self._graph_qubits 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_qubits.nodes))
+            return [self._graph_qubits.nodes[node]["qubits"] for node in range(start, stop, step)]
+
+        return self._graph_qubits.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("top", graph)
+            >>> print(f"{q_top}; {q_top.nodes}")
+            QubitGraph<top>; [0]
+            >>> q_new = QubitGraph("new", graph)
+            >>> print(f"{q_new}; {q_new.nodes}")
+            QubitGraph<new>; [0]
+            >>> q_top[0] = q_new
+            >>> print(f"{q_top[0]}; {q_top[0].nodes}")
+            QubitGraph<top, 0>; [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_qubits.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(0)
+            >>> q.init_graph_nd_grid((2,))
+            >>> tuple(q)
+            (QubitGraph<0>,)
+
+        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(0)
+            >>> q.init_graph_nd_grid((2,))
+            >>> tuple(q)
+            (QubitGraph<0, 0>, QubitGraph<0, 1>)
+
+        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(0)
+            QubitGraph<0>
+
+            >>> q = QubitGraph(0)
+            >>> q.init_graph_nd_grid((2, 2))
+            >>> q
+            QubitGraph<0>
+            >>> q[(0, 1)]
+            QubitGraph<0, (0, 1)>
+            >>> graph = nx.Graph()
+            >>> graph.add_node("aux")
+            >>> q[(0, 1)].init_graph(graph)
+            >>> q[(0, 1)]["aux"]
+            QubitGraph<0, (0, 1), aux>
+        """
+        if self.is_root:
+            return f"QubitGraph<{self._id}>"
+
+        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<{self._id}; cyclic>"
+
+        depth_counter = 1
+
+        ids = [self.id]
+        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(parent.id)
+            parent = parent.parent
+
+            depth_counter += 1
+
+        ids_str = ", ".join(str(id) for id in ids[::-1])
+        return f"QubitGraph<{ids_str}>"
+
+    @property
+    def id(self):
+        """Gets the QubitGraph ID."""
+        return self._id
+
+    @property
+    def graph(self):
+        """Gets the underlying qubit graph."""
+        return self._graph_qubits
+
+    @property
+    def nodes(self):
+        """Gets the set of nodes in the underlying qubit graph.
+
+        If the underlying qubit graph has not been initialized, emit a ``UserWarning`` and return
+        None.
+
+        Returns:
+            networkx.NodeView: 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.
path: pennylane/ftqc/qubit_graph.py
user: login: joeycarter
body: Refactored this property to be `node_labels` in 508c9beae and added the `children` property, which returns an iterator over the underlying QubitGraph objects, in 1b975d66c.
created_at: 2025-03-04T21:36:29Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,601 @@
+# 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 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:
+        id (Any): An identifier for this QubitGraph object.
+        graph (graph-like, optional): The graph to use as the QubitGraph's underlying qubits.
path: pennylane/ftqc/qubit_graph.py
user: login: joeycarter
body: I updated the docstring specifying that the input graph should be "flat" (unnested) in 185993e1a and added an example to the class docstring in dbada7f3d.
created_at: 2025-03-04T22:04:36Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,601 @@
+# 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 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:
+        id (Any): An identifier for this QubitGraph object.
+        graph (graph-like, optional): The graph to use as the QubitGraph's underlying qubits.
+            Inputting None (the default), leaves the underlying qubit graph in an uninitialized
+            state, in which case one of the graph-initialization methods may be used to define the
+            structure of the underlying qubit graph. 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. An object is considered "graph-like" if
+            it has both a 'nodes' and an 'edges' attribute.
+
+    ..  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, id: Any, graph: Optional[nx.Graph] = None):
+        if id is None:
+            raise TypeError("'None' is not a valid QubitGraph ID.")
+
+        self._id = id  # The identifier for this QubitGraph, e.g. a number, string, tuple, etc.
+
+        if graph is not None:
+            self._check_graph_type_supported_and_raise_or_warn(graph)
+
+        self._graph_qubits = graph  # The qubits underlying (nested within) the current qubit
+        self._parent = None  # The parent QubitGraph object under which this QubitGraph is nested
+
+        # Initialize each node in the graph to store an empty QubitGraph object
+        if self._graph_qubits 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_qubits.nodes))
+            return [self._graph_qubits.nodes[node]["qubits"] for node in range(start, stop, step)]
+
+        return self._graph_qubits.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("top", graph)
+            >>> print(f"{q_top}; {q_top.nodes}")
+            QubitGraph<top>; [0]
+            >>> q_new = QubitGraph("new", graph)
+            >>> print(f"{q_new}; {q_new.nodes}")
+            QubitGraph<new>; [0]
+            >>> q_top[0] = q_new
+            >>> print(f"{q_top[0]}; {q_top[0].nodes}")
+            QubitGraph<top, 0>; [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_qubits.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(0)
+            >>> q.init_graph_nd_grid((2,))
+            >>> tuple(q)
+            (QubitGraph<0>,)
+
+        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(0)
+            >>> q.init_graph_nd_grid((2,))
+            >>> tuple(q)
+            (QubitGraph<0, 0>, QubitGraph<0, 1>)
+
+        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(0)
+            QubitGraph<0>
+
+            >>> q = QubitGraph(0)
+            >>> q.init_graph_nd_grid((2, 2))
+            >>> q
+            QubitGraph<0>
+            >>> q[(0, 1)]
+            QubitGraph<0, (0, 1)>
+            >>> graph = nx.Graph()
+            >>> graph.add_node("aux")
+            >>> q[(0, 1)].init_graph(graph)
+            >>> q[(0, 1)]["aux"]
+            QubitGraph<0, (0, 1), aux>
+        """
+        if self.is_root:
+            return f"QubitGraph<{self._id}>"
+
+        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<{self._id}; cyclic>"
+
+        depth_counter = 1
+
+        ids = [self.id]
+        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(parent.id)
+            parent = parent.parent
+
+            depth_counter += 1
+
+        ids_str = ", ".join(str(id) for id in ids[::-1])
+        return f"QubitGraph<{ids_str}>"
+
+    @property
+    def id(self):
+        """Gets the QubitGraph ID."""
+        return self._id
+
+    @property
+    def graph(self):
+        """Gets the underlying qubit graph."""
+        return self._graph_qubits
+
+    @property
+    def nodes(self):
+        """Gets the set of nodes in the underlying qubit graph.
+
+        If the underlying qubit graph has not been initialized, emit a ``UserWarning`` and return
+        None.
+
+        Returns:
+            networkx.NodeView: 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 self._graph_qubits is None:
+            self._warn_uninitialized()
+            return None
+
+        return self._graph_qubits.nodes
+
+    @property
+    def edges(self):
+        """Gets the set of edges in the underlying qubit graph.
+
+        If the underlying qubit graph has not been initialized, emit a UserWarning and return None.
+
+        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 self._graph_qubits is None:
+            self._warn_uninitialized()
+            return None
+
+        return self._graph_qubits.edges
+
+    @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_qubits 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 (self._graph_qubits is None) or (len(self.nodes) == 0)
+
+    @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_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
+
+    def clear(self):
+        """Clears the graph of underlying qubits."""
+        self._graph_qubits = None
path: pennylane/ftqc/qubit_graph.py
user: login: joeycarter
body: No, `clear()` only acts on the underlying graph of the current QubitGraph. If the current QubitGraph still belongs to its parent, then we still want it to track which one is its parent, even if we clear its underlying qubits.

That's a little wordy so here's a diagram :slightly_smiling_face: 

If we start with the following QubitGraph hierarchy:

```
root
 |
 A
 |
(0,0) -- (0,1)
  |        |
(1,0) -- (1,1)
```

and then we call `A.clear()`, we should end up with

```
root
 |
 A
```

After clearing `A`, it still tracks that its parent is `root`.
created_at: 2025-03-04T22:13:15Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,601 @@
+# 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 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:
+        id (Any): An identifier for this QubitGraph object.
+        graph (graph-like, optional): The graph to use as the QubitGraph's underlying qubits.
+            Inputting None (the default), leaves the underlying qubit graph in an uninitialized
+            state, in which case one of the graph-initialization methods may be used to define the
+            structure of the underlying qubit graph. 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. An object is considered "graph-like" if
+            it has both a 'nodes' and an 'edges' attribute.
+
+    ..  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.
path: pennylane/ftqc/qubit_graph.py
user: login: joeycarter
body: I hadn't really considered this as a TODO, and indeed this is already implicitly supported, since we have access to the underlying networkx graph:

```pycon
>>> q = QubitGraph(0)
>>> q.init_graph_nd_grid((2,2))
>>> q.node_labels
NodeView(((0, 0), (0, 1), (1, 0), (1, 1)))
>>> q.graph.add_node("aux")  # Dynamically add an "aux" node here
>>> q.node_labels
NodeView(((0, 0), (0, 1), (1, 0), (1, 1), 'aux'))
>>> q["aux"] = QubitGraph("aux")  # Assign a new QubitGraph to the "aux" node
>>> for child in q.children:
...     print(child)
QubitGraph<0, (0, 0)>
QubitGraph<0, (0, 1)>
QubitGraph<0, (1, 0)>
QubitGraph<0, (1, 1)>
QubitGraph<0, aux>
```

There might be a case to write `QubitGraph.add_node()` and `QubitGraph.add_edge()` methods, to combine the networkx `add_*()` call and assignment operations into one method. I'm not sure how useful this would be, though—I was imagining that any dynamic qubit allocation that we will eventually want to support will mean creating a new QubitGraph object with a known graph structure, rather than dynamically adding nodes to an already-instantiated graph. If we do need this functionality, it should be easy to add.
created_at: 2025-03-04T22:29:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,601 @@
+# 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 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:
+        id (Any): An identifier for this QubitGraph object.
+        graph (graph-like, optional): The graph to use as the QubitGraph's underlying qubits.
+            Inputting None (the default), leaves the underlying qubit graph in an uninitialized
+            state, in which case one of the graph-initialization methods may be used to define the
+            structure of the underlying qubit graph. 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. An object is considered "graph-like" if
+            it has both a 'nodes' and an 'edges' attribute.
+
+    ..  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, id: Any, graph: Optional[nx.Graph] = None):
+        if id is None:
+            raise TypeError("'None' is not a valid QubitGraph ID.")
+
+        self._id = id  # The identifier for this QubitGraph, e.g. a number, string, tuple, etc.
+
+        if graph is not None:
+            self._check_graph_type_supported_and_raise_or_warn(graph)
+
+        self._graph_qubits = graph  # The qubits underlying (nested within) the current qubit
+        self._parent = None  # The parent QubitGraph object under which this QubitGraph is nested
+
+        # Initialize each node in the graph to store an empty QubitGraph object
+        if self._graph_qubits 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_qubits.nodes))
+            return [self._graph_qubits.nodes[node]["qubits"] for node in range(start, stop, step)]
+
+        return self._graph_qubits.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("top", graph)
+            >>> print(f"{q_top}; {q_top.nodes}")
+            QubitGraph<top>; [0]
+            >>> q_new = QubitGraph("new", graph)
+            >>> print(f"{q_new}; {q_new.nodes}")
+            QubitGraph<new>; [0]
+            >>> q_top[0] = q_new
+            >>> print(f"{q_top[0]}; {q_top[0].nodes}")
+            QubitGraph<top, 0>; [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_qubits.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(0)
+            >>> q.init_graph_nd_grid((2,))
+            >>> tuple(q)
+            (QubitGraph<0>,)
+
+        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(0)
+            >>> q.init_graph_nd_grid((2,))
+            >>> tuple(q)
+            (QubitGraph<0, 0>, QubitGraph<0, 1>)
+
+        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(0)
+            QubitGraph<0>
+
+            >>> q = QubitGraph(0)
+            >>> q.init_graph_nd_grid((2, 2))
+            >>> q
+            QubitGraph<0>
+            >>> q[(0, 1)]
+            QubitGraph<0, (0, 1)>
+            >>> graph = nx.Graph()
+            >>> graph.add_node("aux")
+            >>> q[(0, 1)].init_graph(graph)
+            >>> q[(0, 1)]["aux"]
+            QubitGraph<0, (0, 1), aux>
+        """
+        if self.is_root:
+            return f"QubitGraph<{self._id}>"
+
+        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<{self._id}; cyclic>"
+
+        depth_counter = 1
+
+        ids = [self.id]
+        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(parent.id)
+            parent = parent.parent
+
+            depth_counter += 1
+
+        ids_str = ", ".join(str(id) for id in ids[::-1])
+        return f"QubitGraph<{ids_str}>"
+
+    @property
+    def id(self):
+        """Gets the QubitGraph ID."""
+        return self._id
+
+    @property
+    def graph(self):
+        """Gets the underlying qubit graph."""
+        return self._graph_qubits
+
+    @property
+    def nodes(self):
+        """Gets the set of nodes in the underlying qubit graph.
+
+        If the underlying qubit graph has not been initialized, emit a ``UserWarning`` and return
+        None.
+
+        Returns:
+            networkx.NodeView: 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 self._graph_qubits is None:
+            self._warn_uninitialized()
+            return None
+
+        return self._graph_qubits.nodes
+
+    @property
+    def edges(self):
+        """Gets the set of edges in the underlying qubit graph.
+
+        If the underlying qubit graph has not been initialized, emit a UserWarning and return None.
+
+        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 self._graph_qubits is None:
+            self._warn_uninitialized()
+            return None
+
+        return self._graph_qubits.edges
+
+    @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_qubits 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 (self._graph_qubits is None) or (len(self.nodes) == 0)
+
+    @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_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
+
+    def clear(self):
+        """Clears the graph of underlying qubits."""
+        self._graph_qubits = None
+
+    def connected_qubits(self, node):
+        """Returns an iterator over all of the qubits connected to the qubit with label ``node``.
path: pennylane/ftqc/qubit_graph.py
user: login: joeycarter
body: I like this idea, done in ce36cf576.

I changed the name `connected_qubits` to `neighbors` for simplicity. I'll add an example to the docstring.
created_at: 2025-03-05T16:31:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,601 @@
+# 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 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:
+        id (Any): An identifier for this QubitGraph object.
+        graph (graph-like, optional): The graph to use as the QubitGraph's underlying qubits.
+            Inputting None (the default), leaves the underlying qubit graph in an uninitialized
+            state, in which case one of the graph-initialization methods may be used to define the
+            structure of the underlying qubit graph. 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. An object is considered "graph-like" if
+            it has both a 'nodes' and an 'edges' attribute.
+
+    ..  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, id: Any, graph: Optional[nx.Graph] = None):
path: pennylane/ftqc/qubit_graph.py
user: login: joeycarter
body: That's a good point! My thinking was that the ID of a QubitGraph acts in the same way as a wire label, which the user (or some graph-initialization method) should provide. Having an ID allows two otherwise identical QubitGraph objects (e.g. with identical graph structures) to be distinguishable so that they may be used as wires. I had thought of using `uuid` but decided against it because that would result in random IDs, which would be difficult for the user to keep track of.

It was also convenient to design QubitGraph such that a nested QubitGraph's ID is equal to the label of the node in the parent graph that it belongs to. For example, if we have a QubitGraph labelled `root1` containing a 1D chain of nodes labelled 0, 1, 2, ..., then the ID of each child QubitGraph is equal to the label of the node it belongs to, 0, 1, 2, .... The full ID hierarchy gives a unique identifier for every QubitGraph in a circuit, even if multiple QubitGraphs at a single layer in the hierarchy are identical. In this example they would be `<root1, 0>`, `<root1, 1>`, ... Under another parent QubitGraph labelled `root2`, the same 1D chain would be indexed as `<root2, 0>`, `<root2, 1>`, ...

This indexing scheme is convenient for a couple of reasons. First, it makes simple bookkeeping much more intuitive when the node labels and IDs match (e.g. if `q` has ID 0, `q[1]` will return a QubitGraph with ID 1, with full index <0, 1>). Second, it makes it possible to easily determine which node in a graph a QubitGraph belongs to. That might sound obvious, but that simplifies operations like accessing the neighbours of a QubitGraph, since that operation requires going up to the parent node and calling the networkx `neighbors` method with a node label as input (that node label is just the ID of the current QubitGraph).

As for the issue with constructing a QubitGraph with `QubitGraph(nx_graph)`, I agree there should be some hint to the user that this will result in behaviour that they probably did not intend. I can add a warning for when the ID type is graph-like that this is probably not what the user intended and that if they input a graph, they must also input an ID.
created_at: 2025-03-05T19:31:01Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,601 @@
+# 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 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:
+        id (Any): An identifier for this QubitGraph object.
+        graph (graph-like, optional): The graph to use as the QubitGraph's underlying qubits.
+            Inputting None (the default), leaves the underlying qubit graph in an uninitialized
+            state, in which case one of the graph-initialization methods may be used to define the
+            structure of the underlying qubit graph. 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. An object is considered "graph-like" if
+            it has both a 'nodes' and an 'edges' attribute.
+
+    ..  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, id: Any, graph: Optional[nx.Graph] = None):
+        if id is None:
+            raise TypeError("'None' is not a valid QubitGraph ID.")
+
+        self._id = id  # The identifier for this QubitGraph, e.g. a number, string, tuple, etc.
+
+        if graph is not None:
+            self._check_graph_type_supported_and_raise_or_warn(graph)
+
+        self._graph_qubits = graph  # The qubits underlying (nested within) the current qubit
+        self._parent = None  # The parent QubitGraph object under which this QubitGraph is nested
+
+        # Initialize each node in the graph to store an empty QubitGraph object
+        if self._graph_qubits 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_qubits.nodes))
+            return [self._graph_qubits.nodes[node]["qubits"] for node in range(start, stop, step)]
+
+        return self._graph_qubits.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("top", graph)
+            >>> print(f"{q_top}; {q_top.nodes}")
+            QubitGraph<top>; [0]
+            >>> q_new = QubitGraph("new", graph)
+            >>> print(f"{q_new}; {q_new.nodes}")
+            QubitGraph<new>; [0]
+            >>> q_top[0] = q_new
+            >>> print(f"{q_top[0]}; {q_top[0].nodes}")
+            QubitGraph<top, 0>; [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_qubits.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(0)
+            >>> q.init_graph_nd_grid((2,))
+            >>> tuple(q)
+            (QubitGraph<0>,)
+
+        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(0)
+            >>> q.init_graph_nd_grid((2,))
+            >>> tuple(q)
+            (QubitGraph<0, 0>, QubitGraph<0, 1>)
+
+        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.
path: pennylane/ftqc/qubit_graph.py
user: login: joeycarter
body: Just so I understand, you're saying that if we have a QubitGraph structured as, for example,

```
            (root)
              |
  (a -------- b -------- c)
   |          |          |
(0 -- 1)   (2 -- 3)   (4 -- 5)
```

then given the `root` node, we should be able to iterate over nodes `0, 1, 2, 3, 4, 5` (effectively skipping over the intermediate layer with nodes `a, b, c`)? If so then yes, that should be doable.
created_at: 2025-03-05T19:38:19Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,601 @@
+# 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 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:
+        id (Any): An identifier for this QubitGraph object.
+        graph (graph-like, optional): The graph to use as the QubitGraph's underlying qubits.
+            Inputting None (the default), leaves the underlying qubit graph in an uninitialized
+            state, in which case one of the graph-initialization methods may be used to define the
+            structure of the underlying qubit graph. 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. An object is considered "graph-like" if
+            it has both a 'nodes' and an 'edges' attribute.
+
+    ..  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, id: Any, graph: Optional[nx.Graph] = None):
+        if id is None:
+            raise TypeError("'None' is not a valid QubitGraph ID.")
+
+        self._id = id  # The identifier for this QubitGraph, e.g. a number, string, tuple, etc.
+
+        if graph is not None:
+            self._check_graph_type_supported_and_raise_or_warn(graph)
+
+        self._graph_qubits = graph  # The qubits underlying (nested within) the current qubit
+        self._parent = None  # The parent QubitGraph object under which this QubitGraph is nested
+
+        # Initialize each node in the graph to store an empty QubitGraph object
+        if self._graph_qubits 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_qubits.nodes))
+            return [self._graph_qubits.nodes[node]["qubits"] for node in range(start, stop, step)]
+
+        return self._graph_qubits.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("top", graph)
+            >>> print(f"{q_top}; {q_top.nodes}")
+            QubitGraph<top>; [0]
+            >>> q_new = QubitGraph("new", graph)
+            >>> print(f"{q_new}; {q_new.nodes}")
+            QubitGraph<new>; [0]
+            >>> q_top[0] = q_new
+            >>> print(f"{q_top[0]}; {q_top[0].nodes}")
+            QubitGraph<top, 0>; [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_qubits.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(0)
+            >>> q.init_graph_nd_grid((2,))
+            >>> tuple(q)
+            (QubitGraph<0>,)
+
+        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(0)
+            >>> q.init_graph_nd_grid((2,))
+            >>> tuple(q)
+            (QubitGraph<0, 0>, QubitGraph<0, 1>)
+
+        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(0)
+            QubitGraph<0>
+
+            >>> q = QubitGraph(0)
+            >>> q.init_graph_nd_grid((2, 2))
+            >>> q
+            QubitGraph<0>
+            >>> q[(0, 1)]
+            QubitGraph<0, (0, 1)>
+            >>> graph = nx.Graph()
+            >>> graph.add_node("aux")
+            >>> q[(0, 1)].init_graph(graph)
+            >>> q[(0, 1)]["aux"]
+            QubitGraph<0, (0, 1), aux>
path: pennylane/ftqc/qubit_graph.py
user: login: joeycarter
body: Yes I think we can improve this representation. For example, in this case the ID of the QubitGraph at `q[(0, 1)]["aux"]` is actually "aux":

```pycon
>>> q = QubitGraph(0)
>>> q.init_graph_nd_grid((2, 2))
>>> graph = nx.Graph()
>>> graph.add_node("aux")
>>> q[(0, 1)].init_graph(graph)
>>> q[(0, 1)]["aux"].id
'aux'
```

So if that wasn't obvious we should definitely change it! :sweat_smile:

Maybe this would be a better representation:

```pycon
>>> q
QubitGraph<id=0, loc=root>
```

which would be read as "this is QubitGraph 0 and it is a root node". And then:

```pycon
>>> q[(0, 1)]["aux"]
QubitGraph<id='aux', loc=[(0, 1), 0]>
```

which would be read as "this is QubitGraph 'aux', whose parent is the node with label '(0, 1)', whose parent is node with label '0', which is a root node."
created_at: 2025-03-05T19:51:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,601 @@
+# 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 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:
+        id (Any): An identifier for this QubitGraph object.
+        graph (graph-like, optional): The graph to use as the QubitGraph's underlying qubits.
+            Inputting None (the default), leaves the underlying qubit graph in an uninitialized
+            state, in which case one of the graph-initialization methods may be used to define the
+            structure of the underlying qubit graph. 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. An object is considered "graph-like" if
+            it has both a 'nodes' and an 'edges' attribute.
+
+    ..  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, id: Any, graph: Optional[nx.Graph] = None):
path: pennylane/ftqc/qubit_graph.py
user: login: joeycarter
body: Added a warning for this case in 3aaa999ec.
created_at: 2025-03-05T20:44:18Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,601 @@
+# 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 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:
+        id (Any): An identifier for this QubitGraph object.
+        graph (graph-like, optional): The graph to use as the QubitGraph's underlying qubits.
+            Inputting None (the default), leaves the underlying qubit graph in an uninitialized
+            state, in which case one of the graph-initialization methods may be used to define the
+            structure of the underlying qubit graph. 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. An object is considered "graph-like" if
+            it has both a 'nodes' and an 'edges' attribute.
+
+    ..  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, id: Any, graph: Optional[nx.Graph] = None):
path: pennylane/ftqc/qubit_graph.py
user: login: joeycarter
body: I was thinking more about this last night, I think there's a use case for not requiring the user to provide the ID in the constructor, and that is assigning a new QubitGraph to a node in an existing QubitGraph. To keep the node label <-> ID equivalence intact, assigning a QubitGraph `child` to a node of another QubitGraph `parent` effectively means `parent` taking ownership of  `child`, and one of the things it does is assigns the node label to `child`'s ID. So, you can imagine constructing a new QubitGraph with ID `'aux'` and assigning it to a node with label `0`. The assignment operation then immediately overwrites the ID of `child` to be `0` so that the node label and ID agree:

```pycon
>>> q = QubitGraph(0)
>>> q.init_graph_nd_grid((1,))
>>> list(q.children)
[QubitGraph<0, 0>]
>>> q[0] = QubitGraph(id='aux')  # Initialize ID to be 'aux'
>>> q[0].id  # But now ID is 0 after assignment
0
```

Given this use case, I think it makes sense to allow the `id` parameter in the QubitGraph constructor to be None, in which case it assigns a random ID (e.g. with `uuid.uuid4()`). For top-level qubits, if the user doesn't want random IDs, they can always specify one when constructing it.
created_at: 2025-03-06T14:38:24Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,601 @@
+# 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 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:
+        id (Any): An identifier for this QubitGraph object.
+        graph (graph-like, optional): The graph to use as the QubitGraph's underlying qubits.
+            Inputting None (the default), leaves the underlying qubit graph in an uninitialized
+            state, in which case one of the graph-initialization methods may be used to define the
+            structure of the underlying qubit graph. 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. An object is considered "graph-like" if
+            it has both a 'nodes' and an 'edges' attribute.
+
+    ..  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, id: Any, graph: Optional[nx.Graph] = None):
path: pennylane/ftqc/qubit_graph.py
user: login: joeycarter
body: New QubitGraph UI in 5ff29f481.
created_at: 2025-03-06T20:30:04Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,601 @@
+# 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 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:
+        id (Any): An identifier for this QubitGraph object.
+        graph (graph-like, optional): The graph to use as the QubitGraph's underlying qubits.
+            Inputting None (the default), leaves the underlying qubit graph in an uninitialized
+            state, in which case one of the graph-initialization methods may be used to define the
+            structure of the underlying qubit graph. 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. An object is considered "graph-like" if
+            it has both a 'nodes' and an 'edges' attribute.
+
+    ..  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, id: Any, graph: Optional[nx.Graph] = None):
+        if id is None:
+            raise TypeError("'None' is not a valid QubitGraph ID.")
+
+        self._id = id  # The identifier for this QubitGraph, e.g. a number, string, tuple, etc.
+
+        if graph is not None:
+            self._check_graph_type_supported_and_raise_or_warn(graph)
+
+        self._graph_qubits = graph  # The qubits underlying (nested within) the current qubit
+        self._parent = None  # The parent QubitGraph object under which this QubitGraph is nested
+
+        # Initialize each node in the graph to store an empty QubitGraph object
+        if self._graph_qubits 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_qubits.nodes))
+            return [self._graph_qubits.nodes[node]["qubits"] for node in range(start, stop, step)]
+
+        return self._graph_qubits.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("top", graph)
+            >>> print(f"{q_top}; {q_top.nodes}")
+            QubitGraph<top>; [0]
+            >>> q_new = QubitGraph("new", graph)
+            >>> print(f"{q_new}; {q_new.nodes}")
+            QubitGraph<new>; [0]
+            >>> q_top[0] = q_new
+            >>> print(f"{q_top[0]}; {q_top[0].nodes}")
+            QubitGraph<top, 0>; [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_qubits.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(0)
+            >>> q.init_graph_nd_grid((2,))
+            >>> tuple(q)
+            (QubitGraph<0>,)
+
+        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(0)
+            >>> q.init_graph_nd_grid((2,))
+            >>> tuple(q)
+            (QubitGraph<0, 0>, QubitGraph<0, 1>)
+
+        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(0)
+            QubitGraph<0>
+
+            >>> q = QubitGraph(0)
+            >>> q.init_graph_nd_grid((2, 2))
+            >>> q
+            QubitGraph<0>
+            >>> q[(0, 1)]
+            QubitGraph<0, (0, 1)>
+            >>> graph = nx.Graph()
+            >>> graph.add_node("aux")
+            >>> q[(0, 1)].init_graph(graph)
+            >>> q[(0, 1)]["aux"]
+            QubitGraph<0, (0, 1), aux>
path: pennylane/ftqc/qubit_graph.py
user: login: joeycarter
body: Done. 03edcb84a
created_at: 2025-03-06T21:32:30Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,601 @@
+# 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 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:
+        id (Any): An identifier for this QubitGraph object.
+        graph (graph-like, optional): The graph to use as the QubitGraph's underlying qubits.
+            Inputting None (the default), leaves the underlying qubit graph in an uninitialized
+            state, in which case one of the graph-initialization methods may be used to define the
+            structure of the underlying qubit graph. 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. An object is considered "graph-like" if
+            it has both a 'nodes' and an 'edges' attribute.
+
+    ..  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, id: Any, graph: Optional[nx.Graph] = None):
+        if id is None:
+            raise TypeError("'None' is not a valid QubitGraph ID.")
+
+        self._id = id  # The identifier for this QubitGraph, e.g. a number, string, tuple, etc.
+
+        if graph is not None:
+            self._check_graph_type_supported_and_raise_or_warn(graph)
+
+        self._graph_qubits = graph  # The qubits underlying (nested within) the current qubit
+        self._parent = None  # The parent QubitGraph object under which this QubitGraph is nested
+
+        # Initialize each node in the graph to store an empty QubitGraph object
+        if self._graph_qubits 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_qubits.nodes))
+            return [self._graph_qubits.nodes[node]["qubits"] for node in range(start, stop, step)]
+
+        return self._graph_qubits.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("top", graph)
+            >>> print(f"{q_top}; {q_top.nodes}")
+            QubitGraph<top>; [0]
+            >>> q_new = QubitGraph("new", graph)
+            >>> print(f"{q_new}; {q_new.nodes}")
+            QubitGraph<new>; [0]
+            >>> q_top[0] = q_new
+            >>> print(f"{q_top[0]}; {q_top[0].nodes}")
+            QubitGraph<top, 0>; [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_qubits.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(0)
+            >>> q.init_graph_nd_grid((2,))
+            >>> tuple(q)
+            (QubitGraph<0>,)
+
+        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(0)
+            >>> q.init_graph_nd_grid((2,))
+            >>> tuple(q)
+            (QubitGraph<0, 0>, QubitGraph<0, 1>)
+
+        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.
path: pennylane/ftqc/qubit_graph.py
user: login: lillian542
body: That was pretty much what I meant, but now that I've thought about it more, I don't think there's an immediate use case for this - for now, it makes sense that operators that are given a QubitGraph either treat it as a wire label, or we give them special instructions for how to handle it (like we will with `GraphStatePrep`). 

So maybe we want this later, but I don't think we need to worry about it now.
created_at: 2025-03-07T16:12:50Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,817 @@
+# 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. Inputting None (the default), leaves the underlying qubit graph in an
+            uninitialized state, in which case one of the graph-initialization methods may be used
+            to define the structure of the underlying qubit graph. 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. An object is considered
+            "graph-like" if it has both a 'nodes' and an 'edges' attribute. Furthermore, QubitGraph
+            expects a "flat" (unnested) graph, and any graph-like objects nested within the graph
+            structure used as input are ignored.
+        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``.
+
+    **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)] = QubitGraph(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.
+
+    **How to Read the QubitGraph String Representation**
path: pennylane/ftqc/qubit_graph.py
user: login: lillian542
body: We usually include additional sections in the docs after the "core" example as collapsable sections

```suggestion
    .. details::
        :title: How to Read the QubitGraph String Representation
```
created_at: 2025-03-07T16:30:39Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,817 @@
+# 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. Inputting None (the default), leaves the underlying qubit graph in an
+            uninitialized state, in which case one of the graph-initialization methods may be used
+            to define the structure of the underlying qubit graph. 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. An object is considered
+            "graph-like" if it has both a 'nodes' and an 'edges' attribute. Furthermore, QubitGraph
+            expects a "flat" (unnested) graph, and any graph-like objects nested within the graph
+            structure used as input are ignored.
path: pennylane/ftqc/qubit_graph.py
user: login: lillian542
body: This is a lot of info in one chunk, and if people are like me, they are lazy and will skim it and miss things. Could we reduce it to the core information and then add the details to a couple of `.. warning::` or `..note::` boxes? I.e. something like:

```suggestion
        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.
```
                
plus a `note` or `warning` box highlighting that the nested graphs in the graph will be ignored, and a separate one highlighting that "QubitGraph expects an undirected graph as input, specifically an instance of the ``networkx.Graph`` class, and that other graph inputs will be accepted, but may result in unexpected behaviour".

With regards to leaving a graph in an unitialized state and then using a graph-initialization method, we could add another details section further down and have an example of that there, instead of mentioning it in the argument description of the docstring.
created_at: 2025-03-07T17:35:46Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,817 @@
+# 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. Inputting None (the default), leaves the underlying qubit graph in an
+            uninitialized state, in which case one of the graph-initialization methods may be used
+            to define the structure of the underlying qubit graph. 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. An object is considered
+            "graph-like" if it has both a 'nodes' and an 'edges' attribute. Furthermore, QubitGraph
+            expects a "flat" (unnested) graph, and any graph-like objects nested within the graph
+            structure used as input are ignored.
+        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``.
+
+    **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)] = QubitGraph(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.
+
+    **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 32-digit hexadecimal number:
+
+    >>> q.id
+    '7491161c-ca7e-42cc-af3e-8ca6250a370e'
+
path: pennylane/ftqc/qubit_graph.py
user: login: lillian542
body: ```suggestion

    .. details:: 
        :title: Initializing a Blank QubitGraph
        
        If a QubitGraph is created with no graph object, a graph-initialization method may be used ... [etc]
        
```
created_at: 2025-03-07T17:38:40Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,817 @@
+# 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. Inputting None (the default), leaves the underlying qubit graph in an
+            uninitialized state, in which case one of the graph-initialization methods may be used
+            to define the structure of the underlying qubit graph. 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. An object is considered
+            "graph-like" if it has both a 'nodes' and an 'edges' attribute. Furthermore, QubitGraph
+            expects a "flat" (unnested) graph, and any graph-like objects nested within the graph
+            structure used as input are ignored.
+        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``.
+
+    **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)] = QubitGraph(nx.grid_graph((2,)))
path: pennylane/ftqc/qubit_graph.py
user: login: lillian542
body: Can we change the "advertised UI" for this to

```suggestion
    >>> q[(0,0)].init_graph(nx.grid_graph((2,)))
```

My reasoning for this is that while the above works fine for individually indexing into nodes via their labels, if you do:

```
for child in q0.children:
    child = QubitGraph(nx.grid_graph((2,)))
```
 nothing is updated (which I think makes sense, I'm not advocating changing the behaviour there). So it seems more versatile to promote using
 
 ```
for child in q0.children:
    child.init_graph(nx.grid_graph((2,)))
```
created_at: 2025-03-07T18:02:56Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,817 @@
+# 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. Inputting None (the default), leaves the underlying qubit graph in an
+            uninitialized state, in which case one of the graph-initialization methods may be used
+            to define the structure of the underlying qubit graph. 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. An object is considered
+            "graph-like" if it has both a 'nodes' and an 'edges' attribute. Furthermore, QubitGraph
+            expects a "flat" (unnested) graph, and any graph-like objects nested within the graph
+            structure used as input are ignored.
+        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``.
+
+    **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)] = QubitGraph(nx.grid_graph((2,)))
path: pennylane/ftqc/qubit_graph.py
user: login: joeycarter
body: That's a good find, thanks for pointing this out. I agree, we should advertise using `init_graph()` for nesting over assignment of a new QubitGraph. 
created_at: 2025-03-07T18:48:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,817 @@
+# 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. Inputting None (the default), leaves the underlying qubit graph in an
+            uninitialized state, in which case one of the graph-initialization methods may be used
+            to define the structure of the underlying qubit graph. 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. An object is considered
+            "graph-like" if it has both a 'nodes' and an 'edges' attribute. Furthermore, QubitGraph
+            expects a "flat" (unnested) graph, and any graph-like objects nested within the graph
+            structure used as input are ignored.
+        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``.
+
+    **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)] = QubitGraph(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.
+
+    **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 32-digit hexadecimal number:
+
+    >>> q.id
+    '7491161c-ca7e-42cc-af3e-8ca6250a370e'
+
path: pennylane/ftqc/qubit_graph.py
user: login: joeycarter
body: Done: 6f5f8c017

I also added a section on "Traversing the Hierarchical Graph Structure", since I think this would be useful as well.
created_at: 2025-03-07T19:29:52Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,817 @@
+# 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. Inputting None (the default), leaves the underlying qubit graph in an
+            uninitialized state, in which case one of the graph-initialization methods may be used
+            to define the structure of the underlying qubit graph. 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. An object is considered
+            "graph-like" if it has both a 'nodes' and an 'edges' attribute. Furthermore, QubitGraph
+            expects a "flat" (unnested) graph, and any graph-like objects nested within the graph
+            structure used as input are ignored.
path: pennylane/ftqc/qubit_graph.py
user: login: joeycarter
body: Good suggestion, this is done in 818afb2b3.
created_at: 2025-03-07T19:52:53Z
author_association: CONTRIBUTOR

PR comments:
user: login: codecov[bot]
created_at: 2025-02-19T21:47:04Z
body: ## [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.61%. Comparing base [(`51484c7`)](https://app.codecov.io/gh/PennyLaneAI/pennylane/commit/51484c7c283c1a05b80d554b50da720419a35ebf?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennyLaneAI) to head [(`65a399e`)](https://app.codecov.io/gh/PennyLaneAI/pennylane/commit/65a399eb2b62033b68e33b4282d36597d7e55fc3?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennyLaneAI).
> Report is 352 commits behind head on master.

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


```diff
@@           Coverage Diff            @@
##           master    #6962    +/-   ##
========================================
  Coverage   99.60%   99.61%            
========================================
  Files         488      489     +1     
  Lines       47027    47265   +238     
========================================
+ Hits        46843    47081   +238     
  Misses        184      184            
```

</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).

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

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
</details>
author_association: NONE
user: login: joeycarter
created_at: 2025-03-10T14:52:49Z
body: 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/).
author_association: CONTRIBUTOR
user: login: mlxd
created_at: 2025-03-10T16:23:00Z
body: > 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.
author_association: MEMBER
user: login: josh146
created_at: 2025-03-10T16:52:56Z
body: The convention I tried to set early on in the PL docs is:

- Always use backticks to refer to code objects (classes, functions, variables)
- 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, depending on if you are talking about the object or the concept.
- Plain text for package names (NumPy, Python, NetworkX).

Convention might have drifted with time/a lot more docs authors
author_association: MEMBER
user: login: joeycarter
created_at: 2025-03-10T18:20:31Z
body: 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.
author_association: CONTRIBUTOR