Repository: rdkit/rdkit
Pull request #8473: Add Synthon space hit filters
Author: login: DavidACosgrove
Labels: name: enhancement
Created: 2025-04-28T06:52:10Z | Merged: 2025-05-11T03:14:12Z

#### Reference Issue
<!-- Example: Fixes #1234 -->
No issue.  

#### What does this implement/fix? Explain your changes.
Allows filtering of the hits on number of heavy atoms, molecular weight and number of chiral atoms.

#### Any other comments?
A feature request from the recent ChemSpace webinar.


Linked issue #1234: Provide java Window64 platform dll file.
Can you help me provide java windows64 platform dll file for latest  version? 

Thank you very much...
Linked issue comments: user: login: github-actions[bot]
created_at: 2024-12-25T02:35:04Z
body: This issue was marked as stale because it has been open for 90 days with no activity.
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-01-08T02:35:19Z
body: This issue was closed because it has been inactive for 14 days since being marked as stale.
author_association: CONTRIBUTOR

Diff:
diff --git a/Code/GraphMol/SynthonSpaceSearch/CMakeLists.txt b/Code/GraphMol/SynthonSpaceSearch/CMakeLists.txt
index dfd35015133..1f3b112dacc 100644
--- a/Code/GraphMol/SynthonSpaceSearch/CMakeLists.txt
+++ b/Code/GraphMol/SynthonSpaceSearch/CMakeLists.txt
@@ -4,7 +4,7 @@ rdkit_library(SynthonSpaceSearch
         SynthonSpaceSearcher.cpp SynthonSpaceSubstructureSearcher.cpp SynthonSpaceFingerprintSearcher.cpp
         SynthonSpaceRascalSearcher.cpp SynthonSpaceHitSet.cpp SearchResults.cpp MemoryMappedFileReader.cpp
         LINK_LIBRARIES SmilesParse FileParsers ChemTransforms Fingerprints SubstructMatch GraphMol RascalMCES
-        GeneralizedSubstruct)
+        GeneralizedSubstruct Descriptors)
 target_compile_definitions(SynthonSpaceSearch PRIVATE RDKIT_SYNTHONSPACESEARCH_BUILD)
 
 rdkit_headers(SynthonSpace.h SynthonSet.h Synthon.h SearchResults.h
diff --git a/Code/GraphMol/SynthonSpaceSearch/LONG_TEST_catch_tests.cpp b/Code/GraphMol/SynthonSpaceSearch/LONG_TEST_catch_tests.cpp
index 43d33412f8e..17814d165ba 100644
--- a/Code/GraphMol/SynthonSpaceSearch/LONG_TEST_catch_tests.cpp
+++ b/Code/GraphMol/SynthonSpaceSearch/LONG_TEST_catch_tests.cpp
@@ -17,6 +17,7 @@
 #include <GraphMol/FileParsers/MolSupplier.h>
 #include <GraphMol/Fingerprints/MorganGenerator.h>
 #include <GraphMol/Fingerprints/RDKitFPGenerator.h>
+#include <GraphMol/RascalMCES/RascalMCES.h>
 #include <GraphMol/SynthonSpaceSearch/SynthonSpace.h>
 #include <GraphMol/SynthonSpaceSearch/SearchResults.h>
 #include <GraphMol/SynthonSpaceSearch/SynthonSpaceSearch_details.h>
@@ -28,8 +29,8 @@
 
 using namespace RDKit;
 using namespace RDKit::SynthonSpaceSearch;
-
 using namespace RDKit::SynthonSpaceSearch::details;
+using namespace RDKit::RascalMCES;
 
 const char *rdbase = getenv("RDBASE");
 
@@ -374,6 +375,34 @@ TEST_CASE("FP Approx Similarity") {
   tidy5567Binary();
 }
 
+TEST_CASE("Rascal Biggy") {
+  REQUIRE(rdbase);
+  std::string fName(rdbase);
+  std::string libName =
+      fName + "/Code/GraphMol/SynthonSpaceSearch/data/Syntons_5567.csv";
+  SynthonSpace synthonspace;
+  bool cancelled = false;
+  synthonspace.readTextFile(libName, cancelled);
+
+  const std::vector<std::string> smis{
+    "c1ccccc1C(=O)N1CCCC1", "c1ccccc1NC(=O)C1CCN1",
+    "c12ccccc1c(N)nc(N)n2", "c12ccc(C)cc1[nH]nc2C(=O)NCc1cncs1",
+    "c1n[nH]cn1",           "C(=O)NC(CC)C(=O)N(CC)C"};
+  const std::vector<size_t> numRes{254, 89, 2, 34, 0, 14};
+  const std::vector<size_t> maxRes{376110, 278747, 79833, 34817, 190, 45932};
+  SynthonSpaceSearchParams params;
+  params.maxHits = -1;
+  params.numThreads = 1;
+  RascalOptions rascalOptions;
+
+  for (size_t i = 0; i < smis.size(); ++i) {
+    auto queryMol = v2::SmilesParse::MolFromSmiles(smis[i]);
+    auto results = synthonspace.rascalSearch(*queryMol, rascalOptions, params);
+    CHECK(results.getHitMolecules().size() == numRes[i]);
+    CHECK(results.getMaxNumResults() == maxRes[i]);
+  }
+}
+
 #if 0
 // Whilst the code is still under active development, it's convenient to have this
 // in here.  It can come out later.
diff --git a/Code/GraphMol/SynthonSpaceSearch/Synthon.cpp b/Code/GraphMol/SynthonSpaceSearch/Synthon.cpp
index a24694fb764..a7a3b5f2fe6 100644
--- a/Code/GraphMol/SynthonSpaceSearch/Synthon.cpp
+++ b/Code/GraphMol/SynthonSpaceSearch/Synthon.cpp
@@ -9,8 +9,11 @@
 //
 
 #include <DataStructs/ExplicitBitVect.h>
+#include <GraphMol/Chirality.h>
+#include <GraphMol/Descriptors/MolDescriptors.h>
 #include <GraphMol/MolOps.h>
 #include <GraphMol/MolPickler.h>
+#include <GraphMol/Descriptors/MolDescriptors.h>
 #include <GraphMol/FileParsers/FileWriters.h>
 #include <GraphMol/Fingerprints/Fingerprints.h>
 #include <GraphMol/SynthonSpaceSearch/Synthon.h>
@@ -32,6 +35,7 @@ Synthon::Synthon(const std::string &smi) : d_smiles(smi) {
     // bring it all down.
     throw ValueErrorException("Unparsable synthon SMILES " + d_smiles);
   }
+  calcProperties();
 }
 
 Synthon::Synthon(const Synthon &other)
@@ -40,7 +44,12 @@ Synthon::Synthon(const Synthon &other)
       dp_searchMol(std::make_unique<ROMol>(*other.dp_searchMol)),
       dp_pattFP(std::make_unique<ExplicitBitVect>(*other.dp_pattFP)),
       dp_FP(std::make_unique<ExplicitBitVect>(*other.dp_FP)),
-      d_connRegions(other.d_connRegions) {}
+      d_connRegions(other.d_connRegions),
+      d_numDummies(other.d_numDummies),
+      d_numHeavyAtoms(other.d_numHeavyAtoms),
+      d_numChiralAtoms(other.d_numChiralAtoms),
+      d_numChiralAtomsExcDummies(other.d_numChiralAtomsExcDummies),
+      d_molWt(other.d_molWt) {}
 
 Synthon &Synthon::operator=(const Synthon &other) {
   if (this == &other) {
@@ -78,6 +87,11 @@ Synthon &Synthon::operator=(const Synthon &other) {
   } else {
     d_connRegions.clear();
   }
+  d_numDummies = other.d_numDummies;
+  d_numHeavyAtoms = other.d_numHeavyAtoms;
+  d_numChiralAtoms = other.d_numChiralAtoms;
+  d_numChiralAtomsExcDummies = other.d_numChiralAtomsExcDummies;
+  d_molWt = other.d_molWt;
   return *this;
 }
 const std::unique_ptr<ROMol> &Synthon::getOrigMol() const { return dp_origMol; }
@@ -128,9 +142,13 @@ void Synthon::writeToDBStream(std::ostream &os) const {
   } else {
     streamWrite(os, false);
   }
+  streamWrite(os, d_numDummies);
+  streamWrite(os, d_numHeavyAtoms);
+  streamWrite(os, d_numChiralAtoms);
+  streamWrite(os, d_molWt);
 }
 
