github-actions[bot] commited on
Commit
0269d22
Β·
1 Parent(s): 8aa2c1c

Deploy 9eecba1

Browse files

Give Evo 2 a place in the product, not just on a GPU

Source: https://github.com/WINTER4000/turingDNA/commit/9eecba109b5215f1a319c81fee2d9132d7f46140

dee/server.py CHANGED
@@ -819,6 +819,12 @@ _RL_RULES = [
819
  ("/api/primers/multiplex", (30, 60)),
820
  ("/api/primers/analyze", (40, 60)),
821
  ("/api/crispr", (40, 60)),
 
 
 
 
 
 
822
  ("/api/plasmid", (60, 60)),
823
  ("/api/ping", (60, 60)), # dwell heartbeat β€” its own
824
  # bucket so ~2/min never eats
@@ -864,6 +870,8 @@ _EVENT_KINDS = {
864
  "/api/primers/save": "primer_save",
865
  "/api/align": "sequence_align",
866
  "/api/de/round2": "de_round2",
 
 
867
  }
868
  # Sort longest-prefix-first so the most specific rule matches.
869
  _RL_RULES.sort(key=lambda r: len(r[0]), reverse=True)
@@ -3300,6 +3308,108 @@ def create_app() -> Flask:
3300
  resp.headers["Cache-Control"] = "no-store, max-age=0"
3301
  return resp
3302
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3303
  @app.get("/api/benchmarks")
3304
  def benchmarks() -> Response:
3305
  """The receipts β€” how well the engine's zero-shot ranking predicts
 
819
  ("/api/primers/multiplex", (30, 60)),
820
  ("/api/primers/analyze", (40, 60)),
821
  ("/api/crispr", (40, 60)),
822
+ # Evo 2 (7B) on a rented GPU β€” the most expensive call in the product by a
823
+ # wide margin, and unlike ESM-2's Achilles tier there is no free local path
824
+ # to fall back to. Tighter than every other tool bucket on purpose: this
825
+ # limit is about the GPU bill, not about protecting a worker thread.
826
+ ("/api/dna/generate", (6, 60)), # autoregressive β€” priciest
827
+ ("/api/dna", (12, 60)),
828
  ("/api/plasmid", (60, 60)),
829
  ("/api/ping", (60, 60)), # dwell heartbeat β€” its own
830
  # bucket so ~2/min never eats
 
870
  "/api/primers/save": "primer_save",
871
  "/api/align": "sequence_align",
872
  "/api/de/round2": "de_round2",
873
+ "/api/dna/score": "dna_score",
874
+ "/api/dna/generate": "dna_generate",
875
  }
876
  # Sort longest-prefix-first so the most specific rule matches.
877
  _RL_RULES.sort(key=lambda r: len(r[0]), reverse=True)
 
3308
  resp.headers["Cache-Control"] = "no-store, max-age=0"
3309
  return resp
3310
 
3311
+ # ── DNA-level scoring / generation (Evo 2) ───────────────────────────
3312
+ # Until now the ONLY route to Evo 2 was the agent deciding to call
3313
+ # score_or_generate_dna mid-conversation. The engine computed dna_models
3314
+ # for /api/models and no frontend read it, so a capability that runs on a
3315
+ # 7B genome model was, in practice, unreachable by clicking. These are the
3316
+ # two endpoints the DNA view calls.
3317
+ #
3318
+ # Both are deliberately thin: dee/core/dna_scoring.py already owns tier
3319
+ # rules, bounds and the "not configured is not the same as broken"
3320
+ # distinction, and duplicating any of that here would give the product two
3321
+ # answers to the same question.
3322
+
3323
+ def _dna_signin_gate(where: str):
3324
+ """Same contract as CRISPR/primers: no anonymous use. Distinct from
3325
+ the trial timer β€” the 403 carries kind=signin_required so auth.js
3326
+ routes to /signin rather than showing the trial modal."""
3327
+ auth = _auth.get_auth()
3328
+ if auth.anonymous:
3329
+ return jsonify({
3330
+ "error": ("DNA scoring requires a free account. Sign in or "
3331
+ "create one to keep going."),
3332
+ "kind": "signin_required",
3333
+ "signup_url": f"https://turingdna.com/signin/?from={where}",
3334
+ }), 403
3335
+ return None
3336
+
3337
+ @app.post("/api/dna/score")
3338
+ def dna_score() -> Response:
3339
+ gate = _dna_signin_gate("dna")
3340
+ if gate is not None:
3341
+ return gate
3342
+
3343
+ body = request.get_json(force=True, silent=True) or {}
3344
+ reference = "".join(str(body.get("reference") or "").split()).upper()
3345
+ raw_variants = body.get("variants") or []
3346
+ tier = str(body.get("tier") or "achilles").strip().lower()
3347
+
3348
+ if not reference:
3349
+ return jsonify({"error": "missing 'reference'"}), 400
3350
+ if not isinstance(raw_variants, list) or not raw_variants:
3351
+ return jsonify({"error": "missing 'variants'"}), 400
3352
+ variants = [str(v).strip().upper() for v in raw_variants if str(v).strip()]
3353
+ if not variants:
3354
+ return jsonify({"error": "missing 'variants'"}), 400
3355
+
3356
+ try:
3357
+ result = _dna_scoring.score_dna_variants(reference, variants, tier=tier)
3358
+ except _dna_scoring.DnaModelUnavailable as exc:
3359
+ # Customer-safe by construction β€” dna_scoring keeps env-var names
3360
+ # and repo paths in the log, not in this message.
3361
+ return jsonify({"error": str(exc), "kind": "dna_unavailable"}), 503
3362
+ except ValueError as exc:
3363
+ return jsonify({"error": str(exc)}), 400
3364
+ except Exception: # noqa: BLE001
3365
+ app.logger.exception("dna_score failed")
3366
+ return jsonify({
3367
+ "error": ("The DNA model didn't return a result for that "
3368
+ "request. Nothing was scored β€” try again, and if it "
3369
+ "keeps failing the backend is having a moment."),
3370
+ "kind": "dna_error",
3371
+ }), 502
3372
+ return jsonify(result)
3373
+
3374
+ @app.post("/api/dna/generate")
3375
+ def dna_generate() -> Response:
3376
+ gate = _dna_signin_gate("dna")
3377
+ if gate is not None:
3378
+ return gate
3379
+
3380
+ body = request.get_json(force=True, silent=True) or {}
3381
+ prompt = "".join(str(body.get("prompt") or "").split()).upper()
3382
+ tier = str(body.get("tier") or "prometheus").strip().lower()
3383
+ if not prompt:
3384
+ return jsonify({"error": "missing 'prompt'"}), 400
3385
+
3386
+ try:
3387
+ n_tokens = int(body.get("n_tokens") or 200)
3388
+ temperature = float(body.get("temperature") or 1.0)
3389
+ top_k = int(body.get("top_k") or 4)
3390
+ except (TypeError, ValueError):
3391
+ return jsonify({"error": "n_tokens/temperature/top_k must be numbers"}), 400
3392
+
3393
+ try:
3394
+ result = _dna_scoring.generate_dna_sequence(
3395
+ prompt, n_tokens=n_tokens, tier=tier,
3396
+ temperature=temperature, top_k=top_k)
3397
+ except _dna_scoring.DnaModelUnavailable as exc:
3398
+ # Also the path for "generation is Prometheus-only" β€” enforced in
3399
+ # dna_scoring AND again in modal/evo2_scoring, so a caller that
3400
+ # skips this route still gets refused.
3401
+ return jsonify({"error": str(exc), "kind": "dna_unavailable"}), 503
3402
+ except ValueError as exc:
3403
+ return jsonify({"error": str(exc)}), 400
3404
+ except Exception: # noqa: BLE001
3405
+ app.logger.exception("dna_generate failed")
3406
+ return jsonify({
3407
+ "error": ("The DNA model didn't return a sequence for that "
3408
+ "prompt. Nothing was generated β€” try again."),
3409
+ "kind": "dna_error",
3410
+ }), 502
3411
+ return jsonify(result)
3412
+
3413
  @app.get("/api/benchmarks")
3414
  def benchmarks() -> Response:
3415
  """The receipts β€” how well the engine's zero-shot ranking predicts
dee/static/app.css CHANGED
@@ -9691,3 +9691,126 @@ body.de-agent-run .dna-edit-actions { display: none; }
9691
  font-size: 10px; line-height: 1.5; color: var(--ink-faint);
9692
  border-top: 1px solid var(--line); padding-top: 7px;
9693
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9691
  font-size: 10px; line-height: 1.5; color: var(--ink-faint);
9692
  border-top: 1px solid var(--line); padding-top: 7px;
9693
  }
