Skip to content

Commit 6a1cc7d

Browse files
committed
Add GraphMonomorphismMapper initial placement strategy
1 parent bd99397 commit 6a1cc7d

6 files changed

Lines changed: 332 additions & 0 deletions

File tree

cirq-core/cirq/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -368,6 +368,7 @@
368368
index_tags as index_tags,
369369
is_negligible_turn as is_negligible_turn,
370370
LineInitialMapper as LineInitialMapper,
371+
GraphMonomorphismMapper as GraphMonomorphismMapper,
371372
MappingManager as MappingManager,
372373
map_clean_and_borrowable_qubits as map_clean_and_borrowable_qubits,
373374
map_moments as map_moments,

cirq-core/cirq/protocols/json_test_data/spec.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@
9191
# Routing utilities
9292
'HardCodedInitialMapper',
9393
'LineInitialMapper',
94+
'GraphMonomorphismMapper',
9495
'MappingManager',
9596
'RouteCQC',
9697
# Qubit Managers,

cirq-core/cirq/transformers/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
AbstractInitialMapper as AbstractInitialMapper,
5252
HardCodedInitialMapper as HardCodedInitialMapper,
5353
LineInitialMapper as LineInitialMapper,
54+
GraphMonomorphismMapper as GraphMonomorphismMapper,
5455
MappingManager as MappingManager,
5556
RouteCQC as RouteCQC,
5657
routed_circuit_with_mapping as routed_circuit_with_mapping,

cirq-core/cirq/transformers/routing/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@
2323

2424
from cirq.transformers.routing.line_initial_mapper import LineInitialMapper as LineInitialMapper
2525

26+
from cirq.transformers.routing.graph_monomorphism_mapper import (
27+
GraphMonomorphismMapper as GraphMonomorphismMapper,
28+
)
29+
2630
from cirq.transformers.routing.route_circuit_cqc import RouteCQC as RouteCQC
2731

