offtargeteffect commited on
Commit
bdd3f19
Β·
verified Β·
1 Parent(s): 99f834c

Add liability/QC, cluster & tree, and experiment tracking

Browse files
Dockerfile CHANGED
@@ -29,9 +29,7 @@ COPY . .
29
  ENV PORT=5007 \
30
  HOST=0.0.0.0
31
 
32
- # Hugging Face Spaces runs the container as UID 1000 (non-root). Point HOME and
33
- # library cache dirs at /tmp (world-writable) so Panel/Bokeh/matplotlib can
34
- # write caches without permission errors.
35
  ENV HOME=/tmp \
36
  XDG_CACHE_HOME=/tmp/.cache \
37
  MPLCONFIGDIR=/tmp/matplotlib \
 
29
  ENV PORT=5007 \
30
  HOST=0.0.0.0
31
 
32
+ # Hugging Face Spaces runs as UID 1000 (non-root); point caches at /tmp
 
 
33
  ENV HOME=/tmp \
34
  XDG_CACHE_HOME=/tmp/.cache \
35
  MPLCONFIGDIR=/tmp/matplotlib \
core/analysis/analyzer.py CHANGED
@@ -27,6 +27,9 @@ from core.analysis.restriction_sites import (
27
  )
28
  from core.analysis.kozak import check_kozak, KozakResult
29
  from core.analysis.structure import predict_structure, StructureResult
 
 
 
30
 
31
 
32
  @dataclass
@@ -68,6 +71,15 @@ class AnalysisReport:
68
  # Secondary structure (ViennaRNA)
69
  structure: Optional[StructureResult] = None
70
 
 
 
 
 
 
 
 
 
 
71
  # Errors / warnings generated during analysis
72
  warnings: List[str] = field(default_factory=list)
73
 
@@ -92,6 +104,12 @@ class AnalysisReport:
92
  "kozak_score": self.kozak.score if self.kozak else None,
93
  "kozak_strength": self.kozak.strength if self.kozak else None,
94
  "mfe": self.structure.mfe if self.structure else None,
 
 
 
 
 
 
95
  "warnings": self.warnings,
96
  }
97
 
@@ -269,8 +287,22 @@ class SequenceAnalyzer:
269
  struct_data = self.analyze_structure(full_seq)
270
  report.structure = struct_data["structure"]
271
 
 
 
 
 
 
 
 
 
 
 
 
272
  report.warnings = warnings
273
 
 
 
 
274
  # Cache result
275
  seq._analysis_cache[cache_key] = report
276
  return report
 
27
  )
28
  from core.analysis.kozak import check_kozak, KozakResult
29
  from core.analysis.structure import predict_structure, StructureResult
30
+ from core.analysis.uridine import analyze_uridine, UridineReport
31
+ from core.analysis.motifs import scan_motifs, MotifHit
32
+ from core.analysis.liability import assess_liabilities, LiabilityReport
33
 
34
 
35
  @dataclass
 
71
  # Secondary structure (ViennaRNA)
72
  structure: Optional[StructureResult] = None
73
 
74
+ # Uridine content (immunogenicity proxy)
75
+ uridine: Optional[UridineReport] = None
76
+
77
+ # Sequence-liability motifs (uORF, premature polyA, ARE, splice donor)
78
+ motif_hits: List[MotifHit] = field(default_factory=list)
79
+
80
+ # Aggregated liability / QC assessment
81
+ liability: Optional[LiabilityReport] = None
82
+
83
  # Errors / warnings generated during analysis
84
  warnings: List[str] = field(default_factory=list)
85
 
 
104
  "kozak_score": self.kozak.score if self.kozak else None,
105
  "kozak_strength": self.kozak.strength if self.kozak else None,
106
  "mfe": self.structure.mfe if self.structure else None,
107
+ "uridine_percent": self.uridine.u_percent if self.uridine else None,
108
+ "liability_score": self.liability.score if self.liability else None,
109
+ "liability_verdict": self.liability.verdict if self.liability else None,
110
+ "liability_critical": self.liability.n_critical if self.liability else None,
111
+ "liability_warning": self.liability.n_warning if self.liability else None,
112
+ "liability_flag_count": self.liability.flag_count if self.liability else None,
113
  "warnings": self.warnings,
114
  }
115
 
 
287
  struct_data = self.analyze_structure(full_seq)
288
  report.structure = struct_data["structure"]
289
 
290
+ # Uridine content (immunogenicity proxy)
291
+ report.uridine = analyze_uridine(full_seq)
292
+
293
+ # Sequence-liability motifs (region-aware when components are available)
294
+ report.motif_hits = scan_motifs(
295
+ five_prime_utr=seq.five_prime_utr,
296
+ cds=seq.cds,
297
+ three_prime_utr=seq.three_prime_utr,
298
+ full_seq=full_seq,
299
+ )
300
+
301
  report.warnings = warnings
302
 
303
+ # Aggregate everything into the liability / QC assessment
304
+ report.liability = assess_liabilities(report, seq)
305
+
306
  # Cache result
307
  seq._analysis_cache[cache_key] = report
308
  return report