9694
+
9695
+ /* ═══════════════════════════════════════════════════════════════════════
9696
+ DNA Design (Evo 2) β€” mirrors the primer/CRISPR card idiom deliberately,
9697
+ so the newest tool doesn't announce itself as a bolt-on.
9698
+ Tokens: --bg-card / --bg-raised / --line-strong / --ink-*. There is no
9699
+ --surface or --surface-raised in this stylesheet; using them renders
9700
+ invisible components and no test catches it.
9701
+ ═══════════════════════════════════════════════════════════════════════ */
9702
+
9703
+ .dna-mode-row { display: flex; gap: 6px; margin-bottom: 16px; }
9704
+ .dna-mode {
9705
+ padding: 7px 14px; border-radius: var(--r-2);
9706
+ border: 1px solid var(--line-strong); background: var(--gray-0);
9707
+ color: var(--ink-soft); font-size: 12.5px; cursor: pointer;
9708
+ font-family: inherit;
9709
+ }
9710
+ .dna-mode:hover { color: var(--ink); border-color: var(--line-bold); }
9711
+ .dna-mode.is-active {
9712
+ background: var(--ink-strong); color: var(--on-ink);
9713
+ border-color: var(--ink-strong);
9714
+ }
9715
+
9716
+ .dna-textarea {
9717
+ width: 100%; box-sizing: border-box; resize: vertical;
9718
+ font-family: var(--font-mono); font-size: 13px; line-height: 1.5;
9719
+ padding: 12px 14px; border: 1px solid var(--line-strong); border-radius: var(--r-3);
9720
+ background: var(--gray-0); color: var(--ink);
9721
+ }
9722
+ .dna-textarea:focus { outline: none; border-color: var(--brand); }
9723
+
9724
+ .dna-meta { margin: 6px 0 14px; font-size: 11.5px; color: var(--ink-faint); }
9725
+ .dna-meta.is-warn { color: var(--warning); }
9726
+
9727
+ .dna-select {
9728
+ width: 100%; box-sizing: border-box; padding: 9px 12px;
9729
+ border: 1px solid var(--line-strong); border-radius: var(--r-2);
9730
+ background: var(--gray-0); color: var(--ink); font-size: 13px;
9731
+ font-family: inherit;
9732
+ }
9733
+ .dna-select:focus { outline: none; border-color: var(--brand); }
9734
+ /* A tier that exists but isn't reachable stays visible and disabled β€” that
9735
+ is a more useful statement than quietly dropping the row. */
9736
+ .dna-select option:disabled { color: var(--ink-disabled); }
9737
+
9738
+ .dna-gen-knobs { display: flex; gap: 14px; flex-wrap: wrap; margin-bottom: 14px; }
9739
+ .dna-knob { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: var(--ink-soft); }
9740
+ .dna-knob input {
9741
+ width: 110px; padding: 7px 10px; font-family: var(--font-mono); font-size: 13px;
9742
+ border: 1px solid var(--line-strong); border-radius: var(--r-2);
9743
+ background: var(--gray-0); color: var(--ink);
9744
+ }
9745
+ .dna-knob input:focus { outline: none; border-color: var(--brand); }
9746
+
9747
+ .dna-actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 14px; }
9748
+
9749
+ /* The wait matters: a cold 7B container is ~80s and a silent spinner over
9750
+ that long is indistinguishable from a hang. */
9751
+ .dna-wait {
9752
+ margin-top: 12px; font-size: 12.5px; color: var(--ink-soft);
9753
+ background: var(--gray-1); border-left: 3px solid var(--line-bold);
9754
+ padding: 9px 12px; border-radius: var(--r-2);
9755
+ }
9756
+
9757
+ .dna-results-head {
9758
+ display: flex; align-items: baseline; justify-content: space-between;
9759
+ gap: 12px; flex-wrap: wrap; margin-bottom: 10px;
9760
+ }
9761
+ .dna-results-title { font-size: 14px; color: var(--ink-strong); font-weight: 600; }
9762
+ .dna-results-meta { font-size: 11.5px; color: var(--ink-faint); font-family: var(--font-mono); }
9763
+
9764
+ .dna-table { width: 100%; border-collapse: collapse; font-size: 13px; margin-top: 8px; }
9765
+ .dna-table th {
9766
+ text-align: left; font-size: 11px; text-transform: uppercase;
9767
+ letter-spacing: .06em; color: var(--ink-faint); font-weight: 600;
9768
+ padding: 6px 10px 6px 0; border-bottom: 1px solid var(--line);
9769
+ }
9770
+ .dna-table td { padding: 8px 10px 8px 0; border-bottom: 1px solid var(--line); vertical-align: middle; }
9771
+ .dna-table td:last-child, .dna-table th:last-child { width: 42%; padding-right: 0; }
9772
+
9773
+ .dna-delta { white-space: nowrap; }
9774
+ .dna-delta--neg { color: var(--danger); }
9775
+ .dna-delta--pos { color: var(--success); }
9776
+
9777
+ .dna-bar { display: block; height: 8px; border-radius: 999px; min-width: 2px; }
9778
+ .dna-bar--neg { background: color-mix(in srgb, var(--danger) 55%, transparent); }
9779
+ .dna-bar--pos { background: color-mix(in srgb, var(--success) 55%, transparent); }
9780
+
9781
+ .dna-scale-note { margin-top: 10px; font-size: 11.5px; color: var(--ink-faint); line-height: 1.5; }
9782
+
9783
+ /* Refused variants are an input finding, never folded into "no result". */
9784
+ .dna-skipped {
9785
+ margin-top: 16px; padding: 12px 14px; border-radius: var(--r-2);
9786
+ background: color-mix(in srgb, var(--warning) 8%, transparent);
9787
+ border-left: 3px solid var(--warning);
9788
+ }
9789
+ .dna-skipped-hd { margin: 0 0 6px; font-size: 12px; font-weight: 600; color: var(--warning); }
9790
+ .dna-skipped ul { margin: 0; padding-left: 18px; font-size: 12.5px; color: var(--ink-soft); }
9791
+ .dna-skipped li { margin: 3px 0; }
9792
+ .dna-skipped-why { margin: 8px 0 0; font-size: 11.5px; color: var(--ink-faint); line-height: 1.5; }
9793
+
9794
+ .dna-generated {
9795
+ margin: 8px 0 0; padding: 12px 14px; border-radius: var(--r-2);
9796
+ background: var(--gray-1); border: 1px solid var(--line);
9797
+ font-size: 12.5px; line-height: 1.6; color: var(--ink);
9798
+ white-space: pre-wrap; word-break: break-all; overflow-x: auto;
9799
+ }
9800
+ .dna-empty { font-size: 13px; color: var(--ink-faint); margin: 8px 0 0; }
9801
+
9802
+ /* Mobile: the standing rule is 44px tap targets and nothing under 12px. */
9803
+ @media (max-width: 720px) {
9804
+ .dna-mode { padding: 11px 16px; min-height: 44px; font-size: 13px; }
9805
+ .dna-actions { flex-direction: column-reverse; }
9806
+ .dna-actions button { width: 100%; min-height: 44px; }
9807
+ .dna-knob input { width: 100%; min-height: 44px; }
9808
+ .dna-gen-knobs { flex-direction: column; gap: 10px; }
9809
+ .dna-table { font-size: 12px; }
9810
+ .dna-table td:last-child, .dna-table th:last-child { width: 30%; }
9811
+ /* The standing rule is nothing under 12px on a phone. Shared components
9812
+ (<code>, <summary>) sit at 11–11.5px across every existing view and
9813
+ are left alone here β€” but everything this tool owns meets it. */
9814
+ .dna-meta, .dna-results-meta, .dna-scale-note,
9815
+ .dna-skipped-why, .dna-table th { font-size: 12px; }
9816
+ }
dee/static/app.js CHANGED
@@ -390,7 +390,9 @@ renderGutter();
390
  // existed): 'turing' leads the list and is the no-hash fallback. The other
391
  // four tools stay one click away for hands-on work β€” Turing just isn't a
392
  // nav-rail peer anymore, it's what a session starts with.
