Spaces:
Running
Running
github-actions[bot] commited on
Commit Β·
3d57a12
1
Parent(s): 30a9dc2
Deploy 69184b3
Browse filesMake the compiler design guides instead of describing the idea of guides
Source: https://github.com/WINTER4000/turingDNA/commit/69184b3031eab40cc6197d4e5820af5f5780d9b1
- dee/core/compiler.py +239 -9
- dee/server.py +13 -1
- dee/static/app.css +34 -0
- dee/static/app.js +31 -1
- dee/static/index.html +3 -2
- tests/test_compiler.py +116 -0
dee/core/compiler.py
CHANGED
|
@@ -393,6 +393,7 @@ class CompileReport:
|
|
| 393 |
lesion: Optional[LesionCall]
|
| 394 |
passes: List[Pass]
|
| 395 |
scope: Dict[str, str]
|
|
|
|
| 396 |
|
| 397 |
@property
|
| 398 |
def compiled(self) -> bool:
|
|
@@ -442,7 +443,8 @@ SCOPE = {
|
|
| 442 |
def compile_report(wt_allele: str, patient_allele: str, *,
|
| 443 |
window: str = "", offset: int = -1,
|
| 444 |
germline: bool = False,
|
| 445 |
-
|
|
|
|
| 446 |
consequence: Optional[Dict[str, object]] = None,
|
| 447 |
can_check_specificity: bool = False) -> CompileReport:
|
| 448 |
"""Run the passes and report every one, including those that could not run.
|
|
@@ -483,7 +485,7 @@ def compile_report(wt_allele: str, patient_allele: str, *,
|
|
| 483 |
f"{lesion.kind} β no route.", lesion.diagnostics)
|
| 484 |
for name, _ in PASS_ORDER[2:]:
|
| 485 |
add(name, "skipped", "An earlier pass refused.")
|
| 486 |
-
return CompileReport(lesion, passes, dict(SCOPE))
|
| 487 |
|
| 488 |
corr = lesion.correction
|
| 489 |
add("classify", "warn" if any(d.level == "warning" for d in lesion.diagnostics) else "ok",
|
|
@@ -500,7 +502,7 @@ def compile_report(wt_allele: str, patient_allele: str, *,
|
|
| 500 |
add("verify", "error", "Reference disagrees with the alleles.", new)
|
| 501 |
for name, _ in PASS_ORDER[3:]:
|
| 502 |
add(name, "skipped", "An earlier pass refused.")
|
| 503 |
-
return CompileReport(verified, passes, dict(SCOPE))
|
| 504 |
add("verify", "ok",
|
| 505 |
f"Reference has {corr.wt_base} at offset {offset}; the correction "
|
| 506 |
"reproduces it exactly.", new)
|
|
@@ -511,12 +513,26 @@ def compile_report(wt_allele: str, patient_allele: str, *,
|
|
| 511 |
"off-by-one still type-checks at the allele level.")
|
| 512 |
|
| 513 |
# ββ enumerate βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 514 |
-
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 518 |
else:
|
| 519 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 520 |
|
| 521 |
# ββ consequence βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 522 |
# Reports on the RESULT, never on the ability to have produced one.
|
|
@@ -555,7 +571,7 @@ def compile_report(wt_allele: str, patient_allele: str, *,
|
|
| 555 |
"Design record assembled with every pass, its status, and the "
|
| 556 |
"reasons for anything not run.")
|
| 557 |
|
| 558 |
-
return CompileReport(lesion, passes, dict(SCOPE))
|
| 559 |
|
| 560 |
|
| 561 |
def report_to_dict(report: CompileReport) -> Dict[str, object]:
|
|
@@ -568,6 +584,21 @@ def report_to_dict(report: CompileReport) -> Dict[str, object]:
|
|
| 568 |
lesion = report.lesion
|
| 569 |
corr = lesion.correction if lesion else None
|
| 570 |
return {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 571 |
"compiled": report.compiled,
|
| 572 |
"incomplete_because": report.incomplete_because,
|
| 573 |
"scope": report.scope,
|
|
@@ -591,3 +622,202 @@ def report_to_dict(report: CompileReport) -> Dict[str, object]:
|
|
| 591 |
for p in report.passes
|
| 592 |
],
|
| 593 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 393 |
lesion: Optional[LesionCall]
|
| 394 |
passes: List[Pass]
|
| 395 |
scope: Dict[str, str]
|
| 396 |
+
strategies: List["Strategy"] = field(default_factory=list)
|
| 397 |
|
| 398 |
@property
|
| 399 |
def compiled(self) -> bool:
|
|
|
|
| 443 |
def compile_report(wt_allele: str, patient_allele: str, *,
|
| 444 |
window: str = "", offset: int = -1,
|
| 445 |
germline: bool = False,
|
| 446 |
+
strategies: Optional[List["Strategy"]] = None,
|
| 447 |
+
enumerate_diags: Optional[List[Diagnostic]] = None,
|
| 448 |
consequence: Optional[Dict[str, object]] = None,
|
| 449 |
can_check_specificity: bool = False) -> CompileReport:
|
| 450 |
"""Run the passes and report every one, including those that could not run.
|
|
|
|
| 485 |
f"{lesion.kind} β no route.", lesion.diagnostics)
|
| 486 |
for name, _ in PASS_ORDER[2:]:
|
| 487 |
add(name, "skipped", "An earlier pass refused.")
|
| 488 |
+
return CompileReport(lesion, passes, dict(SCOPE), strategies or [])
|
| 489 |
|
| 490 |
corr = lesion.correction
|
| 491 |
add("classify", "warn" if any(d.level == "warning" for d in lesion.diagnostics) else "ok",
|
|
|
|
| 502 |
add("verify", "error", "Reference disagrees with the alleles.", new)
|
| 503 |
for name, _ in PASS_ORDER[3:]:
|
| 504 |
add(name, "skipped", "An earlier pass refused.")
|
| 505 |
+
return CompileReport(verified, passes, dict(SCOPE), strategies or [])
|
| 506 |
add("verify", "ok",
|
| 507 |
f"Reference has {corr.wt_base} at offset {offset}; the correction "
|
| 508 |
"reproduces it exactly.", new)
|
|
|
|
| 513 |
"off-by-one still type-checks at the allele level.")
|
| 514 |
|
| 515 |
# ββ enumerate βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 516 |
+
# Reports on GUIDES, not on the ability to look for them. The first
|
| 517 |
+
# version said "guides can be enumerated" and enumerated none, which made
|
| 518 |
+
# this a classifier wearing a compiler's clothes.
|
| 519 |
+
ediags = list(enumerate_diags or [])
|
| 520 |
+
if strategies is None:
|
| 521 |
+
add("enumerate", "unavailable", CAPABILITY_NOTES["enumerate"], ediags)
|
| 522 |
+
elif not strategies:
|
| 523 |
+
add("enumerate", "error",
|
| 524 |
+
"No guide places this base inside an editing window.", ediags)
|
| 525 |
+
for name, _ in PASS_ORDER[4:]:
|
| 526 |
+
add(name, "skipped", "No strategy to assess.")
|
| 527 |
+
return CompileReport(lesion, passes, dict(SCOPE), strategies or [])
|
| 528 |
else:
|
| 529 |
+
n_by = sum(len(s.bystanders) for s in strategies)
|
| 530 |
+
clean = sum(1 for s in strategies if s.clean)
|
| 531 |
+
add("enumerate",
|
| 532 |
+
"warn" if clean == 0 else "ok",
|
| 533 |
+
f"{len(strategies)} {corr.editor_family} guide(s) reach this base "
|
| 534 |
+
f"on the {corr.strand} strand; {clean} with no bystander, "
|
| 535 |
+
f"{n_by} bystander edit(s) across the set.", ediags)
|
| 536 |
|
| 537 |
# ββ consequence βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 538 |
# Reports on the RESULT, never on the ability to have produced one.
|
|
|
|
| 571 |
"Design record assembled with every pass, its status, and the "
|
| 572 |
"reasons for anything not run.")
|
| 573 |
|
| 574 |
+
return CompileReport(lesion, passes, dict(SCOPE), strategies or [])
|
| 575 |
|
| 576 |
|
| 577 |
def report_to_dict(report: CompileReport) -> Dict[str, object]:
|
|
|
|
| 584 |
lesion = report.lesion
|
| 585 |
corr = lesion.correction if lesion else None
|
| 586 |
return {
|
| 587 |
+
"strategies": [
|
| 588 |
+
{"rank": s.rank, "editor_id": s.editor_id,
|
| 589 |
+
"editor_family": s.editor_family, "strand": s.strand,
|
| 590 |
+
"position": s.position, "spacer": s.spacer, "pam": s.pam,
|
| 591 |
+
"target_spacer_pos": s.target_spacer_pos,
|
| 592 |
+
"target_activity": s.target_activity,
|
| 593 |
+
"on_target_score": s.on_target_score,
|
| 594 |
+
"composite_score": s.composite_score,
|
| 595 |
+
"clean": s.clean,
|
| 596 |
+
"bystanders": [
|
| 597 |
+
{"spacer_pos": b.spacer_pos, "offset": b.offset,
|
| 598 |
+
"from_base": b.from_base, "to_base": b.to_base,
|
| 599 |
+
"activity": b.activity} for b in s.bystanders]}
|
| 600 |
+
for s in (report.strategies or [])
|
| 601 |
+
],
|
| 602 |
"compiled": report.compiled,
|
| 603 |
"incomplete_because": report.incomplete_because,
|
| 604 |
"scope": report.scope,
|
|
|
|
| 622 |
for p in report.passes
|
| 623 |
],
|
| 624 |
}
|
| 625 |
+
|
| 626 |
+
|
| 627 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 628 |
+
# Enumerate: actual guides, not a promise of guides
|
| 629 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 630 |
+
# The first version of the enumerate pass reported "base-editing guides CAN
|
| 631 |
+
# be enumerated" and enumerated none, which made the whole compiler a
|
| 632 |
+
# classifier with ceremony. This composes crispr.find_guides and
|
| 633 |
+
# base_editor.predict_base_edits into real strategies.
|
| 634 |
+
#
|
| 635 |
+
# Still deterministic β no network, no GPU, no model β but no longer
|
| 636 |
+
# dependency-free, hence the lazy imports.
|
| 637 |
+
#
|
| 638 |
+
# The coordinate convention was established EMPIRICALLY, not assumed: the
|
| 639 |
+
# spacer footprint is always window[position-1 : position-1+len] on the
|
| 640 |
+
# forward strand, read directly for a '+' guide and reverse-complemented for
|
| 641 |
+
# a '-' guide. Verified over 160 spacer positions across both strands with
|
| 642 |
+
# zero mismatches before anything was built on it, because guessing here
|
| 643 |
+
# designs a guide against the wrong strand.
|
| 644 |
+
|
| 645 |
+
@dataclass
|
| 646 |
+
class Bystander:
|
| 647 |
+
"""A base the editor will also change, because it sits in the window."""
|
| 648 |
+
spacer_pos: int # 1-based, 5'->3' along the spacer
|
| 649 |
+
offset: int # 0-based on the forward strand β what Evo 2 needs
|
| 650 |
+
from_base: str # on the EDITED strand
|
| 651 |
+
to_base: str
|
| 652 |
+
activity: float
|
| 653 |
+
|
| 654 |
+
|
| 655 |
+
@dataclass
|
| 656 |
+
class Strategy:
|
| 657 |
+
rank: int
|
| 658 |
+
editor_id: str
|
| 659 |
+
editor_family: str
|
| 660 |
+
strand: str # '+' sense | '-' antisense
|
| 661 |
+
position: int # 1-based forward start of the spacer footprint
|
| 662 |
+
spacer: str
|
| 663 |
+
pam: str
|
| 664 |
+
target_spacer_pos: int
|
| 665 |
+
target_activity: float
|
| 666 |
+
on_target_score: float
|
| 667 |
+
composite_score: float
|
| 668 |
+
bystanders: List[Bystander] = field(default_factory=list)
|
| 669 |
+
|
| 670 |
+
@property
|
| 671 |
+
def clean(self) -> bool:
|
| 672 |
+
return not self.bystanders
|
| 673 |
+
|
| 674 |
+
|
| 675 |
+
def spacer_pos_to_offset(position: int, strand: str, spacer_len: int,
|
| 676 |
+
spacer_pos: int) -> int:
|
| 677 |
+
"""1-based spacer index -> 0-based forward-strand offset.
|
| 678 |
+
|
| 679 |
+
Separate and named because it is the single most dangerous line here.
|
| 680 |
+
A '-' guide's spacer runs antiparallel: its 5' base is the LAST base of
|
| 681 |
+
the forward footprint, so the index has to be mirrored.
|
| 682 |
+
"""
|
| 683 |
+
start = position - 1
|
| 684 |
+
if strand == "+":
|
| 685 |
+
return start + (spacer_pos - 1)
|
| 686 |
+
return start + (spacer_len - spacer_pos)
|
| 687 |
+
|
| 688 |
+
|
| 689 |
+
def plan_base_edit_strategies(window: str, target_offset: int,
|
| 690 |
+
corr: Correction, *, editor_id: str = "",
|
| 691 |
+
max_results: int = 6
|
| 692 |
+
) -> Tuple[List[Strategy], List[Diagnostic]]:
|
| 693 |
+
"""Guides that put the target base in the editing window, right strand.
|
| 694 |
+
|
| 695 |
+
`window` is WILD-TYPE reference. Guides are designed against the
|
| 696 |
+
PATIENT'S sequence, which this function derives β and that distinction is
|
| 697 |
+
the whole ballgame. An ABE has to find an A to convert; if you search the
|
| 698 |
+
wild-type reference for an ABE correction you are looking at the G that is
|
| 699 |
+
already correct, and every search returns nothing. The first version of
|
| 700 |
+
this function did exactly that and reported "no guide places this base in
|
| 701 |
+
an editing window" for lesions that are perfectly editable.
|
| 702 |
+
|
| 703 |
+
Returns (strategies, diagnostics). An empty list with a diagnostic is a
|
| 704 |
+
real answer β "no guide places this base in any editing window" is the
|
| 705 |
+
single most common reason a base-editable lesion is still not treatable,
|
| 706 |
+
and it is caused by PAM availability, which nothing can argue with.
|
| 707 |
+
"""
|
| 708 |
+
from dee.core import base_editor as _be
|
| 709 |
+
from dee.core import crispr as _crispr
|
| 710 |
+
|
| 711 |
+
diags: List[Diagnostic] = []
|
| 712 |
+
want_strand = "+" if corr.strand == "sense" else "-"
|
| 713 |
+
|
| 714 |
+
if not window or not (0 <= target_offset < len(window)):
|
| 715 |
+
diags.append(Diagnostic(
|
| 716 |
+
"error", "target_outside_window",
|
| 717 |
+
f"Target offset {target_offset} is outside the "
|
| 718 |
+
f"{len(window)}-base window.", "Check the coordinate."))
|
| 719 |
+
return [], diags
|
| 720 |
+
if window[target_offset].upper() != corr.wt_base:
|
| 721 |
+
diags.append(Diagnostic(
|
| 722 |
+
"error", "window_is_not_wildtype",
|
| 723 |
+
f"The window has {window[target_offset]!r} at the target, but the "
|
| 724 |
+
f"wild-type allele is {corr.wt_base!r}.",
|
| 725 |
+
"Pass the WILD-TYPE reference; the patient sequence is derived "
|
| 726 |
+
"from it here."))
|
| 727 |
+
return [], diags
|
| 728 |
+
|
| 729 |
+
# The sequence the editor actually sees.
|
| 730 |
+
patient_seq = (window[:target_offset] + corr.patient_base
|
| 731 |
+
+ window[target_offset + 1:])
|
| 732 |
+
|
| 733 |
+
if not editor_id:
|
| 734 |
+
# Default to the first editor of the required family. Not an
|
| 735 |
+
# allow-list: the caller can name any editor the catalogue knows.
|
| 736 |
+
fam = [e for e in _be.list_base_editors(corr.editor_family)]
|
| 737 |
+
if not fam:
|
| 738 |
+
diags.append(Diagnostic(
|
| 739 |
+
"error", "no_editor_for_family",
|
| 740 |
+
f"No {corr.editor_family} is registered in the editor catalogue.",
|
| 741 |
+
"Add one to dee/core/base_editor.py."))
|
| 742 |
+
return [], diags
|
| 743 |
+
editor_id = fam[0].id
|
| 744 |
+
|
| 745 |
+
ed = _be.get_base_editor(editor_id)
|
| 746 |
+
if ed is None:
|
| 747 |
+
diags.append(Diagnostic("error", "unknown_editor",
|
| 748 |
+
f"Unknown base editor {editor_id!r}.",
|
| 749 |
+
"Pick one from the catalogue."))
|
| 750 |
+
return [], diags
|
| 751 |
+
if ed.kind != corr.editor_family:
|
| 752 |
+
diags.append(Diagnostic(
|
| 753 |
+
"error", "editor_family_mismatch",
|
| 754 |
+
f"{editor_id} is a {ed.kind}; this correction needs a "
|
| 755 |
+
f"{corr.editor_family}.",
|
| 756 |
+
f"{ed.kind} writes {ed.target_base}>{ed.result_base}, which does "
|
| 757 |
+
"not make this change."))
|
| 758 |
+
return [], diags
|
| 759 |
+
|
| 760 |
+
try:
|
| 761 |
+
guides = _crispr.find_guides(patient_seq, mode="base_edit",
|
| 762 |
+
base_editor=editor_id, max_results=200)
|
| 763 |
+
except ValueError as exc:
|
| 764 |
+
diags.append(Diagnostic(
|
| 765 |
+
"error", "no_guides_possible", str(exc),
|
| 766 |
+
"A base editor needs a PAM at a fixed distance from the target. "
|
| 767 |
+
"Supply a longer reference window so more PAMs are in range."))
|
| 768 |
+
return [], diags
|
| 769 |
+
|
| 770 |
+
out: List[Strategy] = []
|
| 771 |
+
for g in guides:
|
| 772 |
+
if g.strand != want_strand:
|
| 773 |
+
continue
|
| 774 |
+
pred = _be.predict_base_edits(g.spacer, editor_id)
|
| 775 |
+
if not pred.edits:
|
| 776 |
+
continue
|
| 777 |
+
hit = None
|
| 778 |
+
rest: List[Bystander] = []
|
| 779 |
+
for p, fb, tb, act in pred.edits:
|
| 780 |
+
off = spacer_pos_to_offset(g.position, g.strand, len(g.spacer), p)
|
| 781 |
+
if off == target_offset:
|
| 782 |
+
hit = (p, act)
|
| 783 |
+
else:
|
| 784 |
+
rest.append(Bystander(p, off, fb, tb, round(act, 3)))
|
| 785 |
+
if hit is None:
|
| 786 |
+
continue # this guide edits, but not the base we care about
|
| 787 |
+
out.append(Strategy(
|
| 788 |
+
rank=0, editor_id=editor_id, editor_family=ed.kind,
|
| 789 |
+
strand=g.strand, position=g.position, spacer=g.spacer, pam=g.pam,
|
| 790 |
+
target_spacer_pos=hit[0], target_activity=round(hit[1], 3),
|
| 791 |
+
on_target_score=round(g.on_target_score, 3),
|
| 792 |
+
composite_score=round(g.composite_score, 3),
|
| 793 |
+
bystanders=rest))
|
| 794 |
+
|
| 795 |
+
if not out:
|
| 796 |
+
diags.append(Diagnostic(
|
| 797 |
+
"error", "no_guide_places_target_in_window",
|
| 798 |
+
f"No {editor_id} guide on the {corr.strand} strand puts this base "
|
| 799 |
+
f"inside the editing window (positions {ed.window[0]}-{ed.window[1]}).",
|
| 800 |
+
"This is a PAM-availability limit, not a scoring threshold: a base "
|
| 801 |
+
"editor can only reach bases at a fixed distance from an NGG. Try "
|
| 802 |
+
"another editor whose window sits differently, a wider reference "
|
| 803 |
+
"window, or a different Cas variant."))
|
| 804 |
+
return [], diags
|
| 805 |
+
|
| 806 |
+
# Rank by the editor's activity at the TARGET base first. A guide that
|
| 807 |
+
# edits the right base weakly is worse than one that edits it strongly,
|
| 808 |
+
# regardless of how the generic on-target heuristic scores the spacer.
|
| 809 |
+
out.sort(key=lambda s: (-s.target_activity, len(s.bystanders),
|
| 810 |
+
-s.composite_score))
|
| 811 |
+
for i, s in enumerate(out[:max_results], 1):
|
| 812 |
+
s.rank = i
|
| 813 |
+
|
| 814 |
+
n_clean = sum(1 for s in out[:max_results] if s.clean)
|
| 815 |
+
if n_clean == 0:
|
| 816 |
+
diags.append(Diagnostic(
|
| 817 |
+
"warning", "every_guide_has_bystanders",
|
| 818 |
+
"Every guide that reaches this base also edits at least one other "
|
| 819 |
+
"base in its window.",
|
| 820 |
+
"Bystanders are not automatically benign. Score them before "
|
| 821 |
+
"choosing β a silent or intronic bystander in a regulatory "
|
| 822 |
+
"element is exactly the case nothing else checks."))
|
| 823 |
+
return out[:max_results], diags
|
dee/server.py
CHANGED
|
@@ -3484,11 +3484,23 @@ def create_app() -> Flask:
|
|
| 3484 |
if body.get("score_consequence"):
|
| 3485 |
consequence = _score_variant_consequence(wt, patient, window, offset)
|
| 3486 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3487 |
try:
|
| 3488 |
report = _compiler.compile_report(
|
| 3489 |
wt, patient, window=window, offset=offset,
|
| 3490 |
germline=bool(body.get("germline")),
|
| 3491 |
-
|
| 3492 |
consequence=consequence,
|
| 3493 |
can_check_specificity=False,
|
| 3494 |
)
|
|
|
|
| 3484 |
if body.get("score_consequence"):
|
| 3485 |
consequence = _score_variant_consequence(wt, patient, window, offset)
|
| 3486 |
|
| 3487 |
+
# Actually design guides. Cheap, deterministic, no network.
|
| 3488 |
+
strategies, enum_diags = None, None
|
| 3489 |
+
call = _compiler.classify_lesion(wt, patient)
|
| 3490 |
+
if call.correction is not None and window and 0 <= offset < len(window):
|
| 3491 |
+
try:
|
| 3492 |
+
strategies, enum_diags = _compiler.plan_base_edit_strategies(
|
| 3493 |
+
window, offset, call.correction,
|
| 3494 |
+
editor_id=str(body.get("editor") or ""))
|
| 3495 |
+
except Exception: # noqa: BLE001
|
| 3496 |
+
app.logger.exception("guide enumeration failed")
|
| 3497 |
+
strategies, enum_diags = None, None
|
| 3498 |
+
|
| 3499 |
try:
|
| 3500 |
report = _compiler.compile_report(
|
| 3501 |
wt, patient, window=window, offset=offset,
|
| 3502 |
germline=bool(body.get("germline")),
|
| 3503 |
+
strategies=strategies, enumerate_diags=enum_diags,
|
| 3504 |
consequence=consequence,
|
| 3505 |
can_check_specificity=False,
|
| 3506 |
)
|
dee/static/app.css
CHANGED
|
@@ -9961,3 +9961,37 @@ body.de-agent-run .dna-edit-actions { display: none; }
|
|
| 9961 |
.tc-scope p, .tc-pass-detail, .tc-diag-msg, .tc-diag-remedy,
|
| 9962 |
.tc-locus-note, .tc-chip { font-size: 12px; }
|
| 9963 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9961 |
.tc-scope p, .tc-pass-detail, .tc-diag-msg, .tc-diag-remedy,
|
| 9962 |
.tc-locus-note, .tc-chip { font-size: 12px; }
|
| 9963 |
}
|
| 9964 |
+
|
| 9965 |
+
/* Guides the compiler actually designed β the difference between a
|
| 9966 |
+
classifier and a compiler. */
|
| 9967 |
+
.tc-strats { margin-top: 20px; }
|
| 9968 |
+
.tc-strat {
|
| 9969 |
+
padding: 12px 14px; border-radius: var(--r-2); margin-bottom: 9px;
|
| 9970 |
+
border: 1px solid var(--line); background: var(--gray-1);
|
| 9971 |
+
}
|
| 9972 |
+
.tc-strat.is-clean { border-color: color-mix(in srgb, var(--success) 45%, var(--line)); }
|
| 9973 |
+
.tc-strat-top { display: flex; align-items: center; gap: 9px; flex-wrap: wrap; }
|
| 9974 |
+
.tc-strat-rank {
|
| 9975 |
+
width: 19px; height: 19px; border-radius: 50%; flex-shrink: 0;
|
| 9976 |
+
display: inline-flex; align-items: center; justify-content: center;
|
| 9977 |
+
font-size: 11px; background: var(--ink-strong); color: var(--on-ink);
|
| 9978 |
+
}
|
| 9979 |
+
.tc-strat-spacer { font-size: 13px; letter-spacing: .04em; color: var(--ink-strong); }
|
| 9980 |
+
.tc-strat-pam {
|
| 9981 |
+
font-size: 12px; padding: 1px 6px; border-radius: 3px;
|
| 9982 |
+
background: color-mix(in srgb, var(--warning) 18%, transparent); color: var(--warning);
|
| 9983 |
+
}
|
| 9984 |
+
.tc-strat-meta { font-size: 11.5px; color: var(--ink-faint); margin-top: 6px; }
|
| 9985 |
+
.tc-bys { margin-top: 9px; display: flex; gap: 7px; flex-wrap: wrap; align-items: baseline; }
|
| 9986 |
+
.tc-bys--none { font-size: 12px; color: var(--success); }
|
| 9987 |
+
.tc-bys-hd { font-size: 10.5px; text-transform: uppercase; letter-spacing: .07em; color: var(--warning); }
|
| 9988 |
+
.tc-by {
|
| 9989 |
+
font-size: 11.5px; font-family: var(--font-mono);
|
| 9990 |
+
padding: 2px 7px; border-radius: 3px;
|
| 9991 |
+
background: color-mix(in srgb, var(--warning) 12%, transparent); color: var(--ink);
|
| 9992 |
+
}
|
| 9993 |
+
.tc-by-off { color: var(--ink-faint); }
|
| 9994 |
+
.tc-bys-note { flex-basis: 100%; margin: 4px 0 0; font-size: 11.5px; color: var(--ink-faint); line-height: 1.5; }
|
| 9995 |
+
@media (max-width: 720px) {
|
| 9996 |
+
.tc-strat-meta, .tc-by, .tc-bys-note, .tc-bys--none { font-size: 12px; }
|
| 9997 |
+
}
|
dee/static/app.js
CHANGED
|
@@ -12411,7 +12411,36 @@ if (document.readyState === 'loading') {
|
|
| 12411 |
});
|
| 12412 |
}
|
| 12413 |
|
| 12414 |
-
function
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12415 |
const host = $('tcDiags');
|
| 12416 |
if (!host) return;
|
| 12417 |
const all = [];
|
|
@@ -12464,6 +12493,7 @@ if (document.readyState === 'loading') {
|
|
| 12464 |
renderVerdict(data);
|
| 12465 |
renderLocus(win, isNaN(offset) ? -1 : offset, data.correction);
|
| 12466 |
renderPasses(data.passes || []);
|
|
|
|
| 12467 |
renderDiags(data.passes || []);
|
| 12468 |
$('tcResultCard').hidden = false;
|
| 12469 |
} catch (err) {
|
|
|
|
| 12411 |
});
|
| 12412 |
}
|
| 12413 |
|
| 12414 |
+
function renderStrategies(list) {
|
| 12415 |
+
const host = $('tcStrategies');
|
| 12416 |
+
if (!host) return;
|
| 12417 |
+
if (!list || !list.length) { host.hidden = true; host.innerHTML = ''; return; }
|
| 12418 |
+
host.hidden = false;
|
| 12419 |
+
host.innerHTML = '<h3 class="tc-diags-hd">Guides that reach this base</h3>'
|
| 12420 |
+
+ list.map((s) => {
|
| 12421 |
+
const by = (s.bystanders || []).map((b) =>
|
| 12422 |
+
`<span class="tc-by">pos ${b.spacer_pos} · ${esc(b.from_base)}→${esc(b.to_base)}`
|
| 12423 |
+
+ ` <span class="tc-by-off">offset ${b.offset}</span></span>`).join('');
|
| 12424 |
+
return `<div class="tc-strat${s.clean ? ' is-clean' : ''}">`
|
| 12425 |
+
+ `<div class="tc-strat-top">`
|
| 12426 |
+
+ `<span class="tc-strat-rank">${s.rank}</span>`
|
| 12427 |
+
+ `<code class="tc-strat-spacer mono">${esc(s.spacer)}</code>`
|
| 12428 |
+
+ `<span class="tc-strat-pam mono">${esc(s.pam)}</span>`
|
| 12429 |
+
+ `<span class="tc-chip">${esc(s.editor_id)}</span>`
|
| 12430 |
+
+ `<span class="tc-chip">${s.strand === '+' ? 'sense' : 'antisense'}</span>`
|
| 12431 |
+
+ `</div>`
|
| 12432 |
+
+ `<div class="tc-strat-meta">target at spacer position ${s.target_spacer_pos}`
|
| 12433 |
+
+ ` · activity ${s.target_activity} · on-target ${s.on_target_score}</div>`
|
| 12434 |
+
+ (by
|
| 12435 |
+
? `<div class="tc-bys"><span class="tc-bys-hd">Bystander edits</span>${by}`
|
| 12436 |
+
+ `<p class="tc-bys-note">Also changed by this guide. Not automatically benign — `
|
| 12437 |
+
+ `a bystander in a regulatory element is exactly the case nothing else checks.</p></div>`
|
| 12438 |
+
: `<div class="tc-bys tc-bys--none">No bystander edits in this guide's window.</div>`)
|
| 12439 |
+
+ `</div>`;
|
| 12440 |
+
}).join('');
|
| 12441 |
+
}
|
| 12442 |
+
|
| 12443 |
+
function renderDiags(passes) {
|
| 12444 |
const host = $('tcDiags');
|
| 12445 |
if (!host) return;
|
| 12446 |
const all = [];
|
|
|
|
| 12493 |
renderVerdict(data);
|
| 12494 |
renderLocus(win, isNaN(offset) ? -1 : offset, data.correction);
|
| 12495 |
renderPasses(data.passes || []);
|
| 12496 |
+
renderStrategies(data.strategies || []);
|
| 12497 |
renderDiags(data.passes || []);
|
| 12498 |
$('tcResultCard').hidden = false;
|
| 12499 |
} catch (err) {
|
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. -->
|
|
@@ -1353,6 +1353,7 @@
|
|
| 1353 |
<div class="tc-verdict" id="tcVerdict"></div>
|
| 1354 |
<div class="tc-locus" id="tcLocus" hidden></div>
|
| 1355 |
<ol class="tc-passes" id="tcPasses"></ol>
|
|
|
|
| 1356 |
<div class="tc-diags" id="tcDiags"></div>
|
| 1357 |
</section>
|
| 1358 |
</section>
|
|
@@ -2843,7 +2844,7 @@
|
|
| 2843 |
<!-- Cloning reference data must load before app.js so the Designer
|
| 2844 |
can read VECTORS / ENZYMES / CLONING_METHODS / TAGS / LINKERS. -->
|
| 2845 |
<script src="/static/cloning_db.js?v=20260530-ui-polish" defer></script>
|
| 2846 |
-
<script src="/static/app.js?v=20260812-
|
| 2847 |
<!-- The decision trace, BEFORE cockpit.js: applyEvent calls TDTrace.push
|
| 2848 |
on the very first event, and both are `defer`, so document order is
|
| 2849 |
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-tc3" />
|
| 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. -->
|
|
|
|
| 1353 |
<div class="tc-verdict" id="tcVerdict"></div>
|
| 1354 |
<div class="tc-locus" id="tcLocus" hidden></div>
|
| 1355 |
<ol class="tc-passes" id="tcPasses"></ol>
|
| 1356 |
+
<div class="tc-strats" id="tcStrategies" hidden></div>
|
| 1357 |
<div class="tc-diags" id="tcDiags"></div>
|
| 1358 |
</section>
|
| 1359 |
</section>
|
|
|
|
| 2844 |
<!-- Cloning reference data must load before app.js so the Designer
|
| 2845 |
can read VECTORS / ENZYMES / CLONING_METHODS / TAGS / LINKERS. -->
|
| 2846 |
<script src="/static/cloning_db.js?v=20260530-ui-polish" defer></script>
|
| 2847 |
+
<script src="/static/app.js?v=20260812-tc3" defer></script>
|
| 2848 |
<!-- The decision trace, BEFORE cockpit.js: applyEvent calls TDTrace.push
|
| 2849 |
on the very first event, and both are `defer`, so document order is
|
| 2850 |
load order. Loading it after would drop the opening events of a
|
tests/test_compiler.py
CHANGED
|
@@ -315,3 +315,119 @@ def test_scope_travels_with_every_report():
|
|
| 315 |
def test_compile_report_refuses_germline_before_running_any_pass():
|
| 316 |
with pytest.raises(C.GermlineRefused):
|
| 317 |
C.compile_report("G", "A", window=WINDOW, offset=0, germline=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 315 |
def test_compile_report_refuses_germline_before_running_any_pass():
|
| 316 |
with pytest.raises(C.GermlineRefused):
|
| 317 |
C.compile_report("G", "A", window=WINDOW, offset=0, germline=True)
|
| 318 |
+
|
| 319 |
+
|
| 320 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 321 |
+
# Enumerate: real guides. The pass used to promise guides and produce none.
|
| 322 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 323 |
+
HBB = ("CCTGAGGAGAAGGCTGCCGTCACCGCCCTGTGGGGCAAGGTGAACGTGGATGAAGTTGGTGGTGAGG"
|
| 324 |
+
"CCCTGGGCAGGCTGCTGGTGGTCTACCCTTGGACCCAGAGGTTCTTTGAGTCC")
|
| 325 |
+
COMPL = {"A": "T", "T": "A", "C": "G", "G": "C"}
|
| 326 |
+
|
| 327 |
+
|
| 328 |
+
def test_spacer_index_maps_to_the_forward_strand_on_both_strands():
|
| 329 |
+
"""The single most dangerous line in the planner. A '-' guide's spacer runs
|
| 330 |
+
antiparallel, so its 5' base is the LAST base of the forward footprint."""
|
| 331 |
+
from dee.core import crispr
|
| 332 |
+
guides = crispr.find_guides(HBB, mode="base_edit", base_editor="be4max",
|
| 333 |
+
max_results=8)
|
| 334 |
+
checked = 0
|
| 335 |
+
for g in guides:
|
| 336 |
+
for pos in range(1, len(g.spacer) + 1):
|
| 337 |
+
off = C.spacer_pos_to_offset(g.position, g.strand, len(g.spacer), pos)
|
| 338 |
+
got = HBB[off] if g.strand == "+" else COMPL[HBB[off]]
|
| 339 |
+
assert got == g.spacer[pos - 1], (
|
| 340 |
+
f"{g.strand} guide at {g.position}, spacer pos {pos}")
|
| 341 |
+
checked += 1
|
| 342 |
+
assert checked > 100, "should have exercised both strands thoroughly"
|
| 343 |
+
|
| 344 |
+
|
| 345 |
+
def test_guides_are_designed_against_the_patient_sequence_not_the_reference():
|
| 346 |
+
"""The bug that made enumerate return nothing for every lesion: an ABE has
|
| 347 |
+
to find an A to convert, and the wild-type reference has the correct G
|
| 348 |
+
there. Searching the reference finds nothing, forever."""
|
| 349 |
+
corr = C.classify_lesion("G", "A").correction # ABE, sense
|
| 350 |
+
found = [off for off in range(len(HBB))
|
| 351 |
+
if HBB[off] == "G"
|
| 352 |
+
and C.plan_base_edit_strategies(HBB, off, corr)[0]]
|
| 353 |
+
assert found, "at least one G must be reachable by an ABE guide"
|
| 354 |
+
|
| 355 |
+
|
| 356 |
+
def test_a_strategy_carries_a_real_spacer_pam_and_editor():
|
| 357 |
+
corr = C.classify_lesion("G", "A").correction
|
| 358 |
+
off = next(o for o in range(len(HBB))
|
| 359 |
+
if HBB[o] == "G" and C.plan_base_edit_strategies(HBB, o, corr)[0])
|
| 360 |
+
strategies, _ = C.plan_base_edit_strategies(HBB, off, corr)
|
| 361 |
+
s = strategies[0]
|
| 362 |
+
assert len(s.spacer) == 20 and set(s.spacer) <= set("ACGT")
|
| 363 |
+
assert s.pam and s.editor_family == "ABE"
|
| 364 |
+
assert s.strand == "+", "sense correction must engage the sense strand"
|
| 365 |
+
assert 1 <= s.target_spacer_pos <= 20
|
| 366 |
+
|
| 367 |
+
|
| 368 |
+
def test_bystanders_carry_forward_strand_offsets_so_they_can_be_scored():
|
| 369 |
+
"""A bystander without a genomic coordinate cannot be handed to Evo 2,
|
| 370 |
+
which is the entire point of collecting them."""
|
| 371 |
+
corr = C.classify_lesion("G", "A").correction
|
| 372 |
+
for off in range(len(HBB)):
|
| 373 |
+
if HBB[off] != "G":
|
| 374 |
+
continue
|
| 375 |
+
strategies, _ = C.plan_base_edit_strategies(HBB, off, corr)
|
| 376 |
+
for s in strategies:
|
| 377 |
+
for b in s.bystanders:
|
| 378 |
+
assert 0 <= b.offset < len(HBB)
|
| 379 |
+
assert b.offset != off, "the target is not a bystander"
|
| 380 |
+
assert b.from_base == "A" and b.to_base == "G"
|
| 381 |
+
|
| 382 |
+
|
| 383 |
+
def test_an_editor_of_the_wrong_family_is_refused():
|
| 384 |
+
corr = C.classify_lesion("G", "A").correction # needs ABE
|
| 385 |
+
strategies, diags = C.plan_base_edit_strategies(HBB, 12, corr,
|
| 386 |
+
editor_id="be4max")
|
| 387 |
+
assert not strategies
|
| 388 |
+
assert diags[0].code == "editor_family_mismatch"
|
| 389 |
+
assert "C>T" in diags[0].remedy
|
| 390 |
+
|
| 391 |
+
|
| 392 |
+
def test_a_window_that_is_not_wildtype_is_refused():
|
| 393 |
+
corr = C.classify_lesion("G", "A").correction
|
| 394 |
+
off = HBB.index("A")
|
| 395 |
+
strategies, diags = C.plan_base_edit_strategies(HBB, off, corr)
|
| 396 |
+
assert not strategies
|
| 397 |
+
assert diags[0].code == "window_is_not_wildtype"
|
| 398 |
+
|
| 399 |
+
|
| 400 |
+
def test_unreachable_target_says_it_is_a_pam_limit_not_a_score_threshold():
|
| 401 |
+
corr = C.classify_lesion("G", "A").correction
|
| 402 |
+
for off in range(len(HBB)):
|
| 403 |
+
if HBB[off] != "G":
|
| 404 |
+
continue
|
| 405 |
+
strategies, diags = C.plan_base_edit_strategies(HBB, off, corr)
|
| 406 |
+
if not strategies and diags:
|
| 407 |
+
assert diags[0].code == "no_guide_places_target_in_window"
|
| 408 |
+
assert "PAM-availability" in diags[0].remedy
|
| 409 |
+
return
|
| 410 |
+
pytest.skip("every G in this fixture happens to be reachable")
|
| 411 |
+
|
| 412 |
+
|
| 413 |
+
def test_the_enumerate_pass_reports_guides_not_a_promise_of_guides():
|
| 414 |
+
corr = C.classify_lesion("G", "A").correction
|
| 415 |
+
off = next(o for o in range(len(HBB))
|
| 416 |
+
if HBB[o] == "G" and C.plan_base_edit_strategies(HBB, o, corr)[0])
|
| 417 |
+
st, dg = C.plan_base_edit_strategies(HBB, off, corr)
|
| 418 |
+
r = C.compile_report("G", "A", window=HBB, offset=off,
|
| 419 |
+
strategies=st, enumerate_diags=dg)
|
| 420 |
+
p = _by_name(r)["enumerate"]
|
| 421 |
+
assert p.status in ("ok", "warn")
|
| 422 |
+
assert "guide(s) reach this base" in p.detail
|
| 423 |
+
assert r.strategies, "the report carries the actual designs"
|
| 424 |
+
|
| 425 |
+
|
| 426 |
+
def test_no_reachable_guide_stops_the_build_rather_than_emitting_a_record():
|
| 427 |
+
# A real G, so `verify` passes and `enumerate` is the pass that refuses.
|
| 428 |
+
off = HBB.index("G")
|
| 429 |
+
r = C.compile_report("G", "A", window=HBB, offset=off, strategies=[])
|
| 430 |
+
p = _by_name(r)
|
| 431 |
+
assert p["enumerate"].status == "error"
|
| 432 |
+
assert p["emit"].status == "skipped"
|
| 433 |
+
assert not r.compiled
|