core/analysis/clustering.py ADDED
@@ -0,0 +1,201 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Sequence clustering and tree building for worklist exploration.
3
+
4
+ Provides a lightweight, dependency-light pipeline (numpy only) analogous to an
5
+ immune-repertoire clustering view:
6
+
7
+ 1. ``kmer_distance_matrix`` β€” k-mer cosine distance between sequences.
8
+ 2. ``upgma`` β€” average-linkage hierarchical clustering β†’ a SciPy-style
9
+ ``linkage`` array plus a leaf ordering for plotting a dendrogram.
10
+ 3. ``flat_clusters`` β€” cut the tree at a distance threshold into flat clusters.
11
+
12
+ Everything works on raw nucleotide strings; no alignment required.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ from collections import Counter
17
+ from dataclasses import dataclass, field
18
+ from typing import Dict, List, Tuple
19
+
20
+ import numpy as np
21
+
22
+
23
+ def kmer_counts(seq: str, k: int = 4) -> Counter:
24
+ """Count k-mers in a sequence (DNA alphabet, U→T)."""
25
+ s = (seq or "").upper().replace("U", "T")
26
+ if len(s) < k:
27
+ return Counter()
28
+ return Counter(s[i:i + k] for i in range(len(s) - k + 1))
29
+
30
+
31
+ def _cosine_distance(a: Counter, b: Counter) -> float:
32
+ """1 - cosine similarity between two k-mer count vectors."""
33
+ if not a or not b:
34
+ return 1.0
35
+ # dot product over shared keys
36
+ shared = set(a) & set(b)
37
+ dot = sum(a[k] * b[k] for k in shared)
38
+ na = np.sqrt(sum(v * v for v in a.values()))
39
+ nb = np.sqrt(sum(v * v for v in b.values()))
40
+ if na == 0 or nb == 0:
41
+ return 1.0
42
+ cos = dot / (na * nb)
43
+ return float(max(0.0, 1.0 - cos))
44
+
45
+
46
+ def kmer_distance_matrix(sequences: List[str], k: int = 4) -> np.ndarray:
47
+ """Symmetric pairwise k-mer cosine distance matrix (nΓ—n)."""
48
+ vecs = [kmer_counts(s, k) for s in sequences]
49
+ n = len(vecs)
50
+ d = np.zeros((n, n), dtype=float)
51
+ for i in range(n):
52
+ for j in range(i + 1, n):
53
+ dist = _cosine_distance(vecs[i], vecs[j])
54
+ d[i, j] = d[j, i] = dist
55
+ return d
56
+
57
+
58
+ def upgma(dist: np.ndarray) -> Tuple[np.ndarray, List[int]]:
59
+ """
60
+ Average-linkage (UPGMA) hierarchical clustering.
61
+
62
+ Parameters
63
+ ----------
64
+ dist : np.ndarray
65
+ Square symmetric distance matrix (nΓ—n).
66
+
67
+ Returns
68
+ -------
69
+ linkage : np.ndarray
70
+ SciPy-style (n-1)Γ—4 array: each row [id_a, id_b, height, size].
71
+ Leaf ids are 0..n-1; internal nodes get ids n, n+1, …
72
+ leaf_order : list[int]
73
+ Left-to-right leaf ordering for plotting a non-crossing dendrogram.
74
+ """
75
+ n = dist.shape[0]
76
+ if n < 2:
77
+ return np.empty((0, 4)), list(range(n))
78
+
79
+ # active clusters: id -> (members, size). distances kept in a dict.
80
+ sizes: Dict[int, int] = {i: 1 for i in range(n)}
81
+ members: Dict[int, List[int]] = {i: [i] for i in range(n)}
82
+ active = list(range(n))
83
+ D: Dict[Tuple[int, int], float] = {}
84
+ for i in range(n):
85
+ for j in range(i + 1, n):
86
+ D[(i, j)] = dist[i, j]
87
+
88
+ def key(a: int, b: int) -> Tuple[int, int]:
89
+ return (a, b) if a < b else (b, a)
90
+
91
+ linkage = []
92
+ next_id = n
93
+ while len(active) > 1:
94
+ # find closest pair
95
+ best = None
96
+ best_pair = None
97
+ for ai in range(len(active)):
98
+ for bi in range(ai + 1, len(active)):
99
+ a, b = active[ai], active[bi]
100
+ dval = D[key(a, b)]
101
+ if best is None or dval < best:
102
+ best = dval
103
+ best_pair = (a, b)
104
+ a, b = best_pair
105
+ new = next_id
106
+ next_id += 1
107
+ sa, sb = sizes[a], sizes[b]
108
+ members[new] = members[a] + members[b]
109
+ sizes[new] = sa + sb
110
+ linkage.append([float(a), float(b), float(best), float(sizes[new])])
111
+
112
+ # update distances (average linkage weighted by size)
113
+ for c in active:
114
+ if c in (a, b):
115
+ continue
116
+ dac = D[key(a, c)]
117
+ dbc = D[key(b, c)]
118
+ D[key(new, c)] = (sa * dac + sb * dbc) / (sa + sb)
119
+ active.remove(a)
120
+ active.remove(b)
121
+ active.append(new)
122
+
123
+ # leaf order from the merge tree (recursive, left then right)
124
+ def leaves_of(node: int) -> List[int]:
125
+ return members[node]
126
+
127
+ root = active[0]
128
+ leaf_order = leaves_of(root)
129
+ return np.array(linkage, dtype=float), leaf_order
130
+
131
+
132
+ def flat_clusters(linkage: np.ndarray, n_leaves: int, threshold: float) -> List[int]:
133
+ """
134
+ Cut the dendrogram at ``threshold``: merge nodes joined below the threshold,
135
+ returning a cluster id (0-based, contiguous) per original leaf.
136
+ """
137
+ if n_leaves == 0:
138
+ return []
139
+ parent = list(range(n_leaves + len(linkage)))
140
+
141
+ def find(x: int) -> int:
142
+ while parent[x] != x:
143
+ parent[x] = parent[parent[x]]
144
+ x = parent[x]
145
+ return x
146
+
147
+ def union(x: int, y: int) -> None:
148
+ parent[find(x)] = find(y)
149
+
150
+ for i, row in enumerate(linkage):
151
+ a, b, height = int(row[0]), int(row[1]), row[2]
152
+ new = n_leaves + i
153
+ if height <= threshold:
154
+ union(a, new)
155
+ union(b, new)
156
+
157
+ # map each leaf's root to a contiguous cluster id
158
+ roots = [find(i) for i in range(n_leaves)]
159
+ remap: Dict[int, int] = {}
160
+ out = []
161
+ for r in roots:
162
+ if r not in remap:
163
+ remap[r] = len(remap)
164
+ out.append(remap[r])
165
+ return out
166
+
167
+
168
+ @dataclass
169
+ class DendrogramLayout:
170
+ """Coordinates for drawing a dendrogram."""
171
+ leaf_order: List[int] = field(default_factory=list)
172
+ leaf_x: Dict[int, float] = field(default_factory=dict) # leaf id -> x
173
+ # each link: (x0, x1, y0, y1) segments forming the bracket
174
+ segments: List[Tuple[float, float, float, float]] = field(default_factory=list)
175
+ node_x: Dict[int, float] = field(default_factory=dict)
176
+ node_y: Dict[int, float] = field(default_factory=dict)
177
+
178
+
179
+ def dendrogram_layout(linkage: np.ndarray, leaf_order: List[int]) -> DendrogramLayout:
180
+ """Compute x/y coordinates + bracket segments for a horizontal-leaf dendrogram."""
181
+ layout = DendrogramLayout(leaf_order=list(leaf_order))
182
+ n = len(leaf_order)
183
+ # leaves on the x-axis at integer positions, y=0
184
+ for pos, leaf in enumerate(leaf_order):
185
+ layout.leaf_x[leaf] = float(pos)
186
+ layout.node_x[leaf] = float(pos)
187
+ layout.node_y[leaf] = 0.0
188
+
189
+ for i, row in enumerate(linkage):
190
+ a, b, height = int(row[0]), int(row[1]), float(row[2])
191
+ new = n + i
192
+ xa, xb = layout.node_x[a], layout.node_x[b]
193
+ ya, yb = layout.node_y[a], layout.node_y[b]
194
+ xnew = (xa + xb) / 2.0
195
+ layout.node_x[new] = xnew
196
+ layout.node_y[new] = height
197
+ # bracket: up from a, across at height, down to b
198
+ layout.segments.append((xa, xa, ya, height)) # left vertical
199
+ layout.segments.append((xb, xb, yb, height)) # right vertical
200
+ layout.segments.append((xa, xb, height, height)) # horizontal top
201
+ return layout
core/analysis/liability.py ADDED
@@ -0,0 +1,237 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Liability / QC aggregator.
3
+
4
+ Rolls the individual analysis results (GC, homopolymers, restriction sites,
5
+ uridine, CDS validation, Kozak, secondary structure, sequence motifs) into a
6
+ single severity-ranked liability report with an overall QC score (0–100) and a
7
+ pass / review / fail verdict β€” analogous to a developability/liability overlay.
8
+
9
+ This module is a pure aggregator: it reads attributes off an already-computed
10
+ analysis report (duck-typed) and the sequence object, so it imports neither the
11
+ analyzer nor the Panel UI. It only depends on the homopolymer detector (to
12
+ re-scan the construct *body*, excluding the legitimate poly-A tail).
13
+ """
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import dataclass, field
17
+ from typing import Any, List, Optional
18
+
19
+ from core.analysis.homopolymers import detect_homopolymers
20
+
21
+ # Severity levels, ordered most→least severe
22
+ CRITICAL = "critical"
23
+ WARNING = "warning"
24
+ INFO = "info"
25
+
26
+ _SEVERITY_ORDER = {CRITICAL: 0, WARNING: 1, INFO: 2}
27
+ _PENALTY = {CRITICAL: 25, WARNING: 10, INFO: 3}
28
+
29
+ # Thresholds
30
+ _GC_LOW, _GC_HIGH = 40.0, 65.0 # warning band
31
+ _GC_LOW_CRIT, _GC_HIGH_CRIT = 30.0, 70.0 # critical band
32
+ _HOMOPOLYMER_WARN = 10 # body run length β†’ warning
33
+ _HOMOPOLYMER_CRIT = 15 # body run length β†’ critical
34
+ _URIDINE_WARN_PCT = 40.0
35
+ _MFE_PER_NT_INFO = -0.45 # very negative β†’ highly structured
36
+
37
+
38
+ @dataclass
39
+ class LiabilityFlag:
40
+ """A single liability finding."""
41
+ id: str
42
+ category: str # "GC", "Homopolymer", "Restriction", "Uridine",
43
+ # "CDS", "Kozak", "Structure", "Motif"
44
+ severity: str # CRITICAL | WARNING | INFO
45
+ title: str
46
+ detail: str
47
+ location: str = "" # human-readable location, e.g. "CDS pos 123"
48
+ recommendation: str = ""
49
+
50
+
51
+ @dataclass
52
+ class LiabilityReport:
53
+ """Aggregated liability assessment for one sequence."""
54
+ flags: List[LiabilityFlag] = field(default_factory=list)
55
+ score: int = 100 # 0–100, higher is cleaner
56
+ verdict: str = "pass" # "pass" | "review" | "fail"
57
+ n_critical: int = 0
58
+ n_warning: int = 0
59
+ n_info: int = 0
60
+ checks_run: int = 0
61
+
62
+ @property
63
+ def flag_count(self) -> int:
64
+ return len(self.flags)
65
+
66
+ def sorted_flags(self) -> List[LiabilityFlag]:
67
+ return sorted(self.flags, key=lambda f: _SEVERITY_ORDER.get(f.severity, 9))
68
+
69
+
70
+ def _body_sequence(seq: Any) -> str:
71
+ """Construct body = everything except the legitimate poly-A tail."""
72
+ parts = [
73
+ getattr(seq, "five_prime_utr", None),
74
+ getattr(seq, "kozak", None),
75
+ getattr(seq, "cds", None),
76
+ getattr(seq, "three_prime_utr", None),
77
+ ]
78
+ return "".join(p for p in parts if p).upper().replace("U", "T")
79
+
80
+
81
+ def assess_liabilities(report: Any, seq: Any) -> LiabilityReport:
82
+ """
83
+ Build a LiabilityReport from an analysis ``report`` and its ``seq``.
84
+
85
+ ``report`` is duck-typed: it is expected to expose the attributes set by
86
+ SequenceAnalyzer (gc_percent_global, restriction_enzymes_present, uridine,
87
+ has_start_codon/has_stop_codon/in_frame, kozak, structure, motif_hits).
88
+ """
89
+ flags: List[LiabilityFlag] = []
90
+ checks = 0
91
+
92
+ def add(category, severity, title, detail, location="", recommendation=""):
93
+ flags.append(LiabilityFlag(
94
+ id=f"{category.lower()}-{len(flags)}",
95
+ category=category, severity=severity, title=title,
96
+ detail=detail, location=location, recommendation=recommendation,
97
+ ))
98
+
99
+ # ── GC content ────────────────────────────────────────────────────────────
100
+ checks += 1
101
+ gc = getattr(report, "gc_percent_global", None)
102
+ if gc is not None and gc > 0:
103
+ if gc < _GC_LOW_CRIT or gc > _GC_HIGH_CRIT:
104
+ add("GC", CRITICAL, "GC content far outside optimal range",
105
+ f"Global GC is {gc:.1f}% (optimal {_GC_LOW:.0f}–{_GC_HIGH:.0f}%).",
106
+ "global",
107
+ "Re-balance GC; extremes hurt synthesis, translation, and stability.")
108
+ elif gc < _GC_LOW or gc > _GC_HIGH:
109
+ add("GC", WARNING, "GC content outside optimal range",
110
+ f"Global GC is {gc:.1f}% (optimal {_GC_LOW:.0f}–{_GC_HIGH:.0f}%).",
111
+ "global",
112
+ "Nudge GC toward 40–65% during codon optimisation.")
113
+
114
+ # ── Homopolymers in the body (exclude poly-A tail) ────────────────────────
115
+ checks += 1
116
+ body = _body_sequence(seq)
117
+ if body:
118
+ body_runs = detect_homopolymers(body, min_run=_HOMOPOLYMER_WARN)
119
+ if body_runs:
120
+ longest = max(body_runs, key=lambda r: r.length)
121
+ sev = CRITICAL if longest.length >= _HOMOPOLYMER_CRIT else WARNING
122
+ add("Homopolymer", sev, "Homopolymer run in construct body",
123
+ f"{len(body_runs)} run(s) β‰₯{_HOMOPOLYMER_WARN} nt; longest "
124
+ f"{longest.nucleotide}Γ—{longest.length}.",
125
+ f"body pos {longest.start}",
126
+ "Break up long single-base runs to avoid synthesis errors and "
127
+ "polymerase slippage.")
128
+
129
+ # ── Restriction sites ─────────────────────────────────────────────────────
130
+ checks += 1
131
+ enzymes = getattr(report, "restriction_enzymes_present", None) or []
132
+ if enzymes:
133
+ add("Restriction", WARNING, "Internal restriction sites present",
134
+ f"Sites for: {', '.join(sorted(enzymes))}.",
135
+ "construct",
136
+ "Remove internal sites or pick a cloning strategy that avoids them.")
137
+
138
+ # ── Uridine content / high-U stretches ────────────────────────────────────
139
+ checks += 1
140
+ uri = getattr(report, "uridine", None)
141
+ if uri is not None:
142
+ stretches = getattr(uri, "high_u_stretches", []) or []
143
+ u_pct = getattr(uri, "u_percent", 0.0)
144
+ if u_pct >= _URIDINE_WARN_PCT or stretches:
145
+ detail = f"Uridine {u_pct:.1f}%"
146
+ if stretches:
147
+ detail += f"; {len(stretches)} high-U stretch(es)"
148
+ add("Uridine", WARNING, "Elevated uridine content",
149
+ detail + ".",
150
+ "construct",
151
+ "High U is immunostimulatory β€” optimise sequence and/or use "
152
+ "modified nucleotides (e.g. N1-methylpseudouridine).")
153
+
154
+ # ── CDS integrity ─────────────────────────────────────────────────────────
155
+ if getattr(report, "has_start_codon", None) is not None:
156
+ checks += 1
157
+ if report.has_start_codon is False:
158
+ add("CDS", CRITICAL, "CDS missing start codon",
159
+ "CDS does not begin with ATG.", "CDS 5' end",
160
+ "Ensure the CDS starts with ATG.")
161
+ if getattr(report, "has_stop_codon", None) is False:
162
+ add("CDS", CRITICAL, "CDS missing stop codon",
163
+ "CDS does not end with a stop codon.", "CDS 3' end",
164
+ "Append a stop codon (TAA/TAG/TGA).")
165
+ if getattr(report, "in_frame", None) is False:
166
+ add("CDS", CRITICAL, "CDS not in frame",
167
+ "CDS length is not divisible by 3.", "CDS",
168
+ "Fix indels so the CDS length is a multiple of 3.")
169
+
170
+ # ── Kozak context ─────────────────────────────────────────────────────────
171
+ kz = getattr(report, "kozak", None)
172
+ if kz is not None:
173
+ checks += 1
174
+ strength = getattr(kz, "strength", None)
175
+ if strength == "weak":
176
+ add("Kozak", WARNING, "Weak Kozak context",
177
+ f"Kozak score {getattr(kz, 'score', 0):.2f} (weak).",
178
+ "around start codon",
179
+ "Strengthen Kozak: purine (A/G) at -3 and G at +4.")
180
+ elif strength == "adequate":
181
+ add("Kozak", INFO, "Sub-optimal Kozak context",
182
+ f"Kozak score {getattr(kz, 'score', 0):.2f} (adequate).",
183
+ "around start codon",
184
+ "Optional: optimise -3/+4 positions for stronger initiation.")
185
+
186
+ # ── Secondary structure (if computed) ─────────────────────────────────────
187
+ struct = getattr(report, "structure", None)
188
+ if struct is not None and not getattr(struct, "is_stub", True):
189
+ checks += 1
190
+ length = max(len(getattr(struct, "sequence", "") or ""), 1)
191
+ per_nt = getattr(struct, "mfe", 0.0) / length
192
+ if per_nt < _MFE_PER_NT_INFO:
193
+ add("Structure", INFO, "Highly structured mRNA",
194
+ f"MFE {struct.mfe:.1f} kcal/mol ({per_nt:.2f}/nt).",
195
+ "global",
196
+ "Strong structure (esp. near the 5' cap/start) can impede "
197
+ "translation initiation.")
198
+
199
+ # ── Sequence motifs ───────────────────────────────────────────────────────
200
+ motif_hits = getattr(report, "motif_hits", None) or []
201
+ if motif_hits:
202
+ checks += 1
203
+ # group by motif name
204
+ by_name: dict = {}
205
+ for h in motif_hits:
206
+ by_name.setdefault(h.name, []).append(h)
207
+ for name, group in by_name.items():
208
+ first = group[0]
209
+ sev = min((h.severity for h in group), key=lambda s: _SEVERITY_ORDER.get(s, 9))
210
+ positions = ", ".join(f"{h.region}:{h.start}" for h in group[:5])
211
+ if len(group) > 5:
212
+ positions += f" (+{len(group) - 5} more)"
213
+ add("Motif", sev, first.label,
214
+ f"{len(group)} occurrence(s). {first.description}",
215
+ positions,
216
+ first.recommendation)
217
+
218
+ # ── Score & verdict ───────────────────────────────────────────────────────
219
+ n_crit = sum(1 for f in flags if f.severity == CRITICAL)
220
+ n_warn = sum(1 for f in flags if f.severity == WARNING)
221
+ n_info = sum(1 for f in flags if f.severity == INFO)
222
+
223
+ penalty = sum(_PENALTY.get(f.severity, 0) for f in flags)
224
+ score = max(0, min(100, 100 - penalty))
225
+
226
+ if n_crit > 0:
227
+ verdict = "fail"
228
+ elif n_warn > 0:
229
+ verdict = "review"
230
+ else:
231
+ verdict = "pass"
232
+
233
+ return LiabilityReport(
234
+ flags=flags, score=score, verdict=verdict,
235
+ n_critical=n_crit, n_warning=n_warn, n_info=n_info,
236
+ checks_run=checks,
237
+ )
core/analysis/motifs.py ADDED
@@ -0,0 +1,133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Sequence-liability motif scanning for mRNA constructs.
3
+
4
+ Scans the functional regions of an mRNA for short sequence motifs that are
5
+ known to compromise expression, stability, or processing:
6
+
7
+ - **uORF start (upstream AUG)** in the 5'UTR β€” can initiate an upstream
8
+ open reading frame and reduce translation of the main CDS.
9
+ - **Premature polyadenylation signal** (AAUAAA / AUUAAA) inside the CDS β€”
10
+ can cause truncated transcripts.
11
+ - **AU-rich element (ARE, AUUUA)** in the 3'UTR β€” recruits decay machinery
12
+ and shortens mRNA half-life.
13
+ - **Cryptic 5' splice-donor consensus** (GT[AG]AGT) anywhere β€” risk of
14
+ aberrant splicing when transcribed from a DNA template.
15
+
16
+ All scanning is done on DNA alphabet (T, not U); inputs are normalised.
17
+ Severities are plain strings ("critical" / "warning" / "info") so this module
18
+ stays free of any dependency on the liability aggregator.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import re
23
+ from dataclasses import dataclass
24
+ from typing import List, Optional
25
+
26
+ # Severity labels (kept as bare strings to avoid import cycles)
27
+ CRITICAL = "critical"
28
+ WARNING = "warning"
29
+ INFO = "info"
30
+
31
+ # Cap per-motif hits so a pathological sequence can't produce thousands of rows
32
+ _MAX_HITS_PER_MOTIF = 50
33
+
34
+
35
+ @dataclass
36
+ class MotifHit:
37
+ """A single sequence-liability motif occurrence."""
38
+ name: str # machine name, e.g. "uorf"
39
+ label: str # human label, e.g. "Upstream AUG (uORF)"
40
+ region: str # "5'UTR" | "CDS" | "3'UTR" | "full"
41
+ start: int # 0-based start within the scanned region
42
+ end: int # exclusive end within the region
43
+ match: str # the matched subsequence (DNA alphabet)
44
+ severity: str # CRITICAL | WARNING | INFO
45
+ description: str # why it matters
46
+ recommendation: str # what to do about it
47
+
48
+ def __repr__(self) -> str:
49
+ return f"MotifHit({self.name} {self.region}@{self.start} {self.match!r})"
50
+
51
+
52
+ def _norm(seq: Optional[str]) -> str:
53
+ return (seq or "").upper().replace("U", "T")
54
+
55
+
56
+ def _find_all(pattern: str, seq: str) -> List[re.Match]:
57
+ """Find non-overlapping regex matches, capped."""
58
+ return list(re.finditer(pattern, seq))[:_MAX_HITS_PER_MOTIF]
59
+
60
+
61
+ def scan_motifs(
62
+ five_prime_utr: Optional[str] = None,
63
+ cds: Optional[str] = None,
64
+ three_prime_utr: Optional[str] = None,
65
+ full_seq: Optional[str] = None,
66
+ ) -> List[MotifHit]:
67
+ """
68
+ Scan an mRNA's regions for liability motifs.
69
+
70
+ Pass the individual components when available. ``full_seq`` is used for
71
+ region-agnostic scans (splice donor) and as a fallback when components
72
+ are not provided (e.g. a monolithic ``full_mrna`` record).
73
+ """
74
+ hits: List[MotifHit] = []
75
+
76
+ utr5 = _norm(five_prime_utr)
77
+ cds_s = _norm(cds)
78
+ utr3 = _norm(three_prime_utr)
79
+ full = _norm(full_seq) or (utr5 + cds_s + utr3)
80
+
81
+ # ── uORF: any ATG in the 5'UTR ────────────────────────────────────────────
82
+ for m in _find_all("ATG", utr5):
83
+ hits.append(MotifHit(
84
+ name="uorf",
85
+ label="Upstream AUG (uORF)",
86
+ region="5'UTR",
87
+ start=m.start(), end=m.end(), match=m.group(),
88
+ severity=WARNING,
89
+ description="An AUG in the 5'UTR can start an upstream ORF and "
90
+ "reduce ribosome loading on the main CDS.",
91
+ recommendation="Remove or silently mutate upstream AUGs in the 5'UTR.",
92
+ ))
93
+
94
+ # ── Premature polyadenylation signal inside the CDS ───────────────────────
95
+ for m in _find_all("AATAAA|ATTAAA", cds_s):
96
+ hits.append(MotifHit(
97
+ name="premature_polya",
98
+ label="Premature polyA signal",
99
+ region="CDS",
100
+ start=m.start(), end=m.end(), match=m.group(),
101
+ severity=CRITICAL,
102
+ description="A canonical polyadenylation signal inside the CDS can "
103
+ "cause premature cleavage and a truncated protein.",
104
+ recommendation="Codon-optimise to remove the internal AAUAAA/AUUAAA signal.",
105
+ ))
106
+
107
+ # ── AU-rich element (ARE) in the 3'UTR ────────────────────────────────────
108
+ for m in _find_all("ATTTA", utr3):
109
+ hits.append(MotifHit(
110
+ name="are",
111
+ label="AU-rich element (ARE)",
112
+ region="3'UTR",
113
+ start=m.start(), end=m.end(), match=m.group(),
114
+ severity=WARNING,
115
+ description="AUUUA pentamers recruit mRNA-decay machinery and shorten "
116
+ "transcript half-life.",
117
+ recommendation="Remove ARE motifs from the 3'UTR to improve stability.",
118
+ ))
119
+
120
+ # ── Cryptic 5' splice-donor consensus (region-agnostic) ───────────────────
121
+ for m in _find_all("GT[AG]AGT", full):
122
+ hits.append(MotifHit(
123
+ name="splice_donor",
124
+ label="Cryptic splice donor",
125
+ region="full",
126
+ start=m.start(), end=m.end(), match=m.group(),
127
+ severity=INFO,
128
+ description="Matches the 5' splice-donor consensus; may cause aberrant "
129
+ "splicing if transcribed from a DNA template in cells.",
130
+ recommendation="Consider disrupting the GU[A/G]AGU consensus if splicing is a concern.",
131
+ ))
132
+
133
+ return hits
demo/DEMO_SCRIPT.md ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # mRNA Design Studio β€” Demo Script (one page)
2
+
3
+ **Live app:** https://offtargeteffect-mrna-design-studio.hf.space
4
+ **Login:** username `admin` Β· password `vOAMljsXrzCemLZK4A38` *(or remove the password for a smoother live demo β€” see Prep)*
5
+ **Open in its own browser tab** β€” not the Hugging Face embedded preview (that loops on login).
6
+
7
+ ---
8
+
9
+ ## Prep (do 5 min before)
10
+ - [ ] Visit the URL to **wake the Space** (free tier sleeps; first load is slow).
11
+ - [ ] Have the sample file ready to drag in: `demo/demo_sequences_extended.csv` (14 constructs).
12
+ - [ ] *(Optional)* For the live database demo, have the Postgres connection details on a sticky note.
13
+ - [ ] *(Optional)* Remove the login: Space β†’ Settings β†’ secrets β†’ delete `MRNA_STUDIO_PASSWORD` β†’ app opens with no login.
14
+
15
+ ---
16
+
17
+ ## The story β€” follow the sidebar top to bottom (~8–10 min)
18
+
19
+ **1. Import Data (90s)** β€” "It ingests real-world sequence tables and structures them automatically."
20
+ - Drag `demo/demo_sequences_extended.csv` onto the CSV uploader.
21
+ - Show the **auto-suggested column mapping** (gene_name, cds, UTRs, …).
22
+ - Click **Import Records** β†’ 14 sequences land in the Worklist.
23
+ - *Or* demo the **PostgreSQL** path: pick PostgreSQL, paste the connection details, Connect β†’ select `mrna_sequences` β†’ Preview β†’ Import.
24
+
25
+ **2. Worklist β†’ Analyze (2 min)** β€” "Instant QC across the whole panel."
26
+ - Select all β†’ **Analyze**.
27
+ - Point out **GC%**, **CAI** (codon adaptation), **homopolymer** runs (the poly-A tails!), **restriction sites**.
28
+ - Note the contrast: component-based vs monolithic records both analyze cleanly.
29
+
30
+ **3. Model Repository (1 min)** β€” "Pluggable scoring β€” local models or remote APIs."
31
+ - Show the two built-in scorers: **mRNA Stability Scorer** and **RNA Structure Scorer**.
32
+ - Mention you can register a remote API endpoint too.
33
+
34
+ **4. Worklist β†’ Score & Export (2 min)** β€” "Rank candidates, hand off to the lab."
35
+ - Back on the Worklist, **Score** with a loaded model β†’ **sort by score**.
36
+ - **Export CSV** of the ranked panel.
37
+
38
+ **5. Parts Workshop (1 min)** β€” "A reusable parts library."
39
+ - Browse 5'UTR / Kozak / CDS / 3'UTR / poly-A parts; compose a construct.
40
+
41
+ **6. Assemble Plasmid β†’ Generate Sequences (2 min)** β€” "Close the loop."
42
+ - Pick the **pUC19-MCS** backbone, run **QC**, export the assembled construct.
43
+ - In **Generate Sequences**, produce a codon-optimized variant.
44
+
45
+ ---
46
+
47
+ ## If you have only 3 minutes
48
+ Import `demo/demo_sequences_extended.csv` β†’ **Analyze** β†’ **Score** β†’ **Export.**
49
+ That's the whole value: ingest β†’ analyze β†’ score β†’ export.
50
+
51
+ ## Likely questions
52
+ - *"Where does the data live?"* β†’ CSV/Excel upload or a PostgreSQL connection you provide.
53
+ - *"Can I use my own models?"* β†’ Yes β€” register a local Python model or a remote API endpoint.
54
+ - *"Is it hosted?"* β†’ Runs on Hugging Face Spaces (Docker); also runs locally with `make run`.
demo/ENPICOM_gap_analysis.md ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # mRNA Design Studio vs. ENPICOM IGX β€” gap analysis & roadmap
2
+
3
+ ## Positioning (one line)
4
+ ENPICOM = **discover & de-risk** candidates from massive sequenced repertoires (antibodies/TCRs).
5
+ mRNA Design Studio = **design, build & QC** chosen sequences for expression (mRNA).
6
+ Same philosophy (no-code, data + AI for bench scientists); different pipeline stage and scale.
7
+
8
+ ## Shared backbone (already have)
9
+ Import data + metadata Β· pluggable ML scoring Β· sequence analysis Β· candidate select/export Β· code-free UI.
10
+
11
+ ## What would make us a closer antibody-discovery analog
12
+
13
+ ### Quick wins (weeks)
14
+ 1. **Sequence clustering** β€” group by similarity (CDS/UTR family for mRNA; CDR3/V-gene for Ab). Mirrors IGX-Cluster; biggest "explore" gap.
15
+ 2. **Richer liability/QC panel** β€” extend current QC (restriction sites, homopolymers) into a flagged "liability" view: immunogenic motifs, GC extremes, secondary-structure hotspots. Mirrors IGX-Annotate (shallow version).
16
+ 3. **Bulk / large-table ingestion** β€” FASTA/FASTQ import + dedup so we handle thousands, not dozens.
17
+
18
+ ### Medium bets (1–2 quarters)
19
+ 4. **Tree / cluster visualization** for candidate prioritization (phylogenetic-style for Ab; family/variant tree for mRNA). Mirrors IGX-Branch.
20
+ 5. **Model lifecycle, not just a registry** β€” add training, versioning, and experiment tracking (we currently only *call* registered models). Mirrors ENPICOM's AI/MLOps layer.
21
+ 6. **Projects + collaboration** β€” multi-user workspaces, metadata lineage, audit trail. We already have Postgres + auth as a foundation.
22
+
23
+ ### Bigger bets (strategic)
24
+ 7. **NGS repertoire scale** β€” millions of reads, server-side processing. This is ENPICOM's core moat and the largest gap.
25
+ 8. **Structure-based developability** β€” fold/structure models for surface-liability prediction (the antibody-specific heavy lift).
26
+ 9. **Round/enrichment tracking** β€” compare selection/panning rounds over time.
27
+
28
+ ## Honest take
29
+ Closing items 1–4 makes us a credible "design + light analysis" peer for a *focused* workflow.
30
+ Items 7–8 are where ENPICOM's enterprise scale and antibody-structure depth live β€” matching those is a much larger investment and a different product bet.
demo/demo_sequences_extended.csv ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ id,gene_name,five_prime_utr,cds,three_prime_utr,poly_a_tail,full_mrna,target_protein,organism,expression_system,gc_target_percent,notes
2
+ 1,eGFP-hBG-HEK,ACATTTGCTTCTGACACAACTGTGTTCACTAGCAACCTCAAACAGACACCATGGTGCATCTGACTCCTGAGGAGAAGTCT,ATGGTGAGCAAGGGCGAGGAGCTGTTCACCGGGGTGGTGCCCATCCTGGTCGAGCTGGACGGCGACGTAAACGGCCACAAGTTCAGCGTGTCCGGCGAGGGCGAGGGCGATGCCACCTACGGCAAGCTGACCCTGAAGTTCATCTGCACCACCGGCAAGCTGCCCGTGCCCTGGCCCACCCTCGTGACCACCCTGACCTACGGCGTGCAGTGCTTCAGCCGCTACCCCGACCACATGAAGCAGCACGACTTCTTCAAGTCCGCCATGCCCGAAGGCTACGTCCAGGAGCGCACCATCTTCTTCAAGGACGACGGCAACTACAAGACCCGCGCCGAGGTGAAGTTCGAGGGCGACACCCTGGTGAACCGCATCGAGCTGAAGGGCATCGACTTCAAGGAGGACGGCAACATCCTGGGGCACAAGCTGGAGTACAACTACAACAGCCACAACGTCTATATCATGGCCGACAAGCAGAAGAACGGCATCAAGGTGAACTTCAAGATCCGCCACAACATCGAGGACGGCAGCGTGCAGCTCGCCGACCACTACCAGCAGAACACCCCCATCGGCGACGGCCCCGTGCTGCTGCCCGACAACCACTACCTGAGCACCCAGTCCGCCCTGAGCAAAGACCCCAACGAGAAGCGCGATCACATGGTCCTGCTGGAGTTCGTGACCGCCGCCGGGATCACTCTCGGCATGGACGAGCTGTACAAGTAA,GCTCGCTTTCTTGCTGTCCAATTTCTATTAAAGGTTCCTTTGTTCCCTAAGTCCAACTACTAAACTGGGGGATATTATGAAGGGCCTTGAGCATCTGGAT,AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA,,Enhanced GFP,Aequorea victoria,HEK293T,61.5,"Beta-globin UTRs, standard 120A tail. Benchmark reporter."
3
+ 2,eGFP-Alb-CHO,AGATCTTCTTTTAAATTTCTTTTTACTGAATTCAGCCAATATATGTAATCCTACTTTCAATCAATTTTCCTAAGCAATG,ATGGTGAGCAAGGGCGAGGAGCTGTTCACCGGGGTGGTGCCCATCCTGGTCGAGCTGGACGGCGACGTAAACGGCCACAAGTTCAGCGTGTCCGGCGAGGGCGAGGGCGATGCCACCTACGGCAAGCTGACCCTGAAGTTCATCTGCACCACCGGCAAGCTGCCCGTGCCCTGGCCCACCCTCGTGACCACCCTGACCTACGGCGTGCAGTGCTTCAGCCGCTACCCCGACCACATGAAGCAGCACGACTTCTTCAAGTCCGCCATGCCCGAAGGCTACGTCCAGGAGCGCACCATCTTCTTCAAGGACGACGGCAACTACAAGACCCGCGCCGAGGTGAAGTTCGAGGGCGACACCCTGGTGAACCGCATCGAGCTGAAGGGCATCGACTTCAAGGAGGACGGCAACATCCTGGGGCACAAGCTGGAGTACAACTACAACAGCCACAACGTCTATATCATGGCCGACAAGCAGAAGAACGGCATCAAGGTGAACTTCAAGATCCGCCACAACATCGAGGACGGCAGCGTGCAGCTCGCCGACCACTACCAGCAGAACACCCCCATCGGCGACGGCCCCGTGCTGCTGCCCGACAACCACTACCTGAGCACCCAGTCCGCCCTGAGCAAAGACCCCAACGAGAAGCGCGATCACATGGTCCTGCTGGAGTTCGTGACCGCCGCCGGGATCACTCTCGGCATGGACGAGCTGTACAAGTAA,AATAAAGATCTTTATTTTCATTAGATCTGTGTGTTGGTTTTTTGTGTGAATCGATAGTACTAAATACTTTTCAGACACCAGAAATGCAGAGCAGTTCAGA,AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA,,Enhanced GFP,Aequorea victoria,CHO,61.5,Albumin UTRs for prolonged expression in CHO.
4
+ 3,eGFP-Alb-HEK-shortA,AGATCTTCTTTTAAATTTCTTTTTACTGAATTCAGCCAATATATGTAATCCTACTTTCAATCAATTTTCCTAAGCAATG,ATGGTGAGCAAGGGCGAGGAGCTGTTCACCGGGGTGGTGCCCATCCTGGTCGAGCTGGACGGCGACGTAAACGGCCACAAGTTCAGCGTGTCCGGCGAGGGCGAGGGCGATGCCACCTACGGCAAGCTGACCCTGAAGTTCATCTGCACCACCGGCAAGCTGCCCGTGCCCTGGCCCACCCTCGTGACCACCCTGACCTACGGCGTGCAGTGCTTCAGCCGCTACCCCGACCACATGAAGCAGCACGACTTCTTCAAGTCCGCCATGCCCGAAGGCTACGTCCAGGAGCGCACCATCTTCTTCAAGGACGACGGCAACTACAAGACCCGCGCCGAGGTGAAGTTCGAGGGCGACACCCTGGTGAACCGCATCGAGCTGAAGGGCATCGACTTCAAGGAGGACGGCAACATCCTGGGGCACAAGCTGGAGTACAACTACAACAGCCACAACGTCTATATCATGGCCGACAAGCAGAAGAACGGCATCAAGGTGAACTTCAAGATCCGCCACAACATCGAGGACGGCAGCGTGCAGCTCGCCGACCACTACCAGCAGAACACCCCCATCGGCGACGGCCCCGTGCTGCTGCCCGACAACCACTACCTGAGCACCCAGTCCGCCCTGAGCAAAGACCCCAACGAGAAGCGCGATCACATGGTCCTGCTGGAGTTCGTGACCGCCGCCGGGATCACTCTCGGCATGGACGAGCTGTACAAGTAA,AATAAAGATCTTTATTTTCATTAGATCTGTGTGTTGGTTTTTTGTGTGAATCGATAGTACTAAATACTTTTCAGACACCAGAAATGCAGAGCAGTTCAGA,AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA,,Enhanced GFP,Aequorea victoria,HEK293T,61.5,Short 80A tail variant β€” tail-length screen.
5
+ 4,eGFP-noUTR-codonopt,,ATGGTGAGCAAGGGCGAGGAGCTGTTCACCGGGGTGGTGCCCATCCTGGTCGAGCTGGACGGCGACGTAAACGGCCACAAGTTCAGCGTGTCCGGCGAGGGCGAGGGCGATGCCACCTACGGCAAGCTGACCCTGAAGTTCATCTGCACCACCGGCAAGCTGCCCGTGCCCTGGCCCACCCTCGTGACCACCCTGACCTACGGCGTGCAGTGCTTCAGCCGCTACCCCGACCACATGAAGCAGCACGACTTCTTCAAGTCCGCCATGCCCGAAGGCTACGTCCAGGAGCGCACCATCTTCTTCAAGGACGACGGCAACTACAAGACCCGCGCCGAGGTGAAGTTCGAGGGCGACACCCTGGTGAACCGCATCGAGCTGAAGGGCATCGACTTCAAGGAGGACGGCAACATCCTGGGGCACAAGCTGGAGTACAACTACAACAGCCACAACGTCTATATCATGGCCGACAAGCAGAAGAACGGCATCAAGGTGAACTTCAAGATCCGCCACAACATCGAGGACGGCAGCGTGCAGCTCGCCGACCACTACCAGCAGAACACCCCCATCGGCGACGGCCCCGTGCTGCTGCCCGACAACCACTACCTGAGCACCCAGTCCGCCCTGAGCAAAGACCCCAACGAGAAGCGCGATCACATGGTCCTGCTGGAGTTCGTGACCGCCGCCGGGATCACTCTCGGCATGGACGAGCTGTACAAGTAA,,AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA,,Enhanced GFP,Aequorea victoria,in vitro,61.5,Bare CDS for codon-optimization testing.
6
+ 5,eGFP-hBG-Jurkat,ACATTTGCTTCTGACACAACTGTGTTCACTAGCAACCTCAAACAGACACCATGGTGCATCTGACTCCTGAGGAGAAGTCT,ATGGTGAGCAAGGGCGAGGAGCTGTTCACCGGGGTGGTGCCCATCCTGGTCGAGCTGGACGGCGACGTAAACGGCCACAAGTTCAGCGTGTCCGGCGAGGGCGAGGGCGATGCCACCTACGGCAAGCTGACCCTGAAGTTCATCTGCACCACCGGCAAGCTGCCCGTGCCCTGGCCCACCCTCGTGACCACCCTGACCTACGGCGTGCAGTGCTTCAGCCGCTACCCCGACCACATGAAGCAGCACGACTTCTTCAAGTCCGCCATGCCCGAAGGCTACGTCCAGGAGCGCACCATCTTCTTCAAGGACGACGGCAACTACAAGACCCGCGCCGAGGTGAAGTTCGAGGGCGACACCCTGGTGAACCGCATCGAGCTGAAGGGCATCGACTTCAAGGAGGACGGCAACATCCTGGGGCACAAGCTGGAGTACAACTACAACAGCCACAACGTCTATATCATGGCCGACAAGCAGAAGAACGGCATCAAGGTGAACTTCAAGATCCGCCACAACATCGAGGACGGCAGCGTGCAGCTCGCCGACCACTACCAGCAGAACACCCCCATCGGCGACGGCCCCGTGCTGCTGCCCGACAACCACTACCTGAGCACCCAGTCCGCCCTGAGCAAAGACCCCAACGAGAAGCGCGATCACATGGTCCTGCTGGAGTTCGTGACCGCCGCCGGGATCACTCTCGGCATGGACGAGCTGTACAAGTAA,GCTCGCTTTCTTGCTGTCCAATTTCTATTAAAGGTTCCTTTGTTCCCTAAGTCCAACTACTAAACTGGGGGATATTATGAAGGGCCTTGAGCATCTGGAT,AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA,,Enhanced GFP,Aequorea victoria,Jurkat,61.5,Beta-globin UTRs in T-cell line.
7
+ 6,mCherry-hBG-HEK,ACATTTGCTTCTGACACAACTGTGTTCACTAGCAACCTCAAACAGACACCATGGTGCATCTGACTCCTGAGGAGAAGTCT,ATGGTGAGCAAGGGCGAGGAGGATAACATGGCCATCATCAAGGAGTTCATGCGCTTCAAGGTGCACATGGAGGGCTCCGTGAACGGCCACGAGTTCGAGATCGAGGGCGAGGGCGAGGGCCGCCCCTACGAGGGCACCCAGACCGCCAAGCTGAAGGTGACCAAGGGTGGCCCCCTGCCCTTCGCCTGGGACATCCTGTCCCCTCAGTTCATGTACGGCTCCAAGGCCTACGTGAAGCACCCCGCCGACATCCCCGACTACTTGAAGCTGTCCTTCCCCGAGGGCTTCAAGTGGGAGCGCGTGATGAACTTCGAGGACGGCGGCGTGGTGACCGTGACCCAGGACTCCTCCCTGCAGGACGGCGAGTTCATCTACAAGGTGAAGCTGCGCGGCACCAACTTCCCCTCCGACGGCCCCGTAATGCAGAAGAAGACCATGGGCTGGGAGGCCTCCTCCGAGCGGATGTACCCCGAGGACGGCGCCCTGAAGGGCGAGATCAAGCAGAGGCTGAAGCTGAAGGACGGCGGCCACTACGACGCTGAGGTCAAGACCACCTACAAGGCCAAGAAGCCCGTGCAGCTGCCCGGCGCCTACAACGTCAACATCAAGTTGGACATCACCTCCCACAACGAGGACTACACCATCGTGGAACAGTACGAACGCGCCGAGGGCCGCCACTCCACCGGCGGCATGGACGAGCTGTACAAGTAA,GCTCGCTTTCTTGCTGTCCAATTTCTATTAAAGGTTCCTTTGTTCCCTAAGTCCAACTACTAAACTGGGGGATATTATGAAGGGCCTTGAGCATCTGGAT,AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA,,mCherry RFP,Discosoma sp.,HEK293T,62.6,Red reporter with beta-globin UTRs.
8
+ 7,mCherry-Alb-CHO,AGATCTTCTTTTAAATTTCTTTTTACTGAATTCAGCCAATATATGTAATCCTACTTTCAATCAATTTTCCTAAGCAATG,ATGGTGAGCAAGGGCGAGGAGGATAACATGGCCATCATCAAGGAGTTCATGCGCTTCAAGGTGCACATGGAGGGCTCCGTGAACGGCCACGAGTTCGAGATCGAGGGCGAGGGCGAGGGCCGCCCCTACGAGGGCACCCAGACCGCCAAGCTGAAGGTGACCAAGGGTGGCCCCCTGCCCTTCGCCTGGGACATCCTGTCCCCTCAGTTCATGTACGGCTCCAAGGCCTACGTGAAGCACCCCGCCGACATCCCCGACTACTTGAAGCTGTCCTTCCCCGAGGGCTTCAAGTGGGAGCGCGTGATGAACTTCGAGGACGGCGGCGTGGTGACCGTGACCCAGGACTCCTCCCTGCAGGACGGCGAGTTCATCTACAAGGTGAAGCTGCGCGGCACCAACTTCCCCTCCGACGGCCCCGTAATGCAGAAGAAGACCATGGGCTGGGAGGCCTCCTCCGAGCGGATGTACCCCGAGGACGGCGCCCTGAAGGGCGAGATCAAGCAGAGGCTGAAGCTGAAGGACGGCGGCCACTACGACGCTGAGGTCAAGACCACCTACAAGGCCAAGAAGCCCGTGCAGCTGCCCGGCGCCTACAACGTCAACATCAAGTTGGACATCACCTCCCACAACGAGGACTACACCATCGTGGAACAGTACGAACGCGCCGAGGGCCGCCACTCCACCGGCGGCATGGACGAGCTGTACAAGTAA,AATAAAGATCTTTATTTTCATTAGATCTGTGTGTTGGTTTTTTGTGTGAATCGATAGTACTAAATACTTTTCAGACACCAGAAATGCAGAGCAGTTCAGA,AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA,,mCherry RFP,Discosoma sp.,CHO,62.6,"Albumin UTRs, CHO production."
9
+ 8,mCherry-Alb-Primary,AGATCTTCTTTTAAATTTCTTTTTACTGAATTCAGCCAATATATGTAATCCTACTTTCAATCAATTTTCCTAAGCAATG,ATGGTGAGCAAGGGCGAGGAGGATAACATGGCCATCATCAAGGAGTTCATGCGCTTCAAGGTGCACATGGAGGGCTCCGTGAACGGCCACGAGTTCGAGATCGAGGGCGAGGGCGAGGGCCGCCCCTACGAGGGCACCCAGACCGCCAAGCTGAAGGTGACCAAGGGTGGCCCCCTGCCCTTCGCCTGGGACATCCTGTCCCCTCAGTTCATGTACGGCTCCAAGGCCTACGTGAAGCACCCCGCCGACATCCCCGACTACTTGAAGCTGTCCTTCCCCGAGGGCTTCAAGTGGGAGCGCGTGATGAACTTCGAGGACGGCGGCGTGGTGACCGTGACCCAGGACTCCTCCCTGCAGGACGGCGAGTTCATCTACAAGGTGAAGCTGCGCGGCACCAACTTCCCCTCCGACGGCCCCGTAATGCAGAAGAAGACCATGGGCTGGGAGGCCTCCTCCGAGCGGATGTACCCCGAGGACGGCGCCCTGAAGGGCGAGATCAAGCAGAGGCTGAAGCTGAAGGACGGCGGCCACTACGACGCTGAGGTCAAGACCACCTACAAGGCCAAGAAGCCCGTGCAGCTGCCCGGCGCCTACAACGTCAACATCAAGTTGGACATCACCTCCCACAACGAGGACTACACCATCGTGGAACAGTACGAACGCGCCGAGGGCCGCCACTCCACCGGCGGCATGGACGAGCTGTACAAGTAA,AATAAAGATCTTTATTTTCATTAGATCTGTGTGTTGGTTTTTTGTGTGAATCGATAGTACTAAATACTTTTCAGACACCAGAAATGCAGAGCAGTTCAGA,AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA,,mCherry RFP,Discosoma sp.,Primary T cells,62.6,Long tail for primary-cell stability.
10
+ 9,mCherry-noUTR-base,,ATGGTGAGCAAGGGCGAGGAGGATAACATGGCCATCATCAAGGAGTTCATGCGCTTCAAGGTGCACATGGAGGGCTCCGTGAACGGCCACGAGTTCGAGATCGAGGGCGAGGGCGAGGGCCGCCCCTACGAGGGCACCCAGACCGCCAAGCTGAAGGTGACCAAGGGTGGCCCCCTGCCCTTCGCCTGGGACATCCTGTCCCCTCAGTTCATGTACGGCTCCAAGGCCTACGTGAAGCACCCCGCCGACATCCCCGACTACTTGAAGCTGTCCTTCCCCGAGGGCTTCAAGTGGGAGCGCGTGATGAACTTCGAGGACGGCGGCGTGGTGACCGTGACCCAGGACTCCTCCCTGCAGGACGGCGAGTTCATCTACAAGGTGAAGCTGCGCGGCACCAACTTCCCCTCCGACGGCCCCGTAATGCAGAAGAAGACCATGGGCTGGGAGGCCTCCTCCGAGCGGATGTACCCCGAGGACGGCGCCCTGAAGGGCGAGATCAAGCAGAGGCTGAAGCTGAAGGACGGCGGCCACTACGACGCTGAGGTCAAGACCACCTACAAGGCCAAGAAGCCCGTGCAGCTGCCCGGCGCCTACAACGTCAACATCAAGTTGGACATCACCTCCCACAACGAGGACTACACCATCGTGGAACAGTACGAACGCGCCGAGGGCCGCCACTCCACCGGCGGCATGGACGAGCTGTACAAGTAA,,AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA,,mCherry RFP,Discosoma sp.,in vitro,62.6,Minimal construct β€” GC/CAI baseline.
11
+ 10,mCherry-hBG-shortA,ACATTTGCTTCTGACACAACTGTGTTCACTAGCAACCTCAAACAGACACCATGGTGCATCTGACTCCTGAGGAGAAGTCT,ATGGTGAGCAAGGGCGAGGAGGATAACATGGCCATCATCAAGGAGTTCATGCGCTTCAAGGTGCACATGGAGGGCTCCGTGAACGGCCACGAGTTCGAGATCGAGGGCGAGGGCGAGGGCCGCCCCTACGAGGGCACCCAGACCGCCAAGCTGAAGGTGACCAAGGGTGGCCCCCTGCCCTTCGCCTGGGACATCCTGTCCCCTCAGTTCATGTACGGCTCCAAGGCCTACGTGAAGCACCCCGCCGACATCCCCGACTACTTGAAGCTGTCCTTCCCCGAGGGCTTCAAGTGGGAGCGCGTGATGAACTTCGAGGACGGCGGCGTGGTGACCGTGACCCAGGACTCCTCCCTGCAGGACGGCGAGTTCATCTACAAGGTGAAGCTGCGCGGCACCAACTTCCCCTCCGACGGCCCCGTAATGCAGAAGAAGACCATGGGCTGGGAGGCCTCCTCCGAGCGGATGTACCCCGAGGACGGCGCCCTGAAGGGCGAGATCAAGCAGAGGCTGAAGCTGAAGGACGGCGGCCACTACGACGCTGAGGTCAAGACCACCTACAAGGCCAAGAAGCCCGTGCAGCTGCCCGGCGCCTACAACGTCAACATCAAGTTGGACATCACCTCCCACAACGAGGACTACACCATCGTGGAACAGTACGAACGCGCCGAGGGCCGCCACTCCACCGGCGGCATGGACGAGCTGTACAAGTAA,GCTCGCTTTCTTGCTGTCCAATTTCTATTAAAGGTTCCTTTGTTCCCTAAGTCCAACTACTAAACTGGGGGATATTATGAAGGGCCTTGAGCATCTGGAT,AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA,,mCherry RFP,Discosoma sp.,HEK293T,62.6,Short tail variant for stability comparison.
12
+ 11,eGFP-mono-hBG,,,,,ACATTTGCTTCTGACACAACTGTGTTCACTAGCAACCTCAAACAGACACCATGGTGCATCTGACTCCTGAGGAGAAGTCTGCCACCATGGTGAGCAAGGGCGAGGAGCTGTTCACCGGGGTGGTGCCCATCCTGGTCGAGCTGGACGGCGACGTAAACGGCCACAAGTTCAGCGTGTCCGGCGAGGGCGAGGGCGATGCCACCTACGGCAAGCTGACCCTGAAGTTCATCTGCACCACCGGCAAGCTGCCCGTGCCCTGGCCCACCCTCGTGACCACCCTGACCTACGGCGTGCAGTGCTTCAGCCGCTACCCCGACCACATGAAGCAGCACGACTTCTTCAAGTCCGCCATGCCCGAAGGCTACGTCCAGGAGCGCACCATCTTCTTCAAGGACGACGGCAACTACAAGACCCGCGCCGAGGTGAAGTTCGAGGGCGACACCCTGGTGAACCGCATCGAGCTGAAGGGCATCGACTTCAAGGAGGACGGCAACATCCTGGGGCACAAGCTGGAGTACAACTACAACAGCCACAACGTCTATATCATGGCCGACAAGCAGAAGAACGGCATCAAGGTGAACTTCAAGATCCGCCACAACATCGAGGACGGCAGCGTGCAGCTCGCCGACCACTACCAGCAGAACACCCCCATCGGCGACGGCCCCGTGCTGCTGCCCGACAACCACTACCTGAGCACCCAGTCCGCCCTGAGCAAAGACCCCAACGAGAAGCGCGATCACATGGTCCTGCTGGAGTTCGTGACCGCCGCCGGGATCACTCTCGGCATGGACGAGCTGTACAAGTAAGCTCGCTTTCTTGCTGTCCAATTTCTATTAAAGGTTCCTTTGTTCCCTAAGTCCAACTACTAAACTGGGGGATATTATGAAGGGCCTTGAGCATCTGGATAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA,Enhanced GFP,Aequorea victoria,HEK293T,51.5,Monolithic archive record (assembled mRNA).
13
+ 12,eGFP-mono-Alb,,,,,AGATCTTCTTTTAAATTTCTTTTTACTGAATTCAGCCAATATATGTAATCCTACTTTCAATCAATTTTCCTAAGCAATGGCCACCATGGTGAGCAAGGGCGAGGAGCTGTTCACCGGGGTGGTGCCCATCCTGGTCGAGCTGGACGGCGACGTAAACGGCCACAAGTTCAGCGTGTCCGGCGAGGGCGAGGGCGATGCCACCTACGGCAAGCTGACCCTGAAGTTCATCTGCACCACCGGCAAGCTGCCCGTGCCCTGGCCCACCCTCGTGACCACCCTGACCTACGGCGTGCAGTGCTTCAGCCGCTACCCCGACCACATGAAGCAGCACGACTTCTTCAAGTCCGCCATGCCCGAAGGCTACGTCCAGGAGCGCACCATCTTCTTCAAGGACGACGGCAACTACAAGACCCGCGCCGAGGTGAAGTTCGAGGGCGACACCCTGGTGAACCGCATCGAGCTGAAGGGCATCGACTTCAAGGAGGACGGCAACATCCTGGGGCACAAGCTGGAGTACAACTACAACAGCCACAACGTCTATATCATGGCCGACAAGCAGAAGAACGGCATCAAGGTGAACTTCAAGATCCGCCACAACATCGAGGACGGCAGCGTGCAGCTCGCCGACCACTACCAGCAGAACACCCCCATCGGCGACGGCCCCGTGCTGCTGCCCGACAACCACTACCTGAGCACCCAGTCCGCCCTGAGCAAAGACCCCAACGAGAAGCGCGATCACATGGTCCTGCTGGAGTTCGTGACCGCCGCCGGGATCACTCTCGGCATGGACGAGCTGTACAAGTAAAATAAAGATCTTTATTTTCATTAGATCTGTGTGTTGGTTTTTTGTGTGAATCGATAGTACTAAATACTTTTCAGACACCAGAAATGCAGAGCAGTTCAGAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA,Enhanced GFP,Aequorea victoria,CHO,49.9,"Monolithic, albumin UTRs."
14
+ 13,mCherry-mono-hBG,,,,,ACATTTGCTTCTGACACAACTGTGTTCACTAGCAACCTCAAACAGACACCATGGTGCATCTGACTCCTGAGGAGAAGTCTGCCACCATGGTGAGCAAGGGCGAGGAGGATAACATGGCCATCATCAAGGAGTTCATGCGCTTCAAGGTGCACATGGAGGGCTCCGTGAACGGCCACGAGTTCGAGATCGAGGGCGAGGGCGAGGGCCGCCCCTACGAGGGCACCCAGACCGCCAAGCTGAAGGTGACCAAGGGTGGCCCCCTGCCCTTCGCCTGGGACATCCTGTCCCCTCAGTTCATGTACGGCTCCAAGGCCTACGTGAAGCACCCCGCCGACATCCCCGACTACTTGAAGCTGTCCTTCCCCGAGGGCTTCAAGTGGGAGCGCGTGATGAACTTCGAGGACGGCGGCGTGGTGACCGTGACCCAGGACTCCTCCCTGCAGGACGGCGAGTTCATCTACAAGGTGAAGCTGCGCGGCACCAACTTCCCCTCCGACGGCCCCGTAATGCAGAAGAAGACCATGGGCTGGGAGGCCTCCTCCGAGCGGATGTACCCCGAGGACGGCGCCCTGAAGGGCGAGATCAAGCAGAGGCTGAAGCTGAAGGACGGCGGCCACTACGACGCTGAGGTCAAGACCACCTACAAGGCCAAGAAGCCCGTGCAGCTGCCCGGCGCCTACAACGTCAACATCAAGTTGGACATCACCTCCCACAACGAGGACTACACCATCGTGGAACAGTACGAACGCGCCGAGGGCCGCCACTCCACCGGCGGCATGGACGAGCTGTACAAGTAAGCTCGCTTTCTTGCTGTCCAATTTCTATTAAAGGTTCCTTTGTTCCCTAAGTCCAACTACTAAACTGGGGGATATTATGAAGGGCCTTGAGCATCTGGATAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA,mCherry RFP,Discosoma sp.,HEK293T,52.1,"Monolithic red reporter, beta-globin UTRs."
15
+ 14,mCherry-mono-Alb,,,,,AGATCTTCTTTTAAATTTCTTTTTACTGAATTCAGCCAATATATGTAATCCTACTTTCAATCAATTTTCCTAAGCAATGGCCACCATGGTGAGCAAGGGCGAGGAGGATAACATGGCCATCATCAAGGAGTTCATGCGCTTCAAGGTGCACATGGAGGGCTCCGTGAACGGCCACGAGTTCGAGATCGAGGGCGAGGGCGAGGGCCGCCCCTACGAGGGCACCCAGACCGCCAAGCTGAAGGTGACCAAGGGTGGCCCCCTGCCCTTCGCCTGGGACATCCTGTCCCCTCAGTTCATGTACGGCTCCAAGGCCTACGTGAAGCACCCCGCCGACATCCCCGACTACTTGAAGCTGTCCTTCCCCGAGGGCTTCAAGTGGGAGCGCGTGATGAACTTCGAGGACGGCGGCGTGGTGACCGTGACCCAGGACTCCTCCCTGCAGGACGGCGAGTTCATCTACAAGGTGAAGCTGCGCGGCACCAACTTCCCCTCCGACGGCCCCGTAATGCAGAAGAAGACCATGGGCTGGGAGGCCTCCTCCGAGCGGATGTACCCCGAGGACGGCGCCCTGAAGGGCGAGATCAAGCAGAGGCTGAAGCTGAAGGACGGCGGCCACTACGACGCTGAGGTCAAGACCACCTACAAGGCCAAGAAGCCCGTGCAGCTGCCCGGCGCCTACAACGTCAACATCAAGTTGGACATCACCTCCCACAACGAGGACTACACCATCGTGGAACAGTACGAACGCGCCGAGGGCCGCCACTCCACCGGCGGCATGGACGAGCTGTACAAGTAAAATAAAGATCTTTATTTTCATTAGATCTGTGTGTTGGTTTTTTGTGTGAATCGATAGTACTAAATACTTTTCAGACACCAGAAATGCAGAGCAGTTCAGAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA,mCherry RFP,Discosoma sp.,Jurkat,50.5,"Monolithic, albumin UTRs, T-cell line."
models/runs.py ADDED
@@ -0,0 +1,122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Model run / experiment tracking.
3
+
4
+ A lightweight MLOps layer: every time a scoring model is run against a worklist
5
+ we record a ``ModelRun`` (model name + version, timestamp, score statistics, and
6
+ per-sequence scores). ``RunHistory`` stores them and can compare two runs β€” e.g.
7
+ two versions of the same model β€” to show how scores shifted.
8
+
9
+ Pure-Python (uses only the stdlib ``statistics`` module).
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import math
14
+ import statistics
15
+ import uuid
16
+ from dataclasses import dataclass, field
17
+ from typing import Dict, List, Optional
18
+
19
+
20
+ @dataclass
21
+ class ModelRun:
22
+ """A single scoring run over a set of sequences."""
23
+ model_name: str
24
+ model_version: str
25
+ model_source: str
26
+ worklist_name: str
27
+ n_sequences: int
28
+ score_min: float
29
+ score_max: float
30
+ score_mean: float
31
+ score_std: float
32
+ timestamp: str # ISO-8601
33
+ run_id: str = field(default_factory=lambda: str(uuid.uuid4())[:8])
34
+ notes: str = ""
35
+ scores: Dict[str, float] = field(default_factory=dict) # seq_id -> score
36
+
37
+ @property
38
+ def label(self) -> str:
39
+ return f"{self.model_name} v{self.model_version} Β· {self.timestamp}"
40
+
41
+
42
+ def summarize_run(
43
+ model_name: str,
44
+ model_version: str,
45
+ model_source: str,
46
+ worklist_name: str,
47
+ scores: Dict[str, float],
48
+ timestamp: str,
49
+ notes: str = "",
50
+ ) -> ModelRun:
51
+ """Build a ModelRun from a {seq_id: score} mapping (ignoring NaNs in stats)."""
52
+ valid = [v for v in scores.values() if v is not None and not math.isnan(v)]
53
+ n = len(valid)
54
+ smin = min(valid) if valid else float("nan")
55
+ smax = max(valid) if valid else float("nan")
56
+ smean = statistics.fmean(valid) if valid else float("nan")
57
+ sstd = statistics.pstdev(valid) if len(valid) > 1 else 0.0
58
+ return ModelRun(
59
+ model_name=model_name, model_version=model_version,
60
+ model_source=model_source, worklist_name=worklist_name,
61
+ n_sequences=n, score_min=smin, score_max=smax,
62
+ score_mean=smean, score_std=sstd, timestamp=timestamp,
63
+ notes=notes, scores=dict(scores),
64
+ )
65
+
66
+
67
+ @dataclass
68
+ class RunComparison:
69
+ """Per-sequence delta between two runs over their shared sequences."""
70
+ run_a: ModelRun
71
+ run_b: ModelRun
72
+ shared_ids: List[str]
73
+ deltas: Dict[str, float] # seq_id -> (b - a)
74
+ mean_delta: float
75
+ n_improved: int # b > a
76
+ n_worsened: int # b < a
77
+ n_unchanged: int
78
+
79
+
80
+ class RunHistory:
81
+ """Append-only store of ModelRun records."""
82
+
83
+ def __init__(self) -> None:
84
+ self.runs: List[ModelRun] = []
85
+
86
+ def add(self, run: ModelRun) -> None:
87
+ self.runs.append(run)
88
+
89
+ def for_model(self, model_name: str) -> List[ModelRun]:
90
+ return [r for r in self.runs if r.model_name == model_name]
91
+
92
+ def model_names(self) -> List[str]:
93
+ # preserve first-seen order
94
+ seen: List[str] = []
95
+ for r in self.runs:
96
+ if r.model_name not in seen:
97
+ seen.append(r.model_name)
98
+ return seen
99
+
100
+ @staticmethod
101
+ def compare(run_a: ModelRun, run_b: ModelRun) -> RunComparison:
102
+ shared = [sid for sid in run_a.scores if sid in run_b.scores]
103
+ deltas: Dict[str, float] = {}
104
+ improved = worsened = unchanged = 0
105
+ for sid in shared:
106
+ a, b = run_a.scores[sid], run_b.scores[sid]
107
+ if a is None or b is None or math.isnan(a) or math.isnan(b):
108
+ continue
109
+ d = b - a
110
+ deltas[sid] = d
111
+ if d > 1e-9:
112
+ improved += 1
113
+ elif d < -1e-9:
114
+ worsened += 1
115
+ else:
116
+ unchanged += 1
117
+ mean_delta = statistics.fmean(deltas.values()) if deltas else 0.0
118
+ return RunComparison(
119
+ run_a=run_a, run_b=run_b, shared_ids=list(deltas.keys()),
120
+ deltas=deltas, mean_delta=mean_delta,
121
+ n_improved=improved, n_worsened=worsened, n_unchanged=unchanged,
122
+ )
tests/test_clustering.py ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for sequence clustering and dendrogram building."""
2
+ import numpy as np
3
+ import pytest
4
+
5
+ from core.analysis.clustering import (
6
+ kmer_distance_matrix, upgma, flat_clusters, dendrogram_layout,
7
+ )
8
+
9
+
10
+ class TestDistance:
11
+ def test_identical_sequences_zero_distance(self):
12
+ d = kmer_distance_matrix(["ATGCATGC", "ATGCATGC"], k=3)
13
+ assert d[0, 1] == pytest.approx(0.0, abs=1e-9)
14
+
15
+ def test_disjoint_sequences_high_distance(self):
16
+ d = kmer_distance_matrix(["AAAAAAAA", "GGGGGGGG"], k=3)
17
+ assert d[0, 1] > 0.9
18
+
19
+ def test_matrix_is_symmetric_zero_diagonal(self):
20
+ d = kmer_distance_matrix(["ATGCATGC", "TTGGCCAA", "ATGCATGG"], k=3)
21
+ assert np.allclose(d, d.T)
22
+ assert np.allclose(np.diag(d), 0.0)
23
+
24
+ def test_short_sequence_safe(self):
25
+ d = kmer_distance_matrix(["AT", "ATGC"], k=4) # first too short for k
26
+ assert d.shape == (2, 2)
27
+
28
+
29
+ class TestUPGMA:
30
+ SEQS = ["ATGCATGCATGC", "ATGCATGCATGG", "TTTTGGGGTTTT", "TTTTGGGGTTTG"]
31
+
32
+ def test_linkage_shape(self):
33
+ link, order = upgma(kmer_distance_matrix(self.SEQS, k=3))
34
+ assert link.shape == (3, 4) # n-1 merges
35
+ assert sorted(order) == [0, 1, 2, 3] # leaf order is a permutation
36
+
37
+ def test_single_sequence_safe(self):
38
+ link, order = upgma(kmer_distance_matrix(["ATGC"], k=3))
39
+ assert link.shape == (0, 4)
40
+ assert order == [0]
41
+
42
+
43
+ class TestFlatClusters:
44
+ SEQS = ["ATGCATGCATGC", "ATGCATGCATGG", "TTTTGGGGTTTT", "TTTTGGGGTTTG"]
45
+
46
+ def _link(self):
47
+ return upgma(kmer_distance_matrix(self.SEQS, k=3))[0]
48
+
49
+ def test_two_clusters_group_correctly(self):
50
+ cl = flat_clusters(self._link(), 4, threshold=0.5)
51
+ assert cl[0] == cl[1] and cl[2] == cl[3] and cl[0] != cl[2]
52
+
53
+ def test_high_threshold_single_cluster(self):
54
+ cl = flat_clusters(self._link(), 4, threshold=10.0)
55
+ assert len(set(cl)) == 1
56
+
57
+ def test_negative_threshold_all_singletons(self):
58
+ cl = flat_clusters(self._link(), 4, threshold=-1.0)
59
+ assert len(set(cl)) == 4
60
+
61
+ def test_cluster_ids_contiguous_from_zero(self):
62
+ cl = flat_clusters(self._link(), 4, threshold=0.5)
63
+ assert set(cl) == set(range(len(set(cl))))
64
+
65
+
66
+ class TestDendrogramLayout:
67
+ def test_segments_and_heights(self):
68
+ seqs = ["ATGCATGCATGC", "ATGCATGCATGG", "TTTTGGGGTTTT", "TTTTGGGGTTTG"]
69
+ link, order = upgma(kmer_distance_matrix(seqs, k=3))
70
+ lay = dendrogram_layout(link, order)
71
+ assert len(lay.segments) == 3 * 3 # 3 segments per merge
72
+ assert all(y >= 0 for y in lay.node_y.values())
73
+ assert len(lay.leaf_x) == 4
tests/test_liability.py ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for sequence-liability motif scanning and the liability aggregator."""
2
+ from types import SimpleNamespace
3
+
4
+ import pytest
5
+
6
+ from core.analysis.motifs import scan_motifs
7
+ from core.analysis.liability import assess_liabilities, CRITICAL, WARNING
8
+
9
+
10
+ # ── Motif scanning ──────────────────────────────────────────────────────────
11
+
12
+ class TestMotifs:
13
+ def test_uorf_in_5utr(self):
14
+ hits = scan_motifs(five_prime_utr="GGGCATGGGG", cds="ATGAAATAA", three_prime_utr="")
15
+ names = [h.name for h in hits]
16
+ assert "uorf" in names
17
+ uorf = next(h for h in hits if h.name == "uorf")
18
+ assert uorf.region == "5'UTR"
19
+ assert uorf.severity == WARNING
20
+
21
+ def test_premature_polya_in_cds_is_critical(self):
22
+ hits = scan_motifs(cds="ATGAATAAACCCTAA")
23
+ prem = [h for h in hits if h.name == "premature_polya"]
24
+ assert prem and prem[0].severity == CRITICAL
25
+ assert prem[0].region == "CDS"
26
+
27
+ def test_are_in_3utr(self):
28
+ hits = scan_motifs(three_prime_utr="GGATTTAGG")
29
+ are = [h for h in hits if h.name == "are"]
30
+ assert are and are[0].region == "3'UTR"
31
+
32
+ def test_splice_donor_detected_in_full(self):
33
+ hits = scan_motifs(full_seq="CCCGTAAGTCCC")
34
+ assert any(h.name == "splice_donor" for h in hits)
35
+
36
+ def test_clean_sequence_has_no_motifs(self):
37
+ # CDS with no AATAAA/ATTAAA, UTRs without ATG/ATTTA, no GT[AG]AGT
38
+ hits = scan_motifs(
39
+ five_prime_utr="CCGCCGCCGCC",
40
+ cds="ATGGGCGGCGGCTAA",
41
+ three_prime_utr="CCGCCGCCG",
42
+ )
43
+ assert hits == []
44
+
45
+ def test_uridine_input_is_normalised(self):
46
+ # RNA alphabet (U) should be treated like T
47
+ hits = scan_motifs(three_prime_utr="GGAUUUAGG")
48
+ assert any(h.name == "are" for h in hits)
49
+
50
+
51
+ # ── Liability aggregation ───────────────────────────────────────────────────
52
+
53
+ def _clean_report():
54
+ return SimpleNamespace(
55
+ gc_percent_global=52.0,
56
+ restriction_enzymes_present=[],
57
+ uridine=SimpleNamespace(u_percent=22.0, high_u_stretches=[]),
58
+ has_start_codon=True, has_stop_codon=True, in_frame=True,
59
+ kozak=SimpleNamespace(strength="strong", score=0.9),
60
+ structure=SimpleNamespace(is_stub=True, mfe=0.0, sequence=""),
61
+ motif_hits=[],
62
+ )
63
+
64
+
65
+ def _clean_seq():
66
+ return SimpleNamespace(
67
+ five_prime_utr="CCGCCACC", kozak=None,
68
+ cds="ATGGGCGGCGGCTAA", three_prime_utr="CCGCCG",
69
+ poly_a="A" * 120,
70
+ )
71
+
72
+
73
+ class TestLiability:
74
+ def test_clean_sequence_passes(self):
75
+ rep = assess_liabilities(_clean_report(), _clean_seq())
76
+ assert rep.verdict == "pass"
77
+ assert rep.score == 100
78
+ assert rep.n_critical == 0 and rep.flag_count == 0
79
+
80
+ def test_polya_tail_not_flagged_as_homopolymer(self):
81
+ # body has no long run; the 120-A tail must be ignored
82
+ rep = assess_liabilities(_clean_report(), _clean_seq())
83
+ assert not any(f.category == "Homopolymer" for f in rep.flags)
84
+
85
+ def test_body_homopolymer_flagged(self):
86
+ seq = _clean_seq()
87
+ seq.cds = "ATG" + "A" * 16 + "GGCTAA" # 16-A run in the body
88
+ rep = assess_liabilities(_clean_report(), seq)
89
+ hp = [f for f in rep.flags if f.category == "Homopolymer"]
90
+ assert hp and hp[0].severity == CRITICAL
91
+
92
+ def test_extreme_gc_is_critical(self):
93
+ rep_dict = _clean_report()
94
+ rep_dict.gc_percent_global = 25.0
95
+ rep = assess_liabilities(rep_dict, _clean_seq())
96
+ assert any(f.category == "GC" and f.severity == CRITICAL for f in rep.flags)
97
+
98
+ def test_restriction_and_uridine_are_warnings(self):
99
+ r = _clean_report()
100
+ r.restriction_enzymes_present = ["EcoRI", "BamHI"]
101
+ r.uridine = SimpleNamespace(u_percent=46.0, high_u_stretches=[(1, 51, 50)])
102
+ rep = assess_liabilities(r, _clean_seq())
103
+ cats = {f.category for f in rep.flags}
104
+ assert "Restriction" in cats and "Uridine" in cats
105
+ assert rep.verdict == "review"
106
+
107
+ def test_missing_start_codon_fails(self):
108
+ r = _clean_report()
109
+ r.has_start_codon = False
110
+ rep = assess_liabilities(r, _clean_seq())
111
+ assert rep.verdict == "fail"
112
+ assert any(f.category == "CDS" and f.severity == CRITICAL for f in rep.flags)
113
+
114
+ def test_score_decreases_with_severity(self):
115
+ r = _clean_report()
116
+ r.has_start_codon = False # critical (-25)
117
+ r.restriction_enzymes_present = ["EcoRI"] # warning (-10)
118
+ rep = assess_liabilities(r, _clean_seq())
119
+ assert rep.score <= 65
120
+ assert rep.verdict == "fail"
121
+
122
+ def test_motif_hits_become_flags(self):
123
+ r = _clean_report()
124
+ r.motif_hits = scan_motifs(cds="ATGAATAAACCCTAA") # premature polyA (critical)
125
+ rep = assess_liabilities(r, _clean_seq())
126
+ assert any(f.category == "Motif" and f.severity == CRITICAL for f in rep.flags)
127
+ assert rep.verdict == "fail"
tests/test_runs.py ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for model run / experiment tracking."""
2
+ import math
3
+
4
+ from models.runs import summarize_run, RunHistory
5
+
6
+
7
+ def _run(name, version, scores, ts="2026-01-01 00:00:00"):
8
+ return summarize_run(
9
+ model_name=name, model_version=version, model_source="local",
10
+ worklist_name="wl", scores=scores, timestamp=ts,
11
+ )
12
+
13
+
14
+ class TestSummarize:
15
+ def test_basic_stats(self):
16
+ r = _run("m", "1.0", {"a": 1.0, "b": 3.0, "c": 5.0})
17
+ assert r.n_sequences == 3
18
+ assert r.score_min == 1.0 and r.score_max == 5.0
19
+ assert r.score_mean == 3.0
20
+ assert r.score_std > 0
21
+
22
+ def test_nan_scores_excluded(self):
23
+ r = _run("m", "1.0", {"a": 2.0, "b": float("nan")})
24
+ assert r.n_sequences == 1
25
+ assert r.score_mean == 2.0
26
+
27
+ def test_empty_scores_safe(self):
28
+ r = _run("m", "1.0", {})
29
+ assert r.n_sequences == 0
30
+ assert math.isnan(r.score_mean)
31
+
32
+ def test_label(self):
33
+ r = _run("MyModel", "2.1", {"a": 1.0})
34
+ assert "MyModel" in r.label and "2.1" in r.label
35
+
36
+
37
+ class TestRunHistory:
38
+ def test_add_and_for_model(self):
39
+ h = RunHistory()
40
+ h.add(_run("A", "1.0", {"x": 1.0}))
41
+ h.add(_run("B", "1.0", {"x": 2.0}))
42
+ h.add(_run("A", "2.0", {"x": 3.0}))
43
+ assert len(h.runs) == 3
44
+ assert len(h.for_model("A")) == 2
45
+ assert h.model_names() == ["A", "B"]
46
+
47
+ def test_compare_deltas(self):
48
+ a = _run("A", "1.0", {"s1": 1.0, "s2": 2.0, "s3": 5.0})
49
+ b = _run("A", "2.0", {"s1": 2.0, "s2": 2.0, "s3": 4.0}) # +1, 0, -1
50
+ cmp = RunHistory.compare(a, b)
51
+ assert cmp.n_improved == 1
52
+ assert cmp.n_worsened == 1
53
+ assert cmp.n_unchanged == 1
54
+ assert cmp.mean_delta == 0.0
55
+ assert set(cmp.shared_ids) == {"s1", "s2", "s3"}
56
+
57
+ def test_compare_only_shared_sequences(self):
58
+ a = _run("A", "1.0", {"s1": 1.0, "only_a": 9.0})
59
+ b = _run("A", "2.0", {"s1": 2.0, "only_b": 8.0})
60
+ cmp = RunHistory.compare(a, b)
61
+ assert cmp.shared_ids == ["s1"]
62
+ assert cmp.deltas["s1"] == 1.0
ui/app.py CHANGED
@@ -40,6 +40,8 @@ from ui.components.plasmid_view import PlasmidView
40
  from ui.components.model_repository import ModelRepositoryPanel
