Spaces:
Running
Running
| """Applying an edit to a construct β the half of the loop that was missing. | |
| Every other tool in this engine READS. It scores substitutions, folds a | |
| protein, maps a plasmid, ranks guides. None of them change anything, which | |
| means the agent could describe an edit in beautiful detail and never make one: | |
| the scientist still had to open the editor and type it themselves. | |
| This module is the other half. It is deliberately small and deliberately | |
| paranoid, because it is the first thing in the codebase that alters a | |
| construct, and a silent off-by-one here is not a bug report β it is a | |
| scientist ordering the wrong DNA. | |
| Three rules it will not bend: | |
| 1. REFUSE ON MISMATCH. Every edit states what it expects to find. If the | |
| construct does not have that residue or that base at that position, the | |
| edit does not happen and the caller is told exactly what is there | |
| instead. The same rule base_editor.codon_consequence already follows: | |
| an alignment that has slipped must never be papered over. | |
| 2. NEVER GUESS THE FRAME. Residue numbering only means something if the | |
| sequence is a coding sequence. If it is not, a residue-level edit is | |
| refused rather than measured from position 0 and hoped for. | |
| 3. SAY WHICH CODON. "R175H" does not name a codon β His is CAT or CAC, and | |
| which one you install is a real decision with a real effect on | |
| expression. The host's preferred codon is used and the choice is | |
| REPORTED, never left implicit. | |
| Pure: no network, no model, no torch. Everything here is checkable by hand. | |
| """ | |
| from __future__ import annotations | |
| import re | |
| from typing import Any, Dict, Optional | |
| from dee.core.base_editor import CODON_TABLE | |
| # "R175H" β wild-type residue, 1-based position, replacement residue. | |
| _PROTEIN_EDIT = re.compile(r"^([A-Za-z])(\d+)([A-Za-z*])$") | |
| # "A1523G" on DNA reads identically to a protein edit, so the two are told | |
| # apart by the `level` argument rather than by guessing from the letters. | |
| _DNA_EDIT = re.compile(r"^([ACGTacgt])(\d+)([ACGTacgt])$") | |
| _AA_LETTERS = set("ACDEFGHIKLMNPQRSTVWY*") | |
| # Refuse rather than churn: an "edit" this large is a replacement, and it | |
| # should go through the editor where the user can see the whole thing. | |
| MAX_EDITS_PER_CALL = 20 | |
| def _clean_dna(seq: str) -> str: | |
| return re.sub(r"[^ACGTNacgtn]", "", str(seq or "")).upper() | |
| def translate(dna: str) -> str: | |
| """Codons β one-letter amino acids. Partial trailing codon ignored.""" | |
| dna = _clean_dna(dna) | |
| return "".join(CODON_TABLE.get(dna[i:i + 3], "X") | |
| for i in range(0, len(dna) - len(dna) % 3, 3)) | |
| def is_coding(dna: str) -> bool: | |
| """Is this plausibly a CDS β a whole number of codons, starting ATG and | |
| ending on a stop, with no internal stop? | |
| Strict on purpose. A residue number quoted against a sequence that is not | |
| a reading frame is a number about nothing, and the cost of being wrong is | |
| an edit at the wrong codon. | |
| """ | |
| dna = _clean_dna(dna) | |
| if len(dna) < 6 or len(dna) % 3: | |
| return False | |
| if not dna.startswith("ATG"): | |
| return False | |
| aa = translate(dna) | |
| return aa.endswith("*") and "*" not in aa[:-1] | |
| def _preferred_codon(aa: str, host: str) -> str: | |
| """The host's most-used codon for this residue. | |
| Reuses the same tables the library encoder uses (dee.core.codon), so a | |
| codon Turing installs and a codon the Directed Evolution pipeline installs | |
| are the same codon β not two different opinions about the same host. | |
| """ | |
| from dee.core import codon as _codon | |
| table = _codon._resolve_table(host or "e_coli") | |
| return _codon._best_codon(aa.upper(), table) | |
| def parse_edit(spec: str, level: str = "protein") -> Optional[Dict[str, Any]]: | |
| """'R175H' β {'from': 'R', 'pos': 175, 'to': 'H'}. None if malformed. | |
| `level` decides which alphabet applies; it is never inferred, because | |
| 'A123G' is a valid edit in both and guessing would silently edit the | |
| wrong thing on exactly the sequences where it matters most. | |
| """ | |
| spec = str(spec or "").strip() | |
| m = (_PROTEIN_EDIT if level == "protein" else _DNA_EDIT).match(spec) | |
| if not m: | |
| return None | |
| src, pos, dst = m.group(1).upper(), int(m.group(2)), m.group(3).upper() | |
| if pos < 1: | |
| return None | |
| if level == "protein" and (src not in _AA_LETTERS or dst not in _AA_LETTERS): | |
| return None | |
| return {"from": src, "pos": pos, "to": dst} | |
| def apply_edits(sequence: str, specs, *, level: str = "protein", | |
| host: str = "e_coli") -> Dict[str, Any]: | |
| """Apply one or more edits to a construct. | |
| Returns {ok, sequence, applied[], length, ...} or {ok: False, error}. | |
| Nothing is applied unless EVERY edit validates β a half-applied set is a | |
| construct nobody asked for, and the agent would have no way to tell which | |
| half it got. | |
| """ | |
| dna = _clean_dna(sequence) | |
| if not dna: | |
| return {"ok": False, "error": "No sequence to edit."} | |
| if isinstance(specs, str): | |
| specs = [specs] | |
| specs = [s for s in (specs or []) if str(s or "").strip()] | |
| if not specs: | |
| return {"ok": False, "error": "No edit given."} | |
| if len(specs) > MAX_EDITS_PER_CALL: | |
| return {"ok": False, "error": ( | |
| f"{len(specs)} edits in one call β cap is {MAX_EDITS_PER_CALL}. " | |
| f"A change this large is a replacement, not an edit; build it in " | |
| f"the editor where the whole construct is visible.")} | |
| if level not in ("protein", "dna"): | |
| return {"ok": False, "error": "level must be 'protein' or 'dna'."} | |
| parsed = [] | |
| for spec in specs: | |
| p = parse_edit(spec, level) | |
| if not p: | |
| return {"ok": False, "error": ( | |
| f"Couldn't read β{spec}β as a{'n amino-acid' if level == 'protein' else ' DNA'} " | |
| f"edit. Expected e.g. " | |
| f"{'R175H (wild-type residue, position, replacement)' if level == 'protein' else 'A1523G'}.")} | |
| parsed.append((spec, p)) | |
| seen = {} | |
| for spec, p in parsed: | |
| if p["pos"] in seen: | |
| return {"ok": False, "error": ( | |
| f"Two edits at position {p['pos']} ({seen[p['pos']]} and {spec}). " | |
| f"Pick one.")} | |
| seen[p["pos"]] = spec | |
| # ββ protein-level ββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| if level == "protein": | |
| if not is_coding(dna): | |
| return {"ok": False, "kind": "not_coding", "error": ( | |
| "Residue numbering needs a coding sequence β this one is not a " | |
| "clean reading frame (whole codons, ATG start, single stop at " | |
| "the end). Give the CDS, or use level='dna' with base " | |
| "positions.")} | |
| aa = translate(dna) | |
| chars = list(dna) | |
| applied = [] | |
| for spec, p in parsed: | |
| idx = p["pos"] - 1 | |
| if idx >= len(aa) - 1: # -1: never edit the stop by residue | |
| return {"ok": False, "error": ( | |
| f"{spec}: this protein is {len(aa) - 1} residues long " | |
| f"(plus the stop), so there is no residue {p['pos']}.")} | |
| actual = aa[idx] | |
| if actual != p["from"]: | |
| return {"ok": False, "kind": "mismatch", "error": ( | |
| f"{spec}: residue {p['pos']} is {actual}, not {p['from']}. " | |
| f"Not editing β the numbering does not line up, and " | |
| f"guessing which one you meant is how the wrong residue " | |
| f"gets mutated.")} | |
| codon_before = dna[idx * 3:idx * 3 + 3] | |
| codon_after = _preferred_codon(p["to"], host) | |
| chars[idx * 3:idx * 3 + 3] = list(codon_after) | |
| applied.append({ | |
| "edit": f"{p['from']}{p['pos']}{p['to']}", | |
| "level": "protein", | |
| "residue": p["pos"], | |
| "codon_before": codon_before, | |
| "codon_after": codon_after, | |
| # The whole reason this field exists: His is CAT or CAC and | |
| # the choice is ours, so it is stated rather than buried. | |
| "codon_note": (f"{p['to']} installed as {codon_after} β the " | |
| f"most-used {p['to']} codon in {host}."), | |
| "dna_from": (idx * 3) + 1, # 1-based, inclusive | |
| "dna_to": (idx * 3) + 3, | |
| }) | |
| new_dna = "".join(chars) | |
| return { | |
| "ok": True, "sequence": new_dna, "length": len(new_dna), | |
| "applied": applied, "level": "protein", "host": host, | |
| "length_changed": len(new_dna) - len(dna), | |
| "protein_before": aa, "protein_after": translate(new_dna), | |
| } | |
| # ββ base-level βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| chars = list(dna) | |
| applied = [] | |
| for spec, p in parsed: | |
| idx = p["pos"] - 1 | |
| if idx >= len(dna): | |
| return {"ok": False, "error": ( | |
| f"{spec}: this construct is {len(dna):,} bp, so there is no " | |
| f"base {p['pos']}.")} | |
| actual = dna[idx] | |
| if actual != p["from"]: | |
| return {"ok": False, "kind": "mismatch", "error": ( | |
| f"{spec}: base {p['pos']} is {actual}, not {p['from']}. Not " | |
| f"editing β check the coordinate system, since an off-by-one " | |
| f"here silently mutates the neighbour.")} | |
| chars[idx] = p["to"] | |
| applied.append({"edit": f"{p['from']}{p['pos']}{p['to']}", | |
| "level": "dna", "dna_from": p["pos"], "dna_to": p["pos"]}) | |
| new_dna = "".join(chars) | |
| return { | |
| "ok": True, "sequence": new_dna, "length": len(new_dna), | |
| "applied": applied, "level": "dna", "host": host, | |
| "length_changed": 0, | |
| } | |