File size: 15,498 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
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
"""What is this thing I just pasted? β€” answered locally first, and honestly.

The audit calls this "the single most common first move a scientist makes"
(#5, High). Someone has a sequence from a collaborator, a supplementary table
or a sequencing run, and the first question is not "improve it" but "what am I
even looking at?".

THE DISTINCTION THIS MODULE IS BUILT AROUND
-------------------------------------------
**Characterizing is not identifying.** Everything here is computed from the
residues themselves against catalogues already in this repo: it can say "this
is 1,341 bp of DNA, one clean reading frame, carrying a T7 promoter and a
C-terminal 6xHis, and its 3' junction matches pET-28a(+)". That is a real,
checkable, useful answer and it costs nothing.

What it can NOT say is "this is human MC1R". Naming a sequence requires
comparing it to every known sequence, which means BLAST β€” and BLAST is the one
operation in this engine that sends the user's actual residues outside the
Space. So `identified` is **always False** here, and the result names the tool
that would change that. An agent that says "this is insulin" because it found
a His-tag is exactly the failure this module exists to prevent.

That ordering is also the privacy-correct one. Local analysis answers most of
the question for free; BLAST is offered as a deliberate, consented step rather
than being the reflex.

REUSE, DON'T REINVENT
---------------------
The audit's single most common finding is capability that already exists in
the repo with nothing pointing at it. So this module computes almost nothing
of its own:

* `sequence.classify_sequence`  β€” the three-way DNA/protein/ambiguous call
* `plasmid.find_motifs`         β€” the curated motif catalogue (promoters,
                                  terminators, tags, loxP/FRT/att sites)
* `plasmid.find_orfs`           β€” linear-time ORF scan on both strands
* `edits.is_coding` / `translate` β€” the strict CDS test and the codon table
* `accession.classify`          β€” is the paste actually an identifier?
* `vectors`                     β€” the 61-backbone catalogue and its junctions

The protein tag table is **derived by translating the DNA motifs already in
`plasmid._MOTIFS`** rather than typed out here. One source of truth, and no
sequence in this file was written from recall.
"""
from __future__ import annotations

import re
from typing import Any, Dict, List, Optional

from dee.core import accession as _acc
from dee.core import edits as _edits
from dee.core import plasmid as _plasmid
from dee.core import sequence as _seq
from dee.core import vectors as _vectors

# Below this there is not enough sequence to say anything, and a 6-mer matches
# something by chance in every database on earth.
MIN_LEN = 12
# Shortest ORF worth reporting. Below ~30 aa an "ORF" in random DNA is noise:
# a stop codon appears roughly every 21 codons by chance.
MIN_ORF_AA = 30
# A vector junction shorter than this is usually just a restriction site, which
# is shared by hundreds of backbones and proves nothing. Most of the 61
# catalogue entries record only a 6-bp site, so only a minority can ever
# produce a junction call β€” which is the honest outcome, not a shortfall.
MIN_JUNCTION = 16
# Distinct residues below which a "protein" looks synthetic or mis-pasted.
LOW_COMPLEXITY_BELOW = 8
LOW_COMPLEXITY_MIN_LEN = 60

_FASTA_HEADER = re.compile(r"^\s*>([^\n\r]*)", re.M)
_GENBANK = re.compile(r"^\s*LOCUS\s+\S", re.M)


def _tag_peptides() -> List[Dict[str, str]]:
    """Protein-level tags, derived from the repo's own DNA motif catalogue.

    Translating `plasmid._MOTIFS` keeps a single source of truth and means no
    peptide here was typed from memory β€” if the DNA catalogue is corrected,
    this follows automatically.
    """
    out: List[Dict[str, str]] = []
    seen = set()
    for name, motif, ftype in _plasmid._MOTIFS:
        if ftype != "tag" or len(motif) % 3:
            continue
        pep = _edits.translate(motif)
        if not pep or "X" in pep or "*" in pep:
            continue
        if pep in seen:
            continue
        seen.add(pep)
        out.append({"name": name, "peptide": pep})
    return out


_TAGS = _tag_peptides()


def _clean_residues(text: str) -> str:
    """Strip FASTA headers, digits and whitespace; keep letters and stops."""
    body = re.sub(r"^\s*>[^\n]*$", "", text or "", flags=re.M)
    return re.sub(r"[^A-Za-z*]", "", body).upper()


def _read_paste(text: str) -> Dict[str, Any]:
    """Work out what SHAPE the paste is before worrying what it contains."""
    raw = (text or "").strip()
    if not raw:
        return {"format": "empty", "header": None, "residues": ""}
    if _GENBANK.search(raw):
        return {"format": "genbank", "header": None,
                "residues": _clean_residues(raw)}
    m = _FASTA_HEADER.search(raw)
    if m:
        return {"format": "fasta", "header": m.group(1).strip()[:200],
                "residues": _clean_residues(raw)}
    # A bare token with no residue-ish bulk may be an identifier, not a sequence.
    token = raw.split()[0] if raw.split() else ""
    if len(raw.split()) == 1 and _acc.looks_like_accession(token):
        return {"format": "accession", "header": None, "residues": "",
                "token": token}
    return {"format": "raw", "header": None, "residues": _clean_residues(raw)}


