Source code for synkit.Chem.Reaction.Mapper.chem.its

"""
Symmetry-distinct deduplication of atom-to-atom mappings via canonical ITS hashing.

The SLAP search can return several minimum-cost mappings that describe the *same*
reaction once symmetry-equivalent atoms are relabeled (for example, two mappings
that differ only by swapping two equivalent hydrogens or two equivalent ring
positions). Such mappings are isomorphic copies of a single optimum and should be
collapsed to one representative.

The canonical way to test "same reaction up to atom relabeling" is to build the
imaginary transition state (ITS) graph -- the union of the reactant and product
graphs with each atom identified through the mapping and each edge labeled by its
(reactant order, product order) pair -- and compare ITS graphs up to isomorphism.
Two mappings are isomorphic copies if and only if their ITS graphs are isomorphic
as node- and edge-labeled graphs. We obtain a canonical hash of each ITS graph and
deduplicate by that hash.

This module uses SynKit ITS construction and its Weisfeiler-Lehman graph hash
(:class:`synkit.Graph.Feature.wl_hash.WLHash`, a thin wrapper over
``networkx.weisfeiler_lehman_graph_hash``) for canonical hashing. The WL hash is
isomorphism-invariant by construction -- it depends only on iterative aggregation
of node/edge attributes over graph topology, not on input atom ordering -- so two
mappings that are isomorphic copies always receive the same hash.

Note that mappings which are genuinely *different* reaction centres but happen to
tie on chemical distance (e.g. an ambiguity over which of two equivalent leaving
groups departs) have *distinct* ITS graphs and are therefore preserved -- they are
separate optima, not isomorphic duplicates.
"""

from synkit.Graph.Feature.wl_hash import WLHash
from synkit.IO import rsmi_to_its

_hasher = None

# Node attributes seeding the WL colour refinement. ``atom_map`` is deliberately
# excluded so that two mappings differing only by a relabeling of equivalent
# atoms collapse to the same hash. The bond change is carried by the edge
# ``order`` attribute, an ``(reactant_order, product_order)`` tuple, which is fed
# into the refinement so that distinct reaction centres receive distinct hashes.
_WL_NODE_ATTRS = ["element", "aromatic", "charge", "hcount", "lone_pairs"]
_WL_EDGE_ATTRS = ["order"]
_WL_ITERATIONS = 5


def _get_hasher():
    global _hasher
    if _hasher is None:
        _hasher = WLHash(
            node=_WL_NODE_ATTRS,
            edge=_WL_EDGE_ATTRS,
            iterations=_WL_ITERATIONS,
        )
    return _hasher


