Task: Add canonicalization of stereo groups (enhanced stereo) (rdkit/rdkit#6051)

org: rdkit
repo: rdkit
pr_number: 6051
issue_title: Add canonicalization of stereo groups (enhanced stereo)
issue_body: # What's changed?
In previous releases the SMILES generated for molecules with stereo groups was not canonical: the information from the stereo groups was not taken into account when determining the atom ordering and there was no handling of the fact that `C[C@H](F)O |o1:1|` and `C[C@@H](F)O |o1:1|` are the same thing. This resolves that.

# Changed results
SMILES (and SMILES-derived things like some of the mol hashes) for molecules with stereo groups will now be different if canonicalization and isomeric SMILES are enabled (the default).

# Questions for review
- We could theoretically not do the canonicalization of stereogroups if we're just generating SMILES (instead of CXSMILES), but this would have the consequence that the SMILES part of a CXSMILES wouldn't match the SMILES for the molecule. That feels ugly and wrong to me.

resolved_issues: number: 6045
title: EnumerateStereoisomers should remove non-abs enhanced stereo from results
body: **Describe the bug**

```
In [7]: m = Chem.MolFromSmiles('O[C@H](Br)[C@H](F)C |&1:1,3|')

In [8]: set(Chem.MolToCXSmiles(x) for x in EnumerateStereoisomers.EnumerateStereoisomers(m))
Out[8]: {'C[C@@H](F)[C@H](O)Br |&1:1,3|', 'C[C@H](F)[C@@H](O)Br |&1:1,3|'}
```
Given that one of the purposes of `EnumerateStereoisomers()` is to give all the individual stereoisomers which are described by a set of StereoGropus, I believe that the results should no longer have the enhanced stereo.

**Configuration (please complete the following information):**
 - RDKit version: release and master
 - OS: all


Reference solution:
fix_patch: diff --git a/Code/GraphMol/Canon.cpp b/Code/GraphMol/Canon.cpp
index b1c665fe844..55a80ec73c6 100644
--- a/Code/GraphMol/Canon.cpp
+++ b/Code/GraphMol/Canon.cpp
@@ -1187,6 +1187,7 @@ void canonicalizeFragment(ROMol &mol, int atomIdx,
           msI.obj.atom->getChiralTag() != Atom::CHI_UNSPECIFIED &&
           !msI.obj.atom->hasProp(common_properties::_brokenChirality)) {
         if (msI.obj.atom->hasProp(common_properties::_ringStereoAtoms)) {
+          // FIX: handle stereogroups here too
           if (!ringStereoChemAdjusted[msI.obj.atom->getIdx()]) {
             msI.obj.atom->setChiralTag(Atom::CHI_TETRAHEDRAL_CCW);
             ringStereoChemAdjusted.set(msI.obj.atom->getIdx());
@@ -1226,6 +1227,24 @@ void canonicalizeFragment(ROMol &mol, int atomIdx,
               ringStereoChemAdjusted.set(nbrIdx);
             }
           }
+        } else if (size_t sgidx;
+                   msI.obj.atom->getPropIfPresent("_stereoGroup", sgidx)) {
+          // make sure that the reference atom in the stereogroup is CCW
+          auto &sg = mol.getStereoGroups()[sgidx];
+          bool swapIt =
+              msI.obj.atom->getChiralTag() == Atom::CHI_TETRAHEDRAL_CW;
+          if (swapIt) {
+            msI.obj.atom->invertChirality();
+          }
+          if (swapIt || numSwapsChiralAtoms[msI.obj.atom->getIdx()]) {
+            for (auto at : sg.getAtoms()) {
+              if (at == msI.obj.atom) {
+                continue;
+              }
+              at->invertChirality();
+            }
+          }
+
         } else {
           if (msI.obj.atom->getChiralTag() == Atom::CHI_TETRAHEDRAL_CW ||
               msI.obj.atom->getChiralTag() == Atom::CHI_TETRAHEDRAL_CCW) {
@@ -1255,5 +1274,71 @@ void canonicalizeFragment(ROMol &mol, int atomIdx,
     std::cerr<<"----------------------------------------->"<<std::endl;
 #endif
 }
+void rankMolAtoms(const ROMol &mol, std::vector<unsigned int> &res,
+                  bool breakTies = true, bool includeChirality = true,
+                  bool includeIsotopes = true);
+void canonicalizeEnhancedStereo(ROMol &mol,
+                                const std::vector<unsigned int> *atomRanks) {
+  const auto &sgs = mol.getStereoGroups();
+  if (sgs.empty()) {
+    return;
+  }
+
+  std::vector<unsigned int> lranks;
+  if (!atomRanks) {
+    bool breakTies = true;
+    rankMolAtoms(mol, lranks, breakTies);
+    atomRanks = &lranks;
+  }
+  // one thing that makes this all easier is that the stereogroups are
+  // independent of each other
+  std::vector<StereoGroup> newSgs;
+  for (auto &sg : sgs) {
+    // we don't do anything to ABS groups
+    if (sg.getGroupType() == StereoGroupType::STEREO_ABSOLUTE) {
+      newSgs.push_back(sg);
+      continue;
+    }
+
+    // sort the atoms by rank:
+    auto getAtomRank = [&atomRanks](const Atom *at1, const Atom *at2) {
+      return atomRanks->at(at1->getIdx()) < atomRanks->at(at2->getIdx());
+    };
+    auto sgAtoms = sg.getAtoms();
+    std::sort(sgAtoms.begin(), sgAtoms.end(), getAtomRank);
+    // find the reference (lowest-ranked) atom
+    auto refAtom = sgAtoms.front();
+
+    // we will use CCW as the "canonical" state for chirality, so if the
+    // referenceAtom is already CCW then we don't need to do anything more with
+    // this stereogroup
+    auto refState = Atom::ChiralType::CHI_TETRAHEDRAL_CCW;
+#if 0
+    INT_LIST nbrs;
+    auto [beg, end] = mol.getAtomBonds(*refAtom);
+    while (beg != end) {
+      nbrs.push_back(mol[*beg++]->getIdx());
+    }
+    std::cerr << "!!!! " << (*refAtom)->getDegree() << " " << nbrs.size()
+              << std::endl;
+    // sort the neighbor indices by atom ranks
+    nbrs.sort([&atomRanks](auto i, auto j) {
+      return atomRanks->at(i) < atomRanks->at(j);
+    });
+    if ((*refAtom)->getPerturbationOrder(nbrs) % 1) {
+      refState = Atom::ChiralType::CHI_TETRAHEDRAL_CW;
+    }
+#endif
+    if (refAtom->getChiralTag() != refState) {
+      // we need to flip everyone... so loop over the other atoms and flip them
+      // all:
+      std::for_each(sgAtoms.begin(), sgAtoms.end(),
+                    [](auto atom) { atom->invertChirality(); });
+    }
+    newSgs.emplace_back(StereoGroup(sg.getGroupType(), std::move(sgAtoms)));
+    refAtom->setProp("_stereoGroup", newSgs.size() - 1, true);
+  }
+  mol.setStereoGroups(newSgs);
+}
 }  // namespace Canon
 }  // namespace RDKit
diff --git a/Code/GraphMol/Canon.h b/Code/GraphMol/Canon.h
index a51320fad86..5ec1ba50d21 100644
--- a/Code/GraphMol/Canon.h
+++ b/Code/GraphMol/Canon.h
@@ -125,6 +125,17 @@ RDKIT_GRAPHMOL_EXPORT bool chiralAtomNeedsTagInversion(const RDKit::ROMol &mol,
                                                        bool isAtomFirst,
                                                        size_t numClosures);
 
+//! Canonicalizes the atom stereo labels in enhanced stereo groups
+/*!
+
+  For example, after calling this function the chiral centers in the
+  molecules `C[C@H](F)Cl |&1:1|` and `C[C@@H](F)Cl |&1:1|` will have the same
+  chiral tags.
+
+*/
+RDKIT_GRAPHMOL_EXPORT void canonicalizeEnhancedStereo(
+    ROMol &mol, const std::vector<unsigned int> *atomRanks = nullptr);
+
 }  // end of namespace Canon
 }  // end of namespace RDKit
 #endif
