syntheogenesis / tests /test_base_editor.py
Tengo Gzirishvili
CRISPR M1: base-editing design mode (CBE/ABE + iSTOP + AA consequence)
dd2d535
Raw
History Blame Contribute Delete
7.76 kB
"""Base-editing design (dee/core/base_editor.py).
Pins the editor library, sequence-only edit prediction (incl. bystander
detection), and β€” most importantly β€” the frame-aware codon consequence,
which is what tells a customer "this guide installs a premature stop".
A wrong AA call here would be as damaging as a wrong off-target, so the
codon math is tested against hand-verified examples and is required to
return None rather than guess when the alignment doesn't check out.
"""
import pytest
from dee.core import base_editor as BE
# ─────────────────────────── editor library ───────────────────────────
def test_library_shape():
be4 = BE.get_base_editor("be4max")
assert be4.kind == "CBE" and be4.target_base == "C" and be4.result_base == "T"
assert be4.window == (4, 8)
abe = BE.get_base_editor("abe7.10")
assert abe.kind == "ABE" and abe.target_base == "A" and abe.result_base == "G"
assert BE.get_base_editor("nope") is None
def test_list_filters_by_kind():
assert {e.id for e in BE.list_base_editors("CBE")} == {"be4max", "be3", "evocda_be4max"}
assert {e.id for e in BE.list_base_editors("ABE")} == {"abe8e", "abe7.10"}
def test_activity_profiles_bounded_and_peaked():
for ed in BE.list_base_editors():
lo, hi = ed.window
assert set(ed.activity) == set(range(lo, hi + 1))
assert all(0.0 <= v <= 1.0 for v in ed.activity.values())
assert max(ed.activity.values()) == pytest.approx(1.0)
# ─────────────────────────── edit prediction ───────────────────────────
def test_single_cbe_edit_in_window():
spacer = "AAAC" + "A" * 16 # C at position 4 only
p = BE.predict_base_edits(spacer, "be4max")
assert p.n_targets == 1
assert p.edits[0][:3] == (4, "C", "T")
assert p.editability == pytest.approx(0.35) # window edge (pos 4)
assert p.has_bystander is False
assert p.summary == "C4β†’T"
def test_bystander_flagged_and_editability_is_strongest():
spacer = "AAACA" + "C" + "A" * 14 # C at positions 4 and 6
p = BE.predict_base_edits(spacer, "be4max")
assert p.n_targets == 2
assert p.has_bystander is True
assert p.editability == pytest.approx(1.0) # pos 6 is the window peak
assert "bystander" in p.summary
def test_no_target_in_window():
p = BE.predict_base_edits("A" * 20, "be4max")
assert p.n_targets == 0 and p.editability == 0.0
assert "no C" in p.summary
def test_abe_targets_adenine():
spacer = "GGGA" + "G" * 16 # A at position 4
p = BE.predict_base_edits(spacer, "abe7.10")
assert p.n_targets == 1
assert p.edits[0][:3] == (4, "A", "G")
def test_unknown_editor_returns_empty():
p = BE.predict_base_edits("ACGT" * 5, "not_an_editor")
assert p.n_targets == 0 and p.edits == []
# ─────────────────────────── codon consequence ───────────────────────────
def test_istop_cga_to_tga():
# CGA (Arg) --C→T at codon position 1--> TGA (stop). The CRISPR-STOP move.
c = BE.codon_consequence("CGA" + "GGG", 0, "C", "T")
assert c.creates_stop is True
assert c.kind == "nonsense"
assert (c.aa_before, c.aa_after) == ("R", "*")
assert c.label == "R1* (stop gained)"
@pytest.mark.parametrize("codon,aa", [("CAA", "Q"), ("CAG", "Q"), ("CGA", "R")])
def test_all_cbe_istop_codons(codon, aa):
# The three codons CBE can turn into a stop by a single C→T.
c = BE.codon_consequence(codon + "TTT", 0, "C", "T")
assert c.creates_stop and c.aa_before == aa and c.aa_after == "*"
def test_silent_edit():
# GCT (Ala) --T→C at position 3--> GCC (still Ala).
c = BE.codon_consequence("GCT" + "AAA", 2, "T", "C")
assert c.kind == "silent" and c.aa_before == c.aa_after == "A"
assert "silent" in c.label
def test_missense_edit():
# GCT (Ala) --G→A at position 1--> ACT (Thr).
c = BE.codon_consequence("GCT" + "AAA", 0, "G", "A")
assert c.kind == "missense" and (c.aa_before, c.aa_after) == ("A", "T")
def test_stop_loss_edit():
# TGA (stop) --A→G at position 3--> TGG (Trp).
c = BE.codon_consequence("TGA" + "GCT", 2, "A", "G")
assert c.kind == "stop_loss" and c.creates_stop is False
assert (c.aa_before, c.aa_after) == ("*", "W")
def test_residue_numbering_uses_codon_index():
# Second codon β†’ residue 2.
c = BE.codon_consequence("GGG" + "CGA", 3, "C", "T")
assert c.label == "R2* (stop gained)"
def test_refuses_to_guess_on_mismatch_or_out_of_range():
# ref_base disagrees with the CDS β†’ None (no fabricated consequence).
assert BE.codon_consequence("GCT", 0, "A", "T") is None
# index past the end β†’ None.
assert BE.codon_consequence("GCT", 99, "T", "C") is None
# incomplete trailing codon β†’ None.
assert BE.codon_consequence("GC", 0, "G", "A") is None
# ─────────────── integration: find_guides(mode="base_edit") ───────────────
# Locks the full path β€” edit prediction + coordinate mapping (both strands)
# + frame auto-detection + codon translation β€” against a fixed in-frame CDS.
from dee.core import crispr as C # noqa: E402
_CDS = "".join([
"ATG", "GCT", "CGA", "GAT", "CAG", "GGC", "CGT", "CAA", "GCC", "TGG",
"CGC", "GAA", "CGG", "CTG", "CGA", "GCA", "CAG", "GAT", "CGT", "TAA",
]) # 60 nt, starts ATG, ends TAA, no internal stop β†’ auto-detected frame
def test_base_edit_mode_populates_fields_and_ranks_by_editability():
guides = C.find_guides(_CDS, enzyme="cas9", max_results=200,
mode="base_edit", base_editor="be4max")
assert guides
for g in guides:
assert g.be_editor == "be4max"
assert 0.0 <= g.be_editability <= 1.0
eds = [g.be_editability for g in guides]
assert eds == sorted(eds, reverse=True) # editability-first ranking
def test_base_edit_detects_istop_on_forward_strand():
guides = C.find_guides(_CDS, enzyme="cas9", max_results=200,
mode="base_edit", base_editor="be4max")
stops = {g.be_aa_change for g in guides if g.be_creates_stop}
# CAG (Q) → TAG (*) via a C→T in the window, at residues 5 and 8.
assert "Q5* (stop gained)" in stops
assert "Q8* (stop gained)" in stops
for g in guides:
if g.be_creates_stop:
assert g.be_aa_change.endswith("* (stop gained)")
def test_base_edit_reverse_strand_aa_mapping():
# A '-' strand guide editing the coding-strand G→A: validates the
# reverse-strand coordinate + complement transform end-to-end.
guides = C.find_guides(_CDS, enzyme="cas9", max_results=200,
mode="base_edit", base_editor="be4max")
changes = {g.be_aa_change for g in guides if g.strand == "-"}
assert "E12K" in changes
def test_base_edit_flags_bystander():
guides = C.find_guides(_CDS, enzyme="cas9", max_results=200,
mode="base_edit", base_editor="be4max")
assert any(g.be_has_bystander for g in guides)
def test_no_aa_change_without_frame():
# A random (non-CDS) sequence β†’ edits predicted, but no AA call.
import random
rng = random.Random(11)
seq = "".join(rng.choice("ACGT") for _ in range(400))
guides = C.find_guides(seq, enzyme="cas9", max_results=50,
mode="base_edit", base_editor="be4max")
assert guides
assert all(g.be_aa_change == "" for g in guides) # frame unknown β†’ no guess