Tengo Gzirishvili Claude Opus 4.8 commited on
Commit
91ed780
·
1 Parent(s): 1755b9c

Add pairwise sequence alignment (Needleman–Wunsch / Smith–Waterman)

Browse files

Closes the biggest "looks incomplete vs Benchling" gap from the competitive
audit — we couldn't align two sequences at all.

- dee/core/align.py: numpy global + local pairwise alignment with identity %,
match midline, gaps, score; |a|×|b| size cap. +8 unit tests.
- POST /api/align (stateless utility, rate-limited, no sign-in gate).
- "Align two sequences" card in the Plasmid workbench: two inputs, Global/Local
toggle, blocked alignment view + identity/score stats.

Verified live: global 88.9% identity with correct midline, local finds the
shared region (100%, 0 gaps), empty→400. Full suite 324 passing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

dee/core/align.py ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pairwise sequence alignment — Needleman–Wunsch (global) and Smith–Waterman
2
+ (local). numpy + stdlib only; no Biopython dependency so it's trivially testable.
3
+
4
+ Identity-based scoring (match / mismatch + linear gap) — enough for the
5
+ "compare two sequences" use case: visualise percent identity, mismatches, and
6
+ gaps for DNA or protein. O(n·m) DP, so we cap |a|·|b| to keep it snappy.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from typing import Any, Dict
11
+
12
+ import numpy as np
13
+
14
+ # |a| * |b| ceiling (~1225 × 1225). Above this we ask the user to align a
15
+ # shorter region — the pure-Python traceback + DP fill stays sub-second below it.
16
+ MAX_CELLS = 1_500_000
17
+
18
+
19
+ def _clean(s: str) -> str:
20
+ return "".join(c for c in (s or "").upper() if not c.isspace())
21
+
22
+
23
+ def align(a: str, b: str, *, mode: str = "global",
24
+ match: int = 2, mismatch: int = -1, gap: int = -2) -> Dict[str, Any]:
25
+ """Align sequences `a` and `b`.
26
+
27
+ mode: 'global' (Needleman–Wunsch) or 'local' (Smith–Waterman).
28
+ Returns aligned strings, a match midline, percent identity, score, gaps.
29
+ """
30
+ a = _clean(a)
31
+ b = _clean(b)
32
+ if not a or not b:
33
+ raise ValueError("Both sequences must be non-empty.")
34
+ if len(a) * len(b) > MAX_CELLS:
35
+ raise ValueError(
36
+ f"Sequences too large to align in-browser (|a|×|b| > {MAX_CELLS:,}). "
37
+ "Align a shorter region."
38
+ )
39
+
40
+ local = (mode == "local")
41
+ n, m = len(a), len(b)
42
+ H = np.zeros((n + 1, m + 1), dtype=np.int32)
43
+ # traceback codes: 0 diag, 1 up (gap in b), 2 left (gap in a), 3 stop
44
+ T = np.zeros((n + 1, m + 1), dtype=np.int8)
45
+
46
+ if not local:
47
+ H[:, 0] = np.arange(n + 1) * gap
48
+ H[0, :] = np.arange(m + 1) * gap
49
+ T[1:, 0] = 1
50
+ T[0, 1:] = 2
51
+
52
+ best_score, best_i, best_j = 0, 0, 0
53
+ for i in range(1, n + 1):
54
+ ai = a[i - 1]
55
+ Hi, Hprev, Ti = H[i], H[i - 1], T[i]
56
+ for j in range(1, m + 1):
57
+ sc = match if ai == b[j - 1] else mismatch
58
+ cell = Hprev[j - 1] + sc
59
+ t = 0
60
+ up = Hprev[j] + gap
61
+ if up > cell:
62
+ cell, t = up, 1
63
+ left = Hi[j - 1] + gap
64
+ if left > cell:
65
+ cell, t = left, 2
66
+ if local and cell < 0:
67
+ cell, t = 0, 3
68
+ Hi[j] = cell
69
+ Ti[j] = t
70
+ if local and cell > best_score:
71
+ best_score, best_i, best_j = cell, i, j
72
+
73
+ if local:
74
+ score, i, j = best_score, best_i, best_j
75
+ else:
76
+ score, i, j = int(H[n, m]), n, m
77
+
78
+ out_a, out_b = [], []
79
+ while i > 0 or j > 0:
80
+ t = int(T[i, j])
81
+ if local and (H[i, j] == 0 or t == 3):
82
+ break
83
+ if t == 0:
84
+ out_a.append(a[i - 1]); out_b.append(b[j - 1]); i -= 1; j -= 1
85
+ elif t == 1:
86
+ out_a.append(a[i - 1]); out_b.append("-"); i -= 1
87
+ elif t == 2:
88
+ out_a.append("-"); out_b.append(b[j - 1]); j -= 1
89
+ else:
90
+ break
91
+ out_a.reverse(); out_b.reverse()
92
+ aa, bb = "".join(out_a), "".join(out_b)
93
+
94
+ cols = len(aa)
95
+ matches = sum(1 for x, y in zip(aa, bb) if x == y and x != "-")
96
+ identity = round(100.0 * matches / cols, 1) if cols else 0.0
97
+ gaps = aa.count("-") + bb.count("-")
98
+ midline = "".join(
99
+ "|" if (x == y and x != "-") else (" " if (x == "-" or y == "-") else ".")
100
+ for x, y in zip(aa, bb)
101
+ )
102
+ return {
103
+ "mode": "local" if local else "global",
104
+ "score": int(score),
105
+ "identity": identity,
106
+ "length": cols,
107
+ "matches": matches,
108
+ "gaps": gaps,
109
+ "aligned_a": aa,
110
+ "aligned_b": bb,
111
+ "midline": midline,
112
+ }
dee/server.py CHANGED
@@ -1567,6 +1567,23 @@ def create_app() -> Flask:
1567
  return send_file(io.BytesIO(payload.encode("utf-8")), mimetype=mime,
1568
  as_attachment=True, download_name=f"{stem}.{ext}")