41
  from ui.components.plasmid_assembly import PlasmidAssemblyPanel
42
  from ui.components.generate_sequences import GenerateSequencesPanel
 
 
43
 
44
 
45
  # ── Design tokens ─────────────────────────────────────────────────────────────
@@ -150,11 +152,13 @@ _TAB_NAMES = [
150
  "Import Data",
151
  "Model Repository",
152
  "Worklist",
 
 
153
  "Parts Workshop",
154
  "Assemble Plasmid",
155
  "Generate Sequences",
156
  ]
157
- _TAB_KEYS = ["import_db", "model_repo", "worklist", "parts", "assemble", "generate"]
158
 
159
 
160
  logger = logging.getLogger(__name__)
@@ -185,6 +189,8 @@ class StudioApp(param.Parameterized):
185
  self._model_repo = ModelRepositoryPanel(self.state)
186
  self._assembly = PlasmidAssemblyPanel(self.state)
187
  self._generate = GenerateSequencesPanel(self.state)
 
 
188
 
189
  # ── Build persistent widgets once ─────────────────────────────────────
190
  self._tabs = pn.Tabs(
@@ -200,9 +206,11 @@ class StudioApp(param.Parameterized):
200
  pn.panel(self._worklist.panel),
201
  sizing_mode="stretch_width",
202
  )),
