Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/test-and-lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ name: Test & Lint

on:
push:
branches: [ "main", "dev", "staging", "refractor" ]
branches: [ "main", "partialits", "staging", "refractor" ]
pull_request:
branches: [ "main" ]

Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,5 @@ run.sh
docs/*
run_rdcanon.py
Data/Fragment/*
test_partial.py
Data/Benchmark/synthesis/*
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,15 @@
# SynKit
[![PyPI version](https://img.shields.io/pypi/v/synkit.svg)](https://pypi.org/project/synkit/)
[![Conda version](https://img.shields.io/conda/vn/tieulongphan/synkit.svg)](https://anaconda.org/tieulongphan/synkit)
[![Docker Pulls](https://img.shields.io/docker/pulls/tieulongphan/synkit.svg)](https://hub.docker.com/r/tieulongphan/synkit)
[![Docker Image Version](https://img.shields.io/docker/v/tieulongphan/synkit/latest?label=container)](https://hub.docker.com/r/tieulongphan/synkit)
[![License](https://img.shields.io/github/license/tieulongphan/synkit.svg)](https://github.com/tieulongphan/synkit/blob/main/LICENSE)
[![Release](https://img.shields.io/github/v/release/tieulongphan/synkit.svg)](https://github.com/tieulongphan/synkit/releases)
[![Last Commit](https://img.shields.io/github/last-commit/tieulongphan/synkit.svg)](https://github.com/tieulongphan/synkit/commits)
[![Zenodo](https://zenodo.org/badge/DOI/10.5281/zenodo.15269901.svg)](https://doi.org/10.5281/zenodo.15269901)
[![CI](https://github.com/tieulongphan/synkit/actions/workflows/test-and-lint.yml/badge.svg?branch=main)](https://github.com/tieulongphan/synkit/actions/workflows/test-and-lint.yml)
[![Dependency PRs](https://img.shields.io/github/issues-pr-raw/tieulongphan/synkit?label=dependency%20PRs)](https://github.com/tieulongphan/synkit/pulls?q=is%3Apr+label%3Adependencies)
[![Stars](https://img.shields.io/github/stars/tieulongphan/synkit.svg?style=social&label=Star)](https://github.com/tieulongphan/synkit/stargazers)

**Toolkit for Synthesis Planning**

Expand Down
16 changes: 7 additions & 9 deletions Test/Graph/MTG/test_mtg.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,19 +20,17 @@ def setUp(self) -> None:
self.test_graph_2 = [get_rc(rsmi_to_its(var)) for var in test_2]

def test_MTG_1(self):
grp = GroupComp(self.test_graph_1[0], self.test_graph_1[1])
candidates = grp.get_mapping()
print(candidates)
mtg = MTG(self.test_graph_1[0], self.test_graph_1[1], candidates[0])
self.assertEqual(len(mtg.get_nodes()), 6)
self.assertEqual(len(mtg.get_edges()), 7)
mtg = MTG(self.test_graph_1[0:2], mcs_mol=True)
self.assertEqual(mtg._graph.number_of_nodes(), 6)
self.assertEqual(mtg._graph.number_of_edges(), 7)

def test_MTG_2(self):
grp = GroupComp(self.test_graph_2[0], self.test_graph_2[1])
candidates = grp.get_mapping()
mtg = MTG(self.test_graph_2[0], self.test_graph_2[1], candidates[0])
self.assertEqual(len(mtg.get_nodes()), 5)
self.assertEqual(len(mtg.get_edges()), 4)
# print(candidates)
mtg = MTG(self.test_graph_2[0:], candidates)
self.assertEqual(mtg._graph.number_of_nodes(), 5)
self.assertEqual(mtg._graph.number_of_edges(), 4)


if __name__ == "__main__":
Expand Down
Empty file added Test/Graph/Wildcard/__init__.py
Empty file.
55 changes: 55 additions & 0 deletions Test/Graph/Wildcard/test_radwc.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import unittest
from synkit.Graph.Wildcard.radwc import RadWC


class TestRadWC(unittest.TestCase):
def test_no_product_radicals(self):
"""If product has no radicals, output should be unchanged."""
rxn = "[CH3:1][OH:2]>>[CH3:1][OH:2]"
self.assertEqual(RadWC.transform(rxn), rxn)

def test_single_radical_in_product(self):
"""A single radical in product gets a wildcard."""
rxn = "[CH3:1][OH:2]>>[CH2:1].[OH:2]"
out = RadWC.transform(rxn)
# Check [*:3] is attached to [CH2:1]
self.assertIn("[CH2:1]([*:3])", out) # Atom-maps: 1,2 exist, so 3 is next

def test_multiple_radicals_in_product(self):
"""Multiple radicals in product get multiple wildcards."""
rxn = "[CH3:1][OH:2]>>[CH2:1].[O:2]"
out = RadWC.transform(rxn)
# [CH2:1] has *:3 and *:4, [O:2] has *:5
self.assertIn("[CH2:1]([*:3])", out)
self.assertIn("[O:2]([*:5])", out)

def test_radical_and_nonradical_mixture(self):
"""Mixed radical/non-radical product fragments, only radicals get wildcard."""
rxn = "[CH3:1][OH:2]>>[CH2:1].[OH:2]"
out = RadWC.transform(rxn)
# [CH2:1] gets *:3, [OH:2] is unchanged
self.assertIn("[CH2:1]([*:3])", out)
self.assertIn("[OH:2]", out)

def test_user_start_map(self):
"""User-supplied map index is used for wildcards."""
rxn = "[CH3:7][OH:8]>>[CH2:7].[OH:8]"
out = RadWC.transform(rxn, start_map=50)
self.assertIn("[CH2:7]([*:50])", out)

def test_empty_reaction(self):
"""Empty input should raise ValueError."""
with self.assertRaises(ValueError):
RadWC.transform("")

def test_three_component(self):
"""Agent block is preserved."""
rxn = "[CH3:1][OH:2]>[Na+]>[CH2:1].[OH:2]"
out = RadWC.transform(rxn)
self.assertTrue(out.startswith("[CH3:1][OH:2]>[Na+]>"))
self.assertIn("[CH2:1]([*:3])", out)
self.assertIn("[OH:2]", out)


if __name__ == "__main__":
unittest.main()
81 changes: 81 additions & 0 deletions Test/Graph/Wildcard/test_wildcard.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import unittest
from synkit.IO import rsmi_to_graph
from synkit.Graph.Wildcard.wildcard import WildCard


class TestWildCard(unittest.TestCase):
def setUp(self):
# The main, complex test case with atom mapping
self.rsmi_main = (
"[cH:1]1[cH:14][c:10]2[c:23]([cH:11][n:25]1)[cH:17][cH:12][cH:4][c:31]2[NH2:28]."
"[cH:2]1[c:20]([C:22]([OH:7])=[O:21])[s:18][c:24]([S:6][c:29]2[c:15]([Cl:26])[cH:8]"
"[n:19][cH:9][c:16]2[Cl:27])[c:30]1[N+:5]([O-:3])=[O:13]>>"
"[cH:1]1[cH:14][c:10]2[c:23]([cH:11][n:25]1)[cH:17][cH:12][cH:4][c:31]2[NH:28]"
"[C:22]([c:20]1[cH:2][c:30]([N+:5]([O-:3])=[O:13])[c:24]([S:6][c:29]2[c:15]([Cl:26])"
"[cH:8][n:19][cH:9][c:16]2[Cl:27])[s:18]1)=[O:21]"
)
# No atoms lost: R == P, should not add wildcards
self.rsmi_no_loss = "CCO>>CCO"
# All atoms lost: RSMI that loses everything (nonsense, but good test)
self.rsmi_all_lost = "CCO>>"
# Empty
self.rsmi_empty = ""
# Wildcard already present
self.rsmi_existing_wildcard = "[CH3:1][CH2:2][OH:3]>>[CH2:1][CH2:2].[*:4][OH:3]"
# No atom map (should raise error)
self.rsmi_no_atom_map = "C(C)Cl>>CC"

def test_main_case_wildcard_added(self):
"""Complex case: output product contains wildcard and roundtrip is valid."""
out_rsmi = WildCard.rsmi_with_wildcards(self.rsmi_main)
_, product = out_rsmi.split(">>")
self.assertIsInstance(out_rsmi, str)
self.assertIn(
"*", product, "Wildcard '*' should be present in the product side."
)
# Roundtrip: should parse back without error
r, p = rsmi_to_graph(out_rsmi)
self.assertTrue(r.number_of_nodes() > 0)
self.assertTrue(p.number_of_nodes() > 0)

def test_no_atoms_lost(self):
"""No atoms lost: should raise ValueError if input is not atom-mapped."""
with self.assertRaises(ValueError):
WildCard.rsmi_with_wildcards(self.rsmi_no_loss)

def test_all_atoms_lost(self):
"""All atoms lost: should raise ValueError if input is not atom-mapped."""
with self.assertRaises(ValueError):
WildCard.rsmi_with_wildcards(self.rsmi_all_lost)

def test_empty_input(self):
"""Empty input: should raise ValueError."""
with self.assertRaises(ValueError):
WildCard.rsmi_with_wildcards(self.rsmi_empty)

def test_wildcard_not_duplicated(self):
"""Existing wildcards: should not create duplicate wildcards for same lost bond."""
out_rsmi = WildCard.rsmi_with_wildcards(self.rsmi_existing_wildcard)
_, product = out_rsmi.split(">>")
# At least one '*' in the product SMILES string
self.assertIn("*", product)

def test_no_false_positive_wildcards(self):
"""Wildcards are only added if there are truly lost subgraphs; non-atom-mapped input raises."""
rsmi = "C>>C"
with self.assertRaises(ValueError):
WildCard.rsmi_with_wildcards(rsmi)

def test_output_is_str_and_split(self):
"""Should raise ValueError if input is not atom-mapped."""
with self.assertRaises(ValueError):
WildCard.rsmi_with_wildcards(self.rsmi_no_loss)

def test_missing_atom_map_raises(self):
"""Should raise ValueError if atom_map attributes are missing."""
with self.assertRaises(ValueError):
WildCard.rsmi_with_wildcards(self.rsmi_no_atom_map)


if __name__ == "__main__":
unittest.main()
Empty file.
38 changes: 38 additions & 0 deletions Test/IO/combinatorial/test_smarts_expander.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import unittest
from synkit.IO.combinatorial.smarts_expander import SMARTSExpander


class TestSMARTSExpander(unittest.TestCase):

def test_no_placeholders(self):
s = "CCO"
self.assertEqual(list(SMARTSExpander.expand_iter(s)), ["CCO"])
self.assertEqual(SMARTSExpander.expand(s), ["CCO"])

def test_simple_expansion(self):
s = "[C,N:1][O,P:2]"
result = SMARTSExpander.expand(s)
self.assertEqual(
set(result), {"[C:1][O:2]", "[C:1][P:2]", "[N:1][O:2]", "[N:1][P:2]"}
)

def test_disjoint_constraint(self):
s = "[C,N:1][O:1]"
with self.assertRaises(ValueError):
list(SMARTSExpander.expand_iter(s))

def test_realistic_reaction(self):
rxn = (
"[H+:6].[C:7](-[O:8](-[H:12]))(-[C,N,O,P,S:9])(-[C,N,O,P,S:10])(-[H:11])."
"[C:2](-[S:4](-[C,N,O,P,S:5]))(-[C,N,O,P,S:1])(=[O:3])>>"
"[S:4](-[H:6])(-[C,N,O,P,S:5]).[H+:12]."
"[C:7](-[O:8](-[C:2](-[C,N,O,P,S:1])(=[O:3])))(-[C,N,O,P,S:9])(-[C,N,O,P,S:10])(-[H:11])"
)
ex_list = list(SMARTSExpander.expand_iter(rxn))
self.assertEqual(len(ex_list), 625)
# Optional: Just check format or count, not endswith
self.assertTrue(ex_list[0].startswith("[H+:6]"))


if __name__ == "__main__":
unittest.main()
62 changes: 62 additions & 0 deletions Test/IO/combinatorial/test_smarts_generalizer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import unittest
from rdkit import Chem
from synkit.IO.combinatorial.smarts_generalizer import SMARTSGeneralizer


class TestSMARTSGeneralizer(unittest.TestCase):

def setUp(self):
self.gen = SMARTSGeneralizer(sanity_check=True)

def test_basic_generalization(self):
inputs = [
"[C:1]-[N:2]>>[N:1]-[C:2]",
"[N:1]-[N:2]>>[N:1]-[N:2]",
"[O:1]-[N:2]>>[N:1]-[N:2]",
]
output = self.gen.generalize(inputs)
# Instead of strict string match, check correct mapped elements
self.assertIn("[C,N,O:1]", output)
self.assertIn("[N:2]", output)
self.assertIn(">>", output)

def test_single_smarts(self):
inputs = ["[C:1]-[N:2]>>[N:1]-[C:2]"]
output = self.gen.generalize(inputs)
# Should match input exactly
self.assertEqual(output, "[C:1]-[N:2]>>[N:1]-[C:2]")

def test_different_topology_raises(self):
inputs = ["[C:1]-[N:2]>>[N:1]-[C:2]", "[N:1]-[N:2]-[C:3]>>[N:1]-[N:2]-[C:3]"]
with self.assertRaises(ValueError):
self.gen.generalize(inputs)

def test_empty_input_raises(self):
with self.assertRaises(ValueError):
self.gen.generalize([])

def test_molecule_smarts(self):
gen = SMARTSGeneralizer(sanity_check=True)
inputs = ["[C:1]-[N:2]", "[N:1]-[N:2]", "[O:1]-[N:2]"]
out = gen.generalize(inputs)
self.assertEqual(out, "[C,N,O:1]-[N:2]")

mol = Chem.MolFromSmarts(out)
self.assertIsNotNone(mol)

def test_invalid_sanity_check(self):
gen = SMARTSGeneralizer(sanity_check=True)
# Using an obviously broken SMARTS (bad bracket placement)
_ = [
"[C:1]-[N:2]>>[N:1]-[X:2]"
] # 'X' is a valid SMARTS wildcard! Use a real error
with self.assertRaises(ValueError):
gen.generalize(["[C:1][C:2]>>[N:1][C:2]["]) # broken SMARTS

def test_repr(self):
gen = SMARTSGeneralizer()
self.assertIn("sanity_check", repr(gen))


if __name__ == "__main__":
unittest.main()
85 changes: 85 additions & 0 deletions Test/IO/combinatorial/test_smarts_to_graph.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import unittest
import networkx as nx

from synkit.IO.combinatorial.smarts_to_graph import SMARTSToGraph


class TestSMARTSToGraph(unittest.TestCase):

def setUp(self):
self.stg = SMARTSToGraph()

def test_smarts_to_graph_simple(self):
g = self.stg.smarts_to_graph("[C:1]-[O:2]")
self.assertIsInstance(g, nx.Graph)
self.assertEqual(set(g.nodes), {1, 2})
self.assertEqual(g.nodes[1]["element"], "C")
self.assertEqual(g.nodes[2]["element"], "O")
self.assertIsNone(g.nodes[1]["constraint"])

def test_smarts_to_graph_constraint(self):
g = self.stg.smarts_to_graph("[C,N,O:1]-[N:2]")
# Node 1 should be placeholder
self.assertEqual(g.nodes[1]["element"], "*")
self.assertIsInstance(g.nodes[1]["constraint"], list)
self.assertIn("C", g.nodes[1]["constraint"])
self.assertEqual(g.nodes[2]["element"], "N")
self.assertIsNone(g.nodes[2]["constraint"])

def test_smarts_to_graph_hcount(self):
g = self.stg.smarts_to_graph("[CH3:1]-[O:2]")
# For SMARTS as written, RDKit returns 0 hydrogens for both
self.assertEqual(g.nodes[1]["hcount"], 0)
self.assertEqual(g.nodes[2]["hcount"], 0)

def test_invalid_smarts(self):
with self.assertRaises(ValueError):
self.stg.smarts_to_graph("[C:1]-[N")

def test_missing_atom_map(self):
with self.assertRaises(ValueError):
self.stg.smarts_to_graph("[C]-[O:2]")

def test_rxn_smarts_to_graphs(self):
rxn = (
"[H+:6].[C:7](-[O:8](-[H:12]))(-[C,N,O,P,S:9])(-[C,N,O,P,S:10])(-[H:11])."
"[C:2](-[S:4](-[C,N,O,P,S:5]))(-[C,N,O,P,S:1])(=[O:3])>>"
"[S:4](-[H:6])(-[C,N,O,P,S:5]).[H+:12]."
"[C:7](-[O:8](-[C:2](-[C,N,O,P,S:1])(=[O:3])))(-[C,N,O,P,S:9])(-[C,N,O,P,S:10])(-[H:11])"
)
g_react, _ = self.stg.rxn_smarts_to_graphs(rxn)

# These are the atom_map indices that should have constraint (from SMARTS [C,N,O,P,S:idx])
expected_constraint_nodes = {1, 5, 9, 10}
for idx in expected_constraint_nodes:
self.assertIn(idx, g_react.nodes)
self.assertIsNotNone(
g_react.nodes[idx]["constraint"],
f"Node {idx} should have a constraint list but does not",
)
self.assertEqual(
set(g_react.nodes[idx]["constraint"]),
{"C", "N", "O", "P", "S"},
f"Node {idx} has incorrect constraint list",
)
# All other nodes should NOT have a constraint
for idx in set(g_react.nodes) - expected_constraint_nodes:
self.assertIsNone(
g_react.nodes[idx]["constraint"],
f"Node {idx} should NOT have a constraint list",
)

def test_rxn_separator(self):
with self.assertRaises(ValueError):
self.stg.rxn_smarts_to_graphs("[C:1]-[O:2]") # no '>>'

def test_repr_and_describe(self):
r = repr(self.stg)
self.assertIn("placeholders", r)
desc = self.stg.describe()
self.assertIn("smarts_to_graph", desc)
self.assertIn("rxn_smarts_to_graphs", desc)


if __name__ == "__main__":
unittest.main()
Loading
Loading