2832
from cirq.transformers.routing.visualize_routed_circuit import (
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
# Copyright 2026
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# https://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Maps logical to physical qubits by finding a graph monomorphism into the device graph.
16+
17+
This mapper builds an *interaction graph* from the circuit (logical qubits as nodes, and an edge
18+
between two logical qubits if they participate in any 2-qubit operation). It then attempts to find
19+
an injective mapping of logical nodes into physical nodes such that every logical edge maps to a
20+
physical edge (i.e. a subgraph/monomorphism embedding).
21+
22+
If multiple embeddings exist, it chooses the one that (heuristically) is most "central" on the
23+
device by minimizing total distance-to-center and then (tie-break) maximizing total degree.
24+
25+
If no monomorphism exists, it raises ValueError (so a router can fall back to a different strategy).
26+
"""
27+
28+
from __future__ import annotations
29+
30+
from typing import TYPE_CHECKING, Dict, Iterable, Optional, Tuple
31+
32+
import networkx as nx
33+
34+
from cirq import protocols, value
35+
from cirq.transformers.routing import initial_mapper
36+
37+
if TYPE_CHECKING:
38+
import cirq
39+
40+
41+
@value.value_equality
42+
class GraphMonomorphismMapper(initial_mapper.AbstractInitialMapper):
43+
"""Places logical qubits onto physical qubits via graph monomorphism (subgraph embedding)."""
44+
45+
def __init__(
46+
self,
47+
device_graph: nx.Graph,
48+
*,
49+
max_matches: int = 5_000,
50+
timeout_steps: Optional[int] = None,
51+
) -> None:
52+
"""
53+
Args:
54+
device_graph: Device connectivity graph (physical qubits are nodes).
55+
If directed, we treat it as undirected for the purposes of placement.
56+
max_matches: Max number of candidate embeddings to consider before picking best-so-far.
57+
timeout_steps: Optional hard cap on internal iteration steps (additional guardrail).
58+
"""
59+
# For placement, treat connectivity as undirected adjacency.
60+
# (If you need strict directionality, you'd do a DiGraph monomorphism with edge constraints.)
61+
if nx.is_directed(device_graph):
62+
ug = nx.Graph()
63+
ug.add_nodes_from(sorted(list(device_graph.nodes(data=True))))
64+
ug.add_edges_from(sorted(tuple(sorted(e)) for e in device_graph.edges))
65+
self.device_graph = ug
66+
else:
67+
ug = nx.Graph()
68+
ug.add_nodes_from(sorted(list(device_graph.nodes(data=True))))
69+
ug.add_edges_from(sorted(tuple(sorted(e)) for e in device_graph.edges))
70+
self.device_graph = ug
71+
72+
# Center is used only as a heuristic scoring anchor.
73+
# (nx.center returns nodes with minimum eccentricity.)
74+
self.center = nx.center(self.device_graph)[0]
75+
self.max_matches = int(max_matches)
76+
self.timeout_steps = None if timeout_steps is None else int(timeout_steps)
77+
78+
def _make_circuit_interaction_graph(self, circuit: cirq.AbstractCircuit) -> nx.Graph:
79+
"""Builds the circuit interaction graph from 2-qubit operations."""
80+
g = nx.Graph()
81+
logical_qubits = sorted(circuit.all_qubits())
82+
g.add_nodes_from(logical_qubits)
83+
84+
for op in circuit.all_operations():
85+
if protocols.num_qubits(op) != 2:
86+
continue
87+
q0, q1 = op.qubits
88+
if q0 == q1:
89+
continue
90+
# Coalesce repeated interactions into a single simple edge.
91+
g.add_edge(q0, q1)
92+
93+
return g
94+
95+
def _score_embedding(
96+
self,
97+
logical_to_physical: Dict["cirq.Qid", "cirq.Qid"],
98+
dist_to_center: Dict["cirq.Qid", int],
99+
) -> Tuple[int, int]:
100+
"""
101+
Lower score is better.
102+
103+
Primary: sum of distances to center (more compact/central).
104+
Tie-break: -sum degrees (prefer higher-degree placements).
105+
"""
106+
total_dist = 0
107+
total_degree = 0
108+
for _, pq in logical_to_physical.items():
109+
total_dist += dist_to_center.get(pq, 10**9)
110+
total_degree += self.device_graph.degree(pq)
111+
return (total_dist, -total_degree)
112+
113+
def initial_mapping(self, circuit: cirq.AbstractCircuit) -> Dict["cirq.Qid", "cirq.Qid"]:
114+
"""
115+
Returns:
116+
dict mapping logical qubits -> physical qubits.
117+
118+
Raises:
119+
ValueError if no graph monomorphism embedding exists.
120+
"""
121+
circuit_g = self._make_circuit_interaction_graph(circuit)
122+
123+
# Trivial fast path: no qubits.
124+
if circuit_g.number_of_nodes() == 0:
125+
return {}
126+
127+
# If the circuit has more logical qubits than device has physical qubits, impossible.
128+
if circuit_g.number_of_nodes() > self.device_graph.number_of_nodes():
129+
raise ValueError("Circuit has more qubits than the device graph can host.")
130+
131+
# Precompute distances to the device center for scoring.
132+
dist_to_center = dict(nx.single_source_shortest_path_length(self.device_graph, self.center))
133+
134+
# NetworkX subgraph isomorphism:
135+
# GraphMatcher(G_big, G_small).subgraph_isomorphisms_iter()
136+
# yields mappings: big_node -> small_node.
137+
matcher = nx.algorithms.isomorphism.GraphMatcher(self.device_graph, circuit_g)
138+
139+
best_map: Optional[Dict["cirq.Qid", "cirq.Qid"]] = None
140+
best_score: Optional[Tuple[int, int]] = None
141+
142+
steps = 0
143+
matches_seen = 0
144+
145+
for big_to_small in matcher.subgraph_isomorphisms_iter():
146+
# Optional guardrails.
147+
steps += 1
148+
if self.timeout_steps is not None and steps > self.timeout_steps:
149+
break
150+
151+
# Invert to get logical -> physical.
152+
# big_to_small: physical -> logical
153+
logical_to_physical = {lq: pq for pq, lq in big_to_small.items()}
154+
155+
# Ensure all logical nodes are mapped (they should be, but be defensive).
156+
if len(logical_to_physical) != circuit_g.number_of_nodes():
157+
continue
158+
159+
score = self._score_embedding(logical_to_physical, dist_to_center)
160+
if best_score is None or score < best_score:
161+
best_score = score
162+
best_map = logical_to_physical
163+
164+
matches_seen += 1
165+
if matches_seen >= self.max_matches:
166+
break
167+
168+
if best_map is None:
169+
raise ValueError(
170+
"No graph monomorphism embedding found for circuit interaction graph "
171+
"into device graph."
172+
)
173+
174+
return best_map
175+
176+
def _value_equality_values_(self):
177+
return (
178+
tuple(self.device_graph.nodes),
179+
tuple(self.device_graph.edges),
180+
self.max_matches,
181+
self.timeout_steps,
182+
)
183+
184+
def __repr__(self):
185+
graph_type = type(self.device_graph).__name__
186+
return (
187+
"cirq.GraphMonomorphismMapper("
188+
f"nx.{graph_type}({dict(self.device_graph.adjacency())}), "
189+
f"max_matches={self.max_matches}, timeout_steps={self.timeout_steps})"
190+
)
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
# Copyright 2026
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# https://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
from __future__ import annotations
16+
17+
import networkx as nx
18+
import pytest
19+
20+
import cirq
21+
22+
# Unit tests: keep N small and VF2 bounded.
23+
_MAX_MATCHES = 1 # stop at first embedding
24+
_TIMEOUT_STEPS = 2_000 # hard cap search work
25+
26+
27+
def construct_star_circuit_5q():
28+
# Interaction graph edges: (1-3), (2-3), (4-3). Center has degree 3.
29+
return cirq.Circuit(
30+
[
31+
cirq.Moment(cirq.CNOT(cirq.NamedQubit("1"), cirq.NamedQubit("3"))),
32+
cirq.Moment(cirq.CNOT(cirq.NamedQubit("2"), cirq.NamedQubit("3"))),
33+
cirq.Moment(cirq.CNOT(cirq.NamedQubit("4"), cirq.NamedQubit("3"))),
34+
cirq.Moment(cirq.X(cirq.NamedQubit("5"))),
35+
]
36+
)
37+
38+
39+
def construct_path_circuit(k: int):
40+
q = cirq.LineQubit.range(k)
41+
return cirq.Circuit(cirq.CNOT(q[i], q[i + 1]) for i in range(k - 1))
42+
43+
44+
def _interaction_graph(circuit: cirq.AbstractCircuit) -> nx.Graph:
45+
g = nx.Graph()
46+
g.add_nodes_from(sorted(circuit.all_qubits()))
47+
for op in circuit.all_operations():
48+
if cirq.num_qubits(op) != 2:
49+
continue
50+
a, b = op.qubits
51+
if a != b:
52+
g.add_edge(a, b)
53+
return g
54+
55+
56+
def _assert_is_monomorphism(
57+
circuit: cirq.AbstractCircuit, device_graph: nx.Graph, mapping: dict[cirq.Qid, cirq.Qid]
58+
) -> None:
59+
# Injective + total.
60+
assert len(set(mapping.values())) == len(mapping.values())
61+
assert set(mapping.keys()) == set(circuit.all_qubits())
62+
63+
# Edge-preserving.
64+
cg = _interaction_graph(circuit)
65+
dg = device_graph.to_undirected() if nx.is_directed(device_graph) else device_graph
66+
for u, v in cg.edges:
67+
pu, pv = mapping[u], mapping[v]
68+
assert dg.has_edge(pu, pv)
69+
70+
71+
def test_path_embeds_on_small_grid() -> None:
72+
# Small (<= 10) always embeddable on 4x4 grid, should be very fast.
73+
circuit = construct_path_circuit(10)
74+
device = cirq.testing.construct_grid_device(4, 4) # 16 physical
75+
g = device.metadata.nx_graph
76+
77+
mapper = cirq.GraphMonomorphismMapper(g, max_matches=_MAX_MATCHES, timeout_steps=_TIMEOUT_STEPS)
78+
mapping = mapper.initial_mapping(circuit)
79+
80+
_assert_is_monomorphism(circuit, g, mapping)
81+
device.validate_circuit(circuit.transform_qubits(mapping))
82+
83+
84+
def test_star_embeds_on_small_grid() -> None:
85+
# Degree-3 center requires a physical node with degree >= 3 (grid has it).
86+
circuit = construct_star_circuit_5q()
87+
device = cirq.testing.construct_grid_device(4, 4)
88+
g = device.metadata.nx_graph
89+
90+
mapper = cirq.GraphMonomorphismMapper(g, max_matches=_MAX_MATCHES, timeout_steps=_TIMEOUT_STEPS)
91+
mapping = mapper.initial_mapping(circuit)
92+
93+
_assert_is_monomorphism(circuit, g, mapping)
94+
device.validate_circuit(circuit.transform_qubits(mapping))
95+
96+
97+
def test_star_fails_on_ring() -> None:
98+
# Ring max degree is 2, but star requires degree 3 -> impossible.
99+
circuit = construct_star_circuit_5q()
100+
device = cirq.testing.construct_ring_device(10, directed=True)
101+
g = device.metadata.nx_graph
102+
103+
mapper = cirq.GraphMonomorphismMapper(g, max_matches=_MAX_MATCHES, timeout_steps=500)
104+
with pytest.raises(ValueError, match="No graph monomorphism embedding found"):
105+
mapper.initial_mapping(circuit)
106+
107+
108+
def test_path_embeds_on_ring() -> None:
109+
# A path (max degree 2) should embed on a ring.
110+
circuit = construct_path_circuit(6)
111+
device = cirq.testing.construct_ring_device(10, directed=True)
112+
g = device.metadata.nx_graph
113+
114+
mapper = cirq.GraphMonomorphismMapper(g, max_matches=_MAX_MATCHES, timeout_steps=_TIMEOUT_STEPS)
115+
mapping = mapper.initial_mapping(circuit)
116+
117+
_assert_is_monomorphism(circuit, g, mapping)
118+
device.validate_circuit(circuit.transform_qubits(mapping))
119+
120+
121+
def test_more_logical_than_physical_fails_fast() -> None:
122+
# Keep small, but verify the early size check.
123+
circuit = construct_path_circuit(17) # 17 logical
124+
device = cirq.testing.construct_grid_device(4, 4) # 16 physical
125+
g = device.metadata.nx_graph
126+
127+
mapper = cirq.GraphMonomorphismMapper(g, max_matches=_MAX_MATCHES, timeout_steps=_TIMEOUT_STEPS)
128+
with pytest.raises(ValueError, match="more qubits than the device graph can host"):
129+
mapper.initial_mapping(circuit)
130+
131+
132+
def test_repr() -> None:
133+
g = cirq.testing.construct_grid_device(4, 4).metadata.nx_graph
134+
mapper = cirq.GraphMonomorphismMapper(g, max_matches=123, timeout_steps=456)
135+
cirq.testing.assert_equivalent_repr(mapper, setup_code="import cirq\nimport networkx as nx")

0 commit comments

Comments
 (0)