Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

SynEdu learning path
Lesson 7 of 9 · Stage 2 · Rule construction
78% complete
Open S07 in ColabLaunch S07 in BinderDownload S07 notebookRun S07 locally

This talktorial builds the bridge from atom-mapped reaction records to a reusable DPO reaction-rule library. It combines standardization, ensemble mapping, ITS construction, WL prefiltering, graph isomorphism, and optional MØD export Phan et al., 2025Shervashidze et al., 2011Andersen et al., 2016.

Aim of this talktorial

  1. Standardize and canonicalize mapped reaction strings so equivalent maps can be compared reliably.

  2. Construct high-confidence reaction centers from ensemble atom maps and ITS graphs.

  3. Cluster, deduplicate, and export reaction centers as DPO rules.


Learning outcomes

After completing this talktorial, you will be able to:

The complete S07 workflow can be summarized as a linear pipeline. Each step progressively reduces mapping ambiguity and improves the reproducibility of graph-based reaction rule extraction.

S07 workflow for consensus-driven reaction rule extraction

Figure 1. Overview of the S07 workflow for extracting graph-based reaction rules from atom-to-atom mapped reactions. Reactions are first standardized and canonicalized, filtered by ensemble agreement, converted into ITS graphs, and reduced to reaction centers. Reaction centers are clustered to identify unique representatives, completed with hydrogen information, and finally exported as GML reaction rules. Discard branches indicate cases rejected because of insufficient mapping consensus or ambiguous hydrogen completion.

0. Setup & data

This notebook uses SynKit Phan et al., 2025. to accelerate development: fast access to molecular graphs, rule application (DPO/ITS), pattern matching, and utilities for building reaction-centric workflows.

we can easily install synkit via pypi:

pip install synkit
import logging
import os
import warnings

# SynKit reports progress with tqdm. Outside a live Jupyter session tqdm falls
# back to a text bar that writes one stderr line per update, which buries the
# actual results. Bars stay on when you run this notebook yourself.
os.environ.setdefault("TQDM_DISABLE", "1")
logging.disable(logging.INFO)
warnings.filterwarnings("ignore")

import rdkit
import pandas as pd
import networkx as nx
from pathlib import Path
from tqdm.auto import tqdm
from synedu.Utils import load_database

print("RDKit version:", rdkit.__version__)
print("NetworkX version:", nx.__version__)

DATA_DIR = Path("data")
DATA_PATH = DATA_DIR / "data_aam.json.gz"
data = pd.DataFrame(
    load_database(DATA_PATH)[:100]
)  # 100 reactions for manageable runtime
display(data.head())
print(data.shape)

The dataset incorporates three distinct atom mapping methodologies: rxn_mapper Schwaller et al., 2021, graphormer mapper Nugmanov et al., 2022, and local_mapper Chen et al., 2024.

1. Reaction preprocessing

  1. Validate & standardize SMILES using Standardize Schneider et al., 2016 (drop invalid/unparsable).

  2. Canonicalize atom-map numbers with CanonRSMI (from S06) for identical mappings.

from tqdm.auto import tqdm
from synkit.Chem.Reaction.standardize import Standardize
from synkit.Chem.Reaction.canon_rsmi import CanonRSMI

tqdm.pandas(desc="Canonicalizing")


def std_canon(aam: str):
    if aam is None:
        return None
    try:
        std = Standardize()
        canon = CanonRSMI(wl_iterations=4)
        aam_std = std.fit(aam, remove_aam=False)
        canon_out = canon.canonicalise(aam_std)
        return getattr(canon_out, "canonical_rsmi", None)
    except Exception:
        return None


tqdm.pandas(desc="Canonicalizing — rxn_mapper")
data['rxn_canon'] = data['rxn_mapper'].progress_apply(std_canon)

tqdm.pandas(desc="Canonicalizing — graphormer")
data['graph_canon'] = data['graphormer'].progress_apply(std_canon)

tqdm.pandas(desc="Canonicalizing — local_mapper")
data['local_canon'] = data['local_mapper'].progress_apply(std_canon)

2. Ensemble atom-to-atom mapping agreement

Compare the canonicalized mapper outputs by exact string equality; rows where all canonical maps match are high-confidence AAM assignments Phan et al., 2025.

Definition (Canonical Mapped Reaction Representation). Two atom-mapped reaction SMILES s1s_1 and s2s_2 are equivalent under the chosen ITS schema if their corresponding ITS graphs Γ1\Gamma_1 and Γ2\Gamma_2 are label-preserving isomorphic: Γ1≅Γ2\Gamma_1 \cong \Gamma_2. A canonical mapped reaction representation canon(s)\mathrm{canon}(s) is obtained by computing a canonical order of the finite mapped-atom set MM, applying π:M→{1,…,∣M∣}\pi:M\to\{1,\ldots,|M|\} synchronously to both sides, and then using a deterministic reaction-SMILES serializer. The graph certificate—not an unverified claim about the lexicographically smallest possible SMILES—is what defines the canonical form.

Definition (Ensemble Agreement).
Given nn atom-mapping functions φ1,…,φn\varphi_1, \ldots, \varphi_n applied to the same reaction ϱ\varrho, the ensemble agrees on ϱ\varrho if all canonical strings are identical:

canon(φ1(ϱ))=canon(φ2(ϱ))=⋯=canon(φn(ϱ))\mathrm{canon}(\varphi_1(\varrho)) = \mathrm{canon}(\varphi_2(\varrho)) = \cdots = \mathrm{canon}(\varphi_n(\varrho))

When the canonical strings disagree, an exact ITS isomorphism check determines whether the difference is only representational or whether the mappers encode different atom correspondences. Isomorphism can classify the disagreement; it cannot by itself decide which non-equivalent map is chemically correct.

Definition (WL Reaction Hash).
The WL hash of a reaction-center ITS ΓRC\Gamma_{\mathrm{RC}} is a deterministic digest of the successive multisets of refined node colors:

hWL(ΓRC)=encode⁡ ⁣({ ⁣{ct(v):v∈V} ⁣}t=0t∗).h_{\mathrm{WL}}(\Gamma_{\mathrm{RC}}) =\operatorname{encode}\!\left( \{\!\{c_t(v):v\in V\}\!\}_{t=0}^{t^\ast} \right).

Isomorphic labeled graphs have identical hashes, but the converse is false: hash collisions are possible. Reactions with identical WL hashes are therefore candidate isomorphs; exact isomorphism (graph matching) Cordella et al., 2004 is then used to confirm.

import pandas as pd
from typing import Tuple, List, Union, Sequence


def extract_aam(
    df: pd.DataFrame,
    keys: Sequence[str] = ("rxn_mapper", "graphormer", "local_mapper"),
    strip: bool = True,
    require_not_none: bool = True,
    return_indices: bool = False,
    inplace: bool = False,
) -> Union[
    Tuple[pd.DataFrame, pd.DataFrame],
    Tuple[pd.DataFrame, List[int], pd.DataFrame, List[int]],
]:
    k0, k1, k2 = keys
    s0, s1, s2 = df[k0].copy(), df[k1].copy(), df[k2].copy()
    if strip:
        mask0 = s0.map(lambda x: isinstance(x, str))
        mask1 = s1.map(lambda x: isinstance(x, str))
        mask2 = s2.map(lambda x: isinstance(x, str))
        if mask0.any():
            s0.loc[mask0] = s0.loc[mask0].str.strip()
        if mask1.any():
            s1.loc[mask1] = s1.loc[mask1].str.strip()
        if mask2.any():
            s2.loc[mask2] = s2.loc[mask2].str.strip()
    if require_not_none:
        non_null = s0.notna() & s1.notna() & s2.notna()
    else:
        non_null = pd.Series(True, index=df.index)
    eq_mask = non_null & s0.eq(s1) & s1.eq(s2)
    out_df = df.loc[eq_mask].copy()
    non_out_df = df.loc[~eq_mask].copy()
    if not out_df.empty:
        out_df = out_df.assign(aam=s0.loc[eq_mask].values)
        out_df = out_df.drop(columns=[k0, k1, k2], errors="ignore")
    if inplace:
        df.drop(df.index, inplace=True)
        for col in out_df.columns:
            df[col] = out_df[col].values
        df.reset_index(drop=True, inplace=True)
    if return_indices:
        return out_df, out_df.index.to_list(), non_out_df, non_out_df.index.to_list()
    return out_df, non_out_df


