File size: 8,584 Bytes
1bf37a2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
"""The residues you should not mutate, from the people who curated them.

THE PROBLEM
-----------
The engine will happily rank a substitution at the catalytic serine of a
protease as promising. ESM-2 scores how UNUSUAL a residue is, not how load-
bearing it is, and an active-site residue is often perfectly ordinary in
sequence terms β€” it is the geometry that matters, and a language model over
sequence does not see geometry.

So the single cheapest improvement to design quality is not a better model. It
is asking UniProt what is already known about the positions being mutated:
active sites, binding sites, disulfides, metal ligands, domains. That is
curated, citable, human-reviewed annotation, and it turns "this substitution
looks tolerable" into "this substitution is at the catalytic residue".

WHAT THIS IS
------------
A read of UniProt's feature table, mapped onto positions the user cares about.
No prediction, no score, no model β€” every item returned carries the evidence
UniProt itself carries. When UniProt has nothing for a protein, that is
reported as "not annotated", never as "safe": absence of annotation is absence
of knowledge, and conflating the two is how a tool talks someone into an
experiment.

Only (accession) leaves the Space. Never a user sequence.
"""
from __future__ import annotations

import json
import re
import urllib.parse
import urllib.request
from typing import Any, Dict, Iterable, List, Optional

_UA = "TuringDNA/1.0 (https://turingdna.com)"
_ENTRY = "https://rest.uniprot.org/uniprotkb"

# Feature types worth interrupting a design for, in the order a designer cares.
# UniProt emits many more (VARIANT, CONFLICT, chains); those describe the
# record, not a reason to avoid a position, and including them would bury the
# signal.
CRITICAL = {
    "Active site":      "catalytic β€” mutating this typically abolishes activity",
    "Binding site":     "contacts the substrate or cofactor",
    "Site":             "functionally annotated position",
    "Metal binding":    "coordinates a metal ion the fold depends on",
    "Disulfide bond":   "forms a disulfide; losing one cysteine breaks both",
    "Modified residue": "post-translationally modified",
    "Glycosylation":    "glycosylation site",
    "Cross-link":       "covalently cross-linked",
}
CONTEXT = {
    "Domain":   "domain",
    "Region":   "region",
    "Motif":    "motif",
    "Repeat":   "repeat",
    "Transmembrane": "membrane-spanning",
    "Signal":   "signal peptide",
    "Propeptide": "propeptide β€” cleaved from the mature protein",
}


def _get(url: str, timeout: float = 15.0) -> Optional[dict]:
    try:
        req = urllib.request.Request(url, headers={"User-Agent": _UA,
                                                   "Accept": "application/json"})
        with urllib.request.urlopen(req, timeout=timeout) as r:
            return json.loads(r.read().decode("utf-8"))
    except Exception:  # noqa: BLE001
        return None


def _span(f: Dict[str, Any]) -> Optional[tuple]:
    loc = (f.get("location") or {})
    try:
        s = loc["start"]["value"]
        e = loc["end"]["value"]
    except (KeyError, TypeError):
        return None
    if s is None or e is None:
        return None
    return (int(s), int(e))