def _header_accession(header: str) -> Optional[Dict[str, str]]:
    """An accession quoted in a FASTA header is a CLAIM, not evidence."""
    for token in re.split(r"[|\s,;]+", header or ""):
        token = token.strip()
        if len(token) >= 4 and _acc.looks_like_accession(token):
            family, molecule = _acc.classify(token)
            return {"accession": token, "family": family, "molecule": molecule}
    return None


def _vector_junctions(dna: str) -> List[Dict[str, Any]]:
    """Catalogue backbones whose cloning junction appears in this sequence.

    Hits are grouped by end, because a junction identifies a FAMILY, not a
    vector: the whole pET series shares one C-terminal His junction, and the
    catalogue happens to record it at different lengths for different entries.
    A longer recorded junction is a longer catalogue string, not better
    evidence β€” naming the longest match alone would invent a distinction the
    sequence does not support.
    """
    found: Dict[str, List[Dict[str, Any]]] = {}
    try:
        cat = _vectors._load()
    except Exception:                                    # pragma: no cover
        return []
    up = dna.upper()
    for rec in cat.values():
        for field in ("flanking_5p_max", "flanking_3p_max"):
            junction = (rec.get(field) or "").upper()
            if len(junction) < MIN_JUNCTION:
                continue
            for strand, needle in ((1, junction), (-1, _plasmid.revcomp(junction))):
                idx = up.find(needle)
                if idx < 0:
                    continue
                end = "5'" if field.startswith("flanking_5p") else "3'"
                found.setdefault(end, []).append({
                    "vector": rec.get("name") or rec.get("id"),
                    "start": idx, "strand": strand, "matched_bp": len(junction),
                })
                break

    out: List[Dict[str, Any]] = []
    for end, hits in sorted(found.items()):
        hits.sort(key=lambda h: (-h["matched_bp"], str(h["vector"])))
        names = [h["vector"] for h in hits]
        out.append({
            "end": end,
            "vectors": names,
            "start": min(h["start"] for h in hits),
            "longest_match_bp": max(h["matched_bp"] for h in hits),
            "distinguishing": len(names) == 1,
            "note": None if len(names) == 1 else (
                f"{len(names)} catalogue backbones share this {end} junction. "
                f"The match says which FAMILY the construct came from, not "
                f"which vector β€” do not pick one."),
        })
    return out


def _analyse_dna(dna: str) -> Dict[str, Any]:
    ambiguous = sum(1 for c in dna if c not in "ACGT")
    out: Dict[str, Any] = {
        "gc_percent": _plasmid.gc_percent(dna),
        "ambiguity_codes": ambiguous,
        "multiple_of_three": len(dna) % 3 == 0,
        "is_clean_cds": _edits.is_coding(dna),
    }

    orfs = _plasmid.find_orfs(dna, min_aa=MIN_ORF_AA, max_orfs=200)
    if orfs:
        longest = max(orfs, key=lambda o: o["end"] - o["start"])
        span = longest["end"] - longest["start"]
        out["orfs"] = len(orfs)
        out["longest_orf"] = {
            "aa": span // 3 - 1,               # the terminal stop is not a residue
            "start": longest["start"], "end": longest["end"],
            "strand": longest["strand"],
            "covers_percent": round(100.0 * span / len(dna), 1),
        }
    else:
        out["orfs"] = 0
        out["longest_orf"] = None

    out["features"] = [
        {"name": f["name"], "type": f["type"], "start": f["start"],
         "end": f["end"], "strand": f["strand"]}
        for f in _plasmid.find_motifs(dna)
    ]
    out["vector_junctions"] = _vector_junctions(dna)
    return out


def _analyse_protein(protein: str) -> Dict[str, Any]:
    body = protein.rstrip("*")
    distinct = len(set(body))
    out: Dict[str, Any] = {
        "starts_with_methionine": body.startswith("M"),
        "internal_stop": "*" in body,
        "unknown_residues": body.count("X"),
        "distinct_residues": distinct,
    }
    out["tags"] = [
        {"name": t["name"], "peptide": t["peptide"], "start": body.find(t["peptide"])}
        for t in _TAGS if t["peptide"] in body
    ]
    # A real protein uses most of the alphabet. Very low diversity means either
    # a synthetic construct (poly-A linker, His run) or DNA pasted by mistake.
    out["low_complexity"] = (len(body) >= LOW_COMPLEXITY_MIN_LEN
                             and distinct < LOW_COMPLEXITY_BELOW)
    return out


