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+ )
0 commit comments