[docs] def its_canonical_hash(mapped_rxn_smiles): """ Return a canonical hash of the ITS graph of a mapped reaction SMILES. Parameters ---------- mapped_rxn_smiles : str Reaction SMILES annotated with atom map numbers (``"A>>B"``). Returns ------- str or None A hash that is identical for mappings describing the same reaction up to relabeling of symmetry-equivalent atoms, or ``None`` if the ITS graph could not be constructed. """ try: its = rsmi_to_its(mapped_rxn_smiles) return _get_hasher().weisfeiler_lehman_graph_hash(its) except Exception: return None
def _standard_order(edge_attrs): if "standard_order" in edge_attrs: return float(edge_attrs["standard_order"]) order = edge_attrs.get("order") if isinstance(order, (tuple, list)) and len(order) == 2: return float(order[0]) - float(order[1]) return 0.0
[docs] def electron_balance_imbalances(its): """ Return per-node signed bond-order imbalance for an ITS graph. The ITS edge ``standard_order`` is reactant bond order minus product bond order. Summing it over incident changed edges gives a compact local bookkeeping signal: positive values indicate net bond order lost at that atom, negative values indicate net bond order gained. """ imbalances = {node: 0.0 for node in its.nodes} for u, v, attrs in its.edges(data=True): delta = _standard_order(attrs) imbalances[u] += delta imbalances[v] += delta return imbalances
[docs] def reaction_center_atom_maps(mapped_rxn_smiles, tol=1e-9): """ Return atom-map numbers incident to changed ITS edges. The result is suitable for lightweight hydrogen expansion: map heavy atoms first, then display hydrogens only on atoms whose bonds changed. """ try: its = rsmi_to_its(mapped_rxn_smiles) except Exception: return set() maps = set() for u, v, attrs in its.edges(data=True): if abs(_standard_order(attrs)) <= tol: continue for node in (u, v): data = its.nodes[node] atom_map = data.get("atom_map") if atom_map and data.get("element") != "H": maps.add(int(atom_map)) return maps
[docs] def is_electron_balanced(its, tol=1e-9): # noqa: C901 """ Return whether an ITS passes the bond-order electron-balance heuristic. This check deliberately uses explicit-H ITS graphs. Hydrogens are therefore present when synkit can expose them, and SynKit 1.4+ node attributes such as ``lone_pairs`` are available for hashing/bookkeeping. The rule is still a conservative bond-order heuristic rather than a full arrow-pushing proof: - each connected reaction-center component must have zero net changed bond order; - no atom may carry more than two units of local bond-order imbalance, and magnitude-2 centers must appear in opposite-sign pairs. The second condition allows normal terminal heteroatom source/sink behavior in substitutions. Magnitude-2 centers are allowed only when the same reaction-center component contains a matching opposite magnitude-2 center, which covers imine/condensation-style lone-pair bookkeeping while rejecting mappings that delete a C=O double bond and distribute the compensation over unrelated single-bond changes. """ changed_edges = [] adjacency = {} for u, v, attrs in its.edges(data=True): delta = _standard_order(attrs) if abs(delta) <= tol: continue changed_edges.append((u, v, delta)) adjacency.setdefault(u, []).append(v) adjacency.setdefault(v, []).append(u) if not changed_edges: return True imbalances = electron_balance_imbalances(its) if any(abs(value) > 2.0 + tol for value in imbalances.values()): return False edge_by_component = {} for u, v, delta in changed_edges: edge_by_component.setdefault(u, []).append((u, v, delta)) edge_by_component.setdefault(v, []).append((u, v, delta)) seen = set() for start in adjacency: if start in seen: continue stack = [start] component = set() while stack: node = stack.pop() if node in component: continue component.add(node) stack.extend(adjacency.get(node, ())) seen.update(component) total = 0.0 used_edges = set() for node in component: for edge in edge_by_component.get(node, ()): key = frozenset(edge[:2]) if key in used_edges: continue used_edges.add(key) total += edge[2] if abs(total) > tol: return False big_pos = 0 big_neg = 0 for node in component: value = imbalances[node] if value > 1.0 + tol: big_pos += 1 elif value < -1.0 - tol: big_neg += 1 if big_pos != big_neg: return False return True
def _node_missing_on_one_side(data): present = data.get("present") return ( isinstance(present, (tuple, list)) and len(present) == 2 and present[0] != present[1] )
[docs] def electron_balance_status(its, tol=1e-9): """ Return ``True``/``False``/``None`` for electron-balance status. ``None`` means the mapped reaction omits a coproduct/byproduct in a changed component, so the displayed ITS is incomplete and a strict electron-balance verdict would be overconfident. """ adjacency = {} edge_delta = {} for u, v, attrs in its.edges(data=True): delta = _standard_order(attrs) if abs(delta) <= tol: continue adjacency.setdefault(u, []).append(v) adjacency.setdefault(v, []).append(u) edge_delta[frozenset((u, v))] = delta seen = set() for start in adjacency: if start in seen: continue stack = [start] component = set() while stack: node = stack.pop() if node in component: continue component.add(node) stack.extend(adjacency.get(node, ())) seen.update(component) total = 0.0 used_edges = set() for node in component: for nbr in adjacency.get(node, ()): key = frozenset((node, nbr)) if key in used_edges: continue used_edges.add(key) total += edge_delta[key] if abs(total) > tol and any( _node_missing_on_one_side(its.nodes[node]) for node in component ): return None return is_electron_balanced(its, tol=tol)
[docs] def mapped_rxn_is_electron_balanced(mapped_rxn_smiles): """ Return electron-balance status for a mapped reaction SMILES. Returns ``None`` if synkit cannot construct the explicit-H ITS graph, so callers can avoid treating parser failures as proven invalid chemistry. """ try: its = rsmi_to_its(mapped_rxn_smiles, explicit_hydrogen=True) except Exception: return None return electron_balance_status(its)
[docs] def dedup_mapped_rxns(results, smiles_key="smiles"): """ Remove isomorphic duplicate mappings from a list of result dictionaries. Each result is expected to carry a ``"smiles"`` key with the mapped reaction SMILES. The canonical ITS hash is computed for every result and stored under ``"its_hash"``; only the first result seen for each distinct hash is kept, so the original ordering of symmetry-distinct optima is preserved. Results whose ITS graph cannot be built (hash ``None``) are never collapsed together -- they are all kept, keyed by their raw mapped SMILES instead -- so deduplication can only ever remove provable isomorphic copies. Parameters ---------- results : list[dict] Mapping results, each containing a ``"smiles"`` entry. Returns ------- list[dict] The symmetry-distinct subset of ``results``. """ seen = set() deduped = [] for r in results: h = its_canonical_hash(r.get(smiles_key)) r["its_hash"] = h key = h if h is not None else ("raw", r.get(smiles_key)) if key in seen: continue seen.add(key) deduped.append(r) return deduped