1569
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1570
  @app.post("/api/plasmid/save")
1571
  def plasmid_save() -> Response:
1572
  auth, denied = _plasmid_signin_guard()
 
1567
  return send_file(io.BytesIO(payload.encode("utf-8")), mimetype=mime,
1568
  as_attachment=True, download_name=f"{stem}.{ext}")
1569
 
1570
+ @app.post("/api/align")
1571
+ def align_sequences() -> Response:
1572
+ """Pairwise alignment (global/local) of two sequences. Stateless utility,
1573
+ no sign-in needed; bounded by align()'s size cap + the global rate limit."""
1574
+ from .core import align as _AL
1575
+ body = request.get_json(force=True, silent=True) or {}
1576
+ a, b = body.get("a") or "", body.get("b") or ""
1577
+ mode = "local" if body.get("mode") == "local" else "global"
1578
+ try:
1579
+ result = _AL.align(a, b, mode=mode)
1580
+ except ValueError as exc:
1581
+ return jsonify({"error": str(exc)}), 400
1582
+ except Exception: # noqa: BLE001
1583
+ logger.exception("alignment failed")
1584
+ return jsonify({"error": "Alignment failed — check the two sequences."}), 500
1585
+ return jsonify({"ok": True, **result})
1586
+
1587
  @app.post("/api/plasmid/save")
1588
  def plasmid_save() -> Response:
1589
  auth, denied = _plasmid_signin_guard()
dee/static/app.css CHANGED
@@ -5614,6 +5614,16 @@ h3, h4 {
5614
  /* selection arc mirrored on the companion circular map */
5615
  .pm-sel { stroke: var(--brand); stroke-width: 15; opacity: 0.42; stroke-linecap: round; }
5616
 
 
 
 
 
 
 
 
 
 
 
5617
  @media (max-width: 640px) {
5618
  .seqtool-input { width: 100%; }
5619
  .seqtool-find { width: 100%; }
 
5614
  /* selection arc mirrored on the companion circular map */
5615
  .pm-sel { stroke: var(--brand); stroke-width: 15; opacity: 0.42; stroke-linecap: round; }
5616
 
5617
+ /* ── Pairwise alignment card ── */
5618
+ .plasmid-align-card .align-inputs { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin: 10px 0; }
5619
+ @media (max-width: 700px) { .plasmid-align-card .align-inputs { grid-template-columns: 1fr; } }
5620
+ .align-actions { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; }
5621
+ .align-stats { font-family: var(--font-mono); font-size: 12px; color: var(--ink-soft); }
5622
+ .align-output { margin-top: 14px; overflow-x: auto; }
5623
+ .align-block { margin-bottom: 10px; font-family: var(--font-mono); font-size: 12.5px; line-height: 1.5; white-space: pre; }
5624
+ .align-row { white-space: pre; }
5625
+ .align-mid { color: var(--ink-faint); }
5626
+
5627
  @media (max-width: 640px) {
5628
  .seqtool-input { width: 100%; }
5629
  .seqtool-find { width: 100%; }
dee/static/app.js CHANGED
@@ -8026,3 +8026,51 @@ function runOracle(opts){
8026
  outcomes: [{ subject, measured_value: parseFloat($('coResult').value), outcome: 'measured' }] };
8027
  });
8028
  })();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8026
  outcomes: [{ subject, measured_value: parseFloat($('coResult').value), outcome: 'measured' }] };
8027
  });
8028
  })();