diff --git a/Code/GraphMol/FileParsers/cdxml_parser_catch.cpp b/Code/GraphMol/FileParsers/cdxml_parser_catch.cpp
index e4f108ae2e0..ea1c930661b 100644
--- a/Code/GraphMol/FileParsers/cdxml_parser_catch.cpp
+++ b/Code/GraphMol/FileParsers/cdxml_parser_catch.cpp
@@ -411,9 +411,9 @@ TEST_CASE("CDXML") {
   SECTION("Enhanced Stereo") {
     auto fname = cdxmlbase + "beta-cypermethrin.cdxml";
     std::vector<std::string> expected = {
-        "CC1(C)[C@H](C=C(Cl)Cl)[C@H]1C(=O)O[C@@H](C#N)c1cccc(Oc2ccccc2)c1"};
+        "CC1(C)[C@H](C=C(Cl)Cl)[C@@H]1C(=O)O[C@H](C#N)c1cccc(Oc2ccccc2)c1"};
     std::vector<std::string> expected_cx = {
-        "CC1(C)[C@H](C=C(Cl)Cl)[C@H]1C(=O)O[C@@H](C#N)c1cccc(Oc2ccccc2)c1 |&1:3,&2:8,12|"};
+        "CC1(C)[C@H](C=C(Cl)Cl)[C@@H]1C(=O)O[C@H](C#N)c1cccc(Oc2ccccc2)c1 |&1:3,&2:8,12|"};
     auto mols = CDXMLFileToMols(fname);
     CHECK(mols.size() == expected.size());
     int i = 0;
@@ -426,9 +426,9 @@ TEST_CASE("CDXML") {
   SECTION("Enhanced Stereo 2") {
     auto fname = cdxmlbase + "beta-cypermethrin-or-abs.cdxml";
     std::vector<std::string> expected = {
-        "CC1(C)[C@H](C=C(Cl)Cl)[C@H]1C(=O)O[C@@H](C#N)c1cccc(Oc2ccccc2)c1"};
+        "CC1(C)[C@H](C=C(Cl)Cl)[C@@H]1C(=O)O[C@H](C#N)c1cccc(Oc2ccccc2)c1"};
     std::vector<std::string> expected_cx = {
-        "CC1(C)[C@H](C=C(Cl)Cl)[C@H]1C(=O)O[C@@H](C#N)c1cccc(Oc2ccccc2)c1 |o1:8,12|"};
+        "CC1(C)[C@H](C=C(Cl)Cl)[C@@H]1C(=O)O[C@H](C#N)c1cccc(Oc2ccccc2)c1 |o1:8,12|"};
     auto mols = CDXMLFileToMols(fname);
     CHECK(mols.size() == expected.size());
     int i = 0;
