Repository: rdkit/rdkit — pull request #8406 (cpp)
Title: Fixes #8405

There's another special case that we need to cover. This adds it, along with some documentation.

Resolved issues:
title: Some tautomer pairs generating different HetAtomTautomerv2 hashes
body: I mentioned this issue in https://github.com/rdkit/rdkit/pull/8368.

Since that PR was merged, these two pairs of known tautomers generate different hashes:

```
In [5]: from rdkit import  Chem
   ...: from rdkit.Chem import rdMolHash

In [6]: pair1 = ("OC1=CC(C)N=C(C1)O", "O=C1CC(C)NC(=O)C1")

In [7]: pair2 = (r'Cc2cc(C)nc(N/C(N)=N\c1ccc(Cl)c(C(F)(F)F)c1)n2', r'Cc2cc(C)nc(/N=C(N)/Nc1ccc(Cl)c(C(F)(F)F)c1)n2')

In [8]: # we'd expect both tautomers to produce the same hash, which should be deduplicated by the set
   ...: set(rdMolHash.MolHash(Chem.MolFromSmiles(s), rdMolHash.HashFunction.HetAtomTautomerv2) for s in pair1)
Out[8]: 
{'[CH3]-[CH]1-[C]:[C](:[O]):[C]-[C](:[O]):[N]-1_5_0',
 '[CH3]-[C]1:[N]:[C](:[O])-[C]:[C](:[O]):[C]-1_6_0'}

In [9]: # these should also produce a single hash
   ...: set(rdMolHash.MolHash(Chem.MolFromSmiles(s), rdMolHash.HashFunction.HetAtomTautomerv2) for s in pair2)
Out[9]: 
{'[C]:[C]1:[C]:[C](:[C]):[N]:[C](:[N]-[C](-[NH2]):[N]:[C]2:[C]:[C]:[C](-[Cl]):[C](-[C](-[F])(-[F])-[F]):[C]:2):[N]:1_11_0',
 '[C]:[C]1:[C]:[C](:[C]):[N]:[C](:[N]:[C](-[NH2])-[N]:[C]2:[C]:[C]:[C](-[Cl]):[C](-[C](-[F])(-[F])-[F]):[C]:2):[N]:1_11_0'}
```

Also:
```
In [3]: rdMolHash.MolHash(Chem.MolFromSmiles('O=C1CC(C)NC(=O)C1'),rdMolHash.HashFunction.HetAtomTautomerv2)
Out[3]: '[CH3]-[CH]1-[C]:[C](:[O]):[C]-[C](:[O]):[N]-1_5_0'

In [4]: rdMolHash.MolHash(Chem.MolFromSmiles('OC1=CC(C)N=C(C1)O'),rdMolHash.HashFunction.HetAtomTautomerv2)
Out[4]: '[CH3]-[C]1:[N]:[C](:[O])-[C]:[C](:[O]):[C]-1_6_0'

In [5]: rdMolHash.MolHash(Chem.MolFromSmiles('O=C1CC(C)N=C(C1)O'),rdMolHash.HashFunction.HetAtomTautomerv2)
Out[5]: '[CH3]-[CH]1-[C]:[C](:[O]):[C]-[C](:[O]):[N]-1_5_0'

In [6]: _5 == _3
Out[6]: True

In [7]: rdMolHash.MolHash(Chem.MolFromSmiles('OC1=CC(C)NC(C1)=O'),rdMolHash.HashFunction.HetAtomTautomerv2)
Out[7]: '[CH3]-[CH]1-[C]:[C](:[O]):[C]-[C](:[O]):[N]-1_5_0'

In [8]: _7 == _3
Out[8]: True
```


Hints:

Fix patch:
diff --git a/Code/GraphMol/MolHash/hashfunctions.cpp b/Code/GraphMol/MolHash/hashfunctions.cpp
index f27e02cceeb..43a2209b4b3 100644
--- a/Code/GraphMol/MolHash/hashfunctions.cpp
+++ b/Code/GraphMol/MolHash/hashfunctions.cpp
@@ -395,7 +395,7 @@ std::vector<std::uint64_t> getBondFlags(const ROMol &mol) {
       "[C]-[c](:[o,n,s]):[o,n,s]",  //< a limited version of handling this for
                                     // aromatic systems
       "*[SD4](=*)=*",               // sulfates, sulfonyls, etc.
-      "*=[SD4](=*)*",               // the other side
+      "*=[SD4;$(S(=*)=*)](*)*",     // the other side
       "*-[H0]=,#[C,N]=,#*",         // isocyanates, azides, et al
       "*[#7+]-[O-]",                // nitro groups and aromatic n-oxides
       "[O,S;H]-P=O",                // phosphoric acid, etc.
@@ -757,6 +757,73 @@ std::string TautomerHashv2(RWMol *mol, bool proto, bool useCXSmiles,
 #endif
     }
   }
+
+  /*  Another situation we need to correct for is the following:
+
+   These two tautomers should be recognized as equivalent:
+
+      O8          O9
+      ||          |
+   C1-C2-C3-C4-N5=C6-C7
+
+      O8          O9
+      |           |
+   C1-C2=C3-C4-N5=C6-C7
+
+   In the first case neither the N5-C4 nor the C3-C4 bond is recognized as being
+   tautomeric since C4 does not have two conjugated neighbors, in the second
+   case the N5-C4 bond is recognized since C4 does have two conjugated
+   neighbors.
+
+
+   In order to fix this and other similar cases we need to check the neighboring
+   atoms of each start bond and check to see they are connected to other atoms
+   which are involved in bonds which are either in a tautomeric system already
+   (i.e. bondsToModify is set for them) or are start bonds.
+
+   So in the case of the first tautomer above, here we're going to check N5-C4,
+   and recognize that C4 has a neighbor (C3) with an adjacent tautomeric bond
+   (C3-C2). So we'll flag N5-C4 as being part of the tautomeric system.
+
+  */
+  for (const auto bptr : mol->bonds()) {
+    // If this is not a possible starting bond,
+    // then skip it
+    if (!startBonds[bptr->getIdx()]) {
+      continue;
+    }
+
+    for (const auto atm :
+         std::vector<const Atom *>{bptr->getBeginAtom(), bptr->getEndAtom()}) {
+      for (const auto nbrBond : mol->atomBonds(atm)) {
+        if (nbrBond == bptr || bondsToModify[nbrBond->getIdx()]) {
+          continue;
+        }
+        const auto oatom = nbrBond->getOtherAtom(atm);
+        if (!oatom->getTotalNumHs()) {
+          continue;
+        }
+        unsigned int numModifiedNeighbors = 0;
+        for (const auto nbr : mol->atomNeighbors(oatom)) {
+          if (nbr == atm) {
+            continue;
+          }
+          for (const auto nbnd : mol->atomBonds(nbr)) {
+            if (bondsToModify[nbnd->getIdx()] || startBonds[nbnd->getIdx()]) {
+              ++numModifiedNeighbors;
+              break;
+            }
+          }
+          if (numModifiedNeighbors) {
+            break;
+          }
+        }
+        if (numModifiedNeighbors) {
+          bondsToModify.set(nbrBond->getIdx());
+        }
+      }
+    }
+  }
 #ifdef VERBOSE_HASH
   std::cerr << "FINAL: " << bondsToModify << std::endl;
 #endif


