Toxicity screening

Structural-alert and predicted-liability readouts for a molecule, from the command line. This is a repository tool (tools/tox), not part of the wheel. Run it from a git clone of ferric with RDKit installed.

python -m tools.tox --offline "CC(=O)Oc1ccccc1C(=O)O"

The screen combines a local RDKit pass with two optional web providers. The local pass checks several hundred compiled SMARTS patterns from six published alert catalogs (Brenk, PAINS, NIH, and the Glaxo, Dundee and BMS sets via ChEMBL), plus Lipinski and Veber rules, and needs no network.

What the web providers return (probed 2026-09-23):

  • ADMETlab 3.0's documented POST /api/admet returns HTTP 404. The client uses the live but undocumented POST /api/single/admet instead, one molecule per request. The host rate-limits hard, so expect HTTP 429, reported as an unavailable provider (exit 3), on larger batches.
  • ProTox-3.0 has no documented JSON API, and by design the provider never scrapes the HTML results page. It never contributes endpoints: every online run lists it as unsupported, and it never affects the exit status.

For a batch, --offline is still the safer default. The local screen needs no network, and it is the part whose output does not depend on a third-party service being up.

Usage

python -m tools.tox [--offline | --require-online] [--fail-on-alerts]
                    [--timeout SECONDS] [--json] SMILES|FILE [SMILES|FILE ...]
flageffect
--offlinelocal RDKit screen only; makes no network call
--require-onlinean unavailable online provider is a hard failure (exit 2), not a degraded run (exit 3). Cannot be combined with --offline
--fail-on-alertsexit 4 when any molecule has a structural alert
--timeout SECONDSwall-clock limit per web request (default 20). A provider that does not answer is not retried for the rest of the run
--jsonmachine-readable output on stdout, including each provider's provider_status

An input is read as a file when it exists and ends in .smi, .smiles or .txt; otherwise it is treated as a literal SMILES. A file holds one <smiles> [label] per line, and # starts a comment.

# candidates.smi
CC(=O)Oc1ccccc1C(=O)O    aspirin
CN1C=NC2=C1C(=O)N(C)C(=O)N2C   caffeine
python -m tools.tox --offline candidates.smi

Exit status

codemeaning
0clean: every molecule assessed by every provider that was asked to
1usage or input error: a bad flag, an unparseable SMILES, a duplicate label, or nothing to assess
2a required check did not run: the local screen failed, a provider raised instead of returning a result (contract_violation), or --require-online was given and an online provider was unavailable
3online checks unavailable: the local screen ran and is reported in full; each unavailable provider is named with its reason
4structural alerts found (only with --fail-on-alerts)

When several apply, the precedence is 1 > 2 > 4 > 3 > 0.

These are distinct on purpose. "No alerts found" and "the alert screen did not run" produce similar-looking output, and they mean opposite things — so a provider failure is never folded into success. A web-service outage is neither a usage error nor a verdict, so it has its own status: 3 means the local results are usable and the online predictions are missing. If a pipeline needs the online predictions, pass --require-online. Without --fail-on-alerts, alerts are reported but do not change the exit status.

Each provider's outcome is in the JSON as provider_status: ok, unavailable (outage, HTTP error, rate limit, timeout, unparseable response), unsupported (ProTox), no_result, error or contract_violation. The human output shows the same tag in brackets on its !! lines.

Reading the output

Abridged, for aspirin with --offline (each line is followed by a one-line explanation, omitted here):

  alert_brenk                            0.3333 probability  [higher=worse] rdkit-alerts
  alert_pains                                 0 probability  [higher=worse] rdkit-alerts
  alert_total_count                           2 count        [higher=worse] rdkit-alerts
  desc_clogp                               1.31 log10        [higher=worse] rdkit-alerts
  desc_mw                                 180.2 Da           [higher=worse] rdkit-alerts
  lipinski_violation_fraction                 0 probability  [higher=worse] rdkit-alerts

Every line states its polarity, and the JSON carries the same flag as higher_is_worse. An aggregator that guesses the direction will invert a safety ranking, so read the flag rather than assuming one. ADMETlab's F20%/F30% columns are an example of how easy the guess is to get wrong: they are the probability of low oral bioavailability (below 20% or 30%), so they are reported as low_bioavailability_20pct and low_bioavailability_30pct, higher is worse. Reading them as bioavailability, higher is better, inverts them.

A value of None means unknown, never zero. For a probability-valued endpoint, 0.0 means "confidently predicted negative", which is the opposite of "no information".

What the alert scores are not

The alert_* endpoints are scaled hit counts (n/3, capped at 1.0). The output labels them, like lipinski_violation_fraction and veber_violation_fraction, with the unit probability. That label marks a 0–1, higher-is-worse scale, and it is what puts these endpoints into the mean that ToxAssessment.liability_score (the rank-only aggregate on the object assess_smiles returns) takes over every probability-unit endpoint; alert_total_count (unit count) stays out of it. The number itself is a rank-only liability density, not a probability of toxicity, and each alert line's explanation says so. A molecule with zero alerts is not thereby safe: danuglipron screens clean across all six catalogs and was discontinued for a liver signal. Structural alerts catch known problem substructures; they say nothing about dose, exposure or on-target pharmacology.

From Python

from tools.tox.assess import assess_smiles, assess_many

a = assess_smiles("CCO", include_web=False)
for e in a.endpoints:
    if e.known:
        print(e.name, e.value, e.units, "higher_is_worse" if e.higher_is_worse else "")

# A batch reuses ONE provider list: compiling the SMARTS catalogs dominates
# the runtime of a per-molecule loop.
results = assess_many({"aspirin": "CC(=O)Oc1ccccc1C(=O)O"}, include_web=False)

assess_many returns results in input order and does not rank them — ranking depends on what else is being traded off, so it is the caller's job.