def _summary(molecule: str, length: int, detail: Dict[str, Any]) -> str:
    """One line a bench scientist would actually say out loud."""
    if molecule == "protein":
        bits = [f"{length} aa protein"]
        if detail.get("tags"):
            bits.append(", ".join(t["name"] for t in detail["tags"]))
        # Worth surfacing unprompted: a premature stop means the translation
        # is truncated, and every downstream number would be about a protein
        # the cell never makes.
        if detail.get("internal_stop"):
            bits.append("contains an internal stop β€” this translation is truncated")
        if detail.get("unknown_residues"):
            bits.append(f"{detail['unknown_residues']} unknown (X) residues")
        if detail.get("low_complexity"):
            bits.append("unusually low residue diversity")
        return " Β· ".join(bits)
    unit = "nt RNA" if molecule == "rna" else "bp DNA"
    bits = [f"{length} {unit}", f"{detail.get('gc_percent')}% GC"]
    if detail.get("is_clean_cds"):
        bits.append("a clean CDS end to end")
    elif detail.get("longest_orf"):
        orf = detail["longest_orf"]
        bits.append(f"longest ORF {orf['aa']} aa ({orf['covers_percent']}% of it)")
    else:
        bits.append("no ORF of 30 aa or more")
    named = sorted({f["name"] for f in detail.get("features") or []})
    if named:
        bits.append(", ".join(named[:4]) + ("…" if len(named) > 4 else ""))
    for j in (detail.get("vector_junctions") or [])[:1]:
        names = j["vectors"]
        bits.append(f"{j['end']} junction matches {names[0]}" if j["distinguishing"]
                    else (f"{j['end']} junction shared by {len(names)} catalogue "
                          f"backbones ({names[0]}, {names[1]}…)"))
    return " Β· ".join(bits)


def identify(text: str) -> Dict[str, Any]:
    """Characterize a pasted sequence locally. Never claims to have named it."""
    paste = _read_paste(text)

    if paste["format"] == "empty":
        return {"ok": False, "kind": "empty", "error": "Nothing to identify.",
                "next": "Paste a sequence, or an accession to fetch one."}

    if paste["format"] == "accession":
        token = paste["token"]
        family, molecule = _acc.classify(token)
        return {
            "ok": True, "kind": "accession", "identified": False,
            "input_format": "accession", "accession": token,
            "family": family, "molecule": molecule,
            "summary": (f"'{token}' is a {family} identifier"
                        + (f" for {molecule}" if molecule else "")
                        + ", not a sequence."),
            "next": (f"Call fetch_sequence with '{token}' to retrieve it. "
                     f"An accession names the sequence already β€” there is "
                     f"nothing to identify."),
        }

    residues = paste["residues"]
    if len(residues) < MIN_LEN:
        return {"ok": False, "kind": "too_short",
                "error": f"Only {len(residues)} residues; {MIN_LEN} is the "
                         f"minimum worth analysing.",
                "next": "Short oligos are better handled by design_primers."}

    molecule = _seq.classify_sequence(residues)
    if molecule == "dna" and "U" in residues and "T" not in residues:
        molecule = "rna"

    if molecule == "ambiguous":
        return {
            "ok": True, "kind": "ambiguous", "identified": False,
            "input_format": paste["format"], "molecule": "ambiguous",
            "length": len(residues),
            "summary": (f"{len(residues)} residues of A/C/G/T/U/N only β€” at "
                        f"this length that reads equally well as a short "
                        f"peptide or a DNA fragment."),
            "next": ("Ask the user which it is before analysing further. "
                     "Guessing here silently misroutes the whole job."),
        }

    detail = (_analyse_protein(residues) if molecule == "protein"
              else _analyse_dna(residues))

    claim = _header_accession(paste["header"]) if paste.get("header") else None
    result: Dict[str, Any] = {
        "ok": True,
        "kind": "characterized",
        "input_format": paste["format"],
        "molecule": molecule,
        "length": len(residues),
        "summary": _summary(molecule, len(residues), detail),
        "detail": detail,
        # The whole point. Local analysis describes; it does not name.
        "identified": False,
        "to_identify_it": (
            "Nothing above names this sequence β€” it is computed from the "
            "residues against local catalogues. To find out WHAT it is, call "
            "blast_sequence, which searches NCBI. That sends the sequence "
            "outside this Space and the user is asked to approve it first."),
        "caveat": (
            "Motif and junction matches are exact string matches against a "
            "curated list, so a hit is real but an absence means only 'not in "
            "the list' β€” the catalogue is small and deliberately conservative."),
    }

    if paste.get("header"):
        result["header_claim"] = {
            "text": paste["header"],
            "accession": claim,
            "note": ("This came from the FASTA header, which is whatever the "
                     "person who wrote the file typed. Treat it as a claim to "
                     "check, not as evidence β€” fetch the accession and compare "
                     "if it matters."),
        }
    return result