8029
+
8030
+ // ── Pairwise sequence alignment (Plasmid workbench card → /api/align) ──
8031
+ (function initAlign() {
8032
+ const A = document.getElementById('alignA');
8033
+ const B = document.getElementById('alignB');
8034
+ const runBtn = document.getElementById('alignRun');
8035
+ const out = document.getElementById('alignOutput');
8036
+ const stats = document.getElementById('alignStats');
8037
+ if (!A || !B || !runBtn || !out) return;
8038
+ let mode = 'global';
8039
+ document.querySelectorAll('[data-align-mode]').forEach((btn) => {
8040
+ btn.addEventListener('click', () => {
8041
+ mode = btn.getAttribute('data-align-mode');
8042
+ document.querySelectorAll('[data-align-mode]').forEach((b) =>
8043
+ b.classList.toggle('clone-method-active', b === btn));
8044
+ });
8045
+ });
8046
+ function render(r) {
8047
+ const W = 60, aa = r.aligned_a || '', bb = r.aligned_b || '', mid = r.midline || '';
8048
+ let html = '';
8049
+ for (let i = 0; i < aa.length; i += W) {
8050
+ const n = Math.min(i + W, aa.length);
8051
+ html += `<div class="align-block">`
8052
+ + `<div class="align-row">${i + 1} ${escapeHtml(aa.slice(i, n))}</div>`
8053
+ + `<div class="align-row align-mid">${' '.repeat(String(i + 1).length + 1)}${escapeHtml(mid.slice(i, n))}</div>`
8054
+ + `<div class="align-row">${i + 1} ${escapeHtml(bb.slice(i, n))}</div></div>`;
8055
+ }
8056
+ out.innerHTML = html;
8057
+ out.hidden = false;
8058
+ stats.textContent = `${r.mode} · ${r.identity}% identity · ${r.matches}/${r.length} matches · ${r.gaps} gaps · score ${r.score}`;
8059
+ }
8060
+ runBtn.addEventListener('click', async () => {
8061
+ const a = (A.value || '').trim(), b = (B.value || '').trim();
8062
+ if (!a || !b) { stats.textContent = 'Paste two sequences first.'; out.hidden = true; return; }
8063
+ runBtn.disabled = true; stats.textContent = 'Aligning…'; out.hidden = true;
8064
+ try {
8065
+ const res = await fetch('/api/align', {
8066
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
8067
+ body: JSON.stringify({ a, b, mode }),
8068
+ });
8069
+ const data = await res.json().catch(() => ({}));
8070
+ if (!res.ok || data.error) { stats.textContent = data.error || `Alignment failed (${res.status}).`; }
8071
+ else render(data);
8072
+ } catch (e) {
8073
+ stats.textContent = 'Network error — try again.';
8074
+ } finally { runBtn.disabled = false; }
8075
+ });
8076
+ })();
dee/static/index.html CHANGED
@@ -28,7 +28,7 @@
28
  <!-- ?v= query bumps invalidate browser + iframe asset caches when app.css /
29
  app.js change. Bump these numbers whenever you ship a frontend update —
30
  without them, users keep getting the stale file for up to a week. -->
31
- <link rel="stylesheet" href="/static/app.css?v=20260609-fold" />
32
  <link rel="icon" type="image/svg+xml" href="/static/favicon.svg?v=2" />
33
  <link rel="apple-touch-icon" href="/static/favicon.svg?v=2" />
34
  <!-- Mol* (PDBe) 3-D viewer is ~4.9 MB. We do NOT eager-load it on every
@@ -1270,6 +1270,27 @@
1270
  circular-aware. Auto-annotation flags ORFs + common motifs; GenBank features are kept as-is.</p>
1271
  </section>
1272
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1273
  <!-- Cloning / assembly -->
1274
  <section class="card plasmid-clone-card">
1275
  <div class="clone-head">
@@ -1816,6 +1837,6 @@
1816
  <!-- Cloning reference data must load before app.js so the Designer
1817
  can read VECTORS / ENZYMES / CLONING_METHODS / TAGS / LINKERS. -->
1818
  <script src="/static/cloning_db.js?v=20260530-ui-polish" defer></script>
1819
- <script src="/static/app.js?v=20260609-fold" defer></script>
1820
  </body>
1821
  </html>
 
28
  <!-- ?v= query bumps invalidate browser + iframe asset caches when app.css /
29
  app.js change. Bump these numbers whenever you ship a frontend update —
30
  without them, users keep getting the stale file for up to a week. -->
31
+ <link rel="stylesheet" href="/static/app.css?v=20260609-align" />
32
  <link rel="icon" type="image/svg+xml" href="/static/favicon.svg?v=2" />
33
  <link rel="apple-touch-icon" href="/static/favicon.svg?v=2" />
34
  <!-- Mol* (PDBe) 3-D viewer is ~4.9 MB. We do NOT eager-load it on every
 