out, rest = extract_aam(data, keys=("rxn_canon", "graph_canon", "local_canon"))
print("Equivalent maps:", len(out))
print("Non equivalent maps:", len(rest))

Weisfeiler-Lehman Shervashidze et al., 2011 based canonicalization is fast but not exact. Use WL/canonical strings as a cheap filter, then run exact ITS isomorphism on rows where canonical strings disagree or ambiguity remains.

In SynKit, we expose AAMValidator to compare directly atom-to-atom map by converting to ITS and checking isomorphism

import pandas as pd
from typing import Sequence, Tuple, Optional
from synkit.Chem.Reaction.aam_validator import AAMValidator


def refinement(
    df: pd.DataFrame,
    aam_cols: Sequence[str] = ("rxn_canon", "graph_canon", "local_canon"),
    validator: Optional[AAMValidator] = None,
) -> Tuple[pd.DataFrame, pd.DataFrame]:
    if validator is None:
        validator = AAMValidator()
    cols = list(aam_cols)
    if len(cols) < 2:
        raise ValueError("aam_cols must contain at least two columns")
    missing = [c for c in cols if c not in df.columns]
    if missing:
        raise KeyError(f"missing columns: {missing}")
    df2 = df.copy()
    df2[cols] = df2[cols].map(lambda x: x.strip() if isinstance(x, str) else x)
    ref = cols[0]
    solved_idx, unsolved_idx = [], []
    for idx, row in df2[cols].iterrows():
        ref_val = row[ref]
        if ref_val is None or pd.isna(ref_val):
            unsolved_idx.append(idx)
            continue
        ok = True
        for c in cols[1:]:
            val = row[c]
            if val is None or pd.isna(val):
                ok = False
                break
            try:
                if not bool(validator.smiles_check(ref_val, val)):
                    ok = False
                    break
            except Exception:
                ok = False
                break
        (solved_idx if ok else unsolved_idx).append(idx)
    solved = df2.loc[solved_idx].copy()
    solved = solved.drop(columns=[c for c in cols if c != ref], errors="ignore")
    solved = solved.rename(columns={ref: "aam"})
    unsolved = df2.loc[unsolved_idx].copy()
    return solved, unsolved


solve, unsolve = refinement(rest)
df_aam = pd.concat([out, solve], axis=0)
df_aam.shape

Ensemble mapper agreement distribution

Three mappers (RXNMapper, Graphormer, Local Mapper) each produce an atom map for every reaction. The ensemble step classifies reactions by how many mappers agree. Unanimous mappings form the primary high-confidence set. Exact isomorphism can recover cases that differ only in representation; genuinely non-equivalent disagreements require another selection criterion or are discarded.

Source
import matplotlib.pyplot as plt

print(f"df_aam shape: {df_aam.shape}")
print(f"Columns: {list(df_aam.columns)}")

if "class" in df_aam.columns:
    _vc_class = df_aam["class"].value_counts().sort_index()
    fig, ax = plt.subplots(figsize=(max(6, len(_vc_class) * 1.5), 4))
    bars = ax.bar(
        _vc_class.index.astype(str),
        _vc_class.values,
        color="#1F77B4",
        alpha=0.85,
        edgecolor="white",
    )
    ax.bar_label(bars, padding=3, fontsize=9)
    ax.set_xlabel("Agreement class", fontsize=10)
    ax.set_ylabel("Number of reactions", fontsize=10)
    ax.set_title(
        "Ensemble mapping — mapper agreement class distribution",
        fontsize=11,
        fontweight="bold",
    )
    ax.spines[["top", "right"]].set_visible(False)
    plt.xticks(rotation=20, ha="right", fontsize=9)
    plt.tight_layout()
    plt.show()
    print(f"Total ensemble-validated reactions: {len(df_aam)}")
else:
    print("'class' column not found. Available columns:", list(df_aam.columns))
    display(df_aam.head(3))

Q1 — Ensemble mapping

Given a DataFrame with several mapper columns, ensemble_maps(df, map_cols):


Solution:
from typing import List, Tuple
import pandas as pd
from tqdm.auto import tqdm

tqdm.pandas() 

def ensemble_maps(df: pd.DataFrame, map_cols: List[str]) -> Tuple[pd.DataFrame, pd.DataFrame]:
    missing = [c for c in map_cols if c not in df.columns]
    if missing:
        raise KeyError(f"missing columns: {missing}")

    canon_cols = []
    for col in map_cols:
        tgt = f"{col}_canon"
        canon_cols.append(tgt)
        tqdm.pandas(desc=f"Canonicalization {col}") 
        df[tgt] = df[col].progress_apply(std_canon)

    match, unmatch = extract_aam(df, keys=tuple(canon_cols))
    solve, unsolve = refinement(unmatch, aam_cols=tuple(canon_cols))

    out = pd.concat([match, solve], axis=0).reset_index(drop=True)
    out = out.drop_duplicates(subset="aam", keep="first").reset_index(drop=True)
    return out, unsolve

    

3. Rule library construction

With high-confidence aam maps in hand, convert each mapped reaction into an ITS graph, extract the reaction center, and turn that center into a reusable rule entry for the library.

3.1. Graph construction

df_aam.drop_duplicates(subset='aam', inplace=True)

We now use synedu.Utils to convert each atom-mapped reaction to an ITS graph and extract the reaction center.

from synedu.Utils import rsmi_to_its
from synedu.Utils.its_vis import visualize_its
import matplotlib.pyplot as plt


def _safe_rsmi_to_its(rsmi, core=False):
    """Wrap rsmi_to_its to silently return None on bad/unmapped SMILES."""
    try:
        if not isinstance(rsmi, str) or ">>" not in rsmi:
            return None
        return rsmi_to_its(rsmi, core=core)
    except Exception:
        return None


tqdm.pandas(desc="ITS construction")
df_aam["ITS"] = df_aam["aam"].progress_apply(lambda s: _safe_rsmi_to_its(s, core=False))

tqdm.pandas(desc="Reaction center construction")
df_aam["RC"] = df_aam["aam"].progress_apply(lambda s: _safe_rsmi_to_its(s, core=True))

# Drop rows where ITS/RC could not be built
_before = len(df_aam)
df_aam.dropna(subset=["ITS", "RC"], inplace=True)
df_aam.reset_index(drop=True, inplace=True)
print(f"Kept {len(df_aam)}/{_before} reactions with valid ITS/RC")

