Spaces:
Running
Running
github-actions[bot] commited on
Commit Β·
62335fa
1
Parent(s): e81d49b
Deploy 07ea2d8
Browse filesTherapeutic compiler: lower a variant to an edit, or refuse and say why
Source: https://github.com/WINTER4000/turingDNA/commit/07ea2d8cfbde99036b0be78acd5954dc05976393
- dee/core/compiler.py +561 -0
- dee/server.py +51 -0
- dee/static/app.css +147 -0
- dee/static/app.js +203 -2
- dee/static/index.html +95 -2
- tests/test_compiler.py +273 -0
dee/core/compiler.py
ADDED
|
@@ -0,0 +1,561 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The therapeutic compiler β lower a pathogenic variant to an editing strategy.
|
| 2 |
+
|
| 3 |
+
A compiler is not a pipeline with a nicer name. What makes this one is that it
|
| 4 |
+
**refuses to compile** and says why. A tool that always returns a strategy for
|
| 5 |
+
a patient's variant is a plausible-answer generator, and in this domain a
|
| 6 |
+
plausible answer is worse than none: it is the kind of output that gets built
|
| 7 |
+
on. "This is a 4 kb deletion; no base or prime editor addresses it" is the
|
| 8 |
+
useful answer, and it is the one nobody ships.
|
| 9 |
+
|
| 10 |
+
So the diagnostics below are the product. The strategies are what falls out
|
| 11 |
+
when there are no errors.
|
| 12 |
+
|
| 13 |
+
Same discipline as dee/core/edits.py ("REFUSE ON MISMATCH... a silent
|
| 14 |
+
off-by-one here is not a bug report, it is a scientist ordering the wrong
|
| 15 |
+
DNA"), applied one level up: here a silent wrong answer is a scientist
|
| 16 |
+
designing the wrong therapy.
|
| 17 |
+
|
| 18 |
+
SCOPE, enforced in code and not only in copy
|
| 19 |
+
--------------------------------------------
|
| 20 |
+
* **Somatic only.** Germline and embryo applications are refused outright
|
| 21 |
+
(:func:`compile_correction` raises on ``germline=True``). This is the line
|
| 22 |
+
the field draws and this module does not sit on it.
|
| 23 |
+
* **Design and assessment, not a clinical decision.** The output is a design
|
| 24 |
+
record for humans to evaluate. It is not IND-ready, it does not clear a
|
| 25 |
+
strategy for use, and nothing here should reach a patient without the
|
| 26 |
+
ordinary preclinical program.
|
| 27 |
+
* **Predicted specificity is not measured specificity.** Off-target search
|
| 28 |
+
narrows where to look. It does not replace GUIDE-seq / CIRCLE-seq or any
|
| 29 |
+
other empirical assay.
|
| 30 |
+
* **Out of scope entirely:** immunogenicity, pharmacokinetics, dosing,
|
| 31 |
+
manufacturing, and delivery efficacy. The compiler is silent on all of
|
| 32 |
+
them rather than guessing.
|
| 33 |
+
|
| 34 |
+
This module is PURE LOGIC β no network, no GPU, no model. Everything here is
|
| 35 |
+
a deterministic consequence of the two alleles and the genetic code, which is
|
| 36 |
+
why it can be tested exhaustively. Model-derived judgements (what a bystander
|
| 37 |
+
edit *does*) live outside it and are attached later, clearly labelled as
|
| 38 |
+
predictions.
|
| 39 |
+
"""
|
| 40 |
+
|
| 41 |
+
from __future__ import annotations
|
| 42 |
+
|
| 43 |
+
from dataclasses import dataclass, field
|
| 44 |
+
from typing import Dict, List, Optional, Tuple
|
| 45 |
+
|
| 46 |
+
__all__ = [
|
| 47 |
+
"Diagnostic", "Correction", "LesionCall",
|
| 48 |
+
"classify_lesion", "restores_wildtype", "compile_correction",
|
| 49 |
+
"GermlineRefused",
|
| 50 |
+
]
|
| 51 |
+
|
| 52 |
+
_COMPLEMENT = {"A": "T", "T": "A", "C": "G", "G": "C"}
|
| 53 |
+
_BASES = frozenset("ACGT")
|
| 54 |
+
|
| 55 |
+
# Base editors make exactly two chemistries. Everything downstream follows
|
| 56 |
+
# from this and nothing else:
|
| 57 |
+
# ABE adenine base editor A -> G
|
| 58 |
+
# CBE cytosine base editor C -> T
|
| 59 |
+
_ABE_CHANGE = ("A", "G")
|
| 60 |
+
_CBE_CHANGE = ("C", "T")
|
| 61 |
+
|
| 62 |
+
# Routing bounds for prime editing, NOT capability guarantees. Published PE
|
| 63 |
+
# work spans a range that depends on the construct, the locus and the cell
|
| 64 |
+
# type, so a hard biological limit would be a fiction. These are deliberately
|
| 65 |
+
# conservative and are used only to decide "route to PE" vs "refuse" β a
|
| 66 |
+
# lesion near the boundary gets a warning saying the call is marginal, not a
|
| 67 |
+
# promise that it will work.
|
| 68 |
+
PRIME_EDIT_INSERT_BOUND = 44
|
| 69 |
+
PRIME_EDIT_DELETE_BOUND = 80
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
class GermlineRefused(Exception):
|
| 73 |
+
"""Raised for any germline or embryo request. Not a routing decision."""
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
@dataclass
|
| 77 |
+
class Diagnostic:
|
| 78 |
+
"""One compiler message. `code` is stable; `message` is for humans.
|
| 79 |
+
|
| 80 |
+
`remedy` is the field that makes a refusal useful instead of merely
|
| 81 |
+
correct β it says what would have to change for this to compile.
|
| 82 |
+
"""
|
| 83 |
+
level: str # "error" | "warning" | "note"
|
| 84 |
+
code: str
|
| 85 |
+
message: str
|
| 86 |
+
remedy: str = ""
|
| 87 |
+
|
| 88 |
+
@property
|
| 89 |
+
def blocking(self) -> bool:
|
| 90 |
+
return self.level == "error"
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
@dataclass
|
| 94 |
+
class Correction:
|
| 95 |
+
"""The change that restores wild-type, and which chemistry can make it.
|
| 96 |
+
|
| 97 |
+
`strand` matters and is the part most easily got wrong. A base editor
|
| 98 |
+
only ever writes A->G (ABE) or C->T (CBE) on the strand it engages. A
|
| 99 |
+
sense-strand T->C is therefore an ABE edit β on the ANTISENSE strand,
|
| 100 |
+
where that position reads A and must become G. Getting this backwards
|
| 101 |
+
designs a guide against the wrong strand, which fails silently in
|
| 102 |
+
silico and expensively at the bench.
|
| 103 |
+
"""
|
| 104 |
+
wt_base: str
|
| 105 |
+
patient_base: str
|
| 106 |
+
sense_change: str # what must happen on the sense strand, "T>C"
|
| 107 |
+
strand: str # "sense" | "antisense" β where the editor works
|
| 108 |
+
editor_change: str # what the editor actually writes, "A>G"
|
| 109 |
+
editor_family: str # "ABE" | "CBE"
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
@dataclass
|
| 113 |
+
class LesionCall:
|
| 114 |
+
"""What kind of lesion this is and what could address it."""
|
| 115 |
+
kind: str # substitution | insertion | deletion | delins | identity
|
| 116 |
+
size: int # bases changed (max of ref/alt length for delins)
|
| 117 |
+
is_transition: bool
|
| 118 |
+
correction: Optional[Correction]
|
| 119 |
+
route: str # "base_editing" | "prime_editing" | "none"
|
| 120 |
+
diagnostics: List[Diagnostic] = field(default_factory=list)
|
| 121 |
+
|
| 122 |
+
@property
|
| 123 |
+
def compiles(self) -> bool:
|
| 124 |
+
return not any(d.blocking for d in self.diagnostics)
|
| 125 |
+
|
| 126 |
+
def errors(self) -> List[Diagnostic]:
|
| 127 |
+
return [d for d in self.diagnostics if d.blocking]
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def _clean_allele(value: str) -> str:
|
| 131 |
+
"""Alleles arrive as '-', '', 'del', or real bases. Normalise to bases."""
|
| 132 |
+
v = "".join(str(value or "").split()).upper()
|
| 133 |
+
if v in ("-", ".", "DEL", "NONE", "NULL"):
|
| 134 |
+
return ""
|
| 135 |
+
return v
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def _is_transition(a: str, b: str) -> bool:
|
| 139 |
+
"""A<->G or C<->T. Everything else is a transversion."""
|
| 140 |
+
return {a, b} in ({"A", "G"}, {"C", "T"})
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def _base_editor_for(wt: str, patient: str) -> Optional[Correction]:
|
| 144 |
+
"""Which base editor, if any, restores `wt` from `patient`.
|
| 145 |
+
|
| 146 |
+
The whole derivation, because it is short and worth being able to check:
|
| 147 |
+
|
| 148 |
+
sense A->G ABE reads A, writes G -> ABE, sense
|
| 149 |
+
sense C->T CBE reads C, writes T -> CBE, sense
|
| 150 |
+
sense T->C antisense reads A, writes G -> ABE, antisense
|
| 151 |
+
sense G->A antisense reads C, writes T -> CBE, antisense
|
| 152 |
+
|
| 153 |
+
Every remaining substitution is a transversion (A<->C, A<->T, C<->G,
|
| 154 |
+
G<->T), and neither chemistry produces one on either strand. There is no
|
| 155 |
+
base-editing route to a transversion β that is a fact about the enzymes,
|
| 156 |
+
not a gap in this function.
|
| 157 |
+
"""
|
| 158 |
+
change = (patient, wt) # from the patient's base, back to wild-type
|
| 159 |
+
if change == _ABE_CHANGE:
|
| 160 |
+
return Correction(wt, patient, f"{patient}>{wt}", "sense", "A>G", "ABE")
|
| 161 |
+
if change == _CBE_CHANGE:
|
| 162 |
+
return Correction(wt, patient, f"{patient}>{wt}", "sense", "C>T", "CBE")
|
| 163 |
+
|
| 164 |
+
anti = (_COMPLEMENT[patient], _COMPLEMENT[wt])
|
| 165 |
+
if anti == _ABE_CHANGE:
|
| 166 |
+
return Correction(wt, patient, f"{patient}>{wt}", "antisense", "A>G", "ABE")
|
| 167 |
+
if anti == _CBE_CHANGE:
|
| 168 |
+
return Correction(wt, patient, f"{patient}>{wt}", "antisense", "C>T", "CBE")
|
| 169 |
+
return None
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
def classify_lesion(wt_allele: str, patient_allele: str) -> LesionCall:
|
| 173 |
+
"""Route a lesion to an editing chemistry, or refuse with a reason.
|
| 174 |
+
|
| 175 |
+
`wt_allele` is the reference/wild-type allele and `patient_allele` is what
|
| 176 |
+
the patient carries. Correction runs patient -> wild-type; passing them
|
| 177 |
+
the wrong way round designs an editor that installs the disease, so the
|
| 178 |
+
argument names are deliberately not `ref`/`alt` (which flip meaning
|
| 179 |
+
depending on whether you are reading a VCF or thinking about a therapy).
|
| 180 |
+
"""
|
| 181 |
+
wt = _clean_allele(wt_allele)
|
| 182 |
+
pt = _clean_allele(patient_allele)
|
| 183 |
+
diags: List[Diagnostic] = []
|
| 184 |
+
|
| 185 |
+
bad = [b for b in (wt + pt) if b not in _BASES]
|
| 186 |
+
if bad:
|
| 187 |
+
diags.append(Diagnostic(
|
| 188 |
+
"error", "non_dna_allele",
|
| 189 |
+
f"Alleles must be A/C/G/T; got {sorted(set(bad))!r}.",
|
| 190 |
+
"Supply unambiguous bases. IUPAC ambiguity codes and amino-acid "
|
| 191 |
+
"letters are not alleles and cannot be routed."))
|
| 192 |
+
return LesionCall("invalid", 0, False, None, "none", diags)
|
| 193 |
+
|
| 194 |
+
if not wt and not pt:
|
| 195 |
+
diags.append(Diagnostic(
|
| 196 |
+
"error", "empty_alleles", "Both alleles are empty.",
|
| 197 |
+
"Give the wild-type and patient alleles for the position."))
|
| 198 |
+
return LesionCall("invalid", 0, False, None, "none", diags)
|
| 199 |
+
|
| 200 |
+
if wt == pt:
|
| 201 |
+
diags.append(Diagnostic(
|
| 202 |
+
"error", "no_lesion",
|
| 203 |
+
"Wild-type and patient alleles are identical β there is nothing "
|
| 204 |
+
"to correct.",
|
| 205 |
+
"Check the alleles are not swapped, and that the variant call is "
|
| 206 |
+
"against the intended reference."))
|
| 207 |
+
return LesionCall("identity", 0, False, None, "none", diags)
|
| 208 |
+
|
| 209 |
+
# ββ substitution ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 210 |
+
if len(wt) == 1 and len(pt) == 1:
|
| 211 |
+
transition = _is_transition(wt, pt)
|
| 212 |
+
corr = _base_editor_for(wt, pt)
|
| 213 |
+
if corr is not None:
|
| 214 |
+
return LesionCall("substitution", 1, transition, corr,
|
| 215 |
+
"base_editing", diags)
|
| 216 |
+
diags.append(Diagnostic(
|
| 217 |
+
"warning", "transversion_no_base_editor",
|
| 218 |
+
f"{pt}>{wt} is a transversion. No base editor makes this change "
|
| 219 |
+
"on either strand β ABE writes A>G and CBE writes C>T, and "
|
| 220 |
+
"neither produces a transversion.",
|
| 221 |
+
"Prime editing is the route for transversions. This platform "
|
| 222 |
+
"does not design pegRNAs yet, so the strategy stops here rather "
|
| 223 |
+
"than offering a guide that cannot install the change."))
|
| 224 |
+
diags.append(Diagnostic(
|
| 225 |
+
"error", "prime_editing_unavailable",
|
| 226 |
+
"Prime editing is required and is not implemented in this "
|
| 227 |
+
"compiler.",
|
| 228 |
+
"Design the pegRNA in a dedicated prime-editing tool; the "
|
| 229 |
+
"specificity and consequence passes here still apply to it."))
|
| 230 |
+
return LesionCall("substitution", 1, transition, None,
|
| 231 |
+
"prime_editing", diags)
|
| 232 |
+
|
| 233 |
+
# ββ indels and delins βββββββββββββββββββββββββββββββββββββββββββββββ
|
| 234 |
+
if not pt:
|
| 235 |
+
kind, size = "deletion", len(wt)
|
| 236 |
+
bound, what = PRIME_EDIT_DELETE_BOUND, "deletion"
|
| 237 |
+
elif not wt:
|
| 238 |
+
kind, size = "insertion", len(pt)
|
| 239 |
+
bound, what = PRIME_EDIT_INSERT_BOUND, "insertion"
|
| 240 |
+
else:
|
| 241 |
+
kind, size = "delins", max(len(wt), len(pt))
|
| 242 |
+
bound, what = PRIME_EDIT_INSERT_BOUND, "replacement"
|
| 243 |
+
|
| 244 |
+
diags.append(Diagnostic(
|
| 245 |
+
"note", "indel_not_base_editable",
|
| 246 |
+
f"A {size}-base {kind} cannot be corrected by base editing β base "
|
| 247 |
+
"editors rewrite one base chemically and do not add or remove any.",
|
| 248 |
+
"Prime editing is the route for indels of this size."))
|
| 249 |
+
|
| 250 |
+
if size > bound:
|
| 251 |
+
diags.append(Diagnostic(
|
| 252 |
+
"error", "lesion_too_large",
|
| 253 |
+
f"A {size}-base {what} is beyond what this compiler will route "
|
| 254 |
+
f"to prime editing (bound {bound}).",
|
| 255 |
+
"Larger lesions need a different modality β integrase or "
|
| 256 |
+
"recombinase-based insertion, or gene addition. Those are not "
|
| 257 |
+
"editing strategies and are outside this compiler."))
|
| 258 |
+
return LesionCall(kind, size, False, None, "none", diags)
|
| 259 |
+
|
| 260 |
+
if size > bound // 2:
|
| 261 |
+
diags.append(Diagnostic(
|
| 262 |
+
"warning", "lesion_near_bound",
|
| 263 |
+
f"A {size}-base {what} is large for prime editing; efficiency "
|
| 264 |
+
"falls off with edit size and varies by locus and cell type.",
|
| 265 |
+
"Treat the route as marginal and plan an empirical check early."))
|
| 266 |
+
|
| 267 |
+
diags.append(Diagnostic(
|
| 268 |
+
"error", "prime_editing_unavailable",
|
| 269 |
+
"Prime editing is required and is not implemented in this compiler.",
|
| 270 |
+
"Design the pegRNA in a dedicated prime-editing tool; the "
|
| 271 |
+
"specificity and consequence passes here still apply to it."))
|
| 272 |
+
return LesionCall(kind, size, False, None, "prime_editing", diags)
|
| 273 |
+
|
| 274 |
+
|
| 275 |
+
def restores_wildtype(window: str, offset: int, corr: Correction) -> bool:
|
| 276 |
+
"""The compiler's type-check: does applying `corr` actually give wild-type?
|
| 277 |
+
|
| 278 |
+
`window` is reference (wild-type) sequence and `offset` is the 0-based
|
| 279 |
+
index of the variant position within it. The patient's sequence is the
|
| 280 |
+
window with the patient's base substituted in; applying the correction
|
| 281 |
+
must return it to the reference exactly.
|
| 282 |
+
|
| 283 |
+
This exists because every other check in this module reasons about
|
| 284 |
+
ALLELES, and a position that has drifted by one still type-checks at the
|
| 285 |
+
allele level while pointing at the wrong base. Comparing whole sequences
|
| 286 |
+
catches that.
|
| 287 |
+
"""
|
| 288 |
+
if not window or not (0 <= offset < len(window)):
|
| 289 |
+
return False
|
| 290 |
+
if window[offset] != corr.wt_base:
|
| 291 |
+
return False
|
| 292 |
+
patient_seq = window[:offset] + corr.patient_base + window[offset + 1:]
|
| 293 |
+
corrected = patient_seq[:offset] + corr.wt_base + patient_seq[offset + 1:]
|
| 294 |
+
return corrected == window
|
| 295 |
+
|
| 296 |
+
|
| 297 |
+
def compile_correction(wt_allele: str, patient_allele: str, *,
|
| 298 |
+
window: str = "", offset: int = -1,
|
| 299 |
+
germline: bool = False) -> LesionCall:
|
| 300 |
+
"""Front door. Classifies, then verifies against real sequence if given.
|
| 301 |
+
|
| 302 |
+
`germline=True` is refused rather than routed β see the module docstring.
|
| 303 |
+
It raises instead of returning a diagnostic because a refusal that a
|
| 304 |
+
caller can read past and keep going is not a refusal.
|
| 305 |
+
"""
|
| 306 |
+
if germline:
|
| 307 |
+
raise GermlineRefused(
|
| 308 |
+
"This compiler designs somatic therapeutic edits only. Germline "
|
| 309 |
+
"and embryo editing are out of scope and are not routed here.")
|
| 310 |
+
|
| 311 |
+
call = classify_lesion(wt_allele, patient_allele)
|
| 312 |
+
if call.correction is None or not window:
|
| 313 |
+
return call
|
| 314 |
+
|
| 315 |
+
if offset < 0 or offset >= len(window):
|
| 316 |
+
call.diagnostics.append(Diagnostic(
|
| 317 |
+
"error", "offset_outside_window",
|
| 318 |
+
f"Variant offset {offset} is outside the {len(window)}-base "
|
| 319 |
+
"reference window.",
|
| 320 |
+
"Give the 0-based index of the variant within the window you "
|
| 321 |
+
"supplied."))
|
| 322 |
+
return call
|
| 323 |
+
|
| 324 |
+
observed = window[offset]
|
| 325 |
+
if observed != call.correction.wt_base:
|
| 326 |
+
# The single most valuable refusal in the module. Everything else is
|
| 327 |
+
# arithmetic on alleles; this is the check that catches a coordinate
|
| 328 |
+
# that is right by one, or a variant called on the other strand.
|
| 329 |
+
call.diagnostics.append(Diagnostic(
|
| 330 |
+
"error", "reference_mismatch",
|
| 331 |
+
f"The reference window has {observed!r} at offset {offset}, but "
|
| 332 |
+
f"the wild-type allele was given as {call.correction.wt_base!r}.",
|
| 333 |
+
"Refusing rather than editing a position the reference disagrees "
|
| 334 |
+
"about. Check the coordinate, the transcript, and whether the "
|
| 335 |
+
"variant was called on the opposite strand."))
|
| 336 |
+
return call
|
| 337 |
+
|
| 338 |
+
if not restores_wildtype(window, offset, call.correction):
|
| 339 |
+
call.diagnostics.append(Diagnostic(
|
| 340 |
+
"error", "correction_does_not_restore",
|
| 341 |
+
"Applying the correction does not reproduce the reference "
|
| 342 |
+
"sequence.",
|
| 343 |
+
"This is a compiler bug or a malformed window; do not proceed."))
|
| 344 |
+
return call
|
| 345 |
+
|
| 346 |
+
|
| 347 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 348 |
+
# The pass pipeline
|
| 349 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 350 |
+
# A compiler shows its passes. This one shows the passes it CANNOT run and
|
| 351 |
+
# why, which is the part that matters here: a therapeutic design tool that
|
| 352 |
+
# quietly skips specificity analysis and prints a strategy is worse than one
|
| 353 |
+
# that stops and says "the human off-target index does not cover intronic or
|
| 354 |
+
# intergenic space, so I did not clear this guide".
|
| 355 |
+
#
|
| 356 |
+
# Every pass reports one of:
|
| 357 |
+
# ok ran, nothing blocking
|
| 358 |
+
# warn ran, with a caveat the designer must read
|
| 359 |
+
# error ran, and refused
|
| 360 |
+
# unavailable did NOT run, because this deployment cannot β with the reason
|
| 361 |
+
# skipped did not run because an earlier pass already refused
|
| 362 |
+
#
|
| 363 |
+
# "unavailable" is deliberately distinct from "ok". Conflating them is how a
|
| 364 |
+
# tool ends up implying it checked something it never looked at.
|
| 365 |
+
|
| 366 |
+
PASS_ORDER: Tuple[Tuple[str, str], ...] = (
|
| 367 |
+
("resolve", "Resolve variant"),
|
| 368 |
+
("classify", "Classify lesion"),
|
| 369 |
+
("verify", "Verify against reference"),
|
| 370 |
+
("enumerate", "Enumerate strategies"),
|
| 371 |
+
("consequence", "Assess edit consequence"),
|
| 372 |
+
("specificity", "Assess specificity"),
|
| 373 |
+
("emit", "Emit design record"),
|
| 374 |
+
)
|
| 375 |
+
|
| 376 |
+
|
| 377 |
+
@dataclass
|
| 378 |
+
class Pass:
|
| 379 |
+
name: str
|
| 380 |
+
title: str
|
| 381 |
+
status: str # ok | warn | error | unavailable | skipped
|
| 382 |
+
detail: str = ""
|
| 383 |
+
diagnostics: List[Diagnostic] = field(default_factory=list)
|
| 384 |
+
|
| 385 |
+
|
| 386 |
+
@dataclass
|
| 387 |
+
class CompileReport:
|
| 388 |
+
lesion: Optional[LesionCall]
|
| 389 |
+
passes: List[Pass]
|
| 390 |
+
scope: Dict[str, str]
|
| 391 |
+
|
| 392 |
+
@property
|
| 393 |
+
def compiled(self) -> bool:
|
| 394 |
+
"""True only if every pass that RAN succeeded and none was skipped
|
| 395 |
+
for an upstream refusal. An 'unavailable' pass does not fail the
|
| 396 |
+
build, but it does mean the record is explicitly incomplete."""
|
| 397 |
+
return not any(p.status in ("error", "skipped") for p in self.passes)
|
| 398 |
+
|
| 399 |
+
@property
|
| 400 |
+
def incomplete_because(self) -> List[str]:
|
| 401 |
+
return [p.title for p in self.passes if p.status == "unavailable"]
|
| 402 |
+
|
| 403 |
+
|
| 404 |
+
# What this deployment can and cannot do, stated once so the passes and the
|
| 405 |
+
# UI cannot drift apart. Each entry is the honest reason a pass will report
|
| 406 |
+
# `unavailable` β not a TODO, a disclosure.
|
| 407 |
+
CAPABILITY_NOTES = {
|
| 408 |
+
"enumerate": (
|
| 409 |
+
"Guide enumeration for base editing runs here; prime-editing pegRNA "
|
| 410 |
+
"design does not exist in this platform, so any lesion routed to PE "
|
| 411 |
+
"stops before a strategy."),
|
| 412 |
+
"consequence": (
|
| 413 |
+
"Bystander consequence scoring uses a zero-shot genome model. It "
|
| 414 |
+
"ranks hypotheses about what an edit does; it has no validated "
|
| 415 |
+
"relationship to clinical outcome and does not substitute for a "
|
| 416 |
+
"functional assay."),
|
| 417 |
+
"specificity": (
|
| 418 |
+
"The human and mouse off-target index covers CODING SEQUENCE ONLY. "
|
| 419 |
+
"Intronic and intergenic off-targets sit outside it and are NOT "
|
| 420 |
+
"cleared here. For therapeutic work this pass does not replace "
|
| 421 |
+
"GUIDE-seq, CIRCLE-seq or an equivalent empirical assay."),
|
| 422 |
+
}
|
| 423 |
+
|
| 424 |
+
SCOPE = {
|
| 425 |
+
"application": "Somatic therapeutic design only. Germline and embryo "
|
| 426 |
+
"editing are out of scope and refused.",
|
| 427 |
+
"status": "Design and assessment. Not IND-ready, not a clinical "
|
| 428 |
+
"decision, not a clearance of any strategy for use.",
|
| 429 |
+
"silent_on": "Immunogenicity, pharmacokinetics, dosing, manufacturing "
|
| 430 |
+
"and delivery efficacy are not modelled and not reported.",
|
| 431 |
+
}
|
| 432 |
+
|
| 433 |
+
|
| 434 |
+
def compile_report(wt_allele: str, patient_allele: str, *,
|
| 435 |
+
window: str = "", offset: int = -1,
|
| 436 |
+
germline: bool = False,
|
| 437 |
+
can_enumerate: bool = True,
|
| 438 |
+
can_score_consequence: bool = False,
|
| 439 |
+
can_check_specificity: bool = False) -> CompileReport:
|
| 440 |
+
"""Run the passes and report every one, including those that could not run.
|
| 441 |
+
|
| 442 |
+
The three `can_*` flags are supplied by the caller from LIVE capability
|
| 443 |
+
checks (is the DNA model reachable, is an off-target index loaded), never
|
| 444 |
+
hardcoded β the same "availability must mean reachable" rule the rest of
|
| 445 |
+
this codebase learned the hard way. Defaulting the two model-backed passes
|
| 446 |
+
to False means a caller that forgets to check gets an honestly incomplete
|
| 447 |
+
record rather than a falsely complete one.
|
| 448 |
+
"""
|
| 449 |
+
if germline:
|
| 450 |
+
raise GermlineRefused(
|
| 451 |
+
"This compiler designs somatic therapeutic edits only. Germline "
|
| 452 |
+
"and embryo editing are out of scope and are not routed here.")
|
| 453 |
+
|
| 454 |
+
passes: List[Pass] = []
|
| 455 |
+
titles = dict(PASS_ORDER)
|
| 456 |
+
|
| 457 |
+
def add(name, status, detail="", diags=None):
|
| 458 |
+
passes.append(Pass(name, titles[name], status, detail, diags or []))
|
| 459 |
+
|
| 460 |
+
# ββ resolve βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 461 |
+
if window:
|
| 462 |
+
add("resolve", "ok",
|
| 463 |
+
f"{len(window)} nt of reference supplied; variant at offset {offset}.")
|
| 464 |
+
else:
|
| 465 |
+
add("resolve", "warn",
|
| 466 |
+
"No reference window supplied β the lesion can be classified from "
|
| 467 |
+
"alleles alone, but nothing can be checked against real sequence.")
|
| 468 |
+
|
| 469 |
+
# ββ classify ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 470 |
+
lesion = classify_lesion(wt_allele, patient_allele)
|
| 471 |
+
if lesion.errors():
|
| 472 |
+
add("classify", "error",
|
| 473 |
+
f"{lesion.kind} β no route.", lesion.diagnostics)
|
| 474 |
+
for name, _ in PASS_ORDER[2:]:
|
| 475 |
+
add(name, "skipped", "An earlier pass refused.")
|
| 476 |
+
return CompileReport(lesion, passes, dict(SCOPE))
|
| 477 |
+
|
| 478 |
+
corr = lesion.correction
|
| 479 |
+
add("classify", "warn" if any(d.level == "warning" for d in lesion.diagnostics) else "ok",
|
| 480 |
+
f"{lesion.kind}: {corr.sense_change} corrected by {corr.editor_family} "
|
| 481 |
+
f"on the {corr.strand} strand." if corr else lesion.kind,
|
| 482 |
+
lesion.diagnostics)
|
| 483 |
+
|
| 484 |
+
# ββ verify ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 485 |
+
if window and corr:
|
| 486 |
+
verified = compile_correction(wt_allele, patient_allele,
|
| 487 |
+
window=window, offset=offset)
|
| 488 |
+
new = [d for d in verified.diagnostics if d not in lesion.diagnostics]
|
| 489 |
+
if verified.errors():
|
| 490 |
+
add("verify", "error", "Reference disagrees with the alleles.", new)
|
| 491 |
+
for name, _ in PASS_ORDER[3:]:
|
| 492 |
+
add(name, "skipped", "An earlier pass refused.")
|
| 493 |
+
return CompileReport(verified, passes, dict(SCOPE))
|
| 494 |
+
add("verify", "ok",
|
| 495 |
+
f"Reference has {corr.wt_base} at offset {offset}; the correction "
|
| 496 |
+
"reproduces it exactly.", new)
|
| 497 |
+
lesion = verified
|
| 498 |
+
else:
|
| 499 |
+
add("verify", "unavailable",
|
| 500 |
+
"No reference window, so the coordinate could not be checked. An "
|
| 501 |
+
"off-by-one still type-checks at the allele level.")
|
| 502 |
+
|
| 503 |
+
# ββ enumerate βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 504 |
+
if can_enumerate:
|
| 505 |
+
add("enumerate", "ok",
|
| 506 |
+
f"Base-editing guides can be enumerated for a {corr.editor_family} "
|
| 507 |
+
f"edit on the {corr.strand} strand.")
|
| 508 |
+
else:
|
| 509 |
+
add("enumerate", "unavailable", CAPABILITY_NOTES["enumerate"])
|
| 510 |
+
|
| 511 |
+
# ββ consequence βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 512 |
+
add("consequence", "ok" if can_score_consequence else "unavailable",
|
| 513 |
+
CAPABILITY_NOTES["consequence"])
|
| 514 |
+
|
| 515 |
+
# ββ specificity βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 516 |
+
# Always carries its caveat, even when it runs: a pass that reports "ok"
|
| 517 |
+
# on a coding-sequence-only index would read as a clean bill of health.
|
| 518 |
+
add("specificity", "warn" if can_check_specificity else "unavailable",
|
| 519 |
+
CAPABILITY_NOTES["specificity"])
|
| 520 |
+
|
| 521 |
+
# ββ emit ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 522 |
+
add("emit", "ok",
|
| 523 |
+
"Design record assembled with every pass, its status, and the "
|
| 524 |
+
"reasons for anything not run.")
|
| 525 |
+
|
| 526 |
+
return CompileReport(lesion, passes, dict(SCOPE))
|
| 527 |
+
|
| 528 |
+
|
| 529 |
+
def report_to_dict(report: CompileReport) -> Dict[str, object]:
|
| 530 |
+
"""JSON shape for the API. Deliberately verbose: the record is the point,
|
| 531 |
+
so nothing is elided to make the payload tidy."""
|
| 532 |
+
def diag(d: Diagnostic):
|
| 533 |
+
return {"level": d.level, "code": d.code,
|
| 534 |
+
"message": d.message, "remedy": d.remedy}
|
| 535 |
+
|
| 536 |
+
lesion = report.lesion
|
| 537 |
+
corr = lesion.correction if lesion else None
|
| 538 |
+
return {
|
| 539 |
+
"compiled": report.compiled,
|
| 540 |
+
"incomplete_because": report.incomplete_because,
|
| 541 |
+
"scope": report.scope,
|
| 542 |
+
"lesion": {
|
| 543 |
+
"kind": lesion.kind,
|
| 544 |
+
"size": lesion.size,
|
| 545 |
+
"is_transition": lesion.is_transition,
|
| 546 |
+
"route": lesion.route,
|
| 547 |
+
} if lesion else None,
|
| 548 |
+
"correction": {
|
| 549 |
+
"wt_base": corr.wt_base,
|
| 550 |
+
"patient_base": corr.patient_base,
|
| 551 |
+
"sense_change": corr.sense_change,
|
| 552 |
+
"strand": corr.strand,
|
| 553 |
+
"editor_change": corr.editor_change,
|
| 554 |
+
"editor_family": corr.editor_family,
|
| 555 |
+
} if corr else None,
|
| 556 |
+
"passes": [
|
| 557 |
+
{"name": p.name, "title": p.title, "status": p.status,
|
| 558 |
+
"detail": p.detail, "diagnostics": [diag(d) for d in p.diagnostics]}
|
| 559 |
+
for p in report.passes
|
| 560 |
+
],
|
| 561 |
+
}
|
dee/server.py
CHANGED
|
@@ -824,6 +824,7 @@ _RL_RULES = [
|
|
| 824 |
# to fall back to. Tighter than every other tool bucket on purpose: this
|
| 825 |
# limit is about the GPU bill, not about protecting a worker thread.
|
| 826 |
("/api/dna/generate", (6, 60)), # autoregressive β priciest
|
|
|
|
| 827 |
("/api/dna", (12, 60)),
|
| 828 |
("/api/plasmid", (60, 60)),
|
| 829 |
("/api/ping", (60, 60)), # dwell heartbeat β its own
|
|
@@ -872,6 +873,7 @@ _EVENT_KINDS = {
|
|
| 872 |
"/api/de/round2": "de_round2",
|
| 873 |
"/api/dna/score": "dna_score",
|
| 874 |
"/api/dna/generate": "dna_generate",
|
|
|
|
| 875 |
}
|
| 876 |
# Sort longest-prefix-first so the most specific rule matches.
|
| 877 |
_RL_RULES.sort(key=lambda r: len(r[0]), reverse=True)
|
|
@@ -3410,6 +3412,54 @@ def create_app() -> Flask:
|
|
| 3410 |
}), 502
|
| 3411 |
return jsonify(result)
|
| 3412 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3413 |
@app.get("/api/benchmarks")
|
| 3414 |
def benchmarks() -> Response:
|
| 3415 |
"""The receipts β how well the engine's zero-shot ranking predicts
|
|
@@ -4127,6 +4177,7 @@ _VALID_QUANT = {None, "", "none", "int8", "int4", "fp16", "bf16"}
|
|
| 4127 |
from dee.core import scoring as _scoring
|
| 4128 |
# DNA-level tiers (Evo 2, via Modal) β same "/api/models reports what's
|
| 4129 |
# actually runnable" contract, separate id-space (see the models() route).
|
|
|
|
| 4130 |
from dee.core import dna_scoring as _dna_scoring
|
| 4131 |
|
| 4132 |
|
|
|
|
| 824 |
# to fall back to. Tighter than every other tool bucket on purpose: this
|
| 825 |
# limit is about the GPU bill, not about protecting a worker thread.
|
| 826 |
("/api/dna/generate", (6, 60)), # autoregressive β priciest
|
| 827 |
+
("/api/compiler", (30, 60)), # pure logic; probes DNA reach
|
| 828 |
("/api/dna", (12, 60)),
|
| 829 |
("/api/plasmid", (60, 60)),
|
| 830 |
("/api/ping", (60, 60)), # dwell heartbeat β its own
|
|
|
|
| 873 |
"/api/de/round2": "de_round2",
|
| 874 |
"/api/dna/score": "dna_score",
|
| 875 |
"/api/dna/generate": "dna_generate",
|
| 876 |
+
"/api/compiler/compile": "therapeutic_compile",
|
| 877 |
}
|
| 878 |
# Sort longest-prefix-first so the most specific rule matches.
|
| 879 |
_RL_RULES.sort(key=lambda r: len(r[0]), reverse=True)
|
|
|
|
| 3412 |
}), 502
|
| 3413 |
return jsonify(result)
|
| 3414 |
|
| 3415 |
+
# ββ Therapeutic compiler βββββββββββββββββββββββββββββββββββββββββββββ
|
| 3416 |
+
# Lowers a pathogenic variant to an editing strategy, or refuses with a
|
| 3417 |
+
# diagnostic. See dee/core/compiler.py for the scope this operates in β
|
| 3418 |
+
# somatic design and assessment only, never a clinical decision.
|
| 3419 |
+
@app.post("/api/compiler/compile")
|
| 3420 |
+
def compiler_compile() -> Response:
|
| 3421 |
+
gate = _dna_signin_gate("compiler")
|
| 3422 |
+
if gate is not None:
|
| 3423 |
+
return gate
|
| 3424 |
+
|
| 3425 |
+
body = request.get_json(force=True, silent=True) or {}
|
| 3426 |
+
wt = str(body.get("wt_allele") or "")
|
| 3427 |
+
patient = str(body.get("patient_allele") or "")
|
| 3428 |
+
window = "".join(str(body.get("window") or "").split()).upper()
|
| 3429 |
+
try:
|
| 3430 |
+
offset = int(body.get("offset", -1))
|
| 3431 |
+
except (TypeError, ValueError):
|
| 3432 |
+
return jsonify({"error": "offset must be an integer"}), 400
|
| 3433 |
+
|
| 3434 |
+
if not wt and not patient:
|
| 3435 |
+
return jsonify({"error": "missing 'wt_allele' and 'patient_allele'"}), 400
|
| 3436 |
+
|
| 3437 |
+
# Capability comes from a LIVE probe, never a constant. A pass that
|
| 3438 |
+
# cannot run must report `unavailable`, and the only way to know is to
|
| 3439 |
+
# ask. Specificity stays off in this version on purpose: the human
|
| 3440 |
+
# off-target index is coding-sequence only, so this endpoint does not
|
| 3441 |
+
# claim to have cleared a guide it never checked outside coding space.
|
| 3442 |
+
try:
|
| 3443 |
+
can_score = bool(_dna_scoring.runnable("achilles"))
|
| 3444 |
+
except Exception: # noqa: BLE001 β capability must never break a compile
|
| 3445 |
+
app.logger.debug("DNA capability probe failed", exc_info=True)
|
| 3446 |
+
can_score = False
|
| 3447 |
+
|
| 3448 |
+
try:
|
| 3449 |
+
report = _compiler.compile_report(
|
| 3450 |
+
wt, patient, window=window, offset=offset,
|
| 3451 |
+
germline=bool(body.get("germline")),
|
| 3452 |
+
can_enumerate=True,
|
| 3453 |
+
can_score_consequence=can_score,
|
| 3454 |
+
can_check_specificity=False,
|
| 3455 |
+
)
|
| 3456 |
+
except _compiler.GermlineRefused as exc:
|
| 3457 |
+
# 422, not 400: the request is well-formed and understood, and is
|
| 3458 |
+
# being refused on scope. Distinct code so nothing retries it.
|
| 3459 |
+
return jsonify({"error": str(exc), "kind": "out_of_scope"}), 422
|
| 3460 |
+
|
| 3461 |
+
return jsonify(_compiler.report_to_dict(report))
|
| 3462 |
+
|
| 3463 |
@app.get("/api/benchmarks")
|
| 3464 |
def benchmarks() -> Response:
|
| 3465 |
"""The receipts β how well the engine's zero-shot ranking predicts
|
|
|
|
| 4177 |
from dee.core import scoring as _scoring
|
| 4178 |
# DNA-level tiers (Evo 2, via Modal) β same "/api/models reports what's
|
| 4179 |
# actually runnable" contract, separate id-space (see the models() route).
|
| 4180 |
+
from dee.core import compiler as _compiler
|
| 4181 |
from dee.core import dna_scoring as _dna_scoring
|
| 4182 |
|
| 4183 |
|
dee/static/app.css
CHANGED
|
@@ -9814,3 +9814,150 @@ body.de-agent-run .dna-edit-actions { display: none; }
|
|
| 9814 |
.dna-meta, .dna-results-meta, .dna-scale-note,
|
| 9815 |
.dna-skipped-why, .dna-table th { font-size: 12px; }
|
| 9816 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9814 |
.dna-meta, .dna-results-meta, .dna-scale-note,
|
| 9815 |
.dna-skipped-why, .dna-table th { font-size: 12px; }
|
| 9816 |
}
|
| 9817 |
+
|
| 9818 |
+
/* βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 9819 |
+
Therapeutic Compiler
|
| 9820 |
+
The pass list is the hero. Passes light in sequence like a build log;
|
| 9821 |
+
passes that did NOT run stay unlit and dashed, so a gap in the analysis
|
| 9822 |
+
is something you SEE rather than something you have to read for.
|
| 9823 |
+
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 9824 |
+
|
| 9825 |
+
.tc-scope {
|
| 9826 |
+
display: flex; gap: 14px; align-items: flex-start;
|
| 9827 |
+
padding: 14px 16px; margin-bottom: 18px;
|
| 9828 |
+
border: 1px solid var(--line-strong); border-radius: var(--r-3);
|
| 9829 |
+
background: var(--bg-raised);
|
| 9830 |
+
}
|
| 9831 |
+
.tc-scope-key {
|
| 9832 |
+
font-size: 10.5px; letter-spacing: .1em; text-transform: uppercase;
|
| 9833 |
+
color: var(--ink-faint); padding-top: 2px; flex-shrink: 0;
|
| 9834 |
+
}
|
| 9835 |
+
.tc-scope p { margin: 0; font-size: 12.5px; line-height: 1.6; color: var(--ink-soft); }
|
| 9836 |
+
|
| 9837 |
+
.tc-alleles { display: flex; align-items: flex-end; gap: 14px; margin-bottom: 10px; }
|
| 9838 |
+
.tc-allele { display: flex; flex-direction: column; gap: 6px; }
|
| 9839 |
+
.tc-base, .tc-num {
|
| 9840 |
+
font-family: var(--font-mono); font-size: 16px; text-align: center;
|
| 9841 |
+
width: 130px; padding: 10px 12px;
|
| 9842 |
+
border: 1px solid var(--line-strong); border-radius: var(--r-2);
|
| 9843 |
+
background: var(--gray-0); color: var(--ink); text-transform: uppercase;
|
| 9844 |
+
}
|
| 9845 |
+
.tc-num { width: 130px; text-align: left; font-size: 13px; text-transform: none; }
|
| 9846 |
+
.tc-base:focus, .tc-num:focus { outline: none; border-color: var(--brand); }
|
| 9847 |
+
.tc-arrow { font-size: 18px; color: var(--ink-faint); padding-bottom: 12px; }
|
| 9848 |
+
|
| 9849 |
+
.tc-actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 16px; flex-wrap: wrap; }
|
| 9850 |
+
|
| 9851 |
+
/* ββ verdict ββ */
|
| 9852 |
+
.tc-verdict { padding: 14px 16px; border-radius: var(--r-3); margin-bottom: 16px; border-left: 3px solid var(--line-bold); background: var(--gray-1); }
|
| 9853 |
+
.tc-verdict.is-ok { border-left-color: var(--success); }
|
| 9854 |
+
.tc-verdict.is-partial { border-left-color: var(--warning); }
|
| 9855 |
+
.tc-verdict.is-refused { border-left-color: var(--danger); }
|
| 9856 |
+
.tc-verdict-head { font-size: 15px; color: var(--ink-strong); font-weight: 600; }
|
| 9857 |
+
.tc-verdict-sub { font-size: 12.5px; color: var(--ink-soft); margin-top: 4px; line-height: 1.5; }
|
| 9858 |
+
|
| 9859 |
+
/* ββ locus strip ββ */
|
| 9860 |
+
.tc-locus { margin-bottom: 18px; }
|
| 9861 |
+
.tc-locus-row { display: flex; align-items: center; gap: 12px; overflow-x: auto; }
|
| 9862 |
+
.tc-locus-lbl { font-size: 10.5px; text-transform: uppercase; letter-spacing: .08em; color: var(--ink-faint); flex-shrink: 0; }
|
| 9863 |
+
.tc-seq { display: flex; gap: 1px; }
|
| 9864 |
+
.tc-b {
|
| 9865 |
+
display: inline-flex; align-items: center; justify-content: center;
|
| 9866 |
+
width: 20px; height: 26px; font-size: 13px; color: var(--ink-soft);
|
| 9867 |
+
background: var(--gray-1); border-radius: 2px;
|
| 9868 |
+
}
|
| 9869 |
+
.tc-b--hit {
|
| 9870 |
+
color: var(--on-ink); background: var(--danger);
|
| 9871 |
+
box-shadow: 0 0 0 2px color-mix(in srgb, var(--danger) 35%, transparent);
|
| 9872 |
+
font-weight: 700;
|
| 9873 |
+
}
|
| 9874 |
+
.tc-locus-legend { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 10px; }
|
| 9875 |
+
.tc-chip {
|
| 9876 |
+
font-size: 11.5px; padding: 3px 9px; border-radius: 999px;
|
| 9877 |
+
border: 1px solid var(--line-strong); color: var(--ink-soft);
|
| 9878 |
+
}
|
| 9879 |
+
.tc-chip--pt { border-color: var(--danger); color: var(--danger); }
|
| 9880 |
+
.tc-chip--wt { border-color: var(--success); color: var(--success); }
|
| 9881 |
+
.tc-locus-note { font-size: 11.5px; color: var(--ink-faint); margin: 10px 0 0; line-height: 1.55; }
|
| 9882 |
+
|
| 9883 |
+
/* ββ the pass column ββ */
|
| 9884 |
+
.tc-passes { list-style: none; margin: 0; padding: 0; position: relative; }
|
| 9885 |
+
/* the spine the passes hang from */
|
| 9886 |
+
.tc-passes::before {
|
| 9887 |
+
content: ''; position: absolute; left: 11px; top: 12px; bottom: 12px;
|
| 9888 |
+
width: 1px; background: var(--line);
|
| 9889 |
+
}
|
| 9890 |
+
.tc-pass {
|
| 9891 |
+
position: relative; display: flex; gap: 14px; padding: 12px 0 12px 0;
|
| 9892 |
+
opacity: 0; transform: translateY(4px);
|
| 9893 |
+
}
|
| 9894 |
+
.tc-pass.is-lit {
|
| 9895 |
+
animation: tcLight .42s cubic-bezier(.2,.7,.3,1) forwards;
|
| 9896 |
+
animation-delay: calc(var(--i) * 90ms);
|
| 9897 |
+
}
|
| 9898 |
+
@keyframes tcLight { to { opacity: 1; transform: none; } }
|
| 9899 |
+
@media (prefers-reduced-motion: reduce) {
|
| 9900 |
+
.tc-pass, .tc-pass.is-lit { opacity: 1; transform: none; animation: none; }
|
| 9901 |
+
}
|
| 9902 |
+
|
| 9903 |
+
.tc-pass-dot {
|
| 9904 |
+
position: relative; z-index: 1; flex-shrink: 0;
|
| 9905 |
+
width: 23px; height: 23px; border-radius: 50%;
|
| 9906 |
+
display: inline-flex; align-items: center; justify-content: center;
|
| 9907 |
+
font-size: 11px; border: 1px solid var(--line-strong);
|
| 9908 |
+
background: var(--bg-card); color: var(--ink-faint);
|
| 9909 |
+
}
|
| 9910 |
+
.tc-pass--ok .tc-pass-dot { border-color: var(--success); color: var(--success);
|
| 9911 |
+
box-shadow: 0 0 0 3px color-mix(in srgb, var(--success) 14%, transparent); }
|
| 9912 |
+
.tc-pass--warn .tc-pass-dot { border-color: var(--warning); color: var(--warning);
|
| 9913 |
+
box-shadow: 0 0 0 3px color-mix(in srgb, var(--warning) 14%, transparent); }
|
| 9914 |
+
.tc-pass--error .tc-pass-dot { border-color: var(--danger); color: var(--danger);
|
| 9915 |
+
box-shadow: 0 0 0 3px color-mix(in srgb, var(--danger) 16%, transparent); }
|
| 9916 |
+
/* Unlit on purpose β dashed, no glow, dimmed. The visible hole. */
|
| 9917 |
+
.tc-pass--unavailable .tc-pass-dot,
|
| 9918 |
+
.tc-pass--skipped .tc-pass-dot { border-style: dashed; color: var(--ink-disabled); }
|
| 9919 |
+
.tc-pass--unavailable, .tc-pass--skipped { opacity: .62; }
|
| 9920 |
+
.tc-pass--unavailable.is-lit { animation-name: tcLightDim; }
|
| 9921 |
+
@keyframes tcLightDim { to { opacity: .62; transform: none; } }
|
| 9922 |
+
|
| 9923 |
+
.tc-pass-body { min-width: 0; }
|
| 9924 |
+
.tc-pass-hd { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; }
|
| 9925 |
+
.tc-pass-title { font-size: 13.5px; color: var(--ink-strong); font-weight: 600; }
|
| 9926 |
+
.tc-pass-status {
|
| 9927 |
+
font-size: 10.5px; text-transform: uppercase; letter-spacing: .07em;
|
| 9928 |
+
color: var(--ink-faint);
|
| 9929 |
+
}
|
| 9930 |
+
.tc-pass--ok .tc-pass-status { color: var(--success); }
|
| 9931 |
+
.tc-pass--warn .tc-pass-status { color: var(--warning); }
|
| 9932 |
+
.tc-pass--error .tc-pass-status { color: var(--danger); }
|
| 9933 |
+
.tc-pass-detail { margin: 5px 0 0; font-size: 12.5px; line-height: 1.55; color: var(--ink-soft); }
|
| 9934 |
+
|
| 9935 |
+
/* ββ diagnostics ββ */
|
| 9936 |
+
.tc-diags-hd {
|
| 9937 |
+
font-size: 11px; text-transform: uppercase; letter-spacing: .08em;
|
| 9938 |
+
color: var(--ink-faint); margin: 22px 0 10px;
|
| 9939 |
+
}
|
| 9940 |
+
.tc-diag {
|
| 9941 |
+
padding: 11px 14px; border-radius: var(--r-2); margin-bottom: 9px;
|
| 9942 |
+
border-left: 3px solid var(--line-bold); background: var(--gray-1);
|
| 9943 |
+
}
|
| 9944 |
+
.tc-diag--error { border-left-color: var(--danger); }
|
| 9945 |
+
.tc-diag--warning { border-left-color: var(--warning); }
|
| 9946 |
+
.tc-diag--note { border-left-color: var(--line-bold); }
|
| 9947 |
+
.tc-diag-top { display: flex; gap: 10px; align-items: baseline; flex-wrap: wrap; }
|
| 9948 |
+
.tc-diag-code { font-family: var(--font-mono); font-size: 11.5px; color: var(--ink); }
|
| 9949 |
+
.tc-diag-where { font-size: 10.5px; text-transform: uppercase; letter-spacing: .07em; color: var(--ink-faint); }
|
| 9950 |
+
.tc-diag-msg { margin: 6px 0 0; font-size: 12.5px; line-height: 1.55; color: var(--ink); }
|
| 9951 |
+
.tc-diag-remedy { margin: 6px 0 0; font-size: 12.5px; line-height: 1.55; color: var(--ink-soft); }
|
| 9952 |
+
|
| 9953 |
+
@media (max-width: 720px) {
|
| 9954 |
+
.tc-alleles { flex-direction: column; align-items: stretch; }
|
| 9955 |
+
.tc-arrow { display: none; }
|
| 9956 |
+
.tc-base, .tc-num { width: 100%; min-height: 44px; }
|
| 9957 |
+
.tc-actions { flex-direction: column-reverse; }
|
| 9958 |
+
.tc-actions button { width: 100%; min-height: 44px; }
|
| 9959 |
+
.tc-scope { flex-direction: column; gap: 6px; }
|
| 9960 |
+
.tc-b { width: 17px; height: 24px; font-size: 12px; }
|
| 9961 |
+
.tc-scope p, .tc-pass-detail, .tc-diag-msg, .tc-diag-remedy,
|
| 9962 |
+
.tc-locus-note, .tc-chip { font-size: 12px; }
|
| 9963 |
+
}
|
dee/static/app.js
CHANGED
|
@@ -392,7 +392,7 @@ renderGutter();
|
|
| 392 |
// nav-rail peer anymore, it's what a session starts with.
|
| 393 |
// 'dna' (Evo 2) sits next to 'design': DE designs at the protein level, DNA
|
| 394 |
// Design at the nucleotide level. Same phase of the loop, different molecule.
|
| 395 |
-
const ROUTES = ['mission', 'turing', 'structure', 'plasmid', 'design', 'dna', 'crispr', 'primers', 'docs'];
|
| 396 |
|
| 397 |
// ββ UI mode flag (Mission-Control-+-Bench re-architecture, 2026-07-13) ββ
|
| 398 |
// 'bench' is now the default UI; ?ui=classic remains as a rollback escape
|
|
@@ -10314,6 +10314,7 @@ function runOracle(opts){
|
|
| 10314 |
const TOOLS = [
|
| 10315 |
{ label: 'Evolve a sequence', hint: 'Directed evolution', route: 'design' },
|
| 10316 |
{ label: 'Score a DNA change', hint: 'Promoters, splice sites, UTRs', route: 'dna' },
|
|
|
|
| 10317 |
{ label: 'Build a plasmid', hint: 'Map & annotate a construct', route: 'plasmid' },
|
| 10318 |
{ label: 'Design CRISPR guides', hint: 'Guides + specificity', route: 'crispr' },
|
| 10319 |
{ label: 'Check primers', hint: 'Tm, dimers, specificity', route: 'primers' },
|
|
@@ -10955,7 +10956,7 @@ function runOracle(opts){
|
|
| 10955 |
// both are the Design phase, one at the protein level and one at the
|
| 10956 |
// nucleotide level. Bench is the DEFAULT UI β a route missing from this
|
| 10957 |
// list has a nav entry that goes nowhere.
|
| 10958 |
-
const TAB_ROUTES = ['structure', 'design', 'dna', 'plasmid', 'crispr', 'primers'];
|
| 10959 |
let current = null; // { name, sub, phaseIdx, route }
|
| 10960 |
|
| 10961 |
function el(id) { return document.getElementById(id); }
|
|
@@ -12275,3 +12276,203 @@ if (document.readyState === 'loading') {
|
|
| 12275 |
init();
|
| 12276 |
}
|
| 12277 |
}());
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 392 |
// nav-rail peer anymore, it's what a session starts with.
|
| 393 |
// 'dna' (Evo 2) sits next to 'design': DE designs at the protein level, DNA
|
| 394 |
// Design at the nucleotide level. Same phase of the loop, different molecule.
|
| 395 |
+
const ROUTES = ['mission', 'turing', 'structure', 'plasmid', 'design', 'dna', 'compiler', 'crispr', 'primers', 'docs'];
|
| 396 |
|
| 397 |
// ββ UI mode flag (Mission-Control-+-Bench re-architecture, 2026-07-13) ββ
|
| 398 |
// 'bench' is now the default UI; ?ui=classic remains as a rollback escape
|
|
|
|
| 10314 |
const TOOLS = [
|
| 10315 |
{ label: 'Evolve a sequence', hint: 'Directed evolution', route: 'design' },
|
| 10316 |
{ label: 'Score a DNA change', hint: 'Promoters, splice sites, UTRs', route: 'dna' },
|
| 10317 |
+
{ label: 'Compile a variant correction', hint: 'Pathogenic variant \u2192 editing strategy', route: 'compiler' },
|
| 10318 |
{ label: 'Build a plasmid', hint: 'Map & annotate a construct', route: 'plasmid' },
|
| 10319 |
{ label: 'Design CRISPR guides', hint: 'Guides + specificity', route: 'crispr' },
|
| 10320 |
{ label: 'Check primers', hint: 'Tm, dimers, specificity', route: 'primers' },
|
|
|
|
| 10956 |
// both are the Design phase, one at the protein level and one at the
|
| 10957 |
// nucleotide level. Bench is the DEFAULT UI β a route missing from this
|
| 10958 |
// list has a nav entry that goes nowhere.
|
| 10959 |
+
const TAB_ROUTES = ['structure', 'design', 'dna', 'plasmid', 'compiler', 'crispr', 'primers'];
|
| 10960 |
let current = null; // { name, sub, phaseIdx, route }
|
| 10961 |
|
| 10962 |
function el(id) { return document.getElementById(id); }
|
|
|
|
| 12276 |
init();
|
| 12277 |
}
|
| 12278 |
}());
|
| 12279 |
+
|
| 12280 |
+
|
| 12281 |
+
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 12282 |
+
// Therapeutic Compiler
|
| 12283 |
+
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 12284 |
+
// Renders the pass list IN FULL, including passes that did not run. The
|
| 12285 |
+
// sequential reveal is not decoration: a build log arrives in order, and
|
| 12286 |
+
// seeing "Verify against reference" light up before "Assess specificity"
|
| 12287 |
+
// stays dark is the whole point. A tidy green column would imply checks
|
| 12288 |
+
// nobody performed.
|
| 12289 |
+
//
|
| 12290 |
+
// Status vocabulary, kept identical to dee/core/compiler.py so the two
|
| 12291 |
+
// cannot drift:
|
| 12292 |
+
// ok | warn | error | unavailable | skipped
|
| 12293 |
+
// `unavailable` is deliberately NOT a failure and NOT a success β it is
|
| 12294 |
+
// "this deployment could not run this pass", drawn unlit.
|
| 12295 |
+
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 12296 |
+
(function () {
|
| 12297 |
+
const $ = (id) => document.getElementById(id);
|
| 12298 |
+
const esc = (s) => (typeof escapeHtml === 'function' ? escapeHtml(String(s)) : String(s));
|
| 12299 |
+
|
| 12300 |
+
// Illustrative windows, not patient data. The second one exists so the
|
| 12301 |
+
// refusal path is one click away β it is the more informative demo.
|
| 12302 |
+
const EX_OK = {
|
| 12303 |
+
wt: 'G', patient: 'A', offset: 12,
|
| 12304 |
+
window: 'CCTGAGGAGAAGGCTGCCGTCACCGCCCTGTGGGGCAAGGTGAACGTGGAT',
|
| 12305 |
+
};
|
| 12306 |
+
const EX_FAIL = {
|
| 12307 |
+
wt: 'A', patient: 'C', offset: 12,
|
| 12308 |
+
window: 'CCTGAGGAGAAGACTGCCGTCACCGCCCTGTGGGGCAAGGTGAACGTGGAT',
|
| 12309 |
+
};
|
| 12310 |
+
|
| 12311 |
+
const ICON = {
|
| 12312 |
+
ok: '✓', warn: '!', error: '✕',
|
| 12313 |
+
unavailable: '—', skipped: '·',
|
| 12314 |
+
};
|
| 12315 |
+
const LABEL = {
|
| 12316 |
+
ok: 'passed', warn: 'passed with caveat', error: 'refused',
|
| 12317 |
+
unavailable: 'not run', skipped: 'skipped',
|
| 12318 |
+
};
|
| 12319 |
+
|
| 12320 |
+
function setError(msg) {
|
| 12321 |
+
const el = $('tcError');
|
| 12322 |
+
if (!el) return;
|
| 12323 |
+
if (!msg) { el.hidden = true; el.textContent = ''; return; }
|
| 12324 |
+
el.hidden = false; el.textContent = msg;
|
| 12325 |
+
}
|
| 12326 |
+
|
| 12327 |
+
function loadExample(ex) {
|
| 12328 |
+
$('tcWt').value = ex.wt;
|
| 12329 |
+
$('tcPatient').value = ex.patient;
|
| 12330 |
+
$('tcWindow').value = ex.window;
|
| 12331 |
+
$('tcOffset').value = ex.offset;
|
| 12332 |
+
setError('');
|
| 12333 |
+
$('tcResultCard').hidden = true;
|
| 12334 |
+
}
|
| 12335 |
+
|
| 12336 |
+
// The locus strip β the variant in its actual sequence context, with the
|
| 12337 |
+
// patient base and the wild-type base shown at the same position.
|
| 12338 |
+
function renderLocus(win, offset, corr) {
|
| 12339 |
+
const host = $('tcLocus');
|
| 12340 |
+
if (!host) return;
|
| 12341 |
+
if (!win || offset < 0 || offset >= win.length || !corr) { host.hidden = true; return; }
|
| 12342 |
+
const span = 21;
|
| 12343 |
+
const from = Math.max(0, offset - Math.floor(span / 2));
|
| 12344 |
+
const to = Math.min(win.length, from + span);
|
| 12345 |
+
let bases = '';
|
| 12346 |
+
for (let i = from; i < to; i++) {
|
| 12347 |
+
bases += (i === offset)
|
| 12348 |
+
? `<span class="tc-b tc-b--hit">${esc(win[i])}</span>`
|
| 12349 |
+
: `<span class="tc-b">${esc(win[i])}</span>`;
|
| 12350 |
+
}
|
| 12351 |
+
host.hidden = false;
|
| 12352 |
+
host.innerHTML =
|
| 12353 |
+
`<div class="tc-locus-row"><span class="tc-locus-lbl">reference</span>`
|
| 12354 |
+
+ `<span class="tc-seq mono">${bases}</span></div>`
|
| 12355 |
+
+ `<div class="tc-locus-legend">`
|
| 12356 |
+
+ `<span class="tc-chip tc-chip--pt">patient ${esc(corr.patient_base)}</span>`
|
| 12357 |
+
+ `<span class="tc-chip tc-chip--wt">wild-type ${esc(corr.wt_base)}</span>`
|
| 12358 |
+
+ `<span class="tc-chip">${esc(corr.editor_family)} writes ${esc(corr.editor_change)}</span>`
|
| 12359 |
+
+ `<span class="tc-chip">${esc(corr.strand)} strand</span>`
|
| 12360 |
+
+ `</div>`
|
| 12361 |
+
+ `<p class="tc-locus-note">Showing ${to - from} nt around offset ${offset}. The editor engages `
|
| 12362 |
+
+ `the <strong>${esc(corr.strand)}</strong> strand β a base editor only ever writes A→G (ABE) `
|
| 12363 |
+
+ `or C→T (CBE), so the strand follows from the correction, not from preference.</p>`;
|
| 12364 |
+
}
|
| 12365 |
+
|
| 12366 |
+
function renderVerdict(data) {
|
| 12367 |
+
const el = $('tcVerdict');
|
| 12368 |
+
if (!el) return;
|
| 12369 |
+
const c = data.correction;
|
| 12370 |
+
const gaps = data.incomplete_because || [];
|
| 12371 |
+
let cls, head, sub;
|
| 12372 |
+
if (data.compiled) {
|
| 12373 |
+
cls = gaps.length ? 'is-partial' : 'is-ok';
|
| 12374 |
+
head = c ? `${esc(c.editor_family)} · ${esc(c.sense_change)} on the ${esc(c.strand)} strand`
|
| 12375 |
+
: 'Compiled';
|
| 12376 |
+
sub = gaps.length
|
| 12377 |
+
? `Compiled, but the record is incomplete: ${gaps.map(esc).join(', ')} did not run.`
|
| 12378 |
+
: 'Every pass ran and succeeded.';
|
| 12379 |
+
} else {
|
| 12380 |
+
cls = 'is-refused';
|
| 12381 |
+
head = 'Did not compile';
|
| 12382 |
+
sub = 'One or more passes refused. The diagnostics below say what would have to change.';
|
| 12383 |
+
}
|
| 12384 |
+
el.className = 'tc-verdict ' + cls;
|
| 12385 |
+
el.innerHTML = `<div class="tc-verdict-head">${head}</div>`
|
| 12386 |
+
+ `<div class="tc-verdict-sub">${esc(sub)}</div>`;
|
| 12387 |
+
}
|
| 12388 |
+
|
| 12389 |
+
function renderPasses(passes) {
|
| 12390 |
+
const host = $('tcPasses');
|
| 12391 |
+
if (!host) return;
|
| 12392 |
+
host.innerHTML = passes.map((p, i) => (
|
| 12393 |
+
`<li class="tc-pass tc-pass--${esc(p.status)}" style="--i:${i}">`
|
| 12394 |
+
+ `<span class="tc-pass-dot" aria-hidden="true">${ICON[p.status] || ''}</span>`
|
| 12395 |
+
+ `<div class="tc-pass-body">`
|
| 12396 |
+
+ `<div class="tc-pass-hd"><span class="tc-pass-title">${esc(p.title)}</span>`
|
| 12397 |
+
+ `<span class="tc-pass-status">${esc(LABEL[p.status] || p.status)}</span></div>`
|
| 12398 |
+
+ (p.detail ? `<p class="tc-pass-detail">${esc(p.detail)}</p>` : '')
|
| 12399 |
+
+ `</div></li>`
|
| 12400 |
+
)).join('');
|
| 12401 |
+
// Re-trigger the stagger on every compile, not just the first.
|
| 12402 |
+
host.querySelectorAll('.tc-pass').forEach((n) => {
|
| 12403 |
+
n.classList.remove('is-lit');
|
| 12404 |
+
// eslint-disable-next-line no-unused-expressions
|
| 12405 |
+
n.offsetWidth;
|
| 12406 |
+
n.classList.add('is-lit');
|
| 12407 |
+
});
|
| 12408 |
+
}
|
| 12409 |
+
|
| 12410 |
+
function renderDiags(passes) {
|
| 12411 |
+
const host = $('tcDiags');
|
| 12412 |
+
if (!host) return;
|
| 12413 |
+
const all = [];
|
| 12414 |
+
passes.forEach((p) => (p.diagnostics || []).forEach((d) => all.push([p.title, d])));
|
| 12415 |
+
if (!all.length) { host.innerHTML = ''; return; }
|
| 12416 |
+
host.innerHTML = '<h3 class="tc-diags-hd">Diagnostics</h3>' + all.map(([where, d]) => (
|
| 12417 |
+
`<div class="tc-diag tc-diag--${esc(d.level)}">`
|
| 12418 |
+
+ `<div class="tc-diag-top"><code class="tc-diag-code">${esc(d.code)}</code>`
|
| 12419 |
+
+ `<span class="tc-diag-where">${esc(where)}</span></div>`
|
| 12420 |
+
+ `<p class="tc-diag-msg">${esc(d.message)}</p>`
|
| 12421 |
+
+ (d.remedy ? `<p class="tc-diag-remedy"><strong>What would change this:</strong> ${esc(d.remedy)}</p>` : '')
|
| 12422 |
+
+ `</div>`
|
| 12423 |
+
)).join('');
|
| 12424 |
+
}
|
| 12425 |
+
|
| 12426 |
+
async function run() {
|
| 12427 |
+
setError('');
|
| 12428 |
+
const wt = ($('tcWt').value || '').trim();
|
| 12429 |
+
const patient = ($('tcPatient').value || '').trim();
|
| 12430 |
+
if (!wt && !patient) { setError('Give at least one allele.'); return; }
|
| 12431 |
+
|
| 12432 |
+
const win = ($('tcWindow').value || '').replace(/\s+/g, '').toUpperCase();
|
| 12433 |
+
const offset = parseInt($('tcOffset').value, 10);
|
| 12434 |
+
const btn = $('tcRun');
|
| 12435 |
+
btn.disabled = true;
|
| 12436 |
+
try {
|
| 12437 |
+
const res = await fetch('/api/compiler/compile', {
|
| 12438 |
+
method: 'POST',
|
| 12439 |
+
headers: { 'Content-Type': 'application/json' },
|
| 12440 |
+
body: JSON.stringify({
|
| 12441 |
+
wt_allele: wt, patient_allele: patient,
|
| 12442 |
+
window: win, offset: isNaN(offset) ? -1 : offset,
|
| 12443 |
+
}),
|
| 12444 |
+
});
|
| 12445 |
+
const data = await res.json();
|
| 12446 |
+
if (!res.ok) {
|
| 12447 |
+
if (data && data.kind === 'signin_required') {
|
| 12448 |
+
window.dispatchEvent(new Event('td:signin-required'));
|
| 12449 |
+
return;
|
| 12450 |
+
}
|
| 12451 |
+
throw new Error(data.error || 'The compiler could not run.');
|
| 12452 |
+
}
|
| 12453 |
+
renderVerdict(data);
|
| 12454 |
+
renderLocus(win, isNaN(offset) ? -1 : offset, data.correction);
|
| 12455 |
+
renderPasses(data.passes || []);
|
| 12456 |
+
renderDiags(data.passes || []);
|
| 12457 |
+
$('tcResultCard').hidden = false;
|
| 12458 |
+
} catch (err) {
|
| 12459 |
+
setError((err && err.message) || String(err));
|
| 12460 |
+
} finally {
|
| 12461 |
+
btn.disabled = false;
|
| 12462 |
+
}
|
| 12463 |
+
}
|
| 12464 |
+
|
| 12465 |
+
function init() {
|
| 12466 |
+
if (!document.querySelector('[data-view="compiler"]')) return;
|
| 12467 |
+
const on = (id, ev, fn) => { const el = $(id); if (el) el.addEventListener(ev, fn); };
|
| 12468 |
+
on('tcRun', 'click', run);
|
| 12469 |
+
on('tcExampleOk', 'click', () => loadExample(EX_OK));
|
| 12470 |
+
on('tcExampleFail', 'click', () => loadExample(EX_FAIL));
|
| 12471 |
+
}
|
| 12472 |
+
|
| 12473 |
+
if (document.readyState === 'loading') {
|
| 12474 |
+
document.addEventListener('DOMContentLoaded', init);
|
| 12475 |
+
} else {
|
| 12476 |
+
init();
|
| 12477 |
+
}
|
| 12478 |
+
}());
|
dee/static/index.html
CHANGED
|
@@ -112,7 +112,7 @@
|
|
| 112 |
<!-- ?v= query bumps invalidate browser + iframe asset caches when app.css /
|
| 113 |
app.js change. Bump these numbers whenever you ship a frontend update β
|
| 114 |
without them, users keep getting the stale file for up to a week. -->
|
| 115 |
-
<link rel="stylesheet" href="/static/app.css?v=20260812-
|
| 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. -->
|
|
@@ -316,6 +316,24 @@
|
|
| 316 |
<span class="nav-step-nm">Plasmid Editor</span>
|
| 317 |
<span class="nav-step-ph">Build</span>
|
| 318 |
</a>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 319 |
<a class="nav-item nav-step" href="#crispr" data-analytics="nav-crispr" title="CRISPR">
|
| 320 |
<span class="nav-icon" aria-hidden="true">
|
| 321 |
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
|
@@ -1251,6 +1269,81 @@
|
|
| 1251 |
</section>
|
| 1252 |
</section>
|
| 1253 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1254 |
<!-- βββββββββββββββββββββββββββββ CRISPR view (Cas9 knockout) βββ
|
| 1255 |
Paste a gene β ranked SpCas9 sgRNAs with on-target
|
| 1256 |
scoring. Sign-in gated server-side; anonymous users
|
|
@@ -2737,7 +2830,7 @@
|
|
| 2737 |
<!-- Cloning reference data must load before app.js so the Designer
|
| 2738 |
can read VECTORS / ENZYMES / CLONING_METHODS / TAGS / LINKERS. -->
|
| 2739 |
<script src="/static/cloning_db.js?v=20260530-ui-polish" defer></script>
|
| 2740 |
-
<script src="/static/app.js?v=20260812-
|
| 2741 |
<!-- The decision trace, BEFORE cockpit.js: applyEvent calls TDTrace.push
|
| 2742 |
on the very first event, and both are `defer`, so document order is
|
| 2743 |
load order. Loading it after would drop the opening events of a
|
|
|
|
| 112 |
<!-- ?v= query bumps invalidate browser + iframe asset caches when app.css /
|
| 113 |
app.js change. Bump these numbers whenever you ship a frontend update β
|
| 114 |
without them, users keep getting the stale file for up to a week. -->
|
| 115 |
+
<link rel="stylesheet" href="/static/app.css?v=20260812-tc" />
|
| 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. -->
|
|
|
|
| 316 |
<span class="nav-step-nm">Plasmid Editor</span>
|
| 317 |
<span class="nav-step-ph">Build</span>
|
| 318 |
</a>
|
| 319 |
+
<!--
|
| 320 |
+
Compiler sits BEFORE CRISPR in the Edit phase, because
|
| 321 |
+
that is the real order of the work: decide what edit
|
| 322 |
+
the variant needs, then design the guide that makes it.
|
| 323 |
+
Same precedent as the two Design-phase tools β sharing
|
| 324 |
+
a phase label is honest; inventing a phase is not.
|
| 325 |
+
-->
|
| 326 |
+
<a class="nav-item nav-step" href="#compiler" data-analytics="nav-compiler" title="Therapeutic Compiler">
|
| 327 |
+
<span class="nav-icon" aria-hidden="true">
|
| 328 |
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
| 329 |
+
<path d="M4 6h6M4 12h6M4 18h6"/>
|
| 330 |
+
<path d="M14 6h6M14 12h6M14 18h6"/>
|
| 331 |
+
<path d="M11.2 4.6l1.6 2.8M11.2 19.4l1.6-2.8"/>
|
| 332 |
+
</svg>
|
| 333 |
+
</span>
|
| 334 |
+
<span class="nav-step-nm">Therapeutic Compiler</span>
|
| 335 |
+
<span class="nav-step-ph">Edit</span>
|
| 336 |
+
</a>
|
| 337 |
<a class="nav-item nav-step" href="#crispr" data-analytics="nav-crispr" title="CRISPR">
|
| 338 |
<span class="nav-icon" aria-hidden="true">
|
| 339 |
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
|
|
|
| 1269 |
</section>
|
| 1270 |
</section>
|
| 1271 |
|
| 1272 |
+
<!-- βββββββββββββββββββββββ Therapeutic Compiler ββββββββββββββββ
|
| 1273 |
+
Lowers a pathogenic variant to an editing strategy β or
|
| 1274 |
+
refuses, with a diagnostic saying why. The refusals are the
|
| 1275 |
+
product: a tool that always returns a strategy for a
|
| 1276 |
+
patient's variant is a plausible-answer generator.
|
| 1277 |
+
|
| 1278 |
+
The pass list is rendered in full, INCLUDING passes that
|
| 1279 |
+
could not run. An unlit pass is a visible hole in the
|
| 1280 |
+
analysis, which is the honest picture β the alternative is
|
| 1281 |
+
a tidy green column that implies checks nobody performed.
|
| 1282 |
+
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ -->
|
| 1283 |
+
<section class="view view--compiler" data-view="compiler" hidden>
|
| 1284 |
+
<header class="view-head">
|
| 1285 |
+
<h1 class="view-title"><em>Therapeutic Compiler</em></h1>
|
| 1286 |
+
<p class="view-sub">Give it a pathogenic variant; it lowers that to an editing
|
| 1287 |
+
strategy through named passes, and stops at the first one that can't be
|
| 1288 |
+
satisfied. What it refuses to compile β and why β is the point.</p>
|
| 1289 |
+
</header>
|
| 1290 |
+
|
| 1291 |
+
<div class="tc-scope" role="note">
|
| 1292 |
+
<span class="tc-scope-key">Scope</span>
|
| 1293 |
+
<p><strong>Somatic design and assessment only.</strong> Germline and embryo
|
| 1294 |
+
applications are refused in code, not just in copy. Output is a design record
|
| 1295 |
+
for humans to evaluate β it is not IND-ready, not a clinical decision, and not
|
| 1296 |
+
a clearance of any strategy for use. Immunogenicity, pharmacokinetics, dosing,
|
| 1297 |
+
manufacturing and delivery efficacy are not modelled and not reported.</p>
|
| 1298 |
+
</div>
|
| 1299 |
+
|
| 1300 |
+
<section class="card tc-input-card">
|
| 1301 |
+
<div class="tc-alleles">
|
| 1302 |
+
<label class="tc-allele">
|
| 1303 |
+
<span class="field-label">Wild-type allele</span>
|
| 1304 |
+
<input id="tcWt" class="tc-base mono" maxlength="80" spellcheck="false" placeholder="G" />
|
| 1305 |
+
</label>
|
| 1306 |
+
<span class="tc-arrow" aria-hidden="true">β</span>
|
| 1307 |
+
<label class="tc-allele">
|
| 1308 |
+
<span class="field-label">Patient allele</span>
|
| 1309 |
+
<input id="tcPatient" class="tc-base mono" maxlength="80" spellcheck="false" placeholder="A" />
|
| 1310 |
+
</label>
|
| 1311 |
+
</div>
|
| 1312 |
+
<p class="field-hint">Correction runs <strong>patient β wild-type</strong>. Reversed,
|
| 1313 |
+
you would be designing an editor that installs the disease, so the two fields are
|
| 1314 |
+
named for what they are rather than ref/alt. Leave an allele empty for an
|
| 1315 |
+
insertion or deletion.</p>
|
| 1316 |
+
|
| 1317 |
+
<label class="field-label" for="tcWindow">Reference window
|
| 1318 |
+
<span class="field-opt">Β· optional, strongly recommended</span></label>
|
| 1319 |
+
<p class="field-hint">Wild-type sequence around the variant. Without it the lesion can
|
| 1320 |
+
still be classified from alleles alone β but nothing is checked against real
|
| 1321 |
+
sequence, and an off-by-one coordinate still type-checks at the allele level.</p>
|
| 1322 |
+
<textarea id="tcWindow" class="dna-textarea mono" rows="3" spellcheck="false"
|
| 1323 |
+
placeholder="Paste wild-type DNA around the variant (A/C/G/T)β¦"></textarea>
|
| 1324 |
+
|
| 1325 |
+
<label class="field-label" for="tcOffset">Variant offset in that window
|
| 1326 |
+
<span class="field-opt">Β· 0-based</span></label>
|
| 1327 |
+
<input id="tcOffset" class="tc-num mono" type="number" min="0" value="0" />
|
| 1328 |
+
|
| 1329 |
+
<div class="tc-actions">
|
| 1330 |
+
<button class="ghost" type="button" id="tcExampleOk"
|
| 1331 |
+
title="A transition β base-editable, compiles">Example that compiles</button>
|
| 1332 |
+
<button class="ghost" type="button" id="tcExampleFail"
|
| 1333 |
+
title="A transversion β no base editor can make this change">Example that refuses</button>
|
| 1334 |
+
<button class="primary primary-lg" type="button" id="tcRun">Compile</button>
|
| 1335 |
+
</div>
|
| 1336 |
+
<div class="error-banner" id="tcError" hidden></div>
|
| 1337 |
+
</section>
|
| 1338 |
+
|
| 1339 |
+
<section class="card tc-result-card" id="tcResultCard" hidden>
|
| 1340 |
+
<div class="tc-verdict" id="tcVerdict"></div>
|
| 1341 |
+
<div class="tc-locus" id="tcLocus" hidden></div>
|
| 1342 |
+
<ol class="tc-passes" id="tcPasses"></ol>
|
| 1343 |
+
<div class="tc-diags" id="tcDiags"></div>
|
| 1344 |
+
</section>
|
| 1345 |
+
</section>
|
| 1346 |
+
|
| 1347 |
<!-- βββββββββββββββββββββββββββββ CRISPR view (Cas9 knockout) βββ
|
| 1348 |
Paste a gene β ranked SpCas9 sgRNAs with on-target
|
| 1349 |
scoring. Sign-in gated server-side; anonymous users
|
|
|
|
| 2830 |
<!-- Cloning reference data must load before app.js so the Designer
|
| 2831 |
can read VECTORS / ENZYMES / CLONING_METHODS / TAGS / LINKERS. -->
|
| 2832 |
<script src="/static/cloning_db.js?v=20260530-ui-polish" defer></script>
|
| 2833 |
+
<script src="/static/app.js?v=20260812-tc" defer></script>
|
| 2834 |
<!-- The decision trace, BEFORE cockpit.js: applyEvent calls TDTrace.push
|
| 2835 |
on the very first event, and both are `defer`, so document order is
|
| 2836 |
load order. Loading it after would drop the opening events of a
|
tests/test_compiler.py
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The therapeutic compiler. The refusals are the product, so they are what
|
| 2 |
+
this file spends its effort on.
|
| 3 |
+
|
| 4 |
+
The substitution space is 12 ordered base pairs, so it is tested EXHAUSTIVELY
|
| 5 |
+
rather than by example: every wild-type/patient combination is routed and the
|
| 6 |
+
routing is checked against the two chemistries from first principles. A
|
| 7 |
+
sampled test would have let a strand error through, and a strand error
|
| 8 |
+
designs a guide against the wrong strand β silent in silico, expensive at the
|
| 9 |
+
bench.
|
| 10 |
+
"""
|
| 11 |
+
import itertools
|
| 12 |
+
|
| 13 |
+
import pytest
|
| 14 |
+
|
| 15 |
+
from dee.core import compiler as C
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
BASES = "ACGT"
|
| 19 |
+
COMP = {"A": "T", "T": "A", "C": "G", "G": "C"}
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
# ββ the chemistry, restated independently of the implementation βββββββββ
|
| 23 |
+
def _expected_family(wt, patient):
|
| 24 |
+
"""Derived here from scratch so this is a real second opinion, not a
|
| 25 |
+
restatement of the module under test."""
|
| 26 |
+
if (patient, wt) == ("A", "G"):
|
| 27 |
+
return "ABE", "sense"
|
| 28 |
+
if (patient, wt) == ("C", "T"):
|
| 29 |
+
return "CBE", "sense"
|
| 30 |
+
if (COMP[patient], COMP[wt]) == ("A", "G"):
|
| 31 |
+
return "ABE", "antisense"
|
| 32 |
+
if (COMP[patient], COMP[wt]) == ("C", "T"):
|
| 33 |
+
return "CBE", "antisense"
|
| 34 |
+
return None, None
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
@pytest.mark.parametrize("wt,patient",
|
| 38 |
+
[(a, b) for a, b in itertools.product(BASES, BASES) if a != b])
|
| 39 |
+
def test_every_substitution_routes_correctly(wt, patient):
|
| 40 |
+
call = C.classify_lesion(wt, patient)
|
| 41 |
+
fam, strand = _expected_family(wt, patient)
|
| 42 |
+
if fam is None:
|
| 43 |
+
assert call.correction is None, f"{patient}>{wt} is a transversion"
|
| 44 |
+
assert call.route == "prime_editing"
|
| 45 |
+
assert not call.compiles, "a transversion must not compile to a guide"
|
| 46 |
+
else:
|
| 47 |
+
assert call.correction is not None, f"{patient}>{wt} should be {fam}"
|
| 48 |
+
assert call.correction.editor_family == fam
|
| 49 |
+
assert call.correction.strand == strand
|
| 50 |
+
assert call.route == "base_editing"
|
| 51 |
+
assert call.compiles
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def test_the_four_base_editable_corrections_are_exactly_the_transitions():
|
| 55 |
+
"""Transitions are base-editable, transversions are not. If this ever
|
| 56 |
+
reports more than four, a transversion has been mis-routed."""
|
| 57 |
+
editable = [(wt, pt) for wt, pt in itertools.product(BASES, BASES)
|
| 58 |
+
if wt != pt and C.classify_lesion(wt, pt).correction is not None]
|
| 59 |
+
assert len(editable) == 4
|
| 60 |
+
assert all(C.classify_lesion(wt, pt).is_transition for wt, pt in editable)
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def test_the_strand_derivation_a_reviewer_would_check_by_hand():
|
| 64 |
+
"""Spelled out, because getting this backwards is the expensive error.
|
| 65 |
+
|
| 66 |
+
A patient carrying C where wild-type is T needs T restored. On the sense
|
| 67 |
+
strand that is C>T β a CBE edit. A patient carrying G where wild-type is
|
| 68 |
+
A needs A restored: sense G>A, which on the ANTISENSE strand reads C>T,
|
| 69 |
+
also CBE but engaging the other strand.
|
| 70 |
+
"""
|
| 71 |
+
sense = C.classify_lesion("T", "C").correction
|
| 72 |
+
assert (sense.editor_family, sense.strand, sense.editor_change) == ("CBE", "sense", "C>T")
|
| 73 |
+
|
| 74 |
+
anti = C.classify_lesion("A", "G").correction
|
| 75 |
+
assert (anti.editor_family, anti.strand, anti.editor_change) == ("CBE", "antisense", "C>T")
|
| 76 |
+
|
| 77 |
+
sense_abe = C.classify_lesion("G", "A").correction
|
| 78 |
+
assert (sense_abe.editor_family, sense_abe.strand) == ("ABE", "sense")
|
| 79 |
+
|
| 80 |
+
anti_abe = C.classify_lesion("C", "T").correction
|
| 81 |
+
assert (anti_abe.editor_family, anti_abe.strand) == ("ABE", "antisense")
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
# ββ refusals ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 85 |
+
def test_identical_alleles_are_refused_not_silently_compiled():
|
| 86 |
+
call = C.classify_lesion("A", "A")
|
| 87 |
+
assert not call.compiles
|
| 88 |
+
assert call.errors()[0].code == "no_lesion"
|
| 89 |
+
assert "swapped" in call.errors()[0].remedy
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def test_a_transversion_says_why_and_names_the_route_it_cannot_take():
|
| 93 |
+
call = C.classify_lesion("A", "C") # correcting C>A
|
| 94 |
+
codes = [d.code for d in call.diagnostics]
|
| 95 |
+
assert "transversion_no_base_editor" in codes
|
| 96 |
+
assert "prime_editing_unavailable" in codes
|
| 97 |
+
msg = " ".join(d.message for d in call.diagnostics)
|
| 98 |
+
assert "ABE writes A>G" in msg and "CBE writes C>T" in msg
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def test_a_large_deletion_is_refused_and_points_somewhere_real():
|
| 102 |
+
call = C.classify_lesion("A" * 400, "")
|
| 103 |
+
assert not call.compiles
|
| 104 |
+
err = [d for d in call.errors() if d.code == "lesion_too_large"][0]
|
| 105 |
+
assert "400-base deletion" in err.message
|
| 106 |
+
assert "integrase" in err.remedy or "recombinase" in err.remedy
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def test_a_small_indel_routes_to_prime_editing_and_admits_we_lack_it():
|
| 110 |
+
call = C.classify_lesion("", "ATG")
|
| 111 |
+
assert call.kind == "insertion" and call.size == 3
|
| 112 |
+
assert call.route == "prime_editing"
|
| 113 |
+
assert not call.compiles, "we cannot design a pegRNA, so it must not compile"
|
| 114 |
+
assert any(d.code == "indel_not_base_editable" for d in call.diagnostics)
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def test_a_lesion_near_the_bound_is_flagged_marginal():
|
| 118 |
+
size = C.PRIME_EDIT_INSERT_BOUND // 2 + 2
|
| 119 |
+
call = C.classify_lesion("", "A" * size)
|
| 120 |
+
assert any(d.code == "lesion_near_bound" for d in call.diagnostics)
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def test_non_dna_alleles_are_refused():
|
| 124 |
+
for bad in ("N", "R", "Q", "5"):
|
| 125 |
+
call = C.classify_lesion("A", bad)
|
| 126 |
+
assert not call.compiles
|
| 127 |
+
assert call.errors()[0].code == "non_dna_allele"
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def test_ambiguity_codes_are_not_quietly_treated_as_bases():
|
| 131 |
+
"""N is a real thing to receive from a VCF and must not route."""
|
| 132 |
+
assert C.classify_lesion("N", "A").errors()[0].code == "non_dna_allele"
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
# ββ verification against real sequence ββββββββββββββββββββββββββββββββββ
|
| 136 |
+
WINDOW = "GATTACAGATTACAGGCCTTAA"
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def test_it_verifies_the_reference_actually_has_the_wildtype_base():
|
| 140 |
+
off = WINDOW.index("G") # position 0, a G
|
| 141 |
+
call = C.compile_correction("G", "A", window=WINDOW, offset=off)
|
| 142 |
+
assert call.compiles
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def test_an_off_by_one_coordinate_is_caught_by_the_reference_check():
|
| 146 |
+
"""The check that earns its keep: alleles alone still type-check when the
|
| 147 |
+
coordinate has drifted."""
|
| 148 |
+
off = 1 # WINDOW[1] is 'A', not 'G'
|
| 149 |
+
call = C.compile_correction("G", "A", window=WINDOW, offset=off)
|
| 150 |
+
assert not call.compiles
|
| 151 |
+
err = [d for d in call.errors() if d.code == "reference_mismatch"][0]
|
| 152 |
+
assert "'A'" in err.message
|
| 153 |
+
assert "opposite strand" in err.remedy
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
def test_an_offset_outside_the_window_is_refused():
|
| 157 |
+
call = C.compile_correction("G", "A", window=WINDOW, offset=999)
|
| 158 |
+
assert not call.compiles
|
| 159 |
+
assert call.errors()[0].code == "offset_outside_window"
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
def test_restores_wildtype_is_a_whole_sequence_comparison():
|
| 163 |
+
corr = C.classify_lesion("G", "A").correction
|
| 164 |
+
assert C.restores_wildtype(WINDOW, 0, corr)
|
| 165 |
+
assert not C.restores_wildtype(WINDOW, 1, corr)
|
| 166 |
+
assert not C.restores_wildtype("", 0, corr)
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
# ββ the scope boundary ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 170 |
+
def test_germline_raises_rather_than_returning_a_diagnostic():
|
| 171 |
+
"""A refusal a caller can read past and keep going is not a refusal."""
|
| 172 |
+
with pytest.raises(C.GermlineRefused):
|
| 173 |
+
C.compile_correction("G", "A", germline=True)
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
def test_germline_is_refused_before_any_routing_happens():
|
| 177 |
+
"""Even a perfectly compilable lesion must not be routed."""
|
| 178 |
+
with pytest.raises(C.GermlineRefused):
|
| 179 |
+
C.compile_correction("G", "A", window=WINDOW, offset=0, germline=True)
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
# ββ the direction of correction βββββββββββββββββββββββββββββββββββββββββ
|
| 183 |
+
def test_arguments_are_wildtype_first_patient_second():
|
| 184 |
+
"""Swapping these designs an editor that INSTALLS the disease. The two
|
| 185 |
+
orderings must not produce the same plan."""
|
| 186 |
+
a = C.classify_lesion("G", "A") # patient A -> restore G
|
| 187 |
+
b = C.classify_lesion("A", "G") # patient G -> restore A
|
| 188 |
+
assert a.correction.editor_family == "ABE"
|
| 189 |
+
assert b.correction.editor_family == "CBE"
|
| 190 |
+
assert a.correction.strand != b.correction.strand
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 194 |
+
# The pass pipeline. The point of these is that a pass which did NOT run is
|
| 195 |
+
# never reported as one that ran and passed.
|
| 196 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 197 |
+
def _by_name(report):
|
| 198 |
+
return {p.name: p for p in report.passes}
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
def test_every_declared_pass_is_reported():
|
| 202 |
+
r = C.compile_report("G", "A", window=WINDOW, offset=0)
|
| 203 |
+
assert [p.name for p in r.passes] == [n for n, _ in C.PASS_ORDER]
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
def test_unrunnable_passes_are_unavailable_not_ok():
|
| 207 |
+
"""The whole honesty contract. Defaults are False so a caller that forgets
|
| 208 |
+
to check capability gets an incomplete record, not a falsely clean one."""
|
| 209 |
+
r = C.compile_report("G", "A", window=WINDOW, offset=0)
|
| 210 |
+
p = _by_name(r)
|
| 211 |
+
assert p["consequence"].status == "unavailable"
|
| 212 |
+
assert p["specificity"].status == "unavailable"
|
| 213 |
+
assert "Assess edit consequence" in r.incomplete_because
|
| 214 |
+
assert "Assess specificity" in r.incomplete_because
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
def test_specificity_still_carries_its_caveat_when_it_runs():
|
| 218 |
+
"""A coding-sequence-only index reporting a clean 'ok' would read as a
|
| 219 |
+
clean bill of health on a therapeutic guide. It must never say ok."""
|
| 220 |
+
r = C.compile_report("G", "A", window=WINDOW, offset=0,
|
| 221 |
+
can_check_specificity=True)
|
| 222 |
+
spec = _by_name(r)["specificity"]
|
| 223 |
+
assert spec.status == "warn", "must not be reportable as unqualified ok"
|
| 224 |
+
assert "CODING SEQUENCE ONLY" in spec.detail
|
| 225 |
+
assert "GUIDE-seq" in spec.detail
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
def test_consequence_pass_states_it_is_zero_shot_even_when_available():
|
| 229 |
+
r = C.compile_report("G", "A", window=WINDOW, offset=0,
|
| 230 |
+
can_score_consequence=True)
|
| 231 |
+
d = _by_name(r)["consequence"].detail
|
| 232 |
+
assert "zero-shot" in d
|
| 233 |
+
assert "no validated relationship to clinical outcome" in d
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
def test_a_refused_lesion_skips_the_rest_rather_than_reporting_ok():
|
| 237 |
+
r = C.compile_report("A", "C") # transversion, no PE here
|
| 238 |
+
p = _by_name(r)
|
| 239 |
+
assert p["classify"].status == "error"
|
| 240 |
+
assert all(p[n].status == "skipped" for n in
|
| 241 |
+
("verify", "enumerate", "consequence", "specificity", "emit"))
|
| 242 |
+
assert not r.compiled
|
| 243 |
+
|
| 244 |
+
|
| 245 |
+
def test_a_reference_mismatch_stops_the_build():
|
| 246 |
+
r = C.compile_report("G", "A", window=WINDOW, offset=1)
|
| 247 |
+
p = _by_name(r)
|
| 248 |
+
assert p["verify"].status == "error"
|
| 249 |
+
assert p["enumerate"].status == "skipped"
|
| 250 |
+
assert not r.compiled
|
| 251 |
+
|
| 252 |
+
|
| 253 |
+
def test_no_window_makes_verify_unavailable_and_says_why():
|
| 254 |
+
r = C.compile_report("G", "A")
|
| 255 |
+
v = _by_name(r)["verify"]
|
| 256 |
+
assert v.status == "unavailable"
|
| 257 |
+
assert "off-by-one" in v.detail
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
def test_compiled_is_false_when_anything_was_skipped():
|
| 261 |
+
assert not C.compile_report("A", "A").compiled
|
| 262 |
+
|
| 263 |
+
|
| 264 |
+
def test_scope_travels_with_every_report():
|
| 265 |
+
r = C.compile_report("G", "A", window=WINDOW, offset=0)
|
| 266 |
+
assert "Somatic" in r.scope["application"]
|
| 267 |
+
assert "Not IND-ready" in r.scope["status"]
|
| 268 |
+
assert "Immunogenicity" in r.scope["silent_on"]
|
| 269 |
+
|
| 270 |
+
|
| 271 |
+
def test_compile_report_refuses_germline_before_running_any_pass():
|
| 272 |
+
with pytest.raises(C.GermlineRefused):
|
| 273 |
+
C.compile_report("G", "A", window=WINDOW, offset=0, germline=True)
|