@@ -704,44 +704,40 @@ TEST_CASE("CDXML") {
     {
       auto fname = cdxmlbase + "stereo.cdxml";
       std::vector<std::string> expected = {
-        "C[C@@H](Cl)[C@H](N)O.C[C@@H](F)[C@H](N)O.C[C@H](Br)[C@@H](N)O.C[C@H](I)[C@@H](N)O"};
+          "C[C@@H](Cl)[C@H](N)O.C[C@@H](F)[C@H](N)O.C[C@H](Br)[C@@H](N)O.C[C@H](I)[C@@H](N)O"};
       auto mols = CDXMLFileToMols(fname);
       CHECK(mols.size() == expected.size());
       int i = 0;
       for (auto &mol : mols) {
-	CHECK(MolToSmiles(*mol) == expected[i++]);
+        CHECK(MolToSmiles(*mol) == expected[i++]);
       }
     }
     {
       auto fname = cdxmlbase + "wavy.cdxml";
-      std::vector<std::string> expected = {
-        "Cc1cccc(C)c1NC(=O)N=C1CCCN1C",
-        "Cc1cccc(C)c1NC(=O)/N=C1\\CCCN1C"
-      };
+      std::vector<std::string> expected = {"Cc1cccc(C)c1NC(=O)N=C1CCCN1C",
+                                           "Cc1cccc(C)c1NC(=O)/N=C1\\CCCN1C"};
       auto mols = CDXMLFileToMols(fname);
       CHECK(mols.size() == expected.size());
       int i = 0;
       for (auto &mol : mols) {
-          if (i == 0) {
-              CHECK(mol->getBondWithIdx(11)->getBondDir() == Bond::BondDir::EITHERDOUBLE);
-          }
-          CHECK(MolToSmiles(*mol) == expected[i++]);
-          
+        if (i == 0) {
+          CHECK(mol->getBondWithIdx(11)->getBondDir() ==
+                Bond::BondDir::EITHERDOUBLE);
+        }
+        CHECK(MolToSmiles(*mol) == expected[i++]);
       }
     }
     {
       auto fname = cdxmlbase + "wavy-single.cdxml";
-      std::vector<std::string> expected = {
-          "CCCC"
-      };
+      std::vector<std::string> expected = {"CCCC"};
       auto mols = CDXMLFileToMols(fname);
       CHECK(mols.size() == expected.size());
       int i = 0;
       for (auto &mol : mols) {
-          CHECK(mol->getBondWithIdx(0)->getBondDir() == Bond::BondDir::NONE);
-          CHECK(mol->getBondWithIdx(1)->getBondDir() == Bond::BondDir::NONE);
-          CHECK(mol->getBondWithIdx(2)->getBondDir() == Bond::BondDir::NONE);
-          CHECK(MolToSmiles(*mol) == expected[i++]);
+        CHECK(mol->getBondWithIdx(0)->getBondDir() == Bond::BondDir::NONE);
+        CHECK(mol->getBondWithIdx(1)->getBondDir() == Bond::BondDir::NONE);
+        CHECK(mol->getBondWithIdx(2)->getBondDir() == Bond::BondDir::NONE);
+        CHECK(MolToSmiles(*mol) == expected[i++]);
       }
     }
   }