fig, ax = plt.subplots(1, 2, figsize=(20, 8))
_idx = df_aam.index[1]
visualize_its(df_aam["ITS"][_idx], show_node_labels=False, ax=ax[0], show_legend=False)
visualize_its(df_aam["RC"][_idx], show_node_labels=False, ax=ax[1], show_legend=False)
ax[0].set_title("ITS graph", fontsize=24, fontweight="bold")
ax[1].set_title("Reaction center", fontsize=24, fontweight="bold")
plt.tight_layout()
plt.show()

3.2. Core refinement

Graph Clustering

Some extracted reaction centers are isomorphic Cordella et al., 2004. The following pairwise grouping algorithm collects them into equivalence classes.

With a set S={Γ1,Γ2,…,Γn},n≥2S=\{\Gamma_1,\Gamma_2,\dots,\Gamma_n\},\qquad n\ge 2, we want a partition TT of SS into isomorphism classes.

Pseudocode

T←∅S←{Γ1,Γ2,…,Γn}while S≠∅:q∗←arbitrary element of SS←S∖{q∗}C←{q∗}for each q∈copy(S):if VF2_iso⁡(q,q∗) thenC←C∪{q}S←S∖{q}end ifend forT←T∪{C}end whilereturn T\begin{array}{l} T \leftarrow \varnothing\\[4pt] S \leftarrow \{\Gamma_1,\Gamma_2,\dots,\Gamma_n\}\\[6pt] \text{while } S \neq \varnothing :\\ \quad q^{*} \leftarrow \text{arbitrary element of } S\\ \quad S \leftarrow S \setminus \{q^{*}\}\\ \quad C \leftarrow \{q^{*}\}\\ \quad \text{for each } q \in \mathrm{copy}(S) :\\ \quad\quad \text{if }\operatorname{VF2\_iso}(q,q^{*}) \text{ then}\\ \quad\quad\quad C \leftarrow C \cup \{q\}\\ \quad\quad\quad S \leftarrow S \setminus \{q\}\\ \quad\quad \text{end if}\\ \quad \text{end for}\\ \quad T \leftarrow T \cup \{C\}\\ \text{end while}\\[4pt] \text{return } T \end{array}

In SynKit, this algorithm is implemented in GraphCluster

from synedu.Utils import cluster_its_graphs

_rc_graphs = df_aam["RC"].tolist()
_labels = cluster_its_graphs(
    _rc_graphs,
    node_attrs=["element", "formal_charge"],
    edge_attrs=["order"],
)
df_aam["class"] = _labels
result = df_aam.to_dict("records")
print(f"Total reactions: {len(result)},  unique rule classes: {len(set(_labels))}")
result[0]

Q2 — Unique rules

You have a list of dictionaries where each item represents a reaction-center record and contains a class label (the isomorphism class). Implement get_unique_rc(...) to extract one representative reaction center per class.


Solution:
from typing import List, Dict, Any

def get_unique_rc(
    items: List[Dict[str, Any]],
    class_key: str = "class",
    rc_key: str = "RC",
    keep: str = "first",
) -> List[Any]:
    if keep not in ("first", "last"):
        raise ValueError("keep must be 'first' or 'last'")

    reps: dict = {}  # class -> (idx, rc)
    for i, it in enumerate(items):
        cls = it.get(class_key)
        if cls is None:
            continue
        rc = it.get(rc_key)
        if keep == "first":
            if cls not in reps:
                reps[cls] = (i, rc)
        else:  # keep == "last"
            reps[cls] = (i, rc)

    # sort by stored index and return rc values
    ordered = sorted(reps.values(), key=lambda x: x[0])
    return [rc for _, rc in ordered]


    

Alternatively, we can use SynKit’s utility functions.

from synedu.Utils import stratified_sample

rc_records = stratified_sample(result, key="class")  # one record per class
rc = [r["RC"] for r in rc_records]
print(f"Representative reaction centers: {len(rc)}")

Hydrogen completion in reaction centers

Implicit hydrogens are stored as a hcount = (h_r, h_p) tuple on each ITS node. When h_r ≠ h_p a hydrogen atom is transferred between molecules. Graph Modelling Language format requires explicit H nodes so the rule engine can track every atom.

h_expand_its — four-step pipeline

