synkit 1.6.5__py3-none-win_amd64.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (636) hide show
  1. synkit/CRN/Benchmark/__init__.py +53 -0
  2. synkit/CRN/Benchmark/crosschecks.py +13 -0
  3. synkit/CRN/Benchmark/kegg.py +12 -0
  4. synkit/CRN/Benchmark/networks.py +13 -0
  5. synkit/CRN/Benchmark/scaling.py +13 -0
  6. synkit/CRN/Benchmark/validate.py +13 -0
  7. synkit/CRN/Construct/__init__.py +16 -0
  8. synkit/CRN/Construct/abstract.py +668 -0
  9. synkit/CRN/Construct/arity.py +36 -0
  10. synkit/CRN/Construct/builder.py +619 -0
  11. synkit/CRN/Construct/derivation.py +63 -0
  12. synkit/CRN/Construct/flattener.py +100 -0
  13. synkit/CRN/Construct/keys.py +14 -0
  14. synkit/CRN/Construct/mixtures.py +117 -0
  15. synkit/CRN/Construct/rdkit_worker.py +69 -0
  16. synkit/CRN/Construct/smiles.py +99 -0
  17. synkit/CRN/Construct/state.py +31 -0
  18. synkit/CRN/Construct/strategy.py +75 -0
  19. synkit/CRN/Construct/worker.py +42 -0
  20. synkit/CRN/IO/__init__.py +36 -0
  21. synkit/CRN/IO/sbml.py +925 -0
  22. synkit/CRN/Pathway/__init__.py +72 -0
  23. synkit/CRN/Pathway/_adapter.py +333 -0
  24. synkit/CRN/Pathway/pathfinder.py +500 -0
  25. synkit/CRN/Pathway/reachability.py +796 -0
  26. synkit/CRN/Pathway/realizability.py +1322 -0
  27. synkit/CRN/Petrinet/__init__.py +93 -0
  28. synkit/CRN/Petrinet/analyzer.py +486 -0
  29. synkit/CRN/Petrinet/minimal_semiflows.py +436 -0
  30. synkit/CRN/Petrinet/net.py +1041 -0
  31. synkit/CRN/Petrinet/persistence.py +97 -0
  32. synkit/CRN/Petrinet/semiflows.py +483 -0
  33. synkit/CRN/Petrinet/siphon_search.py +402 -0
  34. synkit/CRN/Petrinet/structure.py +286 -0
  35. synkit/CRN/Props/__init__.py +130 -0
  36. synkit/CRN/Props/_rank.py +29 -0
  37. synkit/CRN/Props/deficiency.py +953 -0
  38. synkit/CRN/Props/dynamics.py +832 -0
  39. synkit/CRN/Props/helper.py +151 -0
  40. synkit/CRN/Props/stoich.py +751 -0
  41. synkit/CRN/Props/thermo.py +584 -0
  42. synkit/CRN/Query/__init__.py +66 -0
  43. synkit/CRN/Query/cache.py +41 -0
  44. synkit/CRN/Query/kegg_api.py +99 -0
  45. synkit/CRN/Query/kegg_extract.py +714 -0
  46. synkit/CRN/Query/kegg_impute.py +707 -0
  47. synkit/CRN/Query/kegg_parse.py +480 -0
  48. synkit/CRN/Query/to_syncrn.py +290 -0
  49. synkit/CRN/README.md +234 -0
  50. synkit/CRN/Structure/__init__.py +12 -0
  51. synkit/CRN/Structure/_graph_io.py +713 -0
  52. synkit/CRN/Structure/_matrices.py +88 -0
  53. synkit/CRN/Structure/_parse.py +544 -0
  54. synkit/CRN/Structure/reaction.py +237 -0
  55. synkit/CRN/Structure/rule.py +55 -0
  56. synkit/CRN/Structure/species.py +46 -0
  57. synkit/CRN/Structure/syncrn.py +929 -0
  58. synkit/CRN/Symmetry/__init__.py +20 -0
  59. synkit/CRN/Symmetry/_common.py +1059 -0
  60. synkit/CRN/Symmetry/_ir.py +940 -0
  61. synkit/CRN/Symmetry/automorphism.py +472 -0
  62. synkit/CRN/Symmetry/canon.py +441 -0
  63. synkit/CRN/Symmetry/isomorphism.py +412 -0
  64. synkit/CRN/Symmetry/symmetry.py +61 -0
  65. synkit/CRN/Symmetry/wl_canon.py +967 -0
  66. synkit/CRN/Visualize/__init__.py +11 -0
  67. synkit/CRN/Visualize/labels.py +113 -0
  68. synkit/CRN/Visualize/layout.py +708 -0
  69. synkit/CRN/Visualize/palette.py +292 -0
  70. synkit/CRN/Visualize/validation.py +122 -0
  71. synkit/CRN/Visualize/vis.py +1152 -0
  72. synkit/CRN/__init__.py +233 -0
  73. synkit/CRN/_build.py +50 -0
  74. synkit/CRN/_native_build.py +175 -0
  75. synkit/CRN/analysis.py +629 -0
  76. synkit/CRN/backend.py +74 -0
  77. synkit/CRN/cpp/CMakeLists.txt +58 -0
  78. synkit/CRN/cpp/algebra.cpp +46 -0
  79. synkit/CRN/cpp/closure.cpp +29 -0
  80. synkit/CRN/cpp/exact.h +55 -0
  81. synkit/CRN/cpp/pathway.cpp +46 -0
  82. synkit/CRN/cpp/semiflows.cpp +209 -0
  83. synkit/CRN/cpp/structural.cpp +240 -0
  84. synkit/CRN/cpp/symmetry.cpp +45 -0
  85. synkit/CRN/cpp/write_manifest.cmake +28 -0
  86. synkit/CRN/kinds.py +137 -0
  87. synkit/CRN/lib/synkit_crn.dll +0 -0
  88. synkit/CRN/lib/synkit_crn.json +33 -0
  89. synkit/CRN/native.py +388 -0
  90. synkit/CRN/native_analysis.py +459 -0
  91. synkit/Chem/Cluster/__init__.py +0 -0
  92. synkit/Chem/Cluster/butina.py +140 -0
  93. synkit/Chem/Fingerprint/__init__.py +0 -0
  94. synkit/Chem/Fingerprint/fp_calculator.py +155 -0
  95. synkit/Chem/Fingerprint/smiles_featurizer.py +258 -0
  96. synkit/Chem/Fingerprint/transformation_fp.py +139 -0
  97. synkit/Chem/Mapper/README.rst +43 -0
  98. synkit/Chem/Mapper/__init__.py +255 -0
  99. synkit/Chem/Mapper/_compat.py +130 -0
  100. synkit/Chem/Mapper/adapters/__init__.py +1 -0
  101. synkit/Chem/Mapper/adapters/prediction.py +126 -0
  102. synkit/Chem/Mapper/adapters/template.py +412 -0
  103. synkit/Chem/Mapper/chem/__init__.py +52 -0
  104. synkit/Chem/Mapper/chem/aam.py +673 -0
  105. synkit/Chem/Mapper/chem/blind.py +160 -0
  106. synkit/Chem/Mapper/chem/its.py +309 -0
  107. synkit/Chem/Mapper/chem/smiles.py +564 -0
  108. synkit/Chem/Mapper/graph/__init__.py +66 -0
  109. synkit/Chem/Mapper/graph/automorphism.py +395 -0
  110. synkit/Chem/Mapper/graph/blockcut.py +98 -0
  111. synkit/Chem/Mapper/graph/labeled_graph.py +104 -0
  112. synkit/Chem/Mapper/graph/refine.py +176 -0
  113. synkit/Chem/Mapper/graph/synkit_adapter.py +110 -0
  114. synkit/Chem/Mapper/io/__init__.py +13 -0
  115. synkit/Chem/Mapper/io/idxmapstr.py +82 -0
  116. synkit/Chem/Mapper/mapper.py +685 -0
  117. synkit/Chem/Mapper/reaction_search.py +554 -0
  118. synkit/Chem/Mapper/reporting/__init__.py +1 -0
  119. synkit/Chem/Mapper/reporting/alternatives.py +451 -0
  120. synkit/Chem/Mapper/reporting/analysis.py +681 -0
  121. synkit/Chem/Mapper/reporting/native_analysis.py +485 -0
  122. synkit/Chem/Mapper/reporting/spectrum.py +642 -0
  123. synkit/Chem/Mapper/solvers/__init__.py +1 -0
  124. synkit/Chem/Mapper/solvers/exact/__init__.py +141 -0
  125. synkit/Chem/Mapper/solvers/exact/assignment/__init__.py +1 -0
  126. synkit/Chem/Mapper/solvers/exact/assignment/assignment_certificate.py +113 -0
  127. synkit/Chem/Mapper/solvers/exact/assignment/assignment_domains.py +162 -0
  128. synkit/Chem/Mapper/solvers/exact/assignment/assignment_verification.py +58 -0
  129. synkit/Chem/Mapper/solvers/exact/assignment/incremental_assignment.py +247 -0
  130. synkit/Chem/Mapper/solvers/exact/certificate.py +128 -0
  131. synkit/Chem/Mapper/solvers/exact/core.py +183 -0
  132. synkit/Chem/Mapper/solvers/exact/distance_search/__init__.py +1 -0
  133. synkit/Chem/Mapper/solvers/exact/distance_search/bounds.py +319 -0
  134. synkit/Chem/Mapper/solvers/exact/distance_search/exhaustive.py +179 -0
  135. synkit/Chem/Mapper/solvers/exact/distance_search/inputs.py +283 -0
  136. synkit/Chem/Mapper/solvers/exact/distance_search/records.py +242 -0
  137. synkit/Chem/Mapper/solvers/exact/distance_search/search.py +1025 -0
  138. synkit/Chem/Mapper/solvers/exact/distance_search/verify.py +378 -0
  139. synkit/Chem/Mapper/solvers/exact/edit_support.py +375 -0
  140. synkit/Chem/Mapper/solvers/exact/hybrid.py +224 -0
  141. synkit/Chem/Mapper/solvers/exact/hydrogen.py +264 -0
  142. synkit/Chem/Mapper/solvers/exact/kernel_solvers/__init__.py +1 -0
  143. synkit/Chem/Mapper/solvers/exact/kernel_solvers/branching.py +499 -0
  144. synkit/Chem/Mapper/solvers/exact/kernel_solvers/enumerate.py +1050 -0
  145. synkit/Chem/Mapper/solvers/exact/kernel_solvers/enumeration_result.py +30 -0
  146. synkit/Chem/Mapper/solvers/exact/kernel_solvers/kernel.py +256 -0
  147. synkit/Chem/Mapper/solvers/exact/kernel_solvers/milp.py +194 -0
  148. synkit/Chem/Mapper/solvers/exact/native/__init__.py +1 -0
  149. synkit/Chem/Mapper/solvers/exact/native/build.py +154 -0
  150. synkit/Chem/Mapper/solvers/exact/native/candidates.py +593 -0
  151. synkit/Chem/Mapper/solvers/exact/native/canonical.py +396 -0
  152. synkit/Chem/Mapper/solvers/exact/native/disk_frontier.py +415 -0
  153. synkit/Chem/Mapper/solvers/exact/native/frontier.py +390 -0
  154. synkit/Chem/Mapper/solvers/exact/native/hydrogen_quotient.py +213 -0
  155. synkit/Chem/Mapper/solvers/exact/native/its.py +528 -0
  156. synkit/Chem/Mapper/solvers/exact/native/native_distance.cpp +2803 -0
  157. synkit/Chem/Mapper/solvers/exact/native/orbit_aggregation.py +328 -0
  158. synkit/Chem/Mapper/solvers/exact/native/parallel.py +205 -0
  159. synkit/Chem/Mapper/solvers/exact/native/pmcd.py +203 -0
  160. synkit/Chem/Mapper/solvers/exact/native/runtime.py +40 -0
  161. synkit/Chem/Mapper/solvers/exact/native/search.py +294 -0
  162. synkit/Chem/Mapper/solvers/exact/native/separator.py +224 -0
  163. synkit/Chem/Mapper/solvers/exact/native_build.py +12 -0
  164. synkit/Chem/Mapper/solvers/exact/pabs/__init__.py +1 -0
  165. synkit/Chem/Mapper/solvers/exact/pabs/cost_lattice.py +43 -0
  166. synkit/Chem/Mapper/solvers/exact/pabs/factor_spectrum.py +110 -0
  167. synkit/Chem/Mapper/solvers/exact/pabs/multi_shell.py +368 -0
  168. synkit/Chem/Mapper/solvers/exact/pabs/propagation.py +679 -0
  169. synkit/Chem/Mapper/solvers/exact/pabs/propagation_bounds.py +436 -0
  170. synkit/Chem/Mapper/solvers/exact/pabs/propagation_limits.py +13 -0
  171. synkit/Chem/Mapper/solvers/exact/pabs/propagation_search.py +856 -0
  172. synkit/Chem/Mapper/solvers/exact/pabs/propagation_symmetry.py +66 -0
  173. synkit/Chem/Mapper/solvers/exact/pabs/residual_typed_bonds.py +136 -0
  174. synkit/Chem/Mapper/solvers/exact/pabs/reward_frontier.py +201 -0
  175. synkit/Chem/Mapper/solvers/exact/pabs/separator_bound.py +258 -0
  176. synkit/Chem/Mapper/solvers/exact/pabs/separator_spectrum.py +308 -0
  177. synkit/Chem/Mapper/solvers/exact/pabs/sparse_reward_bound.py +207 -0
  178. synkit/Chem/Mapper/solvers/exact/pabs/suffix_spectrum.py +299 -0
  179. synkit/Chem/Mapper/solvers/exact/search.py +55 -0
  180. synkit/Chem/Mapper/solvers/exact/seeds/__init__.py +1 -0
  181. synkit/Chem/Mapper/solvers/exact/seeds/seed.py +104 -0
  182. synkit/Chem/Mapper/solvers/exact/seeds/seed_fragments.py +148 -0
  183. synkit/Chem/Mapper/solvers/exact/seeds/seed_relaxation.py +181 -0
  184. synkit/Chem/Mapper/solvers/exact/symmetry.py +223 -0
  185. synkit/Chem/Mapper/solvers/native/__init__.py +4 -0
  186. synkit/Chem/Mapper/solvers/native/assignment.py +99 -0
  187. synkit/Chem/Mapper/solvers/native/astar.py +22 -0
  188. synkit/Chem/Mapper/solvers/native/build.py +385 -0
  189. synkit/Chem/Mapper/solvers/native/classification.py +641 -0
  190. synkit/Chem/Mapper/solvers/native/core.py +378 -0
  191. synkit/Chem/Mapper/solvers/native/cpp/CMakeLists.txt +120 -0
  192. synkit/Chem/Mapper/solvers/native/cpp/examples/map_reaction.cpp +29 -0
  193. synkit/Chem/Mapper/solvers/native/cpp/examples/slap_graph.cpp +63 -0
  194. synkit/Chem/Mapper/solvers/native/cpp/include/synkit_mapper/assignment/assignment.h +18 -0
  195. synkit/Chem/Mapper/solvers/native/cpp/include/synkit_mapper/astar/astar.h +18 -0
  196. synkit/Chem/Mapper/solvers/native/cpp/include/synkit_mapper/astar/bound.h +33 -0
  197. synkit/Chem/Mapper/solvers/native/cpp/include/synkit_mapper/core/pmcd_witness.h +16 -0
  198. synkit/Chem/Mapper/solvers/native/cpp/include/synkit_mapper/core/runtime.h +130 -0
  199. synkit/Chem/Mapper/solvers/native/cpp/include/synkit_mapper/hydrogen/hydrogen.h +73 -0
  200. synkit/Chem/Mapper/solvers/native/cpp/include/synkit_mapper/mapper.h +175 -0
  201. synkit/Chem/Mapper/solvers/native/cpp/include/synkit_mapper/mces/mces.h +9 -0
  202. synkit/Chem/Mapper/solvers/native/cpp/include/synkit_mapper/pabs/seed.h +34 -0
  203. synkit/Chem/Mapper/solvers/native/cpp/include/synkit_mapper/reaction/reaction.h +30 -0
  204. synkit/Chem/Mapper/solvers/native/cpp/include/synkit_mapper/sepdp/sepdp.h +21 -0
  205. synkit/Chem/Mapper/solvers/native/cpp/include/synkit_mapper/slap.h +72 -0
  206. synkit/Chem/Mapper/solvers/native/cpp/include/synkit_mapper/solvers/solvers.h +16 -0
  207. synkit/Chem/Mapper/solvers/native/cpp/include/synkit_mapper/symmetry/classification.h +140 -0
  208. synkit/Chem/Mapper/solvers/native/cpp/include/synkit_mapper/symmetry/cpabs.h +33 -0
  209. synkit/Chem/Mapper/solvers/native/cpp/src/assignment/abb.cpp +302 -0
  210. synkit/Chem/Mapper/solvers/native/cpp/src/astar/CMakeLists.txt +12 -0
  211. synkit/Chem/Mapper/solvers/native/cpp/src/astar/README.md +47 -0
  212. synkit/Chem/Mapper/solvers/native/cpp/src/astar/astar.cpp +228 -0
  213. synkit/Chem/Mapper/solvers/native/cpp/src/astar/astar_graph.cpp +37 -0
  214. synkit/Chem/Mapper/solvers/native/cpp/src/astar/bound.cpp +212 -0
  215. synkit/Chem/Mapper/solvers/native/cpp/src/core/dispatch.cpp +272 -0
  216. synkit/Chem/Mapper/solvers/native/cpp/src/core/pmcd_witness.cpp +36 -0
  217. synkit/Chem/Mapper/solvers/native/cpp/src/hydrogen/hydrogen.cpp +380 -0
  218. synkit/Chem/Mapper/solvers/native/cpp/src/mces/README.md +57 -0
  219. synkit/Chem/Mapper/solvers/native/cpp/src/mces/mces.cpp +287 -0
  220. synkit/Chem/Mapper/solvers/native/cpp/src/pabs/bridge.cpp +334 -0
  221. synkit/Chem/Mapper/solvers/native/cpp/src/pabs/chemical_floor.inc +181 -0
  222. synkit/Chem/Mapper/solvers/native/cpp/src/pabs/endpoint_symmetry.inc +104 -0
  223. synkit/Chem/Mapper/solvers/native/cpp/src/pabs/exact_helpers.h +20 -0
  224. synkit/Chem/Mapper/solvers/native/cpp/src/pabs/lp_assignment_certificate.h +165 -0
  225. synkit/Chem/Mapper/solvers/native/cpp/src/pabs/lp_certificate.h +125 -0
  226. synkit/Chem/Mapper/solvers/native/cpp/src/pabs/lp_model.h +365 -0
  227. synkit/Chem/Mapper/solvers/native/cpp/src/pabs/lp_provider.inc +319 -0
  228. synkit/Chem/Mapper/solvers/native/cpp/src/pabs/lp_rounding.inc +170 -0
  229. synkit/Chem/Mapper/solvers/native/cpp/src/pabs/lp_test_hooks.h +342 -0
  230. synkit/Chem/Mapper/solvers/native/cpp/src/pabs/objective_reduction.inc +137 -0
  231. synkit/Chem/Mapper/solvers/native/cpp/src/pabs/overlap_dual.inc +351 -0
  232. synkit/Chem/Mapper/solvers/native/cpp/src/pabs/profile_seed.inc +214 -0
  233. synkit/Chem/Mapper/solvers/native/cpp/src/pabs/seed_heuristics.inc +640 -0
  234. synkit/Chem/Mapper/solvers/native/cpp/src/pabs/sparse_embedding.cpp +430 -0
  235. synkit/Chem/Mapper/solvers/native/cpp/src/pabs/sparse_root_lp.h +176 -0
  236. synkit/Chem/Mapper/solvers/native/cpp/src/pabs/test_hooks.inc +262 -0
  237. synkit/Chem/Mapper/solvers/native/cpp/src/reaction/reaction.cpp +611 -0
  238. synkit/Chem/Mapper/solvers/native/cpp/src/sepdp/README.md +61 -0
  239. synkit/Chem/Mapper/solvers/native/cpp/src/sepdp/sepdp.cpp +253 -0
  240. synkit/Chem/Mapper/solvers/native/cpp/src/slap.cpp +810 -0
  241. synkit/Chem/Mapper/solvers/native/cpp/src/solvers/formulation.h +138 -0
  242. synkit/Chem/Mapper/solvers/native/cpp/src/solvers/maxsat.cpp +526 -0
  243. synkit/Chem/Mapper/solvers/native/cpp/src/solvers/ortools.cpp +475 -0
  244. synkit/Chem/Mapper/solvers/native/cpp/src/solvers/seed.h +80 -0
  245. synkit/Chem/Mapper/solvers/native/cpp/src/solvers/weighted_cnf.h +256 -0
  246. synkit/Chem/Mapper/solvers/native/cpp/src/supporting/README.md +43 -0
  247. synkit/Chem/Mapper/solvers/native/cpp/src/supporting/dfs.cpp +123 -0
  248. synkit/Chem/Mapper/solvers/native/cpp/src/supporting/edit_support.cpp +224 -0
  249. synkit/Chem/Mapper/solvers/native/cpp/src/symmetry/classification.cpp +553 -0
  250. synkit/Chem/Mapper/solvers/native/cpp/src/symmetry/classification_pipeline.cpp +124 -0
  251. synkit/Chem/Mapper/solvers/native/cpp/src/symmetry/classification_stream.h +13 -0
  252. synkit/Chem/Mapper/solvers/native/cpp/src/symmetry/cpabs.cpp +455 -0
  253. synkit/Chem/Mapper/solvers/native/cpp/src/symmetry/edge_deck.cpp +743 -0
  254. synkit/Chem/Mapper/solvers/native/cpp/src/symmetry/vendor/nauty/COPYRIGHT +44 -0
  255. synkit/Chem/Mapper/solvers/native/cpp/src/symmetry/vendor/nauty/LICENSE-2.0.txt +202 -0
  256. synkit/Chem/Mapper/solvers/native/cpp/src/symmetry/vendor/nauty/naugraph.c +732 -0
  257. synkit/Chem/Mapper/solvers/native/cpp/src/symmetry/vendor/nauty/naurng.c +94 -0
  258. synkit/Chem/Mapper/solvers/native/cpp/src/symmetry/vendor/nauty/naurng.h +49 -0
  259. synkit/Chem/Mapper/solvers/native/cpp/src/symmetry/vendor/nauty/nausparse.c +1771 -0
  260. synkit/Chem/Mapper/solvers/native/cpp/src/symmetry/vendor/nauty/nausparse.h +133 -0
  261. synkit/Chem/Mapper/solvers/native/cpp/src/symmetry/vendor/nauty/nautil.c +750 -0
  262. synkit/Chem/Mapper/solvers/native/cpp/src/symmetry/vendor/nauty/naututil.h +355 -0
  263. synkit/Chem/Mapper/solvers/native/cpp/src/symmetry/vendor/nauty/nauty.c +1256 -0
  264. synkit/Chem/Mapper/solvers/native/cpp/src/symmetry/vendor/nauty/nauty.h +1544 -0
  265. synkit/Chem/Mapper/solvers/native/cpp/src/symmetry/vendor/nauty/schreier.c +1131 -0
  266. synkit/Chem/Mapper/solvers/native/cpp/src/symmetry/vendor/nauty/schreier.h +70 -0
  267. synkit/Chem/Mapper/solvers/native/cpp/src/symmetry/vendor/nauty/sorttemplates.c +580 -0
  268. synkit/Chem/Mapper/solvers/native/hydrogen.py +711 -0
  269. synkit/Chem/Mapper/solvers/native/implicit.py +506 -0
  270. synkit/Chem/Mapper/solvers/native/implicit_certificate.py +187 -0
  271. synkit/Chem/Mapper/solvers/native/implicit_preparation.py +309 -0
  272. synkit/Chem/Mapper/solvers/native/lib/synkit_mapper.dll +0 -0
  273. synkit/Chem/Mapper/solvers/native/mces.py +16 -0
  274. synkit/Chem/Mapper/solvers/native/reaction.py +179 -0
  275. synkit/Chem/Mapper/solvers/native/reaction_build.py +277 -0
  276. synkit/Chem/Mapper/solvers/native/reaction_text.py +26 -0
  277. synkit/Chem/Mapper/solvers/native/runtime.py +242 -0
  278. synkit/Chem/Mapper/solvers/native/sepdp.py +47 -0
  279. synkit/Chem/Mapper/solvers/native/slap.py +266 -0
  280. synkit/Chem/Mapper/solvers/native/solvers.py +67 -0
  281. synkit/Chem/Mapper/solvers/native/stereo.py +86 -0
  282. synkit/Chem/Mapper/solvers/native/supporting.py +34 -0
  283. synkit/Chem/Mapper/solvers/native/symmetry.py +91 -0
  284. synkit/Chem/Mapper/solvers/slap/__init__.py +29 -0
  285. synkit/Chem/Mapper/solvers/slap/lap.py +174 -0
  286. synkit/Chem/Mapper/solvers/slap/sequential.py +696 -0
  287. synkit/Chem/Mapper/validation/__init__.py +1 -0
  288. synkit/Chem/Mapper/validation/aam_validator.py +442 -0
  289. synkit/Chem/Mapper/validation/annotations.py +274 -0
  290. synkit/Chem/Mapper/validation/bonds.py +124 -0
  291. synkit/Chem/Mapper/validation/cohorts.py +72 -0
  292. synkit/Chem/Mapper/validation/identifiability.py +150 -0
  293. synkit/Chem/Mapper/validation/numerical_scope.py +36 -0
  294. synkit/Chem/Mapper/validation/orbits.py +137 -0
  295. synkit/Chem/Molecule/__init__.py +0 -0
  296. synkit/Chem/Molecule/_chirality_loci.py +165 -0
  297. synkit/Chem/Molecule/_chirality_types.py +231 -0
  298. synkit/Chem/Molecule/_helical_loci.py +167 -0
  299. synkit/Chem/Molecule/_stereo_axis_evidence.py +202 -0
  300. synkit/Chem/Molecule/_stereo_element_detection.py +193 -0
  301. synkit/Chem/Molecule/_stereo_orientation_constraints.py +511 -0
  302. synkit/Chem/Molecule/_tetrahedral_carriers.py +63 -0
  303. synkit/Chem/Molecule/atom_features.py +379 -0
  304. synkit/Chem/Molecule/chirality.py +1041 -0
  305. synkit/Chem/Molecule/cip_assignment.py +569 -0
  306. synkit/Chem/Molecule/cip_ranking.py +843 -0
  307. synkit/Chem/Molecule/coordinate_stereo.py +503 -0
  308. synkit/Chem/Molecule/derived_stereo_naming.py +165 -0
  309. synkit/Chem/Molecule/descriptors.py +452 -0
  310. synkit/Chem/Molecule/formula.py +249 -0
  311. synkit/Chem/Molecule/global_stereo.py +175 -0
  312. synkit/Chem/Molecule/graph_annotator.py +300 -0
  313. synkit/Chem/Molecule/standardize.py +817 -0
  314. synkit/Chem/Molecule/stereo_evidence.py +179 -0
  315. synkit/Chem/Molecule/stereo_perception.py +1032 -0
  316. synkit/Chem/Molecule/valence.py +123 -0
  317. synkit/Chem/Reaction/Mapper/__init__.py +44 -0
  318. synkit/Chem/Reaction/__init__.py +20 -0
  319. synkit/Chem/Reaction/aam_validator.py +5 -0
  320. synkit/Chem/Reaction/balance_check.py +138 -0
  321. synkit/Chem/Reaction/canon_rsmi.py +249 -0
  322. synkit/Chem/Reaction/cleaning.py +58 -0
  323. synkit/Chem/Reaction/deionize.py +199 -0
  324. synkit/Chem/Reaction/explicit_h_audit.py +232 -0
  325. synkit/Chem/Reaction/fix_aam.py +64 -0
  326. synkit/Chem/Reaction/neutralize.py +207 -0
  327. synkit/Chem/Reaction/radical_wildcard.py +224 -0
  328. synkit/Chem/Reaction/standardize.py +203 -0
  329. synkit/Chem/Reaction/tautomerize.py +205 -0
  330. synkit/Chem/__init__.py +1 -0
  331. synkit/Chem/utils.py +315 -0
  332. synkit/Data/__init__.py +0 -0
  333. synkit/Data/aldol.json.gz +1 -0
  334. synkit/Data/gen_partial_aam.py +147 -0
  335. synkit/Data/paracetamol.json.gz +1 -0
  336. synkit/Graph/Canon/__init__.py +16 -0
  337. synkit/Graph/Canon/_native/__init__.py +13 -0
  338. synkit/Graph/Canon/_native/build.py +93 -0
  339. synkit/Graph/Canon/_native/lib/synkit_canon.dll +0 -0
  340. synkit/Graph/Canon/_native/prepare.py +49 -0
  341. synkit/Graph/Canon/_native/runtime.py +140 -0
  342. synkit/Graph/Canon/_native/search.py +137 -0
  343. synkit/Graph/Canon/canon_algs.py +228 -0
  344. synkit/Graph/Canon/canon_graph.py +531 -0
  345. synkit/Graph/Canon/cpp/CMakeLists.txt +42 -0
  346. synkit/Graph/Canon/cpp/README.md +123 -0
  347. synkit/Graph/Canon/cpp/examples/canonical_graph.cpp +16 -0
  348. synkit/Graph/Canon/cpp/include/synkit_canon/canon.h +85 -0
  349. synkit/Graph/Canon/cpp/src/canonical.cpp +391 -0
  350. synkit/Graph/Canon/cpp/src/symmetry.h +83 -0
  351. synkit/Graph/Canon/cpp/tests/symmetry.cpp +93 -0
  352. synkit/Graph/Canon/exact.py +1020 -0
  353. synkit/Graph/Canon/exact_refinement.py +368 -0
  354. synkit/Graph/Canon/nauty.py +143 -0
  355. synkit/Graph/Context/__init__.py +0 -0
  356. synkit/Graph/Context/hier_context.py +244 -0
  357. synkit/Graph/Context/radius_expand.py +252 -0
  358. synkit/Graph/FG/__init__.py +19 -0
  359. synkit/Graph/FG/api.py +39 -0
  360. synkit/Graph/FG/audit.py +155 -0
  361. synkit/Graph/FG/catalog.py +919 -0
  362. synkit/Graph/FG/detector.py +288 -0
  363. synkit/Graph/FG/model.py +97 -0
  364. synkit/Graph/FG/recognizers.py +274 -0
  365. synkit/Graph/FG/ring_system.py +154 -0
  366. synkit/Graph/Feature/Descriptors/topology.py +857 -0
  367. synkit/Graph/Feature/Fingerprint/__init__.py +0 -0
  368. synkit/Graph/Feature/Fingerprint/wl_rxn_fps.py +222 -0
  369. synkit/Graph/Feature/__init__.py +5 -0
  370. synkit/Graph/Feature/graph_descriptors.py +323 -0
  371. synkit/Graph/Feature/graph_fps.py +96 -0
  372. synkit/Graph/Feature/graph_signature.py +239 -0
  373. synkit/Graph/Feature/hash_fps.py +133 -0
  374. synkit/Graph/Feature/morgan_fps.py +88 -0
  375. synkit/Graph/Feature/path_fps.py +81 -0
  376. synkit/Graph/Feature/wl_hash.py +134 -0
  377. synkit/Graph/Fusion/__init__.py +85 -0
  378. synkit/Graph/Fusion/candidate.py +559 -0
  379. synkit/Graph/Fusion/constructor.py +877 -0
  380. synkit/Graph/Fusion/identity.py +184 -0
  381. synkit/Graph/Fusion/interface.py +700 -0
  382. synkit/Graph/Fusion/search.py +241 -0
  383. synkit/Graph/Hyrogen/__init__.py +6 -0
  384. synkit/Graph/Hyrogen/_misc.py +591 -0
  385. synkit/Graph/Hyrogen/hcomplete.py +995 -0
  386. synkit/Graph/Hyrogen/hcomplete_legacy.py +368 -0
  387. synkit/Graph/Hyrogen/hcompletion_algorithms.py +713 -0
  388. synkit/Graph/Hyrogen/hextend.py +570 -0
  389. synkit/Graph/Hyrogen/hextend_legacy.py +215 -0
  390. synkit/Graph/ITS/__init__.py +4 -0
  391. synkit/Graph/ITS/its_builder.py +120 -0
  392. synkit/Graph/ITS/its_construction.py +719 -0
  393. synkit/Graph/ITS/its_decompose.py +572 -0
  394. synkit/Graph/ITS/its_destruction.py +331 -0
  395. synkit/Graph/ITS/its_expand.py +1251 -0
  396. synkit/Graph/ITS/its_relabel.py +187 -0
  397. synkit/Graph/ITS/its_reverter.py +356 -0
  398. synkit/Graph/ITS/normalize_aam.py +145 -0
  399. synkit/Graph/ITS/partial_its.py +238 -0
  400. synkit/Graph/ITS/rc_extractor.py +656 -0
  401. synkit/Graph/ITS/stereo.py +74 -0
  402. synkit/Graph/MTG/__init__.py +107 -0
  403. synkit/Graph/MTG/gluing.py +451 -0
  404. synkit/Graph/MTG/history.py +928 -0
  405. synkit/Graph/MTG/inventory.py +118 -0
  406. synkit/Graph/MTG/local.py +447 -0
  407. synkit/Graph/MTG/mcs_matcher.py +246 -0
  408. synkit/Graph/MTG/mtg.py +611 -0
  409. synkit/Graph/MTG/mtg_explore.py +74 -0
  410. synkit/Graph/MTG/observation.py +209 -0
  411. synkit/Graph/MTG/process.py +577 -0
  412. synkit/Graph/MTG/queries.py +66 -0
  413. synkit/Graph/MTG/realization.py +95 -0
  414. synkit/Graph/MTG/series_parallel.py +107 -0
  415. synkit/Graph/MTG/utils.py +424 -0
  416. synkit/Graph/Matcher/__init__.py +10 -0
  417. synkit/Graph/Matcher/approx_mcs.py +1264 -0
  418. synkit/Graph/Matcher/auto_est.py +736 -0
  419. synkit/Graph/Matcher/automorphism.py +314 -0
  420. synkit/Graph/Matcher/batch_cluster.py +224 -0
  421. synkit/Graph/Matcher/dedup_matches.py +247 -0
  422. synkit/Graph/Matcher/graph_cluster.py +191 -0
  423. synkit/Graph/Matcher/graph_matcher.py +312 -0
  424. synkit/Graph/Matcher/graph_morphism.py +421 -0
  425. synkit/Graph/Matcher/mcs_matcher.py +804 -0
  426. synkit/Graph/Matcher/multi_turbo_iso.py +186 -0
  427. synkit/Graph/Matcher/orbit.py +284 -0
  428. synkit/Graph/Matcher/partial_matcher.py +694 -0
  429. synkit/Graph/Matcher/sing.py +544 -0
  430. synkit/Graph/Matcher/subgraph_matcher.py +812 -0
  431. synkit/Graph/Matcher/turbo_iso.py +272 -0
  432. synkit/Graph/Matcher/wl_sel.py +537 -0
  433. synkit/Graph/Mech/__init__.py +26 -0
  434. synkit/Graph/Mech/conversion.py +1466 -0
  435. synkit/Graph/Mech/conversion_debug.py +237 -0
  436. synkit/Graph/Mech/electron_accounting.py +200 -0
  437. synkit/Graph/Mech/lwg_editor.py +406 -0
  438. synkit/Graph/Mech/lwg_ops.py +313 -0
  439. synkit/Graph/Morphism/__init__.py +99 -0
  440. synkit/Graph/Morphism/constraints.py +460 -0
  441. synkit/Graph/Morphism/llg.py +999 -0
  442. synkit/Graph/Morphism/morphism.py +349 -0
  443. synkit/Graph/Morphism/stereo.py +258 -0
  444. synkit/Graph/Morphism/stereo_morphism.py +383 -0
  445. synkit/Graph/Morphism/stereo_morphism_helpers.py +957 -0
  446. synkit/Graph/Morphism/stereo_morphism_types.py +117 -0
  447. synkit/Graph/Stereo/__init__.py +376 -0
  448. synkit/Graph/Stereo/_descriptor_core.py +207 -0
  449. synkit/Graph/Stereo/_descriptor_serialization.py +138 -0
  450. synkit/Graph/Stereo/canonical.py +648 -0
  451. synkit/Graph/Stereo/changes.py +964 -0
  452. synkit/Graph/Stereo/composition.py +391 -0
  453. synkit/Graph/Stereo/configured.py +946 -0
  454. synkit/Graph/Stereo/couplings.py +245 -0
  455. synkit/Graph/Stereo/descriptors.py +1030 -0
  456. synkit/Graph/Stereo/enumeration.py +597 -0
  457. synkit/Graph/Stereo/extended_descriptors.py +406 -0
  458. synkit/Graph/Stereo/global_configured.py +152 -0
  459. synkit/Graph/Stereo/global_stereo.py +304 -0
  460. synkit/Graph/Stereo/identity.py +648 -0
  461. synkit/Graph/Stereo/interchange.py +496 -0
  462. synkit/Graph/Stereo/legacy.py +460 -0
  463. synkit/Graph/Stereo/matching.py +899 -0
  464. synkit/Graph/Stereo/native.py +154 -0
  465. synkit/Graph/Stereo/orbits.py +668 -0
  466. synkit/Graph/Stereo/outcomes.py +99 -0
  467. synkit/Graph/Stereo/pairwise.py +350 -0
  468. synkit/Graph/Stereo/rdkit_adapter.py +662 -0
  469. synkit/Graph/Stereo/rdkit_identity.py +118 -0
  470. synkit/Graph/Stereo/rdkit_resonance.py +140 -0
  471. synkit/Graph/Stereo/reaction_interchange.py +266 -0
  472. synkit/Graph/Stereo/semantics.py +296 -0
  473. synkit/Graph/Stereo/supports.py +257 -0
  474. synkit/Graph/Stereo/wire.py +339 -0
  475. synkit/Graph/Wildcard/__init__.py +0 -0
  476. synkit/Graph/Wildcard/fuse_graph.py +156 -0
  477. synkit/Graph/Wildcard/graph_wc.py +303 -0
  478. synkit/Graph/Wildcard/its_merge.py +857 -0
  479. synkit/Graph/Wildcard/radwc.py +114 -0
  480. synkit/Graph/Wildcard/wc_matcher.py +576 -0
  481. synkit/Graph/Wildcard/wildcard.py +225 -0
  482. synkit/Graph/__init__.py +17 -0
  483. synkit/Graph/canon_graph.py +14 -0
  484. synkit/Graph/syn_graph.py +199 -0
  485. synkit/Graph/utils.py +324 -0
  486. synkit/IO/__init__.py +4 -0
  487. synkit/IO/chem_converter.py +1059 -0
  488. synkit/IO/combinatorial/__init__.py +8 -0
  489. synkit/IO/combinatorial/gml_to_graph.py +223 -0
  490. synkit/IO/combinatorial/graph_to_gml.py +280 -0
  491. synkit/IO/combinatorial/graph_to_smarts.py +186 -0
  492. synkit/IO/combinatorial/smarts_expander.py +147 -0
  493. synkit/IO/combinatorial/smarts_generalizer.py +133 -0
  494. synkit/IO/combinatorial/smarts_to_graph.py +179 -0
  495. synkit/IO/conversion.py +42 -0
  496. synkit/IO/data_io.py +269 -0
  497. synkit/IO/data_process.py +143 -0
  498. synkit/IO/debug.py +73 -0
  499. synkit/IO/gml_to_nx.py +150 -0
  500. synkit/IO/graph_to_mol.py +199 -0
  501. synkit/IO/mol_to_graph.py +1105 -0
  502. synkit/IO/mol_to_graph_chemistry.py +545 -0
  503. synkit/IO/nx_to_gml.py +209 -0
  504. synkit/IO/smiles_to_id.py +123 -0
  505. synkit/Mechanism/__init__.py +151 -0
  506. synkit/Mechanism/adapters.py +136 -0
  507. synkit/Mechanism/audit.py +323 -0
  508. synkit/Mechanism/benchmark.py +638 -0
  509. synkit/Mechanism/compatibility.py +13 -0
  510. synkit/Mechanism/drawing.py +85 -0
  511. synkit/Mechanism/electron_models.py +725 -0
  512. synkit/Mechanism/equivalence.py +521 -0
  513. synkit/Mechanism/evidence.py +262 -0
  514. synkit/Mechanism/interchange.py +141 -0
  515. synkit/Mechanism/model.py +918 -0
  516. synkit/Mechanism/neighbor_stereo.py +312 -0
  517. synkit/Mechanism/radical_data.py +740 -0
  518. synkit/Mechanism/replay.py +986 -0
  519. synkit/Mechanism/schema.py +264 -0
  520. synkit/Mechanism/stereo_state.py +416 -0
  521. synkit/Mechanism/symbols.py +91 -0
  522. synkit/Rule/Apply/__init__.py +35 -0
  523. synkit/Rule/Apply/conditions.py +45 -0
  524. synkit/Rule/Apply/dpo.py +998 -0
  525. synkit/Rule/Apply/rule_matcher.py +218 -0
  526. synkit/Rule/Apply/validation.py +354 -0
  527. synkit/Rule/Compose/__init__.py +106 -0
  528. synkit/Rule/Compose/_identity.py +43 -0
  529. synkit/Rule/Compose/composition.py +976 -0
  530. synkit/Rule/Compose/laws.py +439 -0
  531. synkit/Rule/Compose/search.py +771 -0
  532. synkit/Rule/Compose/synrule.py +170 -0
  533. synkit/Rule/Modify/__init__.py +0 -0
  534. synkit/Rule/Modify/implict_rule.py +56 -0
  535. synkit/Rule/Modify/longest_path.py +92 -0
  536. synkit/Rule/Modify/molecule_rule.py +115 -0
  537. synkit/Rule/Modify/prune_templates.py +78 -0
  538. synkit/Rule/Modify/rule_utils.py +194 -0
  539. synkit/Rule/Modify/strip_rule.py +97 -0
  540. synkit/Rule/__init__.py +28 -0
  541. synkit/Rule/generic_stereo.py +884 -0
  542. synkit/Rule/stereo_identity.py +271 -0
  543. synkit/Rule/syn_rule.py +1090 -0
  544. synkit/Synthesis/MSR/__init__.py +0 -0
  545. synkit/Synthesis/MSR/path_finder.py +239 -0
  546. synkit/Synthesis/Metrics/__init__.py +0 -0
  547. synkit/Synthesis/Metrics/_base.py +52 -0
  548. synkit/Synthesis/Metrics/_plot.py +129 -0
  549. synkit/Synthesis/Metrics/_ranking.py +179 -0
  550. synkit/Synthesis/RBL/__init__.py +80 -0
  551. synkit/Synthesis/RBL/engine.py +1249 -0
  552. synkit/Synthesis/RBL/matching.py +652 -0
  553. synkit/Synthesis/RBL/overlap.py +336 -0
  554. synkit/Synthesis/RBL/pipeline.py +376 -0
  555. synkit/Synthesis/RBL/policy.py +139 -0
  556. synkit/Synthesis/RBL/proof.py +370 -0
  557. synkit/Synthesis/RBL/reaction.py +506 -0
  558. synkit/Synthesis/RBL/state.py +169 -0
  559. synkit/Synthesis/RBL/validation.py +1060 -0
  560. synkit/Synthesis/Reactor/__init__.py +60 -0
  561. synkit/Synthesis/Reactor/core/__init__.py +1 -0
  562. synkit/Synthesis/Reactor/core/engine.py +967 -0
  563. synkit/Synthesis/Reactor/core/product.py +621 -0
  564. synkit/Synthesis/Reactor/core/rewrite.py +561 -0
  565. synkit/Synthesis/Reactor/core/strategy.py +44 -0
  566. synkit/Synthesis/Reactor/matching/__init__.py +1 -0
  567. synkit/Synthesis/Reactor/matching/mixin.py +920 -0
  568. synkit/Synthesis/Reactor/matching/policy.py +345 -0
  569. synkit/Synthesis/Reactor/matching/symmetry.py +309 -0
  570. synkit/Synthesis/Reactor/output/__init__.py +1 -0
  571. synkit/Synthesis/Reactor/output/deduplication.py +703 -0
  572. synkit/Synthesis/Reactor/output/policy.py +16 -0
  573. synkit/Synthesis/Reactor/output/serialization.py +485 -0
  574. synkit/Synthesis/Reactor/output/structural.py +893 -0
  575. synkit/Synthesis/Reactor/stereo/__init__.py +1 -0
  576. synkit/Synthesis/Reactor/stereo/assignment.py +35 -0
  577. synkit/Synthesis/Reactor/stereo/mixin.py +780 -0
  578. synkit/Synthesis/Reactor/variants/__init__.py +1 -0
  579. synkit/Synthesis/Reactor/variants/imbalanced.py +180 -0
  580. synkit/Synthesis/Reactor/variants/partial.py +84 -0
  581. synkit/Synthesis/Reactor/workflow/__init__.py +1 -0
  582. synkit/Synthesis/Reactor/workflow/batch.py +550 -0
  583. synkit/Synthesis/Reactor/workflow/benchmark.py +143 -0
  584. synkit/Synthesis/Reactor/workflow/postprocess.py +267 -0
  585. synkit/Synthesis/Reactor/workflow/rule_filter.py +210 -0
  586. synkit/Synthesis/__init__.py +0 -0
  587. synkit/Synthesis/reactor_utils.py +316 -0
  588. synkit/Utils/__init__.py +0 -0
  589. synkit/Utils/utils.py +172 -0
  590. synkit/Vis/__init__.py +91 -0
  591. synkit/Vis/crn/__init__.py +40 -0
  592. synkit/Vis/epd/__init__.py +20 -0
  593. synkit/Vis/epd/arrows.py +425 -0
  594. synkit/Vis/epd/chem.py +197 -0
  595. synkit/Vis/epd/constants.py +83 -0
  596. synkit/Vis/epd/layout.py +283 -0
  597. synkit/Vis/epd/mapping.py +51 -0
  598. synkit/Vis/epd/models.py +136 -0
  599. synkit/Vis/epd/render.py +543 -0
  600. synkit/Vis/epd/utils.py +108 -0
  601. synkit/Vis/epd/visualizer.py +811 -0
  602. synkit/Vis/graph_visualizer.py +375 -0
  603. synkit/Vis/its/__init__.py +19 -0
  604. synkit/Vis/its/drawer.py +615 -0
  605. synkit/Vis/its_drawer.py +35 -0
  606. synkit/Vis/molecule/__init__.py +31 -0
  607. synkit/Vis/molecule/drawer.py +563 -0
  608. synkit/Vis/molecule/reaction.py +285 -0
  609. synkit/Vis/molecule_drawer.py +33 -0
  610. synkit/Vis/mtg/__init__.py +17 -0
  611. synkit/Vis/mtg/drawer.py +769 -0
  612. synkit/Vis/mtg_drawer.py +34 -0
  613. synkit/Vis/pdf_writer.py +146 -0
  614. synkit/Vis/reaction/__init__.py +19 -0
  615. synkit/Vis/reaction/rule.py +141 -0
  616. synkit/Vis/reaction/rxn.py +532 -0
  617. synkit/Vis/reaction_drawer.py +41 -0
  618. synkit/Vis/space/__init__.py +17 -0
  619. synkit/Vis/space/chemical.py +80 -0
  620. synkit/Vis/space/embedding.py +87 -0
  621. synkit/Vis/vis_synedu/Vis/__init__.py +7 -0
  622. synkit/Vis/vis_synedu/Vis/dpo.py +862 -0
  623. synkit/Vis/vis_synedu/rxn_vis.py +377 -0
  624. synkit/Vis/vis_synedu/vis.py +501 -0
  625. synkit/Vis/visual_drawer.py +215 -0
  626. synkit/Vis/visual_model.py +520 -0
  627. synkit/__init__.py +3 -0
  628. synkit/_native.py +91 -0
  629. synkit/examples.py +50 -0
  630. synkit/version.py +23 -0
  631. synkit-1.6.5.dist-info/METADATA +156 -0
  632. synkit-1.6.5.dist-info/RECORD +636 -0
  633. synkit-1.6.5.dist-info/WHEEL +4 -0
  634. synkit-1.6.5.dist-info/licenses/LICENSE +21 -0
  635. synkit-1.6.5.dist-info/licenses/synkit/Chem/Mapper/solvers/native/cpp/src/symmetry/vendor/nauty/COPYRIGHT +44 -0
  636. synkit-1.6.5.dist-info/licenses/synkit/Chem/Mapper/solvers/native/cpp/src/symmetry/vendor/nauty/LICENSE-2.0.txt +202 -0
