# -*- 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]))