github-actions[bot] commited on
Commit
3d57a12
Β·
1 Parent(s): 30a9dc2

Deploy 69184b3

Browse files

Make the compiler design guides instead of describing the idea of guides

Source: https://github.com/WINTER4000/turingDNA/commit/69184b3031eab40cc6197d4e5820af5f5780d9b1

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
- can_enumerate: bool = True,
 
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
- if can_enumerate:
515
- add("enumerate", "ok",
516
- f"Base-editing guides can be enumerated for a {corr.editor_family} "
517
- f"edit on the {corr.strand} strand.")
 
 
 
 
 
 
 
 
518
  else:
519
- add("enumerate", "unavailable", CAPABILITY_NOTES["enumerate"])
 
 
 
 
 
 
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
- can_enumerate=True,
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 renderDiags(passes) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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} &middot; ${esc(b.from_base)}&rarr;${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
+ + ` &middot; activity ${s.target_activity} &middot; 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 &mdash; `
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-tc2" />
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-tc2" defer></script>
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