SeaWolf-AI's picture
Season 3 (Chagas disease): per-season admission limits, panel verdict, opens 30 Nov 2026
5f76b5d verified
Raw
History Blame Contribute Delete
8.39 kB
# -*- coding: utf-8 -*-
"""Season 1 admission gates.
Gates decide eligibility, never quality. Anything that reflects how good a molecule is
belongs in the continuous score; a gate only answers "is this a valid Season 1 entry".
Failing a gate does not put a submission at the bottom of the table - it means the entry
is returned, because a covalent warhead in a non-covalent season is not a bad candidate,
it is the wrong kind of candidate.
The one exception is ADMET, and it is deliberate: a candidate that fails a toxicity gate
is still scored in full and still shown, but sorts below every clean entry no matter how
high that score is. A molecule that binds beautifully and is mutagenic is not a lead.
Every threshold here was checked against a panel of approved drugs first. A gate that
rejects an approved drug is a broken gate, and two of the obvious candidates - hERG and
DILI - were thrown out for exactly that reason: our own ADMET model scores ensitrelvir
at hERG 0.978 and DILI 0.995, and caffeine at hERG 0.885. See data/anchors.json.
"""
from rdkit import Chem, RDLogger
from rdkit.Chem import Descriptors
from rdkit.Chem.FilterCatalog import FilterCatalog, FilterCatalogParams
RDLogger.DisableLog("rdApp.*")
_p = FilterCatalogParams()
_p.AddCatalog(FilterCatalogParams.FilterCatalogs.PAINS)
_PAINS = FilterCatalog(_p)
MW_MAX = 550.0 # ensitrelvir is 531.9; a 500 cap would disqualify the reference drug
HEAVY_MAX = 45
AMES_MAX = 0.7 # panel range was 0.024-0.302, so this leaves real headroom
LOGS_MIN = -7.0 # panel low was -5.42
# Electrophiles that form a covalent bond with the catalytic residue. Season 1 excludes
# them: they are a different medicinal-chemistry problem, and a docking-based score
# rewards raw reactivity in ways that are easy to game.
WARHEADS = [
# A nitrile on its own is not a warhead. Teriflunomide is an approved, non-covalent
# DHODH inhibitor and a bare [CX2]#[NX1] pattern rejected it - the same mistake as
# gating on hERG, caught the same way, by running an approved drug through first.
# What actually reacts with a catalytic cysteine is the peptidyl nitrile: the nitrile
# carbon sitting on an sp3 centre that carries the amide nitrogen, as in nirmatrelvir.
("peptidyl nitrile", "[NX3][CX4][CX2]#[NX1]"),
# Michael acceptors must be terminal to react. The general C=C-C(=O)N pattern also
# matches teriflunomide, whose alkene is an enol stabilised by an adjacent nitrile
# and hydroxyl - an approved drug, and not an electrophile.
("acrylamide", "[CH2X3]=[CHX3][CX3](=O)[NX3]"),
("vinyl sulfone", "[CX3]=[CX3][SX4](=O)(=O)"),
("aldehyde", "[CX3H1](=O)[#6]"),
("epoxide", "[OX2r3]1[#6r3][#6r3]1"),
("aziridine", "[NX3r3]1[#6r3][#6r3]1"),
("haloacetamide", "[NX3][CX3](=O)[CH2][F,Cl,Br,I]"),
("boronic acid", "[BX3]([OX2H1])[OX2H1]"),
("alpha-keto amide", "[CX3](=O)[CX3](=O)[NX3]"),
]
_WARHEADS = [(n, Chem.MolFromSmarts(s)) for n, s in WARHEADS]
def canonical(text):
"""Accept SMILES or InChI. A molecular formula cannot be accepted - too many isomers."""
t = (text or "").strip()
if not t:
return None, "๋นˆ ์ž…๋ ฅ"
m = Chem.MolFromInchi(t) if t.upper().startswith("INCHI=") else Chem.MolFromSmiles(t)
if m is None:
return None, "๊ตฌ์กฐ๋ฅผ ํ•ด์„ํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค (SMILES ๋˜๋Š” InChI๋กœ ์ œ์ถœํ•˜์„ธ์š”; ๋ถ„์ž์‹์€ ์ด์„ฑ์งˆ์ฒด๊ฐ€ ๋งŽ์•„ ํ‰๊ฐ€ ๋ถˆ๊ฐ€)"
return m, None
def check(text, ames=None, logs=None, known_inchikeys=(), covalent_rule=True,
mw_max=None, heavy_max=None, pains_allow=None):
"""Return a verdict dict. `ames`/`logs` come from the ADMET engine; omit them and
those two gates are simply reported as not evaluated rather than silently passed.
`covalent_rule` is target-scoped on purpose. Excluding covalent binders makes sense
for Mpro, where warheads react with a catalytic cysteine and a docking score can be
gamed by raw reactivity. PfDHODH is not inhibited that way, so for Season 1 the rule
buys nothing and only creates false rejections - two approved drugs were wrongly
turned away here before the patterns were tightened.
`mw_max`, `heavy_max` and `pains_allow` are per-season and default to the module
constants, which are Season 1's. They are arguments rather than module state because
the Space is one process serving every season at once: reading them from the
environment would give Season 3's limits to Season 1's entries. seasons.py carries
each season's values and app.py passes them through."""
m, err = canonical(text)
if m is None:
return {"admitted": False,
"reject": [{"code": "parse", "text": err}], "relegate": []}
smi = Chem.MolToSmiles(m)
key = Chem.MolToInchiKey(m)
mw = Descriptors.MolWt(m)
heavy = m.GetNumHeavyAtoms()
reject, relegate, notes = [], [], []
# Each reason carries a code as well as a sentence. The sentence is a fallback; the
# code is what lets the page say it in the reader's language.
for name, patt in (_WARHEADS if covalent_rule else []):
if patt is not None and m.HasSubstructMatch(patt):
reject.append({"code": "covalent", "arg": name,
"text": "๊ณต์œ ๊ฒฐํ•ฉ warhead ๊ฒ€์ถœ (%s) โ€” Season 1์€ ๋น„๊ณต์œ  ํŠธ๋ž™์ž…๋‹ˆ๋‹ค" % name})
break
mw_cap = MW_MAX if mw_max is None else float(mw_max)
heavy_cap = HEAVY_MAX if heavy_max is None else int(heavy_max)
# no module-level default here on purpose: in the Space every season's limits arrive as
# arguments, and a module constant is exactly the thing that would leak across seasons
allow = tuple(pains_allow or ())
if mw > mw_cap:
reject.append({"code": "mw", "arg": "%.1f" % mw, "limit": mw_cap,
"text": "๋ถ„์ž๋Ÿ‰ %.1f > %.0f" % (mw, mw_cap)})
if heavy > heavy_cap:
reject.append({"code": "heavy", "arg": heavy, "limit": heavy_cap,
"text": "๋ฌด๊ฑฐ์šด ์›์ž %d > %d" % (heavy, heavy_cap)})
hits = [h for h in _PAINS.GetMatches(m)
if not any(h.GetDescription().startswith(a) for a in allow)]
if hits:
d = ", ".join(h.GetDescription() for h in hits)[:80]
reject.append({"code": "pains", "arg": d, "text": "PAINS ๊ตฌ์กฐ (%s)" % d})
if key in set(known_inchikeys):
reject.append({"code": "duplicate",
"text": "๊ธฐ์กด ์ œ์ถœ ๋˜๋Š” ๊ธฐ์ค€๋ฌผ์งˆ๊ณผ ๋™์ผํ•œ ๊ตฌ์กฐ์ž…๋‹ˆ๋‹ค"})
# ADMET does not reject. It relegates: full score, shown, but below every clean entry.
if ames is None:
notes.append("Ames ๋ฏธํ‰๊ฐ€")
elif ames > AMES_MAX:
relegate.append({"code": "ames", "arg": "%.3f" % ames, "limit": AMES_MAX,
"text": "๋ณ€์ด์›์„ฑ(Ames) %.3f > %.2f" % (ames, AMES_MAX)})
if logs is None:
notes.append("์šฉํ•ด๋„ ๋ฏธํ‰๊ฐ€")
elif logs < LOGS_MIN:
relegate.append({"code": "solubility", "arg": "%.2f" % logs, "limit": LOGS_MIN,
"text": "๊ทน๋‹จ์  ๋ถˆ์šฉ์„ฑ (logS %.2f < %.1f)" % (logs, LOGS_MIN)})
return {"admitted": not reject, "reject": reject, "relegate": relegate,
"notes": notes, "smiles": smi, "inchikey": key,
"mw": round(mw, 2), "heavy_atoms": heavy}
if __name__ == "__main__":
import json, os, sys
# the Windows console defaults to cp949 here and dies on the em-dash in a reject reason
try:
sys.stdout.reconfigure(encoding="utf-8")
except Exception:
pass
A = json.load(open(os.path.join("data", "anchors.json"), encoding="utf-8"))
print("%-30s %-9s %-7s %-6s %s" % ("compound", "admitted", "MW", "heavy", "reason"))
print("-" * 100)
for a in A["anchors"]:
ad = a.get("measured_admet", {})
v = check(a["smiles"], ames=ad.get("ames"), logs=ad.get("solubility-aqsoldb"))
why = "; ".join(v["reject"] + ["[๊ฐ•๋“ฑ] " + r for r in v["relegate"]]) or "-"
print("%-30s %-9s %-7s %-6s %s"
% (a["label"][:29], "PASS" if v["admitted"] else "REJECT",
v.get("mw"), v.get("heavy_atoms"), why[:60]))