1"""
2ISMAGS Algorithm
3================
4
5Provides a Python implementation of the ISMAGS algorithm. [1]_
6
7ISMAGS does a symmetry analysis to find the constraints on isomorphisms if
8we preclude yielding isomorphisms that differ by a symmetry of the subgraph.
9For example, if the subgraph contains a 4-cycle, every isomorphism would have a
10symmetric version with the nodes rotated relative to the original isomorphism.
11By encoding these symmetries as constraints we reduce the search space for
12isomorphisms and we also simplify processing the resulting isomorphisms.
13
14ISMAGS finds (subgraph) isomorphisms between two graphs, taking the
15symmetry of the subgraph into account. In most cases the VF2 algorithm is
16faster (at least on small graphs) than this implementation, but in some cases
17there are an exponential number of isomorphisms that are symmetrically
18equivalent. In that case, the ISMAGS algorithm will provide only one isomorphism
19per symmetry group, speeding up finding isomorphisms and avoiding the task of
20post-processing many effectively identical isomorphisms.
21
22>>> petersen = nx.petersen_graph()
23>>> ismags = nx.isomorphism.ISMAGS(petersen, petersen)
24>>> isomorphisms = list(ismags.isomorphisms_iter(symmetry=False))
25>>> len(isomorphisms)
26120
27>>> isomorphisms = list(ismags.isomorphisms_iter(symmetry=True))
28>>> answer = [{0: 0, 1: 1, 2: 2, 3: 3, 4: 4, 5: 5, 6: 6, 7: 7, 8: 8, 9: 9}]
29>>> answer == isomorphisms
30True
31
32In addition, this implementation also provides an interface to find the
33largest common induced subgraph [2]_ between any two graphs, again taking
34symmetry into account. Given ``graph`` and ``subgraph`` the algorithm will remove
35nodes from the ``subgraph`` until ``subgraph`` is isomorphic to a subgraph of
36``graph``. Since only the symmetry of ``subgraph`` is taken into account it is
37worth thinking about how you provide your graphs:
38
39>>> graph1 = nx.path_graph(4)
40>>> graph2 = nx.star_graph(3)
41>>> ismags = nx.isomorphism.ISMAGS(graph1, graph2)
42>>> ismags.is_isomorphic()
43False
44>>> largest_common_subgraph = list(ismags.largest_common_subgraph())
45>>> answer = [{1: 0, 0: 1, 2: 2}, {2: 0, 1: 1, 3: 2}]
46>>> answer == largest_common_subgraph
47True
48>>> ismags2 = nx.isomorphism.ISMAGS(graph2, graph1)
49>>> largest_common_subgraph = list(ismags2.largest_common_subgraph())
50>>> answer = [
51... {1: 0, 0: 1, 2: 2},
52... {1: 0, 0: 1, 3: 2},
53... {2: 0, 0: 1, 1: 2},
54... {2: 0, 0: 1, 3: 2},
55... {3: 0, 0: 1, 1: 2},
56... {3: 0, 0: 1, 2: 2},
57... ]
58>>> answer == largest_common_subgraph
59True
60
61However, when not taking symmetry into account, it doesn't matter:
62
63>>> largest_common_subgraph = list(ismags.largest_common_subgraph(symmetry=False))
64>>> answer = [
65... {1: 0, 0: 1, 2: 2},
66... {1: 0, 2: 1, 0: 2},
67... {2: 0, 1: 1, 3: 2},
68... {2: 0, 3: 1, 1: 2},
69... {1: 0, 0: 1, 2: 3},
70... {1: 0, 2: 1, 0: 3},
71... {2: 0, 1: 1, 3: 3},
72... {2: 0, 3: 1, 1: 3},
73... {1: 0, 0: 2, 2: 3},
74... {1: 0, 2: 2, 0: 3},
75... {2: 0, 1: 2, 3: 3},
76... {2: 0, 3: 2, 1: 3},
77... ]
78>>> answer == largest_common_subgraph
79True
80>>> largest_common_subgraph = list(ismags2.largest_common_subgraph(symmetry=False))
81>>> answer = [
82... {1: 0, 0: 1, 2: 2},
83... {1: 0, 0: 1, 3: 2},
84... {2: 0, 0: 1, 1: 2},
85... {2: 0, 0: 1, 3: 2},
86... {3: 0, 0: 1, 1: 2},
87... {3: 0, 0: 1, 2: 2},
88... {1: 1, 0: 2, 2: 3},
89... {1: 1, 0: 2, 3: 3},
90... {2: 1, 0: 2, 1: 3},
91... {2: 1, 0: 2, 3: 3},
92... {3: 1, 0: 2, 1: 3},
93... {3: 1, 0: 2, 2: 3},
94... ]
95>>> answer == largest_common_subgraph
96True
97
98Notes
99-----
100- Node and edge equality is assumed to be transitive: if A is equal to B, and
101 B is equal to C, then A is equal to C.
102
103- With a method that yields subgraph isomorphisms, we can construct functions like
104 ``is_subgraph_isomorphic`` by checking for any yielded mapping. And functions like
105 ``is_isomorphic`` by checking whether the subgraph has the same number of nodes as
106 the graph and is also subgraph isomorphic. This subpackage also allows a
107 ``symmetry`` bool keyword argument so you can find isomorphisms with or
108 without the symmetry constraints.
109
110- For more information, see [2]_ and the documentation for :class:`ISMAGS`
111 which includes a description of the algorithm.
112
113References
114----------
115.. [1] M. Houbraken, S. Demeyer, T. Michoel, P. Audenaert, D. Colle,
116 M. Pickavet, "The Index-Based Subgraph Matching Algorithm with General
117 Symmetries (ISMAGS): Exploiting Symmetry for Faster Subgraph
118 Enumeration", PLoS One 9(5): e97896, 2014.
119 https://doi.org/10.1371/journal.pone.0097896
120.. [2] https://en.wikipedia.org/wiki/Maximum_common_induced_subgraph
121"""
122
123__all__ = ["ISMAGS"]
124
125import itertools
126from collections import Counter, defaultdict
127from functools import reduce, wraps
128
129import networkx as nx
130
131
132def are_all_equal(iterable):
133 """
134 Returns ``True`` if and only if all elements in `iterable` are equal; and
135 ``False`` otherwise.
136
137 Parameters
138 ----------
139 iterable: collections.abc.Iterable
140 The container whose elements will be checked.
141
142 Returns
143 -------
144 bool
145 ``True`` iff all elements in `iterable` compare equal, ``False``
146 otherwise.
147 """
148 try:
149 shape = iterable.shape
150 except AttributeError:
151 pass
152 else:
153 if len(shape) > 1:
154 message = "The function does not works on multidimensional arrays."
155 raise NotImplementedError(message) from None
156
157 iterator = iter(iterable)
158 first = next(iterator, None)
159 return all(item == first for item in iterator)
160
161
162def make_partition(items, test, check=True):
163 """
164 Partitions items into sets based on the outcome of ``test(item1, item2)``.
165 Pairs of items for which `test` returns `True` end up in the same set.
166
167 Parameters
168 ----------
169 items : collections.abc.Iterable[collections.abc.Hashable]
170 Items to partition
171 test : collections.abc.Callable[collections.abc.Hashable, collections.abc.Hashable]
172 A function that will be called with 2 arguments, taken from items.
173 Should return `True` if those 2 items match/tests so need to end up in the same
174 part of the partition, and `False` otherwise.
175 check : bool optional (default: True)
176 If ``True``, check that the resulting partition satisfies the match criteria.
177 Every item should match every item in its part and none outside the part.
178
179 Returns
180 -------
181 list[set]
182 A partition as a list of sets (the parts). Each set contains some of
183 the items in `items`, such that all items are in exactly one part and every
184 pair of items in each part matches. The following will be true:
185 ``all(thing_matcher(*pair) for pair in itertools.combinations(items, 2))``
186
187 Notes
188 -----
189 The function `test` is assumed to be transitive: if ``test(a, b)`` and
190 ``test(b, c)`` return ``True``, then ``test(a, c)`` must also be ``True``.
191 The function `test` is assumed to be commutative: if ``test(a, b)``
192 returns ``True`` then ``test(b, a)`` returns ``True``.
193 """
194 partition = []
195 for item in items:
196 for part in partition:
197 p_item = next(iter(part))
198 if test(item, p_item):
199 part.add(item)
200 break
201 else: # No break
202 partition.append({item})
203
204 if check:
205 if not all(
206 test(t1, t2) and test(t2, t1)
207 for part in partition
208 for t1, t2 in itertools.combinations(part, 2)
209 ):
210 raise nx.NetworkXError(
211 f"\nInvalid partition created with {test}.\n"
212 "Some items in a part do not match. This leads to\n"
213 f"{partition=}"
214 )
215 if not all(
216 not test(t1, t2) and not test(t2, t1)
217 for p1 in partition
218 for p2 in partition
219 if p1 != p2
220 for t1, t2 in itertools.product(p1, p2)
221 ):
222 raise nx.NetworkXError(
223 f"\nInvalid partition created with {test}.\n"
224 "Some items match multiple parts. This leads to\n"
225 f"{partition=}"
226 )
227 return [set(part) for part in partition]
228
229
230def node_to_part_ID_dict(partition):
231 """
232 Creates a dictionary that maps each item in each part to the index of
233 the part to which it belongs.
234
235 Parameters
236 ----------
237 partition: collections.abc.Sequence[collections.abc.Iterable]
238 As returned by :func:`make_partition`.
239
240 Returns
241 -------
242 dict
243 """
244 return {node: ID for ID, part in enumerate(partition) for node in part}
245
246
247def color_degree_by_node(G, n_colors, e_colors):
248 """Returns a dict by node to counts of edge and node color for that node.
249
250 This returns a dict by node to a 2-tuple of node color and degree by
251 (edge color and nbr color). E.g. ``{0: (1, {(0, 2): 5})}`` means that
252 node ``0`` has node type 1 and has 5 edges of type 0 that go to nodes of type 2.
253 Thus, this is a measure of degree (edge count) by color of edge and color
254 of the node on the other side of that edge.
255
256 For directed graphs the degree counts is a 2-tuple of (in, out) degree counts.
257
258 Ideally, if edge_match is None, this could get simplified to just the node
259 color on the other end of the edge. Similarly if node_match is None then only
260 edge color is tracked. And if both are None, we simply count the number of edges.
261 """
262 # n_colors might be incomplete when using `largest_common_subgraph()`
263 if len(n_colors) < len(G):
264 for n, nbrs in G.adjacency():
265 if n not in n_colors:
266 n_colors[n] = None
267 for v in nbrs:
268 e_colors[n, v] = None
269 # undirected colored degree
270 if not G.is_directed():
271 return {
272 u: (n_colors[u], Counter((e_colors[u, v], n_colors[v]) for v in nbrs))
273 for u, nbrs in G.adjacency()
274 }
275 # directed colored out and in degree
276 return {
277 u: (
278 n_colors[u],
279 Counter((e_colors[u, v], n_colors[v]) for v in nbrs),
280 Counter((e_colors[v, u], n_colors[v]) for v in G._pred[u]),
281 )
282 for u, nbrs in G.adjacency()
283 }
284
285
286class EdgeLookup:
287 """Class to handle getitem for undirected edges.
288
289 Note that ``items()`` iterates over one of the two representations of the edge
290 (u, v) and (v, u). So this technically doesn't violate the Mapping
291 invariant that (k,v) pairs reported by ``items()`` satisfy ``.__getitem__(k) == v``.
292 But we are violating the spirit of the protocol by having keys available
293 for lookup by ``__getitem__`` that are not reported by ``items()``.
294
295 Note that if we used frozensets for undirected edges we would have the same
296 behavior we see here. You could ``__getitem__`` either ``{u, v}`` or ``{v, u}``
297 and get the same value -- yet ``items()`` would only report one of the two.
298 So from that perspective we *are* following the Mapping protocol. Our keys
299 are undirected edges. We are using 2-tuples as an imperfect representation
300 of these edges. We are not using 2-tuples as keys. Only as imperfect edges
301 and we use the edges as keys.
302 """
303
304 def __init__(self, edge_dict):
305 self.edge_dict = edge_dict
306
307 def __getitem__(self, edge):
308 if edge in self.edge_dict:
309 return self.edge_dict[edge]
310 return self.edge_dict[edge[::-1]]
311
312 def items(self):
313 return self.edge_dict.items()
314
315
316class ISMAGS:
317 """
318 Implements the ISMAGS subgraph matching algorithm. [1]_ ISMAGS stands for
319 "Index-based Subgraph Matching Algorithm with General Symmetries". As the
320 name implies, it is symmetry aware and will only generate non-symmetric
321 isomorphisms.
322
323 Attributes
324 ----------
325 graph: networkx.Graph
326 subgraph: networkx.Graph
327
328 Notes
329 -----
330 ISMAGS does a symmetry analysis to find the constraints on isomorphisms if
331 we preclude yielding isomorphisms that differ by a symmetry of the subgraph.
332 For example, if the subgraph is a 4-cycle, every isomorphism would have a
333 symmetric version with the nodes rotated relative to the original isomorphism.
334 By encoding these symmetries as constraints we reduce the search space for
335 isomorphisms and we also simplify processing the resulting isomorphisms.
336
337 **Symmetry Analysis**
338
339 The constraints in ISMAGS are based off the handling in ``nauty`` and its many
340 variants, in particular ``saucy``, as discussed in the ISMAGS paper [1]_.
341 That paper cites [3]_ for details on symmetry handling. Figure 2 of [3]_
342 describes the DFS approach to symmetries used here and relying on a data structure
343 called an Ordered Pair Partitions(OPP). This consists of a pair of partitions
344 where each part represents nodes with the same degree-by-color over all colors.
345 We refine these partitions simultaneously in a way to result in permutations
346 of the nodes that preserve the graph structure. We thus find automorphisms
347 for the subgraph of interest. From those we identify pairs of nodes which
348 are structurally equivalent. We then constrain our problem by requiring the
349 first of the pair to always be assigned first in the isomorphism -- thus
350 constraining the isomorphisms reported to only one example from the set of all
351 symmetrically equivalent isomorphisms. These constraints are computed once
352 based on the subgraph symmetries and then used throughout the DFS search for
353 isomorphisms.
354
355 Finding the symmetries involves a DFS of the OPP wherein we "couple" a node
356 to a node in its degree-by-color part of the partition. This "coupling" is done
357 via assigning a new color in the top partition to the node being coupled,
358 and the same new color in the bottom partition to the node being coupled to.
359 This new color has only one node in each partition. The new color also requires
360 that we "refine" both top and bottom partitions by splitting parts until each
361 part represents a common degree-by-color value. Those refinements introduce
362 new colors as the parts are split during refinement. Parts do not get combined
363 during refinement. So the coupling/refining process always results in at least
364 one new part with only one node in both the top and bottom partition. In practice
365 we usually refine into many new one-node parts in both partitions.
366 We continue in this way until each node has its own part/color in the top partition
367 -- and the node in the bottom partition with that color is the symmetric node.
368 That is, an OPP represents an automorphism, and thus a symmetry
369 of the subgraph when each color has a single node in the top partition and a single
370 node in the bottom partition. From those automorphisms we build up a set of nodes
371 that can be obtained from each other by symmetry (they are mutually symmetric).
372 That set of nodes is called an "orbit" of the subgraph under symmetry.
373
374 After finding the orbits for one symmetry, we backtrack in the DFS by removing the
375 latest coupling and replacing it with a coupling from the same top node to a new
376 bottom node in its degree-by-color grouping. When all possible couplings for that
377 node are considered we backtrack to the previously coupled node and recouple in
378 a DFS manner.
379
380 We can prune the DFS search tree in helpful ways. The paper [2]_ demonstrates 6
381 situations of interest in the DFS where pruning is possible:
382
383 - An **Automorphism OPP** is an OPP where every part in both partitions
384 contains a single node. The mapping/automorphism is found by mapping
385 each top node to the bottom node in the same color part. For example,
386 ``[({1}, {2}, {3}); ({2}, {3}, {1})]`` represents the mapping of each
387 node to the next in a triangle. It rotates the nodes around the triangle.
388 - The **Identity OPP** is the first automorphism found during the DFS. It
389 appears on the left side of the DFS tree and is first due to our ordering of
390 coupling nodes to be in an arbitrary but fixed ordering of the nodes. This
391 automorphism does not show any symmetries, but it ensures the orbit for each
392 node includes itself and it sets us up for handling the symmetries. Note that
393 a subgraph with no symmetries will only have the identity automorphism.
394 - A **Non-isomorphic OPP** occurs when refinement creates a different number of
395 parts in the top partition than in the bottom partition. This means no symmetries
396 will be found during further processing of that subtree of the DFS. We prune
397 the subtree and continue.
398 - A **Matching OPP** is such that each top part that has more than one node is
399 in fact equal to the bottom part with the same color. The many-node-parts match
400 exactly. The single-node parts then represent symmetries that do not permute
401 any matching nodes. Matching OPPs arise while finding the Identity Mapping. But
402 the single-node parts are identical in the two partitions, so no useful symmetries
403 are found. But after the Identity Mapping is found, every Matching OPP encountered
404 will have different nodes in at least two single-node parts of the same color.
405 So these positions in the DFS provide us with symmetries without any
406 need to find the whole automorphism. We can prune the subtree, update the orbits
407 and backtrack. Any larger symmetries that combine these symmetries with symmetries
408 of the many-node-parts do not need to be explored because the symmetry "generators"
409 found in this way provide a basis for all symmetries. We will find the symmetry
410 generators of the many-node-parts at another subtree of the DFS.
411 - An **Orbit Pruning OPP** is an OPP where the node coupling to be considered next
412 has both nodes already known to be in the same orbit. We have already identified
413 those permutations when we discovered the orbit. So we can prune the resulting
414 subtree. This is the primary pruning discussed in [1]_.
415 - A **Coset Point** in the DFS is a point of the tree when a node is first
416 back-tracked. That is, its couplings have all been analyzed once and we backtrack
417 to its parent. So, said another way, when a node is backtracked to and is about to
418 be mapped to a different node for the first time, its child in the DFS has been
419 completely analysed. Thus the orbit for that child at this point in the DFS is
420 the full orbit for symmetries involving only that child or larger nodes in the
421 node order. All smaller nodes are mapped to themselves.
422 This orbit is due to symmetries not involving smaller nodes. Such an orbit is
423 called the "coset" of that node. The Coset Point does not lead to pruning or to
424 more symmetries. It is the point in the process where we store the **coset** of
425 the node being backtracked. We use the cosets to construct the symmetry
426 constraints.
427
428 Once the pruned DFS tree has been traversed, we have collected the cosets of some
429 special nodes. Often most nodes are not coupled during the progression down the left
430 side of the DFS. They are separated from other nodes during the partition refinement
431 process after coupling. So they never get coupled directly. Thus the number of cosets
432 we find is typically many fewer than the number of nodes.
433
434 We turn those cosets into constraints on the nodes when building non-symmetric
435 isomorphisms. The node whose coset is used is paired with each other node in the
436 coset. These node-pairs form the constraints. During isomorphism construction we
437 always select the first of the constraint before the other. This removes subtrees
438 from the DFS traversal space used to build isomorphisms.
439
440 The constraints we obtain via symmetry analysis of the subgraph are used for
441 finding non-symmetric isomorphisms. We prune the isomorphism tree based on
442 the constraints we obtain from the symmetry analysis.
443
444 **Isomorphism Construction**
445
446 Once we have symmetry constraints on the isomorphisms, ISMAGS constructs the allowed
447 isomorphisms by mapping each node of the subgraph to all possible nodes (with the
448 same degree-by-color) from the graph. We partition all nodes into degree-by-color
449 parts and order the subgraph nodes we consider using smallest part size first.
450 The idea is to try to map the most difficult subgraph nodes first (most difficult
451 here means least number of graph candidates).
452
453 By considering each potential subgraph node to graph candidate mapping image in turn,
454 we perform a DFS traversal of partial mappings. If the mapping is rejected due to
455 the graph neighbors not matching the degree-by-color of the subgraph neighbors, or
456 rejected due to the constraints imposed from symmetry, we prune that subtree and
457 consider a new graph candidate node for that subgraph node. When no more graph
458 candidates remain we backtrack to the previous node in the mapping and consider a
459 new graph candidate for that node. If we ever get to a depth where all subgraph nodes
460 are mapped and no structural requirements or symmetry constraints are violated,
461 we have found an isomorphism. We yield that mapping and backtrack to find other
462 isomorphisms.
463
464 As we visit more neighbors, the graph candidate nodes have to satisfy more structural
465 restrictions. As described in the ISMAGS paper, [1]_, we store each set of structural
466 restrictions separately as a set of possible candidate nodes rather than computing
467 the intersection of that set with the known graph candidates for the subgraph node.
468 We delay taking the intersection until that node is selected to be in the mapping.
469 While choosing the node with fewest candidates, we avoid computing the intersection
470 by using the size of the minimal set to be intersected rather than the size of the
471 intersection. This may make the node ordering slightly worse via a savings of
472 many intersections most of which are not ever needed.
473
474 References
475 ----------
476 .. [1] M. Houbraken, S. Demeyer, T. Michoel, P. Audenaert, D. Colle,
477 M. Pickavet, "The Index-Based Subgraph Matching Algorithm with General
478 Symmetries (ISMAGS): Exploiting Symmetry for Faster Subgraph
479 Enumeration", PLoS One 9(5): e97896, 2014.
480 https://doi.org/10.1371/journal.pone.0097896
481 .. [2] https://en.wikipedia.org/wiki/Maximum_common_induced_subgraph
482 .. [3] Hadi Katebi, Karem A. Sakallah and Igor L. Markov
483 "Graph Symmetry Detection and Canonical Labeling: Differences and Synergies"
484 in "Turing-100. The Alan Turing Centenary" Ed: A. Voronkov p. 181 -- 195, (2012).
485 https://doi.org/10.29007/gzc1 https://arxiv.org/abs/1208.6271
486 """
487
488 def __init__(self, graph, subgraph, node_match=None, edge_match=None, cache=None):
489 """
490 Parameters
491 ----------
492 graph: networkx.Graph
493 subgraph: networkx.Graph
494 node_match: collections.abc.Callable or None
495 Function used to determine whether two nodes are equivalent. Its
496 signature should look like ``f(n1: dict, n2: dict) -> bool``, with
497 `n1` and `n2` node property dicts. See also
498 :func:`~networkx.algorithms.isomorphism.categorical_node_match` and
499 friends.
500 If `None`, all nodes are considered equal.
501 edge_match: collections.abc.Callable or None
502 Function used to determine whether two edges are equivalent. Its
503 signature should look like ``f(e1: dict, e2: dict) -> bool``, with
504 `e1` and `e2` edge property dicts. See also
505 :func:`~networkx.algorithms.isomorphism.categorical_edge_match` and
506 friends.
507 If `None`, all edges are considered equal.
508 cache: collections.abc.Mapping
509 A cache used for caching graph symmetries.
510 """
511 if graph.is_directed() != subgraph.is_directed():
512 raise ValueError("Directed and undirected graphs cannot be compared.")
513
514 # TODO: allow for precomputed partitions and colors
515 self.graph = graph
516 self.subgraph = subgraph
517 self._symmetry_cache = cache
518 # Naming conventions are taken from the original paper.
519 # For your sanity:
520 # sg: subgraph
521 # g: graph
522 # e: edge(s)
523 # n: node(s)
524 # So: sgn means "subgraph nodes".
525 node_parts = self.create_aligned_partitions(
526 node_match, self.subgraph.nodes, self.graph.nodes
527 )
528 self._sgn_partition, self._gn_partition, self.N_node_colors = node_parts
529 self._sgn_colors = node_to_part_ID_dict(self._sgn_partition)
530 self._gn_colors = node_to_part_ID_dict(self._gn_partition)
531
532 edge_partitions = self.create_aligned_partitions(
533 edge_match, self.subgraph.edges(), self.graph.edges()
534 )
535 self._sge_partition, self._ge_partition, self.N_edge_colors = edge_partitions
536 if self.graph.is_directed():
537 self._sge_colors = node_to_part_ID_dict(self._sge_partition)
538 self._ge_colors = node_to_part_ID_dict(self._ge_partition)
539 else: # allow lookups (u, v) or (v, u)
540 self._sge_colors = EdgeLookup(node_to_part_ID_dict(self._sge_partition))
541 self._ge_colors = EdgeLookup(node_to_part_ID_dict(self._ge_partition))
542
543 def create_aligned_partitions(self, thing_matcher, sg_things, g_things):
544 """Partitions of "things" (nodes or edges) from subgraph and graph
545 based on function `thing_matcher`.
546
547 Returns: sg_partition, g_partition, number_of_matched_parts
548
549 The first `number_of_matched_parts` parts in each partition
550 match in order, e.g. 2nd part matches other's 2nd part.
551 Warning: nodes in parts after that have no matching nodes in the other graph.
552 For morphisms those nodes can't appear in the mapping.
553 """
554 if thing_matcher is None:
555 sg_partition = [set(sg_things)]
556 g_partition = [set(g_things)]
557 return sg_partition, g_partition, 1
558
559 # Use thing_matcher to create a partition
560 # Note: isinstance(G.edges(), OutEdgeDataView) is only true for multi(di)graph
561 sg_multiedge = isinstance(sg_things, nx.classes.reportviews.OutEdgeDataView)
562 g_multiedge = isinstance(g_things, nx.classes.reportviews.OutEdgeDataView)
563 if not sg_multiedge:
564
565 def sg_match(thing1, thing2):
566 return thing_matcher(sg_things[thing1], sg_things[thing2])
567
568 else: # multiedges (note nodes of multigraphs use simple case above)
569
570 def sg_match(thing1, thing2):
571 (u1, v1), (u2, v2) = thing1, thing2
572 return thing_matcher(self.subgraph[u1][v1], self.subgraph[u2][v2])
573
574 if not g_multiedge:
575
576 def g_match(thing1, thing2):
577 return thing_matcher(g_things[thing1], g_things[thing2])
578
579 else: # multiedges (note nodes of multigraphs use simple case above)
580
581 def g_match(thing1, thing2):
582 (u1, v1), (u2, v2) = thing1, thing2
583 return thing_matcher(self.graph[u1][v1], self.graph[u2][v2])
584
585 sg_partition = make_partition(sg_things, sg_match)
586 g_partition = make_partition(g_things, g_match)
587
588 # Align order of g_partition to that of sg_partition
589 sgc_to_gc = {}
590 gc_to_sgc = {}
591 sN, N = len(sg_partition), len(g_partition)
592 for sgc, gc in itertools.product(range(sN), range(N)):
593 sgt = next(iter(sg_partition[sgc]))
594 gt = next(iter(g_partition[gc]))
595 sgt_ = sg_things[sgt] if not sg_multiedge else self.subgraph[sgt[0]][sgt[1]]
596 gt_ = g_things[gt] if not g_multiedge else self.graph[gt[0]][gt[1]]
597 if thing_matcher(sgt_, gt_):
598 # TODO: remove these two if-checks when confident they never arise
599 # The `check` feature in match_partitions should ensure they do not
600 if sgc in sgc_to_gc:
601 raise nx.NetworkXError(
602 f"\nMatching function {thing_matcher} seems faulty.\n"
603 f"Partition found: {sg_partition=}\n"
604 f"So {sgt} in subgraph part {sg_partition[sgc]} matches two "
605 f"graph parts {g_partition[gc]} and "
606 f"{g_partition[sgc_to_gc[sgc]]}\n"
607 )
608 if gc in gc_to_sgc:
609 raise nx.NetworkXError(
610 f"\nMatching function seems broken: {thing_matcher}\n"
611 f"Partitions found: {g_partition=} {sg_partition=}\n"
612 f"So {gt} in graph part {g_partition[gc]} matches two "
613 f"subgraph parts {sg_partition[sgc]} and "
614 f"{sg_partition[gc_to_sgc[gc]]}\n"
615 )
616 sgc_to_gc[sgc] = gc
617 gc_to_sgc[gc] = sgc
618 ## return two lists and the number of partitions that match.
619 new_order = [
620 (sg_partition[sgc], g_partition[gc]) for sgc, gc in sgc_to_gc.items()
621 ]
622 Ncolors = len(new_order)
623 if Ncolors:
624 new_sg_p, new_g_p = [list(x) for x in zip(*new_order)]
625 else:
626 new_sg_p, new_g_p = [], []
627 if Ncolors < sN:
628 extra = [sg_partition[c] for c in range(sN) if c not in sgc_to_gc]
629 new_sg_p = list(new_sg_p) + extra
630 new_g_p = list(new_g_p) + [set()] * len(extra)
631 if Ncolors < N:
632 extra = [g_partition[c] for c in range(N) if c not in gc_to_sgc]
633 new_g_p = list(new_g_p) + extra
634 new_sg_p = list(new_sg_p) + [set()] * len(extra)
635
636 return new_sg_p, new_g_p, Ncolors
637
638 def find_isomorphisms(self, symmetry=True):
639 """Find all subgraph isomorphisms between subgraph and graph
640
641 Finds isomorphisms where :attr:`subgraph` <= :attr:`graph`.
642
643 Parameters
644 ----------
645 symmetry: bool
646 Whether symmetry should be taken into account. If False, found
647 isomorphisms may be symmetrically equivalent.
648
649 Yields
650 ------
651 dict
652 The found isomorphism mappings of {graph_node: subgraph_node}.
653 """
654 # The networkx VF2 algorithm is slightly funny in when it yields an
655 # empty dict and when not.
656 if not self.subgraph:
657 yield {}
658 return
659 elif not self.graph:
660 return
661 elif len(self.graph) < len(self.subgraph):
662 return
663 elif len(self._sgn_partition) > self.N_node_colors:
664 # some subgraph nodes have a color that doesn't occur in graph
665 return
666 elif len(self._sge_partition) > self.N_edge_colors:
667 # some subgraph edges have a color that doesn't occur in graph
668 return
669
670 if symmetry:
671 cosets = self.analyze_subgraph_symmetry()
672 # Turn cosets into constraints.
673 constraints = [(n, co) for n, cs in cosets.items() for co in cs if n != co]
674 else:
675 constraints = []
676
677 cand_sets = self._get_node_color_candidate_sets()
678
679 lookahead_candidates = self._get_color_degree_candidates()
680 for sgn, lookahead_cands in lookahead_candidates.items():
681 cand_sets[sgn].add(frozenset(lookahead_cands))
682
683 if any(cand_sets.values()):
684 # Choose start node based on a heuristic for the min # of candidates
685 # Heuristic here is length of smallest frozenset in candidates' set
686 # of frozensets for that node. Using the smallest length avoids
687 # computing the intersection of the frozensets for each node.
688 start_sgn = min(cand_sets, key=lambda n: min(len(x) for x in cand_sets[n]))
689 cand_sets[start_sgn] = (frozenset.intersection(*cand_sets[start_sgn]),)
690 yield from self._map_nodes(start_sgn, cand_sets, constraints)
691 return
692
693 def _get_color_degree_candidates(self):
694 """
695 Returns a mapping of {subgraph node: set of graph nodes} for
696 which the graph nodes are feasible mapping candidate_sets for the
697 subgraph node, as determined by looking ahead one edge.
698 """
699 g_deg = color_degree_by_node(self.graph, self._gn_colors, self._ge_colors)
700 sg_deg = color_degree_by_node(self.subgraph, self._sgn_colors, self._sge_colors)
701
702 return {
703 sgn: {
704 gn
705 for gn, (_, *g_counts) in g_deg.items()
706 if all(
707 sg_cnt <= g_counts[idx][color]
708 for idx, counts in enumerate(needed_counts)
709 for color, sg_cnt in counts.items()
710 )
711 }
712 for sgn, (_, *needed_counts) in sg_deg.items()
713 }
714
715 def largest_common_subgraph(self, symmetry=True):
716 """
717 Find the largest common induced subgraphs between :attr:`subgraph` and
718 :attr:`graph`.
719
720 Parameters
721 ----------
722 symmetry: bool
723 Whether symmetry should be taken into account. If False, found
724 largest common subgraphs may be symmetrically equivalent.
725
726 Yields
727 ------
728 dict
729 The found isomorphism mappings of {graph_node: subgraph_node}.
730 """
731 # The networkx VF2 algorithm is slightly funny in when it yields an
732 # empty dict and when not.
733 if not self.subgraph:
734 yield {}
735 return
736 elif not self.graph:
737 return
738
739 if symmetry:
740 cosets = self.analyze_subgraph_symmetry()
741 # Turn cosets into constraints.
742 constraints = [(n, cn) for n, cs in cosets.items() for cn in cs if n != cn]
743 else:
744 constraints = []
745
746 candidate_sets = self._get_node_color_candidate_sets()
747
748 if any(candidate_sets.values()):
749 relevant_parts = self._sgn_partition[: self.N_node_colors]
750 to_be_mapped = {frozenset(n for p in relevant_parts for n in p)}
751 yield from self._largest_common_subgraph(
752 candidate_sets, constraints, to_be_mapped
753 )
754 else:
755 return
756
757 def analyze_subgraph_symmetry(self):
758 """
759 Find a minimal set of permutations and corresponding co-sets that
760 describe the symmetry of ``self.subgraph``, given the node and edge
761 equalities given by `node_partition` and `edge_colors`, respectively.
762
763 Returns
764 -------
765 dict[collections.abc.Hashable, set[collections.abc.Hashable]]
766 The found co-sets. The co-sets is a dictionary of
767 ``{node key: set of node keys}``.
768 Every key-value pair describes which ``values`` can be interchanged
769 without changing nodes less than ``key``.
770 """
771 partition, edge_colors = self._sgn_partition, self._sge_colors
772
773 if self._symmetry_cache is not None:
774 key = hash(
775 (
776 tuple(self.subgraph.nodes),
777 tuple(self.subgraph.edges),
778 tuple(map(tuple, node_partition)),
779 tuple(edge_colors.items()),
780 self.subgraph.is_directed(),
781 )
782 )
783 if key in self._symmetry_cache:
784 return self._symmetry_cache[key]
785 partition = self._refine_node_partition(self.subgraph, partition, edge_colors)
786 cosets = self._process_ordered_pair_partitions(
787 self.subgraph, partition, partition, edge_colors
788 )
789 if self._symmetry_cache is not None:
790 self._symmetry_cache[key] = cosets
791 return cosets
792
793 def is_isomorphic(self, symmetry=False):
794 """
795 Returns True if :attr:`graph` is isomorphic to :attr:`subgraph` and
796 False otherwise.
797
798 Returns
799 -------
800 bool
801 """
802 return len(self.subgraph) == len(self.graph) and self.subgraph_is_isomorphic(
803 symmetry
804 )
805
806 def subgraph_is_isomorphic(self, symmetry=False):
807 """
808 Returns True if a subgraph of :attr:`graph` is isomorphic to
809 :attr:`subgraph` and False otherwise.
810
811 Returns
812 -------
813 bool
814 """
815 # symmetry=False, since we only need to know whether there is any
816 # example; figuring out all symmetry elements probably costs more time
817 # than it gains.
818 isom = next(self.subgraph_isomorphisms_iter(symmetry=symmetry), None)
819 return isom is not None
820
821 def isomorphisms_iter(self, symmetry=True):
822 """
823 Does the same as :meth:`find_isomorphisms` if :attr:`graph` and
824 :attr:`subgraph` have the same number of nodes.
825 """
826 if len(self.graph) == len(self.subgraph):
827 yield from self.subgraph_isomorphisms_iter(symmetry=symmetry)
828
829 def subgraph_isomorphisms_iter(self, symmetry=True):
830 """Alternative name for :meth:`find_isomorphisms`."""
831 return self.find_isomorphisms(symmetry)
832
833 def _get_node_color_candidate_sets(self):
834 """
835 Per node in subgraph find all nodes in graph that have the same color.
836 Stored as a dict-of-set-of-frozenset. The dict is keyed by node to a
837 collection of frozensets of graph nodes. Each of these frozensets are
838 a restriction. The node can be mapped only to nodes in the frozenset.
839 Thus it must be mapped to nodes in the intersection of all these sets.
840 We store the sets to delay taking the intersection of them. This helps
841 for two reasons: Firstly any duplicate restriction sets can be ignored;
842 Secondly, some nodes will not need the intersection to be constructed.
843 Note: a dict-of-list-of-set would store duplicate sets in the list and
844 we want to avoid that. But I wonder if checking hash/equality when `add`ing
845 removes the benefit of avoiding computing intersections.
846 """
847 candidate_sets = defaultdict(set)
848 for sgn in self.subgraph.nodes:
849 sgn_color = self._sgn_colors[sgn]
850 if sgn_color >= self.N_node_colors: # color has no candidates
851 candidate_sets[sgn] # creates empty set entry in defaultdict
852 else:
853 candidate_sets[sgn].add(frozenset(self._gn_partition[sgn_color]))
854 return dict(candidate_sets)
855
856 @classmethod
857 def _refine_node_partition(cls, graph, partition, edge_colors):
858 def equal_color(node1, node2):
859 return color_degree[node1] == color_degree[node2]
860
861 node_colors = node_to_part_ID_dict(partition)
862 color_degree = color_degree_by_node(graph, node_colors, edge_colors)
863 while not all(are_all_equal(color_degree[n] for n in p) for p in partition):
864 partition = [
865 p
866 for part in partition
867 for p in (
868 [part]
869 if are_all_equal(color_degree[n] for n in part)
870 else sorted(make_partition(part, equal_color, check=False), key=len)
871 )
872 ]
873 node_colors = node_to_part_ID_dict(partition)
874 color_degree = color_degree_by_node(graph, node_colors, edge_colors)
875 return partition
876
877 def _map_nodes(self, sgn, candidate_sets, constraints, to_be_mapped=None):
878 """
879 Find all subgraph isomorphisms honoring constraints.
880 The collection `candidate_sets` is stored as a dict-of-set-of-frozenset.
881 The dict is keyed by node to a collection of candidate frozensets. Any
882 viable candidate must belong to all the frozensets in the collection.
883 So each frozenset added to the collection is a restriction on the candidates.
884
885 According to the paper, we store the collection of sets rather than their
886 intersection to delay computing many intersections with the hope of avoiding
887 them completely. Having the middle collection be a set also means that
888 duplicate restrictions on candidates are ignored, avoiding another intersection.
889 """
890 # shortcuts for speed
891 subgraph = self.subgraph
892 subgraph_adj = subgraph._adj
893 graph = self.graph
894 graph_adj = graph._adj
895 self_ge_partition = self._ge_partition
896 self_sge_colors = self._sge_colors
897 is_directed = subgraph.is_directed()
898
899 gn_ID_to_node = list(graph)
900 gn_node_to_ID = {n: id for id, n in enumerate(graph)}
901
902 mapping = {}
903 rev_mapping = {}
904 if to_be_mapped is None:
905 to_be_mapped = subgraph_adj.keys()
906
907 # Note that we don't copy candidates here. This means we leak
908 # information between the branches of the search. This is intentional!
909 # Specifically, we modify candidates here. That's OK because we substitute
910 # the set of frozensets with a set containing the frozenset intersection.
911 # So, it doesn't change the membership rule or the length rule for sorting.
912 # Membership: any candidate must be an element of each of the frozensets.
913 # Length: length of the intersection set. Use heuristic min(len of frozensets).
914 # This intersection improves future length heuristics which can only occur
915 # after this element of the queu is popped. But it means future additional
916 # restriction frozensets that duplicate previous ones are not ignored.
917 sgn_candidates = frozenset.intersection(*candidate_sets[sgn])
918 candidate_sets[sgn] = {sgn_candidates}
919 queue = [(sgn, candidate_sets, iter(sgn_candidates))]
920 while queue: # DFS over all possible mappings
921 sgn, candidate_sets, sgn_cand_iter = queue[-1]
922
923 for gn in sgn_cand_iter:
924 # We're going to try to map sgn to gn.
925 if gn in rev_mapping:
926 continue # pragma: no cover
927
928 # REDUCTION and COMBINATION
929 if sgn in mapping:
930 old_gn = mapping[sgn]
931 del rev_mapping[old_gn]
932 mapping[sgn] = gn
933 rev_mapping[gn] = sgn
934 # BASECASE
935 if len(mapping) == len(to_be_mapped):
936 yield rev_mapping.copy()
937 del mapping[sgn]
938 del rev_mapping[gn]
939 continue
940 left_to_map = to_be_mapped - mapping.keys()
941
942 # We copy the candidates dict. But it is not a deepcopy.
943 # This avoids inner set copies, yet still allows updates b/c setitem
944 # changes sgn in new dict without changing original set.
945 # Below be careful to not change the sets of frozensets.
946 cand_sets = candidate_sets.copy()
947
948 # update the candidate_sets for unmapped sgn based on sgn mapped
949 if not is_directed:
950 sgn_nbrs = subgraph_adj[sgn]
951 not_gn_nbrs = graph_adj.keys() - graph_adj[gn].keys()
952 for sgn2 in left_to_map:
953 # edge color must match when sgn2 connected to sgn
954 if sgn2 not in sgn_nbrs:
955 gn2_cands = not_gn_nbrs
956 else:
957 g_edges = self_ge_partition[self_sge_colors[sgn, sgn2]]
958 gn2_cands = {n for e in g_edges if gn in e for n in e}
959 # Node color compatibility should be taken care of by the
960 # initial candidate lists made by find_subgraphs
961
962 # Add gn2_cands to the right collection.
963 # Do not change the original set. So do not use |= operator
964 cand_sets[sgn2] = cand_sets[sgn2] | {frozenset(gn2_cands)}
965 else: # directed
966 sgn_nbrs = subgraph_adj[sgn].keys()
967 sgn_preds = subgraph._pred[sgn].keys()
968 not_gn_nbrs = (
969 graph_adj.keys() - graph_adj[gn].keys() - graph._pred[gn].keys()
970 )
971 for sgn2 in left_to_map:
972 # edge color must match when sgn2 connected to sgn
973 if sgn2 not in sgn_nbrs:
974 if sgn2 not in sgn_preds:
975 gn2_cands = not_gn_nbrs
976 else: # sgn2 in sgn_preds
977 g_edges = self_ge_partition[self_sge_colors[sgn2, sgn]]
978 gn2_cands = {e[0] for e in g_edges if gn == e[1]}
979 else:
980 if sgn2 not in sgn_preds:
981 g_edges = self_ge_partition[self_sge_colors[sgn, sgn2]]
982 gn2_cands = {e[1] for e in g_edges if gn == e[0]}
983 else:
984 # gn2 must have correct color in both directions
985 g_edges = self_ge_partition[self_sge_colors[sgn, sgn2]]
986 gn2_cands = {e[1] for e in g_edges if gn == e[0]}
987 g_edges = self_ge_partition[self_sge_colors[sgn2, sgn]]
988 gn2_cands &= {e[0] for e in g_edges if gn == e[1]}
989 # Do not change the original set. So do not use |= operator
990 cand_sets[sgn2] = cand_sets[sgn2] | {frozenset(gn2_cands)}
991
992 for sgn2 in left_to_map:
993 # symmetry must match. constraints mean gn2>gn iff sgn2>sgn
994 if (sgn, sgn2) in constraints:
995 gn2_cands = set(gn_ID_to_node[gn_node_to_ID[gn] + 1 :])
996 elif (sgn2, sgn) in constraints:
997 gn2_cands = set(gn_ID_to_node[: gn_node_to_ID[gn]])
998 else:
999 continue # pragma: no cover
1000 # Do not change the original set. So do not use |= operator
1001 cand_sets[sgn2] = cand_sets[sgn2] | {frozenset(gn2_cands)}
1002
1003 # The next node is the one that is unmapped and has fewest candidates
1004 # Use the heuristic of the min size of the frozensets rather than
1005 # intersection of all frozensets to delay computing intersections.
1006 new_sgn = min(
1007 left_to_map, key=lambda n: min(len(x) for x in cand_sets[n])
1008 )
1009 new_sgn_candidates = frozenset.intersection(*cand_sets[new_sgn])
1010 if not new_sgn_candidates:
1011 continue
1012 cand_sets[new_sgn] = {new_sgn_candidates}
1013 queue.append((new_sgn, cand_sets, iter(new_sgn_candidates)))
1014 break
1015 else: # all gn candidates tried for sgn.
1016 queue.pop()
1017 if sgn in mapping:
1018 del rev_mapping[mapping[sgn]]
1019 del mapping[sgn]
1020
1021 def _largest_common_subgraph(self, candidates, constraints, to_be_mapped=None):
1022 """
1023 Find all largest common subgraphs honoring constraints.
1024 """
1025 # to_be_mapped is a set of frozensets of subgraph nodes
1026 if to_be_mapped is None:
1027 to_be_mapped = {frozenset(self.subgraph.nodes)}
1028
1029 # The LCS problem is basically a repeated subgraph isomorphism problem
1030 # with smaller and smaller subgraphs. We store the nodes that are
1031 # "part of" the subgraph in to_be_mapped, and we make it a little
1032 # smaller every iteration.
1033
1034 current_size = len(next(iter(to_be_mapped), []))
1035
1036 found_iso = False
1037 if current_size <= len(self.graph):
1038 # There's no point in trying to find isomorphisms of
1039 # graph >= subgraph if subgraph has more nodes than graph.
1040
1041 # Try the isomorphism first with the nodes with lowest ID. So sort
1042 # them. Those are more likely to be part of the final correspondence.
1043 # In theory, this makes finding the first answer(s) faster.
1044 for nodes in sorted(to_be_mapped, key=sorted):
1045 # Find the isomorphism between subgraph[to_be_mapped] <= graph
1046 next_sgn = min(nodes, key=lambda n: min(len(x) for x in candidates[n]))
1047 isomorphs = self._map_nodes(
1048 next_sgn, candidates, constraints, to_be_mapped=nodes
1049 )
1050
1051 # This is effectively `yield from isomorphs`, except that we look
1052 # whether an item was yielded.
1053 try:
1054 item = next(isomorphs)
1055 except StopIteration:
1056 pass
1057 else:
1058 yield item
1059 yield from isomorphs
1060 found_iso = True
1061
1062 # BASECASE
1063 if found_iso or current_size == 1:
1064 # Shrinking has no point because either 1) we end up with a smaller
1065 # common subgraph (and we want the largest), or 2) there'll be no
1066 # more subgraph.
1067 return
1068
1069 left_to_be_mapped = set()
1070 for nodes in to_be_mapped:
1071 for sgn in nodes:
1072 # We're going to remove sgn from to_be_mapped, but subject to
1073 # symmetry constraints. We know that for every constraint we
1074 # have those subgraph nodes are equal. So whenever we would
1075 # remove the lower part of a constraint, remove the higher
1076 # instead. This is all dealth with by _remove_node. And because
1077 # left_to_be_mapped is a set, we don't do double work.
1078
1079 # And finally, make the subgraph one node smaller.
1080 # REDUCTION
1081 new_nodes = self._remove_node(sgn, nodes, constraints)
1082 left_to_be_mapped.add(new_nodes)
1083 # COMBINATION
1084 yield from self._largest_common_subgraph(
1085 candidates, constraints, to_be_mapped=left_to_be_mapped
1086 )
1087
1088 @staticmethod
1089 def _remove_node(node, nodes, constraints):
1090 """
1091 Returns a new set where node has been removed from nodes, subject to
1092 symmetry constraints. We know, that for every constraint we have
1093 those subgraph nodes are equal. So whenever we would remove the
1094 lower part of a constraint, remove the higher instead.
1095 """
1096 while True:
1097 for low, high in constraints:
1098 if low == node and high in nodes:
1099 node = high
1100 break
1101 else: # no break, couldn't find node in constraints
1102 return frozenset(nodes - {node})
1103
1104 @staticmethod
1105 def _get_permutations_by_length(items):
1106 """
1107 Get all permutations of items, but only permute items with the same
1108 length.
1109
1110 >>> found = list(ISMAGS._get_permutations_by_length([{1}, {2}, {3, 4}, {4, 5}]))
1111 >>> answer = [
1112 ... (({1}, {2}), ({3, 4}, {4, 5})),
1113 ... (({1}, {2}), ({4, 5}, {3, 4})),
1114 ... (({2}, {1}), ({3, 4}, {4, 5})),
1115 ... (({2}, {1}), ({4, 5}, {3, 4})),
1116 ... ]
1117 >>> found == answer
1118 True
1119 """
1120 by_len = defaultdict(list)
1121 for item in items:
1122 by_len[len(item)].append(item)
1123
1124 return list(
1125 itertools.product(
1126 *(itertools.permutations(by_len[l]) for l in sorted(by_len))
1127 )
1128 )
1129
1130 def _refine_opp(cls, graph, top, bottom, edge_colors):
1131 def equal_color(node1, node2):
1132 return color_degree[node1] == color_degree[node2]
1133
1134 top = cls._refine_node_partition(graph, top, edge_colors)
1135
1136 possible_bottoms = [bottom]
1137 while possible_bottoms:
1138 bottom = possible_bottoms.pop()
1139 node_colors = node_to_part_ID_dict(bottom)
1140 color_degree = color_degree_by_node(graph, node_colors, edge_colors)
1141 if all(are_all_equal(color_degree[n] for n in p) for p in bottom):
1142 if len(top) == len(bottom):
1143 yield top, bottom
1144 # else Non-isomorphic OPP (pruned here)
1145 # either way continue to next possible bottom
1146 continue
1147 # refine bottom partition
1148 more_bottoms = [[]]
1149 for part in bottom:
1150 if len(part) == 1 or are_all_equal(color_degree[node] for node in part):
1151 for new_bottom in more_bottoms:
1152 new_bottom.append(part)
1153 else:
1154 # This part needs to be refined
1155 refined_part = make_partition(part, equal_color, check=False)
1156 R = len(refined_part)
1157 if R == 1 or R == len({len(p) for p in refined_part}):
1158 # no two parts have same length -- simple case
1159 for n_p in more_bottoms:
1160 n_p.extend(sorted(refined_part, key=len))
1161 else:
1162 # Any part might match any other part with the same size.
1163 # Before refinement they were the same color. So we need to
1164 # include all possible orderings/colors within each size.
1165 permutations = cls._get_permutations_by_length(refined_part)
1166 # Add all permutations of the refined parts to each possible
1167 # bottom. So the number of new possible bottoms is multiplied
1168 # by the number of permutations of the refined parts.
1169 new_partitions = []
1170 for new_partition in more_bottoms:
1171 for p in permutations:
1172 # p is tuple-of-tuples-of-sets. Flatten to list-of-sets
1173 flat_p = [s for tup in p for s in tup]
1174 new_partitions.append(new_partition + flat_p)
1175 more_bottoms = new_partitions
1176
1177 # reverse more_bottoms to keep the "finding identity" bottom first
1178 possible_bottoms.extend(more_bottoms[::-1])
1179
1180 @staticmethod
1181 def _find_permutations(top_partition, bottom_partition):
1182 """
1183 Return a set of 2-tuples of nodes. These nodes are not equal
1184 but are mapped to each other in the symmetry represented by this OPP.
1185 Swapping all the 2-tuples of nodes in this set permutes the nodes
1186 but retains the graph structure. Thus it is a symmetry of the subgraph.
1187 """
1188 # Find permutations
1189 permutations = set()
1190 for top, bot in zip(top_partition, bottom_partition):
1191 if len(top) > 1 or len(bot) > 1:
1192 # ignore parts with > 1 element when they are equal
1193 # These are called Matching OPPs in Katebi 2012.
1194 # Symmetries in matching partitions are built by considering
1195 # only parts that have 1 element.
1196 if top == bot:
1197 continue
1198 raise IndexError(
1199 "Not all nodes are matched. This is"
1200 f" impossible: {top_partition}, {bottom_partition}"
1201 )
1202 # top and bot have only one element
1203 elif top != bot:
1204 permutations.add(frozenset((next(iter(top)), next(iter(bot)))))
1205 return permutations
1206
1207 def _process_ordered_pair_partitions(
1208 self,
1209 graph,
1210 top_partition,
1211 bottom_partition,
1212 edge_colors,
1213 ):
1214 if all(len(top) <= 1 for top in top_partition):
1215 # no symmetries. Each node unique.
1216 return {}
1217
1218 # first mapping found is the identity mapping
1219 finding_identity = True
1220
1221 orbit_id = {node: orbit_i for orbit_i, node in enumerate(graph)}
1222 orbits = [{node} for node in graph]
1223 cosets = {}
1224
1225 node_to_ID = {n: i for i, n in enumerate(graph)}
1226 sort_by_ID = node_to_ID.__getitem__
1227
1228 def _load_next_queue_entry(queue, top_partition, bottom_partition):
1229 # find smallest node (by ID) in a |part|>1 and its partition index
1230 unmapped_nodes = (
1231 (node_to_ID[node], node, idx)
1232 for idx, t_part in enumerate(top_partition)
1233 for node in t_part
1234 if len(t_part) > 1
1235 )
1236 _, node, part_i = min(unmapped_nodes)
1237 b_part = bottom_partition[part_i]
1238 node2_iter = iter(sorted(b_part, key=sort_by_ID))
1239
1240 queue.append([top_partition, bottom_partition, node, part_i, node2_iter])
1241
1242 queue = []
1243 _load_next_queue_entry(queue, top_partition, bottom_partition)
1244
1245 while queue:
1246 tops, bottoms, node, part_i, node2_iter = queue[-1]
1247
1248 for node2 in node2_iter:
1249 if node != node2 and orbit_id[node] == orbit_id[node2]:
1250 # Orbit prune
1251 continue
1252
1253 # couple node to node2
1254 new_top_part = {node}
1255 new_bot_part = {node2}
1256
1257 new_top = [top.copy() for top in tops]
1258 new_top[part_i] -= new_top_part
1259 new_top.insert(part_i, new_top_part)
1260
1261 new_bot = [bot.copy() for bot in bottoms]
1262 new_bot[part_i] -= new_bot_part
1263 new_bot.insert(part_i, new_bot_part)
1264
1265 # collect OPPs
1266 opps = self._refine_opp(graph, new_top, new_bot, edge_colors)
1267 new_q = []
1268 for opp in opps:
1269 # Use OPP to find any of: Identity, Automorphism or Matching OPPs
1270 # else load the OPP onto queue for further exploration
1271 # Note that we check for Orbit pruning later because orbits may
1272 # be updated while OPP is sitting on the queue.
1273 # Note that we check for Non-isomorphic OPPs in `_refine_opp`.
1274 if finding_identity:
1275 # Note: allow zero size parts in identity check
1276 # b/c largest_common_subgraph allows empty parts
1277 if all(len(top) <= 1 for top in opp[0]):
1278 # Identity found. Set flag. Can now prune Matching OPPs
1279 finding_identity = False
1280 continue
1281 elif all(len(t) <= 1 or t == b for t, b in zip(*opp)):
1282 # Found a symmetry! (Full mapping or Matching OPP)
1283 # update orbits using the permutations from the OPP.
1284 permutations = self._find_permutations(*opp)
1285 for n1, n2 in permutations:
1286 orb1 = orbit_id[n1]
1287 orb2 = orbit_id[n2]
1288 if orb1 != orb2:
1289 orbit_set2 = orbits[orb2]
1290 orbits[orb1].update(orbit_set2)
1291 orbits[orb2] = set()
1292 orbit_id.update((n, orb1) for n in orbit_set2)
1293 continue
1294
1295 _load_next_queue_entry(new_q, *opp)
1296 # reverse order to maintain node order DFS (Identity comes first)
1297 queue.extend(new_q[::-1])
1298 break
1299 else: # no more node2 options
1300 queue.pop()
1301 if node not in cosets:
1302 # coset of `node` is its orbit at the time `node` has completed
1303 # its first DFS traversal. DFS is about to go to previous node.
1304 # Make copy so future orbit changes do not change the coset.
1305 cosets[node] = orbits[orbit_id[node]].copy()
1306 return cosets