203
- (_TAB_NAMES[3], pn.panel(self._parts.panel)),
204
- (_TAB_NAMES[4], pn.panel(self._assembly.panel)),
205
- (_TAB_NAMES[5], pn.panel(self._generate.panel)),
 
 
206
  active=0,
207
  sizing_mode="stretch_width",
208
  )
 
40
  from ui.components.model_repository import ModelRepositoryPanel
41
  from ui.components.plasmid_assembly import PlasmidAssemblyPanel
42
  from ui.components.generate_sequences import GenerateSequencesPanel
43
+ from ui.components.cluster_view import ClusterView
44
+ from ui.components.experiment_view import ExperimentView
45
 
46
 
47
  # ── Design tokens ─────────────────────────────────────────────────────────────
 
152
  "Import Data",
153
  "Model Repository",
154
  "Worklist",
155
+ "Cluster & Tree",
156
+ "Experiments",
157
  "Parts Workshop",
158
  "Assemble Plasmid",
159
  "Generate Sequences",
160
  ]
161
+ _TAB_KEYS = ["import_db", "model_repo", "worklist", "clusters", "experiments", "parts", "assemble", "generate"]
162
 
163
 
164
  logger = logging.getLogger(__name__)
 
189
  self._model_repo = ModelRepositoryPanel(self.state)
