diff --git a/source/pip/qsharp/magnets/geometry/__init__.py b/source/pip/qsharp/magnets/geometry/__init__.py index 649b2a37b2..beecd639f2 100644 --- a/source/pip/qsharp/magnets/geometry/__init__.py +++ b/source/pip/qsharp/magnets/geometry/__init__.py @@ -8,6 +8,13 @@ and interaction graphs. """ -from .hypergraph import Hyperedge, Hypergraph +from .hypergraph import Hyperedge, Hypergraph, greedy_edge_coloring +from .lattice1d import Chain1D, Ring1D -__all__ = ["Hyperedge", "Hypergraph"] +__all__ = [ + "Hyperedge", + "Hypergraph", + "greedy_edge_coloring", + "Chain1D", + "Ring1D", +] diff --git a/source/pip/qsharp/magnets/geometry/hypergraph.py b/source/pip/qsharp/magnets/geometry/hypergraph.py index dd55ebf408..f64dc79e63 100644 --- a/source/pip/qsharp/magnets/geometry/hypergraph.py +++ b/source/pip/qsharp/magnets/geometry/hypergraph.py @@ -9,7 +9,9 @@ Hamiltonians, where multi-body interactions can involve more than two sites. """ -from typing import Iterator +from copy import deepcopy +import random +from typing import Iterator, Optional class Hyperedge: @@ -55,18 +57,20 @@ class Hypergraph: various lattice geometries used in quantum simulations. Attributes: - _edges: List of hyperedges in the order they were added. + _edge_list: List of hyperedges in the order they were added. _vertex_set: Set of all unique vertex indices in the hypergraph. - _edge_list: Set of hyperedges for efficient membership testing. + parts: List of lists, where each sublist contains indices of edges + belonging to a specific part of an edge partitioning. This is useful + for parallelism in certain architectures. Example: .. code-block:: python >>> edges = [Hyperedge([0, 1]), Hyperedge([1, 2]), Hyperedge([0, 2])] >>> graph = Hypergraph(edges) - >>> graph.nvertices() + >>> graph.nvertices 3 - >>> graph.nedges() + >>> graph.nedges 3 """ @@ -76,44 +80,156 @@ def __init__(self, edges: list[Hyperedge]) -> None: Args: edges: List of hyperedges defining the hypergraph structure. """ - self._edges = edges self._vertex_set = set() - self._edge_list = set(edges) + self._edge_list = edges + self.parts = [list(range(len(edges)))] # Single partition by default for edge in edges: self._vertex_set.update(edge.vertices) @property def nedges(self) -> int: """Return the number of hyperedges in the hypergraph.""" - return len(self._edges) + return len(self._edge_list) @property def nvertices(self) -> int: """Return the number of vertices in the hypergraph.""" return len(self._vertex_set) + def add_edge(self, edge: Hyperedge, part: int = 0) -> None: + """Add a hyperedge to the hypergraph. + + Args: + edge: The Hyperedge instance to add. + part: Partition index, used for implementations + with edge partitioning for parallel updates. By + default, all edges are added to the single part + with index 0. + """ + self._edge_list.append(edge) + self._vertex_set.update(edge.vertices) + self.parts[part].append(len(self._edge_list) - 1) # Add to specified partition + def vertices(self) -> Iterator[int]: - """Return an iterator over vertices in sorted order. + """Iterate over all vertex indices in the hypergraph. Returns: - Iterator yielding vertex indices in ascending order. + Iterator of vertex indices in ascending order. """ return iter(sorted(self._vertex_set)) - def edges(self, part: int = 0) -> Iterator[Hyperedge]: - """Return an iterator over hyperedges in the hypergraph. + def edges(self) -> Iterator[Hyperedge]: + """Iterate over all hyperedges in the hypergraph. + + Returns: + Iterator of all hyperedges in the hypergraph. + """ + return iter(self._edge_list) + + def edges_by_part(self, part: int) -> Iterator[Hyperedge]: + """Iterate over hyperedges in a specific partition of the hypergraph. Args: - part: Partition index (reserved for subclass implementations - that support edge partitioning for parallel updates). + part: Partition index, used for implementations + with edge partitioning for parallel updates. By + default, all edges are in a single part with + index 0. Returns: - Iterator over all hyperedges in the hypergraph. + Iterator of hyperedges in the specified partition. """ - return iter(self._edge_list) + return iter([self._edge_list[i] for i in self.parts[part]]) def __str__(self) -> str: return f"Hypergraph with {self.nvertices} vertices and {self.nedges} edges." def __repr__(self) -> str: - return f"Hypergraph({list(self._edges)})" + return f"Hypergraph({list(self._edge_list)})" + + +def greedy_edge_coloring( + hypergraph: Hypergraph, # The hypergraph to color. + seed: Optional[int] = None, # Random seed for reproducibility. + trials: int = 1, # Number of trials to perform. +) -> Hypergraph: + """Perform a (nondeterministic) greedy edge coloring of the hypergraph. + Args: + hypergraph: The Hypergraph instance to color. + seed: Optional random seed for reproducibility. + trials: Number of trials to perform. The coloring with the fewest colors + will be returned. Default is 1. + + Returns: + A Hypergraph where each (hyper)edge is assigned a color + such that no two (hyper)edges sharing a vertex have the + same color. + """ + + best = Hypergraph(hypergraph._edge_list) # Placeholder for best coloring found + + if seed is not None: + random.seed(seed) + + # Shuffle edge indices to randomize insertion order + edge_indexes = list(range(hypergraph.nedges)) + random.shuffle(edge_indexes) + + best.parts = [[]] # Initialize with one empty color part + used_vertices = [set()] # Vertices used by each color + + for i in range(len(edge_indexes)): + edge = hypergraph._edge_list[edge_indexes[i]] + for j in range(len(best.parts) + 1): + + # If we've reached a new color, add it + if j == len(best.parts): + best.parts.append([]) + used_vertices.append(set()) + + # Check if this edge can be added to color j + # Note that we always match on the last color if it was added + # if so, add it and break + if not any(v in used_vertices[j] for v in edge.vertices): + best.parts[j].append(edge_indexes[i]) + used_vertices[j].update(edge.vertices) + break + + least_colors = len(best.parts) + + # To do: parallelize over trials + for trial in range(1, trials): + + # Set random seed for reproducibility + # Designed to work with parallel trials + if seed is not None: + random.seed(seed + trial) + + # Shuffle edge indices to randomize insertion order + edge_indexes = list(range(hypergraph.nedges)) + random.shuffle(edge_indexes) + + parts = [[]] # Initialize with one empty color part + used_vertices = [set()] # Vertices used by each color + + for i in range(len(edge_indexes)): + edge = hypergraph._edge_list[edge_indexes[i]] + for j in range(len(parts) + 1): + + # If we've reached a new color, add it + if j == len(parts): + parts.append([]) + used_vertices.append(set()) + + # Check if this edge can be added to color j + # if so, add it and break + if not any(v in used_vertices[j] for v in edge.vertices): + parts[j].append(edge_indexes[i]) + used_vertices[j].update(edge.vertices) + break + + # If this trial used fewer colors, update best + if len(parts) < least_colors: + least_colors = len(parts) + best.parts = deepcopy(parts) + + return best diff --git a/source/pip/qsharp/magnets/geometry/lattice1d.py b/source/pip/qsharp/magnets/geometry/lattice1d.py new file mode 100644 index 0000000000..a5a892fff4 --- /dev/null +++ b/source/pip/qsharp/magnets/geometry/lattice1d.py @@ -0,0 +1,119 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""One-dimensional lattice geometries for quantum simulations. + +This module provides classes for representing 1D lattice structures as +hypergraphs. These lattices are commonly used in quantum spin chain +simulations and other one-dimensional quantum systems. +""" + +from qsharp.magnets.geometry.hypergraph import Hyperedge, Hypergraph + + +class Chain1D(Hypergraph): + """A one-dimensional open chain lattice. + + Represents a linear chain of vertices with nearest-neighbor edges. + The chain has open boundary conditions, meaning the first and last + vertices are not connected. + + The edges are partitioned into two parts for parallel updates: + - Part 0 (if self_loops): Self-loop edges on each vertex + - Part 1: Even-indexed nearest-neighbor edges (0-1, 2-3, ...) + - Part 2: Odd-indexed nearest-neighbor edges (1-2, 3-4, ...) + + Attributes: + length: Number of vertices in the chain. + + Example: + + .. code-block:: python + >>> chain = Chain1D(4) + >>> chain.nvertices + 4 + >>> chain.nedges + 3 + """ + + def __init__(self, length: int, self_loops: bool = False) -> None: + """Initialize a 1D chain lattice. + + Args: + length: Number of vertices in the chain. + self_loops: If True, include self-loop edges on each vertex + for single-site terms. + """ + if self_loops: + _edges = [Hyperedge([i]) for i in range(length)] + else: + _edges = [] + + for i in range(length - 1): + _edges.append(Hyperedge([i, i + 1])) + super().__init__(_edges) + + # Set up edge partitions for parallel updates + if self_loops: + self.parts = [list(range(length - 1))] + else: + self.parts = [] + + self.parts.append(list(range(0, length - 1, 2))) + self.parts.append(list(range(1, length - 1, 2))) + + self.length = length + + +class Ring1D(Hypergraph): + """A one-dimensional ring (periodic chain) lattice. + + Represents a circular chain of vertices with nearest-neighbor edges. + The ring has periodic boundary conditions, meaning the first and last + vertices are connected. + + The edges are partitioned into two parts for parallel updates: + - Part 0 (if self_loops): Self-loop edges on each vertex + - Part 1: Even-indexed nearest-neighbor edges (0-1, 2-3, ...) + - Part 2: Odd-indexed nearest-neighbor edges (1-2, 3-4, ...) + + Attributes: + length: Number of vertices in the ring. + + Example: + + .. code-block:: python + >>> ring = Ring1D(4) + >>> ring.nvertices + 4 + >>> ring.nedges + 4 + """ + + def __init__(self, length: int, self_loops: bool = False) -> None: + """Initialize a 1D ring lattice. + + Args: + length: Number of vertices in the ring. + self_loops: If True, include self-loop edges on each vertex + for single-site terms. + """ + if self_loops: + _edges = [Hyperedge([i]) for i in range(length)] + else: + _edges = [] + + for i in range(length): + _edges.append(Hyperedge([i, (i + 1) % length])) + super().__init__(_edges) + + # Set up edge partitions for parallel updates + if self_loops: + self.parts = [list(range(length))] + else: + self.parts = [] + + self.parts.append(list(range(0, length, 2))) + self.parts.append(list(range(1, length, 2))) + + self.length = length diff --git a/source/pip/tests/magnets/test_hypergraph.py b/source/pip/tests/magnets/test_hypergraph.py index 5a050993c9..3063fcb727 100755 --- a/source/pip/tests/magnets/test_hypergraph.py +++ b/source/pip/tests/magnets/test_hypergraph.py @@ -3,7 +3,11 @@ """Unit tests for hypergraph data structures.""" -from qsharp.magnets.geometry.hypergraph import Hyperedge, Hypergraph +from qsharp.magnets.geometry.hypergraph import ( + Hyperedge, + Hypergraph, + greedy_edge_coloring, +) # Hyperedge tests @@ -48,6 +52,12 @@ def test_hyperedge_empty_vertices(): assert len(edge.vertices) == 0 +def test_hyperedge_duplicate_vertices(): + """Test that duplicate vertices are removed.""" + edge = Hyperedge([1, 2, 2, 1, 3]) + assert edge.vertices == [1, 2, 3] + + # Hypergraph tests @@ -103,15 +113,39 @@ def test_hypergraph_edges_iterator(): assert len(edge_list) == 2 -def test_hypergraph_edges_with_part_parameter(): - """Test edges iterator with part parameter (base class ignores it).""" +def test_hypergraph_edges_by_part(): + """Test edgesByPart returns edges in a specific partition.""" edges = [Hyperedge([0, 1]), Hyperedge([1, 2])] graph = Hypergraph(edges) - # Base class returns all edges regardless of part parameter - edge_list_0 = list(graph.edges(part=0)) - edge_list_1 = list(graph.edges(part=1)) - assert len(edge_list_0) == 2 - assert len(edge_list_1) == 2 + # Default: all edges in part 0 + edge_list = list(graph.edges_by_part(0)) + assert len(edge_list) == 2 + + +def test_hypergraph_add_edge(): + """Test adding an edge to the hypergraph.""" + graph = Hypergraph([]) + graph.add_edge(Hyperedge([0, 1])) + assert graph.nedges == 1 + assert graph.nvertices == 2 + + +def test_hypergraph_add_edge_to_part(): + """Test adding edges to different partitions.""" + graph = Hypergraph([Hyperedge([0, 1])]) + graph.parts.append([]) # Add a second partition + graph.add_edge(Hyperedge([2, 3]), part=1) + assert graph.nedges == 2 + assert len(graph.parts[0]) == 1 + assert len(graph.parts[1]) == 1 + + +def test_hypergraph_parts_default(): + """Test that default parts contain all edge indices.""" + edges = [Hyperedge([0, 1]), Hyperedge([1, 2]), Hyperedge([2, 3])] + graph = Hypergraph(edges) + assert len(graph.parts) == 1 + assert graph.parts[0] == [0, 1, 2] def test_hypergraph_str(): @@ -158,3 +192,141 @@ def test_hypergraph_non_contiguous_vertices(): assert graph.nvertices == 4 vertices = list(graph.vertices()) assert vertices == [0, 5, 10, 20] + + +# greedyEdgeColoring tests + + +def test_greedy_edge_coloring_empty(): + """Test greedy edge coloring on empty hypergraph.""" + graph = Hypergraph([]) + colored = greedy_edge_coloring(graph) + assert colored.nedges == 0 + assert len(colored.parts) == 1 + assert colored.parts[0] == [] + + +def test_greedy_edge_coloring_single_edge(): + """Test greedy edge coloring with a single edge.""" + graph = Hypergraph([Hyperedge([0, 1])]) + colored = greedy_edge_coloring(graph, seed=42) + assert colored.nedges == 1 + assert len(colored.parts) == 1 + + +def test_greedy_edge_coloring_non_overlapping(): + """Test coloring of non-overlapping edges (can share color).""" + edges = [Hyperedge([0, 1]), Hyperedge([2, 3])] + graph = Hypergraph(edges) + colored = greedy_edge_coloring(graph, seed=42) + # Non-overlapping edges can be in the same color + assert colored.nedges == 2 + assert len(colored.parts) == 1 + + +def test_greedy_edge_coloring_overlapping(): + """Test coloring of overlapping edges (need different colors).""" + edges = [Hyperedge([0, 1]), Hyperedge([1, 2])] + graph = Hypergraph(edges) + colored = greedy_edge_coloring(graph, seed=42) + # Overlapping edges need different colors + assert colored.nedges == 2 + assert len(colored.parts) == 2 + + +def test_greedy_edge_coloring_triangle(): + """Test coloring of a triangle (3 edges, all pairwise overlapping).""" + edges = [Hyperedge([0, 1]), Hyperedge([1, 2]), Hyperedge([0, 2])] + graph = Hypergraph(edges) + colored = greedy_edge_coloring(graph, seed=42) + # All edges share vertices pairwise, so need 3 colors + assert colored.nedges == 3 + assert len(colored.parts) == 3 + + +def test_greedy_edge_coloring_validity(): + """Test that coloring is valid (no two edges in same part share a vertex).""" + edges = [ + Hyperedge([0, 1]), + Hyperedge([1, 2]), + Hyperedge([2, 3]), + Hyperedge([3, 4]), + Hyperedge([0, 4]), + ] + graph = Hypergraph(edges) + colored = greedy_edge_coloring(graph, seed=42) + + # Verify each part has no overlapping edges + for part in colored.parts: + used_vertices = set() + for edge_idx in part: + edge = colored._edge_list[edge_idx] + # No vertex should already be used in this part + assert not any(v in used_vertices for v in edge.vertices) + used_vertices.update(edge.vertices) + + +def test_greedy_edge_coloring_all_edges_colored(): + """Test that all edges are assigned to exactly one part.""" + edges = [Hyperedge([0, 1]), Hyperedge([1, 2]), Hyperedge([2, 3])] + graph = Hypergraph(edges) + colored = greedy_edge_coloring(graph, seed=42) + + # Collect all edge indices from all parts + all_colored = [] + for part in colored.parts: + all_colored.extend(part) + + # Should have exactly 3 edges colored, each once + assert sorted(all_colored) == [0, 1, 2] + + +def test_greedy_edge_coloring_reproducible_with_seed(): + """Test that coloring is reproducible with the same seed.""" + edges = [Hyperedge([0, 1]), Hyperedge([1, 2]), Hyperedge([2, 3]), Hyperedge([0, 3])] + graph = Hypergraph(edges) + + colored1 = greedy_edge_coloring(graph, seed=123) + colored2 = greedy_edge_coloring(graph, seed=123) + + assert colored1.parts == colored2.parts + + +def test_greedy_edge_coloring_multiple_trials(): + """Test that multiple trials can find better colorings.""" + edges = [ + Hyperedge([0, 1]), + Hyperedge([1, 2]), + Hyperedge([2, 3]), + Hyperedge([3, 0]), + ] + graph = Hypergraph(edges) + colored = greedy_edge_coloring(graph, seed=42, trials=10) + # A cycle of 4 edges can be 2-colored + assert len(colored.parts) <= 3 # Greedy may not always find optimal + + +def test_greedy_edge_coloring_hyperedges(): + """Test coloring with multi-vertex hyperedges.""" + edges = [ + Hyperedge([0, 1, 2]), + Hyperedge([2, 3, 4]), + Hyperedge([5, 6, 7]), + ] + graph = Hypergraph(edges) + colored = greedy_edge_coloring(graph, seed=42) + + # First two share vertex 2, third is independent + assert colored.nedges == 3 + assert len(colored.parts) >= 2 + + +def test_greedy_edge_coloring_self_loops(): + """Test coloring with self-loop edges.""" + edges = [Hyperedge([0]), Hyperedge([1]), Hyperedge([2])] + graph = Hypergraph(edges) + colored = greedy_edge_coloring(graph, seed=42) + + # Self-loops don't share vertices, can all be same color + assert colored.nedges == 3 + assert len(colored.parts) == 1 diff --git a/source/pip/tests/magnets/test_lattice1d.py b/source/pip/tests/magnets/test_lattice1d.py new file mode 100644 index 0000000000..f940506f36 --- /dev/null +++ b/source/pip/tests/magnets/test_lattice1d.py @@ -0,0 +1,236 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Unit tests for 1D lattice data structures.""" + +from qsharp.magnets.geometry.lattice1d import Chain1D, Ring1D + + +# Chain1D tests + + +def test_chain1d_init_basic(): + """Test basic Chain1D initialization.""" + chain = Chain1D(4) + assert chain.nvertices == 4 + assert chain.nedges == 3 + assert chain.length == 4 + + +def test_chain1d_single_vertex(): + """Test Chain1D with a single vertex (no edges).""" + chain = Chain1D(1) + assert chain.nvertices == 0 + assert chain.nedges == 0 + assert chain.length == 1 + + +def test_chain1d_two_vertices(): + """Test Chain1D with two vertices (one edge).""" + chain = Chain1D(2) + assert chain.nvertices == 2 + assert chain.nedges == 1 + + +def test_chain1d_edges(): + """Test that Chain1D creates correct nearest-neighbor edges.""" + chain = Chain1D(4) + edges = list(chain.edges()) + assert len(edges) == 3 + # Check edges are [0,1], [1,2], [2,3] + assert edges[0].vertices == [0, 1] + assert edges[1].vertices == [1, 2] + assert edges[2].vertices == [2, 3] + + +def test_chain1d_vertices(): + """Test that Chain1D vertices are correct.""" + chain = Chain1D(5) + vertices = list(chain.vertices()) + assert vertices == [0, 1, 2, 3, 4] + + +def test_chain1d_with_self_loops(): + """Test Chain1D with self-loops enabled.""" + chain = Chain1D(4, self_loops=True) + assert chain.nvertices == 4 + # 4 self-loops + 3 nearest-neighbor edges = 7 + assert chain.nedges == 7 + + +def test_chain1d_self_loops_edges(): + """Test that self-loop edges are created correctly.""" + chain = Chain1D(3, self_loops=True) + edges = list(chain.edges()) + # First 3 edges should be self-loops + assert edges[0].vertices == [0] + assert edges[1].vertices == [1] + assert edges[2].vertices == [2] + # Next 2 edges should be nearest-neighbor + assert edges[3].vertices == [0, 1] + assert edges[4].vertices == [1, 2] + + +def test_chain1d_parts_without_self_loops(): + """Test edge partitioning without self-loops.""" + chain = Chain1D(5) + # Should have 2 parts: even edges [0,2] and odd edges [1,3] + assert len(chain.parts) == 2 + assert chain.parts[0] == [0, 2] # edges 0-1, 2-3 + assert chain.parts[1] == [1, 3] # edges 1-2, 3-4 + + +def test_chain1d_parts_with_self_loops(): + """Test edge partitioning with self-loops.""" + chain = Chain1D(4, self_loops=True) + # Should have 3 parts: self-loops, even edges, odd edges + assert len(chain.parts) == 3 + + +def test_chain1d_parts_non_overlapping(): + """Test that edges in the same part don't share vertices.""" + chain = Chain1D(6) + for part_indices in chain.parts: + used_vertices = set() + for idx in part_indices: + edge = chain._edge_list[idx] + assert not any(v in used_vertices for v in edge.vertices) + used_vertices.update(edge.vertices) + + +def test_chain1d_str(): + """Test string representation.""" + chain = Chain1D(4) + assert "4 vertices" in str(chain) + assert "3 edges" in str(chain) + + +# Ring1D tests + + +def test_ring1d_init_basic(): + """Test basic Ring1D initialization.""" + ring = Ring1D(4) + assert ring.nvertices == 4 + assert ring.nedges == 4 + assert ring.length == 4 + + +def test_ring1d_two_vertices(): + """Test Ring1D with two vertices (two edges, same pair).""" + ring = Ring1D(2) + assert ring.nvertices == 2 + # Edge 0-1 and edge 1-0 (wrapping), but both are [0,1] after sorting + assert ring.nedges == 2 + + +def test_ring1d_three_vertices(): + """Test Ring1D with three vertices (triangle).""" + ring = Ring1D(3) + assert ring.nvertices == 3 + assert ring.nedges == 3 + + +def test_ring1d_edges(): + """Test that Ring1D creates correct edges including wrap-around.""" + ring = Ring1D(4) + edges = list(ring.edges()) + assert len(edges) == 4 + # Check edges are [0,1], [1,2], [2,3], [0,3] (sorted) + assert edges[0].vertices == [0, 1] + assert edges[1].vertices == [1, 2] + assert edges[2].vertices == [2, 3] + assert edges[3].vertices == [0, 3] # Wrap-around edge + + +def test_ring1d_vertices(): + """Test that Ring1D vertices are correct.""" + ring = Ring1D(5) + vertices = list(ring.vertices()) + assert vertices == [0, 1, 2, 3, 4] + + +def test_ring1d_with_self_loops(): + """Test Ring1D with self-loops enabled.""" + ring = Ring1D(4, self_loops=True) + assert ring.nvertices == 4 + # 4 self-loops + 4 nearest-neighbor edges = 8 + assert ring.nedges == 8 + + +def test_ring1d_self_loops_edges(): + """Test that self-loop edges are created correctly.""" + ring = Ring1D(3, self_loops=True) + edges = list(ring.edges()) + # First 3 edges should be self-loops + assert edges[0].vertices == [0] + assert edges[1].vertices == [1] + assert edges[2].vertices == [2] + # Next 3 edges should be nearest-neighbor (including wrap) + assert edges[3].vertices == [0, 1] + assert edges[4].vertices == [1, 2] + assert edges[5].vertices == [0, 2] # Wrap-around + + +def test_ring1d_parts_without_self_loops(): + """Test edge partitioning without self-loops.""" + ring = Ring1D(4) + # Should have 2 parts for parallel updates + assert len(ring.parts) == 2 + + +def test_ring1d_parts_with_self_loops(): + """Test edge partitioning with self-loops.""" + ring = Ring1D(4, self_loops=True) + # Should have 3 parts: self-loops, even edges, odd edges + assert len(ring.parts) == 3 + + +def test_ring1d_parts_non_overlapping(): + """Test that edges in the same part don't share vertices.""" + ring = Ring1D(6) + for part_indices in ring.parts: + used_vertices = set() + for idx in part_indices: + edge = ring._edge_list[idx] + assert not any(v in used_vertices for v in edge.vertices) + used_vertices.update(edge.vertices) + + +def test_ring1d_str(): + """Test string representation.""" + ring = Ring1D(4) + assert "4 vertices" in str(ring) + assert "4 edges" in str(ring) + + +def test_ring1d_vs_chain1d_edge_count(): + """Test that ring has one more edge than chain of same length.""" + for length in range(2, 10): + chain = Chain1D(length) + ring = Ring1D(length) + assert ring.nedges == chain.nedges + 1 + + +def test_chain1d_inherits_hypergraph(): + """Test that Chain1D is a Hypergraph subclass with all methods.""" + from qsharp.magnets.geometry.hypergraph import Hypergraph + + chain = Chain1D(4) + assert isinstance(chain, Hypergraph) + # Test inherited methods work + assert hasattr(chain, "edges") + assert hasattr(chain, "vertices") + assert hasattr(chain, "edges_by_part") + + +def test_ring1d_inherits_hypergraph(): + """Test that Ring1D is a Hypergraph subclass with all methods.""" + from qsharp.magnets.geometry.hypergraph import Hypergraph + + ring = Ring1D(4) + assert isinstance(ring, Hypergraph) + # Test inherited methods work + assert hasattr(ring, "edges") + assert hasattr(ring, "vertices") + assert hasattr(ring, "edges_by_part")