-void Synthon::readFromDBStream(std::istream &is) {
+void Synthon::readFromDBStream(std::istream &is, std::uint32_t version) {
   streamRead(is, d_smiles, 0);
   dp_origMol = std::make_unique<ROMol>();
   MolPickler::molFromPickle(is, *dp_origMol);
@@ -153,6 +171,14 @@ void Synthon::readFromDBStream(std::istream &is) {
     streamRead(is, pickle, 0);
     dp_FP = std::make_unique<ExplicitBitVect>(pickle);
   }
+  if (version > 3000) {
+    streamRead(is, d_numDummies);
+    streamRead(is, d_numHeavyAtoms);
+    streamRead(is, d_numChiralAtoms);
+    streamRead(is, d_molWt);
+  } else {
+    calcProperties();
+  }
 }
 
 void Synthon::finishInitialization() {
@@ -167,4 +193,20 @@ void Synthon::finishInitialization() {
   }
 }
 
+void Synthon::calcProperties() {
+  d_numDummies = 0;
+  d_numHeavyAtoms = 0;
+  MolOps::assignStereochemistry(*dp_origMol);
+  for (const auto atom : dp_origMol->atoms()) {
+    if (atom->getAtomicNum() == 0) {
+      d_numDummies++;
+    } else if (atom->getAtomicNum() > 1) {
+      d_numHeavyAtoms++;
+    }
+  }
+  Chirality::findPotentialStereo(*dp_origMol, true, true);
+  d_numChiralAtoms =
+      details::countChiralAtoms(*dp_origMol, &d_numChiralAtomsExcDummies);
+  d_molWt = Descriptors::calcAMW(*dp_origMol);
+}
 }  // namespace RDKit::SynthonSpaceSearch
diff --git a/Code/GraphMol/SynthonSpaceSearch/Synthon.h b/Code/GraphMol/SynthonSpaceSearch/Synthon.h
index 768dc993850..4cfb6a1c768 100644
--- a/Code/GraphMol/SynthonSpaceSearch/Synthon.h
+++ b/Code/GraphMol/SynthonSpaceSearch/Synthon.h
@@ -44,10 +44,17 @@ class RDKIT_SYNTHONSPACESEARCH_EXPORT Synthon {
   const std::vector<std::shared_ptr<ROMol>> &getConnRegions() const;
   void setSearchMol(std::unique_ptr<ROMol> mol);
   void setFP(std::unique_ptr<ExplicitBitVect> fp);
+  unsigned int getNumDummies() const { return d_numDummies; }
+  unsigned int getNumHeavyAtoms() const { return d_numHeavyAtoms; }
+  unsigned int getNumChiralAtoms() const { return d_numChiralAtoms; }
+  unsigned int getNumChiralAtomsExcDummies() const {
+    return d_numChiralAtomsExcDummies;
+  }
+  double getMolWt() const { return d_molWt; }
 
   // Writes to/reads from a binary stream.
   void writeToDBStream(std::ostream &os) const;
-  void readFromDBStream(std::istream &is);
+  void readFromDBStream(std::istream &is, std::uint32_t version);
 
  private:
   std::string d_smiles;
@@ -69,9 +76,20 @@ class RDKIT_SYNTHONSPACESEARCH_EXPORT Synthon {
   // be 1 or 2.  These are derived from the search mol.
   std::vector<std::shared_ptr<ROMol>> d_connRegions;
 
+  unsigned int d_numDummies{0};
+  unsigned int d_numHeavyAtoms{0};
+  // As calculated by details::countChiralAtoms().
+  unsigned int d_numChiralAtoms{0};
+  // This is the number of chiral atoms excluding those that have 2 or
+  // more dummies attached.
+  unsigned int d_numChiralAtomsExcDummies{0};
+  double d_molWt{0.0};
+
   // Once the search molecule has been added, get the connector regions,
   // connector fingerprint etc.
   void finishInitialization();
+  // Calculate the number of dummy atoms etc.
+  void calcProperties();
 };
 
 }  // namespace SynthonSpaceSearch