190
  self._assembly = PlasmidAssemblyPanel(self.state)
191
  self._generate = GenerateSequencesPanel(self.state)
192
+ self._cluster = ClusterView(self.state)
193
+ self._experiments = ExperimentView(self.state)
194
 
195
  # ── Build persistent widgets once ─────────────────────────────────────
196
  self._tabs = pn.Tabs(
 
206
  pn.panel(self._worklist.panel),
207
  sizing_mode="stretch_width",
208
  )),
209
+ (_TAB_NAMES[3], pn.panel(self._cluster.panel)),
210
+ (_TAB_NAMES[4], pn.panel(self._experiments.panel)),
211
+ (_TAB_NAMES[5], pn.panel(self._parts.panel)),
212
+ (_TAB_NAMES[6], pn.panel(self._assembly.panel)),
213
+ (_TAB_NAMES[7], pn.panel(self._generate.panel)),
214
  active=0,
215
  sizing_mode="stretch_width",
216
  )
ui/components/analysis_dashboard.py CHANGED
@@ -181,6 +181,115 @@ def _structure_card(report: AnalysisReport) -> pn.pane.HTML:
181
  """)
182
 
183
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
184
  def _metric_panel(title: str, content: pn.viewable.Viewable) -> pn.Column:
185
  return pn.Column(
186
  pn.pane.HTML(
@@ -266,6 +375,11 @@ class AnalysisDashboard(param.Parameterized):
266
  ),
267
  pn.pane.HTML(warnings_html) if warnings_html else pn.pane.HTML(""),
268
  pn.pane.HTML(summary_html),
 
 
 
 
 
269
  pn.layout.Divider(),
270
  _gc_plot(report),
271
  pn.GridBox(
@@ -274,6 +388,8 @@ class AnalysisDashboard(param.Parameterized):
274
  _metric_panel("Kozak Context", _kozak_card(report)),
275
  _metric_panel("Homopolymers", _homopolymer_card(report)),
276
  _metric_panel("Restriction Sites", _restriction_card(report)),
 
 
277
  _metric_panel("Secondary Structure (MFE)", _structure_card(report)),
278
  ncols=2,
279
  sizing_mode="stretch_width",
 
181
  """)
