Spaces:
Running
Running
| """Why a Golden Gate assembly with six correct fragments still comes back wrong. | |
| THE FAILURE THIS CATCHES | |
| ------------------------ | |
| `cloning.py` simulates the assembly: it finds the Type IIS sites, cuts, and | |
| chains parts whose overhangs match into a circle. If the overhangs chain, it | |
| reports success — and it is right, as a simulation. | |
| What it cannot see is that two of those overhangs are one base apart, or that | |
| one is its own reverse complement. In the tube, T4 ligase does not care which | |
| partner it found; near-identical overhangs cross-ligate and the plate comes | |
| back with fragments in the wrong order or dropped entirely. The simulation | |
| says the design works. The bench says otherwise, a week later. | |
| Every one of those conflicts is decidable from the four-base sequences alone, | |
| before anything is ordered. | |
| WHAT THIS DOES AND DOES NOT CLAIM | |
| --------------------------------- | |
| It reports STRUCTURAL conflicts — facts about the sequences: | |
| duplicate the same overhang used twice; the assembly cannot have | |
| a defined order | |
| palindromic an overhang that is its own reverse complement, so a | |
| fragment ligates to itself and to its own inverse | |
| complementary one overhang is the reverse complement of another, so | |
| the two junctions are interchangeable | |
| near-identical overhangs differing by a single base, the classic | |
| source of low-frequency misassembly | |
| low complexity all-AT ligates weakly, all-GC ligates promiscuously | |
| It does NOT report a "fidelity percentage". Those numbers come from published | |
| empirical ligation datasets measured on real reactions; this engine has none | |
| of that data, and a percentage computed from sequence alone would be a | |
| fabricated measurement wearing a decimal point. What is returned is what can | |
| be checked by eye — which is also what lets a user fix it. | |
| """ | |
| from __future__ import annotations | |
| import re | |
| from typing import Any, Dict, Iterable, List, Optional | |
| # Type IIS enzymes in common use leave a 4-nt 5' overhang. The length is a | |
| # property of the enzyme, so it is a parameter rather than a constant. | |
| DEFAULT_LEN = 4 | |
| _COMP = str.maketrans("ACGT", "TGCA") | |
| def revcomp(s: str) -> str: | |
| return s.translate(_COMP)[::-1] | |
| def _clean(s: str) -> str: | |
| return re.sub(r"[^ACGTacgt]", "", s or "").upper() | |
| def is_palindrome(oh: str) -> bool: | |
| """Self-complementary — ligates to itself AND to its own inverse. | |
| An even-length overhang can be its own reverse complement (AATT, GGCC). | |
| A junction built on one has no defined orientation. | |
| """ | |
| return bool(oh) and oh == revcomp(oh) | |
| def hamming(a: str, b: str) -> int: | |
| """Substitutions between two equal-length overhangs. -1 if lengths differ.""" | |
| if len(a) != len(b): | |
| return -1 | |
| return sum(1 for x, y in zip(a, b) if x != y) | |
| def gc_fraction(oh: str) -> float: | |
| return (oh.count("G") + oh.count("C")) / len(oh) if oh else 0.0 | |
| def check(overhangs: Iterable[str], *, length: int = DEFAULT_LEN, | |
| min_distance: int = 2) -> Dict[str, Any]: | |
| """Every structural conflict in a proposed overhang set. | |
| `min_distance` is the Hamming distance below which two overhangs are | |
| reported as too similar. 2 is the working default: at distance 1 a single | |
| mis-pairing event produces a wrong-but-ligatable junction. | |
| """ | |
| raw = list(overhangs or []) | |
| ohs: List[str] = [] | |
| rejected: List[str] = [] | |
| for o in raw: | |
| c = _clean(o) | |
| if len(c) != length: | |
| rejected.append(f"{o!r} is not {length} nt") | |
| else: | |
| ohs.append(c) | |
| conflicts: List[Dict[str, Any]] = [] | |
| # 1. Duplicates — the assembly has no defined order at all. | |
| seen: Dict[str, int] = {} | |
| for idx, o in enumerate(ohs): | |
| if o in seen: | |
| conflicts.append({ | |
| "kind": "duplicate", "severity": "fatal", | |
| "overhangs": [o], "positions": [seen[o], idx], | |
| "why": f"{o} is used at two junctions, so the fragments " | |
| f"between them can assemble in either order."}) | |
| else: | |
| seen[o] = idx | |
| # 2. Palindromes — a fragment that ligates to itself. | |
| for idx, o in enumerate(ohs): | |
| if is_palindrome(o): | |
| conflicts.append({ | |
| "kind": "palindromic", "severity": "fatal", | |
| "overhangs": [o], "positions": [idx], | |
| "why": f"{o} is its own reverse complement, so the fragment " | |
| f"can ligate to itself and to its own inversion."}) | |
| # 3 & 4. Pairwise: complementarity and near-identity, each checked against | |
| # the partner AND the partner's reverse complement, because the strand a | |
| # ligase sees depends on which fragment arrives. | |
| for i in range(len(ohs)): | |
| for j in range(i + 1, len(ohs)): | |
| a, b = ohs[i], ohs[j] | |
| rb = revcomp(b) | |
| if a == rb: | |
| conflicts.append({ | |
| "kind": "complementary", "severity": "fatal", | |
| "overhangs": [a, b], "positions": [i, j], | |
| "why": f"{a} is the reverse complement of {b}, so those " | |
| f"two junctions are interchangeable."}) | |
| continue | |
| d = min(hamming(a, b), hamming(a, rb)) | |
| if 0 < d < min_distance: | |
| conflicts.append({ | |
| "kind": "near_identical", "severity": "high", | |
| "overhangs": [a, b], "positions": [i, j], "distance": d, | |
| "why": f"{a} and {b} differ by {d} base" | |
| f"{'' if d == 1 else 's'}; a single mis-pairing " | |
| f"gives a wrong junction that still ligates."}) | |
| # 5. Composition — weak or promiscuous ends. | |
| for idx, o in enumerate(ohs): | |
| gc = gc_fraction(o) | |
| if gc == 0.0: | |
| conflicts.append({ | |
| "kind": "low_complexity", "severity": "medium", | |
| "overhangs": [o], "positions": [idx], | |
| "why": f"{o} is all A/T — the weakest ligation in the set, and " | |
| f"the junction most likely to be missing."}) | |
| elif gc == 1.0: | |
| conflicts.append({ | |
| "kind": "low_complexity", "severity": "medium", | |
| "overhangs": [o], "positions": [idx], | |
| "why": f"{o} is all G/C, which ligates promiscuously."}) | |
| fatal = sum(1 for c in conflicts if c["severity"] == "fatal") | |
| high = sum(1 for c in conflicts if c["severity"] == "high") | |
| if not ohs: | |
| verdict = "No usable overhangs given." | |
| elif fatal: | |
| verdict = (f"{fatal} conflict(s) that make the assembly ambiguous. " | |
| f"This set will not build reliably.") | |
| elif high: | |
| verdict = f"{high} pair(s) close enough to misligate at low frequency." | |
| elif conflicts: | |
| verdict = "Usable, with weak junctions noted." | |
| else: | |
| verdict = f"No structural conflicts across {len(ohs)} overhangs." | |
| return { | |
| "ok": True, | |
| "overhangs": ohs, | |
| "count": len(ohs), | |
| "rejected": rejected, | |
| "conflicts": conflicts, | |
| "fatal": fatal, | |
| "usable": fatal == 0 and bool(ohs), | |
| "verdict": verdict, | |
| "basis": ("Structural conflicts computed from the sequences. NOT a " | |
| "fidelity percentage — those come from published empirical " | |
| "ligation datasets this engine does not have, and a number " | |
| "derived from sequence alone would be invented."), | |
| } | |
| def suggest(count: int, *, length: int = DEFAULT_LEN, min_distance: int = 2, | |
| avoid: Optional[Iterable[str]] = None) -> Dict[str, Any]: | |
| """A conflict-free set of `count` overhangs. | |
| Greedy: walk the space in a fixed order and keep any candidate that | |
| conflicts with nothing already chosen. Deterministic on purpose — the same | |
| request gives the same set, so a design is reproducible and a methods | |
| section can name it. | |
| """ | |
| if count < 1: | |
| return {"ok": False, "error": "Need at least one overhang."} | |
| blocked = {_clean(o) for o in (avoid or []) if _clean(o)} | |
| chosen: List[str] = [] | |
| def fits(cand: str) -> bool: | |
| if is_palindrome(cand): | |
| return False | |
| g = gc_fraction(cand) | |
| if g in (0.0, 1.0): | |
| return False | |
| for o in list(chosen) + list(blocked): | |
| if cand == o or cand == revcomp(o): | |
| return False | |
| if 0 < min(hamming(cand, o), hamming(cand, revcomp(o))) < min_distance: | |
| return False | |
| return True | |
| from itertools import product | |
| for combo in product("ACGT", repeat=length): | |
| if len(chosen) >= count: | |
| break | |
| cand = "".join(combo) | |
| if fits(cand): | |
| chosen.append(cand) | |
| if len(chosen) < count: | |
| # The space genuinely runs out; say so rather than return a short set | |
| # that looks complete. | |
| return {"ok": False, "kind": "exhausted", | |
| "error": f"Only {len(chosen)} conflict-free {length}-nt " | |
| f"overhangs exist at distance {min_distance} " | |
| f"(asked for {count}).", | |
| "found": chosen, | |
| "next": "Lower min_distance, use a longer overhang, or split " | |
| "the assembly into two rounds."} | |
| return {"ok": True, "overhangs": chosen, "count": len(chosen), | |
| "min_distance": min_distance, | |
| "note": "Deterministic: the same request returns the same set, so " | |
| "the design is reproducible."} | |