"""TDC Oracle tool — local ML property/bioactivity scoring via PyTDC.
Wraps Therapeutics Data Commons (TDC) pretrained ``Oracle`` scorers. Given a
SMILES string (or list of SMILES) and an oracle name, returns molecular
property / drug-likeness / bioactivity scores computed locally.
This is distinct from the existing RDKit/ADMET tools: TDC oracles include
ML-trained bioactivity classifiers (GSK3B, JNK3, DRD2) and the standard
medicinal-chemistry scorers (QED, SA, LogP) used as goal-directed molecular
optimization objectives.
Notes
-----
- ``QED`` and ``LogP`` are pure RDKit and run fully offline/instantly.
- ``SA`` downloads a tiny fragment-score table on first use, then runs locally.
- ML bioactivity oracles (``GSK3B``, ``JNK3``, ``DRD2``) download a pretrained
model file (a few MB) on first use, then score locally.
PyTDC (1.1.x) ships a legacy ``from rdkit.six import iteritems`` import that
fails on modern RDKit (``rdkit.six`` was removed). That import sits behind a
bare ``except`` that re-raises a misleading "install rdkit" error, even though
the actual QED/SA/LogP scoring functions never touch it. We install a tiny
``rdkit.six`` shim before importing tdc so the real oracles work on current
RDKit. The shim is a no-op for environments where ``rdkit.six`` already exists.
"""
from .base_tool import BaseTool
from .tool_registry import register_tool
def _install_rdkit_six_shim():
"""Provide a minimal ``rdkit.six`` so PyTDC's legacy import succeeds.
``rdkit.six`` was removed from modern RDKit. PyTDC still imports
``iteritems`` from it inside a try/except that masks the real error.
Only the symbol PyTDC references (``iteritems``) is provided.
"""
try:
import rdkit.six # noqa: F401 -- already present, nothing to do
return
except Exception:
pass
try:
import sys
import types
import rdkit # noqa: F401 -- ensure base package exists first
except Exception:
# RDKit itself is missing; let the real import error surface later.
return
six_mod = types.ModuleType("rdkit.six")
six_mod.iteritems = lambda d: iter(d.items())
six_mod.itervalues = lambda d: iter(d.values())
six_mod.iterkeys = lambda d: iter(d.keys())
sys.modules["rdkit.six"] = six_mod
try:
rdkit.six = six_mod
except Exception:
pass
_sa_scores_checked = False
def _ensure_sa_fragment_scores_once():
"""Run the repair at most once, and only when an oracle is actually used.
This must not happen at import time. PyTDC reads its table from a *relative*
``oracle/`` path, so repairing it eagerly wrote a 4.3 MB pickle into
whatever directory the process happened to start in -- for anyone who
merely imported ToolUniverse, including test runs and unrelated tools.
A library import should not create files.
"""
global _sa_scores_checked
if _sa_scores_checked:
return
_sa_scores_checked = True
_ensure_sa_fragment_scores()
def _ensure_sa_fragment_scores():
"""Repair PyTDC's synthetic-accessibility table from RDKit's bundled copy.
PyTDC fetches ``fpscores.pkl`` from Harvard Dataverse into a relative
``oracle/`` directory. That download currently returns **403 Forbidden**,
and PyTDC saves the HTML error page as the pickle without checking, so the
next read dies on ``invalid load key, '<'``. The ``Oracle`` wrapper then
swallows the exception and returns **0.0** -- a number that looks like a
perfectly ordinary score. Aspirin came back with a LogP of 0.0 (it is
~1.3) and an SA of 0.0 (the scale starts at 1), and nothing anywhere
reported a failure.
The authoritative table ships inside RDKit, which is already installed for
any environment that can run these oracles, so use that instead of the
broken download. Only a corrupt or absent file is replaced.
"""
import gzip
import os
import pickle
target = os.path.join("oracle", "fpscores.pkl")
try:
if os.path.exists(target):
with open(target, "rb") as handle:
pickle.load(handle)
return # already valid
except Exception:
pass # corrupt -- fall through and rewrite
try:
import rdkit
source = os.path.join(
os.path.dirname(rdkit.__file__), "Contrib", "SA_Score", "fpscores.pkl.gz"
)
if not os.path.exists(source):
return
with gzip.open(source, "rb") as handle:
scores = pickle.load(handle)
os.makedirs("oracle", exist_ok=True)
tmp = target + ".part"
with open(tmp, "wb") as handle:
pickle.dump(scores, handle)
os.replace(tmp, target) # atomic: never leave a half-written pickle
except Exception:
# Best effort. A failure here leaves the original behaviour intact.
return
# Attempt the optional import at module load so missing-dependency handling is
# a clean error rather than an exception. Mirrors the framework's optional-dep
# pattern (try/except ImportError -> AVAILABLE flag).
TDC_AVAILABLE = False
_IMPORT_ERROR = None
try:
_install_rdkit_six_shim()
from tdc import Oracle as _TDCOracle # noqa: E402
TDC_AVAILABLE = True
except Exception as exc: # ImportError or downstream rdkit-guard ImportError
_TDCOracle = None
_IMPORT_ERROR = str(exc)
# Oracles that are fast and either fully offline or download only a tiny table.
_FAST_ORACLES = {"QED", "SA", "LOGP"}
# Curated list of supported oracle names with one-line descriptions. TDC also
# supports many more; these are the stable, single-SMILES scorers most useful
# for property prediction and goal-directed optimization.
_SUPPORTED_ORACLES = {
"QED": "Quantitative Estimate of Drug-likeness (0-1, higher = more drug-like). RDKit, offline.",
"SA": "Synthetic Accessibility score (1=easy to 10=hard to synthesize). Downloads a small table once.",
"LogP": "Octanol-water partition coefficient (lipophilicity). RDKit, offline.",
"GSK3B": "ML bioactivity oracle: probability of GSK3-beta inhibition (0-1). Downloads model once.",
"JNK3": "ML bioactivity oracle: probability of JNK3 inhibition (0-1). Downloads model once.",
"DRD2": "ML bioactivity oracle: probability of DRD2 activity (0-1). Downloads model once.",
}