Source code for synkit.Chem.Reaction.Mapper.slap.sequential

from __future__ import annotations

from collections import defaultdict

import numpy as np
from scipy.optimize import linear_sum_assignment

from ..graph.labeled_graph import LabeledGraph
from ..graph.synkit_adapter import canonical_graph_hash, stable_int_token


[docs] class GraphMatcher: """WL-partitioned sequential LAP matcher.""" _INF_INT = 100000 _AUT_DEDUP_MAX_NODES = 20 _AUT_DEDUP_MAX_AUTOMORPHISMS = 256 def __init__( self, binary=False, max_lap_fingerprints=10000, cache_label_blocks=False, deterministic_labels=False, ): self.binary = binary self.max_lap_fingerprints = max_lap_fingerprints self.cache_label_blocks = cache_label_blocks self.deterministic_labels = deterministic_labels self._stack = [] self._visited = set() self._label_block_cache = {} self.results = [] self.minval = self._INF_INT self._keep_lap_sols = True
[docs] def reset(self): """Clear state.""" self._stack.clear() self._visited.clear() self._label_block_cache.clear() self.results.clear() self.minval = self._INF_INT
[docs] def get_maps(self, lgp, break_sym_targets=None, interactive=False, base=None): """Find minimum-cost mapping states.""" self.reset() interactive = bool(interactive and break_sym_targets is not None) self._keep_lap_sols = break_sym_targets is not None seed = [lgp[0].copy(), lgp[1].copy()] pending = [[lgp[0].copy(), lgp[1].copy()]] completed = [] if self.binary: for pair in pending: for graph in pair: graph.binarize_graph() chosen = [[], []] if interactive: base = self._read_index_base(base) while pending: self._stack.extend(pending) self.results.clear() self.minval = self._INF_INT self._drain_stack() self._keep_minimum_results() if break_sym_targets is None: break if interactive: pending, chosen = self._interactive_symmetry_split( seed, base, chosen, break_sym_targets, ) self._visited.clear() else: pending, completed = self._symmetry_split(completed, break_sym_targets) if break_sym_targets is not None and not interactive: self.results = completed self._keep_minimum_results() if break_sym_targets is not None: self._drop_isomorphic_symmetry_choices(seed, break_sym_targets) for result in self.results: _compress_pair_labels(result["lgp"]) if interactive: result["base"] = base result["choices"] = ";".join( f"{i + base}>>{j + base}" for i, j in zip(chosen[0], chosen[1]) )
def _read_index_base(self, base): while base not in [0, 1]: try: base = int(input("select 0 or 1-based indexing [0/1]:")) except ValueError: pass return base def _drain_stack(self): while self._stack: self._solve_partition_path(self._stack.pop()) def _keep_minimum_results(self): if not self.results: return best = min(result["val"] for result in self.results) self.results = [result for result in self.results if result["val"] == best] def _drop_isomorphic_symmetry_choices(self, initial_pair, break_sym_targets): if len(self.results) < 2: return if self._dedup_with_automorphism_keys(initial_pair, break_sym_targets): return merged_initial = _merge_pair(initial_pair) kept = [] seen = set() for result in self.results: merged = merged_initial.copy() shift = len(result["lgp"][0].labels) for react_idx in break_sym_targets: label = result["lgp"][0].labels[react_idx] product_idx = result["lgp"][1].label2idxs[label][0] merged.graph[react_idx][product_idx + shift] = 1 merged.graph[product_idx + shift][react_idx] = 1 key = canonical_graph_hash(merged, binary=True) if key in seen: continue seen.add(key) kept.append(result) self.results = kept def _dedup_with_automorphism_keys(self, initial_pair, break_sym_targets): try: if len(initial_pair[0].labels) > self._AUT_DEDUP_MAX_NODES: return False from ..exact.enumerate import ( _automorphism_permutations, canonical_partial_mapping_key, ) for graph in initial_pair: perms = _automorphism_permutations( graph, self.binary, self._AUT_DEDUP_MAX_AUTOMORPHISMS + 1, ) if len(perms) > self._AUT_DEDUP_MAX_AUTOMORPHISMS: return False seen = set() unique = [] for result in self.results: pairs = [] for react_idx in break_sym_targets: label = result["lgp"][0].labels[react_idx] product_idxs = result["lgp"][1].label2idxs.get(label, []) if product_idxs: pairs.append((react_idx, product_idxs[0])) key = canonical_partial_mapping_key(initial_pair, pairs, self.binary) if key in seen: continue seen.add(key) unique.append(result) self.results = unique return True except Exception: return False def _symmetry_split(self, completed, targets): next_pairs = [] while self.results: result = self.results.pop() pair = result["lgp"] label = self._next_symmetry_label(pair, targets) if label is None: completed.append(result) continue new_label = _fresh_label(pair) sols, class_pair = result["lap_sols"][label] for sol in sols: if len(set(sol["groups_pair"][0])) > 1: continue react_class = list(class_pair[0].values())[0] react_idx = min(react_class["idxs"]) product_group = ( 0 if sol["fingerprint"] is None else int(np.where(sol["fingerprint"][0] > 0)[0][0]) ) product_class = list(class_pair[1].values())[product_group] wl_labels = _initial_wl_labels(pair[1]) seen_wl = set() for product_idx in product_class["idxs"]: wl_label = wl_labels[product_idx] if wl_label in seen_wl: continue seen_wl.add(wl_label) branched = [pair[0].copy(), pair[1].copy()] _assign_singleton_label(branched[0], label, react_idx, new_label) _assign_singleton_label(branched[1], label, product_idx, new_label) key = _label_state_key(branched) if key in self._visited: continue self._clear_cached_labels(branched, [label, new_label]) self._visited.add(key) next_pairs.append(branched) return next_pairs, completed def _next_symmetry_label(self, pair, targets): for label in reversed(self._labels_by_size(pair)): idxs = pair[0].label2idxs[label] if len(idxs) > 1 and idxs[0] in targets: return label return None def _interactive_symmetry_split(self, initial_pair, base, chosen, targets): choices = self._collect_interactive_choices(targets) selected = None min_count = self._INF_INT for react_idx in targets: candidates = choices[react_idx] if 1 < len(candidates) < min_count: selected = (react_idx, candidates) min_count = len(candidates) if selected is None: return [], chosen react_idx, candidates = selected options = ", ".join(str(i + base) for i in sorted(candidates)) while True: try: product_idx = ( int(input(f"{react_idx + base} >> ? (probably in {options}):")) - base ) break except ValueError: pass chosen[0].append(react_idx) chosen[1].append(product_idx) constrained = [initial_pair[0].copy(), initial_pair[1].copy()] next_label = 1000 for r_idx, p_idx in zip(chosen[0], chosen[1]): while next_label in constrained[0].label2idxs: next_label += 1 constrained[0].labels[r_idx] = next_label constrained[1].labels[p_idx] = next_label next_label += 1 constrained[0].build_label2idxs() constrained[1].build_label2idxs() return [constrained], chosen def _collect_interactive_choices(self, targets): choices = defaultdict(set) for result in self.results: pair = result["lgp"] for label, react_idxs in pair[0].label2idxs.items(): if react_idxs[0] not in targets: continue sols, class_pair = result["lap_sols"][label] left_classes = list(class_pair[0].values()) right_classes = list(class_pair[1].values()) n_left = len(left_classes) n_right = len(right_classes) if n_left == 1 or n_right == 1: fingerprint = np.ones((n_left, n_right), dtype=bool) else: fingerprint = np.zeros((n_left, n_right), dtype=bool) for sol in sols: if len(set(sol["groups_pair"][0])) == 1: fingerprint |= sol["fingerprint"] > 0 for i, left in enumerate(left_classes): for j, right in enumerate(right_classes): if not fingerprint[i, j]: continue for react_idx in left["idxs"]: choices[react_idx].update(right["idxs"]) return choices def _solve_partition_path(self, pair, avoid_multi_sols=None): avoid_multi_sols = [1] if avoid_multi_sols is None else avoid_multi_sols total = 0 lap_sols = {} for label in self._labels_by_size(pair): cached = label in pair[0]._irred_labels if cached: sols, class_pair = pair[0]._irred_labels[label] else: sols, class_pair = self._solve_label_block(pair, label) total += sols[0]["val"] if total > self.minval: return irreducible = cached or self._should_stop_on_multiple( pair, label, sols, avoid_multi_sols ) if not irreducible: for sol in sols: if not sol["proper"]: continue if len(set(sol["groups_pair"][0])) == 1: irreducible = True break branched = self._refine_by_solution(pair, label, sol, class_pair) key = _label_state_key(branched) if key not in self._visited: self._visited.add(key) self._stack.append(branched) if self._keep_lap_sols: lap_sols[label] = (sols, _classes_as_legacy_pair(class_pair)) if irreducible: pair[0]._irred_labels.setdefault(label, (sols, class_pair)) continue return self.results.append({"lgp": pair, "val": total, "lap_sols": lap_sols}) self.minval = min(self.minval, total) def _should_stop_on_multiple(self, pair, label, sols, avoid_multi_sols): initial_label = pair[0]._ini_labels[pair[0].label2idxs[label][0]] if initial_label not in avoid_multi_sols: return False return sum(1 for sol in sols if sol["proper"]) > 1 def _labels_by_size(self, pair): return sorted( pair[0].label2idxs, key=lambda label: -len(pair[0].label2idxs[label]) ) def _solve_label_block(self, pair, label): key = self._label_block_key(pair, label) if key is not None and key in self._label_block_cache: cost, masks, n_classes, class_pair = self._label_block_cache[key] return self._enumerate_lap_fingerprints(cost, masks, n_classes), class_pair cost, masks, n_classes, class_pair = self._cost_matrix(pair, label) if key is not None: self._label_block_cache[key] = (cost, masks, n_classes, class_pair) return self._enumerate_lap_fingerprints(cost, masks, n_classes), class_pair def _label_block_key(self, pair, label): if not self.cache_label_blocks: return None return (label, tuple(pair[0].labels), tuple(pair[1].labels)) def _cost_matrix(self, pair, label): blocks = [self._partition_block(graph, label) for graph in pair] left_classes, left_offsets = blocks[0] right_classes, right_offsets = blocks[1] cost = np.empty((left_offsets[-1], right_offsets[-1]), dtype=int) for left_pos, left in enumerate(left_classes): a0, a1 = left_offsets[left_pos], left_offsets[left_pos + 1] left_nbrs = left[2] for right_pos, right in enumerate(right_classes): b0, b1 = right_offsets[right_pos], right_offsets[right_pos + 1] cost[a0:a1, b0:b1] = self._neighborhood_cost(left_nbrs, right[2]) n_classes = [len(left_classes), len(right_classes)] masks = ( [ _block_mask(n_classes[0], left_offsets), _block_mask(n_classes[1], right_offsets), ] if n_classes[0] > 1 and n_classes[1] > 1 else [None, None] ) return cost, masks, n_classes, [left_classes, right_classes] def _partition_block(self, graph, label): classes = self._next_classes(graph, label) offsets = [0] for _, idxs, _ in classes: offsets.append(offsets[-1] + len(idxs)) return classes, offsets def _next_classes(self, graph, label): buckets = {} labels = graph.labels adjacency = graph.graph for idx in graph.label2idxs[label]: if self.binary: nbrs = {} for nbr in adjacency.get(idx, {}): nbr_label = labels[nbr] nbrs[nbr_label] = nbrs.get(nbr_label, 0) + 1 if self.deterministic_labels: signature = (label, tuple(sorted(nbrs.items()))) else: signature = (label, frozenset(nbrs.items())) else: nbrs = defaultdict(list) for nbr, weight in adjacency.get(idx, {}).items(): nbrs[labels[nbr]].append(weight) nbrs = { nbr_label: sorted(weights, reverse=True) for nbr_label, weights in nbrs.items() } signature = ( label, tuple( (nbr_label, tuple(weights)) for nbr_label, weights in sorted(nbrs.items()) ), ) color = self._label_token(signature) if color not in buckets: buckets[color] = [color, [], nbrs] buckets[color][1].append(idx) return list(buckets.values()) def _label_token(self, signature): if self.deterministic_labels: return stable_int_token(signature) return hash(signature) & 0x7FFFFFFFFFFFFFFF def _neighborhood_cost(self, left, right): if self.binary: cost = 0 for label, count in left.items(): other = right.get(label) cost += count if other is None else abs(count - other) for label, count in right.items(): if label not in left: cost += count return cost cost = 0 for label, weights in left.items(): cost += self._weight_multiset_cost(weights, right.get(label, [])) for label, weights in right.items(): if label not in left: cost += sum(weights) return cost def _weight_multiset_cost(self, left, right): if self.binary: return abs(left - right) count = min(len(left), len(right)) paired = sum(abs(left[i] - right[i]) for i in range(count)) return paired + sum(left[count:]) + sum(right[count:]) def _enumerate_lap_fingerprints(self, cost, masks, n_classes): if n_classes[0] == 1 or n_classes[1] == 1: row, col = linear_sum_assignment(cost) sols = [ { "row": row, "col": col, "val": np.sum(cost[row, col]), "fingerprint": None, "groups_pair": [[0] * n_classes[0], [0] * n_classes[1]], } ] else: sols = self._enumerate_structural_fingerprints(cost, masks, n_classes) self._mark_proper_solutions(sols) return sols def _enumerate_structural_fingerprints(self, cost, masks, n_classes): solutions = [] best = None perturbed = 100 * cost for iteration in range(self.max_lap_fingerprints): row, col = linear_sum_assignment(perturbed) value = np.sum(cost[row, col]) if iteration == 0: best = value if value != best: break fingerprint = masks[0][:, row] @ masks[1][:, col].T if any(np.all(fingerprint == sol["fingerprint"]) for sol in solutions): break solutions.append( { "row": row, "col": col, "val": value, "fingerprint": fingerprint, "groups_pair": _fingerprint_components(fingerprint, n_classes), } ) perturbed += ((masks[0].T @ fingerprint @ masks[1]) > 0).astype(int) return solutions def _mark_proper_solutions(self, sols): if not sols: return group_rows = [sol["groups_pair"][0] + sol["groups_pair"][1] for sol in sols] width = len(group_rows[0]) for sol in sols: sol["proper"] = True for i, row_i in enumerate(group_rows): for j, row_j in enumerate(group_rows): if i == j or not sols[j]["proper"]: continue image = defaultdict(set) for idx in range(width): image[row_i[idx]].add(row_j[idx]) if all(len(values) == 1 for values in image.values()): sols[i]["proper"] = False break def _refine_by_solution(self, pair, label, sol, class_pair): refined = [pair[0].copy(), pair[1].copy()] group_to_label = {} for group, (next_label, _, _) in zip(sol["groups_pair"][1], class_pair[1]): group_to_label[group] = next_label fresh = _fresh_label(refined) for group in sol["groups_pair"][0]: if group not in group_to_label: group_to_label[group] = fresh fresh += 1 for side in range(2): merged = defaultdict(list) for group, (next_label, idxs, _) in zip( sol["groups_pair"][side], class_pair[side] ): merged[group_to_label[group]].extend(idxs) del refined[side].label2idxs[label] for next_label, idxs in merged.items(): refined[side].label2idxs[next_label] = sorted(idxs) for idx in idxs: refined[side].labels[idx] = next_label self._clear_cached_labels(refined, group_to_label.values()) return refined def _clear_cached_labels(self, pair, parent_labels): neighboring_labels = defaultdict(set) for parent in parent_labels: for graph in pair: for idx in graph.label2idxs[parent]: neighboring_labels[parent].update( graph.labels[nbr] for nbr in graph.graph[idx] ) dirty = set(parent_labels) for left_label, left_neighbors in neighboring_labels.items(): for right_label, right_neighbors in neighboring_labels.items(): if left_label > right_label: dirty.update(left_neighbors & right_neighbors) for label in dirty: pair[0]._irred_labels.pop(label, None)
def _classes_as_legacy_pair(class_pair): return [_classes_as_legacy_dict(classes) for classes in class_pair] def _block_mask(n_classes, offsets): mask = np.zeros((n_classes, offsets[-1]), dtype=int) for row in range(n_classes): mask[row, offsets[row] : offsets[row + 1]] = 1 return mask def _classes_as_legacy_dict(classes): return { color: { "idxs": list(idxs), "nbrs": { label: ([1] * weights if isinstance(weights, int) else list(weights)) for label, weights in nbrs.items() }, } for color, idxs, nbrs in classes } def _fingerprint_components(fingerprint, n_classes): n_left, n_right = n_classes groups_left = [-1] * n_left groups_right = [-1] * n_right group_id = 0 for start in range(n_left): if groups_left[start] != -1: continue frontier_left = {start} seen_left = set() seen_right = set() while frontier_left: fresh_left = frontier_left - seen_left if not fresh_left: break frontier_right = set() for left in fresh_left: groups_left[left] = group_id frontier_right.update(np.where(fingerprint[left] > 0)[0]) seen_left.update(fresh_left) fresh_right = frontier_right - seen_right if not fresh_right: break frontier_left = set() for right in fresh_right: groups_right[right] = group_id frontier_left.update(np.where(fingerprint.T[right] > 0)[0]) seen_right.update(fresh_right) group_id += 1 for right in range(n_right): if groups_right[right] == -1: groups_right[right] = group_id group_id += 1 return [groups_left, groups_right] def _fresh_label(pair): labels = set(pair[0].label2idxs) | set(pair[1].label2idxs) return max(labels, default=999) + 1 def _initial_wl_labels(graph): if graph._ini_wl_labels is None: graph._ini_wl_labels = graph.get_WL_labels() return graph._ini_wl_labels def _assign_singleton_label(graph, old_label, idx, new_label): graph.labels[idx] = new_label graph.label2idxs[old_label].remove(idx) graph.label2idxs[new_label].append(idx) def _merge_pair(pair): shift = len(pair[0].labels) left = {idx: dict(nbrs) for idx, nbrs in pair[0].graph.items()} right = { idx + shift: {nbr + shift: weight for nbr, weight in nbrs.items()} for idx, nbrs in pair[1].graph.items() } return LabeledGraph({**left, **right}, pair[0].labels + pair[1].labels) def _compress_pair_labels(pair): old_to_new = {} next_label = 1 for label in pair[0].labels: if label not in old_to_new: old_to_new[label] = next_label next_label += 1 for graph in pair: graph.labels = [old_to_new[label] for label in graph.labels] graph.build_label2idxs() def _label_state_key(pair): old_to_new = {} next_label = 1 key = [] for graph in pair: for label in graph.labels: if label not in old_to_new: old_to_new[label] = next_label next_label += 1 key.append(old_to_new[label]) return tuple(key)