Spaces:
Running
Running
github-actions[bot] commited on
Commit ·
7b284c7
1
Parent(s): 0ab82d0
Deploy 2565f24
Browse filesMerge pull request #15 from WINTER4000/feat/review-round
Source: https://github.com/WINTER4000/turingDNA/commit/2565f240caa00a235efe766428804beb9c40aa75
- dee/core/agent_tools.py +19 -2
- dee/core/crispr.py +32 -2
- dee/core/crispr_methods.py +72 -8
- dee/core/epistasis.py +95 -11
- dee/core/orchestrator.py +539 -34
- dee/core/resolve.py +128 -1
- dee/core/uniprot.py +71 -10
- dee/optimizer/search.py +80 -2
- dee/server.py +29 -1
- dee/static/app.css +125 -12
- dee/static/app.js +625 -58
- dee/static/catalog.css +35 -6
- dee/static/catalog.js +140 -20
- dee/static/cockpit.js +171 -12
- dee/static/index.html +103 -23
- dee/static/railsplit.js +31 -9
- dee/static/science.css +102 -0
- dee/static/trace.css +411 -0
- dee/static/trace.js +447 -0
- tests/conftest.py +19 -0
- tests/test_catalog_constructs.py +110 -0
- tests/test_chat_library_cap.py +143 -0
- tests/test_cockpit_rail.py +119 -0
- tests/test_cockpit_recovered_attempts.py +113 -0
- tests/test_crispr_disclosure.py +243 -0
- tests/test_crispr_methods.py +13 -2
- tests/test_epistasis_narration.py +229 -0
- tests/test_hidden_attribute.py +128 -0
- tests/test_legibility_ui.py +326 -0
- tests/test_mutation_landscape.py +240 -0
- tests/test_orchestrator.py +31 -4
- tests/test_resolve.py +192 -8
- tests/test_run_budget.py +337 -0
- tests/test_run_target_binding.py +312 -0
- tests/test_search_diversity.py +190 -0
- tests/test_static_minifies_cleanly.py +94 -0
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 |
-
|
|
|
|
| 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.
|
| 1128 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 149 |
-
"
|
| 150 |
-
"
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 182 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 183 |
if verdict == "clash":
|
| 184 |
-
worst = min(sites, key=lambda s: s.shift)
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 188 |
# cooperative
|
| 189 |
best = max(sites, key=lambda s: s.shift)
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 260 |
-
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 636 |
logger.warning("run %s exceeded cost cap (%.4f)", run.run_id, run.cost_usd)
|
| 637 |
-
run
|
| 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 |
-
|
| 839 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 849 |
-
|
| 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 = (
|
| 857 |
+ "\n".join("• " + s for s in left[:6])
|
| 858 |
-
+ "\n\nSay **continue**
|
|
|
|
| 859 |
else:
|
| 860 |
-
body = (
|
| 861 |
-
"
|
| 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
|
| 960 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
"
|
| 1483 |
-
|
| 1484 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
|
|
|
| 1489 |
)
|
| 1490 |
|
| 1491 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1492 |
def build_system_prompt(anonymous: bool, workspace: Optional[Dict[str, Any]] = None,
|
| 1493 |
-
continuity: 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 |
-
|
| 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.
|
| 171 |
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 184 |
-
|
| 185 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 204 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 246 |
for i, v in enumerate(ranked):
|
| 247 |
v.rank = i + 1
|
| 248 |
|
| 249 |
logger.info(
|
| 250 |
-
"Search done. Returning
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1945 |
display: flex;
|
| 1946 |
-
|
| 1947 |
-
|
| 1948 |
-
font-
|
|
|
|
|
|
|
| 1949 |
color: var(--ink-faint);
|
|
|
|
| 1950 |
}
|
| 1951 |
-
.
|
| 1952 |
-
|
| 1953 |
-
|
| 1954 |
-
|
| 1955 |
-
|
|
|
|
| 1956 |
}
|
| 1957 |
-
.
|
| 1958 |
-
.
|
| 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 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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> → <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 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 1808 |
-
+ '
|
| 1809 |
-
+ 'above for a wider search, a
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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 & 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
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2945 |
const counts = new Map(); // position -> count
|
| 2946 |
-
const
|
| 2947 |
let maxCount = 0;
|
| 2948 |
(data.variants || []).forEach((v) => {
|
| 2949 |
const muts = (v.Mutations_AA || '').split(',').map(s => s.trim()).filter(Boolean);
|
| 2950 |
-
//
|
| 2951 |
-
const
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
/
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2977 |
const strengths = [...strength.values()];
|
| 2978 |
-
const
|
| 2979 |
-
const
|
| 2980 |
-
const
|
| 2981 |
-
|
| 2982 |
-
|
| 2983 |
-
|
| 2984 |
-
|
| 2985 |
-
|
| 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 |
-
|
|
|
|
| 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(
|
| 3058 |
dot.setAttribute('class', 'mutmap-dot');
|
| 3059 |
const title = document.createElementNS(svgNS, 'title');
|
| 3060 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 — 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 |
-
|
| 5811 |
-
|
| 5812 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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> → <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 — 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’s mutations, minus a hard penalty for a stop '
|
| 3085 |
+
+ 'codon or for two mutations at the same residue — the same number the '
|
| 3086 |
+
+ '<em>Fitness</em> column shows. Those per-substitution scores are ESM-2 Δ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 — 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 & 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 |
+
+ 'ΔLL, so there is no gradient to show — 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'} — nothing`
|
| 3386 |
+
|
| 3387 |
+
shipped to users as "units(ESM-2 35M)" and "2positions—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 |ΔLL| across '
|
| 3408 |
+
+ plural(n, 'position')
|
| 3409 |
+
+ (info.nOutside
|
| 3410 |
+
? (' — ' + 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 |
+
+ ' — 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 Δ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’s ΣΔ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 — 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 — 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 %</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 % in mESC, U2OS, HEK293 or primary cells — '
|
| 6174 |
+
+ 'yet the real MMEJ/NHEJ balance differs between them. Rank guides with these; don’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 — 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 “How these numbers were computed”.';
|
| 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:
|
| 32 |
border-radius: 3px;
|
| 33 |
-
background: var(--line-
|
| 34 |
-
opacity:
|
| 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 {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 211 |
-
.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 236 |
'<span class="cat-thumb cat-thumb--cx" data-sc="cx" aria-hidden="true">' +
|
| 237 |
-
'<svg viewBox="0 0
|
| 238 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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") {
|
|
|
|
| 311 |
return;
|
| 312 |
}
|
| 313 |
var seg = e.target.closest(".cat-segb");
|
|
@@ -328,16 +394,17 @@
|
|
| 328 |
}
|
| 329 |
}
|
| 330 |
|
| 331 |
-
function
|
| 332 |
-
if (!state.root) return null;
|
| 333 |
-
//
|
| 334 |
-
//
|
| 335 |
var all = state.root.querySelectorAll(".cat-card");
|
| 336 |
for (var i = 0; i < all.length; i++) {
|
| 337 |
-
if (all[i].getAttribute(
|
| 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
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
if (!card || !run || card.querySelector(".cat-confirm")) return;
|
| 406 |
var bar = document.createElement("div");
|
| 407 |
bar.className = "cat-confirm";
|
| 408 |
-
bar.innerHTML =
|
| 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();
|
| 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>↓ 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 |
-
|
| 626 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 &&
|
|
|
|
| 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 = {};
|
|
|
|
| 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 |
-
/* ──
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1930 |
function renderMeter() {
|
| 1931 |
if (!state.els) return;
|
| 1932 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1933 |
var pct = state.ctxLimit
|
| 1934 |
-
? Math.min(100,
|
| 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)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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>↓ 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=
|
| 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-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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">§ 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).
|
| 907 |
-
|
| 908 |
-
|
| 909 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 910 |
<div class="mutmap-card" id="mutmapCard">
|
| 911 |
<div class="mutmap-head">
|
| 912 |
<h3>Mutation landscape</h3>
|
| 913 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
<
|
|
|
|
|
|
|
|
|
|
| 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 −
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1310 |
Always the primary sort. -->
|
| 1311 |
-
<th class="num crispr-sortable" data-sort-key="composite_score" title="
|
| 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
|
| 1350 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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=
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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=
|
| 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-
|
| 2404 |
-
<script src="/static/catalog.js?v=20260801-
|
| 2405 |
-
<script src="/static/railsplit.js?v=20260801-
|
| 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">§ 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>ΔLL</strong> — ESM-2’s prior for that one mutation, before → after your measured results were folded in. Not the same scale as the table’s summed ΣΔ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–<span id="mutmapLen">N</span>) · <strong>bar height</strong> = variants sharing that position · <strong>colour</strong> = the mean Δ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 = ΣΔLL</strong> — the ESM-2 log-likelihood
|
| 994 |
+
change vs. wild type, <em>summed</em> over this variant’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> — 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 (
|
| 168 |
-
|
| 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 |
-
/
|
| 176 |
-
|
|
|
|
|
|
|
| 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, "&").replace(/</g, "<").replace(/>/g, ">")
|
| 63 |
+
.replace(/"/g, """);
|
| 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">×</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."
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
c = cm.METHODS["composite"]
|
| 33 |
assert "on_target" in c["formula"] and "self_off" in c["formula"]
|
| 34 |
-
assert
|
|
|
|
| 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 "ΣΔ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 "Δ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 · medium · 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—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 "' — 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) == "
|
| 303 |
-
assert _of_kind(run, "
|
|
|
|
|
|
|
| 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 |
-
|
| 157 |
-
|
| 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",
|
| 182 |
-
_uniprot_hit("Q99ZW2", "Streptococcus pyogenes serotype M1")
|
|
|
|
| 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",
|
| 188 |
-
_uniprot_hit("P04385", "Saccharomyces cerevisiae")
|
|
|
|
| 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"
|