Repository: rdkit/rdkit — pull request #8368 (cpp)
Title: Fixes github #8205

Recent changes to the v2 tautomer hash (likely #8173) had introduced an order-dependence (ordering of the bonds, not the atoms) in the resutlts.

This removes that order dependence as well as expanding the classes of systems which are explicitly not considered for tautomerism.
 
In addition to the tests I added here, I tested this by running through ~1.4 million compounds from ChEMBL35 and ensuring that the two hashes here are the same:
```
    m = Chem.MolFromMolBlock(mb)
    if not m:
        continue
    smi = Chem.MolToSmiles(m)
    m2 = Chem.MolFromSmiles(smi)
    if not m2:
        continue
    if Chem.MolToSmiles(m2) != smi:
        # if we don't get the canonical SMILES right, we can give up here.
        continue
    h1 = rdMolHash.MolHash(m,rdMolHash.HashFunction.HetAtomTautomerv2)
    m2 = Chem.MolFromSmiles(smi)
    h2 = rdMolHash.MolHash(m2,rdMolHash.HashFunction.HetAtomTautomerv2)
```

Resolved issues:
title: order dependence in tautomer hash
body: **Describe the bug**
Here's the reproducible:
```
In [35]: print(b2)

     RDKit          2D

  0  0  0  0  0  0  0  0  0  0999 V3000
M  V30 BEGIN CTAB
M  V30 COUNTS 15 16 0 0 0
M  V30 BEGIN ATOM
M  V30 1 C -0.006857 -1.225143 0.000000 0
M  V30 2 C -1.242571 -0.508286 0.000000 0
M  V30 3 C -2.481143 -1.220000 0.000000 0
M  V30 4 C -2.484000 -2.648571 0.000000 0
M  V30 5 N -1.248286 -3.365429 0.000000 0
M  V30 6 C -0.009714 -2.653714 0.000000 0
M  V30 7 O -3.722571 -3.360572 0.000000 0
M  V30 8 N -1.239714 0.920286 0.000000 0
M  V30 9 C 1.549143 3.346571 0.000000 0
M  V30 10 C 2.260857 2.108000 0.000000 0
M  V30 11 C 1.302857 1.048286 0.000000 0
M  V30 12 C -0.001143 1.632000 0.000000 0
M  V30 13 C 0.151143 3.052571 0.000000 0
M  V30 14 C -1.251227 -4.793997 0.000000 0
M  V30 15 C -0.908467 4.010717 0.000000 0
M  V30 END ATOM
M  V30 BEGIN BOND
M  V30 1 1 1 2
M  V30 2 2 2 3
M  V30 3 1 3 4
M  V30 4 1 4 5
M  V30 5 1 5 6
M  V30 6 2 6 1
M  V30 7 2 4 7
M  V30 8 1 2 8
M  V30 9 1 9 10
M  V30 10 1 10 11
M  V30 11 1 11 12
M  V30 12 1 12 13
M  V30 13 1 13 9
M  V30 14 1 12 8 CFG=1
M  V30 15 1 5 14
M  V30 16 1 13 15
M  V30 END BOND
M  V30 END CTAB
M  END
$$$$

In [36]: m = Chem.MolFromMolBlock(b2)

In [37]: m2 = Chem.MolFromSmiles(Chem.MolToSmiles(m))

In [38]: rdMolHash.MolHash(m,rdMolHash.HashFunction.HetAtomTautomerv2)
Out[38]: '[CH3]-[CH]1-[CH2]-[CH2]-[CH2]-[C]-1:[N]:[C]1:[C]:[C]:[N](-[CH3]):[C](:[O]):[C]:1_5_0'

In [39]: rdMolHash.MolHash(m2,rdMolHash.HashFunction.HetAtomTautomerv2)
Out[39]: '[CH3]-[CH]1-[CH2]-[CH2]-[CH2]-[C@H]-1-[N]:[C]1:[C]:[C]:[N](-[CH3]):[C](:[O]):[C]:1_4_0'
```

Renumbering the atoms in m2 to match the numbering in m does not change the results for m2, so this seems to be related to the ordering of the bonds.

**Configuration (please complete the following information):**
 - RDKit version: master and current release
 - OS: all


Hints:

Fix patch:
diff --git a/Code/GraphMol/MolHash/hashfunctions.cpp b/Code/GraphMol/MolHash/hashfunctions.cpp
index c687eb6402f..f27e02cceeb 100644
--- a/Code/GraphMol/MolHash/hashfunctions.cpp
+++ b/Code/GraphMol/MolHash/hashfunctions.cpp
@@ -387,11 +387,20 @@ constexpr std::uint64_t bondFlagCarboxyl =
 std::vector<std::uint64_t> getBondFlags(const ROMol &mol) {
   // FIX: oversimplified, but should work for now
   static std::vector<std::string> patterns{
-      "[C;!$(C-C(=[NH])-[NH2])]-[C;!$(C(-C)(=[NH])-[NH2])](=[O,N,S])-[O,N,S]",  //< one side of the "amide", with an ugly exclusion for amidine
-      "[C;!$(C=[O,N,S])]-[O,N,S]-C=[O,N,S]",  //< the other side
+      "[C;!$(C-C(=[NH])-[NH2])]-[C;!$(C(-C)(=[NH])-[NH2])](=[O,N,S])-[O,N,S]",
+      //< one side of the "amide", with an ugly exclusion for amidine
+      "[A;!$(C=[O,N,S])]-[O,N,S]-C=[O,N,S]",  //< the other side
       "[OH0,SH0]-C=[O,N,S]",                  //< "esters" and "carboxyls"
+      "C-[N;$([N+]=C(N)(N)),$(N-C(N)=N)]",    //< quanidine like
       "[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
+      "*-[H0]=,#[C,N]=,#*",         // isocyanates, azides, et al
+      "*[#7+]-[O-]",                // nitro groups and aromatic n-oxides
+      "[O,S;H]-P=O",                // phosphoric acid, etc.
+      "[#6]-P=O",                   // phosphoric acid, etc.
+      "[#6]-N=[SD4]=*"              // don't know what this one is called
   };
   static std::vector<std::unique_ptr<RDKit::RWMol>> queries;
   if (queries.empty()) {
@@ -504,6 +513,41 @@ bool skipNeighborBond(const Atom *atom, const Atom *otherAtom,
            (!isUnsaturatedBond(nbrBond) && !nbrBond->getIsConjugated() &&
             !hasStartBond(atom, startBonds))));
 }
+
+// counts the number of neighboring atoms that have conjugated bonds.
+unsigned int getNumConjugatedNeighbors(
+    const Atom *atom, const boost::dynamic_bitset<> &startBonds) {
+  unsigned int res = 0;
+  for (auto nbr : atom->getOwningMol().atomNeighbors(atom)) {
+    for (auto nbrBond : atom->getOwningMol().atomBonds(nbr)) {
+      if (nbrBond->getIsConjugated() || startBonds[nbrBond->getIdx()]) {
+        ++res;
+        break;
+      }
+    }
+  }
+  return res;
+}
+// special case to prevent "overreach" with things like enamines.
+// the logic here prevents the first bond in CNC=C from being
+// included in the tautomeric system. So we get: [CH3]-[N]:[C]:[C]
+// instead of [C]:[N]:[C]:[C]
+// returns true if the bond is a candidate for overreach.
+// The arguments are, with atom indices corresponding to the example above:
+//   atom: atom 1
+//   oatom: atom 0
+//   bond: bond between atom 1 and atom 2
+//   nbrBond: bond between atom 1 and atom 0
+bool checkForOverreach(
+    const Atom *atom, const Atom *oatom, const Bond *bond, const Bond *nbrBond,
+    const boost::dynamic_bitset<> &startBonds,
+    const std::vector<unsigned int> &numConjugatedNeighbors) {
+  return (startBonds[bond->getIdx()] || hasStartBond(atom, startBonds)) &&
+         !startBonds[nbrBond->getIdx()] && isHeteroAtom(atom) &&
+         !isUnsaturatedBond(nbrBond) &&
+         numConjugatedNeighbors[oatom->getIdx()] < 2;
+}
+
 }  // namespace
 
 std::string TautomerHashv2(RWMol *mol, bool proto, bool useCXSmiles,
@@ -523,9 +567,15 @@ std::string TautomerHashv2(RWMol *mol, bool proto, bool useCXSmiles,
 
   boost::dynamic_bitset<> startBonds(mol->getNumBonds());
   for (const auto bnd : mol->bonds()) {
-    startBonds.set(bnd->getIdx(),
-                   isPossibleStartingBond(bnd, atomFlags, bondFlags));
+    auto isStartBond = isPossibleStartingBond(bnd, atomFlags, bondFlags);
+    startBonds.set(bnd->getIdx(), isStartBond);
   }
+  std::vector<unsigned int> numConjugatedNeighbors(mol->getNumAtoms(), 0);
+  for (const auto atm : mol->atoms()) {
+    numConjugatedNeighbors[atm->getIdx()] =
+        getNumConjugatedNeighbors(atm, startBonds);
+  }
+
 #ifdef VERBOSE_HASH
   std::cerr << " START BONDS: " << startBonds << std::endl;
 #endif
@@ -536,7 +586,11 @@ std::string TautomerHashv2(RWMol *mol, bool proto, bool useCXSmiles,
         !startBonds[bptr->getIdx()]) {
       continue;
     }
+#ifdef VERBOSE_HASH
+    std::cerr << "START BOND: " << bptr->getIdx() << std::endl;
+#endif
     boost::dynamic_bitset<> conjSystem(mol->getNumBonds());
+    boost::dynamic_bitset<> conjAtoms(mol->getNumAtoms());
     boost::dynamic_bitset<> atomsInSystem(mol->getNumAtoms());
     unsigned int numDonorCs = 0;
     unsigned int activeHeteroHs = 0;
@@ -557,31 +611,24 @@ std::string TautomerHashv2(RWMol *mol, bool proto, bool useCXSmiles,
         }
         auto oatom = nbrBond->getOtherAtom(atm);
 
-        // if the bond is unsaturated or to an atom with free Hs, include it:
-        // if (isUnsaturatedBond(nbrBond) || oatom->getTotalNumHs()) {
-
 #ifdef VERBOSE_HASH
         std::cerr << "  check neighbor1 " << nbrBond->getIdx() << " from "
                   << atm->getIdx() << "-" << oatom->getIdx() << std::endl;
         std::cerr << "    " << bondsConsidered[nbrBond->getIdx()] << " icao "
-                  << isCandidateAtom(oatom) << " hsbo "
-                  << hasStartBond(oatom, startBonds) << " atomunsato "
-                  << queryAtomUnsaturated(oatom) << " atomunsat "
-                  << queryAtomUnsaturated(atm) << " bondunsat "
+                  << isCandidateAtom(oatom, atomFlags) << " hsbo "
+                  << hasStartBond(oatom, startBonds) << " unsat "
                   << isUnsaturatedBond(nbrBond) << " icaa "
-                  << isCandidateAtom(atm) << " hsba "
-                  << hasStartBond(atm, startBonds) << std::endl;
+                  << isCandidateAtom(atm, atomFlags) << " hsba "
+                  << hasStartBond(atm, startBonds)
+                  << " ncno: " << numConjugatedNeighbors[oatom->getIdx()]
+                  << std::endl;
 #endif
-        // special case to prevent "overreach" with things like enamines.
-        // the logic here prevents the first bond in CNC=C from being included
-        // in the tautomeric system. So we get: [CH3]-[N]:[C] instead of
-        // [C]:[N]:[C]
-        if (startBonds[bptr->getIdx()] && isHeteroAtom(atm) &&
-            !isUnsaturatedBond(nbrBond)) {
+        if (checkForOverreach(atm, oatom, bptr, nbrBond, startBonds,
+                              numConjugatedNeighbors)) {
           continue;
         }
 
-        // if both bonds are not eligible, then we can skip this neighbor
+        // if the bond is not eligible, then we can skip this neighbor
         if (skipNeighborBond(atm, oatom, nbrBond, startBonds, atomFlags,
                              bondFlags) &&
             skipNeighborBond(oatom, atm, nbrBond, startBonds, atomFlags,
@@ -589,18 +636,21 @@ std::string TautomerHashv2(RWMol *mol, bool proto, bool useCXSmiles,
           continue;
         }
 
+        if (std::find(bq.begin(), bq.end(), nbrBond) == bq.end()) {
 #ifdef VERBOSE_HASH
-        std::cerr << "    push " << nbrBond->getIdx() << " "
-                  << nbrBond->getBeginAtomIdx() << "-"
-                  << nbrBond->getEndAtomIdx() << std::endl;
-        std::cerr << " #### SET1 " << bptr->getIdx() << std::endl;
+          std::cerr << "    push " << nbrBond->getIdx() << " "
+                    << nbrBond->getBeginAtomIdx() << "-"
+                    << nbrBond->getEndAtomIdx() << std::endl;
+          std::cerr << " #### SET1 " << bptr->getIdx() << std::endl;
 #endif
-        bq.push_back(nbrBond);
+          bq.push_back(nbrBond);
+        }
 
         // now we know that we should consider this bond
-
         bondsConsidered.set(bptr->getIdx());
         conjSystem.set(bptr->getIdx());
+        conjAtoms.set(bptr->getBeginAtomIdx());
+        conjAtoms.set(bptr->getEndAtomIdx());
       }
     }
 
@@ -618,6 +668,9 @@ std::string TautomerHashv2(RWMol *mol, bool proto, bool useCXSmiles,
 #endif
       bondsConsidered.set(bnd->getIdx());
       conjSystem.set(bnd->getIdx());
+      conjAtoms.set(bnd->getBeginAtomIdx());
+      conjAtoms.set(bnd->getEndAtomIdx());
+
       for (const auto atm :
            std::vector<const Atom *>{bnd->getBeginAtom(), bnd->getEndAtom()}) {
         if (atomsInSystem[atm->getIdx()]) {
@@ -633,7 +686,8 @@ std::string TautomerHashv2(RWMol *mol, bool proto, bool useCXSmiles,
           atomsInSystem.set(atm->getIdx());
         }
         for (auto nbrBnd : mol->atomBonds(atm)) {
-          if (nbrBnd == bnd) {
+          if (bondsConsidered[nbrBnd->getIdx()] ||
+              std::find(bq.begin(), bq.end(), nbrBnd) != bq.end()) {
             continue;
           }
           auto oatom = nbrBnd->getOtherAtom(atm);
@@ -642,23 +696,22 @@ std::string TautomerHashv2(RWMol *mol, bool proto, bool useCXSmiles,
           std::cerr << "  check neighbor " << nbrBnd->getIdx() << " from "
                     << atm->getIdx() << "-" << oatom->getIdx() << std::endl;
           std::cerr << "    " << bondsConsidered[nbrBnd->getIdx()] << " icao "
-                    << isCandidateAtom(oatom) << " hsbo "
+                    << isCandidateAtom(oatom, atomFlags) << " hsbo "
                     << hasStartBond(oatom, startBonds) << " unsat "
                     << isUnsaturatedBond(nbrBnd) << " icaa "
-                    << isCandidateAtom(atm) << " hsba "
-                    << hasStartBond(atm, startBonds) << std::endl;
+                    << isCandidateAtom(atm, atomFlags) << " hsba "
+                    << hasStartBond(atm, startBonds)
+                    << " ncno: " << numConjugatedNeighbors[oatom->getIdx()]
+                    << std::endl;
 #endif
-          if (bondsConsidered[nbrBnd->getIdx()] ||
-              (skipNeighborBond(atm, oatom, nbrBnd, startBonds, atomFlags,
+          if (checkForOverreach(atm, oatom, bnd, nbrBnd, startBonds,
+                                numConjugatedNeighbors)) {
+            continue;
+          }
+          if ((skipNeighborBond(atm, oatom, nbrBnd, startBonds, atomFlags,
                                 bondFlags) &&
                skipNeighborBond(oatom, atm, nbrBnd, startBonds, atomFlags,
                                 bondFlags))) {
-            // we won't add this bond for further traversal, but if both atoms
-            // are already in this system, then we should add the bond to the
-            // system
-            if (atomsInSystem[oatom->getIdx()]) {
-              conjSystem.set(nbrBnd->getIdx());
-            }
             continue;
           }
           bq.push_back(nbrBnd);
@@ -674,8 +727,30 @@ std::string TautomerHashv2(RWMol *mol, bool proto, bool useCXSmiles,
       std::cerr << "CONJ: " << conjSystem << " hetero " << activeHeteroHs
                 << " donor " << std::endl;
 #endif
-      bondsToModify |= conjSystem;
+      // all bonds between conjugated atoms in the system are considered to be
+      // in the system. There are situations where the traverse doesn't find the
+      // closure bond, so we explicitly check:
+      for (auto i = 0U; i < conjAtoms.size(); i++) {
+        if (conjAtoms[i]) {
+          for (const auto bnd : mol->atomBonds(mol->getAtomWithIdx(i))) {
+            if (conjAtoms[bnd->getOtherAtomIdx(i)]) {
+#ifdef VERBOSE_HASH
+              if (!conjSystem[bnd->getIdx()]) {
+                std::cerr << "  BACKFILL BOND: " << bnd->getIdx() << std::endl;
+              }
+#endif
+              bondsToModify.set(bnd->getIdx());
+            }
+          }
+        }
+      }
     } else {
+      // undo all the bonds we marked as considered in this conjugated system
+      for (unsigned int i = 0; i < mol->getNumBonds(); ++i) {
+        if (conjSystem[i]) {
+          bondsConsidered.reset(i);
+        }
+      }
 #ifdef VERBOSE_HASH
       std::cerr << "REJECT CONJ: " << conjSystem << " hetero " << activeHeteroHs
                 << " donor " << std::endl;


Test patch:
diff --git a/Code/GraphMol/MolHash/catch_tests.cpp b/Code/GraphMol/MolHash/catch_tests.cpp
index 4ce0046b456..d56436cfb70 100644
--- a/Code/GraphMol/MolHash/catch_tests.cpp
+++ b/Code/GraphMol/MolHash/catch_tests.cpp
@@ -411,6 +411,9 @@ TEST_CASE("tautomer v2") {
         data = {
             {{"CC=O", "C=CO"}, {}},
             {{"CCC=O", "CC=CO"}, {"C=CCO"}},
+            // this next one is the specific test to ensure that amino acid
+            // stereochemistry is not lost
+            {{"CCC(=O)NC", "CCC(O)=NC"}, {"CC=C(O)NC"}},
             {{"CC(=O)CC(=O)C", "C=C(O)CC(=O)C", "CC(=O)C=C(O)C",
               "C=C(O)C=C(O)C", "C=C(O)CC(O)=C"},
              {}},
@@ -435,10 +438,9 @@ TEST_CASE("tautomer v2") {
             {{
                  "S=C1N=CN=C2NC=NC12",
                  "S=C2C1N=CN=C1NC=N2",
-
+                 "S=C1NC=NC2N=CNC1=2",
              },
              {
-                 "S=C1NC=NC2N=CNC1=2",
                  "S=C1N=CN=C2N=CNC12",
                  "S=C2C1NC=NC1=NC=N2",
              }},
@@ -449,8 +451,9 @@ TEST_CASE("tautomer v2") {
                  "N1C(=O)NC(=O)C2C=NNC=21",
                  "N1C(=O)NC(=O)C2=CNNC2=1",
                  "N1C(=O)NC(=O)C2=CNNC2=1",
+                 "N1C(=O)NC(=O)C2CN=NC=21",
              },
-             {"N1C(=O)NC(=O)C2CN=NC=21", "N1C(=O)NC(=O)C2CN=NC2=1"}},
+             {"N1C(=O)NC(=O)C2CN=NC2=1"}},
             // ---------------------------
             // more stereochemistry
             // ---------------------------
@@ -932,4 +935,123 @@ TEST_CASE("v2 tautomers, carboxylic acids, amids, and related structures") {
       CHECK(hsh == ref);
     }
   }
+}
+
+TEST_CASE("github #8205: order dependence in tautomer hash") {
+  SECTION("as reported") {
+    auto mol1 = R"CTAB(
+     RDKit          2D
+
+  0  0  0  0  0  0  0  0  0  0999 V3000
+M  V30 BEGIN CTAB
+M  V30 COUNTS 15 16 0 0 0
+M  V30 BEGIN ATOM
+M  V30 1 C -0.006857 -1.225143 0.000000 0
+M  V30 2 C -1.242571 -0.508286 0.000000 0
+M  V30 3 C -2.481143 -1.220000 0.000000 0
+M  V30 4 C -2.484000 -2.648571 0.000000 0
+M  V30 5 N -1.248286 -3.365429 0.000000 0
+M  V30 6 C -0.009714 -2.653714 0.000000 0
+M  V30 7 O -3.722571 -3.360572 0.000000 0
+M  V30 8 N -1.239714 0.920286 0.000000 0
+M  V30 9 C 1.549143 3.346571 0.000000 0
+M  V30 10 C 2.260857 2.108000 0.000000 0
+M  V30 11 C 1.302857 1.048286 0.000000 0
+M  V30 12 C -0.001143 1.632000 0.000000 0
+M  V30 13 C 0.151143 3.052571 0.000000 0
+M  V30 14 C -1.251227 -4.793997 0.000000 0
+M  V30 15 C -0.908467 4.010717 0.000000 0
+M  V30 END ATOM
+M  V30 BEGIN BOND
+M  V30 1 1 1 2
+M  V30 2 2 2 3
+M  V30 3 1 3 4
+M  V30 4 1 4 5
+M  V30 5 1 5 6
+M  V30 6 2 6 1
+M  V30 7 2 4 7
+M  V30 8 1 2 8
+M  V30 9 1 9 10
+M  V30 10 1 10 11
+M  V30 11 1 11 12
+M  V30 12 1 12 13
+M  V30 13 1 13 9
+M  V30 14 1 12 8 CFG=1
+M  V30 15 1 5 14
+M  V30 16 1 13 15
+M  V30 END BOND
+M  V30 END CTAB
+M  END)CTAB"_ctab;
+    REQUIRE(mol1);
+    auto mol2 = v2::SmilesParse::MolFromSmiles(MolToSmiles(*mol1));
+    REQUIRE(mol2);
+    auto hsh1 =
+        MolHash::MolHash(mol1.get(), MolHash::HashFunction::HetAtomTautomerv2);
+    auto hsh2 =
+        MolHash::MolHash(mol2.get(), MolHash::HashFunction::HetAtomTautomerv2);
+    CHECK(hsh1 == hsh2);
+    // make sure the chirality wasn't destroyed
+    CHECK(hsh1.find("@") != std::string::npos);
+  }
+  SECTION("tests for the same issue from #8320 report") {
+    std::vector<std::string> smileses = {
+        "CC(=O)N[C@H](C)S(N)(=O)=O",
+        "O=P(O)(O)[C@@H](O)c1cccc2ccccc12",
+    };
+    for (const auto &smiles : smileses) {
+      INFO(smiles);
+      auto m = v2::SmilesParse::MolFromSmiles(smiles);
+      REQUIRE(m);
+      auto hsh =
+          MolHash::MolHash(m.get(), MolHash::HashFunction::HetAtomTautomerv2);
+      CHECK(hsh.find("@") != std::string::npos);
+    }
+  }
+}
+
+TEST_CASE("examples found in ChEMBL") {
+  SECTION("things that should match") {
+    std::vector<std::pair<std::string, std::string>> smileses = {
+        {"S1(Nc2ccccc2N1)(=O)(=O)", "O=S1(=O)Nc2ccccc2N1"},
+        {"c1ccccc1CN=C=S", "S=C=NCc1ccccc1"},
+        {"O=C1NCCC1", "OC1=NCCC1"},
+        {"c1no[n+]([O-])c1", "n1o[n+](cc1)[O-]"},
+    };
+    for (const auto &[smi1, smi2] : smileses) {
+      INFO(smi1 + " " + smi2);
+      auto m1 = v2::SmilesParse::MolFromSmiles(smi1);
+      REQUIRE(m1);
+      auto hsh1 =
+          MolHash::MolHash(m1.get(), MolHash::HashFunction::HetAtomTautomerv2);
+      auto m2 = v2::SmilesParse::MolFromSmiles(smi2);
+      REQUIRE(m2);
+      auto hsh2 =
+          MolHash::MolHash(m2.get(), MolHash::HashFunction::HetAtomTautomerv2);
+      CHECK(hsh1 == hsh2);
+    }
+  }
+
+  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"},
+
+        {"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",
+         "[N]:[C]1:[N]:[C](:[O]):[C]2:[C]:[C]:[N]:[C]:2:[N]:1_6_0"},
+        {"NC(N)=[N+]1CCc2ccccc2C1",
+         "[NH2]-[C](-[NH2])=[N+]1-[CH2]-[CH2]-[c]2:[cH]:[cH]:[cH]:[cH]:[c]:2-[CH2]-1_0_0"},
+        {"O=S1(=Nc2ccncc2)CCCCC1",
+         "[O]=[S]1(=[N]-[C]2:[C]:[C]:[N]:[C]:[C]:2)-[CH2]-[CH2]-[CH2]-[CH2]-[CH2]-1_4_0"},
+    };
+    for (const auto &[smiles, ref] : data) {
+      INFO(smiles);
+      auto m = v2::SmilesParse::MolFromSmiles(smiles);
+      REQUIRE(m);
+      auto hsh =
+          MolHash::MolHash(m.get(), MolHash::HashFunction::HetAtomTautomerv2);
+      CHECK(hsh == ref);
+    }
+  }
 }
\ No newline at end of file