@@ -0,0 +1,1466 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from collections import Counter
5
+ from typing import Any, Literal, Optional
6
+
7
+ # ============================================================
8
+ # Optional SynKit imports
9
+ # ============================================================
10
+
11
+ try:
12
+ from synkit.Chem.Reaction.canon_rsmi import CanonRSMI
13
+ from synkit.Graph.ITS.its_expand import ITSExpand
14
+ from synkit.IO import rsmi_to_its
15
+ except ImportError:
16
+ CanonRSMI = None
17
+ rsmi_to_its = None
18
+ ITSExpand = None
19
+
20
+
21
+ # ============================================================
22
+ # Regex helpers
23
+ # ============================================================
24
+
25
+ BRACKET_ATOM_RE = re.compile(r"\[[^\[\]]+\]")
26
+ ATOM_MAP_RE = re.compile(r":(\d+)(?=\])")
27
+
28
+
29
+ # ============================================================
30
+ # Arrow-code parsing
31
+ # ============================================================
32
+
33
+
34
+ def parse_atom_list(text: str) -> list[int]:
35
+ """Parse a comma-separated atom-map list.
36
+
37
+ :param text: Atom-map text such as ``"10"`` or ``"10,11"``.
38
+ :type text: str
39
+ :return: Parsed atom-map numbers.
40
+ :rtype: list[int]
41
+ """
42
+ return [int(x.strip()) for x in text.split(",") if x.strip()]
43
+
44
+
45
+ def parse_arrow_step(step: str) -> tuple[list[int], list[int]]:
46
+ """Convert one arrow-code step.
47
+
48
+ For example, ``"10=20"`` becomes ``([10], [20])`` and
49
+ ``"12=11,12"`` becomes ``([12], [11, 12])``.
50
+
51
+ :param step: One arrow-code step containing a left and right side.
52
+ :type step: str
53
+ :return: Parsed left-side and right-side atom-map lists.
54
+ :rtype: tuple[list[int], list[int]]
55
+ :raises ValueError: If ``step`` does not contain ``"="``.
56
+ """
57
+ if "=" not in step:
58
+ raise ValueError(f"Invalid arrow step without '=': {step}")
59
+
60
+ lhs, rhs = step.split("=", 1)
61
+ return parse_atom_list(lhs), parse_atom_list(rhs)
62
+
63
+
64
+ def split_arrow_code(arrow_code: str) -> list[str]:
65
+ """Split an arrow code into non-empty steps.
66
+
67
+ :param arrow_code: Semicolon-separated arrow code.
68
+ :type arrow_code: str
69
+ :return: Individual stripped arrow-code steps.
70
+ :rtype: list[str]
71
+ """
72
+ return [s.strip() for s in arrow_code.split(";") if s.strip()]
73
+
74
+
75
+ def ef_arrow_code_to_arrow_code(ef_arrow_code: str) -> str:
76
+ """Normalize an EF-SMIRKS hyphen-form flow code for SynKit conversion.
77
+
78
+ EF-SMIRKS stores one electron-flow step as ``source-target``; SynKit's
79
+ internal arrow-code helpers use ``source=target``. For example,
80
+ ``"10,20-20,21"`` becomes ``"10,20=20,21"``.
81
+
82
+ :param ef_arrow_code: Semicolon-separated hyphen-form electron-flow code.
83
+ :type ef_arrow_code: str
84
+ :return: Equivalent equals-form SynKit arrow code.
85
+ :rtype: str
86
+ :raises ValueError: If a step does not contain exactly one ``"-"``.
87
+ """
88
+ normalized_steps = []
89
+ for step in split_arrow_code(ef_arrow_code):
90
+ if step.count("-") != 1:
91
+ raise ValueError(
92
+ "An EF-SMIRKS electron-flow step must contain exactly one '-': "
93
+ f"{step!r}"
94
+ )
95
+ lhs, rhs = step.split("-", 1)
96
+ normalized_steps.append(f"{lhs.strip()}={rhs.strip()}")
97
+ if not normalized_steps:
98
+ raise ValueError("EF-SMIRKS electron-flow code is empty.")
99
+ return ";".join(normalized_steps)
100
+
101
+
102
+ def arrow_code_to_ef_arrow_code(arrow_code: str) -> str:
103
+ """Convert internal equals-form arrow code to EF-SMIRKS hyphen form.
104
+
105
+ :param arrow_code: Semicolon-separated SynKit arrow code.
106
+ :type arrow_code: str
107
+ :return: Equivalent hyphen-form electron-flow code.
108
+ :rtype: str
109
+ """
110
+ return ";".join(step.replace("=", "-", 1) for step in split_arrow_code(arrow_code))
111
+
112
+
113
+ def split_ef_smirks(ef_smirks: str) -> tuple[str, str]:
114
+ """Split an EF-SMIRKS string into reaction SMILES and flow code.
115
+
116
+ An EF-SMIRKS is an atom-mapped reaction SMILES followed by whitespace and
117
+ a hyphen-form electron-flow code, e.g. ``"[CH3:1][Cl:2]>>[CH3:1].[Cl-:2]
118
+ 1-1,2"``.
119
+
120
+ :param ef_smirks: RSMI followed by whitespace and electron-flow code.
121
+ :type ef_smirks: str
122
+ :return: Reaction SMILES and hyphen-form electron-flow code.
123
+ :rtype: tuple[str, str]
124
+ :raises ValueError: If the input lacks an RSMI arrow or flow code.
125
+ """
126
+ try:
127
+ reaction_smiles, ef_arrow_code = ef_smirks.strip().rsplit(None, 1)
128
+ except ValueError as exc:
129
+ raise ValueError(
130
+ "EF-SMIRKS must contain reaction SMILES followed by whitespace and "
131
+ "an electron-flow code."
132
+ ) from exc
133
+ if ">>" not in reaction_smiles:
134
+ raise ValueError("EF-SMIRKS reaction component must contain '>>'.")
135
+ return reaction_smiles, ef_arrow_code
136
+
137
+
138
+ def arrow_atom_maps(arrow_code: str) -> set[int]:
139
+ """Return all atom maps used in an arrow code.
140
+
141
+ :param arrow_code: Semicolon-separated arrow code.
142
+ :type arrow_code: str
143
+ :return: Atom-map numbers referenced by the code.
144
+ :rtype: set[int]
145
+ """
146
+ maps: set[int] = set()
147
+
148
+ for step in split_arrow_code(arrow_code):
149
+ lhs, rhs = parse_arrow_step(step)
150
+ maps.update(lhs)
151
+ maps.update(rhs)
152
+
153
+ return maps
154
+
155
+
156
+ def classify_arrow_shape(step: str) -> str:
157
+ """Classify one arrow-code step.
158
+
159
+ :param step: One arrow-code step.
160
+ :type step: str
161
+ :return: Shape label such as ``"a=b"`` or ``"a,b=c,d"``.
162
+ :rtype: str
163
+ """
164
+ lhs, rhs = parse_arrow_step(step)
165
+
166
+ if len(lhs) == 1 and len(rhs) == 1:
167
+ return "a=b"
168
+
169
+ if len(lhs) == 1 and len(rhs) == 2:
170
+ return "a=b,c"
171
+
172
+ if len(lhs) == 2 and len(rhs) == 1:
173
+ return "a,b=c"
174
+
175
+ if len(lhs) == 2 and len(rhs) == 2:
176
+ return "a,b=c,d"
177
+
178
+ return f"unsupported:{len(lhs)},{len(rhs)}"
179
+
180
+
181
+ def check_arrow_code_coverage(arrow_codes: list[str]) -> dict[str, Any]:
182
+ """Check which arrow-code shapes appear in a dataset.
183
+
184
+ :param arrow_codes: Arrow codes to inspect.
185
+ :type arrow_codes: list[str]
186
+ :return: Shape counts, unsupported steps, and an all-supported flag.
187
+ :rtype: dict[str, Any]
188
+ """
189
+ shape_counter = Counter()
190
+ unsupported = []
191
+
192
+ for row_idx, arrow_code in enumerate(arrow_codes, start=1):
193
+ for step in split_arrow_code(arrow_code):
194
+ shape = classify_arrow_shape(step)
195
+ shape_counter[shape] += 1
196
+
197
+ if shape.startswith("unsupported"):
198
+ unsupported.append(
199
+ {
200
+ "row_index": row_idx,
201
+ "arrow_code": arrow_code,
202
+ "step": step,
203
+ "shape": shape,
204
+ }
205
+ )
206
+
207
+ return {
208
+ "shape_counts": dict(shape_counter),
209
+ "unsupported": unsupported,
210
+ "all_supported": len(unsupported) == 0,
211
+ }
212
+
213
+
214
+ # ============================================================
215
+ # Atom-map preprocessing
216
+ # ============================================================
217
+
218
+
219
+ def extract_atom_maps_from_smiles(smiles: str) -> list[int]:
220
+ """Extract atom-map numbers from bracket atoms.
221
+
222
+ For example, ``"[CH:10]"`` yields ``10`` and ``"[N+:61]"`` yields ``61``.
223
+
224
+ :param smiles: SMILES or SMIRKS fragment.
225
+ :type smiles: str
226
+ :return: Atom-map numbers found in bracket atoms.
227
+ :rtype: list[int]
228
+ """
229
+ return [int(x) for x in ATOM_MAP_RE.findall(smiles)]
230
+
231
+
232
+ def duplicate_atom_maps_in_side(smiles: str) -> dict[int, int]:
233
+ """Find duplicated atom maps in one side of a reaction.
234
+
235
+ :param smiles: Reactant-side or product-side SMILES text.
236
+ :type smiles: str
237
+ :return: Mapping of duplicated atom-map numbers to occurrence counts.
238
+ :rtype: dict[int, int]
239
+ """
240
+ counts = Counter(extract_atom_maps_from_smiles(smiles))
241
+ return {atom_map: count for atom_map, count in counts.items() if count > 1}
242
+
243
+
244
+ def remove_duplicate_atom_maps(rsmi: str) -> tuple[str, dict[str, dict[int, int]]]:
245
+ """Make each endpoint map-injective before partial-map expansion.
246
+
247
+ The first occurrence of a positive map on each endpoint is retained and
248
+ the label is removed from every later occurrence. The now-unmapped atoms
249
+ can then receive fresh labels during guarded AAM expansion.
250
+
251
+ :param rsmi: Reaction text in ``reactants>>products`` format.
252
+ :type rsmi: str
253
+ :return: Cleaned reaction and removed occurrence counts by endpoint.
254
+ :rtype: tuple[str, dict[str, dict[int, int]]]
255
+ :raises ValueError: If the reaction does not contain exactly one arrow.
256
+ """
257
+ if rsmi.count(">>") != 1:
258
+ raise ValueError("RSMI must contain exactly one '>>'")
259
+
260
+ removed_by_side: dict[str, dict[int, int]] = {}
261
+ cleaned_sides: list[str] = []
262
+ for side_name, side in zip(("reactants", "products"), rsmi.split(">>", 1)):
263
+ seen: set[int] = set()
264
+ removed: Counter[int] = Counter()
265
+
266
+ def clean_bracket_atom(match: re.Match) -> str:
267
+ token = match.group(0)
268
+ map_match = ATOM_MAP_RE.search(token)
269
+ if map_match is None:
270
+ return token
271
+ atom_map = int(map_match.group(1))
272
+ if atom_map not in seen:
273
+ seen.add(atom_map)
274
+ return token
275
+ removed[atom_map] += 1
276
+ return ATOM_MAP_RE.sub("", token)
277
+
278
+ cleaned_sides.append(BRACKET_ATOM_RE.sub(clean_bracket_atom, side))
279
+ removed_by_side[side_name] = dict(sorted(removed.items()))
280
+
281
+ return ">>".join(cleaned_sides), removed_by_side
282
+
283
+
284
+ def validate_arrow_maps(
285
+ rsmi: str,
286
+ arrow_code: str,
287
+ raise_on_arrow_duplicates: bool = True,
288
+ raise_on_missing_arrow_maps: bool = True,
289
+ ) -> dict[str, Any]:
290
+ """Validate atom maps before SynKit.
291
+
292
+ Rules
293
+ -----
294
+ 1. Duplicated atom maps used by arrow_code are fatal.
295
+ 2. Missing atom maps used by arrow_code are fatal.
296
+ 3. Duplicated non-arrow atom maps are warnings only, because they
297
+ can be removed before SynKit expansion.
298
+
299
+ :param rsmi: Reaction SMILES in ``reactants>>products`` format.
300
+ :type rsmi: str
301
+ :param arrow_code: Arrow code whose atom maps must be validated.
302
+ :type arrow_code: str
303
+ :param raise_on_arrow_duplicates: Whether duplicated arrow atom maps are fatal.
304
+ :type raise_on_arrow_duplicates: bool
305
+ :param raise_on_missing_arrow_maps: Whether missing arrow atom maps are fatal.
306
+ :type raise_on_missing_arrow_maps: bool
307
+ :return: Validation diagnostics.
308
+ :rtype: dict[str, Any]
309
+ :raises ValueError: If the RSMI is malformed or enabled validation fails.
310
+ """
311
+ if ">>" not in rsmi:
312
+ raise ValueError("RSMI must contain '>>'")
313
+
314
+ reactants, products = rsmi.split(">>", 1)
315
+
316
+ arrow_maps = arrow_atom_maps(arrow_code)
317
+
318
+ reactant_maps = extract_atom_maps_from_smiles(reactants)
319
+ product_maps = extract_atom_maps_from_smiles(products)
320
+
321
+ all_raw_maps = set(reactant_maps) | set(product_maps)
322
+
323
+ missing_arrow_maps = sorted(m for m in arrow_maps if m not in all_raw_maps)
324
+
325
+ r_dupes = duplicate_atom_maps_in_side(reactants)
326
+ p_dupes = duplicate_atom_maps_in_side(products)
327
+
328
+ arrow_dupes = {
329
+ "reactants": {m: c for m, c in r_dupes.items() if m in arrow_maps},
330
+ "products": {m: c for m, c in p_dupes.items() if m in arrow_maps},
331
+ }
332
+
333
+ non_arrow_dupes = {
334
+ "reactants": {m: c for m, c in r_dupes.items() if m not in arrow_maps},
335
+ "products": {m: c for m, c in p_dupes.items() if m not in arrow_maps},
336
+ }
337
+
338
+ diagnostics = {
339
+ "arrow_maps": sorted(arrow_maps),
340
+ "missing_arrow_maps": missing_arrow_maps,
341
+ "arrow_duplicate_maps": arrow_dupes,
342
+ "non_arrow_duplicate_maps": non_arrow_dupes,
343
+ "has_missing_arrow_maps": bool(missing_arrow_maps),
344
+ "has_arrow_duplicate_maps": bool(
345
+ arrow_dupes["reactants"] or arrow_dupes["products"]
346
+ ),
347
+ "has_non_arrow_duplicate_maps": bool(
348
+ non_arrow_dupes["reactants"] or non_arrow_dupes["products"]
349
+ ),
350
+ }
351
+
352
+ if raise_on_missing_arrow_maps and diagnostics["has_missing_arrow_maps"]:
353
+ raise ValueError(
354
+ "Some atom maps used in arrow_code are missing from the reaction SMILES. "
355
+ f"Diagnostics: {diagnostics}"
356
+ )
357
+
358
+ if raise_on_arrow_duplicates and diagnostics["has_arrow_duplicate_maps"]:
359
+ raise ValueError(
360
+ "Some atom maps used in arrow_code are duplicated in the reaction SMILES. "
361
+ f"Diagnostics: {diagnostics}"
362
+ )
363
+
364
+ return diagnostics
365
+
366
+
367
+ def remove_non_arrow_atom_maps(rsmi: str, arrow_code: str) -> str:
368
+ """Keep only atom maps involved in arrow_code.
369
+ Remove every other atom map.
370
+
371
+ This is important because some source SMIRKS have duplicated
372
+ non-arrow atom maps, e.g.
373
+
374
+ [N+:61]2=[CH:61]
375
+
376
+ If 61 is not used by arrow_code, we remove it and let SynKit
377
+ CanonRSMI().expand_aam(...) generate clean full maps.
378
+
379
+ :param rsmi: Reaction SMILES to clean.
380
+ :type rsmi: str
381
+ :param arrow_code: Arrow code whose atom maps should be preserved.
382
+ :type arrow_code: str
383
+ :return: Reaction SMILES with non-arrow atom maps removed.
384
+ :rtype: str
385
+ """
386
+ keep_maps = arrow_atom_maps(arrow_code)
387
+
388
+ def clean_bracket_atom(match: re.Match) -> str:
389
+ token = match.group(0)
390
+
391
+ map_match = ATOM_MAP_RE.search(token)
392
+ if map_match is None:
393
+ return token
394
+
395
+ atom_map = int(map_match.group(1))
396
+
397
+ if atom_map in keep_maps:
398
+ return token
399
+
400
+ return ATOM_MAP_RE.sub("", token)
401
+
402
+ return BRACKET_ATOM_RE.sub(clean_bracket_atom, rsmi)
403
+
404
+
405
+ # ============================================================
406
+ # Generic LP/B conversion
407
+ # ============================================================
408
+
409
+
410
+ def generic_convert_step(step: str) -> list[Any]:
411
+ """Generic graph-independent conversion.
412
+
413
+ Supported grammar
414
+ -----------------
415
+ a=b
416
+ LP(a) forms bond a-b
417
+ -> ["LP-/B+", [a], [a, b]]
418
+
419
+ a=b,c
420
+ LP(a) forms/increases bond b-c
421
+ -> ["LP-/B+", [a], [b, c]]
422
+
423
+ a,b=c
424
+ bond a-b breaks; electrons end as LP on c
425
+ -> ["B-/LP+", [a, b], [c]]
426
+
427
+ a,b=c,d
428
+ bond a-b becomes bond c-d
429
+ -> ["B-/B+", [a, b], [c, d]]
430
+
431
+ :param step: One arrow-code step.
432
+ :type step: str
433
+ :return: Generic LP/B conversion record.
434
+ :rtype: list[Any]
435
+ :raises ValueError: If the step shape is unsupported.
436
+ """
437
+ lhs, rhs = parse_arrow_step(step)
438
+
439
+ # a=b
440
+ if len(lhs) == 1 and len(rhs) == 1:
441
+ a = lhs[0]
442
+ b = rhs[0]
443
+ return ["LP-/B+", [a], [a, b]]
444
+
445
+ # a=b,c
446
+ if len(lhs) == 1 and len(rhs) == 2:
447
+ return ["LP-/B+", lhs, rhs]
448
+
449
+ # a,b=c
450
+ if len(lhs) == 2 and len(rhs) == 1:
451
+ return ["B-/LP+", lhs, rhs]
452
+
453
+ # a,b=c,d
454
+ if len(lhs) == 2 and len(rhs) == 2:
455
+ return ["B-/B+", lhs, rhs]
456
+
457
+ raise ValueError(f"Unsupported arrow step: {step}")
458
+
459
+
460
+ def generic_convert_arrow_code(arrow_code: str) -> list[list[Any]]:
461
+ """Convert every step in an arrow code to generic LP/B form.
462
+
463
+ :param arrow_code: Semicolon-separated arrow code.
464
+ :type arrow_code: str
465
+ :return: Generic conversion records for each step.
466
+ :rtype: list[list[Any]]
467
+ """
468
+ return [generic_convert_step(step) for step in split_arrow_code(arrow_code)]
469
+
470
+
471
+ # ============================================================
472
+ # SynKit ITS construction
473
+ # ============================================================
474
+
475
+
476
+ def build_its_from_rsmi(
477
+ rsmi: str,
478
+ arrow_code: str,
479
+ expand_aam: bool = True,
480
+ remove_non_arrow_maps: bool = True,
481
+ aam_fallback_to_other_side: bool = False,
482
+ require_constitution_preservation: bool = False,
483
+ fold_unmapped_explicit_hydrogens: bool = False,
484
+ ignore_stereochemistry: bool = False,
485
+ explicit_hydrogen: bool = False,
486
+ preserve_radical_state: bool = False,
487
+ ):
488
+ """Build SynKit ITS graph from reaction SMILES.
489
+
490
+ Pipeline
491
+ --------
492
+ raw SMIRKS
493
+ -> validate arrow atom maps
494
+ -> remove non-arrow atom maps
495
+ -> CanonRSMI().expand_aam(...)
496
+ -> rsmi_to_its(...)
497
+
498
+ :param rsmi: Reaction SMILES in ``reactants>>products`` format.
499
+ :type rsmi: str
500
+ :param arrow_code: Arrow code used to preserve relevant atom maps.
501
+ :type arrow_code: str
502
+ :param expand_aam: Whether to expand atom mapping before ITS construction.
503
+ :type expand_aam: bool
504
+ :param remove_non_arrow_maps: Whether to remove atom maps not used by the arrow code.
505
+ :type remove_non_arrow_maps: bool
506
+ :param aam_fallback_to_other_side: Retry partial-AAM reconstruction from
507
+ the opposite endpoint if the preferred side fails.
508
+ :type aam_fallback_to_other_side: bool
509
+ :param require_constitution_preservation: Reject AAM expansion if either
510
+ endpoint constitution changes.
511
+ :type require_constitution_preservation: bool
512
+ :param fold_unmapped_explicit_hydrogens: Fold removable unmapped explicit
513
+ H atoms before partial-AAM reconstruction while retaining mapped H.
514
+ :type fold_unmapped_explicit_hydrogens: bool
515
+ :param ignore_stereochemistry: Remove stereo from the working partial-AAM
516
+ RSMI before ITS construction while retaining the original endpoint
517
+ constitution as the guard reference.
518
+ :type ignore_stereochemistry: bool
519
+ :param explicit_hydrogen: Preserve explicit H graph nodes during expanded
520
+ RSMI serialization.
521
+ :type explicit_hydrogen: bool
522
+ :param preserve_radical_state: Preserve supplied endpoint radical counts
523
+ during partial-AAM reconstruction.
524
+ :type preserve_radical_state: bool
525
+ :return: ITS graph, expanded RSMI, cleaned RSMI, and validation diagnostics.
526
+ :rtype: tuple
527
+ :raises ImportError: If required SynKit conversion helpers are unavailable.
528
+ """
529
+ if CanonRSMI is None or rsmi_to_its is None:
530
+ raise ImportError(
531
+ "SynKit conversion helpers are unavailable in this environment."
532
+ )
533
+
534
+ diagnostics = validate_arrow_maps(
535
+ rsmi=rsmi,
536
+ arrow_code=arrow_code,
537
+ raise_on_arrow_duplicates=True,
538
+ raise_on_missing_arrow_maps=True,
539
+ )
540
+
541
+ if remove_non_arrow_maps:
542
+ rsmi_for_its = remove_non_arrow_atom_maps(rsmi, arrow_code)
543
+ else:
544
+ rsmi_for_its = rsmi
545
+
546
+ if expand_aam:
547
+ expansion = ITSExpand().expand_aam_with_its_report(
548
+ rsmi_for_its,
549
+ relabel=False,
550
+ preserve_older_map=True,
551
+ fallback_to_other_side=aam_fallback_to_other_side,
552
+ require_constitution_preservation=require_constitution_preservation,
553
+ fold_unmapped_explicit_hydrogens=fold_unmapped_explicit_hydrogens,
554
+ ignore_stereochemistry=ignore_stereochemistry,
555
+ explicit_hydrogen=explicit_hydrogen,
556
+ preserve_radical_state=preserve_radical_state,
557
+ )
558
+ expanded_rsmi = expansion.rsmi
559
+ diagnostics["aam_expansion"] = expansion.to_dict()
560
+ else:
561
+ expanded_rsmi = rsmi_for_its
562
+ diagnostics["aam_expansion"] = {
563
+ "performed": False,
564
+ "constitution_checked": False,
565
+ }
566
+
567
+ its = rsmi_to_its(expanded_rsmi)
568
+ endpoint_states = _endpoint_electron_states(expanded_rsmi)
569
+ if endpoint_states is not None:
570
+ # ``typesGH`` retains endpoint charge/H presentation but not the
571
+ # endpoint-local lone-pair/radical partition. Keep that partition as
572
+ # conversion evidence on this transient ITS graph so compact arrow
573
+ # annotations can be typed from the actual pre/post resources.
574
+ its.graph["endpoint_electron_states"] = endpoint_states
575
+ diagnostics["endpoint_electron_states_available"] = True
576
+ else:
577
+ diagnostics["endpoint_electron_states_available"] = False
578
+
579
+ return its, expanded_rsmi, rsmi_for_its, diagnostics
580
+
581
+
582
+ def _endpoint_electron_states(
583
+ mapped_reaction: str,
584
+ ) -> dict[int, tuple[dict[str, Any], dict[str, Any]]] | None:
585
+ """Return mapped pre/post nonbonding state for state-aware arrow typing."""
586
+ try:
587
+ from rdkit import Chem
588
+
589
+ from synkit.IO.mol_to_graph import MolToGraph
590
+
591
+ reactants, products = mapped_reaction.split(">>", 1)
592
+ parser = Chem.SmilesParserParams()
593
+ parser.removeHs = False
594
+ sides: list[dict[int, dict[str, Any]]] = []
595
+ for smiles in (reactants, products):
596
+ mol = Chem.MolFromSmiles(smiles, parser)
597
+ if mol is None:
598
+ return None
599
+ states: dict[int, dict[str, Any]] = {}
600
+ for atom in mol.GetAtoms():
601
+ atom_map = int(atom.GetAtomMapNum())
602
+ if atom_map <= 0:
603
+ continue
604
+ if atom_map in states:
605
+ return None
606
+ states[atom_map] = {
607
+ "element": atom.GetSymbol(),
608
+ "charge": int(atom.GetFormalCharge()),
609
+ "radical": int(atom.GetNumRadicalElectrons()),
610
+ "lone_pairs": int(MolToGraph.estimate_lone_pairs(atom)),
611
+ }
612
+ sides.append(states)
613
+ if set(sides[0]) != set(sides[1]):
614
+ return None
615
+ return {
616
+ atom_map: (sides[0][atom_map], sides[1][atom_map]) for atom_map in sides[0]
617
+ }
618
+ except (ImportError, TypeError, ValueError):
619
+ return None
620
+
621
+
622
+ # ============================================================
623
+ # ITS graph helpers
624
+ # ============================================================
625
+
626
+
627
+ def atom_map_to_nodes(its) -> dict[int, list[Any]]:
628
+ """Build atom-map-number -> list of ITS node ids.
629
+
630
+ This catches ambiguous duplicated atom maps after ITS construction.
631
+
632
+ :param its: ITS graph.
633
+ :type its: networkx.Graph
634
+ :return: Mapping from atom-map number to ITS node IDs.
635
+ :rtype: dict[int, list[Any]]
636
+ """
637
+ mapping: dict[int, list[Any]] = {}
638
+
639
+ for node, data in its.nodes(data=True):
640
+ atom_map = int(data.get("atom_map", node))
641
+ mapping.setdefault(atom_map, []).append(node)
642
+
643
+ return mapping
644
+
645
+
646
+ def get_unique_node_for_atom_map(
647
+ its,
648
+ atom_map: int,
649
+ strict: bool = True,
650
+ atom_map_nodes: Optional[dict[int, list[Any]]] = None,
651
+ ) -> Optional[Any]:
652
+ """Get the unique ITS node corresponding to an atom map.
653
+
654
+ :param its: ITS graph.
655
+ :type its: networkx.Graph
656
+ :param atom_map: Atom-map number to resolve.
657
+ :type atom_map: int
658
+ :param strict: Whether a missing atom map should raise.
659
+ :type strict: bool
660
+ :param atom_map_nodes: Optional precomputed atom-map to node index.
661
+ :type atom_map_nodes: Optional[dict[int, list[Any]]]
662
+ :return: Unique ITS node ID, or ``None`` when missing and ``strict`` is false.
663
+ :rtype: Optional[Any]
664
+ :raises ValueError: If the atom map is missing in strict mode or is ambiguous.
665
+ """
666
+ mapping = atom_map_nodes if atom_map_nodes is not None else atom_map_to_nodes(its)
667
+ nodes = mapping.get(int(atom_map), [])
668
+
669
+ if len(nodes) == 0:
670
+ if strict:
671
+ raise ValueError(f"Atom map {atom_map} is missing from ITS graph.")
672
+ return None
673
+
674
+ if len(nodes) == 1:
675
+ return nodes[0]
676
+
677
+ raise ValueError(
678
+ f"Atom map {atom_map} maps to multiple ITS nodes: {nodes}. "
679
+ "This means atom mapping is ambiguous."
680
+ )
681
+
682
+
683
+ def extract_order_from_edge_data(edge_data: Any) -> tuple[float, float]:
684
+ """Extract SynKit ITS edge order.
685
+
686
+ Expected normal edge format:
687
+ {"order": (reactant_order, product_order)}
688
+
689
+ MultiGraph-like fallback:
690
+ {0: {"order": (reactant_order, product_order)}}
691
+
692
+ :param edge_data: ITS edge attributes.
693
+ :type edge_data: Any
694
+ :return: Reactant-side and product-side bond orders.
695
+ :rtype: tuple[float, float]
696
+ """
697
+ if edge_data is None:
698
+ return 0.0, 0.0
699
+
700
+ if isinstance(edge_data, dict) and "order" in edge_data:
701
+ order = edge_data["order"]
702
+ return float(order[0]), float(order[1])
703
+
704
+ if isinstance(edge_data, dict):
705
+ for value in edge_data.values():
706
+ if isinstance(value, dict) and "order" in value:
707
+ order = value["order"]
708
+ return float(order[0]), float(order[1])
709
+
710
+ return 0.0, 0.0
711
+
712
+
713
+ def get_its_bond_order(
714
+ its,
715
+ atom_a: int,
716
+ atom_b: int,
717
+ strict: bool = True,
718
+ context: str = "",
719
+ atom_map_nodes: Optional[dict[int, list[Any]]] = None,
720
+ ) -> tuple[float, float]:
721
+ """Return ITS bond order for atom-map pair.
722
+
723
+ For example, an edge with order ``(0.0, 1.0)`` represents new bond
724
+ formation from reactants to products.
725
+
726
+ :param its: ITS graph.
727
+ :type its: networkx.Graph
728
+ :param atom_a: First atom-map number.
729
+ :type atom_a: int
730
+ :param atom_b: Second atom-map number.
731
+ :type atom_b: int
732
+ :param strict: Whether missing nodes or edges should raise.
733
+ :type strict: bool
734
+ :param context: Optional context appended to strict-mode edge errors.
735
+ :type context: str
736
+ :param atom_map_nodes: Optional precomputed atom-map to node index.
737
+ :type atom_map_nodes: Optional[dict[int, list[Any]]]
738
+ :return: Reactant-side and product-side bond orders.
739
+ :rtype: tuple[float, float]
740
+ :raises ValueError: If strict lookup fails.
741
+ """
742
+ node_a = get_unique_node_for_atom_map(
743
+ its,
744
+ atom_a,
745
+ strict=strict,
746
+ atom_map_nodes=atom_map_nodes,
747
+ )
748
+ node_b = get_unique_node_for_atom_map(
749
+ its,
750
+ atom_b,
751
+ strict=strict,
752
+ atom_map_nodes=atom_map_nodes,
753
+ )
754
+
755
+ if node_a is None or node_b is None:
756
+ return 0.0, 0.0
757
+
758
+ if not its.has_edge(node_a, node_b):
759
+ if strict:
760
+ extra = f" Context: {context}" if context else ""
761
+ raise ValueError(
762
+ f"ITS graph has no edge for atom maps {atom_a}-{atom_b}." f"{extra}"
763
+ )
764
+ return 0.0, 0.0
765
+
766
+ edge_data = its.get_edge_data(node_a, node_b)
767
+ return extract_order_from_edge_data(edge_data)
768
+
769
+
770
+ # ============================================================
771
+ # Sigma/Pi typing
772
+ # ============================================================
773
+
774
+
775
+ def is_zero(x: float, tol: float = 1e-6) -> bool:
776
+ """Return whether a value is approximately zero.
777
+
778
+ :param x: Value to compare.
779
+ :type x: float
780
+ :param tol: Absolute tolerance.
781
+ :type tol: float
782
+ :return: Whether ``x`` is within tolerance of zero.
783
+ :rtype: bool
784
+ """
785
+ return abs(x) < tol
786
+
787
+
788
+ def is_one(x: float, tol: float = 1e-6) -> bool:
789
+ """Return whether a value is approximately one.
790
+
791
+ :param x: Value to compare.
792
+ :type x: float
793
+ :param tol: Absolute tolerance.
794
+ :type tol: float
795
+ :return: Whether ``x`` is within tolerance of one.
796
+ :rtype: bool
797
+ """
798
+ return abs(x - 1.0) < tol
799
+
800
+
801
+ def bond_minus_type(reactant_order: float) -> str:
802
+ """Type consumed bond/electron-pair source.
803
+
804
+ A bond order of one maps to ``Sigma-``; larger orders, including aromatic
805
+ order 1.5, map to ``Pi-``. Unknown orders map to ``B-``.
806
+
807
+ :param reactant_order: Bond order on the reactant side.
808
+ :type reactant_order: float
809
+ :return: Typed consumed-bond label.
810
+ :rtype: str
811
+ """
812
+ if is_one(reactant_order):
813
+ return "Sigma-"
814
+
815
+ if reactant_order > 1.0:
816
+ return "Pi-"
817
+
818
+ return "B-"
819
+
820
+
821
+ def bond_plus_type(
822
+ reactant_order: float,
823
+ product_order: float,
824
+ ) -> str:
825
+ """Type formed/increased bond destination.
826
+
827
+ Rules
828
+ -----
829
+ 0 -> 1 : Sigma+
830
+ 0 -> 1.5 : Sigma+, because new connectivity starts as sigma
831
+ 0 -> 2 : Sigma+, because new connectivity starts as sigma
832
+ 1 -> 2 : Pi+
833
+ 1.5 -> 2 : Pi+
834
+ 2 -> 3 : Pi+
835
+
836
+ :param reactant_order: Bond order on the reactant side.
837
+ :type reactant_order: float
838
+ :param product_order: Bond order on the product side.
839
+ :type product_order: float
840
+ :return: Typed formed-bond label.
841
+ :rtype: str
842
+ """
843
+ if product_order <= 0:
844
+ return "B+"
845
+
846
+ # New bond formation. First new connectivity is sigma.
847
+ if is_zero(reactant_order) and product_order > 0:
848
+ return "Sigma+"
849
+
850
+ # Existing bond order increases. Added component is pi.
851
+ if product_order > reactant_order:
852
+ return "Pi+"
853
+
854
+ # Fallbacks.
855
+ if is_one(product_order):
856
+ return "Sigma+"
857
+
858
+ if product_order > 1.0:
859
+ return "Pi+"
860
+
861
+ return "B+"
862
+
863
+
864
+ def _infer_atom_locus_kind(
865
+ its,
866
+ atom_map: int,
867
+ *,
868
+ role: Literal["source", "target"],
869
+ electron_count: Literal[1, 2],
870
+ ) -> str | None:
871
+ """Infer an atom locus from its complete endpoint resource transition.
872
+
873
+ Compact EPD/EF-SMIRKS identifies atom-map endpoints but does not say
874
+ whether a one-atom fishhook touches a lone pair or a radical orbital. The
875
+ pre/post lone-pair and radical deltas disambiguate the supported cases.
876
+ """
877
+ endpoint_states = its.graph.get("endpoint_electron_states")
878
+ if not isinstance(endpoint_states, dict) or atom_map not in endpoint_states:
879
+ return None
880
+ reactant, product = endpoint_states[atom_map]
881
+ lp_delta = int(product["lone_pairs"]) - int(reactant["lone_pairs"])
882
+ radical_delta = int(product["radical"]) - int(reactant["radical"])
883
+ electron_delta = 2 * lp_delta + radical_delta
884
+ expected_delta = -electron_count if role == "source" else electron_count
885
+ if electron_delta != expected_delta:
886
+ return None
887
+
888
+ if electron_count == 2:
889
+ if radical_delta == 0 and lp_delta == (-1 if role == "source" else 1):
890
+ return "LP"
891
+ return None
892
+
893
+ if role == "source":
894
+ if radical_delta == -1 and lp_delta == 0:
895
+ return "Rad"
896
+ if radical_delta == 1 and lp_delta == -1:
897
+ return "LP"
898
+ else:
899
+ if radical_delta == 1 and lp_delta == 0:
900
+ return "Rad"
901
+ if radical_delta == -1 and lp_delta == 1:
902
+ return "LP"
903
+ return None
904
+
905
+
906
+ def _infer_atom_to_atom_step(
907
+ its,
908
+ source_map: int,
909
+ target_map: int,
910
+ *,
911
+ electron_count: Literal[1, 2],
912
+ ) -> list[Any] | None:
913
+ """Resolve a compact ``a=b`` step as a nonbonding atom transfer."""
914
+ source_kind = _infer_atom_locus_kind(
915
+ its, source_map, role="source", electron_count=electron_count
916
+ )
917
+ target_kind = _infer_atom_locus_kind(
918
+ its, target_map, role="target", electron_count=electron_count
919
+ )
920
+ if source_kind is None or target_kind is None:
921
+ return None
922
+ return [f"{source_kind}-/{target_kind}+", [source_map], [target_map]]
923
+
924
+
925
+ # ============================================================
926
+ # Typed LP/Sigma/Pi conversion
927
+ # ============================================================
928
+
929
+
930
+ def typed_convert_step(
931
+ step: str,
932
+ its,
933
+ strict_bond_lookup: bool = True,
934
+ atom_map_nodes: Optional[dict[int, list[Any]]] = None,
935
+ electron_count: Literal[1, 2] = 2,
936
+ ) -> list[Any]:
937
+ """Convert one arrow-code step into typed LP/Sigma/Pi format.
938
+
939
+ Important
940
+ ---------
941
+ This function does NOT globally force Sigma/Pi from orbital_class.
942
+ Each step is typed from local ITS bond-order changes.
943
+
944
+ :param step: One arrow-code step.
945
+ :type step: str
946
+ :param its: ITS graph used for local bond-order lookup.
947
+ :type its: networkx.Graph
948
+ :param strict_bond_lookup: Whether missing bond lookups should raise.
949
+ :type strict_bond_lookup: bool
950
+ :param atom_map_nodes: Optional precomputed atom-map to node index.
951
+ :type atom_map_nodes: Optional[dict[int, list[Any]]]
952
+ :param electron_count: Electrons carried by the annotated arrow.
953
+ :type electron_count: Literal[1, 2]
954
+ :return: Typed LP/Sigma/Pi conversion record.
955
+ :rtype: list[Any]
956
+ :raises ValueError: If the step shape is unsupported or strict lookup fails.
957
+ """
958
+ lhs, rhs = parse_arrow_step(step)
959
+
960
+ # --------------------------------------------------------
961
+ # Case 1: a=b
962
+ # LP(a) forms bond a-b
963
+ # --------------------------------------------------------
964
+ if len(lhs) == 1 and len(rhs) == 1:
965
+ a = lhs[0]
966
+ b = rhs[0]
967
+
968
+ node_a = get_unique_node_for_atom_map(
969
+ its,
970
+ a,
971
+ strict=strict_bond_lookup,
972
+ atom_map_nodes=atom_map_nodes,
973
+ )
974
+ node_b = get_unique_node_for_atom_map(
975
+ its,
976
+ b,
977
+ strict=strict_bond_lookup,
978
+ atom_map_nodes=atom_map_nodes,
979
+ )
980
+ has_bond = (
981
+ node_a is not None and node_b is not None and its.has_edge(node_a, node_b)
982
+ )
983
+
984
+ r_order, p_order = get_its_bond_order(
985
+ its,
986
+ a,
987
+ b,
988
+ strict=False,
989
+ context=step,
990
+ atom_map_nodes=atom_map_nodes,
991
+ )
992
+ if is_zero(p_order - r_order):
993
+ atom_transfer = _infer_atom_to_atom_step(
994
+ its, a, b, electron_count=electron_count
995
+ )
996
+ if atom_transfer is not None:
997
+ return atom_transfer
998
+ if strict_bond_lookup and not has_bond:
999
+ raise ValueError(
1000
+ f"ITS graph has no edge for atom maps {a}-{b}. "
1001
+ "Endpoint states do not support an atom-to-atom transfer."
1002
+ )
1003
+ if strict_bond_lookup and "endpoint_electron_states" in its.graph:
1004
+ raise ValueError(
1005
+ "ATOM_TRANSFER_STATE_MISMATCH: one-atom endpoints neither change "
1006
+ f"bond {a}-{b} nor match a supported nonbonding resource "
1007
+ "transfer."
1008
+ )
1009
+ elif p_order < r_order and strict_bond_lookup:
1010
+ raise ValueError(
1011
+ f"ARROW_TARGET_BOND_NOT_FORMED: bond {a}-{b} decreases from "
1012
+ f"{r_order} to {p_order}."
1013
+ )
1014
+ plus = bond_plus_type(r_order, p_order)
1015
+ source_kind = (
1016
+ _infer_atom_locus_kind(its, a, role="source", electron_count=electron_count)
1017
+ or "LP"
1018
+ )
1019
+
1020
+ return [f"{source_kind}-/{plus}", [a], [a, b]]
1021
+
1022
+ # --------------------------------------------------------
1023
+ # Case 2: a=b,c
1024
+ # LP(a) forms/increases bond b-c
1025
+ #
1026
+ # Example:
1027
+ # 12=11,12
1028
+ # LP on 12 forms/increases 11-12 bond
1029
+ # --------------------------------------------------------
1030
+ if len(lhs) == 1 and len(rhs) == 2:
1031
+ a = lhs[0]
1032
+ b, c = rhs
1033
+
1034
+ r_order, p_order = get_its_bond_order(
1035
+ its,
1036
+ b,
1037
+ c,
1038
+ strict=strict_bond_lookup,
1039
+ context=step,
1040
+ atom_map_nodes=atom_map_nodes,
1041
+ )
1042
+ plus = bond_plus_type(r_order, p_order)
1043
+ source_kind = (
1044
+ _infer_atom_locus_kind(its, a, role="source", electron_count=electron_count)
1045
+ or "LP"
1046
+ )
1047
+
1048
+ return [f"{source_kind}-/{plus}", [a], [b, c]]
1049
+
1050
+ # --------------------------------------------------------
1051
+ # Case 3: a,b=c
1052
+ # bond a-b breaks; electrons become LP on c
1053
+ # --------------------------------------------------------
1054
+ if len(lhs) == 2 and len(rhs) == 1:
1055
+ a, b = lhs
1056
+ c = rhs[0]
1057
+
1058
+ r_order, _p_order = get_its_bond_order(
1059
+ its,
1060
+ a,
1061
+ b,
1062
+ strict=strict_bond_lookup,
1063
+ context=step,
1064
+ atom_map_nodes=atom_map_nodes,
1065
+ )
1066
+ minus = bond_minus_type(r_order)
1067
+ target_kind = (
1068
+ _infer_atom_locus_kind(its, c, role="target", electron_count=electron_count)
1069
+ or "LP"
1070
+ )
1071
+
1072
+ return [f"{minus}/{target_kind}+", [a, b], [c]]
1073
+
1074
+ # --------------------------------------------------------
1075
+ # Case 4: a,b=c,d
1076
+ # bond a-b becomes bond c-d
1077
+ # --------------------------------------------------------
1078
+ if len(lhs) == 2 and len(rhs) == 2:
1079
+ a, b = lhs
1080
+ c, d = rhs
1081
+
1082
+ src_r_order, _src_p_order = get_its_bond_order(
1083
+ its,
1084
+ a,
1085
+ b,
1086
+ strict=strict_bond_lookup,
1087
+ context=f"source of {step}",
1088
+ atom_map_nodes=atom_map_nodes,
1089
+ )
1090
+
1091
+ dst_r_order, dst_p_order = get_its_bond_order(
1092
+ its,
1093
+ c,
1094
+ d,
1095
+ strict=strict_bond_lookup,
1096
+ context=f"destination of {step}",
1097
+ atom_map_nodes=atom_map_nodes,
1098
+ )
1099
+
1100
+ minus = bond_minus_type(src_r_order)
1101
+ plus = bond_plus_type(dst_r_order, dst_p_order)
1102
+
1103
+ return [f"{minus}/{plus}", [a, b], [c, d]]
1104
+
1105
+ raise ValueError(f"Unsupported arrow step: {step}")
1106
+
1107
+
1108
+ def typed_convert_arrow_code(
1109
+ arrow_code: str,
1110
+ its,
1111
+ strict_bond_lookup: bool = True,
1112
+ electron_count: Literal[1, 2] = 2,
1113
+ ) -> list[list[Any]]:
1114
+ """Convert every step in an arrow code to typed LP/Sigma/Pi form.
1115
+
1116
+ :param arrow_code: Semicolon-separated arrow code.
1117
+ :type arrow_code: str
1118
+ :param its: ITS graph used for local bond-order lookup.
1119
+ :type its: networkx.Graph
1120
+ :param strict_bond_lookup: Whether missing bond lookups should raise.
1121
+ :type strict_bond_lookup: bool
1122
+ :param electron_count: Electrons carried by every supplied arrow.
1123
+ :type electron_count: Literal[1, 2]
1124
+ :return: Typed conversion records for each step.
1125
+ :rtype: list[list[Any]]
1126
+ """
1127
+ atom_map_nodes = atom_map_to_nodes(its)
1128
+
1129
+ return [
1130
+ typed_convert_step(
1131
+ step=step,
1132
+ its=its,
1133
+ strict_bond_lookup=strict_bond_lookup,
1134
+ atom_map_nodes=atom_map_nodes,
1135
+ electron_count=electron_count,
1136
+ )
1137
+ for step in split_arrow_code(arrow_code)
1138
+ ]
1139
+
1140
+
1141
+ # ============================================================
1142
+ # Main public conversion functions
1143
+ # ============================================================
1144
+
1145
+
1146
+ def convert_arrow_code(
1147
+ arrow_code: str,
1148
+ its=None,
1149
+ strict_bond_lookup: bool = True,
1150
+ ) -> dict[str, Any]:
1151
+ """Convert arrow code into generic and typed formats.
1152
+
1153
+ If ``its`` is ``None``, ``typed_converted`` is ``None``.
1154
+
1155
+ :param arrow_code: Semicolon-separated arrow code.
1156
+ :type arrow_code: str
1157
+ :param its: Optional ITS graph for typed conversion.
1158
+ :type its: Optional[networkx.Graph]
1159
+ :param strict_bond_lookup: Whether missing typed bond lookups should raise.
1160
+ :type strict_bond_lookup: bool
1161
+ :return: Arrow code with generic and optional typed conversions.
1162
+ :rtype: dict[str, Any]
1163
+ """
1164
+ converted = generic_convert_arrow_code(arrow_code)
1165
+
1166
+ if its is None:
1167
+ typed_converted = None
1168
+ else:
1169
+ typed_converted = typed_convert_arrow_code(
1170
+ arrow_code=arrow_code,
1171
+ its=its,
1172
+ strict_bond_lookup=strict_bond_lookup,
1173
+ )
1174
+
1175
+ return {
1176
+ "arrow_code": arrow_code,
1177
+ "converted": converted,
1178
+ "typed_converted": typed_converted,
1179
+ }
1180
+
1181
+
1182
+ def convert_reaction_arrow(
1183
+ reaction_smiles: str,
1184
+ arrow_code: str,
1185
+ orbital_class: Optional[str] = None,
1186
+ expand_aam: bool = True,
1187
+ remove_non_arrow_maps: bool = True,
1188
+ strict_bond_lookup: bool = True,
1189
+ ) -> dict[str, Any]:
1190
+ """Complete wrapper.
1191
+
1192
+ reaction SMILES + arrow code
1193
+ -> clean non-arrow maps
1194
+ -> expand AAM with SynKit
1195
+ -> ITS graph
1196
+ -> generic converted
1197
+ -> typed converted
1198
+
1199
+ orbital_class is stored as metadata only.
1200
+ It is not used to force Sigma/Pi typing.
1201
+
1202
+ :param reaction_smiles: Reaction SMILES in ``reactants>>products`` format.
1203
+ :type reaction_smiles: str
1204
+ :param arrow_code: Semicolon-separated arrow code.
1205
+ :type arrow_code: str
1206
+ :param orbital_class: Optional source-dataset orbital classification metadata.
1207
+ :type orbital_class: Optional[str]
1208
+ :param expand_aam: Whether to expand atom mapping before ITS construction.
1209
+ :type expand_aam: bool
1210
+ :param remove_non_arrow_maps: Whether to remove atom maps not used by the arrow code.
1211
+ :type remove_non_arrow_maps: bool
1212
+ :param strict_bond_lookup: Whether missing typed bond lookups should raise.
1213
+ :type strict_bond_lookup: bool
1214
+ :return: Conversion result and ITS preparation metadata.
1215
+ :rtype: dict[str, Any]
1216
+ """
1217
+ its, expanded_rsmi, rsmi_for_its, diagnostics = build_its_from_rsmi(
1218
+ rsmi=reaction_smiles,
1219
+ arrow_code=arrow_code,
1220
+ expand_aam=expand_aam,
1221
+ remove_non_arrow_maps=remove_non_arrow_maps,
1222
+ )
1223
+
1224
+ result = convert_arrow_code(
1225
+ arrow_code=arrow_code,
1226
+ its=its,
1227
+ strict_bond_lookup=strict_bond_lookup,
1228
+ )
1229
+
1230
+ result["reaction_smiles"] = reaction_smiles
1231
+ result["rsmi_for_its"] = rsmi_for_its
1232
+ result["expanded_rsmi"] = expanded_rsmi
1233
+ result["orbital_class"] = orbital_class
1234
+ result["diagnostics"] = diagnostics
1235
+
1236
+ return result
1237
+
1238
+
1239
+ def ef_smirks_to_epd(
1240
+ ef_smirks: str,
1241
+ orbital_class: Optional[str] = None,
1242
+ strict_bond_lookup: bool = True,
1243
+ ) -> dict[str, Any]:
1244
+ """Complete AAM and convert an EF-SMIRKS string into EPD data.
1245
+
1246
+ The supplied atom maps used by the electron-flow code are preserved. All
1247
+ remaining atoms receive maps through :class:`ITSExpand`. The result
1248
+ contains both generic ``epd`` and Lewis-wavefunction-aware ``epd_lw``
1249
+ records; the latter uses local ITS bond orders to distinguish sigma and pi
1250
+ edits.
1251
+
1252
+ :param ef_smirks: RSMI followed by a hyphen-form electron-flow code.
1253
+ :type ef_smirks: str
1254
+ :param orbital_class: Optional source-dataset metadata.
1255
+ :type orbital_class: Optional[str]
1256
+ :param strict_bond_lookup: Whether missing ITS bond lookups should raise.
1257
+ :type strict_bond_lookup: bool
1258
+ :return: Completed AAM, generic EPD, typed ``epd_lw``, and diagnostics.
1259
+ :rtype: dict[str, Any]
1260
+ """
1261
+ reaction_smiles, ef_arrow_code = split_ef_smirks(ef_smirks)
1262
+ arrow_code = ef_arrow_code_to_arrow_code(ef_arrow_code)
1263
+ result = convert_reaction_arrow(
1264
+ reaction_smiles=reaction_smiles,
1265
+ arrow_code=arrow_code,
1266
+ orbital_class=orbital_class,
1267
+ expand_aam=True,
1268
+ remove_non_arrow_maps=True,
1269
+ strict_bond_lookup=strict_bond_lookup,
1270
+ )
1271
+ result.update(
1272
+ {
1273
+ "ef_smirks": ef_smirks,
1274
+ "ef_arrow_code": ef_arrow_code,
1275
+ "complete_aam": result["expanded_rsmi"],
1276
+ "epd": result["converted"],
1277
+ "epd_lw": result["typed_converted"],
1278
+ }
1279
+ )
1280
+ return result
1281
+
1282
+
1283
+ def epd_to_ef_smirks(complete_aam: str, epd: list[list[Any]]) -> str:
1284
+ """Reconstruct an EF-SMIRKS string from complete AAM and EPD records.
1285
+
1286
+ ``epd`` may contain generic action names (``"B-/B+"``) or typed Lewis
1287
+ names (``"Sigma-/Pi+"``). Only action direction and source/target map
1288
+ lists are needed to reconstruct the electron-flow code.
1289
+
1290
+ :param complete_aam: Fully atom-mapped reaction SMILES.
1291
+ :type complete_aam: str
1292
+ :param epd: Generic or typed EPD records of ``[action, source, target]``.
1293
+ :type epd: list[list[Any]]
1294
+ :return: EF-SMIRKS text with a hyphen-form electron-flow code.
1295
+ :rtype: str
1296
+ :raises ValueError: If AAM or EPD records are malformed or unsupported.
1297
+ """
1298
+ if complete_aam.count(">>") != 1:
1299
+ raise ValueError("complete_aam must contain exactly one '>>'.")
1300
+ if not epd:
1301
+ raise ValueError("epd must contain at least one electron-pushing step.")
1302
+
1303
+ def format_maps(atom_maps: list[int]) -> str:
1304
+ """Format an atom-map sequence for one EF-SMIRKS endpoint."""
1305
+ return ",".join(str(atom_map) for atom_map in atom_maps)
1306
+
1307
+ steps = []
1308
+ for record in epd:
1309
+ if len(record) < 3:
1310
+ raise ValueError(f"Invalid EPD record: {record!r}")
1311
+ action, source, target = record[:3]
1312
+ action_parts = str(action).split("/", 1)
1313
+ if len(action_parts) != 2:
1314
+ raise ValueError(f"Invalid EPD action: {action!r}")
1315
+ consumed, produced = action_parts
1316
+ source = [int(atom_map) for atom_map in source]
1317
+ target = [int(atom_map) for atom_map in target]
1318
+
1319
+ if consumed == "LP-" and len(source) == 1 and len(target) in {1, 2}:
1320
+ lhs, rhs = source, target
1321
+ elif produced == "LP+" and len(source) == 2 and len(target) == 1:
1322
+ lhs, rhs = source, target
1323
+ elif (
1324
+ consumed.endswith("-")
1325
+ and produced.endswith("+")
1326
+ and len(source) == 2
1327
+ and len(target) == 2
1328
+ ):
1329
+ lhs, rhs = source, target
1330
+ else:
1331
+ raise ValueError(f"Unsupported EPD record for EF-SMIRKS: {record!r}")
1332
+
1333
+ steps.append(f"{format_maps(lhs)}-{format_maps(rhs)}")
1334
+
1335
+ return f"{complete_aam} {';'.join(steps)}"
1336
+
1337
+
1338
+ def convert_record(
1339
+ record: dict[str, Any],
1340
+ reaction_key: str = "SMIRKS",
1341
+ arrow_key: str = "arrow_code",
1342
+ orbital_key: str = "orbital pair classification",
1343
+ expand_aam: bool = True,
1344
+ remove_non_arrow_maps: bool = True,
1345
+ strict_bond_lookup: bool = True,
1346
+ ) -> dict[str, Any]:
1347
+ """Convert one dictionary record.
1348
+
1349
+ The record is expected to contain reaction, arrow-code, and optional orbital
1350
+ classification fields under the configured keys.
1351
+
1352
+ :param record: Source record to convert.
1353
+ :type record: dict[str, Any]
1354
+ :param reaction_key: Key containing reaction SMILES.
1355
+ :type reaction_key: str
1356
+ :param arrow_key: Key containing arrow code.
1357
+ :type arrow_key: str
1358
+ :param orbital_key: Key containing optional orbital classification metadata.
1359
+ :type orbital_key: str
1360
+ :param expand_aam: Whether to expand atom mapping before ITS construction.
1361
+ :type expand_aam: bool
1362
+ :param remove_non_arrow_maps: Whether to remove atom maps not used by the arrow code.
1363
+ :type remove_non_arrow_maps: bool
1364
+ :param strict_bond_lookup: Whether missing typed bond lookups should raise.
1365
+ :type strict_bond_lookup: bool
1366
+ :return: Converted record with original metadata preserved.
1367
+ :rtype: dict[str, Any]
1368
+ """
1369
+ reaction_smiles = record[reaction_key]
1370
+ arrow_code = record[arrow_key]
1371
+ orbital_class = record.get(orbital_key)
1372
+
1373
+ result = convert_reaction_arrow(
1374
+ reaction_smiles=reaction_smiles,
1375
+ arrow_code=arrow_code,
1376
+ orbital_class=orbital_class,
1377
+ expand_aam=expand_aam,
1378
+ remove_non_arrow_maps=remove_non_arrow_maps,
1379
+ strict_bond_lookup=strict_bond_lookup,
1380
+ )
1381
+
1382
+ # Preserve original metadata.
1383
+ for key, value in record.items():
1384
+ if key not in result:
1385
+ result[key] = value
1386
+
1387
+ return result
1388
+
1389
+
1390
+ def convert_records(
1391
+ records: list[dict[str, Any]],
1392
+ reaction_key: str = "SMIRKS",
1393
+ arrow_key: str = "arrow_code",
1394
+ orbital_key: str = "orbital pair classification",
1395
+ expand_aam: bool = True,
1396
+ remove_non_arrow_maps: bool = True,
1397
+ strict_bond_lookup: bool = True,
1398
+ keep_errors: bool = False,
1399
+ ) -> list[dict[str, Any]]:
1400
+ """Batch conversion.
1401
+
1402
+ keep_errors=False:
1403
+ raise immediately on first error.
1404
+
1405
+ keep_errors=True:
1406
+ collect errors into result dictionaries.
1407
+
1408
+ :param records: Source records to convert.
1409
+ :type records: list[dict[str, Any]]
1410
+ :param reaction_key: Key containing reaction SMILES.
1411
+ :type reaction_key: str
1412
+ :param arrow_key: Key containing arrow code.
1413
+ :type arrow_key: str
1414
+ :param orbital_key: Key containing optional orbital classification metadata.
1415
+ :type orbital_key: str
1416
+ :param expand_aam: Whether to expand atom mapping before ITS construction.
1417
+ :type expand_aam: bool
1418
+ :param remove_non_arrow_maps: Whether to remove atom maps not used by the arrow code.
1419
+ :type remove_non_arrow_maps: bool
1420
+ :param strict_bond_lookup: Whether missing typed bond lookups should raise.
1421
+ :type strict_bond_lookup: bool
1422
+ :param keep_errors: Whether to collect conversion failures instead of raising.
1423
+ :type keep_errors: bool
1424
+ :return: Converted records, including failures when ``keep_errors`` is true.
1425
+ :rtype: list[dict[str, Any]]
1426
+ """
1427
+ results = []
1428
+
1429
+ for idx, record in enumerate(records, start=1):
1430
+ try:
1431
+ result = convert_record(
1432
+ record=record,
1433
+ reaction_key=reaction_key,
1434
+ arrow_key=arrow_key,
1435
+ orbital_key=orbital_key,
1436
+ expand_aam=expand_aam,
1437
+ remove_non_arrow_maps=remove_non_arrow_maps,
1438
+ strict_bond_lookup=strict_bond_lookup,
1439
+ )
1440
+ result["row_index"] = idx
1441
+ results.append(result)
1442
+
1443
+ except Exception as e:
1444
+ if not keep_errors:
1445
+ print("=" * 100)
1446
+ print(f"FAILED ROW {idx}")
1447
+ print("=" * 100)
1448
+ print("orbital_class:", record.get(orbital_key))
1449
+ print("arrow_code:", record.get(arrow_key))
1450
+ print("error:", repr(e))
1451
+ print("=" * 100)
1452
+ raise
1453
+
1454
+ failed = dict(record)
1455
+ failed["row_index"] = idx
1456
+ failed["error"] = repr(e)
1457
+ results.append(failed)
1458
+
1459
+ return results
1460
+
1461
+
1462
+ from .conversion_debug import ( # noqa: E402,F401
1463
+ check_typed_conversion_quality,
1464
+ debug_arrow_bond_orders,
1465
+ debug_record,
1466
+ )