Spaces:
Running
Running
github-actions[bot] commited on
Commit Β·
1bf37a2
1
Parent(s): 15766ed
Deploy 90a38dc
Browse filesMerge pull request #18 from WINTER4000/feat/capability-tools
Source: https://github.com/WINTER4000/turingDNA/commit/90a38dcdce1a8d6d014de0702fe8e26af12e4d8b
- dee/core/agent_tools.py +552 -0
- dee/core/conservation.py +238 -0
- dee/core/domains.py +317 -0
- dee/core/expression.py +257 -0
- dee/core/features.py +202 -0
- dee/core/identify.py +355 -0
- dee/core/mutagenesis.py +224 -0
- dee/core/orchestrator.py +39 -0
- dee/core/overhangs.py +234 -0
- dee/core/solubility.py +347 -0
- dee/core/vectors.py +19 -0
- tests/test_confirm_gate.py +19 -0
- tests/test_conservation.py +124 -0
- tests/test_domains.py +251 -0
- tests/test_expression.py +189 -0
- tests/test_features.py +134 -0
- tests/test_identify_sequence.py +206 -0
- tests/test_mutagenesis.py +143 -0
- tests/test_overhangs.py +135 -0
- tests/test_solubility.py +185 -0
- tests/test_vector_lookup.py +45 -0
dee/core/agent_tools.py
CHANGED
|
@@ -135,6 +135,246 @@ def _tool_fetch_sequence(args: Dict[str, Any]) -> Dict[str, Any]:
|
|
| 135 |
return out
|
| 136 |
|
| 137 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
def _tool_compare_sequences(args: Dict[str, Any]) -> Dict[str, Any]:
|
| 139 |
""""How does my clone differ from the wild type?" β until now unanswerable.
|
| 140 |
|
|
@@ -1208,7 +1448,17 @@ def _tool_lookup_vector(args: Dict[str, Any]) -> Dict[str, Any]:
|
|
| 1208 |
|
| 1209 |
entry = _vec.find(name)
|
| 1210 |
if entry:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1211 |
return {"ok": True, "vector": entry, "source": "curated catalogue",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1212 |
# Said out loud so the agent passes it on: this is a curated
|
| 1213 |
# map, not the user's actual plasmid, and the two differ the
|
| 1214 |
# moment anyone has modified their copy.
|
|
@@ -1858,6 +2108,21 @@ _TOOLS: Dict[str, Dict[str, Any]] = {
|
|
| 1858 |
# Pure local computation on a sequence the caller already has. Nothing
|
| 1859 |
# leaves the Space, nothing is stored.
|
| 1860 |
"compare_sequences": {"fn": _tool_compare_sequences, "requires_signin": False},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1861 |
"check_synthesis": {"fn": _tool_check_synthesis, "requires_signin": False},
|
| 1862 |
"assess_stability": {"fn": _tool_assess_stability, "requires_signin": False},
|
| 1863 |
"fold_structure": {"fn": _tool_fold_structure, "requires_signin": False},
|
|
@@ -2402,6 +2667,293 @@ TOOL_SPECS: List[Dict[str, Any]] = [
|
|
| 2402 |
},
|
| 2403 |
},
|
| 2404 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2405 |
{
|
| 2406 |
"type": "function",
|
| 2407 |
"function": {
|
|
|
|
| 135 |
return out
|
| 136 |
|
| 137 |
|
| 138 |
+
def _tool_assess_solubility(args: Dict[str, Any]) -> Dict[str, Any]:
|
| 139 |
+
"""Why a good design ends up in the pellet β risk factors, never a score.
|
| 140 |
+
|
| 141 |
+
Insolubility is the most common way a design fails: ESM-2 likes it, it
|
| 142 |
+
expresses, and you harvest inclusion bodies. That failure mode was
|
| 143 |
+
completely invisible in this product.
|
| 144 |
+
|
| 145 |
+
No solubility probability is reported, for the same reason assess_stability
|
| 146 |
+
reports no ΞΞG: every tool that gives one is a model trained on measured
|
| 147 |
+
solubility data that is not here, so the number would be invented. Passing
|
| 148 |
+
both `wild_type` and `mutant` is the mode worth trusting β one residue
|
| 149 |
+
apart, everything that would confound an absolute prediction cancels.
|
| 150 |
+
"""
|
| 151 |
+
from dee.core import solubility as _sol
|
| 152 |
+
|
| 153 |
+
wt = (args.get("wild_type") or "").strip()
|
| 154 |
+
mut = (args.get("mutant") or "").strip()
|
| 155 |
+
single = (args.get("protein") or args.get("sequence") or "").strip()
|
| 156 |
+
host = args.get("host") or None
|
| 157 |
+
try:
|
| 158 |
+
ph = float(args.get("ph") or _sol.DEFAULT_PH)
|
| 159 |
+
except (TypeError, ValueError):
|
| 160 |
+
ph = _sol.DEFAULT_PH
|
| 161 |
+
try:
|
| 162 |
+
if wt and mut:
|
| 163 |
+
return _sol.compare(wt, mut, host=host, ph=ph)
|
| 164 |
+
if single:
|
| 165 |
+
return _sol.assess(single, host=host, ph=ph)
|
| 166 |
+
return {"ok": False,
|
| 167 |
+
"error": ("need 'protein', or both 'wild_type' and 'mutant' "
|
| 168 |
+
"to compare a substitution")}
|
| 169 |
+
except Exception: # noqa: BLE001
|
| 170 |
+
logger.exception("assess_solubility failed")
|
| 171 |
+
return {"ok": False, "error": "Solubility assessment failed."}
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
def _tool_assess_expression(args: Dict[str, Any]) -> Dict[str, Any]:
|
| 175 |
+
"""The sequence-visible reasons a construct expresses badly in a host.
|
| 176 |
+
|
| 177 |
+
No predicted yield: that depends on strain, promoter, copy number,
|
| 178 |
+
induction, media and toxicity, none of which is in a sequence. What IS in
|
| 179 |
+
the sequence is rare-codon runs, a poor 5' ramp and a broken reading
|
| 180 |
+
frame β computed against the same codon tables the library encoder writes
|
| 181 |
+
with, so the engine holds one opinion about a codon rather than two.
|
| 182 |
+
"""
|
| 183 |
+
from dee.core import expression as _expr
|
| 184 |
+
|
| 185 |
+
dna = (args.get("dna") or args.get("sequence") or "").strip()
|
| 186 |
+
host = (args.get("host") or "").strip()
|
| 187 |
+
if not dna:
|
| 188 |
+
return {"ok": False,
|
| 189 |
+
"error": ("need 'dna' β the coding sequence. This analyses "
|
| 190 |
+
"codons, so a protein sequence cannot answer it; "
|
| 191 |
+
"reverse-translate first if that is all you have.")}
|
| 192 |
+
try:
|
| 193 |
+
return _expr.assess(dna, host)
|
| 194 |
+
except Exception: # noqa: BLE001
|
| 195 |
+
logger.exception("assess_expression failed")
|
| 196 |
+
return {"ok": False, "error": "Expression assessment failed."}
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
def _tool_map_domains(args: Dict[str, Any]) -> Dict[str, Any]:
|
| 200 |
+
"""What the protein is MADE OF β the boundaries that make a region real.
|
| 201 |
+
|
| 202 |
+
`design_variant_library` takes region_start/region_end, and on a large
|
| 203 |
+
protein those are currently picked blind. "Mutagenise 100-200" is a guess;
|
| 204 |
+
"mutagenise the DNA-binding domain, 100-288" is a decision. This is also
|
| 205 |
+
what makes a single substitution interpretable β p53 R175H matters because
|
| 206 |
+
175 sits inside the DNA-binding domain rather than in a linker.
|
| 207 |
+
|
| 208 |
+
Complements check_residues, which asks about individual positions. This
|
| 209 |
+
asks about architecture.
|
| 210 |
+
"""
|
| 211 |
+
from dee.core import domains as _dom
|
| 212 |
+
|
| 213 |
+
acc = (args.get("accession") or args.get("uniprot") or "").strip()
|
| 214 |
+
if not acc:
|
| 215 |
+
return {"ok": False,
|
| 216 |
+
"error": "need 'accession' β a UniProt accession like P04637"}
|
| 217 |
+
pos = args.get("positions") or None
|
| 218 |
+
if isinstance(pos, str):
|
| 219 |
+
pos = [x for x in re.split(r"[,\s;]+", pos) if x]
|
| 220 |
+
try:
|
| 221 |
+
return _dom.domains(acc, positions=pos)
|
| 222 |
+
except Exception: # noqa: BLE001
|
| 223 |
+
logger.exception("map_domains failed")
|
| 224 |
+
return {"ok": False, "error": "Domain lookup failed."}
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
def _tool_identify_sequence(args: Dict[str, Any]) -> Dict[str, Any]:
|
| 228 |
+
""""What is this?" β answered locally before anything leaves the Space.
|
| 229 |
+
|
| 230 |
+
This is the most common first move a scientist makes, and the tempting
|
| 231 |
+
implementation is to reflex straight to BLAST. That is the wrong default
|
| 232 |
+
twice over: BLAST is slow and shared, and it is the ONE operation here
|
| 233 |
+
that sends the user's residues to NCBI. Most of the question β what
|
| 234 |
+
molecule, how long, is it a reading frame, what tags and promoters are in
|
| 235 |
+
it, which vector family it came from β is answerable from the sequence
|
| 236 |
+
itself against catalogues already in this repo, in microseconds, with
|
| 237 |
+
nothing transmitted.
|
| 238 |
+
|
| 239 |
+
So this tool characterizes locally and then names blast_sequence as the
|
| 240 |
+
deliberate next step. It never reports a sequence as identified: an agent
|
| 241 |
+
that concludes "this is insulin" from a His-tag match is the failure this
|
| 242 |
+
exists to prevent.
|
| 243 |
+
"""
|
| 244 |
+
from dee.core import identify as _ident
|
| 245 |
+
|
| 246 |
+
text = args.get("sequence") or args.get("text") or ""
|
| 247 |
+
if not str(text).strip():
|
| 248 |
+
return {"ok": False, "error": "need 'sequence' β the pasted text"}
|
| 249 |
+
try:
|
| 250 |
+
return _ident.identify(str(text))
|
| 251 |
+
except Exception: # noqa: BLE001
|
| 252 |
+
logger.exception("identify_sequence failed")
|
| 253 |
+
return {"ok": False, "error": "Could not analyse that sequence."}
|
| 254 |
+
|
| 255 |
+
|
| 256 |
+
def _tool_check_conservation(args: Dict[str, Any]) -> Dict[str, Any]:
|
| 257 |
+
"""Which residues evolution has refused to change β counted, not predicted.
|
| 258 |
+
|
| 259 |
+
ESM-2 gives a learned opinion; conservation across real homologs gives an
|
| 260 |
+
observed fact. They fail differently β the model is weakest exactly where
|
| 261 |
+
the audit says (membrane, disordered, multi-domain) and a frequency count
|
| 262 |
+
is unaffected by any of that β so this complements the scorer instead of
|
| 263 |
+
duplicating it.
|
| 264 |
+
"""
|
| 265 |
+
from dee.core import conservation as _cons
|
| 266 |
+
|
| 267 |
+
seqs = args.get("sequences")
|
| 268 |
+
if isinstance(seqs, str):
|
| 269 |
+
seqs = [x for x in re.split(r"[,\s;]+", seqs) if x]
|
| 270 |
+
if not seqs or len(seqs) < 2:
|
| 271 |
+
return {"ok": False,
|
| 272 |
+
"error": "need 'sequences' β a list of homologous proteins"}
|
| 273 |
+
pos = args.get("positions") or None
|
| 274 |
+
if isinstance(pos, str):
|
| 275 |
+
pos = [x for x in re.split(r"[,\s;]+", pos) if x]
|
| 276 |
+
if pos:
|
| 277 |
+
keep = []
|
| 278 |
+
for p in pos:
|
| 279 |
+
m = re.search(r"\d+", str(p))
|
| 280 |
+
if m:
|
| 281 |
+
keep.append(int(m.group(0)))
|
| 282 |
+
pos = keep or None
|
| 283 |
+
try:
|
| 284 |
+
if args.get("alignment_only"):
|
| 285 |
+
return _cons.align_many(seqs, args.get("names"))
|
| 286 |
+
return _cons.score(seqs, args.get("names"), positions=pos)
|
| 287 |
+
except Exception: # noqa: BLE001
|
| 288 |
+
logger.exception("check_conservation failed")
|
| 289 |
+
return {"ok": False, "error": "Conservation analysis failed."}
|
| 290 |
+
|
| 291 |
+
|
| 292 |
+
def _tool_check_residues(args: Dict[str, Any]) -> Dict[str, Any]:
|
| 293 |
+
"""What UniProt already knows about the positions being mutated.
|
| 294 |
+
|
| 295 |
+
ESM-2 scores how UNUSUAL a residue is, not how load-bearing it is, and a
|
| 296 |
+
catalytic residue is often unremarkable in sequence terms β it is the
|
| 297 |
+
geometry that matters and a sequence model does not see geometry. So the
|
| 298 |
+
cheapest improvement to design quality is not a better model; it is asking
|
| 299 |
+
the curators.
|
| 300 |
+
"""
|
| 301 |
+
from dee.core import features as _feat
|
| 302 |
+
|
| 303 |
+
acc = str(args.get("accession") or args.get("uniprot") or "").strip()
|
| 304 |
+
if not acc:
|
| 305 |
+
return {"ok": False, "error": "missing 'accession' (a UniProt ID)"}
|
| 306 |
+
pos = args.get("positions")
|
| 307 |
+
if isinstance(pos, str):
|
| 308 |
+
pos = [x for x in re.split(r"[,\s;]+", pos) if x]
|
| 309 |
+
try:
|
| 310 |
+
if pos:
|
| 311 |
+
return _feat.annotate(acc, pos)
|
| 312 |
+
return _feat.fetch(acc)
|
| 313 |
+
except Exception: # noqa: BLE001
|
| 314 |
+
logger.exception("check_residues failed")
|
| 315 |
+
return {"ok": False, "error": "Feature lookup failed."}
|
| 316 |
+
|
| 317 |
+
|
| 318 |
+
def _tool_check_overhangs(args: Dict[str, Any]) -> Dict[str, Any]:
|
| 319 |
+
"""Why a Golden Gate with six correct fragments still comes back wrong.
|
| 320 |
+
|
| 321 |
+
simulate_assembly chains parts whose overhangs match and reports success β
|
| 322 |
+
correctly, as a simulation. What it cannot see is that two overhangs are
|
| 323 |
+
one base apart, or that one is its own reverse complement. In the tube T4
|
| 324 |
+
ligase does not care which partner it found. Every one of those conflicts
|
| 325 |
+
is decidable from the four-base sequences, before anything is ordered.
|
| 326 |
+
"""
|
| 327 |
+
from dee.core import overhangs as _oh
|
| 328 |
+
|
| 329 |
+
want = args.get("suggest_count")
|
| 330 |
+
if want:
|
| 331 |
+
try:
|
| 332 |
+
n = int(want)
|
| 333 |
+
except (TypeError, ValueError):
|
| 334 |
+
return {"ok": False, "error": "suggest_count must be a number"}
|
| 335 |
+
return _oh.suggest(n, length=int(args.get("length") or _oh.DEFAULT_LEN),
|
| 336 |
+
min_distance=int(args.get("min_distance") or 2),
|
| 337 |
+
avoid=args.get("avoid") or [])
|
| 338 |
+
ohs = args.get("overhangs")
|
| 339 |
+
if isinstance(ohs, str):
|
| 340 |
+
ohs = [x for x in re.split(r"[,\s;]+", ohs) if x]
|
| 341 |
+
if not ohs:
|
| 342 |
+
return {"ok": False,
|
| 343 |
+
"error": "give 'overhangs' (a list) or 'suggest_count' (a number)"}
|
| 344 |
+
try:
|
| 345 |
+
return _oh.check(ohs, length=int(args.get("length") or _oh.DEFAULT_LEN),
|
| 346 |
+
min_distance=int(args.get("min_distance") or 2))
|
| 347 |
+
except Exception: # noqa: BLE001
|
| 348 |
+
logger.exception("check_overhangs failed")
|
| 349 |
+
return {"ok": False, "error": "Overhang check failed."}
|
| 350 |
+
|
| 351 |
+
|
| 352 |
+
def _tool_design_mutagenesis_primers(args: Dict[str, Any]) -> Dict[str, Any]:
|
| 353 |
+
"""Primers that actually MAKE the mutation the engine designed.
|
| 354 |
+
|
| 355 |
+
The gap this closes: the engine says R175H is worth trying, and then
|
| 356 |
+
stops. Everything between "good idea" and DNA in a tube β picking the
|
| 357 |
+
codon, centring the mismatch, balancing the two arms by Tm β was left to
|
| 358 |
+
the user. It is the most common wet-lab follow-through from a designed
|
| 359 |
+
point mutation and it is pure arithmetic on a sequence they already hold.
|
| 360 |
+
"""
|
| 361 |
+
from dee.core import mutagenesis as _mut
|
| 362 |
+
|
| 363 |
+
cds = str(args.get("cds") or args.get("sequence") or "")
|
| 364 |
+
label = str(args.get("substitution") or args.get("variant") or "")
|
| 365 |
+
if not cds.strip():
|
| 366 |
+
return {"ok": False, "error": "missing 'cds' (coding sequence from the ATG)"}
|
| 367 |
+
if not label.strip():
|
| 368 |
+
return {"ok": False, "error": "missing 'substitution' (e.g. R175H)"}
|
| 369 |
+
try:
|
| 370 |
+
return _mut.design(cds, label,
|
| 371 |
+
host=str(args.get("host") or "e_coli"),
|
| 372 |
+
method=str(args.get("method") or "quikchange"))
|
| 373 |
+
except Exception: # noqa: BLE001
|
| 374 |
+
logger.exception("design_mutagenesis_primers failed")
|
| 375 |
+
return {"ok": False, "error": "Primer design failed."}
|
| 376 |
+
|
| 377 |
+
|
| 378 |
def _tool_compare_sequences(args: Dict[str, Any]) -> Dict[str, Any]:
|
| 379 |
""""How does my clone differ from the wild type?" β until now unanswerable.
|
| 380 |
|
|
|
|
| 1448 |
|
| 1449 |
entry = _vec.find(name)
|
| 1450 |
if entry:
|
| 1451 |
+
# Where the plasmid actually comes from. The id is in the catalogue
|
| 1452 |
+
# already; only the URL was missing, and an agent building one from
|
| 1453 |
+
# "69864-3" produces a 404 β which reads to a user as "this vector
|
| 1454 |
+
# doesn't exist".
|
| 1455 |
+
source_url = _vec.addgene_url(entry)
|
| 1456 |
return {"ok": True, "vector": entry, "source": "curated catalogue",
|
| 1457 |
+
**({"addgene_url": source_url,
|
| 1458 |
+
"where_to_get_it": (
|
| 1459 |
+
f"Available from Addgene: {source_url}. Give the user "
|
| 1460 |
+
f"the link rather than describing how to obtain it.")}
|
| 1461 |
+
if source_url else {}),
|
| 1462 |
# Said out loud so the agent passes it on: this is a curated
|
| 1463 |
# map, not the user's actual plasmid, and the two differ the
|
| 1464 |
# moment anyone has modified their copy.
|
|
|
|
| 2108 |
# Pure local computation on a sequence the caller already has. Nothing
|
| 2109 |
# leaves the Space, nothing is stored.
|
| 2110 |
"compare_sequences": {"fn": _tool_compare_sequences, "requires_signin": False},
|
| 2111 |
+
"check_overhangs": {"fn": _tool_check_overhangs, "requires_signin": False},
|
| 2112 |
+
"check_residues": {"fn": _tool_check_residues, "requires_signin": False},
|
| 2113 |
+
"check_conservation": {"fn": _tool_check_conservation, "requires_signin": False},
|
| 2114 |
+
# Deliberately local-only. It exists so the reflex answer to "what is
|
| 2115 |
+
# this?" is NOT blast_sequence, which is the one tool that transmits.
|
| 2116 |
+
"identify_sequence": {"fn": _tool_identify_sequence, "requires_signin": False},
|
| 2117 |
+
# Reads a public curated database. Only the accession leaves the Space β
|
| 2118 |
+
# same contract as check_residues.
|
| 2119 |
+
"map_domains": {"fn": _tool_map_domains, "requires_signin": False},
|
| 2120 |
+
# Physical chemistry and codon arithmetic on a sequence the caller already
|
| 2121 |
+
# has. Nothing transmitted, nothing stored, no model.
|
| 2122 |
+
"assess_solubility": {"fn": _tool_assess_solubility, "requires_signin": False},
|
| 2123 |
+
"assess_expression": {"fn": _tool_assess_expression, "requires_signin": False},
|
| 2124 |
+
"design_mutagenesis_primers": {"fn": _tool_design_mutagenesis_primers,
|
| 2125 |
+
"requires_signin": False},
|
| 2126 |
"check_synthesis": {"fn": _tool_check_synthesis, "requires_signin": False},
|
| 2127 |
"assess_stability": {"fn": _tool_assess_stability, "requires_signin": False},
|
| 2128 |
"fold_structure": {"fn": _tool_fold_structure, "requires_signin": False},
|
|
|
|
| 2667 |
},
|
| 2668 |
},
|
| 2669 |
},
|
| 2670 |
+
{
|
| 2671 |
+
"type": "function",
|
| 2672 |
+
"function": {
|
| 2673 |
+
"name": "assess_solubility",
|
| 2674 |
+
"description": (
|
| 2675 |
+
"AGGREGATION RISK FACTORS for a protein β hydrophobic patches, "
|
| 2676 |
+
"GRAVY, pI vs the working pH, instability index, cysteines in "
|
| 2677 |
+
"a reducing host. Insolubility is the most common way a good "
|
| 2678 |
+
"design fails, so run this on variants the scorer likes before "
|
| 2679 |
+
"anyone orders DNA. "
|
| 2680 |
+
"TWO MODES: pass 'protein' to characterize one sequence, or "
|
| 2681 |
+
"pass BOTH 'wild_type' and 'mutant' to see what a substitution "
|
| 2682 |
+
"changes. PREFER THE SECOND β one residue apart, everything "
|
| 2683 |
+
"that confounds an absolute reading cancels, and the result "
|
| 2684 |
+
"says so. "
|
| 2685 |
+
"IMPORTANT: there is no solubility score or probability here "
|
| 2686 |
+
"and you must not invent one. Every tool that reports one is a "
|
| 2687 |
+
"model trained on measured solubility data that is not "
|
| 2688 |
+
"available in this engine. Relay 'no_score', and relay "
|
| 2689 |
+
"'thresholds' β the hydrophobic-patch cutoff is a plot-reading "
|
| 2690 |
+
"convention, not a validated boundary."
|
| 2691 |
+
),
|
| 2692 |
+
"parameters": {
|
| 2693 |
+
"type": "object",
|
| 2694 |
+
"properties": {
|
| 2695 |
+
"protein": {"type": "string",
|
| 2696 |
+
"description": "One protein sequence to characterize."},
|
| 2697 |
+
"wild_type": {"type": "string",
|
| 2698 |
+
"description": ("Reference protein. Use with "
|
| 2699 |
+
"'mutant' for the trustworthy "
|
| 2700 |
+
"comparison mode.")},
|
| 2701 |
+
"mutant": {"type": "string",
|
| 2702 |
+
"description": ("Variant of the same length β "
|
| 2703 |
+
"point substitutions only.")},
|
| 2704 |
+
"host": {"type": "string",
|
| 2705 |
+
"description": ("Expression host, free text. "
|
| 2706 |
+
"Changes the cysteine advice: the "
|
| 2707 |
+
"E. coli cytoplasm is reducing.")},
|
| 2708 |
+
"ph": {"type": "number",
|
| 2709 |
+
"description": "Working buffer pH. Defaults to 7.4."},
|
| 2710 |
+
},
|
| 2711 |
+
"required": [],
|
| 2712 |
+
},
|
| 2713 |
+
},
|
| 2714 |
+
},
|
| 2715 |
+
{
|
| 2716 |
+
"type": "function",
|
| 2717 |
+
"function": {
|
| 2718 |
+
"name": "assess_expression",
|
| 2719 |
+
"description": (
|
| 2720 |
+
"WHY A CDS MAY EXPRESS BADLY in a host β rare codons, runs of "
|
| 2721 |
+
"consecutive rare codons, the 5' ramp, GC, reading-frame "
|
| 2722 |
+
"integrity. Needs the DNA, not the protein, because it counts "
|
| 2723 |
+
"codons. Run it before ordering a synthesised gene. "
|
| 2724 |
+
"The most actionable output is 'rare_codon_runs': a scattered "
|
| 2725 |
+
"rare codon is absorbed, but three in a row stalls the "
|
| 2726 |
+
"ribosome, and recoding just that run is usually the whole "
|
| 2727 |
+
"fix. "
|
| 2728 |
+
"IMPORTANT: this predicts NO yield and no expression level β "
|
| 2729 |
+
"relay 'no_yield'. Yield depends on strain, promoter, copy "
|
| 2730 |
+
"number, induction and toxicity, none of which is in a "
|
| 2731 |
+
"sequence. If the host has no codon table the tool declines; "
|
| 2732 |
+
"report that as missing reference data, NOT as the organism "
|
| 2733 |
+
"being unsupported, and never substitute another host's table."
|
| 2734 |
+
),
|
| 2735 |
+
"parameters": {
|
| 2736 |
+
"type": "object",
|
| 2737 |
+
"properties": {
|
| 2738 |
+
"dna": {"type": "string",
|
| 2739 |
+
"description": "The coding sequence, 30+ codons."},
|
| 2740 |
+
"host": {"type": "string",
|
| 2741 |
+
"description": ("Expression host, free text (e.g. "
|
| 2742 |
+
"'e_coli', 'yeast', 'human'). "
|
| 2743 |
+
"Required β expression is "
|
| 2744 |
+
"host-specific.")},
|
| 2745 |
+
},
|
| 2746 |
+
"required": ["dna", "host"],
|
| 2747 |
+
},
|
| 2748 |
+
},
|
| 2749 |
+
},
|
| 2750 |
+
{
|
| 2751 |
+
"type": "function",
|
| 2752 |
+
"function": {
|
| 2753 |
+
"name": "map_domains",
|
| 2754 |
+
"description": (
|
| 2755 |
+
"DOMAIN ARCHITECTURE from InterPro for a UniProt accession β "
|
| 2756 |
+
"what the protein is made of, with boundaries. Call this "
|
| 2757 |
+
"BEFORE choosing region_start/region_end for "
|
| 2758 |
+
"design_variant_library on any protein over ~200 aa: a region "
|
| 2759 |
+
"picked without domain boundaries is a guess, and one that "
|
| 2760 |
+
"straddles two domains scrambles two things at once. Also call "
|
| 2761 |
+
"it to say where a substitution sits β 'R175H is in the "
|
| 2762 |
+
"DNA-binding domain' is an interpretation, 'R175H' alone is "
|
| 2763 |
+
"not. Optionally pass 'positions' to have each one located. "
|
| 2764 |
+
"Three things to relay: the coordinates are UniProt PRECURSOR "
|
| 2765 |
+
"numbering (relay 'numbering' β it is offset from mature-"
|
| 2766 |
+
"protein numbering by whatever was cleaved); entries can be "
|
| 2767 |
+
"DISCONTINUOUS, so use 'segments' and never quote first-start "
|
| 2768 |
+
"to last-end as one span; and no match means no signature "
|
| 2769 |
+
"matched, which is expected for novel or designed proteins and "
|
| 2770 |
+
"is NOT evidence of no domains. If 'truncated' is set, say the "
|
| 2771 |
+
"architecture is incomplete."
|
| 2772 |
+
),
|
| 2773 |
+
"parameters": {
|
| 2774 |
+
"type": "object",
|
| 2775 |
+
"properties": {
|
| 2776 |
+
"accession": {"type": "string",
|
| 2777 |
+
"description": "UniProt accession, e.g. P04637."},
|
| 2778 |
+
"positions": {"type": "array", "items": {"type": "string"},
|
| 2779 |
+
"description": ("Optional: positions or "
|
| 2780 |
+
"labels like R175H to locate "
|
| 2781 |
+
"within the architecture.")},
|
| 2782 |
+
},
|
| 2783 |
+
"required": ["accession"],
|
| 2784 |
+
},
|
| 2785 |
+
},
|
| 2786 |
+
},
|
| 2787 |
+
{
|
| 2788 |
+
"type": "function",
|
| 2789 |
+
"function": {
|
| 2790 |
+
"name": "identify_sequence",
|
| 2791 |
+
"description": (
|
| 2792 |
+
"CALL THIS FIRST whenever the user pastes something and asks "
|
| 2793 |
+
"what it is, or pastes a sequence with no explanation. It is "
|
| 2794 |
+
"instant, local, and nothing leaves this Space. Accepts raw "
|
| 2795 |
+
"residues, FASTA, GenBank, or an accession. Returns: which "
|
| 2796 |
+
"molecule (DNA / RNA / protein / ambiguous), length, GC, "
|
| 2797 |
+
"whether it is a clean CDS or where the longest ORF is, which "
|
| 2798 |
+
"catalogued promoters / terminators / tags / recombination "
|
| 2799 |
+
"sites it contains, and whether its ends match a known vector "
|
| 2800 |
+
"junction. "
|
| 2801 |
+
"IMPORTANT: 'identified' is ALWAYS false. This CHARACTERIZES a "
|
| 2802 |
+
"sequence, it does not NAME it β a His-tag match is not "
|
| 2803 |
+
"evidence that a sequence is any particular gene. To find out "
|
| 2804 |
+
"what it actually is, call blast_sequence afterwards, and say "
|
| 2805 |
+
"plainly that this sends the sequence to NCBI. If the result "
|
| 2806 |
+
"is kind='ambiguous', ask the user rather than guessing. If a "
|
| 2807 |
+
"'header_claim' comes back, relay it as what the file SAYS, "
|
| 2808 |
+
"not as fact."
|
| 2809 |
+
),
|
| 2810 |
+
"parameters": {
|
| 2811 |
+
"type": "object",
|
| 2812 |
+
"properties": {
|
| 2813 |
+
"sequence": {
|
| 2814 |
+
"type": "string",
|
| 2815 |
+
"description": ("Exactly what the user pasted β raw "
|
| 2816 |
+
"residues, FASTA (header included), "
|
| 2817 |
+
"GenBank, or an accession."),
|
| 2818 |
+
},
|
| 2819 |
+
},
|
| 2820 |
+
"required": ["sequence"],
|
| 2821 |
+
},
|
| 2822 |
+
},
|
| 2823 |
+
},
|
| 2824 |
+
{
|
| 2825 |
+
"type": "function",
|
| 2826 |
+
"function": {
|
| 2827 |
+
"name": "check_conservation",
|
| 2828 |
+
"description": (
|
| 2829 |
+
"ALIGN HOMOLOGS AND COUNT which residues evolution refuses to "
|
| 2830 |
+
"change. Give 'sequences' (3+ homologous proteins β BLAST the "
|
| 2831 |
+
"target and take the hits) and optionally 'positions'. Returns "
|
| 2832 |
+
"per-position consensus, agreement %, Shannon entropy and a "
|
| 2833 |
+
"plain call (invariant / highly conserved / conserved / "
|
| 2834 |
+
"variable). This is an OBSERVATION, not a prediction, and it "
|
| 2835 |
+
"complements ESM-2 rather than confirming it β agreement "
|
| 2836 |
+
"between them is real corroboration, disagreement is worth "
|
| 2837 |
+
"investigating rather than averaging. Two things to relay: the "
|
| 2838 |
+
"alignment is progressive and therefore approximate, and the "
|
| 2839 |
+
"result is only as meaningful as the homolog set β check "
|
| 2840 |
+
"'trustworthy' and pass on 'diversity_warning', because 40 "
|
| 2841 |
+
"sequences at 99% identity are one sequence counted 40 times "
|
| 2842 |
+
"and every column will look conserved."
|
| 2843 |
+
),
|
| 2844 |
+
"parameters": {
|
| 2845 |
+
"type": "object",
|
| 2846 |
+
"properties": {
|
| 2847 |
+
"sequences": {"type": "array", "items": {"type": "string"},
|
| 2848 |
+
"description": "3+ homologous protein sequences."},
|
| 2849 |
+
"names": {"type": "array", "items": {"type": "string"},
|
| 2850 |
+
"description": "Optional labels, same order."},
|
| 2851 |
+
"positions": {"type": "array", "items": {"type": "string"},
|
| 2852 |
+
"description": "Optional: positions or labels like R175H."},
|
| 2853 |
+
"alignment_only": {"type": "boolean",
|
| 2854 |
+
"description": "Return the alignment without scoring."},
|
| 2855 |
+
},
|
| 2856 |
+
"required": ["sequences"],
|
| 2857 |
+
},
|
| 2858 |
+
},
|
| 2859 |
+
},
|
| 2860 |
+
{
|
| 2861 |
+
"type": "function",
|
| 2862 |
+
"function": {
|
| 2863 |
+
"name": "check_residues",
|
| 2864 |
+
"description": (
|
| 2865 |
+
"ASK UNIPROT WHAT IS KNOWN about the positions being mutated β "
|
| 2866 |
+
"active sites, binding sites, disulfides, metal ligands, "
|
| 2867 |
+
"domains. Call this BEFORE recommending substitutions: ESM-2 "
|
| 2868 |
+
"scores how unusual a residue is, not how load-bearing it is, "
|
| 2869 |
+
"so a catalytic residue can look perfectly tolerable to it. "
|
| 2870 |
+
"Give 'accession' alone for the whole feature table, or with "
|
| 2871 |
+
"'positions' (e.g. ['R175H', 200]) to check specific ones. "
|
| 2872 |
+
"Two things you must relay: positions are UniProt's PRECURSOR "
|
| 2873 |
+
"numbering and may be offset from the user's scheme; and no "
|
| 2874 |
+
"annotation does NOT mean safe β most proteins are annotated "
|
| 2875 |
+
"sparsely."
|
| 2876 |
+
),
|
| 2877 |
+
"parameters": {
|
| 2878 |
+
"type": "object",
|
| 2879 |
+
"properties": {
|
| 2880 |
+
"accession": {"type": "string", "description": "UniProt accession, e.g. P00760."},
|
| 2881 |
+
"positions": {"type": "array", "items": {"type": "string"},
|
| 2882 |
+
"description": "Optional: positions or substitution labels."},
|
| 2883 |
+
},
|
| 2884 |
+
"required": ["accession"],
|
| 2885 |
+
},
|
| 2886 |
+
},
|
| 2887 |
+
},
|
| 2888 |
+
{
|
| 2889 |
+
"type": "function",
|
| 2890 |
+
"function": {
|
| 2891 |
+
"name": "check_overhangs",
|
| 2892 |
+
"description": (
|
| 2893 |
+
"CHECK A GOLDEN GATE OVERHANG SET for conflicts that make an "
|
| 2894 |
+
"assembly misbuild β duplicates, palindromes (an overhang that "
|
| 2895 |
+
"is its own reverse complement, so a fragment ligates to "
|
| 2896 |
+
"itself), complementary pairs (two interchangeable junctions), "
|
| 2897 |
+
"near-identical pairs one base apart, and all-AT/all-GC ends. "
|
| 2898 |
+
"simulate_assembly only proves the overhangs CHAIN; it cannot "
|
| 2899 |
+
"see that two of them cross-ligate, which is why a design with "
|
| 2900 |
+
"six correct fragments still comes back wrong a week later. "
|
| 2901 |
+
"Pass 'overhangs' to check a set, or 'suggest_count' to get a "
|
| 2902 |
+
"conflict-free set. It does NOT return a fidelity percentage β "
|
| 2903 |
+
"that needs published empirical ligation data this engine does "
|
| 2904 |
+
"not have, so never state one."
|
| 2905 |
+
),
|
| 2906 |
+
"parameters": {
|
| 2907 |
+
"type": "object",
|
| 2908 |
+
"properties": {
|
| 2909 |
+
"overhangs": {"type": "array", "items": {"type": "string"},
|
| 2910 |
+
"description": "The set to check, e.g. ['GGAG','AATG','AGGT']."},
|
| 2911 |
+
"suggest_count": {"type": "integer",
|
| 2912 |
+
"description": "Instead, propose this many conflict-free overhangs."},
|
| 2913 |
+
"length": {"type": "integer", "description": "Overhang length. Default 4."},
|
| 2914 |
+
"min_distance": {"type": "integer",
|
| 2915 |
+
"description": "Minimum Hamming distance between overhangs. Default 2."},
|
| 2916 |
+
},
|
| 2917 |
+
},
|
| 2918 |
+
},
|
| 2919 |
+
},
|
| 2920 |
+
{
|
| 2921 |
+
"type": "function",
|
| 2922 |
+
"function": {
|
| 2923 |
+
"name": "design_mutagenesis_primers",
|
| 2924 |
+
"description": (
|
| 2925 |
+
"DESIGN THE PRIMERS that install a point mutation β the step "
|
| 2926 |
+
"between 'R175H is worth trying' and DNA in a tube. Give it "
|
| 2927 |
+
"the coding sequence (starting at the initiator ATG, because "
|
| 2928 |
+
"residue numbers only mean something against a real reading "
|
| 2929 |
+
"frame) and a substitution like R175H. Returns a primer pair "
|
| 2930 |
+
"with Tm, GC%, hairpin and self-dimer checks, the codon it "
|
| 2931 |
+
"chose for the host and why, and the protocol. Use method "
|
| 2932 |
+
"'quikchange' for overlapping complementary primers or "
|
| 2933 |
+
"'back_to_back' for Q5/KLD-style. It REFUSES when the "
|
| 2934 |
+
"sequence's residue at that position disagrees with the "
|
| 2935 |
+
"label β that means the user is holding a different sequence "
|
| 2936 |
+
"than they think, and designing anyway would make primers for "
|
| 2937 |
+
"a mutation nobody asked for. Melting temperatures are "
|
| 2938 |
+
"calculated, not measured; always say the construct must be "
|
| 2939 |
+
"sequenced to confirm."
|
| 2940 |
+
),
|
| 2941 |
+
"parameters": {
|
| 2942 |
+
"type": "object",
|
| 2943 |
+
"properties": {
|
| 2944 |
+
"cds": {"type": "string",
|
| 2945 |
+
"description": "Coding sequence from the initiator ATG."},
|
| 2946 |
+
"substitution": {"type": "string",
|
| 2947 |
+
"description": "e.g. R175H, G12D, C176Y."},
|
| 2948 |
+
"host": {"type": "string",
|
| 2949 |
+
"description": "Codon preference, e.g. e_coli, human, yeast."},
|
| 2950 |
+
"method": {"type": "string",
|
| 2951 |
+
"description": "'quikchange' (default) or 'back_to_back'."},
|
| 2952 |
+
},
|
| 2953 |
+
"required": ["cds", "substitution"],
|
| 2954 |
+
},
|
| 2955 |
+
},
|
| 2956 |
+
},
|
| 2957 |
{
|
| 2958 |
"type": "function",
|
| 2959 |
"function": {
|
dee/core/conservation.py
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Which residues evolution has refused to change β computed, not predicted.
|
| 2 |
+
|
| 3 |
+
WHY THIS EXISTS ALONGSIDE ESM-2
|
| 4 |
+
-------------------------------
|
| 5 |
+
ESM-2 gives a learned opinion about whether a substitution looks plausible.
|
| 6 |
+
Conservation across real homologs gives an observed fact: in 40 orthologs,
|
| 7 |
+
this position is serine 40 times. Those are different kinds of evidence and
|
| 8 |
+
they fail differently β the model is weakest exactly where the audit says it
|
| 9 |
+
is (membrane proteins, disordered regions, multi-domain assemblies), and a
|
| 10 |
+
frequency count is unaffected by any of that.
|
| 11 |
+
|
| 12 |
+
So this complements the scorer rather than duplicating it, which is why the
|
| 13 |
+
audit rated it High: it is the cheapest real signal for "don't mutate this
|
| 14 |
+
residue", and unlike a prediction it can be checked by counting.
|
| 15 |
+
|
| 16 |
+
TWO PIECES
|
| 17 |
+
----------
|
| 18 |
+
`align_many` builds a progressive multiple alignment on top of the pairwise
|
| 19 |
+
aligner that already exists (`align.py`), anchored on the longest sequence.
|
| 20 |
+
This is the classic progressive approach and it is approximate: a true
|
| 21 |
+
simultaneous MSA is exponential, and every practical tool approximates. It is
|
| 22 |
+
honest about being an approximation rather than presenting itself as ground
|
| 23 |
+
truth.
|
| 24 |
+
|
| 25 |
+
`score` then counts. Shannon entropy per column, the most common residue, and
|
| 26 |
+
the fraction of sequences that agree. No model, no training, no weights.
|
| 27 |
+
|
| 28 |
+
THE HONEST LIMIT, WHICH IS ABOUT INPUT NOT ALGORITHM
|
| 29 |
+
----------------------------------------------------
|
| 30 |
+
Conservation is only as meaningful as the homolog set. Forty sequences that
|
| 31 |
+
are 99% identical to each other say nothing β they are one sequence counted
|
| 32 |
+
forty times. The result therefore reports the diversity of the input, and
|
| 33 |
+
refuses to present a confident conservation call on a set with no spread.
|
| 34 |
+
"""
|
| 35 |
+
from __future__ import annotations
|
| 36 |
+
|
| 37 |
+
import math
|
| 38 |
+
import re
|
| 39 |
+
from collections import Counter
|
| 40 |
+
from typing import Any, Dict, List, Optional, Sequence
|
| 41 |
+
|
| 42 |
+
from dee.core.align import align
|
| 43 |
+
|
| 44 |
+
GAP = "-"
|
| 45 |
+
# Below this many sequences a "conserved" call means almost nothing.
|
| 46 |
+
MIN_SEQS = 3
|
| 47 |
+
# Mean pairwise identity above which the set is effectively one sequence.
|
| 48 |
+
REDUNDANT_ABOVE = 95.0
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def _clean(s: str) -> str:
|
| 52 |
+
return re.sub(r"[^A-Za-z*]", "", s or "").upper()
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def align_many(sequences: Sequence[str],
|
| 56 |
+
names: Optional[Sequence[str]] = None) -> Dict[str, Any]:
|
| 57 |
+
"""Progressive multiple alignment, anchored on the longest sequence.
|
| 58 |
+
|
| 59 |
+
Each sequence is aligned pairwise to the anchor and its gaps merged into a
|
| 60 |
+
common frame. Approximate by construction β stated in the result so no
|
| 61 |
+
caller mistakes it for a simultaneous optimum.
|
| 62 |
+
"""
|
| 63 |
+
seqs = [_clean(s) for s in (sequences or [])]
|
| 64 |
+
keep = [(i, s) for i, s in enumerate(seqs) if s]
|
| 65 |
+
if len(keep) < 2:
|
| 66 |
+
return {"ok": False, "error": "Need at least two non-empty sequences."}
|
| 67 |
+
|
| 68 |
+
labels = list(names or [])
|
| 69 |
+
def label(i: int) -> str:
|
| 70 |
+
return str(labels[i]) if i < len(labels) and labels[i] else f"seq{i + 1}"
|
| 71 |
+
|
| 72 |
+
anchor_i, anchor = max(keep, key=lambda kv: len(kv[1]))
|
| 73 |
+
|
| 74 |
+
# Columns the anchor must gain, keyed by the anchor index they precede.
|
| 75 |
+
inserts: Dict[int, int] = {}
|
| 76 |
+
pairs: List[Dict[str, Any]] = []
|
| 77 |
+
for i, s in keep:
|
| 78 |
+
if i == anchor_i:
|
| 79 |
+
continue
|
| 80 |
+
try:
|
| 81 |
+
r = align(anchor, s, mode="global")
|
| 82 |
+
except ValueError as exc:
|
| 83 |
+
return {"ok": False, "kind": "too_large", "error": str(exc),
|
| 84 |
+
"next": "Align shorter sequences, or fewer of them."}
|
| 85 |
+
pairs.append({"i": i, "a": r["aligned_a"], "b": r["aligned_b"],
|
| 86 |
+
"identity": r["identity"]})
|
| 87 |
+
pos = 0
|
| 88 |
+
run = 0
|
| 89 |
+
for ca in r["aligned_a"]:
|
| 90 |
+
if ca == GAP:
|
| 91 |
+
run += 1
|
| 92 |
+
else:
|
| 93 |
+
if run:
|
| 94 |
+
inserts[pos] = max(inserts.get(pos, 0), run)
|
| 95 |
+
run = 0
|
| 96 |
+
pos += 1
|
| 97 |
+
if run:
|
| 98 |
+
inserts[pos] = max(inserts.get(pos, 0), run)
|
| 99 |
+
|
| 100 |
+
def expand(aligned_a: str, aligned_b: str) -> str:
|
| 101 |
+
"""Re-lay one pairwise result into the common frame."""
|
| 102 |
+
out: List[str] = []
|
| 103 |
+
pos = 0
|
| 104 |
+
run: List[str] = []
|
| 105 |
+
for ca, cb in zip(aligned_a, aligned_b):
|
| 106 |
+
if ca == GAP:
|
| 107 |
+
run.append(cb)
|
| 108 |
+
continue
|
| 109 |
+
need = inserts.get(pos, 0)
|
| 110 |
+
out.append("".join(run).ljust(need, GAP)[:need] if need else "")
|
| 111 |
+
run = []
|
| 112 |
+
out.append(cb)
|
| 113 |
+
pos += 1
|
| 114 |
+
need = inserts.get(pos, 0)
|
| 115 |
+
out.append("".join(run).ljust(need, GAP)[:need] if need else "")
|
| 116 |
+
return "".join(out)
|
| 117 |
+
|
| 118 |
+
rows: List[Dict[str, Any]] = []
|
| 119 |
+
frame_anchor = []
|
| 120 |
+
for pos, ch in enumerate(anchor):
|
| 121 |
+
frame_anchor.append(GAP * inserts.get(pos, 0) + ch)
|
| 122 |
+
frame_anchor.append(GAP * inserts.get(len(anchor), 0))
|
| 123 |
+
anchor_row = "".join(frame_anchor)
|
| 124 |
+
rows.append({"name": label(anchor_i), "aligned": anchor_row,
|
| 125 |
+
"identity_to_anchor": 100.0, "is_anchor": True})
|
| 126 |
+
for p in pairs:
|
| 127 |
+
rows.append({"name": label(p["i"]), "aligned": expand(p["a"], p["b"]),
|
| 128 |
+
"identity_to_anchor": p["identity"], "is_anchor": False})
|
| 129 |
+
|
| 130 |
+
width = max(len(r["aligned"]) for r in rows)
|
| 131 |
+
for r in rows:
|
| 132 |
+
r["aligned"] = r["aligned"].ljust(width, GAP)
|
| 133 |
+
|
| 134 |
+
ids = [r["identity_to_anchor"] for r in rows if not r["is_anchor"]]
|
| 135 |
+
return {
|
| 136 |
+
"ok": True,
|
| 137 |
+
"rows": rows,
|
| 138 |
+
"columns": width,
|
| 139 |
+
"sequences": len(rows),
|
| 140 |
+
"anchor": label(anchor_i),
|
| 141 |
+
"mean_identity_to_anchor": round(sum(ids) / len(ids), 1) if ids else 100.0,
|
| 142 |
+
"method": ("Progressive alignment onto the longest sequence, using the "
|
| 143 |
+
"engine's Needleman-Wunsch. Approximate: a simultaneous "
|
| 144 |
+
"optimum is exponential and every practical tool "
|
| 145 |
+
"approximates. Treat column boundaries in gappy regions as "
|
| 146 |
+
"indicative."),
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def score(sequences: Sequence[str], names: Optional[Sequence[str]] = None,
|
| 151 |
+
*, positions: Optional[Sequence[int]] = None) -> Dict[str, Any]:
|
| 152 |
+
"""Per-column conservation across an alignment of homologs.
|
| 153 |
+
|
| 154 |
+
Positions, when given, are numbered along the ANCHOR (the longest input),
|
| 155 |
+
because that is the sequence a user is designing against.
|
| 156 |
+
"""
|
| 157 |
+
seqs = [_clean(s) for s in (sequences or [])]
|
| 158 |
+
if len([s for s in seqs if s]) < MIN_SEQS:
|
| 159 |
+
return {"ok": False, "kind": "too_few",
|
| 160 |
+
"error": f"Conservation needs at least {MIN_SEQS} sequences; "
|
| 161 |
+
f"got {len([s for s in seqs if s])}.",
|
| 162 |
+
"next": "Add orthologs β BLAST the sequence and take the hits."}
|
| 163 |
+
|
| 164 |
+
msa = align_many(seqs, names)
|
| 165 |
+
if not msa.get("ok"):
|
| 166 |
+
return msa
|
| 167 |
+
|
| 168 |
+
rows = msa["rows"]
|
| 169 |
+
anchor_row = next(r for r in rows if r["is_anchor"])["aligned"]
|
| 170 |
+
n = len(rows)
|
| 171 |
+
|
| 172 |
+
cols: List[Dict[str, Any]] = []
|
| 173 |
+
anchor_pos = 0
|
| 174 |
+
for c in range(msa["columns"]):
|
| 175 |
+
column = [r["aligned"][c] for r in rows]
|
| 176 |
+
anchor_ch = anchor_row[c]
|
| 177 |
+
if anchor_ch != GAP:
|
| 178 |
+
anchor_pos += 1
|
| 179 |
+
residues = [ch for ch in column if ch != GAP]
|
| 180 |
+
if not residues:
|
| 181 |
+
continue
|
| 182 |
+
counts = Counter(residues)
|
| 183 |
+
top, top_n = counts.most_common(1)[0]
|
| 184 |
+
# Shannon entropy over observed residues. 0 = every sequence agrees.
|
| 185 |
+
total = len(residues)
|
| 186 |
+
H = -sum((k / total) * math.log2(k / total) for k in counts.values())
|
| 187 |
+
cols.append({
|
| 188 |
+
"column": c,
|
| 189 |
+
"anchor_position": anchor_pos if anchor_ch != GAP else None,
|
| 190 |
+
"anchor_residue": None if anchor_ch == GAP else anchor_ch,
|
| 191 |
+
"consensus": top,
|
| 192 |
+
"agreement_pct": round(100.0 * top_n / total, 1),
|
| 193 |
+
# abs(): -sum(...) of an all-agreeing column yields IEEE -0.0, which
|
| 194 |
+
# prints as "-0.0 bits" and reads like a bug. Entropy is never
|
| 195 |
+
# negative.
|
| 196 |
+
"entropy_bits": abs(round(H, 3)),
|
| 197 |
+
"gaps": n - total,
|
| 198 |
+
# A plain word, because "0.0 bits" is not what a bench scientist
|
| 199 |
+
# reads. Thresholds are stated rather than hidden.
|
| 200 |
+
"call": ("invariant" if H == 0.0 else
|
| 201 |
+
"highly conserved" if H < 0.5 else
|
| 202 |
+
"conserved" if H < 1.0 else
|
| 203 |
+
"variable"),
|
| 204 |
+
})
|
| 205 |
+
|
| 206 |
+
wanted = None
|
| 207 |
+
if positions:
|
| 208 |
+
want = {int(p) for p in positions}
|
| 209 |
+
wanted = [c for c in cols if c["anchor_position"] in want]
|
| 210 |
+
|
| 211 |
+
invariant = [c for c in cols if c["call"] == "invariant"]
|
| 212 |
+
mean_id = msa["mean_identity_to_anchor"]
|
| 213 |
+
redundant = mean_id > REDUNDANT_ABOVE
|
| 214 |
+
return {
|
| 215 |
+
"ok": True,
|
| 216 |
+
"sequences": n,
|
| 217 |
+
"anchor": msa["anchor"],
|
| 218 |
+
"columns": len(cols),
|
| 219 |
+
"invariant_count": len(invariant),
|
| 220 |
+
"conservation": wanted if wanted is not None else cols,
|
| 221 |
+
"mean_identity": mean_id,
|
| 222 |
+
"alignment_method": msa["method"],
|
| 223 |
+
# The limit that is about the INPUT, not the algorithm. Forty
|
| 224 |
+
# sequences at 99% identity are one sequence counted forty times, and
|
| 225 |
+
# every column will look invariant.
|
| 226 |
+
"diversity_warning": (
|
| 227 |
+
f"The homologs are {mean_id}% identical to each other on average. "
|
| 228 |
+
f"At that redundancy nearly every column looks conserved because "
|
| 229 |
+
f"the set carries little independent evidence. Use more divergent "
|
| 230 |
+
f"orthologs." if redundant else None),
|
| 231 |
+
"trustworthy": (not redundant) and n >= MIN_SEQS,
|
| 232 |
+
"caveat": ("Conservation is an OBSERVATION about the sequences given, "
|
| 233 |
+
"not a property of the protein. It is only as meaningful as "
|
| 234 |
+
"the homolog set: too few or too similar and it says "
|
| 235 |
+
"nothing. It complements ESM-2 rather than confirming it β "
|
| 236 |
+
"agreement between the two is genuine corroboration, "
|
| 237 |
+
"disagreement is worth investigating, not averaging."),
|
| 238 |
+
}
|
dee/core/domains.py
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Domain architecture from InterPro β the boundaries that make a region mean
|
| 2 |
+
something (audit #8, High).
|
| 3 |
+
|
| 4 |
+
WHY THIS IS RATED HIGH
|
| 5 |
+
----------------------
|
| 6 |
+
The Directed Evolution pipeline takes `region_start` / `region_end`, and on a
|
| 7 |
+
large protein those numbers are currently chosen blind. "Mutagenise 100-200"
|
| 8 |
+
is a guess; "mutagenise the DNA-binding domain, 100-288" is a decision. Domain
|
| 9 |
+
boundaries are what convert one into the other, and they are also what makes a
|
| 10 |
+
single mutation interpretable β p53 R175H is meaningful precisely because 175
|
| 11 |
+
falls inside the DNA-binding domain, not somewhere in a linker.
|
| 12 |
+
|
| 13 |
+
`features.py` (#7) already asks UniProt what is known about individual
|
| 14 |
+
POSITIONS. This asks InterPro what the protein is made OF. They answer
|
| 15 |
+
different questions and both are curated reads β no model, no prediction.
|
| 16 |
+
|
| 17 |
+
WHAT INTERPRO ACTUALLY RETURNS, AND WHY THE RAW ANSWER IS MISLEADING
|
| 18 |
+
--------------------------------------------------------------------
|
| 19 |
+
Ask InterPro about insulin and you get fourteen rows. That looks like fourteen
|
| 20 |
+
findings; it is five. InterPro *integrates* member-database signatures β Pfam,
|
| 21 |
+
SMART, PROSITE, PANTHER, CDD, PRINTS, CATH-Gene3D, SUPERFAMILY β and several
|
| 22 |
+
of them independently model the same region, each contributing its own row
|
| 23 |
+
that points back at one integrated `IPRβ¦` entry.
|
| 24 |
+
|
| 25 |
+
So this module groups by the integrated entry and reports the member databases
|
| 26 |
+
as supporting evidence. Listing all fourteen would inflate a single domain
|
| 27 |
+
call into a wall of apparent corroboration.
|
| 28 |
+
|
| 29 |
+
The count is reported carefully for the same reason. Five methods agreeing is
|
| 30 |
+
worth something, but they are built from overlapping training data and often
|
| 31 |
+
from the same seed alignments β it is corroboration, not five independent
|
| 32 |
+
observations, and the result says so rather than letting a number imply it.
|
| 33 |
+
|
| 34 |
+
TWO THINGS THAT SILENTLY GIVE WRONG ANSWERS
|
| 35 |
+
-------------------------------------------
|
| 36 |
+
1. **Numbering.** InterPro coordinates are UniProt's, counted from residue 1
|
| 37 |
+
of the full precursor β signal peptide and propeptide included. Insulin's
|
| 38 |
+
domain "26-110" is on the 110-residue precursor; the mature B chain starts
|
| 39 |
+
at 25. A user working from mature-protein numbering will be off by a
|
| 40 |
+
constant, so the offset is stated every time, exactly as `features.py`
|
| 41 |
+
does.
|
| 42 |
+
|
| 43 |
+
2. **Discontinuous domains.** Some entries are genuinely split β IPR022352 on
|
| 44 |
+
insulin is 29-45 *and* 90-109. Collapsing that to 29-109 would silently
|
| 45 |
+
claim 44 residues that are not in the domain. Segments are preserved and a
|
| 46 |
+
span is never inferred from min/max.
|
| 47 |
+
|
| 48 |
+
Only the accession leaves the Space. Never a user sequence β and InterProScan,
|
| 49 |
+
which WOULD accept a raw sequence, is a minutes-long job queue this module
|
| 50 |
+
deliberately does not pretend to be.
|
| 51 |
+
"""
|
| 52 |
+
from __future__ import annotations
|
| 53 |
+
|
| 54 |
+
import json
|
| 55 |
+
import re
|
| 56 |
+
import urllib.parse
|
| 57 |
+
import urllib.request
|
| 58 |
+
from typing import Any, Dict, Iterable, List, Optional
|
| 59 |
+
|
| 60 |
+
_UA = "TuringDNA/1.0 (https://turingdna.com)"
|
| 61 |
+
_API = "https://www.ebi.ac.uk/interpro/api/entry/all/protein/uniprot"
|
| 62 |
+
|
| 63 |
+
# Bounded paging. A heavily annotated protein can exceed one page; an unbounded
|
| 64 |
+
# follow loop is a way to hang a request thread on someone else's pagination.
|
| 65 |
+
PAGE_SIZE = 200
|
| 66 |
+
MAX_PAGES = 5
|
| 67 |
+
|
| 68 |
+
# Entry types, in the order a protein engineer cares about them. `domain` and
|
| 69 |
+
# `repeat` carry real boundaries; `family` classifies the whole protein;
|
| 70 |
+
# `homologous_superfamily` is a structural grouping that usually spans more
|
| 71 |
+
# than the domain itself, so it is kept but ranked last.
|
| 72 |
+
TYPE_ORDER = {
|
| 73 |
+
"domain": 0, "repeat": 1, "family": 2, "conserved_site": 3,
|
| 74 |
+
"active_site": 4, "binding_site": 5, "ptm": 6,
|
| 75 |
+
"homologous_superfamily": 7,
|
| 76 |
+
}
|
| 77 |
+
# The types whose coordinates are usable as a design region.
|
| 78 |
+
BOUNDARY_TYPES = {"domain", "repeat"}
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def _get(url: str, timeout: float = 20.0) -> Optional[dict]:
|
| 82 |
+
try:
|
| 83 |
+
req = urllib.request.Request(url, headers={"User-Agent": _UA,
|
| 84 |
+
"Accept": "application/json"})
|
| 85 |
+
with urllib.request.urlopen(req, timeout=timeout) as r:
|
| 86 |
+
return json.loads(r.read().decode("utf-8"))
|
| 87 |
+
except Exception: # noqa: BLE001
|
| 88 |
+
return None
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def _segments(protein: Dict[str, Any]) -> List[Dict[str, int]]:
|
| 92 |
+
"""Every fragment of every location, deduplicated and sorted.
|
| 93 |
+
|
| 94 |
+
Never min/max β a discontinuous domain is two spans, and merging them
|
| 95 |
+
would claim the residues in between.
|
| 96 |
+
"""
|
| 97 |
+
out = []
|
| 98 |
+
for loc in (protein.get("entry_protein_locations") or []):
|
| 99 |
+
for frag in (loc.get("fragments") or []):
|
| 100 |
+
try:
|
| 101 |
+
s, e = int(frag["start"]), int(frag["end"])
|
| 102 |
+
except (KeyError, TypeError, ValueError):
|
| 103 |
+
continue
|
| 104 |
+
if s > 0 and e >= s:
|
| 105 |
+
out.append({"start": s, "end": e})
|
| 106 |
+
seen, uniq = set(), []
|
| 107 |
+
for seg in sorted(out, key=lambda d: (d["start"], d["end"])):
|
| 108 |
+
key = (seg["start"], seg["end"])
|
| 109 |
+
if key not in seen:
|
| 110 |
+
seen.add(key)
|
| 111 |
+
uniq.append(seg)
|
| 112 |
+
return uniq
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def _raw_entries(acc: str):
|
| 116 |
+
"""(rows, truncated). `truncated` matters: a page that fails mid-walk gives
|
| 117 |
+
a partial architecture, and a partial architecture presented as complete is
|
| 118 |
+
how a domain quietly goes missing."""
|
| 119 |
+
url = f"{_API}/{urllib.parse.quote(acc)}/?page_size={PAGE_SIZE}"
|
| 120 |
+
rows: List[Dict[str, Any]] = []
|
| 121 |
+
for _ in range(MAX_PAGES):
|
| 122 |
+
data = _get(url)
|
| 123 |
+
if data is None:
|
| 124 |
+
return (rows, True) if rows else (None, False)
|
| 125 |
+
rows.extend(data.get("results") or [])
|
| 126 |
+
url = data.get("next")
|
| 127 |
+
if not url:
|
| 128 |
+
return rows, False
|
| 129 |
+
return rows, bool(url)
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def domains(accession: str, positions: Optional[Iterable] = None) -> Dict[str, Any]:
|
| 133 |
+
"""Domain architecture for one UniProt accession, grouped by InterPro entry."""
|
| 134 |
+
acc = re.sub(r"[^A-Za-z0-9\-.]", "", accession or "").upper()
|
| 135 |
+
if not acc:
|
| 136 |
+
return {"ok": False, "error": "No accession given.",
|
| 137 |
+
"next": ("This needs a UniProt accession. Running signatures "
|
| 138 |
+
"against a raw sequence means InterProScan, which is "
|
| 139 |
+
"a minutes-long job queue this engine does not run β "
|
| 140 |
+
"identify the protein first (search_databases or "
|
| 141 |
+
"blast_sequence) and pass the accession.")}
|
| 142 |
+
|
| 143 |
+
rows, truncated = _raw_entries(acc)
|
| 144 |
+
if rows is None:
|
| 145 |
+
return {"ok": False, "kind": "unreachable",
|
| 146 |
+
"error": f"Couldn't reach InterPro for {acc}.",
|
| 147 |
+
"next": "Retry; this is usually momentary."}
|
| 148 |
+
|
| 149 |
+
protein_length = None
|
| 150 |
+
groups: Dict[str, Dict[str, Any]] = {}
|
| 151 |
+
unintegrated: List[Dict[str, Any]] = []
|
| 152 |
+
|
| 153 |
+
for row in rows:
|
| 154 |
+
meta = row.get("metadata") or {}
|
| 155 |
+
prots = row.get("proteins") or []
|
| 156 |
+
if not prots:
|
| 157 |
+
continue
|
| 158 |
+
prot = prots[0]
|
| 159 |
+
if protein_length is None and prot.get("protein_length"):
|
| 160 |
+
protein_length = int(prot["protein_length"])
|
| 161 |
+
segs = _segments(prot)
|
| 162 |
+
if not segs:
|
| 163 |
+
continue
|
| 164 |
+
|
| 165 |
+
db = str(meta.get("source_database") or "").lower()
|
| 166 |
+
entry_acc = str(meta.get("accession") or "")
|
| 167 |
+
integrated = meta.get("integrated")
|
| 168 |
+
|
| 169 |
+
if db == "interpro":
|
| 170 |
+
g = groups.setdefault(entry_acc, {"members": [], "segments": []})
|
| 171 |
+
g.update({"interpro": entry_acc,
|
| 172 |
+
"name": str(meta.get("name") or entry_acc),
|
| 173 |
+
"type": str(meta.get("type") or "unknown"),
|
| 174 |
+
"segments": segs})
|
| 175 |
+
elif integrated:
|
| 176 |
+
g = groups.setdefault(str(integrated), {"members": [], "segments": []})
|
| 177 |
+
g["members"].append({"database": db, "accession": entry_acc})
|
| 178 |
+
else:
|
| 179 |
+
# A signature InterPro has not integrated. Real, but it has not
|
| 180 |
+
# been curated into an entry, so it is kept separate rather than
|
| 181 |
+
# promoted alongside integrated calls.
|
| 182 |
+
unintegrated.append({"database": db, "accession": entry_acc,
|
| 183 |
+
"name": str(meta.get("name") or entry_acc),
|
| 184 |
+
"type": str(meta.get("type") or "unknown"),
|
| 185 |
+
"segments": segs})
|
| 186 |
+
|
| 187 |
+
entries = []
|
| 188 |
+
for key, g in groups.items():
|
| 189 |
+
if not g.get("interpro"):
|
| 190 |
+
# Members pointed at an entry that never came back in this
|
| 191 |
+
# response. Keep them rather than dropping the evidence silently.
|
| 192 |
+
g.update({"interpro": key, "name": key, "type": "unknown"})
|
| 193 |
+
members = sorted({m["database"] for m in g["members"]})
|
| 194 |
+
entries.append({
|
| 195 |
+
"interpro": g["interpro"],
|
| 196 |
+
"name": g["name"],
|
| 197 |
+
"type": g["type"],
|
| 198 |
+
"segments": g["segments"],
|
| 199 |
+
"residues": sum(s["end"] - s["start"] + 1 for s in g["segments"]),
|
| 200 |
+
"discontinuous": len(g["segments"]) > 1,
|
| 201 |
+
"supported_by": members,
|
| 202 |
+
"signatures": sorted(g["members"], key=lambda m: (m["database"],
|
| 203 |
+
m["accession"])),
|
| 204 |
+
})
|
| 205 |
+
entries.sort(key=lambda e: (TYPE_ORDER.get(e["type"], 9),
|
| 206 |
+
e["segments"][0]["start"] if e["segments"] else 0))
|
| 207 |
+
|
| 208 |
+
architecture = [e for e in entries if e["type"] in BOUNDARY_TYPES]
|
| 209 |
+
architecture.sort(key=lambda e: e["segments"][0]["start"])
|
| 210 |
+
|
| 211 |
+
result: Dict[str, Any] = {
|
| 212 |
+
"ok": True,
|
| 213 |
+
"accession": acc,
|
| 214 |
+
"protein_length": protein_length,
|
| 215 |
+
"entries": entries,
|
| 216 |
+
"architecture": [
|
| 217 |
+
{"name": e["name"], "interpro": e["interpro"],
|
| 218 |
+
"segments": e["segments"], "discontinuous": e["discontinuous"]}
|
| 219 |
+
for e in architecture],
|
| 220 |
+
"unintegrated": unintegrated,
|
| 221 |
+
"signature_rows": len(rows),
|
| 222 |
+
"source": "InterPro (EMBL-EBI)",
|
| 223 |
+
"summary": _summarize(acc, entries, architecture, protein_length),
|
| 224 |
+
"numbering": (
|
| 225 |
+
f"Positions are UniProt's, counted from residue 1 of the full "
|
| 226 |
+
f"precursor"
|
| 227 |
+
+ (f" ({protein_length} aa)" if protein_length else "")
|
| 228 |
+
+ ", including any signal peptide or propeptide. If the user is "
|
| 229 |
+
"working from mature-protein numbering, every boundary here is "
|
| 230 |
+
"offset by the length of what was cleaved."),
|
| 231 |
+
"evidence_note": (
|
| 232 |
+
"'supported_by' lists the member databases whose signatures "
|
| 233 |
+
"InterPro integrated into each entry. Several agreeing is "
|
| 234 |
+
"corroboration, but they are built from overlapping data and often "
|
| 235 |
+
"the same seed alignments β read it as one call with more support, "
|
| 236 |
+
"not as N independent observations."),
|
| 237 |
+
"caveat": (
|
| 238 |
+
"Signatures detect what resembles something already known. No "
|
| 239 |
+
"match means no signature matched, which for a designed, novel or "
|
| 240 |
+
"poorly studied protein is expected and is NOT evidence that the "
|
| 241 |
+
"protein has no domains."),
|
| 242 |
+
}
|
| 243 |
+
|
| 244 |
+
if truncated:
|
| 245 |
+
result["truncated"] = True
|
| 246 |
+
result["summary"] = ("PARTIAL β InterPro stopped responding part way "
|
| 247 |
+
"through. " + result["summary"])
|
| 248 |
+
result["caveat"] = ("This architecture is INCOMPLETE: some pages did "
|
| 249 |
+
"not come back, so a domain may be missing "
|
| 250 |
+
"entirely. Retry before relying on it. "
|
| 251 |
+
+ result["caveat"])
|
| 252 |
+
|
| 253 |
+
wanted = _positions(positions)
|
| 254 |
+
if wanted:
|
| 255 |
+
result["positions"] = [_locate(p, entries, protein_length) for p in wanted]
|
| 256 |
+
return result
|
| 257 |
+
|
| 258 |
+
|
| 259 |
+
def _positions(positions: Optional[Iterable]) -> List[int]:
|
| 260 |
+
"""Accept ints or the substitution labels ('R175H') the engine speaks."""
|
| 261 |
+
out: List[int] = []
|
| 262 |
+
for p in (positions or []):
|
| 263 |
+
m = re.search(r"\d+", str(p))
|
| 264 |
+
if m:
|
| 265 |
+
out.append(int(m.group(0)))
|
| 266 |
+
return sorted(set(out))
|
| 267 |
+
|
| 268 |
+
|
| 269 |
+
def _locate(pos: int, entries: List[Dict[str, Any]],
|
| 270 |
+
protein_length: Optional[int]) -> Dict[str, Any]:
|
| 271 |
+
"""Which entries contain this residue."""
|
| 272 |
+
inside = [e for e in entries
|
| 273 |
+
if any(s["start"] <= pos <= s["end"] for s in e["segments"])]
|
| 274 |
+
boundary = [e for e in inside if e["type"] in BOUNDARY_TYPES]
|
| 275 |
+
out: Dict[str, Any] = {
|
| 276 |
+
"position": pos,
|
| 277 |
+
"in": [{"name": e["name"], "interpro": e["interpro"], "type": e["type"]}
|
| 278 |
+
for e in inside],
|
| 279 |
+
}
|
| 280 |
+
if protein_length and pos > protein_length:
|
| 281 |
+
out["note"] = (f"Position {pos} is past the end of this protein "
|
| 282 |
+
f"({protein_length} aa) β check the numbering before "
|
| 283 |
+
f"going further.")
|
| 284 |
+
elif boundary:
|
| 285 |
+
out["note"] = (f"Inside {boundary[0]['name']}. A substitution here is "
|
| 286 |
+
f"in a structured unit, not a linker.")
|
| 287 |
+
elif inside:
|
| 288 |
+
out["note"] = ("Covered only by family or superfamily entries, which "
|
| 289 |
+
"classify the whole protein β this position is not "
|
| 290 |
+
"inside a specific annotated domain.")
|
| 291 |
+
else:
|
| 292 |
+
out["note"] = ("No InterPro entry covers this position. That is not "
|
| 293 |
+
"the same as it being unimportant β it means no "
|
| 294 |
+
"signature matched here.")
|
| 295 |
+
return out
|
| 296 |
+
|
| 297 |
+
|
| 298 |
+
def _summarize(acc: str, entries: List[Dict[str, Any]],
|
| 299 |
+
architecture: List[Dict[str, Any]],
|
| 300 |
+
protein_length: Optional[int]) -> str:
|
| 301 |
+
if not entries:
|
| 302 |
+
return (f"No InterPro signatures matched {acc}. For a novel or "
|
| 303 |
+
f"designed protein that is expected, not a red flag.")
|
| 304 |
+
families = [e["name"] for e in entries if e["type"] == "family"]
|
| 305 |
+
bits = []
|
| 306 |
+
if protein_length:
|
| 307 |
+
bits.append(f"{protein_length} aa")
|
| 308 |
+
if families:
|
| 309 |
+
bits.append(families[0])
|
| 310 |
+
if architecture:
|
| 311 |
+
drawn = " Β· ".join(
|
| 312 |
+
f"{e['name']} ({'+'.join(str(s['start']) + 'β' + str(s['end']) for s in e['segments'])})"
|
| 313 |
+
for e in architecture[:4])
|
| 314 |
+
bits.append(drawn + ("β¦" if len(architecture) > 4 else ""))
|
| 315 |
+
else:
|
| 316 |
+
bits.append("no domain-level boundaries β family-level classification only")
|
| 317 |
+
return " Β· ".join(bits)
|
dee/core/expression.py
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Why a construct expresses badly β the causes that are visible in the DNA.
|
| 2 |
+
|
| 3 |
+
WHAT IS HONEST HERE, AND WHAT IS NOT
|
| 4 |
+
------------------------------------
|
| 5 |
+
#24 is rated High because a design that does not express is a wasted month,
|
| 6 |
+
and nothing in the product currently looks at it. But an expression LEVEL β
|
| 7 |
+
"expect 40 mg/L" β is not something this engine can honestly produce. Yield
|
| 8 |
+
depends on the host strain, the promoter, plasmid copy number, induction
|
| 9 |
+
temperature, media, growth phase, and whether the protein is toxic to the
|
| 10 |
+
cell. None of that is in a sequence, and no model trained on it is available
|
| 11 |
+
here. So there is no predicted yield in this module and there is no score.
|
| 12 |
+
|
| 13 |
+
What IS in the sequence is a specific, enumerable set of causes that are known
|
| 14 |
+
to break translation, and those are computed exactly:
|
| 15 |
+
|
| 16 |
+
* **Rare codons**, from the engine's real usage tables in `codon.py` β the same
|
| 17 |
+
tables the library encoder uses, so a codon this module calls rare and a
|
| 18 |
+
codon the DE pipeline installs are the same opinion, not two.
|
| 19 |
+
* **Rare-codon RUNS**, which matter far more than the overall count. A
|
| 20 |
+
scattered rare codon is absorbed; three in a row is where a ribosome stalls.
|
| 21 |
+
* **The 5' ramp**, because the first ~50 codons carry the initiation region and
|
| 22 |
+
a rare codon there costs more than the same codon at residue 400.
|
| 23 |
+
* **Reading-frame integrity**, because a construct with an internal stop
|
| 24 |
+
expresses a truncated product and every other number would describe a
|
| 25 |
+
protein that is never made.
|
| 26 |
+
|
| 27 |
+
THE RARE-CODON THRESHOLD IS DERIVED, NOT CHOSEN
|
| 28 |
+
------------------------------------------------
|
| 29 |
+
Relative adaptiveness w = f(codon) / f(the most-used synonym), which is the
|
| 30 |
+
Sharp & Li definition. At w < 0.15 the E. coli table selects AGA, AGG, ATA and
|
| 31 |
+
CTA β precisely the set that Rosetta / CodonPlus strains exist to supply
|
| 32 |
+
(argU, ileY, leuW). The cutoff was checked against the tables rather than
|
| 33 |
+
picked, and the codons it selects are reported so the call can be audited.
|
| 34 |
+
|
| 35 |
+
The same cutoff selects three arginine codons in yeast and NOTHING in human β
|
| 36 |
+
which is a real result, not a failure. Mammalian codon usage is much flatter
|
| 37 |
+
and the rare-codon story is genuinely weaker there. Reporting that is more
|
| 38 |
+
useful than manufacturing a warning to look thorough.
|
| 39 |
+
|
| 40 |
+
HOSTS THIS HAS NO TABLE FOR
|
| 41 |
+
----------------------------
|
| 42 |
+
`codon.py` carries E. coli, S. cerevisiae and human. For anything else this
|
| 43 |
+
refuses and says so. That is not a capability gate on the organism β it is a
|
| 44 |
+
missing data file, and silently scoring a plant construct against the E. coli
|
| 45 |
+
table would be far worse than declining. Audit #11 (Kazusa tables) is the fix.
|
| 46 |
+
"""
|
| 47 |
+
from __future__ import annotations
|
| 48 |
+
|
| 49 |
+
import re
|
| 50 |
+
from typing import Any, Dict, List, Optional
|
| 51 |
+
|
| 52 |
+
from dee.core import codon as _codon
|
| 53 |
+
from dee.core import edits as _edits
|
| 54 |
+
|
| 55 |
+
# Relative adaptiveness below which a codon is called rare. Derived: see module
|
| 56 |
+
# docstring. The selected codons travel with every result so this is auditable.
|
| 57 |
+
RARE_BELOW = 0.15
|
| 58 |
+
# Consecutive rare codons at or above this length is a stall risk rather than
|
| 59 |
+
# noise. A lone rare codon is absorbed by the tRNA pool; a run depletes it.
|
| 60 |
+
RUN_LENGTH = 3
|
| 61 |
+
# The initiation region, where a rare codon costs disproportionately.
|
| 62 |
+
RAMP_CODONS = 50
|
| 63 |
+
# Below this there is not enough coding sequence for the statistics to mean
|
| 64 |
+
# anything.
|
| 65 |
+
MIN_CODONS = 30
|
| 66 |
+
STOPS = {"TAA", "TAG", "TGA"}
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def _clean_dna(dna: str) -> str:
|
| 70 |
+
return re.sub(r"[^ACGTUacgtu]", "", dna or "").upper().replace("U", "T")
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def _adaptiveness(host: str) -> Dict[str, Any]:
|
| 74 |
+
"""codon -> (amino acid, relative adaptiveness), from the engine's tables."""
|
| 75 |
+
table = _codon._resolve_table(host)
|
| 76 |
+
w: Dict[str, float] = {}
|
| 77 |
+
aa_of: Dict[str, str] = {}
|
| 78 |
+
for aa, usage in table.items():
|
| 79 |
+
top = max(usage.values()) or 1.0
|
| 80 |
+
for cod, freq in usage.items():
|
| 81 |
+
w[cod] = freq / top
|
| 82 |
+
aa_of[cod] = aa
|
| 83 |
+
return {"w": w, "aa": aa_of}
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def _runs(flags: List[bool], codons: List[str]) -> List[Dict[str, Any]]:
|
| 87 |
+
out: List[Dict[str, Any]] = []
|
| 88 |
+
start = None
|
| 89 |
+
for i, bad in enumerate(flags + [False]):
|
| 90 |
+
if bad and start is None:
|
| 91 |
+
start = i
|
| 92 |
+
elif not bad and start is not None:
|
| 93 |
+
if i - start >= RUN_LENGTH:
|
| 94 |
+
out.append({"start_codon": start + 1, "end_codon": i,
|
| 95 |
+
"length": i - start,
|
| 96 |
+
"codons": codons[start:i]})
|
| 97 |
+
start = None
|
| 98 |
+
return out
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def assess(dna: str, host: str) -> Dict[str, Any]:
|
| 102 |
+
"""Sequence-visible reasons a CDS may express poorly in `host`."""
|
| 103 |
+
seq = _clean_dna(dna)
|
| 104 |
+
if not host or not str(host).strip():
|
| 105 |
+
return {"ok": False, "error": "Which host? Expression is host-specific "
|
| 106 |
+
"β the same CDS behaves differently in "
|
| 107 |
+
"E. coli and in human cells."}
|
| 108 |
+
try:
|
| 109 |
+
adapt = _adaptiveness(str(host))
|
| 110 |
+
except ValueError as exc:
|
| 111 |
+
return {
|
| 112 |
+
"ok": False, "kind": "no_codon_table",
|
| 113 |
+
"error": str(exc),
|
| 114 |
+
"next": ("This is a missing data file, not an unsupported "
|
| 115 |
+
"organism. Scoring the construct against a different "
|
| 116 |
+
"host's table would produce confident nonsense, so it "
|
| 117 |
+
"declines instead. Everything else about the sequence "
|
| 118 |
+
"(check_synthesis, GC, reading frame) still works."),
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
if len(seq) < MIN_CODONS * 3:
|
| 122 |
+
return {"ok": False, "kind": "too_short",
|
| 123 |
+
"error": f"Need at least {MIN_CODONS} codons; got "
|
| 124 |
+
f"{len(seq) // 3}.",
|
| 125 |
+
"next": "Paste the full coding sequence."}
|
| 126 |
+
|
| 127 |
+
frame_ok = len(seq) % 3 == 0
|
| 128 |
+
codons = [seq[i:i + 3] for i in range(0, len(seq) - len(seq) % 3, 3)]
|
| 129 |
+
# The terminal stop is expected; internal ones are the finding.
|
| 130 |
+
terminal_stop = bool(codons) and codons[-1] in STOPS
|
| 131 |
+
body = codons[:-1] if terminal_stop else codons
|
| 132 |
+
internal_stops = [i + 1 for i, c in enumerate(body) if c in STOPS]
|
| 133 |
+
|
| 134 |
+
w_map, aa_map = adapt["w"], adapt["aa"]
|
| 135 |
+
sense = [c for c in body if c not in STOPS]
|
| 136 |
+
unknown = [c for c in sense if c not in w_map]
|
| 137 |
+
scored = [c for c in sense if c in w_map]
|
| 138 |
+
# CAI excludes the single-codon families (Met, Trp): with one option there
|
| 139 |
+
# is no choice to adapt, and including them only pulls every score toward 1.
|
| 140 |
+
cai_pool = [w_map[c] for c in scored if aa_map[c] not in ("M", "W")]
|
| 141 |
+
cai = None
|
| 142 |
+
if cai_pool:
|
| 143 |
+
# Geometric mean, in log space β the direct product underflows on a
|
| 144 |
+
# long CDS and silently returns 0.0.
|
| 145 |
+
import math
|
| 146 |
+
cai = round(math.exp(sum(math.log(max(x, 1e-6)) for x in cai_pool)
|
| 147 |
+
/ len(cai_pool)), 3)
|
| 148 |
+
|
| 149 |
+
rare_flags = [c in w_map and w_map[c] < RARE_BELOW for c in body]
|
| 150 |
+
rare = [{"codon_number": i + 1, "codon": c, "amino_acid": aa_map.get(c),
|
| 151 |
+
"adaptiveness": round(w_map[c], 3)}
|
| 152 |
+
for i, c in enumerate(body)
|
| 153 |
+
if c in w_map and w_map[c] < RARE_BELOW]
|
| 154 |
+
runs = _runs(rare_flags, body)
|
| 155 |
+
ramp = [r for r in rare if r["codon_number"] <= RAMP_CODONS]
|
| 156 |
+
|
| 157 |
+
gc = round(100.0 * sum(c in "GC" for c in seq) / len(seq), 1)
|
| 158 |
+
ramp_seq = seq[:RAMP_CODONS * 3]
|
| 159 |
+
gc_ramp = round(100.0 * sum(c in "GC" for c in ramp_seq) / len(ramp_seq), 1)
|
| 160 |
+
|
| 161 |
+
rare_set = sorted({c for c, x in w_map.items() if x < RARE_BELOW
|
| 162 |
+
and c not in STOPS})
|
| 163 |
+
|
| 164 |
+
flags: List[Dict[str, str]] = []
|
| 165 |
+
if not frame_ok:
|
| 166 |
+
flags.append({"factor": "not a whole number of codons",
|
| 167 |
+
"observed": f"{len(seq)} nt",
|
| 168 |
+
"why": ("The sequence is not in frame as given. Every "
|
| 169 |
+
"codon-level number below is computed on the "
|
| 170 |
+
"frame starting at base 1, which may not be the "
|
| 171 |
+
"intended one.")})
|
| 172 |
+
if internal_stops:
|
| 173 |
+
flags.append({"factor": "internal stop codon",
|
| 174 |
+
"observed": f"codon {internal_stops[0]}"
|
| 175 |
+
+ (f" (+{len(internal_stops) - 1} more)"
|
| 176 |
+
if len(internal_stops) > 1 else ""),
|
| 177 |
+
"why": ("Translation terminates here. The product is a "
|
| 178 |
+
"truncated fragment, so nothing downstream "
|
| 179 |
+
"describes the intended protein.")})
|
| 180 |
+
if not terminal_stop:
|
| 181 |
+
flags.append({"factor": "no terminal stop codon",
|
| 182 |
+
"observed": f"ends {codons[-1] if codons else '?'}",
|
| 183 |
+
"why": ("Without a stop the ribosome reads into the "
|
| 184 |
+
"vector, adding an unintended C-terminal tail.")})
|
| 185 |
+
for run in runs:
|
| 186 |
+
flags.append({"factor": f"run of {run['length']} consecutive rare codons",
|
| 187 |
+
"observed": (f"codons {run['start_codon']}-"
|
| 188 |
+
f"{run['end_codon']}: "
|
| 189 |
+
+ " ".join(run["codons"])),
|
| 190 |
+
"why": ("Consecutive rare codons deplete the local "
|
| 191 |
+
"charged-tRNA pool and stall the ribosome, which "
|
| 192 |
+
"causes truncation and frameshifting. This is "
|
| 193 |
+
"the single most actionable finding here β "
|
| 194 |
+
"recoding just this run is usually enough.")})
|
| 195 |
+
if ramp:
|
| 196 |
+
flags.append({"factor": f"{len(ramp)} rare codon(s) in the first "
|
| 197 |
+
f"{RAMP_CODONS} codons",
|
| 198 |
+
"observed": ", ".join(f"{r['codon']}@{r['codon_number']}"
|
| 199 |
+
for r in ramp[:6]),
|
| 200 |
+
"why": ("The 5' region sets the rate of translation "
|
| 201 |
+
"initiation, so a rare codon here costs more "
|
| 202 |
+
"than the same codon further in.")})
|
| 203 |
+
|
| 204 |
+
return {
|
| 205 |
+
"ok": True,
|
| 206 |
+
"host": host,
|
| 207 |
+
"codons": len(body),
|
| 208 |
+
"codon_adaptation_index": cai,
|
| 209 |
+
"gc_percent": gc,
|
| 210 |
+
"gc_percent_5prime": gc_ramp,
|
| 211 |
+
"rare_codons": rare,
|
| 212 |
+
"rare_codon_count": len(rare),
|
| 213 |
+
"rare_codon_runs": runs,
|
| 214 |
+
"rare_in_ramp": ramp,
|
| 215 |
+
"internal_stops": internal_stops,
|
| 216 |
+
"has_terminal_stop": terminal_stop,
|
| 217 |
+
"in_frame": frame_ok,
|
| 218 |
+
"is_clean_cds": _edits.is_coding(seq),
|
| 219 |
+
"unrecognised_codons": len(unknown),
|
| 220 |
+
"flags": flags,
|
| 221 |
+
"summary": _summary(host, len(body), cai, rare, runs, flags),
|
| 222 |
+
"rare_codons_in_this_host": rare_set,
|
| 223 |
+
"method": (f"Relative adaptiveness w = f(codon) / f(most-used synonym) "
|
| 224 |
+
f"(Sharp & Li), from the engine's own {host} usage table β "
|
| 225 |
+
f"the same table the library encoder writes with. Rare means "
|
| 226 |
+
f"w < {RARE_BELOW}, which in this host selects: "
|
| 227 |
+
f"{' '.join(rare_set) if rare_set else 'nothing'}. CAI is "
|
| 228 |
+
f"the geometric mean of w over sense codons, excluding Met "
|
| 229 |
+
f"and Trp, which have no synonym to choose between."),
|
| 230 |
+
# The line this module exists to be able to say.
|
| 231 |
+
"no_yield": ("This does NOT predict an expression level. Yield depends "
|
| 232 |
+
"on strain, promoter, copy number, induction temperature, "
|
| 233 |
+
"media and protein toxicity β none of which is in a "
|
| 234 |
+
"sequence, and no model trained on them is available "
|
| 235 |
+
"here. These are the sequence-visible causes of failure, "
|
| 236 |
+
"not a forecast."),
|
| 237 |
+
"not_covered": ("Also not checked here: internal Shine-Dalgarno-like "
|
| 238 |
+
"pausing sites, mRNA secondary structure over the RBS "
|
| 239 |
+
"(audit #38/#39, which need an RNA folding engine), "
|
| 240 |
+
"and protein-level causes β for those run "
|
| 241 |
+
"assess_solubility on the translated product."),
|
| 242 |
+
}
|
| 243 |
+
|
| 244 |
+
|
| 245 |
+
def _summary(host: str, n: int, cai: Optional[float], rare: List[Dict[str, Any]],
|
| 246 |
+
runs: List[Dict[str, Any]], flags: List[Dict[str, str]]) -> str:
|
| 247 |
+
bits = [f"{n} codons in {host}"]
|
| 248 |
+
if cai is not None:
|
| 249 |
+
bits.append(f"CAI {cai}")
|
| 250 |
+
bits.append(f"{len(rare)} rare codon(s)")
|
| 251 |
+
if runs:
|
| 252 |
+
bits.append(f"{len(runs)} rare run(s) β codons "
|
| 253 |
+
+ ", ".join(f"{r['start_codon']}-{r['end_codon']}"
|
| 254 |
+
for r in runs[:3]))
|
| 255 |
+
if not flags:
|
| 256 |
+
bits.append("nothing flagged")
|
| 257 |
+
return " Β· ".join(bits)
|
dee/core/features.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The residues you should not mutate, from the people who curated them.
|
| 2 |
+
|
| 3 |
+
THE PROBLEM
|
| 4 |
+
-----------
|
| 5 |
+
The engine will happily rank a substitution at the catalytic serine of a
|
| 6 |
+
protease as promising. ESM-2 scores how UNUSUAL a residue is, not how load-
|
| 7 |
+
bearing it is, and an active-site residue is often perfectly ordinary in
|
| 8 |
+
sequence terms β it is the geometry that matters, and a language model over
|
| 9 |
+
sequence does not see geometry.
|
| 10 |
+
|
| 11 |
+
So the single cheapest improvement to design quality is not a better model. It
|
| 12 |
+
is asking UniProt what is already known about the positions being mutated:
|
| 13 |
+
active sites, binding sites, disulfides, metal ligands, domains. That is
|
| 14 |
+
curated, citable, human-reviewed annotation, and it turns "this substitution
|
| 15 |
+
looks tolerable" into "this substitution is at the catalytic residue".
|
| 16 |
+
|
| 17 |
+
WHAT THIS IS
|
| 18 |
+
------------
|
| 19 |
+
A read of UniProt's feature table, mapped onto positions the user cares about.
|
| 20 |
+
No prediction, no score, no model β every item returned carries the evidence
|
| 21 |
+
UniProt itself carries. When UniProt has nothing for a protein, that is
|
| 22 |
+
reported as "not annotated", never as "safe": absence of annotation is absence
|
| 23 |
+
of knowledge, and conflating the two is how a tool talks someone into an
|
| 24 |
+
experiment.
|
| 25 |
+
|
| 26 |
+
Only (accession) leaves the Space. Never a user sequence.
|
| 27 |
+
"""
|
| 28 |
+
from __future__ import annotations
|
| 29 |
+
|
| 30 |
+
import json
|
| 31 |
+
import re
|
| 32 |
+
import urllib.parse
|
| 33 |
+
import urllib.request
|
| 34 |
+
from typing import Any, Dict, Iterable, List, Optional
|
| 35 |
+
|
| 36 |
+
_UA = "TuringDNA/1.0 (https://turingdna.com)"
|
| 37 |
+
_ENTRY = "https://rest.uniprot.org/uniprotkb"
|
| 38 |
+
|
| 39 |
+
# Feature types worth interrupting a design for, in the order a designer cares.
|
| 40 |
+
# UniProt emits many more (VARIANT, CONFLICT, chains); those describe the
|
| 41 |
+
# record, not a reason to avoid a position, and including them would bury the
|
| 42 |
+
# signal.
|
| 43 |
+
CRITICAL = {
|
| 44 |
+
"Active site": "catalytic β mutating this typically abolishes activity",
|
| 45 |
+
"Binding site": "contacts the substrate or cofactor",
|
| 46 |
+
"Site": "functionally annotated position",
|
| 47 |
+
"Metal binding": "coordinates a metal ion the fold depends on",
|
| 48 |
+
"Disulfide bond": "forms a disulfide; losing one cysteine breaks both",
|
| 49 |
+
"Modified residue": "post-translationally modified",
|
| 50 |
+
"Glycosylation": "glycosylation site",
|
| 51 |
+
"Cross-link": "covalently cross-linked",
|
| 52 |
+
}
|
| 53 |
+
CONTEXT = {
|
| 54 |
+
"Domain": "domain",
|
| 55 |
+
"Region": "region",
|
| 56 |
+
"Motif": "motif",
|
| 57 |
+
"Repeat": "repeat",
|
| 58 |
+
"Transmembrane": "membrane-spanning",
|
| 59 |
+
"Signal": "signal peptide",
|
| 60 |
+
"Propeptide": "propeptide β cleaved from the mature protein",
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def _get(url: str, timeout: float = 15.0) -> Optional[dict]:
|
| 65 |
+
try:
|
| 66 |
+
req = urllib.request.Request(url, headers={"User-Agent": _UA,
|
| 67 |
+
"Accept": "application/json"})
|
| 68 |
+
with urllib.request.urlopen(req, timeout=timeout) as r:
|
| 69 |
+
return json.loads(r.read().decode("utf-8"))
|
| 70 |
+
except Exception: # noqa: BLE001
|
| 71 |
+
return None
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def _span(f: Dict[str, Any]) -> Optional[tuple]:
|
| 75 |
+
loc = (f.get("location") or {})
|
| 76 |
+
try:
|
| 77 |
+
s = loc["start"]["value"]
|
| 78 |
+
e = loc["end"]["value"]
|
| 79 |
+
except (KeyError, TypeError):
|
| 80 |
+
return None
|
| 81 |
+
if s is None or e is None:
|
| 82 |
+
return None
|
| 83 |
+
return (int(s), int(e))
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def fetch(accession: str) -> Dict[str, Any]:
|
| 87 |
+
"""UniProt's curated feature table for one accession."""
|
| 88 |
+
acc = re.sub(r"[^A-Za-z0-9\-]", "", accession or "").upper()
|
| 89 |
+
if not acc:
|
| 90 |
+
return {"ok": False, "error": "No accession given."}
|
| 91 |
+
# The whole entry, filtered here. A ?fields= list looked tidier and is
|
| 92 |
+
# not valid on the entry endpoint β it returns an error, not a subset, so
|
| 93 |
+
# the "tidy" version fetched nothing at all.
|
| 94 |
+
data = _get(f"{_ENTRY}/{urllib.parse.quote(acc)}.json")
|
| 95 |
+
if not data:
|
| 96 |
+
return {"ok": False, "kind": "unreachable",
|
| 97 |
+
"error": f"Couldn't reach UniProt for {acc}.",
|
| 98 |
+
"next": "Retry; this is usually momentary."}
|
| 99 |
+
|
| 100 |
+
feats = []
|
| 101 |
+
for f in (data.get("features") or []):
|
| 102 |
+
sp = _span(f)
|
| 103 |
+
if not sp:
|
| 104 |
+
continue
|
| 105 |
+
ftype = str(f.get("type") or "")
|
| 106 |
+
tier = "critical" if ftype in CRITICAL else (
|
| 107 |
+
"context" if ftype in CONTEXT else None)
|
| 108 |
+
if tier is None:
|
| 109 |
+
continue
|
| 110 |
+
feats.append({
|
| 111 |
+
"type": ftype,
|
| 112 |
+
"start": sp[0], "end": sp[1],
|
| 113 |
+
"tier": tier,
|
| 114 |
+
"description": str(f.get("description") or "")[:180],
|
| 115 |
+
"means": CRITICAL.get(ftype) or CONTEXT.get(ftype, ""),
|
| 116 |
+
# UniProt's own evidence codes travel with the claim. ECO:0000269
|
| 117 |
+
# is experimental; ECO:0000250 is inferred by similarity, and a
|
| 118 |
+
# designer should weight those differently.
|
| 119 |
+
"evidence": sorted({str(e.get("evidenceCode") or "")
|
| 120 |
+
for e in (f.get("evidences") or [])
|
| 121 |
+
if e.get("evidenceCode")}),
|
| 122 |
+
})
|
| 123 |
+
|
| 124 |
+
name = ""
|
| 125 |
+
try:
|
| 126 |
+
name = (data["proteinDescription"]["recommendedName"]
|
| 127 |
+
["fullName"]["value"])
|
| 128 |
+
except (KeyError, TypeError):
|
| 129 |
+
pass
|
| 130 |
+
return {
|
| 131 |
+
"ok": True,
|
| 132 |
+
"accession": data.get("primaryAccession") or acc,
|
| 133 |
+
"protein": name,
|
| 134 |
+
"length": ((data.get("sequence") or {}).get("length")),
|
| 135 |
+
"features": feats,
|
| 136 |
+
"critical_count": sum(1 for f in feats if f["tier"] == "critical"),
|
| 137 |
+
"source": "UniProt",
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def annotate(accession: str, positions: Iterable) -> Dict[str, Any]:
|
| 142 |
+
"""Which of `positions` land on annotated features.
|
| 143 |
+
|
| 144 |
+
Positions may be integers or substitution labels ('R175H'); the label form
|
| 145 |
+
is what the rest of the engine speaks, so accepting it saves the caller a
|
| 146 |
+
parsing step and a chance to get the offset wrong.
|
| 147 |
+
"""
|
| 148 |
+
table = fetch(accession)
|
| 149 |
+
if not table.get("ok"):
|
| 150 |
+
return table
|
| 151 |
+
|
| 152 |
+
wanted: List[Dict[str, Any]] = []
|
| 153 |
+
for p in positions or []:
|
| 154 |
+
if isinstance(p, int):
|
| 155 |
+
wanted.append({"label": str(p), "pos": p})
|
| 156 |
+
continue
|
| 157 |
+
m = re.match(r"^([A-Za-z])?(\d+)([A-Za-z*])?$", str(p).strip())
|
| 158 |
+
if m:
|
| 159 |
+
wanted.append({"label": str(p).strip(), "pos": int(m.group(2))})
|
| 160 |
+
|
| 161 |
+
hits = []
|
| 162 |
+
for w in wanted:
|
| 163 |
+
on = [f for f in table["features"] if f["start"] <= w["pos"] <= f["end"]]
|
| 164 |
+
crit = [f for f in on if f["tier"] == "critical"]
|
| 165 |
+
hits.append({
|
| 166 |
+
"label": w["label"], "position": w["pos"],
|
| 167 |
+
"critical": crit, "context": [f for f in on if f["tier"] == "context"],
|
| 168 |
+
# The whole point of the tool, stated so the agent cannot miss it.
|
| 169 |
+
"verdict": ("AVOID β annotated functional residue" if crit else
|
| 170 |
+
"no functional annotation at this position"),
|
| 171 |
+
})
|
| 172 |
+
|
| 173 |
+
flagged = [h for h in hits if h["critical"]]
|
| 174 |
+
return {
|
| 175 |
+
"ok": True,
|
| 176 |
+
"accession": table["accession"], "protein": table["protein"],
|
| 177 |
+
"length": table["length"],
|
| 178 |
+
"positions": hits,
|
| 179 |
+
"flagged_count": len(flagged),
|
| 180 |
+
"verdict": (f"{len(flagged)} of {len(hits)} positions sit on annotated "
|
| 181 |
+
f"functional residues." if flagged else
|
| 182 |
+
f"None of the {len(hits)} positions are annotated as "
|
| 183 |
+
f"functional."),
|
| 184 |
+
# The failure that would make this tool actively harmful. UniProt
|
| 185 |
+
# numbers the FULL PRECURSOR β bovine trypsin's catalytic triad is at
|
| 186 |
+
# 63/107/200 here, not the classic His57/Asp102/Ser195, because the
|
| 187 |
+
# signal peptide and propeptide are counted. A user thinking in mature
|
| 188 |
+
# or chymotrypsin numbering gets a confident flag on the wrong residue,
|
| 189 |
+
# which is worse than no flag at all.
|
| 190 |
+
"numbering": (f"Positions are UniProt's, numbered from residue 1 of the "
|
| 191 |
+
f"full precursor ({table['length']} aa) including any "
|
| 192 |
+
f"signal peptide or propeptide. If you are using mature-"
|
| 193 |
+
f"protein or a classic numbering scheme, these will be "
|
| 194 |
+
f"offset β check one known residue before trusting the "
|
| 195 |
+
f"rest."),
|
| 196 |
+
"caveat": ("Absence of annotation is absence of KNOWLEDGE, not evidence "
|
| 197 |
+
"that a position is safe β most proteins are annotated "
|
| 198 |
+
"sparsely, and an unannotated residue may still be "
|
| 199 |
+
"essential. Evidence codes are UniProt's own: ECO:0000269 "
|
| 200 |
+
"is experimental, ECO:0000250 is inferred by similarity."),
|
| 201 |
+
"source": "UniProt",
|
| 202 |
+
}
|
dee/core/identify.py
ADDED
|
@@ -0,0 +1,355 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""What is this thing I just pasted? β answered locally first, and honestly.
|
| 2 |
+
|
| 3 |
+
The audit calls this "the single most common first move a scientist makes"
|
| 4 |
+
(#5, High). Someone has a sequence from a collaborator, a supplementary table
|
| 5 |
+
or a sequencing run, and the first question is not "improve it" but "what am I
|
| 6 |
+
even looking at?".
|
| 7 |
+
|
| 8 |
+
THE DISTINCTION THIS MODULE IS BUILT AROUND
|
| 9 |
+
-------------------------------------------
|
| 10 |
+
**Characterizing is not identifying.** Everything here is computed from the
|
| 11 |
+
residues themselves against catalogues already in this repo: it can say "this
|
| 12 |
+
is 1,341 bp of DNA, one clean reading frame, carrying a T7 promoter and a
|
| 13 |
+
C-terminal 6xHis, and its 3' junction matches pET-28a(+)". That is a real,
|
| 14 |
+
checkable, useful answer and it costs nothing.
|
| 15 |
+
|
| 16 |
+
What it can NOT say is "this is human MC1R". Naming a sequence requires
|
| 17 |
+
comparing it to every known sequence, which means BLAST β and BLAST is the one
|
| 18 |
+
operation in this engine that sends the user's actual residues outside the
|
| 19 |
+
Space. So `identified` is **always False** here, and the result names the tool
|
| 20 |
+
that would change that. An agent that says "this is insulin" because it found
|
| 21 |
+
a His-tag is exactly the failure this module exists to prevent.
|
| 22 |
+
|
| 23 |
+
That ordering is also the privacy-correct one. Local analysis answers most of
|
| 24 |
+
the question for free; BLAST is offered as a deliberate, consented step rather
|
| 25 |
+
than being the reflex.
|
| 26 |
+
|
| 27 |
+
REUSE, DON'T REINVENT
|
| 28 |
+
---------------------
|
| 29 |
+
The audit's single most common finding is capability that already exists in
|
| 30 |
+
the repo with nothing pointing at it. So this module computes almost nothing
|
| 31 |
+
of its own:
|
| 32 |
+
|
| 33 |
+
* `sequence.classify_sequence` β the three-way DNA/protein/ambiguous call
|
| 34 |
+
* `plasmid.find_motifs` β the curated motif catalogue (promoters,
|
| 35 |
+
terminators, tags, loxP/FRT/att sites)
|
| 36 |
+
* `plasmid.find_orfs` β linear-time ORF scan on both strands
|
| 37 |
+
* `edits.is_coding` / `translate` β the strict CDS test and the codon table
|
| 38 |
+
* `accession.classify` β is the paste actually an identifier?
|
| 39 |
+
* `vectors` β the 61-backbone catalogue and its junctions
|
| 40 |
+
|
| 41 |
+
The protein tag table is **derived by translating the DNA motifs already in
|
| 42 |
+
`plasmid._MOTIFS`** rather than typed out here. One source of truth, and no
|
| 43 |
+
sequence in this file was written from recall.
|
| 44 |
+
"""
|
| 45 |
+
from __future__ import annotations
|
| 46 |
+
|
| 47 |
+
import re
|
| 48 |
+
from typing import Any, Dict, List, Optional
|
| 49 |
+
|
| 50 |
+
from dee.core import accession as _acc
|
| 51 |
+
from dee.core import edits as _edits
|
| 52 |
+
from dee.core import plasmid as _plasmid
|
| 53 |
+
from dee.core import sequence as _seq
|
| 54 |
+
from dee.core import vectors as _vectors
|
| 55 |
+
|
| 56 |
+
# Below this there is not enough sequence to say anything, and a 6-mer matches
|
| 57 |
+
# something by chance in every database on earth.
|
| 58 |
+
MIN_LEN = 12
|
| 59 |
+
# Shortest ORF worth reporting. Below ~30 aa an "ORF" in random DNA is noise:
|
| 60 |
+
# a stop codon appears roughly every 21 codons by chance.
|
| 61 |
+
MIN_ORF_AA = 30
|
| 62 |
+
# A vector junction shorter than this is usually just a restriction site, which
|
| 63 |
+
# is shared by hundreds of backbones and proves nothing. Most of the 61
|
| 64 |
+
# catalogue entries record only a 6-bp site, so only a minority can ever
|
| 65 |
+
# produce a junction call β which is the honest outcome, not a shortfall.
|
| 66 |
+
MIN_JUNCTION = 16
|
| 67 |
+
# Distinct residues below which a "protein" looks synthetic or mis-pasted.
|
| 68 |
+
LOW_COMPLEXITY_BELOW = 8
|
| 69 |
+
LOW_COMPLEXITY_MIN_LEN = 60
|
| 70 |
+
|
| 71 |
+
_FASTA_HEADER = re.compile(r"^\s*>([^\n\r]*)", re.M)
|
| 72 |
+
_GENBANK = re.compile(r"^\s*LOCUS\s+\S", re.M)
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def _tag_peptides() -> List[Dict[str, str]]:
|
| 76 |
+
"""Protein-level tags, derived from the repo's own DNA motif catalogue.
|
| 77 |
+
|
| 78 |
+
Translating `plasmid._MOTIFS` keeps a single source of truth and means no
|
| 79 |
+
peptide here was typed from memory β if the DNA catalogue is corrected,
|
| 80 |
+
this follows automatically.
|
| 81 |
+
"""
|
| 82 |
+
out: List[Dict[str, str]] = []
|
| 83 |
+
seen = set()
|
| 84 |
+
for name, motif, ftype in _plasmid._MOTIFS:
|
| 85 |
+
if ftype != "tag" or len(motif) % 3:
|
| 86 |
+
continue
|
| 87 |
+
pep = _edits.translate(motif)
|
| 88 |
+
if not pep or "X" in pep or "*" in pep:
|
| 89 |
+
continue
|
| 90 |
+
if pep in seen:
|
| 91 |
+
continue
|
| 92 |
+
seen.add(pep)
|
| 93 |
+
out.append({"name": name, "peptide": pep})
|
| 94 |
+
return out
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
_TAGS = _tag_peptides()
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def _clean_residues(text: str) -> str:
|
| 101 |
+
"""Strip FASTA headers, digits and whitespace; keep letters and stops."""
|
| 102 |
+
body = re.sub(r"^\s*>[^\n]*$", "", text or "", flags=re.M)
|
| 103 |
+
return re.sub(r"[^A-Za-z*]", "", body).upper()
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def _read_paste(text: str) -> Dict[str, Any]:
|
| 107 |
+
"""Work out what SHAPE the paste is before worrying what it contains."""
|
| 108 |
+
raw = (text or "").strip()
|
| 109 |
+
if not raw:
|
| 110 |
+
return {"format": "empty", "header": None, "residues": ""}
|
| 111 |
+
if _GENBANK.search(raw):
|
| 112 |
+
return {"format": "genbank", "header": None,
|
| 113 |
+
"residues": _clean_residues(raw)}
|
| 114 |
+
m = _FASTA_HEADER.search(raw)
|
| 115 |
+
if m:
|
| 116 |
+
return {"format": "fasta", "header": m.group(1).strip()[:200],
|
| 117 |
+
"residues": _clean_residues(raw)}
|
| 118 |
+
# A bare token with no residue-ish bulk may be an identifier, not a sequence.
|
| 119 |
+
token = raw.split()[0] if raw.split() else ""
|
| 120 |
+
if len(raw.split()) == 1 and _acc.looks_like_accession(token):
|
| 121 |
+
return {"format": "accession", "header": None, "residues": "",
|
| 122 |
+
"token": token}
|
| 123 |
+
return {"format": "raw", "header": None, "residues": _clean_residues(raw)}
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def _header_accession(header: str) -> Optional[Dict[str, str]]:
|
| 127 |
+
"""An accession quoted in a FASTA header is a CLAIM, not evidence."""
|
| 128 |
+
for token in re.split(r"[|\s,;]+", header or ""):
|
| 129 |
+
token = token.strip()
|
| 130 |
+
if len(token) >= 4 and _acc.looks_like_accession(token):
|
| 131 |
+
family, molecule = _acc.classify(token)
|
| 132 |
+
return {"accession": token, "family": family, "molecule": molecule}
|
| 133 |
+
return None
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def _vector_junctions(dna: str) -> List[Dict[str, Any]]:
|
| 137 |
+
"""Catalogue backbones whose cloning junction appears in this sequence.
|
| 138 |
+
|
| 139 |
+
Hits are grouped by end, because a junction identifies a FAMILY, not a
|
| 140 |
+
vector: the whole pET series shares one C-terminal His junction, and the
|
| 141 |
+
catalogue happens to record it at different lengths for different entries.
|
| 142 |
+
A longer recorded junction is a longer catalogue string, not better
|
| 143 |
+
evidence β naming the longest match alone would invent a distinction the
|
| 144 |
+
sequence does not support.
|
| 145 |
+
"""
|
| 146 |
+
found: Dict[str, List[Dict[str, Any]]] = {}
|
| 147 |
+
try:
|
| 148 |
+
cat = _vectors._load()
|
| 149 |
+
except Exception: # pragma: no cover
|
| 150 |
+
return []
|
| 151 |
+
up = dna.upper()
|
| 152 |
+
for rec in cat.values():
|
| 153 |
+
for field in ("flanking_5p_max", "flanking_3p_max"):
|
| 154 |
+
junction = (rec.get(field) or "").upper()
|
| 155 |
+
if len(junction) < MIN_JUNCTION:
|
| 156 |
+
continue
|
| 157 |
+
for strand, needle in ((1, junction), (-1, _plasmid.revcomp(junction))):
|
| 158 |
+
idx = up.find(needle)
|
| 159 |
+
if idx < 0:
|
| 160 |
+
continue
|
| 161 |
+
end = "5'" if field.startswith("flanking_5p") else "3'"
|
| 162 |
+
found.setdefault(end, []).append({
|
| 163 |
+
"vector": rec.get("name") or rec.get("id"),
|
| 164 |
+
"start": idx, "strand": strand, "matched_bp": len(junction),
|
| 165 |
+
})
|
| 166 |
+
break
|
| 167 |
+
|
| 168 |
+
out: List[Dict[str, Any]] = []
|
| 169 |
+
for end, hits in sorted(found.items()):
|
| 170 |
+
hits.sort(key=lambda h: (-h["matched_bp"], str(h["vector"])))
|
| 171 |
+
names = [h["vector"] for h in hits]
|
| 172 |
+
out.append({
|
| 173 |
+
"end": end,
|
| 174 |
+
"vectors": names,
|
| 175 |
+
"start": min(h["start"] for h in hits),
|
| 176 |
+
"longest_match_bp": max(h["matched_bp"] for h in hits),
|
| 177 |
+
"distinguishing": len(names) == 1,
|
| 178 |
+
"note": None if len(names) == 1 else (
|
| 179 |
+
f"{len(names)} catalogue backbones share this {end} junction. "
|
| 180 |
+
f"The match says which FAMILY the construct came from, not "
|
| 181 |
+
f"which vector β do not pick one."),
|
| 182 |
+
})
|
| 183 |
+
return out
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
def _analyse_dna(dna: str) -> Dict[str, Any]:
|
| 187 |
+
ambiguous = sum(1 for c in dna if c not in "ACGT")
|
| 188 |
+
out: Dict[str, Any] = {
|
| 189 |
+
"gc_percent": _plasmid.gc_percent(dna),
|
| 190 |
+
"ambiguity_codes": ambiguous,
|
| 191 |
+
"multiple_of_three": len(dna) % 3 == 0,
|
| 192 |
+
"is_clean_cds": _edits.is_coding(dna),
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
orfs = _plasmid.find_orfs(dna, min_aa=MIN_ORF_AA, max_orfs=200)
|
| 196 |
+
if orfs:
|
| 197 |
+
longest = max(orfs, key=lambda o: o["end"] - o["start"])
|
| 198 |
+
span = longest["end"] - longest["start"]
|
| 199 |
+
out["orfs"] = len(orfs)
|
| 200 |
+
out["longest_orf"] = {
|
| 201 |
+
"aa": span // 3 - 1, # the terminal stop is not a residue
|
| 202 |
+
"start": longest["start"], "end": longest["end"],
|
| 203 |
+
"strand": longest["strand"],
|
| 204 |
+
"covers_percent": round(100.0 * span / len(dna), 1),
|
| 205 |
+
}
|
| 206 |
+
else:
|
| 207 |
+
out["orfs"] = 0
|
| 208 |
+
out["longest_orf"] = None
|
| 209 |
+
|
| 210 |
+
out["features"] = [
|
| 211 |
+
{"name": f["name"], "type": f["type"], "start": f["start"],
|
| 212 |
+
"end": f["end"], "strand": f["strand"]}
|
| 213 |
+
for f in _plasmid.find_motifs(dna)
|
| 214 |
+
]
|
| 215 |
+
out["vector_junctions"] = _vector_junctions(dna)
|
| 216 |
+
return out
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
def _analyse_protein(protein: str) -> Dict[str, Any]:
|
| 220 |
+
body = protein.rstrip("*")
|
| 221 |
+
distinct = len(set(body))
|
| 222 |
+
out: Dict[str, Any] = {
|
| 223 |
+
"starts_with_methionine": body.startswith("M"),
|
| 224 |
+
"internal_stop": "*" in body,
|
| 225 |
+
"unknown_residues": body.count("X"),
|
| 226 |
+
"distinct_residues": distinct,
|
| 227 |
+
}
|
| 228 |
+
out["tags"] = [
|
| 229 |
+
{"name": t["name"], "peptide": t["peptide"], "start": body.find(t["peptide"])}
|
| 230 |
+
for t in _TAGS if t["peptide"] in body
|
| 231 |
+
]
|
| 232 |
+
# A real protein uses most of the alphabet. Very low diversity means either
|
| 233 |
+
# a synthetic construct (poly-A linker, His run) or DNA pasted by mistake.
|
| 234 |
+
out["low_complexity"] = (len(body) >= LOW_COMPLEXITY_MIN_LEN
|
| 235 |
+
and distinct < LOW_COMPLEXITY_BELOW)
|
| 236 |
+
return out
|
| 237 |
+
|
| 238 |
+
|
| 239 |
+
def _summary(molecule: str, length: int, detail: Dict[str, Any]) -> str:
|
| 240 |
+
"""One line a bench scientist would actually say out loud."""
|
| 241 |
+
if molecule == "protein":
|
| 242 |
+
bits = [f"{length} aa protein"]
|
| 243 |
+
if detail.get("tags"):
|
| 244 |
+
bits.append(", ".join(t["name"] for t in detail["tags"]))
|
| 245 |
+
# Worth surfacing unprompted: a premature stop means the translation
|
| 246 |
+
# is truncated, and every downstream number would be about a protein
|
| 247 |
+
# the cell never makes.
|
| 248 |
+
if detail.get("internal_stop"):
|
| 249 |
+
bits.append("contains an internal stop β this translation is truncated")
|
| 250 |
+
if detail.get("unknown_residues"):
|
| 251 |
+
bits.append(f"{detail['unknown_residues']} unknown (X) residues")
|
| 252 |
+
if detail.get("low_complexity"):
|
| 253 |
+
bits.append("unusually low residue diversity")
|
| 254 |
+
return " Β· ".join(bits)
|
| 255 |
+
unit = "nt RNA" if molecule == "rna" else "bp DNA"
|
| 256 |
+
bits = [f"{length} {unit}", f"{detail.get('gc_percent')}% GC"]
|
| 257 |
+
if detail.get("is_clean_cds"):
|
| 258 |
+
bits.append("a clean CDS end to end")
|
| 259 |
+
elif detail.get("longest_orf"):
|
| 260 |
+
orf = detail["longest_orf"]
|
| 261 |
+
bits.append(f"longest ORF {orf['aa']} aa ({orf['covers_percent']}% of it)")
|
| 262 |
+
else:
|
| 263 |
+
bits.append("no ORF of 30 aa or more")
|
| 264 |
+
named = sorted({f["name"] for f in detail.get("features") or []})
|
| 265 |
+
if named:
|
| 266 |
+
bits.append(", ".join(named[:4]) + ("β¦" if len(named) > 4 else ""))
|
| 267 |
+
for j in (detail.get("vector_junctions") or [])[:1]:
|
| 268 |
+
names = j["vectors"]
|
| 269 |
+
bits.append(f"{j['end']} junction matches {names[0]}" if j["distinguishing"]
|
| 270 |
+
else (f"{j['end']} junction shared by {len(names)} catalogue "
|
| 271 |
+
f"backbones ({names[0]}, {names[1]}β¦)"))
|
| 272 |
+
return " Β· ".join(bits)
|
| 273 |
+
|
| 274 |
+
|
| 275 |
+
def identify(text: str) -> Dict[str, Any]:
|
| 276 |
+
"""Characterize a pasted sequence locally. Never claims to have named it."""
|
| 277 |
+
paste = _read_paste(text)
|
| 278 |
+
|
| 279 |
+
if paste["format"] == "empty":
|
| 280 |
+
return {"ok": False, "kind": "empty", "error": "Nothing to identify.",
|
| 281 |
+
"next": "Paste a sequence, or an accession to fetch one."}
|
| 282 |
+
|
| 283 |
+
if paste["format"] == "accession":
|
| 284 |
+
token = paste["token"]
|
| 285 |
+
family, molecule = _acc.classify(token)
|
| 286 |
+
return {
|
| 287 |
+
"ok": True, "kind": "accession", "identified": False,
|
| 288 |
+
"input_format": "accession", "accession": token,
|
| 289 |
+
"family": family, "molecule": molecule,
|
| 290 |
+
"summary": (f"'{token}' is a {family} identifier"
|
| 291 |
+
+ (f" for {molecule}" if molecule else "")
|
| 292 |
+
+ ", not a sequence."),
|
| 293 |
+
"next": (f"Call fetch_sequence with '{token}' to retrieve it. "
|
| 294 |
+
f"An accession names the sequence already β there is "
|
| 295 |
+
f"nothing to identify."),
|
| 296 |
+
}
|
| 297 |
+
|
| 298 |
+
residues = paste["residues"]
|
| 299 |
+
if len(residues) < MIN_LEN:
|
| 300 |
+
return {"ok": False, "kind": "too_short",
|
| 301 |
+
"error": f"Only {len(residues)} residues; {MIN_LEN} is the "
|
| 302 |
+
f"minimum worth analysing.",
|
| 303 |
+
"next": "Short oligos are better handled by design_primers."}
|
| 304 |
+
|
| 305 |
+
molecule = _seq.classify_sequence(residues)
|
| 306 |
+
if molecule == "dna" and "U" in residues and "T" not in residues:
|
| 307 |
+
molecule = "rna"
|
| 308 |
+
|
| 309 |
+
if molecule == "ambiguous":
|
| 310 |
+
return {
|
| 311 |
+
"ok": True, "kind": "ambiguous", "identified": False,
|
| 312 |
+
"input_format": paste["format"], "molecule": "ambiguous",
|
| 313 |
+
"length": len(residues),
|
| 314 |
+
"summary": (f"{len(residues)} residues of A/C/G/T/U/N only β at "
|
| 315 |
+
f"this length that reads equally well as a short "
|
| 316 |
+
f"peptide or a DNA fragment."),
|
| 317 |
+
"next": ("Ask the user which it is before analysing further. "
|
| 318 |
+
"Guessing here silently misroutes the whole job."),
|
| 319 |
+
}
|
| 320 |
+
|
| 321 |
+
detail = (_analyse_protein(residues) if molecule == "protein"
|
| 322 |
+
else _analyse_dna(residues))
|
| 323 |
+
|
| 324 |
+
claim = _header_accession(paste["header"]) if paste.get("header") else None
|
| 325 |
+
result: Dict[str, Any] = {
|
| 326 |
+
"ok": True,
|
| 327 |
+
"kind": "characterized",
|
| 328 |
+
"input_format": paste["format"],
|
| 329 |
+
"molecule": molecule,
|
| 330 |
+
"length": len(residues),
|
| 331 |
+
"summary": _summary(molecule, len(residues), detail),
|
| 332 |
+
"detail": detail,
|
| 333 |
+
# The whole point. Local analysis describes; it does not name.
|
| 334 |
+
"identified": False,
|
| 335 |
+
"to_identify_it": (
|
| 336 |
+
"Nothing above names this sequence β it is computed from the "
|
| 337 |
+
"residues against local catalogues. To find out WHAT it is, call "
|
| 338 |
+
"blast_sequence, which searches NCBI. That sends the sequence "
|
| 339 |
+
"outside this Space and the user is asked to approve it first."),
|
| 340 |
+
"caveat": (
|
| 341 |
+
"Motif and junction matches are exact string matches against a "
|
| 342 |
+
"curated list, so a hit is real but an absence means only 'not in "
|
| 343 |
+
"the list' β the catalogue is small and deliberately conservative."),
|
| 344 |
+
}
|
| 345 |
+
|
| 346 |
+
if paste.get("header"):
|
| 347 |
+
result["header_claim"] = {
|
| 348 |
+
"text": paste["header"],
|
| 349 |
+
"accession": claim,
|
| 350 |
+
"note": ("This came from the FASTA header, which is whatever the "
|
| 351 |
+
"person who wrote the file typed. Treat it as a claim to "
|
| 352 |
+
"check, not as evidence β fetch the accession and compare "
|
| 353 |
+
"if it matters."),
|
| 354 |
+
}
|
| 355 |
+
return result
|
dee/core/mutagenesis.py
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Primers that actually make the mutation the engine just designed.
|
| 2 |
+
|
| 3 |
+
THE GAP THIS CLOSES
|
| 4 |
+
-------------------
|
| 5 |
+
The engine will tell you R175H is the substitution worth trying. Then it
|
| 6 |
+
stops. Getting from "R175H is a good idea" to DNA in a tube means designing a
|
| 7 |
+
mutagenesis primer pair by hand β picking the codon, centring the mismatch,
|
| 8 |
+
balancing the flanks so both arms melt at the same temperature. That is the
|
| 9 |
+
most common wet-lab follow-through from a designed point mutation, and until
|
| 10 |
+
now the product had nothing to say about it.
|
| 11 |
+
|
| 12 |
+
It is also pure arithmetic on a sequence the user already has. Nothing here
|
| 13 |
+
needs a network, an account, or a model.
|
| 14 |
+
|
| 15 |
+
DESIGN DECISIONS THAT MATTER AT THE BENCH
|
| 16 |
+
-----------------------------------------
|
| 17 |
+
*The mismatch sits in the middle.* A mutation near a primer's end anneals
|
| 18 |
+
poorly and the reaction fails; the standard rule is 10-15 matching bases on
|
| 19 |
+
BOTH sides, so the arms are grown outward from the change rather than the
|
| 20 |
+
primer being cut from a fixed window.
|
| 21 |
+
|
| 22 |
+
*The two arms are balanced by Tm, not by length.* Flanks of equal length can
|
| 23 |
+
differ by 8 C when one is GC-rich, and the cooler arm is where the reaction
|
| 24 |
+
fails. Each side is extended until it reaches the target Tm.
|
| 25 |
+
|
| 26 |
+
*The codon is chosen for the host, not at random.* Six codons encode leucine
|
| 27 |
+
and they are not interchangeable in E. coli. The existing codon table decides,
|
| 28 |
+
and the choice is reported so it can be checked.
|
| 29 |
+
|
| 30 |
+
*Both protocols are supported and they are not the same shape.* QuikChange-
|
| 31 |
+
style uses fully overlapping complementary primers; the back-to-back style
|
| 32 |
+
(Q5/KLD) uses non-overlapping primers that amplify the whole plasmid outward.
|
| 33 |
+
Which one a lab uses is a kit decision, so the caller says.
|
| 34 |
+
"""
|
| 35 |
+
from __future__ import annotations
|
| 36 |
+
|
| 37 |
+
import re
|
| 38 |
+
from typing import Any, Dict, List, Optional
|
| 39 |
+
|
| 40 |
+
from dee.core.primers import gc_clamp, gc_percent, hairpin_score, revcomp
|
| 41 |
+
from dee.core.primers import self_dimer_3p, tm_c
|
| 42 |
+
|
| 43 |
+
# Standard practice for site-directed mutagenesis: enough matching sequence on
|
| 44 |
+
# each side of the mismatch that the primer still anneals despite it.
|
| 45 |
+
MIN_FLANK = 10
|
| 46 |
+
MAX_FLANK = 25
|
| 47 |
+
TARGET_TM = 60.0 # C; both arms are grown toward this
|
| 48 |
+
TM_TOLERANCE = 3.0
|
| 49 |
+
|
| 50 |
+
_LABEL = re.compile(r"^([A-Z])(\d+)([A-Z*])$", re.I)
|
| 51 |
+
_CODON_STOP = {"TAA", "TAG", "TGA"}
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def parse_substitution(label: str) -> Optional[Dict[str, Any]]:
|
| 55 |
+
"""'R175H' -> {'wt':'R','pos':175,'mut':'H'}."""
|
| 56 |
+
m = _LABEL.match((label or "").strip())
|
| 57 |
+
if not m:
|
| 58 |
+
return None
|
| 59 |
+
return {"wt": m.group(1).upper(), "pos": int(m.group(2)),
|
| 60 |
+
"mut": m.group(3).upper()}
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def _codon_for(aa: str, host: str) -> Optional[str]:
|
| 64 |
+
"""The host's preferred codon for this residue.
|
| 65 |
+
|
| 66 |
+
Delegates to the existing codon table rather than carrying a second one β
|
| 67 |
+
two tables that disagree is a bug nobody finds for months.
|
| 68 |
+
"""
|
| 69 |
+
if aa == "*":
|
| 70 |
+
return "TAA"
|
| 71 |
+
try:
|
| 72 |
+
from dee.core import codon as _codon
|
| 73 |
+
# _resolve_table raises on an unknown host, which is the honest
|
| 74 |
+
# answer β a silent fallback to E. coli would codon-optimise a yeast
|
| 75 |
+
# construct for the wrong organism and say nothing.
|
| 76 |
+
return _codon._best_codon(aa, _codon._resolve_table(host)).upper()
|
| 77 |
+
except Exception: # noqa: BLE001
|
| 78 |
+
return None
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def _grow(template: str, centre: int, span: int, target_tm: float) -> tuple:
|
| 82 |
+
"""Extend outward from a change until the arm reaches `target_tm`.
|
| 83 |
+
|
| 84 |
+
Returns (start, end) into `template`. Length is the OUTPUT of the melting
|
| 85 |
+
temperature, not an input β flanks of equal length routinely differ by
|
| 86 |
+
several degrees, and the cooler arm is the one that fails.
|
| 87 |
+
"""
|
| 88 |
+
start, end = centre, centre + span
|
| 89 |
+
while True:
|
| 90 |
+
left = centre - start
|
| 91 |
+
right = end - (centre + span)
|
| 92 |
+
seq = template[start:end]
|
| 93 |
+
if left >= MIN_FLANK and right >= MIN_FLANK:
|
| 94 |
+
t = tm_c(seq)
|
| 95 |
+
if t is not None and t >= target_tm:
|
| 96 |
+
break
|
| 97 |
+
if left >= MAX_FLANK and right >= MAX_FLANK:
|
| 98 |
+
break
|
| 99 |
+
# Grow the shorter side first so the mismatch stays centred.
|
| 100 |
+
if start > 0 and (left <= right or end >= len(template)):
|
| 101 |
+
start -= 1
|
| 102 |
+
elif end < len(template):
|
| 103 |
+
end += 1
|
| 104 |
+
else:
|
| 105 |
+
break
|
| 106 |
+
return (start, end)
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def design(cds: str, label: str, *, host: str = "e_coli",
|
| 110 |
+
method: str = "quikchange") -> Dict[str, Any]:
|
| 111 |
+
"""Primers that install `label` into `cds`.
|
| 112 |
+
|
| 113 |
+
`cds` must start at the A of the initiator ATG β positions in a
|
| 114 |
+
substitution label are residue numbers, and they only mean anything
|
| 115 |
+
against a real reading frame.
|
| 116 |
+
"""
|
| 117 |
+
seq = re.sub(r"[^ACGTacgt]", "", cds or "").upper()
|
| 118 |
+
sub = parse_substitution(label)
|
| 119 |
+
if not sub:
|
| 120 |
+
return {"ok": False, "error": f"Couldn't read '{label}'. Use e.g. R175H."}
|
| 121 |
+
if method not in ("quikchange", "back_to_back"):
|
| 122 |
+
return {"ok": False,
|
| 123 |
+
"error": "method must be 'quikchange' (overlapping primers) or "
|
| 124 |
+
"'back_to_back' (Q5/KLD, non-overlapping)"}
|
| 125 |
+
if len(seq) < 6:
|
| 126 |
+
return {"ok": False, "error": "No coding sequence given."}
|
| 127 |
+
|
| 128 |
+
i = (sub["pos"] - 1) * 3
|
| 129 |
+
if i + 3 > len(seq):
|
| 130 |
+
return {"ok": False, "kind": "out_of_range",
|
| 131 |
+
"error": f"Position {sub['pos']} is past the end of a "
|
| 132 |
+
f"{len(seq) // 3}-residue sequence.",
|
| 133 |
+
"next": "Check the sequence starts at the initiator ATG."}
|
| 134 |
+
|
| 135 |
+
wt_codon = seq[i:i + 3]
|
| 136 |
+
# Never take the label's word for the wild-type residue. Same rule the
|
| 137 |
+
# edit tool follows: a mismatch here means the user is holding a different
|
| 138 |
+
# sequence than they think, and silently proceeding designs primers for a
|
| 139 |
+
# mutation nobody asked for.
|
| 140 |
+
try:
|
| 141 |
+
from dee.core.edits import translate as _translate
|
| 142 |
+
observed = _translate(wt_codon)
|
| 143 |
+
except Exception: # noqa: BLE001
|
| 144 |
+
observed = None
|
| 145 |
+
if observed and observed.upper() != sub["wt"]:
|
| 146 |
+
return {"ok": False, "kind": "wt_mismatch",
|
| 147 |
+
"error": f"Position {sub['pos']} is {observed}, not "
|
| 148 |
+
f"{sub['wt']} β the sequence and the label disagree.",
|
| 149 |
+
"next": "Check the numbering, or the sequence's reading frame."}
|
| 150 |
+
|
| 151 |
+
new_codon = _codon_for(sub["mut"], host)
|
| 152 |
+
if not new_codon:
|
| 153 |
+
return {"ok": False, "kind": "no_codon",
|
| 154 |
+
"error": f"No codon table entry for {sub['mut']} in '{host}'.",
|
| 155 |
+
"next": "Try another host, or supply the codon explicitly."}
|
| 156 |
+
|
| 157 |
+
mutated = seq[:i] + new_codon + seq[i + 3:]
|
| 158 |
+
|
| 159 |
+
if method == "quikchange":
|
| 160 |
+
s, e = _grow(mutated, i, 3, TARGET_TM)
|
| 161 |
+
fwd = mutated[s:e]
|
| 162 |
+
rev = revcomp(fwd) # fully complementary, by design
|
| 163 |
+
pair = [("forward", fwd, s, e), ("reverse", rev, s, e)]
|
| 164 |
+
overlap = len(fwd)
|
| 165 |
+
else:
|
| 166 |
+
# Back-to-back: the change rides on the 5' end of the forward primer
|
| 167 |
+
# and the two primers amplify the whole plasmid outward from it.
|
| 168 |
+
s, e = _grow(mutated, i, 3, TARGET_TM)
|
| 169 |
+
fwd = mutated[i:e]
|
| 170 |
+
rev = revcomp(mutated[s:i])
|
| 171 |
+
pair = [("forward", fwd, i, e), ("reverse", rev, s, i)]
|
| 172 |
+
overlap = 0
|
| 173 |
+
|
| 174 |
+
primers = []
|
| 175 |
+
for role, p, a, b in pair:
|
| 176 |
+
primers.append({
|
| 177 |
+
"role": role, "sequence": p, "length": len(p),
|
| 178 |
+
"tm_c": tm_c(p), "gc_pct": round(gc_percent(p), 1),
|
| 179 |
+
"gc_clamp": gc_clamp(p),
|
| 180 |
+
"hairpin": hairpin_score(p), "self_dimer_3p": self_dimer_3p(p),
|
| 181 |
+
"template_start": a, "template_end": b,
|
| 182 |
+
})
|
| 183 |
+
|
| 184 |
+
warnings = []
|
| 185 |
+
tms = [p["tm_c"] for p in primers if p["tm_c"] is not None]
|
| 186 |
+
if len(tms) == 2 and abs(tms[0] - tms[1]) > TM_TOLERANCE:
|
| 187 |
+
warnings.append(
|
| 188 |
+
f"The two primers differ by {abs(tms[0] - tms[1]):.1f} C. The "
|
| 189 |
+
f"cooler one is where the reaction fails β consider extending it.")
|
| 190 |
+
for p in primers:
|
| 191 |
+
if p["hairpin"] >= 4:
|
| 192 |
+
warnings.append(f"{p['role']} primer can form a hairpin "
|
| 193 |
+
f"(stem {p['hairpin']}).")
|
| 194 |
+
if p["self_dimer_3p"] >= 3:
|
| 195 |
+
warnings.append(f"{p['role']} primer self-dimerises at its 3' end.")
|
| 196 |
+
if not p["gc_clamp"]:
|
| 197 |
+
warnings.append(f"{p['role']} primer has no G/C at its 3' end.")
|
| 198 |
+
|
| 199 |
+
return {
|
| 200 |
+
"ok": True,
|
| 201 |
+
"substitution": label.upper(),
|
| 202 |
+
"method": method,
|
| 203 |
+
"host": host,
|
| 204 |
+
"wt_codon": wt_codon,
|
| 205 |
+
"new_codon": new_codon,
|
| 206 |
+
# Reported so the codon choice can be checked rather than trusted.
|
| 207 |
+
"codon_note": f"{sub['wt']}({wt_codon}) β {sub['mut']}({new_codon}), "
|
| 208 |
+
f"preferred codon for {host}",
|
| 209 |
+
"primers": primers,
|
| 210 |
+
"overlap_bp": overlap,
|
| 211 |
+
"warnings": warnings,
|
| 212 |
+
"mutated_cds": mutated,
|
| 213 |
+
"protocol": (
|
| 214 |
+
"QuikChange-style: fully overlapping complementary primers, "
|
| 215 |
+
"whole-plasmid PCR, then DpnI to remove methylated template."
|
| 216 |
+
if method == "quikchange" else
|
| 217 |
+
"Back-to-back (Q5/KLD-style): non-overlapping primers amplify the "
|
| 218 |
+
"plasmid outward; the product is then kinased, ligated and "
|
| 219 |
+
"DpnI-treated."),
|
| 220 |
+
"caveat": ("Melting temperatures are calculated, not measured, and "
|
| 221 |
+
"assume standard PCR salt conditions. Verify the construct "
|
| 222 |
+
"by sequencing β these primers propose a change, they do "
|
| 223 |
+
"not confirm one."),
|
| 224 |
+
}
|
dee/core/orchestrator.py
CHANGED
|
@@ -368,6 +368,45 @@ def _summarize(name: str, result: Dict[str, Any]) -> str:
|
|
| 368 |
return (f"{n} hit{'' if n == 1 else 's'} Β· top "
|
| 369 |
f"{top.get('identity_pct')}% id"
|
| 370 |
+ (f" Β· {top.get('organism')}" if top.get("organism") else ""))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 371 |
if name == "compare_sequences":
|
| 372 |
if result.get("identical"):
|
| 373 |
return f"identical Β· {result.get('alignment_length'):,} positions"
|
|
|
|
| 368 |
return (f"{n} hit{'' if n == 1 else 's'} Β· top "
|
| 369 |
f"{top.get('identity_pct')}% id"
|
| 370 |
+ (f" Β· {top.get('organism')}" if top.get("organism") else ""))
|
| 371 |
+
if name == "check_conservation":
|
| 372 |
+
if result.get("rows") is not None: # alignment_only
|
| 373 |
+
return f"{result.get('sequences')} seqs Β· {result.get('columns')} columns"
|
| 374 |
+
inv, n = result.get("invariant_count") or 0, result.get("sequences") or 0
|
| 375 |
+
base = f"{n} homologs Β· {inv} invariant position{'' if inv == 1 else 's'}"
|
| 376 |
+
# An untrustworthy set is the headline, not a footnote: every
|
| 377 |
+
# column looks conserved when the homologs are near-identical.
|
| 378 |
+
return base + ("" if result.get("trustworthy")
|
| 379 |
+
else f" Β· {result.get('mean_identity')}% identical, too redundant to trust")
|
| 380 |
+
if name == "check_residues":
|
| 381 |
+
if result.get("positions") is not None:
|
| 382 |
+
n = result.get("flagged_count") or 0
|
| 383 |
+
tot = len(result.get("positions") or [])
|
| 384 |
+
# Lead with the veto: "3 positions checked" buries the one
|
| 385 |
+
# that sits on the catalytic residue.
|
| 386 |
+
return (f"{n} of {tot} on functional residues" if n
|
| 387 |
+
else f"{tot} positions Β· none annotated")
|
| 388 |
+
return (f"{result.get('critical_count')} functional features Β· "
|
| 389 |
+
f"{result.get('protein', '')[:34]}")
|
| 390 |
+
if name == "check_overhangs":
|
| 391 |
+
if result.get("min_distance") is not None: # a suggest() result
|
| 392 |
+
return f"{result.get('count')} conflict-free overhangs"
|
| 393 |
+
f = result.get("fatal") or 0
|
| 394 |
+
n = len(result.get("conflicts") or [])
|
| 395 |
+
if not n:
|
| 396 |
+
return f"{result.get('count')} overhangs Β· no conflicts"
|
| 397 |
+
# Fatal first: "6 conflicts" reads the same whether the assembly
|
| 398 |
+
# is merely weak or cannot have a defined order at all.
|
| 399 |
+
return (f"{result.get('count')} overhangs Β· {n} conflict"
|
| 400 |
+
f"{'' if n == 1 else 's'}"
|
| 401 |
+
+ (f", {f} fatal" if f else ""))
|
| 402 |
+
if name == "design_mutagenesis_primers":
|
| 403 |
+
ps = result.get("primers") or []
|
| 404 |
+
tms = [p.get("tm_c") for p in ps if p.get("tm_c") is not None]
|
| 405 |
+
base = (f"{result.get('substitution')} Β· {len(ps)} primers"
|
| 406 |
+
+ (f" Β· Tm {min(tms):.0f}-{max(tms):.0f} C" if tms else ""))
|
| 407 |
+
# Warnings are why someone re-reads the design before ordering.
|
| 408 |
+
n = len(result.get("warnings") or [])
|
| 409 |
+
return base + (f" Β· {n} warning{'' if n == 1 else 's'}" if n else "")
|
| 410 |
if name == "compare_sequences":
|
| 411 |
if result.get("identical"):
|
| 412 |
return f"identical Β· {result.get('alignment_length'):,} positions"
|
dee/core/overhangs.py
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Why a Golden Gate assembly with six correct fragments still comes back wrong.
|
| 2 |
+
|
| 3 |
+
THE FAILURE THIS CATCHES
|
| 4 |
+
------------------------
|
| 5 |
+
`cloning.py` simulates the assembly: it finds the Type IIS sites, cuts, and
|
| 6 |
+
chains parts whose overhangs match into a circle. If the overhangs chain, it
|
| 7 |
+
reports success β and it is right, as a simulation.
|
| 8 |
+
|
| 9 |
+
What it cannot see is that two of those overhangs are one base apart, or that
|
| 10 |
+
one is its own reverse complement. In the tube, T4 ligase does not care which
|
| 11 |
+
partner it found; near-identical overhangs cross-ligate and the plate comes
|
| 12 |
+
back with fragments in the wrong order or dropped entirely. The simulation
|
| 13 |
+
says the design works. The bench says otherwise, a week later.
|
| 14 |
+
|
| 15 |
+
Every one of those conflicts is decidable from the four-base sequences alone,
|
| 16 |
+
before anything is ordered.
|
| 17 |
+
|
| 18 |
+
WHAT THIS DOES AND DOES NOT CLAIM
|
| 19 |
+
---------------------------------
|
| 20 |
+
It reports STRUCTURAL conflicts β facts about the sequences:
|
| 21 |
+
|
| 22 |
+
duplicate the same overhang used twice; the assembly cannot have
|
| 23 |
+
a defined order
|
| 24 |
+
palindromic an overhang that is its own reverse complement, so a
|
| 25 |
+
fragment ligates to itself and to its own inverse
|
| 26 |
+
complementary one overhang is the reverse complement of another, so
|
| 27 |
+
the two junctions are interchangeable
|
| 28 |
+
near-identical overhangs differing by a single base, the classic
|
| 29 |
+
source of low-frequency misassembly
|
| 30 |
+
low complexity all-AT ligates weakly, all-GC ligates promiscuously
|
| 31 |
+
|
| 32 |
+
It does NOT report a "fidelity percentage". Those numbers come from published
|
| 33 |
+
empirical ligation datasets measured on real reactions; this engine has none
|
| 34 |
+
of that data, and a percentage computed from sequence alone would be a
|
| 35 |
+
fabricated measurement wearing a decimal point. What is returned is what can
|
| 36 |
+
be checked by eye β which is also what lets a user fix it.
|
| 37 |
+
"""
|
| 38 |
+
from __future__ import annotations
|
| 39 |
+
|
| 40 |
+
import re
|
| 41 |
+
from typing import Any, Dict, Iterable, List, Optional
|
| 42 |
+
|
| 43 |
+
# Type IIS enzymes in common use leave a 4-nt 5' overhang. The length is a
|
| 44 |
+
# property of the enzyme, so it is a parameter rather than a constant.
|
| 45 |
+
DEFAULT_LEN = 4
|
| 46 |
+
_COMP = str.maketrans("ACGT", "TGCA")
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def revcomp(s: str) -> str:
|
| 50 |
+
return s.translate(_COMP)[::-1]
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def _clean(s: str) -> str:
|
| 54 |
+
return re.sub(r"[^ACGTacgt]", "", s or "").upper()
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def is_palindrome(oh: str) -> bool:
|
| 58 |
+
"""Self-complementary β ligates to itself AND to its own inverse.
|
| 59 |
+
|
| 60 |
+
An even-length overhang can be its own reverse complement (AATT, GGCC).
|
| 61 |
+
A junction built on one has no defined orientation.
|
| 62 |
+
"""
|
| 63 |
+
return bool(oh) and oh == revcomp(oh)
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def hamming(a: str, b: str) -> int:
|
| 67 |
+
"""Substitutions between two equal-length overhangs. -1 if lengths differ."""
|
| 68 |
+
if len(a) != len(b):
|
| 69 |
+
return -1
|
| 70 |
+
return sum(1 for x, y in zip(a, b) if x != y)
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def gc_fraction(oh: str) -> float:
|
| 74 |
+
return (oh.count("G") + oh.count("C")) / len(oh) if oh else 0.0
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def check(overhangs: Iterable[str], *, length: int = DEFAULT_LEN,
|
| 78 |
+
min_distance: int = 2) -> Dict[str, Any]:
|
| 79 |
+
"""Every structural conflict in a proposed overhang set.
|
| 80 |
+
|
| 81 |
+
`min_distance` is the Hamming distance below which two overhangs are
|
| 82 |
+
reported as too similar. 2 is the working default: at distance 1 a single
|
| 83 |
+
mis-pairing event produces a wrong-but-ligatable junction.
|
| 84 |
+
"""
|
| 85 |
+
raw = list(overhangs or [])
|
| 86 |
+
ohs: List[str] = []
|
| 87 |
+
rejected: List[str] = []
|
| 88 |
+
for o in raw:
|
| 89 |
+
c = _clean(o)
|
| 90 |
+
if len(c) != length:
|
| 91 |
+
rejected.append(f"{o!r} is not {length} nt")
|
| 92 |
+
else:
|
| 93 |
+
ohs.append(c)
|
| 94 |
+
|
| 95 |
+
conflicts: List[Dict[str, Any]] = []
|
| 96 |
+
|
| 97 |
+
# 1. Duplicates β the assembly has no defined order at all.
|
| 98 |
+
seen: Dict[str, int] = {}
|
| 99 |
+
for idx, o in enumerate(ohs):
|
| 100 |
+
if o in seen:
|
| 101 |
+
conflicts.append({
|
| 102 |
+
"kind": "duplicate", "severity": "fatal",
|
| 103 |
+
"overhangs": [o], "positions": [seen[o], idx],
|
| 104 |
+
"why": f"{o} is used at two junctions, so the fragments "
|
| 105 |
+
f"between them can assemble in either order."})
|
| 106 |
+
else:
|
| 107 |
+
seen[o] = idx
|
| 108 |
+
|
| 109 |
+
# 2. Palindromes β a fragment that ligates to itself.
|
| 110 |
+
for idx, o in enumerate(ohs):
|
| 111 |
+
if is_palindrome(o):
|
| 112 |
+
conflicts.append({
|
| 113 |
+
"kind": "palindromic", "severity": "fatal",
|
| 114 |
+
"overhangs": [o], "positions": [idx],
|
| 115 |
+
"why": f"{o} is its own reverse complement, so the fragment "
|
| 116 |
+
f"can ligate to itself and to its own inversion."})
|
| 117 |
+
|
| 118 |
+
# 3 & 4. Pairwise: complementarity and near-identity, each checked against
|
| 119 |
+
# the partner AND the partner's reverse complement, because the strand a
|
| 120 |
+
# ligase sees depends on which fragment arrives.
|
| 121 |
+
for i in range(len(ohs)):
|
| 122 |
+
for j in range(i + 1, len(ohs)):
|
| 123 |
+
a, b = ohs[i], ohs[j]
|
| 124 |
+
rb = revcomp(b)
|
| 125 |
+
if a == rb:
|
| 126 |
+
conflicts.append({
|
| 127 |
+
"kind": "complementary", "severity": "fatal",
|
| 128 |
+
"overhangs": [a, b], "positions": [i, j],
|
| 129 |
+
"why": f"{a} is the reverse complement of {b}, so those "
|
| 130 |
+
f"two junctions are interchangeable."})
|
| 131 |
+
continue
|
| 132 |
+
d = min(hamming(a, b), hamming(a, rb))
|
| 133 |
+
if 0 < d < min_distance:
|
| 134 |
+
conflicts.append({
|
| 135 |
+
"kind": "near_identical", "severity": "high",
|
| 136 |
+
"overhangs": [a, b], "positions": [i, j], "distance": d,
|
| 137 |
+
"why": f"{a} and {b} differ by {d} base"
|
| 138 |
+
f"{'' if d == 1 else 's'}; a single mis-pairing "
|
| 139 |
+
f"gives a wrong junction that still ligates."})
|
| 140 |
+
|
| 141 |
+
# 5. Composition β weak or promiscuous ends.
|
| 142 |
+
for idx, o in enumerate(ohs):
|
| 143 |
+
gc = gc_fraction(o)
|
| 144 |
+
if gc == 0.0:
|
| 145 |
+
conflicts.append({
|
| 146 |
+
"kind": "low_complexity", "severity": "medium",
|
| 147 |
+
"overhangs": [o], "positions": [idx],
|
| 148 |
+
"why": f"{o} is all A/T β the weakest ligation in the set, and "
|
| 149 |
+
f"the junction most likely to be missing."})
|
| 150 |
+
elif gc == 1.0:
|
| 151 |
+
conflicts.append({
|
| 152 |
+
"kind": "low_complexity", "severity": "medium",
|
| 153 |
+
"overhangs": [o], "positions": [idx],
|
| 154 |
+
"why": f"{o} is all G/C, which ligates promiscuously."})
|
| 155 |
+
|
| 156 |
+
fatal = sum(1 for c in conflicts if c["severity"] == "fatal")
|
| 157 |
+
high = sum(1 for c in conflicts if c["severity"] == "high")
|
| 158 |
+
if not ohs:
|
| 159 |
+
verdict = "No usable overhangs given."
|
| 160 |
+
elif fatal:
|
| 161 |
+
verdict = (f"{fatal} conflict(s) that make the assembly ambiguous. "
|
| 162 |
+
f"This set will not build reliably.")
|
| 163 |
+
elif high:
|
| 164 |
+
verdict = f"{high} pair(s) close enough to misligate at low frequency."
|
| 165 |
+
elif conflicts:
|
| 166 |
+
verdict = "Usable, with weak junctions noted."
|
| 167 |
+
else:
|
| 168 |
+
verdict = f"No structural conflicts across {len(ohs)} overhangs."
|
| 169 |
+
|
| 170 |
+
return {
|
| 171 |
+
"ok": True,
|
| 172 |
+
"overhangs": ohs,
|
| 173 |
+
"count": len(ohs),
|
| 174 |
+
"rejected": rejected,
|
| 175 |
+
"conflicts": conflicts,
|
| 176 |
+
"fatal": fatal,
|
| 177 |
+
"usable": fatal == 0 and bool(ohs),
|
| 178 |
+
"verdict": verdict,
|
| 179 |
+
"basis": ("Structural conflicts computed from the sequences. NOT a "
|
| 180 |
+
"fidelity percentage β those come from published empirical "
|
| 181 |
+
"ligation datasets this engine does not have, and a number "
|
| 182 |
+
"derived from sequence alone would be invented."),
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
def suggest(count: int, *, length: int = DEFAULT_LEN, min_distance: int = 2,
|
| 187 |
+
avoid: Optional[Iterable[str]] = None) -> Dict[str, Any]:
|
| 188 |
+
"""A conflict-free set of `count` overhangs.
|
| 189 |
+
|
| 190 |
+
Greedy: walk the space in a fixed order and keep any candidate that
|
| 191 |
+
conflicts with nothing already chosen. Deterministic on purpose β the same
|
| 192 |
+
request gives the same set, so a design is reproducible and a methods
|
| 193 |
+
section can name it.
|
| 194 |
+
"""
|
| 195 |
+
if count < 1:
|
| 196 |
+
return {"ok": False, "error": "Need at least one overhang."}
|
| 197 |
+
blocked = {_clean(o) for o in (avoid or []) if _clean(o)}
|
| 198 |
+
chosen: List[str] = []
|
| 199 |
+
|
| 200 |
+
def fits(cand: str) -> bool:
|
| 201 |
+
if is_palindrome(cand):
|
| 202 |
+
return False
|
| 203 |
+
g = gc_fraction(cand)
|
| 204 |
+
if g in (0.0, 1.0):
|
| 205 |
+
return False
|
| 206 |
+
for o in list(chosen) + list(blocked):
|
| 207 |
+
if cand == o or cand == revcomp(o):
|
| 208 |
+
return False
|
| 209 |
+
if 0 < min(hamming(cand, o), hamming(cand, revcomp(o))) < min_distance:
|
| 210 |
+
return False
|
| 211 |
+
return True
|
| 212 |
+
|
| 213 |
+
from itertools import product
|
| 214 |
+
for combo in product("ACGT", repeat=length):
|
| 215 |
+
if len(chosen) >= count:
|
| 216 |
+
break
|
| 217 |
+
cand = "".join(combo)
|
| 218 |
+
if fits(cand):
|
| 219 |
+
chosen.append(cand)
|
| 220 |
+
|
| 221 |
+
if len(chosen) < count:
|
| 222 |
+
# The space genuinely runs out; say so rather than return a short set
|
| 223 |
+
# that looks complete.
|
| 224 |
+
return {"ok": False, "kind": "exhausted",
|
| 225 |
+
"error": f"Only {len(chosen)} conflict-free {length}-nt "
|
| 226 |
+
f"overhangs exist at distance {min_distance} "
|
| 227 |
+
f"(asked for {count}).",
|
| 228 |
+
"found": chosen,
|
| 229 |
+
"next": "Lower min_distance, use a longer overhang, or split "
|
| 230 |
+
"the assembly into two rounds."}
|
| 231 |
+
return {"ok": True, "overhangs": chosen, "count": len(chosen),
|
| 232 |
+
"min_distance": min_distance,
|
| 233 |
+
"note": "Deterministic: the same request returns the same set, so "
|
| 234 |
+
"the design is reproducible."}
|
dee/core/solubility.py
ADDED
|
@@ -0,0 +1,347 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Why a designed protein ends up in the pellet β risk factors, not a score.
|
| 2 |
+
|
| 3 |
+
WHAT THE AUDIT ASKED FOR AND WHAT IS HONEST TO BUILD
|
| 4 |
+
-----------------------------------------------------
|
| 5 |
+
#23 is rated High because insolubility is the most common way a good design
|
| 6 |
+
fails: ESM-2 likes the variant, it expresses, and you harvest a pellet of
|
| 7 |
+
inclusion bodies. Today that failure mode is completely invisible in the
|
| 8 |
+
product.
|
| 9 |
+
|
| 10 |
+
What is NOT honest is a solubility probability. Protein-Sol, NetSolP, CamSol
|
| 11 |
+
and SoluProt all report one, and every one of them is a model trained on
|
| 12 |
+
measured solubility data β data this engine does not have and a model it does
|
| 13 |
+
not run. A number here would be invented, so there is no number here. This
|
| 14 |
+
follows `stability.py` exactly, which reports structural risk factors and
|
| 15 |
+
refuses to print a ΞΞG for the same reason.
|
| 16 |
+
|
| 17 |
+
What IS honest is the physical chemistry that is computable from the sequence
|
| 18 |
+
with published, cited scales, reported as named risk factors a scientist can
|
| 19 |
+
check and disagree with.
|
| 20 |
+
|
| 21 |
+
THE TWO MODES, AND WHY THE SECOND IS THE TRUSTWORTHY ONE
|
| 22 |
+
---------------------------------------------------------
|
| 23 |
+
`assess()` characterizes one protein in isolation. Useful, but weakly
|
| 24 |
+
calibrated β absolute hydropathy tells you less than people assume, and every
|
| 25 |
+
threshold below is a convention for reading a plot, not a validated cutoff.
|
| 26 |
+
|
| 27 |
+
`compare()` is the mode that earns its keep, and it is the one the Directed
|
| 28 |
+
Evolution loop should call. Wild type and a point mutant are the same protein
|
| 29 |
+
with one residue changed, so everything that would confound an absolute
|
| 30 |
+
prediction β fold, length, expression host, composition β cancels. "This
|
| 31 |
+
substitution drops a charge and adds a leucine inside an existing hydrophobic
|
| 32 |
+
patch" is a checkable statement about a specific change. "This protein is 62%
|
| 33 |
+
soluble" is not.
|
| 34 |
+
|
| 35 |
+
EVERY NUMBER COMES FROM A CITED SCALE, NONE FROM RECALL
|
| 36 |
+
--------------------------------------------------------
|
| 37 |
+
Hydropathy is Kyte-Doolittle via `Bio.SeqUtils.ProtParamData.kd`; the
|
| 38 |
+
instability index is Guruswamy's DIWV table via Biopython; pI and charge are
|
| 39 |
+
Biopython's. Biopython is already a dependency, the tables ship with it, and
|
| 40 |
+
nothing in this file is a value typed from memory.
|
| 41 |
+
"""
|
| 42 |
+
from __future__ import annotations
|
| 43 |
+
|
| 44 |
+
from typing import Any, Dict, List, Optional
|
| 45 |
+
|
| 46 |
+
from Bio.SeqUtils import ProtParamData
|
| 47 |
+
from Bio.SeqUtils.ProtParam import ProteinAnalysis
|
| 48 |
+
|
| 49 |
+
# Sliding window for the hydropathy plot. Kyte-Doolittle used 7-11 for general
|
| 50 |
+
# character and 19 to call transmembrane helices; 9 is the usual middle.
|
| 51 |
+
KD_WINDOW = 9
|
| 52 |
+
# Mean hydropathy over that window at or above which a stretch is called a
|
| 53 |
+
# hydrophobic patch. THIS IS A READING CONVENTION, not a validated solubility
|
| 54 |
+
# threshold β stated here so it can be argued with rather than hidden.
|
| 55 |
+
PATCH_THRESHOLD = 1.5
|
| 56 |
+
# Guruswamy's own cutoff: above 40 the protein is classed unstable. That one is
|
| 57 |
+
# from the paper, not chosen here.
|
| 58 |
+
INSTABILITY_CUTOFF = 40.0
|
| 59 |
+
# Net charge is what keeps molecules apart. Within this much of the working pH,
|
| 60 |
+
# charge approaches zero and so does electrostatic repulsion.
|
| 61 |
+
PI_DANGER_WINDOW = 1.0
|
| 62 |
+
DEFAULT_PH = 7.4
|
| 63 |
+
MIN_LEN = 20
|
| 64 |
+
|
| 65 |
+
_KD = ProtParamData.kd
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def _clean(protein: str) -> str:
|
| 69 |
+
return "".join(c for c in (protein or "").upper() if c.isalpha())
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def _known(protein: str) -> str:
|
| 73 |
+
"""Only residues Biopython's scales cover. X/B/Z/U have no KD value and
|
| 74 |
+
would raise; dropping them is right, but the count is reported so a
|
| 75 |
+
heavily-ambiguous sequence is not silently analysed as if it were clean."""
|
| 76 |
+
return "".join(c for c in protein if c in _KD)
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def _hydropathy(protein: str) -> List[float]:
|
| 80 |
+
"""Kyte-Doolittle mean over a sliding window, one value per window start."""
|
| 81 |
+
if len(protein) < KD_WINDOW:
|
| 82 |
+
return []
|
| 83 |
+
vals = [_KD[c] for c in protein]
|
| 84 |
+
return [round(sum(vals[i:i + KD_WINDOW]) / KD_WINDOW, 3)
|
| 85 |
+
for i in range(len(vals) - KD_WINDOW + 1)]
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def _patches(protein: str) -> List[Dict[str, Any]]:
|
| 89 |
+
"""Contiguous runs at or above the patch threshold, in 1-based positions.
|
| 90 |
+
|
| 91 |
+
Exposed hydrophobic surface is the thing that actually drives aggregation:
|
| 92 |
+
two molecules stick where they are both greasy. A patch is therefore more
|
| 93 |
+
informative than whole-protein GRAVY, which averages it away.
|
| 94 |
+
"""
|
| 95 |
+
scores = _hydropathy(protein)
|
| 96 |
+
out: List[Dict[str, Any]] = []
|
| 97 |
+
start = None
|
| 98 |
+
for i, s in enumerate(scores):
|
| 99 |
+
if s >= PATCH_THRESHOLD:
|
| 100 |
+
if start is None:
|
| 101 |
+
start = i
|
| 102 |
+
elif start is not None:
|
| 103 |
+
out.append({"start": start + 1, "end": i + KD_WINDOW - 1,
|
| 104 |
+
"peak": max(scores[start:i])})
|
| 105 |
+
start = None
|
| 106 |
+
if start is not None:
|
| 107 |
+
out.append({"start": start + 1, "end": len(protein),
|
| 108 |
+
"peak": max(scores[start:])})
|
| 109 |
+
return out
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
_ROUNDING = {"gravy": 3, "isoelectric_point": 2, "net_charge_at_ph": 2,
|
| 113 |
+
"instability_index": 1, "aromaticity": 3}
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def _metrics(protein: str, ph: float) -> Dict[str, Any]:
|
| 117 |
+
"""Full precision. `compare` subtracts these BEFORE any rounding.
|
| 118 |
+
|
| 119 |
+
Differencing two values that were each already rounded to one decimal
|
| 120 |
+
silently reports 0.0 for a real change β which would zero out precisely
|
| 121 |
+
the small deltas this module exists to surface.
|
| 122 |
+
"""
|
| 123 |
+
pa = ProteinAnalysis(protein)
|
| 124 |
+
patches = _patches(protein)
|
| 125 |
+
return {
|
| 126 |
+
"length": len(protein),
|
| 127 |
+
"gravy": pa.gravy(),
|
| 128 |
+
"isoelectric_point": pa.isoelectric_point(),
|
| 129 |
+
"net_charge_at_ph": pa.charge_at_pH(ph),
|
| 130 |
+
"instability_index": pa.instability_index(),
|
| 131 |
+
"aromaticity": pa.aromaticity(),
|
| 132 |
+
"cysteines": protein.count("C"),
|
| 133 |
+
"hydrophobic_patches": patches,
|
| 134 |
+
"longest_patch": max((p["end"] - p["start"] + 1 for p in patches),
|
| 135 |
+
default=0),
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def _factors(protein: str, ph: float) -> Dict[str, Any]:
|
| 140 |
+
"""The display view: same metrics, rounded for reading."""
|
| 141 |
+
return _round(_metrics(protein, ph))
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def _round(m: Dict[str, Any]) -> Dict[str, Any]:
|
| 145 |
+
return {k: (round(v, _ROUNDING[k]) if k in _ROUNDING else v)
|
| 146 |
+
for k, v in m.items()}
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def _flags(f: Dict[str, Any], ph: float, host: Optional[str]) -> List[Dict[str, str]]:
|
| 150 |
+
"""Named, checkable concerns. Each says what was measured and why it matters."""
|
| 151 |
+
out: List[Dict[str, str]] = []
|
| 152 |
+
if f["gravy"] > 0:
|
| 153 |
+
out.append({"factor": "net hydrophobic",
|
| 154 |
+
"observed": f"GRAVY {f['gravy']}",
|
| 155 |
+
"why": ("Positive GRAVY means the average residue is "
|
| 156 |
+
"hydrophobic. For a soluble cytoplasmic protein "
|
| 157 |
+
"that is unusual and often means a membrane "
|
| 158 |
+
"protein, a fragment, or a folding problem.")})
|
| 159 |
+
if f["longest_patch"] >= KD_WINDOW * 2:
|
| 160 |
+
out.append({"factor": "extended hydrophobic patch",
|
| 161 |
+
"observed": f"{f['longest_patch']} residues",
|
| 162 |
+
"why": ("A long exposed greasy stretch is where two "
|
| 163 |
+
"molecules stick to each other. This is the most "
|
| 164 |
+
"direct sequence-level aggregation risk.")})
|
| 165 |
+
if abs(f["isoelectric_point"] - ph) < PI_DANGER_WINDOW:
|
| 166 |
+
out.append({"factor": "pI close to the working pH",
|
| 167 |
+
"observed": f"pI {f['isoelectric_point']} vs pH {ph}",
|
| 168 |
+
"why": ("Near its pI the protein carries almost no net "
|
| 169 |
+
"charge, so the electrostatic repulsion keeping "
|
| 170 |
+
"molecules apart disappears. Moving the buffer pH "
|
| 171 |
+
"a point either way is often the whole fix.")})
|
| 172 |
+
if f["instability_index"] > INSTABILITY_CUTOFF:
|
| 173 |
+
out.append({"factor": "instability index above the published cutoff",
|
| 174 |
+
"observed": f"{f['instability_index']} (cutoff "
|
| 175 |
+
f"{INSTABILITY_CUTOFF})",
|
| 176 |
+
"why": ("Guruswamy's index, computed from dipeptide "
|
| 177 |
+
"composition. It predicts a short half-life in "
|
| 178 |
+
"vivo, not insolubility β related, not the same.")})
|
| 179 |
+
if f["cysteines"] >= 2 and (host or "").lower().replace("-", "_") in (
|
| 180 |
+
"e_coli", "ecoli", "bacteria"):
|
| 181 |
+
out.append({"factor": f"{f['cysteines']} cysteines in an E. coli host",
|
| 182 |
+
"observed": f"{f['cysteines']} Cys",
|
| 183 |
+
"why": ("The E. coli cytoplasm is reducing, so disulfides "
|
| 184 |
+
"do not form there. A protein that needs them "
|
| 185 |
+
"typically misfolds into inclusion bodies β the "
|
| 186 |
+
"usual answers are periplasmic export or a "
|
| 187 |
+
"disulfide-permissive strain.")})
|
| 188 |
+
return out
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
def assess(protein: str, *, host: Optional[str] = None,
|
| 192 |
+
ph: float = DEFAULT_PH) -> Dict[str, Any]:
|
| 193 |
+
"""Aggregation risk factors for one protein. Never a solubility score."""
|
| 194 |
+
seq = _clean(protein)
|
| 195 |
+
known = _known(seq)
|
| 196 |
+
if len(known) < MIN_LEN:
|
| 197 |
+
return {"ok": False, "kind": "too_short",
|
| 198 |
+
"error": f"Need at least {MIN_LEN} scored residues; got "
|
| 199 |
+
f"{len(known)}.",
|
| 200 |
+
"next": "Paste the full protein sequence."}
|
| 201 |
+
|
| 202 |
+
f = _factors(known, ph)
|
| 203 |
+
flags = _flags(f, ph, host)
|
| 204 |
+
result = {
|
| 205 |
+
"ok": True,
|
| 206 |
+
"factors": f,
|
| 207 |
+
"flags": flags,
|
| 208 |
+
"ph": ph,
|
| 209 |
+
"host": host,
|
| 210 |
+
"summary": _summary(f, flags),
|
| 211 |
+
"scales": ("Hydropathy: Kyte-Doolittle. Instability index: Guruswamy "
|
| 212 |
+
"(DIWV). pI and charge: Biopython. All ship with Biopython "
|
| 213 |
+
"β no value here was entered by hand."),
|
| 214 |
+
# The line this module exists to be able to say.
|
| 215 |
+
"no_score": ("This does NOT report a solubility score or probability. "
|
| 216 |
+
"That needs a model trained on measured solubility data "
|
| 217 |
+
"(Protein-Sol, NetSolP, CamSol), which is not available "
|
| 218 |
+
"here β so a number would be invented. These are named "
|
| 219 |
+
"risk factors instead, each one checkable."),
|
| 220 |
+
"thresholds": (f"'Hydrophobic patch' means mean Kyte-Doolittle "
|
| 221 |
+
f"β₯ {PATCH_THRESHOLD} over a {KD_WINDOW}-residue "
|
| 222 |
+
f"window. That is a convention for reading a hydropathy "
|
| 223 |
+
f"plot, not a validated solubility cutoff. The "
|
| 224 |
+
f"instability cutoff of {INSTABILITY_CUTOFF} is "
|
| 225 |
+
f"Guruswamy's own."),
|
| 226 |
+
"caveat": ("Absolute risk factors are weakly calibrated. For deciding "
|
| 227 |
+
"between variants, compare() is far more defensible: wild "
|
| 228 |
+
"type and a point mutant differ by one residue, so "
|
| 229 |
+
"everything that would confound an absolute prediction "
|
| 230 |
+
"cancels."),
|
| 231 |
+
}
|
| 232 |
+
dropped = len(seq) - len(known)
|
| 233 |
+
if dropped:
|
| 234 |
+
result["unscored_residues"] = dropped
|
| 235 |
+
result["factors"]["length"] = len(known)
|
| 236 |
+
result["note"] = (f"{dropped} residue(s) (X/B/Z/U or similar) have no "
|
| 237 |
+
f"value on these scales and were excluded. Every "
|
| 238 |
+
f"number above is for the remaining {len(known)}.")
|
| 239 |
+
return result
|
| 240 |
+
|
| 241 |
+
|
| 242 |
+
def compare(wild_type: str, mutant: str, *, host: Optional[str] = None,
|
| 243 |
+
ph: float = DEFAULT_PH) -> Dict[str, Any]:
|
| 244 |
+
"""How one substitution moves each risk factor.
|
| 245 |
+
|
| 246 |
+
The defensible mode. Same protein, one residue changed, so fold, length,
|
| 247 |
+
host and composition all cancel β what is left is attributable to the
|
| 248 |
+
substitution.
|
| 249 |
+
"""
|
| 250 |
+
wt = _known(_clean(wild_type))
|
| 251 |
+
mu = _known(_clean(mutant))
|
| 252 |
+
if len(wt) < MIN_LEN or len(mu) < MIN_LEN:
|
| 253 |
+
return {"ok": False, "kind": "too_short",
|
| 254 |
+
"error": f"Both sequences need at least {MIN_LEN} scored "
|
| 255 |
+
f"residues."}
|
| 256 |
+
if len(wt) != len(mu):
|
| 257 |
+
return {"ok": False, "kind": "length_mismatch",
|
| 258 |
+
"error": (f"Wild type is {len(wt)} residues and the mutant is "
|
| 259 |
+
f"{len(mu)}. This compares point substitutions; an "
|
| 260 |
+
f"indel changes the numbering and the two are no "
|
| 261 |
+
f"longer position-comparable."),
|
| 262 |
+
"next": "Use compare_sequences to align them first."}
|
| 263 |
+
|
| 264 |
+
changes = [{"position": i + 1, "from": a, "to": b}
|
| 265 |
+
for i, (a, b) in enumerate(zip(wt, mu)) if a != b]
|
| 266 |
+
if not changes:
|
| 267 |
+
return {"ok": False, "kind": "identical",
|
| 268 |
+
"error": "The two sequences are identical."}
|
| 269 |
+
|
| 270 |
+
raw_w, raw_m = _metrics(wt, ph), _metrics(mu, ph)
|
| 271 |
+
fw, fm = _round(raw_w), _round(raw_m)
|
| 272 |
+
# Subtract BEFORE rounding β see _metrics.
|
| 273 |
+
deltas = {k: round(raw_m[k] - raw_w[k], 3)
|
| 274 |
+
for k in ("gravy", "isoelectric_point", "net_charge_at_ph",
|
| 275 |
+
"instability_index", "aromaticity", "cysteines",
|
| 276 |
+
"longest_patch")}
|
| 277 |
+
|
| 278 |
+
# The most actionable single fact: did the change land in a greasy region
|
| 279 |
+
# that was already a risk?
|
| 280 |
+
in_patch = []
|
| 281 |
+
for c in changes:
|
| 282 |
+
for p in fw["hydrophobic_patches"]:
|
| 283 |
+
if p["start"] <= c["position"] <= p["end"]:
|
| 284 |
+
in_patch.append({"position": c["position"],
|
| 285 |
+
"patch": f"{p['start']}-{p['end']}"})
|
| 286 |
+
break
|
| 287 |
+
|
| 288 |
+
notes: List[str] = []
|
| 289 |
+
if deltas["gravy"] > 0 and in_patch:
|
| 290 |
+
notes.append("Adds hydrophobicity inside a patch that was already "
|
| 291 |
+
"hydrophobic β the change compounds an existing risk "
|
| 292 |
+
"rather than creating a new one.")
|
| 293 |
+
elif deltas["gravy"] > 0:
|
| 294 |
+
notes.append("Makes the protein more hydrophobic overall.")
|
| 295 |
+
elif deltas["gravy"] < 0:
|
| 296 |
+
notes.append("Makes the protein less hydrophobic overall.")
|
| 297 |
+
if deltas["cysteines"] > 0:
|
| 298 |
+
notes.append(f"Introduces {deltas['cysteines']} cysteine(s) β a new "
|
| 299 |
+
f"free thiol can form the wrong disulfide, and in a "
|
| 300 |
+
f"reducing cytoplasm it will not form the right one.")
|
| 301 |
+
elif deltas["cysteines"] < 0:
|
| 302 |
+
notes.append(f"Removes {abs(deltas['cysteines'])} cysteine(s). If it "
|
| 303 |
+
f"was half of a structural disulfide, the partner is now "
|
| 304 |
+
f"unpaired too.")
|
| 305 |
+
if abs(deltas["net_charge_at_ph"]) >= 1:
|
| 306 |
+
notes.append(f"Changes net charge at pH {ph} by "
|
| 307 |
+
f"{deltas['net_charge_at_ph']:+.2f}, which changes the "
|
| 308 |
+
f"electrostatic repulsion between molecules.")
|
| 309 |
+
if abs(fm["isoelectric_point"] - ph) < PI_DANGER_WINDOW <= abs(
|
| 310 |
+
fw["isoelectric_point"] - ph):
|
| 311 |
+
notes.append(f"Moves the pI to {fm['isoelectric_point']}, close to the "
|
| 312 |
+
f"working pH β near-zero net charge is where aggregation "
|
| 313 |
+
f"is easiest.")
|
| 314 |
+
|
| 315 |
+
return {
|
| 316 |
+
"ok": True,
|
| 317 |
+
"changes": changes,
|
| 318 |
+
"wild_type": fw,
|
| 319 |
+
"mutant": fm,
|
| 320 |
+
"deltas": deltas,
|
| 321 |
+
"in_existing_hydrophobic_patch": in_patch,
|
| 322 |
+
"notes": notes,
|
| 323 |
+
"summary": (f"{len(changes)} substitution(s) Β· "
|
| 324 |
+
+ (" ".join(notes) if notes
|
| 325 |
+
else "no material change to any computed risk factor.")),
|
| 326 |
+
"no_score": ("These are DIFFERENCES in computed physical properties, "
|
| 327 |
+
"not a predicted change in solubility. They say what the "
|
| 328 |
+
"substitution does to the molecule, not what the "
|
| 329 |
+
"experiment will yield."),
|
| 330 |
+
"why_this_is_more_trustworthy": (
|
| 331 |
+
"Wild type and mutant differ by one residue, so fold, length, "
|
| 332 |
+
"host and composition cancel. Absolute solubility predictions "
|
| 333 |
+
"carry all of those as error; a delta does not."),
|
| 334 |
+
}
|
| 335 |
+
|
| 336 |
+
|
| 337 |
+
def _summary(f: Dict[str, Any], flags: List[Dict[str, str]]) -> str:
|
| 338 |
+
bits = [f"{f['length']} aa", f"GRAVY {f['gravy']}",
|
| 339 |
+
f"pI {f['isoelectric_point']}"]
|
| 340 |
+
if f["longest_patch"]:
|
| 341 |
+
bits.append(f"longest hydrophobic patch {f['longest_patch']} aa")
|
| 342 |
+
if flags:
|
| 343 |
+
bits.append(f"{len(flags)} risk factor(s): "
|
| 344 |
+
+ ", ".join(x["factor"] for x in flags))
|
| 345 |
+
else:
|
| 346 |
+
bits.append("no flagged risk factors")
|
| 347 |
+
return " Β· ".join(bits)
|
dee/core/vectors.py
CHANGED
|
@@ -96,6 +96,25 @@ def search(query: str, limit: int = 8) -> List[Dict[str, Any]]:
|
|
| 96 |
return hits[:limit]
|
| 97 |
|
| 98 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
def catalogue_size() -> int:
|
| 100 |
return len(_load())
|
| 101 |
|
|
|
|
| 96 |
return hits[:limit]
|
| 97 |
|
| 98 |
|
| 99 |
+
def addgene_url(entry: Optional[Dict[str, Any]]) -> Optional[str]:
|
| 100 |
+
"""Canonical Addgene page for a catalogue entry, or None.
|
| 101 |
+
|
| 102 |
+
Computed here rather than left to the model. The stored ids carry a lot
|
| 103 |
+
suffix ("69864-3"); the plasmid page is keyed on the number alone, so an
|
| 104 |
+
agent assembling the URL from the raw string produces a 404 and a user who
|
| 105 |
+
concludes the vector does not exist.
|
| 106 |
+
|
| 107 |
+
This is the honest half of audit #6. Addgene publishes no API β the search
|
| 108 |
+
endpoint is HTML β so the engine links to the record it already knows
|
| 109 |
+
rather than scraping a page and presenting the result as retrieved data.
|
| 110 |
+
"""
|
| 111 |
+
if not entry:
|
| 112 |
+
return None
|
| 113 |
+
raw = str(entry.get("addgene") or "").strip()
|
| 114 |
+
m = re.match(r"^(\d+)", raw)
|
| 115 |
+
return f"https://www.addgene.org/{m.group(1)}/" if m else None
|
| 116 |
+
|
| 117 |
+
|
| 118 |
def catalogue_size() -> int:
|
| 119 |
return len(_load())
|
| 120 |
|
tests/test_confirm_gate.py
CHANGED
|
@@ -330,6 +330,25 @@ _UNGATED = {
|
|
| 330 |
"check_synthesis", "assess_stability",
|
| 331 |
# Pure local computation on two sequences the caller already holds.
|
| 332 |
"compare_sequences",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 333 |
}
|
| 334 |
|
| 335 |
|
|
|
|
| 330 |
"check_synthesis", "assess_stability",
|
| 331 |
# Pure local computation on two sequences the caller already holds.
|
| 332 |
"compare_sequences",
|
| 333 |
+
# Arithmetic on a sequence the caller supplied. Proposes primers; ordering
|
| 334 |
+
# them is the user's action, in their vendor's cart, not ours.
|
| 335 |
+
"design_mutagenesis_primers",
|
| 336 |
+
# Arithmetic on four-base strings the caller supplied.
|
| 337 |
+
"check_overhangs",
|
| 338 |
+
# Reads a public curated database. Only the accession leaves the Space.
|
| 339 |
+
"check_residues",
|
| 340 |
+
# Pure counting over sequences the caller supplied.
|
| 341 |
+
"check_conservation",
|
| 342 |
+
# String matching against bundled catalogues. Its whole purpose is to
|
| 343 |
+
# answer "what is this?" WITHOUT the transmission that blast_sequence
|
| 344 |
+
# needs, so gating it would tax the private path and leave the
|
| 345 |
+
# transmitting one comparatively cheap.
|
| 346 |
+
"identify_sequence",
|
| 347 |
+
# Reads a public curated database. Only the accession leaves the Space.
|
| 348 |
+
"map_domains",
|
| 349 |
+
# Physical chemistry and codon arithmetic on sequences the caller already
|
| 350 |
+
# holds. Nothing transmitted, nothing stored.
|
| 351 |
+
"assess_solubility", "assess_expression",
|
| 352 |
}
|
| 353 |
|
| 354 |
|
tests/test_conservation.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Audit #17 + #18: multiple alignment, and conservation counted from it.
|
| 2 |
+
|
| 3 |
+
ESM-2 gives a learned opinion about whether a substitution looks plausible.
|
| 4 |
+
Conservation across real homologs gives an OBSERVED fact: in 40 orthologs this
|
| 5 |
+
position is serine 40 times. They fail differently β the model is weakest
|
| 6 |
+
exactly where the audit says (membrane, disordered, multi-domain) and a
|
| 7 |
+
frequency count is unaffected by any of that.
|
| 8 |
+
|
| 9 |
+
The tests that matter are about the INPUT, not the algorithm. Conservation is
|
| 10 |
+
only as meaningful as the homolog set, and a set of near-identical sequences
|
| 11 |
+
makes every column look invariant. A tool that reports that as "conserved"
|
| 12 |
+
without comment is worse than no tool.
|
| 13 |
+
"""
|
| 14 |
+
import pytest
|
| 15 |
+
|
| 16 |
+
from dee.core import agent_tools as t
|
| 17 |
+
from dee.core import conservation as C
|
| 18 |
+
from dee.core import orchestrator as orch
|
| 19 |
+
|
| 20 |
+
# Position 1,3,4,5,6 invariant; position 2 varies across all five.
|
| 21 |
+
HOMOLOGS = ["MAKQWLTVEG", "MSKQWLTVEG", "MTKQWLSVEG", "MAKQWLTVDG", "MGKQWLTIEG"]
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def test_an_invariant_column_is_found_and_a_variable_one_is_not():
|
| 25 |
+
r = C.score(HOMOLOGS)
|
| 26 |
+
by_pos = {c["anchor_position"]: c for c in r["conservation"]}
|
| 27 |
+
assert by_pos[5]["call"] == "invariant" and by_pos[5]["agreement_pct"] == 100.0
|
| 28 |
+
assert by_pos[2]["call"] == "variable" and by_pos[2]["agreement_pct"] < 60
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def test_entropy_is_never_reported_as_negative_zero():
|
| 32 |
+
"""-sum(...) over an all-agreeing column yields IEEE -0.0, which prints as
|
| 33 |
+
'-0.0 bits' and reads like a bug in a number users are asked to trust."""
|
| 34 |
+
r = C.score(HOMOLOGS)
|
| 35 |
+
for c in r["conservation"]:
|
| 36 |
+
assert c["entropy_bits"] >= 0.0
|
| 37 |
+
assert str(c["entropy_bits"]) != "-0.0"
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def test_a_redundant_homolog_set_is_flagged_and_marked_untrustworthy():
|
| 41 |
+
"""The failure that would make this tool actively misleading. Forty
|
| 42 |
+
sequences at 99% identity are ONE sequence counted forty times, and every
|
| 43 |
+
column will look invariant."""
|
| 44 |
+
r = C.score(["MAKQWLTVEG"] * 4 + ["MAKQWLTVEA"])
|
| 45 |
+
assert r["trustworthy"] is False
|
| 46 |
+
assert r["diversity_warning"] and "redundancy" in r["diversity_warning"]
|
| 47 |
+
assert "more divergent" in r["diversity_warning"]
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def test_a_diverse_set_is_trusted():
|
| 51 |
+
"""A checker that never trusts anything is a checker nobody reads."""
|
| 52 |
+
r = C.score(HOMOLOGS)
|
| 53 |
+
assert r["trustworthy"] is True and r["diversity_warning"] is None
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def test_too_few_sequences_is_refused_with_a_way_forward():
|
| 57 |
+
r = C.score(["MAKQ", "MSKQ"])
|
| 58 |
+
assert r["ok"] is False and r["kind"] == "too_few"
|
| 59 |
+
assert "BLAST" in r["next"]
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def test_positions_are_numbered_along_the_anchor_the_user_designs_against():
|
| 63 |
+
r = C.score(HOMOLOGS, positions=[5])
|
| 64 |
+
assert [(c["anchor_position"], c["anchor_residue"]) for c in r["conservation"]] \
|
| 65 |
+
== [(5, "W")]
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def test_indels_do_not_shift_the_anchor_numbering():
|
| 69 |
+
"""A homolog with a deletion must not renumber the sequence the user is
|
| 70 |
+
designing against β that is how a conservation call lands on the wrong
|
| 71 |
+
residue."""
|
| 72 |
+
r = C.score(["MAKQWLTVEG", "MAKWLTVEG", "MAKQWLTVEG"])
|
| 73 |
+
positions = [c["anchor_position"] for c in r["conservation"]
|
| 74 |
+
if c["anchor_position"]]
|
| 75 |
+
assert positions == sorted(positions)
|
| 76 |
+
assert max(positions) == 10 # anchor length, unchanged by the gap
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def test_the_alignment_says_it_is_approximate():
|
| 80 |
+
"""Progressive alignment is not a simultaneous optimum. Every practical
|
| 81 |
+
tool approximates; pretending otherwise is the dishonest part."""
|
| 82 |
+
m = C.align_many(HOMOLOGS)
|
| 83 |
+
assert m["ok"] and "Approximate" in m["method"]
|
| 84 |
+
assert all(len(r["aligned"]) == m["columns"] for r in m["rows"])
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def test_the_anchor_is_the_longest_sequence():
|
| 88 |
+
m = C.align_many(["MAK", "MAKQWLTVEG", "MAKQ"], ["a", "b", "c"])
|
| 89 |
+
assert m["anchor"] == "b"
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def test_it_positions_itself_against_esm2_rather_than_as_a_confirmation():
|
| 93 |
+
"""Two independent signals. Averaging them would destroy the only thing
|
| 94 |
+
that makes having both worthwhile."""
|
| 95 |
+
r = C.score(HOMOLOGS)
|
| 96 |
+
assert "complements ESM-2" in r["caveat"]
|
| 97 |
+
assert "disagreement is worth investigating" in r["caveat"]
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def test_conservation_is_framed_as_an_observation_not_a_property():
|
| 101 |
+
r = C.score(HOMOLOGS)
|
| 102 |
+
assert "OBSERVATION" in r["caveat"]
|
| 103 |
+
assert "not a property of the protein" in r["caveat"]
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def test_it_is_reachable_ungated_and_specced():
|
| 107 |
+
assert "check_conservation" in t._TOOLS
|
| 108 |
+
assert any(s["function"]["name"] == "check_conservation" for s in orch.TOOL_SPECS)
|
| 109 |
+
assert orch._requires_confirm("check_conservation") is False
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def test_the_summary_leads_with_untrustworthiness_when_it_applies():
|
| 113 |
+
r = t.execute_tool("check_conservation",
|
| 114 |
+
{"sequences": ["MAKQWLTVEG"] * 4 + ["MAKQWLTVEA"]},
|
| 115 |
+
auth_anonymous=True)
|
| 116 |
+
assert "too redundant to trust" in orch._summarize("check_conservation", r)
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def test_the_spec_makes_the_agent_relay_both_limits():
|
| 120 |
+
d = next(s["function"]["description"] for s in orch.TOOL_SPECS
|
| 121 |
+
if s["function"]["name"] == "check_conservation")
|
| 122 |
+
assert "approximate" in d
|
| 123 |
+
assert "diversity_warning" in d and "trustworthy" in d
|
| 124 |
+
assert "complements ESM-2" in d
|
tests/test_domains.py
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for InterPro domain architecture (audit #8).
|
| 2 |
+
|
| 3 |
+
Fixtures are trimmed from a real InterPro response for P01308 (insulin) β the
|
| 4 |
+
14-row / 5-entry case that motivated grouping, and the discontinuous entry that
|
| 5 |
+
motivated keeping segments. No test here touches the network.
|
| 6 |
+
"""
|
| 7 |
+
import pytest
|
| 8 |
+
|
| 9 |
+
from dee.core import domains as D
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def _row(db, acc, typ, integrated, frags, name=None, length=110):
|
| 13 |
+
return {
|
| 14 |
+
"metadata": {"accession": acc, "name": name or acc,
|
| 15 |
+
"source_database": db, "type": typ,
|
| 16 |
+
"integrated": integrated},
|
| 17 |
+
"proteins": [{
|
| 18 |
+
"accession": "p01308", "protein_length": length,
|
| 19 |
+
"entry_protein_locations": [
|
| 20 |
+
{"fragments": [{"start": s, "end": e, "dc-status": "CONTINUOUS"}
|
| 21 |
+
for s, e in frags]}],
|
| 22 |
+
}],
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
# Real shape: five integrated entries described by fourteen rows, one of them
|
| 27 |
+
# discontinuous, plus one signature InterPro has not integrated.
|
| 28 |
+
INSULIN = [
|
| 29 |
+
_row("cdd", "cd04367", "domain", "IPR004825", [(26, 110)]),
|
| 30 |
+
_row("cathgene3d", "G3DSA:1.10.100.10", "homologous_superfamily", None, [(25, 110)]),
|
| 31 |
+
_row("interpro", "IPR004825", "family", None, [(1, 110)], "Insulin"),
|
| 32 |
+
_row("interpro", "IPR016179", "domain", None, [(28, 109)], "Insulin-like"),
|
| 33 |
+
_row("interpro", "IPR022352", "family", None, [(29, 45), (90, 109)],
|
| 34 |
+
"Insulin/IGF/relaxin"),
|
| 35 |
+
_row("interpro", "IPR022353", "conserved_site", None, [(95, 109)],
|
| 36 |
+
"Insulin, conserved site"),
|
| 37 |
+
_row("interpro", "IPR036438", "homologous_superfamily", None, [(10, 109)],
|
| 38 |
+
"Insulin-like superfamily"),
|
| 39 |
+
_row("pfam", "PF00049", "domain", "IPR016179", [(28, 109)]),
|
| 40 |
+
_row("prints", "PR00276", "family", "IPR022352", [(29, 45), (90, 109)]),
|
| 41 |
+
_row("prints", "PR00277", "family", "IPR004825", [(25, 36), (101, 110)]),
|
| 42 |
+
_row("prosite", "PS00262", "conserved_site", "IPR022353", [(95, 109)]),
|
| 43 |
+
_row("panther", "PTHR11454", "family", "IPR004825", [(1, 110)]),
|
| 44 |
+
_row("smart", "SM00078", "domain", "IPR016179", [(28, 109)]),
|
| 45 |
+
_row("ssf", "SSF56994", "homologous_superfamily", "IPR036438", [(10, 109)]),
|
| 46 |
+
]
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
@pytest.fixture
|
| 50 |
+
def insulin(monkeypatch):
|
| 51 |
+
monkeypatch.setattr(D, "_get",
|
| 52 |
+
lambda *a, **k: {"results": INSULIN, "next": None})
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
# ββ the grouping that keeps the answer honest ββββββββββββββββββββββββββββ
|
| 56 |
+
def test_fourteen_signature_rows_become_five_entries(insulin):
|
| 57 |
+
"""Member databases model the same region independently and each adds a
|
| 58 |
+
row. Reporting all fourteen would inflate one domain call into a wall of
|
| 59 |
+
apparent corroboration."""
|
| 60 |
+
r = D.domains("P01308")
|
| 61 |
+
assert r["signature_rows"] == 14
|
| 62 |
+
assert len(r["entries"]) == 5
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def test_member_databases_are_reported_as_support_not_as_findings(insulin):
|
| 66 |
+
r = D.domains("P01308")
|
| 67 |
+
like = [e for e in r["entries"] if e["interpro"] == "IPR016179"][0]
|
| 68 |
+
assert like["supported_by"] == ["pfam", "smart"]
|
| 69 |
+
assert {s["accession"] for s in like["signatures"]} == {"PF00049", "SM00078"}
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def test_agreement_is_not_described_as_independent_observations(insulin):
|
| 73 |
+
"""They share training data and often the same seed alignments. Letting a
|
| 74 |
+
count imply independence would overstate the evidence."""
|
| 75 |
+
note = D.domains("P01308")["evidence_note"]
|
| 76 |
+
assert "not as N independent observations" in note
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def test_an_unintegrated_signature_is_kept_but_separate(insulin):
|
| 80 |
+
r = D.domains("P01308")
|
| 81 |
+
assert [u["accession"] for u in r["unintegrated"]] == ["G3DSA:1.10.100.10"]
|
| 82 |
+
assert all(e["interpro"].startswith("IPR") for e in r["entries"])
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
# ββ the two things that silently give wrong answers ββββββββββββββββββββββ
|
| 86 |
+
def test_a_discontinuous_entry_keeps_its_segments(insulin):
|
| 87 |
+
"""IPR022352 is 29-45 AND 90-109. Collapsing to 29-109 would silently
|
| 88 |
+
claim 44 residues that are not in it."""
|
| 89 |
+
r = D.domains("P01308")
|
| 90 |
+
e = [x for x in r["entries"] if x["interpro"] == "IPR022352"][0]
|
| 91 |
+
assert e["discontinuous"] is True
|
| 92 |
+
assert e["segments"] == [{"start": 29, "end": 45}, {"start": 90, "end": 109}]
|
| 93 |
+
assert e["residues"] == (45 - 29 + 1) + (109 - 90 + 1)
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def test_precursor_numbering_is_always_stated(insulin):
|
| 97 |
+
n = D.domains("P01308")["numbering"]
|
| 98 |
+
assert "precursor" in n and "110 aa" in n
|
| 99 |
+
assert "signal peptide" in n
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def test_absence_of_a_signature_is_not_absence_of_a_domain(monkeypatch):
|
| 103 |
+
monkeypatch.setattr(D, "_get", lambda *a, **k: {"results": [], "next": None})
|
| 104 |
+
r = D.domains("Q00000")
|
| 105 |
+
assert r["ok"] and r["entries"] == []
|
| 106 |
+
assert "expected" in r["summary"]
|
| 107 |
+
assert "NOT evidence" in r["caveat"]
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
# ββ architecture and position mapping ββββββββββββββββββββββββββββββββββββ
|
| 111 |
+
def test_architecture_holds_only_boundary_types(insulin):
|
| 112 |
+
"""Families classify the whole protein and superfamilies overshoot the
|
| 113 |
+
domain β neither gives a usable region."""
|
| 114 |
+
arch = D.domains("P01308")["architecture"]
|
| 115 |
+
assert [a["interpro"] for a in arch] == ["IPR016179"]
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def test_architecture_reads_n_to_c_not_in_response_order(monkeypatch):
|
| 119 |
+
"""InterPro returns entries in its own order. An architecture listed out
|
| 120 |
+
of sequence order is not an architecture."""
|
| 121 |
+
rows = [
|
| 122 |
+
_row("interpro", "IPR000C", "domain", None, [(319, 357)], "tetramerisation", 393),
|
| 123 |
+
_row("interpro", "IPR000A", "domain", None, [(6, 30)], "transactivation", 393),
|
| 124 |
+
_row("interpro", "IPR000B", "domain", None, [(100, 288)], "DNA-binding", 393),
|
| 125 |
+
_row("interpro", "IPR000F", "family", None, [(3, 369)], "p53 family", 393),
|
| 126 |
+
]
|
| 127 |
+
monkeypatch.setattr(D, "_get", lambda *a, **k: {"results": rows, "next": None})
|
| 128 |
+
r = D.domains("P04637")
|
| 129 |
+
assert [a["name"] for a in r["architecture"]] == [
|
| 130 |
+
"transactivation", "DNA-binding", "tetramerisation"]
|
| 131 |
+
# And the family, which spans nearly everything, is not offered as a region.
|
| 132 |
+
assert "p53 family" not in [a["name"] for a in r["architecture"]]
|
| 133 |
+
assert "transactivation (6β30)" in r["summary"]
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def test_a_position_inside_a_domain_is_called_that_way(insulin):
|
| 137 |
+
r = D.domains("P01308", positions=["F49L"])
|
| 138 |
+
assert r["positions"][0]["position"] == 49
|
| 139 |
+
assert "Insulin-like" in r["positions"][0]["note"]
|
| 140 |
+
assert "not a linker" in r["positions"][0]["note"]
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def test_a_position_covered_only_by_a_family_is_not_called_a_domain(insulin):
|
| 144 |
+
"""Position 24 sits in the family and superfamily spans but in no domain.
|
| 145 |
+
Saying "in the insulin family" is true; saying "in a domain" is not."""
|
| 146 |
+
note = D.domains("P01308", positions=[24])["positions"][0]["note"]
|
| 147 |
+
assert "not inside a specific annotated domain" in note
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def test_a_position_past_the_end_is_flagged_rather_than_answered(insulin):
|
| 151 |
+
"""The commonest cause is mature-vs-precursor numbering, so a silent 'no
|
| 152 |
+
domain here' would confirm the user's mistake."""
|
| 153 |
+
note = D.domains("P01308", positions=[900])["positions"][0]["note"]
|
| 154 |
+
assert "past the end" in note
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def test_substitution_labels_and_bare_integers_both_work(insulin):
|
| 158 |
+
a = D.domains("P01308", positions=["A24T"])["positions"][0]
|
| 159 |
+
b = D.domains("P01308", positions=[24])["positions"][0]
|
| 160 |
+
assert a["position"] == b["position"] == 24
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
# ββ failure modes ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 164 |
+
def test_unreachable_interpro_is_not_reported_as_no_domains(monkeypatch):
|
| 165 |
+
monkeypatch.setattr(D, "_get", lambda *a, **k: None)
|
| 166 |
+
r = D.domains("P01308")
|
| 167 |
+
assert r["ok"] is False and r["kind"] == "unreachable"
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def test_a_partial_walk_is_marked_truncated(monkeypatch):
|
| 171 |
+
"""A page failing mid-walk yields a partial architecture, and a partial
|
| 172 |
+
architecture presented as complete is how a domain goes missing."""
|
| 173 |
+
calls = {"n": 0}
|
| 174 |
+
|
| 175 |
+
def flaky(url, **kw):
|
| 176 |
+
calls["n"] += 1
|
| 177 |
+
if calls["n"] == 1:
|
| 178 |
+
return {"results": INSULIN, "next": "https://example.invalid/page2"}
|
| 179 |
+
return None
|
| 180 |
+
|
| 181 |
+
monkeypatch.setattr(D, "_get", flaky)
|
| 182 |
+
r = D.domains("P01308")
|
| 183 |
+
assert r["ok"] and r["truncated"] is True
|
| 184 |
+
assert r["summary"].startswith("PARTIAL")
|
| 185 |
+
assert "INCOMPLETE" in r["caveat"]
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
def test_paging_stops_at_the_bound(monkeypatch):
|
| 189 |
+
"""An unbounded follow loop hangs a request thread on someone else's
|
| 190 |
+
pagination."""
|
| 191 |
+
seen = []
|
| 192 |
+
|
| 193 |
+
def endless(url, **kw):
|
| 194 |
+
seen.append(url)
|
| 195 |
+
return {"results": [], "next": "https://example.invalid/next"}
|
| 196 |
+
|
| 197 |
+
monkeypatch.setattr(D, "_get", endless)
|
| 198 |
+
D.domains("P01308")
|
| 199 |
+
assert len(seen) == D.MAX_PAGES
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
def test_a_missing_accession_explains_why_a_raw_sequence_will_not_do(monkeypatch):
|
| 203 |
+
r = D.domains("")
|
| 204 |
+
assert r["ok"] is False
|
| 205 |
+
assert "InterProScan" in r["next"]
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
def test_only_the_accession_leaves_the_space(monkeypatch):
|
| 209 |
+
"""The engine's standing rule: nothing but an identifier goes out. The
|
| 210 |
+
positions the user cares about are matched locally against the response,
|
| 211 |
+
so even those are never transmitted."""
|
| 212 |
+
seen = []
|
| 213 |
+
monkeypatch.setattr(D, "_get",
|
| 214 |
+
lambda url, **k: seen.append(url) or {"results": INSULIN,
|
| 215 |
+
"next": None})
|
| 216 |
+
D.domains("P01308", positions=[24, 49])
|
| 217 |
+
assert seen == [f"{D._API}/P01308/?page_size={D.PAGE_SIZE}"]
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
def test_malformed_fragments_are_skipped_not_crashed(monkeypatch):
|
| 221 |
+
bad = [_row("interpro", "IPR000001", "domain", None, [], "Broken")]
|
| 222 |
+
bad[0]["proteins"][0]["entry_protein_locations"] = [
|
| 223 |
+
{"fragments": [{"start": None, "end": 10}, {"start": 50, "end": 20},
|
| 224 |
+
{"start": 5, "end": 9}]}]
|
| 225 |
+
monkeypatch.setattr(D, "_get", lambda *a, **k: {"results": bad, "next": None})
|
| 226 |
+
r = D.domains("P01308")
|
| 227 |
+
assert r["ok"]
|
| 228 |
+
assert r["entries"][0]["segments"] == [{"start": 5, "end": 9}]
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
# ββ the agent tool βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 232 |
+
def test_the_agent_tool_is_registered(insulin):
|
| 233 |
+
from dee.core import agent_tools as t
|
| 234 |
+
assert "map_domains" in t._TOOLS
|
| 235 |
+
out = t._TOOLS["map_domains"]["fn"]({"accession": "P01308",
|
| 236 |
+
"positions": "F49L, 24"})
|
| 237 |
+
assert out["ok"] and len(out["positions"]) == 2
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
def test_the_tool_refuses_a_missing_accession():
|
| 241 |
+
from dee.core import agent_tools as t
|
| 242 |
+
assert t._TOOLS["map_domains"]["fn"]({})["ok"] is False
|
| 243 |
+
|
| 244 |
+
|
| 245 |
+
def test_the_spec_warns_about_numbering_and_discontinuity():
|
| 246 |
+
from dee.core import agent_tools as t
|
| 247 |
+
desc = [s for s in t.TOOL_SPECS
|
| 248 |
+
if s["function"]["name"] == "map_domains"][0]["function"]["description"]
|
| 249 |
+
assert "PRECURSOR" in desc
|
| 250 |
+
assert "DISCONTINUOUS" in desc
|
| 251 |
+
assert "design_variant_library" in desc
|
tests/test_expression.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for sequence-visible expression risk (audit #24).
|
| 2 |
+
|
| 3 |
+
Two things are actually being defended here: that no yield is ever predicted,
|
| 4 |
+
and that the rare-codon threshold is derived from the tables rather than
|
| 5 |
+
chosen β the second is what makes the first honest rather than merely cautious.
|
| 6 |
+
"""
|
| 7 |
+
import pytest
|
| 8 |
+
|
| 9 |
+
from dee.core import expression as E
|
| 10 |
+
|
| 11 |
+
ADAPTED = "ATG" + "AAACTGGCGCGTGAAGATCTGAAAGCGGTTCTGGAAAAC" * 3 + "TAA"
|
| 12 |
+
RARE_HEAVY = "ATG" + "AAACTGGCGAGAAGGATAGATCTGAAAGCGGTTCTGGAAAAC" * 3 + "TAA"
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
# ββ the honesty contract βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 16 |
+
def test_no_expression_level_is_ever_predicted():
|
| 17 |
+
r = E.assess(ADAPTED, "e_coli")
|
| 18 |
+
assert r["ok"]
|
| 19 |
+
assert "does NOT predict an expression level" in r["no_yield"]
|
| 20 |
+
for key in ("yield", "expression_level", "mg_per_l", "predicted_yield",
|
| 21 |
+
"expression_score"):
|
| 22 |
+
assert key not in r
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def test_the_result_names_what_it_does_not_cover():
|
| 26 |
+
"""An assessment that looks complete is worse than one that lists its own
|
| 27 |
+
blind spots β the user assumes the unchecked thing was checked."""
|
| 28 |
+
n = E.assess(ADAPTED, "e_coli")["not_covered"]
|
| 29 |
+
assert "Shine-Dalgarno" in n
|
| 30 |
+
assert "#38" in n and "#39" in n
|
| 31 |
+
assert "assess_solubility" in n
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
# ββ the threshold is derived, and the derivation is checkable ββββββββββββ
|
| 35 |
+
def test_the_rare_cutoff_selects_the_codons_rosetta_strains_supply():
|
| 36 |
+
"""w < 0.15 against the E. coli table selects AGA, AGG, ATA, CTA β exactly
|
| 37 |
+
the set argU/ileY/leuW exist to supply. That is the evidence the cutoff is
|
| 38 |
+
measuring something real rather than being a round number."""
|
| 39 |
+
selected = set(E.assess(ADAPTED, "e_coli")["rare_codons_in_this_host"])
|
| 40 |
+
assert {"AGA", "AGG", "ATA", "CTA"} <= selected
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def test_the_same_cutoff_finds_nothing_rare_in_human_and_says_so():
|
| 44 |
+
"""Mammalian codon usage is much flatter. Manufacturing a warning to look
|
| 45 |
+
thorough would be worse than the empty result."""
|
| 46 |
+
r = E.assess(ADAPTED, "human")
|
| 47 |
+
assert r["rare_codons_in_this_host"] == []
|
| 48 |
+
assert r["rare_codon_count"] == 0
|
| 49 |
+
assert "nothing" in r["method"]
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def test_the_method_line_lists_the_selected_codons():
|
| 53 |
+
m = E.assess(ADAPTED, "e_coli")["method"]
|
| 54 |
+
assert "Sharp & Li" in m
|
| 55 |
+
assert str(E.RARE_BELOW) in m
|
| 56 |
+
assert "AGA" in m
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
# ββ rare codons and the runs that matter βββββββββββββββββββββββββββββββββ
|
| 60 |
+
def test_an_adapted_sequence_is_clean():
|
| 61 |
+
r = E.assess(ADAPTED, "e_coli")
|
| 62 |
+
assert r["rare_codon_count"] == 0
|
| 63 |
+
assert r["flags"] == []
|
| 64 |
+
assert r["codon_adaptation_index"] > 0.9
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def test_rare_codons_lower_the_adaptation_index():
|
| 68 |
+
assert (E.assess(RARE_HEAVY, "e_coli")["codon_adaptation_index"]
|
| 69 |
+
< E.assess(ADAPTED, "e_coli")["codon_adaptation_index"])
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def test_consecutive_rare_codons_are_reported_as_a_run():
|
| 73 |
+
"""The finding that changes what someone does. A scattered rare codon is
|
| 74 |
+
absorbed; three in a row depletes the local charged-tRNA pool."""
|
| 75 |
+
r = E.assess(RARE_HEAVY, "e_coli")
|
| 76 |
+
assert r["rare_codon_runs"]
|
| 77 |
+
run = r["rare_codon_runs"][0]
|
| 78 |
+
assert run["length"] >= E.RUN_LENGTH
|
| 79 |
+
assert run["codons"] == ["AGA", "AGG", "ATA"]
|
| 80 |
+
flag = [f for f in r["flags"] if "consecutive rare" in f["factor"]][0]
|
| 81 |
+
assert "recoding just this run" in flag["why"]
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def test_scattered_rare_codons_do_not_form_a_run():
|
| 85 |
+
dna = "ATG" + ("AGA" + "CTGGCGAAAGATCTGGAA") * 6 + "TAA"
|
| 86 |
+
r = E.assess(dna, "e_coli")
|
| 87 |
+
assert r["rare_codon_count"] >= 6
|
| 88 |
+
assert r["rare_codon_runs"] == []
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def test_the_five_prime_ramp_is_reported_separately():
|
| 92 |
+
"""A rare codon at codon 5 costs more than the same codon at codon 400."""
|
| 93 |
+
r = E.assess(RARE_HEAVY, "e_coli")
|
| 94 |
+
assert r["rare_in_ramp"]
|
| 95 |
+
assert all(x["codon_number"] <= E.RAMP_CODONS for x in r["rare_in_ramp"])
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def test_cai_excludes_single_codon_families():
|
| 99 |
+
"""Met and Trp have no synonym to choose between. Including them only
|
| 100 |
+
drags every score toward 1 and makes a bad sequence look adapted."""
|
| 101 |
+
only_mw = "ATG" + "ATGTGG" * 20 + "TAA"
|
| 102 |
+
r = E.assess(only_mw, "e_coli")
|
| 103 |
+
assert r["codon_adaptation_index"] is None or r["codon_adaptation_index"] > 0
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def test_cai_does_not_underflow_on_a_long_cds():
|
| 107 |
+
"""The direct product of hundreds of fractions underflows to 0.0 and
|
| 108 |
+
silently reports a perfectly adapted gene as unadapted."""
|
| 109 |
+
long_cds = "ATG" + "AAACTGGCGCGTGAAGATCTGAAAGCGGTTCTGGAAAAC" * 60 + "TAA"
|
| 110 |
+
cai = E.assess(long_cds, "e_coli")["codon_adaptation_index"]
|
| 111 |
+
assert cai is not None and cai > 0.5
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
# ββ reading frame ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 115 |
+
def test_an_internal_stop_is_flagged_as_a_truncated_product():
|
| 116 |
+
dna = "ATG" + "AAACTGGCG" * 4 + "TAA" + "AAACTGGCG" * 6 + "TAA"
|
| 117 |
+
r = E.assess(dna, "e_coli")
|
| 118 |
+
assert r["internal_stops"]
|
| 119 |
+
flag = [f for f in r["flags"] if "internal stop" in f["factor"]][0]
|
| 120 |
+
assert "truncated" in flag["why"]
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def test_a_missing_terminal_stop_is_flagged():
|
| 124 |
+
r = E.assess(ADAPTED[:-3], "e_coli")
|
| 125 |
+
assert r["has_terminal_stop"] is False
|
| 126 |
+
assert any("no terminal stop" in f["factor"] for f in r["flags"])
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def test_an_out_of_frame_length_is_flagged_not_silently_truncated():
|
| 130 |
+
r = E.assess(ADAPTED + "AT", "e_coli")
|
| 131 |
+
assert r["in_frame"] is False
|
| 132 |
+
assert any("whole number of codons" in f["factor"] for f in r["flags"])
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
# ββ hosts ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 136 |
+
def test_an_unknown_host_declines_instead_of_using_another_table():
|
| 137 |
+
"""Scoring a plant construct against the E. coli table would produce
|
| 138 |
+
confident nonsense. The refusal must read as missing data, not as the
|
| 139 |
+
organism being unsupported."""
|
| 140 |
+
r = E.assess(ADAPTED, "plant")
|
| 141 |
+
assert r["ok"] is False and r["kind"] == "no_codon_table"
|
| 142 |
+
assert "missing data file, not an unsupported organism" in r["next"]
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def test_a_missing_host_is_refused_because_expression_is_host_specific():
|
| 146 |
+
assert E.assess(ADAPTED, "")["ok"] is False
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def test_every_known_host_alias_works():
|
| 150 |
+
for host in ("e_coli", "ecoli", "yeast", "s_cerevisiae", "human", "h_sapiens"):
|
| 151 |
+
assert E.assess(ADAPTED, host)["ok"], host
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
def test_the_same_sequence_scores_differently_per_host():
|
| 155 |
+
"""The point of asking for a host. E. coli-optimised DNA is not
|
| 156 |
+
human-optimised."""
|
| 157 |
+
a = E.assess(ADAPTED, "e_coli")["codon_adaptation_index"]
|
| 158 |
+
b = E.assess(ADAPTED, "human")["codon_adaptation_index"]
|
| 159 |
+
assert a != b
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
def test_too_short_is_refused():
|
| 163 |
+
assert E.assess("ATGAAACTG", "e_coli")["kind"] == "too_short"
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
# ββ the agent tool βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 167 |
+
def test_the_tool_runs_and_needs_a_host():
|
| 168 |
+
from dee.core import agent_tools as t
|
| 169 |
+
fn = t._TOOLS["assess_expression"]["fn"]
|
| 170 |
+
assert fn({"dna": ADAPTED, "host": "e_coli"})["ok"]
|
| 171 |
+
assert fn({"host": "e_coli"})["ok"] is False
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
def test_the_tool_explains_that_protein_input_cannot_answer_this():
|
| 175 |
+
from dee.core import agent_tools as t
|
| 176 |
+
err = t._TOOLS["assess_expression"]["fn"]({"host": "e_coli"})["error"]
|
| 177 |
+
assert "reverse-translate" in err
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
def test_the_spec_forbids_substituting_another_hosts_table():
|
| 181 |
+
from dee.core import agent_tools as t
|
| 182 |
+
desc = next(s for s in t.TOOL_SPECS
|
| 183 |
+
if s["function"]["name"] == "assess_expression")["function"]["description"]
|
| 184 |
+
assert "never substitute another host's table" in desc
|
| 185 |
+
assert "no_yield" in desc
|
| 186 |
+
# No enum on host β a hardcoded organism list is the hardest gate to see.
|
| 187 |
+
props = next(s for s in t.TOOL_SPECS
|
| 188 |
+
if s["function"]["name"] == "assess_expression")["function"]["parameters"]["properties"]
|
| 189 |
+
assert "enum" not in props["host"]
|
tests/test_features.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Audit #7: the residues you should not mutate, from the people who curated them.
|
| 2 |
+
|
| 3 |
+
ESM-2 scores how UNUSUAL a residue is, not how load-bearing it is. A catalytic
|
| 4 |
+
serine is often unremarkable in sequence terms β it is the geometry that
|
| 5 |
+
matters, and a sequence model does not see geometry. So the engine will rank a
|
| 6 |
+
substitution at the active site as promising, and the cheapest fix is not a
|
| 7 |
+
better model but asking UniProt.
|
| 8 |
+
|
| 9 |
+
Network is stubbed throughout: a test that depends on a live public API is a
|
| 10 |
+
test that fails on Tuesdays and teaches everyone to ignore it.
|
| 11 |
+
"""
|
| 12 |
+
import json
|
| 13 |
+
|
| 14 |
+
import pytest
|
| 15 |
+
|
| 16 |
+
from dee.core import agent_tools as t
|
| 17 |
+
from dee.core import features as F
|
| 18 |
+
from dee.core import orchestrator as orch
|
| 19 |
+
|
| 20 |
+
ENTRY = {
|
| 21 |
+
"primaryAccession": "P00760",
|
| 22 |
+
"proteinDescription": {"recommendedName": {"fullName": {"value": "Serine protease 1"}}},
|
| 23 |
+
"sequence": {"length": 246},
|
| 24 |
+
"features": [
|
| 25 |
+
{"type": "Active site", "location": {"start": {"value": 63}, "end": {"value": 63}},
|
| 26 |
+
"description": "Charge relay system",
|
| 27 |
+
"evidences": [{"evidenceCode": "ECO:0000255"}]},
|
| 28 |
+
{"type": "Disulfide bond", "location": {"start": {"value": 30}, "end": {"value": 46}}},
|
| 29 |
+
{"type": "Domain", "location": {"start": {"value": 20}, "end": {"value": 240}},
|
| 30 |
+
"description": "Peptidase S1"},
|
| 31 |
+
# Not a reason to avoid a position β must be filtered out.
|
| 32 |
+
{"type": "Sequence conflict", "location": {"start": {"value": 100}, "end": {"value": 100}}},
|
| 33 |
+
# Malformed span: must be skipped, not crash.
|
| 34 |
+
{"type": "Active site", "location": {"start": {"value": None}, "end": {"value": None}}},
|
| 35 |
+
],
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
@pytest.fixture(autouse=True)
|
| 40 |
+
def _no_network(monkeypatch):
|
| 41 |
+
monkeypatch.setattr(F, "_get", lambda *a, **k: ENTRY)
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def test_functional_features_are_kept_and_record_noise_is_dropped():
|
| 45 |
+
"""Sequence conflicts and variants describe the RECORD, not a reason to
|
| 46 |
+
avoid a position. Including them would bury the signal."""
|
| 47 |
+
r = F.fetch("P00760")
|
| 48 |
+
kinds = {f["type"] for f in r["features"]}
|
| 49 |
+
assert "Active site" in kinds and "Disulfide bond" in kinds
|
| 50 |
+
assert "Sequence conflict" not in kinds
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def test_a_malformed_span_is_skipped_not_crashed():
|
| 54 |
+
r = F.fetch("P00760")
|
| 55 |
+
assert r["ok"] and all(f["start"] is not None for f in r["features"])
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def test_a_position_on_an_active_site_says_avoid():
|
| 59 |
+
a = F.annotate("P00760", ["S63A"])
|
| 60 |
+
h = a["positions"][0]
|
| 61 |
+
assert h["critical"] and h["verdict"].startswith("AVOID")
|
| 62 |
+
assert "catalytic" in h["critical"][0]["means"]
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def test_an_unannotated_position_is_never_called_safe():
|
| 66 |
+
"""The failure that would make this tool harmful. Most proteins are
|
| 67 |
+
annotated sparsely; 'no annotation' is absence of knowledge."""
|
| 68 |
+
a = F.annotate("P00760", [200])
|
| 69 |
+
assert a["positions"][0]["critical"] == []
|
| 70 |
+
assert "safe" not in a["positions"][0]["verdict"].lower()
|
| 71 |
+
assert "absence of KNOWLEDGE" in a["caveat"]
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def test_the_precursor_numbering_trap_is_stated():
|
| 75 |
+
"""UniProt numbers the FULL precursor. Bovine trypsin's triad is at
|
| 76 |
+
63/107/200 there, not the classic His57/Asp102/Ser195, because the signal
|
| 77 |
+
peptide and propeptide are counted. A user in mature numbering gets a
|
| 78 |
+
confident flag on the wrong residue β worse than no flag."""
|
| 79 |
+
a = F.annotate("P00760", [63])
|
| 80 |
+
assert "precursor" in a["numbering"]
|
| 81 |
+
assert "246" in a["numbering"] # the real length, not a guess
|
| 82 |
+
assert "offset" in a["numbering"]
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def test_substitution_labels_are_accepted_because_that_is_what_the_engine_speaks():
|
| 86 |
+
a = F.annotate("P00760", ["R63H", 63, "63"])
|
| 87 |
+
assert [h["position"] for h in a["positions"]] == [63, 63, 63]
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def test_uniprot_evidence_codes_travel_with_the_claim():
|
| 91 |
+
"""ECO:0000269 is experimental; ECO:0000250 is inferred by similarity. A
|
| 92 |
+
designer should weight those differently, so the code is not discarded."""
|
| 93 |
+
r = F.fetch("P00760")
|
| 94 |
+
act = next(f for f in r["features"] if f["type"] == "Active site")
|
| 95 |
+
assert act["evidence"] == ["ECO:0000255"]
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def test_domains_are_context_not_a_veto():
|
| 99 |
+
"""Being inside a domain is not a reason to avoid a residue β almost every
|
| 100 |
+
residue is. Only the critical tier vetoes."""
|
| 101 |
+
a = F.annotate("P00760", [200])
|
| 102 |
+
assert any(c["type"] == "Domain" for c in a["positions"][0]["context"])
|
| 103 |
+
assert a["positions"][0]["critical"] == []
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def test_an_unreachable_uniprot_is_not_reported_as_no_features(monkeypatch):
|
| 107 |
+
""""Couldn't ask" and "nothing known" must not look the same β one means
|
| 108 |
+
retry, the other means proceed with care."""
|
| 109 |
+
monkeypatch.setattr(F, "_get", lambda *a, **k: None)
|
| 110 |
+
r = F.fetch("P00760")
|
| 111 |
+
assert r["ok"] is False and r["kind"] == "unreachable"
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def test_it_is_reachable_ungated_and_specced():
|
| 115 |
+
assert "check_residues" in t._TOOLS
|
| 116 |
+
assert any(s["function"]["name"] == "check_residues" for s in orch.TOOL_SPECS)
|
| 117 |
+
assert orch._requires_confirm("check_residues") is False
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def test_the_spec_makes_the_agent_relay_both_warnings():
|
| 121 |
+
d = next(s["function"]["description"] for s in orch.TOOL_SPECS
|
| 122 |
+
if s["function"]["name"] == "check_residues")
|
| 123 |
+
assert "PRECURSOR" in d
|
| 124 |
+
assert "does NOT mean safe" in d
|
| 125 |
+
assert "BEFORE recommending" in d
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def test_no_sequence_is_ever_sent(monkeypatch):
|
| 129 |
+
"""Standing rule: only (accession) leaves the Space."""
|
| 130 |
+
seen = []
|
| 131 |
+
monkeypatch.setattr(F, "_get", lambda url, **k: seen.append(url) or ENTRY)
|
| 132 |
+
F.annotate("P00760", ["R63H"])
|
| 133 |
+
assert seen and all("P00760" in u for u in seen)
|
| 134 |
+
assert not any(len(u) > 200 for u in seen), "a sequence would blow the URL up"
|
tests/test_identify_sequence.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for local sequence identification (audit #5).
|
| 2 |
+
|
| 3 |
+
The interesting tests here are not "does it compute GC". They are the ones
|
| 4 |
+
that stop the module from OVER-CLAIMING, because the whole reason it exists is
|
| 5 |
+
that the obvious implementation β paste goes straight to BLAST, motif hit gets
|
| 6 |
+
reported as an identification β is wrong in both directions.
|
| 7 |
+
"""
|
| 8 |
+
import pytest
|
| 9 |
+
|
| 10 |
+
from dee.core import identify as ident
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
# A T7 expression cassette built from motifs that are already in the repo's own
|
| 14 |
+
# catalogue, so nothing here is a sequence typed from recall.
|
| 15 |
+
def _cassette() -> str:
|
| 16 |
+
from dee.core import plasmid
|
| 17 |
+
t7 = dict((n, s) for n, s, t in plasmid._MOTIFS)["T7 promoter"]
|
| 18 |
+
orf = "ATG" + "GCTAGCGGTACCGAATTCGATCTGAAAGCT" * 4 + "TAA"
|
| 19 |
+
# The pET C-terminal junction, taken from the vector catalogue itself.
|
| 20 |
+
from dee.core import vectors
|
| 21 |
+
tail = vectors._load()["pet28a"]["flanking_3p_max"]
|
| 22 |
+
return t7 + "GGAGACCACAACGG" + orf + tail
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
# ββ the honesty contract βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 26 |
+
def test_local_analysis_never_reports_a_sequence_as_identified():
|
| 27 |
+
"""The single most important assertion in this file.
|
| 28 |
+
|
| 29 |
+
Characterizing is not identifying. An agent that concludes "this is
|
| 30 |
+
insulin" from a His-tag match is the failure this module prevents.
|
| 31 |
+
"""
|
| 32 |
+
r = ident.identify(_cassette())
|
| 33 |
+
assert r["ok"] and r["identified"] is False
|
| 34 |
+
assert "blast_sequence" in r["to_identify_it"]
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def test_it_says_that_blast_transmits():
|
| 38 |
+
r = ident.identify(_cassette())
|
| 39 |
+
assert "outside this Space" in r["to_identify_it"]
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def test_a_shared_vector_junction_is_reported_as_a_family_not_a_vector():
|
| 43 |
+
"""The pET series shares one C-terminal His junction, and the catalogue
|
| 44 |
+
records it at different lengths per entry. A longer recorded junction is a
|
| 45 |
+
longer catalogue string, not better evidence β picking the longest and
|
| 46 |
+
naming it alone would invent a distinction the sequence cannot support."""
|
| 47 |
+
r = ident.identify(_cassette())
|
| 48 |
+
hits = r["detail"]["vector_junctions"]
|
| 49 |
+
assert hits, "the pET C-terminal junction should match"
|
| 50 |
+
three = [h for h in hits if h["end"] == "3'"][0]
|
| 51 |
+
assert len(three["vectors"]) > 1
|
| 52 |
+
assert three["distinguishing"] is False
|
| 53 |
+
assert "not which vector" in three["note"]
|
| 54 |
+
assert "shared by" in r["summary"]
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def test_a_fasta_header_is_a_claim_and_is_labelled_as_one():
|
| 58 |
+
r = ident.identify(">sp|P01308|INS_HUMAN Insulin\n" + "MKVLAAGIVGLNRSTGWQFD" * 4)
|
| 59 |
+
claim = r["header_claim"]
|
| 60 |
+
assert claim["accession"]["accession"] == "P01308"
|
| 61 |
+
assert claim["accession"]["family"] == "uniprot"
|
| 62 |
+
assert "claim to check, not as evidence" in claim["note"]
|
| 63 |
+
# And it must not have upgraded that claim into an identification.
|
| 64 |
+
assert r["identified"] is False
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def test_absence_of_a_motif_is_not_evidence_of_absence():
|
| 68 |
+
r = ident.identify("ATGAAACCCGGGTTTAAACCCGGGTTTAAACCCGGGTTTAAACCCTAA")
|
| 69 |
+
assert "not in the list" in r["caveat"]
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
# ββ molecule classification ββββββββββββββββββββββββββββββββββββββββββββββ
|
| 73 |
+
def test_short_all_actg_input_is_ambiguous_and_asks_rather_than_guesses():
|
| 74 |
+
"""'ATGCATGCATGCATGC' is a plausible DNA fragment AND a plausible peptide.
|
| 75 |
+
Silently picking one misroutes the entire job."""
|
| 76 |
+
r = ident.identify("ATGCATGCATGCATGC")
|
| 77 |
+
assert r["kind"] == "ambiguous"
|
| 78 |
+
assert "Ask the user" in r["next"]
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def test_rna_is_distinguished_from_dna():
|
| 82 |
+
r = ident.identify("AUGGCUAGCGGUACCGAAUUCGAUCUGAAAGCUAUGGCUAGCGGUACC")
|
| 83 |
+
assert r["molecule"] == "rna"
|
| 84 |
+
assert "nt RNA" in r["summary"]
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def test_protein_is_recognised_by_alphabet():
|
| 88 |
+
r = ident.identify("MKVLAAGIVGLNLGGRSTGWQFDPESLVNRHKYWMFE" * 2)
|
| 89 |
+
assert r["molecule"] == "protein"
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def test_an_accession_is_not_treated_as_a_sequence():
|
| 93 |
+
r = ident.identify("NM_000207.3")
|
| 94 |
+
assert r["kind"] == "accession"
|
| 95 |
+
assert r["accession"] == "NM_000207.3"
|
| 96 |
+
assert r["family"] == "refseq"
|
| 97 |
+
# The bug this pins: accession.classify returns (family, molecule), NOT
|
| 98 |
+
# (kind, token). Unpacking it the other way sent the agent off to fetch a
|
| 99 |
+
# sequence literally named "dna".
|
| 100 |
+
assert "fetch_sequence with 'NM_000207.3'" in r["next"]
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def test_too_short_is_refused_with_a_reason():
|
| 104 |
+
r = ident.identify("ATGCA")
|
| 105 |
+
assert r["ok"] is False and r["kind"] == "too_short"
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def test_empty_input_is_refused():
|
| 109 |
+
assert ident.identify(" ")["kind"] == "empty"
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
# ββ DNA characterization βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 113 |
+
def test_a_clean_cds_is_called_a_clean_cds():
|
| 114 |
+
dna = "ATG" + "GCTAGCGGTACCGAATTCGATCTGAAAGCT" * 4 + "TAA"
|
| 115 |
+
r = ident.identify(dna)
|
| 116 |
+
assert r["detail"]["is_clean_cds"] is True
|
| 117 |
+
assert "clean CDS" in r["summary"]
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def test_an_internal_stop_disqualifies_a_clean_cds():
|
| 121 |
+
dna = "ATG" + "GCTAGCTAAGGTACCGAATTCGATCTGAAAGCT" * 4 + "TAA"
|
| 122 |
+
assert ident.identify(dna)["detail"]["is_clean_cds"] is False
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def test_random_dna_with_no_real_orf_says_so():
|
| 126 |
+
"""A stop codon turns up roughly every 21 codons by chance, so reporting a
|
| 127 |
+
short 'ORF' from noise would be worse than reporting none."""
|
| 128 |
+
dna = "TTTAAACCCGGG" * 8
|
| 129 |
+
r = ident.identify(dna)
|
| 130 |
+
assert r["detail"]["longest_orf"] is None
|
| 131 |
+
assert "no ORF" in r["summary"]
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def test_motifs_are_found_on_both_strands():
|
| 135 |
+
from dee.core import plasmid
|
| 136 |
+
t7 = dict((n, s) for n, s, t in plasmid._MOTIFS)["T7 promoter"]
|
| 137 |
+
fwd = ident.identify("GGGGCCCCAAAA" + t7 + "GGGGCCCCAAAA")
|
| 138 |
+
rev = ident.identify("GGGGCCCCAAAA" + plasmid.revcomp(t7) + "GGGGCCCCAAAA")
|
| 139 |
+
assert any(f["name"] == "T7 promoter" for f in fwd["detail"]["features"])
|
| 140 |
+
assert any(f["name"] == "T7 promoter" for f in rev["detail"]["features"])
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def test_ambiguity_codes_are_counted_not_silently_dropped():
|
| 144 |
+
dna = "ATGNNNGCTAGCGGTACCGAATTCGATCTGAAAGCTTAA"
|
| 145 |
+
assert ident.identify(dna)["detail"]["ambiguity_codes"] == 3
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
# ββ protein characterization βββββββββββββββββββββββββββββββββββββββββββββ
|
| 149 |
+
def test_tag_peptides_are_derived_from_the_dna_catalogue_not_typed_here():
|
| 150 |
+
"""One source of truth: correcting the DNA motif table must move these."""
|
| 151 |
+
from dee.core import edits, plasmid
|
| 152 |
+
derived = {t["peptide"] for t in ident._TAGS}
|
| 153 |
+
expected = {edits.translate(s) for n, s, ty in plasmid._MOTIFS
|
| 154 |
+
if ty == "tag" and len(s) % 3 == 0}
|
| 155 |
+
assert derived <= expected
|
| 156 |
+
assert len(derived) >= 5
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
def test_a_his_tag_is_found_in_a_protein():
|
| 160 |
+
r = ident.identify("MKVLAAGIVGLNLGGRSTGWQFDPESLVNRHKYWMFE" + "HHHHHH")
|
| 161 |
+
assert any(t["name"] == "6ΓHis tag" for t in r["detail"]["tags"])
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
def test_low_complexity_protein_is_flagged():
|
| 165 |
+
"""Either a synthetic linker or β far more often β DNA pasted into the
|
| 166 |
+
protein field. Both deserve a flag rather than a confident analysis."""
|
| 167 |
+
r = ident.identify("AAAAGGGGCCCCTTTT" * 6)
|
| 168 |
+
assert r["molecule"] == "dna" # correctly routed away from protein
|
| 169 |
+
r2 = ident.identify("MAAAAAGGGGGAAAAA" * 6)
|
| 170 |
+
assert r2["molecule"] == "protein"
|
| 171 |
+
assert r2["detail"]["low_complexity"] is True
|
| 172 |
+
assert "low residue diversity" in r2["summary"]
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
def test_a_normal_protein_is_not_flagged_low_complexity():
|
| 176 |
+
r = ident.identify("MKVLAAGIVGLNLGGRSTGWQFDPESLVNRHKYWMFECT" * 3)
|
| 177 |
+
assert r["detail"]["low_complexity"] is False
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
# ββ the agent tool βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 181 |
+
def test_the_agent_tool_is_registered_and_ungated():
|
| 182 |
+
from dee.core import agent_tools as t
|
| 183 |
+
assert "identify_sequence" in t._TOOLS
|
| 184 |
+
spec = [s for s in t.TOOL_SPECS
|
| 185 |
+
if s["function"]["name"] == "identify_sequence"][0]
|
| 186 |
+
assert "sequence" in spec["function"]["parameters"]["required"]
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
def test_the_tool_description_tells_the_model_not_to_claim_an_id():
|
| 190 |
+
from dee.core import agent_tools as t
|
| 191 |
+
spec = [s for s in t.TOOL_SPECS
|
| 192 |
+
if s["function"]["name"] == "identify_sequence"][0]
|
| 193 |
+
desc = spec["function"]["description"]
|
| 194 |
+
assert "ALWAYS false" in desc
|
| 195 |
+
assert "blast_sequence" in desc
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
def test_the_tool_runs_end_to_end():
|
| 199 |
+
from dee.core import agent_tools as t
|
| 200 |
+
out = t._TOOLS["identify_sequence"]["fn"]({"sequence": _cassette()})
|
| 201 |
+
assert out["ok"] and out["identified"] is False
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
def test_the_tool_refuses_empty_input():
|
| 205 |
+
from dee.core import agent_tools as t
|
| 206 |
+
assert t._TOOLS["identify_sequence"]["fn"]({"sequence": ""})["ok"] is False
|
tests/test_mutagenesis.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Audit #37: primers that MAKE the mutation, not just name it.
|
| 2 |
+
|
| 3 |
+
The engine says R175H is worth trying and then stops. Everything between
|
| 4 |
+
"good idea" and DNA in a tube β picking the codon, centring the mismatch,
|
| 5 |
+
balancing the two arms by Tm β was left to the user, and it is pure
|
| 6 |
+
arithmetic on a sequence they already hold.
|
| 7 |
+
|
| 8 |
+
The tests that matter here are the refusals. A primer pair is a thing someone
|
| 9 |
+
orders and puts in a reaction; designing one for a mutation the sequence
|
| 10 |
+
cannot support wastes a week, and the failure is invisible until sequencing.
|
| 11 |
+
"""
|
| 12 |
+
import pytest
|
| 13 |
+
|
| 14 |
+
from dee.core import agent_tools as t
|
| 15 |
+
from dee.core import mutagenesis as mut
|
| 16 |
+
from dee.core import orchestrator as orch
|
| 17 |
+
from dee.core.edits import translate
|
| 18 |
+
|
| 19 |
+
# M followed by 200 arginines. Synthetic on purpose: a real gene here would be
|
| 20 |
+
# a sequence typed from memory, which is the one thing this repo never does.
|
| 21 |
+
CDS = "ATG" + "CGT" * 200
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def test_the_designed_mutation_is_actually_installed():
|
| 25 |
+
"""The only end-to-end claim that matters: translate the product and the
|
| 26 |
+
new residue is there."""
|
| 27 |
+
r = mut.design(CDS, "R175H")
|
| 28 |
+
assert r["ok"]
|
| 29 |
+
assert translate(r["mutated_cds"])[174] == "H"
|
| 30 |
+
assert translate(r["mutated_cds"])[173] == "R" # neighbours untouched
|
| 31 |
+
assert len(r["mutated_cds"]) == len(CDS) # no frame shift
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def test_it_refuses_when_the_sequence_disagrees_with_the_label():
|
| 35 |
+
"""The expensive failure. If position 175 is not R, the user is holding a
|
| 36 |
+
different sequence than they think β designing anyway makes primers for a
|
| 37 |
+
mutation nobody asked for, and nobody finds out until sequencing."""
|
| 38 |
+
r = mut.design(CDS, "K175H")
|
| 39 |
+
assert r["ok"] is False and r["kind"] == "wt_mismatch"
|
| 40 |
+
assert "is R, not K" in r["error"]
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def test_a_position_past_the_end_is_refused_with_the_real_length():
|
| 44 |
+
r = mut.design(CDS, "R900H")
|
| 45 |
+
assert r["ok"] is False and r["kind"] == "out_of_range"
|
| 46 |
+
assert "201-residue" in r["error"]
|
| 47 |
+
assert "initiator ATG" in r["next"]
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def test_the_codon_is_the_host_s_and_the_choice_is_shown():
|
| 51 |
+
"""Six codons encode leucine and they are not interchangeable. The choice
|
| 52 |
+
is reported so it can be checked rather than trusted."""
|
| 53 |
+
r = mut.design(CDS, "R175L", host="e_coli")
|
| 54 |
+
assert r["ok"] and len(r["new_codon"]) == 3
|
| 55 |
+
assert translate(r["new_codon"]) == "L"
|
| 56 |
+
assert r["new_codon"] in r["codon_note"] and "e_coli" in r["codon_note"]
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def test_an_unknown_host_is_refused_rather_than_silently_defaulted():
|
| 60 |
+
"""A silent fallback to E. coli would codon-optimise a yeast construct for
|
| 61 |
+
the wrong organism and say nothing."""
|
| 62 |
+
r = mut.design(CDS, "R175H", host="klingon")
|
| 63 |
+
assert r["ok"] is False and r["kind"] == "no_codon"
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def test_the_mismatch_sits_in_the_middle_of_the_primer():
|
| 67 |
+
"""A mutation near a primer's end anneals poorly and the reaction fails.
|
| 68 |
+
Both flanks must clear the minimum."""
|
| 69 |
+
r = mut.design(CDS, "R175H")
|
| 70 |
+
codon_start = 174 * 3
|
| 71 |
+
for p in r["primers"]:
|
| 72 |
+
if p["role"] != "forward":
|
| 73 |
+
continue
|
| 74 |
+
left = codon_start - p["template_start"]
|
| 75 |
+
right = p["template_end"] - (codon_start + 3)
|
| 76 |
+
assert left >= mut.MIN_FLANK and right >= mut.MIN_FLANK, (left, right)
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def test_quikchange_primers_are_complementary_and_back_to_back_are_not():
|
| 80 |
+
"""Different kits, different primer geometry. Returning one shape for both
|
| 81 |
+
would produce primers that simply do not work in the user's protocol."""
|
| 82 |
+
from dee.core.primers import revcomp
|
| 83 |
+
q = mut.design(CDS, "R175H", method="quikchange")
|
| 84 |
+
f, r_ = [p["sequence"] for p in q["primers"]]
|
| 85 |
+
assert r_ == revcomp(f) and q["overlap_bp"] == len(f)
|
| 86 |
+
|
| 87 |
+
b = mut.design(CDS, "R175H", method="back_to_back")
|
| 88 |
+
bf, br = [p["sequence"] for p in b["primers"]]
|
| 89 |
+
assert br != revcomp(bf) and b["overlap_bp"] == 0
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def test_every_primer_carries_the_numbers_a_bench_scientist_checks():
|
| 93 |
+
r = mut.design(CDS, "R175H")
|
| 94 |
+
for p in r["primers"]:
|
| 95 |
+
assert p["tm_c"] is not None and p["gc_pct"] is not None
|
| 96 |
+
assert "hairpin" in p and "self_dimer_3p" in p and "gc_clamp" in p
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def test_a_tm_mismatch_between_the_arms_is_warned_about():
|
| 100 |
+
"""The cooler arm is where the reaction fails, and equal LENGTHS routinely
|
| 101 |
+
differ by several degrees."""
|
| 102 |
+
r = mut.design(CDS, "R175H")
|
| 103 |
+
tms = [p["tm_c"] for p in r["primers"]]
|
| 104 |
+
if abs(tms[0] - tms[1]) > mut.TM_TOLERANCE:
|
| 105 |
+
assert any("differ by" in w for w in r["warnings"])
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def test_it_never_claims_the_construct_is_verified():
|
| 109 |
+
"""Primers propose a change. Only sequencing confirms one, and the tool
|
| 110 |
+
must not let the agent imply otherwise."""
|
| 111 |
+
r = mut.design(CDS, "R175H")
|
| 112 |
+
assert "sequencing" in r["caveat"].lower()
|
| 113 |
+
assert "calculated, not measured" in r["caveat"]
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def test_malformed_input_is_refused_not_guessed():
|
| 117 |
+
assert mut.design(CDS, "nonsense")["ok"] is False
|
| 118 |
+
assert mut.design("", "R175H")["ok"] is False
|
| 119 |
+
assert mut.design(CDS, "R175H", method="magic")["ok"] is False
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def test_it_is_reachable_ungated_and_specced():
|
| 123 |
+
"""Fifth and sixth capabilities this week that existed and could not be
|
| 124 |
+
called. The lesson stuck: a module with no spec does not exist."""
|
| 125 |
+
assert "design_mutagenesis_primers" in t._TOOLS
|
| 126 |
+
assert any(s["function"]["name"] == "design_mutagenesis_primers"
|
| 127 |
+
for s in orch.TOOL_SPECS)
|
| 128 |
+
assert t._TOOLS["design_mutagenesis_primers"]["requires_signin"] is False
|
| 129 |
+
assert orch._requires_confirm("design_mutagenesis_primers") is False
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def test_the_spec_tells_the_model_about_the_refusal_and_the_caveat():
|
| 133 |
+
d = next(s["function"]["description"] for s in orch.TOOL_SPECS
|
| 134 |
+
if s["function"]["name"] == "design_mutagenesis_primers")
|
| 135 |
+
assert "REFUSES" in d
|
| 136 |
+
assert "sequenced" in d and "calculated, not measured" in d
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def test_the_tool_wrapper_matches_the_module():
|
| 140 |
+
out = t.execute_tool("design_mutagenesis_primers",
|
| 141 |
+
{"cds": CDS, "substitution": "R175H"},
|
| 142 |
+
auth_anonymous=True)
|
| 143 |
+
assert out["ok"] and len(out["primers"]) == 2
|
tests/test_overhangs.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Audit #40: why a Golden Gate with six correct fragments still comes back wrong.
|
| 2 |
+
|
| 3 |
+
simulate_assembly chains parts whose overhangs match and reports success β and
|
| 4 |
+
it is right, as a simulation. What it cannot see is that two overhangs are one
|
| 5 |
+
base apart, or that one is its own reverse complement. T4 ligase does not care
|
| 6 |
+
which partner it found, so the plate comes back with fragments in the wrong
|
| 7 |
+
order and the design looked fine the whole time.
|
| 8 |
+
|
| 9 |
+
All of it is decidable from four-base strings before anything is ordered,
|
| 10 |
+
which is the point: this is arithmetic, not prediction.
|
| 11 |
+
"""
|
| 12 |
+
import json
|
| 13 |
+
|
| 14 |
+
import pytest
|
| 15 |
+
|
| 16 |
+
from dee.core import agent_tools as t
|
| 17 |
+
from dee.core import orchestrator as orch
|
| 18 |
+
from dee.core import overhangs as oh
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def test_a_palindrome_is_fatal():
|
| 22 |
+
"""AATT is its own reverse complement, so the fragment ligates to itself
|
| 23 |
+
and to its own inversion. No amount of correct chaining saves it."""
|
| 24 |
+
assert oh.is_palindrome("AATT") and oh.is_palindrome("GGCC")
|
| 25 |
+
r = oh.check(["AATT", "GGAG", "AGGT", "TTCG"])
|
| 26 |
+
bad = [c for c in r["conflicts"] if c["kind"] == "palindromic"]
|
| 27 |
+
assert bad and bad[0]["severity"] == "fatal"
|
| 28 |
+
assert r["usable"] is False
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def test_complementary_overhangs_are_caught_because_the_junctions_swap():
|
| 32 |
+
"""revcomp(GGAG) == CTCC. Two junctions that are interchangeable give an
|
| 33 |
+
assembly with no defined order."""
|
| 34 |
+
assert oh.revcomp("GGAG") == "CTCC"
|
| 35 |
+
r = oh.check(["GGAG", "CTCC", "AATG", "TTCG"])
|
| 36 |
+
kinds = {c["kind"] for c in r["conflicts"]}
|
| 37 |
+
assert "complementary" in kinds and r["fatal"] >= 1
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def test_one_base_apart_is_flagged_before_it_costs_a_week():
|
| 41 |
+
"""The classic low-frequency misassembly: distance 1 means a single
|
| 42 |
+
mis-pairing still produces a ligatable junction."""
|
| 43 |
+
r = oh.check(["GGAG", "GGAC", "AATG", "TTCG"])
|
| 44 |
+
near = [c for c in r["conflicts"] if c["kind"] == "near_identical"]
|
| 45 |
+
assert near and near[0]["distance"] == 1
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def test_duplicates_are_fatal():
|
| 49 |
+
r = oh.check(["GGAG", "GGAG", "AATG"])
|
| 50 |
+
assert any(c["kind"] == "duplicate" for c in r["conflicts"])
|
| 51 |
+
assert r["usable"] is False
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def test_similarity_is_checked_against_the_reverse_complement_too():
|
| 55 |
+
"""Which strand the ligase sees depends on which fragment arrives, so a
|
| 56 |
+
near-match to the PARTNER'S COMPLEMENT is just as dangerous."""
|
| 57 |
+
a = "GGAG"
|
| 58 |
+
near_rc = "CTCG" # one base from revcomp(GGAG) = CTCC
|
| 59 |
+
assert oh.hamming(a, oh.revcomp(near_rc)) <= 1 or \
|
| 60 |
+
oh.hamming(oh.revcomp(a), near_rc) <= 1
|
| 61 |
+
r = oh.check([a, near_rc, "AATG"])
|
| 62 |
+
assert any(c["kind"] in ("near_identical", "complementary")
|
| 63 |
+
for c in r["conflicts"])
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def test_low_complexity_ends_are_noted_but_not_fatal():
|
| 67 |
+
"""All-AT ligates weakly and all-GC promiscuously. Real, but not a reason
|
| 68 |
+
to refuse a design that is otherwise sound."""
|
| 69 |
+
r = oh.check(["TTTT", "GGAG", "AATG"])
|
| 70 |
+
lc = [c for c in r["conflicts"] if c["kind"] == "low_complexity"]
|
| 71 |
+
assert lc and lc[0]["severity"] == "medium"
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def test_a_clean_set_is_reported_clean():
|
| 75 |
+
"""A checker that flags everything is a checker nobody reads."""
|
| 76 |
+
r = oh.check(["GGAG", "AATG", "AGGT", "TTCG"])
|
| 77 |
+
assert r["conflicts"] == [] and r["usable"] is True
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def test_suggested_sets_survive_their_own_checker():
|
| 81 |
+
"""The obvious way for a generator to be wrong, and free to test."""
|
| 82 |
+
for n in (4, 8, 12):
|
| 83 |
+
s = oh.suggest(n)
|
| 84 |
+
assert s["ok"] and len(s["overhangs"]) == n
|
| 85 |
+
assert oh.check(s["overhangs"])["conflicts"] == [], s["overhangs"]
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def test_suggest_is_deterministic_so_a_design_is_reproducible():
|
| 89 |
+
"""A methods section has to be able to name the set."""
|
| 90 |
+
assert oh.suggest(6)["overhangs"] == oh.suggest(6)["overhangs"]
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def test_running_out_of_space_is_said_plainly():
|
| 94 |
+
"""The 4-nt space genuinely exhausts. Returning a short set that looks
|
| 95 |
+
complete would be the worst possible answer."""
|
| 96 |
+
r = oh.suggest(400)
|
| 97 |
+
assert r["ok"] is False and r["kind"] == "exhausted"
|
| 98 |
+
assert "Lower min_distance" in r["next"]
|
| 99 |
+
assert r["found"], "the partial result is still worth showing"
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def test_wrong_length_overhangs_are_rejected_not_padded():
|
| 103 |
+
r = oh.check(["GGA", "GGAGT", "AATG"])
|
| 104 |
+
assert len(r["rejected"]) == 2 and r["count"] == 1
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def test_no_fidelity_percentage_is_ever_invented():
|
| 108 |
+
"""NEB's fidelity data is empirical, measured on real ligations. A number
|
| 109 |
+
derived from sequence alone would be a fabricated measurement wearing a
|
| 110 |
+
decimal point β the same failure as ΞΞG."""
|
| 111 |
+
r = oh.check(["AATT", "GGAG", "CTCC"])
|
| 112 |
+
conflicts = json.dumps(r["conflicts"]).lower()
|
| 113 |
+
assert "fidelity" not in conflicts and "%" not in conflicts
|
| 114 |
+
assert "does not have" in r["basis"]
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def test_it_is_reachable_ungated_and_specced():
|
| 118 |
+
assert "check_overhangs" in t._TOOLS
|
| 119 |
+
assert any(s["function"]["name"] == "check_overhangs" for s in orch.TOOL_SPECS)
|
| 120 |
+
assert orch._requires_confirm("check_overhangs") is False
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def test_the_summary_puts_fatal_first():
|
| 124 |
+
""""5 conflicts" reads the same whether the assembly is merely weak or
|
| 125 |
+
cannot have a defined order at all."""
|
| 126 |
+
r = t.execute_tool("check_overhangs",
|
| 127 |
+
{"overhangs": ["AATT", "GGAG", "CTCC", "GGAC"]},
|
| 128 |
+
auth_anonymous=True)
|
| 129 |
+
assert "fatal" in orch._summarize("check_overhangs", r)
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def test_the_spec_forbids_stating_a_fidelity_number():
|
| 133 |
+
d = next(s["function"]["description"] for s in orch.TOOL_SPECS
|
| 134 |
+
if s["function"]["name"] == "check_overhangs")
|
| 135 |
+
assert "fidelity percentage" in d and "never state one" in d
|
tests/test_solubility.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for aggregation risk factors (audit #23).
|
| 2 |
+
|
| 3 |
+
The load-bearing tests are the ones that stop this becoming a solubility
|
| 4 |
+
predictor. Everything else in the field reports a probability; this reports
|
| 5 |
+
named risk factors, and the difference is the whole point.
|
| 6 |
+
"""
|
| 7 |
+
import pytest
|
| 8 |
+
|
| 9 |
+
from dee.core import solubility as S
|
| 10 |
+
|
| 11 |
+
# A charged, hydrophilic coiled-coil-ish sequence and a membrane-like one.
|
| 12 |
+
SOLUBLE = "MKQLEDKVEELLSKNYHLENEVARLKKLVGER" * 3
|
| 13 |
+
GREASY = "MALLVAGLLIFLAVAVLVGWVLAGIFLAAVLLVGAWMLIFAVLGLAVLFWA" * 2
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
# ββ the honesty contract βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 17 |
+
def test_no_solubility_score_is_ever_reported():
|
| 18 |
+
r = S.assess(SOLUBLE)
|
| 19 |
+
assert r["ok"]
|
| 20 |
+
assert "no_score" in r
|
| 21 |
+
assert "would be invented" in r["no_score"]
|
| 22 |
+
# Nothing that reads as a probability or a prediction.
|
| 23 |
+
for key in ("solubility", "solubility_score", "probability", "prediction",
|
| 24 |
+
"soluble_pct"):
|
| 25 |
+
assert key not in r
|
| 26 |
+
assert key not in r["factors"]
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def test_the_patch_threshold_is_declared_as_a_convention_not_a_boundary():
|
| 30 |
+
"""Every threshold here is a plot-reading convention. Presenting one as a
|
| 31 |
+
validated solubility cutoff would be the same lie as printing a score."""
|
| 32 |
+
t = S.assess(SOLUBLE)["thresholds"]
|
| 33 |
+
assert "not a validated solubility cutoff" in t
|
| 34 |
+
assert str(S.PATCH_THRESHOLD) in t and str(S.KD_WINDOW) in t
|
| 35 |
+
# The one borrowed cutoff is attributed.
|
| 36 |
+
assert "Guruswamy's own" in t
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def test_the_scales_are_attributed():
|
| 40 |
+
s = S.assess(SOLUBLE)["scales"]
|
| 41 |
+
assert "Kyte-Doolittle" in s and "Guruswamy" in s
|
| 42 |
+
assert "no value here was entered by hand" in s
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def test_absolute_mode_points_at_the_comparison_mode():
|
| 46 |
+
assert "compare()" in S.assess(SOLUBLE)["caveat"]
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
# ββ absolute assessment ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 50 |
+
def test_a_hydrophilic_protein_is_not_flagged_hydrophobic():
|
| 51 |
+
r = S.assess(SOLUBLE)
|
| 52 |
+
assert r["factors"]["gravy"] < 0
|
| 53 |
+
assert not any(f["factor"] == "net hydrophobic" for f in r["flags"])
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def test_a_membrane_like_protein_is_flagged():
|
| 57 |
+
r = S.assess(GREASY)
|
| 58 |
+
assert r["factors"]["gravy"] > 0
|
| 59 |
+
names = [f["factor"] for f in r["flags"]]
|
| 60 |
+
assert "net hydrophobic" in names
|
| 61 |
+
assert "extended hydrophobic patch" in names
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def test_a_hydrophobic_patch_is_located_not_just_counted():
|
| 65 |
+
"""Whole-protein GRAVY averages a patch away. Aggregation happens at the
|
| 66 |
+
patch, so it has to be findable."""
|
| 67 |
+
r = S.assess(GREASY)
|
| 68 |
+
patches = r["factors"]["hydrophobic_patches"]
|
| 69 |
+
assert patches
|
| 70 |
+
assert all(p["start"] >= 1 and p["end"] <= len(GREASY) for p in patches)
|
| 71 |
+
assert all(p["end"] > p["start"] for p in patches)
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def test_pi_near_the_working_ph_is_flagged_with_the_reason():
|
| 75 |
+
r = S.assess(SOLUBLE, ph=round(S.assess(SOLUBLE)["factors"]
|
| 76 |
+
["isoelectric_point"], 1))
|
| 77 |
+
flag = [f for f in r["flags"] if "pI close" in f["factor"]]
|
| 78 |
+
assert flag
|
| 79 |
+
assert "electrostatic repulsion" in flag[0]["why"]
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def test_cysteines_are_only_flagged_where_the_chemistry_applies():
|
| 83 |
+
"""A reducing cytoplasm is an E. coli fact. Flagging Cys in a yeast or
|
| 84 |
+
mammalian host would be a warning about nothing."""
|
| 85 |
+
cys = "MKQLCDKVEELCSKNYHLENEVARLKKLVGER" * 3
|
| 86 |
+
assert any("cysteines" in f["factor"]
|
| 87 |
+
for f in S.assess(cys, host="e_coli")["flags"])
|
| 88 |
+
assert not any("cysteines" in f["factor"]
|
| 89 |
+
for f in S.assess(cys, host="human")["flags"])
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def test_unscorable_residues_are_excluded_and_reported():
|
| 93 |
+
"""X has no Kyte-Doolittle value. Dropping it silently would report
|
| 94 |
+
numbers for a sequence the user did not give."""
|
| 95 |
+
r = S.assess("MKQLEDKVEELLSKNYHLENEVARLKKLVGER" + "XXXX" + SOLUBLE)
|
| 96 |
+
assert r["unscored_residues"] == 4
|
| 97 |
+
assert "excluded" in r["note"]
|
| 98 |
+
assert r["factors"]["length"] == len(S._known(S._clean(
|
| 99 |
+
"MKQLEDKVEELLSKNYHLENEVARLKKLVGER" + "XXXX" + SOLUBLE)))
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def test_too_short_is_refused():
|
| 103 |
+
assert S.assess("MKQL")["kind"] == "too_short"
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
# ββ the comparison mode, which is the trustworthy one ββββββββββββββββββββ
|
| 107 |
+
def test_deltas_are_computed_before_rounding():
|
| 108 |
+
"""Differencing two values each already rounded to one decimal reports 0.0
|
| 109 |
+
for a real change β which would zero out exactly the small deltas this
|
| 110 |
+
mode exists to surface."""
|
| 111 |
+
wt = SOLUBLE
|
| 112 |
+
mut = wt[:20] + "L" + wt[21:]
|
| 113 |
+
c = S.compare(wt, mut)
|
| 114 |
+
# pI moves by ~1.064; a rounded-then-subtracted version reports 1.07.
|
| 115 |
+
assert c["deltas"]["isoelectric_point"] == pytest.approx(1.064, abs=0.005)
|
| 116 |
+
assert c["deltas"]["net_charge_at_ph"] != 1.0
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def test_comparison_states_why_it_is_more_trustworthy():
|
| 120 |
+
c = S.compare(SOLUBLE, SOLUBLE[:5] + "C" + SOLUBLE[6:])
|
| 121 |
+
assert "cancel" in c["why_this_is_more_trustworthy"]
|
| 122 |
+
assert "not a predicted change in solubility" in c["no_score"]
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def test_an_introduced_cysteine_is_called_out():
|
| 126 |
+
c = S.compare(SOLUBLE, SOLUBLE[:5] + "C" + SOLUBLE[6:], host="e_coli")
|
| 127 |
+
assert c["deltas"]["cysteines"] == 1
|
| 128 |
+
assert any("free thiol" in n for n in c["notes"])
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def test_a_removed_cysteine_mentions_the_orphaned_partner():
|
| 132 |
+
wt = SOLUBLE[:5] + "C" + SOLUBLE[6:15] + "C" + SOLUBLE[16:]
|
| 133 |
+
mut = wt[:5] + "A" + wt[6:]
|
| 134 |
+
c = S.compare(wt, mut)
|
| 135 |
+
assert c["deltas"]["cysteines"] == -1
|
| 136 |
+
assert any("partner is now" in n for n in c["notes"])
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def test_a_change_inside_an_existing_patch_is_distinguished():
|
| 140 |
+
"""Adding a leucine to an already-greasy stretch compounds a risk;
|
| 141 |
+
adding one to a polar region creates a smaller, different one."""
|
| 142 |
+
mut = GREASY[:10] + "L" + GREASY[11:]
|
| 143 |
+
c = S.compare(GREASY, mut)
|
| 144 |
+
assert c["in_existing_hydrophobic_patch"]
|
| 145 |
+
assert any("compounds an existing risk" in n for n in c["notes"])
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def test_changes_are_enumerated_with_positions():
|
| 149 |
+
mut = SOLUBLE[:3] + "W" + SOLUBLE[4:]
|
| 150 |
+
c = S.compare(SOLUBLE, mut)
|
| 151 |
+
assert c["changes"] == [{"position": 4, "from": SOLUBLE[3], "to": "W"}]
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
def test_an_indel_is_refused_rather_than_compared_positionally():
|
| 155 |
+
"""After an indel the two sequences are no longer position-comparable, so
|
| 156 |
+
a per-position diff would report every downstream residue as changed."""
|
| 157 |
+
r = S.compare(SOLUBLE, SOLUBLE[:-5])
|
| 158 |
+
assert r["ok"] is False and r["kind"] == "length_mismatch"
|
| 159 |
+
assert "compare_sequences" in r["next"]
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
def test_identical_sequences_are_refused():
|
| 163 |
+
assert S.compare(SOLUBLE, SOLUBLE)["kind"] == "identical"
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
# ββ the agent tool βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 167 |
+
def test_the_tool_supports_both_modes():
|
| 168 |
+
from dee.core import agent_tools as t
|
| 169 |
+
fn = t._TOOLS["assess_solubility"]["fn"]
|
| 170 |
+
assert fn({"protein": SOLUBLE})["ok"]
|
| 171 |
+
both = fn({"wild_type": SOLUBLE, "mutant": SOLUBLE[:3] + "W" + SOLUBLE[4:]})
|
| 172 |
+
assert both["ok"] and both["changes"]
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
def test_the_tool_refuses_with_neither():
|
| 176 |
+
from dee.core import agent_tools as t
|
| 177 |
+
assert t._TOOLS["assess_solubility"]["fn"]({})["ok"] is False
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
def test_the_spec_forbids_inventing_a_score():
|
| 181 |
+
from dee.core import agent_tools as t
|
| 182 |
+
desc = next(s for s in t.TOOL_SPECS
|
| 183 |
+
if s["function"]["name"] == "assess_solubility")["function"]["description"]
|
| 184 |
+
assert "must not invent one" in desc
|
| 185 |
+
assert "PREFER THE SECOND" in desc
|
tests/test_vector_lookup.py
CHANGED
|
@@ -170,3 +170,48 @@ def test_the_tool_description_points_away_from_fetch_sequence():
|
|
| 170 |
desc = spec["function"]["description"]
|
| 171 |
assert "fetch_sequence" in desc
|
| 172 |
assert "refusal is NOT" in desc
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 170 |
desc = spec["function"]["description"]
|
| 171 |
assert "fetch_sequence" in desc
|
| 172 |
assert "refusal is NOT" in desc
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
# --------------------------------------------------------------------------- #
|
| 176 |
+
# where the plasmid actually comes from (audit #6, the honest half)
|
| 177 |
+
# --------------------------------------------------------------------------- #
|
| 178 |
+
def test_the_addgene_url_is_built_in_code_not_left_to_the_model():
|
| 179 |
+
"""The stored ids carry a lot suffix ("69864-3") and the plasmid page is
|
| 180 |
+
keyed on the number alone. An agent assembling the URL from the raw string
|
| 181 |
+
produces a 404, which a user reads as "this vector doesn't exist"."""
|
| 182 |
+
from dee.core import vectors as v
|
| 183 |
+
entry = v.find("pET-28a(+)")
|
| 184 |
+
assert entry["addgene"] == "69864-3"
|
| 185 |
+
assert v.addgene_url(entry) == "https://www.addgene.org/69864/"
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
def test_every_catalogue_addgene_id_yields_a_well_formed_url():
|
| 189 |
+
from dee.core import vectors as v
|
| 190 |
+
import re
|
| 191 |
+
have = [e for e in v._load().values() if e.get("addgene")]
|
| 192 |
+
assert have, "the catalogue records Addgene ids"
|
| 193 |
+
for entry in have:
|
| 194 |
+
url = v.addgene_url(entry)
|
| 195 |
+
assert re.fullmatch(r"https://www\.addgene\.org/\d+/", url), entry["name"]
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
def test_a_vector_with_no_addgene_id_gets_no_url_rather_than_a_guess():
|
| 199 |
+
from dee.core import vectors as v
|
| 200 |
+
entry = v.find("pUC19")
|
| 201 |
+
assert not entry.get("addgene")
|
| 202 |
+
assert v.addgene_url(entry) is None
|
| 203 |
+
out = t._TOOLS["lookup_vector"]["fn"]({"name": "pUC19"})
|
| 204 |
+
assert out["ok"] and "addgene_url" not in out
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
def test_lookup_vector_hands_over_the_link_when_it_has_one():
|
| 208 |
+
out = t._TOOLS["lookup_vector"]["fn"]({"name": "lentiCRISPR v2"})
|
| 209 |
+
assert out["addgene_url"] == "https://www.addgene.org/52961/"
|
| 210 |
+
assert "Give the user the link" in out["where_to_get_it"]
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
def test_addgene_url_survives_junk_input():
|
| 214 |
+
from dee.core import vectors as v
|
| 215 |
+
assert v.addgene_url(None) is None
|
| 216 |
+
assert v.addgene_url({}) is None
|
| 217 |
+
assert v.addgene_url({"addgene": "not-a-number"}) is None
|