github-actions[bot] commited on
Commit
1bf37a2
Β·
1 Parent(s): 15766ed

Deploy 90a38dc

Browse files

Merge pull request #18 from WINTER4000/feat/capability-tools

Source: https://github.com/WINTER4000/turingDNA/commit/90a38dcdce1a8d6d014de0702fe8e26af12e4d8b

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