syntheogenesis / dee /core /identify.py
github-actions[bot]
Deploy 90a38dc
1bf37a2
Raw
History Blame Contribute Delete
15.5 kB
"""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