diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index bae615bb..b42aa98d 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -6,14 +6,42 @@ on: workflow_dispatch: jobs: + libinchi: + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [windows-latest, macos-latest, ubuntu-24.04, ubuntu-24.04-arm] + steps: + - uses: actions/checkout@v4 + with: + submodules: true + - name: Set up CMake + uses: lukka/get-cmake@latest + - name: Build libinchi + run: python build_inchi.py + - name: Upload libinchi artifact + uses: actions/upload-artifact@v4 + with: + name: libinchi-${{ matrix.os }} + path: chython/files/libinchi/libinchi.* + if-no-files-found: error + binary: + needs: libinchi runs-on: ${{ matrix.os }} strategy: matrix: - os: [windows-latest, macos-latest, ubuntu-24.04] + os: [windows-latest, macos-latest, ubuntu-24.04, ubuntu-24.04-arm] python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] steps: - uses: actions/checkout@v4 + with: + submodules: true + - name: Download libinchi artifact + uses: actions/download-artifact@v4 + with: + name: libinchi-${{ matrix.os }} + path: chython/files/libinchi/ - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: diff --git a/.gitignore b/.gitignore index 7dea1ed7..d3968a7b 100644 --- a/.gitignore +++ b/.gitignore @@ -3,8 +3,12 @@ __pycache__ *.so *.dll *.dynlib +*.dylib *.c +# vendored IUPAC InChI source (compiled into libinchi at build time) +!INCHI/** + .idea venv diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..1e7f7316 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "INCHI"] + path = INCHI + url = https://github.com/IUPAC-InChI/InChI diff --git a/INCHI b/INCHI new file mode 160000 index 00000000..11a87982 --- /dev/null +++ b/INCHI @@ -0,0 +1 @@ +Subproject commit 11a87982bb518f57ac013f0b258c283655e1ea1d diff --git a/INCHI/LICENCE b/INCHI/LICENCE deleted file mode 100644 index a586d2f1..00000000 --- a/INCHI/LICENCE +++ /dev/null @@ -1,259 +0,0 @@ -IUPAC/InChI-Trust Licence for the -International Chemical Identifier (InChI) Software -("IUPAC/InChI-Trust InChI Licence No. 1.0") - -Copyright (c) IUPAC and InChI Trust -This library is free software; you can redistribute it and/or modify it under the terms of the -IUPAC/InChI Trust InChI Licence No. 1.0), or (at your option) any later version. - -Terms and Conditions for Copying, Distribution and Modification of the InChI Software - - 0. This Licence Agreement applies to any software library or other program which contains a notice -placed by the copyright holder or other authorized party saying it may be distributed under the terms of -this Licence. The Licensee is addressed as "you". - - 'IUPAC' means the International Union of Pure and Applied Chemistry. - - A "library" means a collection of software functions and/or data prepared so as to be conveniently -linked with application programs (which use some of those functions and data) to form executables. - - The "Library", below, refers to any such software library or work which has been distributed under -these terms. A "work based on the Library" means either the Library or any derivative work under -copyright law: that is to say, a work containing the Library or a portion of it, either verbatim or with -modifications and/or translated straightforwardly into another language. (Hereinafter, translation is -included without limitation in the term "modification".) - - "Source code" for a work means the preferred form of the work for making modifications to it. For a -library, complete source code means all the source code for all modules it contains, plus any associated -interface definition files, plus the scripts used to control compilation and installation of the library. - - Activities other than copying, distribution and modification are not covered by this Licence; they are -outside its scope. The act of running a program using the Library is not restricted, and output from -such a program is covered only if its contents constitute a work based on the Library (independent of -the use of the Library in a tool for writing it). Whether that is true depends on what the Library does -and what the program that uses the Library does. - -1. You may copy and distribute verbatim copies of the Library's complete source code as you receive it, -in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate -copyright notice and disclaimer of warranty; keep intact all the notices that refer to this Licence and to -the absence of any warranty; and distribute a copy of this Licence along with the Library. - -You may charge a fee for the physical act of transferring a copy, and you may at your option offer -warranty protection in exchange for a fee. - -2. You may modify your copy or copies of the Library or any portion of it, thus forming a work based -on the Library, and copy and distribute such modifications or work under the terms of Section 1 above, -provided that you also meet all of these conditions: - -a) The modified work must itself be a software library. - -b) You must cause the files modified to carry prominent notices stating that you changed the files and -the date of any change. - -c) You must cause the whole of the work to be licensed at no charge to all third parties under the terms -of this Licence. This requirement does not extend to any "work that uses the Library" that might also -be compiled or linked against the "work based on the Library." - -d) If a facility in the modified Library refers to a function or a table of data to be supplied by an -application program that uses the facility, other than as an argument passed when the facility is invoked, -then you must make a good faith effort to ensure that, in the event an application does not supply such -function or table, the facility still operates, and performs whatever part of its purpose remains -meaningful. - -(For example, a function in a library to compute square roots has a purpose that is entirely well-defined -independent of the application. Therefore, Subsection 2d requires that any application-supplied -function or table used by this function must be optional: if the application does not supply it, the square -root function must still compute square roots.) - -These requirements apply to the modified work as a whole. If identifiable sections of that work are not -derived from the Library, and can be reasonably considered independent and separate works in -themselves, then this Licence, and its terms, do not apply to those sections when you distribute them as -separate works. But when you distribute the same sections as part of a whole which is a work based on -the Library, the distribution of the whole must be on the terms of this Licence, whose permissions for -other Licensees extend to the entire whole, and thus to each and every part regardless of who wrote it. - -Thus, it is not the intent of this section to claim rights or contest your rights to work written entirely by -you; rather, the intent is to exercise the right to control the distribution of derivative or collective works -based on the Library. - -In addition, mere aggregation of another work not based on the Library with the Library (or with a -work based on the Library) on a volume of a storage or distribution medium does not bring the other -work under the scope of this Licence. - -3. You may opt to apply the terms of the ordinary GNU General Public Licence instead of this Licence -to a given copy of the Library. To do this, you must alter all the notices that refer to this Licence, so -that they refer to the ordinary GNU General Public Licence, version 2, instead of to this Licence. (If a -newer version than version 2 of the ordinary GNU General Public Licence has appeared, then you can -specify that version instead if you wish.) Do not make any other change in these notices. - -Once this change is made in a given copy, it is irreversible for that copy, so the ordinary GNU General -Public Licence applies to all subsequent copies and derivative works made from that copy. - -This option is useful when you wish to copy part of the code of the Library into a program that is not a -library. - -4. You may copy and distribute the Library (or a portion or derivative of it, under Section 2) in object -code or executable form under the terms of Sections 1 and 2 above provided that you accompany it -with the complete corresponding machine-readable source code, which must be distributed under the -terms of Sections 1 and 2 above on a medium customarily used for software interchange. - -If distribution of object code is made by offering access to copy from a designated place, then offering -equivalent access to copy the source code from the same place satisfies the requirement to distribute -the source code, even though third parties are not compelled to copy the source along with the object -code. - -5. A program that contains no derivative of any portion of the Library, but is designed to work with the -Library by being compiled or linked with it, is called a "work that uses the Library". Such a work, in -isolation, is not a derivative work of the Library, and therefore falls outside the scope of this Licence. - -6. You may combine or link a "work that uses the Library" with the Library to produce a work -containing portions of the Library, and distribute that work under terms of your choice. - -You must give prominent notice with each copy of the work that the Library is used in it and that the -Library and its use are covered by this Licence. You must supply a copy of this Licence. If the work -during execution displays copyright notices, you must include the copyright notice for the Library -among them, as well as a reference directing the user to the copy of this Licence. Also, you must do -one of these things: - -a) Accompany the work with the complete corresponding machine-readable source code for the -Library including whatever changes to the Library were used in the work (which must be distributed -under Sections 1 and 2 above). - -b) Use a suitable shared library mechanism for linking with the Library. A suitable mechanism is one -that (1) uses at run time a copy of the library already present on the user's computer system, rather than -copying library functions into the executable, and (2) will operate properly with a modified version of -the library, if the user installs one, as long as the modified version is interface-compatible with the -version that the work was made with. - -c) Accompany the work with a written offer, valid for at least three years, to give the same user the -materials specified in Subsection 6a, above, for a charge no more than the cost of performing this -distribution. - -d) If distribution of the work is made by offering access to copy from a designated place, offer -equivalent access to copy the above specified materials from the same place. - -e) Verify that the user has already received a copy of these materials or that you have already sent this -user a copy. - -7. You may place library facilities that are a work based on the Library side-by-side in a single library -together with other library facilities not covered by this Licence, and distribute such a combined library, -provided that the separate distribution of the work based on the Library and of the other library -facilities is otherwise permitted, and provided that you do these two things: - -a) Accompany the combined library with a copy of the same work based on the Library, uncombined -with any other library facilities. This must be distributed under the terms of the Sections above. - -b) Give prominent notice with the combined library of the fact that part of it is a work based on the -Library, and explaining where to find the accompanying uncombined form of the same work. - -8. You may not copy, modify, sublicense, link with, or distribute the Library except as expressly -provided under this Licence. Any attempt otherwise to copy, modify, sublicense, link with, or -distribute the Library is void, and will automatically terminate your rights under this Licence. -However, parties who have received copies, or rights, from you under this Licence will not have their -Licences terminated so long as such parties remain in full compliance. - -9. You are not required to accept this Licence, since you have not signed it. However, nothing else -grants you permission to modify or distribute the Library or its derivative works. These actions are -prohibited by law if you do not accept this Licence. Therefore, by modifying or distributing the -Library (or any work based on the Library), you indicate your acceptance of this Licence to do so, and -all its terms and conditions for copying, distributing or modifying the Library or works based on it. - -10. Each time you redistribute the Library (or any work based on the Library), the recipient -automatically receives a Licence from the original licensor to copy, distribute, link with or modify the -Library subject to these terms and conditions. You may not impose any further restrictions on the -recipients' exercise of the rights granted herein. You are not responsible for enforcing compliance by -third parties with this Licence. - -11. If, as a consequence of a court judgment or allegation of patent infringement or for any other reason -(not limited to patent issues), conditions are imposed on you (whether by court order, agreement or -otherwise) that contradict the conditions of this Licence, they do not excuse you from the conditions of -this Licence. If you cannot distribute so as to satisfy simultaneously your obligations under this -Licence and any other pertinent obligations, then as a consequence you may not distribute the Library -at all. For example, if a patent Licence would not permit royalty-free redistribution of the Library by -all those who receive copies directly or indirectly through you, then the only way you could satisfy -both it and this Licence would be to refrain entirely from distribution of the Library. - -If any portion of this section is held invalid or unenforceable under any particular circumstance, the -balance of the section is intended to apply, and the section as a whole is intended to apply in other -circumstances. - -It is not the purpose of this section to induce you to infringe any patents or other property right claims -or to contest validity of any such claims; this section has the sole purpose of protecting the integrity of -the free software distribution system which is implemented by public Licence practices. Many people -have made generous contributions to the wide range of software distributed through that system in -reliance on consistent application of that system; it is up to the author/donor to decide if he or she is -willing to distribute software through any other system and a licensee cannot impose that choice. - -This section is intended to make thoroughly clear what is believed to be a consequence of the rest of -this Licence. - -12. If the distribution and/or use of the Library is restricted in certain countries either by patents or by -copyrighted interfaces, the original copyright holder who places the Library under this Licence may -add an explicit geographical distribution limitation excluding those countries, so that distribution is -permitted only in or among countries not thus excluded. In such case, this Licence incorporates the -limitation as if written in the body of this Licence. - -13. IUPAC and the InChI Trust may publish revised and/or new versions of the IUPAC/InChI Trust -Licence for the International Chemical Identifier (InChI) Software from time to time. Such new -versions will be similar in spirit to the present version, but may differ in detail to address new problems -or concerns. - -Each version is given a distinguishing version number. If the Library specifies a version number of -this Licence which applied to it and "any later version", you have the option of following the terms and -conditions either of that version or of any later version published by IUPAC and the InChI Trust. - -14. If you wish to incorporate parts of the Library into other free programs whose distribution -conditions are incompatible with these, write to the author to ask for permission. - -15. If you modify the Library in any way whatsoever, the output from any such modified Library may -not be referred to as 'InChI' or any similar name. Any attempt to refer to such output as 'InChI' will -automatically terminate your rights under this Licence. - -NO WARRANTY - -16. Because the Library is licensed free of charge, there is no warranty for the Library, to the -extent permitted by applicable law. Except when otherwise stated in writing the copyright -holders and other parties provide the Library "as is" without warranty of any kind, either -expressed or implied, including, but not limited to, the implied warranties of merchantability and -fitness for a particular purpose. The entire risk as to the quality and performance of the Library -is with you. Should the Library prove defective, you assume the cost of all necessary servicing, -repair or correction. - -17. In no event unless required by applicable law or agreed to in writing will any copyright -holder, or any party who may modify and/or redistribute the Library as permitted above, be -liable to you for damages, including any general, special, incidental or consequential damages -arising out of the use or inability to use the Library (including but not limited to loss of data or -data being rendered inaccurate or losses sustained by you or third parties or a failure of the -Library to operate with any other software), even if such holder or other party has been advised -of the possibility of such damages. - -END OF TERMS AND CONDITIONS - -Instructions for Use - -You must attach the following notices to the library at the beginning of each source file - as a -minimum each file needs to contain the "copyright" line and a link to the full notice. - -[INSERT YOUR LIBRARY'S NAME AND ITS PURPOSE] -Copyright (c) [YEAR][COPYRIGHT OWNER] -This library is free software; you can redistribute it and/or modify it under the terms of the -IUPAC/InChI Trust InChI Licence 1.0, or any later version. - -Please note that this library is distributed WITHOUT ANY WARRANTIES whatsoever, -whether expressed or implied. See the IUPAC/InChI Trust Licence for the International -Chemical Identifier (InChI) Software ("IUPAC/InChI-Trust InChI Licence No. 1.0") for -more details. - -You should have received a copy of the IUPAC/InChI Trust InChI Licence No. 1.0 with this -library; if not, please e-mail: - -info@inchi-trust.org - -In the event that you require anything else or have any questions, please write to: - -[INSERT COPYRIGHT OWNERS DETAILS] -[INSERT ADDRESS] - -or contact us via email at: [INSERT EMAIL ADDRESS] - -(c) 2020 IUPAC and InChI Trust diff --git a/INCHI/libinchi.dll b/INCHI/libinchi.dll deleted file mode 100644 index 3f47dc90..00000000 Binary files a/INCHI/libinchi.dll and /dev/null differ diff --git a/INCHI/libinchi.dynlib b/INCHI/libinchi.dynlib deleted file mode 100755 index fd5afca3..00000000 Binary files a/INCHI/libinchi.dynlib and /dev/null differ diff --git a/INCHI/libinchi.so b/INCHI/libinchi.so deleted file mode 100644 index e58af940..00000000 Binary files a/INCHI/libinchi.so and /dev/null differ diff --git a/INCHI/libinchi_arm64.dylib b/INCHI/libinchi_arm64.dylib deleted file mode 100755 index 4a6edc9a..00000000 Binary files a/INCHI/libinchi_arm64.dylib and /dev/null differ diff --git a/INCHI/readme.txt b/INCHI/readme.txt deleted file mode 100644 index 49c629f4..00000000 --- a/INCHI/readme.txt +++ /dev/null @@ -1,47 +0,0 @@ -/* - * International Chemical Identifier (InChI) - * Version 1 - * Software version 1.06 - * December 15, 2020 - * - * The InChI library and programs are free software developed under the - * auspices of the International Union of Pure and Applied Chemistry (IUPAC). - * Originally developed at NIST. - * Modifications and additions by IUPAC and the InChI Trust. - * Some portions of code were developed/changed by external contributors - * (either contractor or volunteer) which are listed in the file - * 'External-contributors' included in this distribution. - * - * IUPAC/InChI-Trust Licence No.1.0 for the - * International Chemical Identifier (InChI) - * Copyright (C) IUPAC and InChI Trust - * - * This library is free software; you can redistribute it and/or modify it - * under the terms of the IUPAC/InChI Trust InChI Licence No.1.0, - * or any later version. - * - * Please note that this library is distributed WITHOUT ANY WARRANTIES - * whatsoever, whether expressed or implied. - * See the IUPAC/InChI-Trust InChI Licence No.1.0 for more details. - * - * You should have received a copy of the IUPAC/InChI Trust InChI - * Licence No. 1.0 with this library; if not, please e-mail: - * - * info@inchi-trust.org - * - */ - - -This package contains InChI Software version 1.06. - -This is a combination of bugfix release and feature release. - -What is new: - -- security-related bugfixes; -- a number of other bugfixes and minor improvements; -- experimental support for pseudo element (Zz, or "star") atoms; -- modified experimental support of InChI/InChIKey for regular single-strand polymers (explicit pseudo atoms are used); -- minor update to InChI API Library; -- optional support of Intel(R) Threading Building Blocks scalable memory allocators; -- added several convenience features and software options. diff --git a/build.py b/build.py index 5c56bfa3..0fef97f5 100644 --- a/build.py +++ b/build.py @@ -16,6 +16,7 @@ # You should have received a copy of the GNU Lesser General Public License # along with this program; if not, see . # +from build_inchi import build as build_libinchi, libname_for_platform from Cython.Build import build_ext, cythonize from pathlib import Path from setuptools import Extension @@ -26,23 +27,15 @@ platform = get_platform() if platform == 'win-amd64': - libname = 'libinchi.dll' extra_compile_args = ['/O2'] -elif platform == 'linux-x86_64': - libname = 'libinchi.so' +elif platform.startswith('linux'): extra_compile_args = ['-O3'] -elif platform.startswith('macosx') and platform.endswith('x86_64'): - libname = 'libinchi.dynlib' - extra_compile_args = [] -elif platform.startswith('macosx') and platform.endswith('arm64'): - libname = 'libinchi_arm64.dylib' - extra_compile_args = [] else: - libname = None extra_compile_args = [] +libname = libname_for_platform() if libname: - copyfile(Path('INCHI') / libname, Path('chython/files/libinchi') / libname) + build_libinchi(Path('chython/files/libinchi') / libname) extensions = [ Extension('chython.algorithms._isomorphism', diff --git a/build_inchi.py b/build_inchi.py new file mode 100644 index 00000000..f1a3a959 --- /dev/null +++ b/build_inchi.py @@ -0,0 +1,88 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2026 Ramil Nugmanov +# This file is part of chython. +# +# chython is free software; you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program; if not, see . +# +""" +Build libinchi from the bundled INCHI submodule and copy it into +chython/files/libinchi/. + +Run standalone: + python build_inchi.py [--target ] + +If --target is not given the binary is written to chython/files/libinchi/. +""" +import argparse +import sys +from pathlib import Path +from shutil import copyfile, which +from subprocess import run +from sysconfig import get_platform +from tempfile import TemporaryDirectory +from warnings import warn + + +def libname_for_platform() -> str | None: + p = get_platform() + if p == 'win-amd64': + return 'libinchi.dll' + if p.startswith('linux'): + return 'libinchi.so' + if p.startswith('macosx'): + return 'libinchi.dylib' + return None + + +def build(target: Path) -> bool: + """ + Build libinchi and write the binary to *target*. + Returns True on success, False if skipped (source or cmake missing). + """ + if target.exists(): + return True + + libname = libname_for_platform() + if libname is None: + warn(f'Unsupported platform {get_platform()}; skipping libinchi build') + return False + + source = Path('INCHI/INCHI-1-SRC/INCHI_API/libinchi/src') + if not source.is_dir(): + warn(f'InChI source not found at {source}; skipping libinchi build') + return False + if which('cmake') is None: + warn('cmake not found; skipping libinchi build') + return False + + target.parent.mkdir(parents=True, exist_ok=True) + with TemporaryDirectory() as tmp: + run(['cmake', '-S', str(source), '-B', tmp, '-DCMAKE_BUILD_TYPE=Release'], check=True) + run(['cmake', '--build', tmp, '--config', 'Release', '--target', 'libinchi'], check=True) + for produced in Path(tmp).rglob(libname): + copyfile(produced, target) + print(f'libinchi written to {target}') + return True + warn(f'libinchi build produced no {libname}; skipping') + return False + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description='Build libinchi shared library') + parser.add_argument('--target', type=Path, + default=Path('chython/files/libinchi') / (libname_for_platform() or 'libinchi'), + help='Destination path for the compiled binary') + args = parser.parse_args() + sys.exit(0 if build(args.target) else 1) diff --git a/chython/algorithms/groups/__init__.py b/chython/algorithms/groups/__init__.py index 2e5c1c63..bc6b1d43 100644 --- a/chython/algorithms/groups/__init__.py +++ b/chython/algorithms/groups/__init__.py @@ -26,10 +26,120 @@ from ._reductions import rules as reduction_rules from ._transformations import rules as transformation_rules +# Descriptor bucket boundaries (MW in Da) +_MW_BOUNDS = (150, 250, 350, 450, 550) + + +def fingerprint_schema() -> dict[str, int]: + """ + Return the stable mapping from feature name to bit index used by + ``functional_fingerprint()``. Import this once in chemder to build + the selector's ``mapping`` array. + + Bit layout + ---------- + 0 – 5 rings_count buckets 0,1,2,3,4,5+ + 6 – 11 hba_count buckets 0,1,2,3,4,5+ + 12 – 17 hbd_count buckets 0,1,2,3,4,5+ + 18 – 22 sp3_fraction quintiles 0-20%, 20-40%, 40-60%, 60-80%, 80-100% + 23 – 28 molecular_mass buckets <150,150-250,250-350,350-450,450-550,550+ + 29 – 34 rotatable_bonds buckets 0,1,2,3,4-6,7+ + 35 + functional group presence (one bit per pattern in functional_rules) + """ + schema: dict[str, int] = {} + for i, name in enumerate(('rings_0', 'rings_1', 'rings_2', 'rings_3', 'rings_4', 'rings_5p')): + schema[name] = i + for i, name in enumerate(('hba_0', 'hba_1', 'hba_2', 'hba_3', 'hba_4', 'hba_5p')): + schema[name] = 6 + i + for i, name in enumerate(('hbd_0', 'hbd_1', 'hbd_2', 'hbd_3', 'hbd_4', 'hbd_5p')): + schema[name] = 12 + i + for i, name in enumerate(('sp3_q0', 'sp3_q1', 'sp3_q2', 'sp3_q3', 'sp3_q4')): + schema[name] = 18 + i + for i, name in enumerate(('mw_lt150', 'mw_150', 'mw_250', 'mw_350', 'mw_450', 'mw_550p')): + schema[name] = 23 + i + for i, name in enumerate(('rbc_0', 'rbc_1', 'rbc_2', 'rbc_3', 'rbc_4_6', 'rbc_7p')): + schema[name] = 29 + i + for offset, fg_name in enumerate(functional_rules): + schema[fg_name] = 35 + offset + return schema + class FunctionalGroups: __slots__ = () + @cached_property + def functional_fingerprint(self) -> bytes: + """ + 256-bit (32-byte) binary feature vector for fast AVX2 bitmap filtering. + + Bit layout matches ``fingerprint_schema()``: + 0– 5 rings_count buckets 0 / 1 / 2 / 3 / 4 / 5+ + 6–11 hba buckets 0 / 1 / 2 / 3 / 4 / 5+ + 12–17 hbd buckets 0 / 1 / 2 / 3 / 4 / 5+ + 18–22 sp3 fraction quintiles 0–20% / 20–40% / 40–60% / 60–80% / 80–100% + 23–28 molecular_mass <150 / 150–250 / 250–350 / 350–450 / 450–550 / 550+ + 29–34 rotatable_bonds 0 / 1 / 2 / 3 / 4–6 / 7+ + 35+ one bit per pattern in functional_rules (presence only) + """ + bits = bytearray(32) + + def _set(idx): + bits[idx >> 3] |= 1 << (idx & 7) + + # rings_count bucket (0–5+) + rc = min(self.rings_count, 5) + _set(rc) + + # hba bucket (0–5+) + hba = min(self.hydrogen_bond_acceptors_count, 5) + _set(6 + hba) + + # hbd bucket (0–5+) + hbd = min(self.hydrogen_bond_donors_count, 5) + _set(12 + hbd) + + # sp3 fraction quintile (0–4) + sp3q = min(int(self.carbon_sp3_fraction * 5), 4) + _set(18 + sp3q) + + # molecular_mass bucket + mw = self.molecular_mass + if mw < 150: + _set(23) + elif mw < 250: + _set(24) + elif mw < 350: + _set(25) + elif mw < 450: + _set(26) + elif mw < 550: + _set(27) + else: + _set(28) + + # rotatable_bonds bucket: 0/1/2/3/4-6/7+ + rbc = self.rotatable_bonds_count + if rbc == 0: + _set(29) + elif rbc == 1: + _set(30) + elif rbc == 2: + _set(31) + elif rbc == 3: + _set(32) + elif rbc <= 6: + _set(33) + else: + _set(34) + + # functional group bits (one bit per named pattern, presence only) + fgs = self.functional_groups + for offset, name in enumerate(functional_rules): + if name in fgs: + _set(35 + offset) + + return bytes(bits) + @cached_property def functional_groups(self) -> dict[str, int]: """ @@ -61,7 +171,8 @@ def protective_groups(self) -> dict[str, int]: return found def remove_protection(self, name=None, canonicalize=True, - fix_tautomers=True, ignore_pyrrole_hydrogen=False) -> bool: + fix_tautomers=True, ignore_pyrrole_hydrogen=False, + *, start=None) -> bool: """ Remove protective groups from the given molecule if applicable. @@ -69,6 +180,8 @@ def remove_protection(self, name=None, canonicalize=True, :param canonicalize: Run full canonicalization after removal. :param fix_tautomers: Canonicalize tautomer forms. Passed to canonicalize(). :param ignore_pyrrole_hydrogen: Fix invalid rings like Cn1cc[nH]c1. Passed to canonicalize(). + :param start: Starting atom number for newly added atoms (e.g. restored carbonyl O). + When None, defaults to max(existing) + 1. """ to_delete = set() to_add = [] @@ -91,9 +204,11 @@ def remove_protection(self, name=None, canonicalize=True, for n, a, b in add: to_add.append((mp[n], a, b)) + m = start for n, a, b in to_add: - m = self.add_atom(a, _skip_calculation=True) + m = self.add_atom(a, n=m, _skip_calculation=True) self.add_bond(m, n, b, _skip_calculation=True) + m += 1 for n in to_delete: self.delete_atom(n, _skip_calculation=True) if to_delete or to_add: @@ -209,4 +324,4 @@ def __matmul__(self, other): return self.react(other) -__all__ = ['FunctionalGroups'] +__all__ = ['FunctionalGroups', 'fingerprint_schema'] diff --git a/chython/algorithms/groups/_functional.py b/chython/algorithms/groups/_functional.py index 2b45d1fd..37774c5a 100644 --- a/chython/algorithms/groups/_functional.py +++ b/chython/algorithms/groups/_functional.py @@ -115,6 +115,9 @@ def _rules(): # acids rules['alkyl_carboxylic_acid'] = smarts('[O;D1;z1;x0:100][C;z2;x2;D3:1](=[O:2])[C;z1:3]') rules['aryl_carboxylic_acid'] = smarts('[O;D1;z1;x0:100][C;z2;x2;D3:1](=[O:2])[C;a:3]') + rules['alkenyl_carboxylic_acid'] = smarts('[O;D1;z1;x0:100][C;z2;x2;D3:1](=[O:2])[C;z2:3]=[C:4]') + rules['alkynyl_carboxylic_acid'] = smarts('[O;D1;z1;x0:100][C;z2;x2;D3:1](=[O:2])[C;z3;x0:3]') + rules['cyclic_carboxylic_acid'] = smarts('[O;D1;z1;x0:100][C;z2;x2;D3:1](=[O:2])[C;z1;r5,r6:3]') rules['carboxylic_acid'] = smarts('[O;D1;z1;x0:100][C;z2;x2;D3:1]=[O:2]') rules['acyl_chloride'] = smarts('[Cl:100][C;z2;x2;D3:1]=[O:2]') rules['acyl_fluoride'] = smarts('[F:100][C;z2;x2;D3:1]=[O:2]') @@ -137,6 +140,9 @@ def _rules(): rules['primary_amide'] = smarts('[N;D1;z1;x0:1][C;z2;x2;D3:2]=[O:3]') rules['secondary_amide'] = smarts('[N;D2;z1;x0:1][C;z2;x2;D3:2]=[O:3]') + # sulfo (sulfonic acid: R-SO3H; distinct from sulfonyl halide / sulfonamide) + rules['sulfo'] = smarts('[S;x3;D4:1](=[O:2])(=[O:3])[O;D1:4]') + # sulfonyl rules['sulfonyl_chloride'] = smarts('[S;D4:1](=[O:2])(=[O:3])[Cl;D1:100]') rules['sulfonyl_fluoride'] = smarts('[S;D4:1](=[O:2])(=[O:3])[F;D1:100]') @@ -146,6 +152,9 @@ def _rules(): # nitrogen functional groups rules['nitrile'] = smarts('[N;D1;z3;x0:2]#[C;D2;x1:1]') rules['azide'] = smarts('[N;x1;D2:1]=[N+:2]=[N-:3]') + rules['diazo'] = smarts('[C;z2:1]=[N+;D2:2]=[N-;D1:3]') + rules['diazonium'] = smarts('[C;a:1]-[N+;D2:2]#[N;D1:3]') + rules['azo'] = smarts('[N;D2;x1:1]=[N;D2;x1:2]') rules['isocyanate'] = smarts('[N;z2;x0;D2:1]=[C:2]=[O:3]') rules['isocyano'] = smarts('[N;D2;x0;+:1]#[C;-:2]') rules['guanidine'] = smarts('[N;z1;x0:1][C;!R:2]([N;z1;x0:3])=[N;x0:4]') @@ -174,6 +183,7 @@ def _rules(): # silanes (R-SiR3, for Hiyama coupling) rules['aryl_silane'] = smarts('[Si;D4:100]-;!@[C;a:1]') rules['alkenyl_silane'] = smarts('[Si;D4:100]-;!@[C;z2:1]=[C:2]') + rules['alkynyl_silane'] = smarts('[Si;D4:100]-;!@[C;z3:1]#[C:2]') # phosphorus ylides and phosphonates rules['phosphonium_ylide'] = smarts('[P;D4;z2;x0:100]=[C:1]') @@ -224,6 +234,10 @@ def _rules(): # hydrazone (C=N-NH-R, product of carbonyl + hydrazine condensation) rules['hydrazone'] = smarts('[C;z2:1]=[N;D2;z2;x1:2]-[N;D2;z1;x1:3]') + # sulfonylhydrazone (C=N-NH-SO2R, e.g. tosylhydrazone; precursor to diazo via Bamford-Stevens) + # N:3 bonded to N and S -> x2 + rules['sulfonylhydrazone'] = smarts('[C;z2:1]=[N;D2;z2;x1:2]-[N;D2;z1;x2:3]-[S;D4;x3:100](=[O])(=[O])') + # thioamide (for Hantzsch thiazole) rules['thioamide'] = smarts('[S;z2;x0;D1:2]=[C;D3;x2:1]-[N;D1:3]') @@ -281,6 +295,32 @@ def _rules(): # pyridine-like nitrogen (for N-oxidation): aromatic N with no H, degree 2 rules['pyridine_n'] = smarts('[N;a;D2;h0:1]') + # esters: methyl ester has distinct reactivity (saponification, transesterification) + rules['methyl_ester'] = smarts('[O;z2;x0:2]=[C;D3;x2;z2:1]-[O;D2;x0:3]-[C;D1:100]') + + # epoxide: strained 3-membered ring + rules['epoxide'] = smarts('[O;D2;r3:1]1-[C;r3:2]-[C;r3:3]-1') + + # fluorine substituents (descriptor flags: metabolic stability, bioisosteres) + rules['trifluoromethyl'] = smarts('[C;D4;z1;x3:1]([F:2])([F:3])[F:4]') + rules['difluoromethyl'] = smarts('[C;D3;z1;x2:1]([F:2])[F:3]') + + # structural alerts / medchem flags + rules['catechol'] = smarts('[O;D1;z1;x0:1]-[C;a:2]:[C;a:3]-[O;D1;z1;x0:4]') + rules['disulfide'] = smarts('[S;D2;z1:1]-[S;D2;z1:2]') + # vinyl sulfone / vinyl sulfonamide / vinyl sulfonate (covalent warhead, PAINS alert) + rules['vinyl_sulfone'] = smarts('[S;D4;x3:1](=[O:2])(=[O:3])(-[O,N:100])-[C;z2:4]=[C:5]') + rules['peroxide'] = smarts('[O;D2;z1:1]-[O;D2;z1:2]') + rules['nitroso'] = smarts('[N;D2;z2:1]=[O;D1:2]') + rules['isothiocyanate'] = smarts('[N;z2;x0;D2:1]=[C:2]=[S;D1:3]') + rules['maleimide'] = smarts('[N;r5:1]1-[C;z2;r5:2](=[O:3])-[C;z2;r5:4]=[C;z2;r5:5]-[C;z2;r5:6]1=[O:7]') + + # scaffold flags (for BB classification and filtering) + rules['fused_aromatic'] = smarts('[A;a;D3:1](:[A;a])(:[A;a]):[A;a]') + rules['polyarene'] = smarts('[A;a:1]:[A;a;D3:2](:[A;a:3]):[A;a:4]:[A;a;D3:5](:[A;a:6]):[A;a:7]') + rules['diaryl'] = smarts('[C,N;a:1]-;!@[C,N;a:2]') + rules['bridged_diaryl'] = smarts('[C,N;a:1]-;!@[C,O,N,S:3]-;!@[C,N;a:2]') + return rules diff --git a/chython/algorithms/groups/_reductions.py b/chython/algorithms/groups/_reductions.py index 580a54cc..c785f6e1 100644 --- a/chython/algorithms/groups/_reductions.py +++ b/chython/algorithms/groups/_reductions.py @@ -74,6 +74,10 @@ def _rules(): rules.append(_make_reactor('carbonyl_to_amine', 'aldehyde', 'primary_amine', '[A:1]-[N:20]')) rules.append(_make_reactor('carbonyl_to_amine', 'ketone', 'primary_amine', '[A:1]-[N:20]')) + # carbonyl → methylene/methyl (Wolff-Kishner, Clemmensen). C=O fully deoxygenated to CH2/CH3. + rules.append(_make_reactor('ketone_to_methylene', 'ketone', None, '[A:1]')) + rules.append(_make_reactor('aldehyde_to_methyl', 'aldehyde', None, '[A:1]')) + # reductive dehalogenation: Ar-X → Ar-H (H2/Pd, n-Bu3SnH, Zn/AcOH) rules.append(_make_reactor('debromination', 'aryl_bromide', None, '[A:1]')) rules.append(_make_reactor('dechlorination', 'aryl_chloride', None, '[A:1]')) diff --git a/chython/algorithms/groups/_transformations.py b/chython/algorithms/groups/_transformations.py index 9c6eda4f..6eddcbe2 100644 --- a/chython/algorithms/groups/_transformations.py +++ b/chython/algorithms/groups/_transformations.py @@ -170,6 +170,15 @@ def _rules(): # acid fluoride formation: RCOOH → RCOF (cyanuric fluoride, DAST) rules.append(_make_reactor('acid_fluorination', 'carboxylic_acid', 'acyl_fluoride', '[F:20]-[A:1]=[A:2]')) + # acid to diazoketone: RCOOH/RCOX → RC(=O)CH=N2 (+ TMS-diazomethane). Arndt-Eistert step 1. + # Accepts free acid or activated acid (acyl halide); the OH/halide is the leaving group. + rules.append(_make_reactor('acid_to_diazoketone', 'carboxylic_acid', 'ketone', + '[A:1](=[A:2])-[C:20]=[N;+:21]=[N;-:22]')) + rules.append(_make_reactor('acid_to_diazoketone', 'acyl_chloride', 'ketone', + '[A:1](=[A:2])-[C:20]=[N;+:21]=[N;-:22]')) + rules.append(_make_reactor('acid_to_diazoketone', 'acyl_fluoride', 'ketone', + '[A:1](=[A:2])-[C:20]=[N;+:21]=[N;-:22]')) + # Sandmeyer: ArNH2 → ArX (NaNO2/HX, CuX) rules.append(_make_reactor('sandmeyer_bromination', 'primary_aniline', 'aryl_bromide', '[Br:20]-[A:2]')) rules.append(_make_reactor('sandmeyer_iodination', 'primary_aniline', 'aryl_iodide', '[I:20]-[A:2]')) @@ -263,6 +272,26 @@ def _rules(): rules.append(_make_reactor('halide_to_thiol', 'alkyl_bromide', 'thiol', '[A:1]-[S:20]')) rules.append(_make_reactor('halide_to_thiol', 'alkyl_chloride', 'thiol', '[A:1]-[S:20]')) + # Grignard methylation: C=O → C(-OH)(-CH3) (+ MeMgBr/MeLi, implicit). Carbonyl + methyl nucleophile. + rules.append(_make_reactor('grignard_methylation', 'ketone', 'tertiary_alcohol', '[A:2]-[A:1]-[C:20]')) + rules.append(_make_reactor('grignard_methylation', 'aldehyde', 'secondary_alcohol', '[A:2]-[A:1]-[C:20]')) + + # Katritzky salt formation: RNH2 → N-alkyl 2,4,6-triphenylpyridinium (+ triphenylpyrylium BF4). + # Amine activation step of deaminative Csp3-Csp2 coupling. N becomes a traceless leaving group. + rules.append(_make_reactor('katritzky_salt', 'primary_amine', None, + '[A:2]-[N;+:1]:1:[C:20](-[C:30]:2:[C:31]:[C:32]:[C:33]:[C:34]:[C:35]:2):' + '[C:21]:[C:22](-[C:40]:3:[C:41]:[C:42]:[C:43]:[C:44]:[C:45]:3):' + '[C:23]:[C:24]:1-[C:50]:4:[C:51]:[C:52]:[C:53]:[C:54]:[C:55]:4')) + + # Bamford-Stevens: sulfonylhydrazone → diazo compound (base). C=N-NH-SO2R → C=N+=N-. + rules.append(_make_reactor('bamford_stevens', 'sulfonylhydrazone', None, + '[A:1]=[N;+:2]=[N;-:3]')) + + # Protodesilylation: C(sp2/sp)-SiR3 → C-H (TBAF, K2CO3/MeOH). TMS as temporary blocking group. + rules.append(_make_reactor('protodesilylation', 'aryl_silane', None, '[A:1]')) + rules.append(_make_reactor('protodesilylation', 'alkenyl_silane', None, '[A:1]=[A:2]')) + rules.append(_make_reactor('protodesilylation', 'alkynyl_silane', 'terminal_alkyne', '[A:1]#[A:2]')) + # Halide to thioether: RX → RSMe (NaSMe) rules.append(_make_reactor('halide_to_thioether', 'aryl_chloride', 'thioether', '[A:1]-[S:20]-[C:21]')) rules.append(_make_reactor('halide_to_thioether', 'aryl_bromide', 'thioether', '[A:1]-[S:20]-[C:21]')) diff --git a/chython/algorithms/groups/test/test_reactions.py b/chython/algorithms/groups/test/test_reactions.py index b9fe588e..c0d68f38 100644 --- a/chython/algorithms/groups/test/test_reactions.py +++ b/chython/algorithms/groups/test/test_reactions.py @@ -195,9 +195,9 @@ _transformations = [ ('isoxazole_from_diketone', 'isoxazole', 'O=C(C)CC(=O)C>>c1(onc(C)c1)C', 1, - {'alpha_halogenation', 'oximation', 'oxo_to_difluoro', 'oxo_to_thioxo'}), + {'alpha_halogenation', 'oximation', 'oxo_to_difluoro', 'oxo_to_thioxo', 'grignard_methylation'}), ('pyridazine_from_diketone', 'pyridazine', 'O=C(C)CCC(=O)C>>c1(C)nnc(C)cc1', 1, - {'alpha_halogenation', 'oximation', 'oxo_to_difluoro', 'oxo_to_thioxo'}), + {'alpha_halogenation', 'oximation', 'oxo_to_difluoro', 'oxo_to_thioxo', 'grignard_methylation'}), ('appel_primary_alcohol', 'appel', 'OCC>>BrCC', 1, {'triflation', 'appel_chloride', 'mesylation', 'tosylation', 'fluorination', 'appel_iodide', 'azidation'}), ('appel_secondary_alcohol', 'appel', 'OC(C)CC>>BrC(C)CC', 1, @@ -214,6 +214,18 @@ {'nitration', 'bromination', 'iodination', 'fluorination'}), ('iodination_benzene', 'iodination', 'c1ccccc1>>Ic1ccccc1', 1, {'nitration', 'bromination', 'chlorination', 'fluorination'}), + ('acid_to_diazoketone', 'acid_to_diazoketone', 'FC1(F)C(C(=O)O)C1>>FC1(F)CC1C(=O)C=[N+]=[N-]', 1, + {'acid_chlorination', 'acid_fluorination', 'acid_to_amide'}), + ('diazoketone_from_acyl_chloride', 'acid_to_diazoketone', 'FC1(F)C(C(=O)Cl)C1>>FC1(F)CC1C(=O)C=[N+]=[N-]', 1, + {'acyl_chloride_hydrolysis', 'acyl_chloride_to_amide'}), + ('protodesilylation', 'protodesilylation', 'C[Si](C)(C)C#CCCCC>>C#CCCCC', 1), + ('bamford_stevens', 'bamford_stevens', 'Cc1ccc(cc1)S(=O)(=O)NN=C(C)c1ccccc1>>CC(=[N+]=[N-])c1ccccc1', 1, + {'bromination', 'chlorination', 'fluorination', 'iodination', 'nitration'}), + ('katritzky_salt', 'katritzky_salt', + 'CCCCN>>CCCC[n+]1c(-c2ccccc2)cc(-c2ccccc2)cc1-c1ccccc1', 1, + {'amino_to_azide', 'amino_to_hydroxy', 'amino_to_isocyanate', 'amino_to_isothiocyanate'}), + ('grignard_methylation', 'grignard_methylation', 'C1CC(CCO1)=O>>C1COCCC1(O)C', 1, + {'alpha_halogenation', 'oximation', 'oxo_to_difluoro', 'oxo_to_thioxo'}), ] _oxidations = [ @@ -227,8 +239,10 @@ ] _reductions = [ - ('aldehyde_to_alcohol', 'aldehyde_to_alcohol', 'O=Cc1ccccc1>>OCc1ccccc1', 1, {'carbonyl_to_amine'}), - ('ketone_to_alcohol', 'ketone_to_alcohol', 'O=C(C)c1ccccc1>>OC(C)c1ccccc1', 1, {'carbonyl_to_amine'}), + ('aldehyde_to_alcohol', 'aldehyde_to_alcohol', 'O=Cc1ccccc1>>OCc1ccccc1', 1, + {'carbonyl_to_amine', 'aldehyde_to_methyl'}), + ('ketone_to_alcohol', 'ketone_to_alcohol', 'O=C(C)c1ccccc1>>OC(C)c1ccccc1', 1, + {'carbonyl_to_amine', 'ketone_to_methylene'}), ('acid_to_alcohol', 'acid_to_alcohol', 'OC(=O)c1ccccc1>>OCc1ccccc1', 1), ('ester_to_alcohol', 'ester_to_alcohol', 'COC(=O)c1ccccc1>>OCc1ccccc1', 1), ('amide_to_amine_primary', 'amide_to_amine', 'NC(=O)c1ccccc1>>NCc1ccccc1', 1), @@ -238,6 +252,10 @@ ('azide_to_amine', 'azide_to_amine', 'CN=[N+]=[N-]>>CN', 1), ('deoxygenation_primary', 'deoxygenation', 'OCC1CCCCC1>>CC1CCCCC1', 1), ('deoxygenation_secondary', 'deoxygenation', 'OC(C)c1ccccc1>>CCc1ccccc1', 1), + ('ketone_to_methylene', 'ketone_to_methylene', 'O=C(C)c1ccccc1>>CCc1ccccc1', 1, + {'carbonyl_to_amine', 'ketone_to_alcohol'}), + ('aldehyde_to_methyl', 'aldehyde_to_methyl', 'O=Cc1ccccc1>>Cc1ccccc1', 1, + {'carbonyl_to_amine', 'aldehyde_to_alcohol'}), ] diff --git a/chython/algorithms/mapping/__init__.py b/chython/algorithms/mapping/__init__.py index 98ae2223..988234de 100644 --- a/chython/algorithms/mapping/__init__.py +++ b/chython/algorithms/mapping/__init__.py @@ -29,16 +29,18 @@ class Mapping(Reconstruct, Attention): def reset_mapping(self) -> bool: """ - Reset atom-to-atom mapping by remapping atoms to unique numbers. + Reset atom-to-atom mapping by remapping every atom in the reaction to a globally unique + number. This makes the reactant, reagent and product number spaces disjoint, so no atom on + one side shares a number with an atom on another (the correspondence is fully erased). + + Return True if any numbering changed. """ - r = [n for m in chain(self.reactants, self.reagents) for n in m._atoms] - p = [n for m in self.products for n in m._atoms] c = count(1) - if len(r) != len(set(r)): - for m in chain(self.reactants, self.reagents): - m.remap({n: next(c) for n in m._atoms}) - if len(p) != len(set(p)): - for m in self.products: + # renumber everything from one counter only when not already globally unique (avoid + # pointless churn / cache flush on clean inputs). + all_atoms = [n for m in self.molecules() for n in m._atoms] + if len(all_atoms) != len(set(all_atoms)): + for m in self.molecules(): m.remap({n: next(c) for n in m._atoms}) if next(c) != 1: self.flush_cache() diff --git a/chython/algorithms/mapping/attention.py b/chython/algorithms/mapping/attention.py index 811edb4b..d16a22d2 100644 --- a/chython/algorithms/mapping/attention.py +++ b/chython/algorithms/mapping/attention.py @@ -18,11 +18,11 @@ # along with this program; if not, see . # from functools import cache -from itertools import repeat +from itertools import chain, count, repeat from logging import getLogger, INFO from numpy import array, argmax, clip, concatenate, int32, int64, isclose, ix_, mean, nan_to_num from numpy import nonzero, ones, unravel_index, zeros -from scipy.sparse.csgraph import shortest_path + logger = getLogger('chython.attention') logger.setLevel(INFO) @@ -108,6 +108,12 @@ def _encode_molecule(mol): neighbors: 0=cls, 2+=neighbor_count+2 distances: 0=padding, 1=unreachable/cross-component, 2+=shortest_path+2 """ + try: + from scipy.sparse.csgraph import shortest_path + except ImportError: + raise ImportError('scipy package is required for attention mapping. ' + 'Install it with: pip install scipy') + n_atoms = len(mol) size = n_atoms + 1 # +1 for mol_cls @@ -216,6 +222,29 @@ def _greedy_mapping(am, r_map, p_map, r_adj, p_adj, multiplier): class Attention: __slots__ = () + def _reset_mapping_loose(self) -> bool: + """ + Reset mapping only where a side (reactants+reagents / products) has *internal* duplicate + numbers; a reactant and a product may still share a number afterwards. The attention mapper + builds its own disjoint numbering downstream and relies on this loose reset — unlike the + public ``reset_mapping``, which makes every atom in the reaction globally unique. + + Return True if any numbering changed. + """ + r = [n for m in chain(self.reactants, self.reagents) for n in m._atoms] + p = [n for m in self.products for n in m._atoms] + c = count(1) + if len(r) != len(set(r)): + for m in chain(self.reactants, self.reagents): + m.remap({n: next(c) for n in m._atoms}) + if len(p) != len(set(p)): + for m in self.products: + m.remap({n: next(c) for n in m._atoms}) + if next(c) != 1: + self.flush_cache() + return True + return False + def attention_mapping(self, *, return_score: bool = False, multiplier=1.75, keep_reactants_numbering=False) -> bool | float: """Do atom-to-atom mapping. Return True if mapping changed.""" @@ -223,7 +252,7 @@ def attention_mapping(self, *, return_score: bool = False, multiplier=1.75, logger.info('atom-to-atom mapping not supported for hypervalent compounds') return False - fixed = self.reset_mapping() + fixed = self._reset_mapping_loose() r_map, p_map, rg_map, equal_atoms, p2r, r2p, r_adj, p_adj = _prepare_masks( self.reactants, self.products, self.reagents ) diff --git a/chython/algorithms/mapping/reconstruct.py b/chython/algorithms/mapping/reconstruct.py index 01be0b02..e59f8092 100644 --- a/chython/algorithms/mapping/reconstruct.py +++ b/chython/algorithms/mapping/reconstruct.py @@ -16,7 +16,7 @@ # You should have received a copy of the GNU Lesser General Public License # along with this program; if not, see . # -from itertools import combinations, permutations +from itertools import combinations, permutations, product as iproduct from ..groups._oxidations import rules as oxidation_rules from ..groups._reactions import rules as reaction_rules from ..groups._reductions import rules as reduction_rules @@ -26,7 +26,7 @@ class Reconstruct: __slots__ = () - def reconstruct_mapping(self) -> list[str]: + def reconstruct_mapping(self, *, max_size_ratio: float = 5., min_filter_size: int = 42) -> list[str]: """ Annotate reaction by trying to reconstruct the product from reactants using predefined reaction templates. @@ -35,11 +35,24 @@ def reconstruct_mapping(self) -> list[str]: standardized and canonicalized before calling this method. Tries in order: - 1. Standalone deprotection - 2. Standalone protection (reverse) - 3. Single-molecule transforms (oxidize/reduce/transform) - 4. Deprotection + transform composition - 5. Multi-component reactions (subset-based) + 1. Standalone protection (kept separate; protection is normally its own reaction) + 2. Protective-group balancing: strip each reactant's reactant-only PGs once, then + 3. Standalone deprotection (a deprotected reactant equals the product) + 4. Single-molecule transforms (oxidize/reduce/transform) on deprotected reactants + 5. Multi-component reactions on deprotected reactant subsets + + Steps 3-5 run on the deprotected reactants, so deprotection composes with both transforms + and couplings; their labels are prefixed with the ``deprotect:`` steps that produced them. + + :param max_size_ratio: reject grossly unbalanced records where the product has at least + this many times the total reactant atom count (purifications, missing reactants, bad + data). Lower it to reject more aggressively, raise it to be more permissive. The + protection path is always attempted regardless (protections may graft large groups from + an unlisted reagent). Set to a non-positive value or ``float('inf')`` to disable. + :param min_filter_size: only apply ``max_size_ratio`` when the product has at least this + many atoms. Small products are cheap to enumerate, so we skip the filter and go full + compute below this threshold; the filter exists to save time on large, slow records. + Set to 0 to always apply the ratio filter. Returns list of matched reaction/deprotection names. Empty list if no match found. @@ -47,61 +60,83 @@ def reconstruct_mapping(self) -> list[str]: """ assert self.reactants, 'No reactants in reaction' assert len(self.products) == 1, 'Only single product reactions supported' + # reset_mapping makes every atom number globally unique, so the reactant and product number + # spaces are disjoint. The reconstruction remaps surviving reactant atoms onto their product + # numbers, which is only safe (and correct) when the two spaces don't overlap. self.reset_mapping() - reactants = list(self.reactants) + reactants = self.reactants product = self.products[0] - pfgs = product.functional_groups product_size = len(product) - # Iterate over candidate subsets by atom count - for subset_indices in _candidate_subsets(reactants, product_size): - if len(subset_indices) == 1: - r = reactants[subset_indices[0]] - result = _try_single(r, product, pfgs) - else: - subset = [reactants[i] for i in subset_indices] - result = _try_multi(subset, product) - if result: + # Precheck: purification (product is literally one of the reactants, i.e. recrystallization + # or workup logged as a reaction). Cheap structural equality; bail before the expensive + # functional_groups / template enumeration below. + if product in reactants: + return [] + + # 1. Standalone protection (separate reaction; never composed with transforms/couplings). + # Tried before the size filter: a protection legitimately grafts a large group from an + # unlisted reagent, so the product can dwarf the listed reactants yet still be a real match. + for r in reactants: + if (result := _try_protect(r, product)) is not None: mapping, labels = result - _safe_remap(self.products[0], mapping) + r.remap(mapping) return labels - return [] - + # Reject grossly unbalanced records (missing reactants, bad data): a product with + # >=max_size_ratio x the total reactant atoms cannot be reconstructed by any remaining + # template. Skipped for small products (< min_filter_size atoms): enumeration there is cheap, + # so full compute is preferred over the risk of rejecting a real reaction. + total_reactant_atoms = sum(len(r) for r in reactants) + if max_size_ratio > 0 and product_size >= min_filter_size \ + and product_size >= max_size_ratio * total_reactant_atoms: + return [] + + # 2+3. Deprotection: strip each reactant's reactant-only PGs and, the moment a deprotected + # reactant equals the product, return without deprotecting the rest. `x` maps the surviving + # atoms (reactant numbers) onto product numbers; the original reactant shares those numbers, + # so we remap it directly (removed-PG atoms aren't in `x` and keep their own numbers). + # + # Each reactant then contributes one or two candidate forms to the chemistry phases: always + # the raw reactant, plus its deprotected form when a deprotection happened. We run chemistry + # on both because the listed PG is not always cleaved as a clean deprotection — it may be + # transformed in place by the reaction, in which case the raw form is the right substrate. + # `forms[i]` is a list of (mol, labels) for reactant i, raw first so it is preferred. + forms = [] + for r in reactants: + candidates = [(r, [])] + if (result := _deprotect_excess(r, product)) is not None: + m, labels = result + if (mapping := m.get_fast_mapping(product)) is not None: + r.remap(mapping) + return labels + candidates.append((m, labels)) + forms.append(candidates) + + # 4. Single-molecule transforms (largest reactant first). FG-screened, so tiny reagents cost + # almost nothing; this also covers reagent-adding transforms the size-based subset gate skips. + for i in sorted(range(len(forms)), key=lambda j: len(reactants[j]), reverse=True): + for m, dep in forms[i]: + if (result := _try_transforms(m, product)) is not None: + mapping, labels = result + reactants[i].remap(mapping) + return dep + labels + + # 5. Multi-component reactions over reactant subsets (size-ordered). For each subset we try + # every combination of its members' candidate forms (raw / deprotected). + for subset_indices in _candidate_subsets(reactants, product_size): + if len(subset_indices) == 1: + continue # singles already covered by phases 3-4 + for combo in iproduct(*(forms[i] for i in subset_indices)): + subset = [m for m, _ in combo] + if (result := _try_multi(subset, product)) is not None: + mapping, labels = result + for i in subset_indices: + reactants[i].remap(mapping) + return [l for _, dep in combo for l in dep] + labels -def _safe_remap(mol, mapping): - """Remap mol atoms using mapping, handling potential overlaps via temp numbers.""" - try: - mol.remap(mapping) - except ValueError: - # Overlap: remap all atoms to safe temp space first - existing = list(mol._atoms) - targets = set(mapping.values()) - base = max(max(existing), max(targets), max(mapping)) + 1 - # Step 1: all atoms to temp - temp = {n: base + i for i, n in enumerate(existing)} - mol.remap(temp) - # Step 2: temp to final (mapped atoms get target, unmapped get unique new numbers) - used = set(mapping.values()) - counter = base + len(existing) - final = {} - for i, n in enumerate(existing): - if n in mapping: - final[base + i] = mapping[n] - else: - # Pick a number not in use - while counter in used: - counter += 1 - final[base + i] = counter - used.add(counter) - counter += 1 - mol.remap(final) - - -def _match(generated, product): - """Compare generated product to expected product using fast mapping.""" - return product.get_fast_mapping(generated) + return [] def _candidate_subsets(reactants, product_size): @@ -145,75 +180,66 @@ def _candidate_subsets(reactants, product_size): yield from quads -def _try_single(r, product, pfgs): +def _strip(mol, groups, start=None): """ - Try all single-reactant paths in order: - 1. Standalone deprotection - 2. Standalone protection (reverse) - 3. Single-molecule transforms (unified oxidize/reduce/transform) - 4. Deprotection + transform composition + Return a copy of ``mol`` with the named protective groups removed, or None on failure. - Returns (mapping, labels) or None. - """ - # 1. Standalone deprotection - result = _try_deprotect(r, product) - if result: - return result - - # 2. Standalone protection (reverse: product is deprotected form of reactant) - result = _try_protect(r, product) - if result: - return result - - # 3. Single-molecule transforms with FG screening - result = _try_transforms(r, product, pfgs) - if result: - return result - - # 4. Deprotection + transform composition - result = _try_deprotect_then_transform(r, product, pfgs) - if result: - return result + ``remove_protection`` canonicalizes the result (kekule/thiele/...), which can raise on a + structure that has no valid kekule form. Reconstruction must never crash, so a failed strip is + reported as "no match" (None) rather than propagated. - return None + ``start``, when given, is passed to ``remove_protection`` as the starting atom number for any + newly added atoms (e.g. the carbonyl O restored from an acetal), avoiding collisions with + other molecules in a multi-component reaction context. + """ + m = mol.copy(keep_sssr=True, keep_components=True) + try: + cursor = start + for p in groups: + before = set(m._atoms) + m.remove_protection(p, start=cursor) + if cursor is not None: + cursor += len(m) - len(before) + except Exception: # noqa: BLE001 - an unkekulizable deprotection product is simply not a match + return None + return m -def _try_deprotect(r, product): +def _deprotect_excess(r, product): """ - Try standalone deprotection: reactant has PGs, product has none of those. - Returns (mapping, labels) or None. + Strip the protective groups that ``r`` carries but the product does not (a deprotection step). + + Returns ``(molecule, labels)`` when a deprotection happened — ``molecule`` is the deprotected + copy of ``r`` (keeping ``r``'s atom numbers minus the removed PG atoms, so it stays directly + comparable to the disjoint-numbered product) and ``labels`` are the ``deprotect:`` steps. + Returns ``None`` when there is nothing to strip or the strip fails. + + PGs shared with the product, or present only in the product, are left untouched: extra + product-side PGs mean a protection happened and are handled by ``_try_protect`` separately. """ rpg = r.protective_groups ppg = product.protective_groups - # product must have NO PGs that reactant doesn't have - for p in ppg: - if p not in rpg: - return None - # shared PGs must have same counts + # only strip reactant-only PGs whose shared instances already balance; if a shared PG has + # mismatched counts, the bookkeeping is ambiguous, so deprotect nothing and let matching fail. for p in rpg: if p in ppg and rpg[p] != ppg[p]: return None - # PGs to remove: present in reactant, absent from product to_remove = [p for p in rpg if p not in ppg] if not to_remove: return None - # remove all instances of each PG type - m = r.copy(keep_sssr=True, keep_components=True) - for p in to_remove: - m.remove_protection(p) - if x := product.get_fast_mapping(m): - return x, [f'deprotect:{name}' for name in to_remove] - return None + if (m := _strip(r, to_remove, max(product) + 1)) is None: + return None + return m, [f'deprotect:{name}' for name in to_remove] def _try_protect(r, product): """ - Try standalone protection (reverse direction): - product is reactant with PGs added. - Equivalent to: product has PGs that reactant doesn't. - We check if deprotecting product yields reactant. + Try standalone protection (reverse direction): the product is the reactant with PGs added, i.e. + the product carries PGs the reactant does not. Verify by deprotecting the product back to the + reactant. - Returns (mapping, labels) or None. + Returns ``(mapping, labels)`` on a match — ``mapping`` is ``{reactant_atom: product_atom}`` and + ``labels`` are the ``protect:`` steps — else None. """ rpg = r.protective_groups ppg = product.protective_groups @@ -229,23 +255,48 @@ def _try_protect(r, product): to_add = [p for p in ppg if p not in rpg] if not to_add: return None - # deprotect a copy of product and compare against reactant - m = product.copy(keep_sssr=True, keep_components=True) - for p in to_add: - m.remove_protection(p) + # deprotect a copy of product and compare against reactant. `m` keeps the product's numbering + # minus the grafted PG atoms, so reactant -> m is {reactant_atom: product_atom}; the grafted PG + # atoms live only on the product side and stay unmapped (keeping their own unique numbers). + if (m := _strip(product, to_add)) is None: + return None if x := r.get_fast_mapping(m): return x, [f'protect:{name}' for name in to_add] return None -def _try_transforms(r, product, pfgs): +def _reactor_products(reactor, *mols): + """ + Iterate a reactor's product molecules, tolerating chemistry failures. + + Template application ends with canonicalization (kekule/thiele/...), which can raise on a + generated structure that has no valid kekule form or otherwise breaks aromaticity. That + exception surfaces from the reactor generator's ``__next__`` and, once raised, kills the + generator. Reconstruction must never crash on such a template, so we stop iterating this reactor + on any exception and move on to the next rule. + """ + gen = iter(reactor(*mols)) + while True: + try: + rxn = next(gen) + except StopIteration: + return + except Exception: # noqa: BLE001 - a template yielding invalid chemistry is not a match + return + yield rxn.products[0] + + +def _try_transforms(r, product): """ Unified loop over oxidation, reduction, and transformation rules. Reactor products are fully canonicalized via reactor pipeline. - Returns (mapping, labels) or None. + Returns (mapping, labels) or None. The mapping is restricted to ``r``'s atoms, so reactor-created + atoms (numbered outside ``r``, e.g. the new Br of a bromination) are dropped and the result is + directly remappable onto the reactant. """ rfgs = r.functional_groups + pfgs = product.functional_groups # cached on product # Oxidation rules for name, fg_name, output_fg, reactor in oxidation_rules: @@ -255,10 +306,9 @@ def _try_transforms(r, product, pfgs): continue if output_fg is None and rfgs[fg_name] - pfgs.get(fg_name, 0) != 1: continue - for rxn in reactor(r): - p = rxn.products[0] - if x := _match(p, product): - return x, [f'oxidize:{name}'] + for p in _reactor_products(reactor, r): + if x := p.get_fast_mapping(product): + return {n: x[n] for n in r if n in x}, [f'oxidize:{name}'] # Reduction rules for name, fg_name, output_fg, reactor in reduction_rules: @@ -268,10 +318,9 @@ def _try_transforms(r, product, pfgs): continue if output_fg is None and rfgs[fg_name] - pfgs.get(fg_name, 0) != 1: continue - for rxn in reactor(r): - p = rxn.products[0] - if x := _match(p, product): - return x, [f'reduce:{name}'] + for p in _reactor_products(reactor, r): + if x := p.get_fast_mapping(product): + return {n: x[n] for n in r if n in x}, [f'reduce:{name}'] # Transformation rules for name, fg_name, output_fg, reactor in transformation_rules: @@ -281,41 +330,10 @@ def _try_transforms(r, product, pfgs): continue if output_fg is None and rfgs[fg_name] - pfgs.get(fg_name, 0) != 1: continue - for rxn in reactor(r): - p = rxn.products[0] - if x := _match(p, product): - return x, [f'transform:{name}'] - - return None - + for p in _reactor_products(reactor, r): + if x := p.get_fast_mapping(product): + return {n: x[n] for n in r if n in x}, [f'transform:{name}'] -def _try_deprotect_then_transform(r, product, pfgs): - """ - Composition: deprotect first, then apply single-molecule transforms. - Returns (mapping, labels) or None. - """ - rpg = r.protective_groups - ppg = product.protective_groups - # Validate PG delta (same logic as standalone deprotect) - for p in ppg: - if p not in rpg: - return None - for p in rpg: - if p in ppg and rpg[p] != ppg[p]: - return None - to_remove = [p for p in rpg if p not in ppg] - if not to_remove: - return None - # Deprotect on a raw copy (preserves atom numbers for transform) - deprot = r.copy(keep_sssr=True, keep_components=True) - for p in to_remove: - deprot.remove_protection(p) - # Use deprotected molecule's FGs for screening (raw, unpatched) - result = _try_transforms(deprot, product, pfgs) - if result: - mapping, transform_labels = result - labels = [f'deprotect:{n}' for n in to_remove] + transform_labels - return mapping, labels return None @@ -324,20 +342,22 @@ def _try_multi(subset, product): Multi-component reactions: try all reaction templates that match the subset length and FG requirements. - Returns (mapping, labels) or None. + Returns (mapping, labels) or None. The mapping is restricted to the fed reactants' atoms, so + reactor-created atoms (numbered outside the subset) are dropped and the result is directly + remappable onto the reactants. """ fgs = [r.functional_groups for r in subset] n = len(subset) + source = set().union(*subset) for name, fg_names, reactor in reaction_rules: if len(fg_names) != n: continue for perm in permutations(range(n)): if all(fg_names[i] in fgs[j] for i, j in enumerate(perm)): - for rxn in reactor(*(subset[j] for j in perm)): - p = rxn.products[0] - if x := _match(p, product): - return x, [f'react:{name}'] + for p in _reactor_products(reactor, *(subset[j] for j in perm)): + if x := p.get_fast_mapping(product): + return {a: x[a] for a in source if a in x}, [f'react:{name}'] break return None diff --git a/chython/files/libinchi/wrapper.py b/chython/files/libinchi/wrapper.py index f398dfe6..6c6a0272 100644 --- a/chython/files/libinchi/wrapper.py +++ b/chython/files/libinchi/wrapper.py @@ -526,15 +526,12 @@ class INCHIStructure(Structure): if platform == 'win-amd64': opt_flag = '/' libname = 'libinchi.dll' -elif platform == 'linux-x86_64': +elif platform.startswith('linux'): opt_flag = '-' libname = 'libinchi.so' -elif platform.startswith('macosx') and platform.endswith('x86_64'): - opt_flag = '-' - libname = 'libinchi.dynlib' elif platform.startswith('macosx'): opt_flag = '-' - libname = 'libinchi_arm64.dylib' + libname = 'libinchi.dylib' else: warn('unsupported platform for libinchi', ImportWarning) libname = None diff --git a/pyproject.toml b/pyproject.toml index 6de62697..717022cb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = 'chython' -version = '2.19' +version = '2.20' description = 'Library for processing molecules and reactions in python way' authors = ['Ramil Nugmanov '] license = 'LGPLv3'