182
 
183
 
184
+ def _uridine_card(report: AnalysisReport) -> pn.pane.HTML:
185
+ u = report.uridine
186
+ if u is None:
187
+ return pn.pane.HTML('<div style="color:#64748B;font-size:12px;">No uridine data.</div>')
188
+ color = "#059669" if u.u_percent < 35 else "#D97706" if u.u_percent < 45 else "#DC2626"
189
+ stretches = len(u.high_u_stretches)
190
+ return pn.pane.HTML(f"""
191
+ <div>
192
+ <span style="font-size:18px;font-weight:700;color:{color};">{u.u_percent:.1f}%</span>
193
+ <span style="font-size:11px;color:#64748B;margin-left:6px;">uridine</span>
194
+ </div>
195
+ <div style="font-size:11px;color:#64748B;margin-top:4px;">
196
+ {stretches} high-U stretch(es) Β· U/A ratio {u.ua_ratio:.2f}</div>
197
+ <div style="font-size:10px;color:#94A3B8;margin-top:3px;">
198
+ High U is immunostimulatory β€” modified nucleotides mitigate it.</div>
199
+ """)
200
+
201
+
202
+ def _motif_card(report: AnalysisReport) -> pn.pane.HTML:
203
+ hits = report.motif_hits
204
+ if not hits:
205
+ return pn.pane.HTML(
206
+ '<div style="color:#059669;font-size:12px;">No liability motifs detected.</div>'
207
+ )
208
+ sev_color = {"critical": "#DC2626", "warning": "#D97706", "info": "#64748B"}
209
+ rows = ""
210
+ for h in hits[:12]:
211
+ c = sev_color.get(h.severity, "#64748B")
212
+ rows += (
213
+ f'<div style="display:flex;gap:8px;align-items:baseline;margin-bottom:3px;">'
214
+ f'<span style="background:{c};color:white;border-radius:3px;padding:1px 5px;'
215
+ f'font-size:9px;text-transform:uppercase;">{h.severity}</span>'
216
+ f'<span style="font-size:12px;">{h.label}</span>'
217
+ f'<span style="font-size:10px;color:#94A3B8;font-family:monospace;">'
218
+ f'{h.region}:{h.start}</span></div>'
219
+ )
220
+ more = f'<div style="font-size:10px;color:#94A3B8;">+{len(hits)-12} more</div>' if len(hits) > 12 else ""
221
+ return pn.pane.HTML(rows + more)
222
+
223
+
224
+ _VERDICT_STYLE = {
225
+ "pass": ("#059669", "PASS"),
226
+ "review": ("#D97706", "REVIEW"),
227
+ "fail": ("#DC2626", "FAIL"),
228
+ }
229
+ _SEV_STYLE = {
230
+ "critical": ("#DC2626", "Critical"),
231
+ "warning": ("#D97706", "Warning"),
232
+ "info": ("#64748B", "Info"),
233
+ }
234
+
235
+
236
+ def render_liability_panel(report: AnalysisReport) -> pn.pane.HTML:
237
+ """Reusable liability / QC scorecard + ranked flag list (pure HTML)."""
238
+ lia = getattr(report, "liability", None)
239
+ if lia is None:
240
+ return pn.pane.HTML('<div style="color:#64748B;font-size:12px;">No liability assessment.</div>')
241
+
242
+ vcolor, vlabel = _VERDICT_STYLE.get(lia.verdict, ("#64748B", lia.verdict.upper()))
243
+ score_color = "#059669" if lia.score >= 85 else "#D97706" if lia.score >= 60 else "#DC2626"
244
+
245
+ counts = (
246
+ f'<span style="color:#DC2626;font-weight:700;">{lia.n_critical}</span> critical Β· '
247
+ f'<span style="color:#D97706;font-weight:700;">{lia.n_warning}</span> warning Β· '
248
+ f'<span style="color:#64748B;font-weight:700;">{lia.n_info}</span> info'
249
+ )
250
+
251
+ if not lia.flags:
252
+ flag_html = (
253
+ '<div style="color:#059669;font-size:12px;padding:6px 0;">'
254
+ f'No liabilities flagged across {lia.checks_run} checks.</div>'
255
+ )
256
+ else:
257
+ items = ""
258
+ for f in lia.sorted_flags():
259
+ sc, sl = _SEV_STYLE.get(f.severity, ("#64748B", f.severity.title()))
260
+ loc = f'<span style="font-family:monospace;color:#94A3B8;font-size:10px;margin-left:6px;">{f.location}</span>' if f.location else ""
261
+ rec = f'<div style="font-size:11px;color:#64748B;margin-top:2px;">↳ {f.recommendation}</div>' if f.recommendation else ""
262
+ items += f"""
263
+ <div style="border-left:3px solid {sc};padding:6px 0 6px 10px;margin-bottom:8px;">
264
+ <div style="display:flex;align-items:baseline;gap:8px;">
265
+ <span style="background:{sc};color:white;border-radius:3px;padding:1px 6px;
266
+ font-size:9px;text-transform:uppercase;font-weight:700;">{sl}</span>
267
+ <span style="font-size:13px;font-weight:600;color:#0F172A;">{f.title}</span>
268
+ {loc}
269
+ </div>
270
+ <div style="font-size:12px;color:#334155;margin-top:2px;">{f.detail}</div>
271
+ {rec}
272
+ </div>"""
273
+ flag_html = items
274
+
275
+ return pn.pane.HTML(f"""
276
+ <div style="border:1px solid #CBD5E1;border-radius:8px;padding:14px 16px;background:white;">
277
+ <div style="display:flex;align-items:center;gap:18px;flex-wrap:wrap;
278
+ border-bottom:1px solid #E2E8F0;padding-bottom:10px;margin-bottom:10px;">
279
+ <div>
280
+ <div style="font-size:10px;color:#64748B;letter-spacing:.05em;">QC SCORE</div>
281
+ <div style="font-size:30px;font-weight:800;color:{score_color};line-height:1;">{lia.score}</div>
282
+ </div>
283
+ <div style="background:{vcolor};color:white;border-radius:6px;padding:6px 14px;
284
+ font-size:14px;font-weight:800;letter-spacing:.05em;">{vlabel}</div>
285
+ <div style="font-size:12px;color:#475569;">{counts}</div>
286
+ <div style="font-size:11px;color:#94A3B8;margin-left:auto;">{lia.checks_run} checks</div>
287
+ </div>
288
+ {flag_html}
289
+ </div>
290
+ """)
291
+
292
+
293
  def _metric_panel(title: str, content: pn.viewable.Viewable) -> pn.Column:
294
  return pn.Column(
295
  pn.pane.HTML(
 
375
  ),
376
  pn.pane.HTML(warnings_html) if warnings_html else pn.pane.HTML(""),
377
  pn.pane.HTML(summary_html),
378
+ pn.pane.HTML(
379
+ '<div style="font-size:13px;font-weight:700;color:#0F172A;'
380
+ 'margin:6px 0 6px 0;">Liability / QC assessment</div>'
381
+ ),
382
+ render_liability_panel(report),
383
  pn.layout.Divider(),
384
  _gc_plot(report),
385
  pn.GridBox(
 
388
  _metric_panel("Kozak Context", _kozak_card(report)),
389
  _metric_panel("Homopolymers", _homopolymer_card(report)),
390
  _metric_panel("Restriction Sites", _restriction_card(report)),
391
+ _metric_panel("Uridine Content", _uridine_card(report)),
392
+ _metric_panel("Liability Motifs", _motif_card(report)),
393
  _metric_panel("Secondary Structure (MFE)", _structure_card(report)),
394
  ncols=2,
395
  sizing_mode="stretch_width",
ui/components/cluster_view.py ADDED
@@ -0,0 +1,183 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Cluster & tree explorer.
3
+
4
+ Clusters the current worklist's sequences by k-mer distance and renders an
5
+ interactive dendrogram (UPGMA tree) with a distance cutoff that splits the tree
6
+ into flat clusters β€” an exploration view analogous to repertoire clustering.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from typing import TYPE_CHECKING, List, Tuple
11
+
12
+ import panel as pn
13
+ import param
14
+ import plotly.graph_objects as go
15
+
16
+ from core.analysis.clustering import (
17
+ kmer_distance_matrix, upgma, flat_clusters, dendrogram_layout,
18
+ )
19
+
20
+ if TYPE_CHECKING:
21
+ from ui.state import AppState
22
+
23
+
24
+ # Categorical palette for clusters (cycled)
25
+ _PALETTE = [
26
+ "#0F766E", "#2563EB", "#D97706", "#DC2626", "#7C3AED",
27
+ "#059669", "#DB2777", "#0891B2", "#CA8A04", "#4F46E5",
28
+ ]
29
+
30
+
31
+ def _empty(msg: str) -> pn.pane.HTML:
32
+ return pn.pane.HTML(f'<div style="color:#64748B;padding:30px;text-align:center;">{msg}</div>')
33
+
34
+
35
+ class ClusterView(param.Parameterized):
36
+ """Worklist clustering + dendrogram panel."""
37
+
38
+ def __init__(self, state: "AppState", **params: object) -> None:
39
+ super().__init__(**params)
40
+ self._state = state
41
+ self._k = pn.widgets.IntSlider(
42
+ name="k-mer size", start=2, end=6, value=4, width=160, margin=(4, 10))
43
+ self._threshold = pn.widgets.FloatSlider(
44
+ name="Distance cutoff", start=0.0, end=1.0, step=0.02, value=0.30,
45
+ width=260, margin=(4, 10))
46
+ self._region = pn.widgets.Select(
47
+ name="Compare", options=["Full sequence", "CDS only"],
48
+ value="Full sequence", width=150, margin=(4, 10))
49
+
50
+ # ── data ────────────────────────────────────────────────────────────────
51
+ def _sequences(self) -> Tuple[List[str], List[str]]:
52
+ names: List[str] = []
53
+ seqs: List[str] = []
54
+ for item in self._state.worklist.items:
55
+ s = item.sequence
56
+ if self._region.value == "CDS only":
57
+ txt = s.cds or ""
58
+ else:
59
+ try:
60
+ txt = s.assembled_sequence
61
+ except Exception:
62
+ txt = s.cds or ""
63
+ if txt:
64
+ names.append(s.name)
65
+ seqs.append(txt)
66
+ return names, seqs
67
+
68
+ # ── rendering ─────────────────────────────────────────────────────────────
69
+ def _render(self, k: int, threshold: float, region: str) -> pn.viewable.Viewable:
70
+ names, seqs = self._sequences()
71
+ n = len(seqs)
72
+ if n < 2:
73
+ return _empty("Add at least 2 sequences (with content) to the worklist to cluster.")
74
+
75
+ D = kmer_distance_matrix(seqs, k=k)
76
+ link, order = upgma(D)
77
+ clusters = flat_clusters(link, n, threshold)
78
+ layout = dendrogram_layout(link, order)
79
+ n_clusters = len(set(clusters))
80
+
81
+ fig = go.Figure()
82
+
83
+ # dendrogram branches as a single trace (None-separated segments)
84
+ xs: List = []
85
+ ys: List = []
86
+ for (x0, x1, y0, y1) in layout.segments:
87
+ xs += [x0, x1, None]
88
+ ys += [y0, y1, None]
89
+ fig.add_trace(go.Scatter(
90
+ x=xs, y=ys, mode="lines",
91
+ line={"color": "#94A3B8", "width": 1.2},
92
+ hoverinfo="skip", showlegend=False,
93
+ ))
94
+
95
+ # cutoff line
96
+ fig.add_hline(
97
+ y=threshold, line_dash="dash", line_color="#DC2626", opacity=0.7,
98
+ annotation_text=f"cutoff {threshold:.2f}", annotation_position="top left",
99
+ )
100
+
101
+ # leaf markers grouped per cluster (legend = clusters)
102
+ for c in range(n_clusters):
103
+ cx = [layout.leaf_x[leaf] for leaf in order if clusters[leaf] == c]
104
+ ctext = [names[leaf] for leaf in order if clusters[leaf] == c]
105
+ if not cx:
106
+ continue
107
+ fig.add_trace(go.Scatter(
108
+ x=cx, y=[0] * len(cx), mode="markers",
109
+ marker={"size": 11, "color": _PALETTE[c % len(_PALETTE)],
110
+ "line": {"color": "white", "width": 1}},
111
+ name=f"Cluster {c}", text=ctext,
112
+ hovertemplate="%{text}<br>cluster " + str(c) + "<extra></extra>",
113
+ ))
114
+
115
+ fig.update_layout(
116
+ title={"text": f"UPGMA dendrogram β€” {n} sequences, {n_clusters} cluster(s)",
117
+ "font": {"size": 13}},
118
+ xaxis={"tickmode": "array",
119
+ "tickvals": [layout.leaf_x[leaf] for leaf in order],
120
+ "ticktext": [names[leaf] for leaf in order],
121
+ "tickangle": -40, "tickfont": {"size": 10}},
122
+ yaxis={"title": "k-mer distance", "rangemode": "tozero"},
123
+ height=440, margin={"l": 60, "r": 20, "t": 40, "b": 130},
124
+ plot_bgcolor="#F8FAFC", paper_bgcolor="white",
125
+ legend={"orientation": "h", "y": -0.45, "font": {"size": 10}},
126
+ )
127
+
128
+ # cluster membership summary
129
+ groups: dict = {}
130
+ for leaf in order:
131
+ groups.setdefault(clusters[leaf], []).append(names[leaf])
132
+ rows = ""
133
+ for c in sorted(groups):
134
+ color = _PALETTE[c % len(_PALETTE)]
135
+ members = ", ".join(groups[c])
136
+ rows += (
137
+ f'<tr>'
138
+ f'<td style="padding:4px 10px;"><span style="display:inline-block;width:10px;'
139
+ f'height:10px;border-radius:2px;background:{color};margin-right:6px;"></span>'
140
+ f'Cluster {c}</td>'
141
+ f'<td style="padding:4px 10px;color:#475569;font-size:12px;">{len(groups[c])}</td>'
142
+ f'<td style="padding:4px 10px;color:#334155;font-size:12px;">{members}</td>'
143
+ f'</tr>'
144
+ )
145
+ table = pn.pane.HTML(
146
+ '<div style="font-size:12px;font-weight:700;margin:10px 0 4px 0;">Cluster membership</div>'
147
+ '<table style="border-collapse:collapse;width:100%;">'
148
+ '<tr style="border-bottom:1px solid #E2E8F0;font-size:11px;color:#64748B;">'
149
+ '<td style="padding:4px 10px;">Cluster</td><td style="padding:4px 10px;">Size</td>'
150
+ '<td style="padding:4px 10px;">Members</td></tr>'
151
+ f'{rows}</table>'
152
+ )
153
+
154
+ return pn.Column(pn.pane.Plotly(fig, sizing_mode="stretch_width"), table,
155
+ sizing_mode="stretch_width")
156
+
157
+ @param.depends("_state.worklist")
158
+ def panel(self) -> pn.Column:
159
+ wl = self._state.worklist
160
+ if wl is None or wl.count == 0:
161
+ return pn.Column(
162
+ pn.pane.HTML('<div style="font-size:16px;font-weight:800;padding:8px 0;">'
163
+ 'Cluster &amp; Tree</div>'),
164
+ _empty("Worklist is empty. Import sequences to cluster them."),
165
+ styles={"padding": "8px 16px"},
166
+ )
167
+
168
+ controls = pn.Row(
169
+ self._region, self._k, self._threshold,
170
+ sizing_mode="stretch_width",
171
+ styles={"background": "#F8FAFC", "border": "1px solid #E2E8F0",
172
+ "border-radius": "6px", "padding": "4px 8px", "margin": "0 0 8px 0"},
173
+ )
174
+ body = pn.bind(self._render, self._k, self._threshold, self._region)
175
+ return pn.Column(
176
+ pn.pane.HTML('<div style="font-size:16px;font-weight:800;padding:8px 0;">'
177
+ f'Cluster &amp; Tree <span style="color:#64748B;font-size:13px;">'
178
+ f'({wl.count} sequences)</span></div>'),
179
+ controls,
180
+ pn.panel(body),
181
+ sizing_mode="stretch_width",
182
+ styles={"padding": "8px 16px"},
183
+ )
ui/components/experiment_view.py ADDED
@@ -0,0 +1,197 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Experiments / model lifecycle view.
3
+
4
+ Surfaces the model lifecycle that sits on top of the registry:
5
+ - registered models and their versions,
6
+ - a run history (every scoring run, with version + score statistics),
7
+ - a run-vs-run comparison (e.g. two versions of the same model) showing how
8
+ per-sequence scores shifted.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ from typing import TYPE_CHECKING, Dict, List, Optional
13
+
14
+ import panel as pn
15
+ import param
16
+
17
+ if TYPE_CHECKING:
18
+ from ui.state import AppState
19
+
20
+
21
+ def _fmt(x: Optional[float]) -> str:
22
+ return "β€”" if x is None or (isinstance(x, float) and x != x) else f"{x:.3f}"
23
+
24
+
25
+ class ExperimentView(param.Parameterized):
26
+ """Model lifecycle / experiment tracking panel."""
27
+
28
+ def __init__(self, state: "AppState", **params: object) -> None:
29
+ super().__init__(**params)
30
+ self._state = state
31
+ self._run_a = pn.widgets.Select(name="Run A (baseline)", width=320, margin=(4, 10))
32
+ self._run_b = pn.widgets.Select(name="Run B (compare)", width=320, margin=(4, 10))
33
+
34
+ # ── registered models ─────────────────────────────────────────────────────
35
+ def _models_table(self) -> pn.pane.HTML:
36
+ reg = self._state.model_registry
37
+ models = reg.all_models if reg else []
38
+ if not models:
39
+ return pn.pane.HTML('<div style="color:#64748B;font-size:12px;">No models registered yet.</div>')
40
+ rows = ""
41
+ for m in models:
42
+ try:
43
+ ver = m.model.version
44
+ except Exception:
45
+ ver = "β€”"
46
+ rows += (
47
+ f'<tr style="border-bottom:1px solid #F1F5F9;">'
48
+ f'<td style="padding:4px 10px;font-size:12px;">{m.model.name}</td>'
49
+ f'<td style="padding:4px 10px;font-size:12px;color:#475569;">{m.model_type}</td>'
50
+ f'<td style="padding:4px 10px;font-size:12px;"><span style="background:#F0FDFA;'
51
+ f'color:#0F766E;border-radius:3px;padding:1px 6px;">v{ver}</span></td>'
52
+ f'<td style="padding:4px 10px;font-size:12px;color:#64748B;">{m.source}</td>'
53
+ f'</tr>'
54
+ )
55
+ return pn.pane.HTML(
56
+ '<table style="border-collapse:collapse;width:100%;">'
57
+ '<tr style="font-size:11px;color:#64748B;border-bottom:1px solid #E2E8F0;">'
58
+ '<td style="padding:4px 10px;">Model</td><td style="padding:4px 10px;">Type</td>'
59
+ '<td style="padding:4px 10px;">Version</td><td style="padding:4px 10px;">Source</td></tr>'
60
+ f'{rows}</table>'
61
+ )
62
+
63
+ # ── run history ───────────────────────────────────────────────────────────
64
+ def _runs_table(self) -> pn.pane.HTML:
65
+ runs = self._state.run_history.runs
66
+ if not runs:
67
+ return pn.pane.HTML(
68
+ '<div style="color:#64748B;font-size:12px;">No runs yet. Score a worklist '
69
+ 'with a model (Worklist β†’ Run) to record an experiment.</div>'
70
+ )
71
+ rows = ""
72
+ for r in reversed(runs): # newest first
73
+ rows += (
74
+ f'<tr style="border-bottom:1px solid #F1F5F9;">'
75
+ f'<td style="padding:4px 10px;font-size:11px;color:#64748B;">{r.timestamp}</td>'
76
+ f'<td style="padding:4px 10px;font-size:12px;">{r.model_name}</td>'
77
+ f'<td style="padding:4px 10px;font-size:12px;">v{r.model_version}</td>'
78
+ f'<td style="padding:4px 10px;font-size:12px;">{r.n_sequences}</td>'
79
+ f'<td style="padding:4px 10px;font-size:12px;">{_fmt(r.score_mean)}</td>'
80
+ f'<td style="padding:4px 10px;font-size:12px;color:#64748B;">'
81
+ f'{_fmt(r.score_min)}–{_fmt(r.score_max)}</td>'
82
+ f'<td style="padding:4px 10px;font-size:11px;color:#94A3B8;">{r.worklist_name}</td>'
83
+ f'</tr>'
84
+ )
85
+ return pn.pane.HTML(
86
+ '<table style="border-collapse:collapse;width:100%;">'
87
+ '<tr style="font-size:11px;color:#64748B;border-bottom:1px solid #E2E8F0;">'
88
+ '<td style="padding:4px 10px;">Time</td><td style="padding:4px 10px;">Model</td>'
89
+ '<td style="padding:4px 10px;">Version</td><td style="padding:4px 10px;">N</td>'
90
+ '<td style="padding:4px 10px;">Mean</td><td style="padding:4px 10px;">Range</td>'
91
+ '<td style="padding:4px 10px;">Worklist</td></tr>'
92
+ f'{rows}</table>'
93
+ )
94
+
95
+ # ── comparison ────────────────────────────────────────────────────────────
96
+ def _run_options(self) -> Dict[str, object]:
97
+ return {f"{r.run_id} Β· {r.label}": r.run_id for r in reversed(self._state.run_history.runs)}
98
+
99
+ def _name_lookup(self) -> Dict[str, str]:
100
+ names: Dict[str, str] = {}
101
+ for item in self._state.worklist.items:
102
+ names[item.sequence.id] = item.sequence.name
103
+ return names
104
+
105
+ def _render_comparison(self, run_a_id: str, run_b_id: str) -> pn.viewable.Viewable:
106
+ from models.runs import RunHistory
107
+ runs = {r.run_id: r for r in self._state.run_history.runs}
108
+ ra, rb = runs.get(run_a_id), runs.get(run_b_id)
109
+ if not ra or not rb:
110
+ return pn.pane.HTML('<div style="color:#64748B;font-size:12px;">Pick two runs to compare.</div>')
111
+ if ra.run_id == rb.run_id:
112
+ return pn.pane.HTML('<div style="color:#64748B;font-size:12px;">Pick two different runs.</div>')
113
+
114
+ cmp = RunHistory.compare(ra, rb)
115
+ if not cmp.shared_ids:
116
+ return pn.pane.HTML(
117
+ '<div style="color:#D97706;font-size:12px;">No shared sequences between these runs.</div>'
118
+ )
119
+ d = cmp.mean_delta
120
+ dcolor = "#059669" if d > 0 else "#DC2626" if d < 0 else "#64748B"
121
+ summary = pn.pane.HTML(f"""
122
+ <div style="display:flex;gap:18px;align-items:center;flex-wrap:wrap;
123
+ border:1px solid #E2E8F0;border-radius:8px;padding:10px 14px;margin:6px 0;">
124
+ <div><div style="font-size:10px;color:#64748B;">MEAN Ξ” (B βˆ’ A)</div>
125
+ <div style="font-size:22px;font-weight:800;color:{dcolor};">{d:+.3f}</div></div>
126
+ <div style="font-size:12px;color:#059669;">β–² {cmp.n_improved} improved</div>
127
+ <div style="font-size:12px;color:#DC2626;">β–Ό {cmp.n_worsened} worsened</div>
128
+ <div style="font-size:12px;color:#64748B;">= {cmp.n_unchanged} unchanged</div>
129
+ <div style="font-size:11px;color:#94A3B8;margin-left:auto;">
130
+ {len(cmp.shared_ids)} shared sequences</div>
131
+ </div>
132
+ """)
133
+
134
+ names = self._name_lookup()
135
+ ordered = sorted(cmp.deltas.items(), key=lambda kv: kv[1]) # worst→best
136
+ rows = ""
137
+ for sid, delta in ordered[:50]:
138
+ c = "#059669" if delta > 0 else "#DC2626" if delta < 0 else "#64748B"
139
+ nm = names.get(sid, sid[:8])
140
+ rows += (
141
+ f'<tr style="border-bottom:1px solid #F1F5F9;">'
142
+ f'<td style="padding:3px 10px;font-size:12px;">{nm}</td>'
143
+ f'<td style="padding:3px 10px;font-size:12px;color:#64748B;">{_fmt(ra.scores.get(sid))}</td>'
144
+ f'<td style="padding:3px 10px;font-size:12px;color:#64748B;">{_fmt(rb.scores.get(sid))}</td>'
145
+ f'<td style="padding:3px 10px;font-size:12px;font-weight:700;color:{c};">{delta:+.3f}</td>'
146
+ f'</tr>'
147
+ )
148
+ table = pn.pane.HTML(
149
+ '<table style="border-collapse:collapse;width:100%;">'
150
+ '<tr style="font-size:11px;color:#64748B;border-bottom:1px solid #E2E8F0;">'
151
+ '<td style="padding:3px 10px;">Sequence</td>'
152
+ f'<td style="padding:3px 10px;">A (v{ra.model_version})</td>'
153
+ f'<td style="padding:3px 10px;">B (v{rb.model_version})</td>'
154
+ '<td style="padding:3px 10px;">Ξ”</td></tr>'
155
+ f'{rows}</table>'
156
+ )
157
+ return pn.Column(summary, table, sizing_mode="stretch_width")
158
+
159
+ # ── panel ─────────────────────────────────────────────────────────────────
160
+ @param.depends("_state.run_history", "_state.model_registry")
161
+ def panel(self) -> pn.Column:
162
+ # refresh comparison dropdown options
163
+ opts = self._run_options()
164
+ self._run_a.options = opts
165
+ self._run_b.options = opts
166
+ run_ids = list(opts.values())
167
+ if len(run_ids) >= 2:
168
+ self._run_a.value = run_ids[1] # older of the two newest
169
+ self._run_b.value = run_ids[0] # newest
170
+ elif run_ids:
171
+ self._run_a.value = self._run_b.value = run_ids[0]
172
+
173
+ comparison = pn.bind(self._render_comparison, self._run_a, self._run_b)
174
+
175
+ def card(title: str, body: pn.viewable.Viewable) -> pn.Column:
176
+ return pn.Column(
177
+ pn.pane.HTML(f'<div style="font-size:13px;font-weight:700;margin:6px 0;">{title}</div>'),
178
+ body,
179
+ styles={"background": "white", "border": "1px solid #CBD5E1",
180
+ "border-radius": "8px", "padding": "12px 14px"},
181
+ margin=(0, 0, 12, 0), sizing_mode="stretch_width",
182
+ )
183
+
184
+ return pn.Column(
185
+ pn.pane.HTML(
186
+ '<div style="font-size:16px;font-weight:800;padding:8px 0 2px 0;">Experiments</div>'
187
+ '<div style="font-size:12px;color:#64748B;margin-bottom:10px;">'
188
+ 'Model versions, scoring-run history, and version-to-version comparison.</div>'
189
+ ),
190
+ card("Registered models", self._models_table()),
191
+ card("Run history", self._runs_table()),
192
+ card("Compare runs (version A β†’ B)",
193
+ pn.Column(pn.Row(self._run_a, self._run_b), pn.panel(comparison),
194
+ sizing_mode="stretch_width")),
195
+ sizing_mode="stretch_width",
196
+ styles={"padding": "8px 16px", "max-height": "78vh", "overflow-y": "auto"},
197
+ )
ui/components/worklist_view.py CHANGED
@@ -55,11 +55,17 @@ class WorklistView(param.Parameterized):
55
  row["CAI"] = f"{base.get('cai', 0):.3f}" if base.get('cai') else "β€”"
56
  row["Homopolymers"] = base.get('homopolymer_count', 0)
57
  row["Restriction Sites"] = base.get('restriction_site_count', 0)
 
 
 
 
58
  else:
59
  row["GC%"] = "β€”"
60
  row["CAI"] = "β€”"
61
  row["Homopolymers"] = "β€”"
62
  row["Restriction Sites"] = "β€”"
 
 
63
 
64
  # Add model score columns from analyses
65
  for analysis_name, analysis_data in item.analyses.items():
@@ -262,10 +268,39 @@ class WorklistView(param.Parameterized):
262
  self._create_wl_section,
263
  pn.pane.HTML(f'<div style="margin-bottom:8px;">{origin_chips}</div>') if origin_chips else pn.pane.HTML(""),
264
  table_or_empty,
 
265
  sizing_mode="stretch_width",
266
  styles={"padding": "8px 16px"},
267
  )
268
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
269
  def _create_worklist_from_selection(self, name: str, selection: List[int], df: pd.DataFrame) -> None:
270
  """Create a new worklist from selected rows."""
271
  if not selection:
@@ -380,7 +415,41 @@ class WorklistView(param.Parameterized):
380
  item.status = "error"
381
  item.notes = f"{model_name}: {str(e)}"
382
 
 
 
 
383
  self._state.param.trigger("worklist")
384
  self._state.set_status(
385
  f"{model_name} complete: {analyzed_count} scored, {skipped_count} skipped (already scored)"
386
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
  row["CAI"] = f"{base.get('cai', 0):.3f}" if base.get('cai') else "β€”"
56
  row["Homopolymers"] = base.get('homopolymer_count', 0)
57
  row["Restriction Sites"] = base.get('restriction_site_count', 0)
58
+ verdict = base.get('liability_verdict')
59
+ score = base.get('liability_score')
60
+ row["QC"] = f"{verdict.title()} Β· {score}" if verdict is not None else "β€”"
61
+ row["Liabilities"] = base.get('liability_flag_count', 0) if base.get('liability_flag_count') is not None else "β€”"
62
  else:
63
  row["GC%"] = "β€”"
64
  row["CAI"] = "β€”"
65
  row["Homopolymers"] = "β€”"
66
  row["Restriction Sites"] = "β€”"
67
+ row["QC"] = "β€”"
68
+ row["Liabilities"] = "β€”"
69
 
70
  # Add model score columns from analyses
71
  for analysis_name, analysis_data in item.analyses.items():
 
268
  self._create_wl_section,
269
  pn.pane.HTML(f'<div style="margin-bottom:8px;">{origin_chips}</div>') if origin_chips else pn.pane.HTML(""),
270
  table_or_empty,
271
+ self._liability_detail,
272
  sizing_mode="stretch_width",
273
  styles={"padding": "8px 16px"},
274
  )
275
 
276
+ @param.depends("_state.active_sequence")
277
+ def _liability_detail(self) -> pn.viewable.Viewable:
278
+ """Liability / QC breakdown for the currently selected sequence."""
279
+ seq = self._state.active_sequence
280
+ if seq is None:
281
+ return pn.pane.HTML(
282
+ '<div style="color:#94A3B8;font-size:12px;padding:10px 2px;">'
283
+ 'Click a row to see its liability / QC breakdown.</div>'
284
+ )
285
+ try:
286
+ from core.analysis.analyzer import SequenceAnalyzer
287
+ from ui.components.analysis_dashboard import render_liability_panel
288
+
289
+ report = SequenceAnalyzer().run_full_analysis(seq)
290
+ return pn.Column(
291
+ pn.pane.HTML(
292
+ f'<div style="font-size:13px;font-weight:700;margin:10px 0 6px 0;">'
293
+ f'Liabilities β€” {seq.name}</div>'
294
+ ),
295
+ render_liability_panel(report),
296
+ sizing_mode="stretch_width",
297
+ )
298
+ except Exception as e: # noqa: BLE001 β€” surface any analysis error inline
299
+ return pn.pane.HTML(
300
+ f'<div style="color:#DC2626;font-size:12px;padding:8px 0;">'
301
+ f'Liability analysis error: {e}</div>'
302
+ )
303
+
304
  def _create_worklist_from_selection(self, name: str, selection: List[int], df: pd.DataFrame) -> None:
305
  """Create a new worklist from selected rows."""
306
  if not selection:
 
415
  item.status = "error"
416
  item.notes = f"{model_name}: {str(e)}"
417
 
418
+ # Record this scoring run for experiment / version tracking
419
+ self._record_run(model_reg)
420
+
421
  self._state.param.trigger("worklist")
422
  self._state.set_status(
423
  f"{model_name} complete: {analyzed_count} scored, {skipped_count} skipped (already scored)"
424
  )
425
+
426
+ def _record_run(self, model_reg: object) -> None:
427
+ """Capture a ModelRun snapshot of the current scores for this model."""
428
+ from datetime import datetime
429
+ from models.runs import summarize_run
430
+
431
+ name = model_reg.model.name
432
+ # gather all current scores for this model across the worklist
433
+ scores = {}
434
+ for item in self._state.worklist.items:
435
+ data = item.analyses.get(name)
436
+ if isinstance(data, dict) and "score" in data:
437
+ scores[item.sequence.id] = data["score"]
438
+ if not scores:
439
+ return
440
+
441
+ try:
442
+ version = model_reg.model.version
443
+ except Exception:
444
+ version = "1.0"
445
+
446
+ run = summarize_run(
447
+ model_name=name,
448
+ model_version=str(version),
449
+ model_source=getattr(model_reg, "source", ""),
450
+ worklist_name=self._state.worklist.name,
451
+ scores=scores,
452
+ timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
453
+ )
454
+ self._state.run_history.add(run)
455
+ self._state.param.trigger("run_history")
ui/state.py CHANGED
@@ -21,6 +21,7 @@ from core.models.worklist import Worklist
21
  from core.models.parts import SequencePart
22
  from core.database.base import DatabaseConnector, SchemaMapper
23
  from models.base import ModelRegistry
 
24
 
25
 
26
  class AppState(param.Parameterized):
@@ -68,6 +69,12 @@ class AppState(param.Parameterized):
68
  instantiate=True,
69
  )