1270
  circular-aware. Auto-annotation flags ORFs + common motifs; GenBank features are kept as-is.</p>
1271
  </section>
1272
 
1273
+ <!-- Pairwise sequence alignment -->
1274
+ <section class="card plasmid-align-card">
1275
+ <div class="clone-head">
1276
+ <h2 class="clone-title">Align two sequences</h2>
1277
+ <div class="clone-methods" role="tablist">
1278
+ <button class="clone-method clone-method-active" data-align-mode="global" type="button">Global</button>
1279
+ <button class="clone-method" data-align-mode="local" type="button">Local</button>
1280
+ </div>
1281
+ </div>
1282
+ <p class="field-hint">Compare two DNA or protein sequences — percent identity, mismatches and gaps. <strong>Global</strong> aligns end-to-end (Needleman–Wunsch); <strong>Local</strong> finds the best shared region (Smith–Waterman).</p>
1283
+ <div class="align-inputs">
1284
+ <textarea id="alignA" class="primer-textarea mono" rows="3" spellcheck="false" placeholder="Sequence A — ACGT… or protein"></textarea>
1285
+ <textarea id="alignB" class="primer-textarea mono" rows="3" spellcheck="false" placeholder="Sequence B"></textarea>
1286
+ </div>
1287
+ <div class="align-actions">
1288
+ <button class="primary" type="button" id="alignRun">Align</button>
1289
+ <span class="align-stats" id="alignStats"></span>
1290
+ </div>
1291
+ <div class="align-output" id="alignOutput" hidden></div>
1292
+ </section>
1293
+
1294
  <!-- Cloning / assembly -->
1295
  <section class="card plasmid-clone-card">
1296
  <div class="clone-head">
 
1837
  <!-- Cloning reference data must load before app.js so the Designer
1838
  can read VECTORS / ENZYMES / CLONING_METHODS / TAGS / LINKERS. -->
1839
  <script src="/static/cloning_db.js?v=20260530-ui-polish" defer></script>
1840
+ <script src="/static/app.js?v=20260609-align" defer></script>
1841
  </body>
1842
  </html>
tests/test_align.py ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for pairwise alignment (dee/core/align.py)."""
2
+ import pytest
3
+
4
+ from dee.core import align as A
5
+
6
+
7
+ def test_identical_global():
8
+ r = A.align("ACGTACGT", "ACGTACGT")
9
+ assert r["identity"] == 100.0
10
+ assert r["gaps"] == 0
11
+ assert r["aligned_a"] == r["aligned_b"] == "ACGTACGT"
12
+ assert r["mode"] == "global"
13
+
14
+
15
+ def test_single_mismatch():
16
+ r = A.align("ACGTACGT", "ACGTTCGT")
17
+ assert r["gaps"] == 0
18
+ assert r["length"] == 8
19
+ assert r["matches"] == 7
20
+ assert 80.0 < r["identity"] < 100.0
21
+ assert r["midline"].count(".") == 1 # one mismatch column
22
+
23
+
24
+ def test_insertion_makes_a_gap():
25
+ # b has an extra base → a gap appears in the alignment
26
+ r = A.align("ACGTACGT", "ACGTAACGT")
27
+ assert r["gaps"] >= 1
28
+ assert "-" in r["aligned_a"]
29
+ # all original bases still match where aligned
30
+ assert r["matches"] == 8
31
+
32
+
33
+ def test_local_finds_embedded_region():
34
+ a = "GGGGGGACGTACGTGGGGGG"
35
+ b = "TTTTACGTACGTTTTT"
36
+ r = A.align(a, b, mode="local")
37
+ assert r["mode"] == "local"
38
+ # the shared ACGTACGT core should align at 100% identity, no gaps
39
+ assert "ACGTACGT" in r["aligned_a"].replace("-", "")
40
+ assert r["identity"] == 100.0
41
+ assert r["gaps"] == 0
42
+
43
+
44
+ def test_whitespace_and_case_normalised():
45
+ r = A.align("ac gt\nac gt", "ACGTACGT")
46
+ assert r["identity"] == 100.0
47
+
48
+
49
+ def test_empty_raises():
50
+ with pytest.raises(ValueError):
51
+ A.align("", "ACGT")
52
+ with pytest.raises(ValueError):
53
+ A.align("ACGT", " ")
54
+
55
+
56
+ def test_oversize_raises():
57
+ big = "A" * 2000
58
+ with pytest.raises(ValueError):
59
+ A.align(big, big) # 4,000,000 cells > MAX_CELLS
60
+
61
+
62
+ def test_midline_lengths_consistent():
63
+ r = A.align("ACGTACGTAC", "ACGAACGTTC")
64
+ assert len(r["aligned_a"]) == len(r["aligned_b"]) == len(r["midline"]) == r["length"]