diff --git a/Code/GraphMol/MolInterchange/molinterchange_catch.cpp b/Code/GraphMol/MolInterchange/molinterchange_catch.cpp
index 5df108945f8..b8caf502703 100644
--- a/Code/GraphMol/MolInterchange/molinterchange_catch.cpp
+++ b/Code/GraphMol/MolInterchange/molinterchange_catch.cpp
@@ -173,7 +173,7 @@ TEST_CASE("StereoGroups") {
     auto smi = MolToCXSmiles(*mols[0]);
     CHECK(
         smi ==
-        "C[C@@H]1C([C@H](O)F)O[C@H](C)C([C@@H](O)F)[C@@H]1C |a:7,o1:3,10,&1:1,&2:13|");
+        "C[C@H]1OC([C@H](O)F)[C@H](C)[C@H](C)C1[C@@H](O)F |a:1,o1:4,12,&1:7,&2:9|");
   }
   SECTION("writing multiple mols") {
     std::vector<ROMol *> mols{ormol.get(), andmol.get()};
diff --git a/Code/GraphMol/RGroupDecomposition/catch_rgd.cpp b/Code/GraphMol/RGroupDecomposition/catch_rgd.cpp
index be248c5a147..9f6b099c444 100644
--- a/Code/GraphMol/RGroupDecomposition/catch_rgd.cpp
+++ b/Code/GraphMol/RGroupDecomposition/catch_rgd.cpp
@@ -462,22 +462,22 @@ TEST_CASE("substructure parameters and RGD: enhanced stereo") {
       CHECK(flatten_whitespace(toJSON(rows)) == flatten_whitespace(R"JSON(
 [
   {
-    "Core":"C1C[C@@]([*:1])([*:2])N1",
+    "Core":"C1C[C@]([*:1])([*:2])N1",
     "R1":"F[*:1]",
     "R2":"[H][*:2]"
   },
   {
-    "Core":"C1C[C@@]([*:1])([*:2])N1",
+    "Core":"C1C[C@]([*:1])([*:2])N1",
     "R1":"O[*:1]",
     "R2":"F[*:2]"
   },
   {
-    "Core":"C1C[C@@]([*:1])([*:2])N1",
+    "Core":"C1C[C@]([*:1])([*:2])N1",
     "R1":"[H][*:1]",
     "R2":"F[*:2]"
   },
   {
-    "Core":"C1C[C@@]([*:1])([*:2])N1",
+    "Core":"C1C[C@]([*:1])([*:2])N1",
     "R1":"Cl[*:1]",
     "R2":"[H][*:2]"
   }
@@ -496,22 +496,22 @@ TEST_CASE("substructure parameters and RGD: enhanced stereo") {
       CHECK(flatten_whitespace(toJSON(rows)) == flatten_whitespace(R"JSON(
 [
   {
-    "Core":"C1C[C@@]([*:1])([*:2])N1",
+    "Core":"C1C[C@]([*:1])([*:2])N1",
     "R1":"F[*:1]",
     "R2":"[H][*:2]"
   },
   {
-    "Core":"C1C[C@@]([*:1])([*:2])N1",
+    "Core":"C1C[C@]([*:1])([*:2])N1",
     "R1":"O[*:1]",
     "R2":"F[*:2]"
   },
   {
-    "Core":"C1C[C@@]([*:1])([*:2])N1",
+    "Core":"C1C[C@]([*:1])([*:2])N1",
     "R1":"[H][*:1]",
     "R2":"F[*:2]"
   },
   {
-    "Core":"C1C[C@@]([*:1])([*:2])N1",
+    "Core":"C1C[C@]([*:1])([*:2])N1",
     "R1":"Cl[*:1]",
     "R2":"[H][*:2]"
   }
@@ -537,7 +537,7 @@ TEST_CASE("substructure parameters and RGD: enhanced stereo") {
       CHECK(flatten_whitespace(toJSON(rows)) == flatten_whitespace(R"JSON(
 [
   {
-    "Core":"C1C[C@@]([*:1])([*:2])N1",
+    "Core":"C1C[C@]([*:1])([*:2])N1",
     "R1":"F[*:1]",
     "R2":"[H][*:2]"
   },
@@ -565,7 +565,7 @@ TEST_CASE("substructure parameters and RGD: enhanced stereo") {
       CHECK(flatten_whitespace(toJSON(rows)) == flatten_whitespace(R"JSON(
 [
   {
-    "Core":"C1C[C@@]([*:1])([*:2])N1",
+    "Core":"C1C[C@]([*:1])([*:2])N1",
     "R1":"F[*:1]",
     "R2":"[H][*:2]"
   },
@@ -580,7 +580,7 @@ TEST_CASE("substructure parameters and RGD: enhanced stereo") {
     "R2":"[H][*:2]"
   },
   {
-    "Core":"C1C[C@@]([*:1])([*:2])N1",
+    "Core":"C1C[C@]([*:1])([*:2])N1",
     "R1":"Cl[*:1]",
     "R2":"[H][*:2]"
   }
diff --git a/Code/GraphMol/SmilesParse/SmilesWrite.cpp b/Code/GraphMol/SmilesParse/SmilesWrite.cpp
index 0e544d001b8..7da43dc0f1e 100644
--- a/Code/GraphMol/SmilesParse/SmilesWrite.cpp
+++ b/Code/GraphMol/SmilesParse/SmilesWrite.cpp
@@ -366,6 +366,10 @@ std::string FragmentSmilesConstruct(
   }
   std::list<unsigned int> ringClosuresToErase;
 
+  if (params.canonical && params.doIsomericSmiles) {
+    Canon::canonicalizeEnhancedStereo(mol, &ranks);
+  }
+
   Canon::canonicalizeFragment(mol, atomIdx, colors, ranks, molStack,
                               bondsInPlay, bondSymbols, params.doIsomericSmiles,
                               params.doRandom);
diff --git a/Code/GraphMol/Wrap/rdmolfiles.cpp b/Code/GraphMol/Wrap/rdmolfiles.cpp
index bc56b785c54..d89e571cd9f 100644
--- a/Code/GraphMol/Wrap/rdmolfiles.cpp
+++ b/Code/GraphMol/Wrap/rdmolfiles.cpp
@@ -18,6 +18,7 @@
 #include <GraphMol/RDKitBase.h>
 #include <GraphMol/MolOps.h>
 #include <GraphMol/new_canon.h>
+#include <GraphMol/Canon.h>
 #include <GraphMol/SmilesParse/SmilesParse.h>
 #include <GraphMol/SmilesParse/SmilesWrite.h>
 #include <GraphMol/SmilesParse/SmartsWrite.h>
@@ -657,6 +658,10 @@ python::dict MetadataFromPNGString(python::object png) {
   return translateMetadata(metadata);
 }
 
+void CanonicalizeEnhancedStereo(ROMol &mol) {
+  Canon::canonicalizeEnhancedStereo(mol);
+}
+
 }  // namespace RDKit
 
 // MolSupplier stuff
@@ -1908,6 +1913,9 @@ BOOST_PYTHON_MODULE(rdmolfiles) {
        python::arg("includeIsotopes") = true),
       docString.c_str());
 
+  python::def("CanonicalizeEnhancedStereo", CanonicalizeEnhancedStereo,
+              (python::arg("mol")));
+
   python::def(
       "CreateAtomIntPropertyList", FileParserUtils::createAtomIntPropertyList,
       (python::arg("mol"), python::arg("propName"),
diff --git a/Code/GraphMol/catch_canon.cpp b/Code/GraphMol/catch_canon.cpp
index a24c67d71a5..c468a6fcf3a 100644
--- a/Code/GraphMol/catch_canon.cpp
+++ b/Code/GraphMol/catch_canon.cpp
@@ -15,6 +15,7 @@
 #include <GraphMol/Chirality.h>
 #include <GraphMol/new_canon.h>
 #include <GraphMol/MolOps.h>
+#include <GraphMol/Canon.h>
 
 #include <GraphMol/FileParsers/FileParsers.h>
 #include <GraphMol/FileParsers/MolFileStereochem.h>
@@ -153,16 +154,10 @@ TEST_CASE("double bond stereo and canonicalization") {
     bool breakTies = false;
     std::vector<unsigned int> ranks;
     Canon::rankMolAtoms(*mol, ranks, breakTies);
-    std::copy(ranks.begin(), ranks.end(),
-              std::ostream_iterator<unsigned int>(std::cerr, " "));
-    std::cerr << std::endl;
     mol->getBondWithIdx(1)->setStereo(Bond::BondStereo::STEREOCIS);
     mol->getBondWithIdx(7)->setStereo(Bond::BondStereo::STEREOTRANS);
     std::vector<unsigned int> ranks2;
     Canon::rankMolAtoms(*mol, ranks2, breakTies);
-    std::copy(ranks2.begin(), ranks2.end(),
-              std::ostream_iterator<unsigned int>(std::cerr, " "));
-    std::cerr << std::endl;
     CHECK(ranks[0] == ranks2[9]);
     CHECK(ranks[1] == ranks2[8]);
     CHECK(ranks[2] == ranks2[6]);
@@ -175,9 +170,6 @@ TEST_CASE("double bond stereo and canonicalization") {
     mol->getBondWithIdx(7)->setStereo(Bond::BondStereo::STEREOCIS);
     std::vector<unsigned int> ranks3;
     Canon::rankMolAtoms(*mol, ranks3, breakTies);
-    std::copy(ranks3.begin(), ranks3.end(),
-              std::ostream_iterator<unsigned int>(std::cerr, " "));
-    std::cerr << std::endl;
     CHECK(ranks2 == ranks3);
   }
   SECTION("STEREOANY is higher priority than STEREONONE") {
@@ -188,9 +180,171 @@ TEST_CASE("double bond stereo and canonicalization") {
     bool breakTies = false;
     std::vector<unsigned int> ranks;
     Canon::rankMolAtoms(*mol, ranks, breakTies);
-    std::copy(ranks.begin(), ranks.end(),
-              std::ostream_iterator<unsigned int>(std::cerr, " "));
-    std::cerr << std::endl;
     CHECK(ranks[0] < ranks[9]);
   }
-}
\ No newline at end of file
+}
+
+TEST_CASE("enhanced stereo canonicalization") {
+  SECTION("simple chiral tags") {
+    std::vector<std::pair<std::string, std::string>> tests = {
+        {"C[C@H](F)Cl |&1:1|", "C[C@@H](F)Cl |&1:1|"},
+        {"C[C@H](F)Cl |o1:1|", "C[C@@H](F)Cl |o1:1|"},
+    };
+    for (const auto& [smi1, smi2] : tests) {
+      INFO(smi1 + " : " + smi2);
+      std::unique_ptr<RWMol> mol1{SmilesToMol(smi1)};
+      REQUIRE(mol1);
+      std::unique_ptr<RWMol> mol2{SmilesToMol(smi2)};
+      REQUIRE(mol2);
+
+      Canon::canonicalizeEnhancedStereo(*mol1);
+      Canon::canonicalizeEnhancedStereo(*mol2);
+
+      CHECK(mol1->getAtomWithIdx(1)->getChiralTag() ==
+            mol2->getAtomWithIdx(1)->getChiralTag());
+    }
+  }
+  SECTION("abs groups are not modified") {
+    std::vector<std::pair<std::string, std::string>> tests = {
+        {"C[C@H](F)Cl |a:1|", "C[C@@H](F)Cl |a:1|"},
+    };
+    for (const auto& [smi1, smi2] : tests) {
+      INFO(smi1 + " : " + smi2);
+      std::unique_ptr<RWMol> mol1{SmilesToMol(smi1)};
+      REQUIRE(mol1);
+      std::unique_ptr<RWMol> mol2{SmilesToMol(smi2)};
+      REQUIRE(mol2);
+
+      Canon::canonicalizeEnhancedStereo(*mol1);
+      Canon::canonicalizeEnhancedStereo(*mol2);
+
+      CHECK(mol1->getAtomWithIdx(1)->getChiralTag() !=
+            mol2->getAtomWithIdx(1)->getChiralTag());
+    }
+  }
+  SECTION("relative chiral tags") {
+    std::vector<std::pair<std::string, std::string>> tests = {
+        {"C[C@H](F)[C@H](Br)O |&1:1,3|", "C[C@@H](F)[C@@H](Br)O |&1:1,3|"},
+        {"C[C@H](F)[C@@H](Br)O |&1:1,3|", "C[C@@H](F)[C@H](Br)O |&1:1,3|"},
+        {"O[C@H](Br)[C@H](F)C |&1:1,3|", "O[C@@H](Br)[C@@H](F)C |&1:1,3|"},
+        {"O[C@H](Br)[C@@H](F)C |&1:1,3|", "O[C@@H](Br)[C@H](F)C |&1:1,3|"},
+    };
+    for (const auto& [smi1, smi2] : tests) {
+      INFO(smi1 + " : " + smi2);
+      std::unique_ptr<RWMol> mol1{SmilesToMol(smi1)};
+      REQUIRE(mol1);
+      std::unique_ptr<RWMol> mol2{SmilesToMol(smi2)};
+      REQUIRE(mol2);
+
+      Canon::canonicalizeEnhancedStereo(*mol1);
+      Canon::canonicalizeEnhancedStereo(*mol2);
+
+      CHECK(mol1->getAtomWithIdx(1)->getChiralTag() ==
+            mol2->getAtomWithIdx(1)->getChiralTag());
+      CHECK(mol1->getAtomWithIdx(3)->getChiralTag() ==
+            mol2->getAtomWithIdx(3)->getChiralTag());
+    }
+  }
+  SECTION("multiple groups") {
+    std::vector<std::pair<std::string, std::string>> tests = {
+        {"C[C@H](F)[C@H](Br)O |&1:1,o2:3|",
+         "C[C@@H](F)[C@@H](Br)O |&1:1,o2:3|"},
+        {"C[C@H](F)[C@H](Br)O |&1:1,o2:3|",
+         "C[C@@H](F)[C@@H](Br)O |o1:3,&1:1|"},
+    };
+    for (const auto& [smi1, smi2] : tests) {
+      INFO(smi1 + " : " + smi2);
+      std::unique_ptr<RWMol> mol1{SmilesToMol(smi1)};
+      REQUIRE(mol1);
+      std::unique_ptr<RWMol> mol2{SmilesToMol(smi2)};
+      REQUIRE(mol2);
+
+      Canon::canonicalizeEnhancedStereo(*mol1);
+      Canon::canonicalizeEnhancedStereo(*mol2);
+
+      CHECK(mol1->getAtomWithIdx(1)->getChiralTag() ==
+            mol2->getAtomWithIdx(1)->getChiralTag());
+      CHECK(mol1->getAtomWithIdx(3)->getChiralTag() ==
+            mol2->getAtomWithIdx(3)->getChiralTag());
+    }
+  }
+}
+
+TEST_CASE("using enhanced stereo in rankMolAtoms") {
+  SECTION("basics: different ranks") {
+    std::vector<std::string> smis{
+        "C[C@H](F)[C@@H](F)C |a:1|",
+        "C[C@H](F)[C@@H](F)C |&1:1|"
+        "C[C@H](F)[C@@H](F)C |o1:1|",
+        "C[C@H](F)[C@@H](F)C |o1:1,a:3|",
+        "C[C@H](F)[C@@H](F)C |o1:1,&:3|",
+        "C[C@H](F)[C@@H](F)C |a:1,&2:3|",
+    };
+    for (auto& smi : smis) {
+      INFO(smi);
+      std::unique_ptr<RWMol> mol{SmilesToMol(smi)};
+      REQUIRE(mol);
+      bool breakTies = false;
+      std::vector<unsigned int> atomRanks;
+      Canon::rankMolAtoms(*mol, atomRanks, breakTies);
+      CHECK(atomRanks[1] != atomRanks[3]);
+    }
+  }
+  SECTION("basics: same ranks") {
+    std::vector<std::string> smis{
+        "C[C@H](F)[C@@H](F)C |o1:1,o2:3|",
+        "C[C@H](F)[C@@H](F)C |&1:1,&2:3|",
+        "C[C@H](F)[C@@H](F)C |a:1,a:3|",
+    };
+    for (auto& smi : smis) {
+      INFO(smi);
+      std::unique_ptr<RWMol> mol{SmilesToMol(smi)};
+      REQUIRE(mol);
+      bool breakTies = false;
+      std::vector<unsigned int> atomRanks;
+      Canon::rankMolAtoms(*mol, atomRanks, breakTies);
+      CHECK(atomRanks[1] == atomRanks[3]);
+    }
+  }
+  SECTION("more complex, include group membership") {
+    auto m1 =
+        "C[C@@H]1CC(C[C@@H](C)[C@@H](C)O)C[C@H](C)C1 |o1:1,7,o2:5,11|"_smiles;
+    REQUIRE(m1);
+    std::vector<unsigned int> atomRanks;
+    bool breakTies = false;
+    Canon::rankMolAtoms(*m1, atomRanks, breakTies);
+    CHECK(atomRanks[11] > atomRanks[1]);
+
+    auto m2 =
+        "C[C@H](O)[C@H](C)CC1C[C@H](C)C[C@@H](C)C1 |o1:1,8;o2:3,11|"_smiles;
+    REQUIRE(m2);
+    Canon::rankMolAtoms(*m2, atomRanks, breakTies);
+    CHECK(atomRanks[11] > atomRanks[8]);
+  }
+}
+
+TEST_CASE("more enhanced stereo canonicalization") {
+  // FIX: add tests for ring stereo in an s group
+  SECTION("case 1") {
+    auto m1 =
+        "C[C@@H](O)[C@H](C)[C@@H](C)[C@@H](C)F |a:3,&1:1,7,&2:5,r|"_smiles;
+    REQUIRE(m1);
+    auto m2 = "C[C@H](O)[C@H](C)[C@@H](C)[C@H](C)F |a:3,&1:1,7,&2:5,r|"_smiles;
+    REQUIRE(m2);
+    Canon::canonicalizeEnhancedStereo(*m1);
+    Canon::canonicalizeEnhancedStereo(*m2);
+    CHECK(MolToCXSmiles(*m1) == MolToCXSmiles(*m2));
+  }
+  SECTION("case 2") {
+    auto m1 =
+        "C[C@@H](O)[C@H](C)[C@@H](C)[C@@H](C)O |&3:3,5,o1:7,&2:1,r|"_smiles;
+    REQUIRE(m1);
+    auto m2 =
+        "C[C@@H](O)[C@H](C)[C@@H](C)[C@@H](C)O |&3:3,5,&2:7,o1:1,r|"_smiles;
+    REQUIRE(m2);
+    Canon::canonicalizeEnhancedStereo(*m1);
+    Canon::canonicalizeEnhancedStereo(*m2);
+
+    CHECK(MolToCXSmiles(*m1) == MolToCXSmiles(*m2));
+  }
+}
diff --git a/Code/GraphMol/catch_graphmol.cpp b/Code/GraphMol/catch_graphmol.cpp
index 7608f800e81..6dbbbed327b 100644
--- a/Code/GraphMol/catch_graphmol.cpp
+++ b/Code/GraphMol/catch_graphmol.cpp
@@ -2018,7 +2018,7 @@ TEST_CASE("github #4071: StereoGroups not preserved by RenumberAtoms()",
             mol->getStereoGroups()[i].getGroupType());
     }
     CHECK(MolToCXSmiles(*nmol) ==
-          "C[C@H]([C@@H](C)[C@@H](C)O)[C@@H](C)O |o1:7,&1:1,&2:2,&3:4|");
+          "C[C@H](O)[C@H](C)[C@H](C)[C@H](C)O |o1:1,&1:3,&2:5,&3:7|");
   }
 }
 
diff --git a/Code/GraphMol/new_canon.cpp b/Code/GraphMol/new_canon.cpp
index 0286384f1dc..c489bfa8081 100644
--- a/Code/GraphMol/new_canon.cpp
+++ b/Code/GraphMol/new_canon.cpp
@@ -553,6 +553,16 @@ void initCanonAtoms(const ROMol &mol, std::vector<Canon::canon_atom> &atoms,
     atoms[i].bonds.reserve(atoms[i].degree);
     getBonds(mol, atoms[i].atom, atoms[i].bonds, includeChirality, atoms);
   }
+  if (includeChirality) {
+    unsigned int sgidx = 1;
+    for (auto &sg : mol.getStereoGroups()) {
+      for (auto atom : sg.getAtoms()) {
+        atoms[atom->getIdx()].whichStereoGroup = sgidx;
+        atoms[atom->getIdx()].typeOfStereoGroup = sg.getGroupType();
+      }
+      ++sgidx;
+    }
+  }
 }
 namespace detail {
 
diff --git a/Code/GraphMol/new_canon.h b/Code/GraphMol/new_canon.h
index 53853ed9062..1e7e6fead7f 100644
--- a/Code/GraphMol/new_canon.h
+++ b/Code/GraphMol/new_canon.h
@@ -13,6 +13,7 @@
 #include <RDGeneral/hanoiSort.h>
 #include <GraphMol/ROMol.h>
 #include <GraphMol/RingInfo.h>
+#include <GraphMol/StereoGroup.h>
 #include <RDGeneral/BoostStartInclude.h>
 #include <cstdint>
 #include <boost/dynamic_bitset.hpp>
@@ -96,14 +97,15 @@ struct RDKIT_GRAPHMOL_EXPORT bondholder {
     return 0;
   }
 };
-class RDKIT_GRAPHMOL_EXPORT canon_atom {
- public:
+struct RDKIT_GRAPHMOL_EXPORT canon_atom {
   const Atom *atom{nullptr};
   int index{-1};
   unsigned int degree{0};
   unsigned int totalNumHs{0};
   bool hasRingNbr{false};
   bool isRingStereoAtom{false};
+  unsigned int whichStereoGroup{0};
+  StereoGroupType typeOfStereoGroup{StereoGroupType::STEREO_ABSOLUTE};
   int *nbrIds{nullptr};
   const std::string *p_symbol{
       nullptr};  // if provided, this is used to order atoms
@@ -380,30 +382,67 @@ class RDKIT_GRAPHMOL_EXPORT AtomCompareFunctor {
     }
     // chirality if we're using it
     if (df_useChirality) {
-      // first atom stereochem:
-      ivi = 0;
-      ivj = 0;
-      // can't actually use values here, because they are arbitrary
-      ivi = dp_atoms[i].atom->getChiralTag() != 0;
-      ivj = dp_atoms[j].atom->getChiralTag() != 0;
-      if (ivi < ivj) {
-        return -1;
-      } else if (ivi > ivj) {
-        return 1;
-      }
-      // stereo set
-      if (ivi && ivj) {
-        if (ivi) {
-          ivi = getChiralRank(dp_mol, dp_atoms, i);
-        }
-        if (ivj) {
-          ivj = getChiralRank(dp_mol, dp_atoms, j);
+      // look at enhanced stereo
+      ivi = dp_atoms[i].whichStereoGroup;  // can't use the index itself, but if
+                                           // it's set then we're in an SG
+      ivj = dp_atoms[j].whichStereoGroup;
+      if (ivi || ivj) {
+        if (ivi && !ivj) {
+          return 1;
+        } else if (ivj && !ivi) {
+          return -1;
+        } else if (ivi && ivj) {
+          ivi = static_cast<unsigned int>(dp_atoms[i].typeOfStereoGroup);
+          ivj = static_cast<unsigned int>(dp_atoms[j].typeOfStereoGroup);
+          if (ivi < ivj) {
+            return -1;
+          } else if (ivi > ivj) {
+            return 1;
+          }
+          ivi = dp_atoms[i].whichStereoGroup - 1;
+          ivj = dp_atoms[j].whichStereoGroup - 1;
+          // now check the current classes of the other members of the SG
+          std::set<unsigned int> sgi;
+          for (auto sgat : dp_mol->getStereoGroups()[ivi].getAtoms()) {
+            sgi.insert(dp_atoms[sgat->getIdx()].index);
+          }
+          std::set<unsigned int> sgj;
+          for (auto sgat : dp_mol->getStereoGroups()[ivj].getAtoms()) {
+            sgj.insert(dp_atoms[sgat->getIdx()].index);
+          }
+          if (sgi < sgj) {
+            return -1;
+          } else if (sgi > sgj) {
+            return 1;
+          }
         }
+      } else {
+        // if there's no stereogroup, then use whatever atom stereochem is
+        // specfied:
+        ivi = 0;
+        ivj = 0;
+        // can't actually use values here, because they are arbitrary
+        ivi = dp_atoms[i].atom->getChiralTag() != 0;
+        ivj = dp_atoms[j].atom->getChiralTag() != 0;
         if (ivi < ivj) {
           return -1;
         } else if (ivi > ivj) {
           return 1;
         }
+        // stereo set
+        if (ivi && ivj) {
+          if (ivi) {
+            ivi = getChiralRank(dp_mol, dp_atoms, i);
+          }
+          if (ivj) {
+            ivj = getChiralRank(dp_mol, dp_atoms, j);
+          }
+          if (ivi < ivj) {
+            return -1;
+          } else if (ivi > ivj) {
+            return 1;
+          }
+        }
       }
     }
 
diff --git a/Code/PgSQL/rdkit/expected/rdkit-91.out b/Code/PgSQL/rdkit/expected/rdkit-91.out
index 0c148561aaa..88fe41ff2ad 100644
--- a/Code/PgSQL/rdkit/expected/rdkit-91.out
+++ b/Code/PgSQL/rdkit/expected/rdkit-91.out
@@ -1340,15 +1340,15 @@ select mol_to_smarts(mol_adjust_query_properties('*c1ncc(*)cc1'::qmol));
 
 -- CXSmiles
 SELECT mol_to_smiles(mol_from_smiles('C[C@H](F)[C@H](C)[C@@H](C)Br |a:1,o1:3,5|'));
-         mol_to_smiles         
--------------------------------
- C[C@@H]([C@H](C)F)[C@@H](C)Br
+        mol_to_smiles         
+------------------------------
+ C[C@H](F)[C@@H](C)[C@H](C)Br
 (1 row)
 
 SELECT mol_to_cxsmiles(mol_from_smiles('C[C@H](F)[C@H](C)[C@@H](C)Br |a:1,o1:3,5|'));
-              mol_to_cxsmiles               
---------------------------------------------
- C[C@@H]([C@H](C)F)[C@@H](C)Br |a:2,o1:1,5|
+              mol_to_cxsmiles              
+-------------------------------------------
+ C[C@H](F)[C@@H](C)[C@H](C)Br |a:1,o1:3,5|
 (1 row)
 
 SELECT mol_to_cxsmarts(mol_from_smiles('C[C@H](F)[C@H](C)[C@@H](C)Br |a:1,o1:3,5|'));
@@ -1365,9 +1365,9 @@ SELECT mol_to_cxsmarts(qmol_from_smarts('C[C@H]([F,Cl,Br])[C@H](C)[C@@H](C)Br |a
 
 -- CXSmiles from mol_out
 SELECT mol_out(mol_from_smiles('C[C@H](F)[C@H](C)[C@@H](C)Br |a:1,o1:3,5|'));
-                  mol_out                   
---------------------------------------------
- C[C@@H]([C@H](C)F)[C@@H](C)Br |a:2,o1:1,5|
+                  mol_out                  
+-------------------------------------------
+ C[C@H](F)[C@@H](C)[C@H](C)Br |a:1,o1:3,5|
 (1 row)
 
 -- github #3688: bad input to qmol_from_ctab() crashes db
diff --git a/Docs/Book/RDKit_Book.rst b/Docs/Book/RDKit_Book.rst
index cf47872b7e0..dd74cfe52b1 100644
--- a/Docs/Book/RDKit_Book.rst
+++ b/Docs/Book/RDKit_Book.rst
@@ -2028,8 +2028,10 @@ Use cases
 
 The initial target is to not lose data on an ``V3k mol -> RDKit -> V3k mol`` round trip. Manipulation and depiction are future goals.
 
-It is possible to enumerate the elements of a ``StereoGroup`` using the function :py:func:`rdkit.Chem.EnumerateStereoisomers.EumerateStereoisomers`, which also
-preserves membership in the original ``StereoGroup``.
+It is possible to enumerate the elements of a ``StereoGroup`` using the function
+:py:func:`rdkit.Chem.EnumerateStereoisomers.EumerateStereoisomers`. Note that
+this removes the ``StereoGroup`` information from the products since they now
+correspond to specific molecules:
 
 .. doctest ::
 
@@ -2040,7 +2042,7 @@ preserves membership in the original ``StereoGroup``.
   [1]
   >>> from rdkit.Chem.EnumerateStereoisomers import EnumerateStereoisomers
   >>> [Chem.MolToCXSmiles(x) for x in EnumerateStereoisomers(m)]
-  ['C[C@@H](F)C[C@H](O)Cl |&1:1|', 'C[C@H](F)C[C@H](O)Cl |&1:1|']
+  ['C[C@@H](F)C[C@H](O)Cl', 'C[C@H](F)C[C@H](O)Cl']
 
 Reactions also preserve ``StereoGroup``s. Product atoms are included in the ``StereoGroup`` as long as the reaction doesn't create or destroy chirality at the atom.
 
diff --git a/ReleaseNotes.md b/ReleaseNotes.md
index 28bd502512e..38fcec68117 100644
--- a/ReleaseNotes.md
+++ b/ReleaseNotes.md
@@ -8,6 +8,7 @@
 ## Backwards incompatible changes
 
 - The ring-finding functions will now run even if the molecule already has ring information. Older versions of the RDKit would return whatever ring information was present, even if it had been generated using a different algorithm.
+- The canonical SMILES and CXSMILES generated for molecules with enhanced stereochemistry (stereo groups) is different than in previous releases. The enhanced stereochemistry information and the stereo groups themselves are now canonical. This does *not* affect molecules which do not have enhanced stereo and will not have any effect if you generate non-isomeric SMILES. This change also affects the output of the MolHash and RegistrationHash code when applied to molecules with enhanced stereo.
 
 ## Bug Fixes:
 
diff --git a/rdkit/Chem/RegistrationHash.py b/rdkit/Chem/RegistrationHash.py
index 3c69d4ec522..d8ee27da39c 100644
--- a/rdkit/Chem/RegistrationHash.py
+++ b/rdkit/Chem/RegistrationHash.py
@@ -127,6 +127,8 @@ def GetMolLayers(original_molecule: Chem.rdchem.Mol, data_field_names: Optional[
   mol = _RemoveUnnecessaryHs(original_molecule, preserve_stereogenic_hs=True)
   _StripAtomMapLabels(mol)
 
+  Chem.CanonicalizeEnhancedStereo(mol)
+
   formula = rdMolHash.MolHash(mol, rdMolHash.HashFunction.MolFormula)
   cxsmiles, canonical_mol = _CanonicalizeStereoGroups(mol)
   tautomer_hash = GetStereoTautomerHash(canonical_mol)
@@ -437,8 +439,9 @@ def _CanonicalizeStereoGroups(mol):
     compare the CXSMILES of those.
     """
 
-  if not len(mol.GetStereoGroups()):
-    return Chem.MolToCXSmiles(mol), mol
+  # if not len(mol.GetStereoGroups()):
+  Chem.CanonicalizeEnhancedStereo(mol)
+  return Chem.MolToCXSmiles(mol), mol
 
   mol = Chem.Mol(mol)