File size: 8,390 Bytes
5f76b5d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
# -*- 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]))