github-actions[bot] commited on
Commit
7b284c7
·
1 Parent(s): 0ab82d0

Deploy 2565f24

Browse files

Merge pull request #15 from WINTER4000/feat/review-round

Source: https://github.com/WINTER4000/turingDNA/commit/2565f240caa00a235efe766428804beb9c40aa75

dee/core/agent_tools.py CHANGED
@@ -146,8 +146,9 @@ def _tool_fold_structure(args: Dict[str, Any]) -> Dict[str, Any]:
146
  logger.exception("fold_structure uniprot resolve failed")
147
  return {"ok": False, "error": "Structure lookup failed — try again shortly."}
148
  if not result.get("ok"):
149
- return {"ok": False, "error": result.get("error") or "No structure found."}
150
- return {
 
151
  "ok": True,
152
  "gene_symbol": gene,
153
  "organism": organism,
@@ -156,6 +157,22 @@ def _tool_fold_structure(args: Dict[str, Any]) -> Dict[str, Any]:
156
  "alphafold_page": result.get("alphafold_page", ""),
157
  "source": "AlphaFold DB",
158
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
159
 
160
 
161
  # The subset of a guide worth reading inline in a chat reply. The full
 
146
  logger.exception("fold_structure uniprot resolve failed")
147
  return {"ok": False, "error": "Structure lookup failed — try again shortly."}
148
  if not result.get("ok"):
149
+ return {"ok": False, "error": result.get("error") or "No structure found.",
150
+ "kind": result.get("kind") or ""}
151
+ out = {
152
  "ok": True,
153
  "gene_symbol": gene,
154
  "organism": organism,
 
157
  "alphafold_page": result.get("alphafold_page", ""),
158
  "source": "AlphaFold DB",
159
  }
160
+ # WHICH entry this actually is, not just its accession. The agent said
161
+ # "I6ZGA9" for beta-lactamase TEM-1 and the reviewer had no way to tell
162
+ # from that whether it was the right protein — it was a 264 aa fragment
163
+ # where they had pasted 286 aa (see resolve.resolve_uniprot). The
164
+ # identifying facts have to travel with the accession.
165
+ for key in ("entry_name", "protein_name", "protein_length", "reviewed",
166
+ "matched_organism", "plddt", "model_version",
167
+ "structure_verified"):
168
+ if result.get(key) is not None:
169
+ out[key] = result[key]
170
+ # Caveats are not decoration. A fragment entry's residue numbers do not
171
+ # line up with the user's sequence, and passing that on silently is how a
172
+ # scientist mutates the wrong position.
173
+ if result.get("caveats"):
174
+ out["caveats"] = list(result["caveats"])
175
+ return out
176
 
177
 
178
  # The subset of a guide worth reading inline in a chat reply. The full
dee/core/crispr.py CHANGED
@@ -262,6 +262,27 @@ _CFD_PAM: Dict[str, float] = {
262
  }
263
 
264
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
265
  # ─── Data classes ────────────────────────────────────────────────────
266
 
267
  @dataclass
@@ -402,6 +423,13 @@ def guide_to_dict(g: "GuideRNA") -> Dict[str, Any]:
402
  "pam": g.pam,
403
  "target_context": g.target_context,
404
  "composite_score": g.composite_score,
 
 
 
 
 
 
 
405
  "on_target_score": g.on_target_score,
406
  "cfd_max_offtarget": g.cfd_max_offtarget,
407
  "offtarget_count": g.offtarget_count,
@@ -1124,8 +1152,10 @@ def find_guides(
1124
  spacer, pos, all_candidates, enzyme,
1125
  )
1126
  # Composite score: on-target activity discounted by off-target
1127
- # penalty. A guide with on=0.9 and max_cfd=0.5 ends up at ~0.45.
1128
- composite = on_score * (1.0 - 0.6 * max_cfd)
 
 
1129
  # Cut-position for the KO position-bias heuristic. Display-only:
1130
  # ko_efficacy is NOT part of composite_score, so this never
1131
  # reorders the table. Use the SAME strand-aware cut convention as
 
262
  }
263
 
264
 
265
+ # ─── The one tuning constant in the whole CRISPR ranking ─────────────
266
+ #
267
+ # composite = on_target × (1 − COMPOSITE_OFFTARGET_WEIGHT × self_off)
268
+ #
269
+ # 0.6 is a JUDGEMENT CALL, not a published constant, and it is the only
270
+ # number in this file that isn't traceable to a paper. It decides how hard
271
+ # a self-off-target hit demotes a guide: at 0.6, a guide whose spacer is
272
+ # duplicated exactly elsewhere in the pasted input (CFD 1.0) keeps 40% of
273
+ # its on-target score instead of collapsing to zero — because a repeat
274
+ # inside a pasted region is often a legitimate second copy of the target,
275
+ # not a disqualifying off-target, and zeroing it would bury guides that
276
+ # are fine for the user's actual experiment.
277
+ #
278
+ # It is a named, exported constant (and is serialised onto every guide by
279
+ # guide_to_dict) specifically so the UI can print the arithmetic. A
280
+ # reviewing scientist ranked a table by "composite" and had no way to find
281
+ # out that a 0.6 weight existed at all; a weight nobody can see is not a
282
+ # method, it's a hunch with a decimal point.
283
+ COMPOSITE_OFFTARGET_WEIGHT = 0.6
284
+
285
+
286
  # ─── Data classes ────────────────────────────────────────────────────
287
 
288
  @dataclass
 
423
  "pam": g.pam,
424
  "target_context": g.target_context,
425
  "composite_score": g.composite_score,
426
+ # The weight that made composite_score, shipped WITH the number it
427
+ # weighted. The table sorts by composite, so the user is entitled to
428
+ # see the arithmetic; sending the constant per row (rather than once
429
+ # per response) means the agent's design_crispr_guides path, which
430
+ # builds its panel straight from these dicts and never touches the
431
+ # REST envelope, discloses it too.
432
+ "composite_offtarget_weight": COMPOSITE_OFFTARGET_WEIGHT,
433
  "on_target_score": g.on_target_score,
434
  "cfd_max_offtarget": g.cfd_max_offtarget,
435
  "offtarget_count": g.offtarget_count,
 
1152
  spacer, pos, all_candidates, enzyme,
1153
  )
1154
  # Composite score: on-target activity discounted by off-target
1155
+ # penalty. A guide with on=0.9 and max_cfd=0.5 ends up at ~0.63.
1156
+ # The weight is named (not inlined) because the UI prints it —
1157
+ # see COMPOSITE_OFFTARGET_WEIGHT for why 0.6 and not 1.0.
1158
+ composite = on_score * (1.0 - COMPOSITE_OFFTARGET_WEIGHT * max_cfd)
1159
  # Cut-position for the KO position-bias heuristic. Display-only:
1160
  # ko_efficacy is NOT part of composite_score, so this never
1161
  # reorders the table. Use the SAME strand-aware cut convention as
dee/core/crispr_methods.py CHANGED
@@ -24,11 +24,26 @@ METHODS: Dict[str, Dict[str, Any]] = {
24
  "label": "Composite",
25
  "what": "The column the table sorts by — one number balancing "
26
  "how well a guide should cut against how uniquely it targets.",
27
- "formula": "composite = on_target × (1 − self_off)",
28
- "basis": "A plain product of the two scores below. There are no hidden "
29
- "weights and no tuning constants: an efficacy estimate scaled "
30
- "down by the specificity penalty.",
31
- "limits": "Inherits every limit of its two inputs — in particular the "
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
  "specificity term only sees the sequence you pasted.",
33
  "citations": [],
34
  },
@@ -116,6 +131,17 @@ METHODS: Dict[str, Dict[str, Any]] = {
116
  "the templated single-base insertion class Cas9 is known to "
117
  "produce. These ARE per-guide, sequence-driven predictions — "
118
  "not a fixed average.",
 
 
 
 
 
 
 
 
 
 
 
119
  "limits": "Two real limits. (1) HEURISTIC: this is not the inDelphi "
120
  "neural network — it reproduces roughly 80% of that model's "
121
  "rank-ordering, not its calibrated frequencies. (2) "
@@ -138,15 +164,53 @@ METHOD_ORDER: List[str] = ["composite", "on_target", "self_off", "genome_off",
138
  "ko_score", "indels"]
139
 
140
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
141
  def methods_payload() -> Dict[str, Any]:
142
  """Serializable provenance block for GET /api/crispr/methods."""
143
  return {
144
  "order": list(METHOD_ORDER),
145
  "methods": {k: dict(METHODS[k]) for k in METHOD_ORDER},
 
146
  "summary": (
147
  "Every score here is computed from published, sequence-based "
148
- "methods — no black box. Two scope limits matter most: for human "
149
- "and mouse the genome off-target search covers coding sequence "
150
- "only, and indel predictions are not tuned to your cell type."
 
 
 
151
  ),
152
  }
 
24
  "label": "Composite",
25
  "what": "The column the table sorts by — one number balancing "
26
  "how well a guide should cut against how uniquely it targets.",
27
+ # Kept in sync with dee.core.crispr.COMPOSITE_OFFTARGET_WEIGHT by
28
+ # test_composite_weight_is_disclosed_with_its_real_value, because this
29
+ # entry previously claimed a formula the code did not use.
30
+ "formula": "composite = on_target × (1 − 0.6 × self_off)",
31
+ "basis": "An efficacy estimate scaled down by the specificity "
32
+ "penalty. The 0.6 is the only tuning constant in the whole "
33
+ "CRISPR ranking and it is our judgement call, not a "
34
+ "published value: it means a guide whose spacer is "
35
+ "duplicated exactly elsewhere in your input keeps 40% of "
36
+ "its on-target score instead of dropping to zero, because a "
37
+ "repeat inside a pasted region is often a second legitimate "
38
+ "copy of your target rather than a disqualifying "
39
+ "off-target. Every guide carries the weight in its own row "
40
+ "(composite_offtarget_weight), so the arithmetic is "
41
+ "checkable per guide.",
42
+ "limits": "The 0.6 is uncalibrated — no measured cleavage dataset was "
43
+ "used to fit it, and a different weight would reorder guides "
44
+ "whose self-off scores differ. Sort by On-target and Self-off "
45
+ "separately if you want to apply your own trade-off. It also "
46
+ "inherits every limit of its two inputs — in particular the "
47
  "specificity term only sees the sequence you pasted.",
48
  "citations": [],
49
  },
 
131
  "the templated single-base insertion class Cas9 is known to "
132
  "produce. These ARE per-guide, sequence-driven predictions — "
133
  "not a fixed average.",
134
+ # The repair context these numbers assume, stated as a first-class
135
+ # field so the UI can print it next to FS % rather than leaving the
136
+ # reader to infer it. Everything here is read off the algorithm:
137
+ # crispr._predict_indels models MMEJ deletions + a fixed 32% templated
138
+ # +1 insertion class and has no donor, cell-type or organism argument.
139
+ "assumes": "Template-free end-joining repair (MMEJ + classical NHEJ) "
140
+ "of a blunt Cas9 double-strand break, in a cell with intact "
141
+ "repair machinery and no HDR donor supplied. Supply a repair "
142
+ "template, use a nickase or a base editor, or work in a "
143
+ "repair-deficient background, and these outcomes do not "
144
+ "apply.",
145
  "limits": "Two real limits. (1) HEURISTIC: this is not the inDelphi "
146
  "neural network — it reproduces roughly 80% of that model's "
147
  "rank-ordering, not its calibrated frequencies. (2) "
 
164
  "ko_score", "indels"]
165
 
166
 
167
+ def genome_scopes() -> Dict[str, Dict[str, Any]]:
168
+ """What the genome off-target search actually covers, per organism.
169
+
170
+ Read straight off ``offtarget.GENOME_SOURCES`` rather than restated here:
171
+ the results panel prints this next to the off-target numbers, and a
172
+ hand-copied list is exactly how a panel ends up claiming a whole-genome
173
+ screen for an organism that is indexed over coding sequence only. Adding
174
+ an organism to the registry adds it here with no second edit.
175
+ """
176
+ try:
177
+ from dee.core.offtarget import GENOME_SOURCES
178
+ except Exception: # noqa: BLE001 — provenance must never break the route
179
+ return {}
180
+ out: Dict[str, Dict[str, Any]] = {}
181
+ for key, src in GENOME_SOURCES.items():
182
+ scope = str(src.get("scope", ""))
183
+ complete = scope == "full genome"
184
+ out[key] = {
185
+ "name": str(src.get("name", key)),
186
+ "scope": scope,
187
+ "complete": complete,
188
+ # One sentence the UI can print verbatim, so the wording lives
189
+ # next to the registry that decides whether it is true.
190
+ "note": (
191
+ "Complete genome indexed — an off-target anywhere is found."
192
+ if complete else
193
+ "CODING SEQUENCE ONLY. Off-targets in introns, intergenic and "
194
+ "regulatory DNA are outside this index and will not appear. "
195
+ "Use a whole-genome tool if your application needs them."
196
+ ),
197
+ }
198
+ return out
199
+
200
+
201
  def methods_payload() -> Dict[str, Any]:
202
  """Serializable provenance block for GET /api/crispr/methods."""
203
  return {
204
  "order": list(METHOD_ORDER),
205
  "methods": {k: dict(METHODS[k]) for k in METHOD_ORDER},
206
+ "genome_scopes": genome_scopes(),
207
  "summary": (
208
  "Every score here is computed from published, sequence-based "
209
+ "methods — no black box, and exactly one tuning constant (the "
210
+ "0.6 off-target weight in Composite, which is our judgement "
211
+ "call). Two scope limits matter most: for human and mouse the "
212
+ "genome off-target search covers coding sequence only, and indel "
213
+ "predictions assume template-free end-joining repair with fixed "
214
+ "constants — they are not tuned to your cell type."
215
  ),
216
  }
dee/core/epistasis.py CHANGED
@@ -174,22 +174,103 @@ def _magnitude_word(x: float) -> str:
174
  return "strong" if ax >= 1.5 else "moderate" if ax >= 0.7 else "slight"
175
 
176
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
177
  def _narrate(verdict: str, sites: List[SiteShift], risk: float, signed: float) -> str:
 
 
 
 
 
 
 
 
 
 
 
178
  if verdict == "single":
 
 
 
 
 
 
179
  return "A single substitution — no interactions to analyze."
 
 
180
  if verdict == "independent":
181
- return ("The substitutions act roughly independently — no strong "
182
- "interaction detected. Predicted fitness should hold up.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
183
  if verdict == "clash":
184
- worst = min(sites, key=lambda s: s.shift) # most negative
185
- return (f"Predicted antagonism: {worst.label} looks {_magnitude_word(worst.shift)}ly "
186
- "less favorable once the other mutations are present — the substitutions may "
187
- "clash. Worth testing it separately, or dropping it from this combination.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
188
  # cooperative
189
  best = max(sites, key=lambda s: s.shift)
190
- return (f"Predicted cooperativity: the substitutions reinforce each other — {best.label} "
191
- f"looks {_magnitude_word(best.shift)}ly stronger in this combination than alone. "
192
- "A promising multi-mutant to prioritize.")
 
 
 
 
 
 
 
 
 
 
 
 
193
 
194
 
195
  def _wt_rows(
@@ -252,12 +333,15 @@ def analyze_variant(
252
  w, p, m = sites_parsed[0]
253
  row = _wt_rows(wt_protein, [p], masked_log_probs_fn, wt_cache)[p]
254
  marg = float(row[_AA_COL[m]] - row[_AA_COL[w]])
 
255
  return VariantEpistasis(
256
  variant_id=variant_id, mutations=mut_str, n_sites=1,
257
  additive_score=marg, epistasis_signed=0.0, epistasis_risk=0.0,
258
  corrected_score=marg, verdict="single",
259
- note=_narrate("single", [], 0.0, 0.0),
260
- sites=[SiteShift(f"{w}{p + 1}{m}", p, w, m, marg, marg, 0.0)],
 
 
261
  )
262
 
263
  mutant = _apply_mutations(wt_protein, sites_parsed)
 
174
  return "strong" if ax >= 1.5 else "moderate" if ax >= 0.7 else "slight"
175
 
176
 
177
+ # A site is only worth naming as a second actor if it moved enough to mean
178
+ # something. Same 0.3 the UI uses to tint a mutation chip (app.js
179
+ # _radarSiteChip), so the prose and the colours agree about what "moved" is.
180
+ _SITE_NOTABLE = 0.3
181
+
182
+
183
+ def _plural(n: int, word: str) -> str:
184
+ return f"{n} {word}" if n == 1 else f"{n} {word}s"
185
+
186
+
187
+ def _the_others(n_other: int, verb_sing: str, verb_plur: str) -> str:
188
+ """'The other site barely moves' / 'The other 3 sites barely move'.
189
+ A bare _plural() here produced "The other 1 site barely move"."""
190
+ subject = "The other site" if n_other == 1 else f"The other {n_other} sites"
191
+ return f"{subject} {verb_sing if n_other == 1 else verb_plur}"
192
+
193
+
194
  def _narrate(verdict: str, sites: List[SiteShift], risk: float, signed: float) -> str:
195
+ """One row's explanation, built from THAT row's numbers.
196
+
197
+ This used to be one fixed sentence per verdict class, so a ten-row
198
+ Interaction Radar printed the same three sentences over and over and a
199
+ reviewing scientist read the whole panel as copy-paste — the analysis
200
+ looked like it had not actually run per variant. Nothing here inflates:
201
+ every adjective is a threshold lookup (:func:`_magnitude_word`) over a
202
+ number that is also printed, and the sites named are the ones that
203
+ actually moved. Two rows in the same verdict class differ because their
204
+ measurements differ, which is the point.
205
+ """
206
  if verdict == "single":
207
+ # sites carries the one substitution, so the marginal can be stated
208
+ # rather than the row reading as an empty "nothing to see here".
209
+ if sites:
210
+ s = sites[0]
211
+ return (f"{s.label} is the only substitution — nothing for it to interact "
212
+ f"with. On the wild-type background it scores {s.marginal_ll:+.2f} ΣΔLL.")
213
  return "A single substitution — no interactions to analyze."
214
+
215
+ n = len(sites)
216
  if verdict == "independent":
217
+ if not sites:
218
+ return ("No scorable interaction between these substitutions they "
219
+ "look independent.")
220
+ top = max(sites, key=lambda s: abs(s.shift))
221
+ # "Nothing moved at all" and "two sites moved and cancelled out" are
222
+ # both 'independent', and they are not the same finding. Leading with
223
+ # the same clause for both is what made these rows interchangeable.
224
+ movers = [s for s in sites if abs(s.shift) >= _SITE_NOTABLE]
225
+ if not movers:
226
+ return (f"Nothing moves much in context — the largest shift is {top.label} "
227
+ f"at {top.shift:+.2f} ΣΔLL, well inside the ±{COOP_FLOOR:g} flag "
228
+ f"threshold. The {n} substitutions act independently, so the "
229
+ f"additive score should hold.")
230
+ spread = ("every site shifts but they cancel out" if len(movers) == n
231
+ else f"{_plural(len(movers), 'site')} of {n} shifts")
232
+ return (f"Largest context shift is {top.label} at {top.shift:+.2f} ΣΔLL "
233
+ f"({top.marginal_ll:+.2f} alone → {top.context_ll:+.2f} in combination); "
234
+ f"{spread}, for a net {signed:+.2f} ΣΔLL — under the ±{COOP_FLOOR:g} flag "
235
+ f"threshold. Independent enough that the additive score should hold.")
236
+
237
  if verdict == "clash":
238
+ worst = min(sites, key=lambda s: s.shift) # most negative
239
+ others = [s for s in sites if s is not worst and s.shift <= -_SITE_NOTABLE]
240
+ if others:
241
+ second = min(others, key=lambda s: s.shift)
242
+ extra = (f" {second.label} is dragged down too ({second.shift:+.2f}), so "
243
+ f"{_plural(len(others) + 1, 'site')} of {n} antagonize.")
244
+ else:
245
+ extra = " " + _the_others(n - 1, "is largely unaffected.",
246
+ "are largely unaffected.")
247
+ # The worst site IS the whole risk in a 2-site variant with one loser,
248
+ # so restating it as a "total" reads like a second, disagreeing number.
249
+ total = (f" Total antagonism across the variant is {risk:.2f} ΣΔLL."
250
+ if risk - abs(worst.shift) >= 0.05 else "")
251
+ return (f"{worst.label} loses {abs(worst.shift):.2f} ΣΔLL once the other "
252
+ f"substitutions are present ({worst.marginal_ll:+.2f} alone → "
253
+ f"{worst.context_ll:+.2f} in combination) — a {_magnitude_word(worst.shift)} "
254
+ f"antagonistic shift, so this combination is flagged as a clash.{extra}"
255
+ f"{total} Test {worst.label} on its own, or drop it from this combination.")
256
+
257
  # cooperative
258
  best = max(sites, key=lambda s: s.shift)
259
+ gained = [s for s in sites if s.shift >= _SITE_NOTABLE]
260
+ drag = [s for s in sites if s.shift <= -_SITE_NOTABLE]
261
+ if len(gained) > 1:
262
+ extra = f" {_plural(len(gained), 'site')} of {n} gain in context."
263
+ elif drag:
264
+ worst = min(drag, key=lambda s: s.shift)
265
+ # Honest counterweight: net cooperative does not mean every site won.
266
+ extra = (f" {worst.label} still loses {abs(worst.shift):.2f} ΣΔLL, so the gain "
267
+ f"is not uniform across the {n} sites.")
268
+ else:
269
+ extra = " " + _the_others(n - 1, "barely moves.", "barely move.")
270
+ return (f"{best.label} gains {best.shift:.2f} ΣΔLL in this combination "
271
+ f"({best.marginal_ll:+.2f} alone → {best.context_ll:+.2f} together) — a "
272
+ f"{_magnitude_word(best.shift)} cooperative shift, so the substitutions "
273
+ f"reinforce each other.{extra} Net {signed:+.2f} ΣΔLL over the additive score.")
274
 
275
 
276
  def _wt_rows(
 
333
  w, p, m = sites_parsed[0]
334
  row = _wt_rows(wt_protein, [p], masked_log_probs_fn, wt_cache)[p]
335
  marg = float(row[_AA_COL[m]] - row[_AA_COL[w]])
336
+ only = SiteShift(f"{w}{p + 1}{m}", p, w, m, marg, marg, 0.0)
337
  return VariantEpistasis(
338
  variant_id=variant_id, mutations=mut_str, n_sites=1,
339
  additive_score=marg, epistasis_signed=0.0, epistasis_risk=0.0,
340
  corrected_score=marg, verdict="single",
341
+ # The site goes in so the note can quote the real marginal instead
342
+ # of the same content-free sentence on every single-mutant row.
343
+ note=_narrate("single", [only], 0.0, 0.0),
344
+ sites=[only],
345
  )
346
 
347
  mutant = _apply_mutations(wt_protein, sites_parsed)
dee/core/orchestrator.py CHANGED
@@ -76,12 +76,45 @@ MAX_EXTENSIONS = 4
76
  _STEER_MAX_QUEUED = 8
77
  _STEER_MAX_CHARS = 4000
78
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79
  # Hard ceiling on retained history. Gemini's window is ~1M tokens so this
80
  # isn't about fitting — it's about an abandoned tab not silently costing more
81
  # every turn. Oldest messages drop first; the system prompt is never part of
82
  # history (rebuilt per call) so it can't be evicted.
83
  MAX_HISTORY_MESSAGES = 80
84
 
 
 
 
 
 
 
 
 
 
 
 
 
 
85
  _RUN_TTL_SECONDS = 6 * 60 * 60 # matches agent.py's session TTL
86
 
87
 
@@ -292,6 +325,29 @@ class Run:
292
  # looking this up there would mean a database round-trip per step for
293
  # something that doesn't change within a run.
294
  continuity: str = ""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
295
 
296
 
297
  _RUNS: Dict[str, Run] = {}
@@ -484,6 +540,11 @@ def public_state(run: Run) -> Dict[str, Any]:
484
  "seq": run.seq,
485
  "steps": run.steps,
486
  "cost_usd": round(run.cost_usd, 6),
 
 
 
 
 
487
  "context_tokens_used": run.context_tokens,
488
  "context_tokens_limit": _llm.CONTEXT_WINDOW_TOKENS,
489
  "plan": list(run.plan),
@@ -491,6 +552,104 @@ def public_state(run: Run) -> Dict[str, Any]:
491
  }
492
 
493
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
494
  def _digest_dropped(messages: List[Dict[str, Any]]) -> str:
495
  """A compact, factual account of history about to be evicted.
496
 
@@ -518,7 +677,11 @@ def _digest_dropped(messages: List[Dict[str, Any]]) -> str:
518
  tools[-1] += " (failed)"
519
  elif role == "user":
520
  text = str(m.get("content") or "").strip()
521
- if text:
 
 
 
 
522
  asks.append(text[:160])
523
 
524
  bits: List[str] = []
@@ -571,7 +734,14 @@ def _trim_history(run: Run) -> Optional[str]:
571
  # forgotten what it was asked will still answer — just not the question.
572
  if run.goal:
573
  head.append({"role": "user",
574
- "content": f"[Original request, pinned] {run.goal}"})
 
 
 
 
 
 
 
575
  head.append({"role": "user", "content": note})
576
  run.history = head + run.history[cut:]
577
  return note
@@ -587,12 +757,31 @@ def _drive(run: Run, config: _llm.OpenRouterConfig) -> None:
587
  instead of spinning on a run that quietly died.
588
  """
589
  try:
590
- while run.steps < MAX_STEPS + run.extensions * STEP_EXTENSION:
 
 
 
 
 
 
 
 
 
 
 
591
  if run.stop_requested:
592
  run.status = "stopped"
593
  _emit(run, "done", {"reason": "stopped"})
594
  return
595
 
 
 
 
 
 
 
 
 
596
  # Anything the user typed while we were working lands HERE, at a
597
  # clean boundary. It cannot go in mid-step: a user message spliced
598
  # between an assistant tool_calls message and its results makes the
@@ -615,7 +804,8 @@ def _drive(run: Run, config: _llm.OpenRouterConfig) -> None:
615
  # workspace that no longer matches what the user is looking at.
616
  "messages": ([{"role": "system",
617
  "content": build_system_prompt(
618
- run.anonymous, run.workspace, run.continuity)}]
 
619
  + run.history),
620
  "tools": TOOL_SPECS,
621
  "temperature": 0,
@@ -632,14 +822,11 @@ def _drive(run: Run, config: _llm.OpenRouterConfig) -> None:
632
 
633
  run.cost_usd += cost
634
  run.context_tokens = ptoks
635
- if run.cost_usd > config.max_cost_usd:
636
  logger.warning("run %s exceeded cost cap (%.4f)", run.run_id, run.cost_usd)
637
- run.status = "error"
638
- _emit(run, "error", {
639
- "error": "This run hit its budget ceiling. Start a new one to continue.",
640
- "error_kind": "cost_capped",
641
- })
642
  return
 
643
 
644
  text = (message.get("content") or "").strip()
645
  tool_calls = message.get("tool_calls") or []
@@ -703,10 +890,6 @@ def _drive(run: Run, config: _llm.OpenRouterConfig) -> None:
703
  if note:
704
  _emit(run, "compacted", {"note": note})
705
 
706
- # ── Budget spent. A checkpoint, not a wall. ──────────────────────
707
- _checkpoint(run)
708
- return
709
-
710
  except Exception: # noqa: BLE001 — a crashed thread must not hang the client
711
  logger.exception("orchestrator run %s crashed", run.run_id)
712
  run.status = "error"
@@ -777,9 +960,13 @@ def _run_one_tool(run: Run, name: str, call_id: str, args: Dict[str, Any]) -> bo
777
 
778
  # run.owner is the user_id for a signed-in run, "ip:<hash>" for anonymous.
779
  # Only pass a real id — an anonymous run must never resolve to a row.
 
780
  result = _tools.execute_tool(name, args, run.anonymous,
781
  user_id=(None if run.anonymous else run.owner))
782
  model_result = _strip_ui(result)
 
 
 
783
 
784
  _emit(run, "tool_result", {
785
  "id": call_id,
@@ -835,31 +1022,227 @@ def _clean_plan(raw: Any) -> List[Dict[str, str]]:
835
  return out
836
 
837
 
838
- def _checkpoint(run: Run) -> None:
839
- """Out of step budget report where we got to and offer to continue.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
840
 
841
  The old behaviour said "I've taken this as far as one run goes", marked the
842
  run done, and left the user to guess what had and hadn't happened. That is
843
  an abandonment dressed as a completion. A checkpoint states what remains,
844
  keeps the run resumable, and is honest about why it paused.
 
 
 
 
845
  """
846
  left = [p["step"] for p in run.plan if p["status"] in ("pending", "active")]
 
847
  if run.extensions >= MAX_EXTENSIONS:
848
- run.status = "done"
849
- _emit(run, "text", {"text": (
850
- "I've hit this run's overall ceiling. Everything above is saved — "
851
- "start a new run and I'll pick up from it.")})
852
- _emit(run, "done", {"reason": "max_steps"})
853
  return
854
 
 
 
 
 
 
 
 
 
855
  if left:
856
- body = ("I've used this stretch of the run. Still to do:\n"
857
  + "\n".join("• " + s for s in left[:6])
858
- + "\n\nSay **continue** and I'll carry on, or redirect me.")
 
859
  else:
860
- body = ("I've used this stretch of the run without closing out the "
861
- "task. Say **continue** and I'll carry on, or tell me what to "
862
- "focus on.")
863
  run.status = "awaiting_input"
864
  run.pending_tool_call_id = None
865
  _emit(run, "text", {"text": body})
@@ -867,6 +1250,7 @@ def _checkpoint(run: Run) -> None:
867
  "remaining": left,
868
  "steps_used": run.steps,
869
  "can_continue": True,
 
870
  })
871
 
872
 
@@ -885,6 +1269,7 @@ def _spawn(run: Run) -> None:
885
  "error_kind": "agent_unconfigured",
886
  })
887
  return
 
888
  run.status = "running"
889
  run.stop_requested = False
890
  t = threading.Thread(target=_drive, args=(run, config), daemon=True)
@@ -956,9 +1341,72 @@ def set_workspace(run: Run, workspace: Optional[Dict[str, Any]]) -> None:
956
  run.workspace = clean
957
 
958
 
959
- def start(run: Run, message: str, workspace: Optional[Dict[str, Any]] = None) -> None:
960
- """Begin (or continue) a run with a user message."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
961
  set_workspace(run, workspace)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
962
  # The opening ask names the run in the history list. Set once, so a long
963
  # conversation keeps the title it started with rather than renaming
964
  # itself to whatever was asked last.
@@ -1479,19 +1927,76 @@ def continuity_note(user_id: str) -> str:
1479
  lines.append(bit)
1480
 
1481
  return (
1482
- "\n\nWHAT THEY WERE ALREADY WORKING ON\n"
1483
- "This user has saved work. Do NOT re-introduce yourself or propose "
1484
- "starting from scratch on something they already have:\n"
 
 
 
 
 
 
 
 
 
1485
  + "\n".join(lines)
1486
  + "\nUse list_my_work for ids and detail. If a library has results "
1487
  "logged, propose_round2 is the natural next step; if it has none and "
1488
- "they mention bench numbers, log_outcome is.\n"
 
1489
  )
1490
 
1491
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1492
  def build_system_prompt(anonymous: bool, workspace: Optional[Dict[str, Any]] = None,
1493
- continuity: str = "") -> str:
 
1494
  return (SYSTEM_PROMPT
1495
  + (_ANON_NOTE if anonymous else "")
1496
  + (continuity or "")
1497
- + _workspace_note(workspace or {}))
 
 
76
  _STEER_MAX_QUEUED = 8
77
  _STEER_MAX_CHARS = 4000
78
 
79
+ # Extensions granted WITHOUT stopping to ask, while the run is still doing
80
+ # real work and the money is not the binding constraint. The reviewer's
81
+ # 2026-07-30 log has "I've used this stretch of the run … say continue" firing
82
+ # repeatedly, with the reviewer typing "continue" each time — which reads as
83
+ # the agent stalling, not pacing itself. The step cap was never the guard that
84
+ # mattered (the cost cap is, and this file has said so since it was written);
85
+ # it was just the loud one. So the first few stretches extend themselves and
86
+ # the user is only asked when there is a real decision to make: no progress,
87
+ # or the budget genuinely running down.
88
+ AUTO_EXTENSIONS = 2
89
+
90
+ # Spend, as a fraction of the run's cost cap.
91
+ # WARN — say so once, in the user's terms, and keep going.
92
+ # CHECKPOINT — park while there is still enough left to finish something.
93
+ # The old code had neither: the first and only signal about money was the
94
+ # ceiling, delivered as a red error block mid-task ("not clear what happened
95
+ # here / was this a user limit?").
96
+ COST_WARN_FRACTION = 0.60
97
+ COST_CHECKPOINT_FRACTION = 0.85
98
+
99
  # Hard ceiling on retained history. Gemini's window is ~1M tokens so this
100
  # isn't about fitting — it's about an abandoned tab not silently costing more
101
  # every turn. Oldest messages drop first; the system prompt is never part of
102
  # history (rebuilt per call) so it can't be evicted.
103
  MAX_HISTORY_MESSAGES = 80
104
 
105
+ # How much of the bound target's sequence survives compaction. Pinning the
106
+ # letters is the whole fix for "asked for protein sequence again even though
107
+ # it was given the sequence 1 turn ago" — but a pinned 400 kb plasmid re-sent
108
+ # every step would be a bill, not a fix. Above this the pin keeps the target's
109
+ # IDENTITY and says plainly that the letters were dropped, so the agent
110
+ # re-fetches instead of asking the user to paste it again.
111
+ TARGET_PIN_MAX_NT = 20_000
112
+
113
+ # Every message this module pins into the head of a compacted history. They
114
+ # are our own scaffolding, not things the user said, so _digest_dropped must
115
+ # not re-report them back as "earlier from the user".
116
+ _PIN_PREFIX = "[TuringDNA pinned]"
117
+
118
  _RUN_TTL_SECONDS = 6 * 60 * 60 # matches agent.py's session TTL
119
 
120
 
 
325
  # looking this up there would mean a database round-trip per step for
326
  # something that doesn't change within a run.
327
  continuity: str = ""
328
+ # WHAT THIS RUN IS ABOUT. See _bind_target: the single most consequential
329
+ # field on a Run, and the thing whose absence caused the worst failure in
330
+ # the 2026-07-30 reviewer log — the agent planning a thermostability job
331
+ # against a protein from a DIFFERENT conversation. Empty until a tool
332
+ # actually resolves something; a run must never assert a target it does
333
+ # not have.
334
+ target: Dict[str, Any] = field(default_factory=dict)
335
+ # Real tool executions (ask_user and update_plan don't count — neither
336
+ # changes anything in the world). Compared against tools_at_checkpoint to
337
+ # tell "still working" from "looping", which is what decides whether the
338
+ # step budget extends itself or stops and asks.
339
+ tools_run: int = 0
340
+ tools_at_checkpoint: int = 0
341
+ # The spend warning fires once per run, not once per step.
342
+ budget_warned: bool = False
343
+ # This run's compute allowance and whether it is gone. Mirrored onto the
344
+ # Run (from OpenRouterConfig at spawn) so the HTTP layer can answer "can
345
+ # this run continue?" without re-reading the environment — and so the
346
+ # client can show the budget rather than being surprised by it.
347
+ cost_limit_usd: float = 0.0
348
+ budget_spent: bool = False
349
+ # Set when this run was seeded from an earlier one that ran out of budget.
350
+ carried_from: str = ""
351
 
352
 
353
  _RUNS: Dict[str, Run] = {}
 
540
  "seq": run.seq,
541
  "steps": run.steps,
542
  "cost_usd": round(run.cost_usd, 6),
543
+ # Published so a client can SHOW the allowance instead of the user
544
+ # meeting it for the first time as a wall (reviewer, 2026-07-30:
545
+ # "not clear what happened here / was this a user limit?").
546
+ "cost_limit_usd": round(run.cost_limit_usd, 6),
547
+ "budget_spent": run.budget_spent,
548
  "context_tokens_used": run.context_tokens,
549
  "context_tokens_limit": _llm.CONTEXT_WINDOW_TOKENS,
550
  "plan": list(run.plan),
 
552
  }
553
 
554
 
555
+ # ─── The run's target ───────────────────────────────────────────────────
556
+ # THE incident this exists for (reviewer log, 2026-07-30, beta-lactamase
557
+ # TEM-1): asked to "design a more thermostable variant of this protein", the
558
+ # agent "began referencing a protein from a different conversation instead of
559
+ # the current conversation" — it planned against IsPETase, a target from
560
+ # another run. In the same session it also asked the reviewer for a sequence
561
+ # it had been given one turn earlier.
562
+ #
563
+ # Both are the same hole. Nothing bound a run to what the run was ABOUT. The
564
+ # target existed only as a `tool` message inside run.history, which compaction
565
+ # is free to evict, while the system prompt — rebuilt from scratch and re-sent
566
+ # on EVERY step — carried the user's saved libraries under a heading telling
567
+ # the model not to start from scratch on work they already had. So the most
568
+ # prominent, most repeated, most instruction-shaped thing in context named a
569
+ # target from a different conversation, and the one the user was actually
570
+ # talking about was a truncated JSON blob forty messages back, or gone.
571
+ #
572
+ # Binding is deliberately EVIDENCE-ONLY: a target is recorded when a tool
573
+ # actually resolved one, never inferred from what the user typed. A run that
574
+ # has resolved nothing asserts nothing.
575
+
576
+ def _bind_target(run: Run, name: str, args: Dict[str, Any],
577
+ result: Dict[str, Any]) -> None:
578
+ """Record what this run is about, from a tool result that proves it."""
579
+ if not isinstance(result, dict) or not result.get("ok"):
580
+ # A failed lookup is not a target. Binding "Couldn't find blaTEM"
581
+ # would put a name the tool explicitly refused into the standing
582
+ # instructions as established fact.
583
+ return
584
+
585
+ if name == "fetch_sequence":
586
+ seq = str(result.get("sequence") or "")
587
+ if not seq:
588
+ return
589
+ # A later successful resolve REPLACES the binding: that is the user
590
+ # changing subject mid-run, and a binding that couldn't follow them
591
+ # would be a cage rather than a fix.
592
+ run.target = {
593
+ "label": str(result.get("label") or "")[:120],
594
+ "gene_symbol": str(result.get("gene_symbol") or "")[:32],
595
+ "organism": str(args.get("organism") or "")[:48],
596
+ "kind": str(result.get("kind") or "")[:24],
597
+ "source": str(result.get("source") or "")[:24],
598
+ "length": int(result.get("length") or len(seq)),
599
+ "sequence": seq,
600
+ "bound_at_step": run.steps,
601
+ }
602
+ return
603
+
604
+ if name == "fold_structure":
605
+ acc = str(result.get("uniprot") or "")[:16]
606
+ if run.target:
607
+ # ENRICH, never replace. fold_structure resolves an accession;
608
+ # letting it redefine the run's subject is the bleed itself, one
609
+ # stray structure lookup away.
610
+ if acc and not run.target.get("uniprot"):
611
+ run.target = dict(run.target, uniprot=acc)
612
+ return
613
+ # Nothing bound yet: "fold TEM-1" IS the run's subject, even with no
614
+ # sequence in hand. No `sequence` key — we have not resolved one.
615
+ gene = str(result.get("gene_symbol") or "")[:32]
616
+ if not gene:
617
+ return
618
+ run.target = {
619
+ "label": gene,
620
+ "gene_symbol": gene,
621
+ "organism": str(result.get("organism") or "")[:48],
622
+ "kind": "structure",
623
+ "source": str(result.get("source") or "")[:24],
624
+ "uniprot": acc,
625
+ "bound_at_step": run.steps,
626
+ }
627
+
628
+
629
+ def _target_pin(target: Dict[str, Any]) -> str:
630
+ """The target, phrased so it survives compaction intact.
631
+
632
+ This is the mechanical half of the re-ask fix. `_trim_history` evicts the
633
+ oldest messages and `_digest_dropped` replaces them with "tools already
634
+ run: fetch_sequence" — the LETTERS are destroyed, and after that the only
635
+ way for the agent to obtain the user's sequence is to ask for it again.
636
+ Which is exactly what the reviewer saw.
637
+ """
638
+ label = (target.get("label") or target.get("gene_symbol")
639
+ or "the target of this run")
640
+ seq = str(target.get("sequence") or "")
641
+ n = int(target.get("length") or len(seq))
642
+ head = f"{_PIN_PREFIX} Target of this run: {label}"
643
+ if not seq:
644
+ return head
645
+ if len(seq) <= TARGET_PIN_MAX_NT:
646
+ return (f"{head} — the sequence already resolved in this "
647
+ f"conversation, {n:,} nt:\n{seq}")
648
+ return (f"{head} — {n:,} nt, too long to keep pinned in context. Call "
649
+ f"fetch_sequence again if you need the letters. Do NOT ask the "
650
+ f"user to re-send something they already gave you.")
651
+
652
+
653
  def _digest_dropped(messages: List[Dict[str, Any]]) -> str:
654
  """A compact, factual account of history about to be evicted.
655
 
 
677
  tools[-1] += " (failed)"
678
  elif role == "user":
679
  text = str(m.get("content") or "").strip()
680
+ # Our own pinned scaffolding is not something the user said.
681
+ # Re-reporting it as "earlier from the user" turned the goal pin
682
+ # into a fake quote, and would drag a pinned sequence's first 160
683
+ # bases into the digest as if the user had typed them.
684
+ if text and not text.startswith(_PIN_PREFIX):
685
  asks.append(text[:160])
686
 
687
  bits: List[str] = []
 
734
  # forgotten what it was asked will still answer — just not the question.
735
  if run.goal:
736
  head.append({"role": "user",
737
+ "content": f"{_PIN_PREFIX} Original request: {run.goal}"})
738
+ # Re-pin the target too. The goal survives compaction but the SEQUENCE did
739
+ # not, and an agent that remembers it was asked to make a protein more
740
+ # thermostable but no longer holds the protein will do the only thing left
741
+ # — ask the user for it again. That is the reviewer's "asked for protein
742
+ # sequence again even though it was given the sequence 1 turn ago".
743
+ if run.target:
744
+ head.append({"role": "user", "content": _target_pin(run.target)})
745
  head.append({"role": "user", "content": note})
746
  run.history = head + run.history[cut:]
747
  return note
 
757
  instead of spinning on a run that quietly died.
758
  """
759
  try:
760
+ while True:
761
+ # ── Out of steps? Buy more, if this is still work. ────────────
762
+ # The step budget used to end the stretch and make the user type
763
+ # "continue", every 24 steps, no matter how well it was going.
764
+ # _auto_extend takes that decision when there is nothing for the
765
+ # user to decide; when there IS (nothing is landing, or the money
766
+ # is running down) it declines and we checkpoint properly.
767
+ if run.steps >= MAX_STEPS + run.extensions * STEP_EXTENSION:
768
+ if not _auto_extend(run, config):
769
+ _checkpoint(run, reason="steps")
770
+ return
771
+
772
  if run.stop_requested:
773
  run.status = "stopped"
774
  _emit(run, "done", {"reason": "stopped"})
775
  return
776
 
777
+ # ── Money, checked before another step is spent ───────────────
778
+ # Parking with budget still on the clock leaves a run the user can
779
+ # resume. The old code only ever noticed spend AFTER the ceiling
780
+ # had been crossed, which leaves nothing to resume with.
781
+ if _cost_fraction(run, config) >= COST_CHECKPOINT_FRACTION:
782
+ _checkpoint(run, reason="cost")
783
+ return
784
+
785
  # Anything the user typed while we were working lands HERE, at a
786
  # clean boundary. It cannot go in mid-step: a user message spliced
787
  # between an assistant tool_calls message and its results makes the
 
804
  # workspace that no longer matches what the user is looking at.
805
  "messages": ([{"role": "system",
806
  "content": build_system_prompt(
807
+ run.anonymous, run.workspace, run.continuity,
808
+ run.target)}]
809
  + run.history),
810
  "tools": TOOL_SPECS,
811
  "temperature": 0,
 
822
 
823
  run.cost_usd += cost
824
  run.context_tokens = ptoks
825
+ if _cost_fraction(run, config) >= 1.0:
826
  logger.warning("run %s exceeded cost cap (%.4f)", run.run_id, run.cost_usd)
827
+ _budget_spent(run, config)
 
 
 
 
828
  return
829
+ _warn_on_spend(run, config)
830
 
831
  text = (message.get("content") or "").strip()
832
  tool_calls = message.get("tool_calls") or []
 
890
  if note:
891
  _emit(run, "compacted", {"note": note})
892
 
 
 
 
 
893
  except Exception: # noqa: BLE001 — a crashed thread must not hang the client
894
  logger.exception("orchestrator run %s crashed", run.run_id)
895
  run.status = "error"
 
960
 
961
  # run.owner is the user_id for a signed-in run, "ip:<hash>" for anonymous.
962
  # Only pass a real id — an anonymous run must never resolve to a row.
963
+ run.tools_run += 1
964
  result = _tools.execute_tool(name, args, run.anonymous,
965
  user_id=(None if run.anonymous else run.owner))
966
  model_result = _strip_ui(result)
967
+ # Bind BEFORE the event is emitted, so the very next system prompt — the
968
+ # one the model plans with — already knows what this run is about.
969
+ _bind_target(run, name, args, result)
970
 
971
  _emit(run, "tool_result", {
972
  "id": call_id,
 
1022
  return out
1023
 
1024
 
1025
+ # ─── Budget: warn, pace, hand over ──────────────────────────────────────
1026
+ # The reviewer's 2026-07-30 note on the old ceiling was "not clear what
1027
+ # happened here / was this a user limit?" — a fair question, because
1028
+ # "This run hit its budget ceiling. Start a new one to continue." names no
1029
+ # amount, no unit and no owner, arrives as a red error block, and strands
1030
+ # everything the run had already done. All three are fixed below.
1031
+
1032
+ def _cost_fraction(run: Run, config: _llm.OpenRouterConfig) -> float:
1033
+ """How much of this run's allowance is gone, 0-1. Zero when uncapped."""
1034
+ limit = float(getattr(config, "max_cost_usd", 0.0) or 0.0)
1035
+ if limit <= 0:
1036
+ return 0.0
1037
+ return run.cost_usd / limit
1038
+
1039
+
1040
+ def _warn_on_spend(run: Run, config: _llm.OpenRouterConfig) -> None:
1041
+ """One heads-up, in the user's terms, well before anything stops."""
1042
+ if run.budget_warned or _cost_fraction(run, config) < COST_WARN_FRACTION:
1043
+ return
1044
+ run.budget_warned = True
1045
+ limit = float(config.max_cost_usd)
1046
+ _emit(run, "budget", {
1047
+ "spent_usd": round(run.cost_usd, 4),
1048
+ "limit_usd": round(limit, 4),
1049
+ "fraction": round(_cost_fraction(run, config), 3),
1050
+ })
1051
+ _emit(run, "text", {"text": (
1052
+ f"Heads up — I've used about {round(_cost_fraction(run, config) * 100)}% "
1053
+ f"of this run's compute allowance (${limit:.2f} of model time per run; "
1054
+ f"it's a cap on the run, not a limit on your account). I'll pause and "
1055
+ f"check in with you before it runs out.")})
1056
+
1057
+
1058
+ def _budget_spent(run: Run, config: _llm.OpenRouterConfig) -> None:
1059
+ """The allowance is gone. End the run cleanly and carry the work across."""
1060
+ limit = float(config.max_cost_usd)
1061
+ run.budget_spent = True
1062
+ _hand_over(run, reason="budget_spent", why=(
1063
+ f"That's this run's full compute allowance spent (${limit:.2f} of "
1064
+ f"model time per run — it's a cap on the run, not a limit on your "
1065
+ f"account, and nothing you did is lost)."))
1066
+
1067
+
1068
+ def handover_note(run: Run) -> str:
1069
+ """A factual brief a fresh run can start from.
1070
+
1071
+ Built deterministically from what this run actually did — no model call,
1072
+ nothing inferred. "Start a new one to continue" was advice the product
1073
+ could not act on: a new run began cold, with no target, no plan and no
1074
+ record of what had already happened, so the reviewer's work was simply
1075
+ gone.
1076
+ """
1077
+ bits: List[str] = []
1078
+ if run.goal:
1079
+ bits.append(f"Original request: {run.goal}")
1080
+ target = run.target or {}
1081
+ if target:
1082
+ ident = target.get("label") or target.get("gene_symbol") or ""
1083
+ line = f"Target already resolved: {ident}"
1084
+ if target.get("uniprot"):
1085
+ line += f" (UniProt {target['uniprot']})"
1086
+ if target.get("length"):
1087
+ line += f" · {int(target['length']):,} nt"
1088
+ bits.append(line)
1089
+ done = [p["step"] for p in run.plan if p["status"] == "done"]
1090
+ left = [p["step"] for p in run.plan if p["status"] in ("pending", "active")]
1091
+ if done:
1092
+ bits.append("Already finished: " + "; ".join(done[:8]))
1093
+ if left:
1094
+ bits.append("Still to do: " + "; ".join(left[:8]))
1095
+ tools: List[str] = []
1096
+ for ev in run.events:
1097
+ if ev.get("kind") == "tool_result" and ev.get("ok"):
1098
+ name = str(ev.get("name") or "")
1099
+ if name and name not in tools:
1100
+ tools.append(name)
1101
+ if tools:
1102
+ bits.append("Tools already run successfully: " + ", ".join(tools[:12]))
1103
+ if not bits:
1104
+ return ""
1105
+ return (f"{_PIN_PREFIX} Carried over from the previous run, which reached "
1106
+ f"its limit part-way through.\n" + "\n".join(bits)
1107
+ + "\nPick up from here. Do not repeat finished work, and do not "
1108
+ "ask the user for anything they have already given you.")
1109
+
1110
+
1111
+ # A handover waits here for the owner's next run. In-process and short-lived
1112
+ # on purpose: this is continuity across one interrupted piece of work, not a
1113
+ # memory feature. Keyed by owner, cleared the moment it is used.
1114
+ _HANDOVER_TTL_SECONDS = 30 * 60
1115
+ _HANDOVERS_MAX = 200
1116
+ _HANDOVERS: Dict[str, Dict[str, Any]] = {}
1117
+
1118
+
1119
+ def _record_handover(run: Run, note: str) -> None:
1120
+ if run.anonymous or not run.owner or not note:
1121
+ return
1122
+ with _RUNS_LOCK:
1123
+ cutoff = time.time() - _HANDOVER_TTL_SECONDS
1124
+ for k in [k for k, v in _HANDOVERS.items() if v.get("at", 0) < cutoff]:
1125
+ _HANDOVERS.pop(k, None)
1126
+ if len(_HANDOVERS) >= _HANDOVERS_MAX and run.owner not in _HANDOVERS:
1127
+ return
1128
+ _HANDOVERS[run.owner] = {
1129
+ "run_id": run.run_id,
1130
+ "note": note,
1131
+ "goal": run.goal,
1132
+ "target": dict(run.target),
1133
+ "plan": [dict(p) for p in run.plan],
1134
+ "at": time.time(),
1135
+ }
1136
+
1137
+
1138
+ def take_handover(owner: str, anonymous: bool = False) -> Optional[Dict[str, Any]]:
1139
+ """The pending handover for this owner, consumed. None if there isn't one.
1140
+
1141
+ Consumed rather than read so it can only ever seed ONE run — a handover
1142
+ that kept re-applying would silently drag an old goal into unrelated work,
1143
+ which is the very failure the target binding above exists to stop.
1144
+ """
1145
+ if anonymous or not owner:
1146
+ return None
1147
+ with _RUNS_LOCK:
1148
+ item = _HANDOVERS.pop(owner, None)
1149
+ if not item or item.get("at", 0) < time.time() - _HANDOVER_TTL_SECONDS:
1150
+ return None
1151
+ return item
1152
+
1153
+
1154
+ def _hand_over(run: Run, reason: str, why: str) -> None:
1155
+ """End a run that cannot continue, leaving the work recoverable.
1156
+
1157
+ Deliberately NOT an `error` event: a budget ceiling is an expected end to
1158
+ a run, not a fault, and painting a red block over work that largely
1159
+ succeeded is what made the reviewer ask whether something had broken.
1160
+ """
1161
+ note = handover_note(run)
1162
+ left = [p["step"] for p in run.plan if p["status"] in ("pending", "active")]
1163
+ _record_handover(run, note)
1164
+ run.status = "done"
1165
+ run.pending_tool_call_id = None
1166
+ tail = ("" if not left else
1167
+ "\n\nStill to do:\n" + "\n".join("• " + s for s in left[:6]))
1168
+ _emit(run, "text", {"text": (
1169
+ why + " Send your next message and I'll start a fresh run carrying "
1170
+ "this one's target, plan and progress over." + tail)})
1171
+ _emit(run, "checkpoint", {
1172
+ "remaining": left,
1173
+ "steps_used": run.steps,
1174
+ "can_continue": False,
1175
+ "reason": reason,
1176
+ "carry_over": bool(note),
1177
+ })
1178
+ _emit(run, "done", {"reason": reason, "carry_over": bool(note)})
1179
+
1180
+
1181
+ def _auto_extend(run: Run, config: _llm.OpenRouterConfig) -> bool:
1182
+ """Top the step budget up without stopping to ask. True if granted.
1183
+
1184
+ Only when there is genuinely nothing for the user to decide:
1185
+ • extensions left in the automatic allowance, AND
1186
+ • real work landed during the stretch just finished — update_plan and
1187
+ ask_user change nothing in the world, so a stretch of only those is a
1188
+ loop, and quietly funding it is the runaway the cap exists to stop;
1189
+ • AND the money is not the binding constraint. Once spend is the reason
1190
+ we would stop, the user is the one who should decide, not us.
1191
+ """
1192
+ if run.extensions >= min(AUTO_EXTENSIONS, MAX_EXTENSIONS):
1193
+ return False
1194
+ if run.tools_run <= run.tools_at_checkpoint:
1195
+ return False
1196
+ if _cost_fraction(run, config) >= COST_WARN_FRACTION:
1197
+ return False
1198
+ run.extensions += 1
1199
+ run.tools_at_checkpoint = run.tools_run
1200
+ # Recorded, not rendered. The transcript and the export show it; the rail
1201
+ # does not, because "I gave myself twelve more steps" is not news to a
1202
+ # user watching tool calls land.
1203
+ _emit(run, "extended", {
1204
+ "steps_used": run.steps,
1205
+ "steps_limit": MAX_STEPS + run.extensions * STEP_EXTENSION,
1206
+ "extensions": run.extensions,
1207
+ })
1208
+ return True
1209
+
1210
+
1211
+ def _checkpoint(run: Run, reason: str = "steps") -> None:
1212
+ """Pause and hand the decision back — report where we got to.
1213
 
1214
  The old behaviour said "I've taken this as far as one run goes", marked the
1215
  run done, and left the user to guess what had and hadn't happened. That is
1216
  an abandonment dressed as a completion. A checkpoint states what remains,
1217
  keeps the run resumable, and is honest about why it paused.
1218
+
1219
+ "Why" now has to be specific. "I've used this stretch of the run" describes
1220
+ nothing a user can act on — it names no unit, no amount and no cause — and
1221
+ the reviewer read the repeats of it as the agent stalling.
1222
  """
1223
  left = [p["step"] for p in run.plan if p["status"] in ("pending", "active")]
1224
+ run.tools_at_checkpoint = run.tools_run
1225
  if run.extensions >= MAX_EXTENSIONS:
1226
+ _hand_over(run, reason="max_steps", why=(
1227
+ f"I've reached this run's overall ceiling at {run.steps} steps."))
 
 
 
1228
  return
1229
 
1230
+ if reason == "cost":
1231
+ head = (f"I've used most of this run's compute allowance "
1232
+ f"(${run.cost_usd:.2f} of model time so far — a cap on the "
1233
+ f"run, not a limit on your account).")
1234
+ else:
1235
+ head = (f"I've run {run.steps} steps on this — that's where I check in "
1236
+ f"rather than keep spending on my own read of the task.")
1237
+
1238
  if left:
1239
+ body = (head + " Still to do:\n"
1240
  + "\n".join("• " + s for s in left[:6])
1241
+ + f"\n\nSay **continue** for another {STEP_EXTENSION} steps, "
1242
+ f"or redirect me.")
1243
  else:
1244
+ body = (head + " I haven't closed the task out. Say **continue** for "
1245
+ f"another {STEP_EXTENSION} steps, or tell me what to focus on.")
 
1246
  run.status = "awaiting_input"
1247
  run.pending_tool_call_id = None
1248
  _emit(run, "text", {"text": body})
 
1250
  "remaining": left,
1251
  "steps_used": run.steps,
1252
  "can_continue": True,
1253
+ "reason": reason,
1254
  })
1255
 
1256
 
 
1269
  "error_kind": "agent_unconfigured",
1270
  })
1271
  return
1272
+ run.cost_limit_usd = float(config.max_cost_usd or 0.0)
1273
  run.status = "running"
1274
  run.stop_requested = False
1275
  t = threading.Thread(target=_drive, args=(run, config), daemon=True)
 
1341
  run.workspace = clean
1342
 
1343
 
1344
+ def _apply_handover(run: Run, item: Dict[str, Any], source_run_id: str,
1345
+ inherit: bool) -> None:
1346
+ """Seed a fresh run from an interrupted one's brief.
1347
+
1348
+ ``inherit`` distinguishes the two ways this happens, and the distinction
1349
+ matters more than it looks:
1350
+
1351
+ • asked for explicitly (carry_from) — the caller has said this new run
1352
+ IS the old one continued, so it adopts the goal, the bound target and
1353
+ the plan outright;
1354
+ • picked up automatically after a budget stop — the brief goes into
1355
+ history as CONTEXT the model can read and disregard, and nothing is
1356
+ bound. A user whose next message is about something else entirely
1357
+ must not find their new run silently asserting an old target. That
1358
+ would be the cross-conversation bleed again, rebuilt from the other
1359
+ side.
1360
+ """
1361
+ note = str(item.get("note") or "")
1362
+ if not note:
1363
+ return
1364
+ if inherit:
1365
+ if not run.goal and item.get("goal"):
1366
+ run.goal = str(item["goal"])[:400]
1367
+ if not run.target and isinstance(item.get("target"), dict):
1368
+ run.target = dict(item["target"])
1369
+ if not run.plan and isinstance(item.get("plan"), list):
1370
+ run.plan = [dict(p) for p in item["plan"] if isinstance(p, dict)]
1371
+ run.carried_from = source_run_id
1372
+ run.history.append({"role": "user", "content": note})
1373
+ # Said out loud. Silently inheriting a previous run's goal is the same
1374
+ # class of failure as the cross-conversation bleed — context the user
1375
+ # cannot see and cannot correct.
1376
+ _emit(run, "text", {"text": (
1377
+ "Picking up from your last run — same target and plan, fresh budget.")})
1378
+ if run.plan:
1379
+ _emit(run, "plan", {"steps": list(run.plan)})
1380
+
1381
+
1382
+ def start(run: Run, message: str, workspace: Optional[Dict[str, Any]] = None,
1383
+ carry_from: Optional[Run] = None) -> None:
1384
+ """Begin (or continue) a run with a user message.
1385
+
1386
+ ``carry_from`` seeds this run from one that ran out of budget mid-task, so
1387
+ "start a new one to continue" stops being advice the product can't act on.
1388
+ Ownership is enforced here rather than by the caller: the brief lands in
1389
+ the model's context verbatim, and one run reading another owner's goal
1390
+ would be a cross-account leak, not merely a bug.
1391
+ """
1392
  set_workspace(run, workspace)
1393
+ if carry_from is not None:
1394
+ if not run.owner or carry_from.owner != run.owner or carry_from.anonymous:
1395
+ raise ValueError("carry_from must be a run the same owner started")
1396
+ _apply_handover(run, {
1397
+ "note": handover_note(carry_from),
1398
+ "goal": carry_from.goal,
1399
+ "target": dict(carry_from.target),
1400
+ "plan": [dict(p) for p in carry_from.plan],
1401
+ }, carry_from.run_id, inherit=True)
1402
+ elif not run.history:
1403
+ # Nothing explicit — but if this owner's last run ended on a limit
1404
+ # with work outstanding, this IS the "start a new one" they were told
1405
+ # to do. Consumed, so it can seed exactly one run.
1406
+ pending = take_handover(run.owner, run.anonymous)
1407
+ if pending:
1408
+ _apply_handover(run, pending, str(pending.get("run_id") or ""),
1409
+ inherit=False)
1410
  # The opening ask names the run in the history list. Set once, so a long
1411
  # conversation keeps the title it started with rather than renaming
1412
  # itself to whatever was asked last.
 
1927
  lines.append(bit)
1928
 
1929
  return (
1930
+ # The heading used to read "WHAT THEY WERE ALREADY WORKING ON", which
1931
+ # is ambiguous in the one way that matters: it does not say these came
1932
+ # from OTHER conversations. Combined with "do NOT propose starting
1933
+ # from scratch", it read as a standing instruction to work on them —
1934
+ # and on 2026-07-30 a run about beta-lactamase TEM-1 started planning
1935
+ # against IsPETase, a library from a different run. Naming the scope
1936
+ # is half the fix; _target_note is the other half.
1937
+ "\n\nWORK FROM OTHER CONVERSATIONS (background — NOT this run's "
1938
+ "subject)\n"
1939
+ "This user has saved work from earlier, separate conversations. It is "
1940
+ "here so you don't re-introduce yourself or propose building something "
1941
+ "they already have — it is NOT what this conversation is about:\n"
1942
  + "\n".join(lines)
1943
  + "\nUse list_my_work for ids and detail. If a library has results "
1944
  "logged, propose_round2 is the natural next step; if it has none and "
1945
+ "they mention bench numbers, log_outcome is. Never treat one of these "
1946
+ "as the target of the current run unless the user names it here.\n"
1947
  )
1948
 
1949
 
1950
+ def _target_note(target: Dict[str, Any]) -> str:
1951
+ """State, every step, what this conversation is about.
1952
+
1953
+ Placed LAST in the prompt on purpose: it is the most proximate standing
1954
+ instruction, and it has to outrank the saved-work list above it. Only
1955
+ values a tool actually returned appear here.
1956
+ """
1957
+ if not target:
1958
+ return ""
1959
+ label = (target.get("label") or target.get("gene_symbol")
1960
+ or "the target resolved in this conversation")
1961
+ lines = [f"\n\nTHE TARGET OF THIS RUN — BOUND TO THIS CONVERSATION\n"
1962
+ f"Everything in this run is about: {label}"]
1963
+ if target.get("gene_symbol"):
1964
+ lines.append(f" · gene / name: {target['gene_symbol']}")
1965
+ if target.get("organism"):
1966
+ lines.append(f" · organism: {target['organism']}")
1967
+ if target.get("uniprot"):
1968
+ lines.append(f" · UniProt: {target['uniprot']}")
1969
+ if target.get("sequence"):
1970
+ n = int(target.get("length") or len(str(target["sequence"])))
1971
+ lines.append(
1972
+ f" · sequence: {n:,} nt, ALREADY RESOLVED in this conversation "
1973
+ f"(source: {target.get('source') or 'fetch_sequence'})")
1974
+ lines.append(
1975
+ "You already hold that sequence — feed it straight from the "
1976
+ "fetch_sequence result into the next tool. DO NOT ASK THE USER to "
1977
+ "paste or re-send it. They gave it to you; asking again is the "
1978
+ "single thing that most makes this feel broken. If the transcript "
1979
+ "has been condensed and you genuinely no longer have the letters, "
1980
+ "call fetch_sequence yourself rather than asking them.")
1981
+ else:
1982
+ lines.append(
1983
+ "No sequence has been resolved for it yet — call fetch_sequence "
1984
+ "before any tool that needs one.")
1985
+ lines.append(
1986
+ "Anything listed under WORK FROM OTHER CONVERSATIONS belongs to a "
1987
+ "different conversation. It is not this one. Never plan, fold, score "
1988
+ "or design against it, and never talk about it as if it were what you "
1989
+ "are working on now. If you believe the user has changed target, say "
1990
+ "so and resolve the new one with fetch_sequence — never switch "
1991
+ "silently.")
1992
+ return "\n".join(lines)
1993
+
1994
+
1995
  def build_system_prompt(anonymous: bool, workspace: Optional[Dict[str, Any]] = None,
1996
+ continuity: str = "",
1997
+ target: Optional[Dict[str, Any]] = None) -> str:
1998
  return (SYSTEM_PROMPT
1999
  + (_ANON_NOTE if anonymous else "")
2000
  + (continuity or "")
2001
+ + _workspace_note(workspace or {})
2002
+ + _target_note(target or {}))
dee/core/resolve.py CHANGED
@@ -122,6 +122,69 @@ def _fetch_refseq(accession: str) -> str:
122
  return _exon._fasta_to_seq(fasta) if fasta else ""
123
 
124
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
125
  def resolve_uniprot(organism: str, gene_symbol: str) -> Dict:
126
  """Resolve a gene symbol (+ organism) to a UniProt accession and the
127
  AlphaFold-DB structure URLs, for the structure viewer (Phase 3, M5).
@@ -129,6 +192,25 @@ def resolve_uniprot(organism: str, gene_symbol: str) -> Dict:
129
  Returns {ok, uniprot, alphafold_url, alphafold_page, error?}. Only the
130
  (organism, gene_symbol) leaves the Space. Works for any organism —
131
  AlphaFold DB covers all of UniProt.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
132
  """
133
  if not gene_symbol:
134
  return {"ok": False, "error": "No gene symbol to look up."}
@@ -155,17 +237,62 @@ def resolve_uniprot(organism: str, gene_symbol: str) -> Dict:
155
  f"{_up.normalize_organism(organism) or 'that organism'}. "
156
  f"Check the gene name, or give the organism explicitly.")}
157
  acc = hit.accession
158
- return {
159
  "matched_organism": hit.organism,
160
  "reviewed": hit.reviewed,
161
  "ok": True,
162
  "uniprot": acc,
 
 
 
 
 
163
  # Fallback URL only — the client re-resolves the current model version
164
  # via the AlphaFold prediction API (the DB bumps versions: v4→v6→…).
165
  "alphafold_url": f"https://alphafold.ebi.ac.uk/files/AF-{acc}-F1-model_v6.pdb",
166
  "alphafold_page": f"https://alphafold.ebi.ac.uk/entry/{acc}",
 
167
  }
168
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
169
 
170
  def _resolve_via_uniprot(symbol: str, organism: str) -> Dict:
171
  """Gene symbol → CDS for organisms Ensembl's main REST API doesn't serve.
 
122
  return _exon._fasta_to_seq(fasta) if fasta else ""
123
 
124
 
125
+ # AlphaFold DB's own prediction index. Asking it whether a model exists is
126
+ # the only honest way to answer that question — the file URL below is
127
+ # CONSTRUCTED, and a constructed URL is a claim, not a fact.
128
+ #
129
+ # Two things verified live on 2026-08-01:
130
+ # • the DB now serves model_v6 only. AF-P62593-F1-model_v4.pdb is a 404 and
131
+ # ...model_v6.pdb is a 200, so a stale version number in a hand-built URL
132
+ # reads as "no structure" for a protein that has one. Do not conclude
133
+ # "not found" from an old version number.
134
+ # • a well-formed accession with no entry (A0A0A0AAAA) answers 400, not 200
135
+ # with an empty list — so "no model" and "the service is down" have to be
136
+ # told apart at the transport, exactly as UniProtUnavailable is.
137
+ ALPHAFOLD_API = "https://alphafold.ebi.ac.uk/api/prediction/"
138
+
139
+
140
+ def alphafold_model(accession: str):
141
+ """Does AlphaFold DB actually hold a model for this accession?
142
+
143
+ Returns ("ok", {...}) | ("absent", None) | ("unavailable", None).
144
+
145
+ "unavailable" is NOT "absent" (docs/ENGINEERING.md §9): telling a
146
+ scientist their protein has no predicted structure because an EBI endpoint
147
+ blinked is a false negative that makes a working viewer look empty.
148
+ """
149
+ import json as _json
150
+ from dee.core import uniprot as _up
151
+
152
+ acc = re.sub(r"[^A-Za-z0-9]", "", str(accession or ""))[:16]
153
+ if not acc:
154
+ return ("absent", None)
155
+ try:
156
+ raw = _up._get(ALPHAFOLD_API + acc)
157
+ except _up.UniProtUnavailable:
158
+ return ("unavailable", None)
159
+ if not raw:
160
+ return ("absent", None) # 400/404 — a real "nothing there"
161
+ try:
162
+ payload = _json.loads(raw.decode("utf-8"))
163
+ except (ValueError, AttributeError):
164
+ return ("unavailable", None) # a 200 that isn't JSON is a bad day
165
+ entries = payload if isinstance(payload, list) else [payload]
166
+ entry = next((e for e in entries if isinstance(e, dict)), None)
167
+ if not entry:
168
+ return ("absent", None)
169
+ # Take the URLs the API itself reports rather than rebuilding them, so a
170
+ # future version bump (v6 → v7) cannot silently start 404ing.
171
+ pdb_url = str(entry.get("pdbUrl") or "")
172
+ if not pdb_url and not entry.get("cifUrl"):
173
+ # A 200 carrying no model file is not a confirmation. Treat it as "we
174
+ # could not check" rather than as either a yes or a no — claiming
175
+ # "verified" off a response that names no file is exactly the kind of
176
+ # confident-and-wrong this guard exists to stop.
177
+ return ("unavailable", None)
178
+ return ("ok", {
179
+ "pdb_url": pdb_url,
180
+ "cif_url": str(entry.get("cifUrl") or ""),
181
+ # AlphaFold's own mean pLDDT for this model. Reported, never derived —
182
+ # this number reaches a scientist deciding whether to trust a fold.
183
+ "plddt": entry.get("globalMetricValue"),
184
+ "version": entry.get("latestVersion"),
185
+ })
186
+
187
+
188
  def resolve_uniprot(organism: str, gene_symbol: str) -> Dict:
189
  """Resolve a gene symbol (+ organism) to a UniProt accession and the
190
  AlphaFold-DB structure URLs, for the structure viewer (Phase 3, M5).
 
192
  Returns {ok, uniprot, alphafold_url, alphafold_page, error?}. Only the
193
  (organism, gene_symbol) leaves the Space. Works for any organism —
194
  AlphaFold DB covers all of UniProt.
195
+
196
+ THE TEM-1 INCIDENT (reviewer log, 2026-07-30). A reviewer pasted
197
+ beta-lactamase TEM-1 and the bench showed I6ZGA9 as the model's accession.
198
+ Verified against the live APIs on 2026-08-01:
199
+
200
+ • I6ZGA9 exists — UniProtKB unreviewed (TrEMBL), I6ZGA9_ECOLX, E. coli,
201
+ "Beta-lactamase", flagged **Fragment**, 264 aa, and AlphaFold really
202
+ does hold AF-I6ZGA9-F1 at 95.25 pLDDT. So the 3-D view was showing a
203
+ genuine structure, and "I6ZGA9 is not a recognised UniProt
204
+ identifier" is not correct.
205
+ • But it was the WRONG entry. The reviewer's sequence is 286 aa and is
206
+ P62593 (BLAT_ECOLX, Swiss-Prot, "Beta-lactamase TEM", alt name
207
+ TEM-1). I6ZGA9's sequence begins 11 residues in — it is a partial
208
+ entry — so every residue number in that viewer was offset by 11, in a
209
+ product whose entire job is telling someone which residue to mutate.
210
+
211
+ Two fixes: uniprot.find_gene now searches the protein-name field too and
212
+ prefers a reviewed entry on either field (which lands TEM-1 on P62593),
213
+ and this function no longer asserts a structure it has not confirmed.
214
  """
215
  if not gene_symbol:
216
  return {"ok": False, "error": "No gene symbol to look up."}
 
237
  f"{_up.normalize_organism(organism) or 'that organism'}. "
238
  f"Check the gene name, or give the organism explicitly.")}
239
  acc = hit.accession
240
+ out = {
241
  "matched_organism": hit.organism,
242
  "reviewed": hit.reviewed,
243
  "ok": True,
244
  "uniprot": acc,
245
+ "entry_name": hit.entry_name,
246
+ "protein_name": hit.protein_name,
247
+ "protein_length": len(hit.protein),
248
+ "fragment": hit.fragment,
249
+ "matched_by": hit.matched_by,
250
  # Fallback URL only — the client re-resolves the current model version
251
  # via the AlphaFold prediction API (the DB bumps versions: v4→v6→…).
252
  "alphafold_url": f"https://alphafold.ebi.ac.uk/files/AF-{acc}-F1-model_v6.pdb",
253
  "alphafold_page": f"https://alphafold.ebi.ac.uk/entry/{acc}",
254
+ "structure_verified": False,
255
  }
256
 
257
+ # Caveats the caller must be able to pass on. A partial entry's residue
258
+ # numbering does not line up with the user's sequence, and that is the
259
+ # difference between "mutate residue 104" and mutating the wrong residue.
260
+ caveats = []
261
+ if hit.fragment:
262
+ caveats.append(
263
+ f"{acc} is a PARTIAL (fragment) UniProt entry of "
264
+ f"{len(hit.protein)} aa — residue numbering in this model will "
265
+ f"not line up with a full-length sequence.")
266
+ if not hit.reviewed:
267
+ caveats.append(
268
+ f"{acc} is an unreviewed (TrEMBL) entry — computationally "
269
+ f"annotated, not curated.")
270
+ if caveats:
271
+ out["caveats"] = caveats
272
+
273
+ # A CONSTRUCTED file URL is a claim, not a fact. Confirm the model exists
274
+ # before anyone paints it as this protein's structure.
275
+ status, model = alphafold_model(acc)
276
+ if status == "absent":
277
+ return {"ok": False, "kind": "no_structure", "uniprot": acc,
278
+ "error": (f"Matched {gene_symbol} to UniProt {acc} "
279
+ f"({hit.entry_name or 'no entry name'}), but "
280
+ f"AlphaFold DB has no predicted model for it, so "
281
+ f"there is no structure to show.")}
282
+ if status == "ok" and model:
283
+ if model.get("pdb_url"):
284
+ out["alphafold_url"] = model["pdb_url"]
285
+ out["structure_verified"] = True
286
+ # AlphaFold's own numbers, reported verbatim.
287
+ if model.get("plddt") is not None:
288
+ out["plddt"] = model["plddt"]
289
+ if model.get("version") is not None:
290
+ out["model_version"] = model["version"]
291
+ # status == "unavailable": EBI didn't answer. Keep the constructed URL and
292
+ # leave structure_verified False rather than declaring a real protein
293
+ # unmodelled because an endpoint blinked (§9, transient ≠ absent).
294
+ return out
295
+
296
 
297
  def _resolve_via_uniprot(symbol: str, organism: str) -> Dict:
298
  """Gene symbol → CDS for organisms Ensembl's main REST API doesn't serve.
dee/core/uniprot.py CHANGED
@@ -109,6 +109,15 @@ class GeneHit:
109
  protein: str # amino-acid sequence
110
  reviewed: bool # SwissProt (True) vs TrEMBL (False)
111
  embl_cds: tuple # EMBL protein_ids that carry the CDS
 
 
 
 
 
 
 
 
 
112
 
113
 
114
  # Organism names are letters, spaces, dots, hyphens and the odd apostrophe
@@ -121,6 +130,11 @@ _ORG_SAFE = re.compile(r"[^A-Za-z0-9 .'\-]")
121
  _ORG_MAX = 64
122
  # Same reasoning for the gene symbol, which is interpolated as gene:{…}.
123
  _GENE_SAFE = re.compile(r"[^A-Za-z0-9._\-]")
 
 
 
 
 
124
 
125
 
126
  def normalize_organism(organism: str) -> str:
@@ -167,22 +181,60 @@ def _search(query: str) -> Optional[dict]:
167
 
168
 
169
  def find_gene(gene_symbol: str, organism: str) -> Optional[GeneHit]:
170
- """Resolve (gene, organism) to a UniProt entry. None if genuinely absent.
171
 
172
- Prefers a REVIEWED (SwissProt) entry and only falls back to unreviewed if
173
- there is no reviewed one — with `reviewed=False` on the result so callers
174
- can say so rather than passing off a computationally-annotated entry as
175
- curated.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
176
  """
177
  gene = _GENE_SAFE.sub("", str(gene_symbol or "").strip())[:32]
 
178
  org = normalize_organism(organism)
179
  if not gene or not org:
180
  return None
181
 
 
 
 
 
 
 
 
 
 
 
 
 
182
  hit = None
183
- for reviewed in (True, False):
184
- q = (f'gene:{gene} AND organism_name:"{org}"'
185
- + (" AND reviewed:true" if reviewed else ""))
 
 
186
  hit = _search(q)
187
  if hit:
188
  break
@@ -199,10 +251,17 @@ def find_gene(gene_symbol: str, organism: str) -> Optional[GeneHit]:
199
  embl.append(prop["value"])
200
 
201
  protein_name = ""
 
202
  try:
203
- protein_name = (((hit.get("proteinDescription") or {})
204
- .get("recommendedName") or {})
205
  .get("fullName") or {}).get("value") or ""
 
 
 
 
 
 
206
  except (AttributeError, TypeError):
207
  pass
208
 
@@ -214,6 +273,8 @@ def find_gene(gene_symbol: str, organism: str) -> Optional[GeneHit]:
214
  protein=seq,
215
  reviewed=bool(hit.get("entryType", "").startswith("UniProtKB reviewed")),
216
  embl_cds=tuple(embl[:4]),
 
 
217
  )
218
 
219
 
 
109
  protein: str # amino-acid sequence
110
  reviewed: bool # SwissProt (True) vs TrEMBL (False)
111
  embl_cds: tuple # EMBL protein_ids that carry the CDS
112
+ # UniProt's own "Fragment" / "Fragments" flag. A fragment entry is a
113
+ # PARTIAL protein, so its residue numbering is offset from the full
114
+ # sequence — which makes "the clash is at position 104" point at the wrong
115
+ # residue. Carried out so callers can say so instead of passing a partial
116
+ # entry off as the whole protein. See the TEM-1 incident in resolve.py.
117
+ fragment: bool = False
118
+ # Which query found it: "gene" or "protein_name". Diagnostic, and the
119
+ # thing that made the TEM-1 mis-resolution legible.
120
+ matched_by: str = "gene"
121
 
122
 
123
  # Organism names are letters, spaces, dots, hyphens and the odd apostrophe
 
130
  _ORG_MAX = 64
131
  # Same reasoning for the gene symbol, which is interpolated as gene:{…}.
132
  _GENE_SAFE = re.compile(r"[^A-Za-z0-9._\-]")
133
+ # And for the protein-name term, interpolated as protein_name:"…". The double
134
+ # quote is the character that matters: leaving one in would close the quoted
135
+ # term early and change what the query means.
136
+ _NAME_SAFE = re.compile(r"[^A-Za-z0-9 ._\-]")
137
+ _NAME_MAX = 48
138
 
139
 
140
  def normalize_organism(organism: str) -> str:
 
181
 
182
 
183
  def find_gene(gene_symbol: str, organism: str) -> Optional[GeneHit]:
184
+ """Resolve (gene or protein name, organism) to a UniProt entry.
185
 
186
+ None only if genuinely absent. REVIEWED (SwissProt) beats unreviewed, and
187
+ unreviewed comes back with `reviewed=False` so callers can say so rather
188
+ than passing a computationally-annotated entry off as curated.
189
+
190
+ WHY THE PROTEIN-NAME QUERY EXISTS (incident, reviewer log 2026-07-30).
191
+ Scientists name proteins, not gene loci. Asked for beta-lactamase
192
+ "TEM-1" in E. coli this searched `gene:TEM-1` only, and:
193
+
194
+ gene:TEM-1 … reviewed:true → nothing
195
+ gene:TEM-1 → I6ZGA9, TrEMBL, 264 aa, FRAGMENT
196
+
197
+ ...so the structure viewer was handed I6ZGA9 — verified live: a real
198
+ accession (the reviewer's "not a recognised UniProt identifier" is wrong),
199
+ with a real AlphaFold model at 95.25 pLDDT, but a PARTIAL entry whose
200
+ sequence starts 11 residues into the protein the user had pasted. Every
201
+ residue number in the 3-D view was therefore off by 11.
202
+
203
+ The canonical entry was there the whole time. TEM-1 is not a gene name on
204
+ it — the gene is `bla`; "TEM-1" is a protein ALTERNATIVE name:
205
+
206
+ protein_name:TEM-1 … reviewed:true → P62593 BLAT_ECOLX, Swiss-Prot,
207
+ 286 aa, "Beta-lactamase TEM"
208
+
209
+ 286 aa is exactly what the reviewer pasted. So: try both fields, and take
210
+ a REVIEWED match on either before an unreviewed match on the first. This
211
+ adds no allow-list — it removes a way of missing entries that were always
212
+ there.
213
  """
214
  gene = _GENE_SAFE.sub("", str(gene_symbol or "").strip())[:32]
215
+ name = _NAME_SAFE.sub(" ", str(gene_symbol or "").strip())[:_NAME_MAX].strip()
216
  org = normalize_organism(organism)
217
  if not gene or not org:
218
  return None
219
 
220
+ # Reviewed-first ACROSS both fields. Getting this order wrong is the whole
221
+ # bug: an unreviewed gene-name match used to beat a curated protein-name
222
+ # match that named the same protein.
223
+ strategies = [
224
+ ("gene", f'gene:{gene} AND organism_name:"{org}" AND reviewed:true'),
225
+ ("protein_name", f'protein_name:"{name}" AND organism_name:"{org}" '
226
+ f'AND reviewed:true') if name else None,
227
+ ("gene", f'gene:{gene} AND organism_name:"{org}"'),
228
+ ("protein_name", f'protein_name:"{name}" AND organism_name:"{org}"')
229
+ if name else None,
230
+ ]
231
+
232
  hit = None
233
+ matched_by = "gene"
234
+ for entry in strategies:
235
+ if entry is None:
236
+ continue
237
+ matched_by, q = entry
238
  hit = _search(q)
239
  if hit:
240
  break
 
251
  embl.append(prop["value"])
252
 
253
  protein_name = ""
254
+ fragment = False
255
  try:
256
+ description = hit.get("proteinDescription") or {}
257
+ protein_name = ((description.get("recommendedName") or {})
258
  .get("fullName") or {}).get("value") or ""
259
+ # "Fragment" / "Fragments". UniProt also uses this slot for
260
+ # "Precursor", which is NOT a fragment — a precursor is the complete
261
+ # translated chain including its signal peptide, and P62593 (the
262
+ # correct TEM-1) is flagged exactly that. Matching on the prefix
263
+ # rather than "is the flag set" is the difference.
264
+ fragment = str(description.get("flag") or "").lower().startswith("fragment")
265
  except (AttributeError, TypeError):
266
  pass
267
 
 
273
  protein=seq,
274
  reviewed=bool(hit.get("entryType", "").startswith("UniProtKB reviewed")),
275
  embl_cds=tuple(embl[:4]),
276
+ fragment=fragment,
277
+ matched_by=matched_by,
278
  )
279
 
280
 
dee/optimizer/search.py CHANGED
@@ -78,6 +78,28 @@ class SearchConfig:
78
  duplicate_position_penalty: float = 1e3 # Per repeated residue position.
79
  seed: Optional[int] = None
80
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
81
 
82
  @dataclass
83
  class Variant:
@@ -206,6 +228,54 @@ def _simulated_anneal(
206
  temperature *= cooling
207
 
208
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
209
  def evolve(pool_df: pd.DataFrame, cfg: Optional[SearchConfig] = None) -> List[Variant]:
210
  """Run the SA search and return the top-K unique multi-mutants.
211
 
@@ -242,14 +312,22 @@ def evolve(pool_df: pd.DataFrame, cfg: Optional[SearchConfig] = None) -> List[Va
242
  len(hall_of_fame),
243
  )
244
 
245
- ranked = sorted(hall_of_fame.values(), key=lambda v: v.fitness, reverse=True)[: cfg.k]
 
 
 
 
246
  for i, v in enumerate(ranked):
247
  v.rank = i + 1
248
 
249
  logger.info(
250
- "Search done. Returning top %d / %d unique variants. Best fitness=%.3f.",
 
 
251
  len(ranked),
252
  len(hall_of_fame),
 
 
253
  ranked[0].fitness if ranked else float("nan"),
254
  )
255
  return ranked
 
78
  duplicate_position_penalty: float = 1e3 # Per repeated residue position.
79
  seed: Optional[int] = None
80
 
81
+ # ─── Cross-variant library diversity ────────────────────────────
82
+ # duplicate_position_penalty is a WITHIN-variant rule: it stops one
83
+ # variant mutating the same residue twice. It says nothing about the
84
+ # returned K as a set — and because fitness is an ADDITIVE sum of ΔLL,
85
+ # the top-K of that sum are near-copies of each other: whatever the
86
+ # best few single mutations are, they are in almost every variant.
87
+ #
88
+ # Measured on TEM-1 beta-lactamase (BLAT_ECOLX, the DMS fixture in
89
+ # dee/data/dms_fixtures, ESM-2 35M, top-15% pool, k=10, 8 restarts x
90
+ # 1200 steps, seed 7): H87L, H1M and M157T each occupied 10 of the 10
91
+ # returned variants, and the whole library spanned 8 distinct
92
+ # substitutions. That is the library a reviewer got — one substitution
93
+ # in nine of ten rows — and it is bad experimental design, not just
94
+ # dull: if every clone carries H87L you cannot attribute any measured
95
+ # effect to it, and the round-2 surrogate learns nothing about it
96
+ # either, because there is no contrast to learn from.
97
+ #
98
+ # So cap occupancy when the hall of fame is assembled into the
99
+ # returned library. Set either to >= 1.0 to switch that cap off.
100
+ max_substitution_share: float = 0.5 # one (position, aa) in at most half the library
101
+ max_position_share: float = 0.7 # one residue position in at most this share
102
+
103
 
104
  @dataclass
105
  class Variant:
 
228
  temperature *= cooling
229
 
230
 
231
+ def _select_diverse(ranked: List[Variant], cfg: SearchConfig) -> List[Variant]:
232
+ """Assemble the returned library from the fitness-ordered hall of fame,
233
+ under per-substitution and per-residue occupancy quotas.
234
+
235
+ Greedy in fitness order, so the single best variant is always kept — the
236
+ cap changes which *near-duplicates* of it come along, not what wins. A
237
+ variant that would push any of its substitutions over quota is deferred,
238
+ not discarded: if the quotas can't fill K (a small hall of fame, or a
239
+ pool spanning few positions), the shortfall is backfilled from the
240
+ deferred list in fitness order, so this can never return fewer variants
241
+ than the plain top-K did.
242
+ """
243
+ k = cfg.k
244
+ if k <= 0 or not ranked:
245
+ return []
246
+ # ceil, and never below 1 — a quota of 0 would reject everything,
247
+ # including the best variant.
248
+ sub_quota = max(1, int(math.ceil(cfg.max_substitution_share * k)))
249
+ pos_quota = max(1, int(math.ceil(cfg.max_position_share * k)))
250
+
251
+ chosen: List[Variant] = []
252
+ deferred: List[Variant] = []
253
+ sub_used: Dict[Tuple[int, str], int] = {}
254
+ pos_used: Dict[int, int] = {}
255
+
256
+ for v in ranked:
257
+ if len(chosen) >= k:
258
+ break
259
+ fits = all(
260
+ sub_used.get((m.position, m.mut_aa), 0) < sub_quota
261
+ and pos_used.get(m.position, 0) < pos_quota
262
+ for m in v.mutations
263
+ )
264
+ if fits:
265
+ chosen.append(v)
266
+ for m in v.mutations:
267
+ sub_used[(m.position, m.mut_aa)] = sub_used.get((m.position, m.mut_aa), 0) + 1
268
+ pos_used[m.position] = pos_used.get(m.position, 0) + 1
269
+ else:
270
+ deferred.append(v)
271
+
272
+ if len(chosen) < k:
273
+ chosen.extend(deferred[: k - len(chosen)])
274
+ # Rank is still fitness order — the cap decides membership, not order.
275
+ chosen.sort(key=lambda v: v.fitness, reverse=True)
276
+ return chosen
277
+
278
+
279
  def evolve(pool_df: pd.DataFrame, cfg: Optional[SearchConfig] = None) -> List[Variant]:
280
  """Run the SA search and return the top-K unique multi-mutants.
281
 
 
312
  len(hall_of_fame),
313
  )
314
 
315
+ # Sort the WHOLE hall of fame, then choose K from it. Truncating to K
316
+ # first would leave _select_diverse nothing to swap in — the near-copies
317
+ # of the best variant are exactly what fills the first K slots.
318
+ by_fitness = sorted(hall_of_fame.values(), key=lambda v: v.fitness, reverse=True)
319
+ ranked = _select_diverse(by_fitness, cfg)
320
  for i, v in enumerate(ranked):
321
  v.rank = i + 1
322
 
323
  logger.info(
324
+ "Search done. Returning %d / %d unique variants (diversity caps: "
325
+ "substitution <= %.0f%%, position <= %.0f%% of the library). "
326
+ "Best fitness=%.3f.",
327
  len(ranked),
328
  len(hall_of_fame),
329
+ cfg.max_substitution_share * 100,
330
+ cfg.max_position_share * 100,
331
  ranked[0].fitness if ranked else float("nan"),
332
  )
333
  return ranked
dee/server.py CHANGED
@@ -1311,7 +1311,20 @@ def create_app() -> Flask:
1311
  }), 400
1312
  auth = _auth.get_auth()
1313
  run = _orch.create_run(owner=(auth.user_id or ""), anonymous=auth.anonymous)
1314
- _orch.start(run, message, body.get("workspace"))
 
 
 
 
 
 
 
 
 
 
 
 
 
1315
  resp = jsonify(_orch.public_state(run))
1316
  return _auth.increment_anon_runs_on_response(resp)
1317
 
@@ -1378,6 +1391,21 @@ def create_app() -> Flask:
1378
  # sit in a client-side queue until the run finished, by which point
1379
  # the wrong work was already done and paid for. steer() hands it to
1380
  # the run loop, which picks it up at its next step boundary.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1381
  if run.status == "running":
1382
  if _orch.steer(run, message):
1383
  _orch.set_workspace(run, body.get("workspace"))
 
1311
  }), 400
1312
  auth = _auth.get_auth()
1313
  run = _orch.create_run(owner=(auth.user_id or ""), anonymous=auth.anonymous)
1314
+ # Continuing a run that ran out of budget mid-task. The old ceiling
1315
+ # said "start a new one to continue" and the new one started cold —
1316
+ # no target, no plan, no record of what had already happened — so the
1317
+ # advice was unfollowable and the work was simply lost. Passing the
1318
+ # source run's ID (never a client-supplied brief: that text lands in
1319
+ # the model's context) rebuilds the handover server-side, and
1320
+ # _orch.start enforces that the caller owns it.
1321
+ carry_id = str(body.get("carry_from") or "").strip()
1322
+ source = _orch.get_run(carry_id) if carry_id else None
1323
+ try:
1324
+ _orch.start(run, message, body.get("workspace"), carry_from=source)
1325
+ except ValueError:
1326
+ return jsonify({"error": "That run isn't yours to continue.",
1327
+ "kind": "carry_forbidden"}), 403
1328
  resp = jsonify(_orch.public_state(run))
1329
  return _auth.increment_anon_runs_on_response(resp)
1330
 
 
1391
  # sit in a client-side queue until the run finished, by which point
1392
  # the wrong work was already done and paid for. steer() hands it to
1393
  # the run loop, which picks it up at its next step boundary.
1394
+ # A run whose compute allowance is gone cannot answer this. Spawning
1395
+ # it again would spend one more model call to rediscover that and show
1396
+ # the user a second dead end. 410 is the shape the cockpit already
1397
+ # recovers from (it clears the run and re-sends as a fresh start), and
1398
+ # the server-side handover is what makes that fresh start land on the
1399
+ # work instead of a blank page. `carry_from` is returned so a client
1400
+ # that wants to be explicit about it can be.
1401
+ if run.budget_spent:
1402
+ return jsonify({
1403
+ "error": ("That run used its full compute allowance. Your next "
1404
+ "message starts a fresh run carrying its target, "
1405
+ "plan and progress over."),
1406
+ "kind": "run_budget_spent",
1407
+ "carry_from": run.run_id,
1408
+ }), 410
1409
  if run.status == "running":
1410
  if _orch.steer(run, message):
1411
  _orch.set_workspace(run, body.get("workspace"))
dee/static/app.css CHANGED
@@ -1941,22 +1941,49 @@ input:focus, textarea:focus, select:focus {
1941
  margin-bottom: 2px;
1942
  }
1943
  .mutmap-head .muted { margin: 0; font-size: 11px; }
1944
- .mutmap-legend {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1945
  display: flex;
1946
- align-items: center;
1947
- gap: 8px;
1948
- font-size: 11px;
 
 
1949
  color: var(--ink-faint);
 
1950
  }
1951
- .lg-dot {
1952
- width: 8px;
1953
- height: 8px;
1954
- border-radius: 50%;
1955
- display: inline-block;
 
1956
  }
1957
- .lg-mild { background: var(--brand-200); }
1958
- .lg-mid { background: var(--brand); }
1959
- .lg-strong { background: var(--brand-deep); }
1960
 
1961
  .mutmap-canvas {
1962
  background: var(--gray-0);
@@ -7794,6 +7821,27 @@ body[data-cockpit="open"]:not([data-bench="open"]) .cockpit { top: 0; }
7794
  display: flex; flex-direction: column; gap: 14px;
7795
  -webkit-overflow-scrolling: touch; /* iOS momentum (standing constraint) */
7796
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7797
  /* min-width:0 + break-word on every turn, not just the sequence card.
7798
 
7799
  The rail is ~390px and the agent is instructed to put sequences on their
@@ -8482,6 +8530,28 @@ body[data-cockpit="min"] .cp-head { cursor: pointer; }
8482
  straight through as a course correction — but it stays tagged, so reading a
8483
  run back later answers "why did it switch to mouse at this point?". */
8484
  .cp-user { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8485
  .cp-q-tag {
8486
  font-family: var(--font-mono); font-size: 9.5px; letter-spacing: 0.08em;
8487
  text-transform: uppercase; color: var(--ink-faint); flex: 0 0 auto;
@@ -8609,3 +8679,46 @@ body[data-cockpit="min"] .cp-head { cursor: pointer; }
8609
  them fail; the banner says to run the full tool for both. */
8610
  body.de-agent-run #downloadMenu,
8611
  body.de-agent-run .dna-edit-actions { display: none; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1941
  margin-bottom: 2px;
1942
  }
1943
  .mutmap-head .muted { margin: 0; font-size: 11px; }
1944
+ /* ── the colour key ──────────────────────────────────────────────────────
1945
+ Replaces the three .lg-dot chips (Low / Med / High) that used to sit in the
1946
+ head. Those were removed as scaffolding and nothing took over, so a signed
1947
+ continuous quantity shipped with no key at all for a month.
1948
+
1949
+ The gradient itself is an inline style written by renderMutationKey() from
1950
+ mutmapColor(), the same function that fills the dots — so there is exactly
1951
+ one definition of the scale and the key cannot drift from the chart. Only
1952
+ the geometry and the type live here. */
1953
+ .mutmap-key {
1954
+ margin-top: 12px;
1955
+ padding-top: 11px;
1956
+ border-top: 1px solid var(--line);
1957
+ }
1958
+ .mutmap-key:empty { display: none; } /* no run, no rule floating on its own */
1959
+ .mutmap-key-scale { max-width: 380px; }
1960
+ .mutmap-key-bar {
1961
+ display: block;
1962
+ height: 9px;
1963
+ border-radius: 2px;
1964
+ /* The ramp is near-iso-luminant so it reads on both canvases; a hairline
1965
+ keeps its ENDS from bleeding into a mid-tone background. */
1966
+ border: 1px solid var(--line-strong);
1967
+ }
1968
+ .mutmap-key-ticks {
1969
  display: flex;
1970
+ justify-content: space-between;
1971
+ margin-top: 3px;
1972
+ font-family: var(--font-mono);
1973
+ font-size: 9.5px;
1974
+ letter-spacing: 0.04em;
1975
  color: var(--ink-faint);
1976
+ font-variant-numeric: tabular-nums;
1977
  }
1978
+ .mutmap-key-note {
1979
+ margin: 8px 0 0;
1980
+ max-width: 68ch;
1981
+ font-size: 11px;
1982
+ line-height: 1.5;
1983
+ color: var(--ink-faint);
1984
  }
1985
+ .mutmap-key-note strong { color: var(--ink-soft); font-weight: 600; }
1986
+ .mutmap-key-note em { font-style: italic; }
 
1987
 
1988
  .mutmap-canvas {
1989
  background: var(--gray-0);
 
7821
  display: flex; flex-direction: column; gap: 14px;
7822
  -webkit-overflow-scrolling: touch; /* iOS momentum (standing constraint) */
7823
  }
7824
+ /* THE GLITCHED BOXES.
7825
+
7826
+ The transcript is a column flex container, so every turn in it is a flex
7827
+ ITEM and inherits flex-shrink:1. Once a run is long enough to overflow the
7828
+ rail, the free space goes negative and the browser shrinks the items before
7829
+ it ever considers scrolling.
7830
+
7831
+ Most turns survive that: min-height:auto gives a flex item a content-based
7832
+ minimum — but ONLY while its overflow is `visible`. `.cp-tool` sets
7833
+ `overflow: hidden` (it has to; the rounded corners clip the result table),
7834
+ which drops its automatic minimum to zero. So the tool rows, and only the
7835
+ tool rows, get squeezed — measured at 9.6px tall against a 34.6px header,
7836
+ i.e. collapsed to roughly their own border with the content clipped away.
7837
+ That is the "glitched box" in the review screenshots, and it is also why
7838
+ the transcript's scrollHeight equalled its clientHeight: nothing was
7839
+ scrolling, everything was being crushed to fit.
7840
+
7841
+ Pinning every child to its natural size restores scrolling and the boxes.
7842
+ Do not "simplify" this to a rule on .cp-tool alone — the next component
7843
+ that clips its own overflow would silently inherit the same bug. */
7844
+ .cp-transcript > * { flex: 0 0 auto; }
7845
  /* min-width:0 + break-word on every turn, not just the sequence card.
7846
 
7847
  The rail is ~390px and the agent is instructed to put sequences on their
 
8530
  straight through as a course correction — but it stays tagged, so reading a
8531
  run back later answers "why did it switch to mouse at this point?". */
8532
  .cp-user { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; }
8533
+ /* THE CUT-OFF TEXT.
8534
+
8535
+ Making the user's turn a flex row (for the STEER tag) turned its text into
8536
+ a flex item, and a flex item's default `min-width: auto` refuses to shrink
8537
+ below its MIN-CONTENT width. `overflow-wrap: break-word` on .cp-turn does
8538
+ not help here: break-word permits a break at render time but deliberately
8539
+ does not reduce the intrinsic min-content size, so the box still asks for
8540
+ the width of the longest unbroken token.
8541
+
8542
+ Paste the 286-residue TEM-1 protein from the review and that token is the
8543
+ whole sequence: measured at 2,389px of text inside a 391px rail — 2,024px
8544
+ running off the right edge, unwrapped and unreachable.
8545
+
8546
+ `overflow-wrap: anywhere` is the one that DOES shrink min-content, and
8547
+ min-width:0 removes the floor as well. Ordinary prose is unaffected: like
8548
+ break-word, `anywhere` only breaks a word that would otherwise overflow.
8549
+
8550
+ Note this is the fallback, not the good outcome. A bare sequence still
8551
+ reads better as a .cp-seq card (mono, FASTA-wrapped, copyable). md() routes
8552
+ nucleotide lines there already; its bare-sequence test is `/^[ACGTUN]+$/i`,
8553
+ so protein never matches. Widening that test is the real fix. */
8554
+ .cp-user > div { min-width: 0; overflow-wrap: anywhere; }
8555
  .cp-q-tag {
8556
  font-family: var(--font-mono); font-size: 9.5px; letter-spacing: 0.08em;
8557
  text-transform: uppercase; color: var(--ink-faint); flex: 0 0 auto;
 
8679
  them fail; the banner says to run the full tool for both. */
8680
  body.de-agent-run #downloadMenu,
8681
  body.de-agent-run .dna-edit-actions { display: none; }
8682
+
8683
+ /* ══════════════════════════════════════════════ hidden must mean hidden ══
8684
+ Eight elements in this app carried the `hidden` attribute and rendered
8685
+ anyway. One cause, and it looks like correct CSS in every diff:
8686
+
8687
+ the UA sheet's `[hidden] { display: none }` is an AUTHOR-vs-UA contest,
8688
+ not a specificity contest. Any author `display:` on the element wins,
8689
+ however low its specificity.
8690
+
8691
+ So `.run-meta { display: flex }` quietly cancelled `<div class="run-meta"
8692
+ hidden>`, and a scientist looking at a library painted from a chat saw an
8693
+ empty bordered box under "How to read this" — 888x30, one pixel of border,
8694
+ nothing in it. That is the empty box in the review screenshots.
8695
+
8696
+ Measured on a fresh load, the same fault was rendering a 92px CRISPR
8697
+ base-editor row, a 136px primer organism block, a stray filter-clear glyph,
8698
+ two clone panes, and — worst — the entire 946x401 primer results card,
8699
+ headings and all, above a form nobody had submitted yet.
8700
+
8701
+ `.x[hidden]` is (0,2,0) and beats a bare `.x`, so these can live together
8702
+ in one block. Every element listed is toggled through the `hidden`
8703
+ PROPERTY in app.js (`el.hidden = false`), which removes the attribute, so
8704
+ nothing here can strand a panel that is supposed to appear.
8705
+
8706
+ `.sidebar-scrim` is the deliberate exception and keeps its own rule above:
8707
+ it stays boxed while it fades out.
8708
+
8709
+ tests/test_hidden_attribute.py locks this down — it re-derives the list
8710
+ from the markup and fails on the next class that acquires a `display:`
8711
+ without a guard. */
8712
+ .run-meta[hidden],
8713
+ .filter-clear[hidden],
8714
+ .crispr-context-row[hidden],
8715
+ .crispr-vector-row[hidden],
8716
+ .crispr-vendor-row[hidden],
8717
+ .crispr-vendor-strip[hidden],
8718
+ .primer-organism-row[hidden],
8719
+ .primer-results-card[hidden],
8720
+ .primer-scan-status[hidden],
8721
+ .plasmid-seq-tools[hidden],
8722
+ .clone-pane[hidden],
8723
+ .clone-gg-enz[hidden],
8724
+ .ghost[hidden] { display: none; }
dee/static/app.js CHANGED
@@ -1110,7 +1110,11 @@ function teardownSkeleton() {
1110
  <tr>
1111
  <th class="sortable" data-sort="rank" title="Rank by predicted fitness (1 = best).">Rank <span class="sort-ind">↕</span></th>
1112
  <th class="sortable" data-sort="mutations" title="Substitutions vs. the wild-type protein, in the format WT_aa-position-new_aa.">Mutations <span class="sort-ind">↕</span></th>
1113
- <th class="num sortable" data-sort="fitness" title="Cumulative ΣΔLL from ESM-2: sum of log-likelihood improvements over WT across all substitutions in this variant.">Fitness <span class="sort-ind">↕</span></th>
 
 
 
 
1114
  <th class="num sortable" data-sort="gc" title="GC content of the codon-optimized DNA, %.">GC% <span class="sort-ind">↕</span></th>
1115
  <th class="num sortable" data-sort="tm" title="Lower of the two primer Tm values (°C).">Tm (°C) <span class="sort-ind">↕</span></th>
1116
  <th class="num sortable" data-sort="length" title="Length of the codon-optimized DNA, bp.">bp <span class="sort-ind">↕</span></th>
@@ -1368,7 +1372,7 @@ function _renderLearnedPanel(surrogate) {
1368
  return `<div class="learned-row" style="--i:${i}">
1369
  <span class="learned-arrow learned-${cls}">${arrow}</span>
1370
  <span class="learned-label">${escapeHtml(d.label)}</span>
1371
- <span class="learned-scores"><span class="ls-old">${d.prior_score.toFixed(2)}</span> &rarr; <span class="ls-new">${d.adjusted_score.toFixed(2)}</span></span>
1372
  ${badge}
1373
  <span class="learned-reason">${escapeHtml(d.reason)}</span>
1374
  </div>`;
@@ -1431,7 +1435,7 @@ function _renderCalibration(round1Data, measurements) {
1431
  svg.setAttribute('viewBox', `0 0 ${W} ${H}`);
1432
  svg.setAttribute('class', 'calib-svg');
1433
  svg.setAttribute('role', 'img');
1434
- svg.setAttribute('aria-label', 'Scatter plot of ESM-2 predicted fitness versus your measured values');
1435
 
1436
  const xAxis = document.createElementNS(svgNS, 'line');
1437
  xAxis.setAttribute('x1', pad.left); xAxis.setAttribute('x2', pad.left + innerW);
@@ -1457,13 +1461,16 @@ function _renderCalibration(round1Data, measurements) {
1457
  const xLabel = document.createElementNS(svgNS, 'text');
1458
  xLabel.setAttribute('x', pad.left + innerW / 2); xLabel.setAttribute('y', H - 6);
1459
  xLabel.setAttribute('text-anchor', 'middle'); xLabel.setAttribute('class', 'calib-axislabel');
1460
- xLabel.textContent = 'ESM-2 predicted fitness (round 1)';
 
 
 
1461
  svg.appendChild(xLabel);
1462
  const yLabel = document.createElementNS(svgNS, 'text');
1463
  yLabel.setAttribute('x', -(pad.top + innerH / 2)); yLabel.setAttribute('y', 12);
1464
  yLabel.setAttribute('transform', 'rotate(-90)');
1465
  yLabel.setAttribute('text-anchor', 'middle'); yLabel.setAttribute('class', 'calib-axislabel');
1466
- yLabel.textContent = 'Your measured value';
1467
  svg.appendChild(yLabel);
1468
 
1469
  body.innerHTML = '';
@@ -1587,7 +1594,7 @@ function _radarSiteChip(site) {
1587
  if (site.shift <= -0.3) cls = 'anti';
1588
  else if (site.shift >= 0.3) cls = 'coop';
1589
  const sign = site.shift > 0 ? '+' : '';
1590
- return `<span class="radar-site radar-site--${cls}" title="ESM-2 ΔLL shift in context: ${sign}${site.shift.toFixed(2)}">${escapeHtml(site.label)}</span>`;
1591
  }
1592
 
1593
  function _renderRadar(analysis) {
@@ -1601,9 +1608,11 @@ function _renderRadar(analysis) {
1601
  const m = _radarVerdictMeta(v.verdict);
1602
  const chips = (v.sites || []).map(_radarSiteChip).join('<span class="radar-plus">·</span>');
1603
  const moved = Math.abs(v.corrected_score - v.additive_score) >= 0.05;
 
 
1604
  const scoreLine = moved
1605
- ? `<span class="radar-scores">corrected <b>${v.corrected_score.toFixed(2)}</b> <span class="rs-old">was ${v.additive_score.toFixed(2)}</span></span>`
1606
- : `<span class="radar-scores">score <b>${v.additive_score.toFixed(2)}</b></span>`;
1607
  return `<div class="radar-row radar-row--${m.cls}">
1608
  <div class="radar-row-top">
1609
  <span class="radar-verdict radar-verdict--${m.cls}">${m.label}</span>
@@ -1624,6 +1633,47 @@ function _renderRadar(analysis) {
1624
  st.hidden = false; st.textContent = msg;
1625
  st.classList.toggle('radar-status--warn', !!warn);
1626
  };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1627
  btn.addEventListener('click', async () => {
1628
  const data = state.lastRun || {};
1629
  const variants = (data.variants || [])
@@ -1631,8 +1681,8 @@ function _renderRadar(analysis) {
1631
  .slice(0, 12)
1632
  .map((v) => ({ Variant_ID: v.Variant_ID, Mutations_AA: v.Mutations_AA }));
1633
  if (!variants.length) { setStatus('No multi-mutant variants to analyze.', true); return; }
1634
- const orig = btn.textContent; btn.disabled = true; btn.textContent = 'Analyzing…';
1635
- setStatus(`Re-scoring ${variants.length} combination${variants.length === 1 ? '' : 's'} in context with ESM-2…`);
1636
  try {
1637
  const res = await fetch('/api/de/epistasis', {
1638
  method: 'POST', headers: { 'Content-Type': 'application/json' },
@@ -1657,7 +1707,14 @@ function _renderRadar(analysis) {
1657
  }
1658
  btn.textContent = 'Re-analyze';
1659
  } catch (e) { setStatus('Interaction analysis failed — please try again.', true); }
1660
- finally { btn.disabled = false; if (btn.textContent === 'Analyzing…') btn.textContent = orig; }
 
 
 
 
 
 
 
1661
  });
1662
  })();
1663
 
@@ -1732,8 +1789,26 @@ function renderResults(data) {
1732
  // hidden — promising a download next to a hidden button is exactly the
1733
  // kind of small lie this table can't afford.
1734
  const provenance = data.agentRun ? 'designed in this conversation' : 'ready to download';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1735
  $('#resultSummary').innerHTML =
1736
- `<strong>${evolvedCount}</strong> variants${hasWt ? ' + wild type' : ''} of <strong>${escapeHtml(data.wt_identifier)}</strong> · ${data.wt_protein.length} aa · ${provenance}${gpNote}`;
1737
 
1738
  // Learning flywheel: remember this run + (re)build the round-2 panel.
1739
  state.lastRun = data;
@@ -1784,6 +1859,20 @@ function renderResults(data) {
1784
  card.scrollIntoView({ behavior: 'smooth', block: 'start' });
1785
  }
1786
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1787
  /* design_variant_library → the Library table.
1788
 
1789
  Lossless: the rows in `panel.variants` come from codon.variants_to_dataframe,
@@ -1791,8 +1880,13 @@ function renderResults(data) {
1791
  real computed value rather than a chat-shaped approximation. What's missing
1792
  is a server-side JOB — so the two controls that need one (library download,
1793
  in-place DNA editing) are switched off via .de-agent-run rather than left
1794
- to fail against a null job id. */
1795
- function paintAgentDesignRun(panel) {
 
 
 
 
 
1796
  if (!panel || !Array.isArray(panel.variants) || !panel.variants.length) return false;
1797
  renderResults({
1798
  variants: panel.variants,
@@ -1800,13 +1894,35 @@ function paintAgentDesignRun(panel) {
1800
  wt_identifier: panel.wt_identifier || 'Turing run',
1801
  round: panel.round || 1,
1802
  agentRun: true,
 
1803
  });
1804
  state.jobId = null;
1805
  document.body.classList.add('de-agent-run');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1806
  window.TDAgentPaint.note('#resultsCard',
1807
- `These ${panel.variants.length} variants were scored by the same model the full `
1808
- + 'tool uses a chat run just caps the library size. Run Directed Evolution '
1809
- + 'above for a wider search, a downloadable library, and DNA editing.');
 
1810
  return true;
1811
  }
1812
  window.TDDesign = Object.assign(window.TDDesign || {}, { paintAgentRun: paintAgentDesignRun });
@@ -2855,7 +2971,10 @@ function renderStatsStrip(data) {
2855
 
2856
  const tiles = [
2857
  { label: 'Variants', val: variants.length, sub: `${min(nMutations)}–${max(nMutations)} mutations each · mean ${mean(nMutations).toFixed(1)}` },
2858
- { label: 'Top fitness', val: max(fitness).toFixed(2), sub: `mean ${mean(fitness).toFixed(2)} ΣΔLL` },
 
 
 
2859
  { label: 'Position coverage', val: `${positionsTouched.size}`, sub: `${coveragePct}% of WT (${uniqueSubs.size} unique subs)` },
2860
  { label: 'Mean GC', val: `${mean(gc).toFixed(1)}%`, sub: `${min(gc).toFixed(1)}–${max(gc).toFixed(1)}% range` },
2861
  { label: 'Cooler primer Tm', val: `${min(tms).toFixed(1)}°`, sub: `mean ${mean(tms).toFixed(1)}°C` },
@@ -2908,16 +3027,76 @@ function renderRunMeta(data) {
2908
  ['Device', s.device],
2909
  ['Seed', s.seed != null ? s.seed : 'random'],
2910
  ['Wall time', elapsed],
 
 
 
 
 
 
2911
  ];
2912
  const pillHtml = pills.map(([k, v]) => `<span class="run-pill">${escapeHtml(k)} <strong>${escapeHtml(String(v))}</strong></span>`).join('');
2913
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2914
  // Auto-generated methods paragraph — copy-paste into a manuscript.
2915
  const wtLen = (data.wt_protein || '').length;
2916
  const wtId = data.wt_identifier || 'wild-type';
2917
- const methods = `Variants of ${escapeHtml(wtId)} (${wtLen} aa) were designed in silico with ESM-2 ${modelLabel.replace('ESM-2 ', '')} (Lin et al., <cite>Science</cite> 2023) using the wild-type marginal log-likelihood scoring scheme of Meier et al. (<cite>Adv. Neural Inf. Process. Syst.</cite> 2021). Single-point substitutions in the top ${(100 - s.percentile).toFixed(0)}% by ΔLL were retained as the combinatorial search space. ${s.k} multi-mutant variants with ${s.min_mutations}–${s.max_mutations} simultaneous substitutions were generated by simulated annealing (${s.restarts} restarts × ${s.steps_per_restart} steps, geometric cooling) maximizing cumulative ΔLL with stop-codon and duplicate-position penalties. Optimized DNA was reverse-translated using ${hostLabel} codon-usage frequencies and synonymously cleaned of BsaI, BsmBI, and NotI recognition sites for Golden Gate compatibility.`;
2918
 
2919
  box.innerHTML = `
2920
  <div class="run-meta-pills">${pillHtml}</div>
 
2921
  <div class="run-methods">
2922
  <div class="run-methods-head">
2923
  <h4>Materials &amp; methods (copy-paste)</h4>
@@ -2931,37 +3110,111 @@ function renderRunMeta(data) {
2931
 
2932
  // =============================================================== MUTATION MAP
2933
  // Lollipop chart: x = residue position, y = how many variants share a mutation
2934
- // at that position. Dots colored by mean ΔLL strength. Standard genomics
2935
- // visualization (cBioPortal / ProteinPaint).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2936
  function renderMutationMap(data) {
2937
  const root = document.getElementById('mutmapCanvas');
2938
  if (!root) return;
 
2939
  root.innerHTML = '';
2940
 
2941
  const wtLen = (data.wt_protein || '').length;
2942
  if (!wtLen) return;
 
 
 
 
2943
 
2944
- // Aggregate mutation counts and signed ΔLL contribution per position.
 
 
 
 
 
 
 
 
 
 
 
 
 
2945
  const counts = new Map(); // position -> count
2946
- const strength = new Map(); // position -> sum of fitness contribution
2947
  let maxCount = 0;
2948
  (data.variants || []).forEach((v) => {
2949
  const muts = (v.Mutations_AA || '').split(',').map(s => s.trim()).filter(Boolean);
2950
- // Per-variant fitness divided by mutation count is our crude per-mut weight.
2951
- const w = Number(v.Predicted_Fitness_Score) / Math.max(1, muts.length);
 
 
2952
  muts.forEach((m) => {
2953
  const numMatch = m.match(/[0-9]+/);
2954
  if (!numMatch) return;
2955
  const pos = parseInt(numMatch[0], 10);
2956
  counts.set(pos, (counts.get(pos) || 0) + 1);
2957
- strength.set(pos, (strength.get(pos) || 0) + w);
2958
  if (counts.get(pos) > maxCount) maxCount = counts.get(pos);
2959
  });
2960
  });
2961
  if (!counts.size) {
2962
  root.innerHTML = '<div class="muted" style="padding:16px;text-align:center;">No mutations to plot.</div>';
 
2963
  return;
2964
  }
 
 
2965
 
2966
  // SVG geometry
2967
  const W = root.clientWidth || 800;
@@ -2973,17 +3226,28 @@ function renderMutationMap(data) {
2973
  const xScale = (p) => pad.left + (p - 1) / Math.max(1, wtLen - 1) * innerW;
2974
  const yScale = (c) => pad.top + innerH - (c / maxCount) * innerH;
2975
 
2976
- // Compute color by strength: brand-200 (low) brand (mid) brand-deep (high)
 
 
 
 
 
 
 
 
 
2977
  const strengths = [...strength.values()];
2978
- const sMax = Math.max(...strengths);
2979
- const sMin = Math.min(...strengths);
2980
- const colorFor = (s) => {
2981
- if (sMax === sMin) return '#3D3A34';
2982
- const t = (s - sMin) / (sMax - sMin);
2983
- if (t < 0.33) return '#CFCBC2';
2984
- if (t < 0.66) return '#3D3A34';
2985
- return '#1B1A17';
2986
- };
 
 
2987
 
2988
  // Ticks every ~50 residues, with at least 4 ticks.
2989
  const tickStep = Math.max(10, Math.ceil(wtLen / Math.max(4, Math.min(12, Math.floor(wtLen / 25)))));
@@ -3024,8 +3288,9 @@ function renderMutationMap(data) {
3024
  axis.appendChild(lbl);
3025
  });
3026
 
3027
- // Y axis labels (count)
3028
- const yLabels = [maxCount, Math.ceil(maxCount / 2), 1];
 
3029
  yLabels.forEach((c) => {
3030
  const y = yScale(c);
3031
  const lbl = document.createElementNS(svgNS, 'text');
@@ -3050,19 +3315,126 @@ function renderMutationMap(data) {
3050
  stem.setAttribute('class', 'mutmap-stem');
3051
  svg.appendChild(stem);
3052
 
 
3053
  const dot = document.createElementNS(svgNS, 'circle');
3054
  dot.setAttribute('cx', x);
3055
  dot.setAttribute('cy', y);
3056
  dot.setAttribute('r', Math.max(3, 3 + c * 0.4));
3057
- dot.setAttribute('fill', colorFor(strength.get(pos)));
3058
  dot.setAttribute('class', 'mutmap-dot');
3059
  const title = document.createElementNS(svgNS, 'title');
3060
- title.textContent = `Position ${pos} · ${c} variant${c === 1 ? '' : 's'} mutate here · Σ contribution ${strength.get(pos).toFixed(2)}`;
 
 
 
 
 
 
 
 
3061
  dot.appendChild(title);
3062
  svg.appendChild(dot);
3063
  });
3064
 
3065
  root.appendChild(svg);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3066
  }
3067
 
3068
  // =============================================================== COMMAND PALETTE
@@ -5579,6 +5951,21 @@ if (_quitBtn) {
5579
  const origLabel = labelEl ? labelEl.textContent : '';
5580
  if (labelEl) labelEl.textContent = 'Designing…';
5581
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5582
  try {
5583
  // Read the enzyme picker (Phase 1). Defaults to cas9 if the
5584
  // picker isn't in the DOM (cached old build).
@@ -5605,11 +5992,10 @@ if (_quitBtn) {
5605
  // to the slowest step the request will hit so the user knows
5606
  // what's taking time (esp. for the ~60-90 s first build of
5607
  // the human genome index).
5608
- const progressShell = document.getElementById('crisprProgressShell');
5609
  const progressStatus = document.getElementById('crisprProgressStatus');
5610
  const progressElapsed = document.getElementById('crisprProgressElapsed');
5611
  const progressMessage = document.getElementById('crisprProgressMessage');
5612
- let progressTimer = null;
5613
  if (progressShell) {
5614
  progressShell.hidden = false;
5615
  if (progressStatus) {
@@ -5638,13 +6024,6 @@ if (_quitBtn) {
5638
  }
5639
  }, 100);
5640
  }
5641
- // Wrap in a try/finally so the spinner ALWAYS hides, even
5642
- // on early returns (signin modal, error, etc.).
5643
- const _hideProgress = () => {
5644
- if (progressTimer) { clearInterval(progressTimer); progressTimer = null; }
5645
- if (progressShell) progressShell.hidden = true;
5646
- };
5647
-
5648
  // Phase 2C-1: lazy-load the vector catalog on first design
5649
  // click (subsequent clicks reuse the cache). Picker only
5650
  // gets populated AFTER the load, so the first fetch sends
@@ -5654,6 +6033,11 @@ if (_quitBtn) {
5654
  await ensureVectorsLoaded(enzyme);
5655
  const vector_id = (vectorSelect && vectorSelect.value) || '';
5656
 
 
 
 
 
 
5657
  const res = await fetch('/api/crispr/design', {
5658
  method: 'POST',
5659
  headers: { 'Content-Type': 'application/json' },
@@ -5724,6 +6108,118 @@ if (_quitBtn) {
5724
  'Your guides are scored normally &mdash; the <em>Genome off</em> column will fill in after you click <strong>Design</strong> again in a minute or two.';
5725
  }
5726
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5727
  // ─── Phase 3 (M3): results UX — sort / filter / explain ─────────
5728
  // The design response is cached so header-click sorting and filter
5729
  // toggles re-render the table WITHOUT re-fetching from the backend.
@@ -5780,8 +6276,22 @@ if (_quitBtn) {
5780
  p.push(`Editability is <strong>${(g.be_editability || 0).toFixed(2)}</strong> (higher = the edit is more likely).`);
5781
  } else {
5782
  p.push(`On-target activity <strong>${(g.on_target_score || 0).toFixed(2)}</strong>, predicted knockout efficacy <strong>${(g.ko_efficacy || 0).toFixed(2)}</strong>${g.ko_reasoning ? ' — ' + escapeHtml(g.ko_reasoning) : ''}.`);
 
 
 
 
 
 
 
 
 
 
 
5783
  if (typeof g.frameshift_pct === 'number') {
5784
  p.push(`About <strong>${g.frameshift_pct.toFixed(0)}%</strong> of predicted repair outcomes cause a frameshift; the most likely single outcome is <strong>${escapeHtml(g.top_indel_label || '—')}</strong>.`);
 
 
 
5785
  }
5786
  }
5787
  const selfCfd = g.cfd_max_offtarget || 0;
@@ -5802,14 +6312,22 @@ if (_quitBtn) {
5802
  // guide. Previously this always told the user to go run CRISPOR — even
5803
  // when a real genome search had just run here, which is what made the
5804
  // tool feel like a stop on the way to somewhere else.
 
 
 
 
 
 
5805
  if (!g.genome_organism) {
5806
  p.push('<em>No genome off-target search was run — pick an organism above to screen your top guides against the genome.</em>');
5807
- } else if (g.genome_organism === 'ecoli') {
5808
- p.push('<em>Screened against the complete E. coli K-12 genome.</em>');
5809
  } else {
5810
- p.push('<em>Screened against ' + escapeHtml(g.genome_organism) +
5811
- ' coding sequence. Intronic and intergenic off-targets are outside this index — ' +
5812
- 'use a whole-genome tool if your application needs them.</em>');
 
 
 
 
5813
  }
5814
  // Phase 3 (M5): structure-view button when the cut maps to a residue
5815
  // AND a gene symbol + organism are set (needed to resolve UniProt).
@@ -6266,6 +6784,14 @@ if (_quitBtn) {
6266
  const enzLabel = enzyme === 'cas12a' ? 'Cas12a (TTTV PAM)' : 'SpCas9 (NGG PAM)';
6267
  const be = (data.base_editor || '').toUpperCase();
6268
  const org = data.genome_organism || '';
 
 
 
 
 
 
 
 
6269
  const top = guides.slice().sort((a, b) => (b.composite_score || 0) - (a.composite_score || 0))[0] || guides[0];
6270
  const strand = (top.strand === '-' || top.strand < 0) ? 'antisense' : 'sense';
6271
  const comp = top.composite_score != null ? Number(top.composite_score).toFixed(2) : '—';
@@ -6273,7 +6799,22 @@ if (_quitBtn) {
6273
  if (isBE) {
6274
  m = `Guide RNAs (n=${guides.length}) were designed in silico for ${enzLabel} base editing with the ${escapeHtml(be) || 'selected'} editor (Komor et al., <cite>Nature</cite> 2016; Gaudelli et al., <cite>Nature</cite> 2017). On-target activity used Doench-style sequence features; per-guide editability was scored over the editor's activity window, with the predicted edit and amino-acid consequence reported. The top-ranked guide (${escapeHtml(top.spacer || '')}, ${strand} strand) was selected.`;
6275
  } else {
6276
- m = `Guide RNAs (n=${guides.length}) were designed in silico for ${enzLabel}. On-target activity was scored from Doench-style sequence features, and off-target potential within the provided sequence by the cutting-frequency-determination (CFD) matrix (Doench et al., <cite>Nat. Biotechnol.</cite> 2016); knockout likelihood was estimated from the predicted indel spectrum (frameshift fraction and out-of-frame dominance). The top-ranked guide (${escapeHtml(top.spacer || '')}, ${strand} strand, composite ${comp})${enzyme === 'cas12a' ? ' was selected' : ' was selected, and cloning oligos were generated for the standard BbsI/BsmBI vector'}. Off-target assessment is limited to the input sequence and is not genome-wide.`;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6277
  }
6278
  tdRenderMethods('crisprRunMeta', [
6279
  ['Mode', isBE ? 'base edit' : 'knockout'],
@@ -6313,6 +6854,11 @@ if (_quitBtn) {
6313
  // previous design had no vector picked).
6314
  lastGuides = data.guides || [];
6315
  crisprData = data;
 
 
 
 
 
6316
  if (!_isRerender && window.TDCrisprOutcomes) window.TDCrisprOutcomes.populate(data.guides || []);
6317
  renderVendorButtons();
6318
 
@@ -10180,6 +10726,12 @@ function runOracle(opts){
10180
  + '<p class="method-what">' + esc(m.what) + '</p>'
10181
  + (m.formula ? '<p class="method-formula">' + esc(m.formula) + '</p>' : '')
10182
  + (m.basis ? '<p class="method-basis">' + esc(m.basis) + '</p>' : '')
 
 
 
 
 
 
10183
  + (m.limits ? '<p class="method-limits' + (isScopeWarning(m.limits) ? ' warn' : '')
10184
  + '">' + esc(m.limits) + '</p>' : '')
10185
  + ((m.citations && m.citations.length)
@@ -10189,11 +10741,26 @@ function runOracle(opts){
10189
  body.innerHTML = html || '<p class="muted">Methods unavailable.</p>';
10190
  }
10191
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10192
  function load() {
10193
  if (loaded) return;
10194
  loaded = true;
10195
- fetch('/api/crispr/methods')
10196
- .then((r) => r.json())
10197
  .then(render)
10198
  .catch(() => {
10199
  const body = document.getElementById('crisprMethodsBody');
@@ -10204,7 +10771,7 @@ function runOracle(opts){
10204
 
10205
  const det = document.getElementById('crisprMethods');
10206
  if (det) det.addEventListener('toggle', function () { if (det.open) load(); });
10207
- window.TDMethods = { load: load };
10208
  })();
10209
 
10210
  // ═══════════════════════════════════════════════════════════════════════
 
1110
  <tr>
1111
  <th class="sortable" data-sort="rank" title="Rank by predicted fitness (1 = best).">Rank <span class="sort-ind">↕</span></th>
1112
  <th class="sortable" data-sort="mutations" title="Substitutions vs. the wild-type protein, in the format WT_aa-position-new_aa.">Mutations <span class="sort-ind">↕</span></th>
1113
+ <!-- Unit in the header, not only in the title. This shell is
1114
+ rebuilt from JS after the skeleton, so it has to carry the
1115
+ same markup as index.html's copy or the unit would vanish
1116
+ on every real run and survive only in the empty state. -->
1117
+ <th class="num sortable" data-sort="fitness" title="Cumulative ΣΔLL from ESM-2: sum of log-likelihood improvements over WT across all substitutions in this variant.">Fitness <span class="th-unit">ΣΔLL</span> <span class="sort-ind">↕</span></th>
1118
  <th class="num sortable" data-sort="gc" title="GC content of the codon-optimized DNA, %.">GC% <span class="sort-ind">↕</span></th>
1119
  <th class="num sortable" data-sort="tm" title="Lower of the two primer Tm values (°C).">Tm (°C) <span class="sort-ind">↕</span></th>
1120
  <th class="num sortable" data-sort="length" title="Length of the codon-optimized DNA, bp.">bp <span class="sort-ind">↕</span></th>
 
1372
  return `<div class="learned-row" style="--i:${i}">
1373
  <span class="learned-arrow learned-${cls}">${arrow}</span>
1374
  <span class="learned-label">${escapeHtml(d.label)}</span>
1375
+ <span class="learned-scores"><span class="ls-old">${d.prior_score.toFixed(2)}</span> &rarr; <span class="ls-new">${d.adjusted_score.toFixed(2)}</span> <span class="ls-unit">ΔLL</span></span>
1376
  ${badge}
1377
  <span class="learned-reason">${escapeHtml(d.reason)}</span>
1378
  </div>`;
 
1435
  svg.setAttribute('viewBox', `0 0 ${W} ${H}`);
1436
  svg.setAttribute('class', 'calib-svg');
1437
  svg.setAttribute('role', 'img');
1438
+ svg.setAttribute('aria-label', 'Scatter plot of ESM-2 predicted fitness in ΣΔLL versus your measured values');
1439
 
1440
  const xAxis = document.createElementNS(svgNS, 'line');
1441
  xAxis.setAttribute('x1', pad.left); xAxis.setAttribute('x2', pad.left + innerW);
 
1461
  const xLabel = document.createElementNS(svgNS, 'text');
1462
  xLabel.setAttribute('x', pad.left + innerW / 2); xLabel.setAttribute('y', H - 6);
1463
  xLabel.setAttribute('text-anchor', 'middle'); xLabel.setAttribute('class', 'calib-axislabel');
1464
+ // Axis titles carry the unit. "predicted fitness" against "measured value"
1465
+ // is two unlabelled scales facing each other; the reader cannot tell what
1466
+ // a one-unit step on x means without it.
1467
+ xLabel.textContent = 'ESM-2 predicted fitness · ΣΔLL (round 1)';
1468
  svg.appendChild(xLabel);
1469
  const yLabel = document.createElementNS(svgNS, 'text');
1470
  yLabel.setAttribute('x', -(pad.top + innerH / 2)); yLabel.setAttribute('y', 12);
1471
  yLabel.setAttribute('transform', 'rotate(-90)');
1472
  yLabel.setAttribute('text-anchor', 'middle'); yLabel.setAttribute('class', 'calib-axislabel');
1473
+ yLabel.textContent = 'Your measured value (your assay units)';
1474
  svg.appendChild(yLabel);
1475
 
1476
  body.innerHTML = '';
 
1594
  if (site.shift <= -0.3) cls = 'anti';
1595
  else if (site.shift >= 0.3) cls = 'coop';
1596
  const sign = site.shift > 0 ? '+' : '';
1597
+ return `<span class="radar-site radar-site--${cls}" title="ESM-2 ΔLL shift in context: ${sign}${site.shift.toFixed(2)} — how much this substitution's log-likelihood gain changes once the other mutations are present.">${escapeHtml(site.label)}</span>`;
1598
  }
1599
 
1600
  function _renderRadar(analysis) {
 
1608
  const m = _radarVerdictMeta(v.verdict);
1609
  const chips = (v.sites || []).map(_radarSiteChip).join('<span class="radar-plus">·</span>');
1610
  const moved = Math.abs(v.corrected_score - v.additive_score) >= 0.05;
1611
+ // Same unit rule as the library table: the number is meaningless
1612
+ // without ΣΔLL next to it, and nobody hovers a score.
1613
  const scoreLine = moved
1614
+ ? `<span class="radar-scores">corrected <b>${v.corrected_score.toFixed(2)}</b> <span class="rs-unit">ΣΔLL</span> <span class="rs-old">was ${v.additive_score.toFixed(2)}</span></span>`
1615
+ : `<span class="radar-scores">score <b>${v.additive_score.toFixed(2)}</b> <span class="rs-unit">ΣΔLL</span></span>`;
1616
  return `<div class="radar-row radar-row--${m.cls}">
1617
  <div class="radar-row-top">
1618
  <span class="radar-verdict radar-verdict--${m.cls}">${m.label}</span>
 
1633
  st.hidden = false; st.textContent = msg;
1634
  st.classList.toggle('radar-status--warn', !!warn);
1635
  };
1636
+
1637
+ /* Progress signal for a genuinely slow path.
1638
+
1639
+ "Slightly long loading time" was the review note. It is one ESM-2
1640
+ forward pass per mutated site per background, run serially inside a
1641
+ single POST — 12 variants is comfortably tens of seconds on CPU, and
1642
+ the only feedback was a button reading "Analyzing…", which after 20
1643
+ seconds is indistinguishable from a hang. Tool rows in the rail have
1644
+ had an elapsed clock since the CRISPR index made that exact mistake
1645
+ (cockpit.js startTimer); this path never used one.
1646
+
1647
+ Elapsed + the count of passes, not a percentage: the server returns one
1648
+ response for the whole library, so there is no per-variant progress to
1649
+ report and a bar that crept to 60% would be inventing one. */
1650
+ let radarTimer = null;
1651
+ const stopRadarClock = () => {
1652
+ if (radarTimer) { clearInterval(radarTimer); radarTimer = null; }
1653
+ };
1654
+ const startRadarClock = (n) => {
1655
+ const st = document.getElementById('radarStatus');
1656
+ if (!st) return;
1657
+ stopRadarClock();
1658
+ const t0 = Date.now();
1659
+ st.hidden = false;
1660
+ st.classList.remove('radar-status--warn');
1661
+ const lead = `Re-scoring ${n} combination${n === 1 ? '' : 's'} in context with ESM-2 `
1662
+ + '— one masked forward pass per mutated site, run in sequence.';
1663
+ st.innerHTML = `<span class="radar-lead"></span> <span class="radar-elapsed"></span>`
1664
+ + '<span class="radar-progress" role="progressbar" aria-label="Analyzing interactions"></span>';
1665
+ st.querySelector('.radar-lead').textContent = lead;
1666
+ const el = st.querySelector('.radar-elapsed');
1667
+ const tick = () => {
1668
+ const s = Math.round((Date.now() - t0) / 1000);
1669
+ const txt = s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${s % 60}s`;
1670
+ el.textContent = txt;
1671
+ btn.textContent = `Analyzing… ${txt}`;
1672
+ };
1673
+ tick();
1674
+ radarTimer = setInterval(tick, 1000);
1675
+ };
1676
+
1677
  btn.addEventListener('click', async () => {
1678
  const data = state.lastRun || {};
1679
  const variants = (data.variants || [])
 
1681
  .slice(0, 12)
1682
  .map((v) => ({ Variant_ID: v.Variant_ID, Mutations_AA: v.Mutations_AA }));
1683
  if (!variants.length) { setStatus('No multi-mutant variants to analyze.', true); return; }
1684
+ const orig = btn.textContent; btn.disabled = true; btn.textContent = 'Analyzing… 0s';
1685
+ startRadarClock(variants.length);
1686
  try {
1687
  const res = await fetch('/api/de/epistasis', {
1688
  method: 'POST', headers: { 'Content-Type': 'application/json' },
 
1707
  }
1708
  btn.textContent = 'Re-analyze';
1709
  } catch (e) { setStatus('Interaction analysis failed — please try again.', true); }
1710
+ finally {
1711
+ // Before anything else: an interval that outlives the request keeps
1712
+ // counting on a finished panel and rewrites the button label back
1713
+ // to "Analyzing…" every second, which is worse than no clock.
1714
+ stopRadarClock();
1715
+ btn.disabled = false;
1716
+ if (/^Analyzing…/.test(btn.textContent)) btn.textContent = orig;
1717
+ }
1718
  });
1719
  })();
1720
 
 
1789
  // hidden — promising a download next to a hidden button is exactly the
1790
  // kind of small lie this table can't afford.
1791
  const provenance = data.agentRun ? 'designed in this conversation' : 'ready to download';
1792
+ /* Requested vs delivered, on the result itself.
1793
+
1794
+ Two ways these differ and both were silent. A conversation-driven
1795
+ design is capped (see CHAT_LIBRARY_CAP), and a sidebar run can return
1796
+ fewer than K when the search cannot find that many distinct variants
1797
+ above the percentile floor. Either way the user typed a number and got
1798
+ a smaller one, with the table's own counter cheerfully reporting the
1799
+ smaller one as if it were the whole story. */
1800
+ const requestedK = data.agentRun
1801
+ ? (Number(data.requestedK) || null)
1802
+ : (Number((data.settings_used || {}).k) || null);
1803
+ const countNote = (requestedK && requestedK !== evolvedCount)
1804
+ ? `<span class="count-note">Requested <b>${requestedK}</b> · scored <b>${evolvedCount}</b>`
1805
+ + (data.agentRun
1806
+ ? ` — a design run inside a conversation is capped at ${CHAT_LIBRARY_CAP}.`
1807
+ : ' — the search found no further distinct variants above the percentile floor.')
1808
+ + '</span>'
1809
+ : '';
1810
  $('#resultSummary').innerHTML =
1811
+ `<strong>${evolvedCount}</strong> variants${hasWt ? ' + wild type' : ''} of <strong>${escapeHtml(data.wt_identifier)}</strong> · ${data.wt_protein.length} aa · ${provenance}${gpNote}${countNote}`;
1812
 
1813
  // Learning flywheel: remember this run + (re)build the round-2 panel.
1814
  state.lastRun = data;
 
1859
  card.scrollIntoView({ behavior: 'smooth', block: 'start' });
1860
  }
1861
 
1862
+ /* The chat design tool's REAL bounds, mirrored from
1863
+ dee/core/agent_tools.py::_tool_design_variant_library:
1864
+
1865
+ k = int(args.get("k", 10)) → CHAT_LIBRARY_DEFAULT_K
1866
+ k = max(1, min(20, k)) → CHAT_LIBRARY_CAP
1867
+
1868
+ Hardcoded because the browser has no way to ask the server for them, and
1869
+ guarded by tests/test_chat_library_cap.py, which reads both numbers out of
1870
+ the Python and fails if this file or index.html disagrees. A disclosure
1871
+ that has drifted from the behaviour it describes is worse than none — the
1872
+ user then has a specific wrong number to trust. */
1873
+ const CHAT_LIBRARY_CAP = 20;
1874
+ const CHAT_LIBRARY_DEFAULT_K = 10;
1875
+
1876
  /* design_variant_library → the Library table.
1877
 
1878
  Lossless: the rows in `panel.variants` come from codon.variants_to_dataframe,
 
1880
  real computed value rather than a chat-shaped approximation. What's missing
1881
  is a server-side JOB — so the two controls that need one (library download,
1882
  in-place DNA editing) are switched off via .de-agent-run rather than left
1883
+ to fail against a null job id.
1884
+
1885
+ `callArgs` is the tool_call's own arguments, handed over by the cockpit. It
1886
+ is the only place the REQUESTED variant count exists on the client: the
1887
+ result payload carries what was delivered and nothing about what was asked
1888
+ for, so without it "you asked for 30, here are 10" cannot be said at all. */
1889
+ function paintAgentDesignRun(panel, callArgs) {
1890
  if (!panel || !Array.isArray(panel.variants) || !panel.variants.length) return false;
1891
  renderResults({
1892
  variants: panel.variants,
 
1894
  wt_identifier: panel.wt_identifier || 'Turing run',
1895
  round: panel.round || 1,
1896
  agentRun: true,
1897
+ requestedK: Number((callArgs || {}).k) || null,
1898
  });
1899
  state.jobId = null;
1900
  document.body.classList.add('de-agent-run');
1901
+
1902
+ const delivered = panel.variants.length;
1903
+ const asked = Number((callArgs || {}).k) || null;
1904
+ // Three honest cases, and they say different things. Lumping them into
1905
+ // one sentence ("a chat run just caps the library size") is what left a
1906
+ // user staring at 10 rows after typing 30.
1907
+ let lead;
1908
+ if (asked && asked > delivered) {
1909
+ lead = `You asked for ${asked} variants; ${delivered} were scored. `
1910
+ + `A design run inside a conversation is capped at ${CHAT_LIBRARY_CAP} `
1911
+ + 'because it has to finish inside one reply instead of running as a '
1912
+ + 'background job. ';
1913
+ } else if (asked) {
1914
+ lead = `${delivered} variants — the ${asked} you asked for. `
1915
+ + `A design run inside a conversation is capped at ${CHAT_LIBRARY_CAP}. `;
1916
+ } else {
1917
+ lead = `${delivered} variants. A design run inside a conversation returns `
1918
+ + `${CHAT_LIBRARY_DEFAULT_K} by default and is capped at ${CHAT_LIBRARY_CAP}, `
1919
+ + 'because it has to finish inside one reply. ';
1920
+ }
1921
  window.TDAgentPaint.note('#resultsCard',
1922
+ lead
1923
+ + 'They were scored by the same model and the same search the full tool uses, '
1924
+ + 'on a smaller budget. Run Directed Evolution above for a wider search, a '
1925
+ + 'downloadable library, and DNA editing.');
1926
  return true;
1927
  }
1928
  window.TDDesign = Object.assign(window.TDDesign || {}, { paintAgentRun: paintAgentDesignRun });
 
2971
 
2972
  const tiles = [
2973
  { label: 'Variants', val: variants.length, sub: `${min(nMutations)}–${max(nMutations)} mutations each · mean ${mean(nMutations).toFixed(1)}` },
2974
+ // A tile is read at a glance and never hovered, so the unit has to be
2975
+ // in the label — "Top fitness 3.41" alone tells a reader nothing about
2976
+ // what 3.41 is or what a good one looks like.
2977
+ { label: 'Top fitness (ΣΔLL)', val: max(fitness).toFixed(2), sub: `mean ${mean(fitness).toFixed(2)} · unnormalised, no fixed zero` },
2978
  { label: 'Position coverage', val: `${positionsTouched.size}`, sub: `${coveragePct}% of WT (${uniqueSubs.size} unique subs)` },
2979
  { label: 'Mean GC', val: `${mean(gc).toFixed(1)}%`, sub: `${min(gc).toFixed(1)}–${max(gc).toFixed(1)}% range` },
2980
  { label: 'Cooler primer Tm', val: `${min(tms).toFixed(1)}°`, sub: `mean ${mean(tms).toFixed(1)}°C` },
 
3027
  ['Device', s.device],
3028
  ['Seed', s.seed != null ? s.seed : 'random'],
3029
  ['Wall time', elapsed],
3030
+ // "What score guides the simulated annealing search?" — asked by a
3031
+ // reviewing scientist who could not answer it from this panel. The
3032
+ // objective was only ever stated inside the copy-paste methods
3033
+ // paragraph, which nobody reads as documentation. It belongs in the
3034
+ // parameter row next to the restarts and steps it governs.
3035
+ ['Objective', 'maximise Σ ΔLL'],
3036
  ];
3037
  const pillHtml = pills.map(([k, v]) => `<span class="run-pill">${escapeHtml(k)} <strong>${escapeHtml(String(v))}</strong></span>`).join('');
3038
 
3039
+ // Occupancy of the most-shared substitution across the returned library,
3040
+ // MEASURED from the variants on screen rather than asserted from config.
3041
+ // A reviewer's TEM-1 run came back with one substitution in nine of ten
3042
+ // rows; search.py now caps that, and this is how the user can see it did
3043
+ // — and see it for their own run, whatever the cap is set to.
3044
+ const _occ = new Map();
3045
+ (data.variants || []).forEach((v) => {
3046
+ (v.Mutations_AA || '').split(',').map(x => x.trim()).filter(Boolean)
3047
+ .forEach(m => _occ.set(m, (_occ.get(m) || 0) + 1));
3048
+ });
3049
+ const _nVar = (data.variants || []).length;
3050
+ let _topMut = null, _topN = 0;
3051
+ _occ.forEach((n, m) => { if (n > _topN) { _topN = n; _topMut = m; } });
3052
+ // Concatenated, not interpolated. rjsmin — which minifies every asset in
3053
+ // production — mis-tokenises an APOSTROPHE INSIDE A NESTED template
3054
+ // literal: it reads the ' as the start of a single-quoted string and
3055
+ // loses track of the template. Isolated 2026-08-01: a plain template
3056
+ // holding "the search's set" is fine, and a nested template without an
3057
+ // apostrophe is fine; only the combination breaks. Once desynced it
3058
+ // treats the // in the next `https://…` template as a line comment and
3059
+ // deletes the rest of that line, closing backtick and paren included, so
3060
+ // the SERVED app.js fails to parse and nothing in it runs. `node --check`
3061
+ // on the source passes throughout. tests/test_static_minifies_cleanly.py
3062
+ // catches it now.
3063
+ const diversityHtml = (_nVar && _topMut)
3064
+ ? '<p class="run-diversity"><strong>Library spread.</strong> The most-shared substitution, '
3065
+ + '<code>' + escapeHtml(_topMut) + '</code>, is in <strong>' + _topN + ' of ' + _nVar
3066
+ + '</strong> returned variants; the library covers <strong>' + _occ.size
3067
+ + '</strong> distinct substitutions. The search caps how much of a library any one '
3068
+ + 'substitution may occupy &mdash; a set of near-identical clones tells you nothing '
3069
+ + 'about which mutation did the work.</p>'
3070
+ : '';
3071
+ const diversitySentence = (_nVar && _topMut)
3072
+ ? ' The returned set was assembled from the hall of fame under a per-substitution '
3073
+ + 'occupancy cap, so that no single substitution dominates the library (most-shared: '
3074
+ + escapeHtml(_topMut) + ', ' + _topN + ' of ' + _nVar + ' variants).'
3075
+ : '';
3076
+
3077
+ // The direct answer to "what does the search maximise?", in prose, above
3078
+ // the manuscript paragraph. Confirmed against dee/optimizer/search.py
3079
+ // (_fitness) and dee/models/scorer.py (score_all_substitutions) before
3080
+ // it was written: additive sum of per-substitution ΔLL, minus two hard
3081
+ // penalties, and it is the same number the Fitness column shows.
3082
+ const objectiveHtml =
3083
+ '<p class="run-objective"><strong>What the search maximises.</strong> The sum of the '
3084
+ + 'per-substitution scores of a variant&rsquo;s mutations, minus a hard penalty for a stop '
3085
+ + 'codon or for two mutations at the same residue &mdash; the same number the '
3086
+ + '<em>Fitness</em> column shows. Those per-substitution scores are ESM-2 &Delta;LL '
3087
+ + '(wild-type marginal log-likelihood of the mutant residue minus the wild-type residue); '
3088
+ + 'after you log bench results, round 2 replaces them with the learned acquisition score '
3089
+ + 'and the search is otherwise unchanged. The objective is additive, so it does '
3090
+ + '<em>not</em> model epistasis &mdash; that is what the Radar is for.</p>';
3091
+
3092
  // Auto-generated methods paragraph — copy-paste into a manuscript.
3093
  const wtLen = (data.wt_protein || '').length;
3094
  const wtId = data.wt_identifier || 'wild-type';
3095
+ const methods = `Variants of ${escapeHtml(wtId)} (${wtLen} aa) were designed in silico with ESM-2 ${modelLabel.replace('ESM-2 ', '')} (Lin et al., <cite>Science</cite> 2023) using the wild-type marginal log-likelihood scoring scheme of Meier et al. (<cite>Adv. Neural Inf. Process. Syst.</cite> 2021). Single-point substitutions in the top ${(100 - s.percentile).toFixed(0)}% by ΔLL were retained as the combinatorial search space. ${s.k} multi-mutant variants with ${s.min_mutations}–${s.max_mutations} simultaneous substitutions were generated by simulated annealing (${s.restarts} restarts × ${s.steps_per_restart} steps, geometric cooling) maximizing cumulative ΔLL with stop-codon and duplicate-position penalties.${diversitySentence} Optimized DNA was reverse-translated using ${hostLabel} codon-usage frequencies and synonymously cleaned of BsaI, BsmBI, and NotI recognition sites for Golden Gate compatibility.`;
3096
 
3097
  box.innerHTML = `
3098
  <div class="run-meta-pills">${pillHtml}</div>
3099
+ <div class="run-notes">${objectiveHtml}${diversityHtml}</div>
3100
  <div class="run-methods">
3101
  <div class="run-methods-head">
3102
  <h4>Materials &amp; methods (copy-paste)</h4>
 
3110
 
3111
  // =============================================================== MUTATION MAP
3112
  // Lollipop chart: x = residue position, y = how many variants share a mutation
3113
+ // at that position, colour = the signed mean ΔLL at that position. Standard
3114
+ // genomics visualization (cBioPortal / ProteinPaint).
3115
+ //
3116
+ // ── the colour scale, and why it is this one ────────────────────────────────
3117
+ // ΔLL is continuous and SIGNED, and the sign is the entire claim: positive
3118
+ // means ESM-2 finds the substitution MORE plausible than the residue evolution
3119
+ // actually chose, negative means less. The first version threw that away
3120
+ // twice over. It min-max normalised the values — which erases zero, so a set
3121
+ // of uniformly bad mutations rendered identically to a set of uniformly good
3122
+ // ones — and then quantised the result into three hard-coded greys,
3123
+ // '#CFCBC2' / '#3D3A34' / '#1B1A17'. Measured against a real 30-variant GFP
3124
+ // library those three collapsed to ONE (the run mutates two positions with
3125
+ // equal mean, so sMax === sMin and every dot took the same fill), and that
3126
+ // fill scored 1.53:1 against the dark canvas — below the 3:1 floor for a
3127
+ // non-text element, which is exactly the "invisible" in the review. The card
3128
+ // meanwhile described a legend, "faint · medium · saturated", that had been
3129
+ // deleted from the markup.
3130
+ //
3131
+ // What replaces it:
3132
+ // • Diverging, anchored at ΔLL = 0, so the zero crossing is a colour
3133
+ // boundary you can see rather than a number you have to read.
3134
+ // • Blue ↔ orange, the Okabe–Ito colour-vision-deficiency-safe pair
3135
+ // (#0072B2 / #D55E00). Red/green would be the obvious "bad/good" choice
3136
+ // and is the one pair ~8% of male readers cannot separate. This is a
3137
+ // scientific readout, so it does not get to be the obvious one.
3138
+ // • Near-constant luminance across the whole ramp. A conventional diverging
3139
+ // scale pales towards the middle, which disappears on the light theme's
3140
+ // white canvas; holding every stop between 0.13 and 0.30 relative
3141
+ // luminance keeps ≥3:1 against BOTH canvases (white, and #1B1A18) at
3142
+ // every value. Verified numerically, not by eye —
3143
+ // tests/test_mutmap_scale.py re-derives the contrast for the whole ramp.
3144
+ // • A legend that states the units and the domain, including when the
3145
+ // domain is clipped.
3146
+ const MUTMAP_NEG = [0, 114, 178]; // Okabe–Ito blue — worse than wild type
3147
+ const MUTMAP_POS = [213, 94, 0]; // Okabe–Ito vermillion — better than WT
3148
+ const MUTMAP_MID = [123, 119, 111]; // warm neutral: ΔLL ≈ 0, "no signal"
3149
+
3150
+ // t ∈ [-1, +1] → CSS rgb(). A straight blend from the neutral out to each
3151
+ // endpoint. Deliberately not a lightness ramp: see the note above.
3152
+ function mutmapColor(t) {
3153
+ const k = Math.max(-1, Math.min(1, Number(t) || 0));
3154
+ const end = k < 0 ? MUTMAP_NEG : MUTMAP_POS;
3155
+ const a = Math.abs(k);
3156
+ const c = MUTMAP_MID.map((m, i) => Math.round(m + (end[i] - m) * a));
3157
+ return `rgb(${c[0]}, ${c[1]}, ${c[2]})`;
3158
+ }
3159
+
3160
+ // The rail is draggable now (railsplit.js) and the chart is laid out in
3161
+ // absolute pixels read from clientWidth, so a resize has to re-lay it out or
3162
+ // the SVG stretches its own axis labels.
3163
+ let _mutmapLast = null;
3164
+ let _mutmapRO = null;
3165
+
3166
  function renderMutationMap(data) {
3167
  const root = document.getElementById('mutmapCanvas');
3168
  if (!root) return;
3169
+ _mutmapLast = data;
3170
  root.innerHTML = '';
3171
 
3172
  const wtLen = (data.wt_protein || '').length;
3173
  if (!wtLen) return;
3174
+ // Fill the real protein length into the axis key above the chart, so
3175
+ // "x = residue position (1–N)" names an actual range instead of an N.
3176
+ const lenSlot = document.getElementById('mutmapLen');
3177
+ if (lenSlot) lenSlot.textContent = String(wtLen);
3178
 
3179
+ // Aggregate per position: how many variants mutate here, and the mean
3180
+ // signed ΔLL attributed to the position.
3181
+ //
3182
+ // The attribution is the honest limit of what the payload carries. A
3183
+ // variant reports ΣΔLL over its own substitutions and nothing per-residue,
3184
+ // so each of its mutations is credited ΣΔLL ÷ (number of mutations) and
3185
+ // those are averaged over the variants that touch the position. It is an
3186
+ // even split, not a measurement of that one substitution — the tooltip
3187
+ // and the legend both say "attributed" for that reason. Do not relabel it
3188
+ // as the substitution's own ΔLL.
3189
+ //
3190
+ // MEAN, not the sum the first version used: the sum grows with the number
3191
+ // of variants at a position, which is already the bar's height, so a
3192
+ // frequently-hit position looked "strong" purely for being frequent.
3193
  const counts = new Map(); // position -> count
3194
+ const total = new Map(); // position -> summed attributed ΔLL
3195
  let maxCount = 0;
3196
  (data.variants || []).forEach((v) => {
3197
  const muts = (v.Mutations_AA || '').split(',').map(s => s.trim()).filter(Boolean);
3198
+ if (!muts.length) return; // WT row carries no mutations
3199
+ const score = Number(v.Predicted_Fitness_Score);
3200
+ if (!Number.isFinite(score)) return; // never coerce a blank to 0
3201
+ const w = score / muts.length;
3202
  muts.forEach((m) => {
3203
  const numMatch = m.match(/[0-9]+/);
3204
  if (!numMatch) return;
3205
  const pos = parseInt(numMatch[0], 10);
3206
  counts.set(pos, (counts.get(pos) || 0) + 1);
3207
+ total.set(pos, (total.get(pos) || 0) + w);
3208
  if (counts.get(pos) > maxCount) maxCount = counts.get(pos);
3209
  });
3210
  });
3211
  if (!counts.size) {
3212
  root.innerHTML = '<div class="muted" style="padding:16px;text-align:center;">No mutations to plot.</div>';
3213
+ renderMutationKey(null);
3214
  return;
3215
  }
3216
+ const strength = new Map(); // position -> MEAN attributed ΔLL
3217
+ counts.forEach((n, pos) => strength.set(pos, total.get(pos) / n));
3218
 
3219
  // SVG geometry
3220
  const W = root.clientWidth || 800;
 
3226
  const xScale = (p) => pad.left + (p - 1) / Math.max(1, wtLen - 1) * innerW;
3227
  const yScale = (c) => pad.top + innerH - (c / maxCount) * innerH;
3228
 
3229
+ /* The domain. Symmetric about zero an asymmetric one would put the
3230
+ neutral colour somewhere other than ΔLL = 0 and the scale would lie
3231
+ about the sign.
3232
+
3233
+ Clipped at the 95th percentile of |ΔLL| so one outlier position cannot
3234
+ flatten every other dot to grey, but ONLY when there are enough
3235
+ positions for a percentile to mean anything. Below 8 the domain is the
3236
+ plain observed maximum, and the legend says which of the two you are
3237
+ looking at — announcing "clipped at p95" over three data points would
3238
+ be a statistic invented to sound rigorous. */
3239
  const strengths = [...strength.values()];
3240
+ const mags = strengths.map(Math.abs).sort((a, b) => a - b);
3241
+ const CLIP_MIN_N = 8;
3242
+ const clipped = mags.length >= CLIP_MIN_N;
3243
+ let domain = clipped
3244
+ ? mags[Math.min(mags.length - 1, Math.floor(0.95 * (mags.length - 1)))]
3245
+ : mags[mags.length - 1];
3246
+ if (!(domain > 0)) domain = 0; // every position exactly 0
3247
+ const nOutside = clipped ? mags.filter(m => m > domain).length : 0;
3248
+ // A dot at ±domain reaches full saturation; beyond it the colour holds
3249
+ // (that is what clipping means) and the legend declares it.
3250
+ const colorFor = (s) => mutmapColor(domain > 0 ? s / domain : 0);
3251
 
3252
  // Ticks every ~50 residues, with at least 4 ticks.
3253
  const tickStep = Math.max(10, Math.ceil(wtLen / Math.max(4, Math.min(12, Math.floor(wtLen / 25)))));
 
3288
  axis.appendChild(lbl);
3289
  });
3290
 
3291
+ // Y axis labels (count). Deduped: a library that hits every position once
3292
+ // gives maxCount = 1 and printed "1" three times on top of itself.
3293
+ const yLabels = [...new Set([maxCount, Math.ceil(maxCount / 2), 1])];
3294
  yLabels.forEach((c) => {
3295
  const y = yScale(c);
3296
  const lbl = document.createElementNS(svgNS, 'text');
 
3315
  stem.setAttribute('class', 'mutmap-stem');
3316
  svg.appendChild(stem);
3317
 
3318
+ const mean = strength.get(pos);
3319
  const dot = document.createElementNS(svgNS, 'circle');
3320
  dot.setAttribute('cx', x);
3321
  dot.setAttribute('cy', y);
3322
  dot.setAttribute('r', Math.max(3, 3 + c * 0.4));
3323
+ dot.setAttribute('fill', colorFor(mean));
3324
  dot.setAttribute('class', 'mutmap-dot');
3325
  const title = document.createElementNS(svgNS, 'title');
3326
+ // Signed, and it says what the number IS. "Σ contribution" named a
3327
+ // quantity that was neither a sum the reader could reconstruct nor
3328
+ // comparable between positions. Concatenation, not a template
3329
+ // literal: rjsmin strips whitespace either side of `${…}` in the
3330
+ // SERVED bundle, which is what put "1 variant mutate here" and
3331
+ // "units(ESM-2 35M)" in front of a reviewer.
3332
+ title.textContent = 'Position ' + pos + ' · ' + c
3333
+ + (c === 1 ? ' variant mutates here · ' : ' variants mutate here · ')
3334
+ + 'mean attributed ΔLL ' + (mean >= 0 ? '+' : '') + mean.toFixed(2);
3335
  dot.appendChild(title);
3336
  svg.appendChild(dot);
3337
  });
3338
 
3339
  root.appendChild(svg);
3340
+ renderMutationKey({ domain, clipped, nOutside, nPositions: counts.size,
3341
+ model: (data.settings_used || {}).model });
3342
+
3343
+ // One observer for the life of the page; it re-runs the layout above with
3344
+ // whatever data was last rendered.
3345
+ if (!_mutmapRO && typeof ResizeObserver !== 'undefined') {
3346
+ let pending = null;
3347
+ _mutmapRO = new ResizeObserver(() => {
3348
+ if (pending) return;
3349
+ pending = requestAnimationFrame(() => {
3350
+ pending = null;
3351
+ if (_mutmapLast && root.isConnected && root.clientWidth) {
3352
+ renderMutationMap(_mutmapLast);
3353
+ }
3354
+ });
3355
+ });
3356
+ _mutmapRO.observe(root);
3357
+ }
3358
+ }
3359
+
3360
+ /* The legend. A gradient with no numbers on it is decoration; this one has to
3361
+ answer "what is being encoded, in what units, over what range, and is the
3362
+ range the whole story". The swatches come from mutmapColor(), so the key
3363
+ cannot drift away from the chart it explains. */
3364
+ function renderMutationKey(info) {
3365
+ const host = document.getElementById('mutmapKey');
3366
+ if (!host) return;
3367
+ if (!info || !(info.domain > 0)) {
3368
+ // Every position identical (or zero). A ramp here would imply a
3369
+ // spread that isn't in the data.
3370
+ host.innerHTML = info
3371
+ ? '<p class="mutmap-key-note">Every plotted position carries the same attributed '
3372
+ + '&Delta;LL, so there is no gradient to show &mdash; the dots are one colour '
3373
+ + 'because the data is one value, not because the scale collapsed.</p>'
3374
+ : '';
3375
+ return;
3376
+ }
3377
+ /* Built with ordinary quoted strings and `+`, NOT a template literal.
3378
+
3379
+ The assets are minified in production by rjsmin, which predates ES6 and
3380
+ does not recognise a backtick as opening a string. Inside a template
3381
+ literal it therefore treats the prose as code and strips "insignificant"
3382
+ whitespace next to punctuation — so
3383
+
3384
+ `... units${modelLabel ? ' (' + m + ')' : ''}.`
3385
+ `... across ${n} position${n === 1 ? '' : 's'} &mdash; nothing`
3386
+
3387
+ shipped to users as "units(ESM-2 35M)" and "2positions&mdash;nothing".
3388
+ Verified by running rjsmin.jsmin() over this function: the spaces either
3389
+ side of every ${…} boundary are gone in the minified output, while the
3390
+ same text inside '…' survives untouched.
3391
+
3392
+ This is not local to the legend — it is a property of every template
3393
+ literal in app.js — but the fix here is to stop relying on it. Spaced
3394
+ em dashes are the house style (30 of 33 in index.html, 352 of 421 in
3395
+ app.js), and this paragraph is the one that has to read as though a
3396
+ scientist wrote it. */
3397
+ const stops = [-1, -0.75, -0.5, -0.25, 0, 0.25, 0.5, 0.75, 1]
3398
+ .map(t => mutmapColor(t) + ' ' + ((t + 1) / 2 * 100).toFixed(0) + '%')
3399
+ .join(', ');
3400
+ const d = info.domain;
3401
+ const fmt = (n) => (n >= 0 ? '+' : '') + n.toFixed(2);
3402
+ const modelLabel = { small: 'ESM-2 35M', medium: 'ESM-2 650M', large: 'ESM-2 3B' }[info.model];
3403
+ const n = info.nPositions;
3404
+ const plural = (k, word) => k + ' ' + word + (k === 1 ? '' : 's');
3405
+
3406
+ const domainNote = info.clipped
3407
+ ? ('Scale clipped at the 95th percentile of |&Delta;LL| across '
3408
+ + plural(n, 'position')
3409
+ + (info.nOutside
3410
+ ? (' &mdash; ' + plural(info.nOutside, 'position') + ' '
3411
+ + (info.nOutside === 1 ? 'falls' : 'fall')
3412
+ + ' beyond it and ' + (info.nOutside === 1 ? 'is' : 'are')
3413
+ + ' drawn at full saturation.')
3414
+ : '.'))
3415
+ : ('Scale spans the full observed range across ' + plural(n, 'position')
3416
+ + ' &mdash; nothing is clipped.');
3417
+
3418
+ host.innerHTML =
3419
+ '<div class="mutmap-key-scale" role="img" aria-label="Diverging colour scale from '
3420
+ + escapeHtml(fmt(-d)) + ' to ' + escapeHtml(fmt(d))
3421
+ + ' log-likelihood units, centred on zero">'
3422
+ + '<span class="mutmap-key-bar" style="background: linear-gradient(to right, '
3423
+ + stops + ');"></span>'
3424
+ + '<span class="mutmap-key-ticks">'
3425
+ + '<span>' + escapeHtml(fmt(-d)) + '</span><span>0</span>'
3426
+ + '<span>' + escapeHtml(fmt(d)) + '</span>'
3427
+ + '</span>'
3428
+ + '</div>'
3429
+ + '<p class="mutmap-key-note">'
3430
+ + '<strong>Mean attributed &Delta;LL per mutation</strong>, in log-likelihood units'
3431
+ + (modelLabel ? ' (' + escapeHtml(modelLabel) + ')' : '') + '. '
3432
+ + 'Positive means the model finds the substitution <em>more</em> plausible than the '
3433
+ + 'wild-type residue; negative, less. Each variant&rsquo;s &Sigma;&Delta;LL is split '
3434
+ + 'evenly across its own mutations, so this is '
3435
+ + 'an attribution, not a per-substitution measurement.'
3436
+ + '</p>'
3437
+ + '<p class="mutmap-key-note">' + domainNote + '</p>';
3438
  }
3439
 
3440
  // =============================================================== COMMAND PALETTE
 
5951
  const origLabel = labelEl ? labelEl.textContent : '';
5952
  if (labelEl) labelEl.textContent = 'Designing…';
5953
 
5954
+ // The progress shimmer's stop switch, declared OUT here deliberately.
5955
+ // `finally` is a sibling block to `try`, so a `const` declared inside
5956
+ // the try is not in scope in the finally — the old placement made the
5957
+ // closing `_hideProgress()` throw ReferenceError on EVERY design, and
5958
+ // a throw in a finally also swallows whatever error the catch was
5959
+ // handling. Measured 2026-08-01: after a *successful* design the
5960
+ // shimmer was still on screen at 74 px with its elapsed counter still
5961
+ // ticking (3.0s and climbing), and the setInterval leaked every run.
5962
+ let progressTimer = null;
5963
+ let progressShell = null;
5964
+ const _hideProgress = () => {
5965
+ if (progressTimer) { clearInterval(progressTimer); progressTimer = null; }
5966
+ if (progressShell) progressShell.hidden = true;
5967
+ };
5968
+
5969
  try {
5970
  // Read the enzyme picker (Phase 1). Defaults to cas9 if the
5971
  // picker isn't in the DOM (cached old build).
 
5992
  // to the slowest step the request will hit so the user knows
5993
  // what's taking time (esp. for the ~60-90 s first build of
5994
  // the human genome index).
5995
+ progressShell = document.getElementById('crisprProgressShell');
5996
  const progressStatus = document.getElementById('crisprProgressStatus');
5997
  const progressElapsed = document.getElementById('crisprProgressElapsed');
5998
  const progressMessage = document.getElementById('crisprProgressMessage');
 
5999
  if (progressShell) {
6000
  progressShell.hidden = false;
6001
  if (progressStatus) {
 
6024
  }
6025
  }, 100);
6026
  }
 
 
 
 
 
 
 
6027
  // Phase 2C-1: lazy-load the vector catalog on first design
6028
  // click (subsequent clicks reuse the cache). Picker only
6029
  // gets populated AFTER the load, so the first fetch sends
 
6033
  await ensureVectorsLoaded(enzyme);
6034
  const vector_id = (vectorSelect && vectorSelect.value) || '';
6035
 
6036
+ // Warm the genome registry while the design runs, so the scope
6037
+ // strip can name the actual coverage on its FIRST paint instead
6038
+ // of a beat later. Fire-and-forget: a failure only costs detail.
6039
+ _ensureGenomeScopes();
6040
+
6041
  const res = await fetch('/api/crispr/design', {
6042
  method: 'POST',
6043
  headers: { 'Content-Type': 'application/json' },
 
6108
  'Your guides are scored normally &mdash; the <em>Genome off</em> column will fill in after you click <strong>Design</strong> again in a minute or two.';
6109
  }
6110
 
6111
+ // ─── Scope strip: what these numbers assume, at the point you read them
6112
+ //
6113
+ // Three facts a reviewing scientist could not get out of this panel:
6114
+ // 1. "Composite" bakes in a 0.6 weight nobody could see.
6115
+ // 2. FS % / Top indel are end-joining repair predictions with fixed
6116
+ // constants — they are NOT conditioned on the user's cell type,
6117
+ // and the panel never admitted it.
6118
+ // 3. For human/mouse the genome search covers CODING SEQUENCE ONLY,
6119
+ // while the surrounding panel reads like a whole-genome screen.
6120
+ //
6121
+ // All three live here, once, immediately above the table — not repeated
6122
+ // per column and not hidden behind the collapsed provenance block, which
6123
+ // is where they were and which is why they went unread. The per-guide
6124
+ // why-panel keeps a one-clause version tied to that guide's own numbers;
6125
+ // the collapsed block keeps the full method text.
6126
+ //
6127
+ // The genome wording comes from the server's own registry via
6128
+ // /api/crispr/methods (genome_scopes), never from a list hardcoded here.
6129
+ let _genomeScopes = null;
6130
+
6131
+ function _scopeStripEl() {
6132
+ let el = document.getElementById('crisprScopeStrip');
6133
+ if (!el) {
6134
+ el = document.createElement('div');
6135
+ el.id = 'crisprScopeStrip';
6136
+ el.className = 'crispr-scope-strip';
6137
+ const tableWrap = resCard.querySelector('.result-table-wrap');
6138
+ if (tableWrap && tableWrap.parentNode) tableWrap.parentNode.insertBefore(el, tableWrap);
6139
+ else resCard.appendChild(el);
6140
+ }
6141
+ return el;
6142
+ }
6143
+
6144
+ function _renderScopeStrip(data) {
6145
+ const el = _scopeStripEl();
6146
+ const guides = data.guides || [];
6147
+ const isBE = data.mode === 'base_edit';
6148
+ const org = data.genome_organism || '';
6149
+ const rows = [];
6150
+
6151
+ // 1. Composite — the weight, read off the guide that carries it, so
6152
+ // the strip can never drift from the constant the engine used.
6153
+ // (Older cached payloads have no weight field; then say nothing
6154
+ // rather than assert a number we didn't get.)
6155
+ if (!isBE) {
6156
+ const w = guides.length ? guides[0].composite_offtarget_weight : null;
6157
+ if (typeof w === 'number') {
6158
+ rows.push(['Composite',
6159
+ 'on-target × (1 − ' + w + ' × self-off). The ' + w + ' is our judgement call, '
6160
+ + 'not a published constant &mdash; a guide duplicated exactly elsewhere in your '
6161
+ + 'input keeps ' + Math.round((1 - w) * 100) + '% of its on-target score rather than dropping to zero. '
6162
+ + 'Sort by On-target and Self-off separately to apply your own trade-off.']);
6163
+ }
6164
+ }
6165
+
6166
+ // 2. Repair context for the indel columns (knockout mode only —
6167
+ // base-edit mode hides them).
6168
+ if (!isBE) {
6169
+ rows.push(['Repair outcomes',
6170
+ '<strong>Top indel</strong>, <strong>FS&nbsp;%</strong> and <strong>Dominance</strong> assume '
6171
+ + 'template-free end-joining (MMEJ + NHEJ) of a blunt Cas9 cut, with no HDR donor. '
6172
+ + 'The model uses fixed published constants and takes <strong>no cell-type input</strong>, '
6173
+ + 'so the same guide returns the same FS&nbsp;% in mESC, U2OS, HEK293 or primary cells &mdash; '
6174
+ + 'yet the real MMEJ/NHEJ balance differs between them. Rank guides with these; don&rsquo;t quote them '
6175
+ + 'as rates for your cell line.']);
6176
+ }
6177
+
6178
+ // 3. Off-target scope. Three genuinely different states, and the
6179
+ // difference matters more than the numbers do.
6180
+ const screened = data.genome_searched_top_n || 0;
6181
+ if (!org) {
6182
+ rows.push(['Off-target scope',
6183
+ '<strong>The pasted sequence only.</strong> No genome was searched &mdash; the '
6184
+ + '<em>Self-off</em> column compares each guide against the other candidate sites in your '
6185
+ + 'input, nothing more. Pick an organism above to screen your top guides against a genome.']);
6186
+ } else {
6187
+ const sc = _genomeScopes && _genomeScopes[org];
6188
+ const name = (sc && sc.name) || org;
6189
+ const complete = sc ? !!sc.complete : null;
6190
+ const note = sc ? sc.note
6191
+ : 'Coverage for this organism is listed under &ldquo;How these numbers were computed&rdquo;.';
6192
+ const depth = screened
6193
+ ? ' Only the top ' + screened + ' of ' + guides.length + ' guides were screened (the ones you would realistically order).'
6194
+ : '';
6195
+ rows.push(['Off-target scope',
6196
+ (complete === false ? '<strong>Not a whole-genome screen.</strong> ' : '')
6197
+ + 'Searched against <strong>' + escapeHtml(name) + '</strong>. ' + note + depth]);
6198
+ }
6199
+
6200
+ el.innerHTML = rows.map(([k, v]) =>
6201
+ '<div class="crispr-scope-row"><span class="crispr-scope-k">' + escapeHtml(k) + '</span>'
6202
+ + '<span class="crispr-scope-v">' + v + '</span></div>').join('');
6203
+ el.hidden = !rows.length;
6204
+ }
6205
+
6206
+ // Fetch the genome registry once, then repaint the strip with the real
6207
+ // per-organism coverage. Deliberately fire-and-forget: the strip already
6208
+ // rendered a truthful (if less specific) line synchronously, so a failed
6209
+ // fetch degrades to less detail, never to a wrong claim.
6210
+ function _ensureGenomeScopes() {
6211
+ if (_genomeScopes || !window.TDMethods || !window.TDMethods.ensure) return;
6212
+ window.TDMethods.ensure().then((payload) => {
6213
+ if (!payload || !payload.genome_scopes) return;
6214
+ _genomeScopes = payload.genome_scopes;
6215
+ // Repaint the two places that name the coverage: the strip and
6216
+ // the copy-paste methods paragraph. (Per-guide why-panels fall
6217
+ // back to pointing at the strip until the next render, which is
6218
+ // vaguer but never wrong.)
6219
+ if (crisprData) { _renderScopeStrip(crisprData); _renderCrisprMeta(crisprData); }
6220
+ }).catch(() => {});
6221
+ }
6222
+
6223
  // ─── Phase 3 (M3): results UX — sort / filter / explain ─────────
6224
  // The design response is cached so header-click sorting and filter
6225
  // toggles re-render the table WITHOUT re-fetching from the backend.
 
6276
  p.push(`Editability is <strong>${(g.be_editability || 0).toFixed(2)}</strong> (higher = the edit is more likely).`);
6277
  } else {
6278
  p.push(`On-target activity <strong>${(g.on_target_score || 0).toFixed(2)}</strong>, predicted knockout efficacy <strong>${(g.ko_efficacy || 0).toFixed(2)}</strong>${g.ko_reasoning ? ' — ' + escapeHtml(g.ko_reasoning) : ''}.`);
6279
+ // The composite arithmetic for THIS guide, with the weight the
6280
+ // engine actually used. The weight rides on the guide
6281
+ // (composite_offtarget_weight) rather than being written here, so
6282
+ // a change to crispr.COMPOSITE_OFFTARGET_WEIGHT can't leave the
6283
+ // explanation showing a number the ranking no longer uses.
6284
+ const cw = g.composite_offtarget_weight;
6285
+ if (typeof cw === 'number' && typeof g.composite_score === 'number') {
6286
+ p.push(`Composite <strong>${g.composite_score.toFixed(3)}</strong> = on-target `
6287
+ + `${(g.on_target_score || 0).toFixed(3)} × (1 − ${cw} × self-off `
6288
+ + `${(g.cfd_max_offtarget || 0).toFixed(3)}).`);
6289
+ }
6290
  if (typeof g.frameshift_pct === 'number') {
6291
  p.push(`About <strong>${g.frameshift_pct.toFixed(0)}%</strong> of predicted repair outcomes cause a frameshift; the most likely single outcome is <strong>${escapeHtml(g.top_indel_label || '—')}</strong>.`);
6292
+ // Never let a frameshift percentage be read as a measured rate
6293
+ // for the reader's own cells: the model has no cell-type input.
6294
+ p.push('Those are end-joining predictions with fixed constants and <strong>no cell-type conditioning</strong> — use them to compare guides, not as expected rates in your line.');
6295
  }
6296
  }
6297
  const selfCfd = g.cfd_max_offtarget || 0;
 
6312
  // guide. Previously this always told the user to go run CRISPOR — even
6313
  // when a real genome search had just run here, which is what made the
6314
  // tool feel like a stop on the way to somewhere else.
6315
+ // Coverage comes from the server's genome registry, not from a list
6316
+ // written here. The old test was `organism === 'ecoli' ? complete :
6317
+ // coding-sequence-only`, which told every yeast, worm and fly run it
6318
+ // had screened coding sequence only — those genomes are indexed
6319
+ // COMPLETE. The full scope wording lives in the strip above the
6320
+ // table; this is the one-clause version tied to this guide.
6321
  if (!g.genome_organism) {
6322
  p.push('<em>No genome off-target search was run — pick an organism above to screen your top guides against the genome.</em>');
 
 
6323
  } else {
6324
+ const sc = _genomeScopes && _genomeScopes[g.genome_organism];
6325
+ const nm = escapeHtml((sc && sc.name) || g.genome_organism);
6326
+ p.push(!sc
6327
+ ? `<em>Screened against ${nm} — see the off-target scope note above the table.</em>`
6328
+ : (sc.complete
6329
+ ? `<em>Screened against the complete ${nm} genome.</em>`
6330
+ : `<em>Screened against ${nm} — coding sequence only; intronic and intergenic sites are outside this index.</em>`));
6331
  }
6332
  // Phase 3 (M5): structure-view button when the cut maps to a residue
6333
  // AND a gene symbol + organism are set (needed to resolve UniProt).
 
6784
  const enzLabel = enzyme === 'cas12a' ? 'Cas12a (TTTV PAM)' : 'SpCas9 (NGG PAM)';
6785
  const be = (data.base_editor || '').toUpperCase();
6786
  const org = data.genome_organism || '';
6787
+ // How many guides the genome search actually covered, and over what.
6788
+ // Both come from the run + the server's genome registry; when the
6789
+ // registry hasn't loaded we omit the qualifier rather than guess it.
6790
+ const screened = data.genome_searched_top_n || 0;
6791
+ const scopeSrc = _genomeScopes && _genomeScopes[org];
6792
+ const scopeName = (scopeSrc && scopeSrc.name) || org;
6793
+ const scopeQual = !scopeSrc ? ''
6794
+ : (scopeSrc.complete ? ' (complete genome)' : ' (coding sequence only; intronic and intergenic sites not covered)');
6795
  const top = guides.slice().sort((a, b) => (b.composite_score || 0) - (a.composite_score || 0))[0] || guides[0];
6796
  const strand = (top.strand === '-' || top.strand < 0) ? 'antisense' : 'sense';
6797
  const comp = top.composite_score != null ? Number(top.composite_score).toFixed(2) : '—';
 
6799
  if (isBE) {
6800
  m = `Guide RNAs (n=${guides.length}) were designed in silico for ${enzLabel} base editing with the ${escapeHtml(be) || 'selected'} editor (Komor et al., <cite>Nature</cite> 2016; Gaudelli et al., <cite>Nature</cite> 2017). On-target activity used Doench-style sequence features; per-guide editability was scored over the editor's activity window, with the predicted edit and amino-acid consequence reported. The top-ranked guide (${escapeHtml(top.spacer || '')}, ${strand} strand) was selected.`;
6801
  } else {
6802
+ // Everything a reader would need to reproduce or challenge the
6803
+ // ranking, including the two things the paragraph used to leave
6804
+ // out: the composite's weight, and what repair context the indel
6805
+ // numbers assume. Both are read off the run, not asserted here.
6806
+ const cw = top.composite_offtarget_weight;
6807
+ const rankSentence = (typeof cw === 'number')
6808
+ ? ` Guides were ranked by a composite score, on-target × (1 − ${cw} × CFD_max), in which the ${cw} off-target weight is an uncalibrated in-house choice rather than a published constant.`
6809
+ : '';
6810
+ // Scope, stated as what was actually screened. The old closing
6811
+ // sentence claimed "not genome-wide" unconditionally, which stopped
6812
+ // being true the moment the genome search shipped — and is still
6813
+ // the honest answer when no organism was picked.
6814
+ const scopeSentence = (org && screened)
6815
+ ? ` The ${screened} top-ranked guides were additionally screened for genomic off-targets against ${escapeHtml(scopeName)}${scopeQual}; the remaining candidates were not screened.`
6816
+ : ' Off-target assessment is limited to the input sequence and is not genome-wide.';
6817
+ m = `Guide RNAs (n=${guides.length}) were designed in silico for ${enzLabel}. On-target activity was scored from Doench-style sequence features, and off-target potential within the provided sequence by the cutting-frequency-determination (CFD) matrix (Doench et al., <cite>Nat. Biotechnol.</cite> 2016).${rankSentence} Knockout likelihood was estimated from a predicted indel spectrum — microhomology-mediated deletions (Bae et al., <cite>Nat. Methods</cite> 2014) plus a templated +1 insertion class — summarised as the frameshift fraction and the share of the single most likely outcome; that model assumes template-free end-joining repair and uses fixed constants, so it is not conditioned on cell type. The top-ranked guide (${escapeHtml(top.spacer || '')}, ${strand} strand, composite ${comp})${enzyme === 'cas12a' ? ' was selected' : ' was selected, and cloning oligos were generated for the standard BbsI/BsmBI vector'}.${scopeSentence}`;
6818
  }
6819
  tdRenderMethods('crisprRunMeta', [
6820
  ['Mode', isBE ? 'base edit' : 'knockout'],
 
6854
  // previous design had no vector picked).
6855
  lastGuides = data.guides || [];
6856
  crisprData = data;
6857
+ // What the numbers assume, above the numbers. Rendered on every
6858
+ // paint (including sort/filter re-renders) so it can never be
6859
+ // scrolled away from the columns it qualifies.
6860
+ _renderScopeStrip(data);
6861
+ _ensureGenomeScopes();
6862
  if (!_isRerender && window.TDCrisprOutcomes) window.TDCrisprOutcomes.populate(data.guides || []);
6863
  renderVendorButtons();
6864
 
 
10726
  + '<p class="method-what">' + esc(m.what) + '</p>'
10727
  + (m.formula ? '<p class="method-formula">' + esc(m.formula) + '</p>' : '')
10728
  + (m.basis ? '<p class="method-basis">' + esc(m.basis) + '</p>' : '')
10729
+ // "assumes" is the biological context a number is only valid
10730
+ // inside — for the indel columns, template-free end-joining
10731
+ // repair. Rendered as its own line rather than folded into
10732
+ // basis, because it changes whether the number applies at all.
10733
+ + (m.assumes ? '<p class="method-assumes"><strong>Assumes:</strong> '
10734
+ + esc(m.assumes) + '</p>' : '')
10735
  + (m.limits ? '<p class="method-limits' + (isScopeWarning(m.limits) ? ' warn' : '')
10736
  + '">' + esc(m.limits) + '</p>' : '')
10737
  + ((m.citations && m.citations.length)
 
10741
  body.innerHTML = html || '<p class="muted">Methods unavailable.</p>';
10742
  }
10743
 
10744
+ // One in-flight fetch shared by the collapsed provenance block and the
10745
+ // always-visible scope strip above the table. The strip needs
10746
+ // genome_scopes (which organism is indexed over the COMPLETE genome and
10747
+ // which over coding sequence only) and that answer has to come from the
10748
+ // server's own registry — a scope list copied into JS is how a panel ends
10749
+ // up promising a whole-genome screen the engine never ran.
10750
+ let pending = null;
10751
+ function ensure() {
10752
+ if (!pending) {
10753
+ pending = fetch('/api/crispr/methods')
10754
+ .then((r) => r.json())
10755
+ .catch((e) => { pending = null; throw e; });
10756
+ }
10757
+ return pending;
10758
+ }
10759
+
10760
  function load() {
10761
  if (loaded) return;
10762
  loaded = true;
10763
+ ensure()
 
10764
  .then(render)
10765
  .catch(() => {
10766
  const body = document.getElementById('crisprMethodsBody');
 
10771
 
10772
  const det = document.getElementById('crisprMethods');
10773
  if (det) det.addEventListener('toggle', function () { if (det.open) load(); });
10774
+ window.TDMethods = { load: load, ensure: ensure };
10775
  })();
10776
 
10777
  // ═══════════════════════════════════════════════════════════════════════
dee/static/catalog.css CHANGED
@@ -26,18 +26,37 @@
26
  }
27
  .rail-split[hidden] { display: none; }
28
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
  .rail-split-grip {
30
  width: 3px;
31
- height: 46px;
32
  border-radius: 3px;
33
- background: var(--line-strong);
34
- opacity: 0;
35
  transition: opacity var(--t-fast, .12s) var(--ease, ease),
 
36
  background var(--t-fast, .12s) var(--ease, ease);
37
  }
38
  .rail-split:hover .rail-split-grip,
39
  .rail-split:focus-visible .rail-split-grip,
40
- body.is-railsplitting .rail-split-grip { opacity: 1; background: var(--ink-soft); }
 
 
 
 
41
 
42
  .rail-split:focus-visible {
43
  outline: 2px solid var(--ink-strong);
@@ -207,8 +226,18 @@ body.cat-open { overflow: hidden; }
207
  .cat-thumb[data-sc="error"] .cat-thumb-msg::after { content: "Structure unavailable"; }
208
  .cat-thumb[data-sc="ok"] .cat-acc { display: block; }
209
  .cat-thumb[data-sc="none"] .cat-acc { display: none; }
210
- .cat-thumb--cx { color: var(--ink-faint); }
211
- .cat-thumb--cx svg { width: 26px; height: 26px; }
 
 
 
 
 
 
 
 
 
 
212
 
213
  /* ── body ───────────────────────────────────────────────────────────────── */
214
  .cat-main { grid-area: main; min-width: 0; display: flex; flex-direction: column; gap: 5px; }
 
26
  }
27
  .rail-split[hidden] { display: none; }
28
 
29
+ /* Visible AT REST, and this is the whole point of the control.
30
+
31
+ The first version started at opacity 0 and only appeared on hover. Measured
32
+ in the browser: `getComputedStyle(.rail-split-grip).opacity === "0"` with
33
+ the split open and the pointer elsewhere — so the boundary between the
34
+ conversation and the bench looked exactly like the fixed 1px border it used
35
+ to be, and nothing on screen said it could move. A handle you have to
36
+ already know about is not a handle.
37
+
38
+ So it shows: a short grip on the seam, in --line-bold — one step up from
39
+ the .cockpit border-right it sits on, present enough to read as a grab
40
+ point, quiet enough not to become a third vertical rule in the layout. It
41
+ grows and takes ink on hover, focus and drag, which is where the affordance
42
+ should be loud. */
43
  .rail-split-grip {
44
  width: 3px;
45
+ height: 34px;
46
  border-radius: 3px;
47
+ background: var(--line-bold);
48
+ opacity: 1;
49
  transition: opacity var(--t-fast, .12s) var(--ease, ease),
50
+ height var(--t-fast, .12s) var(--ease, ease),
51
  background var(--t-fast, .12s) var(--ease, ease);
52
  }
53
  .rail-split:hover .rail-split-grip,
54
  .rail-split:focus-visible .rail-split-grip,
55
+ body.is-railsplitting .rail-split-grip {
56
+ height: 46px;
57
+ background: var(--ink-soft);
58
+ }
59
+ @media (prefers-reduced-motion: reduce) { .rail-split-grip { transition: none; } }
60
 
61
  .rail-split:focus-visible {
62
  outline: 2px solid var(--ink-strong);
 
226
  .cat-thumb[data-sc="error"] .cat-thumb-msg::after { content: "Structure unavailable"; }
227
  .cat-thumb[data-sc="ok"] .cat-acc { display: block; }
228
  .cat-thumb[data-sc="none"] .cat-acc { display: none; }
229
+ /* A construct has no fold to show, so its tile carries a mark instead of a
230
+ picture. Dropping the border is the point: a 1px frame around a small glyph
231
+ on an empty ground is the shape of a broken image, and that is what it was
232
+ being read as. Without the frame it reads as an icon beside a title, which
233
+ is what it is. */
234
+ .cat-thumb--cx {
235
+ color: var(--ink-faint);
236
+ border-color: transparent;
237
+ background: var(--bg-subtle);
238
+ }
239
+ .cat-thumb--cx svg { width: 34px; height: 34px; }
240
+ .cat-card--cx:hover .cat-thumb--cx { color: var(--ink-soft); }
241
 
242
  /* ── body ───────────────────────────────────────────────────────────────── */
243
  .cat-main { grid-area: main; min-width: 0; display: flex; flex-direction: column; gap: 5px; }
dee/static/catalog.js CHANGED
@@ -10,11 +10,14 @@
10
  conversations.
11
 
12
  This is that catalog. Both kinds of thing in one panel, searchable, with the
13
- two verbs that were missing. And each conversation carries the fold of the
14
  protein it is about, drawn from the run's own saved bench snapshot — because
15
  a scientist finds "the one about the beta-lactamase" by recognising it, not
16
  by reading ten near-identical sentences.
17
 
 
 
 
18
  Depends on: TDStructCard (thumbnails), TDCockpit (switchRun/newRun),
19
  TDBench (openConstruct). Degrades if any is missing rather than throwing.
20
  =========================================================================== */
@@ -175,6 +178,30 @@
175
  primers: "Primers", plasmid: "Map",
176
  };
177
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
178
  function thumbHtml(r) {
179
  if (r.uniprot) {
180
  return '<span class="cat-thumb" data-sc-host data-sc="idle">' +
@@ -228,20 +255,58 @@
228
  "</article>";
229
  }
230
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
231
  function constructCard(c, i) {
232
  var meta = [];
233
  if (c.kind) meta.push(String(c.kind));
234
  var w = when(c.at); if (w) meta.push(w);
235
- return '<article class="cat-card cat-card--cx" data-i="' + i + '" data-cx="' + esc(c.id) + '">' +
 
 
 
 
236
  '<span class="cat-thumb cat-thumb--cx" data-sc="cx" aria-hidden="true">' +
237
- '<svg viewBox="0 0 26 26" fill="none" stroke="currentColor" stroke-width="1.1">' +
238
- '<path d="M6 4c8 4 6 14 14 18M6 22c8-4 6-14 14-18"/></svg></span>' +
239
  '<div class="cat-main">' +
240
  '<button type="button" class="cat-open" data-act="opencx" data-cx="' + esc(c.id) +
241
  '" data-name="' + esc(c.name) + '">' +
242
  '<span class="cat-name">' + esc(c.name) + "</span></button>" +
243
  '<div class="cat-meta">' + esc(meta.join(" · ")) + "</div>" +
244
- "</div></article>";
 
 
 
 
 
 
 
 
245
  }
246
 
247
  /* ── render ─────────────────────────────────────────────────────────── */
@@ -307,7 +372,8 @@
307
  if (act === "open") { openRun(btn.getAttribute("data-run")); return; }
308
  if (act === "opencx") { openConstruct(btn); return; }
309
  if (act === "rename") { startRename(btn.getAttribute("data-run")); return; }
310
- if (act === "delete") { confirmDelete(btn.getAttribute("data-run")); return; }
 
311
  return;
312
  }
313
  var seg = e.target.closest(".cat-segb");
@@ -328,16 +394,17 @@
328
  }
329
  }
330
 
331
- function cardFor(runId) {
332
- if (!state.root) return null;
333
- // Run ids are minted as uuid4().hex, so an attribute-value scan is
334
- // exact without needing CSS.escape (which older Safari lacks).
335
  var all = state.root.querySelectorAll(".cat-card");
336
  for (var i = 0; i < all.length; i++) {
337
- if (all[i].getAttribute("data-run") === runId) return all[i];
338
  }
339
  return null;
340
  }
 
341
 
342
  /* Rename in place. A prompt() would be two fewer lines and would also be a
343
  modal inside a modal that cannot be styled, cannot be cancelled with
@@ -397,26 +464,79 @@
397
  });
398
  }
399
 
400
- /* Two-step delete, inline. There is no undo behind this the transcript
401
- is the record — so the confirm names what is about to go. */
402
- function confirmDelete(runId) {
403
- var card = cardFor(runId);
404
- var run = state.runs.filter(function (r) { return r.runId === runId; })[0];
405
- if (!card || !run || card.querySelector(".cat-confirm")) return;
406
  var bar = document.createElement("div");
407
  bar.className = "cat-confirm";
408
- bar.innerHTML = '<span>Delete “' + esc(run.title || "Untitled run") +
409
- '”? Its transcript goes with it.</span>' +
410
  '<button type="button" class="cat-act is-danger" data-yes>Delete</button>' +
411
  '<button type="button" class="cat-act" data-no>Keep</button>';
412
  card.appendChild(bar);
413
  bar.querySelector("[data-no]").addEventListener("click", function () { bar.remove(); });
414
  bar.querySelector("[data-yes]").addEventListener("click", function () {
415
- bar.remove(); doDelete(runId);
416
  });
417
  bar.querySelector("[data-yes]").focus();
418
  }
419
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
420
  function doDelete(runId) {
421
  state.busy[runId] = 1; paint();
422
  fetch("/api/orchestrator/runs/" + encodeURIComponent(runId), { method: "DELETE" })
 
10
  conversations.
11
 
12
  This is that catalog. Both kinds of thing in one panel, searchable, with the
13
+ verbs that were missing. And each conversation carries the fold of the
14
  protein it is about, drawn from the run's own saved bench snapshot — because
15
  a scientist finds "the one about the beta-lactamase" by recognising it, not
16
  by reading ten near-identical sentences.
17
 
18
+ Constructs are deletable too, as far as the API allows — see CX_DELETE for
19
+ which kinds have a route and which are still a server-side gap.
20
+
21
  Depends on: TDStructCard (thumbnails), TDCockpit (switchRun/newRun),
22
  TDBench (openConstruct). Degrades if any is missing rather than throwing.
23
  =========================================================================== */
 
178
  primers: "Primers", plasmid: "Map",
179
  };
180
 
181
+ /* Where a construct of each kind can be deleted, and what it is called
182
+ while you are being asked to confirm it.
183
+
184
+ The reviewer asked for two things — "no catalog of conversations/chats
185
+ or way to delete 'constructs' and conversations". Conversations got both
186
+ verbs. Constructs got neither, and half of that is a server gap rather
187
+ than a UI one: plasmids and primer analyses have owner-checked DELETE
188
+ routes, saved LIBRARIES and CRISPR DESIGNS have none, in server.py or in
189
+ auth.py. A library is the primary artifact this product makes, so that
190
+ is the bigger half of the hole.
191
+
192
+ This table is the whole switch. A kind with no entry renders no Delete
193
+ button, because the alternative is a button that 404s — and a delete
194
+ control that silently does nothing is worse than an honest absence. Add
195
+ `DELETE /api/library/<id>` and `DELETE /api/crispr/designs/<id>` plus
196
+ their auth.py counterparts and the two commented lines below finish the
197
+ feature; nothing else here changes. */
198
+ var CX_DELETE = {
199
+ plasmid: { path: "/api/plasmid/library/", noun: "plasmid map" },
200
+ primer: { path: "/api/primers/analyses/", noun: "primer analysis" },
201
+ // library: { path: "/api/library/", noun: "variant library" },
202
+ // crispr: { path: "/api/crispr/designs/", noun: "CRISPR design" },
203
+ };
204
+
205
  function thumbHtml(r) {
206
  if (r.uniprot) {
207
  return '<span class="cat-thumb" data-sc-host data-sc="idle">' +
 
255
  "</article>";
256
  }
257
 
258
+ /* One mark per kind of artifact.
259
+
260
+ Every construct used to get the same glyph: two crossing curves meant as
261
+ a DNA helix, drawn without its rungs, at 26px inside a 104x84 bordered
262
+ tile. At that size against an empty frame it reads as a large X — the
263
+ browser's broken-image placeholder, which is exactly what a reviewer
264
+ took it for. Worse, it was the same X for a plasmid map, a primer set, a
265
+ CRISPR design and a variant library, so the tile cost 104px of card and
266
+ carried no information at all.
267
+
268
+ These are the four things this product makes, and each mark says which:
269
+ a ranked list, a circular map, a cut site, a converging pair. Stroke
270
+ glyphs on a 24-unit grid, so they stay legible at the tile's size and
271
+ inherit the card's ink. */
272
+ var CX_GLYPH = {
273
+ library: '<path d="M4 6h16M4 12h11M4 18h6"/>',
274
+ plasmid: '<circle cx="12" cy="12" r="8"/>' +
275
+ '<path d="M12 4a8 8 0 0 1 6.93 4" stroke-width="2.4"/>',
276
+ crispr: '<path d="M3 9h6M15 9h6M3 15h6M15 15h6"/>' +
277
+ '<path d="M12 3v18" stroke-dasharray="2.5 2.5"/>',
278
+ primer: '<path d="M3 12h18"/><path d="M6.5 8.2 10.5 12l-4 3.8"/>' +
279
+ '<path d="M17.5 15.8 13.5 12l4-3.8"/>',
280
+ };
281
+ var CX_GLYPH_FALLBACK = '<path d="M5 5.5c7 3.5 5.5 12.5 12.5 16M5 19.5c7-3.5 5.5-12.5 12.5-16"/>' +
282
+ '<path d="M7.6 8.4h8M8.6 15.6h8"/>';
283
+
284
  function constructCard(c, i) {
285
  var meta = [];
286
  if (c.kind) meta.push(String(c.kind));
287
  var w = when(c.at); if (w) meta.push(w);
288
+ var del = CX_DELETE[c.kind];
289
+ var busy = state.busy[c.id];
290
+ var glyph = CX_GLYPH[c.kind] || CX_GLYPH_FALLBACK;
291
+ return '<article class="cat-card cat-card--cx' + (busy ? " is-busy" : "") +
292
+ '" data-i="' + i + '" data-cx="' + esc(c.id) + '" data-kind="' + esc(c.kind || "") + '">' +
293
  '<span class="cat-thumb cat-thumb--cx" data-sc="cx" aria-hidden="true">' +
294
+ '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" ' +
295
+ 'stroke-linecap="round" stroke-linejoin="round">' + glyph + "</svg></span>" +
296
  '<div class="cat-main">' +
297
  '<button type="button" class="cat-open" data-act="opencx" data-cx="' + esc(c.id) +
298
  '" data-name="' + esc(c.name) + '">' +
299
  '<span class="cat-name">' + esc(c.name) + "</span></button>" +
300
  '<div class="cat-meta">' + esc(meta.join(" · ")) + "</div>" +
301
+ (_notes[c.id] ? '<p class="cat-note" role="status">' + esc(_notes[c.id]) + "</p>" : "") +
302
+ "</div>" +
303
+ (del
304
+ ? '<div class="cat-acts">' +
305
+ '<button type="button" class="cat-act is-danger" data-act="delcx" data-cx="' + esc(c.id) +
306
+ '" aria-label="Delete this ' + esc(del.noun) + '" title="Delete">Delete</button>' +
307
+ "</div>"
308
+ : "") +
309
+ "</article>";
310
  }
311
 
312
  /* ── render ─────────────────────────────────────────────────────────── */
 
372
  if (act === "open") { openRun(btn.getAttribute("data-run")); return; }
373
  if (act === "opencx") { openConstruct(btn); return; }
374
  if (act === "rename") { startRename(btn.getAttribute("data-run")); return; }
375
+ if (act === "delete") { confirmDeleteRun(btn.getAttribute("data-run")); return; }
376
+ if (act === "delcx") { confirmDeleteConstruct(btn.getAttribute("data-cx")); return; }
377
  return;
378
  }
379
  var seg = e.target.closest(".cat-segb");
 
394
  }
395
  }
396
 
397
+ function cardBy(attr, id) {
398
+ if (!state.root || !id) return null;
399
+ // Ids are minted as uuid4().hex, so an attribute-value scan is exact
400
+ // without needing CSS.escape (which older Safari lacks).
401
  var all = state.root.querySelectorAll(".cat-card");
402
  for (var i = 0; i < all.length; i++) {
403
+ if (all[i].getAttribute(attr) === id) return all[i];
404
  }
405
  return null;
406
  }
407
+ function cardFor(runId) { return cardBy("data-run", runId); }
408
 
409
  /* Rename in place. A prompt() would be two fewer lines and would also be a
410
  modal inside a modal that cannot be styled, cannot be cancelled with
 
464
  });
465
  }
466
 
467
+ /* Two-step delete, inline. There is no undo behind any of these, so the
468
+ confirm names the thing that is about to go rather than asking "are you
469
+ sure?" about an unnamed row. */
470
+ function confirmDelete(card, message, onYes) {
471
+ if (!card || card.querySelector(".cat-confirm")) return;
 
472
  var bar = document.createElement("div");
473
  bar.className = "cat-confirm";
474
+ bar.innerHTML = "<span>" + message + "</span>" +
 
475
  '<button type="button" class="cat-act is-danger" data-yes>Delete</button>' +
476
  '<button type="button" class="cat-act" data-no>Keep</button>';
477
  card.appendChild(bar);
478
  bar.querySelector("[data-no]").addEventListener("click", function () { bar.remove(); });
479
  bar.querySelector("[data-yes]").addEventListener("click", function () {
480
+ bar.remove(); onYes();
481
  });
482
  bar.querySelector("[data-yes]").focus();
483
  }
484
 
485
+ function confirmDeleteRun(runId) {
486
+ var run = state.runs.filter(function (r) { return r.runId === runId; })[0];
487
+ if (!run) return;
488
+ confirmDelete(cardFor(runId),
489
+ "Delete “" + esc(run.title || "Untitled run") +
490
+ "”? Its transcript goes with it.",
491
+ function () { doDelete(runId); });
492
+ }
493
+
494
+ /* Constructs are saved WORK — a plasmid map, a primer analysis — not a
495
+ conversation about it, so the confirm says which kind is going and does
496
+ not promise anything about the run that produced it. */
497
+ function confirmDeleteConstruct(cxId) {
498
+ var cx = state.constructs.filter(function (c) { return String(c.id) === String(cxId); })[0];
499
+ if (!cx) return;
500
+ var del = CX_DELETE[cx.kind];
501
+ if (!del) return;
502
+ confirmDelete(cardBy("data-cx", cxId),
503
+ "Delete the " + esc(del.noun) + " “" + esc(cx.name || "Untitled") +
504
+ "”? This removes the saved record.",
505
+ function () { doDeleteConstruct(cxId); });
506
+ }
507
+
508
+ function doDeleteConstruct(cxId) {
509
+ var cx = state.constructs.filter(function (c) { return String(c.id) === String(cxId); })[0];
510
+ var del = cx && CX_DELETE[cx.kind];
511
+ if (!del) return;
512
+ state.busy[cxId] = 1; paint();
513
+ fetch(del.path + encodeURIComponent(cxId), { method: "DELETE" })
514
+ .then(function (r) {
515
+ return r.json().catch(function () { return { ok: r.ok }; });
516
+ })
517
+ .then(function (d) {
518
+ delete state.busy[cxId];
519
+ if (d && d.ok) {
520
+ state.constructs = state.constructs.filter(function (c) {
521
+ return String(c.id) !== String(cxId);
522
+ });
523
+ // Mission Control lists the same constructs from the same
524
+ // endpoint. Leaving it showing a card whose record is gone
525
+ // is the silted-up-dropdown problem in a second place.
526
+ try {
527
+ if (window.TDMission && window.TDMission.reload) window.TDMission.reload();
528
+ } catch (e) {}
529
+ } else {
530
+ note(cxId, (d && d.error) || "Couldn't delete that.");
531
+ }
532
+ paint();
533
+ }).catch(function () {
534
+ delete state.busy[cxId];
535
+ note(cxId, "Couldn't reach the server.");
536
+ paint();
537
+ });
538
+ }
539
+
540
  function doDelete(runId) {
541
  state.busy[runId] = 1; paint();
542
  fetch("/api/orchestrator/runs/" + encodeURIComponent(runId), { method: "DELETE" })
dee/static/cockpit.js CHANGED
@@ -45,6 +45,12 @@
45
  timer: null,
46
  els: null,
47
  toolNodes: {}, // tool_call id → its DOM block, so the result fills it in
 
 
 
 
 
 
48
  mounted: false,
49
  stick: true, // follow the tail? false once the user scrolls up
50
  queue: [], // messages typed while the agent was working
@@ -383,10 +389,23 @@
383
  /* Cost and context are already computed server-side and returned
384
  on every response — they were simply never shown. For someone
385
  deciding whether to pay, "what did that cost and how full is
386
- the context" is the question. */
 
 
 
 
 
 
 
 
 
 
 
387
  '<div class="cp-meter" id="cpMeter" hidden>' +
388
  '<span class="cp-meter-bar"><span class="cp-meter-fill" id="cpMeterFill"></span></span>' +
389
  '<span class="cp-meter-t" id="cpMeterText"></span>' +
 
 
390
  '</div>' +
391
  '<div class="cp-transcript" id="cpTranscript" role="log" aria-live="polite"></div>' +
392
  '<button type="button" class="cp-jump" id="cpJump" hidden>&darr; Jump to latest</button>' +
@@ -412,8 +431,14 @@
412
  meter: root.querySelector("#cpMeter"),
413
  meterFill: root.querySelector("#cpMeterFill"),
414
  meterText: root.querySelector("#cpMeterText"),
 
415
  };
416
 
 
 
 
 
 
417
  state.els.newBtn.addEventListener("click", function () {
418
  closeMenu();
419
  newRun();
@@ -621,9 +646,78 @@
621
  }, 1400);
622
  }
623
 
 
 
 
 
 
 
 
 
 
 
624
  function addError(text) {
625
- addNode("cp-err",
626
- '<span class="cp-err-i" aria-hidden="true">!</span><div>' + esc(text) + "</div>");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
627
  }
628
 
629
  /* A tool in flight. Returns the node so the matching result can fill it
@@ -671,6 +765,12 @@
671
  if (!node) node = addToolCall({ verb: ev.name, name: ev.name, replay: true });
672
  node.classList.remove("cp-tool--running");
673
  node.classList.add(ev.ok ? "cp-tool--ok" : "cp-tool--fail");
 
 
 
 
 
 
674
 
675
  // Freeze the clock at the real duration — how long a step took is
676
  // part of the record, not just reassurance while it runs.
@@ -870,9 +970,12 @@
870
  };
871
  function _viewFor(name) { return TOOL_VIEW[name] || null; }
872
 
 
 
 
873
  var PAINTERS = {
874
- design_variant_library: function (panel) {
875
- return !!(window.TDDesign && window.TDDesign.paintAgentRun(panel));
876
  },
877
  design_crispr_guides: function (panel) {
878
  return !!(window.TDCrispr && window.TDCrispr.paintAgentRun(panel));
@@ -915,7 +1018,8 @@
915
  alphafold_page: p.alphafold_page || "",
916
  });
917
  if (live) gotoView(ui.view);
918
- } else if (PAINTERS[ev.name] && ui.panel && PAINTERS[ev.name](ui.panel)) {
 
919
  if (live) gotoView(ui.view);
920
  }
921
  // After gotoView: opening the bench stamps the goal sentence as the
@@ -1232,6 +1336,14 @@
1232
  // Keep every event, in order, for the export. This is the only
1233
  // complete record the client has of what actually happened.
1234
  state.transcript.push(ev);
 
 
 
 
 
 
 
 
1235
  switch (ev.kind) {
1236
  case "user":
1237
  // Live, we already echoed this locally for immediacy. On a
@@ -1246,6 +1358,7 @@
1246
  break;
1247
  case "text": stopThinking(); addText(ev.text, ev.replay); break;
1248
  case "tool_call":
 
1249
  stopThinking(); addToolCall(ev);
1250
  // Show it on the CANVAS too, not only in the rail — and move
1251
  // to where the output will land before it arrives.
@@ -1333,6 +1446,12 @@
1333
  if (typeof d.cost_usd === "number") state.cost = d.cost_usd;
1334
  if (typeof d.context_tokens_used === "number") state.ctxUsed = d.context_tokens_used;
1335
  if (typeof d.context_tokens_limit === "number") state.ctxLimit = d.context_tokens_limit;
 
 
 
 
 
 
1336
  renderMeter();
1337
  setStatus(d.status);
1338
  // A settled run can't be timing anything; leaving intervals
@@ -1649,12 +1768,17 @@
1649
  function reset() {
1650
  clearTimers();
1651
  stopThinking();
1652
- state.runId = null; state.lastSeq = 0; state.toolNodes = {}; _title = "";
 
1653
  state.pendingEcho = []; state.ready = []; state.ident = {};
1654
  _planNode = null; // a new run gets a fresh checklist, not the old one
1655
  state.digest = {}; state.transcript = [];
1656
  state.cost = 0; state.ctxUsed = 0; state.ctxLimit = 0;
1657
  _noted = false; _phase = 0; _seq = "";
 
 
 
 
1658
  saveRun();
1659
  setStatus("idle");
1660
  renderMeter();
@@ -1926,22 +2050,57 @@
1926
  }, 0);
1927
  }
1928
 
1929
- /* ── cost + context meter ────────────────────────────────────────── */
 
 
 
 
 
 
 
 
1930
  function renderMeter() {
1931
  if (!state.els) return;
1932
- if (!state.ctxLimit && !state.cost) { state.els.meter.hidden = true; return; }
 
 
 
 
 
 
1933
  var pct = state.ctxLimit
1934
- ? Math.min(100, Math.round((state.ctxUsed / state.ctxLimit) * 100)) : 0;
1935
  state.els.meter.hidden = false;
1936
  state.els.meterFill.style.width = pct + "%";
1937
  state.els.meter.classList.toggle("cp-meter--warn", pct >= 80);
 
1938
  var bits = [];
1939
- if (state.ctxLimit) bits.push("context " + pct + "%");
 
 
 
 
 
 
 
 
 
 
1940
  // Sub-cent runs are the norm; "$0.00" reads as broken, so show the
1941
  // real figure at the precision it actually has.
1942
  if (state.cost) bits.push("$" + (state.cost < 0.01
1943
- ? state.cost.toFixed(4) : state.cost.toFixed(2)));
1944
  state.els.meterText.textContent = bits.join(" · ");
 
 
 
 
 
 
 
 
 
 
1945
  }
1946
 
1947
  /* ── elapsed timers ──────────────────────────────────────────────────
 
45
  timer: null,
46
  els: null,
47
  toolNodes: {}, // tool_call id → its DOM block, so the result fills it in
48
+ /* tool_call id → the arguments the agent passed. The RESULT event
49
+ carries what came back and nothing about what was asked for, so
50
+ without this the design panel cannot say "you asked for 30, 10 were
51
+ scored" — which is exactly the sentence a reviewer went looking for
52
+ and could not find. */
53
+ toolArgs: {},
54
  mounted: false,
55
  stick: true, // follow the tail? false once the user scrolls up
56
  queue: [], // messages typed while the agent was working
 
389
  /* Cost and context are already computed server-side and returned
390
  on every response — they were simply never shown. For someone
391
  deciding whether to pay, "what did that cost and how full is
392
+ the context" is the question.
393
+
394
+ It shipped as the bare string "context 0% · $0.11", which a
395
+ reviewing scientist read as noise, and fairly: neither number
396
+ named what it measured, and a sub-1% context reading rendered
397
+ as a flat "0%" that looked broken. Both are kept — hiding cost
398
+ from someone deciding whether to keep going is worse than
399
+ showing it — but named, given their denominator, and given the
400
+ decision trace as somewhere to read what they mean in full
401
+ sentences. The row doubles as the trace's entry point, which is
402
+ why it now shows whenever there is a run rather than only once
403
+ a cost has accrued. */
404
  '<div class="cp-meter" id="cpMeter" hidden>' +
405
  '<span class="cp-meter-bar"><span class="cp-meter-fill" id="cpMeterFill"></span></span>' +
406
  '<span class="cp-meter-t" id="cpMeterText"></span>' +
407
+ '<button type="button" class="cp-trace-btn" id="cpTrace" hidden ' +
408
+ 'aria-label="Open the decision trace — every step of this run">Trace</button>' +
409
  '</div>' +
410
  '<div class="cp-transcript" id="cpTranscript" role="log" aria-live="polite"></div>' +
411
  '<button type="button" class="cp-jump" id="cpJump" hidden>&darr; Jump to latest</button>' +
 
431
  meter: root.querySelector("#cpMeter"),
432
  meterFill: root.querySelector("#cpMeterFill"),
433
  meterText: root.querySelector("#cpMeterText"),
434
+ traceBtn: root.querySelector("#cpTrace"),
435
  };
436
 
437
+ state.els.traceBtn.addEventListener("click", function () {
438
+ closeMenu();
439
+ if (window.TDTrace) window.TDTrace.toggle();
440
+ });
441
+
442
  state.els.newBtn.addEventListener("click", function () {
443
  closeMenu();
444
  newRun();
 
646
  }, 1400);
647
  }
648
 
649
+ /* The same error, three times in a row, is ONE problem — not three.
650
+ The 2026-07-30 reviewer log has a stack of identical red blocks on a
651
+ single request; every copy after the first adds alarm and no
652
+ information. Consecutive duplicates collapse into one block with a
653
+ repeat count, so nothing is hidden and nothing is shouted twice.
654
+
655
+ Only CONSECUTIVE ones: two identical errors with real work between them
656
+ are two events in the record, and merging those would lose the fact
657
+ that it happened again later. */
658
+ var _lastErr = null; // { text, node, n }
659
  function addError(text) {
660
+ var msg = String(text || "");
661
+ var tail = state.els && state.els.transcript.lastElementChild;
662
+ if (_lastErr && _lastErr.node === tail && _lastErr.text === msg) {
663
+ _lastErr.n += 1;
664
+ /* A data-attribute hook, not a class: dee/static/app.css is owned
665
+ elsewhere, and inventing a class name there would be a silent
666
+ no-op (see docs/ENGINEERING.md §13.7). */
667
+ var c = _lastErr.node.querySelector("[data-cp-err-n]");
668
+ if (c) c.textContent = " ×" + _lastErr.n;
669
+ scrollDown();
670
+ return _lastErr.node;
671
+ }
672
+ var node = addNode("cp-err",
673
+ '<span class="cp-err-i" aria-hidden="true">!</span><div>' + esc(msg) +
674
+ "<span data-cp-err-n></span></div>");
675
+ _lastErr = { text: msg, node: node, n: 1 };
676
+ return node;
677
+ }
678
+
679
+ /* An attempt that was RETRIED and then worked is not a failure — it is
680
+ the agent recovering, which is the behaviour we want it to have.
681
+
682
+ Reviewer, 2026-07-30: three red "couldn't find the gene" blocks on a
683
+ request that went on to produce a correct answer (286 aa → 861 bp, the
684
+ reviewer checked it). The run was fine; the transcript read like it had
685
+ broken three times. Rendering a superseded attempt at full error weight
686
+ makes a working recovery look like a failing product.
687
+
688
+ So when a tool SUCCEEDS, earlier failed attempts at the SAME tool are
689
+ demoted in place: the red border and red text come off, the cross
690
+ becomes a neutral retry mark, and the line says plainly that the
691
+ attempt returned nothing and was retried. Nothing is deleted, nothing
692
+ is recoloured green, and the original error stays on the node's title —
693
+ the transcript export reads the event log, not the DOM, so the full
694
+ record is untouched either way. */
695
+ function demoteSupersededAttempts(name) {
696
+ if (!name || !state.els) return 0;
697
+ var nodes = state.els.transcript.querySelectorAll(
698
+ '[data-cp-tool="' + name + '"][data-cp-state="fail"]');
699
+ for (var i = 0; i < nodes.length; i++) {
700
+ var n = nodes[i];
701
+ n.setAttribute("data-cp-state", "superseded");
702
+ n.classList.remove("cp-tool--fail");
703
+ var cross = n.querySelector(".cp-cross");
704
+ if (cross) {
705
+ /* No class: .cp-cross is red by definition and .cp-tick is
706
+ green, and neither is honest here. An unclassed span
707
+ inherits the row's own mono font and ink. The inline
708
+ flex-basis replaces the one .cp-cross was providing —
709
+ deliberately inline rather than a new rule, since the
710
+ stylesheet is another workstream's file. */
711
+ cross.outerHTML =
712
+ '<span aria-hidden="true" style="flex:0 0 auto">↻</span>';
713
+ }
714
+ var sum = n.querySelector(".cp-tool-sum");
715
+ if (sum) {
716
+ if (!n.getAttribute("title")) n.setAttribute("title", sum.textContent);
717
+ sum.textContent = "no result — retried";
718
+ }
719
+ }
720
+ return nodes.length;
721
  }
722
 
723
  /* A tool in flight. Returns the node so the matching result can fill it
 
765
  if (!node) node = addToolCall({ verb: ev.name, name: ev.name, replay: true });
766
  node.classList.remove("cp-tool--running");
767
  node.classList.add(ev.ok ? "cp-tool--ok" : "cp-tool--fail");
768
+ // Tagged so a later success at the same tool can find it. Attributes
769
+ // rather than an in-memory index, because a reload replays the whole
770
+ // transcript from the event log and any index would be empty.
771
+ node.setAttribute("data-cp-tool", String(ev.name || ""));
772
+ node.setAttribute("data-cp-state", ev.ok ? "ok" : "fail");
773
+ if (ev.ok) demoteSupersededAttempts(ev.name);
774
 
775
  // Freeze the clock at the real duration — how long a step took is
776
  // part of the record, not just reassurance while it runs.
 
970
  };
971
  function _viewFor(name) { return TOOL_VIEW[name] || null; }
972
 
973
+ /* Second argument is the tool_call's own args (state.toolArgs). Painters
974
+ that don't need it simply ignore it, so this stays additive — only the
975
+ design panel currently reads it, to state requested-vs-scored. */
976
  var PAINTERS = {
977
+ design_variant_library: function (panel, args) {
978
+ return !!(window.TDDesign && window.TDDesign.paintAgentRun(panel, args));
979
  },
980
  design_crispr_guides: function (panel) {
981
  return !!(window.TDCrispr && window.TDCrispr.paintAgentRun(panel));
 
1018
  alphafold_page: p.alphafold_page || "",
1019
  });
1020
  if (live) gotoView(ui.view);
1021
+ } else if (PAINTERS[ev.name] && ui.panel &&
1022
+ PAINTERS[ev.name](ui.panel, (ev.id && state.toolArgs[ev.id]) || {})) {
1023
  if (live) gotoView(ui.view);
1024
  }
1025
  // After gotoView: opening the bench stamps the goal sentence as the
 
1336
  // Keep every event, in order, for the export. This is the only
1337
  // complete record the client has of what actually happened.
1338
  state.transcript.push(ev);
1339
+ /* Same events, second consumer. The rail renders them as a river that
1340
+ scrolls away; the trace holds them still and numbered. Fed here, at
1341
+ the single point every event passes through, so a replay after
1342
+ reload rebuilds the trace exactly as it rebuilds the transcript —
1343
+ and so no future event kind can reach one surface but not the
1344
+ other. TDTrace.push swallows its own errors; a trace row must never
1345
+ be able to kill a run. */
1346
+ if (window.TDTrace) window.TDTrace.push(ev);
1347
  switch (ev.kind) {
1348
  case "user":
1349
  // Live, we already echoed this locally for immediacy. On a
 
1358
  break;
1359
  case "text": stopThinking(); addText(ev.text, ev.replay); break;
1360
  case "tool_call":
1361
+ if (ev.id) state.toolArgs[ev.id] = ev.args || {};
1362
  stopThinking(); addToolCall(ev);
1363
  // Show it on the CANVAS too, not only in the rail — and move
1364
  // to where the output will land before it arrives.
 
1446
  if (typeof d.cost_usd === "number") state.cost = d.cost_usd;
1447
  if (typeof d.context_tokens_used === "number") state.ctxUsed = d.context_tokens_used;
1448
  if (typeof d.context_tokens_limit === "number") state.ctxLimit = d.context_tokens_limit;
1449
+ if (window.TDTrace) {
1450
+ window.TDTrace.setMeta({
1451
+ cost: state.cost, ctxUsed: state.ctxUsed,
1452
+ ctxLimit: state.ctxLimit, status: d.status,
1453
+ });
1454
+ }
1455
  renderMeter();
1456
  setStatus(d.status);
1457
  // A settled run can't be timing anything; leaving intervals
 
1768
  function reset() {
1769
  clearTimers();
1770
  stopThinking();
1771
+ state.runId = null; state.lastSeq = 0; state.toolNodes = {}; state.toolArgs = {};
1772
+ _title = "";
1773
  state.pendingEcho = []; state.ready = []; state.ident = {};
1774
  _planNode = null; // a new run gets a fresh checklist, not the old one
1775
  state.digest = {}; state.transcript = [];
1776
  state.cost = 0; state.ctxUsed = 0; state.ctxLimit = 0;
1777
  _noted = false; _phase = 0; _seq = "";
1778
+ // The trace is per-run. Carrying the previous run's steps into a new
1779
+ // one would be the same mistake the plan node used to make — a stale
1780
+ // record presented as the current one.
1781
+ if (window.TDTrace) { window.TDTrace.reset(); window.TDTrace.close(); }
1782
  saveRun();
1783
  setStatus("idle");
1784
  renderMeter();
 
2050
  }, 0);
2051
  }
2052
 
2053
+ /* ── the run strip: context, cost, and the way into the trace ────────
2054
+ Was "context 0% · $0.11" — two numbers, no nouns, no denominators. */
2055
+ function fmtTokens(n) {
2056
+ n = Number(n) || 0;
2057
+ if (n >= 1000000) return (n / 1000000).toFixed(n % 1000000 === 0 ? 0 : 1) + "M";
2058
+ if (n >= 1000) return Math.round(n / 1000) + "k";
2059
+ return String(n);
2060
+ }
2061
+
2062
  function renderMeter() {
2063
  if (!state.els) return;
2064
+ var steps = window.TDTrace ? window.TDTrace.count() : 0;
2065
+ // The strip now also carries the trace button, so it appears as soon
2066
+ // as the run has done anything — not only once a cost has accrued.
2067
+ if (!state.ctxLimit && !state.cost && !steps) {
2068
+ state.els.meter.hidden = true;
2069
+ return;
2070
+ }
2071
  var pct = state.ctxLimit
2072
+ ? Math.min(100, (state.ctxUsed / state.ctxLimit) * 100) : 0;
2073
  state.els.meter.hidden = false;
2074
  state.els.meterFill.style.width = pct + "%";
2075
  state.els.meter.classList.toggle("cp-meter--warn", pct >= 80);
2076
+
2077
  var bits = [];
2078
+ if (state.ctxLimit) {
2079
+ // "0%" on a 1M window is the common case and reads as broken or
2080
+ // as a placeholder, so the token counts carry the magnitude here.
2081
+ // The percentage is added back only once it's the point — the bar
2082
+ // shows the fraction the rest of the time, the trace states it
2083
+ // exactly, and at 390px (the rail's real width) the extra "(4%)"
2084
+ // is what pushes "model cost" onto a second line.
2085
+ bits.push("Context " + fmtTokens(state.ctxUsed) + " / " +
2086
+ fmtTokens(state.ctxLimit) +
2087
+ (pct >= 50 ? " (" + Math.round(pct) + "%)" : ""));
2088
+ }
2089
  // Sub-cent runs are the norm; "$0.00" reads as broken, so show the
2090
  // real figure at the precision it actually has.
2091
  if (state.cost) bits.push("$" + (state.cost < 0.01
2092
+ ? state.cost.toFixed(4) : state.cost.toFixed(2)) + " model cost");
2093
  state.els.meterText.textContent = bits.join(" · ");
2094
+ // The full sentences live in the trace, but a hover here costs
2095
+ // nothing and helps the reader who never opens it.
2096
+ state.els.meter.title =
2097
+ "Context — how much of the model's " + Number(state.ctxLimit || 0).toLocaleString() +
2098
+ "-token window this conversation occupies; when it fills, earlier steps are " +
2099
+ "condensed rather than dropped. Model cost — USD spent on model calls in this run " +
2100
+ "so far. Open the decision trace for the full step-by-step record.";
2101
+
2102
+ state.els.traceBtn.hidden = !steps;
2103
+ state.els.traceBtn.textContent = "Trace · " + steps;
2104
  }
2105
 
2106
  /* ── elapsed timers ──────────────────────────────────────────────────
dee/static/index.html CHANGED
@@ -112,11 +112,21 @@
112
  <!-- ?v= query bumps invalidate browser + iframe asset caches when app.css /
113
  app.js change. Bump these numbers whenever you ship a frontend update —
114
  without them, users keep getting the stale file for up to a week. -->
115
- <link rel="stylesheet" href="/static/app.css?v=20260731-structpanel" />
116
  <!-- The work catalog + the draggable rail. Kept out of app.css so two new
117
  self-contained surfaces stay reviewable; every colour is an app.css
118
  token, so both themes work with nothing added. -->
119
- <link rel="stylesheet" href="/static/catalog.css?v=20260801-catalog" />
 
 
 
 
 
 
 
 
 
 
120
  <link rel="icon" type="image/svg+xml" href="/static/favicon.svg?v=2" />
121
  <link rel="apple-touch-icon" href="/static/favicon.svg?v=2" />
122
  <!-- Mol* (PDBe) 3-D viewer is ~4.9 MB. We do NOT eager-load it on every
@@ -746,6 +756,19 @@
746
  <span class="setting-name">Variants to generate</span>
747
  <span class="setting-hint">Top-K multi-mutants returned in the library.</span>
748
  <input type="number" id="settingK" value="30" min="5" max="500" step="5" />
 
 
 
 
 
 
 
 
 
 
 
 
 
749
  </label>
750
 
751
  <label class="setting">
@@ -892,6 +915,12 @@
892
  real spread) — see _renderLearnedPanel in app.js. -->
893
  <section class="learned-panel" id="learnedPanel" hidden>
894
  <p class="card-kicker">&sect; What Turing learned</p>
 
 
 
 
 
 
895
  <div class="learned-list" id="learnedList"></div>
896
  </section>
897
  <!-- Predicted-vs-measured calibration — client-side only, built
@@ -903,16 +932,30 @@
903
  <div class="calib-body" id="calibBody"></div>
904
  </section>
905
 
906
- <!-- Mutation map (lollipop chart). Was carrying a chip-
907
- legend (Low / Med / High dots) that read as
908
- dashboard scaffolding; legend text is now inline
909
- in the description. -->
 
 
 
 
 
 
910
  <div class="mutmap-card" id="mutmapCard">
911
  <div class="mutmap-head">
912
  <h3>Mutation landscape</h3>
913
- <p class="muted">Where mutations land across the protein. Bar height counts variants sharing a position; color encodes ΔLL strength (faint &middot; medium &middot; saturated).</p>
 
 
 
 
 
 
 
914
  </div>
915
  <div class="mutmap-canvas" id="mutmapCanvas"></div>
 
916
  </div>
917
 
918
  <!-- ================= INTERACTION RADAR (Pillar 1) =================
@@ -936,6 +979,24 @@
936
  <p class="radar-foot muted" id="radarFoot" hidden></p>
937
  </section>
938
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
939
  <!-- Filter bar — instant client-side filter over the rendered table. -->
940
  <div class="table-toolbar">
941
  <div class="filter-input-wrap">
@@ -954,7 +1015,10 @@
954
  <tr>
955
  <th class="sortable" data-sort="rank" title="Rank by predicted fitness (1 = best).">Rank <span class="sort-ind">↕</span></th>
956
  <th class="sortable" data-sort="mutations" title="Substitutions vs. the wild-type protein, in the format WT_aa-position-new_aa.">Mutations <span class="sort-ind">↕</span></th>
957
- <th class="num sortable" data-sort="fitness" title="Cumulative ΣΔLL from ESM-2: sum of log-likelihood improvements over WT across all substitutions in this variant.">Fitness <span class="sort-ind">↕</span></th>
 
 
 
958
  <th class="num sortable" data-sort="gc" title="GC content of the codon-optimized DNA. PCR-friendly range: 40–60%.">GC % <span class="sort-ind">↕</span></th>
959
  <th class="num sortable" data-sort="tm" title="Cooler of the two PCR primer Tm values (nearest-neighbor, standard salt). Annealing = Tm − 5°C for Q5/Phusion.">Tm (°C) <span class="sort-ind">↕</span></th>
960
  <th class="num sortable" data-sort="bp" title="Amplicon length in base pairs. Same for every variant in this library (length-preserving substitutions only).">bp <span class="sort-ind">↕</span></th>
@@ -1306,9 +1370,16 @@
1306
  <th>Spacer</th>
1307
  <th>PAM</th>
1308
  <!-- Composite is the ranking column —
1309
- on-target × (1 − CFD-penalty).
 
 
 
 
 
 
 
1310
  Always the primary sort. -->
1311
- <th class="num crispr-sortable" data-sort-key="composite_score" title="On-target × (1 − CFD off-target penalty). Click to sort.">Composite</th>
1312
  <th class="num crispr-sortable" data-sort-key="on_target_score" title="TuringDNA on-target activity score (Doench-inspired sequence-only features). 1.0 = expected strong cut. Click to sort.">On-target</th>
1313
  <!-- CFD-against-input is the Phase 1
1314
  differentiator. 0.00 = no other
@@ -1324,17 +1395,17 @@
1324
  e.g. "-3 ATG (28%)" or "+1 T (32%)". The
1325
  dominant outcome usually accounts for 15-40%
1326
  of all repair events. -->
1327
- <th class="col-ko" title="Single most-likely repair outcome (inDelphi-inspired). The number is the predicted frequency of THIS outcome among all repair events.">Top indel</th>
1328
  <!-- Phase 2A: % of predicted repair outcomes that
1329
  introduce a frameshift. Higher = more likely
1330
  to produce a true loss-of-function allele. -->
1331
- <th class="num col-ko crispr-sortable" data-sort-key="frameshift_pct" title="% of predicted repair outcomes that introduce a frameshift (non-multiple-of-3 indel). Higher = stronger KO likelihood. Click to sort.">FS %</th>
1332
  <!-- Phase 2A: dominance of the SINGLE most-likely
1333
  outcome, as a percent. Direct biological
1334
  readout — "will I get one main edit product
1335
  or many?" 40%+ = clean, 25-40% = typical,
1336
  <25% = messy heterogeneous repair. -->
1337
- <th class="num col-ko crispr-sortable" data-sort-key="top_dominance_pct" title="Frequency of the single most-likely repair outcome. 40%+ = clean; <25% = messy. Click to sort.">Dominance</th>
1338
  <!-- Phase 2A: does the spacer's editing window
1339
  (positions 4-8) hold a C or A? If yes, this
1340
  guide could ALSO be used for a point edit
@@ -1346,12 +1417,16 @@
1346
  <th class="num col-be crispr-sortable" data-sort-key="be_editability" title="Strongest in-window editing activity for a target base (0–1). Higher = more likely. Click to sort.">Editability</th>
1347
  <th class="col-be" title="Amino-acid consequence of the edit when a reading frame is known (gene symbol given, or pasted sequence is a coding sequence). '* (stop gained)' = a DSB-free knockout via CRISPR-STOP.">AA change</th>
1348
  <th class="col-be" title="A clean edit changes a single base in the window; a bystander edit hits more than one. 'STOP' marks guides that install a premature stop codon (knockout by base editing).">Outcome</th>
1349
- <!-- Phase 2B-1: scored against the full chosen
1350
- organism's genome (currently E. coli only on
1351
- the free tier). Empty when no organism is
1352
  picked; "unique" when the organism was
1353
- searched and no real off-targets found. -->
1354
- <th class="num" title="Worst CFD score against any site in the chosen organism's genome. 'unique' = clean (no real off-targets); 0.5+ = strong off-target that may cleave (look at the tooltip for location).">Genome off</th>
 
 
 
 
 
1355
  <!-- Phase 2B-1: only populated when the user
1356
  gave a gene symbol AND the input could be
1357
  aligned to the gene's CDS. Shows exon
@@ -2393,16 +2468,21 @@
2393
  <!-- Cloning reference data must load before app.js so the Designer
2394
  can read VECTORS / ENZYMES / CLONING_METHODS / TAGS / LINKERS. -->
2395
  <script src="/static/cloning_db.js?v=20260530-ui-polish" defer></script>
2396
- <script src="/static/app.js?v=20260731-structpanel" defer></script>
 
 
 
 
 
2397
  <!-- THE COCKPIT — the persistent orchestrator rail. Loads after app.js so
2398
  TDBench/TDStructure exist when a tool result asks the workspace to
2399
  render something. This is the only conversation surface in the app. -->
2400
- <script src="/static/cockpit.js?v=20260730-editor10" defer></script>
2401
  <!-- structcard before catalog: the catalog calls TDStructCard.observe as
2402
  soon as it paints. Both are defer, so document order is load order. -->
2403
- <script src="/static/structcard.js?v=20260801-catalog" defer></script>
2404
- <script src="/static/catalog.js?v=20260801-catalog" defer></script>
2405
- <script src="/static/railsplit.js?v=20260801-catalog" defer></script>
2406
  <!-- Dwell-time heartbeat. Loads after auth.js so its /api/ping calls go
2407
  through the JWT-attaching fetch wrapper (signed-in attribution). -->
2408
  <script src="/static/telemetry.js?v=20260622-analytics" defer></script>
 
112
  <!-- ?v= query bumps invalidate browser + iframe asset caches when app.css /
113
  app.js change. Bump these numbers whenever you ship a frontend update —
114
  without them, users keep getting the stale file for up to a week. -->
115
+ <link rel="stylesheet" href="/static/app.css?v=20260801-review" />
116
  <!-- The work catalog + the draggable rail. Kept out of app.css so two new
117
  self-contained surfaces stay reviewable; every colour is an app.css
118
  token, so both themes work with nothing added. -->
119
+ <link rel="stylesheet" href="/static/catalog.css?v=20260801-review" />
120
+ <!-- Units, the run strip and the decision trace. Same reasoning as
121
+ catalog.css: kept out of app.css so a self-contained surface stays
122
+ reviewable, and every colour is an app.css token so both themes
123
+ work with nothing added. -->
124
+ <link rel="stylesheet" href="/static/trace.css?v=20260801-review" />
125
+ <!-- Disclosure surfaces added in the 2026-08 scientific review (CRISPR
126
+ scope strip, provenance "Assumes" line). Separate file for the same
127
+ reason as catalog.css: small, self-contained, and app.css's cascade
128
+ is hostile. Uses app.css tokens only, so both themes work. -->
129
+ <link rel="stylesheet" href="/static/science.css?v=20260801-review" />
130
  <link rel="icon" type="image/svg+xml" href="/static/favicon.svg?v=2" />
131
  <link rel="apple-touch-icon" href="/static/favicon.svg?v=2" />
132
  <!-- Mol* (PDBe) 3-D viewer is ~4.9 MB. We do NOT eager-load it on every
 
756
  <span class="setting-name">Variants to generate</span>
757
  <span class="setting-hint">Top-K multi-mutants returned in the library.</span>
758
  <input type="number" id="settingK" value="30" min="5" max="500" step="5" />
759
+ <!-- The cap disclosed WHERE THE NUMBER IS TYPED. A
760
+ reviewer set 30 here, asked Turing for a library
761
+ and got 10 rows; the only notice was one clause
762
+ in a banner in a different section of the page.
763
+ The numbers are the real ones from
764
+ dee/core/agent_tools.py (k clamped to 1–20,
765
+ default 10) and tests/test_chat_library_cap.py
766
+ fails if this text and that clamp drift apart. -->
767
+ <p class="setting-note">This box drives the <strong>Directed Evolution</strong> run
768
+ below, which honours it up to 500. A library designed <strong>in conversation
769
+ with Turing</strong> does not read this box: that path runs inside a single
770
+ reply rather than as a background job, so it returns <strong>10 variants by
771
+ default and at most 20</strong>.</p>
772
  </label>
773
 
774
  <label class="setting">
 
915
  real spread) — see _renderLearnedPanel in app.js. -->
916
  <section class="learned-panel" id="learnedPanel" hidden>
917
  <p class="card-kicker">&sect; What Turing learned</p>
918
+ <!-- These are SINGLE-substitution scores (dee/server.py
919
+ _de_round2_library reads m.delta_ll per mutation),
920
+ so the unit is ΔLL — not the table's summed ΣΔLL.
921
+ Labelling them the same would imply the two columns
922
+ are on one scale, and they are not. -->
923
+ <p class="unit-legend unit-legend--tight">Per-substitution score, <strong>&Delta;LL</strong> &mdash; ESM-2&rsquo;s prior for that one mutation, before &rarr; after your measured results were folded in. Not the same scale as the table&rsquo;s summed &Sigma;&Delta;LL.</p>
924
  <div class="learned-list" id="learnedList"></div>
925
  </section>
926
  <!-- Predicted-vs-measured calibration — client-side only, built
 
932
  <div class="calib-body" id="calibBody"></div>
933
  </section>
934
 
935
+ <!-- Mutation map (lollipop chart).
936
+
937
+ The chip legend that used to sit here (Low / Med /
938
+ High dots) was deleted as dashboard scaffolding and
939
+ its explanation moved into the description below —
940
+ which then described a three-step colour ramp that
941
+ encoded a signed, continuous quantity. #mutmapKey is
942
+ the replacement, and it is built by renderMutationKey()
943
+ from the same colour function the dots use, so the
944
+ key and the chart cannot disagree. -->
945
  <div class="mutmap-card" id="mutmapCard">
946
  <div class="mutmap-head">
947
  <h3>Mutation landscape</h3>
948
+ <!-- Colour is the MEAN attributed ΔLL, signed
949
+ not the sum. The sum grew with the variant
950
+ count that bar height already encodes, so the
951
+ two channels said the same thing twice and
952
+ neither said the direction. The full key,
953
+ with units and domain, is rendered into
954
+ #mutmapKey below. -->
955
+ <p class="muted">Where mutations land across the protein. <strong>x</strong> = residue position (1&ndash;<span id="mutmapLen">N</span>) &middot; <strong>bar height</strong> = variants sharing that position &middot; <strong>colour</strong> = the mean &Delta;LL there, signed, in ESM-2 log-likelihood units. Full scale and domain in the key below.</p>
956
  </div>
957
  <div class="mutmap-canvas" id="mutmapCanvas"></div>
958
+ <div class="mutmap-key" id="mutmapKey"></div>
959
  </div>
960
 
961
  <!-- ================= INTERACTION RADAR (Pillar 1) =================
 
979
  <p class="radar-foot muted" id="radarFoot" hidden></p>
980
  </section>
981
 
982
+ <!-- The Fitness column's unit, stated where the column is,
983
+ not only in a hover title and not only inside the
984
+ collapsed "How to read this" block above. A reviewing
985
+ scientist asked "what does a fitness score of 1.0
986
+ represent? 2/3 is over and 1/3 is under, but none are
987
+ negative" — all three parts of that are answered here:
988
+ the unit, the absence of a fixed zero, and why a
989
+ top-ranked slice is not expected to contain negatives.
990
+ Styling lives in trace.css (app.css is off-limits to
991
+ this change set). -->
992
+ <p class="unit-legend" id="fitnessLegend">
993
+ <strong>Fitness = &Sigma;&Delta;LL</strong> &mdash; the ESM-2 log-likelihood
994
+ change vs.&nbsp;wild type, <em>summed</em> over this variant&rsquo;s substitutions.
995
+ Unnormalised: no fixed zero and no maximum, and because the table shows the
996
+ top-ranked slice of the search, negatives rarely survive into it.
997
+ Ranks variants <em>within this library</em> &mdash; not across proteins or runs.
998
+ </p>
999
+
1000
  <!-- Filter bar — instant client-side filter over the rendered table. -->
1001
  <div class="table-toolbar">
1002
  <div class="filter-input-wrap">
 
1015
  <tr>
1016
  <th class="sortable" data-sort="rank" title="Rank by predicted fitness (1 = best).">Rank <span class="sort-ind">↕</span></th>
1017
  <th class="sortable" data-sort="mutations" title="Substitutions vs. the wild-type protein, in the format WT_aa-position-new_aa.">Mutations <span class="sort-ind">↕</span></th>
1018
+ <!-- The unit rides in the header itself. It was title-only
1019
+ since May 2026, which means it did not exist for anyone
1020
+ who didn't hover the right 60px of the page. -->
1021
+ <th class="num sortable" data-sort="fitness" title="Cumulative ΣΔLL from ESM-2: sum of log-likelihood improvements over WT across all substitutions in this variant.">Fitness <span class="th-unit">ΣΔLL</span> <span class="sort-ind">↕</span></th>
1022
  <th class="num sortable" data-sort="gc" title="GC content of the codon-optimized DNA. PCR-friendly range: 40–60%.">GC % <span class="sort-ind">↕</span></th>
1023
  <th class="num sortable" data-sort="tm" title="Cooler of the two PCR primer Tm values (nearest-neighbor, standard salt). Annealing = Tm − 5°C for Q5/Phusion.">Tm (°C) <span class="sort-ind">↕</span></th>
1024
  <th class="num sortable" data-sort="bp" title="Amplicon length in base pairs. Same for every variant in this library (length-preserving substitutions only).">bp <span class="sort-ind">↕</span></th>
 
1370
  <th>Spacer</th>
1371
  <th>PAM</th>
1372
  <!-- Composite is the ranking column —
1373
+ on-target × (1 − 0.6 × self-off CFD).
1374
+ The 0.6 is the engine's only tuning
1375
+ constant and a judgement call, so it
1376
+ is spelled out here, in the scope
1377
+ strip, per guide in the why-panel, and
1378
+ in the methods paragraph — a weight
1379
+ that decides the sort order has to be
1380
+ readable without asking us.
1381
  Always the primary sort. -->
1382
+ <th class="num crispr-sortable" data-sort-key="composite_score" title="on-target × (1 − 0.6 × self-off CFD). The 0.6 off-target weight is our uncalibrated judgement call, not a published constant — expand a row to see the arithmetic for that guide. Click to sort.">Composite</th>
1383
  <th class="num crispr-sortable" data-sort-key="on_target_score" title="TuringDNA on-target activity score (Doench-inspired sequence-only features). 1.0 = expected strong cut. Click to sort.">On-target</th>
1384
  <!-- CFD-against-input is the Phase 1
1385
  differentiator. 0.00 = no other
 
1395
  e.g. "-3 ATG (28%)" or "+1 T (32%)". The
1396
  dominant outcome usually accounts for 15-40%
1397
  of all repair events. -->
1398
+ <th class="col-ko" title="Single most-likely repair outcome (inDelphi-inspired). The number is the predicted frequency of THIS outcome among all repair events. Assumes template-free end-joining repair and is NOT conditioned on your cell type.">Top indel</th>
1399
  <!-- Phase 2A: % of predicted repair outcomes that
1400
  introduce a frameshift. Higher = more likely
1401
  to produce a true loss-of-function allele. -->
1402
+ <th class="num col-ko crispr-sortable" data-sort-key="frameshift_pct" title="% of predicted repair outcomes that introduce a frameshift (non-multiple-of-3 indel). Higher = stronger KO likelihood. Assumes template-free end-joining (MMEJ + NHEJ) with fixed constants and NO cell-type conditioning — rank guides with it, don't quote it as a rate for your line. Click to sort.">FS %</th>
1403
  <!-- Phase 2A: dominance of the SINGLE most-likely
1404
  outcome, as a percent. Direct biological
1405
  readout — "will I get one main edit product
1406
  or many?" 40%+ = clean, 25-40% = typical,
1407
  <25% = messy heterogeneous repair. -->
1408
+ <th class="num col-ko crispr-sortable" data-sort-key="top_dominance_pct" title="Frequency of the single most-likely repair outcome. 40%+ = clean; <25% = messy. Same end-joining assumptions as FS % — no cell-type conditioning. Click to sort.">Dominance</th>
1409
  <!-- Phase 2A: does the spacer's editing window
1410
  (positions 4-8) hold a C or A? If yes, this
1411
  guide could ALSO be used for a point edit
 
1417
  <th class="num col-be crispr-sortable" data-sort-key="be_editability" title="Strongest in-window editing activity for a target base (0–1). Higher = more likely. Click to sort.">Editability</th>
1418
  <th class="col-be" title="Amino-acid consequence of the edit when a reading frame is known (gene symbol given, or pasted sequence is a coding sequence). '* (stop gained)' = a DSB-free knockout via CRISPR-STOP.">AA change</th>
1419
  <th class="col-be" title="A clean edit changes a single base in the window; a bystander edit hits more than one. 'STOP' marks guides that install a premature stop codon (knockout by base editing).">Outcome</th>
1420
+ <!-- Phase 2B-1: scored against the chosen organism's
1421
+ indexed genome. Empty when no organism is
 
1422
  picked; "unique" when the organism was
1423
+ searched and no real off-targets found.
1424
+ COVERAGE IS NOT UNIFORM: E. coli, yeast, worm
1425
+ and fly are indexed complete; human and mouse
1426
+ are CODING SEQUENCE ONLY. The scope strip above
1427
+ the table states which applied to this run,
1428
+ sourced from offtarget.GENOME_SOURCES. -->
1429
+ <th class="num" title="Worst CFD score against any site in the chosen organism's INDEXED genome. 'unique' = no off-target found above threshold; 0.5+ = strong off-target that may cleave (tooltip gives the location). Coverage varies by organism — human and mouse are coding sequence only, and only the top-ranked guides are screened. See the scope note above the table.">Genome off</th>
1430
  <!-- Phase 2B-1: only populated when the user
1431
  gave a gene symbol AND the input could be
1432
  aligned to the gene's CDS. Shows exon
 
2468
  <!-- Cloning reference data must load before app.js so the Designer
2469
  can read VECTORS / ENZYMES / CLONING_METHODS / TAGS / LINKERS. -->
2470
  <script src="/static/cloning_db.js?v=20260530-ui-polish" defer></script>
2471
+ <script src="/static/app.js?v=20260801-review" defer></script>
2472
+ <!-- The decision trace, BEFORE cockpit.js: applyEvent calls TDTrace.push
2473
+ on the very first event, and both are `defer`, so document order is
2474
+ load order. Loading it after would drop the opening events of a
2475
+ restored run on the floor — silently, since the push is guarded. -->
2476
+ <script src="/static/trace.js?v=20260801-review" defer></script>
2477
  <!-- THE COCKPIT — the persistent orchestrator rail. Loads after app.js so
2478
  TDBench/TDStructure exist when a tool result asks the workspace to
2479
  render something. This is the only conversation surface in the app. -->
2480
+ <script src="/static/cockpit.js?v=20260801-review" defer></script>
2481
  <!-- structcard before catalog: the catalog calls TDStructCard.observe as
2482
  soon as it paints. Both are defer, so document order is load order. -->
2483
+ <script src="/static/structcard.js?v=20260801-review" defer></script>
2484
+ <script src="/static/catalog.js?v=20260801-review" defer></script>
2485
+ <script src="/static/railsplit.js?v=20260801-review" defer></script>
2486
  <!-- Dwell-time heartbeat. Loads after auth.js so its /api/ping calls go
2487
  through the JWT-attaching fetch wrapper (signed-in attribution). -->
2488
  <script src="/static/telemetry.js?v=20260622-analytics" defer></script>
dee/static/railsplit.js CHANGED
@@ -35,8 +35,17 @@
35
  return Math.max(MIN_W, Math.min(maxW(), Math.round(w)));
36
  }
37
 
 
 
 
 
 
 
 
 
38
  function splitActive() {
39
  return document.body.getAttribute("data-bench") === "open" &&
 
40
  window.innerWidth >= SPLIT_MIN_VIEWPORT;
41
  }
42
 
@@ -89,6 +98,9 @@
89
  handle.setAttribute("aria-label", "Resize the conversation panel");
90
  handle.setAttribute("aria-valuemin", String(MIN_W));
91
  handle.setAttribute("tabindex", "0");
 
 
 
92
  handle.innerHTML = '<span class="rail-split-grip" aria-hidden="true"></span>';
93
 
94
  handle.addEventListener("pointerdown", onDown);
@@ -163,20 +175,30 @@
163
  window.addEventListener("pointermove", onMove);
164
  window.addEventListener("pointerup", onUp);
165
  window.addEventListener("pointercancel", onUp);
 
 
 
 
 
 
 
 
 
 
 
 
166
  window.addEventListener("resize", function () {
167
- if (!splitActive()) { sync(); return; }
168
- // A narrower window can invalidate a stored width.
169
- var cur = parseInt(getComputedStyle(document.documentElement)
170
- .getPropertyValue("--bench-rail"), 10) || DEFAULT_W;
171
- handle.setAttribute("aria-valuemax", String(maxW()));
172
- if (cur !== clamp(cur)) apply(clamp(cur), false);
173
  });
174
 
175
- // Bench opens and closes by flipping data-bench on <body>, with no
176
- // event to listen for — so watch the attribute itself.
 
 
177
  if ("MutationObserver" in window) {
178
  new MutationObserver(sync).observe(document.body, {
179
- attributes: true, attributeFilter: ["data-bench"],
180
  });
181
  }
182
  }
 
35
  return Math.max(MIN_W, Math.min(maxW(), Math.round(w)));
36
  }
37
 
38
+ /* Three conditions, and the third was missing on the first cut.
39
+
40
+ Collapsing the rail (the ▾ in the cockpit header, toggleMin) narrows
41
+ .cockpit to 236px on desktop but leaves --bench-rail at whatever the
42
+ user dragged it to, because the collapsed bar is not the split any
43
+ more. The handle went on tracking --bench-rail, so it sat ~150px out
44
+ in the canvas, over the workspace, offering to resize an edge that was
45
+ no longer there. A boundary marker has to be on the boundary or gone. */
46
  function splitActive() {
47
  return document.body.getAttribute("data-bench") === "open" &&
48
+ document.body.getAttribute("data-cockpit") !== "min" &&
49
  window.innerWidth >= SPLIT_MIN_VIEWPORT;
50
  }
51
 
 
98
  handle.setAttribute("aria-label", "Resize the conversation panel");
99
  handle.setAttribute("aria-valuemin", String(MIN_W));
100
  handle.setAttribute("tabindex", "0");
101
+ // Says what it does on hover. The grip is visible now (catalog.css),
102
+ // but "this line is draggable" still has to be stated once.
103
+ handle.title = "Drag to resize the conversation · double-click to reset";
104
  handle.innerHTML = '<span class="rail-split-grip" aria-hidden="true"></span>';
105
 
106
  handle.addEventListener("pointerdown", onDown);
 
175
  window.addEventListener("pointermove", onMove);
176
  window.addEventListener("pointerup", onUp);
177
  window.addEventListener("pointercancel", onUp);
178
+
179
+ /* The resize handler used to branch: sync() when the split had gone
180
+ away, a cheap re-clamp when it was still there. The branch was the
181
+ bug. Narrowing the window past 900px hid the handle and handed
182
+ --bench-rail back to the mobile rules (correct); widening it again
183
+ took the OTHER branch, which never un-hid anything. Measured: drag
184
+ the rail to 553px, narrow to 800, widen back to 1440 — handle
185
+ `hidden`, rail back at the 392px default, and no way to get either
186
+ back short of a reload.
187
+
188
+ sync() already does the whole job and is idempotent, so just call
189
+ it. The only state it must not stamp on is a drag in flight. */
190
  window.addEventListener("resize", function () {
191
+ if (dragging) return;
192
+ sync();
 
 
 
 
193
  });
194
 
195
+ /* The bench opens/closes and the rail collapses/expands by flipping
196
+ attributes on <body>, with no event to listen for — so watch the
197
+ attributes themselves. data-cockpit matters as much as data-bench:
198
+ see splitActive(). */
199
  if ("MutationObserver" in window) {
200
  new MutationObserver(sync).observe(document.body, {
201
+ attributes: true, attributeFilter: ["data-bench", "data-cockpit"],
202
  });
203
  }
204
  }
dee/static/science.css ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ═══════════════════════════════════════════════════════════════════════
2
+ science.css — disclosure surfaces added for the 2026-08 scientific
3
+ review. Kept out of app.css deliberately: this is a small, self-contained
4
+ layer, and app.css is ~8,000 lines with a hostile cascade (see
5
+ docs/ENGINEERING.md §13).
6
+
7
+ TOKENS: only variables that already exist are used — here --line, --ink
8
+ and --ink-soft. Inventing a token name silently computes the whole
9
+ shorthand to nothing (§13.1), so grep app.css before adding one.
10
+ ═══════════════════════════════════════════════════════════════════════ */
11
+
12
+ /* ─── CRISPR scope strip ───────────────────────────────────────────────
13
+ The three things the results panel used to leave the reader to infer:
14
+ the composite's weight, what repair context the indel columns assume,
15
+ and how much of the genome was actually searched. Sits directly above
16
+ the table, always visible — not a collapsed <details>, because the whole
17
+ failure being fixed is that these facts were one click away and nobody
18
+ clicked. Quiet by default so it informs without shouting over the data. */
19
+ .crispr-scope-strip {
20
+ margin: 10px 0 12px;
21
+ padding: 10px 12px;
22
+ border: 1px solid var(--line);
23
+ border-left-width: 3px;
24
+ border-radius: 6px;
25
+ font-size: 0.82rem;
26
+ line-height: 1.5;
27
+ color: var(--ink-soft);
28
+ display: grid;
29
+ gap: 6px;
30
+ }
31
+
32
+ .crispr-scope-strip:empty { display: none; }
33
+
34
+ .crispr-scope-row {
35
+ display: grid;
36
+ grid-template-columns: 8.6rem 1fr;
37
+ gap: 10px;
38
+ align-items: baseline;
39
+ }
40
+
41
+ .crispr-scope-k {
42
+ font-weight: 600;
43
+ color: var(--ink);
44
+ letter-spacing: 0.01em;
45
+ text-transform: uppercase;
46
+ font-size: 0.68rem;
47
+ padding-top: 0.12rem;
48
+ }
49
+
50
+ .crispr-scope-v strong { color: var(--ink); font-weight: 600; }
51
+ .crispr-scope-v em { font-style: italic; }
52
+
53
+ /* Stack on narrow screens — a 8.6rem label column plus prose does not fit
54
+ a phone, and the label is short enough to read as a heading. */
55
+ @media (max-width: 640px) {
56
+ .crispr-scope-row { grid-template-columns: 1fr; gap: 2px; }
57
+ }
58
+
59
+ /* ─── Provenance block: the "Assumes" line ─────────────────────────────
60
+ Rendered by TDMethods from METHODS[*].assumes. Distinct from .basis
61
+ (how the number was made) because it states the biological context the
62
+ number is only valid inside — the difference between "here's the
63
+ method" and "here's when it doesn't apply". */
64
+ .method-assumes {
65
+ margin: 4px 0 0;
66
+ color: var(--ink-soft);
67
+ }
68
+
69
+ .method-assumes strong { color: var(--ink); }
70
+
71
+ /* ─── Directed evolution: search objective + library spread ───────────
72
+ The reviewer asked what score the simulated annealing maximises and
73
+ could not find out — it was stated only inside the copy-paste
74
+ manuscript paragraph. These two notes sit between the parameter pills
75
+ and that paragraph: the objective in prose, and the MEASURED occupancy
76
+ of the most-shared substitution in the library on screen. */
77
+ .run-notes {
78
+ display: grid;
79
+ gap: 8px;
80
+ margin: 10px 0 4px;
81
+ }
82
+
83
+ .run-notes:empty { display: none; }
84
+
85
+ .run-objective,
86
+ .run-diversity {
87
+ margin: 0;
88
+ padding: 9px 11px;
89
+ border: 1px solid var(--line);
90
+ border-radius: 6px;
91
+ font-size: 0.82rem;
92
+ line-height: 1.55;
93
+ color: var(--ink-soft);
94
+ }
95
+
96
+ .run-objective strong,
97
+ .run-diversity strong { color: var(--ink); font-weight: 600; }
98
+
99
+ .run-diversity code {
100
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
101
+ font-size: 0.94em;
102
+ }
dee/static/trace.css ADDED
@@ -0,0 +1,411 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ═══════════════════════════════════════════════════════════════════════
2
+ LEGIBILITY (2026-08-01) — units, the run strip, and the decision trace.
3
+
4
+ All of it lives here rather than in app.css for the reason PR #14 kept the
5
+ catalog out: app.css is ~8,600 lines with a hostile cascade, and three
6
+ people are editing this tree at once. Everything below is either a NEW
7
+ class or a deliberate, commented override of an app.css rule; nothing
8
+ redefines a token.
9
+
10
+ Colours are app.css tokens only (--line, --line-strong, --ink,
11
+ --ink-strong, --ink-soft, --ink-faint, --bg-card, --bg-subtle, --bg-app,
12
+ --danger, --success, --font-mono, --r-2..--r-4, --elev-4, --topbar-h).
13
+ Inventing a var() name here would compute the whole shorthand to nothing
14
+ — see ENGINEERING.md §13.1.
15
+ ═══════════════════════════════════════════════════════════════════════ */
16
+
17
+
18
+ /* ── 1. UNITS ─────────────────────────────────────────────────────────────
19
+ "Fitness 1.0" had its unit in a hover title only, so for anyone who did
20
+ not hover the right 60px it did not exist. These carry it in the page. */
21
+
22
+ /* The unit chip inside a column header. Subordinate to the header word but
23
+ not hidden — it has to survive being read at a glance. */
24
+ .th-unit {
25
+ font-family: var(--font-mono);
26
+ font-size: 9.5px;
27
+ letter-spacing: 0.02em;
28
+ font-weight: 400;
29
+ color: var(--ink-faint);
30
+ /* The header cell is .num (right-aligned) and already tight; keep the
31
+ chip on the same baseline rather than wrapping it to its own line. */
32
+ white-space: nowrap;
33
+ }
34
+
35
+ /* The always-visible legend that answers "what does 1.0 represent?" without
36
+ opening the collapsed "How to read this" block. */
37
+ .unit-legend {
38
+ margin: 0 0 10px;
39
+ padding: 8px 11px;
40
+ border-left: 2px solid var(--line-strong);
41
+ background: var(--bg-subtle);
42
+ border-radius: 0 var(--r-2) var(--r-2) 0;
43
+ font-size: 12px;
44
+ line-height: 1.55;
45
+ color: var(--ink-soft);
46
+ }
47
+ .unit-legend strong { color: var(--ink-strong); font-weight: 600; }
48
+ .unit-legend em { font-style: italic; }
49
+ .unit-legend--tight { margin: 6px 0 10px; font-size: 11.5px; }
50
+
51
+ /* Inline unit after a score, in the Radar rows and the round-2 learned rows.
52
+ Mono so it reads as a unit rather than as part of the number. */
53
+ .rs-unit,
54
+ .ls-unit {
55
+ font-family: var(--font-mono);
56
+ font-size: 9.5px;
57
+ color: var(--ink-faint);
58
+ letter-spacing: 0.02em;
59
+ }
60
+
61
+
62
+ /* ── 2. THE CHAT-LIBRARY CAP ──────────────────────────────────────────────
63
+ The reviewer typed 30 into "Variants to generate" and a Turing-run library
64
+ came back with 10 rows. The only disclosure was a banner in a different
65
+ section of the page. This one sits under the input itself. */
66
+ .setting-note {
67
+ margin: 6px 0 0;
68
+ font-size: 11px;
69
+ line-height: 1.5;
70
+ color: var(--ink-faint);
71
+ }
72
+ .setting-note strong { color: var(--ink-soft); font-weight: 600; }
73
+
74
+ /* Requested-vs-delivered, stated on the result itself. Not an error state —
75
+ a chat run capping at 20 is correct behaviour, it just has to be said. */
76
+ .count-note {
77
+ display: block;
78
+ margin-top: 4px;
79
+ font-family: var(--font-mono);
80
+ font-size: 10.5px;
81
+ letter-spacing: 0.02em;
82
+ color: var(--ink-faint);
83
+ }
84
+ .count-note b { color: var(--ink-soft); font-weight: 600; }
85
+
86
+
87
+ /* ── 3. INTERACTION RADAR PROGRESS ────────────────────────────────────────
88
+ One ESM-2 forward pass per variant, serial, with no signal at all beyond a
89
+ button reading "Analyzing…". Tool rows in the rail have had an elapsed
90
+ clock since the CRISPR index made two minutes of silence read as a hang;
91
+ this path never used it. */
92
+ .radar-elapsed {
93
+ font-family: var(--font-mono);
94
+ font-size: 10.5px;
95
+ letter-spacing: 0.03em;
96
+ color: var(--ink-faint);
97
+ font-variant-numeric: tabular-nums; /* so the seconds don't jitter */
98
+ }
99
+ .radar-progress {
100
+ display: block;
101
+ height: 2px;
102
+ margin-top: 7px;
103
+ background: var(--line);
104
+ overflow: hidden;
105
+ border-radius: 1px;
106
+ }
107
+ /* Indeterminate on purpose. The server returns one response for the whole
108
+ library, so there is no honest per-variant fraction to draw — a bar that
109
+ filled to 60% would be inventing progress. This says "working", and the
110
+ elapsed clock next to it says how long for. */
111
+ .radar-progress::after {
112
+ content: "";
113
+ display: block;
114
+ width: 34%;
115
+ height: 100%;
116
+ background: var(--ink-soft);
117
+ animation: tdRadarSlide 1.25s var(--ease-snap) infinite;
118
+ }
119
+ @keyframes tdRadarSlide {
120
+ 0% { transform: translateX(-100%); }
121
+ 100% { transform: translateX(320%); }
122
+ }
123
+ @media (prefers-reduced-motion: reduce) {
124
+ .radar-progress::after { animation: none; width: 100%; opacity: 0.35; }
125
+ }
126
+
127
+
128
+ /* ── 4. THE RUN STRIP (was the unlabelled "context 0% · $0.11") ───────────
129
+ Two numbers with no nouns read as noise. They are kept — cost is exactly
130
+ what someone deciding whether to keep going wants — but named, given a
131
+ denominator so a sub-1% reading isn't rendered as a flat "0%", and given
132
+ the trace as somewhere to go and read what they mean in full sentences. */
133
+
134
+ /* The bar was flex:1 and ate the row; the text now carries the meaning, so
135
+ the bar becomes a fixed-width glance indicator. Specificity (0,2,0) beats
136
+ app.css's (0,1,0) `.cp-meter-bar`, and this file loads after it — both
137
+ conditions are needed (ENGINEERING.md §13.2/§13.3). */
138
+ .cp-meter .cp-meter-bar { flex: 0 0 28px; }
139
+ /* Wraps rather than truncates. Measured at 390px (the rail's real width):
140
+ "Context 41k / 1M (4%) · $0.14 model cost" overflows by 7px, and an
141
+ ellipsis there eats "model cost" — putting the label back out of reach on
142
+ exactly the narrow screens this change set exists to serve. A second line
143
+ costs 12px; a hidden noun costs the whole fix. */
144
+ .cp-meter .cp-meter-t {
145
+ flex: 1 1 auto;
146
+ min-width: 0;
147
+ white-space: normal;
148
+ line-height: 1.35;
149
+ font-variant-numeric: tabular-nums;
150
+ }
151
+
152
+ /* The way into the trace. Text, not a glyph: the whole complaint upstream of
153
+ this file is unlabelled controls. */
154
+ .cp-trace-btn {
155
+ flex: 0 0 auto;
156
+ font-family: var(--font-mono);
157
+ font-size: 9.5px;
158
+ letter-spacing: 0.06em;
159
+ text-transform: uppercase;
160
+ padding: 3px 8px;
161
+ border: 1px solid var(--line-strong);
162
+ border-radius: 999px;
163
+ background: transparent;
164
+ color: var(--ink-soft);
165
+ cursor: pointer;
166
+ white-space: nowrap;
167
+ }
168
+ .cp-trace-btn:hover { border-color: var(--ink-soft); color: var(--ink-strong); }
169
+ .cp-trace-btn[hidden] { display: none; }
170
+ /* Matches .cp-icon's rule: the 57px collapsed rail has room for nothing. */
171
+ body[data-cockpit="min"] .cp-trace-btn { display: none; }
172
+
173
+
174
+ /* ── 5. THE DECISION TRACE ────────────────────────────────────────────────
175
+ The rail already showed every step; it showed them as a river. Ten minutes
176
+ in, "what did it actually do and why" is unanswerable because the answer
177
+ scrolled past. This is the same events, held still. */
178
+ .td-trace {
179
+ position: fixed;
180
+ inset: 0;
181
+ z-index: 60; /* above the rail (z 40s) and the canvas */
182
+ display: flex;
183
+ align-items: center;
184
+ justify-content: center;
185
+ padding: 24px;
186
+ }
187
+ .td-trace[hidden] { display: none; }
188
+
189
+ .td-trace-scrim {
190
+ position: absolute;
191
+ inset: 0;
192
+ background: rgba(10, 10, 10, 0.42);
193
+ border: 0;
194
+ padding: 0;
195
+ cursor: pointer;
196
+ }
197
+
198
+ .td-trace-panel {
199
+ position: relative;
200
+ display: flex;
201
+ flex-direction: column;
202
+ width: min(760px, 100%);
203
+ max-height: min(82vh, 900px);
204
+ background: var(--bg-card);
205
+ border: 1px solid var(--line-strong);
206
+ border-radius: var(--r-4);
207
+ box-shadow: var(--elev-4);
208
+ overflow: hidden;
209
+ }
210
+
211
+ .td-trace-head {
212
+ display: flex;
213
+ align-items: flex-start;
214
+ gap: 12px;
215
+ padding: 16px 18px 12px;
216
+ border-bottom: 1px solid var(--line);
217
+ flex: 0 0 auto;
218
+ }
219
+ .td-trace-head h2 {
220
+ margin: 0;
221
+ font-size: 15px;
222
+ letter-spacing: -0.01em;
223
+ color: var(--ink-strong);
224
+ }
225
+ .td-trace-sub {
226
+ margin: 4px 0 0;
227
+ font-size: 12px;
228
+ line-height: 1.5;
229
+ color: var(--ink-faint);
230
+ }
231
+ .td-trace-x {
232
+ margin-left: auto;
233
+ flex: 0 0 auto;
234
+ width: 26px;
235
+ height: 26px;
236
+ line-height: 1;
237
+ font-size: 15px;
238
+ border: 1px solid var(--line-strong);
239
+ border-radius: var(--r-2);
240
+ background: transparent;
241
+ color: var(--ink-soft);
242
+ cursor: pointer;
243
+ }
244
+ .td-trace-x:hover { border-color: var(--ink-soft); color: var(--ink-strong); }
245
+
246
+ /* Goal + the two meters, spelled out in words. This is where "context 3%"
247
+ is allowed the room to say what it is. */
248
+ .td-trace-meta {
249
+ flex: 0 0 auto;
250
+ padding: 12px 18px;
251
+ border-bottom: 1px solid var(--line);
252
+ background: var(--bg-subtle);
253
+ font-size: 12px;
254
+ line-height: 1.6;
255
+ color: var(--ink-soft);
256
+ }
257
+ .td-trace-goal {
258
+ margin: 0 0 8px;
259
+ color: var(--ink-strong);
260
+ font-size: 13px;
261
+ }
262
+ .td-trace-goal b {
263
+ display: block;
264
+ font-family: var(--font-mono);
265
+ font-size: 9px;
266
+ letter-spacing: 0.1em;
267
+ text-transform: uppercase;
268
+ color: var(--ink-faint);
269
+ font-weight: 500;
270
+ margin-bottom: 2px;
271
+ }
272
+ .td-trace-facts { margin: 0; padding: 0; list-style: none; }
273
+ .td-trace-facts li { margin: 0; }
274
+ .td-trace-facts b { color: var(--ink-strong); font-weight: 600; }
275
+
276
+ .td-trace-list {
277
+ flex: 1 1 auto;
278
+ min-height: 0;
279
+ overflow-y: auto;
280
+ margin: 0;
281
+ padding: 0;
282
+ list-style: none;
283
+ -webkit-overflow-scrolling: touch;
284
+ }
285
+
286
+ .td-step { border-bottom: 1px solid var(--line); }
287
+ .td-step:last-child { border-bottom: 0; }
288
+
289
+ .td-step > details > summary {
290
+ display: flex;
291
+ align-items: baseline;
292
+ gap: 10px;
293
+ padding: 10px 18px;
294
+ cursor: pointer;
295
+ list-style: none;
296
+ }
297
+ .td-step > details > summary::-webkit-details-marker { display: none; }
298
+ .td-step > details > summary:hover { background: var(--bg-subtle); }
299
+ .td-step > details[open] > summary { background: var(--bg-subtle); }
300
+
301
+ .td-step-n {
302
+ flex: 0 0 auto;
303
+ min-width: 20px;
304
+ font-family: var(--font-mono);
305
+ font-size: 10px;
306
+ color: var(--ink-faint);
307
+ font-variant-numeric: tabular-nums;
308
+ }
309
+ .td-step-main { flex: 1 1 auto; min-width: 0; }
310
+ .td-step-obj {
311
+ display: block;
312
+ font-size: 13px;
313
+ color: var(--ink-strong);
314
+ overflow-wrap: break-word;
315
+ }
316
+ .td-step-args {
317
+ display: block;
318
+ margin-top: 1px;
319
+ font-family: var(--font-mono);
320
+ font-size: 10.5px;
321
+ color: var(--ink-faint);
322
+ overflow-wrap: break-word;
323
+ }
324
+ .td-step-status {
325
+ flex: 0 0 auto;
326
+ font-family: var(--font-mono);
327
+ font-size: 9px;
328
+ letter-spacing: 0.08em;
329
+ text-transform: uppercase;
330
+ padding: 2px 7px;
331
+ border-radius: 999px;
332
+ border: 1px solid var(--line-strong);
333
+ color: var(--ink-faint);
334
+ }
335
+ .td-step--ok .td-step-status { color: var(--success); border-color: var(--success); }
336
+ .td-step--fail .td-step-status { color: var(--danger); border-color: var(--danger); }
337
+ .td-step--run .td-step-status { color: var(--ink-soft); }
338
+ .td-step-t {
339
+ flex: 0 0 auto;
340
+ font-family: var(--font-mono);
341
+ font-size: 10px;
342
+ color: var(--ink-faint);
343
+ font-variant-numeric: tabular-nums;
344
+ min-width: 34px;
345
+ text-align: right;
346
+ }
347
+
348
+ .td-step-detail {
349
+ padding: 2px 18px 14px 48px;
350
+ font-size: 12.5px;
351
+ line-height: 1.6;
352
+ color: var(--ink);
353
+ }
354
+ .td-step-lbl {
355
+ margin: 10px 0 2px;
356
+ font-family: var(--font-mono);
357
+ font-size: 9px;
358
+ letter-spacing: 0.1em;
359
+ text-transform: uppercase;
360
+ color: var(--ink-faint);
361
+ }
362
+ .td-step-detail p { margin: 0; overflow-wrap: break-word; }
363
+ .td-step-detail p.td-muted { color: var(--ink-faint); font-style: italic; }
364
+ .td-step-kv {
365
+ margin: 0;
366
+ display: grid;
367
+ grid-template-columns: minmax(0, auto) minmax(0, 1fr);
368
+ gap: 1px 12px;
369
+ font-family: var(--font-mono);
370
+ font-size: 11px;
371
+ }
372
+ .td-step-kv dt { color: var(--ink-faint); }
373
+ .td-step-kv dd { margin: 0; color: var(--ink-strong); overflow-wrap: anywhere; }
374
+
375
+ /* Plan steps replayed inside a "Plan" entry. */
376
+ .td-plan { margin: 0; padding: 0; list-style: none; font-size: 12.5px; }
377
+ .td-plan li { display: flex; gap: 8px; align-items: baseline; }
378
+ .td-plan-g { flex: 0 0 auto; width: 12px; color: var(--ink-faint); font-family: var(--font-mono); font-size: 11px; }
379
+ .td-plan--done > span:last-child { color: var(--ink-faint); }
380
+
381
+ .td-trace-empty {
382
+ margin: 0;
383
+ padding: 28px 18px;
384
+ text-align: center;
385
+ font-size: 12.5px;
386
+ color: var(--ink-faint);
387
+ }
388
+ .td-trace-empty[hidden] { display: none; }
389
+
390
+ .td-trace-foot {
391
+ flex: 0 0 auto;
392
+ padding: 10px 18px;
393
+ border-top: 1px solid var(--line);
394
+ font-size: 11px;
395
+ line-height: 1.5;
396
+ color: var(--ink-faint);
397
+ }
398
+
399
+ /* The rail is ~390px and phones are narrower still; a 24px inset frame there
400
+ wastes a third of the screen. Full-bleed sheet instead. */
401
+ @media (max-width: 720px) {
402
+ .td-trace { padding: 0; align-items: stretch; }
403
+ .td-trace-panel {
404
+ width: 100%;
405
+ max-height: none;
406
+ height: 100%;
407
+ border: 0;
408
+ border-radius: 0;
409
+ }
410
+ .td-step-detail { padding-left: 18px; }
411
+ }
dee/static/trace.js ADDED
@@ -0,0 +1,447 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ═══════════════════════════════════════════════════════════════════════
2
+ THE DECISION TRACE (2026-08-01)
3
+
4
+ A reviewing scientist asked for "a function where the entire decision
5
+ process is shown" — numbered steps, each with an objective, a rationale, a
6
+ status and a result. The cockpit rail already renders every one of those
7
+ events. It renders them as a river: ten minutes into a run, "what did it
8
+ actually do, and why did it do that" is unanswerable, because the answer
9
+ scrolled off the top and the transcript is interleaved with prose.
10
+
11
+ So this invents no instrumentation. Every field below comes out of the
12
+ orchestrator's existing event stream (dee/core/orchestrator.py `_emit`):
13
+
14
+ kind fields this file reads
15
+ ──────────── ──────────────────────────────────────────────────────────
16
+ user text → the goal, or a mid-run correction
17
+ text text → the model's own words BEFORE a call,
18
+ which is the only honest "rationale"
19
+ available — see _pendingWhy
20
+ plan steps[{step,status}]→ the agent's own decomposition
21
+ tool_call id,name,verb,args,at
22
+ tool_result id,ok,summary,error,at
23
+ ask question,options
24
+ compacted note
25
+ error error
26
+ checkpoint / done → run outcome, not a step
27
+
28
+ Two things this deliberately does NOT do:
29
+
30
+ • It does not time steps in the browser. Every event carries a server
31
+ `at` (unix seconds, 3dp), so a step's duration is result.at − call.at —
32
+ real, and still correct after a reload replays the run from seq 0. A
33
+ client-side stopwatch would show nothing on replay, or worse, show the
34
+ replay's own duration and pass it off as the step's.
35
+ • It does not synthesise a reason. If the model said nothing before a
36
+ call, the row says so and offers the active plan step instead, labelled
37
+ as the plan rather than quoted as the model's reasoning.
38
+
39
+ Public surface (cockpit.js is the only caller):
40
+ TDTrace.push(ev) one orchestrator event, in order
41
+ TDTrace.reset() new/switched run
42
+ TDTrace.setMeta({...}) cost + context + status from the poll
43
+ TDTrace.count() number of steps, for the entry-point button
44
+ TDTrace.open/close/toggle/isOpen
45
+ ═══════════════════════════════════════════════════════════════════════ */
46
+ (function () {
47
+ "use strict";
48
+
49
+ /* ── state ─────────────────────────────────────────────────────────── */
50
+ var entries = []; // ordered trace entries (see push)
51
+ var byId = {}; // tool_call id → its entry
52
+ var goal = "";
53
+ var meta = { cost: 0, ctxUsed: 0, ctxLimit: 0, status: "" };
54
+ var planRev = 0;
55
+ var lastPlan = null; // most recent plan steps, for the fallback reason
56
+ var pendingWhy = []; // assistant prose since the previous step
57
+ var els = null;
58
+ var isOpen = false;
59
+
60
+ function esc(s) {
61
+ return String(s == null ? "" : s)
62
+ .replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
63
+ .replace(/"/g, "&quot;");
64
+ }
65
+
66
+ /* ── ingest ────────────────────────────────────────────────────────── */
67
+ /* Never let a malformed event break the rail. applyEvent calls this
68
+ inline, so a throw here would kill the transcript too. */
69
+ function push(ev) {
70
+ try { _push(ev); } catch (e) { /* a trace row is never worth a dead run */ }
71
+ if (isOpen) render();
72
+ }
73
+
74
+ function _push(ev) {
75
+ if (!ev || !ev.kind) return;
76
+ switch (ev.kind) {
77
+ case "user":
78
+ // The FIRST user turn is the goal; everything after it is a
79
+ // course correction, and which one it was is exactly what a
80
+ // reader of a finished run needs to know.
81
+ if (!goal) { goal = ev.text || ""; return; }
82
+ pendingWhy = [];
83
+ entries.push({ t: "steer", label: "Course correction",
84
+ text: ev.text || "" });
85
+ return;
86
+ case "text":
87
+ if (ev.text) pendingWhy.push(String(ev.text));
88
+ return;
89
+ case "plan":
90
+ planRev++;
91
+ lastPlan = ev.steps || [];
92
+ entries.push({ t: "plan", rev: planRev, steps: lastPlan.slice() });
93
+ pendingWhy = [];
94
+ return;
95
+ case "tool_call": {
96
+ var e = {
97
+ t: "tool",
98
+ id: ev.id || "",
99
+ name: ev.name || "",
100
+ verb: ev.verb || ev.name || "step",
101
+ args: ev.args || {},
102
+ at: typeof ev.at === "number" ? ev.at : null,
103
+ status: "run",
104
+ why: pendingWhy.join("\n\n").trim(),
105
+ // Snapshot the plan step that was active AT THIS MOMENT.
106
+ // Reading it later would attribute a step to whatever the
107
+ // plan says now, which is a different claim.
108
+ planStep: _activePlanStep(),
109
+ };
110
+ pendingWhy = [];
111
+ entries.push(e);
112
+ if (e.id) byId[e.id] = e;
113
+ return;
114
+ }
115
+ case "tool_result": {
116
+ var target = ev.id ? byId[ev.id] : null;
117
+ if (!target) {
118
+ // A result with no matching call (older transcript, or a
119
+ // truncated replay). Record it rather than dropping it.
120
+ target = { t: "tool", name: ev.name || "", verb: ev.name || "step",
121
+ args: {}, at: null, why: "", planStep: "" };
122
+ entries.push(target);
123
+ }
124
+ target.status = ev.ok ? "ok" : "fail";
125
+ target.summary = ev.summary || "";
126
+ target.error = ev.error || "";
127
+ target.resultKind = ev.result_kind || "";
128
+ target.endAt = typeof ev.at === "number" ? ev.at : null;
129
+ return;
130
+ }
131
+ case "ask":
132
+ entries.push({ t: "ask", question: ev.question || "",
133
+ options: ev.options || [] });
134
+ pendingWhy = [];
135
+ return;
136
+ case "compacted":
137
+ entries.push({ t: "compacted", note: ev.note || "" });
138
+ return;
139
+ case "error":
140
+ entries.push({ t: "error", text: ev.error || "" });
141
+ return;
142
+ case "checkpoint":
143
+ meta.checkpoint = true;
144
+ return;
145
+ case "done":
146
+ meta.done = true;
147
+ return;
148
+ default:
149
+ return;
150
+ }
151
+ }
152
+
153
+ function _activePlanStep() {
154
+ if (!lastPlan || !lastPlan.length) return "";
155
+ for (var i = 0; i < lastPlan.length; i++) {
156
+ if (lastPlan[i].status === "active") return lastPlan[i].step || "";
157
+ }
158
+ for (var j = 0; j < lastPlan.length; j++) {
159
+ if ((lastPlan[j].status || "pending") === "pending") return lastPlan[j].step || "";
160
+ }
161
+ return "";
162
+ }
163
+
164
+ function reset() {
165
+ entries = []; byId = {}; goal = ""; planRev = 0; lastPlan = null;
166
+ pendingWhy = [];
167
+ meta = { cost: 0, ctxUsed: 0, ctxLimit: 0, status: "" };
168
+ if (isOpen) render();
169
+ }
170
+
171
+ function setMeta(m) {
172
+ if (!m) return;
173
+ if (typeof m.cost === "number") meta.cost = m.cost;
174
+ if (typeof m.ctxUsed === "number") meta.ctxUsed = m.ctxUsed;
175
+ if (typeof m.ctxLimit === "number") meta.ctxLimit = m.ctxLimit;
176
+ if (typeof m.status === "string") meta.status = m.status;
177
+ if (typeof m.title === "string") meta.title = m.title;
178
+ if (isOpen) render();
179
+ }
180
+
181
+ function count() {
182
+ return entries.length;
183
+ }
184
+
185
+ /* ── formatting ────────────────────────────────────────────────────── */
186
+ function secs(a, b) {
187
+ if (typeof a !== "number" || typeof b !== "number") return "";
188
+ var d = Math.max(0, b - a);
189
+ if (d < 60) return (d < 10 ? d.toFixed(1) : Math.round(d)) + "s";
190
+ return Math.floor(d / 60) + "m " + Math.round(d % 60) + "s";
191
+ }
192
+
193
+ /* Arguments the agent actually passed. The server already strips
194
+ `sequence` before emitting, so nothing here can leak a construct; the
195
+ remaining values are short scalars (gene, organism, k, host). Anything
196
+ structured is summarised rather than dumped — a trace row is a record,
197
+ not a JSON viewer. */
198
+ function argRows(args) {
199
+ var keys = Object.keys(args || {});
200
+ if (!keys.length) return "";
201
+ var out = "";
202
+ for (var i = 0; i < keys.length; i++) {
203
+ var k = keys[i], v = args[k];
204
+ var txt;
205
+ if (v == null) continue;
206
+ if (Array.isArray(v)) txt = v.length + " item" + (v.length === 1 ? "" : "s");
207
+ else if (typeof v === "object") txt = "(object)";
208
+ else {
209
+ txt = String(v);
210
+ if (txt.length > 160) txt = txt.slice(0, 160) + "…";
211
+ }
212
+ out += "<dt>" + esc(k) + "</dt><dd>" + esc(txt) + "</dd>";
213
+ }
214
+ return out ? '<dl class="td-step-kv">' + out + "</dl>" : "";
215
+ }
216
+
217
+ function argSummary(args) {
218
+ var order = ["gene_symbol", "organism", "text", "target", "host", "property", "k"];
219
+ var bits = [];
220
+ for (var i = 0; i < order.length; i++) {
221
+ var v = (args || {})[order[i]];
222
+ if (v == null || typeof v === "object") continue;
223
+ var s = String(v);
224
+ if (!s || s.length > 48) continue;
225
+ bits.push(order[i] === "k" ? "k=" + s : s);
226
+ }
227
+ return bits.join(" · ");
228
+ }
229
+
230
+ var STATUS_WORD = { ok: "done", fail: "failed", run: "running" };
231
+
232
+ function toolRow(e, n) {
233
+ var cls = e.status === "ok" ? "td-step--ok"
234
+ : e.status === "fail" ? "td-step--fail" : "td-step--run";
235
+ var el = secs(e.at, e.endAt);
236
+ var args = argSummary(e.args);
237
+
238
+ var why = e.why
239
+ ? '<p>' + esc(e.why) + "</p>"
240
+ : (e.planStep
241
+ ? '<p class="td-muted">The model called this without narrating it. '
242
+ + "The plan step active at the time was: " + esc(e.planStep) + "</p>"
243
+ : '<p class="td-muted">The model called this without narrating it, '
244
+ + "and no plan was set.</p>");
245
+
246
+ var result;
247
+ if (e.status === "run") {
248
+ result = '<p class="td-muted">Still running.</p>';
249
+ } else if (e.status === "fail") {
250
+ result = "<p>" + esc(e.error || "Failed.") + "</p>"
251
+ + (e.resultKind ? '<p class="td-muted">' + esc(e.resultKind) + "</p>" : "");
252
+ } else {
253
+ result = e.summary ? "<p>" + esc(e.summary) + "</p>"
254
+ : '<p class="td-muted">Completed; the tool returned no summary line.</p>';
255
+ }
256
+
257
+ return '<li class="td-step ' + cls + '"><details><summary>'
258
+ + '<span class="td-step-n">' + n + "</span>"
259
+ + '<span class="td-step-main">'
260
+ + '<span class="td-step-obj">' + esc(e.verb) + "</span>"
261
+ + (args ? '<span class="td-step-args">' + esc(args) + "</span>" : "")
262
+ + "</span>"
263
+ + '<span class="td-step-status">' + esc(STATUS_WORD[e.status] || e.status) + "</span>"
264
+ + '<span class="td-step-t">' + esc(el) + "</span>"
265
+ + "</summary>"
266
+ + '<div class="td-step-detail">'
267
+ + '<p class="td-step-lbl">Objective</p><p>' + esc(e.verb)
268
+ + (e.name ? " — tool <code>" + esc(e.name) + "</code>" : "") + "</p>"
269
+ + '<p class="td-step-lbl">Rationale</p>' + why
270
+ + (argRows(e.args) ? '<p class="td-step-lbl">Inputs</p>' + argRows(e.args) : "")
271
+ + '<p class="td-step-lbl">Result</p>' + result
272
+ + "</div></details></li>";
273
+ }
274
+
275
+ function plainRow(n, title, sub, body, cls) {
276
+ return '<li class="td-step ' + (cls || "") + '"><details><summary>'
277
+ + '<span class="td-step-n">' + n + "</span>"
278
+ + '<span class="td-step-main"><span class="td-step-obj">' + esc(title) + "</span>"
279
+ + (sub ? '<span class="td-step-args">' + esc(sub) + "</span>" : "")
280
+ + "</span>"
281
+ + '<span class="td-step-status">note</span>'
282
+ + '<span class="td-step-t"></span>'
283
+ + "</summary>"
284
+ + '<div class="td-step-detail">' + body + "</div></details></li>";
285
+ }
286
+
287
+ var PLAN_GLYPH = { done: "✓", active: "▸", skipped: "–", pending: "○" };
288
+
289
+ function planRow(e, n) {
290
+ var items = "";
291
+ for (var i = 0; i < e.steps.length; i++) {
292
+ var st = e.steps[i].status || "pending";
293
+ items += '<li class="td-plan--' + esc(st) + '">'
294
+ + '<span class="td-plan-g">' + (PLAN_GLYPH[st] || "○") + "</span>"
295
+ + "<span>" + esc(e.steps[i].step || "") + "</span></li>";
296
+ }
297
+ var done = 0;
298
+ for (var j = 0; j < e.steps.length; j++) if (e.steps[j].status === "done") done++;
299
+ return plainRow(
300
+ n,
301
+ e.rev === 1 ? "Set out a plan" : "Revised the plan (revision " + e.rev + ")",
302
+ e.steps.length + " step" + (e.steps.length === 1 ? "" : "s") + " · " + done + " done",
303
+ '<p class="td-step-lbl">Plan at this point</p><ul class="td-plan">' + items + "</ul>");
304
+ }
305
+
306
+ function render() {
307
+ if (!els) return;
308
+ var rows = "";
309
+ var n = 0;
310
+ for (var i = 0; i < entries.length; i++) {
311
+ var e = entries[i];
312
+ n++;
313
+ if (e.t === "tool") rows += toolRow(e, n);
314
+ else if (e.t === "plan") rows += planRow(e, n);
315
+ else if (e.t === "steer") {
316
+ rows += plainRow(n, "You steered the run", "",
317
+ '<p class="td-step-lbl">What you said</p><p>' + esc(e.text) + "</p>"
318
+ + '<p class="td-step-lbl">Effect</p><p class="td-muted">Applied at the '
319
+ + "next step boundary, so at most one tool call was already in flight.</p>");
320
+ } else if (e.t === "ask") {
321
+ rows += plainRow(n, "Asked you a question", "run parked",
322
+ '<p class="td-step-lbl">Question</p><p>' + esc(e.question) + "</p>"
323
+ + (e.options.length
324
+ ? '<p class="td-step-lbl">Options offered</p><p>'
325
+ + esc(e.options.join(" · ")) + "</p>" : ""));
326
+ } else if (e.t === "compacted") {
327
+ rows += plainRow(n, "Condensed earlier steps", "context limit",
328
+ '<p class="td-step-lbl">Why</p><p>Earlier messages were replaced by a '
329
+ + "deterministic digest to stay inside the context window. The original "
330
+ + "goal is pinned and never condensed.</p>"
331
+ + (e.note ? '<p class="td-step-lbl">Digest</p><p>' + esc(e.note) + "</p>" : ""));
332
+ } else if (e.t === "error") {
333
+ rows += plainRow(n, "Run error", "", '<p>' + esc(e.text) + "</p>", "td-step--fail");
334
+ } else { n--; }
335
+ }
336
+
337
+ els.list.innerHTML = rows;
338
+ els.empty.hidden = !!rows;
339
+ if (!rows) {
340
+ els.empty.textContent = goal
341
+ ? "This run has not taken a step yet."
342
+ : "No run yet. Ask Turing for something and every step it takes will be recorded here.";
343
+ }
344
+
345
+ els.sub.textContent = n === 0
346
+ ? "Every step of the current run, held still."
347
+ : n + " step" + (n === 1 ? "" : "s") + " in this run"
348
+ + (meta.done ? " · finished" : meta.status === "running" ? " · still running" : "");
349
+
350
+ var facts = "";
351
+ if (goal) {
352
+ facts += '<p class="td-trace-goal"><b>Goal</b>' + esc(goal) + "</p>";
353
+ }
354
+ facts += '<ul class="td-trace-facts">';
355
+ // The two numbers the rail shows as "context 0% · $0.11", written out.
356
+ if (meta.ctxLimit) {
357
+ var pct = meta.ctxUsed / meta.ctxLimit * 100;
358
+ facts += "<li><b>Context used:</b> " + fmtTokens(meta.ctxUsed) + " of "
359
+ + fmtTokens(meta.ctxLimit) + " tokens ("
360
+ + (pct >= 1 ? Math.round(pct) : pct.toFixed(1)) + "%) — how much of the "
361
+ + "model's window this conversation occupies. When it fills, earlier steps "
362
+ + "are condensed rather than dropped.</li>";
363
+ }
364
+ if (meta.cost) {
365
+ facts += "<li><b>Model cost so far:</b> $" + meta.cost.toFixed(4)
366
+ + " — what this run has spent on model calls. Compute run on this "
367
+ + "server (scoring, folding, guide design) is not billed per call.</li>";
368
+ }
369
+ facts += "</ul>";
370
+ els.meta.innerHTML = facts;
371
+ }
372
+
373
+ function fmtTokens(n) {
374
+ n = Number(n) || 0;
375
+ if (n >= 1000000) return (n / 1000000).toFixed(n % 1000000 === 0 ? 0 : 2) + "M";
376
+ if (n >= 1000) return (n / 1000).toFixed(n >= 100000 ? 0 : 1) + "k";
377
+ return String(n);
378
+ }
379
+
380
+ /* ── mount ─────────────────────────────────────────────────────────── */
381
+ function mount() {
382
+ if (els) return els;
383
+ var root = document.createElement("div");
384
+ root.className = "td-trace";
385
+ root.id = "tdTrace";
386
+ root.hidden = true;
387
+ root.innerHTML =
388
+ '<button type="button" class="td-trace-scrim" data-close aria-label="Close decision trace"></button>' +
389
+ '<section class="td-trace-panel" role="dialog" aria-labelledby="tdTraceTitle">' +
390
+ '<header class="td-trace-head">' +
391
+ "<div>" +
392
+ '<h2 id="tdTraceTitle">Decision trace</h2>' +
393
+ '<p class="td-trace-sub" id="tdTraceSub"></p>' +
394
+ "</div>" +
395
+ '<button type="button" class="td-trace-x" data-close aria-label="Close decision trace">&times;</button>' +
396
+ "</header>" +
397
+ '<div class="td-trace-meta" id="tdTraceMeta"></div>' +
398
+ '<ol class="td-trace-list" id="tdTraceList"></ol>' +
399
+ '<p class="td-trace-empty" id="tdTraceEmpty"></p>' +
400
+ '<p class="td-trace-foot">Built from the run\'s own event log — the same events ' +
401
+ "the rail renders. Every step, input and result below is what the orchestrator " +
402
+ "recorded; nothing here is reconstructed after the fact.</p>" +
403
+ "</section>";
404
+ document.body.appendChild(root);
405
+ els = {
406
+ root: root,
407
+ list: root.querySelector("#tdTraceList"),
408
+ empty: root.querySelector("#tdTraceEmpty"),
409
+ sub: root.querySelector("#tdTraceSub"),
410
+ meta: root.querySelector("#tdTraceMeta"),
411
+ };
412
+ [].forEach.call(root.querySelectorAll("[data-close]"), function (b) {
413
+ b.addEventListener("click", close);
414
+ });
415
+ document.addEventListener("keydown", function (e) {
416
+ if (e.key === "Escape" && isOpen) close();
417
+ });
418
+ return els;
419
+ }
420
+
421
+ function open() {
422
+ mount();
423
+ isOpen = true;
424
+ els.root.hidden = false;
425
+ render();
426
+ // The list is the scrollable region; a re-open should show the top of
427
+ // the run, not wherever it was left.
428
+ els.list.scrollTop = 0;
429
+ }
430
+ function close() {
431
+ if (!els) return;
432
+ isOpen = false;
433
+ els.root.hidden = true;
434
+ }
435
+ function toggle() { if (isOpen) close(); else open(); }
436
+
437
+ window.TDTrace = {
438
+ push: push,
439
+ reset: reset,
440
+ setMeta: setMeta,
441
+ count: count,
442
+ open: open,
443
+ close: close,
444
+ toggle: toggle,
445
+ isOpen: function () { return isOpen; },
446
+ };
447
+ })();
tests/conftest.py CHANGED
@@ -8,6 +8,25 @@ not listed in requirements.txt. Run with: pip install pytest && pytest -q
8
  import os
9
  import sys
10
 
 
 
11
  _REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
12
  if _REPO_ROOT not in sys.path:
13
  sys.path.insert(0, _REPO_ROOT)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  import os
9
  import sys
10
 
11
+ import pytest
12
+
13
  _REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
14
  if _REPO_ROOT not in sys.path:
15
  sys.path.insert(0, _REPO_ROOT)
16
+
17
+
18
+ @pytest.fixture(autouse=True)
19
+ def _no_leaked_handover():
20
+ """The orchestrator parks a handover, keyed by owner, when a run ends on a
21
+ budget limit — so the owner's next run can pick the work up.
22
+
23
+ That map is module-level process state, and almost every orchestrator test
24
+ uses owner "u1". Without this, a test that ends a run on a limit seeds the
25
+ NEXT test's run with its goal and plan, and the failure surfaces somewhere
26
+ unrelated. Same class of bug as the persistence flake in
27
+ docs/ENGINEERING.md §13.23: shared state crossing a test boundary.
28
+ """
29
+ from dee.core import orchestrator as _orch
30
+ _orch._HANDOVERS.clear()
31
+ yield
32
+ _orch._HANDOVERS.clear()
tests/test_catalog_constructs.py ADDED
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Deleting constructs, not just conversations.
2
+
3
+ The reviewer's words were "no catalog of conversations/chats or way to delete
4
+ 'constructs' and conversations". The catalog shipped with rename and delete for
5
+ conversations and neither for constructs — the cards had one action, "open".
6
+
7
+ Half of that gap is a server gap. Plasmids and primer analyses have
8
+ owner-checked DELETE routes; saved LIBRARIES and CRISPR DESIGNS have no route
9
+ in server.py and no function in auth.py, and a library is the primary artifact
10
+ this product makes. So catalog.js drives a table, CX_DELETE, and renders a
11
+ Delete button only for the kinds that have somewhere to send it — a delete
12
+ control that 404s is worse than an honest absence.
13
+
14
+ These tests keep the two halves in step: every endpoint the client is prepared
15
+ to call has to exist on the server, and the kinds that cannot be deleted have
16
+ to stay silent rather than grow a button that fails.
17
+ """
18
+ import re
19
+
20
+ from dee import server
21
+
22
+ _CAT = "dee/static/catalog.js"
23
+
24
+
25
+ def _read(path):
26
+ with open(path, encoding="utf-8") as fh:
27
+ return fh.read()
28
+
29
+
30
+ def _cx_delete_table():
31
+ """The live CX_DELETE map, parsed out of catalog.js (commented-out entries
32
+ are deliberately NOT picked up — they are the documented server gap)."""
33
+ src = _read(_CAT)
34
+ block = src[src.index("var CX_DELETE = {"):]
35
+ block = block[:block.index("};")]
36
+ out = {}
37
+ for line in block.split("\n"):
38
+ stripped = line.strip()
39
+ if stripped.startswith("//"):
40
+ continue
41
+ m = re.search(r'(\w+)\s*:\s*\{\s*path:\s*"([^"]+)"', stripped)
42
+ if m:
43
+ out[m.group(1)] = m.group(2)
44
+ return out
45
+
46
+
47
+ def _delete_routes():
48
+ app = server.create_app()
49
+ return {str(r.rule) for r in app.url_map.iter_rules() if "DELETE" in (r.methods or set())}
50
+
51
+
52
+ def test_every_endpoint_the_catalog_will_call_actually_exists():
53
+ """The failure this prevents: shipping a Delete button whose fetch 404s,
54
+ which looks to the user exactly like the bug they reported."""
55
+ routes = _delete_routes()
56
+ for kind, path in _cx_delete_table().items():
57
+ # "/api/plasmid/library/" in the client -> "/api/plasmid/library/<id>"
58
+ # on the server. Match on the prefix, since the client appends the id.
59
+ assert any(rule.startswith(path) for rule in routes), (
60
+ f"catalog.js will DELETE {path}<id> for a {kind} construct, but no "
61
+ f"DELETE route starts with that. Routes: {sorted(routes)}")
62
+
63
+
64
+ def test_the_two_kinds_with_no_route_render_no_delete_button():
65
+ """Libraries and CRISPR designs genuinely cannot be deleted yet. The
66
+ catalog must not pretend otherwise — and when the routes land, uncommenting
67
+ two lines in CX_DELETE is the whole client change."""
68
+ table = _cx_delete_table()
69
+ routes = _delete_routes()
70
+ for kind, prefix in (("library", "/api/library/"), ("crispr", "/api/crispr/designs/")):
71
+ if any(r.startswith(prefix) for r in routes):
72
+ # The server gap is closed — then the client must offer the verb.
73
+ assert kind in table, (
74
+ f"{prefix}<id> now exists server-side; uncomment the {kind} entry "
75
+ "in CX_DELETE so the catalog offers it")
76
+ else:
77
+ assert kind not in table, (
78
+ f"catalog.js offers to delete a {kind} construct but there is no "
79
+ f"DELETE route under {prefix}")
80
+
81
+
82
+ def test_the_construct_card_only_grows_actions_it_can_perform():
83
+ src = _read(_CAT)
84
+ card = src[src.index("function constructCard"):]
85
+ card = card[:card.index("\n }")]
86
+ assert "CX_DELETE[c.kind]" in card, (
87
+ "the Delete button must be gated on the endpoint table, not rendered "
88
+ "unconditionally")
89
+ assert 'data-act="delcx"' in card
90
+
91
+
92
+ def test_deleting_a_construct_confirms_first_and_names_it():
93
+ """Same rule as conversations: there is no undo behind any of this, so the
94
+ confirm says which thing is going rather than asking 'are you sure?'."""
95
+ src = _read(_CAT)
96
+ fn = src[src.index("function confirmDeleteConstruct"):]
97
+ fn = fn[:fn.index("\n }")]
98
+ assert "confirmDelete(" in fn
99
+ assert "cx.name" in fn, "the confirm must name the construct"
100
+ assert "del.noun" in fn, "the confirm must say what kind of thing it is"
101
+
102
+
103
+ def test_deleting_a_construct_refreshes_mission_control():
104
+ """Mission Control lists the same constructs from the same endpoint.
105
+ Leaving it showing a card whose record is gone is the silted-up-dropdown
106
+ problem in a second place."""
107
+ src = _read(_CAT)
108
+ fn = src[src.index("function doDeleteConstruct"):]
109
+ fn = fn[:fn.index("\n }\n")]
110
+ assert "TDMission" in fn and "reload" in fn
tests/test_chat_library_cap.py ADDED
@@ -0,0 +1,143 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The chat library caps at 20, and the app has to say so where it matters.
2
+
3
+ The incident: a reviewer typed 30 into "Variants to generate", asked Turing
4
+ for a library, and got 10 rows. Their words — "did not explicitly or clearly
5
+ state anywhere that only 10 variants would be scored… you have to search for
6
+ this info in small text in a banner in a different section."
7
+
8
+ The cap is real and correct: ``_tool_design_variant_library`` runs inside one
9
+ reply rather than as a background job, so it clamps ``k`` to 1–20 and defaults
10
+ to 10. The bug was purely that nothing said so at the input, and the one thing
11
+ that did say so ("a chat run just caps the library size") named no number.
12
+
13
+ The disclosure now names 20 and 10 in two places, and the browser has no way
14
+ to ask the server what they are. So these tests read the numbers out of the
15
+ Python and fail if the copy has drifted from the behaviour it describes — a
16
+ disclosure with a stale number is worse than none, because the user then has a
17
+ specific wrong number to plan around.
18
+ """
19
+ import re
20
+
21
+ import pytest
22
+
23
+ from dee.core import agent_tools as t
24
+
25
+ _APP_JS = "dee/static/app.js"
26
+ _COCKPIT = "dee/static/cockpit.js"
27
+ _INDEX = "dee/static/index.html"
28
+
29
+
30
+ def _read(p):
31
+ with open(p, encoding="utf-8") as fh:
32
+ return fh.read()
33
+
34
+
35
+ def _python_bounds():
36
+ """(default_k, cap) as the chat tool actually enforces them."""
37
+ src = _read("dee/core/agent_tools.py")
38
+ body = re.search(
39
+ r"def _tool_design_variant_library\(.*?\n(?=\ndef |\n# )", src, re.S).group(0)
40
+ default = int(re.search(r'int\(args\.get\("k",\s*(\d+)\)\)', body).group(1))
41
+ cap = int(re.search(r"k\s*=\s*max\(1,\s*min\((\d+),\s*k\)\)", body).group(1))
42
+ return default, cap
43
+
44
+
45
+ # --------------------------------------------------------------------------- #
46
+ # the numbers themselves
47
+ # --------------------------------------------------------------------------- #
48
+ def test_the_cap_is_what_the_tool_actually_enforces():
49
+ """Executed, not read: a 30-variant request has to come back clamped."""
50
+ default, cap = _python_bounds()
51
+ assert (default, cap) == (10, 20)
52
+ spec = next(s for s in __import__("dee.core.orchestrator", fromlist=["x"]).TOOL_SPECS
53
+ if s["function"]["name"] == "design_variant_library")
54
+ desc = spec["function"]["parameters"]["properties"]["k"]["description"]
55
+ assert str(cap) in desc and str(default) in desc, desc
56
+
57
+
58
+ def test_the_client_constants_match_the_server_clamp():
59
+ app = _read(_APP_JS)
60
+ default, cap = _python_bounds()
61
+ assert f"const CHAT_LIBRARY_CAP = {cap};" in app
62
+ assert f"const CHAT_LIBRARY_DEFAULT_K = {default};" in app
63
+
64
+
65
+ # --------------------------------------------------------------------------- #
66
+ # WHERE the number is entered — the whole point of the complaint
67
+ # --------------------------------------------------------------------------- #
68
+ def test_the_cap_is_disclosed_at_the_input_not_only_in_a_banner():
69
+ html = _read(_INDEX)
70
+ default, cap = _python_bounds()
71
+ # the note has to sit with the K field, not somewhere else on the page
72
+ block = re.search(
73
+ r'<input type="number" id="settingK".*?</label>', html, re.S)
74
+ assert block, "settingK field not found — did the settings card move?"
75
+ note = block.group(0)
76
+ assert 'class="setting-note"' in note, note
77
+ assert str(cap) in note and str(default) in note, note
78
+ # and it must distinguish the two paths, or it just looks like the sidebar
79
+ # run is capped at 20 too
80
+ assert "Directed Evolution" in note and "Turing" in note
81
+
82
+
83
+ def test_the_note_has_styles_to_render_with():
84
+ """A class with no rule is invisible, and this one is built into static
85
+ HTML — nothing else in the app would fail if the stylesheet lost it."""
86
+ css = _read("dee/static/trace.css")
87
+ assert ".setting-note" in css
88
+ assert ".count-note" in css
89
+
90
+
91
+ # --------------------------------------------------------------------------- #
92
+ # the delivered result has to state requested vs scored
93
+ # --------------------------------------------------------------------------- #
94
+ def test_the_painted_library_states_requested_versus_scored():
95
+ app = _read(_APP_JS)
96
+ fn = re.search(r"function paintAgentDesignRun\(.*?\n\}", app, re.S).group(0)
97
+ assert "callArgs" in fn, "the requested k is only available on the tool_call args"
98
+ assert "You asked for" in fn
99
+ assert "CHAT_LIBRARY_CAP" in fn
100
+ # the summary line above the table carries it too, for the sidebar path
101
+ assert 'class="count-note"' in app
102
+ assert "Requested" in app
103
+
104
+
105
+ def test_the_requested_count_actually_reaches_the_painter():
106
+ """The tool RESULT carries what came back and nothing about what was
107
+ asked for. Without the tool_call args being stashed and handed over, the
108
+ painter has no requested number and the sentence cannot be written —
109
+ which is a silent no-op, not an error."""
110
+ cp = _read(_COCKPIT)
111
+ assert "state.toolArgs[ev.id] = ev.args" in cp
112
+ assert "toolArgs: {}" in cp
113
+ # handed to the painter, and the design painter accepts it
114
+ assert re.search(r"PAINTERS\[ev\.name\]\(ui\.panel,\s*\(ev\.id && state\.toolArgs\[ev\.id\]\)",
115
+ cp), "painter called without the call args"
116
+ assert re.search(r"design_variant_library: function \(panel, args\)", cp)
117
+ assert "TDDesign.paintAgentRun(panel, args)" in cp
118
+ # ...and cleared with the run, so run 2 can't inherit run 1's arguments
119
+ assert "state.toolArgs = {}" in cp
120
+
121
+
122
+ # --------------------------------------------------------------------------- #
123
+ # round 2 in chat has its own, smaller, hardcoded ceiling
124
+ # --------------------------------------------------------------------------- #
125
+ def test_chat_round_two_returns_at_most_ten_and_says_so_in_its_own_payload():
126
+ """propose_round2 hardcodes k=10 and then slices [:10] again. Nothing in
127
+ the UI reads that path's count, so this only guards the number quoted in
128
+ the tool description the model reads."""
129
+ src = _read("dee/core/agent_tools.py")
130
+ body = re.search(r"def _tool_propose_round2\(.*?\n(?=\ndef |\n# )", src, re.S).group(0)
131
+ assert re.search(r'"k":\s*10', body)
132
+ assert re.search(r"\[:10\]", body)
133
+
134
+
135
+ @pytest.mark.parametrize("asked, delivered", [(30, 10), (20, 20), (5, 5)])
136
+ def test_the_sentence_only_claims_a_shortfall_when_there_is_one(asked, delivered):
137
+ """Three cases and they say different things; collapsing them into one
138
+ line ("a chat run just caps the library size") is what left a user
139
+ staring at 10 rows after typing 30."""
140
+ app = _read(_APP_JS)
141
+ fn = re.search(r"function paintAgentDesignRun\(.*?\n\}", app, re.S).group(0)
142
+ assert "asked > delivered" in fn
143
+ assert "the ${asked} you asked for" in fn
tests/test_cockpit_rail.py ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The chat rail: the glitched boxes, the cut-off text, and the split handle.
2
+
3
+ Three separate review findings, one file, because all three are properties of
4
+ the same 390px column and all three were reproduced by measuring the live DOM
5
+ rather than by reading the CSS.
6
+
7
+ **Glitched boxes.** `.cp-transcript` is a column flex container, so every turn
8
+ is a flex item with the default `flex-shrink: 1`. Once a run overflows the
9
+ rail the browser shrinks the items before it considers scrolling. Most turns
10
+ survive on `min-height: auto`'s content-based minimum — but that minimum is
11
+ only content-based while `overflow` is `visible`, and `.cp-tool` sets
12
+ `overflow: hidden`. So the tool rows, and only the tool rows, collapse.
13
+ Measured on an eight-tool transcript: rows at **9.6px** against a 34.6px
14
+ header, and `scrollHeight == clientHeight`, i.e. nothing scrolled, everything
15
+ was crushed.
16
+
17
+ **Cut-off text.** `.cp-user` is a flex row (it carries the STEER tag), which
18
+ makes the message text a flex item whose `min-width: auto` refuses to go below
19
+ its MIN-CONTENT width. `overflow-wrap: break-word` does not reduce min-content
20
+ — that is the difference between it and `anywhere`. Paste the 286-residue
21
+ TEM-1 protein from the review and the box asks for **2,389px inside a 391px
22
+ rail**: 2,024px of sequence off the right edge.
23
+
24
+ **The split handle.** It shipped at `opacity: 0` until hover, and it survived
25
+ neither the rail being collapsed nor the window being narrowed and widened
26
+ again.
27
+ """
28
+ import re
29
+
30
+ _CSS = "dee/static/app.css"
31
+ _CATCSS = "dee/static/catalog.css"
32
+ _RAIL = "dee/static/railsplit.js"
33
+
34
+
35
+ def _read(path):
36
+ with open(path, encoding="utf-8") as fh:
37
+ return fh.read()
38
+
39
+
40
+ def _rule(css, selector):
41
+ """The declaration block for an exact selector, comments stripped."""
42
+ css = re.sub(r"/\*.*?\*/", " ", css, flags=re.S)
43
+ m = re.search(re.escape(selector) + r"\s*\{([^}]*)\}", css)
44
+ assert m, f"no rule for `{selector}`"
45
+ return m.group(1)
46
+
47
+
48
+ # --------------------------------------------------------------------------- #
49
+ # glitched boxes
50
+ # --------------------------------------------------------------------------- #
51
+ def test_transcript_children_cannot_be_shrunk():
52
+ """Without this the tool rows collapse to their own border. Note it is on
53
+ the CHILDREN of the transcript, not on .cp-tool: the next component that
54
+ clips its own overflow would otherwise inherit the same bug silently."""
55
+ body = _rule(_read(_CSS), ".cp-transcript > *")
56
+ assert re.search(r"flex\s*:\s*0\s+0\s+auto", body), (
57
+ ".cp-transcript > * must be flex: 0 0 auto")
58
+
59
+
60
+ def test_the_tool_row_still_clips_its_own_overflow():
61
+ """The tempting 'fix' is to drop `overflow: hidden` from .cp-tool, which
62
+ removes the shrink but also lets a result table escape the rounded corner
63
+ and the rail's width. Keep the clip; pin the size instead."""
64
+ assert re.search(r"overflow\s*:\s*hidden", _rule(_read(_CSS), ".cp-tool"))
65
+
66
+
67
+ # --------------------------------------------------------------------------- #
68
+ # cut-off text
69
+ # --------------------------------------------------------------------------- #
70
+ def test_a_pasted_sequence_can_shrink_below_its_min_content_width():
71
+ body = _rule(_read(_CSS), ".cp-user > div")
72
+ assert re.search(r"min-width\s*:\s*0", body), "min-width:0 removes the flex floor"
73
+ assert re.search(r"overflow-wrap\s*:\s*anywhere", body), (
74
+ "must be `anywhere`, not `break-word`: only `anywhere` reduces the "
75
+ "min-content size, and min-content is what the flex item asks for")
76
+
77
+
78
+ # --------------------------------------------------------------------------- #
79
+ # the split handle
80
+ # --------------------------------------------------------------------------- #
81
+ def test_the_grip_is_visible_before_you_touch_it():
82
+ """It shipped invisible until hover — measured `opacity: "0"` with the
83
+ split open. A handle you have to already know about is not a handle."""
84
+ body = _rule(_read(_CATCSS), ".rail-split-grip")
85
+ m = re.search(r"opacity\s*:\s*([0-9.]+)", body)
86
+ assert m and float(m.group(1)) > 0.5, (
87
+ "the resting grip must be visible; hover/focus can make it louder")
88
+
89
+
90
+ def test_the_handle_stands_down_when_the_rail_is_collapsed():
91
+ """Collapsing the rail narrows .cockpit to 236px but leaves --bench-rail
92
+ where the user dragged it, so the handle sat ~150px out over the canvas
93
+ offering to resize an edge that was not there."""
94
+ src = _read(_RAIL)
95
+ fn = src[src.index("function splitActive"):]
96
+ fn = fn[:fn.index("\n }")]
97
+ assert "data-cockpit" in fn, "splitActive() must account for the collapsed rail"
98
+
99
+
100
+ def test_a_narrow_window_does_not_retire_the_handle_permanently():
101
+ """The resize handler used to branch, and only the 'split has gone away'
102
+ branch called sync(). Narrow past 900px and back and the handle stayed
103
+ hidden with the stored width lost until a reload."""
104
+ src = _read(_RAIL)
105
+ m = re.search(r'addEventListener\("resize",\s*function[^)]*\)\s*\{(.*?)\n \}\);',
106
+ src, re.S)
107
+ assert m, "no resize handler found"
108
+ assert "sync()" in m.group(1), "resize must go through sync()"
109
+ assert "dragging" in m.group(1), (
110
+ "sync() must not stamp on a drag in flight — that is the one state it "
111
+ "would clobber")
112
+
113
+
114
+ def test_the_observer_watches_both_body_attributes():
115
+ src = _read(_RAIL)
116
+ m = re.search(r"attributeFilter:\s*\[([^\]]*)\]", src)
117
+ assert m, "no attributeFilter"
118
+ watched = m.group(1)
119
+ assert "data-bench" in watched and "data-cockpit" in watched
tests/test_cockpit_recovered_attempts.py ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """A recovered attempt must not render as a failure - reviewer log 2026-07-30.
2
+
3
+ The reviewer's run on beta-lactamase TEM-1 threw three red "couldn't find the
4
+ gene" blocks on a request that then went on to produce a correct answer (286
5
+ aa -> 861 bp, which they checked). The run was fine. The transcript read like
6
+ the product had broken three times.
7
+
8
+ Source-level assertions, matching the pattern in tests/test_focus_on.py and
9
+ tests/test_plasmid_editor.py - there is no JS runner in this repo. The
10
+ behaviour itself was measured in a real headless browser against
11
+ http://localhost:7863 on 2026-08-01, with the app driven by a scripted fake
12
+ model (three failing fetch_sequence calls, then one that resolves):
13
+
14
+ BEFORE 3 x class "cp-turn cp-tool cp-tool--fail"
15
+ border-top-color color(srgb 0.436627 0.267451 0.246353) (danger)
16
+ .cp-tool-sum rgb(244, 121, 111) (danger)
17
+ glyph .cp-cross (red x)
18
+
19
+ AFTER 3 x class "cp-turn cp-tool" (no --fail)
20
+ border-top-color rgb(43, 41, 38) (--line, the default)
21
+ .cp-tool-sum rgb(144, 138, 128) (--ink-faint)
22
+ glyph neutral, unclassed
23
+ title the original error text, still there
24
+ summary "no result - retried"
25
+
26
+ and identical after a reload, so the demotion survives transcript replay.
27
+ Zero page errors; documentElement.scrollWidth - innerWidth == 0.
28
+ """
29
+ import re
30
+
31
+ import pytest
32
+
33
+ _COCKPIT = "dee/static/cockpit.js"
34
+
35
+
36
+ def _read(path):
37
+ with open(path, encoding="utf-8") as fh:
38
+ return fh.read()
39
+
40
+
41
+ @pytest.fixture(scope="module")
42
+ def js():
43
+ return _read(_COCKPIT)
44
+
45
+
46
+ def test_a_failed_tool_call_is_tagged_so_a_later_success_can_find_it(js):
47
+ """Attributes, not an in-memory index: a reload replays the whole
48
+ transcript from the event log, and an index would be empty at that
49
+ point."""
50
+ assert 'setAttribute("data-cp-tool"' in js
51
+ assert 'setAttribute("data-cp-state"' in js
52
+
53
+
54
+ def test_a_success_demotes_earlier_failed_attempts_at_the_same_tool(js):
55
+ assert "function demoteSupersededAttempts" in js
56
+ assert re.search(r"if \(ev\.ok\) demoteSupersededAttempts\(ev\.name\)", js)
57
+ body = js[js.index("function demoteSupersededAttempts"):]
58
+ body = body[:body.index("\n function fillToolResult")]
59
+ # Same tool only. Demoting an unrelated failure because something else
60
+ # later worked would hide a real problem.
61
+ assert '[data-cp-tool="\' + name + \'"][data-cp-state="fail"]' in body
62
+ assert 'classList.remove("cp-tool--fail")' in body
63
+
64
+
65
+ def test_the_demoted_row_is_neutral_not_green(js):
66
+ """It did not succeed. Recolouring it as a success would be a lie about
67
+ what happened; the point is only that it is no longer shouted."""
68
+ body = js[js.index("function demoteSupersededAttempts"):]
69
+ body = body[:body.index("\n function fillToolResult")]
70
+ assert 'class="cp-tick"' not in body # the markup, not the prose
71
+ assert 'classList.add("cp-tool--ok")' not in body
72
+
73
+
74
+ def test_the_original_error_is_kept_not_discarded(js):
75
+ """Softening the render must not delete the record. The error stays on the
76
+ node's title, and the transcript export reads the event log rather than
77
+ the DOM, so the full text survives either way."""
78
+ body = js[js.index("function demoteSupersededAttempts"):]
79
+ body = body[:body.index("\n function fillToolResult")]
80
+ assert 'setAttribute("title"' in body
81
+
82
+
83
+ def test_no_new_css_class_is_invented(js):
84
+ """docs/ENGINEERING.md §13.7: a guessed class name is a silent no-op, and
85
+ dee/static/app.css is a different workstream's file. The demoted state is
86
+ reached by REMOVING a class, and the two hooks are data-attributes."""
87
+ body = js[js.index("function demoteSupersededAttempts"):]
88
+ body = body[:body.index("\n function fillToolResult")]
89
+ for cls in re.findall(r'classList\.add\("([^"]+)"\)', body):
90
+ raise AssertionError(f"demotion adds a class ({cls}) that must exist "
91
+ f"in app.css - it does not own that file")
92
+ css = _read("dee/static/app.css")
93
+ assert ".cp-tool--fail" in css and ".cp-cross" in css # the ones read
94
+
95
+
96
+ def test_repeated_identical_errors_collapse_instead_of_stacking(js):
97
+ """Three copies of one error is one problem shouted three times. Only
98
+ CONSECUTIVE duplicates merge - two identical errors with work between them
99
+ really are two events."""
100
+ body = js[js.index("function addError"):]
101
+ body = body[:body.index("\n /* An attempt that was RETRIED")]
102
+ assert "_lastErr" in body
103
+ assert "lastElementChild" in body # consecutive-only check
104
+ assert "data-cp-err-n" in body
105
+
106
+
107
+ def test_the_cockpit_cache_key_was_bumped(js):
108
+ """docs/ENGINEERING.md §14: without a fresh ?v= the browser serves the old
109
+ file and the change appears not to work."""
110
+ html = _read("dee/static/index.html")
111
+ m = re.search(r"cockpit\.js\?v=([^\"]+)", html)
112
+ assert m, "cockpit.js has no cache key"
113
+ assert m.group(1) != "20260730-editor10", "cache key not bumped"
tests/test_crispr_disclosure.py ADDED
@@ -0,0 +1,243 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The CRISPR panel must disclose what its numbers mean, not just print them.
2
+
3
+ Written after a reviewing scientist read the results table and could not
4
+ answer three questions from the interface:
5
+
6
+ 1. "Showing on-target and composite scores are good but the weights making
7
+ the composite score should be transparent." — the table sorts by
8
+ ``composite``, which silently applies a 0.6 off-target weight. Nothing
9
+ on screen said the weight existed, and crispr_methods.py actively said
10
+ the opposite ("no hidden weights and no tuning constants") while the
11
+ engine multiplied by 0.6.
12
+ 2. Frameshift % and top-indel are end-joining repair predictions with
13
+ fixed constants. The panel never stated the repair context they assume,
14
+ and never admitted outcomes vary by cell type.
15
+ 3. The genome off-target panel reads like a whole-genome screen. For human
16
+ and mouse the index is coding sequence only.
17
+
18
+ These assert the disclosures exist AND that they still match the algorithm —
19
+ a provenance file that drifts from the code is worse than none, because it
20
+ converts an unknown into a confident wrong answer.
21
+ """
22
+ import pytest
23
+
24
+ from dee import server
25
+ from dee.core import crispr as C
26
+ from dee.core import crispr_methods as cm
27
+
28
+
29
+ @pytest.fixture
30
+ def client():
31
+ app = server.create_app()
32
+ app.config.update(TESTING=True)
33
+ return app.test_client()
34
+
35
+
36
+ # A sequence with enough NGG PAMs to yield guides on both strands.
37
+ SEQ = (
38
+ "ATGGCCTACGACGCATCTTCCCGACAACGTGGACCGGTACCGGAGGTTTAA"
39
+ "CCGGTACCGGATCGATCGGGCCCAGGTACCGGATTACCGGTACCGGATCC"
40
+ "GGGTACCGGATCGGGCCCAGGTTTAACCGGTACCGGATCGATCGGGAA"
41
+ )
42
+
43
+
44
+ # --------------------------------------------------------------------- #
45
+ # 1. The composite weight
46
+ # --------------------------------------------------------------------- #
47
+ def test_composite_weight_is_a_named_constant_the_engine_actually_uses():
48
+ """The weight must be one value, not a literal inlined at the call site.
49
+
50
+ It was `on_score * (1.0 - 0.6 * max_cfd)` with the 0.6 written in place,
51
+ so nothing could report it without hardcoding a copy that could drift.
52
+ """
53
+ assert isinstance(C.COMPOSITE_OFFTARGET_WEIGHT, float)
54
+ assert 0.0 < C.COMPOSITE_OFFTARGET_WEIGHT <= 1.0
55
+
56
+ guides = C.find_guides(SEQ, max_results=50)
57
+ assert guides
58
+ for g in guides:
59
+ expected = g.on_target_score * (1.0 - C.COMPOSITE_OFFTARGET_WEIGHT * g.cfd_max_offtarget)
60
+ # Both sides are stored rounded to 3 dp, so allow one unit of that.
61
+ assert g.composite_score == pytest.approx(expected, abs=2e-3), (
62
+ f"composite for {g.spacer} is not on_target x (1 - w x self_off)"
63
+ )
64
+
65
+
66
+ def test_every_serialized_guide_carries_the_weight_that_ranked_it():
67
+ """The UI prints the arithmetic per guide; it must read the weight off the
68
+ payload rather than keep its own copy. The agent's design_crispr_guides
69
+ tool builds its panel from these same dicts, so it discloses it too."""
70
+ guides = C.find_guides(SEQ, max_results=10)
71
+ dicts = [C.guide_to_dict(g) for g in guides]
72
+ assert dicts
73
+ for d in dicts:
74
+ assert d["composite_offtarget_weight"] == C.COMPOSITE_OFFTARGET_WEIGHT
75
+
76
+
77
+ def test_composite_weight_is_disclosed_with_its_real_value():
78
+ """crispr_methods must state the weight the engine uses.
79
+
80
+ This replaces an earlier assertion that the basis said "no hidden
81
+ weights". That was locking in a false claim: the code has multiplied the
82
+ off-target term by 0.6 since the composite shipped. The contract changed
83
+ deliberately — the provenance now describes the algorithm.
84
+ """
85
+ c = cm.METHODS["composite"]
86
+ w = C.COMPOSITE_OFFTARGET_WEIGHT
87
+ assert str(w) in c["formula"], c["formula"]
88
+ assert "on_target" in c["formula"] and "self_off" in c["formula"]
89
+ # And it must say the weight is a judgement call, not a published value.
90
+ assert "judgement call" in c["basis"]
91
+ assert "uncalibrated" in c["limits"]
92
+
93
+
94
+ def test_composite_provenance_does_not_claim_there_are_no_weights():
95
+ # The exact sentence that made the reviewer's question unanswerable.
96
+ c = cm.METHODS["composite"]
97
+ assert "no hidden weights" not in c["basis"].lower()
98
+ assert "no tuning constants" not in c["basis"].lower()
99
+
100
+
101
+ # --------------------------------------------------------------------- #
102
+ # 2. Repair context for the indel columns
103
+ # --------------------------------------------------------------------- #
104
+ def test_indel_predictor_genuinely_takes_no_cell_type_input():
105
+ """Guards the executive decision NOT to ship a cell-line selector.
106
+
107
+ A dropdown that changes nothing is worse than no dropdown. If someone
108
+ later gives _predict_indels a cell-type or repair-context argument, this
109
+ fails — and that is the signal that the disclosure can become a control.
110
+ """
111
+ import inspect
112
+ params = list(inspect.signature(C._predict_indels).parameters)
113
+ assert params == ["spacer", "pam", "target_context", "enzyme"], params
114
+
115
+
116
+ def test_indel_predictions_are_identical_for_the_same_sequence():
117
+ """The cell-type-agnostic claim, measured rather than asserted: the same
118
+ guide returns the same numbers every time, because nothing else feeds it."""
119
+ a = C.find_guides(SEQ, max_results=20)
120
+ b = C.find_guides(SEQ, max_results=20)
121
+ for ga, gb in zip(a, b):
122
+ assert (ga.frameshift_pct, ga.top_indel_label, ga.top_dominance_pct) == \
123
+ (gb.frameshift_pct, gb.top_indel_label, gb.top_dominance_pct)
124
+
125
+
126
+ def test_indels_state_the_repair_context_they_assume():
127
+ m = cm.METHODS["indels"]
128
+ assumes = m.get("assumes", "")
129
+ assert assumes, "indel columns must state the repair context they assume"
130
+ assert "end-joining" in assumes
131
+ assert "HDR" in assumes # no donor template modelled
132
+ # And the pre-existing cell-type caveat must survive.
133
+ assert "CELL-TYPE-AGNOSTIC" in m["limits"]
134
+
135
+
136
+ def test_methods_route_serves_the_assumes_field(client):
137
+ body = client.get("/api/crispr/methods").get_json()
138
+ assert body["methods"]["indels"]["assumes"]
139
+
140
+
141
+ # --------------------------------------------------------------------- #
142
+ # 3. Genome off-target scope
143
+ # --------------------------------------------------------------------- #
144
+ def test_genome_scopes_are_derived_from_the_registry_not_restated():
145
+ """The UI names the coverage per organism. That answer has to come from
146
+ offtarget.GENOME_SOURCES — a hand-copied list is exactly how a panel ends
147
+ up promising a whole-genome screen the engine never ran."""
148
+ from dee.core.offtarget import GENOME_SOURCES
149
+ scopes = cm.genome_scopes()
150
+ assert set(scopes) == set(GENOME_SOURCES)
151
+ for key, src in GENOME_SOURCES.items():
152
+ assert scopes[key]["scope"] == src["scope"]
153
+ assert scopes[key]["complete"] is (src["scope"] == "full genome")
154
+
155
+
156
+ def test_mammalian_scope_note_says_coding_sequence_only_in_caps():
157
+ scopes = cm.genome_scopes()
158
+ for org in ("human", "mouse"):
159
+ assert scopes[org]["complete"] is False
160
+ note = scopes[org]["note"]
161
+ assert "CODING SEQUENCE ONLY" in note
162
+ assert "intron" in note and "intergenic" in note
163
+
164
+
165
+ def test_complete_genome_organisms_are_not_labelled_partial():
166
+ """The old frontend test was `organism === 'ecoli' ? complete : CDS-only`,
167
+ which told every yeast / worm / fly run it had screened coding sequence
168
+ only. Those genomes are indexed complete."""
169
+ scopes = cm.genome_scopes()
170
+ for org in ("ecoli", "yeast", "worm", "fly"):
171
+ assert scopes[org]["complete"] is True
172
+ assert "CODING SEQUENCE ONLY" not in scopes[org]["note"]
173
+
174
+
175
+ def test_methods_route_carries_the_scope_map(client):
176
+ body = client.get("/api/crispr/methods").get_json()
177
+ assert body["ok"] is True
178
+ assert body["genome_scopes"]["human"]["complete"] is False
179
+ assert body["genome_scopes"]["ecoli"]["complete"] is True
180
+
181
+
182
+ # --------------------------------------------------------------------- #
183
+ # The disclosures have to reach the browser, not just the API
184
+ # --------------------------------------------------------------------- #
185
+ def _static(name: str) -> str:
186
+ from pathlib import Path
187
+ return (Path(server.__file__).resolve().parent / "static" / name).read_text(encoding="utf-8")
188
+
189
+
190
+ def test_frontend_reads_the_weight_from_the_payload_not_a_hardcoded_copy():
191
+ """A second copy of 0.6 in JS is a drift bug waiting to happen: change the
192
+ constant in Python and the explanation keeps printing the old number."""
193
+ js = _static("app.js")
194
+ assert "composite_offtarget_weight" in js
195
+ # The why-panel and the scope strip must both build the sentence from it.
196
+ assert js.count("composite_offtarget_weight") >= 3
197
+
198
+
199
+ def test_scope_strip_is_rendered_on_every_paint():
200
+ js = _static("app.js")
201
+ assert "_renderScopeStrip" in js
202
+ assert "crisprScopeStrip" in js
203
+ # It must not be behind a <details> — the failure being fixed is that
204
+ # these facts were one click away and went unread.
205
+ html = _static("index.html")
206
+ assert "crisprScopeStrip" not in html or "details" not in html.split("crisprScopeStrip")[0][-200:]
207
+
208
+
209
+ def test_design_progress_shell_can_actually_be_hidden():
210
+ """Found while verifying the disclosures in a real browser, not by reading.
211
+
212
+ `_hideProgress` was a `const` declared INSIDE the design handler's `try`,
213
+ and it is called from the `finally`. `finally` is a sibling block, so the
214
+ binding was never in scope there: every CRISPR design threw
215
+ "ReferenceError: _hideProgress is not defined" out of the finally, which
216
+ also swallows whatever error the catch was handling. Measured on the
217
+ shipping build before the fix: after a SUCCESSFUL design the progress
218
+ shimmer was still on screen at 74 px with its elapsed counter ticking
219
+ (3.0s and climbing) and the setInterval leaked once per run. After:
220
+ shell hidden, height 0, no page errors.
221
+ """
222
+ js = _static("app.js")
223
+ # Anchor on a line unique to the CRISPR design handler.
224
+ handler = js[js.index("_saveCrisprResume(seq);"):]
225
+ decl = handler.index("const _hideProgress")
226
+ try_block = handler.index("try {")
227
+ assert decl < try_block, (
228
+ "_hideProgress must be declared before the try, or the finally that "
229
+ "calls it cannot see it"
230
+ )
231
+
232
+
233
+ def test_science_css_is_linked_and_uses_only_real_tokens():
234
+ html = _static("index.html")
235
+ assert "/static/science.css?v=" in html
236
+ css = _static("science.css")
237
+ real = {"--line", "--line-strong", "--line-bold", "--bg-app",
238
+ "--ink", "--ink-strong", "--ink-soft", "--ink-faint"}
239
+ import re
240
+ used = set(re.findall(r"var\((--[a-z0-9-]+)", css))
241
+ assert used <= real, f"invented CSS tokens (they compute to nothing): {used - real}"
242
+ # §13.5: a stray */ silently kills the rest of the stylesheet.
243
+ assert css.count("/*") == css.count("*/")
tests/test_crispr_methods.py CHANGED
@@ -28,10 +28,21 @@ def test_every_ordered_method_exists_and_is_complete():
28
 
29
  def test_composite_formula_is_stated_not_hidden():
30
  # The reviewer's ask: "the weights making the composite score should be
31
- # transparent." It's a plain product — say so explicitly.
 
 
 
 
 
 
 
 
 
 
32
  c = cm.METHODS["composite"]
33
  assert "on_target" in c["formula"] and "self_off" in c["formula"]
34
- assert "no hidden" in c["basis"].lower()
 
35
 
36
 
37
  def test_on_target_does_not_claim_to_be_rule_set_2():
 
28
 
29
  def test_composite_formula_is_stated_not_hidden():
30
  # The reviewer's ask: "the weights making the composite score should be
31
+ # transparent."
32
+ #
33
+ # CONTRACT CHANGED 2026-08-01, deliberately. This test used to assert
34
+ # `"no hidden" in basis`, which locked in a false claim: the composite is
35
+ # NOT a plain product. crispr.py has always computed
36
+ # `on_target * (1 - 0.6 * self_off)`, so "no hidden weights and no tuning
37
+ # constants" was the disclosure hiding the one weight there is. The
38
+ # provenance now states the weight and calls it a judgement call; the
39
+ # value is asserted against the engine's own constant in
40
+ # tests/test_crispr_disclosure.py so the two cannot drift apart again.
41
+ from dee.core.crispr import COMPOSITE_OFFTARGET_WEIGHT
42
  c = cm.METHODS["composite"]
43
  assert "on_target" in c["formula"] and "self_off" in c["formula"]
44
+ assert str(COMPOSITE_OFFTARGET_WEIGHT) in c["formula"]
45
+ assert "judgement call" in c["basis"]
46
 
47
 
48
  def test_on_target_does_not_claim_to_be_rule_set_2():
tests/test_epistasis_narration.py ADDED
@@ -0,0 +1,229 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Interaction Radar prose — one row must not read like the row above it.
2
+
3
+ The incident: a reviewing scientist ran the Radar, got ten rows, and quoted
4
+ the repeated sentence back verbatim. ``_narrate`` was one fixed template per
5
+ verdict class, so every clash said "Predicted antagonism: X looks …ly less
6
+ favorable…" and every independent row said the same twenty words with nothing
7
+ from the measurement in them. The analysis had genuinely run per variant; the
8
+ panel gave no evidence of it, which is indistinguishable from a panel that
9
+ prints canned text.
10
+
11
+ These lock the fix as a property of the OUTPUT, not of the template: within a
12
+ verdict class, rows built from different numbers must produce different notes,
13
+ and the notes must quote the numbers they were built from. They also guard the
14
+ honesty edge — the adjectives are threshold lookups, so a "strong" claim must
15
+ be backed by a shift the threshold actually admits.
16
+ """
17
+ import re
18
+
19
+ import numpy as np
20
+ import pytest
21
+
22
+ from dee.core import epistasis as E
23
+ from dee.core.epistasis import AA_ORDER, COOP_FLOOR, analyze_variant
24
+
25
+ _COL = {aa: i for i, aa in enumerate(AA_ORDER)}
26
+
27
+
28
+ def _matrix(n, overrides):
29
+ m = np.zeros((n, len(AA_ORDER)), dtype=np.float64)
30
+ for (pos, aa), val in overrides.items():
31
+ m[pos, _COL[aa]] = val
32
+ return m
33
+
34
+
35
+ class _FakeLP:
36
+ def __init__(self, by_seq):
37
+ self.by_seq = by_seq
38
+
39
+ def __call__(self, seq, positions):
40
+ m = self.by_seq[seq]
41
+ return np.stack([m[p] for p in list(positions)])
42
+
43
+
44
+ def _two_site(marg_a, marg_b, shift_a, shift_b, wt_len=8):
45
+ """A 2-site variant A1C,A5D with exactly the marginals and shifts asked for.
46
+
47
+ Returns the VariantEpistasis. Building the fixture from (marginal, shift)
48
+ rather than from raw log-probs is what lets a test say "give me a -1.5
49
+ clash" and read the resulting sentence.
50
+ """
51
+ wt = "A" * wt_len
52
+ mutant = list(wt)
53
+ mutant[0], mutant[4] = "C", "D"
54
+ mutant = "".join(mutant)
55
+ lp_wt = _matrix(wt_len, {(0, "C"): marg_a, (4, "D"): marg_b})
56
+ lp_mut = _matrix(wt_len, {(0, "C"): marg_a + shift_a, (4, "D"): marg_b + shift_b})
57
+ return analyze_variant(wt, "A1C,A5D", _FakeLP({wt: lp_wt, mutant: lp_mut}))
58
+
59
+
60
+ # --------------------------------------------------------------------------- #
61
+ # the actual complaint: ten rows, one sentence
62
+ # --------------------------------------------------------------------------- #
63
+ @pytest.mark.parametrize("verdict, rows", [
64
+ # (marg_a, marg_b, shift_a, shift_b) — all land in one verdict class.
65
+ ("clash", [(0.4, 0.3, -1.9, 0.0), (0.9, 0.2, -1.2, -0.5),
66
+ (0.5, 0.6, -0.7, 0.1), (1.2, 0.4, -2.6, -0.9)]),
67
+ ("cooperative", [(0.2, 0.1, 1.0, 0.5), (0.8, 0.3, 0.6, 0.2),
68
+ (0.4, 0.4, 2.2, 0.1), (0.1, 0.9, 0.9, 0.8)]),
69
+ ("independent", [(0.5, 0.5, 0.0, 0.0), (0.3, 0.2, 0.4, -0.2),
70
+ (0.7, 0.1, 0.35, 0.05), (0.2, 0.6, -0.4, 0.35)]),
71
+ ])
72
+ def test_rows_in_one_verdict_class_do_not_share_a_sentence(verdict, rows):
73
+ notes = []
74
+ for (ma, mb, sa, sb) in rows:
75
+ res = _two_site(ma, mb, sa, sb)
76
+ assert res.verdict == verdict, (verdict, ma, mb, sa, sb, res.verdict)
77
+ notes.append(res.note)
78
+ assert len(set(notes)) == len(notes), notes
79
+
80
+
81
+ def test_the_note_quotes_the_numbers_it_was_built_from():
82
+ """A sentence that names no measurement can be printed without doing the
83
+ measurement — which is exactly how the old one read."""
84
+ res = _two_site(0.40, 0.30, -1.90, 0.00)
85
+ assert res.verdict == "clash"
86
+ # worst site's own three numbers: shift magnitude, marginal, in-context
87
+ assert "1.90" in res.note
88
+ assert "+0.40" in res.note
89
+ assert "-1.50" in res.note # 0.40 + (-1.90)
90
+ assert "A1C" in res.note
91
+
92
+
93
+ def test_every_verdict_names_the_site_that_drove_it():
94
+ """"the substitutions clash" without saying WHICH one is not actionable —
95
+ the user's next move is to drop or re-test one specific mutation."""
96
+ clash = _two_site(0.4, 0.3, -1.9, 0.0)
97
+ coop = _two_site(0.2, 0.1, 1.0, 0.5)
98
+ indep = _two_site(0.3, 0.2, 0.4, -0.2)
99
+ assert "A1C" in clash.note and clash.verdict == "clash"
100
+ assert "A1C" in coop.note and coop.verdict == "cooperative"
101
+ assert "A1C" in indep.note and indep.verdict == "independent"
102
+
103
+
104
+ # --------------------------------------------------------------------------- #
105
+ # the keywords the UI and the agent read (app.js _radarVerdictMeta renders the
106
+ # chip; check_interactions passes `note` to the model as `explanation`)
107
+ # --------------------------------------------------------------------------- #
108
+ def test_the_verdict_word_survives_in_the_prose():
109
+ assert "clash" in _two_site(0.4, 0.3, -1.9, 0.0).note.lower()
110
+ assert "reinforce" in _two_site(0.2, 0.1, 1.0, 0.5).note.lower()
111
+ assert "independent" in _two_site(0.3, 0.2, 0.4, -0.2).note.lower()
112
+
113
+
114
+ # --------------------------------------------------------------------------- #
115
+ # honesty: the adjective is a threshold lookup, never a flourish
116
+ # --------------------------------------------------------------------------- #
117
+ @pytest.mark.parametrize("shift, word", [
118
+ (-2.4, "strong"), (-1.5, "strong"), (-1.0, "moderate"),
119
+ (-0.7, "moderate"), (-0.6, "slight"),
120
+ ])
121
+ def test_magnitude_words_track_the_threshold_not_the_mood(shift, word):
122
+ res = _two_site(3.0, 0.3, shift, 0.0)
123
+ assert res.verdict == "clash"
124
+ assert f"a {word} antagonistic shift" in res.note, res.note
125
+
126
+
127
+ def test_no_superlative_appears_that_the_numbers_do_not_support():
128
+ """A 0.6 ΣΔLL drop is 'slight' by the module's own thresholds. Calling it
129
+ strong is the exact failure mode ENGINEERING.md §9 exists to prevent —
130
+ the scientist acts on the adjective, not the float."""
131
+ res = _two_site(3.0, 0.3, -0.6, 0.0)
132
+ assert "strong" not in res.note
133
+ assert "slight" in res.note
134
+
135
+
136
+ # --------------------------------------------------------------------------- #
137
+ # the two shapes of 'independent' are not the same finding
138
+ # --------------------------------------------------------------------------- #
139
+ def test_flat_and_cancelling_independents_read_differently():
140
+ """Nothing moved, versus two sites moved half a log-unit each and
141
+ cancelled. Both score 'independent'; only one of them is boring."""
142
+ flat = _two_site(0.5, 0.5, 0.0, 0.0)
143
+ cancel = _two_site(0.5, 0.5, 0.45, -0.45)
144
+ assert flat.verdict == cancel.verdict == "independent"
145
+ assert flat.note != cancel.note
146
+ assert "cancel" in cancel.note
147
+ # and the threshold it was measured against is stated, not implied
148
+ assert f"{COOP_FLOOR:g}" in flat.note and f"{COOP_FLOOR:g}" in cancel.note
149
+
150
+
151
+ def test_a_net_cooperative_row_still_reports_a_site_that_lost():
152
+ """Net-positive does not mean every substitution won. Suppressing the
153
+ loser is how a user orders a variant with a known bad site in it."""
154
+ # -0.45 is deliberate: notable enough to name (>= 0.3) but under
155
+ # RISK_FLOOR, so the verdict stays cooperative instead of flipping to
156
+ # clash. That band is exactly where a hidden loser can ride along.
157
+ res = _two_site(0.3, 0.3, 1.4, -0.45)
158
+ assert res.verdict == "cooperative"
159
+ assert "A5D" in res.note and "0.45" in res.note
160
+ assert "not uniform" in res.note
161
+
162
+
163
+ # --------------------------------------------------------------------------- #
164
+ # grammar that a naive pluralizer gets wrong ("The other 1 site barely move")
165
+ # --------------------------------------------------------------------------- #
166
+ def test_singular_and_plural_remainders_agree_with_their_verb():
167
+ two = _two_site(0.2, 0.1, 1.0, 0.0) # 1 other site
168
+ assert "The other site barely moves." in two.note
169
+ assert "The other 1 site" not in two.note
170
+
171
+ # 3 sites, one mover → 2 others
172
+ wt = "A" * 10
173
+ mutant = "C" + "A" * 3 + "D" + "A" * 3 + "E" + "A"
174
+ lp_wt = _matrix(10, {(0, "C"): 0.2, (4, "D"): 0.1, (8, "E"): 0.1})
175
+ lp_mut = _matrix(10, {(0, "C"): 1.2, (4, "D"): 0.1, (8, "E"): 0.1})
176
+ three = analyze_variant(wt, "A1C,A5D,A9E", _FakeLP({wt: lp_wt, mutant: lp_mut}))
177
+ assert three.verdict == "cooperative"
178
+ assert "The other 2 sites barely move." in three.note
179
+
180
+
181
+ # --------------------------------------------------------------------------- #
182
+ # a single substitution has a number too
183
+ # --------------------------------------------------------------------------- #
184
+ def test_single_mutants_report_their_marginal():
185
+ """The old note was 'A single substitution — no interactions to analyze.',
186
+ identical on every single-mutant row and carrying nothing measured."""
187
+ wt = "AAAAAA"
188
+ res = analyze_variant(wt, "A1C", _FakeLP({wt: _matrix(6, {(0, "C"): 1.23})}))
189
+ assert res.verdict == "single"
190
+ assert "A1C" in res.note and "+1.23" in res.note
191
+
192
+
193
+ # --------------------------------------------------------------------------- #
194
+ # the unit, because "1.0" on its own means nothing (the same reviewer's other
195
+ # complaint, and this prose is one of the places the number appears)
196
+ # --------------------------------------------------------------------------- #
197
+ @pytest.mark.parametrize("args", [
198
+ (0.4, 0.3, -1.9, 0.0), # clash
199
+ (0.2, 0.1, 1.0, 0.5), # cooperative
200
+ (0.3, 0.2, 0.4, -0.2), # independent
201
+ (0.5, 0.5, 0.0, 0.0), # flat independent
202
+ ])
203
+ def test_every_number_in_the_prose_carries_its_unit(args):
204
+ note = _two_site(*args).note
205
+ assert "ΣΔLL" in note
206
+ # No bare decimal outside a parenthetical pair or immediately before the
207
+ # unit — catches a future edit that adds "risk 1.42" with nothing after it.
208
+ stripped = re.sub(r"[-+]?\d+\.\d+\s*ΣΔLL", "", note)
209
+ stripped = re.sub(r"\([-+]?\d+\.\d+ alone → [-+]?\d+\.\d+ in combination\)", "", stripped)
210
+ stripped = re.sub(r"\([-+]?\d+\.\d+ alone → [-+]?\d+\.\d+ together\)", "", stripped)
211
+ stripped = re.sub(r"\([-+]?\d+\.\d+\)", "", stripped) # "dragged down too (-0.40)"
212
+ stripped = re.sub(r"±\d+(\.\d+)?", "", stripped) # the flag threshold
213
+ stripped = re.sub(r"\b\d+ sites?\b", "", stripped) # counts, not measurements
214
+ assert not re.search(r"\d+\.\d+", stripped), stripped
215
+
216
+
217
+ # --------------------------------------------------------------------------- #
218
+ # the chip threshold and the prose threshold must not drift apart
219
+ # --------------------------------------------------------------------------- #
220
+ def test_the_notable_site_threshold_matches_the_ui_chip():
221
+ """app.js _radarSiteChip tints a mutation chip at |shift| >= 0.3. If the
222
+ prose used a different bar, a row could name a site as "dragged down too"
223
+ while its chip rendered neutral, or vice versa — the panel would visibly
224
+ disagree with itself."""
225
+ with open("dee/static/app.js", encoding="utf-8") as fh:
226
+ app = fh.read()
227
+ block = re.search(r"function _radarSiteChip\(site\) \{(.*?)\n\}", app, re.S).group(1)
228
+ ui = {float(x) for x in re.findall(r"shift\s*[<>]=\s*-?([0-9.]+)", block)}
229
+ assert ui == {E._SITE_NOTABLE}, (ui, E._SITE_NOTABLE)
tests/test_hidden_attribute.py ADDED
@@ -0,0 +1,128 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """`hidden` has to mean hidden.
2
+
3
+ Eight elements carried the `hidden` attribute and rendered anyway, because the
4
+ UA sheet's `[hidden] { display: none }` loses to ANY author `display:` on the
5
+ element — it is an author-vs-UA contest, not a specificity one, so a plain
6
+ `.run-meta { display: flex }` cancels it.
7
+
8
+ What that looked like: an empty bordered box, 888x30 with one pixel of border
9
+ and nothing in it, sitting under "How to read this" on every library painted
10
+ from a conversation (renderRunMeta re-hides the box when the payload has no
11
+ `settings_used`, which an agent-painted run never does). A reviewing scientist
12
+ photographed it. Measured on a fresh load the same fault was also rendering a
13
+ 92px CRISPR base-editor row, a 136px primer organism block, two clone panes, a
14
+ stray filter-clear glyph, and the entire 946x401 primer results card.
15
+
16
+ This test re-derives the list from the markup rather than hard-coding it, so
17
+ the next class that acquires a `display:` fails here instead of in a
18
+ screenshot.
19
+ """
20
+ import re
21
+
22
+ _HTML = "dee/static/index.html"
23
+ _SHEETS = ("dee/static/app.css", "dee/static/catalog.css")
24
+
25
+ _EXEMPT = {
26
+ # Deliberate, and says so in a comment: the scrim keeps its box while it
27
+ # fades out, so `.sidebar-scrim[hidden] { display: block }` is the point.
28
+ "sidebar-scrim":
29
+ "keeps its box during the fade-out transition, on purpose",
30
+ # These get a display ONLY from `body[data-ui="bench"][data-bench="open"]
31
+ # .bench-strip`, and app.js clears their `hidden` in the same block that
32
+ # sets data-bench. The state and the attribute never co-occur, and a bare
33
+ # `.bench-strip[hidden]` guard is (0,2,0) — it would lose to that (0,3,1)
34
+ # selector anyway, so adding one would look like protection and be none.
35
+ "bench-strip":
36
+ "display comes only from a body[data-bench=open] rule; JS clears "
37
+ "hidden in the same block",
38
+ "bench-canvashead":
39
+ "same as .bench-strip",
40
+ }
41
+
42
+
43
+ def _read(path):
44
+ with open(path, encoding="utf-8") as fh:
45
+ return fh.read()
46
+
47
+
48
+ def _classes_rendered_with_hidden(html):
49
+ """Every class that appears on an element written with a bare `hidden`."""
50
+ out = set()
51
+ for m in re.finditer(r"<\w+([^>]*)>", html):
52
+ attrs = m.group(1)
53
+ if not re.search(r"(^|\s)hidden(\s|=|/|$)", attrs):
54
+ continue
55
+ cm = re.search(r'class="([^"]*)"', attrs)
56
+ if cm:
57
+ out.update(cm.group(1).split())
58
+ return out
59
+
60
+
61
+ def _strip_comments(css):
62
+ return re.sub(r"/\*.*?\*/", " ", css, flags=re.S)
63
+
64
+
65
+ def _classes_given_a_display(css):
66
+ """Classes that are the SUBJECT of a rule declaring `display:`.
67
+
68
+ Subject means the last compound selector — `.brand-mark svg` styles the
69
+ svg, not `.brand-mark`, so it cannot cancel `hidden` on `.brand-mark`.
70
+ Selectors already qualified with `[hidden]` are the fix, not the fault.
71
+ """
72
+ css = _strip_comments(css)
73
+ out = {}
74
+ for rule in re.finditer(r"([^{}]+)\{([^{}]*)\}", css):
75
+ selector, body = rule.group(1), rule.group(2)
76
+ decl = re.search(r"(?:^|[;{\s])display\s*:\s*([a-z-]+)", body)
77
+ if not decl:
78
+ continue
79
+ # `display: none` cannot cancel `hidden` — it agrees with it.
80
+ if decl.group(1) == "none":
81
+ continue
82
+ for part in selector.split(","):
83
+ part = part.strip()
84
+ if not part or part.startswith("@"):
85
+ continue
86
+ subject = re.split(r"[\s>+~]+", part)[-1]
87
+ if "[hidden]" in subject:
88
+ continue
89
+ for cls in re.findall(r"\.([A-Za-z0-9_-]+)", subject):
90
+ out.setdefault(cls, part)
91
+ return out
92
+
93
+
94
+ def _guarded(css):
95
+ return set(re.findall(r"\.([A-Za-z0-9_-]+)\[hidden\]", _strip_comments(css)))
96
+
97
+
98
+ def test_no_class_silently_cancels_the_hidden_attribute():
99
+ html = _read(_HTML)
100
+ displays, guards = {}, set()
101
+ for sheet in _SHEETS:
102
+ css = _read(sheet)
103
+ displays.update(_classes_given_a_display(css))
104
+ guards |= _guarded(css)
105
+
106
+ unguarded = sorted(
107
+ cls for cls in _classes_rendered_with_hidden(html)
108
+ if cls in displays and cls not in guards and cls not in _EXEMPT
109
+ )
110
+ assert not unguarded, (
111
+ "these classes are written with `hidden` in index.html but a stylesheet "
112
+ "gives them a display:, which beats the UA [hidden] rule, so they render "
113
+ "anyway — add `.<class>[hidden] { display: none; }`: "
114
+ + ", ".join(f"{c} (from `{displays[c]}`)" for c in unguarded)
115
+ )
116
+
117
+
118
+ def test_the_run_meta_box_is_the_one_from_the_review():
119
+ """Named explicitly: this is the empty bordered box in the screenshots, and
120
+ it appears twice (#runMeta on Directed Evolution, #primerRunMeta on Primer
121
+ Analysis) because both use the .run-meta class."""
122
+ css = _read("dee/static/app.css")
123
+ assert re.search(r"\.run-meta\[hidden\][^{]*\{[^}]*display\s*:\s*none", _strip_comments(css)), (
124
+ ".run-meta[hidden] must resolve to display:none; without it the box "
125
+ "renders empty on any library painted from a conversation."
126
+ )
127
+ html = _read(_HTML)
128
+ assert html.count('class="run-meta"') >= 2
tests/test_legibility_ui.py ADDED
@@ -0,0 +1,326 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Legibility of the design surface: units, the run strip, the decision trace.
2
+
3
+ Three review findings, all of the same shape — the app knew something and did
4
+ not say it where it could be read:
5
+
6
+ • "what does a fitness score of 1.0 represent? 2/3 is over and 1/3 is under,
7
+ but none are negative." The unit (ΣΔLL) lived in a hover `title` on one
8
+ column header, added May 2026. On a touch device it did not exist at all.
9
+ • "context 0% · $0.11" — two numbers, no nouns, no denominators.
10
+ • "a function where the entire decision process is shown" — the events all
11
+ existed; they scrolled away.
12
+
13
+ These are markup/wiring assertions because every one of these failures is
14
+ SILENT: a missing class renders nothing, a painter called with the wrong
15
+ arity returns undefined, a script loaded in the wrong order drops the opening
16
+ events of a restored run. None of them raises.
17
+ """
18
+ import re
19
+
20
+ import pytest
21
+
22
+ _APP_JS = "dee/static/app.js"
23
+ _COCKPIT = "dee/static/cockpit.js"
24
+ _TRACE_JS = "dee/static/trace.js"
25
+ _TRACE_CSS = "dee/static/trace.css"
26
+ _INDEX = "dee/static/index.html"
27
+
28
+
29
+ def _read(p):
30
+ with open(p, encoding="utf-8") as fh:
31
+ return fh.read()
32
+
33
+
34
+ # --------------------------------------------------------------------------- #
35
+ # 1. the unit, visible without hovering
36
+ # --------------------------------------------------------------------------- #
37
+ def test_the_fitness_column_carries_its_unit_in_both_table_shells():
38
+ """There are TWO copies of the results table header: the static one in
39
+ index.html and the one teardownSkeleton() rebuilds in JS after the loading
40
+ skeleton. Fixing only the static copy means the unit shows on an empty
41
+ page and vanishes on every real run."""
42
+ for src in (_read(_INDEX), _read(_APP_JS)):
43
+ header = re.search(r'data-sort="fitness"[^>]*>(.*?)</th>', src, re.S)
44
+ assert header, "fitness column header not found"
45
+ assert 'class="th-unit"' in header.group(1), header.group(1)
46
+ assert "ΣΔLL" in header.group(1)
47
+
48
+
49
+ def test_the_unit_is_not_only_in_a_hover_title():
50
+ """The regression this locks: someone "tidies" the header back to a bare
51
+ word because the title attribute already says it. The title is not
52
+ readable on a phone and not discoverable on a desktop."""
53
+ html = _read(_INDEX)
54
+ header = re.search(r'data-sort="fitness"[^>]*>(.*?)</th>', html, re.S).group(1)
55
+ visible = re.sub(r"<[^>]+>", "", header)
56
+ assert "ΣΔLL" in visible, visible
57
+
58
+
59
+ def test_the_legend_answers_all_three_parts_of_the_question():
60
+ """Unit, no fixed zero, and why a top-ranked slice has no negatives in it.
61
+ The reviewer asked all three; answering one of them is not a fix."""
62
+ html = _read(_INDEX)
63
+ legend = re.search(r'<p class="unit-legend" id="fitnessLegend">(.*?)</p>',
64
+ html, re.S).group(1)
65
+ assert "&Sigma;&Delta;LL" in legend or "ΣΔLL" in legend
66
+ assert "no fixed zero" in legend
67
+ assert "negatives" in legend
68
+ # and it must NOT be inside the collapsed "How to read this" block
69
+ pos = html.index('id="fitnessLegend"')
70
+ details = [m.span() for m in re.finditer(r"<details.*?</details>", html, re.S)]
71
+ assert not any(a < pos < b for a, b in details), "legend hidden inside a <details>"
72
+
73
+
74
+ def test_every_place_the_score_is_printed_names_its_unit():
75
+ app = _read(_APP_JS)
76
+ # stat tile label (the tile is read at a glance and never hovered)
77
+ assert "'Top fitness (ΣΔLL)'" in app
78
+ # calibration chart axis
79
+ assert "ESM-2 predicted fitness · ΣΔLL" in app
80
+ assert "your assay units" in app
81
+ # interaction radar score line
82
+ assert 'class="rs-unit"' in app
83
+ # round-2 learned rows are PER-SUBSTITUTION, so ΔLL, not ΣΔLL — labelling
84
+ # them the same would imply one scale across two different numbers
85
+ assert 'class="ls-unit">ΔLL<' in app
86
+
87
+
88
+ def test_the_learned_panel_says_it_is_a_different_scale():
89
+ html = _read(_INDEX)
90
+ panel = re.search(r'<section class="learned-panel".*?</section>', html, re.S).group(0)
91
+ assert "&Delta;LL" in panel
92
+ assert "Not the same scale" in panel
93
+
94
+
95
+ def test_the_mutation_map_states_its_axes_outside_the_svg():
96
+ """The chart is an SVG with preserveAspectRatio="none", which
97
+ non-uniformly scales any text inside it — so the axis key is prose above
98
+ the chart, and the protein length is filled in from the real run."""
99
+ html = _read(_INDEX)
100
+ app = _read(_APP_JS)
101
+ head = re.search(r'<div class="mutmap-head">(.*?)</div>', html, re.S).group(1)
102
+ assert "residue position" in head
103
+ assert 'id="mutmapLen"' in head
104
+ assert "log-likelihood" in head
105
+ assert "getElementById('mutmapLen')" in app
106
+
107
+
108
+ @pytest.mark.parametrize("cls", ["th-unit", "unit-legend", "rs-unit", "ls-unit"])
109
+ def test_the_unit_classes_have_rules(cls):
110
+ """ENGINEERING.md §13.7: a guessed class name is a silent no-op. These are
111
+ all new, all in trace.css, and all invisible if the rule is missing."""
112
+ assert "." + cls in _read(_TRACE_CSS)
113
+
114
+
115
+ # --------------------------------------------------------------------------- #
116
+ # 2. the run strip — was "context 0% · $0.11"
117
+ # --------------------------------------------------------------------------- #
118
+ def test_both_meter_numbers_are_named_and_have_a_denominator():
119
+ cp = _read(_COCKPIT)
120
+ meter = re.search(r"function renderMeter\(\) \{.*?\n \}", cp, re.S).group(0)
121
+ assert '"Context "' in meter, "the context reading is unlabelled"
122
+ assert "model cost" in meter, "the dollar figure is unlabelled"
123
+ # a 1M window makes the honest reading "0%", which reads as broken; the
124
+ # token counts are what carry the magnitude
125
+ assert "fmtTokens(state.ctxUsed)" in meter and "fmtTokens(state.ctxLimit)" in meter
126
+ assert "state.els.meter.title" in meter, "no plain-language explanation anywhere"
127
+
128
+
129
+ def test_a_sub_one_percent_context_is_not_rendered_as_a_flat_zero():
130
+ """The old strip printed "context 0%" for the entire normal life of a run,
131
+ because a few thousand tokens of a 1M window rounds to zero. The token
132
+ counts are what carry the magnitude now; the percentage is appended only
133
+ once it is the thing worth reading. Measured, not guessed: at the rail's
134
+ real 390px, the extra "(4%)" is what pushed "model cost" onto a second
135
+ line, and an ellipsis there hides the noun this whole change adds."""
136
+ cp = _read(_COCKPIT)
137
+ meter = re.search(r"function renderMeter\(\) \{.*?\n \}", cp, re.S).group(0)
138
+ assert 'pct >= 50 ? " (" + Math.round(pct) + "%)" : ""' in meter
139
+ # ...and the exact figure is never lost — the trace states it either way
140
+ assert "pct >= 1 ? Math.round(pct) : pct.toFixed(1)" in _read(_TRACE_JS)
141
+
142
+
143
+ def test_the_warn_threshold_survived_the_rewrite():
144
+ """The 80%-full warning is the only reason the percentage is still shown
145
+ at all. Losing it while renaming things would be a silent downgrade."""
146
+ cp = _read(_COCKPIT)
147
+ assert 'classList.toggle("cp-meter--warn", pct >= 80)' in cp
148
+
149
+
150
+ # --------------------------------------------------------------------------- #
151
+ # 3. the decision trace
152
+ # --------------------------------------------------------------------------- #
153
+ def test_the_trace_is_fed_from_the_single_point_every_event_passes():
154
+ """Feeding it from anywhere else means a future event kind reaches the
155
+ rail and not the trace, and nothing fails."""
156
+ cp = _read(_COCKPIT)
157
+ body = re.search(r"function applyEvent\(ev\) \{(.*?)\n \}", cp, re.S).group(1)
158
+ assert "window.TDTrace.push(ev)" in body
159
+ # before the switch, so an unhandled kind still reaches the trace
160
+ assert body.index("TDTrace.push(ev)") < body.index("switch (ev.kind)")
161
+
162
+
163
+ def test_the_trace_consumes_every_event_kind_the_rail_does():
164
+ """A kind the rail routes but the trace drops is a hole in the record."""
165
+ cp = _read(_COCKPIT)
166
+ tr = _read(_TRACE_JS)
167
+ body = re.search(r"function applyEvent\(ev\) \{(.*?)\n \}", cp, re.S).group(1)
168
+ rail_kinds = set(re.findall(r'case "(\w+)":', body))
169
+ trace_kinds = set(re.findall(r'case "(\w+)":', tr))
170
+ assert rail_kinds <= trace_kinds, rail_kinds - trace_kinds
171
+
172
+
173
+ def test_the_trace_loads_before_the_cockpit():
174
+ """Both are `defer`, so document order is load order. Loaded after, the
175
+ guarded `window.TDTrace &&` push silently drops the opening events of a
176
+ restored run — the trace would be missing exactly the steps that explain
177
+ how the run started."""
178
+ html = _read(_INDEX)
179
+ assert html.index("/static/trace.js") < html.index("/static/cockpit.js")
180
+
181
+
182
+ def test_the_trace_stylesheet_is_linked():
183
+ html = _read(_INDEX)
184
+ assert "/static/trace.css" in html
185
+
186
+
187
+ def test_each_step_carries_objective_rationale_status_and_result():
188
+ """The four fields the reviewer named, by reference to a tool they use."""
189
+ tr = _read(_TRACE_JS)
190
+ row = re.search(r"function toolRow\(e, n\) \{.*?\n \}", tr, re.S).group(0)
191
+ for label in ("Objective", "Rationale", "Result"):
192
+ assert f">{label}</p>" in row, label
193
+ assert "td-step-status" in row
194
+ assert "STATUS_WORD" in tr
195
+
196
+
197
+ def test_a_step_with_no_stated_reason_says_so_instead_of_inventing_one():
198
+ """The plan step is offered as context and LABELLED as the plan. Quoting
199
+ it as the model's rationale would be putting words in its mouth."""
200
+ tr = _read(_TRACE_JS)
201
+ row = re.search(r"function toolRow\(e, n\) \{.*?\n \}", tr, re.S).group(0)
202
+ assert "without narrating it" in row
203
+ assert "The plan step active at the time was" in row
204
+
205
+
206
+ def test_durations_come_from_the_server_clock_not_a_browser_stopwatch():
207
+ """Every event carries `at` from orchestrator._emit. A client-side timer
208
+ would show nothing after a reload replays the run, or would time the
209
+ replay and present that as the step's duration."""
210
+ tr = _read(_TRACE_JS)
211
+ assert "secs(e.at, e.endAt)" in tr
212
+ assert "Date.now()" not in tr, "browser clock used for step timing"
213
+
214
+
215
+ def test_the_trace_is_per_run_and_cleared_with_it():
216
+ cp = _read(_COCKPIT)
217
+ reset = re.search(r"function reset\(\) \{.*?\n \}", cp, re.S).group(0)
218
+ assert "TDTrace.reset()" in reset
219
+ assert "TDTrace.close()" in reset
220
+
221
+
222
+ def test_the_trace_never_breaks_the_rail():
223
+ """push() is called inline from applyEvent, so a throw would take the
224
+ transcript down with it."""
225
+ tr = _read(_TRACE_JS)
226
+ push = re.search(r"function push\(ev\) \{.*?\n \}", tr, re.S).group(0)
227
+ assert "try {" in push and "catch" in push
228
+
229
+
230
+ def test_the_meter_explains_itself_in_full_sentences_inside_the_trace():
231
+ tr = _read(_TRACE_JS)
232
+ assert "Context used:" in tr
233
+ assert "Model cost so far:" in tr
234
+ assert "token" in tr
235
+
236
+
237
+ def test_the_entry_point_is_labelled_text_not_a_bare_glyph():
238
+ """The complaint upstream of all of this is unlabelled UI. A ▤ button
239
+ would reproduce it."""
240
+ cp = _read(_COCKPIT)
241
+ assert 'id="cpTrace"' in cp
242
+ assert ">Trace</button>" in cp
243
+ assert 'aria-label="Open the decision trace' in cp
244
+ assert '"Trace · " + steps' in cp # and it says how many
245
+
246
+
247
+ @pytest.mark.parametrize("cls", [
248
+ "td-trace", "td-trace-panel", "td-step", "td-step-obj", "td-step-status",
249
+ "td-step-detail", "td-trace-meta", "cp-trace-btn",
250
+ ])
251
+ def test_the_trace_classes_have_rules(cls):
252
+ assert "." + cls in _read(_TRACE_CSS)
253
+
254
+
255
+ # --------------------------------------------------------------------------- #
256
+ # 4. interaction analysis progress
257
+ # --------------------------------------------------------------------------- #
258
+ def test_the_radar_has_an_elapsed_clock_like_every_other_slow_path():
259
+ app = _read(_APP_JS)
260
+ assert "startRadarClock" in app and "stopRadarClock" in app
261
+ assert 'class="radar-elapsed"' in app
262
+ assert "Analyzing… ${txt}" in app
263
+
264
+
265
+ def test_the_radar_clock_is_always_stopped():
266
+ """An interval outliving the request keeps counting on a finished panel
267
+ and rewrites the button label back to "Analyzing…" every second."""
268
+ app = _read(_APP_JS)
269
+ fn = re.search(r"\(function initRadar\(\) \{.*?\n\}\)\(\);", app, re.S).group(0)
270
+ finally_block = re.search(r"finally \{(.*?)\n \}", fn, re.S).group(1)
271
+ assert "stopRadarClock()" in finally_block
272
+
273
+
274
+ def test_the_radar_progress_bar_is_indeterminate_on_purpose():
275
+ """One POST covers the whole library, so there is no per-variant fraction
276
+ to draw. A bar that crept to 60% would be inventing progress."""
277
+ css = _read(_TRACE_CSS)
278
+ assert ".radar-progress" in css
279
+ assert "Indeterminate on purpose" in css
280
+ assert "prefers-reduced-motion" in css
281
+
282
+
283
+ def test_the_radar_says_what_the_wait_is_for():
284
+ """"Analyzing…" for 40 seconds is indistinguishable from a hang. Naming
285
+ the work — n combinations, one forward pass per site — makes the wait
286
+ legible even before the clock is read."""
287
+ app = _read(_APP_JS)
288
+ assert "one masked forward pass per mutated site" in app
289
+
290
+
291
+ # --------------------------------------------------------------------------- #
292
+ # 5. this whole change set is invisible behind a stale cache
293
+ # --------------------------------------------------------------------------- #
294
+ def test_the_changed_assets_got_a_fresh_cache_key():
295
+ """ENGINEERING.md §14: assets are served max-age=31536000 and busted only
296
+ by ?v=. Shipping app.js/cockpit.js edits without a bump is the documented
297
+ way to make a frontend change appear not to work."""
298
+ html = _read(_INDEX)
299
+ for asset in ("app.js", "cockpit.js", "trace.js", "trace.css"):
300
+ m = re.search(r"/static/" + re.escape(asset) + r"\?v=([\w.-]+)", html)
301
+ assert m, asset
302
+ # Assert the tag belongs to THIS review round, not a specific
303
+ # workstream's label. Four branches edited app.js and cockpit.js in
304
+ # parallel and each bumped the tag to its own name; pinning one
305
+ # literal here just makes whichever branch merges last look broken,
306
+ # while testing nothing about staleness.
307
+ assert m.group(1).startswith("20260801-"), (asset, m.group(1))
308
+
309
+
310
+ def test_trace_css_uses_only_tokens_that_exist():
311
+ """ENGINEERING.md §13.1: an invalid var() poisons the whole shorthand —
312
+ `border: 1px solid var(--rule)` computes to `border: none`, not to a
313
+ default colour. Two invented names once made a panel render as floating
314
+ text."""
315
+ css = _read("dee/static/app.css")
316
+ declared = set(re.findall(r"^\s*(--[\w-]+)\s*:", css, re.M))
317
+ used = set(re.findall(r"var\((--[\w-]+)", _read(_TRACE_CSS)))
318
+ assert used <= declared, used - declared
319
+
320
+
321
+ def test_trace_css_comment_markers_balance():
322
+ """ENGINEERING.md §13.5: one stray `*/` silently kills the rest of a
323
+ stylesheet, and the only reason it was caught last time is that a measured
324
+ metric moved the wrong way."""
325
+ css = _read(_TRACE_CSS)
326
+ assert css.count("/*") == css.count("*/"), (css.count("/*"), css.count("*/"))
tests/test_mutation_landscape.py ADDED
@@ -0,0 +1,240 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The mutation landscape's colour scale.
2
+
3
+ A reviewing scientist asked for "clearer / more continuous color coding than
4
+ white, gray, and invisible". They were describing this chart. It encoded
5
+ ESM-2 delta-log-likelihood — continuous, signed, and the sign is the whole
6
+ claim — as three hard-coded greys chosen by min-max normalisation, with no
7
+ legend and a card description that named a legend which had been deleted.
8
+
9
+ Two failures, both silent:
10
+
11
+ * min-max erases zero. A library whose positions are all worse than wild
12
+ type rendered identically to one whose positions are all better, because
13
+ the scale only ever showed rank within the run.
14
+ * the greys were literals, so they ignored the theme. Measured against a
15
+ real 30-variant GFP library on the dark canvas, the fill that every dot
16
+ ended up with scored **1.53:1** — under the 3:1 floor for a non-text
17
+ element. That is the "invisible".
18
+
19
+ The replacement is diverging, anchored at zero, and near-iso-luminant so the
20
+ same ramp clears 3:1 on BOTH canvases. These tests re-derive the ramp from
21
+ app.js and check that numerically, because "looks fine" is what shipped the
22
+ 1.53:1 version.
23
+ """
24
+ import re
25
+
26
+ import pytest
27
+
28
+ _APP = "dee/static/app.js"
29
+ _CSS = "dee/static/app.css"
30
+ _HTML = "dee/static/index.html"
31
+
32
+ # The two canvases the dots are drawn on, read from app.css's own tokens
33
+ # (.mutmap-canvas background) — dark theme --gray-0 and light theme white.
34
+ CANVAS_DARK = (27, 26, 24)
35
+ CANVAS_LIGHT = (255, 255, 255)
36
+
37
+ # WCAG 2.1 non-text contrast minimum. A dot IS the datum here, so it is not
38
+ # decorative and the floor applies.
39
+ MIN_CONTRAST = 3.0
40
+
41
+
42
+ def _read(path):
43
+ with open(path, encoding="utf-8") as fh:
44
+ return fh.read()
45
+
46
+
47
+ def _anchor(name):
48
+ """Pull one MUTMAP_* triple straight out of app.js, so the test cannot
49
+ drift away from the shipped constant."""
50
+ m = re.search(name + r"\s*=\s*\[\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\]", _read(_APP))
51
+ assert m, f"{name} not found in {_APP}"
52
+ return tuple(int(g) for g in m.groups())
53
+
54
+
55
+ def _ramp(t, neg, mid, pos):
56
+ """The Python twin of mutmapColor(): blend the neutral out to an endpoint."""
57
+ k = max(-1.0, min(1.0, t))
58
+ end = neg if k < 0 else pos
59
+ a = abs(k)
60
+ return tuple(round(m + (e - m) * a) for m, e in zip(mid, end))
61
+
62
+
63
+ def _relative_luminance(rgb):
64
+ def channel(c):
65
+ c = c / 255.0
66
+ return c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4
67
+ r, g, b = (channel(v) for v in rgb)
68
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b
69
+
70
+
71
+ def _contrast(a, b):
72
+ la, lb = _relative_luminance(a), _relative_luminance(b)
73
+ hi, lo = max(la, lb), min(la, lb)
74
+ return (hi + 0.05) / (lo + 0.05)
75
+
76
+
77
+ def _stops(n=41):
78
+ neg, mid, pos = _anchor("MUTMAP_NEG"), _anchor("MUTMAP_MID"), _anchor("MUTMAP_POS")
79
+ return [(-1 + 2 * i / (n - 1), _ramp(-1 + 2 * i / (n - 1), neg, mid, pos))
80
+ for i in range(n)]
81
+
82
+
83
+ # --------------------------------------------------------------------------- #
84
+ # the scale itself
85
+ # --------------------------------------------------------------------------- #
86
+ def test_every_value_on_the_ramp_clears_3_to_1_on_both_canvases():
87
+ """The old scale's failure mode, made impossible to reintroduce quietly.
88
+
89
+ A conventional diverging ramp pales towards the middle, which is fine on
90
+ white and vanishes on a dark canvas — and vice versa for a dark middle. So
91
+ this ramp holds luminance nearly constant and varies hue instead; that
92
+ choice is only worth anything if it is actually checked."""
93
+ worst_dark = min((_contrast(c, CANVAS_DARK), t) for t, c in _stops())
94
+ worst_light = min((_contrast(c, CANVAS_LIGHT), t) for t, c in _stops())
95
+ assert worst_dark[0] >= MIN_CONTRAST, (
96
+ f"worst contrast on the dark canvas is {worst_dark[0]:.2f}:1 at t={worst_dark[1]:.2f}")
97
+ assert worst_light[0] >= MIN_CONTRAST, (
98
+ f"worst contrast on the light canvas is {worst_light[0]:.2f}:1 at t={worst_light[1]:.2f}")
99
+
100
+
101
+ def test_the_scale_diverges_around_zero():
102
+ """Zero has to be the neutral and the two halves have to be different
103
+ hues, or the sign — the only thing this chart is really saying — is not
104
+ encoded at all."""
105
+ neg, mid, pos = _anchor("MUTMAP_NEG"), _anchor("MUTMAP_MID"), _anchor("MUTMAP_POS")
106
+ assert _ramp(0.0, neg, mid, pos) == mid
107
+ # Negative end is blue-dominant, positive end is red-dominant. Not a
108
+ # stylistic assertion: it is what makes "better than wild type" and "worse
109
+ # than wild type" separable at a glance.
110
+ assert neg[2] > neg[0], "the negative anchor must be blue-dominant"
111
+ assert pos[0] > pos[2], "the positive anchor must be warm/red-dominant"
112
+ for t in (0.25, 0.5, 0.75, 1.0):
113
+ cool = _ramp(-t, neg, mid, pos)
114
+ warm = _ramp(+t, neg, mid, pos)
115
+ assert cool[2] > cool[0], f"t=-{t} lost its blue bias"
116
+ assert warm[0] > warm[2], f"t=+{t} lost its warm bias"
117
+
118
+
119
+ def test_the_endpoints_survive_red_green_colour_vision_deficiency():
120
+ """Red/green is the obvious encoding for bad/good and the one ~8% of male
121
+ readers cannot separate. These anchors are Okabe–Ito blue and vermillion;
122
+ the check is that they stay far apart after protanope and deuteranope
123
+ simulation (Brettel/Viénot-style channel collapse), not that they look
124
+ nice to a trichromat."""
125
+ neg, mid, pos = _anchor("MUTMAP_NEG"), _anchor("MUTMAP_MID"), _anchor("MUTMAP_POS")
126
+
127
+ def protan(rgb):
128
+ r, g, b = rgb
129
+ return (0.170 * r + 0.830 * g, 0.170 * r + 0.830 * g, b)
130
+
131
+ def deutan(rgb):
132
+ r, g, b = rgb
133
+ return (0.330 * r + 0.670 * g, 0.330 * r + 0.670 * g, b)
134
+
135
+ for name, sim in (("protanopia", protan), ("deuteranopia", deutan)):
136
+ a, b = sim(neg), sim(pos)
137
+ dist = sum((x - y) ** 2 for x, y in zip(a, b)) ** 0.5
138
+ assert dist > 60, f"{name}: the two ends collapse to within {dist:.0f} of each other"
139
+ # And each end must still separate from the ΔLL≈0 neutral, or a strong
140
+ # result reads the same as no result.
141
+ for end, label in ((a, "negative"), (b, "positive")):
142
+ n = sim(mid)
143
+ d = sum((x - y) ** 2 for x, y in zip(end, n)) ** 0.5
144
+ assert d > 25, f"{name}: the {label} end collapses into the neutral"
145
+
146
+
147
+ def test_the_three_hard_coded_greys_are_gone():
148
+ """The literals that produced the 1.53:1 dots. Their absence is the fix;
149
+ a well-meaning revert would put them straight back."""
150
+ src = _read(_APP)
151
+ for dead in ("'#CFCBC2'", "'#3D3A34'", "'#1B1A17'"):
152
+ assert dead not in src.split("function renderMutationMap")[1][:6000], (
153
+ f"{dead} is back in the mutation map — that is the quantised grey scale")
154
+
155
+
156
+ # --------------------------------------------------------------------------- #
157
+ # the legend
158
+ # --------------------------------------------------------------------------- #
159
+ def test_the_key_states_units_and_direction():
160
+ """A gradient with no numbers on it is decoration. The reviewer's complaint
161
+ was partly that nothing said what the colour meant."""
162
+ src = _read(_APP)
163
+ key = src[src.index("function renderMutationKey"):]
164
+ key = key[:key.index("\n}\n")]
165
+ assert "log-likelihood units" in key, "the key must name the units"
166
+ assert "more" in key and "wild-type" in key, \
167
+ "the key must say which direction is better than wild type"
168
+ assert "attribution, not a per-substitution measurement" in key, (
169
+ "the key must not let an even split of a variant's ΣΔLL read as a "
170
+ "measured per-substitution value")
171
+
172
+
173
+ def test_the_key_declares_the_domain_and_says_when_it_is_clipped():
174
+ """Clipping at a percentile is fine; not saying so is not. And claiming a
175
+ percentile over three data points would be a statistic invented to sound
176
+ rigorous, so the honest branch has to exist too."""
177
+ src = _read(_APP)
178
+ assert "95th percentile" in src
179
+ assert "Scale spans the full observed range" in src
180
+ assert "CLIP_MIN_N" in src, (
181
+ "there must be a floor below which the percentile is not claimed")
182
+
183
+
184
+ def test_the_card_no_longer_describes_a_legend_that_is_not_there():
185
+ """The head text read 'faint · medium · saturated' — describing the chip
186
+ legend that had already been deleted. The markup now carries a real key."""
187
+ html = _read(_HTML)
188
+ assert "faint &middot; medium &middot; saturated" not in html
189
+ assert 'id="mutmapKey"' in html
190
+
191
+
192
+ def test_the_key_copy_survives_the_production_minifier():
193
+ """rjsmin predates ES6 and does not treat a backtick as opening a string.
194
+ Inside a template literal it reads the prose as code and strips whitespace
195
+ next to punctuation, so
196
+
197
+ `... units${modelLabel ? ' (' + m + ')' : ''}.`
198
+
199
+ shipped as "units(ESM-2 35M)" and "2positions&mdash;nothing is clipped" —
200
+ in the one paragraph whose whole job is to make a scientific readout look
201
+ like a scientist wrote it. Nothing raises; the source is fine and only the
202
+ served asset is wrong, which is why this asserts on minified output.
203
+
204
+ The fix is ordinary quoted strings, which rjsmin leaves alone.
205
+ """
206
+ rjsmin = pytest.importorskip(
207
+ "rjsmin", reason="production minifier; CI installs it (see ENGINEERING.md 13.21)")
208
+ src = _read(_APP)
209
+ start = src.index("function renderMutationKey")
210
+ fn = src[start:src.index("\n}\n", start)]
211
+ out = rjsmin.jsmin(fn)
212
+ assert "' log-likelihood units, centred on zero" in out, \
213
+ "the space before 'log-likelihood' was eaten by the minifier"
214
+ assert "' &mdash; nothing is clipped.'" in out, \
215
+ "the spaces around the em dash were eaten by the minifier"
216
+ assert "' (' + escapeHtml(modelLabel)" in fn.replace("'(' ", "' (' "), \
217
+ "the space before the model label must be inside a quoted string"
218
+ # And no template literal may creep back into the copy — that is the trap.
219
+ # Comments stripped: the one above deliberately quotes the broken form.
220
+ code = re.sub(r"/\*.*?\*/", " ", fn, flags=re.S)
221
+ code = "\n".join(ln for ln in code.split("\n") if not ln.strip().startswith("//"))
222
+ assert "`" not in code, "the legend copy must not go back into a template literal"
223
+
224
+
225
+ def test_the_colour_encodes_a_mean_not_a_sum():
226
+ """Summing the per-position contribution made a frequently-mutated
227
+ position look 'strong' purely for being frequent — and the bar's height
228
+ already encodes exactly that count, so the chart said the same thing
229
+ twice and called the second one strength."""
230
+ src = _read(_APP)
231
+ body = src[src.index("function renderMutationMap"):]
232
+ body = body[:body.index("function renderMutationKey")]
233
+ # Comments stripped: they deliberately name the thing being warned against
234
+ # ("Σ contribution named a quantity that..."), and matching prose would
235
+ # make this test pass or fail on the wording. Same reason as
236
+ # test_plasmid_editor's _code() helper.
237
+ code = "\n".join(ln for ln in body.split("\n")
238
+ if not ln.strip().startswith(("//", "*", "/*")))
239
+ assert "total.get(pos) / n" in code, "per-position colour must be a mean"
240
+ assert "Σ contribution" not in code, "the old summed label is back"
tests/test_orchestrator.py CHANGED
@@ -246,7 +246,16 @@ def test_step_cap_checkpoints_instead_of_dead_ending(monkeypatch, no_tools):
246
  The old behaviour marked the run `done` and said "I've taken this as far
247
  as one run goes" — which reads as completion while actually abandoning the
248
  task mid-flight, with no way to find out where it got to. Now it parks
249
- awaiting input, names what's left, and can be resumed."""
 
 
 
 
 
 
 
 
 
250
  monkeypatch.setattr(orch, "MAX_STEPS", 3)
251
  _script(monkeypatch, [_msg_tool("fetch_sequence", {"text": "X"},
252
  call_id=f"c{i}") for i in range(50)])
@@ -280,7 +289,13 @@ def test_checkpoint_names_the_unfinished_plan_steps(monkeypatch, no_tools):
280
 
281
  def test_run_never_exceeds_the_extension_ceiling(monkeypatch, no_tools):
282
  """Continuing must stay bounded — otherwise "continue" is an infinite
283
- money tap on a run that may simply be stuck."""
 
 
 
 
 
 
284
  monkeypatch.setattr(orch, "MAX_STEPS", 2)
285
  monkeypatch.setattr(orch, "MAX_EXTENSIONS", 1)
286
  _script(monkeypatch, [_msg_tool("fetch_sequence", {"text": "X"},
@@ -294,13 +309,25 @@ def test_run_never_exceeds_the_extension_ceiling(monkeypatch, no_tools):
294
 
295
 
296
  def test_cost_cap_stops_the_run(monkeypatch, no_tools):
 
 
 
 
 
 
 
 
 
 
297
  _script(monkeypatch, [_msg_tool("fetch_sequence", {"text": "X"},
298
  call_id=f"c{i}") for i in range(50)],
299
  cost=0.4)
300
  run = orch.create_run(owner="u1", anonymous=False)
301
  orch.start(run, "expensive")
302
- assert _run_to_rest(run) == "error"
303
- assert _of_kind(run, "error")[0]["error_kind"] == "cost_capped"
 
 
304
 
305
 
306
  def test_openrouter_failure_surfaces_as_a_clean_error(monkeypatch, no_tools):
 
246
  The old behaviour marked the run `done` and said "I've taken this as far
247
  as one run goes" — which reads as completion while actually abandoning the
248
  task mid-flight, with no way to find out where it got to. Now it parks
249
+ awaiting input, names what's left, and can be resumed.
250
+
251
+ AUTO_EXTENSIONS is pinned to 0 so this stays a test of the CHECKPOINT.
252
+ Since 2026-08-01 a run that is still landing tool calls, with budget to
253
+ spare, tops its own step allowance up instead of stopping to make the user
254
+ type "continue" — the reviewer's log has that prompt firing repeatedly and
255
+ reads it as the agent stalling. Auto-extension has its own tests in
256
+ tests/test_run_budget.py; leaving it on here would only move the same
257
+ checkpoint 24 steps later."""
258
+ monkeypatch.setattr(orch, "AUTO_EXTENSIONS", 0)
259
  monkeypatch.setattr(orch, "MAX_STEPS", 3)
260
  _script(monkeypatch, [_msg_tool("fetch_sequence", {"text": "X"},
261
  call_id=f"c{i}") for i in range(50)])
 
289
 
290
  def test_run_never_exceeds_the_extension_ceiling(monkeypatch, no_tools):
291
  """Continuing must stay bounded — otherwise "continue" is an infinite
292
+ money tap on a run that may simply be stuck.
293
+
294
+ AUTO_EXTENSIONS pinned to 0 for the same reason as the test above: with
295
+ self-extension on, the single extension MAX_EXTENSIONS allows here would
296
+ be spent automatically and the user would never be offered the "continue"
297
+ this test is about. The ceiling itself is what's under test."""
298
+ monkeypatch.setattr(orch, "AUTO_EXTENSIONS", 0)
299
  monkeypatch.setattr(orch, "MAX_STEPS", 2)
300
  monkeypatch.setattr(orch, "MAX_EXTENSIONS", 1)
301
  _script(monkeypatch, [_msg_tool("fetch_sequence", {"text": "X"},
 
309
 
310
 
311
  def test_cost_cap_stops_the_run(monkeypatch, no_tools):
312
+ """The cap still binds — but it ends the run, it does not fault it.
313
+
314
+ CONTRACT CHANGED 2026-08-01, deliberately. This used to assert
315
+ status=="error" with error_kind "cost_capped", which is what put a red
316
+ error block over a mostly-successful run and produced the reviewer's "not
317
+ clear what happened here / was this a user limit?". Spending an allowance
318
+ is an expected end to a run, not a fault, so it now lands as `done` with
319
+ reason "budget_spent" and a handover. What must NOT change is that the
320
+ money stops the run — that is still asserted here. The message wording and
321
+ the carry-over are covered in tests/test_run_budget.py."""
322
  _script(monkeypatch, [_msg_tool("fetch_sequence", {"text": "X"},
323
  call_id=f"c{i}") for i in range(50)],
324
  cost=0.4)
325
  run = orch.create_run(owner="u1", anonymous=False)
326
  orch.start(run, "expensive")
327
+ assert _run_to_rest(run) == "done"
328
+ assert _of_kind(run, "done")[-1]["reason"] == "budget_spent"
329
+ assert run.steps < 50 # it really did stop early
330
+ assert not _of_kind(run, "error")
331
 
332
 
333
  def test_openrouter_failure_surfaces_as_a_clean_error(monkeypatch, no_tools):
tests/test_resolve.py CHANGED
@@ -151,12 +151,38 @@ def _uniprot_hit(acc="P04637", org="Homo sapiens", seq="MEEPQ"):
151
  }]}
152
 
153
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
154
  def test_resolve_uniprot_ok(monkeypatch):
155
  captured = {}
156
- def _get(url, **k):
157
- captured["url"] = url
158
- return json.dumps(_uniprot_hit()).encode()
159
- monkeypatch.setattr(U, "_get", _get)
160
  out = R.resolve_uniprot("human", "TP53")
161
  assert out["ok"] and out["uniprot"] == "P04637"
162
  assert out["alphafold_url"] == "https://alphafold.ebi.ac.uk/files/AF-P04637-F1-model_v6.pdb"
@@ -178,14 +204,16 @@ def test_resolve_uniprot_serves_bacteria_and_yeast(monkeypatch):
178
  """The structure viewer used to refuse anything but human/mouse via a
179
  two-entry taxid table, so SpCas9 came back as "no structure" even though
180
  AlphaFold has modelled Q99ZW2 all along. Any organism must resolve."""
181
- monkeypatch.setattr(U, "_get", lambda url, **k: json.dumps(
182
- _uniprot_hit("Q99ZW2", "Streptococcus pyogenes serotype M1")).encode())
 
183
  out = R.resolve_uniprot("s. pyogenes", "cas9")
184
  assert out["ok"] and out["uniprot"] == "Q99ZW2"
185
  assert "AF-Q99ZW2" in out["alphafold_url"]
186
 
187
- monkeypatch.setattr(U, "_get", lambda url, **k: json.dumps(
188
- _uniprot_hit("P04385", "Saccharomyces cerevisiae")).encode())
 
189
  assert R.resolve_uniprot("yeast", "GAL1")["ok"] is True
190
 
191
 
@@ -200,6 +228,162 @@ def test_resolve_uniprot_outage_is_not_a_missing_gene(monkeypatch):
200
  assert "check" not in out["error"].lower()
201
 
202
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
203
  # ─────────── upstream outage vs genuine absence (2026-07-27) ───────────
204
 
205
  def test_ensembl_outage_is_not_reported_as_a_bad_gene_symbol(monkeypatch):
 
151
  }]}
152
 
153
 
154
+ def _alphafold_hit(acc="P04637", plddt=94.9, version=6):
155
+ """Shape of one AlphaFold prediction-API entry, as resolve reads it.
156
+
157
+ Field names checked against the live API on 2026-08-01
158
+ (https://alphafold.ebi.ac.uk/api/prediction/P62593).
159
+ """
160
+ return [{
161
+ "modelEntityId": f"AF-{acc}-F1",
162
+ "pdbUrl": f"https://alphafold.ebi.ac.uk/files/AF-{acc}-F1-model_v{version}.pdb",
163
+ "cifUrl": f"https://alphafold.ebi.ac.uk/files/AF-{acc}-F1-model_v{version}.cif",
164
+ "globalMetricValue": plddt,
165
+ "latestVersion": version,
166
+ }]
167
+
168
+
169
+ def _two_hop(uniprot_payload, alphafold_payload, captured=None):
170
+ """resolve_uniprot makes TWO calls now: UniProt, then AlphaFold DB to
171
+ confirm a model actually exists (added 2026-08-01 — see the TEM-1
172
+ incident in resolve.resolve_uniprot). One stub, routed by host."""
173
+ def _get(url, **k):
174
+ if "alphafold" in url:
175
+ return json.dumps(alphafold_payload).encode() if alphafold_payload else b""
176
+ if captured is not None:
177
+ captured["url"] = url
178
+ return json.dumps(uniprot_payload).encode()
179
+ return _get
180
+
181
+
182
  def test_resolve_uniprot_ok(monkeypatch):
183
  captured = {}
184
+ monkeypatch.setattr(U, "_get",
185
+ _two_hop(_uniprot_hit(), _alphafold_hit(), captured))
 
 
186
  out = R.resolve_uniprot("human", "TP53")
187
  assert out["ok"] and out["uniprot"] == "P04637"
188
  assert out["alphafold_url"] == "https://alphafold.ebi.ac.uk/files/AF-P04637-F1-model_v6.pdb"
 
204
  """The structure viewer used to refuse anything but human/mouse via a
205
  two-entry taxid table, so SpCas9 came back as "no structure" even though
206
  AlphaFold has modelled Q99ZW2 all along. Any organism must resolve."""
207
+ monkeypatch.setattr(U, "_get", _two_hop(
208
+ _uniprot_hit("Q99ZW2", "Streptococcus pyogenes serotype M1"),
209
+ _alphafold_hit("Q99ZW2")))
210
  out = R.resolve_uniprot("s. pyogenes", "cas9")
211
  assert out["ok"] and out["uniprot"] == "Q99ZW2"
212
  assert "AF-Q99ZW2" in out["alphafold_url"]
213
 
214
+ monkeypatch.setattr(U, "_get", _two_hop(
215
+ _uniprot_hit("P04385", "Saccharomyces cerevisiae"),
216
+ _alphafold_hit("P04385")))
217
  assert R.resolve_uniprot("yeast", "GAL1")["ok"] is True
218
 
219
 
 
228
  assert "check" not in out["error"].lower()
229
 
230
 
231
+ # ───────────────── the TEM-1 mis-resolution (reviewer log, 2026-07-30) ──────
232
+ #
233
+ # The bench showed I6ZGA9 as the AlphaFold model's accession for
234
+ # beta-lactamase TEM-1. Checked against the LIVE APIs on 2026-08-01, verbatim:
235
+ #
236
+ # GET rest.uniprot.org/uniprotkb/I6ZGA9.json → 200
237
+ # entryType "UniProtKB unreviewed (TrEMBL)", uniProtkbId I6ZGA9_ECOLX,
238
+ # Escherichia coli, proteinDescription.recommendedName "Beta-lactamase",
239
+ # flag "Fragment", sequence.length 264
240
+ # GET alphafold.ebi.ac.uk/api/prediction/I6ZGA9 → 200
241
+ # AF-I6ZGA9-F1, globalMetricValue 95.25, latestVersion 6
242
+ # GET rest.uniprot.org/uniprotkb/P62593.json → 200
243
+ # "UniProtKB reviewed (Swiss-Prot)", BLAT_ECOLX, "Beta-lactamase TEM",
244
+ # alternativeNames include TEM-1, gene bla, flag "Precursor", length 286
245
+ # GET alphafold.ebi.ac.uk/api/prediction/P62593 → 200
246
+ # AF-P62593-F1, globalMetricValue 94.88, latestVersion 6, sequence begins
247
+ # MSIQHFRVALIPFFAAFCLPVFAHPETL… — the reviewer's paste, exactly
248
+ # GET alphafold.ebi.ac.uk/files/AF-P62593-F1-model_v4.pdb → 404
249
+ # GET alphafold.ebi.ac.uk/files/AF-P62593-F1-model_v6.pdb → 200
250
+ #
251
+ # So I6ZGA9 IS a real accession with a real model — "not a recognised UniProt
252
+ # identifier" is not correct — but it is a PARTIAL entry beginning 11 residues
253
+ # into the protein the reviewer pasted, so the viewer's residue numbering was
254
+ # offset by 11. Two defects, one visible symptom.
255
+
256
+ def _tem1_style_payload(acc, entry_id, entry_type, length, flag=None,
257
+ name="Beta-lactamase"):
258
+ description = {"recommendedName": {"fullName": {"value": name}}}
259
+ if flag:
260
+ description["flag"] = flag
261
+ return {"results": [{
262
+ "primaryAccession": acc,
263
+ "uniProtkbId": entry_id,
264
+ "entryType": entry_type,
265
+ "organism": {"scientificName": "Escherichia coli"},
266
+ "proteinDescription": description,
267
+ "sequence": {"value": "M" * length},
268
+ "uniProtKBCrossReferences": [],
269
+ }]}
270
+
271
+
272
+ def test_a_reviewed_protein_name_match_beats_an_unreviewed_gene_match(monkeypatch):
273
+ """The TEM-1 defect itself.
274
+
275
+ `gene:TEM-1 … reviewed:true` finds nothing (the gene is `bla`; TEM-1 is a
276
+ protein ALTERNATIVE name), so the old two-query resolver fell straight to
277
+ `gene:TEM-1` unreviewed and took I6ZGA9 — a 264 aa fragment. The reviewed
278
+ entry, P62593 at 286 aa, was reachable the whole time via protein_name.
279
+ """
280
+ seen = []
281
+
282
+ def _get(url, **k):
283
+ query = urllib.parse.unquote(url)
284
+ seen.append(query)
285
+ if "alphafold" in url:
286
+ return json.dumps(_alphafold_hit("P62593", 94.88)).encode()
287
+ # "protein_name:" with the colon — the &fields= list also contains the
288
+ # bare word protein_name, so matching on that alone makes this stub
289
+ # answer the GENE query and quietly invert what the test proves.
290
+ if "protein_name:" in query and "reviewed:true" in query:
291
+ return json.dumps(_tem1_style_payload(
292
+ "P62593", "BLAT_ECOLX", "UniProtKB reviewed (Swiss-Prot)",
293
+ 286, flag="Precursor", name="Beta-lactamase TEM")).encode()
294
+ if "gene:TEM-1" in query and "reviewed:true" not in query:
295
+ return json.dumps(_tem1_style_payload(
296
+ "I6ZGA9", "I6ZGA9_ECOLX", "UniProtKB unreviewed (TrEMBL)",
297
+ 264, flag="Fragment")).encode()
298
+ return b'{"results": []}'
299
+
300
+ monkeypatch.setattr(U, "_get", _get)
301
+ hit = U.find_gene("TEM-1", "ecoli")
302
+ assert hit is not None
303
+ assert hit.accession == "P62593", "still resolving to the TrEMBL fragment"
304
+ assert hit.reviewed is True and hit.fragment is False
305
+ assert hit.matched_by == "protein_name"
306
+ # The reviewed gene query must still be tried FIRST — protein_name is a
307
+ # fallback, not a replacement.
308
+ assert "gene:TEM-1" in seen[0] and "reviewed:true" in seen[0]
309
+
310
+
311
+ def test_a_fragment_entry_is_reported_as_partial_not_passed_off_as_the_protein(
312
+ monkeypatch):
313
+ """A fragment's residue numbering is offset from the full sequence. In a
314
+ product whose output is "mutate residue 104", handing one over silently is
315
+ the difference between the right residue and the wrong one."""
316
+ monkeypatch.setattr(U, "_get", _two_hop(
317
+ _tem1_style_payload("I6ZGA9", "I6ZGA9_ECOLX",
318
+ "UniProtKB unreviewed (TrEMBL)", 264,
319
+ flag="Fragment"),
320
+ _alphafold_hit("I6ZGA9", 95.25)))
321
+ out = R.resolve_uniprot("ecoli", "TEM-1")
322
+ assert out["ok"] is True # a fragment model is still a model
323
+ assert out["fragment"] is True
324
+ joined = " ".join(out["caveats"]).lower()
325
+ assert "partial" in joined or "fragment" in joined
326
+ assert "unreviewed" in joined
327
+ assert out["protein_length"] == 264
328
+
329
+
330
+ def test_precursor_is_not_mistaken_for_a_fragment(monkeypatch):
331
+ """UniProt puts "Precursor" in the same slot as "Fragment", and P62593 —
332
+ the CORRECT TEM-1 — is flagged Precursor. A precursor is the complete
333
+ chain including its signal peptide; calling it partial would attach a
334
+ false caveat to the right answer."""
335
+ monkeypatch.setattr(U, "_get", _two_hop(
336
+ _tem1_style_payload("P62593", "BLAT_ECOLX",
337
+ "UniProtKB reviewed (Swiss-Prot)", 286,
338
+ flag="Precursor", name="Beta-lactamase TEM"),
339
+ _alphafold_hit("P62593", 94.88)))
340
+ out = R.resolve_uniprot("ecoli", "TEM-1")
341
+ assert out["ok"] and out["fragment"] is False
342
+ assert "caveats" not in out
343
+ assert out["plddt"] == 94.88 and out["structure_verified"] is True
344
+
345
+
346
+ def test_an_accession_with_no_alphafold_model_never_becomes_a_structure(
347
+ monkeypatch):
348
+ """The file URL is CONSTRUCTED — a claim, not a fact. Before this, any
349
+ UniProt hit produced an AF-…-model_v6.pdb URL whether or not AlphaFold had
350
+ ever modelled it, and the viewer was told to go and paint it."""
351
+ monkeypatch.setattr(U, "_get", _two_hop(_uniprot_hit("Q9XXXX"), None))
352
+ out = R.resolve_uniprot("human", "SOMEGENE")
353
+ assert out["ok"] is False
354
+ assert out["kind"] == "no_structure"
355
+ assert "Q9XXXX" in out["error"]
356
+ assert "alphafold_url" not in out
357
+
358
+
359
+ def test_an_alphafold_outage_is_not_a_missing_structure(monkeypatch):
360
+ """Transient ≠ absent (docs/ENGINEERING.md §9). If EBI doesn't answer, we
361
+ keep the fallback URL and say the model is UNVERIFIED — telling a
362
+ scientist their protein has no predicted structure because an endpoint
363
+ blinked is a false negative that makes a working viewer look empty."""
364
+ def _get(url, **k):
365
+ if "alphafold" in url:
366
+ raise U.UniProtUnavailable("HTTP 503")
367
+ return json.dumps(_uniprot_hit("P04637")).encode()
368
+ monkeypatch.setattr(U, "_get", _get)
369
+ out = R.resolve_uniprot("human", "TP53")
370
+ assert out["ok"] is True
371
+ assert out["structure_verified"] is False
372
+ assert out["alphafold_url"].endswith("AF-P04637-F1-model_v6.pdb")
373
+
374
+
375
+ def test_the_model_url_comes_from_alphafold_not_from_us(monkeypatch):
376
+ """Verified live 2026-08-01: AF-P62593-F1-model_v4.pdb is a 404 and
377
+ ...model_v6.pdb is a 200. A hand-built URL carrying a stale version number
378
+ reads as "no structure" for a protein that has one, so when the API tells
379
+ us the file's location we use ITS answer."""
380
+ monkeypatch.setattr(U, "_get", _two_hop(
381
+ _uniprot_hit("P04637"), _alphafold_hit("P04637", version=7)))
382
+ out = R.resolve_uniprot("human", "TP53")
383
+ assert out["alphafold_url"].endswith("AF-P04637-F1-model_v7.pdb")
384
+ assert out["model_version"] == 7
385
+
386
+
387
  # ─────────── upstream outage vs genuine absence (2026-07-27) ───────────
388
 
389
  def test_ensembl_outage_is_not_reported_as_a_bad_gene_symbol(monkeypatch):
tests/test_run_budget.py ADDED
@@ -0,0 +1,337 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Budget: warn, pace, hand over — reviewer log, 2026-07-30.
2
+
3
+ Two complaints, both about a run running out of room:
4
+
5
+ • "This run hit its budget ceiling. Start a new one to continue." arrived
6
+ with no warning, as a RED ERROR BLOCK, mid-task. The reviewer's note was
7
+ "not clear what happened here / was this a user limit?" — a fair question,
8
+ because the sentence names no budget, no number and no unit, and offers no
9
+ way to carry the work across. Everything done so far was simply stranded.
10
+
11
+ • "I've used this stretch of the run. Still to do: … Say **continue**"
12
+ fired over and over and the reviewer had to keep typing "continue". A
13
+ pause the user must clear every 24 steps is not pacing, it reads as the
14
+ agent stalling.
15
+
16
+ The two are one design problem: the step budget was the loud gate and the
17
+ COST budget — the one that actually protects anyone — was a silent wall at
18
+ the end. Inverted here. The step budget extends itself while there is real
19
+ progress and real money left; the cost budget warns, then checkpoints with
20
+ room to spare, and only hands over when it is genuinely spent.
21
+ """
22
+ import json
23
+ import time
24
+
25
+ import pytest
26
+
27
+ from dee.core import llm as _llm
28
+ from dee.core import orchestrator as orch
29
+
30
+
31
+ def _msg_tool(name, args, call_id="c1", content=None):
32
+ return {
33
+ "content": content,
34
+ "tool_calls": [{
35
+ "id": call_id,
36
+ "function": {"name": name, "arguments": json.dumps(args)},
37
+ }],
38
+ }
39
+
40
+
41
+ def _msg_text(text):
42
+ return {"content": text, "tool_calls": []}
43
+
44
+
45
+ def _script(monkeypatch, messages, cost=0.001):
46
+ seq = list(messages)
47
+
48
+ def fake(config, body):
49
+ nxt = seq.pop(0) if seq else _msg_text("finished")
50
+ return nxt, cost, 1234
51
+ monkeypatch.setattr(_llm, "call", fake)
52
+
53
+
54
+ def _run_to_rest(run, timeout=5.0):
55
+ deadline = time.time() + timeout
56
+ while time.time() < deadline:
57
+ if run.status in ("awaiting_input", "done", "error", "stopped"):
58
+ return run.status
59
+ time.sleep(0.01)
60
+ raise AssertionError(f"run never settled (status={run.status})")
61
+
62
+
63
+ def _of_kind(run, kind):
64
+ return [e for e in run.events if e["kind"] == kind]
65
+
66
+
67
+ def _all_text(run):
68
+ return " ".join(e.get("text", "") for e in _of_kind(run, "text"))
69
+
70
+
71
+ @pytest.fixture(autouse=True)
72
+ def _configured(monkeypatch):
73
+ monkeypatch.setenv("OPENROUTER_API_KEY", "test-key")
74
+ monkeypatch.setenv("AGENT_MAX_COST_USD", "0.5")
75
+
76
+
77
+ @pytest.fixture
78
+ def no_tools(monkeypatch):
79
+ monkeypatch.setattr(orch._tools, "execute_tool",
80
+ lambda name, args, anon, **kw: {"ok": True})
81
+
82
+
83
+ # --------------------------------------------------------------------------- #
84
+ # 3. the ceiling arrived with no warning and no way across
85
+ # --------------------------------------------------------------------------- #
86
+ def test_spending_is_warned_about_before_the_ceiling(monkeypatch, no_tools):
87
+ """REPRODUCES "not clear what happened here".
88
+
89
+ Before the fix the only signal about spend was the ceiling itself. A run
90
+ could go from silent to dead in one step with nothing in between.
91
+ """
92
+ _script(monkeypatch, [_msg_tool("fetch_sequence", {"text": "X"},
93
+ call_id=f"c{i}") for i in range(50)],
94
+ cost=0.16)
95
+ run = orch.create_run(owner="u1", anonymous=False)
96
+ orch.start(run, "long job")
97
+ _run_to_rest(run)
98
+
99
+ warns = _of_kind(run, "budget")
100
+ assert warns, "the run never told the user it was approaching the ceiling"
101
+ assert warns[0]["spent_usd"] <= warns[0]["limit_usd"]
102
+ said = _all_text(run).lower()
103
+ assert "budget" in said or "allowance" in said
104
+
105
+
106
+ def test_the_ceiling_message_says_what_it_means(monkeypatch, no_tools):
107
+ """"This run hit its budget ceiling" names no number, no unit, and does
108
+ not say whose limit it is — hence "was this a user limit?"."""
109
+ _script(monkeypatch, [_msg_tool("fetch_sequence", {"text": "X"},
110
+ call_id=f"c{i}") for i in range(50)],
111
+ cost=0.4)
112
+ run = orch.create_run(owner="u1", anonymous=False)
113
+ orch.start(run, "expensive")
114
+ _run_to_rest(run)
115
+
116
+ said = _all_text(run).lower()
117
+ assert "$" in _all_text(run), "the message states no amount"
118
+ assert "not a limit on your account" in said or "not your account" in said, \
119
+ "the message never answers 'was this a user limit?'"
120
+
121
+
122
+ def test_hitting_the_ceiling_is_not_rendered_as_an_error(monkeypatch, no_tools):
123
+ """A budget ceiling is an expected end to a run, not a fault. Emitting it
124
+ as an `error` event paints a red block and settles the stage as failed —
125
+ over work that largely succeeded."""
126
+ _script(monkeypatch, [_msg_tool("fetch_sequence", {"text": "X"},
127
+ call_id=f"c{i}") for i in range(50)],
128
+ cost=0.4)
129
+ run = orch.create_run(owner="u1", anonymous=False)
130
+ orch.start(run, "expensive")
131
+ _run_to_rest(run)
132
+ assert not _of_kind(run, "error")
133
+ assert _of_kind(run, "done")[-1]["reason"] == "budget_spent"
134
+
135
+
136
+ def test_the_work_can_be_carried_into_a_new_run(monkeypatch, no_tools):
137
+ """REPRODUCES "no way to carry the work over".
138
+
139
+ "Start a new one to continue" was advice the product could not act on: a
140
+ new run started cold, with no target, no plan and no record of what had
141
+ already been done.
142
+ """
143
+ _script(monkeypatch, [_msg_tool("fetch_sequence", {"text": "X"},
144
+ call_id=f"c{i}") for i in range(50)],
145
+ cost=0.4)
146
+ run = orch.create_run(owner="u1", anonymous=False)
147
+ run.plan = [{"step": "Fetch TEM-1", "status": "done"},
148
+ {"step": "Score a stability library", "status": "active"}]
149
+ orch.start(run, "make TEM-1 more thermostable")
150
+ _run_to_rest(run)
151
+
152
+ note = orch.handover_note(run)
153
+ assert "make TEM-1 more thermostable" in note # the original goal
154
+ assert "Score a stability library" in note # what is left
155
+
156
+ fresh = orch.create_run(owner="u1", anonymous=False)
157
+ orch.start(fresh, "carry on", carry_from=run)
158
+ _run_to_rest(fresh)
159
+ assert "Score a stability library" in json.dumps(fresh.history)
160
+ assert fresh.goal == run.goal
161
+
162
+
163
+ def test_carry_over_never_inherits_another_owners_run(monkeypatch, no_tools):
164
+ """The handover lands verbatim in a system-adjacent context. It may only
165
+ ever come from a run the same person owns."""
166
+ _script(monkeypatch, [_msg_text("hi")])
167
+ theirs = orch.create_run(owner="u2", anonymous=False)
168
+ theirs.goal = "their secret project"
169
+ mine = orch.create_run(owner="u1", anonymous=False)
170
+ with pytest.raises(ValueError):
171
+ orch.start(mine, "carry on", carry_from=theirs)
172
+
173
+
174
+ # --------------------------------------------------------------------------- #
175
+ # 4. the "continue" loop
176
+ # --------------------------------------------------------------------------- #
177
+ def test_a_progressing_run_extends_itself_instead_of_asking(monkeypatch,
178
+ no_tools):
179
+ """REPRODUCES the continue loop.
180
+
181
+ Before the fix, running out of the first 24 steps always parked the run
182
+ and made the user type "continue" — even though the cost cap, which is the
183
+ guard that actually matters, was barely touched. With work still landing
184
+ and money still on the clock, asking is friction, not supervision.
185
+ """
186
+ monkeypatch.setattr(orch, "MAX_STEPS", 3)
187
+ _script(monkeypatch, [_msg_tool("fetch_sequence", {"text": "X"},
188
+ call_id=f"c{i}") for i in range(6)]
189
+ + [_msg_text("finished")],
190
+ cost=0.0001)
191
+ run = orch.create_run(owner="u1", anonymous=False)
192
+ orch.start(run, "long job")
193
+ assert _run_to_rest(run) == "done"
194
+ assert not _of_kind(run, "checkpoint"), \
195
+ "the user was asked to say 'continue' while the run was still working"
196
+ assert run.extensions >= 1
197
+
198
+
199
+ def test_a_stalled_run_still_stops_and_asks(monkeypatch, no_tools):
200
+ """Auto-extension is for a run that is getting somewhere. A run that
201
+ burned a whole stretch without executing a single tool is looping, and
202
+ spending more on it silently is the failure mode extensions exist to
203
+ bound."""
204
+ monkeypatch.setattr(orch, "MAX_STEPS", 2)
205
+ # update_plan is not work: it changes nothing in the world.
206
+ _script(monkeypatch, [_msg_tool("update_plan",
207
+ {"steps": [{"step": "think", "status": "active"}]},
208
+ call_id=f"p{i}") for i in range(40)],
209
+ cost=0.0001)
210
+ run = orch.create_run(owner="u1", anonymous=False)
211
+ orch.start(run, "spin")
212
+ assert _run_to_rest(run) == "awaiting_input"
213
+ assert _of_kind(run, "checkpoint")
214
+
215
+
216
+ def test_auto_extension_stays_bounded(monkeypatch, no_tools):
217
+ """Self-extending must not become an unbounded money tap."""
218
+ monkeypatch.setattr(orch, "MAX_STEPS", 2)
219
+ monkeypatch.setattr(orch, "AUTO_EXTENSIONS", 1)
220
+ _script(monkeypatch, [_msg_tool("fetch_sequence", {"text": "X"},
221
+ call_id=f"c{i}") for i in range(400)],
222
+ cost=0.0001)
223
+ run = orch.create_run(owner="u1", anonymous=False)
224
+ orch.start(run, "loop forever")
225
+ assert _run_to_rest(run) == "awaiting_input"
226
+ assert run.extensions == 1
227
+ assert _of_kind(run, "checkpoint")
228
+
229
+
230
+ def test_the_checkpoint_says_why_it_paused_in_plain_terms(monkeypatch, no_tools):
231
+ """"I've used this stretch of the run" describes nothing a user can act
232
+ on. Say what ran out and roughly how much more there is."""
233
+ monkeypatch.setattr(orch, "MAX_STEPS", 2)
234
+ monkeypatch.setattr(orch, "AUTO_EXTENSIONS", 0)
235
+ _script(monkeypatch, [_msg_tool("fetch_sequence", {"text": "X"},
236
+ call_id=f"c{i}") for i in range(80)],
237
+ cost=0.0001)
238
+ run = orch.create_run(owner="u1", anonymous=False)
239
+ orch.start(run, "long job")
240
+ assert _run_to_rest(run) == "awaiting_input"
241
+ said = _all_text(run).lower()
242
+ assert "stretch of the run" not in said
243
+ assert "step" in said
244
+
245
+
246
+ # --------------------------------------------------------------------------- #
247
+ # the route: "start a new one to continue" has to be followable
248
+ # --------------------------------------------------------------------------- #
249
+ @pytest.fixture
250
+ def client(monkeypatch):
251
+ from dee import server
252
+ app = server.create_app()
253
+ app.config.update(TESTING=True)
254
+ return app.test_client()
255
+
256
+
257
+ def _as(monkeypatch, user_id):
258
+ import types
259
+ from dee import server
260
+ monkeypatch.setattr(server._auth, "get_auth",
261
+ lambda: types.SimpleNamespace(anonymous=False,
262
+ user_id=user_id))
263
+ monkeypatch.setattr(server._auth, "require_auth_or_quota", lambda: None)
264
+ monkeypatch.setattr(server._auth, "increment_anon_runs_on_response",
265
+ lambda resp: resp)
266
+
267
+
268
+ def test_replying_to_a_spent_run_routes_to_a_fresh_one(client, monkeypatch,
269
+ no_tools):
270
+ """A run with no allowance left cannot answer. Re-spawning it would spend
271
+ one more model call to rediscover that and hand the user a second dead
272
+ end; 410 is the shape the cockpit already recovers from by starting a
273
+ fresh run — which is exactly what should happen."""
274
+ _as(monkeypatch, "u1")
275
+ _script(monkeypatch, [_msg_tool("fetch_sequence", {"text": "X"},
276
+ call_id=f"c{i}") for i in range(50)],
277
+ cost=0.4)
278
+ run = orch.create_run(owner="u1", anonymous=False)
279
+ orch.start(run, "expensive")
280
+ _run_to_rest(run)
281
+ assert run.budget_spent
282
+
283
+ r = client.post(f"/api/orchestrator/{run.run_id}/reply",
284
+ json={"message": "keep going"})
285
+ assert r.status_code == 410
286
+ assert r.get_json()["kind"] == "run_budget_spent"
287
+ assert r.get_json()["carry_from"] == run.run_id
288
+
289
+
290
+ def test_the_next_start_picks_the_stranded_work_up(client, monkeypatch,
291
+ no_tools):
292
+ """The other half: the fresh run must land on the work, not a blank page.
293
+ Without this "start a new one to continue" is advice the product cannot
294
+ act on."""
295
+ _as(monkeypatch, "u1")
296
+ _script(monkeypatch, [_msg_tool("fetch_sequence", {"text": "X"},
297
+ call_id=f"c{i}") for i in range(50)],
298
+ cost=0.4)
299
+ dead = orch.create_run(owner="u1", anonymous=False)
300
+ dead.plan = [{"step": "Score a stability library", "status": "active"}]
301
+ orch.start(dead, "make TEM-1 more thermostable")
302
+ _run_to_rest(dead)
303
+
304
+ _script(monkeypatch, [_msg_text("ok")])
305
+ r = client.post("/api/orchestrator/start", json={"message": "keep going"})
306
+ assert r.status_code == 200
307
+ fresh = orch.get_run(r.get_json()["run_id"])
308
+ _run_to_rest(fresh)
309
+ assert "make TEM-1 more thermostable" in json.dumps(fresh.history)
310
+ assert fresh.carried_from == dead.run_id
311
+
312
+
313
+ def test_carry_from_another_owners_run_is_refused(client, monkeypatch, no_tools):
314
+ _as(monkeypatch, "u1")
315
+ theirs = orch.create_run(owner="u2", anonymous=False)
316
+ theirs.goal = "their secret project"
317
+ _script(monkeypatch, [_msg_text("ok")])
318
+ r = client.post("/api/orchestrator/start",
319
+ json={"message": "hi", "carry_from": theirs.run_id})
320
+ assert r.status_code == 403
321
+ assert r.get_json()["kind"] == "carry_forbidden"
322
+
323
+
324
+ def test_cost_checkpoints_before_it_hits_the_wall(monkeypatch, no_tools):
325
+ """With money nearly gone, park while there is still enough left to
326
+ finish something — a checkpoint the user can resume beats a ceiling they
327
+ cannot."""
328
+ monkeypatch.setattr(orch, "MAX_STEPS", 40)
329
+ _script(monkeypatch, [_msg_tool("fetch_sequence", {"text": "X"},
330
+ call_id=f"c{i}") for i in range(80)],
331
+ cost=0.05)
332
+ run = orch.create_run(owner="u1", anonymous=False)
333
+ orch.start(run, "steady spend")
334
+ assert _run_to_rest(run) == "awaiting_input"
335
+ cps = _of_kind(run, "checkpoint")
336
+ assert cps and cps[-1]["reason"] == "cost"
337
+ assert run.cost_usd < 0.5 # parked with budget still on the clock
tests/test_run_target_binding.py ADDED
@@ -0,0 +1,312 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The run's TARGET must belong to the run — reviewer log, 2026-07-30.
2
+
3
+ Two failures in that log, one root cause. A reviewer pasted beta-lactamase
4
+ TEM-1, worked with it for several turns, and then:
5
+
6
+ • asked "design a more thermostable variant of this protein" and the agent
7
+ "began referencing a protein from a different conversation instead of the
8
+ current conversation" — it planned against IsPETase, a target from
9
+ another run entirely;
10
+ • asked for the protein sequence AGAIN, one turn after being given it.
11
+
12
+ Both are the same defect. Nothing in this codebase ever bound a run to what
13
+ the run was ABOUT. The target existed only as a `tool` message buried in
14
+ `run.history` — which compaction is free to evict — while the system prompt,
15
+ rebuilt from scratch on every single step, carried:
16
+
17
+ WHAT THEY WERE ALREADY WORKING ON
18
+ This user has saved work. Do NOT ... propose starting from scratch on
19
+ something they already have:
20
+ - IsPETase thermostability (48 variants) — 6 results logged
21
+
22
+ ...and not one word about TEM-1. So the most prominent, most recently
23
+ restated, most instruction-carrying thing in the model's context was a target
24
+ from a DIFFERENT conversation, and the thing the user was actually talking
25
+ about was a truncated JSON blob 40 messages back, or gone.
26
+
27
+ These tests measure the model's INPUT, not its output — they capture the
28
+ exact `messages` payload handed to OpenRouter and assert on it. A test that
29
+ asserted "the agent didn't mention IsPETase" would be asserting something
30
+ about a model we cannot run here; what we can prove is that the context we
31
+ hand it no longer sets the trap.
32
+ """
33
+ import json
34
+ import time
35
+
36
+ import pytest
37
+
38
+ from dee.core import llm as _llm
39
+ from dee.core import orchestrator as orch
40
+
41
+
42
+ # An obvious stand-in, never a real sequence. Length is what these tests care
43
+ # about; inventing a plausible TEM-1 CDS to sit in a fixture is exactly the
44
+ # habit docs/ENGINEERING.md §9 forbids.
45
+ _STUB_CDS = "ATGC" * 40
46
+
47
+
48
+ def _cfg():
49
+ return _llm.OpenRouterConfig(
50
+ api_key="test-key", model="test/model", max_steps=8, max_cost_usd=0.5
51
+ )
52
+
53
+
54
+ def _msg_tool(name, args, call_id="c1", content=None):
55
+ return {
56
+ "content": content,
57
+ "tool_calls": [{
58
+ "id": call_id,
59
+ "function": {"name": name, "arguments": json.dumps(args)},
60
+ }],
61
+ }
62
+
63
+
64
+ def _msg_text(text):
65
+ return {"content": text, "tool_calls": []}
66
+
67
+
68
+ def _run_to_rest(run, timeout=5.0):
69
+ deadline = time.time() + timeout
70
+ while time.time() < deadline:
71
+ if run.status in ("awaiting_input", "done", "error", "stopped"):
72
+ return run.status
73
+ time.sleep(0.01)
74
+ raise AssertionError(f"run never settled (status={run.status})")
75
+
76
+
77
+ @pytest.fixture(autouse=True)
78
+ def _configured(monkeypatch):
79
+ monkeypatch.setenv("OPENROUTER_API_KEY", "test-key")
80
+ monkeypatch.setenv("AGENT_MAX_COST_USD", "0.5")
81
+
82
+
83
+ @pytest.fixture
84
+ def bodies(monkeypatch):
85
+ """Every request body sent to OpenRouter, in order.
86
+
87
+ The whole point: assert on what the model was ASKED, since we cannot run
88
+ the model itself here.
89
+ """
90
+ sent = []
91
+
92
+ def make(messages, cost=0.001):
93
+ seq = list(messages)
94
+
95
+ def fake(config, body):
96
+ sent.append(body)
97
+ nxt = seq.pop(0) if seq else _msg_text("done")
98
+ return nxt, cost, 1234
99
+ monkeypatch.setattr(_llm, "call", fake)
100
+ return sent
101
+ return make
102
+
103
+
104
+ @pytest.fixture
105
+ def resolves_tem1(monkeypatch):
106
+ """fetch_sequence resolves the reviewer's pasted protein to a CDS."""
107
+ def _exec(name, args, anon, **kw):
108
+ if name == "fetch_sequence":
109
+ return {"ok": True, "kind": "sequence", "sequence": _STUB_CDS,
110
+ "length": len(_STUB_CDS), "gene_symbol": "TEM-1",
111
+ "label": "beta-lactamase TEM-1 · CDS 861 nt",
112
+ "source": "input"}
113
+ return {"ok": True}
114
+ monkeypatch.setattr(orch._tools, "execute_tool", _exec)
115
+
116
+
117
+ # --------------------------------------------------------------------------- #
118
+ # 1. cross-conversation bleed
119
+ # --------------------------------------------------------------------------- #
120
+ def test_the_prompt_names_this_runs_target_not_another_conversations(
121
+ monkeypatch, bodies, resolves_tem1):
122
+ """REPRODUCES the reviewer's worst finding.
123
+
124
+ The run resolves TEM-1. The user's saved work — from other conversations —
125
+ is an IsPETase library. Before the fix the system prompt on the step AFTER
126
+ the resolve contained "IsPETase" and did NOT contain "TEM-1": the only
127
+ named target in the model's standing instructions was the wrong one.
128
+ """
129
+ import dee.auth as _auth
130
+ monkeypatch.setattr(_auth, "list_libraries", lambda u: [
131
+ {"id": "lib-pet", "name": "IsPETase thermostability", "n_variants": 48},
132
+ ])
133
+ monkeypatch.setattr(_auth, "list_de_outcomes", lambda u, lid: [{}] * 6)
134
+
135
+ sent = bodies([
136
+ _msg_tool("fetch_sequence", {"text": "MSIQHFRVAL…"}, call_id="f1"),
137
+ _msg_text("Resolved."),
138
+ ])
139
+ run = orch.create_run(owner="u1", anonymous=False)
140
+ orch.start(run, "design a more thermostable variant of this protein")
141
+ assert _run_to_rest(run) == "done"
142
+
143
+ # The step taken AFTER fetch_sequence returned — i.e. the one that plans
144
+ # the thermostability work.
145
+ system = sent[-1]["messages"][0]["content"]
146
+ assert "TEM-1" in system, (
147
+ "the run's own target is nowhere in the model's standing context")
148
+ assert "861" in system, "the resolved length is not bound to the run"
149
+
150
+ # The other conversation's work must still be visible, but marked as
151
+ # exactly that — not as the subject of this run.
152
+ assert "IsPETase" in system
153
+ i_target = system.index("TEM-1")
154
+ i_other = system.index("IsPETase")
155
+ assert i_other < i_target, (
156
+ "the bound target must be the LAST thing stated, so it is the most "
157
+ "proximate instruction, not the saved-work list")
158
+ assert "OTHER CONVERSATIONS" in system.upper(), (
159
+ "saved work is not labelled as belonging to other conversations")
160
+
161
+
162
+ def test_the_bound_target_is_stated_as_non_substitutable(
163
+ monkeypatch, bodies, resolves_tem1):
164
+ """It is not enough to mention the target; the prompt has to say the
165
+ saved-work list is not a substitute for it. That sentence is the fix for
166
+ "began referencing a protein from a different conversation"."""
167
+ sent = bodies([
168
+ _msg_tool("fetch_sequence", {"text": "MSIQHFRVAL…"}, call_id="f1"),
169
+ _msg_text("Resolved."),
170
+ ])
171
+ run = orch.create_run(owner="u1", anonymous=False)
172
+ orch.start(run, "design a more thermostable variant of this protein")
173
+ _run_to_rest(run)
174
+
175
+ system = sent[-1]["messages"][0]["content"].lower()
176
+ assert "different conversation" in system
177
+
178
+
179
+ def test_no_target_bound_means_no_target_block(monkeypatch, bodies):
180
+ """A run that has resolved nothing must not claim a target. Asserting a
181
+ bound target that does not exist would be its own fabrication."""
182
+ monkeypatch.setattr(orch._tools, "execute_tool",
183
+ lambda name, args, anon, **kw: {"ok": True})
184
+ sent = bodies([_msg_text("hello")])
185
+ run = orch.create_run(owner="u1", anonymous=False)
186
+ orch.start(run, "hi")
187
+ _run_to_rest(run)
188
+ assert "TARGET OF THIS RUN" not in sent[0]["messages"][0]["content"]
189
+
190
+
191
+ def test_a_failed_resolve_binds_nothing(monkeypatch, bodies):
192
+ """"Couldn't find the gene" must not become the run's target. Binding a
193
+ failure would put a name the tool explicitly refused into the standing
194
+ instructions as fact."""
195
+ monkeypatch.setattr(
196
+ orch._tools, "execute_tool",
197
+ lambda name, args, anon, **kw: {"ok": False,
198
+ "error": "Couldn't find “blaTEM” in ecoli."})
199
+ sent = bodies([
200
+ _msg_tool("fetch_sequence", {"text": "blaTEM"}, call_id="f1"),
201
+ _msg_text("no luck"),
202
+ ])
203
+ run = orch.create_run(owner="u1", anonymous=False)
204
+ orch.start(run, "fetch blaTEM")
205
+ _run_to_rest(run)
206
+ assert not run.target
207
+ assert "TARGET OF THIS RUN" not in sent[-1]["messages"][0]["content"]
208
+
209
+
210
+ def test_the_user_can_move_the_target_within_a_run(monkeypatch, bodies):
211
+ """Binding must not be a cage. A second successful resolve is the user
212
+ changing subject, and the run has to follow them."""
213
+ seqs = {"n": 0}
214
+
215
+ def _exec(name, args, anon, **kw):
216
+ seqs["n"] += 1
217
+ first = seqs["n"] == 1
218
+ return {"ok": True, "kind": "gene", "sequence": _STUB_CDS,
219
+ "length": len(_STUB_CDS),
220
+ "gene_symbol": "TEM-1" if first else "MSTN",
221
+ "label": ("beta-lactamase TEM-1 · CDS 861 nt" if first
222
+ else "MSTN · CDS 1128 nt"),
223
+ "source": "uniprot"}
224
+ monkeypatch.setattr(orch._tools, "execute_tool", _exec)
225
+
226
+ sent = bodies([
227
+ _msg_tool("fetch_sequence", {"text": "TEM-1"}, call_id="f1"),
228
+ _msg_tool("fetch_sequence", {"text": "MSTN"}, call_id="f2"),
229
+ _msg_text("switched"),
230
+ ])
231
+ run = orch.create_run(owner="u1", anonymous=False)
232
+ orch.start(run, "TEM-1 please")
233
+ _run_to_rest(run)
234
+
235
+ system = sent[-1]["messages"][0]["content"]
236
+ assert "MSTN" in system
237
+ assert run.target.get("gene_symbol") == "MSTN"
238
+
239
+
240
+ # --------------------------------------------------------------------------- #
241
+ # 2. "asked for protein sequence again even though it was given the sequence
242
+ # 1 turn ago"
243
+ # --------------------------------------------------------------------------- #
244
+ def test_the_resolved_sequence_survives_compaction(monkeypatch, bodies,
245
+ resolves_tem1):
246
+ """REPRODUCES the re-ask, mechanically.
247
+
248
+ This is the concrete half of the same defect and it needs no model
249
+ judgement at all. `_trim_history` evicts the oldest messages once history
250
+ passes MAX_HISTORY_MESSAGES, and `_digest_dropped` replaces them with
251
+ "tools already run: fetch_sequence". The LETTERS are destroyed. After that
252
+ the agent has no way to obtain the user's sequence except to ask for it —
253
+ which is exactly what the reviewer saw.
254
+
255
+ A long run reaches that cap easily: the reviewer's run ran past its step
256
+ budget and was extended by hand several times, and every step appends two
257
+ or three messages.
258
+ """
259
+ monkeypatch.setattr(orch, "MAX_HISTORY_MESSAGES", 8)
260
+ sent = bodies(
261
+ [_msg_tool("fetch_sequence", {"text": "MSIQHFRVAL…"}, call_id="f1")]
262
+ + [_msg_tool("check_prior_art", {"topic": "TEM-1"}, call_id=f"c{i}")
263
+ for i in range(12)]
264
+ + [_msg_text("done")]
265
+ )
266
+ run = orch.create_run(owner="u1", anonymous=False)
267
+ orch.start(run, "design a more thermostable variant of this protein")
268
+ _run_to_rest(run)
269
+
270
+ # Compaction must actually have fired, or this test proves nothing.
271
+ assert [e for e in run.events if e["kind"] == "compacted"], \
272
+ "history never compacted — the reproduction did not run"
273
+
274
+ last = json.dumps(sent[-1]["messages"])
275
+ assert _STUB_CDS in last, (
276
+ "the user's own sequence is no longer anywhere in the model's "
277
+ "context — the only way left to get it is to ask them for it again")
278
+
279
+
280
+ def test_the_prompt_says_the_sequence_is_already_in_hand(
281
+ monkeypatch, bodies, resolves_tem1):
282
+ """The standing instructions have to state, every step, that the sequence
283
+ is already resolved — otherwise "do you have the sequence?" is a
284
+ reasonable question for the model to ask."""
285
+ sent = bodies([
286
+ _msg_tool("fetch_sequence", {"text": "MSIQHFRVAL…"}, call_id="f1"),
287
+ _msg_text("ok"),
288
+ ])
289
+ run = orch.create_run(owner="u1", anonymous=False)
290
+ orch.start(run, "design a more thermostable variant of this protein")
291
+ _run_to_rest(run)
292
+
293
+ system = sent[-1]["messages"][0]["content"].lower()
294
+ assert "do not ask the user" in system
295
+ assert "already" in system
296
+
297
+
298
+ def test_a_pinned_target_is_never_counted_as_free_context(monkeypatch,
299
+ resolves_tem1):
300
+ """Pinning must stay bounded. A 400 kb pasted plasmid re-sent on every
301
+ step would be a bill, not a fix — over the cap we pin the identity and
302
+ say the letters were dropped rather than pinning them."""
303
+ monkeypatch.setattr(orch, "MAX_HISTORY_MESSAGES", 4)
304
+ monkeypatch.setattr(orch, "TARGET_PIN_MAX_NT", 32)
305
+ run = orch.create_run(owner="u1", anonymous=False)
306
+ run.target = {"label": "big construct", "sequence": "A" * 5000,
307
+ "length": 5000}
308
+ run.history = [{"role": "user", "content": f"m{i}"} for i in range(10)]
309
+ orch._trim_history(run)
310
+ head = json.dumps(run.history[:3])
311
+ assert "A" * 100 not in head
312
+ assert "5,000" in head or "5000" in head
tests/test_search_diversity.py ADDED
@@ -0,0 +1,190 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Cross-variant diversity in the returned variant library
2
+ (`dee.optimizer.search._select_diverse` / `evolve`).
3
+
4
+ The incident: a reviewing scientist ran TEM-1 beta-lactamase and got ten
5
+ variants, nine of which carried the same substitution. ``search.py`` had a
6
+ ``duplicate_position_penalty`` — but that is a WITHIN-variant rule, stopping
7
+ one variant mutating a residue twice. Nothing constrained the K variants as a
8
+ SET, and because fitness is an additive sum of ΔLL, the top-K of that sum are
9
+ near-copies of each other: whatever the best few single mutations are, they
10
+ appear in nearly every returned variant.
11
+
12
+ Measured on the real protein (scripts/measure_library_diversity.py, TEM-1
13
+ BLAT_ECOLX fixture, ESM-2 35M, top-15% pool, k=10, 8 restarts x 1200 steps,
14
+ seed 7):
15
+
16
+ before H1M / H87L / M157T each in 10 of 10 variants; 8 distinct
17
+ substitutions across the whole library
18
+ after most-shared substitution in 5 of 10; 14 distinct substitutions
19
+
20
+ These tests pin the properties that make that safe: the best variant is never
21
+ displaced, K is always filled, and the cap can be switched off.
22
+
23
+ Pure pandas + the module under test. No torch, no network.
24
+ """
25
+ import math
26
+
27
+ import pandas as pd
28
+ import pytest
29
+
30
+ from dee.optimizer.search import SearchConfig, Variant, _select_diverse, evolve
31
+
32
+
33
+ # --------------------------------------------------------------------------- #
34
+ # helpers
35
+ # --------------------------------------------------------------------------- #
36
+ def _pool_df(rows):
37
+ """rows := [(position, wt_aa, mut_aa, delta_ll)]"""
38
+ return pd.DataFrame(rows, columns=["position", "wt_aa", "mut_aa", "delta_ll"])
39
+
40
+
41
+ def _dominated_pool():
42
+ """A pool with one runaway substitution plus a long tail of small ones.
43
+
44
+ This is the shape that produced the incident: a couple of substitutions
45
+ with ΔLL far above everything else, so every high-fitness combination
46
+ contains them.
47
+ """
48
+ rows = [(0, "A", "L", 9.0), (1, "C", "M", 4.0)]
49
+ rows += [(p, "G", "V", 0.5 - 0.001 * p) for p in range(2, 40)]
50
+ rows += [(p, "G", "I", 0.4 - 0.001 * p) for p in range(2, 40)]
51
+ return _pool_df(rows)
52
+
53
+
54
+ def _occupancy(variants):
55
+ counts = {}
56
+ for v in variants:
57
+ for label in v.mutation_labels:
58
+ counts[label] = counts.get(label, 0) + 1
59
+ return counts
60
+
61
+
62
+ def _cfg(**kw):
63
+ base = dict(k=10, max_mutations=5, min_mutations=2,
64
+ n_restarts=8, steps_per_restart=600, seed=7)
65
+ base.update(kw)
66
+ return SearchConfig(**base)
67
+
68
+
69
+ # --------------------------------------------------------------------------- #
70
+ # the headline property
71
+ # --------------------------------------------------------------------------- #
72
+ def test_no_substitution_may_occupy_more_than_its_share_of_the_library():
73
+ pool = _dominated_pool()
74
+
75
+ uncapped = evolve(pool, _cfg(max_substitution_share=1.0, max_position_share=1.0))
76
+ capped = evolve(pool, _cfg())
77
+
78
+ before = max(_occupancy(uncapped).values())
79
+ after = max(_occupancy(capped).values())
80
+ quota = math.ceil(SearchConfig().max_substitution_share * 10)
81
+
82
+ # The uncapped run must actually exhibit the problem, or this test is
83
+ # asserting nothing — the fixture has to reproduce the incident first.
84
+ assert before > quota, f"fixture did not reproduce the pile-up (max {before})"
85
+ assert after <= quota, f"cap not enforced: {after} > {quota}"
86
+
87
+
88
+ def test_capping_increases_the_number_of_distinct_substitutions_offered():
89
+ pool = _dominated_pool()
90
+ uncapped = evolve(pool, _cfg(max_substitution_share=1.0, max_position_share=1.0))
91
+ capped = evolve(pool, _cfg())
92
+ assert len(_occupancy(capped)) > len(_occupancy(uncapped))
93
+
94
+
95
+ def test_position_share_is_capped_too():
96
+ """H87L and H87I are different substitutions at the SAME residue. A library
97
+ that explores one position twenty ways is barely more informative than one
98
+ that explores it once."""
99
+ pool = _dominated_pool()
100
+ capped = evolve(pool, _cfg())
101
+ per_position = {}
102
+ for v in capped:
103
+ for m in v.mutations:
104
+ per_position[m.position] = per_position.get(m.position, 0) + 1
105
+ assert max(per_position.values()) <= math.ceil(SearchConfig().max_position_share * 10)
106
+
107
+
108
+ # --------------------------------------------------------------------------- #
109
+ # what the cap must NOT do
110
+ # --------------------------------------------------------------------------- #
111
+ def test_the_best_variant_is_never_displaced():
112
+ """Selection is greedy in fitness order and the counters start empty, so
113
+ rank 1 always survives the cap. If diversity could cost the top hit, the
114
+ feature would be trading away the product's actual answer."""
115
+ pool = _dominated_pool()
116
+ uncapped = evolve(pool, _cfg(max_substitution_share=1.0, max_position_share=1.0))
117
+ capped = evolve(pool, _cfg())
118
+ assert capped[0].key == uncapped[0].key
119
+ assert capped[0].fitness == uncapped[0].fitness
120
+
121
+
122
+ def test_library_size_is_still_k():
123
+ """Deferred variants are backfilled, so a binding quota can never return a
124
+ shorter library than the plain top-K did."""
125
+ pool = _dominated_pool()
126
+ uncapped = evolve(pool, _cfg(max_substitution_share=1.0, max_position_share=1.0))
127
+ capped = evolve(pool, _cfg())
128
+ assert len(capped) == len(uncapped) == 10
129
+
130
+
131
+ def test_backfill_fires_when_the_quota_cannot_be_met():
132
+ """A hall of fame that is genuinely all the same variant: the quota cannot
133
+ be satisfied, and the answer is still K variants, not 1."""
134
+ # A 3-substitution pool with 2-3 mutations per variant admits exactly four
135
+ # distinct variants, so ask for three of them.
136
+ muts = evolve(_pool_df([(0, "A", "L", 9.0), (1, "C", "M", 8.0), (2, "D", "K", 7.0)]),
137
+ _cfg(k=3, max_mutations=3))
138
+ assert len(muts) == 3
139
+ # Every variant is drawn from that 3-substitution pool, so occupancy MUST
140
+ # exceed the 50% quota — the backfill is what makes that possible.
141
+ assert max(_occupancy(muts).values()) > math.ceil(0.5 * 3)
142
+
143
+
144
+ def test_results_stay_ranked_by_fitness():
145
+ capped = evolve(_dominated_pool(), _cfg())
146
+ fits = [v.fitness for v in capped]
147
+ assert fits == sorted(fits, reverse=True)
148
+ assert [v.rank for v in capped] == list(range(1, len(capped) + 1))
149
+
150
+
151
+ def test_share_of_one_restores_the_previous_behaviour_exactly():
152
+ """The off switch has to be a true no-op, so a caller can reproduce an old
153
+ library and so the before/after measurement is honest."""
154
+ pool = _dominated_pool()
155
+ off = evolve(pool, _cfg(max_substitution_share=1.0, max_position_share=1.0))
156
+ plain_top_k = sorted(
157
+ evolve(pool, _cfg(k=500, max_substitution_share=1.0, max_position_share=1.0)),
158
+ key=lambda v: v.fitness, reverse=True)[:10]
159
+ assert [v.key for v in off] == [v.key for v in plain_top_k]
160
+
161
+
162
+ # --------------------------------------------------------------------------- #
163
+ # _select_diverse in isolation
164
+ # --------------------------------------------------------------------------- #
165
+ def _v(labels, fitness):
166
+ from dee.optimizer.search import Mutation
167
+ muts = tuple(Mutation(position=int(l[1:-1]) - 1, wt_aa=l[0], mut_aa=l[-1], delta_ll=0.0)
168
+ for l in labels)
169
+ return Variant(mutations=muts, fitness=fitness)
170
+
171
+
172
+ def test_select_diverse_prefers_fitness_within_the_quota():
173
+ ranked = [
174
+ _v(["A1L", "C2M"], 10.0),
175
+ _v(["A1L", "D3K"], 9.0),
176
+ _v(["A1L", "E4R"], 8.0), # quota for A1L is 1 at k=2, so this defers
177
+ _v(["F5W", "G6Y"], 1.0),
178
+ ]
179
+ cfg = SearchConfig(k=2, max_substitution_share=0.5, max_position_share=0.5)
180
+ out = _select_diverse(ranked, cfg)
181
+ assert [v.fitness for v in out] == [10.0, 1.0]
182
+
183
+
184
+ def test_select_diverse_handles_an_empty_hall_of_fame():
185
+ assert _select_diverse([], SearchConfig()) == []
186
+
187
+
188
+ @pytest.mark.parametrize("k", [0, -1])
189
+ def test_select_diverse_refuses_a_nonpositive_k(k):
190
+ assert _select_diverse([_v(["A1L", "C2M"], 1.0)], SearchConfig(k=k)) == []
tests/test_static_minifies_cleanly.py ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Production serves MINIFIED assets. The source parsing is not the check.
2
+
3
+ dee/server.py::_minified_asset runs every .js and .css through rjsmin/rcssmin
4
+ before serving, so the file the browser executes is not the file in the repo.
5
+ On 2026-08-01 an apostrophe inside a NESTED template literal —
6
+ `${cond ? `the search's set …` : ''}` inside a larger template — desynced
7
+ rjsmin's string tracking: it read the ' as the start of a single-quoted
8
+ string. Isolated: a plain template containing an apostrophe minifies fine,
9
+ and a nested template without one minifies fine; only the combination breaks.
10
+ From that point it treated the `//` in
11
+
12
+ push(`https://alphafold.ebi.ac.uk/files/AF-${acc}-F1-model_v${v}.pdb`)
13
+
14
+ as a line comment and deleted the rest of the line, closing backtick and
15
+ paren included. `node --check` on the source passed. The SERVED file threw
16
+ "SyntaxError: missing ) after argument list" on load, so nothing in app.js
17
+ ran at all — no console error pointing at the real cause, just an app that
18
+ booted dead. It was caught by driving a browser, not by reading the diff.
19
+
20
+ The cheap always-on canary is below: URLs written in code must survive the
21
+ minifier. When rjsmin desyncs it eats them, so the count drops. The strong
22
+ check (node --check on the minified output) runs when node is present.
23
+ """
24
+ import shutil
25
+ import subprocess
26
+ from pathlib import Path
27
+
28
+ import pytest
29
+
30
+ import dee.server as server
31
+
32
+ STATIC = Path(server.__file__).resolve().parent / "static"
33
+
34
+ # Assets this workstream owns. cockpit.js and the other surfaces belong to
35
+ # other files; adding them here is fine, but a failure must be actionable by
36
+ # whoever owns the file.
37
+ JS_ASSETS = ["app.js"]
38
+ CSS_ASSETS = ["science.css"]
39
+
40
+
41
+ def _minify(name: str) -> str:
42
+ out = server._minified_asset(name)
43
+ assert out is not None, (
44
+ f"{name} would be served RAW — rjsmin/rcssmin missing, which also "
45
+ "silently changes the cache headers (see ENGINEERING.md §13.21)"
46
+ )
47
+ return out
48
+
49
+
50
+ @pytest.mark.parametrize("name", JS_ASSETS)
51
+ def test_urls_in_code_survive_minification(name):
52
+ """The canary for a desynced minifier: it eats from `//` to end of line."""
53
+ src = (STATIC / name).read_text(encoding="utf-8")
54
+ out = _minify(name)
55
+ # Comments are stripped, so the source count is an upper bound; what
56
+ # matters is that no URL inside actual code disappears. Every URL the
57
+ # runtime needs is in a string, and the minifier must preserve all of them.
58
+ for url in ("https://alphafold.ebi.ac.uk", "https://"):
59
+ assert url in out, f"{name}: minifier ate {url}"
60
+ assert out.count("https://") >= src.count("https://") - _comment_urls(src), (
61
+ f"{name}: minified output lost URLs that are not in comments — "
62
+ "rjsmin has almost certainly lost string context"
63
+ )
64
+
65
+
66
+ def _comment_urls(src: str) -> int:
67
+ """URLs that legitimately disappear because they sit in a comment."""
68
+ n = 0
69
+ for line in src.splitlines():
70
+ stripped = line.lstrip()
71
+ if stripped.startswith("//") or stripped.startswith("*") or stripped.startswith("/*"):
72
+ n += line.count("https://")
73
+ return n
74
+
75
+
76
+ @pytest.mark.skipif(shutil.which("node") is None, reason="node not installed")
77
+ @pytest.mark.parametrize("name", JS_ASSETS)
78
+ def test_minified_js_still_parses(name, tmp_path):
79
+ """The real check, when a JS engine is available: does the SERVED file parse?"""
80
+ p = tmp_path / name
81
+ p.write_text(_minify(name), encoding="utf-8")
82
+ r = subprocess.run(["node", "--check", str(p)], capture_output=True, text=True)
83
+ assert r.returncode == 0, f"minified {name} does not parse:\n{r.stderr[:800]}"
84
+
85
+
86
+ @pytest.mark.parametrize("name", CSS_ASSETS)
87
+ def test_css_minifies_and_keeps_its_rules(name):
88
+ out = _minify(name)
89
+ src = (STATIC / name).read_text(encoding="utf-8")
90
+ # Every selector in the source must still be in the served file. A stray
91
+ # `*/` (ENGINEERING.md §13.5) silently drops everything after it.
92
+ for sel in (".crispr-scope-strip", ".crispr-scope-row", ".method-assumes",
93
+ ".run-objective", ".run-diversity"):
94
+ assert sel in src and sel in out, f"{name}: {sel} did not survive minification"