Test patch:
diff --git a/Code/GraphMol/MolHash/catch_tests.cpp b/Code/GraphMol/MolHash/catch_tests.cpp
index d56436cfb70..35143fffa74 100644
--- a/Code/GraphMol/MolHash/catch_tests.cpp
+++ b/Code/GraphMol/MolHash/catch_tests.cpp
@@ -435,15 +435,17 @@ TEST_CASE("tautomer v2") {
              {}},
             {{"CC(=O)C=CC", "C=C(O)C=CC"}, {"CC(=O)CC=C"}},
             {{"N=C1N=CN(C)C2N=CNC=21", "NC1N=CN(C)C2=NC=NC2=1"}, {}},
-            {{
-                 "S=C1N=CN=C2NC=NC12",
-                 "S=C2C1N=CN=C1NC=N2",
-                 "S=C1NC=NC2N=CNC1=2",
-             },
-             {
-                 "S=C1N=CN=C2N=CNC12",
-                 "S=C2C1NC=NC1=NC=N2",
-             }},
+            {
+                {
+                    "S=C1N=CN=C2NC=NC12",
+                    "S=C2C1N=CN=C1NC=N2",
+                    "S=C1NC=NC2N=CNC1=2",
+                    "S=C1N=CN=C2N=CNC12",
+                    "S=C2C1NC=NC1=NC=N2",
+                    "S=C2C1NC=NC=1NC=N2",
+                },
+                {},
+            },
             {{"S=C1NC=NC2N=CNC1=2", "S=C1NC=NC2NC=NC1=2", "SC1=NC=NC2N=CNC1=2"},
              {}},
             {{"CC1=CN=CN1", "CC1CN=CN=1"}, {}},
@@ -1004,6 +1006,7 @@ M  END)CTAB"_ctab;
       REQUIRE(m);
       auto hsh =
           MolHash::MolHash(m.get(), MolHash::HashFunction::HetAtomTautomerv2);
+      INFO(hsh);
       CHECK(hsh.find("@") != std::string::npos);
     }
   }
@@ -1034,8 +1037,7 @@ TEST_CASE("examples found in ChEMBL") {
   SECTION("specific problems") {
     std::vector<std::pair<std::string, std::string>> data = {
         {"NNC(=O)CC1=NNC(=O)C1",
-         "[NH2]-[N]:[C](:[O])-[C]:[C]1:[C]-[C](:[O]):[N]-[N]:1_6_0"},
-
+         "[NH2]-[N]:[C](:[O]):[C]:[C]1:[C]:[C](:[O]):[N]:[N]:1_6_0"},
         {"Cc1ncn2c1NC=NC2N",
          "[C]:[C]1:[N]:[C]:[N]2:[C](-[NH2]):[N]:[C]:[N]:[C]:1:2_7_0"},
         {"Nc1nc2c(c(=O)[nH]1)CC=N2",
@@ -1054,4 +1056,32 @@ TEST_CASE("examples found in ChEMBL") {
       CHECK(hsh == ref);
     }
   }
+}
+
+TEST_CASE("github #8405: some tautomer mismatches") {
+  SECTION("things that should match") {
+    std::vector<std::vector<std::string>> smilesSets = {
+        {
+            "O=C1CC(C)NC(=O)C1",
+            "OC1=CC(C)N=C(C1)O",
+            "OC1=CC(C)NC(C1)=O",
+            "O=C1CC(C)N=C(C1)O",
+        },
+    };
+    for (const auto &smileses : smilesSets) {
+      auto m0 = v2::SmilesParse::MolFromSmiles(smileses[0]);
+      REQUIRE(m0);
+      auto hsh0 =
+          MolHash::MolHash(m0.get(), MolHash::HashFunction::HetAtomTautomerv2);
+      for (auto i = 1u; i < smileses.size(); ++i) {
+        auto smi = smileses[i];
+        INFO(smi);
+        auto m = v2::SmilesParse::MolFromSmiles(smi);
+        REQUIRE(m);
+        auto hsh =
+            MolHash::MolHash(m.get(), MolHash::HashFunction::HetAtomTautomerv2);
+        CHECK(hsh == hsh0);
+      }
+    }
+  }
 }
\ No newline at end of file