Season 3 (Chagas disease): per-season admission limits, panel verdict, opens 30 Nov 2026
5f76b5d verified | # -*- 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])) | |