393
- const ROUTES = ['mission', 'turing', 'structure', 'plasmid', 'design', 'crispr', 'primers', 'docs'];
 
 
394
 
395
  // ── UI mode flag (Mission-Control-+-Bench re-architecture, 2026-07-13) ──
396
  // 'bench' is now the default UI; ?ui=classic remains as a rollback escape
@@ -10311,6 +10313,7 @@ function runOracle(opts){
10311
  // The tools, for the ⌘K command palette (verbs on the current work).
10312
  const TOOLS = [
10313
  { label: 'Evolve a sequence', hint: 'Directed evolution', route: 'design' },
 
10314
  { label: 'Build a plasmid', hint: 'Map & annotate a construct', route: 'plasmid' },
10315
  { label: 'Design CRISPR guides', hint: 'Guides + specificity', route: 'crispr' },
10316
  { label: 'Check primers', hint: 'Tm, dimers, specificity', route: 'primers' },
@@ -10348,6 +10351,10 @@ function runOracle(opts){
10348
  { route: 'plasmid', label: 'Build a plasmid', kw: ['plasmid','vector','clone','clonin','annotate','assembl','gibson','golden','backbone','insert','map '] },
10349
  { route: 'crispr', label: 'Design CRISPR guides', kw: ['crispr','guide','grna','sgrna','knockout','knock out','cas9','base edit','base-edit','excis','disrupt'] },
10350
  { route: 'primers', label: 'Check primers', kw: ['primer',' pcr','anneal','dimer','amplif','oligo','melting'] },
 
 
 
 
10351
  ];
10352
  function matchIntent(q) {
10353
  const s = ' ' + String(q || '').toLowerCase() + ' ';
@@ -10944,7 +10951,11 @@ function runOracle(opts){
10944
  // keep working. Only ever active in the opt-in bench UI.
10945
  // ═══════════════════════════════════════════════════════════════════════
10946
  (function () {
10947
- const TAB_ROUTES = ['structure', 'design', 'plasmid', 'crispr', 'primers'];
 
 
 
 
10948
  let current = null; // { name, sub, phaseIdx, route }
10949
 
10950
  function el(id) { return document.getElementById(id); }
@@ -11894,3 +11905,373 @@ if (document.readyState === 'loading') {
11894
  } else {
11895
  initModelPicker();
11896
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
390
  // existed): 'turing' leads the list and is the no-hash fallback. The other
391
  // four tools stay one click away for hands-on work β€” Turing just isn't a
392
  // nav-rail peer anymore, it's what a session starts with.
393
+ // 'dna' (Evo 2) sits next to 'design': DE designs at the protein level, DNA
394
+ // Design at the nucleotide level. Same phase of the loop, different molecule.
395
+ const ROUTES = ['mission', 'turing', 'structure', 'plasmid', 'design', 'dna', 'crispr', 'primers', 'docs'];
396
 
397
  // ── UI mode flag (Mission-Control-+-Bench re-architecture, 2026-07-13) ──
398
  // 'bench' is now the default UI; ?ui=classic remains as a rollback escape
 
10313
  // The tools, for the ⌘K command palette (verbs on the current work).
10314
  const TOOLS = [
10315
  { label: 'Evolve a sequence', hint: 'Directed evolution', route: 'design' },
10316
+ { label: 'Score a DNA change', hint: 'Promoters, splice sites, UTRs', route: 'dna' },
10317
  { label: 'Build a plasmid', hint: 'Map & annotate a construct', route: 'plasmid' },
10318
  { label: 'Design CRISPR guides', hint: 'Guides + specificity', route: 'crispr' },
10319
  { label: 'Check primers', hint: 'Tm, dimers, specificity', route: 'primers' },
 
10351
  { route: 'plasmid', label: 'Build a plasmid', kw: ['plasmid','vector','clone','clonin','annotate','assembl','gibson','golden','backbone','insert','map '] },
10352
  { route: 'crispr', label: 'Design CRISPR guides', kw: ['crispr','guide','grna','sgrna','knockout','knock out','cas9','base edit','base-edit','excis','disrupt'] },
10353
  { route: 'primers', label: 'Check primers', kw: ['primer',' pcr','anneal','dimer','amplif','oligo','melting'] },
10354
+ // DNA-level, so the vocabulary is regulatory/non-coding rather than
10355
+ // protein. Deliberately LAST: 'design'/'crispr' above own the terms a
10356
+ // protein or editing question would use, and matching is first-hit.
10357
+ { route: 'dna', label: 'Score a DNA change', kw: ['promoter','enhancer','splice','utr',' rbs','ribosome binding','non-coding','noncoding','regulatory','intron','terminator','operator','evo 2','evo2','nucleotide'] },
10358
  ];
10359
  function matchIntent(q) {
10360
  const s = ' ' + String(q || '').toLowerCase() + ' ';
 
10951
  // keep working. Only ever active in the opt-in bench UI.
10952
  // ═══════════════════════════════════════════════════════════════════════
10953
  (function () {
10954
+ // 'dna' sits next to 'design' for the same reason it does in the nav:
10955
+ // both are the Design phase, one at the protein level and one at the
10956
+ // nucleotide level. Bench is the DEFAULT UI β€” a route missing from this
10957
+ // list has a nav entry that goes nowhere.
10958
+ const TAB_ROUTES = ['structure', 'design', 'dna', 'plasmid', 'crispr', 'primers'];
10959
  let current = null; // { name, sub, phaseIdx, route }
10960
 
10961
  function el(id) { return document.getElementById(id); }
 
11905
  } else {
11906
  initModelPicker();
11907
  }
11908
+
11909
+
11910
+ // ═══════════════════════════════════════════════════════════════════════
11911
+ // DNA Design β€” Evo 2 (7B) scoring and generation
11912
+ // ═══════════════════════════════════════════════════════════════════════
11913
+ // The DNA-level counterpart to Directed Evolution. Before this view, the only
11914
+ // way to reach Evo 2 was for Turing to decide to call score_or_generate_dna
11915
+ // mid-conversation: /api/models computed `dna_models` and NOTHING in the
11916
+ // frontend read it, so the one capability that understands non-coding and
11917
+ // regulatory sequence was unreachable by clicking.
11918
+ //
11919
+ // Two things this file is careful about, both learned the hard way elsewhere
11920
+ // in this codebase:
11921
+ //
11922
+ // Β· Availability is what the SERVER says, per tier. A tier whose backend
11923
+ // isn't reachable is disabled with its reason shown, never offered and
11924
+ // then failed. (/api/models' `available` is a live probe now β€” see
11925
+ // dee/core/modal_client.reachable.)
11926
+ // Β· A cold 7B container takes ~80s (warm ~23s, both measured). A spinner
11927
+ // with no elapsed time over that long is indistinguishable from a hang,
11928
+ // which is the exact complaint the Interaction Radar's elapsed-time work
11929
+ // already answered once.
11930
+ // ═══════════════════════════════════════��═══════════════════════════════
11931
+ (function () {
11932
+ const $ = (id) => document.getElementById(id);
11933
+ const esc = (s) => (typeof escapeHtml === 'function' ? escapeHtml(String(s)) : String(s));
11934
+
11935
+ // The E. coli lac promoter / operator region β€” the same fragment used to
11936
+ // verify the backend end to end, with substitutions whose WT bases are
11937
+ // real. An example that gets refused would teach the wrong lesson.
11938
+ const EXAMPLE_REF =
11939
+ 'TTTACACTTTATGCTTCCGGCTCGTATGTTGTGTGGAATTGTGAGCGGATAACAATTTCA' +
11940
+ 'CACAGGAAACAGCTATGACCATGATTACGGATTCACTGGCCGTCGTTTTACAA';
11941
+ const EXAMPLE_VARIANTS = 'T10G\nG20C\nT30A\nG45C';
11942
+
11943
+ let mode = 'score';
11944
+ let tiers = [];
11945
+ let busy = false;
11946
+ let timerId = null;
11947
+
11948
+ function view() { return document.querySelector('[data-view="dna"]'); }
11949
+
11950
+ // ── tier picker, driven by what the server says is reachable ─────────
11951
+ async function loadTiers() {
11952
+ const sel = $('dnaTier');
11953
+ if (!sel) return;
11954
+ try {
11955
+ const res = await fetch('/api/models');
11956
+ const data = await res.json();
11957
+ tiers = (data && data.dna_models) || [];
11958
+ } catch (_) {
11959
+ tiers = [];
11960
+ }
11961
+ if (!tiers.length) {
11962
+ sel.innerHTML = '<option value="">DNA scoring isn\'t available here</option>';
11963
+ sel.disabled = true;
11964
+ setTierNote('');
11965
+ return;
11966
+ }
11967
+ sel.disabled = false;
11968
+ sel.innerHTML = tiers.map((t) => {
11969
+ // Disabled rather than hidden: "this tier exists but isn't running
11970
+ // right now" is a different, more useful statement than silence.
11971
+ const off = t.available ? '' : ' disabled';
11972
+ const suffix = t.available ? '' : ' β€” unavailable';
11973
+ return `<option value="${esc(t.id)}"${off}>${esc(t.label)}${suffix}</option>`;
11974
+ }).join('');
11975
+ const firstUp = tiers.find((t) => t.available);
11976
+ if (firstUp) sel.value = firstUp.id;
11977
+ syncTier();
11978
+ }
11979
+
11980
+ function currentTier() {
11981
+ const sel = $('dnaTier');
11982
+ return tiers.find((t) => t.id === (sel && sel.value)) || null;
11983
+ }
11984
+
11985
+ function setTierNote(text) {
11986
+ const el = $('dnaTierNote');
11987
+ if (el) el.textContent = text || '';
11988
+ }
11989
+
11990
+ function syncTier() {
11991
+ const t = currentTier();
11992
+ const runBtn = $('dnaRun');
11993
+ if (!t) {
11994
+ setTierNote('');
11995
+ if (runBtn) runBtn.disabled = true;
11996
+ return;
11997
+ }
11998
+ let note = t.note || '';
11999
+ if (mode === 'generate' && !t.generation) {
12000
+ // Say it here rather than letting the server 503 after a click.
12001
+ note += ' Generation is Prometheus-only β€” the checkpoint is the '
12002
+ + 'same on both tiers, generation access is the difference.';
12003
+ }
12004
+ if (t.max_reference_nt) {
12005
+ note += ` Up to ${Number(t.max_reference_nt).toLocaleString()} nt per call.`;
12006
+ }
12007
+ setTierNote(note.trim());
12008
+ if (runBtn) {
12009
+ runBtn.disabled = busy || !t.available
12010
+ || (mode === 'generate' && !t.generation);
12011
+ }
12012
+ }
12013
+
12014
+ // ── mode ─────────────────────────────────────────────────────────────
12015
+ function setMode(next) {
12016
+ mode = next;
12017
+ const scoreBtn = $('dnaModeScore');
12018
+ const genBtn = $('dnaModeGenerate');
12019
+ if (scoreBtn) {
12020
+ scoreBtn.classList.toggle('is-active', mode === 'score');
12021
+ scoreBtn.setAttribute('aria-selected', String(mode === 'score'));
12022
+ }
12023
+ if (genBtn) {
12024
+ genBtn.classList.toggle('is-active', mode === 'generate');
12025
+ genBtn.setAttribute('aria-selected', String(mode === 'generate'));
12026
+ }
12027
+ const sp = $('dnaScorePane');
12028
+ const gp = $('dnaGeneratePane');
12029
+ if (sp) sp.hidden = mode !== 'score';
12030
+ if (gp) gp.hidden = mode !== 'generate';
12031
+ const run = $('dnaRun');
12032
+ if (run) run.textContent = mode === 'score' ? 'Score variants' : 'Generate sequence';
12033
+ // Generation needs Prometheus; jump there if the current pick can't.
12034
+ if (mode === 'generate') {
12035
+ const t = currentTier();
12036
+ if (t && !t.generation) {
12037
+ const gen = tiers.find((x) => x.generation && x.available);
12038
+ if (gen) $('dnaTier').value = gen.id;
12039
+ }
12040
+ }
12041
+ hideResults();
12042
+ syncTier();
12043
+ }
12044
+
12045
+ // ── input helpers ────────────────────────────────────────────────────
12046
+ function cleanSeq(raw) {
12047
+ // Tolerate FASTA headers and whitespace β€” people paste what they have.
12048
+ return String(raw || '')
12049
+ .split('\n').filter((l) => !l.trim().startsWith('>')).join('')
12050
+ .replace(/\s+/g, '').toUpperCase();
12051
+ }
12052
+
12053
+ function refMeta() {
12054
+ const el = $('dnaRefMeta');
12055
+ if (!el) return;
12056
+ const seq = cleanSeq($('dnaReference') && $('dnaReference').value);
12057
+ if (!seq) { el.hidden = true; return; }
12058
+ const bad = seq.replace(/[ACGT]/g, '');
12059
+ const gc = seq ? Math.round(100 * (seq.match(/[GC]/g) || []).length / seq.length) : 0;
12060
+ el.hidden = false;
12061
+ el.textContent = bad
12062
+ ? `${seq.length.toLocaleString()} nt Β· ${gc}% GC Β· ${new Set(bad).size} non-ACGT character(s) β€” those will be rejected`
12063
+ : `${seq.length.toLocaleString()} nt Β· ${gc}% GC`;
12064
+ el.classList.toggle('is-warn', Boolean(bad));
12065
+ }
12066
+
12067
+ function showError(msg) {
12068
+ const el = $('dnaError');
12069
+ if (!el) return;
12070
+ el.hidden = false;
12071
+ el.textContent = msg;
12072
+ }
12073
+ function clearError() {
12074
+ const el = $('dnaError');
12075
+ if (el) { el.hidden = true; el.textContent = ''; }
12076
+ }
12077
+ function hideResults() {
12078
+ const c = $('dnaResultsCard');
12079
+ if (c) c.hidden = true;
12080
+ }
12081
+
12082
+ // ── the wait ─────────────────────────────────────────────────────────
12083
+ function startWait() {
12084
+ const el = $('dnaWait');
12085
+ if (!el) return;
12086
+ const t0 = Date.now();
12087
+ el.hidden = false;
12088
+ const tick = () => {
12089
+ const s = Math.round((Date.now() - t0) / 1000);
12090
+ // Honest, and specific about WHY it might be slow. "Loading…" for
12091
+ // 80 seconds reads as broken; naming the cold start does not.
12092
+ el.textContent = s < 25
12093
+ ? `Scoring on the GPU β€” ${s}s`
12094
+ : `Still going β€” ${s}s. A cold container loads a 7B checkpoint `
12095
+ + `before its first pass; that's usually under 90s.`;
12096
+ };
12097
+ tick();
12098
+ timerId = setInterval(tick, 1000);
12099
+ }
12100
+ function stopWait() {
12101
+ if (timerId) { clearInterval(timerId); timerId = null; }
12102
+ const el = $('dnaWait');
12103
+ if (el) { el.hidden = true; el.textContent = ''; }
12104
+ }
12105
+
12106
+ // ── results ──────────────────────────────────────────────────────────
12107
+ function renderScores(data) {
12108
+ const card = $('dnaResultsCard');
12109
+ const body = $('dnaResultsBody');
12110
+ const meta = $('dnaResultsMeta');
12111
+ if (!card || !body) return;
12112
+
12113
+ const scores = (data.scores || []).slice().sort(
12114
+ (a, b) => (a.delta_ll || 0) - (b.delta_ll || 0));
12115
+ const skipped = data.skipped || [];
12116
+
12117
+ const bits = [];
12118
+ if (typeof data.reference_ll === 'number') {
12119
+ bits.push(`reference log-likelihood ${data.reference_ll.toFixed(4)}`);
12120
+ }
12121
+ if (data.checkpoint) bits.push(esc(data.checkpoint));
12122
+ if (typeof data.elapsed_s === 'number') bits.push(`${data.elapsed_s}s`);
12123
+ if (meta) meta.textContent = bits.join(' Β· ');
12124
+
12125
+ let html = '';
12126
+ if (scores.length) {
12127
+ // Scale bars against the largest |delta| in THIS result set, and
12128
+ // say so β€” a bar with an unstated scale invites reading it as an
12129
+ // absolute effect size.
12130
+ const max = Math.max.apply(null, scores.map((s) => Math.abs(s.delta_ll || 0))) || 1;
12131
+ html += '<table class="dna-table"><thead><tr>'
12132
+ + '<th>Variant</th><th>&Delta; log-likelihood</th><th>Effect</th>'
12133
+ + '</tr></thead><tbody>';
12134
+ scores.forEach((s) => {
12135
+ const d = Number(s.delta_ll || 0);
12136
+ const pct = Math.round(100 * Math.abs(d) / max);
12137
+ const dir = d < 0 ? 'neg' : 'pos';
12138
+ html += `<tr><td class="mono">${esc(s.label)}</td>`
12139
+ + `<td class="mono dna-delta dna-delta--${dir}">${d.toFixed(4)}</td>`
12140
+ + `<td><span class="dna-bar dna-bar--${dir}" style="width:${pct}%"></span></td></tr>`;
12141
+ });
12142
+ html += '</tbody></table>';
12143
+ html += '<p class="dna-scale-note">Bars are relative to the largest '
12144
+ + 'effect in this set, not an absolute scale. Negative = the '
12145
+ + 'model finds the change less likely than what\'s there now.</p>';
12146
+ }
12147
+
12148
+ if (skipped.length) {
12149
+ // Never fold these into "no result". A refused variant is a
12150
+ // statement about the INPUT, and hiding it would let a typo look
12151
+ // like a shorter answer.
12152
+ html += '<div class="dna-skipped"><p class="dna-skipped-hd">'
12153
+ + `Not scored (${skipped.length})</p><ul>`;
12154
+ skipped.forEach((s) => {
12155
+ const label = esc(s.label || s.variant || s);
12156
+ const why = s.why || s.reason || 'the reference base at that position does not match';
12157
+ html += `<li><span class="mono">${label}</span> β€” ${esc(why)}</li>`;
12158
+ });
12159
+ html += '</ul><p class="dna-skipped-why">These were refused rather than '
12160
+ + 'scored: positions are 1-based against the reference above, and '
12161
+ + 'a mismatched WT base usually means an off-by-one or the wrong '
12162
+ + 'strand.</p></div>';
12163
+ }
12164
+
12165
+ if (!scores.length && !skipped.length) {
12166
+ html = '<p class="dna-empty">The model returned no scores for that request.</p>';
12167
+ }
12168
+
12169
+ body.innerHTML = html;
12170
+ card.hidden = false;
12171
+ }
12172
+
12173
+ function renderGenerated(data) {
12174
+ const card = $('dnaResultsCard');
12175
+ const body = $('dnaResultsBody');
12176
+ const meta = $('dnaResultsMeta');
12177
+ if (!card || !body) return;
12178
+ const seq = String(data.sequence || data.generated || '');
12179
+ const bits = [];
12180
+ if (seq) bits.push(`${seq.length.toLocaleString()} nt generated`);
12181
+ if (data.checkpoint) bits.push(esc(data.checkpoint));
12182
+ if (typeof data.elapsed_s === 'number') bits.push(`${data.elapsed_s}s`);
12183
+ if (meta) meta.textContent = bits.join(' Β· ');
12184
+ body.innerHTML = seq
12185
+ ? `<pre class="dna-generated mono">${esc(seq.replace(/(.{60})/g, '$1\n'))}</pre>`
12186
+ + '<p class="dna-scale-note">Sampled sequence β€” plausible to the model, '
12187
+ + 'not validated. Nothing here has been checked for function, '
12188
+ + 'synthesisability, or anything else.</p>'
12189
+ : '<p class="dna-empty">The model returned no sequence.</p>';
12190
+ card.hidden = false;
12191
+ }
12192
+
12193
+ // ── run ──────────────────────────────────────────────────────────────
12194
+ async function run() {
12195
+ if (busy) return;
12196
+ clearError();
12197
+ hideResults();
12198
+
12199
+ const tier = ($('dnaTier') && $('dnaTier').value) || '';
12200
+ if (!tier) { showError('No DNA model tier is available on this deployment.'); return; }
12201
+
12202
+ let url, payload;
12203
+ if (mode === 'score') {
12204
+ const reference = cleanSeq($('dnaReference') && $('dnaReference').value);
12205
+ const variants = String(($('dnaVariants') && $('dnaVariants').value) || '')
12206
+ .split(/[\n,;]+/).map((v) => v.trim().toUpperCase()).filter(Boolean);
12207
+ if (!reference) { showError('Paste a reference sequence first.'); return; }
12208
+ if (!variants.length) { showError('Add at least one variant, like T10G.'); return; }
12209
+ url = '/api/dna/score';
12210
+ payload = { reference, variants, tier };
12211
+ } else {
12212
+ const prompt = cleanSeq($('dnaPrompt') && $('dnaPrompt').value);
12213
+ if (!prompt) { showError('Paste a starting sequence first.'); return; }
12214
+ url = '/api/dna/generate';
12215
+ payload = {
12216
+ prompt, tier,
12217
+ n_tokens: Number(($('dnaNTokens') || {}).value || 200),
12218
+ temperature: Number(($('dnaTemperature') || {}).value || 1.0),
12219
+ top_k: Number(($('dnaTopK') || {}).value || 4),
12220
+ };
12221
+ }
12222
+
12223
+ const btn = $('dnaRun');
12224
+ busy = true;
12225
+ if (btn) btn.disabled = true;
12226
+ startWait();
12227
+ try {
12228
+ const res = await fetch(url, {
12229
+ method: 'POST',
12230
+ headers: { 'Content-Type': 'application/json' },
12231
+ body: JSON.stringify(payload),
12232
+ });
12233
+ const data = await res.json();
12234
+ if (!res.ok) {
12235
+ if (data && data.kind === 'signin_required') {
12236
+ window.dispatchEvent(new Event('td:signin-required'));
12237
+ return; // the modal IS the message
12238
+ }
12239
+ throw new Error(data.error || 'The DNA model call failed.');
12240
+ }
12241
+ if (mode === 'score') renderScores(data);
12242
+ else renderGenerated(data);
12243
+ } catch (err) {
12244
+ showError((err && err.message) || String(err));
12245
+ } finally {
12246
+ busy = false;
12247
+ stopWait();
12248
+ syncTier();
12249
+ }
12250
+ }
12251
+
12252
+ // ── wire ─────────────────────────────────────────────────────────────
12253
+ function init() {
12254
+ if (!view()) return;
12255
+ const on = (id, ev, fn) => { const el = $(id); if (el) el.addEventListener(ev, fn); };
12256
+ on('dnaModeScore', 'click', () => setMode('score'));
12257
+ on('dnaModeGenerate', 'click', () => setMode('generate'));
12258
+ on('dnaTier', 'change', syncTier);
12259
+ on('dnaRun', 'click', run);
12260
+ on('dnaReference', 'input', refMeta);
12261
+ on('dnaExample', 'click', () => {
12262
+ setMode('score');
12263
+ const r = $('dnaReference'), v = $('dnaVariants');
12264
+ if (r) r.value = EXAMPLE_REF;
12265
+ if (v) v.value = EXAMPLE_VARIANTS;
12266
+ refMeta();
12267
+ clearError();
12268
+ });
12269
+ loadTiers();
12270
+ }
12271
+
12272
+ if (document.readyState === 'loading') {
12273
+ document.addEventListener('DOMContentLoaded', init);
12274
+ } else {
12275
+ init();
12276
+ }
12277
+ }());
dee/static/index.html CHANGED
@@ -112,7 +112,7 @@
112
  <!-- ?v= query bumps invalidate browser + iframe asset caches when app.css /
113
  app.js change. Bump these numbers whenever you ship a frontend update β€”
114
  without them, users keep getting the stale file for up to a week. -->
115
- <link rel="stylesheet" href="/static/app.css?v=20260811-buildC" />
116
  <!-- The work catalog + the draggable rail. Kept out of app.css so two new
117
  self-contained surfaces stay reviewable; every colour is an app.css
118
  token, so both themes work with nothing added. -->
@@ -250,10 +250,11 @@
250
  </a>
251
 
252
  <!--
253
- THE LOOP β€” the four tools as one ordered workflow, not a
254
  scattered tab-list. A hairline spine (.nav-loop::before) ties
255
  them together; each name carries its loop phase on the right.
256
  Β· Directed Evolution β€” ESM-2 zero-shot variant libraries (Design)
 
257
  Β· Plasmid Editor β€” map / annotate / clone a construct (Build)
258
  Β· CRISPR β€” knockout & base-edit guide design (Edit)
259
  Β· Primer Analysis β€” score & rank candidate primers (Verify)
@@ -273,6 +274,26 @@
273
  <span class="nav-step-nm">Directed Evolution</span>
274
  <span class="nav-step-ph">Design</span>
275
  </a>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
276
  <a class="nav-item nav-step" href="#plasmid" data-analytics="nav-plasmid" title="Plasmid Editor">
277
  <span class="nav-icon" aria-hidden="true">
278
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
@@ -507,6 +528,7 @@
507
  <nav class="bench-tabs" id="benchTabs" aria-label="Construct artifacts">
508
  <button class="bench-tab" data-route="structure" data-analytics="bench-tab-structure" type="button">Structure</button>
509
  <button class="bench-tab" data-route="design" data-analytics="bench-tab-design" type="button">Library</button>
 
510
  <button class="bench-tab" data-route="plasmid" data-analytics="bench-tab-plasmid" type="button">Map</button>
511
  <button class="bench-tab" data-route="crispr" data-analytics="bench-tab-crispr" type="button">Guides</button>
512
  <button class="bench-tab" data-route="primers" data-analytics="bench-tab-primers" type="button">Primers</button>
@@ -1104,6 +1126,119 @@
1104
  by the per-user /dashboard/ page on the landing site
1105
  (task #98). See sidebar comment above for context. -->
1106
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1107
  <!-- ═════════════════════════════ CRISPR view (Cas9 knockout) ═══
1108
  Paste a gene β†’ ranked SpCas9 sgRNAs with on-target
1109
  scoring. Sign-in gated server-side; anonymous users
@@ -2128,6 +2263,35 @@
2128
  </ul>
2129
  </section>
2130
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2131
  <section>
2132
  <h3>Directed Evolution</h3>
2133
  <p>Design a smart mutation library for an existing protein using ESM-2 zero-shot scoring. Provide a wild-type sequence (or send a CDS from the Plasmid Editor); receive a library of multi-mutant variants ranked by predicted evolutionary fitness, codon-optimized for your host, ready to order.</p>
@@ -2561,7 +2725,7 @@
2561
  <!-- Cloning reference data must load before app.js so the Designer
2562
  can read VECTORS / ENZYMES / CLONING_METHODS / TAGS / LINKERS. -->
2563
  <script src="/static/cloning_db.js?v=20260530-ui-polish" defer></script>
2564
- <script src="/static/app.js?v=20260811-buildC" defer></script>
2565
  <!-- The decision trace, BEFORE cockpit.js: applyEvent calls TDTrace.push
2566
  on the very first event, and both are `defer`, so document order is
2567
  load order. Loading it after would drop the opening events of a
 
112
  <!-- ?v= query bumps invalidate browser + iframe asset caches when app.css /
113
  app.js change. Bump these numbers whenever you ship a frontend update β€”
114
  without them, users keep getting the stale file for up to a week. -->
115
+ <link rel="stylesheet" href="/static/app.css?v=20260812-dna" />
116
  <!-- The work catalog + the draggable rail. Kept out of app.css so two new
117
  self-contained surfaces stay reviewable; every colour is an app.css
118
  token, so both themes work with nothing added. -->
 
250
  </a>
251
 
252
  <!--
253
+ THE LOOP β€” the tools as one ordered workflow, not a
254
  scattered tab-list. A hairline spine (.nav-loop::before) ties
255
  them together; each name carries its loop phase on the right.
256
  Β· Directed Evolution β€” ESM-2 zero-shot variant libraries (Design)
257
+ Β· DNA Design β€” Evo 2 zero-shot nucleotide scoring (Design)
258
  Β· Plasmid Editor β€” map / annotate / clone a construct (Build)
259
  Β· CRISPR β€” knockout & base-edit guide design (Edit)
260
  Β· Primer Analysis β€” score & rank candidate primers (Verify)
 
274
  <span class="nav-step-nm">Directed Evolution</span>
275
  <span class="nav-step-ph">Design</span>
276
  </a>
277
+ <!--
278
+ DNA Design sits BESIDE Directed Evolution, not as a
279
+ fifth phase: DE designs at the PROTEIN level (ESM-2),
280
+ this designs at the DNA level (Evo 2). Both are the
281
+ loop's "Design" step, which is why they share a phase
282
+ label β€” inventing a fifth phase here would break the
283
+ Design/Build/Edit/Verify story the landing page and the
284
+ Start Here router are both built on.
285
+ -->
286
+ <a class="nav-item nav-step" href="#dna" data-analytics="nav-dna" title="DNA Design">
287
+ <span class="nav-icon" aria-hidden="true">
288
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
289
+ <path d="M5 3c0 5 14 6 14 9s-14 4-14 9"/>
290
+ <path d="M19 3c0 5-14 6-14 9s14 4 14 9"/>
291
+ <path d="M8 6h8M7 9.6h10M7 14.4h10M8 18h8"/>
292
+ </svg>
293
+ </span>
294
+ <span class="nav-step-nm">DNA Design</span>
295
+ <span class="nav-step-ph">Design</span>
296
+ </a>
297
  <a class="nav-item nav-step" href="#plasmid" data-analytics="nav-plasmid" title="Plasmid Editor">
298
  <span class="nav-icon" aria-hidden="true">
299
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
 
528
  <nav class="bench-tabs" id="benchTabs" aria-label="Construct artifacts">
529
  <button class="bench-tab" data-route="structure" data-analytics="bench-tab-structure" type="button">Structure</button>
530
  <button class="bench-tab" data-route="design" data-analytics="bench-tab-design" type="button">Library</button>
531
+ <button class="bench-tab" data-route="dna" data-analytics="bench-tab-dna" type="button">DNA</button>
532
  <button class="bench-tab" data-route="plasmid" data-analytics="bench-tab-plasmid" type="button">Map</button>
533
  <button class="bench-tab" data-route="crispr" data-analytics="bench-tab-crispr" type="button">Guides</button>
534
  <button class="bench-tab" data-route="primers" data-analytics="bench-tab-primers" type="button">Primers</button>
 
1126
  by the per-user /dashboard/ page on the landing site
1127
  (task #98). See sidebar comment above for context. -->
1128
 
1129
+ <!-- ═════════════════════════════ DNA Design (Evo 2) ════════════
1130
+ The DNA-level counterpart to Directed Evolution. DE scores
1131
+ amino-acid substitutions with ESM-2; this scores NUCLEOTIDE
1132
+ substitutions with Evo 2 (7B), which is the only thing in
1133
+ this product that understands non-coding and regulatory
1134
+ sequence at all β€” promoters.py is a curated lookup table and
1135
+ exon.py is coordinate arithmetic, neither is a model.
1136
+
1137
+ Every call here is a metered GPU call: unlike protein's
1138
+ Achilles tier (35M on the Space's own CPU) there is no free
1139
+ local path for a 7B model. Hence the account gate, the
1140
+ tighter rate-limit bucket, and the honest wait copy β€” a cold
1141
+ container is ~80s, warm ~23s, and a dead spinner over that
1142
+ long reads as a hang.
1143
+ ════════════════════════════════════════════════════════ -->
1144
+ <section class="view view--dna" data-view="dna" hidden>
1145
+ <header class="view-head">
1146
+ <h1 class="view-title"><em>DNA Design</em></h1>
1147
+ <p class="view-sub">Score changes to DNA itself β€” promoters, splice sites, UTRs,
1148
+ any non-coding stretch β€” against a genome foundation model. Directed Evolution
1149
+ asks whether a protein change is tolerated; this asks whether a
1150
+ <em>sequence</em> change is, in the context it actually sits in.</p>
1151
+ </header>
1152
+
1153
+ <section class="card dna-input-card">
1154
+ <div class="dna-mode-row" role="tablist" aria-label="What to do">
1155
+ <button class="dna-mode is-active" type="button" id="dnaModeScore"
1156
+ role="tab" aria-selected="true">Score variants</button>
1157
+ <button class="dna-mode" type="button" id="dnaModeGenerate"
1158
+ role="tab" aria-selected="false">Generate sequence</button>
1159
+ </div>
1160
+
1161
+ <!-- ── Score ── -->
1162
+ <div id="dnaScorePane">
1163
+ <label class="field-label" for="dnaReference">Reference sequence</label>
1164
+ <p class="field-hint">The stretch of DNA your change sits in β€” a promoter, a
1165
+ splice junction, a UTR. Context is the point: the same substitution scores
1166
+ differently depending on what surrounds it, which is exactly what a lookup
1167
+ table cannot tell you.</p>
1168
+ <textarea id="dnaReference" class="dna-textarea mono" rows="5" spellcheck="false"
1169
+ placeholder="Paste DNA (A/C/G/T)…"></textarea>
1170
+ <p class="dna-meta" id="dnaRefMeta" hidden></p>
1171
+
1172
+ <label class="field-label" for="dnaVariants">Variants to score</label>
1173
+ <p class="field-hint">One per line, as <code>&lt;WT base&gt;&lt;position&gt;&lt;new base&gt;</code>
1174
+ β€” e.g. <code>T10G</code>. Positions are 1-based against the reference above.
1175
+ A variant whose WT base doesn't match the reference is <strong>refused, not
1176
+ scored</strong>, and listed separately so a typo can't quietly shrink your table.</p>
1177
+ <textarea id="dnaVariants" class="dna-textarea mono" rows="4" spellcheck="false"
1178
+ placeholder="T10G&#10;G20C&#10;T30A"></textarea>
1179
+ </div>
1180
+
1181
+ <!-- ── Generate ── -->
1182
+ <div id="dnaGeneratePane" hidden>
1183
+ <label class="field-label" for="dnaPrompt">Starting sequence</label>
1184
+ <p class="field-hint">The model continues from what you paste. Prometheus tier only β€”
1185
+ generation is the one real capability difference between the DNA tiers
1186
+ (the checkpoint is identical).</p>
1187
+ <textarea id="dnaPrompt" class="dna-textarea mono" rows="5" spellcheck="false"
1188
+ placeholder="Paste the DNA to continue from (A/C/G/T)…"></textarea>
1189
+ <div class="dna-gen-knobs">
1190
+ <label class="dna-knob">Bases to generate
1191
+ <input type="number" id="dnaNTokens" value="200" min="1" max="2000" />
1192
+ </label>
1193
+ <label class="dna-knob">Temperature
1194
+ <input type="number" id="dnaTemperature" value="1.0" min="0.1" max="2" step="0.1" />
1195
+ <span class="field-hint">Lower = more conservative.</span>
1196
+ </label>
1197
+ <label class="dna-knob">Top-k
1198
+ <input type="number" id="dnaTopK" value="4" min="1" max="4" />
1199
+ <span class="field-hint">There are only 4 bases.</span>
1200
+ </label>
1201
+ </div>
1202
+ </div>
1203
+
1204
+ <label class="field-label" for="dnaTier">Model tier</label>
1205
+ <select id="dnaTier" class="dna-select"></select>
1206
+ <p class="field-hint" id="dnaTierNote"></p>
1207
+
1208
+ <div class="dna-actions">
1209
+ <button class="ghost" type="button" id="dnaExample"
1210
+ title="Loads the E. coli lac promoter / operator region and three real substitutions">Try an example</button>
1211
+ <button class="primary primary-lg" type="button" id="dnaRun">Score variants</button>
1212
+ </div>
1213
+ <p class="dna-wait" id="dnaWait" hidden></p>
1214
+ <div class="error-banner" id="dnaError" hidden></div>
1215
+ </section>
1216
+
1217
+ <section class="card dna-results-card" id="dnaResultsCard" hidden>
1218
+ <div class="dna-results-head">
1219
+ <span class="dna-results-title">Predicted effect</span>
1220
+ <span class="dna-results-meta" id="dnaResultsMeta"></span>
1221
+ </div>
1222
+ <details class="how-to-read">
1223
+ <summary>How to read this</summary>
1224
+ <div class="how-to-read-body">
1225
+ <p><strong>&Delta; log-likelihood</strong> &mdash; how much more, or less, likely the
1226
+ model finds your sequence after the change. <strong>Negative</strong> means the
1227
+ model is more surprised by the variant than by the reference: the change breaks a
1228
+ pattern the model learned from real genomes. <strong>Near zero</strong> means
1229
+ it's unremarkable. <strong>Positive</strong> means the variant looks
1230
+ <em>more</em> typical than what's there now.</p>
1231
+ <p>This is <strong>zero-shot</strong> β€” no training on your system, no measured data.
1232
+ It ranks candidates; it does not predict expression level, and it is not a
1233
+ substitute for a reporter assay. Treat it as a prior for what to test first.</p>
1234
+ <p><strong>Reference log-likelihood</strong> is the model's score for your unchanged
1235
+ sequence, shown so the deltas have something to sit against.</p>
1236
+ </div>
1237
+ </details>
1238
+ <div id="dnaResultsBody"></div>
1239
+ </section>
1240
+ </section>
1241
+
1242
  <!-- ═════════════════════════════ CRISPR view (Cas9 knockout) ═══
1243
  Paste a gene β†’ ranked SpCas9 sgRNAs with on-target
1244
  scoring. Sign-in gated server-side; anonymous users
 
2263
  </ul>
2264
  </section>
2265
 
2266
+ <section>
2267
+ <h3>DNA Design</h3>
2268
+ <p>The DNA-level counterpart to Directed Evolution. Everything else here works on
2269
+ protein (ESM-2) or on deterministic sequence bookkeeping; this is the only tool
2270
+ that reads <em>nucleotides</em> with a learned model β€” <strong>Evo 2</strong>
2271
+ (Arc Institute, 7B, Apache&nbsp;2.0), trained autoregressively on genomes.</p>
2272
+ <ul class="docs-list">
2273
+ <li><strong>Scoring.</strong> Ξ” log-likelihood for each point substitution against
2274
+ a reference you supply: how much more, or less, likely the model finds the
2275
+ sequence after your change. Negative = the change breaks a pattern the model
2276
+ learned from real genomes.</li>
2277
+ <li><strong>Context is the point.</strong> The same substitution scores differently
2278
+ depending on what surrounds it. That is what a curated parts table cannot do,
2279
+ and it is why this works on non-coding sequence β€” promoters, splice sites,
2280
+ UTRs, terminators β€” that the rest of the toolkit only annotates.</li>
2281
+ <li><strong>Generation.</strong> Continues a sequence you paste. Prometheus tier
2282
+ only; the checkpoint is identical on both tiers, generation access is the
2283
+ difference.</li>
2284
+ <li><strong>Refusals are shown.</strong> A variant whose wild-type base disagrees
2285
+ with your reference is listed as not scored, with the reason β€” never folded
2286
+ into a quietly shorter table.</li>
2287
+ </ul>
2288
+ <p class="docs-callout">Zero-shot: no training on your system and no measured data. It
2289
+ ranks candidates and gives you a prior for what to test first β€” it does
2290
+ <strong>not</strong> predict expression level and does not replace a reporter
2291
+ assay. Every call runs on a rented GPU; unlike protein's Achilles tier there is no
2292
+ free local path for a 7B model, so DNA scoring needs an account.</p>
2293
+ </section>
2294
+
2295
  <section>
2296
  <h3>Directed Evolution</h3>
2297
  <p>Design a smart mutation library for an existing protein using ESM-2 zero-shot scoring. Provide a wild-type sequence (or send a CDS from the Plasmid Editor); receive a library of multi-mutant variants ranked by predicted evolutionary fitness, codon-optimized for your host, ready to order.</p>
 
2725
  <!-- Cloning reference data must load before app.js so the Designer
2726
  can read VECTORS / ENZYMES / CLONING_METHODS / TAGS / LINKERS. -->
2727
  <script src="/static/cloning_db.js?v=20260530-ui-polish" defer></script>
2728
+ <script src="/static/app.js?v=20260812-dna" defer></script>
2729
  <!-- The decision trace, BEFORE cockpit.js: applyEvent calls TDTrace.push
2730
  on the very first event, and both are `defer`, so document order is
2731
  load order. Loading it after would drop the opening events of a
tests/test_dna_endpoints.py ADDED
@@ -0,0 +1,176 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """/api/dna/score and /api/dna/generate β€” the DNA view's only way in.
2
+
3
+ Before these existed, the ONLY route to Evo 2 was the agent choosing to call
4
+ score_or_generate_dna mid-conversation: /api/models computed `dna_models` and
5
+ no frontend read it, so a 7B genome model was unreachable by clicking.
6
+
7
+ What these pin is the same contract the rest of the tool endpoints keep:
8
+ never run anonymously, never pretend a tier can do something it cannot, and
9
+ never dress an unavailable backend as a scientific result.
10
+ """
11
+ import pytest
12
+
13
+ from dee import server
14
+ from dee.core import dna_scoring
15
+
16
+
17
+ @pytest.fixture
18
+ def client():
19
+ app = server.create_app()
20
+ app.config.update(TESTING=True)
21
+ return app.test_client()
22
+
23
+
24
+ @pytest.fixture
25
+ def signed_in(monkeypatch):
26
+ """Every DNA call is a metered GPU call, so both routes are account-gated
27
+ the same way CRISPR and primers are."""
28
+ class _Auth:
29
+ anonymous = False
30
+ user_id = "11111111-1111-1111-1111-111111111111"
31
+ monkeypatch.setattr(server._auth, "get_auth", lambda *a, **k: _Auth())
32
+
33
+
34
+ REF = "TTTACACTTTATGCTTCCGGCTCGTATGTTGTGTGGAATTGTGAGCGGATAACAATTTCA"
35
+
36
+
37
+ # ── the account gate ─────────────────────────────────────────────────────
38
+ def test_scoring_refuses_anonymous_callers(client, monkeypatch):
39
+ class _Anon:
40
+ anonymous = True
41
+ user_id = None
42
+ monkeypatch.setattr(server._auth, "get_auth", lambda *a, **k: _Anon())
43
+ r = client.post("/api/dna/score", json={"reference": REF, "variants": ["T10G"]})
44
+ assert r.status_code == 403
45
+ assert r.get_json()["kind"] == "signin_required"
46
+
47
+
48
+ def test_generation_refuses_anonymous_callers(client, monkeypatch):
49
+ class _Anon:
50
+ anonymous = True
51
+ user_id = None
52
+ monkeypatch.setattr(server._auth, "get_auth", lambda *a, **k: _Anon())
53
+ r = client.post("/api/dna/generate", json={"prompt": REF})
54
+ assert r.status_code == 403
55
+
56
+
57
+ # ── input validation, before anything is billed ──────────────────────────
58
+ def test_a_missing_reference_is_a_400_not_a_gpu_call(client, signed_in, monkeypatch):
59
+ def boom(*a, **k):
60
+ raise AssertionError("called the GPU for an empty request")
61
+ monkeypatch.setattr(dna_scoring, "score_dna_variants", boom)
62
+ assert client.post("/api/dna/score", json={"variants": ["T10G"]}).status_code == 400
63
+
64
+
65
+ def test_empty_variants_is_a_400_not_a_gpu_call(client, signed_in, monkeypatch):
66
+ def boom(*a, **k):
67
+ raise AssertionError("called the GPU for an empty request")
68
+ monkeypatch.setattr(dna_scoring, "score_dna_variants", boom)
69
+ r = client.post("/api/dna/score", json={"reference": REF, "variants": []})
70
+ assert r.status_code == 400
71
+
72
+
73
+ def test_whitespace_and_case_are_normalised(client, signed_in, monkeypatch):
74
+ """Users paste FASTA-ish text with newlines; the model wants bases."""
75
+ seen = {}
76
+
77
+ def fake(reference, variants, tier="achilles"):
78
+ seen["reference"] = reference
79
+ seen["variants"] = variants
80
+ return {"ok": True, "scores": [], "skipped": [], "reference_ll": -1.0}
81
+
82
+ monkeypatch.setattr(dna_scoring, "score_dna_variants", fake)
83
+ client.post("/api/dna/score", json={
84
+ "reference": "ttta cact\ntta", "variants": [" t10g "]})
85
+ assert seen["reference"] == "TTTACACTTTA"
86
+ assert seen["variants"] == ["T10G"]
87
+
88
+
89
+ # ── the honesty contract ─────────────────────────────────────────────────
90
+ def test_an_unavailable_backend_is_a_503_not_an_empty_result(client, signed_in,
91
+ monkeypatch):
92
+ """The failure this whole module exists to prevent: reporting "no variants
93
+ scored" when the truth is "nothing was asked"."""
94
+ def unavailable(*a, **k):
95
+ raise dna_scoring.DnaModelUnavailable("DNA scoring isn't switched on.")
96
+ monkeypatch.setattr(dna_scoring, "score_dna_variants", unavailable)
97
+ r = client.post("/api/dna/score", json={"reference": REF, "variants": ["T10G"]})
98
+ assert r.status_code == 503
99
+ assert r.get_json()["kind"] == "dna_unavailable"
100
+
101
+
102
+ def test_generation_on_the_achilles_tier_is_refused(client, signed_in):
103
+ """Prometheus-only, enforced here AND in dna_scoring AND again in
104
+ modal/evo2_scoring β€” a caller that skips a layer still gets refused."""
105
+ r = client.post("/api/dna/generate", json={"prompt": REF, "tier": "achilles"})
106
+ assert r.status_code == 503
107
+ assert "Prometheus" in r.get_json()["error"]
108
+
109
+
110
+ def test_a_backend_crash_never_reports_a_scientific_result(client, signed_in,
111
+ monkeypatch):
112
+ def boom(*a, **k):
113
+ raise RuntimeError("connection reset")
114
+ monkeypatch.setattr(dna_scoring, "score_dna_variants", boom)
115
+ r = client.post("/api/dna/score", json={"reference": REF, "variants": ["T10G"]})
116
+ assert r.status_code == 502
117
+ body = r.get_json()
118
+ assert body["kind"] == "dna_error"
119
+ assert "connection reset" not in body["error"], "no raw exception text to users"
120
+
121
+
122
+ def test_a_successful_score_is_passed_through_whole(client, signed_in, monkeypatch):
123
+ """Including `skipped`. A variant whose WT base disagrees with the
124
+ reference is REFUSED, not scored β€” dropping that list would turn a
125
+ refusal into a silently shorter table."""
126
+ payload = {"ok": True, "reference_ll": -147.9081,
127
+ "scores": [{"label": "T10G", "delta_ll": -5.5079}],
128
+ "skipped": [{"label": "A10G", "why": "reference base is T"}],
129
+ "tier": "achilles", "checkpoint": "evo2_7b"}
130
+ monkeypatch.setattr(dna_scoring, "score_dna_variants", lambda *a, **k: payload)
131
+ r = client.post("/api/dna/score",
132
+ json={"reference": REF, "variants": ["T10G", "A10G"]})
133
+ assert r.status_code == 200
134
+ got = r.get_json()
135
+ assert got["scores"][0]["delta_ll"] == -5.5079
136
+ assert got["skipped"], "the refusal list must survive to the UI"
137
+ assert got["reference_ll"] == -147.9081
138
+
139
+
140
+ def test_generation_forwards_its_sampling_knobs(client, signed_in, monkeypatch):
141
+ seen = {}
142
+
143
+ def fake(prompt, n_tokens=200, tier="prometheus", temperature=1.0, top_k=4):
144
+ seen.update(prompt=prompt, n_tokens=n_tokens, tier=tier,
145
+ temperature=temperature, top_k=top_k)
146
+ return {"ok": True, "sequence": "ACGT"}
147
+
148
+ monkeypatch.setattr(dna_scoring, "generate_dna_sequence", fake)
149
+ client.post("/api/dna/generate", json={
150
+ "prompt": REF, "n_tokens": 64, "temperature": 0.8, "top_k": 3})
151
+ assert seen["n_tokens"] == 64 and seen["top_k"] == 3
152
+ assert seen["temperature"] == 0.8
153
+ assert seen["tier"] == "prometheus"
154
+
155
+
156
+ def test_non_numeric_sampling_knobs_are_a_400(client, signed_in, monkeypatch):
157
+ def boom(*a, **k):
158
+ raise AssertionError("called the GPU with junk parameters")
159
+ monkeypatch.setattr(dna_scoring, "generate_dna_sequence", boom)
160
+ r = client.post("/api/dna/generate", json={"prompt": REF, "n_tokens": "lots"})
161
+ assert r.status_code == 400
162
+
163
+
164
+ # ── the cost guard ───────────────────────────────────────────────────────
165
+ def test_dna_routes_carry_their_own_tighter_rate_limits():
166
+ """Evo 2 7B on a rented GPU is the most expensive call in the product and
167
+ has no free local path. Its bucket must be tighter than the tool default,
168
+ and generation tighter still."""
169
+ rules = dict(server._RL_RULES)
170
+ assert rules["/api/dna/generate"][0] < rules["/api/dna"][0]
171
+ assert rules["/api/dna"][0] < rules["/api/crispr"][0]
172
+
173
+
174
+ def test_dna_calls_are_logged_as_their_own_event_kinds():
175
+ assert server._EVENT_KINDS["/api/dna/score"] == "dna_score"
176
+ assert server._EVENT_KINDS["/api/dna/generate"] == "dna_generate"