70
 
 
 
 
 
 
 
71
  # ── Plasmid backbones ─────────────────────────────────────────────────────
72
  backbone_library: List[PlasmidBackbone] = param.List(
73
  default=[], doc="Available plasmid backbones"
@@ -97,6 +104,8 @@ class AppState(param.Parameterized):
97
  params["worklist"] = Worklist()
98
  if "model_registry" not in params:
99
  params["model_registry"] = ModelRegistry()
 
 
100
  super().__init__(**params)
101
 
102
  # Load seed parts library if parts_library is empty
 
21
  from core.models.parts import SequencePart
22
  from core.database.base import DatabaseConnector, SchemaMapper
23
  from models.base import ModelRegistry
24
+ from models.runs import RunHistory
25
 
26
 
27
  class AppState(param.Parameterized):
 
69
  instantiate=True,
70
  )
71
 
72
+ # ── Experiment / run history (model lifecycle) ─────────────────────────────
73
+ run_history: RunHistory = param.ClassSelector(
74
+ class_=RunHistory, default=None, allow_None=False,
75
+ instantiate=True,
76
+ )
77
+
78
  # ── Plasmid backbones ─────────────────────────────────────────────────────
79
  backbone_library: List[PlasmidBackbone] = param.List(
80
  default=[], doc="Available plasmid backbones"
 
104
  params["worklist"] = Worklist()
105
  if "model_registry" not in params:
106
  params["model_registry"] = ModelRegistry()
107
+ if "run_history" not in params:
108
+ params["run_history"] = RunHistory()
109
  super().__init__(**params)
110
 
111
  # Load seed parts library if parts_library is empty