From 253d236279c9088dddea88c137126f7f777ab34e Mon Sep 17 00:00:00 2001 From: mmalekian Date: Tue, 7 Apr 2026 22:02:24 +0000 Subject: [PATCH] Add topology_utils --- dwave/graphs/generators/__init__.py | 1 + dwave/graphs/generators/chimera.py | 2 +- dwave/graphs/generators/common/__init__.py | 26 + .../graphs/generators/{ => common}/common.py | 28 +- dwave/graphs/generators/common/coord.py | 133 ++++ dwave/graphs/generators/common/node_edge.py | 703 ++++++++++++++++++ dwave/graphs/generators/common/planeshift.py | 112 +++ dwave/graphs/generators/common/shape.py | 153 ++++ dwave/graphs/generators/common/topology.py | 138 ++++ dwave/graphs/generators/pegasus.py | 2 +- dwave/graphs/generators/zephyr/__init__.py | 21 + dwave/graphs/generators/zephyr/zcoord.py | 352 +++++++++ .../graphs/generators/{ => zephyr}/zephyr.py | 571 ++++++++++---- dwave/graphs/generators/zephyr/znode_edge.py | 439 +++++++++++ dwave/graphs/generators/zephyr/zplaneshift.py | 52 ++ dwave/graphs/generators/zephyr/zshape.py | 96 +++ dwave/graphs/tuplelike.py | 51 ++ pyproject.toml | 1 - tests/test_generator_zephyr.py | 22 + tests/test_zcoord.py | 245 ++++++ tests/test_znode_edge.py | 322 ++++++++ tests/test_zplaneshift.py | 91 +++ tests/test_zshape.py | 76 ++ 23 files changed, 3471 insertions(+), 166 deletions(-) create mode 100755 dwave/graphs/generators/common/__init__.py rename dwave/graphs/generators/{ => common}/common.py (54%) create mode 100755 dwave/graphs/generators/common/coord.py create mode 100755 dwave/graphs/generators/common/node_edge.py create mode 100755 dwave/graphs/generators/common/planeshift.py create mode 100755 dwave/graphs/generators/common/shape.py create mode 100755 dwave/graphs/generators/common/topology.py create mode 100755 dwave/graphs/generators/zephyr/__init__.py create mode 100755 dwave/graphs/generators/zephyr/zcoord.py rename dwave/graphs/generators/{ => zephyr}/zephyr.py (66%) create mode 100755 dwave/graphs/generators/zephyr/znode_edge.py create mode 100755 dwave/graphs/generators/zephyr/zplaneshift.py create mode 100755 dwave/graphs/generators/zephyr/zshape.py create mode 100755 dwave/graphs/tuplelike.py create mode 100755 tests/test_zcoord.py create mode 100755 tests/test_znode_edge.py create mode 100755 tests/test_zplaneshift.py create mode 100755 tests/test_zshape.py diff --git a/dwave/graphs/generators/__init__.py b/dwave/graphs/generators/__init__.py index cf8b8025..1dc8d214 100644 --- a/dwave/graphs/generators/__init__.py +++ b/dwave/graphs/generators/__init__.py @@ -16,3 +16,4 @@ from dwave.graphs.generators.markov import markov_network from dwave.graphs.generators.pegasus import * from dwave.graphs.generators.zephyr import * +from dwave.graphs.generators.common import * diff --git a/dwave/graphs/generators/chimera.py b/dwave/graphs/generators/chimera.py index d13ffa91..4f663b02 100644 --- a/dwave/graphs/generators/chimera.py +++ b/dwave/graphs/generators/chimera.py @@ -22,7 +22,7 @@ from networkx.algorithms.bipartite import color from networkx import diameter -from .common import _add_compatible_nodes, _add_compatible_edges, _add_compatible_terms +from dwave.graphs.generators.common import _add_compatible_nodes, _add_compatible_edges, _add_compatible_terms __all__ = ['chimera_graph', 'chimera_coordinates', diff --git a/dwave/graphs/generators/common/__init__.py b/dwave/graphs/generators/common/__init__.py new file mode 100755 index 00000000..5af34e04 --- /dev/null +++ b/dwave/graphs/generators/common/__init__.py @@ -0,0 +1,26 @@ +# Copyright 2026 D-Wave +# +# 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. +# +# ================================================================================================ + +from dwave.graphs.generators.common.common import ( + _add_compatible_edges, + _add_compatible_nodes, + _add_compatible_terms, +) +from dwave.graphs.generators.common.coord import * +from dwave.graphs.generators.common.node_edge import * +from dwave.graphs.generators.common.planeshift import * +from dwave.graphs.generators.common.shape import * +from dwave.graphs.generators.common.topology import * diff --git a/dwave/graphs/generators/common.py b/dwave/graphs/generators/common/common.py similarity index 54% rename from dwave/graphs/generators/common.py rename to dwave/graphs/generators/common/common.py index 5855a575..41a73eaa 100644 --- a/dwave/graphs/generators/common.py +++ b/dwave/graphs/generators/common/common.py @@ -1,3 +1,19 @@ +# Copyright 2026 D-Wave +# +# 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. +# +# ================================================================================================ + def _add_compatible_edges(G, edge_list): # Check edge_list defines a subgraph of G and create subgraph. @@ -9,7 +25,8 @@ def _add_compatible_edges(G, edge_list): G.remove_edges_from(list(G.edges)) G.add_edges_from(edge_list) if G.number_of_edges() < len(edge_list): - raise ValueError('edge_list contains duplicates.') + raise ValueError("edge_list contains duplicates.") + def _add_compatible_nodes(G, node_list): if node_list is not None: @@ -19,11 +36,12 @@ def _add_compatible_nodes(G, node_list): remove_nodes = set(G) - nodes G.remove_nodes_from(remove_nodes) if G.number_of_nodes() < len(node_list): - raise ValueError('node_list contains duplicates.') - + raise ValueError("node_list contains duplicates.") + + def _add_compatible_terms(G, node_list, edge_list): _add_compatible_edges(G, edge_list) _add_compatible_nodes(G, node_list) - #Check node deletion hasn't caused edge deletion: + # Check node deletion hasn't caused edge deletion: if edge_list is not None and len(edge_list) != G.number_of_edges(): - raise ValueError('The edge_list contains nodes absent from the node_list') + raise ValueError("The edge_list contains nodes absent from the node_list") diff --git a/dwave/graphs/generators/common/coord.py b/dwave/graphs/generators/common/coord.py new file mode 100755 index 00000000..45550154 --- /dev/null +++ b/dwave/graphs/generators/common/coord.py @@ -0,0 +1,133 @@ +# Copyright 2026 D-Wave +# +# 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. +# +# ================================================================================================ + + +from __future__ import annotations + +from enum import Enum +from functools import total_ordering + +from dwave.graphs.generators.common.shape import TopologyShape +from dwave.graphs.tuplelike import TupleLike + +__all__ = ["Coord", "CoordKind"] + + +class CoordKind(Enum): # Kinds of coordinates of nodes in topologies + CARTESIAN = 0 + TOPOLOGY = 1 + + +@total_ordering +class Coord(TupleLike): + """A class to represent the coordinate of a topology node.""" + + __hash__ = TupleLike.__hash__ + + @classmethod + def topology_name(cls) -> str: + """Returns the name of the topology associated with the class. + + Raises: + NotImplementedError: To be implemented in subclasses for + specific topologies. + + Returns: + str: The name of the topology the class is designed for. + """ + raise NotImplementedError + + @classmethod + def kind(cls) -> CoordKind: + """Returns the kind of the coordinate associated with the class. + + Raises: + NotImplementedError: To be implemented in subclasses for + specific coordinates. + + Returns: + CoordKind: The kind of class's coordinate. + """ + raise NotImplementedError + + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + + def _args_valid_topology(self, *args, **kwargs) -> None: + """Verifies the given coordinate is a valid coordinate.""" + raise NotImplementedError + + def is_shape_consistent(self, shape: TopologyShape) -> bool: + """Tells whether the coordinate is consistent with a topology shape. + + Args: + shape: The shape to check the consistency of the coordinate with. + + Returns: + bool: Whether the coordinate is consistent with the shape. + """ + raise NotImplementedError + + def is_quotient(self) -> bool: + """Whether the given coordinate is a quotient coordinate.""" + raise NotImplementedError + + def to_quotient( + self, + ) -> Coord: + """Converts the coordinate to its corresponding coordinate in a quotient graph.""" + raise NotImplementedError + + def to_non_quotient( + self, + shape: TopologyShape, + **kwargs, + ) -> list[Coord]: + """Expands the coordinate to a non-quotient shape; i.e. it gives + all coordinates in a non-quotient graph whose quotient is the coordinate. + + Args: + shape: The non-quotient shape to expand the coordinate to. + + Returns: + list[Coord]: The expansion of the coordinate into non-quotient. + """ + raise NotImplementedError + + def convert(self, coord_kind: CoordKind) -> Coord: + """Converts the coordinate to other kinds of coordinate in the same topology. + + Args: + coord_kind (CoordKind): The coordinate kind to convert the coordinate to. + + Raises: + NotImplementedError: To be implemented in subclasses for + specific coordinates. + + Returns: + Coord: The converted coordinate. + """ + raise NotImplementedError + + def __eq__(self, other: object) -> bool: + if (type(self) is not type(other)) or (self.is_quotient() != other.is_quotient()): + return NotImplemented + return self._tuple_format == other._tuple_format + + def __lt__(self, other: object) -> bool: + if (type(self) is not type(other)) or (self.is_quotient() != other.is_quotient()): + return NotImplemented + return self._tuple_format < other._tuple_format diff --git a/dwave/graphs/generators/common/node_edge.py b/dwave/graphs/generators/common/node_edge.py new file mode 100755 index 00000000..378eb106 --- /dev/null +++ b/dwave/graphs/generators/common/node_edge.py @@ -0,0 +1,703 @@ +# Copyright 2026 D-Wave +# +# 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. +# +# ================================================================================================ + + +from __future__ import annotations + +from enum import Enum +from functools import total_ordering +from typing import Any, Callable, ClassVar, Generator, Iterable, Type + +from dwave.graphs.generators.common.coord import Coord, CoordKind +from dwave.graphs.generators.common.planeshift import TopologyPlaneShift +from dwave.graphs.generators.common.shape import TopologyShape, _Infinite, _Quotient + +# pyright: reportIncompatibleMethodOverride=false +# pyright: reportArgumentType=false +# pyright: reportReturnType=false +# mypy: disable-error-code=override +__all__ = [ + "NodeKind", + "TopologyNode", + "EdgeKind", + "Edge", + "TopologyEdge", + "NeighborContributorMixin", + "HasInternalNeighborsMixin", + "HasExternalNeighborsMixin", + "HasOddNeighborsMixin", +] + + +class NodeKind(Enum): # Kinds of nodes in D-Wave topologies + VERTICAL = 0 + HORIZONTAL = 1 + + +class EdgeKind(Enum): # Kinds of edges in D-Wave topologies + INVALID = 0 # to represent non-valid edges in a given topology + INTERNAL = 1 + EXTERNAL = 2 + ODD = 3 + + +# Note to developer: +# Any future additional EdgeKind requires its own corresponding +# subclass of :class:`NeighborContributorMixin` which has +# a method that generates neighbors of that kind. +# The EdgeKind togther with name of that method should be included in the dictionary. +EDGE_KINDS_CONTRIBUTOR_MAP = { + EdgeKind.INTERNAL: "internal_neighbors", + EdgeKind.EXTERNAL: "external_neighbors", + EdgeKind.ODD: "odd_neighbors", +} + + +class Edge: + """Represents an edge of a graph in a canonical order. + + Args: + x: One endpoint of edge. + y: Another endpoint of edge. + + ..note:: ``x`` and ``y`` must be mutually comparable. + """ + + def __init__(self, x: Any, y: Any) -> None: + self._edge = self._set_edge(x, y) + + def _set_edge(self, x: Any, y: Any) -> tuple[Any, Any]: + """Returns a canonical representation of the edge between x, y. + + Args: + x: One endpoint of edge. + y: Another endpoint of edge. + + Raises: + NotImplementedError: To be implemented in the subclasses. + + Returns: + tuple[Any, Any]: A canonical representation of the edge between x, y. + """ + raise NotImplementedError + + def __hash__(self) -> int: + return hash(self._edge) + + def __getitem__(self, index: int) -> int: + return self._edge[index] + + def __eq__(self, other: object) -> bool: + if not type(self) is type(other): + return NotImplemented + return self._edge == other._edge + + def __str__(self) -> str: + return "(" + str(self._edge[0]) + ", " + str(self._edge[1]) + ")" + + def __repr__(self) -> str: + return f"{type(self).__name__}{self._edge}" + + +class TopologyEdge(Edge): + """A blueprint class to represent an edge of a D-Wave's topology graph in a canonical order. + + Args: + x: One endpoint of edge. + y: Another endpoint of edge. + check_edge_valid: + Flag to check whether the edge is a valid edge in the topology. Defaults to ``True``. + + Raises: + TypeError: If an endpoint of edge does not belong to the + topology node class associated with this class. + """ + + associated_topology_node: ClassVar[Type[TopologyNode]] + + @classmethod + def topology_name(cls) -> str: + """Returns the name of the topology associated with the class. + + Raises: + NotImplementedError: To be implemented in subclasses for + specific topologies. + + Returns: + str: The name of the topology the class is designed for. + """ + raise NotImplementedError + + def __init__( + self, + x: TopologyNode, + y: TopologyNode, + check_edge_valid: bool = True, + ) -> None: + + if any(type(z) is not self.associated_topology_node for z in (x, y)): + raise TypeError( + f"Expected x, y to be {self.associated_topology_node}, got {type(x), type(y)}" + ) + self._edge_kind = None + if check_edge_valid: + self._check_edge_valid(x, y) + super().__init__(x, y) + + def _check_edge_valid(self, x: TopologyNode, y: TopologyNode) -> None: + """Checks whether the edge is a valid edge in the topology. + + Args: + x: One endpoint of edge. + y: Another endpoint of edge. Must have the same type as ``x``. + + Raises: + TypeError: If the endpoints have different types. + ValueError: If the endpoints of the edge have different shapes. + ValueError: If the edge is not a valid edge in the topology. + """ + if x.shape != y.shape: + raise ValueError(f"Expected x, y to have the same shape, got {x.shape, y.shape}") + + self._edge_kind = self._find_edge_kind(x, y) + if self._edge_kind is EdgeKind.INVALID: + raise ValueError(f"{x, y} are not neighbors in {self.topology_name}") + + def _find_edge_kind(self, x: TopologyNode, y: TopologyNode) -> EdgeKind: + """Finds the kind of edge in the topology. + + Args: + x: One endpoint of edge. + y: Another endpoint of edge. + + Returns: + EdgeKind: The kind of edge in the topology. + """ + for kind in self.associated_topology_node.supported_edgekinds(): + if x.is_neighbor(y, nbr_kind=kind): + return kind + return EdgeKind.INVALID + + @property + def edge_kind(self) -> EdgeKind: + """The kind of edge in the topology.""" + if self._edge_kind is None: + self._edge_kind = self._find_edge_kind(*self._edge) + return self._edge_kind + + +@total_ordering +class TopologyNode: + """ + A blueprint class to represent the node of a topology. + + ..note:: For a subclass, the methods that have to do with the edges + incident with a node, such as neighbors, degree, ... require that + the subclass is also a subclass of a subclass of :class:`NeighborContributorMixin`. + + Args: + coord: Coordinate of the node. + shape: Shape of the topology graph the node belongs to. + Defaults to ``None``. + coord_kind: + The kind of coordinate the node is represented with. + - Defaults to ``None``. + - If ``None``, it is set based on ``coord``. + check_node_valid: + Flag to check whether the node is a valid node in the topology. Defaults to ``True``. + """ + + associated_topology_edge: ClassVar[Type[TopologyEdge]] + + @classmethod + def topology_name(cls) -> str: + """Returns the name of the topology associated with the class. + + Raises: + NotImplementedError: To be implemented in subclasses for + specific topology nodes. + + Returns: + str: The name of the topology the class is designed for. + """ + raise NotImplementedError + + @classmethod + def supported_edgekinds(cls) -> dict[EdgeKind, type]: + """Gives the edge kinds incident with the nodes of this class. + + Returns: + dict[EdgeKind, type]: A dictionary whose + - keys are edge kinds incident with a typical node. + - Values are subclasses of :class:`NeighborContributor` + whose associated_edgekind is the given key. + """ + edgekind_cls_map: dict[EdgeKind, type] = {} + for parent_class in cls.__mro__: + if NeighborContributorMixin in parent_class.__bases__ and hasattr( + parent_class, "associated_edgekind" + ): + edgekind_cls_map[parent_class.associated_edgekind] = parent_class + return edgekind_cls_map + + def __init__( + self, + coord: Coord | tuple[int | _Quotient, ...], + shape: TopologyShape | tuple[int | _Quotient | _Infinite, ...] | None = None, + coord_kind: CoordKind | None = None, + check_node_valid: bool = True, + ) -> None: + self._shape = self._set_shape(shape=shape, check_shape_valid=check_node_valid) + + self._coord_kind = self._set_coord_kind( + coord=coord, + coord_kind=coord_kind, + ) + + self._ccoord = self._set_ccoord(coord=coord, check_coord_valid=check_node_valid) + + def _set_shape( + self, + shape: TopologyShape | tuple[int | _Quotient | _Infinite, ...] | None, + check_shape_valid: bool, + ) -> TopologyShape: + """Sets the shape of the topology graph the node belongs to. + + Args: + shape: Shape of the topology graph the node belongs to. + check_shape_valid: + Flag to check whether the shape is a valid shape for the topology. + + Raises: + NotImplementedError: To be implemented in subclasses for + specific topology nodes. + + Returns: + TopologyShape: Shape of the topology graph the node belongs to. + """ + raise NotImplementedError + + def _set_coord_kind( + self, coord: Coord | tuple[int | _Quotient, ...], coord_kind: CoordKind | None + ) -> CoordKind: + """Gives the coordinate kind that the node is represented with. + + Args: + coord: Coordinate of the node. + coord_kind: The coordinate kind to represent the node with. + + Raises: + NotImplementedError: To be implemented in subclasses for + specific topology nodes. + + Returns: + CoordKind: The coordinate kind that the node is represented with. + """ + raise NotImplementedError + + def _set_ccoord( + self, + coord: Coord | tuple[int | _Quotient, ...], + check_coord_valid: bool, + ) -> Coord: + """Gives the canonical coordinate of the node to use in class methods' computations. + + Args: + coord: Coordinate of the node. + check_coord_valid: Flag to check whether the coordinate is valid in the topology. + + Raises: + NotImplementedError: To be implemented in subclasses. + + Returns: + Coord: The canonical coordinate of the node. + """ + raise NotImplementedError + + @property + def coord_kind(self) -> CoordKind: + """The kind of coordinate the node is represented with.""" + return self._coord_kind + + @property + def topology_coord(self) -> Coord: + """The topology coordinate of the node. + + Raises: + NotImplementedError: To be implemented in subclasses. + + Returns: + Coord: The topology coordinate of the node. + """ + raise NotImplementedError + + @property + def shape(self) -> TopologyShape: + """The shape of the topology graph the node belongs to.""" + return self._shape + + @property + def node_kind(self) -> NodeKind: + """The kind of node.""" + raise NotImplementedError + + @property + def direction(self) -> int: + """The direction of the qubit the node is representing.""" + _node_kind = self.node_kind + if _node_kind is NodeKind.VERTICAL or _node_kind is NodeKind.HORIZONTAL: + return _node_kind.value + else: + raise AssertionError(f"Unhandled NodeKind value: {_node_kind}") + + def is_quotient(self) -> bool: + """Tells if the node is quotient.""" + return self._ccoord.is_quotient() and self._shape.is_quotient() + + def to_quotient(self) -> TopologyNode: + """Returns the quotient node corresponding to the node.""" + return self.__class__( + coord=self._ccoord.to_quotient(), + shape=self._shape.to_quotient(), + coord_kind=self.coord_kind, + check_node_valid=False, + ) + + def to_non_quotient(self, shape: TopologyShape) -> list[TopologyNode]: + """Gives all nodes in a non-quotient graph whose quotient is the node. + + Args: + shape: The non-quotient shape to expand the node to. + + Returns: + list[TopologyNode]: The expansion of the node into non-quotient. + """ + non_quo_coords = [self._ccoord.to_non_quotient(shape=shape)] + return [ + self.__class__(coord=coord, shape=shape, coord_kind=self.coord_kind) + for coord in non_quo_coords + ] + + @property + def coord(self) -> Coord: + """Coordinate of the node, expressed in the coordinate system the node is represented with to the user.""" + return (self._ccoord).convert(self.coord_kind) + + def is_vertical(self) -> bool: + """Tells if the node represents a vertical qubit.""" + return self.node_kind is NodeKind.VERTICAL + + def is_horizontal(self) -> bool: + """Tells if the node represents a horizontal qubit.""" + return self.node_kind is NodeKind.HORIZONTAL + + def neighbor_edge_kind( + self, + other: TopologyNode, + ) -> EdgeKind: + """Returns the kind of edge between two nodes. + + Args: + other: Another topology node. + + Returns: + EdgeKind: The edge kind between the two nodes in perfect yield topology. + """ + if type(self) is not type(other): + return NotImplemented + try: + return self.associated_topology_edge(self, other).edge_kind + except ValueError: + return EdgeKind.INVALID + + def neighbors( + self, + nbr_kind: EdgeKind | Iterable[EdgeKind] | None = None, + where: Callable[[Coord], bool] = lambda coord: True, + ) -> Generator[TopologyNode, None, None]: + """Generates neighbors of the node when restricted by + the kind of edge between the node and the neighbors and coordinate. + + Args: + nbr_kind: + Edge kind filter. Restricts yielded neighbors to those connected + by the given edge kind(s). + If ``None``, no filtering is applied. Defaults to ``None``. + where: A coordinate filter. Defaults to always. + + Yields: + TopologyNode: Neighbors of the node when restricted by ``nbr_kind`` and ``where``. + """ + + edgekind_cls_map = self.supported_edgekinds() + + base_edgekinds = {kind for kind in edgekind_cls_map} + if nbr_kind is None: + output_edgekinds = base_edgekinds + else: + if isinstance(nbr_kind, EdgeKind): + nbr_kind = {nbr_kind} + output_edgekinds = set(nbr_kind).intersection(base_edgekinds) + for kind in output_edgekinds: + if kind not in EDGE_KINDS_CONTRIBUTOR_MAP: + raise NotImplementedError(f"Edge contributor class not implemented for {kind}") + else: + method_name = EDGE_KINDS_CONTRIBUTOR_MAP[kind] + method = getattr(self, method_name) + yield from method(where=where) + + def is_neighbor( + self, + other: TopologyNode, + nbr_kind: EdgeKind | Iterable[EdgeKind] | None = None, + where: Callable[[Coord], bool] = lambda coord: True, + ) -> bool: + """Tells whether another node is a neighbor of the node + when restricted by edge kind and coordinate. + + Args: + other: Another node. + nbr_kind: + Edge kind filter. Restricts neighbor to be connected to the node + by the given edge kind(s). + If ``None``, no filtering is applied. Defaults to ``None``. + where: + A coordinate filter. Defaults to always. + + Returns: + bool: Whether ``other`` is a neighbor of the node when + restricted by ``nbr_kind`` and ``where``. + """ + return other in self.neighbors(nbr_kind=nbr_kind, where=where) + + def incident_edges( + self, + nbr_kind: EdgeKind | Iterable[EdgeKind] | None = None, + where: Callable[[Coord], bool] = lambda coord: True, + ) -> Generator[TopologyEdge, None, None]: + """Returns the edges incident with the node when restricted + by edge kind and coordinate. + + Args: + nbr_kind: + Edge kind filter. Restricts returned edges to those having the given edge kind(s). + If ``None``, no filtering is applied. Defaults to ``None``. + where: A coordinate filter. Defaults to always. + + Returns: + list[TopologyEdge]: List of edges incident with self when restricted by ``nbr_kind`` and ``where``. + """ + for v in self.neighbors(nbr_kind=nbr_kind, where=where): + yield self.associated_topology_edge(self, v) + + def degree( + self, + nbr_kind: EdgeKind | Iterable[EdgeKind] | None = None, + where: Callable[[Coord], bool] = lambda coord: True, + ) -> int: + """Returns degree of the node when restricted by edge kind and coordinate. + + Args: + nbr_kind: + Edge kind filter. Restricts counting the neighbors to those connected by the given edge kind(s). + If ``None``, no filtering is applied. Defaults to ``None``. + where: + A coordinate filter. Defaults to always. + + Returns: + int: degree of the node when restricted by ``nbr_kind`` and ``where``. + """ + return len(list(self.neighbors(nbr_kind=nbr_kind, where=where))) + + def __add__(self, shift: TopologyPlaneShift | tuple[int, int]) -> TopologyNode: + """Shifts the node in the Cartesian plane. + + Args: + shift: Shift to be applied to the node. + + Raises: + NotImplementedError: To be implemented in subclasses. + + Returns: + TopologyNode: The shifted node. + """ + raise NotImplementedError + + def __sub__(self, other: TopologyNode) -> TopologyPlaneShift: + """Finds the displacement between the node and another node. + + Args: + other: The node to find the displacement with. + + Raises: + NotImplementedError: To be implemented in subclasses. + + Returns: + TopologyPlaneShift: The displacement whose application to the node moves it to the other node. + """ + raise NotImplementedError + + def __eq__(self, other: object) -> bool: + if not (type(self) is type(other) and self._shape == other._shape): + return NotImplemented + return self._ccoord == other._ccoord + + def __lt__(self, other: object) -> bool: + if not (type(self) is type(other) and self._shape == other._shape): + return NotImplemented + return self._ccoord < other._ccoord + + def __hash__(self) -> int: + return hash((type(self), self._shape, self._ccoord)) + + def __repr__(self) -> str: + coord = (self._ccoord).convert(self._coord_kind) + return f"{type(self).__name__}{coord, self._shape, self._coord_kind}" + + def __str__(self) -> str: + coord = (self._ccoord).convert(self._coord_kind) + return f"{coord.to_tuple(), self._shape.to_tuple()}" + + +class NeighborContributorMixin: + """A blueprint for a neighbor contributor class for a topology node.""" + + associated_edgekind: EdgeKind + + +class HasInternalNeighborsMixin(NeighborContributorMixin): + """A class to encapsulate methods for internal coupler featuring topologies.""" + + associated_edgekind = EdgeKind.INTERNAL + + def internal_neighbors( + self, + where: Callable[[Coord], bool] = lambda coord: True, + ) -> Generator[TopologyNode, None, None]: + """Generates internal neighbors of a node when restricted by coordinate. + + Args: + where: A coordinate filter. Defaults to always. + + Raises: + NotImplementedError: To be implemented in subclasses. + + Yields: + TopologyNode: Internal neighbors of the node when restricted by ``where``. + """ + raise NotImplementedError + + def is_internal_neighbor( + self, + other: TopologyNode, + where: Callable[[Coord], bool] = lambda coord: True, + ) -> bool: + """Tells whether a node is an internal neighbor of the node + when restricted by coordinate. + + Args: + other: The other node to check whether the node is + an internal neighbor of. + where: A coordinate filter. Defaults to always. + + Returns: + bool: Whether the other node is an internal neighbor + of the node when restricted by ``where``. + """ + return other in self.internal_neighbors(where=where) + + +class HasExternalNeighborsMixin(NeighborContributorMixin): + """A class to encapsulate methods for external coupler featuring topologies.""" + + associated_edgekind = EdgeKind.EXTERNAL + + def external_neighbors( + self, + where: Callable[[Coord], bool] = lambda coord: True, + ) -> Generator[TopologyNode, None, None]: + """Generates external neighbors of a node when restricted by coordinate. + + Args: + where: A coordinate filter. Defaults to always. + + Raises: + NotImplementedError: To be implemented in subclasses. + + Yields: + TopologyNode: External neighbors of the node when restricted by ``where``. + """ + raise NotImplementedError + + def is_external_neighbor( + self, + other: TopologyNode, + where: Callable[[Coord], bool] = lambda coord: True, + ) -> bool: + """Tells whether a node is an external neighbor of the node + when restricted by coordinate. + + Args: + other: The other node to check whether the node is + an external neighbor of. + where: A coordinate filter. Defaults to always. + + Returns: + bool: Whether the other node is an external neighbor + of the node when restricted by ``where``. + """ + return other in self.external_neighbors(where=where) + + +class HasOddNeighborsMixin(NeighborContributorMixin): + """A class to encapsulate methods for odd coupler featuring topologies.""" + + associated_edgekind = EdgeKind.ODD + + def odd_neighbors( + self, + where: Callable[[Coord], bool] = lambda coord: True, + ) -> Generator[TopologyNode, None, None]: + """Generates odd neighbors of a node when restricted by coordinate. + + Args: + where : A coordinate filter. Defaults to always. + + Raises: NotImplementedError: To be implemented in subclasses. + + Yields: + TopologyNode: Odd neighbors of the node when restricted by ``where``. + """ + raise NotImplementedError + + def is_odd_neighbor( + self, + other: TopologyNode, + where: Callable[[Coord], bool] = lambda coord: True, + ) -> bool: + """Tells whether a node is an odd neighbor of the node + when restricted by coordinate. + + Args: + other: The other node to check whether the node is + an odd neighbor of. + where: A coordinate filter. Defaults to always. + + Returns: + bool: Whether the other node is an odd neighbor + of the node when restricted by ``where``. + """ + return other in self.odd_neighbors(where=where) diff --git a/dwave/graphs/generators/common/planeshift.py b/dwave/graphs/generators/common/planeshift.py new file mode 100755 index 00000000..57c4a89c --- /dev/null +++ b/dwave/graphs/generators/common/planeshift.py @@ -0,0 +1,112 @@ +# Copyright 2026 D-Wave +# +# 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. +# +# ================================================================================================ + +from __future__ import annotations + +from dwave.graphs.tuplelike import TupleLike + +__all__ = ["TopologyPlaneShift"] + + +class TopologyPlaneShift(TupleLike): + """Represents a displacement in a Cartesian plane associated with a topology. + + Args: + x: The displacement in the x-direction of a Cartesian coordinate. + y: The displacement in the y-direction of a Cartesian coordinate. + """ + + def __init__(self, x: int, y: int) -> None: + self._check_args_valid(x, y) + self._xy = (x, y) + + @classmethod + def topology_name(cls) -> str: + """Returns the name of the topology associated with the class. + + Raises: + NotImplementedError: To be implemented in subclasses for + specific topologies. + + Returns: + str: The name of the topology the class is designed for. + """ + raise NotImplementedError + + def _check_args_valid(self, x: int, y: int) -> None: + """Verifies whether the given plane displacement is valid. + + Args: + x: The displacement in the x-direction of a Cartesian coordinate. + y: The displacement in the y-direction of a Cartesian coordinate. + + Raises: + NotImplementedError: To be implemented in subclasses. + """ + raise NotImplementedError + + @property + def x(self) -> int: + """The displacement in the x-direction""" + return self._xy[0] + + @property + def y(self) -> int: + """The displacement in the y-direction""" + return self._xy[1] + + def to_tuple(self) -> tuple[int, int]: + """Returns the pair of values that uniquely identifies the displacement.""" + return self._xy + + @classmethod + def _construct(cls: type[TopologyPlaneShift], *args) -> TopologyPlaneShift: + """Constructs a class instance.""" + return cls(*args) + + def __mul__(self, scale: int) -> TopologyPlaneShift: + """Multiplies the displacement from left by a scalar ``scale``. + + Args: + scale: The scale for left-multiplying the displacement with. + + Returns: + TopologyPlaneShift: The result of left-multiplying the displacement by ``scale``. + """ + return self._construct(scale * self.x, scale * self.y) + + def __rmul__(self, scale: int) -> TopologyPlaneShift: + """Multiplies the displacement from right by a scalar ``scale``. + + Args: + scale: The scale for right-multiplying the displacement with. + + Returns: + TopologyPlaneShift: The result of right-multiplying the displacement by ``scale``. + """ + return self * scale + + def __add__(self, other: TopologyPlaneShift) -> TopologyPlaneShift: + """ + Adds another displacement to the displacement. + + Args: + other: The displacement to be added. + + Returns: + TopologyPlaneShift: The displacement in Cartesian Coord by self followed by other. + """ + return self._construct(self.x + other.x, self.y + other.y) diff --git a/dwave/graphs/generators/common/shape.py b/dwave/graphs/generators/common/shape.py new file mode 100755 index 00000000..6dbea5d0 --- /dev/null +++ b/dwave/graphs/generators/common/shape.py @@ -0,0 +1,153 @@ +# Copyright 2026 D-Wave +# +# 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. +# +# ================================================================================================ + + +from __future__ import annotations + +from dwave.graphs.tuplelike import TupleLike + +__all__ = [ + "_Quotient", + "QUOTIENT", + "_Infinite", + "INFINITE", + "TopologyShape", +] + + +class _Quotient: + """An object to represent the Quotient graph of a topology.""" + + def __new__(cls): + raise TypeError("QUOTIENT is not to be instantiated") + + def __str__(self) -> str: + return "" + + def __repr__(self) -> str: + return "" + + +QUOTIENT = object.__new__(_Quotient) # Unique instance of _Quotient + + +class _Infinite: + """An object to represent an infinte grid size of the topology.""" + + def __new__(cls): + raise TypeError("INFINITE is not to be instantiated") + + def __str__(self) -> str: + return "" + + def __repr__(self) -> str: + return "" + + +INFINITE = object.__new__(_Infinite) # Unique instance of _Infinite + + +class TopologyShape(TupleLike): + """A class to represent the shape parameters associated with a topology. + + Args: + m: The grid size of topology. Defaults to ``INFINITE``. + t: The tile size of topology. Defaults to ``QUOTIENT``. + check_shape_valid: Flag to whether to check the + parameters are valid on instantiation. Defaults to ``True``. + """ + + @classmethod + def topology_name(cls) -> str: + """Returns the name of the topology associated with the class. + + Raises: + NotImplementedError: To be implemented in subclasses for + specific topologies. + + Returns: + str: The name of the topology the class is designed for. + """ + raise NotImplementedError + + def __init__( + self, + m: int | _Infinite = INFINITE, + t: int | _Quotient = QUOTIENT, + check_shape_valid: bool = True, + *args, + **kwargs, + ) -> None: + if check_shape_valid: + self._args_are_valid(m, t, *args, **kwargs) + self.m = m + self.t = t + + def _args_are_valid(self, m: int | _Infinite, t: int | _Quotient, *args, **kwargs) -> None: + """Checks whether the given parameters are valid for a topology shape. + + Args: + m: The grid size of topology. + t: The tile size of topology. + + Raises: + NotImplementedError: To be implemented in subclasses. + """ + raise NotImplementedError + + def to_quotient(self) -> TopologyShape: + """Converts the shape to its corresponding quotient shape. + + Raises: + NotImplementedError: To be implemented in subclasses. + + Returns: + TopologyShape: The shape converted to its corresponding quotient shape. + """ + raise NotImplementedError + + def is_quotient(self) -> bool: + """Tells whether the shape represents a quotient shape. + + Raises: + NotImplementedError: To be implemented in subclasses. + + Returns: + bool: Whether the shape is quotient. + """ + raise NotImplementedError + + def to_infinite(self) -> TopologyShape: + """Converts the shape to its corresponding infinite grid size shape. + + Raises: + NotImplementedError: To be implemented in subclasses. + + Returns: + TopologyShape: The shape converted to its corresponding infinite grid size shape. + """ + raise NotImplementedError + + def is_infinite(self) -> bool: + """Tells whether the shape represents a shape with infinite grid size. + + Raises: + NotImplementedError: To be implemented in subclasses. + + Returns: + bool: Whether the shape represents a shape with infinite grid size. + """ + raise NotImplementedError diff --git a/dwave/graphs/generators/common/topology.py b/dwave/graphs/generators/common/topology.py new file mode 100755 index 00000000..56d20d6d --- /dev/null +++ b/dwave/graphs/generators/common/topology.py @@ -0,0 +1,138 @@ +# Copyright 2026 D-Wave +# +# 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. +# +# ================================================================================================ + + +from __future__ import annotations + +from typing import Callable, Iterable + +from dwave.graphs.generators.common.coord import Coord, CoordKind +from dwave.graphs.generators.common.node_edge import EdgeKind, TopologyEdge, TopologyNode +from dwave.graphs.generators.common.planeshift import TopologyPlaneShift +from dwave.graphs.generators.common.shape import TopologyShape + +__all__ = ["Topology"] + + +class Topology: + def __init__( + self, + planeshift_class: TopologyPlaneShift, + shape_class: TopologyShape, + node_class: TopologyNode, + edge_class: TopologyEdge, + coord_class: Coord | Iterable[Coord], + ) -> None: + """A class to access various classes associated with a topology. + + Args: + planeshift_class: The displacement class in a Cartesian plane associated with the topology. + shape_class: The shape class associated with the topology. + node_class: The node class associated with the topology. + edge_class: The edge class associated with the topology. + coord_class: The coordinate class(es) associated with the topology. + + Raises: + ValueError: If the topology names of the arguments are not consistent. + """ + _coord_classes = [coord_class] if isinstance(coord_class, Coord) else coord_class + all_topology_names = { + _associated_class_.topology_name() + for _associated_class_ in (planeshift_class, shape_class, node_class, edge_class) + } | {_associated_class_.topology_name() for _associated_class_ in _coord_classes} + if len(all_topology_names) != 1: + raise ValueError( + f"All arguments must have the same topology_name, got {all_topology_names}" + ) + self._check_node_edge_class( + node_class=node_class, + edge_class=edge_class, + ) + self.planeshift_class = planeshift_class + self.shape_class = shape_class + self.coord_class = _coord_classes + self.node_class = node_class + self.edge_class = edge_class + + @classmethod + def _check_node_edge_class(cls, node_class: TopologyNode, edge_class: TopologyEdge): + """Checks whether the node and edge classes are consistent. + + Args: + node_class: The node class associated with the topology. + edge_class: The edge class associated with the topology. + + Raises: + ValueError: If the edge class's associated node class is not the given node class. + ValueError: If the node class's associated edge class is not the given edge class. + """ + if edge_class.associated_topology_node is not node_class: + raise ValueError( + f"The node_class doen't match with the associated node class for {edge_class}" + ) + if node_class.associated_topology_edge is not edge_class: + raise ValueError( + f"The edge_class doen't match with the associated edge class for {node_class}" + ) + + def nodes( + self, + shape: TopologyShape, + coord_kind: CoordKind | None = None, + ) -> set[TopologyNode]: + """Returns the nodes of the topology graph with a shape. + + Args: + shape: The shape of topology graph. + coord_kind: The kind of coordinate the nodes are represented with. + Defaults to ``None``. + Raises: + NotImplementedError: To be implemented in subclasses. + + Returns: + set[TopologyNode]: The nodes of the topology graph with the given + shape and coordinate kind. + """ + raise NotImplementedError + + def edges( + self, + shape: TopologyShape, + edge_kind: EdgeKind | Iterable[EdgeKind] | None = None, + where: Callable[[Coord], bool] = lambda coord: True, + coord_kind: CoordKind | None = None, + ) -> set[TopologyEdge]: + """Returns the edges of the topology graph with a shape and optional + coordinate and edge kind. + + Args: + shape: The shape of topology graph. + edge_kind: + Edge kind filter. Restricts edges to the given edge kind(s). + If ``None``, no filtering is applied. Defaults to ``None``. + where: A coordinate filter. Defaults to always. + coord_kind: + The kind of coordinate the edges endpoints are represented with. + Defaults to ``None``. + + Raises: + NotImplementedError: To be implemented in subclasses. + + Returns: + set[TopologyEdge]: The edges of the topology graph with the given + shape, coordinate and edge kind. + """ + raise NotImplementedError diff --git a/dwave/graphs/generators/pegasus.py b/dwave/graphs/generators/pegasus.py index 79c640b9..d0e9fdc9 100644 --- a/dwave/graphs/generators/pegasus.py +++ b/dwave/graphs/generators/pegasus.py @@ -21,7 +21,7 @@ import networkx as nx from .chimera import _chimera_coordinates_cache -from .common import _add_compatible_edges, _add_compatible_nodes, _add_compatible_terms +from dwave.graphs.generators.common import _add_compatible_edges, _add_compatible_nodes, _add_compatible_terms __all__ = ['pegasus_graph', 'pegasus_coordinates', diff --git a/dwave/graphs/generators/zephyr/__init__.py b/dwave/graphs/generators/zephyr/__init__.py new file mode 100755 index 00000000..5b8ffc36 --- /dev/null +++ b/dwave/graphs/generators/zephyr/__init__.py @@ -0,0 +1,21 @@ +# Copyright 2026 D-Wave +# +# 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. +# +# ================================================================================================ + +from dwave.graphs.generators.zephyr.zcoord import * +from dwave.graphs.generators.zephyr.zephyr import * +from dwave.graphs.generators.zephyr.znode_edge import * +from dwave.graphs.generators.zephyr.zplaneshift import * +from dwave.graphs.generators.zephyr.zshape import * diff --git a/dwave/graphs/generators/zephyr/zcoord.py b/dwave/graphs/generators/zephyr/zcoord.py new file mode 100755 index 00000000..84a54117 --- /dev/null +++ b/dwave/graphs/generators/zephyr/zcoord.py @@ -0,0 +1,352 @@ +# Copyright 2026 D-Wave +# +# 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. +# +# ================================================================================================ +# pyright: reportIncompatibleMethodOverride=false +# mypy: disable-error-code=override + +from __future__ import annotations + +from typing import Literal + +from dwave.graphs.generators.common import QUOTIENT, Coord, CoordKind, _Infinite, _Quotient +from dwave.graphs.generators.zephyr.zshape import ZephyrShape + +__all__ = ["ZephyrCartesianCoord", "ZephyrCoord"] + + +class ZephyrCartesianCoord(Coord): + """A class to represent the node of a Zephyr graph + in its Cartesian coordinate. + + Args: + x: ``x`` value of the node's Cartesian coordinate. + y: ``y`` value of the node's Cartesian coordinate. + k: Index of node within its Zephyr tile. + check_coord: Whether to check the coordinate is valid. + Defaults to ``True``. + """ + + @classmethod + def topology_name(cls) -> Literal["zephyr"]: + """Returns the name of the topology associated with the class. + + Returns: + Literal["zephyr"] + """ + return "zephyr" + + @classmethod + def kind(cls) -> CoordKind: + """Returns the kind of the coordinate associated with the class. + + Returns: + CoordKind: The kind of class's coordinate. + """ + return CoordKind.CARTESIAN + + def __init__( + self, + x: int, + y: int, + k: int | _Quotient, + check_coord: bool = True, + ) -> None: + if check_coord: + self._args_valid_topology(x, y, k) + self._x: int = x + self._y: int = y + self._k: int | _Quotient = k + + def _args_valid_topology(self, x: int, y: int, k: int | _Quotient) -> None: + """Verifies the coordinate is a valid Zephyr Cartesian coordinate. + + Args: + x: x value for the node's Cartesian coordinate. + y: y value for the node's Cartesian coordinate. + k: Index of node within its Zephyr tile. + + Raises: + ValueError: If ``x`` or ``y`` are negative. + ValueError: If ``x`` and ``y`` have the same parity. + ValueError: If ``k`` is non-quotient and negative. + """ + if x < 0 or y < 0: + raise ValueError(f"Expected x, y to be non-negative, got {x, y}") + if x % 2 == y % 2: + raise ValueError(f"Expected x, y to differ in parity, got {x, y}") + if k is not QUOTIENT and k < 0: + raise ValueError(f"Expected k to be non-negative, got {k}") + + def is_shape_consistent(self, shape: ZephyrShape) -> bool: + """Tells whether the coordinate is consistent with a Zephyr shape. + + Args: + shape: The shape to check the consistency of the coordinate with. + + Returns: + bool: Whether the coordinate is consistent with the shape. + """ + # check x, y value of coord is consistent with m + shape_m, shape_t = shape + if not isinstance(shape_m, _Infinite): + if not all(val in range(4 * shape_m + 1) for val in (self._x, self._y)): + return False + + # check k value of coord is consistent with t + if isinstance(shape_t, _Quotient): + return isinstance(self._k, _Quotient) + elif isinstance(self._k, _Quotient): + return False + return self._k in range(shape_t) + + @property + def x(self) -> int: + """``x`` value of the coordinate.""" + return self._x + + @property + def y(self) -> int: + """``y`` value of the coordinate.""" + return self._y + + @property + def k(self) -> int | _Quotient: + """``k`` value of the coordinate.""" + return self._k + + def to_tuple(self) -> tuple[int, int, int | _Quotient]: + """Returns the tuple cooresponding to the coordinate.""" + return (self._x, self._y, self._k) + + def is_quotient(self) -> bool: + """Whether the given coordinate is a quotient coordinate.""" + return self.k is QUOTIENT + + def to_quotient(self) -> ZephyrCartesianCoord: + """Converts the Cartesian coordinate to its corresponding + Cartesian coordinate in a quotient Zephyr graph.""" + return ZephyrCartesianCoord(x=self._x, y=self._y, k=QUOTIENT, check_coord=False) + + def to_non_quotient(self, shape: ZephyrShape, **kwargs) -> list[ZephyrCartesianCoord]: + """Expands the coordinate to non-quotient coordinates; + i.e. it gives all coordinates in a non-quotient Zephyr graph whose + quotient is the coordinate. + + Args: + shape: The Zephyr graph's shape to expand the coordinate to. + + Raises: + ValueError: If ``shape`` is quotient. + ValueError: If the coordinate is not consistent with the shape. + + Returns: + list[ZephyrCartesianCoord]: The expansion of the coordinate into non-quotient. + """ + + if shape.is_quotient(): + raise ValueError(f"Expected shape to be non-quotient, got {shape}") + + # Check value of the Cartesian coordinate is consistent with shape + if not self.is_quotient(): + if not self.is_shape_consistent(shape=shape): + raise ValueError(f"{self} is not consistent with {shape}") + return [self] + + x, y = self.x, self.y + if ZephyrCartesianCoord(x, y, 0, check_coord=False).is_shape_consistent(shape): + return [ZephyrCartesianCoord(x, y, k, check_coord=False) for k in range(shape.t)] + else: + raise ValueError(f"{self} is not consistent with {shape}") + + def convert(self, coord_kind: CoordKind) -> ZephyrCartesianCoord | ZephyrCoord: + """Converts the coordinate to a given kind of Zephyr coordinates. + + Args: + coord_kind: The kind of coordinate to covert into. + + Returns: + ZephyrCartesianCoord | ZephyrCoord: The converted coordinate. + """ + if coord_kind is CoordKind.CARTESIAN: + return self + + x, y, k = self + if x % 2 == 0: + u = 0 + w = x // 2 + j = ((y - 1) % 4) // 2 + z = y // 4 + else: + u = 1 + w = y // 2 + j = ((x - 1) % 4) // 2 + z = x // 4 + return ZephyrCoord(u=u, w=w, k=k, j=j, z=z, check_coord=False) + + +class ZephyrCoord(Coord): + """A class to represent the node of a Zephyr graph + in its Zephyr coordinate. + + Args: + u: The orientation of qubit + - u = 0 if vertical, + - u = 1 if horizontal. + w: The perpendicular block offset. + k: The qubit index within tile. + j: The shift identifier + - j = 0 if qubit is shifted to the left/top. + - j = 1 if qubit is shifted to the right/bottom. + z: The parallel tile offset. + check_coord: Whether to check the coordinate is valid. Defaults to ``True``. + """ + + @classmethod + def topology_name(cls) -> Literal["zephyr"]: + """Returns the name of the topology associated with the class.""" + return "zephyr" + + @classmethod + def kind(cls) -> CoordKind: + """Returns the kind of the coordinate associated with the class. + + Returns: + CoordKind: The kind of class's coordinate. + """ + return CoordKind.TOPOLOGY + + def __init__( + self, + u: int, + w: int, + k: int | _Quotient, + j: int, + z: int, + check_coord: bool = True, + ) -> None: + + if check_coord: + self._args_valid_topology(u, w, k, j, z) + self.u = u + self.w = w + self.k = k + self.j = j + self.z = z + + def _args_valid_topology(self, u: int, w: int, k: int | _Quotient, j: int, z: int) -> None: + """Verifies the coordinate is a valid Zephyr coordinate. + + Args: + u: The orientation of qubit + w: The perpendicular block offset. + k: The qubit index within tile. + j: The shift identifier + z: The parallel tile offset. + + Raises: + ValueError: If any of ``u`` or ``j`` is not 0 or 1. + ValueError: If any of ``w`` or ``z`` is negative. + ValueError: If ``k`` is non-quotient and negative. + """ + if any(par not in (0, 1) for par in (u, j)): + raise ValueError(f"Expected u, j to be 0 or 1, got {(u, w, k, j, z) = }") + if any(par < 0 for par in (w, z)): + raise ValueError(f"Expected w, z to be non-negative, got {(u, w, k, j, z) = }") + if k is not QUOTIENT and k < 0: + raise ValueError(f"Expected k to be QUOTIENT or non-negative, got {k}") + + def is_shape_consistent(self, shape: ZephyrShape) -> bool: + """Tells whether the coordinate is consistent with a Zephyr shape. + + Args: + shape: The shape to check the consistency of the coordinate with. + + Returns: + bool: Whether the coordinate is consistent with the shape. + """ + # w, z value of coord is consistent with grid size + shape_m, shape_t = shape + if not isinstance(shape_m, _Infinite): + if self.w not in range(2 * shape_m + 1) or self.z not in range(shape_m): + return False + + # k value of coord is consistent with tile size + if isinstance(shape_t, _Quotient): + return isinstance(self.k, _Quotient) + elif isinstance(self.k, _Quotient): + return False + return self.k in range(shape_t) + + def is_quotient(self) -> bool: + """Whether the given coordinate is a quotient coordinate.""" + return self.k is QUOTIENT + + def to_tuple(self) -> tuple[int, int, int | _Quotient, int, int]: + """Returns the tuple cooresponding to the coordinate.""" + return (self.u, self.w, self.k, self.j, self.z) + + def to_quotient(self) -> ZephyrCoord: + """Converts the coordinate to its corresponding + Zephyr coordinate in a quotient Zephyr graph.""" + return ZephyrCoord(u=self.u, w=self.w, k=QUOTIENT, j=self.j, z=self.z) + + def to_non_quotient(self, shape: ZephyrShape, **kwargs) -> list[ZephyrCoord]: + """Expands the coordinate to all non-quotient coordinates; + i.e. it gives all coordinates in a non-quotient Zephyr graph whose + quotient is the coordinate. + + Args: + shape: The graph's shape to expand the coordinate to. + + Raises: + ValueError: If ``shape`` is quotient. + ValueError: If the coordinate is not consistent with the shape. + + Returns: + list[ZephyrCoord]: The expansion of the coordinate into non-quotient. + """ + if shape.is_quotient(): + raise ValueError(f"Expected shape to be non-quotient, got {shape}") + if not self.is_quotient(): + if not self.is_shape_consistent(shape): + raise ValueError(f"{self} is not consistent with {shape}.") + return [self] + + u, w, _, j, z = self + if ZephyrCoord(u, w, 0, j, z, check_coord=False).is_shape_consistent(shape): + return [ZephyrCoord(u, w, k, j, z, check_coord=False) for k in range(shape.t)] + else: + raise ValueError(f"{self} is not consistent with {shape}") + + def convert(self, coord_kind: CoordKind) -> ZephyrCartesianCoord | ZephyrCoord: + """Converts the coordinate to a given kind of Zephyr coordinates. + + Args: + coord_kind: The kind of coordinate to covert into. + + Returns: + ZephyrCartesianCoord | ZephyrCoord: The converted coordinate. + """ + if coord_kind is CoordKind.TOPOLOGY: + return self + + u, w, k, j, z = self + if u == 0: + x = 2 * w + y = 4 * z + 2 * j + 1 + else: + x = 4 * z + 2 * j + 1 + y = 2 * w + return ZephyrCartesianCoord(x=x, y=y, k=k, check_coord=False) diff --git a/dwave/graphs/generators/zephyr.py b/dwave/graphs/generators/zephyr/zephyr.py similarity index 66% rename from dwave/graphs/generators/zephyr.py rename to dwave/graphs/generators/zephyr/zephyr.py index 43708844..5d057251 100644 --- a/dwave/graphs/generators/zephyr.py +++ b/dwave/graphs/generators/zephyr/zephyr.py @@ -15,24 +15,50 @@ """ Generators for some graphs derived from the D-Wave System. """ + from itertools import product +from typing import Iterable import networkx as nx - -from .chimera import _chimera_coordinates_cache - -from .common import _add_compatible_edges, _add_compatible_nodes, _add_compatible_terms - -__all__ = ['zephyr_graph', - 'zephyr_coordinates', - 'zephyr_sublattice_mappings', - 'zephyr_torus', - 'zephyr_four_color', - ] - -def zephyr_graph(m, t=4, create_using=None, node_list=None, edge_list=None, - data=True, coordinates=False, check_node_list=False, - check_edge_list=False): +from dwave.graphs.generators.chimera import _chimera_coordinates_cache +from dwave.graphs.generators.common import ( + INFINITE, + QUOTIENT, + CoordKind, + EdgeKind, + Topology, + _add_compatible_edges, + _add_compatible_nodes, + _add_compatible_terms, + _Infinite, + _Quotient, +) +from dwave.graphs.generators.zephyr.zcoord import ZephyrCartesianCoord, ZephyrCoord +from dwave.graphs.generators.zephyr.znode_edge import ZephyrEdge, ZephyrNode +from dwave.graphs.generators.zephyr.zplaneshift import ZephyrPlaneShift +from dwave.graphs.generators.zephyr.zshape import ZephyrShape + +__all__ = [ + "zephyr_graph", + "zephyr_coordinates", + "zephyr_sublattice_mappings", + "zephyr_torus", + "zephyr_four_color", + "Zephyr", +] + + +def zephyr_graph( + m, + t=4, + create_using=None, + node_list=None, + edge_list=None, + data=True, + coordinates=False, + check_node_list=False, + check_edge_list=False, +): """ Creates a Zephyr graph with grid parameter ``m`` and tile parameter ``t``. @@ -49,15 +75,15 @@ def zephyr_graph(m, t=4, create_using=None, node_list=None, edge_list=None, with the new graph. Usually used to set the type of the graph. node_list : iterable (optional, default None) Iterable of nodes in the graph. If not specified, calculated from (``m``, ``t``) - and ``coordinates``. The nodes should typically be compatible with the - requested lattice shape parameters and coordinate system, incompatible - nodes are accepted unless you set :code:`check_node_list=True`. If not - specified, all :math:`4 t m (2 m + 1)` nodes compatible with the + and ``coordinates``. The nodes should typically be compatible with the + requested lattice shape parameters and coordinate system, incompatible + nodes are accepted unless you set :code:`check_node_list=True`. If not + specified, all :math:`4 t m (2 m + 1)` nodes compatible with the topology description are included. edge_list : iterable (optional, default None) - Iterable of edges in the graph. Edges must be 2-tuples of the nodes - specified in node_list, or calculated from (``m``, ``t``) and ``coordinates`` - per the topology description below; incompatible edges are ignored + Iterable of edges in the graph. Edges must be 2-tuples of the nodes + specified in node_list, or calculated from (``m``, ``t``) and ``coordinates`` + per the topology description below; incompatible edges are ignored unless you set :code:`check_edge_list=True`. If not specified, all edges compatible with the ``node_list`` and topology description are included. data : bool, optional (default :code:`True`) @@ -70,15 +96,15 @@ def zephyr_graph(m, t=4, create_using=None, node_list=None, edge_list=None, check_node_list : bool (optional, default :code:`False`) If :code:`True`, the ``node_list`` elements are checked for compatibility with the graph topology and node labeling conventions, and an error is thrown - if any node is incompatible or duplicates exist. - In other words, ``node_lists`` must specify a subgraph of the default - (full yield) graph described below. An exception is allowed if + if any node is incompatible or duplicates exist. + In other words, ``node_lists`` must specify a subgraph of the default + (full yield) graph described below. An exception is allowed if ``check_edge_list=False``, any node in edge_list will also be treated as valid. check_edge_list : bool (optional, default :code:`False`) If :code:`True`, ``edge_list`` elements are checked for compatibility with the graph topology and node labeling conventions, and an error is thrown - if any edge is incompatible or duplicates exist. - In other words, ``edge_list`` must specify a subgraph of the default + if any edge is incompatible or duplicates exist. + In other words, ``edge_list`` must specify a subgraph of the default (full yield) graph described below. Returns @@ -154,8 +180,8 @@ def zephyr_graph(m, t=4, create_using=None, node_list=None, edge_list=None, References ---------- - Boothby, Raymond, King. - Zephyr Topology of D-Wave Quantum Processors + Boothby, Raymond, King. + Zephyr Topology of D-Wave Quantum Processors October 2021. https://dwavesys.com/media/fawfas04/14-1056a-a_zephyr_topology_of_d-wave_quantum_processors.pdf """ @@ -165,55 +191,72 @@ def zephyr_graph(m, t=4, create_using=None, node_list=None, edge_list=None, G.name = "zephyr_graph(%s, %s)" % (m, t) - M = 2*m+1 + M = 2 * m + 1 if coordinates: + def label(*q): return q - labels = 'coordinate' + + labels = "coordinate" else: - labels = 'int' + labels = "int" + def label(u, w, k, j, z): return (((u * M + w) * t + k) * 2 + j) * m + z - construction = (("family", "zephyr"), ("rows", m), ("columns", m), - ("tile", t), ("data", data), ("labels", labels)) + construction = ( + ("family", "zephyr"), + ("rows", m), + ("columns", m), + ("tile", t), + ("data", data), + ("labels", labels), + ) G.graph.update(construction) - + if edge_list is None: check_edge_list = False if node_list is None: check_node_list = False - + if edge_list is None or check_edge_list is True: # external edges - G.add_edges_from((label(u, w, k, j, z), label(u, w, k, j, z + 1)) - for u, w, k, j, z in product( - (0, 1), range(M), range(t), (0, 1), range(m-1) - )) + G.add_edges_from( + (label(u, w, k, j, z), label(u, w, k, j, z + 1)) + for u, w, k, j, z in product((0, 1), range(M), range(t), (0, 1), range(m - 1)) + ) # odd edges - G.add_edges_from((label(u, w, k, 0, z), label(u, w, k, 1, z-a)) - for u, w, k, a in product( - (0, 1), range(M), range(t), (0, 1) - ) - for z in range(a, m)) + G.add_edges_from( + (label(u, w, k, 0, z), label(u, w, k, 1, z - a)) + for u, w, k, a in product((0, 1), range(M), range(t), (0, 1)) + for z in range(a, m) + ) # internal edges - G.add_edges_from((label(0, 2*w+1+a*(2*i-1), k, j, z), label(1, 2*z+1+b*(2*j-1), h, i, w)) - for w, z, h, k, i, j, a, b in product( - range(m), range(m), range(t), range(t), (0, 1), (0, 1), (0, 1), (0, 1) - )) + G.add_edges_from( + ( + label(0, 2 * w + 1 + a * (2 * i - 1), k, j, z), + label(1, 2 * z + 1 + b * (2 * j - 1), h, i, w), + ) + for w, z, h, k, i, j, a, b in product( + range(m), range(m), range(t), range(t), (0, 1), (0, 1), (0, 1), (0, 1) + ) + ) if edge_list is not None: _add_compatible_edges(G, edge_list) else: if check_node_list or node_list is None: - G.add_nodes_from(label(u, w, k, j, z) for u in range(2) - for w in range(2*m+1) - for k in range(t) - for j in range(2) - for z in range(m)) + G.add_nodes_from( + label(u, w, k, j, z) + for u in range(2) + for w in range(2 * m + 1) + for k in range(t) + for j in range(2) + for z in range(m) + ) G.add_edges_from(edge_list) if node_list is not None: @@ -226,16 +269,18 @@ def label(u, w, k, j, z): if data: if coordinates: + def fill_data(): d = get_node_data((u, w, k, j, z)) if d is not None: - d['linear_index'] = v + d["linear_index"] = v else: + def fill_data(): d = get_node_data(v) if d is not None: - d['zephyr_index'] = (u, w, k, j, z) + d["zephyr_index"] = (u, w, k, j, z) v = 0 get_node_data = G.nodes.get @@ -268,6 +313,7 @@ class zephyr_coordinates(object): :func:`.zephyr_graph` : Describes the various coordinate conventions. """ + def __init__(self, m, t=4): self.args = m, 2 * m + 1, t @@ -310,15 +356,13 @@ def linear_to_zephyr(self, r): return u, w, k, j, z def iter_zephyr_to_linear(self, qlist): - """Converts a sequence of 5-term Zephyr coordinates to linear indices. - """ + """Converts a sequence of 5-term Zephyr coordinates to linear indices.""" m, M, t = self.args - for (u, w, k, j, z) in qlist: + for u, w, k, j, z in qlist: yield (((u * M + w) * t + k) * 2 + j) * m + z def iter_linear_to_zephyr(self, rlist): - """Converts a sequence of linear indices to 5-term Zephyr coordinates. - """ + """Converts a sequence of linear indices to 5-term Zephyr coordinates.""" m, M, t = self.args for r in rlist: r, z = divmod(r, m) @@ -338,32 +382,30 @@ def _pair_repack(f, plist): yield u, v def iter_zephyr_to_linear_pairs(self, plist): - """Converts pairs of 5-term Zephyr coordinates to pairs of linear indices. - """ + """Converts pairs of 5-term Zephyr coordinates to pairs of linear indices.""" return self._pair_repack(self.iter_zephyr_to_linear, plist) def iter_linear_to_zephyr_pairs(self, plist): - """Converts pairs of linear indices to pairs of 5-term Zephyr coordinates. - """ + """Converts pairs of linear indices to pairs of 5-term Zephyr coordinates.""" return self._pair_repack(self.iter_linear_to_zephyr, plist) def graph_to_linear(self, g): """Returns a copy of the graph ``g`` relabeled to have linear indices. - + Parameters ---------- g : NetworkX Graph - The Zephyr graph to be relabeled. - + The Zephyr graph to be relabeled. + Returns ------- G : NetworkX Graph A Zephyr graph relabeled with linear indices. """ - labels = g.graph.get('labels') - if labels == 'int': + labels = g.graph.get("labels") + if labels == "int": return g.copy() - elif labels == 'coordinate': + elif labels == "coordinate": nodes = self.iter_zephyr_to_linear(g) edges = self.iter_zephyr_to_linear_pairs(g.edges) else: @@ -373,31 +415,31 @@ def graph_to_linear(self, g): ) return zephyr_graph( - g.graph['rows'], - t = g.graph['tile'], + g.graph["rows"], + t=g.graph["tile"], node_list=nodes, edge_list=edges, - data=g.graph['data'], + data=g.graph["data"], ) def graph_to_zephyr(self, g): """Returns a copy of the graph ``g`` relabeled to have Zephyr coordinates. - + Parameters ---------- g : NetworkX Graph - The Zephyr graph to be relabeled. - + The Zephyr graph to be relabeled. + Returns ------- G : NetworkX Graph A Zephyr graph relabeled with Zephyr coordinates. """ - labels = g.graph.get('labels') - if labels == 'int': + labels = g.graph.get("labels") + if labels == "int": nodes = self.iter_linear_to_zephyr(g) edges = self.iter_linear_to_zephyr_pairs(g.edges) - elif labels == 'coordinate': + elif labels == "coordinate": return g.copy() else: raise ValueError( @@ -406,11 +448,11 @@ def graph_to_zephyr(self, g): ) return zephyr_graph( - g.graph['rows'], - t=g.graph['tile'], + g.graph["rows"], + t=g.graph["tile"], node_list=nodes, edge_list=edges, - data=g.graph['data'], + data=g.graph["data"], coordinates=True, ) @@ -477,6 +519,7 @@ def mapping(q): return mapping + def _single_chimera_zephyr_sublattice_mapping(source_to_chimera, zephyr_to_target, offset): """Constructs a mapping from a Chimera graph to a Zephyr graph, via an offset. This function is used by zephyr_sublattice_mappings, and serves to construct @@ -533,6 +576,7 @@ def mapping(q): return mapping + def _double_chimera_zephyr_sublattice_mapping(source_to_chimera, zephyr_to_target, offset): """Constructs a mapping from a Chimera graph to a Zephyr graph, via an offset. This function is used by zephyr_sublattice_mappings, and serves to construct @@ -567,6 +611,7 @@ def _double_chimera_zephyr_sublattice_mapping(source_to_chimera, zephyr_to_targe """ t, y_offset, x_offset, j0, j1 = offset + def mapping(q): y, x, u, k = source_to_chimera(q) wz, kz = divmod(k, t) @@ -593,24 +638,24 @@ def zephyr_sublattice_mappings(source, target, offset_list=None): * a ``chimera_graph(m_s, n_s, 2*t)`` to nodes of a ``zephyr_graph(m_t, t)`` where ``m_s <= m_t`` and ``n_s <= m_t``. - This sublattice mapping is used to identify subgraphs of the target Zephyr graph + This sublattice mapping is used to identify subgraphs of the target Zephyr graph which are isomorphic to the source graph. However, if the target graph is not of perfect yield,\ [#]_ this function does not generally produce isomorphisms; for example, if a node is missing in the target graph, it may still appear in the image of the source graph. - The tile parameter of Chimera graphs must be either the same or double - that of the target Zephyr graphs; if both graphs are Zephyr graphs, - the tile parameters must be the same. The mappings produced preserve + The tile parameter of Chimera graphs must be either the same or double + that of the target Zephyr graphs; if both graphs are Zephyr graphs, + the tile parameters must be the same. The mappings produced preserve the linear ordering of tile indices; see the ``_zephyr_zephyr_sublattice_mapping``, ``_double_chimera_zephyr_sublattice_mapping``, and ``_single_chimera_zephyr_sublattice_mapping`` internal functions in the source code. - + .. [#] - The yield is the percentage of working qubits on a QPU and the subset + The yield is the percentage of working qubits on a QPU and the subset of available qubits is called the :ref:`working graph `. - + Parameters ---------- source : NetworkX Graph @@ -639,38 +684,42 @@ def zephyr_sublattice_mappings(source, target, offset_list=None): isomorphisms of Zephyr graphs permit permutations of major tile indices on a per-row and per-column basis in addition to reflections of the grid that induce inversion of orthogonal minor offsets and rotations that induce - inversions of minor offsets, orientation, or both. Although the full set + inversions of minor offsets, orientation, or both. Although the full set of sublattice mappings would take those isomorphisms into account, this function does not handle that complex task. """ - if target.graph.get('family') != 'zephyr': - raise ValueError("Source graph must be a Zephyr graph constructed by dwave.graphs.zephyr_graph") + if target.graph.get("family") != "zephyr": + raise ValueError( + "Source graph must be a Zephyr graph constructed by dwave.graphs.zephyr_graph" + ) - m_t = target.graph['rows'] - t = target.graph['tile'] - labels_t = target.graph['labels'] - if labels_t == 'int': + m_t = target.graph["rows"] + t = target.graph["tile"] + labels_t = target.graph["labels"] + if labels_t == "int": zephyr_to_target = _zephyr_coordinates_cache[m_t, t].zephyr_to_linear - elif labels_t == 'coordinate': + elif labels_t == "coordinate": + def zephyr_to_target(q): return q + else: raise ValueError(f"Zephyr node labeling {labels_t} not recognized") - labels_s = source.graph['labels'] - if source.graph.get('family') == 'chimera': - t_t = source.graph['tile'] - m_s = source.graph['rows'] - n_s = source.graph['columns'] + labels_s = source.graph["labels"] + if source.graph.get("family") == "chimera": + t_t = source.graph["tile"] + m_s = source.graph["rows"] + n_s = source.graph["columns"] if t_t == t: make_mapping = _single_chimera_zephyr_sublattice_mapping if offset_list is None: - krange = range(t+1) - mrange = range(2*m_t - m_s + 1) - nrange = range(2*m_t - n_s + 1) + krange = range(t + 1) + mrange = range(2 * m_t - m_s + 1) + nrange = range(2 * m_t - n_s + 1) offset_list = product([t], mrange, nrange, krange, krange) - elif t_t == 2*t: + elif t_t == 2 * t: make_mapping = _double_chimera_zephyr_sublattice_mapping if offset_list is None: jrange = range(2) @@ -678,50 +727,59 @@ def zephyr_to_target(q): nrange = range(m_t - n_s + 1) offset_list = product([t], mrange, nrange, jrange, jrange) else: - raise ValueError("Cannot construct sublattice mappings from Chimera " - "to this Zephyr graph unless the tile parameter of " - f"the chimera graph is {t} or {2*t}.") + raise ValueError( + "Cannot construct sublattice mappings from Chimera " + "to this Zephyr graph unless the tile parameter of " + f"the chimera graph is {t} or {2*t}." + ) + + if labels_s == "coordinate": - if labels_s == 'coordinate': def source_to_inner(q): return q - elif labels_s == 'int': + + elif labels_s == "int": source_to_inner = _chimera_coordinates_cache[m_s, n_s, t_t].linear_to_chimera else: raise ValueError(f"Chimera node labeling {labels_s} not recognized") - elif source.graph.get('family') == 'zephyr': - m_s = source.graph['rows'] + elif source.graph.get("family") == "zephyr": + m_s = source.graph["rows"] if offset_list is None: - mrange = range((2*m_t+1) - (2*m_s+1) + 1) + mrange = range((2 * m_t + 1) - (2 * m_s + 1) + 1) offset_list = product(mrange, mrange) - labels_s = source.graph['labels'] - if labels_s == 'int': + labels_s = source.graph["labels"] + if labels_s == "int": source_to_inner = _zephyr_coordinates_cache[m_s, t].linear_to_zephyr - elif labels_s == 'coordinate': + elif labels_s == "coordinate": + def source_to_inner(q): return q + else: raise ValueError(f"Zephyr node labeling {labels_s} not recognized") make_mapping = _zephyr_zephyr_sublattice_mapping else: - raise ValueError("source graph must be a Chimera graph or Zephyr graph " - "constructed by dwave.graphs.chimera_graph or " - "dwave.graphs.zephyr_graph respectively") + raise ValueError( + "source graph must be a Chimera graph or Zephyr graph " + "constructed by dwave.graphs.chimera_graph or " + "dwave.graphs.zephyr_graph respectively" + ) for offset in offset_list: yield make_mapping(source_to_inner, zephyr_to_target, offset) + def zephyr_torus(m, t=4, node_list=None, edge_list=None): """ Creates a Zephyr graph modified to allow for periodic boundary conditions and translational invariance. - + The graph matches the local connectivity properties of a standard Zephyr graph, but with modified periodic boundary condition. Tiles of :math:`8t` nodes are arranged - on an :math:`m` by :math:`m` torus. + on an :math:`m` by :math:`m` torus. Parameters ---------- @@ -731,15 +789,15 @@ def zephyr_torus(m, t=4, node_list=None, edge_list=None): t : int Tile parameter for the Zephyr lattice. node_list : iterable (optional, default None) - Iterable of nodes in the graph. If None, nodes are generated + Iterable of nodes in the graph. If None, nodes are generated for an undiluted torus calculated from ``m`` and ``t`` as described below. The node list must describe a subset - of the torus nodes to be maintained in the graph + of the torus nodes to be maintained in the graph using the coordinate node labeling scheme. edge_list : iterable (optional, default None) Iterable of edges in the graph. If None, edges are generated for an undiluted torus calculated from ``m`` and ``t`` - as described below. The edge list must describe + as described below. The edge list must describe a subgraph of the torus, using the coordinate node labeling scheme. Returns @@ -751,12 +809,12 @@ def zephyr_torus(m, t=4, node_list=None, edge_list=None): A Zephyr torus is a generalization of the standard Zephyr graph whereby degree-twenty connectivity is maintained, but the boundary - condition is modified to enforce an additional translational-invariance + condition is modified to enforce an additional translational-invariance symmetry [Ray2023]_. Local connectivity in the Zephyr torus is identical to connectivity for Zephyr graph nodes away from the boundary. - A tile consists of :math:`8t` nodes, and the torus has :math:`m` by :math:`m` tiles. + A tile consists of :math:`8t` nodes, and the torus has :math:`m` by :math:`m` tiles. Tile displacement modulo :math:`m` defines an automorphism. - + See :func:`.zephyr_graph` for additional information. Examples @@ -768,35 +826,54 @@ def zephyr_torus(m, t=4, node_list=None, edge_list=None): False """ - G = zephyr_graph(m=m, t=t, node_list=None, edge_list=None, - data=True, coordinates=True) - + G = zephyr_graph(m=m, t=t, node_list=None, edge_list=None, data=True, coordinates=True) + def relabel(u, w, k, j, z): - return (u, w%(2*m), k, j, z) - + return (u, w % (2 * m), k, j, z) + # Contract internal couplers spanning the boundary: - G.add_edges_from([(relabel(*edge[0]), relabel(*edge[1])) - for edge in G.edges() if edge[0][1]==2*m or edge[1][1]==2*m]) - - if m>1: + G.add_edges_from( + [ + (relabel(*edge[0]), relabel(*edge[1])) + for edge in G.edges() + if edge[0][1] == 2 * m or edge[1][1] == 2 * m + ] + ) + + if m > 1: # Add boundary spanning external couplers: - G.add_edges_from([((u, w, k, 1, m - 1), (u, w, k, 0, 0)) - for u in range(2) - for w in range(2*m) - for k in range(t)]) - G.add_edges_from([((u, w, k, j, m - 1), (u, w, k, j, 0)) - for u in range(2) - for w in range(2*m) - for k in range(t) - for j in range(2)]) - + G.add_edges_from( + [ + ((u, w, k, 1, m - 1), (u, w, k, 0, 0)) + for u in range(2) + for w in range(2 * m) + for k in range(t) + ] + ) + G.add_edges_from( + [ + ((u, w, k, j, m - 1), (u, w, k, j, 0)) + for u in range(2) + for w in range(2 * m) + for k in range(t) + for j in range(2) + ] + ) + # Delete variables contracted at the boundary: - G.remove_nodes_from([(u, 2*m, k, j, z) - for u in range(2) for k in range(t) for j in range(2) for z in range(m)]) - + G.remove_nodes_from( + [ + (u, 2 * m, k, j, z) + for u in range(2) + for k in range(t) + for j in range(2) + for z in range(m) + ] + ) + _add_compatible_terms(G, node_list, edge_list) - - G.graph['boundary_condition'] = 'torus' + + G.graph["boundary_condition"] = "torus" return G @@ -809,7 +886,7 @@ def zephyr_four_color(q, scheme=0): q : tuple Qubit label in standard coordinate format: u, w, k, j, z scheme : int - Two patterns not related by automorphism are supported + Two patterns not related by automorphism are supported Returns ------- color : int @@ -823,10 +900,188 @@ def zephyr_four_color(q, scheme=0): >>> colors = {q: dwave.graphs.zephyr_four_color(q) for q in G.nodes()} """ u, w, _, j, z = q - + if scheme == 0: - return j + ((w + 2*(z+u) + j)&2) + return j + ((w + 2 * (z + u) + j) & 2) elif scheme == 1: - return (2*u + w + 2*z + j) & 3 + return (2 * u + w + 2 * z + j) & 3 else: - raise ValueError('Unknown scheme') + raise ValueError("Unknown scheme") + + +class Zephyr(Topology): + """A class to access various classes associated with D-Wave's Zephyr topology.""" + + def __init__(self): + super().__init__( + planeshift_class=ZephyrPlaneShift, + shape_class=ZephyrShape, + coord_class=[ZephyrCartesianCoord, ZephyrCoord], + node_class=ZephyrNode, + edge_class=ZephyrEdge, + ) + + def _find_shape( + self, shape: ZephyrShape | tuple[int | _Infinite, int | _Quotient] + ) -> ZephyrShape: + """Finds the Zephyr shape of the graph. + + Args: + shape: The shape of Zephyr graph. + + Raises: + ValueError: If shape is not a valid Zephyr shape. + + Returns: + ZephyrShape: The shape of the Zephyr graph. + """ + if not isinstance(shape, ZephyrShape): + try: + shape = ZephyrShape(*shape) + except (ValueError, TypeError): + raise ValueError(f"{shape} cannot be an instance of ZephyrShape") + return shape + + def nodes( + self, + shape: ZephyrShape | tuple[int | _Infinite, int | _Quotient], + coord_kind: CoordKind = CoordKind.CARTESIAN, + ) -> list[ZephyrNode]: + """Returns the nodes of a Zephyr graph. + + Args: + shape: The shape of the Zephyr graph. + coord_kind: + The kind of coordinate the edges endpoints are represented with. + Defaults to ``CoordKind.CARTESIAN``. + Raises: + ValueError: If the grid size of the shape is infinite. + + Returns: + list[ZephyrNode]: The nodes of the Zephyr graph. + """ + shape = self._find_shape(shape) + if shape.m is INFINITE: + raise ValueError( + "Cannot generate infinite number of nodes!\nProvide a finite grid size." + ) + m, t = shape + nodes = [] + range_x = range(4 * m + 1) + range_t = [QUOTIENT] if t is QUOTIENT else range(t) + for x in range_x: + if x % 2 == 0: + range_y = range(1, 4 * m + 1, 2) + else: + range_y = range(0, 4 * m + 1, 2) + for y, k in product(range_y, range_t): + nodes.append( + ZephyrNode( + coord=ZephyrCartesianCoord(x, y, k), + shape=shape, + coord_kind=coord_kind, + check_node_valid=False, + ) + ) + return nodes + + def edges( + self, + shape: ZephyrShape | tuple[int | _Infinite, int | _Quotient], + edge_kind: EdgeKind | Iterable[EdgeKind] | None = None, + coord_kind: CoordKind = CoordKind.CARTESIAN, + ) -> list[ZephyrEdge]: + """Returns the edges of a Zephyr graph with a shape and optional + coordinate and edge kind. + + Args: + shape: The shape of Zephyr graph. + edge_kind: + Edge kind filter. Restricts edges to the given edge kind(s). + If ``None``, no filtering is applied. Defaults to ``None``. + coord_kind: + The kind of coordinate the edges endpoints are represented with. + Defaults to ``CoordKind.CARTESIAN``. + + Returns: + list[ZephyrEdge]: The edges of the Zephyr graph. + """ + + def get_internal_edges(): + for x in range(0, 4 * m, 2): + for y in range(1, 4 * m, 2): + square = [(x, y), (x + 1, y - 1), (x + 2, y), (x + 1, y + 1), (x, y)] + for i, sq_e in enumerate(square): + if i == 4: + continue + for k1, k2 in product(k_vals, k_vals): + coord1 = ZephyrCartesianCoord(*sq_e, k=k1) + coord1 = coord1.convert(coord_kind) + node1 = ZephyrNode( + coord1, shape=shape, coord_kind=coord_kind, check_node_valid=False + ) + coord2 = ZephyrCartesianCoord(*square[i + 1], k=k2) + node2 = ZephyrNode( + coord2, shape=shape, coord_kind=coord_kind, check_node_valid=False + ) + e12 = ZephyrEdge(x=node1, y=node2, check_edge_valid=False) + edges.append(e12) + + def get_external_edges(): + for x in range(1, 4 * m - 4, 2): + for y in range(0, 4 * m + 1, 2): + ccoord = (x, y) + ccoord_ext = (x + 4, y) + for k in k_vals: + for step in (-1, 1): + coord1 = ZephyrCartesianCoord(*ccoord[::step], k, check_coord=False) + node1 = ZephyrNode( + coord1, shape=shape, coord_kind=coord_kind, check_node_valid=False + ) + coord2 = ZephyrCartesianCoord(*ccoord_ext[::step], k, check_coord=False) + node2 = ZephyrNode( + coord2, shape=shape, coord_kind=coord_kind, check_node_valid=False + ) + e12 = ZephyrEdge(x=node1, y=node2, check_edge_valid=False) + edges.append(e12) + + def get_odd_edges(): + for x in range(1, 4 * m - 2, 2): + for y in range(0, 4 * m + 1, 2): + ccoord = (x, y) + ccoord_odd = (x + 2, y) + for k in k_vals: + for step in (-1, 1): + coord1 = ZephyrCartesianCoord(*ccoord[::step], k, check_coord=False) + node1 = ZephyrNode( + coord1, shape=shape, coord_kind=coord_kind, check_node_valid=False + ) + coord2 = ZephyrCartesianCoord(*ccoord_odd[::step], k, check_coord=False) + node2 = ZephyrNode( + coord2, shape=shape, coord_kind=coord_kind, check_node_valid=False + ) + e12 = ZephyrEdge(x=node1, y=node2, check_edge_valid=False) + edges.append(e12) + + shape = self._find_shape(shape) + if shape.m is INFINITE: + raise ValueError( + "Cannot generate infinite number of nodes!\nProvide a finite grid size." + ) + m, t = shape + k_vals = [QUOTIENT] if t is QUOTIENT else range(t) + if edge_kind is None: + _edge_kinds = {EdgeKind.INTERNAL, EdgeKind.EXTERNAL, EdgeKind.ODD} + elif isinstance(edge_kind, EdgeKind): + _edge_kinds = {edge_kind} + else: + _edge_kinds = set(edge_kind) + edges = [] + if EdgeKind.INTERNAL in _edge_kinds: + get_internal_edges() + if EdgeKind.EXTERNAL in _edge_kinds: + get_external_edges() + if EdgeKind.ODD in _edge_kinds: + get_odd_edges() + + return edges diff --git a/dwave/graphs/generators/zephyr/znode_edge.py b/dwave/graphs/generators/zephyr/znode_edge.py new file mode 100755 index 00000000..8ba4dad2 --- /dev/null +++ b/dwave/graphs/generators/zephyr/znode_edge.py @@ -0,0 +1,439 @@ +# Copyright 2026 D-Wave +# +# 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. +# +# ================================================================================================ + +from __future__ import annotations + +from itertools import product +from typing import Callable, Generator, Literal + +from dwave.graphs.generators.common.coord import CoordKind +from dwave.graphs.generators.common.node_edge import ( + HasExternalNeighborsMixin, + HasInternalNeighborsMixin, + HasOddNeighborsMixin, + NodeKind, + TopologyEdge, + TopologyNode, +) +from dwave.graphs.generators.common.shape import QUOTIENT, _Infinite, _Quotient +from dwave.graphs.generators.zephyr.zcoord import ZephyrCartesianCoord, ZephyrCoord +from dwave.graphs.generators.zephyr.zplaneshift import ZephyrPlaneShift +from dwave.graphs.generators.zephyr.zshape import ZephyrShape + +# pyright: reportIncompatibleMethodOverride=false +# pyright: reportArgumentType=false +# mypy: disable-error-code=override +__all__ = ["ZephyrEdge", "ZephyrNode"] + + +class ZephyrEdge(TopologyEdge): + """Represents an edge in a graph with Zephyr topology in a canonical order. + + Args: + x: Endpoint of edge. Must have same shape as ``y``. + y: Another endpoint of edge. Must have same shape as ``x`` + check_edge_valid: Flag to whether check the validity of values and types of ``x``, ``y``. + Defaults to ``True``. + + Raises: + TypeError: If either of x or y is not an instance of :class:`ZephyrNode`. + ValueError: If x, y do not have the same shape. + ValueError: If x, y are not neighbors in a perfect yield (quotient) + Zephyr graph. + + Example 1: + >>> from dwave.graphs import ZephyrNode, ZephyrEdge + >>> e = ZephyrEdge(ZephyrNode((3, 2)), ZephyrNode((7, 2))) + >>> print(e) + ZephyrEdge(ZephyrNode(ZephyrCartesianCoord(x=3, y=2, k = QUOTIENT)), ZephyrNode(ZephyrCartesianCoord(x=7, y=2, k = QUOTIENT))) + Example 2: + >>> from dwave.graphs import ZephyrNode, ZephyrEdge + >>> ZephyrEdge(ZephyrNode((2, 3)), ZephyrNode((6, 3))) # raises error, since the two are not neighbors + """ + + @classmethod + def topology_name(cls) -> Literal["zephyr"]: + """Returns the name of the topology associated with the class.""" + return "zephyr" + + def __init__( + self, + x: ZephyrNode, + y: ZephyrNode, + check_edge_valid: bool = True, + ) -> None: + super().__init__(x, y, check_edge_valid) + + def _set_edge(self, x: ZephyrNode, y: ZephyrNode) -> tuple[ZephyrNode, ZephyrNode]: + """Returns a canonical representation of the edge between x, y. + + Args: + x: Endpoint of edge. + y: Another endpoint of edge. + + Returns: + tuple[ZephyrNode, ZephyrNode]: An ordered tuple corresponding to the edge between x, y. + """ + return (x, y) if x < y else (y, x) + + +class ZephyrNode( + TopologyNode, HasInternalNeighborsMixin, HasExternalNeighborsMixin, HasOddNeighborsMixin +): + """Represents a node of a graph with Zephyr topology with coordinate and optional shape, + coordinate kind representation and node validation. + + Args: + coord: Coordinate in (quotient) Zephyr graph. + shape: shape of Zephyr graph containing the node. + Defaults to ``None``. + coord_kind: The kind of coordinate the node is represented with. + If ``None``, it is inferred from ``coord``. + Defaults to ``None``. + check_node_valid: Flag to whether check the validity of values and types of ``coord``, ``shape``. + Defaults to ``True``. + + ..note:: + + If the given coord has non-None ``k`` value (in either Cartesian or Zephyr coordinates), + ``shape = None`` raises ValueError. In this case the tile size of Zephyr, t, + must be provided. + + Example: + >>> from znode_edge import ZephyrNode, ZephyrShape + >>> zn1 = ZephyrNode((5, 2), ZephyrShape(m=5)) + >>> list(zn1.neighbors()) + [ZephyrNode(ZephyrCartesianCoord(1, 2, ), ZephyrShape(5, ), ), + ZephyrNode(ZephyrCartesianCoord(9, 2, ), ZephyrShape(5, ), ), + ZephyrNode(ZephyrCartesianCoord(4, 1, ), ZephyrShape(5, ), ), + ZephyrNode(ZephyrCartesianCoord(4, 3, ), ZephyrShape(5, ), ), + ZephyrNode(ZephyrCartesianCoord(6, 1, ), ZephyrShape(5, ), ), + ZephyrNode(ZephyrCartesianCoord(6, 3, ), ZephyrShape(5, ), ), + ZephyrNode(ZephyrCartesianCoord(3, 2, ), ZephyrShape(5, ), ), + ZephyrNode(ZephyrCartesianCoord(7, 2, ), ZephyrShape(5, ), )] + >>> from minorminer.utils.zephyr.node_edge import ZephyrNode, ZephyrShape + >>> zn1 = ZephyrNode((5, 2), ZephyrShape(m=5)) + >>> list(zn1.neighbors(nbr_kind=EdgeKind.ODD)) + [ZephyrNode(ZephyrCartesianCoord(3, 2, ), ZephyrShape(5, ), ), + ZephyrNode(ZephyrCartesianCoord(7, 2, ), ZephyrShape(5, ), )] + """ + + associated_topology_edge = ZephyrEdge + + @classmethod + def topology_name(cls) -> Literal["zephyr"]: + """Returns the name of the topology associated with the class.""" + return "zephyr" + + def __init__( + self, + coord: ( + ZephyrCartesianCoord + | ZephyrCoord + | tuple[int, int, int | _Quotient] + | tuple[int, int, int | _Quotient, int, int] + | tuple[int, int] + | tuple[int, int, int, int] + ), + shape: ZephyrShape | tuple[int | _Infinite, int | _Quotient] | None = None, + coord_kind: CoordKind | None = None, + check_node_valid: bool = True, + ) -> None: + super().__init__( + coord=coord, + shape=shape, + coord_kind=coord_kind, + check_node_valid=check_node_valid, + ) + + def _set_shape( + self, + shape: ZephyrShape | tuple[int | _Quotient | _Infinite, ...] | None, + check_shape_valid: bool, + ) -> ZephyrShape: + """Sets the shape of the Zephyr graph the node belongs to. + + Args: + shape: Shape of the Zephyr graph the node belongs to. + check_shape_valid: Flag to check whether the shape is valid for Zephyr. + + Raises: + ValueError: If the shape is not a valid Zephyr shape. + + Returns: + ZephyrShape: Shape of the Zephyr graph the node belongs to. + """ + if shape is None: + return ZephyrShape() + try: + return ZephyrShape(*shape, check_shape_valid=check_shape_valid) + except (ValueError, TypeError) as e: + raise ValueError(f"{shape} cannot be converted to :class:`ZephyrShape`") from e + + def _set_coord_kind( + self, + coord: ( + ZephyrCartesianCoord + | ZephyrCoord + | tuple[int, int, int | _Quotient] + | tuple[int, int, int | _Quotient, int, int] + | tuple[int, int] + | tuple[int, int, int, int] + ), + coord_kind: CoordKind | None, + ) -> CoordKind: + """Gives the coordinate kind that the node is represented with. + + Args: + coord: Coordinate of the node. + coord_kind:The coordinate kind to represent the node with. + + Returns: + CoordKind: The coordinate kind that the node is represented with. + """ + if coord_kind is not None: + return coord_kind + if len(coord) in [2, 3]: + return CoordKind.CARTESIAN + return CoordKind.TOPOLOGY + + def _tuple_to_coord( + self, + coord: ( + tuple[int, int, int | _Quotient] + | tuple[int, int, int | _Quotient, int, int] + | tuple[int, int] + | tuple[int, int, int, int] + ), + ) -> ZephyrCoord | ZephyrCartesianCoord: + """Converts a coordinate to a Zephyr coordinate. + + Args: + coord: A coordinate. + + Raises: + ValueError: If the length of tuple is 2 or 3 and + it cannot be converted to a :class:`ZephyrCartesianCoord`. + ValueError: If the length of tuple is 4 or 5 and + it cannot be converted to a :class:`ZephyrCoord`. + + Returns: + ZephyrCoord | ZephyrCartesianCoord: + The Zephyr coordinate the coordinate corresponds to. + """ + if len(coord) == 2: + coord = (coord[0], coord[1], QUOTIENT) + elif len(coord) == 4: + coord = (coord[0], coord[1], QUOTIENT, coord[2], coord[3]) + + if len(coord) == 3: + try: + return ZephyrCartesianCoord(*coord) + except (ValueError, TypeError): + raise ValueError(f"{coord} cannot be converted to :class:`ZephyrCartesianCoord`.") + try: + return ZephyrCoord(*coord) + except (ValueError, TypeError): + raise ValueError(f"{coord} cannot be converted to :class:`ZephyrCoord`.") + + def _set_ccoord( + self, + coord: ( + ZephyrCartesianCoord + | ZephyrCoord + | tuple[int, int, int | _Quotient] + | tuple[int, int, int | _Quotient, int, int] + | tuple[int, int] + | tuple[int, int, int, int] + ), + check_coord_valid: bool, + ) -> ZephyrCartesianCoord: + """Gives the Cartesian coordinate of the node as a canonical coordinate + to use in class methods' computations. + + Args: + coord: Coordinate of the node. + check_coord_valid: Flag to check whether the coordinate is valid in Zephyr. + + Returns: + Coord: The Cartesian coordinate of the node. + """ + if isinstance(coord, tuple): + coord = self._tuple_to_coord(coord) + if isinstance(coord, ZephyrCoord): + coord = coord.convert(CoordKind.CARTESIAN) + if check_coord_valid: + coord._args_valid_topology(*coord) + if not coord.is_shape_consistent(self._shape): + raise ValueError(f"{coord} is not consistent with {self._shape}") + return coord + + @property + def ccoord(self) -> ZephyrCartesianCoord: + """The Cartesian coordinate of the node.""" + return self._ccoord + + @property + def zcoord(self) -> ZephyrCoord: + """The Zephyr coordinate of the node.""" + return (self._ccoord).convert(CoordKind.TOPOLOGY) + + @property + def topology_coord(self) -> ZephyrCoord: + """The topology (Zephyr) coordinate of the node.""" + return self.zcoord + + @property + def node_kind(self) -> NodeKind: + """The kind of the node.""" + if self._ccoord.x % 2 == 0: + return NodeKind.VERTICAL + return NodeKind.HORIZONTAL + + def internal_neighbors( + self, + where: Callable[[ZephyrCartesianCoord | ZephyrCoord], bool] = lambda coord: True, + ) -> Generator[ZephyrNode, None, None]: + """Generator of internal neighbors of a Zephyr node when + restricted by coordinate. + Args: + where: A coordinate filter. Applies to + -``ccoord`` if :py:attr:`self.coord_kind` is ``CoordKind.CARTESIAN``, + -``zcoord`` if :py:attr:`self.coord_kind` is ``CoordKind.TOPOLOGY``. + - Defaults to always. Yields: + ZephyrNode: Internal neighbors of the node when restricted by ``where``. + """ + x, y, _ = self._ccoord + k_vals = [QUOTIENT] if self._shape.t is QUOTIENT else range(self._shape.t) + for i, j, k in product((-1, 1), (-1, 1), k_vals): + try: + ccoord = ZephyrCartesianCoord(x=x + i, y=y + j, k=k, check_coord=True) + # Check ccoord is consistent with shape + if ccoord.is_shape_consistent(self._shape): + coord = ccoord.convert(self._coord_kind) + if not where(coord): + continue + yield ZephyrNode(coord=coord, shape=self._shape, coord_kind=self._coord_kind) + except ValueError: + continue + + def external_neighbors( + self, + where: Callable[[ZephyrCartesianCoord | ZephyrCoord], bool] = lambda coord: True, + ) -> Generator[ZephyrNode, None, None]: + """Generator of external neighbors of a Zephyr node when + restricted by coordinate. + Args: + where: A coordinate filter. Applies to + -``ccoord`` if :py:attr:`self.coord_kind` is ``CoordKind.CARTESIAN``, + -``zcoord`` if :py:attr:`self.coord_kind` is ``CoordKind.TOPOLOGY``. + - Defaults to always. + Yields: + ZephyrNode: External neighbors of node when restricted by ``where``. + """ + x, y, k = self._ccoord + changing_index = 1 if x % 2 == 0 else 0 + for s in [-4, 4]: + new_x = x + s if changing_index == 0 else x + new_y = y + s if changing_index == 1 else y + try: + ccoord = ZephyrCartesianCoord(x=new_x, y=new_y, k=k, check_coord=True) + if ccoord.is_shape_consistent(self._shape): + coord = ccoord.convert(self._coord_kind) + if not where(coord): + continue + yield ZephyrNode(coord=coord, shape=self._shape, coord_kind=self._coord_kind) + except ValueError: + continue + + def odd_neighbors( + self, + where: Callable[[ZephyrCartesianCoord | ZephyrCoord], bool] = lambda coord: True, + ) -> Generator[ZephyrNode, None, None]: + """Generator of odd neighbors of of a Zephyr node when + restricted by ``where``. + Args: + where: A coordinate filter. Applies to + -``ccoord`` if :py:attr:`self.coord_kind` is ``CoordKind.CARTESIAN``, + -``zcoord`` if :py:attr:`self.coord_kind` is ``CoordKind.TOPOLOGY``. + - Defaults to always. + Yields: + ZephyrNode: Odd neighbors of node when restricted by ``where``. + """ + x, y, k = self._ccoord + changing_index = 1 if x % 2 == 0 else 0 + for s in [-2, 2]: + new_x = x + s if changing_index == 0 else x + new_y = y + s if changing_index == 1 else y + try: + ccoord = ZephyrCartesianCoord(x=new_x, y=new_y, k=k, check_coord=True) + if ccoord.is_shape_consistent(self._shape): + coord = ccoord.convert(self._coord_kind) + if not where(coord): + continue + yield ZephyrNode(coord=coord, shape=self._shape, coord_kind=self._coord_kind) + except ValueError: + continue + + def __add__(self, shift: ZephyrPlaneShift | tuple[int, int]) -> ZephyrNode: + """Shifts the node in the Zephyr Cartesian plane. + + Args: + shift: Shift to be applied to the node. + + Raises: + ValueError: If the shift cannot be converted to a :class:`ZephyrPlaneShift` object. + + Returns: + ZephyrNode: The shifted node. + """ + if not isinstance(shift, ZephyrPlaneShift): + try: + shift = ZephyrPlaneShift(*shift) + except (ValueError, TypeError) as e: + raise ValueError(f"{shift} cannot be converted to :class:`ZephyrPlaneShift`") from e + x, y, k = self._ccoord + new_x = x + shift[0] + new_y = y + shift[1] + + return ZephyrNode( + coord=ZephyrCartesianCoord(x=new_x, y=new_y, k=k), + shape=self._shape, + coord_kind=self._coord_kind, + ) + + def __sub__(self, other: ZephyrNode) -> ZephyrPlaneShift: + """Finds the displacement between the node and another node. + + Args: + other: The node to find the displacement with. + + Raises: + ValueError: If there is no valid shift in Zephyr Cartesian plane + that moves the node to the other node. + + Returns: + ZephyrPlaneShift: The displacement that when added to the node moves it to the other node. + """ + x_shift: int = self._ccoord.x - other._ccoord.x + y_shift: int = self._ccoord.y - other._ccoord.y + try: + return ZephyrPlaneShift(x=x_shift, y=y_shift) + except ValueError as e: + raise ValueError(f"{other} cannot be subtracted from {self}") from e + + +ZephyrEdge.associated_topology_node = ZephyrNode diff --git a/dwave/graphs/generators/zephyr/zplaneshift.py b/dwave/graphs/generators/zephyr/zplaneshift.py new file mode 100755 index 00000000..85c6d574 --- /dev/null +++ b/dwave/graphs/generators/zephyr/zplaneshift.py @@ -0,0 +1,52 @@ +# Copyright 2026 D-Wave +# +# 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. +# +# ================================================================================================ + + +from typing import Literal + +from dwave.graphs.generators.common import TopologyPlaneShift + +__all__ = ["ZephyrPlaneShift"] + + +class ZephyrPlaneShift(TopologyPlaneShift): + """Represents a displacement in the Zephyr quotient plane (expressed in Cartesian coordinates). + + Args: + x: The displacement in the x-direction of a Zephyr Cartesian coordinate. + y: The displacement in the y-direction of a Zephyr Cartesian coordinate. + + Raises: + ValueError: If ``x`` and ``y`` have different parity. + """ + + @classmethod + def topology_name(cls) -> Literal["zephyr"]: + """Returns the name of the topology associated with the class.""" + return "zephyr" + + def _check_args_valid(self, x: int, y: int) -> None: + """Verifies whether the given plane displacement is valid. + + Args: + x: The displacement in the x-direction of a Cartesian coordinate. + y: The displacement in the y-direction of a Cartesian coordinate. + + Raises: + ValueError: If ``x`` and ``y`` have different parity. + """ + if x % 2 != y % 2: + raise ValueError(f"Expected x, y to have the same parity, got {x, y}") diff --git a/dwave/graphs/generators/zephyr/zshape.py b/dwave/graphs/generators/zephyr/zshape.py new file mode 100755 index 00000000..5853742e --- /dev/null +++ b/dwave/graphs/generators/zephyr/zshape.py @@ -0,0 +1,96 @@ +# Copyright 2026 D-Wave +# +# 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. +# +# ================================================================================================ +# pyright: reportIncompatibleMethodOverride=false +# mypy: disable-error-code=override + +from __future__ import annotations + +from dwave.graphs.generators.common.shape import ( + INFINITE, + QUOTIENT, + TopologyShape, + _Infinite, + _Quotient, +) + +__all__ = ["ZephyrShape"] + + +class ZephyrShape(TopologyShape): + """A class to represent the shape parmaters associated with a Zephyr graph. + + Args: + m: The grid size of the graph. Defaults to ``INFINITE``. + t: The tile size of the graph. Defaults to ``QUOTIENT``. + check_shape_valid: Flag to whether to check the parameters + are valid on instantiation. Defaults to ``True``. + """ + + @classmethod + def topology_name(cls) -> str: + """Returns the name of the topology of the graph.""" + return "zephyr" + + def __init__( + self, + m: int | _Infinite = INFINITE, + t: int | _Quotient = QUOTIENT, + check_shape_valid: bool = True, + *args, + **kwargs, + ) -> None: + super().__init__(m, t, check_shape_valid, *args, **kwargs) + + def _args_are_valid(self, m: int | _Infinite, t: int | _Quotient, *args, **kwargs) -> None: + """Checks whether the given parameters are valid for a Zephyr shape. + + Args: + m: The grid size of Zephyr graph. + t: The tile size of Zephyr graph. + Raises: + ValueError: If m is not ``INFINITE`` and is non-positive. + ValueError: If t is not ``QUOTIENT`` and is non-positive. + """ + if not isinstance(m, _Infinite): + if m <= 0: + raise ValueError( + f"Expected either ``INFINITE`` or a positive integer for m, got {m}" + ) + if not isinstance(t, _Quotient): + if t <= 0: + raise ValueError( + f"Expected either ``QUOTIENT`` or a positive integer for t, got {t}" + ) + + def to_tuple(self) -> tuple[int | _Infinite, int | _Quotient]: + """Returns the tuple that identifies the Zephyr shape.""" + return (self.m, self.t) + + def to_quotient(self) -> ZephyrShape: + """Converts the shape to its corresponding quotient tile size shape.""" + return ZephyrShape(m=self.m, t=QUOTIENT) + + def is_quotient(self) -> bool: + """Tells whether the shape represents a shape with quotient tile size.""" + return self.t is QUOTIENT + + def to_infinite(self) -> TopologyShape: + """Converts the shape to its corresponding infinite grid size shape.""" + return ZephyrShape(m=INFINITE, t=self.t) + + def is_infinite(self) -> bool: + """Tells whether the shape represents a shape with infinite grid size.""" + return self.m is INFINITE diff --git a/dwave/graphs/tuplelike.py b/dwave/graphs/tuplelike.py new file mode 100755 index 00000000..17fca898 --- /dev/null +++ b/dwave/graphs/tuplelike.py @@ -0,0 +1,51 @@ +from functools import cached_property +from typing import Any, Iterator + + +__all__ = ["TupleLike"] + + +class TupleLike: + """A blueprint class to represent objects which can be identified + with a fixed number of ordered values.""" + def __init__(self, *args, **kwargs) -> None: + pass + + def to_tuple(self) -> tuple[Any, ...]: + """Returns the tuple of values that uniquely identifies the object. + + Raises: + NotImplementedError: To be implemented in subclasses. + + Returns: + tuple[Any, ...]: The tuple of values that uniquely identifies the object. + """ + raise NotImplementedError + + @cached_property + def _tuple_format(self) -> tuple[Any, ...]: + """The tuple associated with the object.""" + return self.to_tuple() + + def __eq__(self, value: object) -> bool: + if type(self) is not type(value): + return NotImplemented + return self._tuple_format == value._tuple_format + + def __hash__(self) -> int: + return hash((type(self), self._tuple_format)) + + def __iter__(self) -> Iterator[Any]: + return iter(self._tuple_format) + + def __len__(self) -> int: + return len(self._tuple_format) + + def __getitem__(self, i: int) -> Any: + return self._tuple_format[i] + + def __repr__(self) -> str: + return f"{type(self).__name__}{self._tuple_format}" + + def __str__(self) -> str: + return f"{self._tuple_format}" diff --git a/pyproject.toml b/pyproject.toml index ab6df579..5b3659d3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,6 @@ readme = {file = "README.rst", content-type = "text/x-rst"} license = "Apache-2.0" license-files = ["LICEN[CS]E*"] classifiers = [ - "License :: OSI Approved :: Apache Software License", "Operating System :: OS Independent", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", diff --git a/tests/test_generator_zephyr.py b/tests/test_generator_zephyr.py index 9078cd5a..d7a195b3 100644 --- a/tests/test_generator_zephyr.py +++ b/tests/test_generator_zephyr.py @@ -17,6 +17,9 @@ import networkx as nx import dwave.graphs as dnx import numpy as np +from parameterized import parameterized +from dwave.graphs.generators.zephyr import Zephyr + class TestZephyrGraph(unittest.TestCase): def test_single_tile(self): @@ -331,3 +334,22 @@ def tests_list(self): # 1 invalid edge G = dnx.zephyr_torus(m=m, t=t, edge_list = edge_list) + +class TestZephyrTopology(unittest.TestCase): + def test_basic(self): + Zephyr() + + @parameterized.expand( + [((4, 2), ), ((6, 1), ), ((12, 4), )] + ) + def test_num_nodes(self, shape): + zeph = Zephyr() + self.assertEqual(len(zeph.nodes(shape=shape)), len(dnx.zephyr_graph(m=shape[0], t= shape[1]).nodes())) + + @parameterized.expand( + [((4, 2), ), ((6, 1), ), ((12, 3), )] + ) + def test_num_edges(self, shape): + zeph = Zephyr() + self.assertEqual(len(zeph.edges(shape=shape)), len(dnx.zephyr_graph(m=shape[0], t= shape[1]).edges())) + diff --git a/tests/test_zcoord.py b/tests/test_zcoord.py new file mode 100755 index 00000000..245a0e81 --- /dev/null +++ b/tests/test_zcoord.py @@ -0,0 +1,245 @@ +# Copyright 2026 D-Wave +# +# 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. +# +# ================================================================================================ + + +from unittest import TestCase + +from parameterized import parameterized +from dwave.graphs.generators.common import (QUOTIENT, INFINITE, CoordKind,) +from dwave.graphs.generators.zephyr import (ZephyrShape, ZephyrCartesianCoord, + ZephyrCoord, ) + + +class TestZephyrShape(TestCase): + @parameterized.expand( + [((-1, 2), ),((0, 3), ), ((3, -1),), ((QUOTIENT, 3),), ((3, INFINITE),), ((3, 0), )]) + def test_invalid_raises_error(self, bad_input): + with self.assertRaises((ValueError, TypeError)): + ZephyrShape(*bad_input) + + + @parameterized.expand( + [ + ((4, 6), ), ((1, 1), ), ((1, 10), ), ((), ), + ((INFINITE, QUOTIENT),), ((INFINITE, 1), ), ((3, QUOTIENT), ) + ] + ) + def test_valid_runs(self, good_shape): + ZephyrShape(*good_shape) + + @parameterized.expand( + [ + ((2, QUOTIENT), True), ((INFINITE, QUOTIENT), True), + ((INFINITE, 1), False), ((4, 6), False), ((1, 1), False), + ] + ) + def test_is_quotient(self, shape, expected): + self.assertEqual(ZephyrShape(*shape).is_quotient(), expected) + + + @parameterized.expand( + [ + ((2, 3), (2, QUOTIENT)), ((1, QUOTIENT), (1, QUOTIENT)), ((INFINITE, 3), (INFINITE, QUOTIENT)) + ] + ) + def test_to_quotient(self, shape, shape_quo): + self.assertEqual(ZephyrShape(*shape).to_quotient(), ZephyrShape(*shape_quo)) + + @parameterized.expand( + [ + ((2, 3), (INFINITE, 3)), ((1, QUOTIENT), (INFINITE, QUOTIENT)), ((INFINITE, 4), (INFINITE, 4)) + ] + ) + def test_to_infinite(self, shape, shape_inf): + self.assertEqual(ZephyrShape(*shape).to_infinite(), ZephyrShape(*shape_inf)) + + @parameterized.expand( + [ + ((INFINITE, 2), True), ((INFINITE, QUOTIENT), True), + ((1, QUOTIENT), False), ((4, 6), False), ((1, 1), False), + ] + ) + def test_is_infinite(self, shape, expected): + self.assertEqual(ZephyrShape(*shape).is_infinite(), expected) + + + +class TestZephyrCartesianCoord(TestCase): + @parameterized.expand( + [ + ((-1, 2, 2),), + ((6, -1, 2),), + ((1, 3, -2),), + ((1, 1, 1),), + ((2, 4, 1),), + ((0, 0, 0),), + ((-1, 2, 2),), + ((-1, 2, QUOTIENT),), + ] + ) + def test_invalid_input_raises_error(self, xyk): + with self.assertRaises((ValueError, TypeError)): + ZephyrCartesianCoord(*xyk) + + @parameterized.expand( + [ + ((0, 17, 4),), + ((1, 0, QUOTIENT),), + ((1, 2, 10),), + ] + ) + def test_valid_input_runs(self, xyk): + ZephyrCartesianCoord(*xyk) + + @parameterized.expand([ + ((17, 0, 0), (4, 1), False), + ((0, 17, 0), (4, QUOTIENT), False), + ((0, 3, 3), (1, ), False), + ((5, 2, 1), (6, QUOTIENT), False), + ((5, 2, 1), (6, 1), False), + ((5, 2, 1), (6, 2), True), + ((16, 1, QUOTIENT), (4, ), True), + ((1, 12, 1), (3, 2), True), + ((3, 0, QUOTIENT), (4, ), True), + ((6, 3, 10), (2, 12), True), + ]) + def test_is_shape_consistent(self, xyk, shape, expected): + self.assertEqual(ZephyrCartesianCoord(*xyk).is_shape_consistent(ZephyrShape(*shape)), expected) + + @parameterized.expand( + [ + ((5, 2, 1), (6, 1),), + ((5, 2, 1), (6, QUOTIENT),) + ] + ) + def test_to_non_quotient_raises_error(self, xyk, shape): + with self.assertRaises((ValueError, TypeError)): + ZephyrCartesianCoord(*xyk).to_non_quotient(ZephyrShape(*shape)) + + @parameterized.expand( + [ + ((5, 2, 1), (6, 10), 1), + ((5, 2, QUOTIENT), (6, 2), 2), + ((1, 2, QUOTIENT), (1, 10), 10) + ] + ) + def test_to_non_quotient(self, xyk, shape, expected_len): + self.assertEqual( + len(ZephyrCartesianCoord(*xyk).to_non_quotient(ZephyrShape(*shape))), + expected_len + ) + + @parameterized.expand([((0, 1, QUOTIENT), ), ((12, 3, QUOTIENT), )]) + def test_cartesian_to_zephyr_runs(self, xyk): + ccoord = ZephyrCartesianCoord(*xyk) + self.assertIs(ccoord.convert(CoordKind.TOPOLOGY).kind(), CoordKind.TOPOLOGY) + self.assertIs(ccoord.convert(CoordKind.CARTESIAN).kind(), CoordKind.CARTESIAN) + + @parameterized.expand( + [ + ((0, 0, QUOTIENT, 0, 0), (0, 1, QUOTIENT)), + ((1, 0, QUOTIENT, 0, 0), (1, 0, QUOTIENT)), + ((1, 6, 3, 0, 1), (5, 12, 3)), + ] + ) + def test_cartesian_to_zephyr(self, zcoord, ccoord): + self.assertEqual( + ZephyrCoord(*zcoord), ZephyrCartesianCoord(*ccoord).convert(CoordKind.TOPOLOGY) + ) + self.assertEqual(ZephyrCartesianCoord(*ccoord), (ZephyrCoord(*zcoord)).convert(CoordKind.CARTESIAN)) + self.assertEqual( + ZephyrCoord(*zcoord), (ZephyrCoord(*zcoord)).convert(CoordKind.TOPOLOGY) + ) + self.assertEqual( + ZephyrCartesianCoord(*ccoord), (ZephyrCartesianCoord(*ccoord)).convert(CoordKind.CARTESIAN) + ) + + +class TestZephyrCoord(TestCase): + @parameterized.expand( + [ + ((1, 24, 0, 1, None),), # All good except z_val + ((2, 0, 0, 0, 0),), # All good except u_val + ((None, 3, 0, 0, 4),), # All good except u_val + ((0, -1, 1, 1, 3),), # All good except w_val + ((1, 23, -1, 1, 5),), # All good except k_val + ((1, 24, 1, 3.5, 9),), # All good except j_val + ] + ) + def test_invalid_input_raises_error(self, uwkjz): + with self.assertRaises((ValueError, TypeError)): + ZephyrCoord(*uwkjz) + + @parameterized.expand( + [ + ((0, 17, 4, 1, 0),), + ((1, 0, QUOTIENT, 0, 0),), + ((1, 2, 10, 1, 23),), + ] + ) + def test_valid_input_runs(self, uwkjz): + ZephyrCoord(*uwkjz) + + @parameterized.expand([ + ((1, 24, 0, 1, 12), (12, 2), False), # All good except z_val + ((1, 20, 3, 1, 12), (12, 6), False), # All good except z_val + ((0, 0, 0, 0, 0), (1, 1), True), + ((0, 0, 0, 0, 0), (1, QUOTIENT), False), + ((0, 15, 2, 0, 0), (6, 4), False) + ]) + def test_is_shape_consistent(self, uwkjz, shape, expected): + self.assertEqual(ZephyrCoord(*uwkjz).is_shape_consistent(ZephyrShape(*shape)), expected) + + @parameterized.expand( + [ + ((0, 0, 0, 0, 0), (6, QUOTIENT),), + ((0, 15, 2, 0, 0), (6, 4),) + ] + ) + def test_to_non_quotient_raises_error(self, uwkjz, shape): + with self.assertRaises((ValueError, TypeError)): + ZephyrCoord(*uwkjz).to_non_quotient(ZephyrShape(*shape)) + + @parameterized.expand( + [ + ((0, 3, 1, 0, 5), (6, 10), 1), + ((1, 12, QUOTIENT, 1, 5), (6, 2), 2), + ((0, 2, QUOTIENT, 1, 0), (1, 10), 10) + ] + ) + def test_to_non_quotient(self, uwkjz, shape, expected_len): + self.assertEqual( + len(ZephyrCoord(*uwkjz).to_non_quotient(ZephyrShape(*shape))), + expected_len + ) + + @parameterized.expand([((0, 2, 4, 1, 5), ), ((1, 3, 3, 0, 0), ), ((1, 2, QUOTIENT, 1, 5), )]) + def test_zephyr_to_cartesian_runs(self, uwkjz): + zcoord = ZephyrCoord(*uwkjz) + self.assertIs(zcoord.convert(CoordKind.CARTESIAN).kind(), CoordKind.CARTESIAN) + self.assertIs(zcoord.convert(CoordKind.TOPOLOGY).kind(), CoordKind.TOPOLOGY) + + @parameterized.expand([((0, 2, 4, 1, 5), ) ,((1, 3, 3, 0, 0), ), ((1, 2, QUOTIENT, 1, 5), )]) + def test_ccoord_to_zcoord(self, uwkjz): + zcoord = ZephyrCoord(*uwkjz) + ccoord = zcoord.convert(CoordKind.CARTESIAN) + self.assertEqual(zcoord, ccoord.convert(CoordKind.TOPOLOGY)) + + @parameterized.expand([((0, 1, QUOTIENT), ), ((1, 0, QUOTIENT), ), ((12, 3, QUOTIENT), )]) + def test_zcoord_to_ccoord(self, xyk): + ccoord = ZephyrCartesianCoord(*xyk) + zcoord = ccoord.convert(CoordKind.TOPOLOGY) + self.assertEqual(ccoord, zcoord.convert(CoordKind.CARTESIAN)) diff --git a/tests/test_znode_edge.py b/tests/test_znode_edge.py new file mode 100755 index 00000000..864c6f6c --- /dev/null +++ b/tests/test_znode_edge.py @@ -0,0 +1,322 @@ +# Copyright 2026 D-Wave +# +# 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. +# +# ================================================================================================ +import unittest +from parameterized import parameterized +from dwave.graphs.generators.common import (EdgeKind, NodeKind, QUOTIENT, INFINITE,) +from dwave.graphs.generators.zephyr import (ZephyrPlaneShift, ZephyrEdge, ZephyrNode, ZephyrCartesianCoord, ZephyrShape, + ZephyrCoord,) + +class TestZephyrEdge(unittest.TestCase): + + @parameterized.expand( + [ + (ZephyrCoord(0, 10, 3, 1, 3), ZephyrCoord(0, 10, 3, 1, 2), ZephyrShape(6, 4)), + (ZephyrCartesianCoord(4, 3, 2), ZephyrCartesianCoord(4, 1, 2), (6, 3)), + ((1, 6, QUOTIENT), (5, 6, QUOTIENT), None) + ] + ) + def test_valid_input_runs(self, x, y, shape) -> None: + zn_x = ZephyrNode(coord=x, shape=shape) + zn_y = ZephyrNode(coord=y, shape=shape) + + ZephyrEdge(zn_x, zn_y) + + @parameterized.expand( + [ + ((2, 4), TypeError), + ((ZephyrNode(coord=ZephyrCartesianCoord(4, 3, 2), shape=(6, 3)), ZephyrNode(coord=ZephyrCartesianCoord(4, 3, 2), shape=(6, 3))), ValueError), + ] + ) + def test_invalid_input_raises_error(self, invalid_edge, expected_err): + with self.assertRaises(expected_err): + ZephyrEdge(*invalid_edge) + + +U_VALS = [0, 1] +J_VALS = [0, 1] +M_VALS = [1, 6, 12] +T_VALS = [1, 2, 4, 6] + + + + +class TestZephyrNode(unittest.TestCase): + def setUp(self): + self.u_vals = [0, 1] + self.invalid_u_vals = [2, -1, None, 3.5] + self.invalid_w_vals = [-1] + self.j_vals = [0, 1] + self.invalid_j_vals = [2, -1, None, 3.5] + self.m_vals = [6, 1, 20] + self.invalid_m_vals = [0, 2.5, -3] + self.t_vals = [6, 1, 20] + self.invalid_t_vals = [0, 2.5, -2] + self.invalid_z_vals = [-1, None] + xym_vals = [ + ((0, 3), 1), + ((5, 2), 6), + ((16, 1), 4), + ((1, 12), 3), + ((3, 0), 4), + ((5, 4), 6), + ((0, 3), 6), + ((6, 3), 5), + ] + self.xyms = xym_vals + [(xy, INFINITE) for xy, _ in xym_vals] + self.left_up_xyms = [((0, 3), 6), ((0, 3), INFINITE), ((11, 0), INFINITE), ((0, 5), 8)] + self.right_down_xyms = [((1, 12), 3), ((16, 1), 4)] + self.midgrid_xyms = [((5, 2), 6), ((5, 4), 6), ((6, 7), 5)] + + + @parameterized.expand( + [ + ((u, w, k, j, z), (m, t)) + for u in U_VALS + for j in J_VALS + for m in M_VALS + for t in T_VALS + for w in range(2 * m + 1) + for k in range(t) + for z in range(m) + ][::100] + ) + def test_znode_topology_coord_runs(self, uwkjz, mt): + ZephyrNode(coord=uwkjz, shape=mt) + + @parameterized.expand( + [ + ((-1, 2), 6), + ((6, -1), 4), + ((1, 3), -5), + ((1, 3), 6), + ((2, 4), 6), + ((17, 0), 4), + ((0, 17), 4), + ((0, 0, 0), 1), + ((-1, 2), None), + ] + ) + def test_bad_args_raises_error_ccoord(self, xy, m): + with self.assertRaises((ValueError, TypeError)): + ZephyrNode(coord=xy, shape=ZephyrShape(m=m)) + + + + + @parameterized.expand( + [ + ((2, 0, 0, 0, 0), (6, 4)), # All good except u_val + ((None, 3, 0, 0, 4), (6, 1)), # All good except u_val + ((3.5, 8, 2, 0, 0), (8, 4)), # All good except u_val + ((0, -1, 1, 1, 3), (6, 4)), # All good except w_val + ((0, 10, 2.5, 0, 5), (6, 4)), # All good except k_val + ((1, 23, -1, 1, 5), (12, 2)), # All good except k_val + ((1, 24, 1, 3.5, 9), (12, 4)), # All good except j_val + ((1, 24, 0, 1, None), (12, 2)), # All good except z_val + ((1, 20, 3, 1, 12), (12, 6)), # All good except z_val + ((0, 0, 0, 0, 0), (0, 0)), # All good except m_val + ((0, 0, 0, 0, 0), (1, -1)), # All good except t_val + ] + ) + def test_bad_args_raises_error_zcoord(self, uwkjz, mt): + with self.assertRaises((ValueError, TypeError)): + ZephyrNode(coord=uwkjz, shape=mt) + + @parameterized.expand( + [ + ((0, 3), 6, ZephyrPlaneShift(-1, -1)), + ((0, 3), INFINITE, ZephyrPlaneShift(-1, -1)), + ((11, 0), INFINITE, ZephyrPlaneShift(-1, -1)), + ((0, 5), 8, ZephyrPlaneShift(-1, -1)), + ((1, 12), 3, ZephyrPlaneShift(1, 1)), + ((16, 1), 4, ZephyrPlaneShift(1, 1)), + ] + ) + def test_add_sub_raises_error_invalid(self, xy, m, zps) -> None: + zn = ZephyrNode(xy, ZephyrShape(m=m)) + with self.assertRaises(ValueError): + zn + zps + + @parameterized.expand( + [ + (ZephyrNode((5, 2), ZephyrShape(6)), ZephyrPlaneShift(-2, -2), ZephyrNode((3, 0), ZephyrShape(6))), + (ZephyrNode((5, 2), ZephyrShape(6)), ZephyrPlaneShift(2, -2), ZephyrNode((7, 0), ZephyrShape(6))), + (ZephyrNode((5, 2), ZephyrShape(6)), ZephyrPlaneShift(2, 2), ZephyrNode((7, 4), ZephyrShape(6))), + (ZephyrNode((5, 2), ZephyrShape(6)), ZephyrPlaneShift(-2, 2), ZephyrNode((3, 4), ZephyrShape(6))), + (ZephyrNode((5, 4), ZephyrShape(4)), ZephyrPlaneShift(-4, -4), ZephyrNode((1, 0), ZephyrShape(4))), + (ZephyrNode((6, 7), ZephyrShape(5)), ZephyrPlaneShift(1, 11), ZephyrNode((7, 18), ZephyrShape(5))), + (ZephyrNode((0, 3), ZephyrShape(6)), ZephyrPlaneShift(0, 0), ZephyrNode((0, 3), ZephyrShape(6))), + (ZephyrNode((1, 12), ZephyrShape(3)), ZephyrPlaneShift(1, -1), ZephyrNode((2, 11), ZephyrShape(3))), + ] + ) + def test_add_sub(self, zn, zps, expected) -> None: + self.assertEqual(zn + zps, expected) + + def test_neighbors_boundary(self) -> None: + x, y, k, t = 1, 12, 4, 6 + expected_nbrs = { + ZephyrNode((x + i, y + j, kp), ZephyrShape(t=t)) + for i in (-1, 1) + for j in (-1, 1) + for kp in range(t) + } + expected_nbrs |= {ZephyrNode((x + 2, y, k), ZephyrShape(t=t)), ZephyrNode((x + 4, y, k), ZephyrShape(t=t))} + self.assertEqual(set(ZephyrNode((x, y, k), ZephyrShape(t=t)).neighbors()), expected_nbrs) + + def test_neighbors_mid(self): + x, y, k, t = 10, 5, 3, 6 + expected_nbrs = { + ZephyrNode((x + i, y + j, kp), ZephyrShape(t=t)) + for i in (-1, 1) + for j in (-1, 1) + for kp in range(t) + } + expected_nbrs |= {ZephyrNode((x, y + 4, k), ZephyrShape(t=t)), ZephyrNode((x, y - 4, k), ZephyrShape(t=t))} + expected_nbrs |= {ZephyrNode((x, y + 2, k), ZephyrShape(t=t)), ZephyrNode((x, y - 2, k), ZephyrShape(t=t))} + self.assertEqual(set(ZephyrNode((x, y, k), ZephyrShape(t=t)).neighbors()), expected_nbrs) + + def test_zcoord(self) -> None: + ZephyrNode((11, 12, 4), ZephyrShape(t=6)).zcoord == ZephyrCoord(1, 0, 4, 0, 2) + ZephyrNode((1, 0)).zcoord == ZephyrCoord(1, 0, QUOTIENT, 0, 0) + ZephyrNode((0, 1)).zcoord == ZephyrCoord(0, 0, QUOTIENT, 0, 0) + + @parameterized.expand( + [ + ((u, w, k, j, z), (m, t)) + for u in U_VALS + for j in J_VALS + for m in M_VALS + for t in T_VALS + for w in range(2 * m + 1) + for k in range(t) + for z in range(m) + ][::300] + ) + def test_direction(self, uwkjz, mt) -> None: + zn = ZephyrNode(coord=uwkjz, shape=mt) + self.assertEqual(zn.direction, uwkjz[0]) + + @parameterized.expand( + [ + ((u, w, k, j, z), (m, t)) + for u in U_VALS + for j in J_VALS + for m in M_VALS + for t in T_VALS + for w in range(2 * m + 1) + for k in range(t) + for z in range(m) + ][::200] + ) + def test_node_kind(self, uwkjz, mt) -> None: + zn = ZephyrNode(coord=uwkjz, shape=mt) + if uwkjz[0] == 0: + self.assertTrue(zn.is_vertical()) + self.assertEqual(zn.node_kind, NodeKind.VERTICAL) + else: + self.assertTrue(zn.is_horizontal()) + self.assertEqual(zn.node_kind, NodeKind.HORIZONTAL) + + @parameterized.expand( + [ + (ZephyrNode((1, 0)), EdgeKind.INTERNAL), + (ZephyrNode((1, 2)), EdgeKind.INTERNAL), + (ZephyrNode((0, 3)), EdgeKind.ODD), + (ZephyrNode((0, 5)), EdgeKind.EXTERNAL), + (ZephyrNode((0, 7)), EdgeKind.INVALID), + (ZephyrNode((1, 6)), EdgeKind.INVALID), + ] + ) + def test_neighbor_kind(self, zn1, nbr_kind) -> None: + zn0 = ZephyrNode((0, 1)) + self.assertIs(zn0.neighbor_edge_kind(zn1), nbr_kind) + + @parameterized.expand( + [ + (ZephyrNode((0, 1)), {ZephyrNode((1, 0)), ZephyrNode((1, 2))}), + ( + ZephyrNode((0, 1, 0), ZephyrShape(t=4)), + {ZephyrNode((1, 0, k), ZephyrShape(t=4)) for k in range(4)} + | {ZephyrNode((1, 2, k), ZephyrShape(t=4)) for k in range(4)}, + ), + ] + ) + def test_internal(self, zn, expected) -> None: + set_internal = {x for x in zn.internal_neighbors()} + self.assertEqual(set_internal, expected) + + @parameterized.expand( + [ + (ZephyrNode((0, 1)), {ZephyrNode((0, 5))}), + (ZephyrNode((0, 1, 2), ZephyrShape(t=4)), {ZephyrNode((0, 5, 2), ZephyrShape(t=4))}), + ( + ZephyrNode((11, 6, 3), ZephyrShape(t=4)), + {ZephyrNode((7, 6, 3), ZephyrShape(t=4)), ZephyrNode((15, 6, 3), ZephyrShape(t=4))}, + ), + ] + ) + def test_external(self, zn, expected) -> None: + set_external = {x for x in zn.external_neighbors()} + self.assertEqual(set_external, expected) + + @parameterized.expand( + [ + (ZephyrNode((0, 1)), {ZephyrNode((0, 3))}), + (ZephyrNode((0, 1, 2), ZephyrShape(t=4)), {ZephyrNode((0, 3, 2), ZephyrShape(t=4))}), + (ZephyrNode((15, 8)), {ZephyrNode((13, 8)), ZephyrNode((17, 8))}), + ] + ) + def test_odd(self, zn, expected) -> None: + set_odd = {x for x in zn.odd_neighbors()} + self.assertEqual(set_odd, expected) + + @parameterized.expand( + [ + ((5, 2), 6, None, 4, 4), + ((5, 2), 6, EdgeKind.INTERNAL, 4, 0), + ((5, 2), 10, EdgeKind.EXTERNAL, 0, 2), + ((5, 2), 4, [EdgeKind.INTERNAL, EdgeKind.EXTERNAL], 4, 2), + ((5, 2), 3, [EdgeKind.ODD], 0, 2), + ((6, 7), 12, None, 4, 4), + ((6, 7), 8, EdgeKind.INTERNAL, 4, 0), + ((6, 7), 12, EdgeKind.EXTERNAL, 0, 2), + ((6, 7), 12, EdgeKind.ODD, 0, 2), + ((0, 1), 4, EdgeKind.INTERNAL, 2, 0), + ((0, 1), 2, EdgeKind.ODD, 0, 1), + ((0, 1), 4, EdgeKind.EXTERNAL, 0, 1), + ((0, 1), 4, None, 2, 2), + ((24, 5), INFINITE, EdgeKind.INTERNAL, 4, 0), + ((24, 5), INFINITE, EdgeKind.ODD, 0, 2), + ((24, 5), INFINITE, EdgeKind.EXTERNAL, 0, 2), + ((24, 5), INFINITE, None, 4, 4), + ((24, 5), 6, EdgeKind.INTERNAL, 2, 0), + ((24, 5), 8, EdgeKind.ODD, 0, 2), + ((24, 5), 6, EdgeKind.EXTERNAL, 0, 2), + ((24, 5), 8, None, 4, 4), + ((24, 5), 8, EdgeKind.INVALID, 0, 0), + ] + ) + def test_degree(self, xy, m, nbr_kind, a, b) -> None: + for t in [QUOTIENT, 1, 4, 6]: + if t is QUOTIENT: + coord, t_p = xy, 1 + else: + coord, t_p = xy + (0, ), t + zn = ZephyrNode(coord=coord, shape=ZephyrShape(m=m, t=t)) + + with self.subTest(case=t): + self.assertEqual(zn.degree(nbr_kind=nbr_kind), a * t_p + b) + diff --git a/tests/test_zplaneshift.py b/tests/test_zplaneshift.py new file mode 100755 index 00000000..c253bdc4 --- /dev/null +++ b/tests/test_zplaneshift.py @@ -0,0 +1,91 @@ +# Copyright 2026 D-Wave +# +# 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. +# +# ================================================================================================ + + +import unittest +from itertools import combinations + +from parameterized import parameterized + +from dwave.graphs.generators.zephyr import ZephyrPlaneShift + + +class TestZephyrPlaneShift(unittest.TestCase): + def setUp(self) -> None: + self.shifts = [ + (0, 2), + (-3, -1), + (-2, 0), + (1, 1), + (1, -3), + (-4, 6), + (10, 4), + (0, 0), + ] + + def test_construction(self) -> None: + for shift in self.shifts: + zps = ZephyrPlaneShift(*shift) + self.assertEqual(shift, (zps.x, zps.y)) + + @parameterized.expand( + [ + (0,), + (1,), + (2,), + (5,), + (10,), + (-3,), + ] + ) + def test_multiply(self, scale) -> None: + for shift in self.shifts: + self.assertEqual( + ZephyrPlaneShift(shift[0] * scale, shift[1] * scale), ZephyrPlaneShift(*shift) * scale + ) + self.assertEqual( + ZephyrPlaneShift(shift[0] * scale, shift[1] * scale), scale * ZephyrPlaneShift(*shift) + ) + + def test_add(self) -> None: + for s0, s1 in combinations(self.shifts, 2): + self.assertEqual( + ZephyrPlaneShift(*s0) + ZephyrPlaneShift(*s1), ZephyrPlaneShift(s0[0] + s1[0], s0[1] + s1[1]) + ) + + @parameterized.expand( + [ + (-1, ZephyrPlaneShift(0, -4), ZephyrPlaneShift(0, 4)), + (3, ZephyrPlaneShift(2, 10), ZephyrPlaneShift(6, 30)), + ] + ) + def test_mul(self, c, ps, expected) -> None: + self.assertEqual(c * ps, expected) + + @parameterized.expand( + [ + (5, TypeError), + ("NE", TypeError), + ((0, 2, None), TypeError), + ((2, 0.5), ValueError), + ((4, 1), ValueError), + ((0, 1), ValueError), + ] + ) + def test_invalid_input_gives_error(self, invalid, expected_err) -> None: + with self.assertRaises(expected_err): + ZephyrPlaneShift(*invalid) + diff --git a/tests/test_zshape.py b/tests/test_zshape.py new file mode 100755 index 00000000..9e9d0952 --- /dev/null +++ b/tests/test_zshape.py @@ -0,0 +1,76 @@ +# Copyright 2026 D-Wave +# +# 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. +# +# ================================================================================================ + + +from unittest import TestCase + +from parameterized import parameterized +from dwave.graphs.generators.common import (QUOTIENT, INFINITE) +from dwave.graphs.generators.zephyr import ZephyrShape + + +class TestZephyrShape(TestCase): + @parameterized.expand( + [((-1, 2), ),((0, 3), ), ((3, -1),), ((QUOTIENT, 3),), ((3, INFINITE),), ((3, 0), )]) + def test_invalid_raises_error(self, bad_input): + with self.assertRaises((ValueError, TypeError)): + ZephyrShape(*bad_input) + + + @parameterized.expand( + [ + ((4, 6), ), ((1, 1), ), ((1, 10), ), ((), ), + ((INFINITE, QUOTIENT),), ((INFINITE, 1), ), ((3, QUOTIENT), ) + ] + ) + def test_valid_runs(self, good_shape): + ZephyrShape(*good_shape) + + @parameterized.expand( + [ + ((2, QUOTIENT), True), ((INFINITE, QUOTIENT), True), + ((INFINITE, 1), False), ((4, 6), False), ((1, 1), False), + ] + ) + def test_is_quotient(self, shape, expected): + self.assertEqual(ZephyrShape(*shape).is_quotient(), expected) + + + @parameterized.expand( + [ + ((2, 3), (2, QUOTIENT)), ((1, QUOTIENT), (1, QUOTIENT)), ((INFINITE, 3), (INFINITE, QUOTIENT)) + ] + ) + def test_to_quotient(self, shape, shape_quo): + self.assertEqual(ZephyrShape(*shape).to_quotient(), ZephyrShape(*shape_quo)) + + @parameterized.expand( + [ + ((2, 3), (INFINITE, 3)), ((1, QUOTIENT), (INFINITE, QUOTIENT)), ((INFINITE, 4), (INFINITE, 4)) + ] + ) + def test_to_infinite(self, shape, shape_inf): + self.assertEqual(ZephyrShape(*shape).to_infinite(), ZephyrShape(*shape_inf)) + + @parameterized.expand( + [ + ((INFINITE, 2), True), ((INFINITE, QUOTIENT), True), + ((1, QUOTIENT), False), ((4, 6), False), ((1, 1), False), + ] + ) + def test_is_infinite(self, shape, expected): + self.assertEqual(ZephyrShape(*shape).is_infinite(), expected) +