github-actions[bot] commited on
Commit
62335fa
Β·
1 Parent(s): e81d49b

Deploy 07ea2d8

Browse files

Therapeutic compiler: lower a variant to an edit, or refuse and say why

Source: https://github.com/WINTER4000/turingDNA/commit/07ea2d8cfbde99036b0be78acd5954dc05976393

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: '&#10003;', warn: '!', error: '&#10005;',
12313
+ unavailable: '&#8212;', skipped: '&#183;',
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&rarr;G (ABE) `
12363
+ + `or C&rarr;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)} &middot; ${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-dna2" />
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-dna2" defer></script>
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)