StepAction
①Decompose ITS → separate reactant / product graphs (convert tuple attrs → scalars)
②Identify donor atoms (h_r > h_p) and acceptor atoms (h_r < h_p)
③Pair donors ↔ acceptors (`n_shared = min(
④Rebuild ITS — the shared H node gets two edges
H edgeorderGML blockColour
H – donor(1, 0)left🔴 broken
H – acceptor(0, 1)right🟢 formed

Only balanced transfers are expanded.
If donors and acceptors do not have the same count (e.g. H is gained from an unmapped solvent molecule), the surplus is left implicit. A single-ended H node would encode creation or deletion of hydrogen inside the local rule rather than transfer from an explicit auxiliary species. This lesson omits such nodes to keep its exported rules atom-conserving; a broader reaction model should include the external species instead.

Ambiguous cases — when multiple donors and acceptors exist, different pairings can produce non-isomorphic reaction centers (see §3.2).

import networkx as nx
import matplotlib.pyplot as plt
from synedu.Utils.reaction import build_its
from synedu.Utils.its_vis import visualize_its


def its_decompose(its: nx.Graph):
    """Decompose ITS back into reactant and product molecular graphs."""
    r, p = nx.Graph(), nx.Graph()
    for n, data in its.nodes(data=True):
        r_attrs, p_attrs = {}, {}
        for k, v in data.items():
            if isinstance(v, tuple) and len(v) == 2:
                r_attrs[k], p_attrs[k] = v[0], v[1]
            else:
                r_attrs[k] = p_attrs[k] = v
        r_attrs["atom_map"] = p_attrs["atom_map"] = n
        r.add_node(n, **r_attrs)
        p.add_node(n, **p_attrs)
    for u, v, data in its.edges(data=True):
        br, bp = data.get("order", (0.0, 0.0))
        if float(br) > 0:
            r.add_edge(u, v, order=float(br))
        if float(bp) > 0:
            p.add_edge(u, v, order=float(bp))
    return r, p


def h_expand_its(its: nx.Graph) -> nx.Graph:
    """
    Expand implicit H transfers in an ITS into explicit H nodes.

    Only *balanced* donor-acceptor pairs are made explicit.
    Explicit H atoms already present in the ITS (from mapped SMILES) are
    skipped — their bonds are already encoded as ITS edges, so treating them
    as donors/acceptors would create spurious H-H bonds.
    """
    r, p = its_decompose(its)

    donors, acceptors = [], []
    for n in sorted(set(r.nodes()) & set(p.nodes())):
        if r.nodes[n].get("element") == "H":
            continue  # already explicit — skip to avoid H-H bonds
        hr = int(r.nodes[n].get("hcount", 0) or 0)
        hp = int(p.nodes[n].get("hcount", 0) or 0)
        # GetTotalNumHs() counts explicit H neighbours — subtract them
        # so we only track *implicit* transfers (explicit H nodes are
        # already encoded as ITS edges and must not be double-counted).
        expl_r = sum(1 for m in r.neighbors(n) if r.nodes[m].get("element") == "H")
        expl_p = sum(1 for m in p.neighbors(n) if p.nodes[m].get("element") == "H")
        implicit_r = max(0, hr - expl_r)
        implicit_p = max(0, hp - expl_p)
        donors.extend([n] * max(0, implicit_r - implicit_p))
        acceptors.extend([n] * max(0, implicit_p - implicit_r))

    n_shared = min(len(donors), len(acceptors))
    if n_shared == 0:
        return its  # no balanced transfers to expand

    next_id = max(set(r.nodes()) | set(p.nodes()), default=0) + 1
    for i in range(n_shared):
        hid = next_id + i
        _h_attrs = dict(
            element="H", formal_charge=0, aromatic=False, hcount=0, atom_map=hid
        )
        r.add_node(hid, **_h_attrs)
        r.add_edge(donors[i], hid, order=1.0)
        r.nodes[donors[i]]["hcount"] = max(
            0, (r.nodes[donors[i]].get("hcount", 0) or 0) - 1
        )
        p.add_node(hid, **_h_attrs)
        p.add_edge(acceptors[i], hid, order=1.0)
        p.nodes[acceptors[i]]["hcount"] = max(
            0, (p.nodes[acceptors[i]].get("hcount", 0) or 0) - 1
        )

    return build_its(r, p)


rc_h = [h_expand_its(g) for g in rc]
print(f"H-expanded reaction centers: {len(rc_h)}")

# ── Show comparison: first RC with a shared H transfer ───────────────
_show_idx = next(
    (i for i, g in enumerate(rc_h) if g.number_of_nodes() > rc[i].number_of_nodes()), 0
)
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 5), facecolor="white")
visualize_its(
    rc[_show_idx],
    ax=ax1,
    title="RC — implicit H",
    show_legend=False,
    layout="kamada_kawai",
)
visualize_its(
    rc_h[_show_idx],
    ax=ax2,
    title="RC — explicit H transfer",
    show_legend=True,
    layout="kamada_kawai",
)
plt.suptitle(
    "Reaction center before / after hydrogen expansion", fontsize=11, fontweight="bold"
)
plt.tight_layout()
plt.show()

_with_h = sum(1 for a, b in zip(rc, rc_h) if b.number_of_nodes() > a.number_of_nodes())
print(f"{_with_h}/{len(rc)} rule classes have explicit H transfer")

Ambiguous hydrogen expansion

When an RC contains multiple donor atoms and multiple acceptor atoms, more than one valid donor-acceptor pairing exists. Different pairings can produce non-isomorphic reaction centers, each encoding a different explicit-hydrogen atom-correspondence topology. This representation-level ambiguity does not by itself identify a reaction mechanism.

The reaction below involves trichloroacetonitrile reacting with an N-hydroxy compound. Its RC has two donors and two acceptors:

AtomRolehcount change
O6 (N–O–H oxygen)donor(1→0) loses H
C12 (aromatic C–H)donor(1→0) loses H
N5 (amine nitrogen)acceptor(0→1) gains H
N18 (nitrile nitrogen)acceptor(0→1) gains H

This gives two distinct pairings:

PairingO6 donates toC12 donates toTopology
AN5N18first atom-correspondence topology
BN18N5alternative bridge topology

h_expand_all enumerates every permutation and returns only the structurally distinct outcomes.

from itertools import permutations as _perms
import networkx as nx
import matplotlib.pyplot as plt
from synedu.Utils import rsmi_to_its, wl_hash
from synedu.Utils.reaction import build_its
from synedu.Utils.its_vis import visualize_its


def h_expand_all(its: nx.Graph):
    """
    Return all structurally distinct H-expanded ITS graphs.

    Tries every permutation of donor-acceptor pairings;
    uses the WL hash only as a bucket key, then de-duplicates
    each bucket by exact labeled-graph isomorphism.
    """
    r, p = its_decompose(its)
    donors, acceptors = [], []
    for n in sorted(set(r.nodes()) & set(p.nodes())):
        if r.nodes[n].get("element") == "H":
            continue
        hr = int(r.nodes[n].get("hcount", 0) or 0)
        hp = int(p.nodes[n].get("hcount", 0) or 0)
        expl_r = sum(1 for m in r.neighbors(n) if r.nodes[m].get("element") == "H")
        expl_p = sum(1 for m in p.neighbors(n) if p.nodes[m].get("element") == "H")
        implicit_r = max(0, hr - expl_r)
        implicit_p = max(0, hp - expl_p)
        donors.extend([n] * max(0, implicit_r - implicit_p))
        acceptors.extend([n] * max(0, implicit_p - implicit_r))

    if not donors or not acceptors:
        return [its]

    n_shared = min(len(donors), len(acceptors))
    donors = donors[:n_shared]
    acceptors = acceptors[:n_shared]
    results, seen = [], {}

    for acc_perm in _perms(acceptors):
        r_c, p_c = r.copy(), p.copy()
        nxt = max(set(r.nodes()) | set(p.nodes()), default=0) + 1
        for i, (d, a) in enumerate(zip(donors, acc_perm)):
            hid = nxt + i
            _h = dict(
                element="H", formal_charge=0, aromatic=False, hcount=0, atom_map=hid
            )
            r_c.add_node(hid, **_h)
            r_c.add_edge(d, hid, order=1.0)
            r_c.nodes[d]["hcount"] = max(0, (r_c.nodes[d].get("hcount", 0) or 0) - 1)
            p_c.add_node(hid, **_h)
            p_c.add_edge(a, hid, order=1.0)
            p_c.nodes[a]["hcount"] = max(0, (p_c.nodes[a].get("hcount", 0) or 0) - 1)
        cand = build_its(r_c, p_c)
        h = wl_hash(cand, node_attrs=["element", "formal_charge"], edge_attrs=["order"])
        bucket = seen.setdefault(h, [])
        node_match = lambda a, b: (
            a.get("element") == b.get("element")
            and a.get("formal_charge") == b.get("formal_charge")
        )
        edge_match = lambda a, b: a.get("order") == b.get("order")
        if any(
            nx.is_isomorphic(
                cand,
                previous,
                node_match=node_match,
                edge_match=edge_match,
            )
            for previous in bucket
        ):
            continue
        bucket.append(cand)
        results.append(cand)
    return results


# ── Real reaction: trichloroacetonitrile + N-hydroxy compound ────────────
_rsmi_amb = (
    "[CH3:1][O:2][C:3](=[O:4])[N:5]([OH:6])[c:7]1[cH:8][cH:9][cH:10][cH:11][cH:12]1"
    ".[N:18]#[C:17][C:14]([Cl:13])([Cl:15])[Cl:16]"
    ">>"
    "[CH3:1][O:2][C:3](=[O:4])[NH:5][c:7]1[cH:8][cH:9][cH:10][cH:11][c:12]1"
    "[NH:18][C:17](=[O:6])[C:14]([Cl:13])([Cl:15])[Cl:16]"
)

_rc_amb = rsmi_to_its(_rsmi_amb, core=True)

print("RC summary:")
for n in sorted(_rc_amb.nodes()):
    d = _rc_amb.nodes[n]
    hc = d.get("hcount", (0, 0))
    role = (
        "donor"
        if isinstance(hc, tuple) and hc[0] > hc[1]
        else "acceptor" if isinstance(hc, tuple) and hc[1] > hc[0] else ""
    )
    if role:
        print(f"  atom {n:2d} ({d.get('element')})  hcount={hc}  → {role}")

candidates = h_expand_all(_rc_amb)
print(f"\n→ {len(candidates)} structurally distinct expansion(s)")

_wl_fn = lambda g: wl_hash(
    g, node_attrs=["element", "formal_charge"], edge_attrs=["order"]
)
for i, cand in enumerate(candidates):
    print(
        f"  Pairing {i+1}: {cand.number_of_nodes()} nodes, "
        f"{cand.number_of_edges()} edges  WL={_wl_fn(cand)[:12]}…"
    )

# ── Visualize: RC + both expansions ─────────────────────────────────────
_n = len(candidates)
_pair_titles = [
    "Pairing A\nO6→N5,  C12→N18\nfirst correspondence topology",
    "Pairing B\nO6→N18,  C12→N5\nalternative correspondence topology",
]

fig, axes = plt.subplots(1, _n + 1, figsize=(6.5 * (_n + 1), 6), facecolor="white")

visualize_its(
    _rc_amb,
    ax=axes[0],
    title="Ambiguous RC (implicit H)\n2 donors · 2 acceptors",
    show_legend=False,
    show_edge_labels=True,
    layout="kamada_kawai",
)
axes[0].text(
    0.5,
    -0.05,
    "Donors: O6 (N–OH) · C12 (Ar–H)\nAcceptors: N5 (amine) · N18 (nitrile N)",
    ha="center",
    va="top",
    transform=axes[0].transAxes,
    fontsize=8,
    color="#333",
    bbox=dict(boxstyle="round,pad=0.3", fc="lightyellow", alpha=0.95),
)

for i, cand in enumerate(candidates):
    ttl = _pair_titles[i] if i < len(_pair_titles) else f"Pairing {i+1}"
    visualize_its(
        cand,
        ax=axes[i + 1],
        title=ttl,
        show_legend=(i == _n - 1),
        show_edge_labels=True,
        layout="kamada_kawai",
    )
    axes[i + 1].text(
        0.5,
        -0.05,
        f"WL: {_wl_fn(cand)[:14]}…",
        ha="center",
        va="top",
        transform=axes[i + 1].transAxes,
        fontsize=7,
        color="#666",
    )

plt.suptitle(
    "Ambiguous H expansion — 2 non-isomorphic reaction centers\n"
    "(trichloroacetonitrile + N-hydroxy compound)",
    fontsize=11,
    fontweight="bold",
)
plt.tight_layout()
plt.show()

3.3. Rule selection

Rule library size vs dataset size

A key question is how quickly the rule vocabulary grows with dataset size. We subsample the dataset at increasing sizes and plot the number of exact isomorphism classes. Sub-linear growth alone does not prove saturation: (N^\alpha) still diverges for every (\alpha>0).

from synedu.Utils import wl_hash
tqdm.pandas(desc="WL hash calculation")
_wl = lambda g: wl_hash(
    g, node_attrs=["element", "formal_charge"], edge_attrs=["order"]
)
df_aam["wl_hash"] = df_aam["RC"].progress_apply(_wl)
df_aam["wl_hash_its"] = df_aam["ITS"].progress_apply(_wl)
df_aam['wl_hash'].value_counts()
Source
import matplotlib.pyplot as plt
import numpy as np

_sizes = [100, 300, 500, 1000, 2000, 5000, len(df_aam)]
_sizes = [s for s in _sizes if s <= len(df_aam)]
_unique_rules = []

for sz in _sizes:
    _sub = df_aam.sample(n=sz, random_state=42) if sz < len(df_aam) else df_aam
    _unique_rules.append(_sub['class'].nunique())

fig, ax = plt.subplots(figsize=(8, 4), facecolor='white')
ax.plot(
    _sizes,
    _unique_rules,
    'o-',
    color='#1F77B4',
    linewidth=2.2,
    markersize=7,
    markerfacecolor='white',
    markeredgewidth=2,
)
ax.fill_between(_sizes, _unique_rules, alpha=0.12, color='#1F77B4')

# Fit power law for annotation
_xs = np.log(np.array(_sizes, dtype=float))
_ys = np.log(np.array(_unique_rules, dtype=float))
with warnings.catch_warnings():
    # Few points on a log-log fit; the conditioning warning is expected here.
    warnings.simplefilter("ignore")
    _coef = np.polyfit(_xs, _ys, 1)
ax.text(
    0.05,
    0.92,
    f'Growth exponent $\\alpha \\approx {_coef[0]:.2f}$\n'
    f'($<$1 = sub-linear growth; not proof of saturation)',
    transform=ax.transAxes,
    fontsize=9,
    va='top',
    bbox=dict(boxstyle='round,pad=0.3', fc='white', ec='#ccc', alpha=0.9),
)

ax.set_xlabel('Dataset size (reactions)', fontsize=10)
ax.set_ylabel('Unique rule classes (exact isomorphism)', fontsize=10)
ax.set_title('Rule Library Size vs Dataset Size', fontsize=11, fontweight='bold')
ax.grid(alpha=0.3)
plt.tight_layout()
plt.show()
print(f'At full dataset ({_sizes[-1]} rxns): {_unique_rules[-1]} unique rules')

Applying the same reduction logic to isomorphic ITS graphs is feasible, though computationally more expensive than focusing solely on the reaction center. To optimize the computationally intensive ITS reduction, we can use Weisfeiler–Lehman Shervashidze et al., 2011 pre-filtering (see S06). Exact isomorphism checks then run only within buckets that share the same graph invariant.

Rule frequency and cluster size distribution

The WL hash partitions reactions into candidate buckets by reaction-center topology. The left panel shows the distribution of bucket sizes on a log scale—a long tail is typical (a few very common patterns, many rare ones). The right panel shows the 20 most frequent buckets. A singleton hash bucket contains one observed pattern, but only exact isomorphism establishes a definitive rule class.

The most frequent WL hash classes represent the most common reaction mechanisms in the dataset.
We visualize the reaction center ITS graph for the representative of each top-10 class.

Source
import matplotlib.pyplot as plt
from synedu.Utils.its_vis import visualize_its

# For each WL hash, pick the *most frequent* exact isomorphism class.

# 1. majority exact class per WL hash
_wl_majority_class = (
    df_aam.groupby("wl_hash")["class"]
    .agg(lambda s: s.value_counts().idxmax())
    .to_dict()
)

# 2. class-indexed lookups from rc_records
_class_to_rch = {}
_class_to_rc = {}
_class_to_its = {}
for i, rec in enumerate(rc_records):
    cls = rec.get("class")
    if cls not in _class_to_rch:
        _class_to_rch[cls] = rc_h[i]
        _class_to_rc[cls] = rc[i]
        if "ITS" in rec:
            _class_to_its[cls] = rec["ITS"]


def _has_dangling_h(g):
    """True if any H atom in g has no heavy-atom neighbour.

    This signals incomplete atom mapping: H was tracked but the heavy
    atom it bonds to was not mapped, so the H appears disconnected.
    Classes with dangling H are skipped in the gallery.
    """
    for n in g.nodes():
        if g.nodes[n].get("element") != "H":
            continue
        heavy_nbrs = [m for m in g.neighbors(n) if g.nodes[m].get("element") != "H"]
        if not heavy_nbrs:
            return True
    return False


def _has_hh_edge(g):
    return any(
        g.nodes[u].get("element") == "H" and g.nodes[v].get("element") == "H"
        for u, v in g.edges()
    )


def _augment_rc_with_closure(rc_graph, its_full):
    """Add preserved ITS bonds between RC nodes to close cycles."""
    if its_full is None:
        return rc_graph
    aug = rc_graph.copy()
    rc_nodes = set(rc_graph.nodes())
    for u, v, d in its_full.edges(data=True):
        if u not in rc_nodes or v not in rc_nodes or aug.has_edge(u, v):
            continue
        order = d.get("order", (0.0, 0.0))
        br, bp = order if isinstance(order, tuple) else (float(order), float(order))
        if abs(float(br) - float(bp)) < 1e-9 and float(br) > 0:
            aug.add_edge(u, v, **d)
    return aug


# Top-10 by WL hash frequency.
# Skip classes where H atoms are dangling (no heavy-atom neighbour):
# these have incomplete atom mapping and produce misleading disconnected H.
_freq = df_aam["wl_hash"].value_counts()
_freq_items = list(_freq.items())

fig, axes = plt.subplots(2, 5, figsize=(16, 7), facecolor="white")
axes = axes.flatten()

_shown, _rank = 0, 0
while _shown < 10 and _rank < len(_freq_items):
    wl_key, count = _freq_items[_rank]
    _rank += 1

    maj_cls = _wl_majority_class.get(wl_key)
    g_try = _class_to_rch.get(maj_cls)

    if g_try is None:
        row = df_aam[df_aam["wl_hash"] == wl_key].iloc[0]
        g_try = h_expand_its(row["RC"])

    # Skip classes with dangling H atoms (incomplete atom mapping)
    if _has_dangling_h(g_try):
        continue

    if _has_hh_edge(g_try):
        raw_rc = _class_to_rc.get(maj_cls)
        its_ref = _class_to_its.get(maj_cls)
        if raw_rc is not None and its_ref is not None:
            g_display = _augment_rc_with_closure(raw_rc, its_ref)
        else:
            g_display = g_try
    else:
        g_display = g_try

    ax = axes[_shown]
    try:
        visualize_its(
            g_display,
            ax=ax,
            title=f"#{_shown + 1}  n={count}",
            show_legend=False,
            show_edge_labels=True,
            node_size=420,
            font_size=7,
            layout="kamada_kawai",
        )
    except Exception as exc:
        ax.text(
            0.5,
            0.5,
            str(exc)[:40],
            ha="center",
            va="center",
            transform=ax.transAxes,
            fontsize=7,
        )
        ax.set_title(f"#{_shown + 1}  n={count}", fontsize=8, fontweight="bold")
        ax.axis("off")
    _shown += 1

from matplotlib.patches import Patch

_LEG = [
    Patch(color="#888888", label="preserved"),
    Patch(color="#D62728", label="broken"),
    Patch(color="#2CA02C", label="formed"),
    Patch(color="#FF7F0E", label="changed"),
]
fig.legend(
    handles=_LEG,
    loc="lower center",
    ncol=4,
    fontsize=9,
    title="Edge type",
    title_fontsize=9,
)
fig.suptitle(
    "Top-10 Reaction Center Classes  (by WL hash frequency) — H-expanded",
    fontsize=12,
    fontweight="bold",
)
plt.tight_layout(rect=[0, 0.07, 1, 1])
plt.show()
Source
import matplotlib.pyplot as plt
import numpy as np

_vc = df_aam['wl_hash'].value_counts()
_sizes = _vc.values

fig, axes = plt.subplots(1, 2, figsize=(13, 4))
fig.suptitle(
    "Reaction rule distribution — WL hash clusters", fontsize=11, fontweight="bold"
)

# Left: log-log histogram of cluster sizes
_max = max(_sizes)
_bins = np.logspace(0, np.log10(_max + 1), min(30, _max + 2))
axes[0].hist(_sizes, bins=_bins, color="#1F77B4", alpha=0.85, edgecolor="white")
axes[0].set_xscale("log")
if _sizes.max() > 5:
    axes[0].set_yscale("log")
axes[0].set_xlabel("Cluster size  (reactions per WL hash)")
axes[0].set_ylabel("Number of clusters")
axes[0].set_title("Cluster size distribution")
axes[0].spines[["top", "right"]].set_visible(False)

# Right: top-20 most frequent rules
_top = _vc.head(20)
axes[1].barh(
    range(len(_top)), _top.values[::-1], color="#D62728", alpha=0.85, edgecolor="white"
)
axes[1].set_yticks(range(len(_top)))
axes[1].set_yticklabels([f"rule {i+1}" for i in range(len(_top))][::-1], fontsize=8)
axes[1].set_xlabel("Frequency  (# reactions)")
axes[1].set_title("Top-20 most frequent rules")
axes[1].spines[["top", "right"]].set_visible(False)

plt.tight_layout()
plt.show()

print(f"Unique rules (WL hashes):  {len(_vc)}")
print(f"Most frequent rule:        {_sizes.max()} reactions")
print(
    f"Singleton rules:           {(_sizes == 1).sum()}  ({(_sizes==1).sum()/len(_vc)*100:.1f}%)"
)

For this dataset, wl_hash yields the same classes as exact graph isomorphism. WL hashing remains an approximation, so definitive equivalence still requires an exact isomorphism check.

Q3 — ITS uniqueness

Now integrate a function named cluster_with_wl_prefilter(df, graph_key, wl_key) to prefilter the bucket before


Solution:
import pandas as pd
from synedu.Utils import cluster_its_graphs

def cluster_with_wl_prefilter(
    df: pd.DataFrame,
    graph_key: str = "RC",
    wl_key: str = "wl_hash",
) -> pd.DataFrame:
    df2 = df.copy()
    if wl_key not in df2.columns:
        raise KeyError(f"{wl_key} not found in DataFrame")

    results = []
    next_class = 0
    for wl_val, idxs in df2.groupby(wl_key).groups.items():
        bucket = df2.loc[idxs]
        graphs = bucket[graph_key].tolist()
        labels = cluster_its_graphs(graphs)
        bucket = bucket.copy()
        bucket["class"] = [lbl + next_class for lbl in labels]
        next_class = int(bucket["class"].max()) + 1
        results.append(bucket)

    return pd.concat(results, ignore_index=True) if results else pd.DataFrame()

DPO rules

Each representative reaction center in rc_h (the H-expanded list built in §3.1) is now converted to a MØD GML rule — the formal DPO representation L←K→RL \leftarrow K \rightarrow R.

GML blockMeaningITS edges included
leftbonds broken in the reactionorder = (b_r, 0) — exist only in reactant
rightbonds formed in the reactionorder = (0, b_p) — exist only in product
contextbonds unchanged + all atomsorder = (b, b) with b > 0

Because we use rc_h, every transferred hydrogen is represented as an explicit H node with a broken bond to its donor (left) and a formed bond to its acceptor (right). This satisfies the MØD requirement that all atoms — including hydrogens — are tracked explicitly.

Building its_to_gml — step by step

The conversion has three sub-tasks:

Sub-taskInputOutput
① Classify each edgeorder = (b_r, b_p) tupleleft / right / context
② Label each nodeelement, formal_charge attrsatom label string (e.g. "N+")
③ Label each bondscalar bond order bGML bond string ("1", "2", "#", "3")

Consider the small RC below. Each edge carries an order = (b_r, b_p) tuple:

EdgeorderGML blockMeaning
C – O(1, 0)leftbond broken
C – N(0, 1)rightbond formed
C – C(1, 1)contextbond preserved
N – H(0, 1)rightH transferred (formed on acceptor)
O – H(1, 0)leftH transferred (broken on donor)
import networkx as nx
import matplotlib.pyplot as plt
from synedu.Utils.its_vis import visualize_its

# ── Minimal demo RC: C-O broken, C-N formed, C-C preserved, + H transfer ──
_demo = nx.Graph()
_demo.add_node(
    1, element="C", formal_charge=(0, 0), hcount=(0, 0), aromatic=(False, False)
)
_demo.add_node(
    2, element="O", formal_charge=(0, 0), hcount=(1, 0), aromatic=(False, False)
)
_demo.add_node(
    3, element="N", formal_charge=(0, 0), hcount=(0, 1), aromatic=(False, False)
)
_demo.add_node(
    4, element="C", formal_charge=(0, 0), hcount=(0, 0), aromatic=(False, False)
)
_demo.add_node(
    5, element="H", formal_charge=(0, 0), hcount=(0, 0), aromatic=(False, False)
)

_demo.add_edge(1, 2, order=(1.0, 0.0))  # C-O  broken    → left
_demo.add_edge(1, 3, order=(0.0, 1.0))  # C-N  formed    → right
_demo.add_edge(1, 4, order=(1.0, 1.0))  # C-C  preserved → context
_demo.add_edge(2, 5, order=(1.0, 0.0))  # O-H  broken    → left
_demo.add_edge(3, 5, order=(0.0, 1.0))  # N-H  formed    → right

# ── Edge classification ────────────────────────────────────────────────────
_BOND_LBL = {1.0: "1", 1.5: "#", 2.0: "2", 3.0: "3"}

print(f"{'Edge':>7}  {'order':>12}  GML block")
print("-" * 40)
for u, v, d in _demo.edges(data=True):
    br, bp = float(d["order"][0]), float(d["order"][1])
    eu, ev = _demo.nodes[u]["element"], _demo.nodes[v]["element"]
    if br == bp and br > 0:
        block = "context"
    elif br > 0 and bp == 0:
        block = "left"
    elif br == 0 and bp > 0:
        block = "right"
    else:
        block = "left + right"  # bond order changes (e.g. single→double)
    print(f"{eu:>2}-{ev:<2}  {str(d['order']):>14}  {block}")

# ── Visualise ─────────────────────────────────────────────────────────────
fig, ax = plt.subplots(figsize=(5, 4), facecolor="white")
visualize_its(
    _demo,
    ax=ax,
    title="Demo RC — edge classification",
    show_legend=True,
    show_edge_labels=True,
    layout="kamada_kawai",
)
plt.tight_layout()
plt.show()
Step ① — helper functions

Two small helpers underpin its_to_gml:

HelperInputOutputExample
_bond_label(b)scalar float bond orderGML label string2.0 → "2", 1.5 → "#"
_node_label(attrs)node attribute dictatom label string{"element":"N","formal_charge":1} → "N+"
def _bond_label(order: float) -> str:
    """Map a scalar bond order to its GML label string."""
    return {1.0: "1", 1.5: "#", 2.0: "2", 3.0: "3"}.get(
        round(float(order), 2), str(order)
    )


def _node_label(attrs: dict) -> str:
    """Return element symbol with optional charge suffix (e.g. N+, O-)."""
    elem = attrs.get("element", "*")
    chg_raw = attrs.get("formal_charge", 0)
    # formal_charge can be a scalar or an ITS (r, p) tuple — take reactant side
    chg = int((chg_raw[0] if isinstance(chg_raw, tuple) else chg_raw) or 0)
    if chg > 0:
        return f"{elem}+"
    if chg < 0:
        return f"{elem}-"
    return str(elem)


# ── Quick tests ──────────────────────────────────────────────────────────
print("_bond_label tests:")
for b, expected in [(1.0, "1"), (2.0, "2"), (1.5, "#"), (3.0, "3")]:
    out = _bond_label(b)
    print(f"  _bond_label({b}) = {out!r}  {'✓' if out == expected else '✗'}")

print("\n_node_label tests:")
cases = [
    ({"element": "C", "formal_charge": 0}, "C"),
    ({"element": "N", "formal_charge": 1}, "N+"),
    ({"element": "O", "formal_charge": -1}, "O-"),
    ({"element": "N", "formal_charge": (1, 1)}, "N+"),  # ITS tuple form
]
for attrs, expected in cases:
    out = _node_label(attrs)
    print(f"  _node_label({attrs}) = {out!r}  {'✓' if out == expected else '✗'}")
Step ② — assembling its_to_gml

With the helpers in place, the full function is a single pass over edges:

for each edge (u, v):
    br, bp = order
    if br > 0 and bp == 0  →  left  (broken)
    if br == 0 and bp > 0  →  right (formed)
    if br > 0 and bp > 0
        br ≠ bp             →  left + right (order changes)
        br == bp            →  context (preserved, optional)

all nodes always go into context
import networkx as nx
from typing import Union


def its_to_gml(
    its: nx.Graph,
    rule_id: Union[int, str] = 0,
    include_context_edges: bool = True,
) -> str:
    """Convert an ITS / H-expanded RC graph to a MØD GML rule string.

    :param its: ITS graph (edge order = (b_r, b_p) tuples).
    :param rule_id: Identifier written to the ``ruleID`` field.
    :param include_context_edges: If True, preserved bonds are emitted in
        the ``context`` block (required by MØD for bond-order context).
    :returns: GML rule string.
    """
    left_edges, right_edges, ctx_edges = [], [], []

    for u, v, d in its.edges(data=True):
        order = d.get("order", (0.0, 0.0))
        br, bp = (
            (float(order[0]), float(order[1]))
            if isinstance(order, (tuple, list))
            else (float(order), float(order))
        )

        if abs(br - bp) < 1e-9:  # preserved bond
            if br > 0 and include_context_edges:
                ctx_edges.append((u, v, _bond_label(br)))
        else:
            if br > 0:
                left_edges.append((u, v, _bond_label(br)))  # broken
            if bp > 0:
                right_edges.append((u, v, _bond_label(bp)))  # formed

    lines = [f'rule [', f'  ruleID "{rule_id}"', '  left [']
    for u, v, lbl in left_edges:
        lines.append(f'    edge [ source {u} target {v} label "{lbl}" ]')
    lines.append('  ]')

    lines.append('  context [')
    for n in sorted(its.nodes()):
        lines.append(f'    node [ id {n} label "{_node_label(its.nodes[n])}" ]')
    for u, v, lbl in ctx_edges:
        lines.append(f'    edge [ source {u} target {v} label "{lbl}" ]')
    lines.append('  ]')

    lines.append('  right [')
    for u, v, lbl in right_edges:
        lines.append(f'    edge [ source {u} target {v} label "{lbl}" ]')
    lines.append('  ]')
    lines.append(']')
    return '\n'.join(lines)


# ── Apply to the demo RC and inspect the output ──────────────────────────
gml_demo = its_to_gml(_demo, rule_id="demo", include_context_edges=True)
print(gml_demo)
# Generate one GML rule string per unique H-expanded reaction center
rules = [
    its_to_gml(g, rule_id=i, include_context_edges=True) for i, g in enumerate(rc_h)
]
print(f"Generated {len(rules)} GML rules")
print("\nFirst rule:")
print(rules[0] if rules else "(empty)")

Q5 — Implement its_to_gml

Using the edge classification logic above, implement its_to_gml(its, rule_id, include_context_edges) that converts an ITS/RC graph to a MØD GML string.

Requirements:


Solution:
import networkx as nx
from typing import Union


def _bond_label(order: float) -> str:
    o = round(float(order), 2)
    return {1.0: "1", 1.5: "#", 2.0: "2", 3.0: "3"}.get(o, str(o))


def _node_label(attrs: dict) -> str:
    elem = attrs.get("element", "*")
    chg_raw = attrs.get("formal_charge", 0)
    chg = int((chg_raw[0] if isinstance(chg_raw, tuple) else chg_raw) or 0)
    if chg > 0: return f"{elem}+"
    if chg < 0: return f"{elem}-"
    return str(elem)


def its_to_gml(
    its: nx.Graph,
    rule_id: Union[int, str] = 0,
    include_context_edges: bool = True,
) -> str:
    left_edges, right_edges, ctx_edges = [], [], []
    for u, v, d in its.edges(data=True):
        br, bp = d.get("order", (0.0, 0.0))
        br, bp = float(br), float(bp)
        if br != bp:
            if br > 0:
                left_edges.append((u, v, _bond_label(br)))
            if bp > 0:
                right_edges.append((u, v, _bond_label(bp)))
        elif include_context_edges and br > 0:
            ctx_edges.append((u, v, _bond_label(br)))

    lines = [f'rule [', f'  ruleID "{rule_id}"', '  left [']
    for u, v, lbl in left_edges:
        lines.append(f'    edge [ source {u} target {v} label "{lbl}" ]')
    lines.append('  ]')

    lines.append('  context [')
    for n in sorted(its.nodes()):
        lines.append(f'    node [ id {n} label "{_node_label(its.nodes[n])}" ]')
    for u, v, lbl in ctx_edges:
        lines.append(f'    edge [ source {u} target {v} label "{lbl}" ]')
    lines.append('  ]')

    lines.append('  right [')
    for u, v, lbl in right_edges:
        lines.append(f'    edge [ source {u} target {v} label "{lbl}" ]')
    lines.append('  ]')
    lines.append(']')
    return '\n'.join(lines)

Test on the demo RC:

print(its_to_gml(_demo, rule_id="demo", include_context_edges=True))

Example GML output (one rule, L/K/R encoded as sub-blocks):

rule [
  ruleID "0"
  left [
    edge [ source 5 target 19 label "1" ]
  ]
  context [
    node [ id 5 label "N" ]
    node [ id 19 label "H" ]
    node [ id 23 label "N" ]
  ]
  right [
    edge [ source 23 target 19 label "1" ]
  ]
]

Interpretation

BlockDPO componentMeaning
context (K)preserved subgraphin this atom-conserving export, atoms present on both sides; all exported nodes appear here
left (L∖K)deleted bondsbonds present only in the reactant — broken during the reaction
right (R∖K)created bondsbonds present only in the product — formed during the reaction

In the example above, the H node (id 19) bridges from N (atom 5) in the reactant to N (atom 23) in the product — a direct N→N proton transfer encoded as an explicit H node with one broken and one formed bond.

Because rc_h contains H-expanded reaction centers, every represented hydrogen transfer already appears as an explicit node. The resulting rules use the rule-GML structure consumed by MØD Andersen et al., 2016; applications with additional labels or constraints may require corresponding label configuration.

# Print a GML rule with H transfer (if one exists), else print the first rule
_ex = next((r for r in rules if "H" in r), rules[0] if rules else "(no rules)")
print(_ex)

Q4 — Data pipeline

Implement a single high-level function that ingests a pandas DataFrame containing multiple atom-mapping candidates per reaction and produces a deduplicated library of reaction centers / DPO rules (optionally exported to GML), plus the rows that remain ambiguous.


Solution:
from typing import List
from synedu.Utils import rsmi_to_its, cluster_its_graphs, stratified_sample, wl_hash

def rule_lib_pipeline(
        data: pd.DataFrame,
        map_cols: List[str],
        export_gml: bool = False):

    # 1. get high-confidence aam
    data, _ = ensemble_maps(data, map_cols)

    # 2. convert to RC
    tqdm.pandas(desc="ITS construction")
    data["ITS"] = data["aam"].progress_apply(lambda s: rsmi_to_its(s, core=False))

    tqdm.pandas(desc="Reaction center construction")
    data["RC"] = data["aam"].progress_apply(lambda s: rsmi_to_its(s, core=True))

    # 3. compute wl_hash for prefilter
    tqdm.pandas(desc="WL hash")
    _wl = lambda g: wl_hash(g, node_attrs=["element", "formal_charge"], edge_attrs=["order"])
    data["wl_hash"] = data["RC"].progress_apply(_wl)

    # 4. clustering
    rc_graphs = data["RC"].tolist()
    data["class"] = cluster_its_graphs(rc_graphs,
                                       node_attrs=["element", "formal_charge"],
                                       edge_attrs=["order"])
    result = data.to_dict("records")

    # 5. get unique reaction centers and expand H
    rc_records = stratified_sample(result, key="class")
    rc = [r["RC"] for r in rc_records]
    rc_h = [h_expand_its(g) for g in rc]

    # 6. convert to GML
    if export_gml:
        return [its_to_gml(g, rule_id=i, include_context_edges=False)
                for i, g in enumerate(rc_h)]
    return rc_h

4. Discussion

5. Quiz

  1. Why is reaction standardization needed before comparing atom-mapped reactions from different mappers?

  2. How does comparing several mapper outputs help identify high-confidence atom mappings?

  3. Why is WL hashing useful for clustering reaction centers, and why is exact isomorphism still needed?

  4. What information must a DPO rule preserve so it can be exported and reused as a reaction-rule library entry?

6. References

References
  1. Phan, T.-L., González Laffitte, M. E., Weinbauer, K., Merkle, D., Andersen, J. L., Fagerberg, R., Gatter, T., & Stadler, P. F. (2025). SynKit: A Graph-Based Python Framework for Rule-Based Reaction Modeling and Analysis. Journal of Chemical Information and Modeling, 65(24), 13012–13019. 10.1021/acs.jcim.5c02123
  2. Shervashidze, N., Schweitzer, P., van Leeuwen, E. J., Mehlhorn, K., & Borgwardt, K. M. (2011). Weisfeiler-Lehman Graph Kernels. Journal of Machine Learning Research, 12, 2539–2561. https://www.jmlr.org/papers/v12/shervashidze11a.html
  3. Andersen, J. L., Flamm, C., Merkle, D., & Stadler, P. F. (2016). A software package for chemically inspired graph transformation. International Conference on Graph Transformation, 73–88. 10.1007/978-3-319-40530-8_5
  4. Schwaller, P., Hoover, B., Reymond, J.-L., Strobelt, H., & Laino, T. (2021). Extraction of organic chemistry grammar from unsupervised learning of chemical reactions. Science Advances, 7(15), eabe4166. 10.1126/sciadv.abe4166
  5. Nugmanov, R., Dyubankova, N., Gedich, A., & Wegner, J. K. (2022). Bidirectional graphormer for reactivity understanding: neural network trained to reaction atom-to-atom mapping task. Journal of Chemical Information and Modeling, 62(14), 3307–3315. 10.1021/acs.jcim.2c00344
  6. Chen, S., An, S., Babazade, R., & Jung, Y. (2024). Precise atom-to-atom mapping for organic reactions via human-in-the-loop machine learning. Nature Communications, 15(1), 2250. 10.1038/s41467-024-46364-y
  7. Schneider, N., Stiefl, N., & Landrum, G. A. (2016). What’s What: The (Nearly) Definitive Guide to Reaction Role Assignment. Journal of Chemical Information and Modeling, 56(12), 2336–2346. 10.1021/acs.jcim.6b00564
  8. Phan, T.-L., Weinbauer, K., González Laffitte, M. E., Pan, Y., Merkle, D., Andersen, J. L., Fagerberg, R., Flamm, C., & Stadler, P. F. (2025). SynTemp: Efficient Extraction of Graph-Based Reaction Rules from Large-Scale Reaction Databases. Journal of Chemical Information and Modeling, 65(6), 2882–2896. 10.1021/acs.jcim.4c01795
  9. Cordella, L. P., Foggia, P., Sansone, C., & Vento, M. (2004). A (Sub)Graph Isomorphism Algorithm for Matching Large Graphs. IEEE Transactions on Pattern Analysis and Machine Intelligence, 26(10), 1367–1372. 10.1109/TPAMI.2004.75