diff --git a/Code/GraphMol/SynthonSpaceSearch/SynthonSpace.cpp b/Code/GraphMol/SynthonSpaceSearch/SynthonSpace.cpp
index 9b37d0ed7c5..2c36e5661e0 100644
--- a/Code/GraphMol/SynthonSpaceSearch/SynthonSpace.cpp
+++ b/Code/GraphMol/SynthonSpaceSearch/SynthonSpace.cpp
@@ -43,7 +43,7 @@ namespace RDKit::SynthonSpaceSearch {
 
 // used for serialization
 constexpr int32_t versionMajor = 3;
-constexpr int32_t versionMinor = 0;
+constexpr int32_t versionMinor = 1;
 constexpr int32_t endianId = 0xa100f;
 
 size_t SynthonSpace::getNumReactions() const { return d_reactions.size(); }
@@ -358,7 +358,7 @@ namespace {
 // large enough to accept everything.
 void readSynthons(
     const size_t startNum, size_t endNum, const char *fileMap,
-    const std::vector<std::uint64_t> &synthonPos,
+    const std::vector<std::uint64_t> &synthonPos, std::uint32_t version,
     std::vector<std::pair<std::string, std::unique_ptr<Synthon>>> &synthons) {
   if (endNum > synthons.size()) {
     endNum = synthons.size();
@@ -369,7 +369,7 @@ void readSynthons(
     std::istringstream is(view, std::ios::binary);
     auto tmp =
         std::make_pair(std::string(), std::unique_ptr<Synthon>(new Synthon));
-    tmp.second->readFromDBStream(is);
+    tmp.second->readFromDBStream(is, version);
     tmp.first = tmp.second->getSmiles();
     synthons[i] = std::move(tmp);
   }
@@ -377,14 +377,14 @@ void readSynthons(
 
 void threadedReadSynthons(
     const char *fileMap, const std::vector<std::uint64_t> &synthonPos,
-    unsigned int numThreads,
+    unsigned int numThreads, std::uint32_t version,
     std::vector<std::pair<std::string, std::unique_ptr<Synthon>>> &synthons) {
   size_t eachThread = 1 + (synthonPos.size() / numThreads);
   size_t start = 0;
   std::vector<std::thread> threads;
   for (unsigned int i = 0U; i < numThreads; ++i, start += eachThread) {
     threads.push_back(std::thread(readSynthons, start, start + eachThread,
-                                  fileMap, std::ref(synthonPos),
+                                  fileMap, std::ref(synthonPos), version,
                                   std::ref(synthons)));
   }
   for (auto &t : threads) {
@@ -497,14 +497,14 @@ void SynthonSpace::readDBFile(const std::string &inFilename,
   unsigned int numThreadsToUse = getNumThreadsToUse(numThreads);
   if (numThreadsToUse > 1) {
     threadedReadSynthons(fileMap.d_mappedMemory, synthonPos, numThreadsToUse,
-                         d_synthonPool);
+                         d_fileMajorVersion, d_synthonPool);
   } else {
     readSynthons(0, numSynthons, fileMap.d_mappedMemory, synthonPos,
-                 d_synthonPool);
+                 d_fileMajorVersion, d_synthonPool);
   }
 #else
   readSynthons(0, numSynthons, fileMap.d_mappedMemory, synthonPos,
-               d_synthonPool);
+               d_fileMajorVersion, d_synthonPool);
 #endif
   if (!std::is_sorted(
           d_synthonPool.begin(), d_synthonPool.end(),
@@ -578,10 +578,14 @@ void SynthonSpace::writeEnumeratedFile(const std::string &outFilename) const {
                           << " some time and result in a large file."
                           << std::endl;
   std::ofstream os(outFilename);
+  enumerateToStream(os);
+  os.close();
+}
+
+void SynthonSpace::enumerateToStream(std::ostream &os) const {
   for (const auto &[fst, snd] : d_reactions) {
     snd->enumerateToStream(os);
   }
-  os.close();
 }
 
 bool SynthonSpace::hasFingerprints() const { return !d_fpType.empty(); }
diff --git a/Code/GraphMol/SynthonSpaceSearch/SynthonSpace.h b/Code/GraphMol/SynthonSpaceSearch/SynthonSpace.h
index 659ae3efa4e..ab35182b1cd 100644
--- a/Code/GraphMol/SynthonSpaceSearch/SynthonSpace.h
+++ b/Code/GraphMol/SynthonSpaceSearch/SynthonSpace.h
@@ -27,6 +27,7 @@
 */
 
 #include <map>
+#include <sstream>
 #include <string>
 #include <vector>
 
@@ -109,6 +110,16 @@ struct RDKIT_SYNTHONSPACESEARCH_EXPORT SynthonSpaceSearchParams {
              // appropriate for Morgan fingerprints.  With RDKit fingerprints,
              // 0.05 is adequate, and higher than that has been seen to
              // produce long run times.
+  unsigned int minHitHeavyAtoms{0};  // Minimum number of heavy atoms in a hit.
+  int maxHitHeavyAtoms{-1};          // Maximum number of heavy atoms in a hit.
+                                     // -1 means no maximum.
+  double minHitMolWt{0};             // Minimum molecular weight for a hit.
+  double maxHitMolWt{0};  // Maximum molecular weight for a hit.  0.0 means
+                          // no maximum.
+  unsigned int minHitChiralAtoms{
+      0};                      // Minimum number of chiral atoms in a hit.
+  int maxHitChiralAtoms{-1};   // Maximum number of chiral atoms in a hit.
+                               // -1 means no maximum.
   std::uint64_t timeOut{600};  // Maximum number of seconds to spend on a single
                                // search.  0 means no maximum.
   int numThreads = 1;  // The number of threads to use.  If > 0, will use that
@@ -297,6 +308,7 @@ class RDKIT_SYNTHONSPACESEARCH_EXPORT SynthonSpace {
    * @param outFilename: name of the file to write
    */
   void writeEnumeratedFile(const std::string &outFilename) const;
+  void enumerateToStream(std::ostream &os) const;
 
   /*!
    * Create the fingerprints for the synthons ready for fingerprint searches.
diff --git a/Code/GraphMol/SynthonSpaceSearch/SynthonSpaceFingerprintSearcher.cpp b/Code/GraphMol/SynthonSpaceSearch/SynthonSpaceFingerprintSearcher.cpp
index 985d77d26ee..fdc92fc8fd7 100644
--- a/Code/GraphMol/SynthonSpaceSearch/SynthonSpaceFingerprintSearcher.cpp
+++ b/Code/GraphMol/SynthonSpaceSearch/SynthonSpaceFingerprintSearcher.cpp
@@ -239,6 +239,9 @@ SynthonSpaceFingerprintSearcher::searchFragSet(
 bool SynthonSpaceFingerprintSearcher::quickVerify(
     const SynthonSpaceHitSet *hitset,
     const std::vector<size_t> &synthNums) const {
+  if (!SynthonSpaceSearcher::quickVerify(hitset, synthNums)) {
+    return false;
+  }
   // The hitsets produced by the fingerprint searcher are SynthonSpaceFPHitSets,
   // which have the synthon fps as well.
   const auto hs = dynamic_cast<const SynthonSpaceFPHitSet *>(hitset);
@@ -258,7 +261,10 @@ bool SynthonSpaceFingerprintSearcher::quickVerify(
          getParams().similarityCutoff - getParams().approxSimilarityAdjuster;
 }
 
-bool SynthonSpaceFingerprintSearcher::verifyHit(const ROMol &hit) const {
+bool SynthonSpaceFingerprintSearcher::verifyHit(ROMol &hit) const {
+  if (!SynthonSpaceSearcher::verifyHit(hit)) {
+    return false;
+  }
   const std::unique_ptr<ExplicitBitVect> fp(d_fpGen.getFingerprint(hit));
   if (const auto sim = TanimotoSimilarity(*fp, *d_queryFP);
       sim >= getParams().similarityCutoff) {
diff --git a/Code/GraphMol/SynthonSpaceSearch/SynthonSpaceFingerprintSearcher.h b/Code/GraphMol/SynthonSpaceSearch/SynthonSpaceFingerprintSearcher.h
index e003f1ebba7..12e79a02989 100644
--- a/Code/GraphMol/SynthonSpaceSearch/SynthonSpaceFingerprintSearcher.h
+++ b/Code/GraphMol/SynthonSpaceSearch/SynthonSpaceFingerprintSearcher.h
@@ -50,7 +50,7 @@ class SynthonSpaceFingerprintSearcher : public SynthonSpaceSearcher {
 
   bool quickVerify(const SynthonSpaceHitSet *hitset,
                    const std::vector<size_t> &synthNums) const override;
-  bool verifyHit(const ROMol &hit) const override;
+  bool verifyHit(ROMol &hit) const override;
 };
 }  // namespace RDKit::SynthonSpaceSearch
 
diff --git a/Code/GraphMol/SynthonSpaceSearch/SynthonSpaceHitSet.cpp b/Code/GraphMol/SynthonSpaceSearch/SynthonSpaceHitSet.cpp
index a10f483437c..8e9078d0b5a 100644
--- a/Code/GraphMol/SynthonSpaceSearch/SynthonSpaceHitSet.cpp
+++ b/Code/GraphMol/SynthonSpaceSearch/SynthonSpaceHitSet.cpp
@@ -21,9 +21,7 @@ SynthonSpaceHitSet::SynthonSpaceHitSet(
     synthonsToUse.emplace_back();
     synthonsToUse[i].reserve(stu[i].size());
     for (size_t j = 0; j < stu[i].size(); ++j) {
-      synthonsToUse[i].emplace_back(
-          std::make_pair(synthons[i][stu[i][j]].first,
-                         synthons[i][stu[i][j]].second->getOrigMol().get()));
+      synthonsToUse[i].emplace_back(synthons[i][stu[i][j]]);
     }
   }
 
diff --git a/Code/GraphMol/SynthonSpaceSearch/SynthonSpaceHitSet.h b/Code/GraphMol/SynthonSpaceSearch/SynthonSpaceHitSet.h
index ce128394575..7c575b7a7ec 100644
--- a/Code/GraphMol/SynthonSpaceSearch/SynthonSpaceHitSet.h
+++ b/Code/GraphMol/SynthonSpaceSearch/SynthonSpaceHitSet.h
@@ -36,7 +36,8 @@ struct RDKIT_SYNTHONSPACESEARCH_EXPORT SynthonSpaceHitSet {
   SynthonSpaceHitSet &operator=(SynthonSpaceHitSet &&rhs) = delete;
 
   const SynthonSet *d_reaction{nullptr};
-  std::vector<std::vector<std::pair<std::string, const ROMol *>>> synthonsToUse;
+  std::vector<std::vector<std::pair<std::string, const Synthon *>>>
+      synthonsToUse;
   size_t numHits{0};
   // The fragments that this hitset is derived from, useful for debugging.
   std::vector<const ROMol *> frags;
diff --git a/Code/GraphMol/SynthonSpaceSearch/SynthonSpaceRascalSearcher.cpp b/Code/GraphMol/SynthonSpaceSearch/SynthonSpaceRascalSearcher.cpp
index ce30848bbd5..e8f37279462 100644
--- a/Code/GraphMol/SynthonSpaceSearch/SynthonSpaceRascalSearcher.cpp
+++ b/Code/GraphMol/SynthonSpaceSearch/SynthonSpaceRascalSearcher.cpp
@@ -151,17 +151,20 @@ SynthonSpaceRascalSearcher::searchFragSet(
 bool SynthonSpaceRascalSearcher::quickVerify(
     const SynthonSpaceHitSet *hitset,
     const std::vector<size_t> &synthNums) const {
+  if (!SynthonSpaceSearcher::quickVerify(hitset, synthNums)) {
+    return false;
+  }
   // If the query is an exact substructure of the product, then that's an upper
   // bound on the Johnson similarity.  Check that that is not below the
   // threshold.
   int qbit = getQuery().getNumAtoms() + getQuery().getNumBonds();
   int numAtoms = 0, numBonds = 0;
   for (size_t i = 0; i < synthNums.size(); i++) {
-    const auto &smol = hitset->synthonsToUse[i][synthNums[i]].second;
+    const auto &synth = hitset->synthonsToUse[i][synthNums[i]].second;
     // Adjust for connector points that aren't in the final product.
-    numAtoms += smol->getNumAtoms() -
+    numAtoms += synth->getOrigMol()->getNumAtoms() -
                 hitset->d_reaction->getSynthonConnectorPatterns()[i].count();
-    numBonds += smol->getNumBonds() -
+    numBonds += synth->getOrigMol()->getNumBonds() -
                 hitset->d_reaction->getSynthonConnectorPatterns()[i].count();
   }
   // The Johnson similarity is
@@ -174,7 +177,11 @@ bool SynthonSpaceRascalSearcher::quickVerify(
   return bestSim >= d_rascalOptions.similarityThreshold;
 }
 
-bool SynthonSpaceRascalSearcher::verifyHit(const ROMol &hit) const {
+bool SynthonSpaceRascalSearcher::verifyHit(ROMol &hit) const {
+  if (!SynthonSpaceSearcher::verifyHit(hit)) {
+    return false;
+  }
+
   auto res = RascalMCES::rascalMCES(hit, getQuery(), d_rascalOptions);
   // Rascal reports all matches that proceed to full MCES elucidation,
   // even if the final similarity value ends up below the threshold.
diff --git a/Code/GraphMol/SynthonSpaceSearch/SynthonSpaceRascalSearcher.h b/Code/GraphMol/SynthonSpaceSearch/SynthonSpaceRascalSearcher.h
index f7087f3eacf..ac9c2b164a2 100644
--- a/Code/GraphMol/SynthonSpaceSearch/SynthonSpaceRascalSearcher.h
+++ b/Code/GraphMol/SynthonSpaceSearch/SynthonSpaceRascalSearcher.h
@@ -44,7 +44,7 @@ class SynthonSpaceRascalSearcher : public SynthonSpaceSearcher {
   bool quickVerify(const SynthonSpaceHitSet *hitset,
                    const std::vector<size_t> &synthNums) const override;
 
-  bool verifyHit(const ROMol &hit) const override;
+  bool verifyHit(ROMol &hit) const override;
 
   const RascalMCES::RascalOptions &d_rascalOptions;
   RascalMCES::RascalOptions d_rascalFragOptions;
diff --git a/Code/GraphMol/SynthonSpaceSearch/SynthonSpaceSearch_details.cpp b/Code/GraphMol/SynthonSpaceSearch/SynthonSpaceSearch_details.cpp
index fec92bb903d..55b3b3b7d8e 100644
--- a/Code/GraphMol/SynthonSpaceSearch/SynthonSpaceSearch_details.cpp
+++ b/Code/GraphMol/SynthonSpaceSearch/SynthonSpaceSearch_details.cpp
@@ -20,6 +20,7 @@
 #include <boost/dynamic_bitset.hpp>
 
 #include <RDGeneral/ControlCHandler.h>
+#include <GraphMol/Chirality.h>
 #include <GraphMol/MolOps.h>
 #include <GraphMol/QueryAtom.h>
 #include <GraphMol/QueryBond.h>
@@ -831,14 +832,12 @@ std::unique_ptr<ROMol> buildProduct(
     const std::vector<const ROMol *> &synthons) {
   MolzipParams mzparams;
   mzparams.label = MolzipLabel::Isotope;
-
   auto prodMol = std::make_unique<ROMol>(*synthons.front());
   for (size_t i = 1; i < synthons.size(); ++i) {
     prodMol.reset(combineMols(*prodMol, *synthons[i]));
   }
   prodMol = molzip(*prodMol, mzparams);
   MolOps::sanitizeMol(*dynamic_cast<RWMol *>(prodMol.get()));
-
   return prodMol;
 }
 
@@ -867,4 +866,29 @@ std::map<std::string, std::vector<ROMol *>> mapFragsBySmiles(
   return fragSmiToFrag;
 }
 
+unsigned int countChiralAtoms(ROMol &mol, unsigned int *numExcDummies) {
+  auto sis = Chirality::findPotentialStereo(mol, true, true);
+  unsigned int numChiralAtoms = 0;
+  if (numExcDummies) {
+    *numExcDummies = 0;
+  }
+  for (auto &si : sis) {
+    ++numChiralAtoms;
+    if (numExcDummies) {
+      auto atom = mol.getAtomWithIdx(si.centeredOn);
+      unsigned int numDummies = 0;
+      for (auto nbr : mol.atomNeighbors(atom)) {
+        if (nbr->getAtomicNum() == 0 &&
+            nbr->getIsotope() <= MAX_CONNECTOR_NUM) {
+          numDummies++;
+        }
+      }
+      if (numDummies < 2) {
+        (*numExcDummies)++;
+      }
+    }
+  }
+  return numChiralAtoms;
+}
+
 }  // namespace RDKit::SynthonSpaceSearch::details
diff --git a/Code/GraphMol/SynthonSpaceSearch/SynthonSpaceSearch_details.h b/Code/GraphMol/SynthonSpaceSearch/SynthonSpaceSearch_details.h
index 9bceca329d2..8cdab808cd1 100644
--- a/Code/GraphMol/SynthonSpaceSearch/SynthonSpaceSearch_details.h
+++ b/Code/GraphMol/SynthonSpaceSearch/SynthonSpaceSearch_details.h
@@ -158,6 +158,18 @@ RDKIT_SYNTHONSPACESEARCH_EXPORT std::map<std::string, std::vector<ROMol *>>
 mapFragsBySmiles(std::vector<std::vector<std::unique_ptr<ROMol>>> &fragSets,
                  bool &cancelled);
 
+// Count the number of chiral atoms, both specified and unspecified i.e. any
+// atoms returned by findPotentialStereo. If numExcDummies is not nullptr,
+// it will be sent back with the number of chiral centres excluding those
+// that include 2 or more dummy atoms (which are flagged as chiral if the
+// dummies have different isotopes which will be the case here).  They
+// may not be chiral in the final product if the 2 synthons attached are the
+// same.  For example in [1*]C([2*])(N)[C@H](O)F with [1*]F and [2*]F, the
+// first C is deemed possibly chiral, but isn't with the 2 F atoms are
+// attached.
+RDKIT_SYNTHONSPACESEARCH_EXPORT unsigned int countChiralAtoms(
+    ROMol &mol, unsigned int *numExcDummies = nullptr);
+
 }  // namespace SynthonSpaceSearch::details
 }  // namespace RDKit
 
diff --git a/Code/GraphMol/SynthonSpaceSearch/SynthonSpaceSearcher.cpp b/Code/GraphMol/SynthonSpaceSearch/SynthonSpaceSearcher.cpp
index 7bf819ea88f..d69c976f00f 100644
--- a/Code/GraphMol/SynthonSpaceSearch/SynthonSpaceSearcher.cpp
+++ b/Code/GraphMol/SynthonSpaceSearch/SynthonSpaceSearcher.cpp
@@ -12,9 +12,11 @@
 #include <thread>
 #include <boost/random/discrete_distribution.hpp>
 
+#include <GraphMol/Chirality.h>
 #include <GraphMol/MolOps.h>
 #include <GraphMol/CIPLabeler/Descriptor.h>
 #include <GraphMol/ChemTransforms/ChemTransforms.h>
+#include <GraphMol/FileParsers/FileWriters.h>
 #include <GraphMol/SmilesParse/SmilesWrite.h>
 #include <GraphMol/SmilesParse/SmartsWrite.h>
 #include <GraphMol/SynthonSpaceSearch/SynthonSpaceSearch_details.h>
@@ -85,7 +87,8 @@ std::unique_ptr<ROMol> SynthonSpaceSearcher::buildAndVerifyHit(
   std::vector<const ROMol *> synths(synthNums.size());
   std::vector<std::string> synthNames(synthNums.size());
   for (size_t i = 0; i < synthNums.size(); i++) {
-    synths[i] = hitset->synthonsToUse[i][synthNums[i]].second;
+    synths[i] =
+        hitset->synthonsToUse[i][synthNums[i]].second->getOrigMol().get();
     synthNames[i] = hitset->synthonsToUse[i][synthNums[i]].first;
   }
 
@@ -94,7 +97,6 @@ std::unique_ptr<ROMol> SynthonSpaceSearcher::buildAndVerifyHit(
     return prod;
   }
   prod = details::buildProduct(synths);
-
   // Do a final check of the whole thing.  It can happen that the
   // fragments match synthons but the final product doesn't match.
   // A key example is when the 2 synthons come together to form an
@@ -160,7 +162,6 @@ void processReactions(
 
   while (true) {
     std::int64_t thisR = ++mostRecentReaction;
-    // std::cout << thisR << " vs " << lastReaction << std::endl;
     if (thisR > lastReaction) {
       break;
     }
@@ -229,6 +230,68 @@ SynthonSpaceSearcher::doTheSearch(
   return allHits;
 }
 
+bool SynthonSpaceSearcher::quickVerify(
+    const SynthonSpaceHitSet *hitset,
+    const std::vector<size_t> &synthNums) const {
+  if (getParams().minHitHeavyAtoms || getParams().maxHitHeavyAtoms > -1) {
+    unsigned int numHeavyAtoms = 0;
+    for (unsigned int i = 0; i < synthNums.size(); ++i) {
+      numHeavyAtoms +=
+          hitset->synthonsToUse[i][synthNums[i]].second->getNumHeavyAtoms();
+    }
+    if (numHeavyAtoms < getParams().minHitHeavyAtoms ||
+        (getParams().maxHitHeavyAtoms > -1 &&
+         numHeavyAtoms >
+             static_cast<unsigned int>(getParams().maxHitHeavyAtoms))) {
+      return false;
+    }
+  }
+  if (getParams().minHitMolWt > 0.0 || getParams().maxHitMolWt > 0.0) {
+    double molWt = 0.0;
+    for (unsigned int i = 0; i < synthNums.size(); ++i) {
+      molWt += hitset->synthonsToUse[i][synthNums[i]].second->getMolWt();
+    }
+    if (molWt < getParams().minHitMolWt ||
+        (getParams().maxHitMolWt > 0.0 && molWt > getParams().maxHitMolWt)) {
+      return false;
+    }
+  }
+  if (getParams().minHitChiralAtoms || getParams().maxHitChiralAtoms > -1) {
+    unsigned int numChiralAtoms = 0;
+    unsigned int numExcDummies = 0;
+    for (unsigned int i = 0; i < synthNums.size(); ++i) {
+      numChiralAtoms +=
+          hitset->synthonsToUse[i][synthNums[i]].second->getNumChiralAtoms();
+      numExcDummies += hitset->synthonsToUse[i][synthNums[i]]
+                           .second->getNumChiralAtomsExcDummies();
+    }
+    // numChiralAtoms is the upper bound on the number of chiral atoms in
+    // the hit, numExcDummies the lower bound.
+    if (numChiralAtoms < getParams().minHitChiralAtoms ||
+        (getParams().maxHitChiralAtoms > -1 &&
+         numExcDummies >
+             static_cast<unsigned int>(getParams().maxHitChiralAtoms))) {
+      return false;
+    }
+  }
+  return true;
+}
+
+// It's conceivable that building the full molecule has changed the
+// chiral atom count.
+bool SynthonSpaceSearcher::verifyHit(ROMol &mol) const {
+  if (getParams().minHitChiralAtoms || getParams().maxHitChiralAtoms) {
+    auto numChiralAtoms = details::countChiralAtoms(mol);
+    if (numChiralAtoms < getParams().minHitChiralAtoms ||
+        (getParams().maxHitChiralAtoms > -1 &&
+         numChiralAtoms >
+             static_cast<unsigned int>(getParams().maxHitChiralAtoms))) {
+      return false;
+    }
+  }
+  return true;
+}
+
 namespace {
 void sortHits(std::vector<std::unique_ptr<ROMol>> &hits) {
   if (!hits.empty() && hits.front()->hasProp("Similarity")) {
diff --git a/Code/GraphMol/SynthonSpaceSearch/SynthonSpaceSearcher.h b/Code/GraphMol/SynthonSpaceSearch/SynthonSpaceSearcher.h
index e8904363714..9d8f7d69da5 100644
--- a/Code/GraphMol/SynthonSpaceSearch/SynthonSpaceSearcher.h
+++ b/Code/GraphMol/SynthonSpaceSearch/SynthonSpaceSearcher.h
@@ -68,6 +68,20 @@ class SynthonSpaceSearcher {
       const SynthonSpaceHitSet *hitset,
       const std::vector<size_t> &synthNums) const;
 
+ protected:
+  // Checks that the given molecule is definitely a hit according to
+  // the derived class' criteria.  This function checks the chiralAtomCount
+  // if appropriate, which required a non-const ROMol.
+  virtual bool verifyHit(ROMol &mol) const;
+
+  // Do a check against number of heavy atoms etc. if options call for it
+  // which can be done without having to build the full molecule from the
+  // synthons. Some of the search methods (fingerprints, for example) can do
+  // additional quick checks on whether this set of synthons can match the query
+  // without building the full molecule.
+  virtual bool quickVerify(const SynthonSpaceHitSet *hitset,
+                           const std::vector<size_t> &synthNums) const;
+
  private:
   std::unique_ptr<std::mt19937> d_randGen;
 
@@ -87,19 +101,6 @@ class SynthonSpaceSearcher {
       std::vector<std::vector<std::unique_ptr<ROMol>>> &fragSets,
       const TimePoint *endTime, bool &timedOut, std::uint64_t &totHits);
 
-  // Some of the search methods (fingerprints, for example) can do a quick
-  // check on whether this set of synthons can match the query without having to
-  // build the full molecule from the synthons.  They will over-ride this
-  // function which by default passes everything.
-  virtual bool quickVerify(
-      [[maybe_unused]] const SynthonSpaceHitSet *hitset,
-      [[maybe_unused]] const std::vector<size_t> &synthNums) const {
-    return true;
-  }
-  // Checks that the given molecule is definitely a hit according to
-  // the derived class' criteria.
-  virtual bool verifyHit(const ROMol &mol) const = 0;
-
   // Build the molecules from the synthons identified in hitsets.
   // Checks that all the results produced match the
   // query.  Duplicates by name are not returned,
@@ -130,33 +131,6 @@ class SynthonSpaceSearcher {
       const std::string &reaction_id) const;
 };
 
-#if 0
-  // Build the molecules from the synthons identified in hitsets.
-  // Checks that all the results produced match the
-  // query.  totHits is the maximum number of hits that are possible from
-  // the hitsets, including duplicates.  Duplicates by name are not returned,
-  // but duplicate SMILES from different reactions will be.  Hitsets will
-  // be re-ordered on exit.
-  void buildHits(std::vector<std::unique_ptr<SynthonSpaceHitSet>> &hitsets,
-                 size_t totHits, const TimePoint *endTime, bool &timedOut,
-                 std::vector<std::unique_ptr<ROMol>> &results) const;
-  void buildAllHits(
-      const std::vector<std::unique_ptr<SynthonSpaceHitSet>> &hitsets,
-      std::set<std::string> &resultsNames, const TimePoint *endTime,
-      bool &timedOut, std::vector<std::unique_ptr<ROMol>> &results) const;
-  void buildRandomHits(
-      const std::vector<std::unique_ptr<SynthonSpaceHitSet>> &hitsets,
-      size_t totHits, std::set<std::string> &resultsNames,
-      const TimePoint *endTime, bool &timedOut,
-      std::vector<std::unique_ptr<ROMol>> &results) const;
-  // get the subset of synthons for the given reaction to use for this
-  // enumeration.
-  std::vector<std::vector<ROMol *>> getSynthonsToUse(
-      const std::vector<boost::dynamic_bitset<>> &synthonsToUse,
-      const std::string &reaction_id) const;
-};
-#endif
-
 }  // namespace SynthonSpaceSearch
 }  // namespace RDKit
 #endif  // SYNTHONSPACESEARCHER_H
diff --git a/Code/GraphMol/SynthonSpaceSearch/SynthonSpaceSubstructureSearcher.cpp b/Code/GraphMol/SynthonSpaceSearch/SynthonSpaceSubstructureSearcher.cpp
index e9097a52164..77cd8fe30c2 100644
--- a/Code/GraphMol/SynthonSpaceSearch/SynthonSpaceSubstructureSearcher.cpp
+++ b/Code/GraphMol/SynthonSpaceSearch/SynthonSpaceSubstructureSearcher.cpp
@@ -399,7 +399,10 @@ SynthonSpaceSubstructureSearcher::searchFragSet(
   return results;
 }
 
-bool SynthonSpaceSubstructureSearcher::verifyHit(const ROMol &hit) const {
+bool SynthonSpaceSubstructureSearcher::verifyHit(ROMol &hit) const {
+  if (!SynthonSpaceSearcher::verifyHit(hit)) {
+    return false;
+  }
   return !SubstructMatch(hit, getQuery(), d_matchParams).empty();
 }
 
diff --git a/Code/GraphMol/SynthonSpaceSearch/SynthonSpaceSubstructureSearcher.h b/Code/GraphMol/SynthonSpaceSearch/SynthonSpaceSubstructureSearcher.h
index f4e025e04bf..d60f3e87050 100644
--- a/Code/GraphMol/SynthonSpaceSearch/SynthonSpaceSubstructureSearcher.h
+++ b/Code/GraphMol/SynthonSpaceSearch/SynthonSpaceSubstructureSearcher.h
@@ -60,7 +60,7 @@ class SynthonSpaceSubstructureSearcher : public SynthonSpaceSearcher {
   void extraSearchSetup(
       std::vector<std::vector<std::unique_ptr<ROMol>>> &fragSets) override;
 
-  bool verifyHit(const ROMol &hit) const override;
+  bool verifyHit(ROMol &hit) const override;
 
   void getConnectorRegions(
       const std::vector<std::unique_ptr<ROMol>> &molFrags,
diff --git a/Code/GraphMol/SynthonSpaceSearch/Wrap/rdSynthonSpaceSearch.cpp b/Code/GraphMol/SynthonSpaceSearch/Wrap/rdSynthonSpaceSearch.cpp
index e1720cda9d2..060f07c3144 100644
--- a/Code/GraphMol/SynthonSpaceSearch/Wrap/rdSynthonSpaceSearch.cpp
+++ b/Code/GraphMol/SynthonSpaceSearch/Wrap/rdSynthonSpaceSearch.cpp
@@ -20,7 +20,6 @@
 namespace python = boost::python;
 
 namespace RDKit {
-
 python::list hitMolecules_helper(const SynthonSpaceSearch::SearchResults &res) {
   python::list pyres;
   for (auto &r : res.getHitMolecules()) {
@@ -246,6 +245,29 @@ BOOST_PYTHON_MODULE(rdSynthonSpaceSearch) {
           " appropriate for Morgan fingerprints.  With RDKit fingerprints,"
           " 0.05 is adequate, and higher than that has been seen to"
           " produce long run times.")
+      .def_readwrite(
+          "minHitHeavyAtoms",
+          &SynthonSpaceSearch::SynthonSpaceSearchParams::minHitHeavyAtoms,
+          "Minimum number of heavy atoms in a hit.  Default=0.")
+      .def_readwrite(
+          "maxHitHeavyAtoms",
+          &SynthonSpaceSearch::SynthonSpaceSearchParams::maxHitHeavyAtoms,
+          "Maximum number of heavy atoms in a hit.  Default=-1 means no maximum.")
+      .def_readwrite("minHitMolWt",
+                     &SynthonSpaceSearch::SynthonSpaceSearchParams::minHitMolWt,
+                     "Minimum molecular weight for a hit.  Default=0.0.")
+      .def_readwrite(
+          "maxHitMolWt",
+          &SynthonSpaceSearch::SynthonSpaceSearchParams::maxHitMolWt,
+          "Maximum molecular weight for a hit.  Default=0.0 mean no maximum.")
+      .def_readwrite(
+          "minHitChiralAtoms",
+          &SynthonSpaceSearch::SynthonSpaceSearchParams::minHitChiralAtoms,
+          "Minimum number of chiral atoms in a hit.  Default=0.")
+      .def_readwrite(
+          "maxHitChiralAtoms",
+          &SynthonSpaceSearch::SynthonSpaceSearchParams::maxHitChiralAtoms,
+          "Maximum number of chiral atoms in a hit.  Default=-1 means no maximum.")
       .def_readwrite(
           "timeOut", &SynthonSpaceSearch::SynthonSpaceSearchParams::timeOut,
           "Time limit for search, in seconds.  Default is 600s, 0 means no"
@@ -257,7 +279,8 @@ BOOST_PYTHON_MODULE(rdSynthonSpaceSearch) {
           " number.  If <= 0, will use the number of hardware"
           " threads plus this number.  So if the number of"
           " hardware threads is 8, and numThreads is -1, it will"
-          " use 7 threads.  Default=1.");
+          " use 7 threads.  Default=1.")
+      .def("__setattr__", &safeSetattr);
 
   docString = "SynthonSpaceSearch object.";
   python::class_<SynthonSpaceSearch::SynthonSpace, boost::noncopyable>(
diff --git a/Code/GraphMol/SynthonSpaceSearch/Wrap/testSynthonSpaceSearch.py b/Code/GraphMol/SynthonSpaceSearch/Wrap/testSynthonSpaceSearch.py
index 23c3da13585..13d843c637f 100644
--- a/Code/GraphMol/SynthonSpaceSearch/Wrap/testSynthonSpaceSearch.py
+++ b/Code/GraphMol/SynthonSpaceSearch/Wrap/testSynthonSpaceSearch.py
@@ -39,7 +39,7 @@
 
 from rdkit import Chem, rdBase
 from rdkit.Chem import (rdSynthonSpaceSearch, rdFingerprintGenerator,
-                        rdRascalMCES, rdGeneralizedSubstruct)
+                        rdRascalMCES, rdGeneralizedSubstruct, rdMolDescriptors)
 
 
 class TestCase(unittest.TestCase):
@@ -126,7 +126,49 @@ def testExtendedSubsructureSearch(self):
     xqry = rdGeneralizedSubstruct.CreateExtendedQueryMol(m)
     results = synthonspace.SubstructureSearch(xqry)
     self.assertEqual(12, len(results.GetHitMolecules()))
-  
+
+  def testHeavyAtomCutoffs(self):
+    fName = self.sssDir / "idorsia_toy_space_a.spc"
+    synthonspace = rdSynthonSpaceSearch.SynthonSpace()
+    synthonspace.ReadDBFile(fName)
+    params = rdSynthonSpaceSearch.SynthonSpaceSearchParams()
+    params.maxHits = -1
+    params.maxHitHeavyAtoms = 30
+    params.minHitHeavyAtoms = 25
+    q = Chem.MolFromSmarts("c1ccccc1C(=O)N1CCCC1")
+    results = synthonspace.SubstructureSearch(q)
+    self.assertEqual(220, len(results.GetHitMolecules()))
+    results = synthonspace.SubstructureSearch(q, params=params)
+    self.assertEqual(141, len(results.GetHitMolecules()))
+
+  def testMolWeightCutoffs(self):
+    fName = self.sssDir / "idorsia_toy_space_a.spc"
+    synthonspace = rdSynthonSpaceSearch.SynthonSpace()
+    synthonspace.ReadDBFile(fName)
+    params = rdSynthonSpaceSearch.SynthonSpaceSearchParams()
+    params.maxHits = -1
+    params.maxHitMolWt = 450
+    params.minHitMolWt = 350
+    q = Chem.MolFromSmarts("c1ccccc1C(=O)N1CCCC1")
+    results = synthonspace.SubstructureSearch(q)
+    self.assertEqual(220, len(results.GetHitMolecules()))
+    results = synthonspace.SubstructureSearch(q, params=params)
+    self.assertEqual(185, len(results.GetHitMolecules()))
+    
+  def testChiralAtomtCutoffs(self):
+    fName = self.sssDir / "idorsia_toy_space_a.spc"
+    synthonspace = rdSynthonSpaceSearch.SynthonSpace()
+    synthonspace.ReadDBFile(fName)
+    params = rdSynthonSpaceSearch.SynthonSpaceSearchParams()
+    params.maxHits = -1
+    params.maxHitChiralAtoms = 2
+    params.minHitChiralAtoms = 1
+    q = Chem.MolFromSmarts("Cc1nc(-c2ccnn2CC(C))n([C@@H]2CCOC2)n1")
+    results = synthonspace.SubstructureSearch(q)
+    self.assertEqual(20, len(results.GetHitMolecules()))
+    results = synthonspace.SubstructureSearch(q, params=params)
+    self.assertEqual(10, len(results.GetHitMolecules()))
+    
     
 if __name__ == "__main__":
   unittest.main()
diff --git a/Code/GraphMol/SynthonSpaceSearch/fingerprint_search_catch_tests.cpp b/Code/GraphMol/SynthonSpaceSearch/fingerprint_search_catch_tests.cpp
index 92391c49fe7..9da55db3c3d 100644
--- a/Code/GraphMol/SynthonSpaceSearch/fingerprint_search_catch_tests.cpp
+++ b/Code/GraphMol/SynthonSpaceSearch/fingerprint_search_catch_tests.cpp
@@ -12,10 +12,11 @@
 #include <filesystem>
 #include <fstream>
 
-#include <GraphMol/SubstructLibrary/SubstructLibrary.h>
+#include <GraphMol/Descriptors/MolDescriptors.h>
 #include <GraphMol/FileParsers/MolSupplier.h>
 #include <GraphMol/Fingerprints/MorganGenerator.h>
 #include <GraphMol/Fingerprints/RDKitFPGenerator.h>
+#include <GraphMol/SubstructLibrary/SubstructLibrary.h>
 #include <GraphMol/SynthonSpaceSearch/SynthonSpace.h>
 #include <GraphMol/SynthonSpaceSearch/SearchResults.h>
 #include <GraphMol/SynthonSpaceSearch/SynthonSpaceSearch_details.h>
@@ -176,3 +177,65 @@ TEST_CASE("Missing exact match") {
   CHECK(results.getHitMolecules().size() == 1);
   CHECK(results.getHitMolecules()[0]->getProp<double>("Similarity") == 1.0);
 }
+
+TEST_CASE("Hit Filters") {
+  REQUIRE(rdbase);
+  std::string fName(rdbase);
+  SynthonSpace synthonspace;
+  std::string libName =
+      fName + "/Code/GraphMol/SynthonSpaceSearch/data/idorsia_toy_space_a.spc";
+  std::unique_ptr<FingerprintGenerator<std::uint64_t>> fpGen(
+      RDKitFP::getRDKitFPGenerator<std::uint64_t>());
+  SearchResults results;
+  auto queryMol = "CCNC(=O)Cc1cncc(CCOC2c3ccccc3CC2)c1"_smiles;
+  SynthonSpaceSearchParams params;
+  params.similarityCutoff = 0.45;
+  synthonspace.readDBFile(libName);
+  results = synthonspace.fingerprintSearch(*queryMol, *fpGen, params);
+  CHECK(results.getHitMolecules().size() == 18);
+  {
+    SynthonSpaceSearchParams params;
+    params.minHitHeavyAtoms = 28;
+    params.similarityCutoff = 0.45;
+    results = synthonspace.fingerprintSearch(*queryMol, *fpGen, params);
+    CHECK(results.getHitMolecules().size() == 13);
+    params.maxHitHeavyAtoms = 29;
+    results = synthonspace.fingerprintSearch(*queryMol, *fpGen, params);
+    CHECK(results.getHitMolecules().size() == 12);
+    for (const auto &r : results.getHitMolecules()) {
+      auto numHeavies = Descriptors::calcNumHeavyAtoms(*r);
+      CHECK((numHeavies == 28 || numHeavies == 29));
+    }
+  }
+  {
+    SynthonSpaceSearchParams params;
+    params.similarityCutoff = 0.45;
+    params.minHitMolWt = 375.0;
+    results = synthonspace.fingerprintSearch(*queryMol, *fpGen, params);
+    CHECK(results.getHitMolecules().size() == 13);
+    params.maxHitMolWt = 390.0;
+    results = synthonspace.fingerprintSearch(*queryMol, *fpGen, params);
+    CHECK(results.getHitMolecules().size() == 4);
+    for (const auto &r : results.getHitMolecules()) {
+      auto molWt = Descriptors::calcExactMW(*r);
+      CHECK((molWt >= 375.0 || molWt <= 390.0));
+    }
+  }
+  {
+    SynthonSpaceSearchParams params;
+    params.similarityCutoff = 0.45;
+    auto chiralQuery = "Cc1nccn1CCc1ccsc1COO[C@@H]1CCC[C@H](N)C1"_smiles;
+    results = synthonspace.fingerprintSearch(*chiralQuery, *fpGen, params);
+    CHECK(results.getHitMolecules().size() == 17);
+    params.minHitChiralAtoms = 1;
+    results = synthonspace.fingerprintSearch(*chiralQuery, *fpGen, params);
+    CHECK(results.getHitMolecules().size() == 11);
+    params.maxHitChiralAtoms = 1;
+    results = synthonspace.fingerprintSearch(*chiralQuery, *fpGen, params);
+    CHECK(results.getHitMolecules().size() == 4);
+    for (const auto &r : results.getHitMolecules()) {
+      auto numChiralAtoms = details::countChiralAtoms(*r);
+      CHECK((numChiralAtoms >= 1 && numChiralAtoms <= 3));
+    }
+  }
+}
\ No newline at end of file
diff --git a/Code/GraphMol/SynthonSpaceSearch/rascal_search_catch_tests.cpp b/Code/GraphMol/SynthonSpaceSearch/rascal_search_catch_tests.cpp
index b6618a2c639..30f8b1df94f 100644
--- a/Code/GraphMol/SynthonSpaceSearch/rascal_search_catch_tests.cpp
+++ b/Code/GraphMol/SynthonSpaceSearch/rascal_search_catch_tests.cpp
@@ -114,31 +114,3 @@ TEST_CASE("RASCAL Small tests") {
     }
   }
 }
-
-TEST_CASE("Rascal Biggy") {
-  REQUIRE(rdbase);
-  std::string fName(rdbase);
-  std::string libName =
-      fName + "/Code/GraphMol/SynthonSpaceSearch/data/Syntons_5567.csv";
-  SynthonSpace synthonspace;
-  bool cancelled = false;
-  synthonspace.readTextFile(libName, cancelled);
-
-  const std::vector<std::string> smis{
-      "c1ccccc1C(=O)N1CCCC1", "c1ccccc1NC(=O)C1CCN1",
-      "c12ccccc1c(N)nc(N)n2", "c12ccc(C)cc1[nH]nc2C(=O)NCc1cncs1",
-      "c1n[nH]cn1",           "C(=O)NC(CC)C(=O)N(CC)C"};
-  const std::vector<size_t> numRes{254, 89, 2, 34, 0, 14};
-  const std::vector<size_t> maxRes{376110, 278747, 79833, 34817, 190, 45932};
-  SynthonSpaceSearchParams params;
-  params.maxHits = -1;
-  params.numThreads = 1;
-  RascalOptions rascalOptions;
-
-  for (size_t i = 0; i < smis.size(); ++i) {
-    auto queryMol = v2::SmilesParse::MolFromSmiles(smis[i]);
-    auto results = synthonspace.rascalSearch(*queryMol, rascalOptions, params);
-    CHECK(results.getHitMolecules().size() == numRes[i]);
-    CHECK(results.getMaxNumResults() == maxRes[i]);
-  }
-}
diff --git a/Code/GraphMol/SynthonSpaceSearch/substructure_search_catch_tests.cpp b/Code/GraphMol/SynthonSpaceSearch/substructure_search_catch_tests.cpp
index 58acb09005e..0e4afb7e994 100644
--- a/Code/GraphMol/SynthonSpaceSearch/substructure_search_catch_tests.cpp
+++ b/Code/GraphMol/SynthonSpaceSearch/substructure_search_catch_tests.cpp
@@ -725,4 +725,38 @@ F[2*]	277310376-742385dd	2	fake-chiral
   REQUIRE(mol2);
   auto res2 = space.substructureSearch(*mol2);
   CHECK(res2.getHitMolecules().size() == 2);
+}
+TEST_CASE("Chiral substructure search") {
+  SynthonSpace space;
+  std::istringstream iss(R"(SMILES	synton_id	synton#	reaction_id
+F[1*]	277310376-742385846	0	fake-chiral
+Cl[1*]	287123986-010598048	0	fake-chiral
+O[C@H](F)C(N)([1*])[2*]	584456271-623025187	1	fake-chiral
+O[C@H](F)C(Br)([1*])[2*]	584456271-623025187	1	fake-chiral
+F[2*]	277310376-742385dd	2	fake-chiral
+)");
+  bool cancelled = false;
+  space.readStream(iss, cancelled);
+
+  auto qmol = "N-C-C"_smarts;
+  REQUIRE(qmol);
+  auto res1 = space.substructureSearch(*qmol);
+  CHECK(res1.getHitMolecules().size() == 2);
+
+  SubstructMatchParameters mparams;
+  SynthonSpaceSearchParams sparams;
+  sparams.minHitChiralAtoms = 2;
+  auto res2 = space.substructureSearch(*qmol, mparams, sparams);
+  REQUIRE(res2.getHitMolecules().size() == 1);
+  CHECK(MolToSmiles(*res2.getHitMolecules().front()) == "NC(F)(Cl)[C@H](O)F");
+
+  sparams.minHitChiralAtoms = 0;
+  sparams.maxHitChiralAtoms = 1;
+  auto res3 = space.substructureSearch(*qmol, mparams, sparams);
+  REQUIRE(res3.getHitMolecules().size() == 1);
+  CHECK(MolToSmiles(*res3.getHitMolecules().front()) == "NC(F)(F)[C@H](O)F");
+
+  sparams.maxHitChiralAtoms = 0;
+  auto res4 = space.substructureSearch(*qmol, mparams, sparams);
+  CHECK(res4.getHitMolecules().size() == 0);
 }
\ No newline at end of file


Reviews:
user: login: greglandrum
body: some changes suggested.
There's also a problem with the maxNumChiralCenters logic that is described in a standalone comment
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-05-05T07:54:12Z
user: login: DavidACosgrove
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-05T15:24:54Z
user: login: DavidACosgrove
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-05T15:35:59Z
user: login: DavidACosgrove
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-05T15:43:14Z
user: login: DavidACosgrove
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-07T06:46:29Z
user: login: DavidACosgrove
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-07T06:47:46Z
user: login: greglandrum
body: LGTM
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-11T03:14:03Z

Review comments:
diff_hunk: @@ -167,4 +181,22 @@ void Synthon::finishInitialization() {
   }
 }
 
+void Synthon::calcProperties() {
+  d_numDummies = 0;
+  d_numHeavyAtoms = 0;
+  d_numChiralAtoms = 0;
+  d_molWt = 0;
+  MolOps::assignStereochemistry(*dp_origMol);
+  for (const auto atom : dp_origMol->atoms()) {
+    if (atom->getAtomicNum() == 0) {
+      d_numDummies++;
+    } else if (atom->getAtomicNum() > 1) {
+      d_numHeavyAtoms++;
+    }
+    if (atom->hasProp("_CIPCode") || atom->hasProp("_ChiralityPossible")) {
path: Code/GraphMol/SynthonSpaceSearch/Synthon.cpp
user: login: greglandrum
body: You should check the ChiralTag here, not the CIPCode property:
```suggestion
    if (atom->getChiralTag() != CHI_UNSPECIFIED) || atom->hasProp("_ChiralityPossible")) {
```
created_at: 2025-04-29T14:23:25Z
author_association: MEMBER
diff_hunk: @@ -167,4 +181,22 @@ void Synthon::finishInitialization() {
   }
 }
 
+void Synthon::calcProperties() {
+  d_numDummies = 0;
+  d_numHeavyAtoms = 0;
+  d_numChiralAtoms = 0;
+  d_molWt = 0;
+  MolOps::assignStereochemistry(*dp_origMol);
+  for (const auto atom : dp_origMol->atoms()) {
+    if (atom->getAtomicNum() == 0) {
+      d_numDummies++;
+    } else if (atom->getAtomicNum() > 1) {
+      d_numHeavyAtoms++;
+    }
+    if (atom->hasProp("_CIPCode") || atom->hasProp("_ChiralityPossible")) {
+      d_numChiralAtoms++;
+    }
+  }
+  d_molWt = Descriptors::calcExactMW(*dp_origMol);
path: Code/GraphMol/SynthonSpaceSearch/Synthon.cpp
user: login: greglandrum
body: I think you want `MolOps::getAvgMolWt()` here
created_at: 2025-04-30T05:36:55Z
author_association: MEMBER
diff_hunk: @@ -167,4 +181,22 @@ void Synthon::finishInitialization() {
   }
 }
 
+void Synthon::calcProperties() {
+  d_numDummies = 0;
+  d_numHeavyAtoms = 0;
+  d_numChiralAtoms = 0;
+  d_molWt = 0;
+  MolOps::assignStereochemistry(*dp_origMol);
+  for (const auto atom : dp_origMol->atoms()) {
+    if (atom->getAtomicNum() == 0) {
+      d_numDummies++;
+    } else if (atom->getAtomicNum() > 1) {
+      d_numHeavyAtoms++;
+    }
+    if (atom->hasProp("_CIPCode") || atom->hasProp("_ChiralityPossible")) {
path: Code/GraphMol/SynthonSpaceSearch/Synthon.cpp
user: login: greglandrum
body: You have a function below to calculate the number of chiral atoms, why aren't you using it here?
created_at: 2025-04-30T05:41:54Z
author_association: MEMBER
diff_hunk: @@ -862,4 +862,14 @@ std::map<std::string, std::vector<ROMol *>> mapFragsBySmiles(
   return fragSmiToFrag;
 }
 
+unsigned int countChiralAtoms(const ROMol &mol) {
+  unsigned int numChiralAtoms = 0;
+  for (const auto atom : mol.atoms()) {
+    if (atom->hasProp("_CIPCode") || atom->hasProp("_ChiralityPossible")) {
path: Code/GraphMol/SynthonSpaceSearch/SynthonSpaceSearch_details.cpp
user: login: greglandrum
body: Same comment as above:
```suggestion
    if (atom->getChiralTag() != CHI_UNSPECIFIED) || atom->hasProp("_ChiralityPossible")) {
```
why isn't this function being used above?
created_at: 2025-04-30T05:42:54Z
author_association: MEMBER
diff_hunk: @@ -158,6 +158,10 @@ RDKIT_SYNTHONSPACESEARCH_EXPORT std::map<std::string, std::vector<ROMol *>>
 mapFragsBySmiles(std::vector<std::vector<std::unique_ptr<ROMol>>> &fragSets,
                  bool &cancelled);
 
+// Count the number of chiral atoms, both specified and unspecified i.e. any
+// atoms with properties _CIPCode or _ChiralityPossible.
path: Code/GraphMol/SynthonSpaceSearch/SynthonSpaceSearch_details.h
user: login: greglandrum
body: once the code is fixed, please also fix this comment
created_at: 2025-05-05T07:06:04Z
author_association: MEMBER
diff_hunk: @@ -114,31 +114,3 @@ TEST_CASE("RASCAL Small tests") {
     }
   }
 }
-
path: Code/GraphMol/SynthonSpaceSearch/rascal_search_catch_tests.cpp
user: login: greglandrum
body: Why are you removing a test here?
created_at: 2025-05-05T07:09:46Z
author_association: MEMBER
diff_hunk: @@ -109,6 +109,16 @@ struct RDKIT_SYNTHONSPACESEARCH_EXPORT SynthonSpaceSearchParams {
              // appropriate for Morgan fingerprints.  With RDKit fingerprints,
              // 0.05 is adequate, and higher than that has been seen to
              // produce long run times.
+  unsigned int minHitHeavyAtoms{0};  // Minimum number of heavy atoms in a hit.
+  unsigned int maxHitHeavyAtoms{0};  // Maximum number of heavy atoms in a hit.
+                                     // 0 means no maximum.
+  double minHitMolWt{0};             // Minimum molecular weight for a hit.
+  double maxHitMolWt{0};  // Maximum molecular weight for a hit.  0.0 means
+                          // no maximum.
+  unsigned int minHitChiralAtoms{
+      0};  // Minimum number of chiral atoms in a hit.
+  unsigned int maxHitChiralAtoms{0};  // Maximum number of chiral atoms in a
path: Code/GraphMol/SynthonSpaceSearch/SynthonSpace.h
user: login: greglandrum
body: Using an unsigned int for this means that you can't do searches where you explicitly don't want chiral centers (or potential chiral centers) in the molecule.

I'm not sure whether this is a concern or not
created_at: 2025-05-05T07:43:45Z
author_association: MEMBER
diff_hunk: @@ -114,31 +114,3 @@ TEST_CASE("RASCAL Small tests") {
     }
   }
 }
-
path: Code/GraphMol/SynthonSpaceSearch/rascal_search_catch_tests.cpp
user: login: DavidACosgrove
body: I moved it into LONG_TEST_catch_tests.cpp.
created_at: 2025-05-05T15:24:54Z
author_association: COLLABORATOR
diff_hunk: @@ -167,4 +181,22 @@ void Synthon::finishInitialization() {
   }
 }
 
+void Synthon::calcProperties() {
+  d_numDummies = 0;
+  d_numHeavyAtoms = 0;
+  d_numChiralAtoms = 0;
+  d_molWt = 0;
+  MolOps::assignStereochemistry(*dp_origMol);
+  for (const auto atom : dp_origMol->atoms()) {
+    if (atom->getAtomicNum() == 0) {
+      d_numDummies++;
+    } else if (atom->getAtomicNum() > 1) {
+      d_numHeavyAtoms++;
+    }
+    if (atom->hasProp("_CIPCode") || atom->hasProp("_ChiralityPossible")) {
+      d_numChiralAtoms++;
+    }
+  }
+  d_molWt = Descriptors::calcExactMW(*dp_origMol);
path: Code/GraphMol/SynthonSpaceSearch/Synthon.cpp
user: login: DavidACosgrove
body: I was worrying about isotopes but I see that they are explicitly catered for.
created_at: 2025-05-05T15:35:58Z
author_association: COLLABORATOR
diff_hunk: @@ -167,4 +181,22 @@ void Synthon::finishInitialization() {
   }
 }
 
+void Synthon::calcProperties() {
+  d_numDummies = 0;
+  d_numHeavyAtoms = 0;
+  d_numChiralAtoms = 0;
+  d_molWt = 0;
+  MolOps::assignStereochemistry(*dp_origMol);
+  for (const auto atom : dp_origMol->atoms()) {
+    if (atom->getAtomicNum() == 0) {
+      d_numDummies++;
+    } else if (atom->getAtomicNum() > 1) {
+      d_numHeavyAtoms++;
+    }
+    if (atom->hasProp("_CIPCode") || atom->hasProp("_ChiralityPossible")) {
path: Code/GraphMol/SynthonSpaceSearch/Synthon.cpp
user: login: DavidACosgrove
body: Using the function involves another loop through the atoms in the molecule.  It seemed more efficient to avoid that.  But it clearly creates a maintenance issue so yes.
created_at: 2025-05-05T15:43:13Z
author_association: COLLABORATOR
diff_hunk: @@ -167,4 +181,22 @@ void Synthon::finishInitialization() {
   }
 }
 
+void Synthon::calcProperties() {
+  d_numDummies = 0;
+  d_numHeavyAtoms = 0;
+  d_numChiralAtoms = 0;
+  d_molWt = 0;
+  MolOps::assignStereochemistry(*dp_origMol);
+  for (const auto atom : dp_origMol->atoms()) {
+    if (atom->getAtomicNum() == 0) {
+      d_numDummies++;
+    } else if (atom->getAtomicNum() > 1) {
+      d_numHeavyAtoms++;
+    }
+    if (atom->hasProp("_CIPCode") || atom->hasProp("_ChiralityPossible")) {
path: Code/GraphMol/SynthonSpaceSearch/Synthon.cpp
user: login: DavidACosgrove
body: I've now changed the function to work off findPotentialStereo which seems to be more reliable for what is needed.
created_at: 2025-05-07T06:46:29Z
author_association: COLLABORATOR
diff_hunk: @@ -109,6 +109,16 @@ struct RDKIT_SYNTHONSPACESEARCH_EXPORT SynthonSpaceSearchParams {
              // appropriate for Morgan fingerprints.  With RDKit fingerprints,
              // 0.05 is adequate, and higher than that has been seen to
              // produce long run times.
+  unsigned int minHitHeavyAtoms{0};  // Minimum number of heavy atoms in a hit.
+  unsigned int maxHitHeavyAtoms{0};  // Maximum number of heavy atoms in a hit.
+                                     // 0 means no maximum.
+  double minHitMolWt{0};             // Minimum molecular weight for a hit.
+  double maxHitMolWt{0};  // Maximum molecular weight for a hit.  0.0 means
+                          // no maximum.
+  unsigned int minHitChiralAtoms{
+      0};  // Minimum number of chiral atoms in a hit.
+  unsigned int maxHitChiralAtoms{0};  // Maximum number of chiral atoms in a
path: Code/GraphMol/SynthonSpaceSearch/SynthonSpace.h
user: login: DavidACosgrove
body: I guess someone might want non-chiral hits.  I've changed the default to maximum to -1, and also maxHitHeavyAtoms for consistency.
created_at: 2025-05-07T06:47:46Z
author_association: COLLABORATOR

PR comments:
user: login: greglandrum
created_at: 2025-05-05T07:41:54Z
body: The logic for the maximum number of chiral centers doesn't work for cases where a synthon has dummy atoms on a potential chiral center.
For example, if I search this space using `maxHitChiralAtoms=1` I get no results:
```
SMILES	synton_id	synton#	reaction_id
F[1*]	277310376-742385846	0	fake-chiral
Cl[1*]	287123986-010598048	0	fake-chiral
O[C@H](F)C(N)([1*])[2*]	584456271-623025187	1	fake-chiral
O[C@H](F)C(Br)([1*])[2*]	584456271-623025187	1	fake-chiral
F[2*]	277310376-742385dd	2	fake-chiral
```
One of the results you get when searching without limits is:` NC(F)(F)[C@H](O)F`. That only has one chiral center and should be returned by the limited search as well.

author_association: MEMBER
user: login: greglandrum
created_at: 2025-05-08T17:26:36Z
body: @DavidACosgrove there are now some conflicts here that need to be resolved
author_association: MEMBER
user: login: DavidACosgrove
created_at: 2025-05-09T07:04:13Z
body: @greglandrum The errors in the minimallib build are not due to the PR.  A lot of them are complaining about rapidjson headers.
author_association: COLLABORATOR