def fetch(accession: str) -> Dict[str, Any]:
    """UniProt's curated feature table for one accession."""
    acc = re.sub(r"[^A-Za-z0-9\-]", "", accession or "").upper()
    if not acc:
        return {"ok": False, "error": "No accession given."}
    # The whole entry, filtered here. A ?fields= list looked tidier and is
    # not valid on the entry endpoint β€” it returns an error, not a subset, so
    # the "tidy" version fetched nothing at all.
    data = _get(f"{_ENTRY}/{urllib.parse.quote(acc)}.json")
    if not data:
        return {"ok": False, "kind": "unreachable",
                "error": f"Couldn't reach UniProt for {acc}.",
                "next": "Retry; this is usually momentary."}

    feats = []
    for f in (data.get("features") or []):
        sp = _span(f)
        if not sp:
            continue
        ftype = str(f.get("type") or "")
        tier = "critical" if ftype in CRITICAL else (
            "context" if ftype in CONTEXT else None)
        if tier is None:
            continue
        feats.append({
            "type": ftype,
            "start": sp[0], "end": sp[1],
            "tier": tier,
            "description": str(f.get("description") or "")[:180],
            "means": CRITICAL.get(ftype) or CONTEXT.get(ftype, ""),
            # UniProt's own evidence codes travel with the claim. ECO:0000269
            # is experimental; ECO:0000250 is inferred by similarity, and a
            # designer should weight those differently.
            "evidence": sorted({str(e.get("evidenceCode") or "")
                                for e in (f.get("evidences") or [])
                                if e.get("evidenceCode")}),
        })

    name = ""
    try:
        name = (data["proteinDescription"]["recommendedName"]
                    ["fullName"]["value"])
    except (KeyError, TypeError):
        pass
    return {
        "ok": True,
        "accession": data.get("primaryAccession") or acc,
        "protein": name,
        "length": ((data.get("sequence") or {}).get("length")),
        "features": feats,
        "critical_count": sum(1 for f in feats if f["tier"] == "critical"),
        "source": "UniProt",
    }


def annotate(accession: str, positions: Iterable) -> Dict[str, Any]:
    """Which of `positions` land on annotated features.

    Positions may be integers or substitution labels ('R175H'); the label form
    is what the rest of the engine speaks, so accepting it saves the caller a
    parsing step and a chance to get the offset wrong.
    """
    table = fetch(accession)
    if not table.get("ok"):
        return table

    wanted: List[Dict[str, Any]] = []
    for p in positions or []:
        if isinstance(p, int):
            wanted.append({"label": str(p), "pos": p})
            continue
        m = re.match(r"^([A-Za-z])?(\d+)([A-Za-z*])?$", str(p).strip())
        if m:
            wanted.append({"label": str(p).strip(), "pos": int(m.group(2))})

    hits = []
    for w in wanted:
        on = [f for f in table["features"] if f["start"] <= w["pos"] <= f["end"]]
        crit = [f for f in on if f["tier"] == "critical"]
        hits.append({
            "label": w["label"], "position": w["pos"],
            "critical": crit, "context": [f for f in on if f["tier"] == "context"],
            # The whole point of the tool, stated so the agent cannot miss it.
            "verdict": ("AVOID β€” annotated functional residue" if crit else
                        "no functional annotation at this position"),
        })

    flagged = [h for h in hits if h["critical"]]
    return {
        "ok": True,
        "accession": table["accession"], "protein": table["protein"],
        "length": table["length"],
        "positions": hits,
        "flagged_count": len(flagged),
        "verdict": (f"{len(flagged)} of {len(hits)} positions sit on annotated "
                    f"functional residues." if flagged else
                    f"None of the {len(hits)} positions are annotated as "
                    f"functional."),
        # The failure that would make this tool actively harmful. UniProt
        # numbers the FULL PRECURSOR β€” bovine trypsin's catalytic triad is at
        # 63/107/200 here, not the classic His57/Asp102/Ser195, because the
        # signal peptide and propeptide are counted. A user thinking in mature
        # or chymotrypsin numbering gets a confident flag on the wrong residue,
        # which is worse than no flag at all.
        "numbering": (f"Positions are UniProt's, numbered from residue 1 of the "
                      f"full precursor ({table['length']} aa) including any "
                      f"signal peptide or propeptide. If you are using mature-"
                      f"protein or a classic numbering scheme, these will be "
                      f"offset β€” check one known residue before trusting the "
                      f"rest."),
        "caveat": ("Absence of annotation is absence of KNOWLEDGE, not evidence "
                   "that a position is safe β€” most proteins are annotated "
                   "sparsely, and an unannotated residue may still be "
                   "essential. Evidence codes are UniProt's own: ECO:0000269 "
                   "is experimental, ECO:0000250 is inferred by similarity."),
        "source": "UniProt",
    }