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'