Spaces:
Running
Genome off-target: make the existing engine actually shippable, and stop
Browse filestelling users to go to CRISPOR
A scientist reviewing the tool concluded they had to leave for CRISPOR to get
off-target. The engine has had a full genome off-target search since Phase 2B
(dee/core/offtarget.py: lazy FASTA fetch, 8-nt PAM-proximal seed index, CFD
scoring, human/mouse/E. coli all ready) and it is wired end to end. Three
things hid it:
1. It could not finish. The search ran on EVERY candidate guide, before the
sort. Measured 2026-07-19: ~3.5s per mammalian query x 50 guides = ~3 min
of off-target per design, which is why it shipped default-off. Now only the
top-ranked guides are screened — the ones you would actually order —
verified 50 guides -> 10 queries, est. 172s -> 34s. The authoritative sort
is unaffected: genome hits never feed composite_score.
2. The UI said the opposite of the truth. The CRISPR hero claimed
"Off-target risk is not assessed", and three other places sent users to
CRISPOR unconditionally — including in runs where a real genome search had
just completed. All four now describe what actually ran.
3. The organism picker claimed a "~1 min" first run. Real measured build is
~165s for the human CDS index; corrected.
Also: /api/crispr/methods gains a genome_off entry documenting both real
scope limits (human/mouse are CODING SEQUENCE ONLY, so intronic/intergenic
sites are unseen; and only top-ranked guides are screened), and the response
now returns genome_searched_top_n so the UI states how many were checked
rather than implying all of them. A whole-genome tool is still the right call
for intergenic work — said plainly instead of either hiding it or overclaiming.
478 tests green.
- dee/core/crispr.py +28 -4
- dee/core/crispr_methods.py +34 -8
- dee/server.py +9 -0
- dee/static/app.js +13 -1
- dee/static/index.html +11 -9
- tests/test_crispr_methods.py +34 -2
|
@@ -696,6 +696,12 @@ _INDEL_MMEJ_DECAY = 10.0 # del-length-decay constant (Bae 2014)
|
|
| 696 |
_INDEL_PLUS1_FRACTION = 0.32 # baseline +1 templated insertion class size
|
| 697 |
_INDEL_TOP_N = 5 # how many outcomes to keep per guide
|
| 698 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 699 |
|
| 700 |
def _predict_indels(
|
| 701 |
spacer: str,
|
|
@@ -1114,14 +1120,32 @@ def find_guides(
|
|
| 1114 |
# Only runs when the caller named an organism AND the enzyme is
|
| 1115 |
# SpCas9 (the CFD matrix + indexing assume NGG-PAM 20-nt spacers).
|
| 1116 |
# The first call after a cold start pays the lazy index-build cost
|
| 1117 |
-
# (~5 s for E. coli
|
| 1118 |
-
# Errors degrade silently — guides
|
| 1119 |
-
# stay at their defaults.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1120 |
if target_organism and enzyme == "cas9":
|
| 1121 |
try:
|
| 1122 |
from dee.core import offtarget as _ot
|
| 1123 |
if _ot.is_organism_ready(target_organism):
|
| 1124 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1125 |
hits = _ot.find_genomic_offtargets(
|
| 1126 |
g.spacer, target_organism, max_results=10,
|
| 1127 |
)
|
|
|
|
| 696 |
_INDEL_PLUS1_FRACTION = 0.32 # baseline +1 templated insertion class size
|
| 697 |
_INDEL_TOP_N = 5 # how many outcomes to keep per guide
|
| 698 |
|
| 699 |
+
# How many top-ranked guides get the (expensive) genome off-target query.
|
| 700 |
+
# A mammalian query measures ~3.5 s, so this is the difference between a
|
| 701 |
+
# ~35 s design and a ~3 min one. Public so the API can tell the user
|
| 702 |
+
# exactly how many guides were screened rather than implying all of them.
|
| 703 |
+
GENOME_OFFTARGET_TOP_N = 10
|
| 704 |
+
|
| 705 |
|
| 706 |
def _predict_indels(
|
| 707 |
spacer: str,
|
|
|
|
| 1120 |
# Only runs when the caller named an organism AND the enzyme is
|
| 1121 |
# SpCas9 (the CFD matrix + indexing assume NGG-PAM 20-nt spacers).
|
| 1122 |
# The first call after a cold start pays the lazy index-build cost
|
| 1123 |
+
# (~5 s for E. coli, ~3 min for a mammalian CDS index); subsequent
|
| 1124 |
+
# calls hit the in-memory cache. Errors degrade silently — guides
|
| 1125 |
+
# still rank, off-target fields stay at their defaults.
|
| 1126 |
+
#
|
| 1127 |
+
# ONLY the top-ranked guides are searched. Measured 2026-07-19: a
|
| 1128 |
+
# mammalian query costs ~3.5 s, so running all 50 candidates cost
|
| 1129 |
+
# ~3 MINUTES per design and made the feature unshippable (it was
|
| 1130 |
+
# left default-off as a result). Nobody orders guide #47 — the
|
| 1131 |
+
# off-target question only matters for the handful you'd actually
|
| 1132 |
+
# clone, so we spend the time there and leave the rest marked
|
| 1133 |
+
# "not searched" (genome_organism stays "", which the UI already
|
| 1134 |
+
# renders as "—", distinct from "searched, none found").
|
| 1135 |
if target_organism and enzyme == "cas9":
|
| 1136 |
try:
|
| 1137 |
from dee.core import offtarget as _ot
|
| 1138 |
if _ot.is_organism_ready(target_organism):
|
| 1139 |
+
# Rank-order a shallow copy purely to choose WHICH guides
|
| 1140 |
+
# are worth the expensive query. The authoritative sort
|
| 1141 |
+
# still happens below and is unaffected: genome hits never
|
| 1142 |
+
# feed composite_score (that uses the input-only self-off).
|
| 1143 |
+
if mode == "base_edit":
|
| 1144 |
+
_ranked = sorted(guides, key=lambda g: (-g.be_editability,
|
| 1145 |
+
-g.composite_score, g.position))
|
| 1146 |
+
else:
|
| 1147 |
+
_ranked = sorted(guides, key=lambda g: (-g.composite_score, g.position))
|
| 1148 |
+
for g in _ranked[:GENOME_OFFTARGET_TOP_N]:
|
| 1149 |
hits = _ot.find_genomic_offtargets(
|
| 1150 |
g.spacer, target_organism, max_results=10,
|
| 1151 |
)
|
|
@@ -56,12 +56,37 @@ METHODS: Dict[str, Dict[str, Any]] = {
|
|
| 56 |
"basis": "The exact 20×4 mismatch-penalty matrix from Doench 2016 "
|
| 57 |
"Supplementary Table 19 — the industry standard for "
|
| 58 |
"off-target ranking.",
|
| 59 |
-
"limits": "SCOPE
|
| 60 |
-
"
|
| 61 |
-
"
|
| 62 |
-
"
|
| 63 |
"citations": ["Doench et al. 2016, Nat Biotechnol, Suppl. Table 19 (CFD)"],
|
| 64 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
"ko_score": {
|
| 66 |
"label": "KO score",
|
| 67 |
"what": "Probability the cut produces a true loss-of-function knockout.",
|
|
@@ -104,7 +129,8 @@ METHODS: Dict[str, Dict[str, Any]] = {
|
|
| 104 |
}
|
| 105 |
|
| 106 |
# Order the UI should present them in (matches the table's column order).
|
| 107 |
-
METHOD_ORDER: List[str] = ["composite", "on_target", "self_off", "
|
|
|
|
| 108 |
|
| 109 |
|
| 110 |
def methods_payload() -> Dict[str, Any]:
|
|
@@ -114,8 +140,8 @@ def methods_payload() -> Dict[str, Any]:
|
|
| 114 |
"methods": {k: dict(METHODS[k]) for k in METHOD_ORDER},
|
| 115 |
"summary": (
|
| 116 |
"Every score here is computed from published, sequence-based "
|
| 117 |
-
"methods — no black box. Two scope limits matter most:
|
| 118 |
-
"
|
| 119 |
-
"predictions are not tuned to your cell type."
|
| 120 |
),
|
| 121 |
}
|
|
|
|
| 56 |
"basis": "The exact 20×4 mismatch-penalty matrix from Doench 2016 "
|
| 57 |
"Supplementary Table 19 — the industry standard for "
|
| 58 |
"off-target ranking.",
|
| 59 |
+
"limits": "SCOPE: this column searches only the sequence you pasted. "
|
| 60 |
+
"For a real off-target check, pick an organism — the engine "
|
| 61 |
+
"then screens your top guides against the genome and fills "
|
| 62 |
+
"the “Genome off” column (see below).",
|
| 63 |
"citations": ["Doench et al. 2016, Nat Biotechnol, Suppl. Table 19 (CFD)"],
|
| 64 |
},
|
| 65 |
+
"genome_off": {
|
| 66 |
+
"label": "Genome off",
|
| 67 |
+
"what": "Real off-target search against the organism's genome — the "
|
| 68 |
+
"column that tells you whether a guide cuts somewhere it "
|
| 69 |
+
"shouldn't. Runs when you choose an organism.",
|
| 70 |
+
"formula": "Seed-indexed search (8 nt PAM-proximal seed, ≤1 seed and "
|
| 71 |
+
"≤4 total mismatches), each candidate CFD-scored; hits "
|
| 72 |
+
"above CFD 0.05 are kept and ranked.",
|
| 73 |
+
"basis": "Same Doench 2016 CFD matrix used for Self-off, applied "
|
| 74 |
+
"across the indexed genome rather than just your input. Your "
|
| 75 |
+
"guide never leaves the engine — only the public reference "
|
| 76 |
+
"genome is downloaded, and it is cached and reused.",
|
| 77 |
+
"limits": "Two scope limits. (1) COVERAGE: E. coli is the complete "
|
| 78 |
+
"genome, but human (GRCh38) and mouse (GRCm39) are indexed "
|
| 79 |
+
"over CODING SEQUENCE ONLY — so off-targets in introns and "
|
| 80 |
+
"intergenic DNA are not seen. If you need a whole-genome "
|
| 81 |
+
"sweep, that still calls for a dedicated genome-wide tool. "
|
| 82 |
+
"(2) DEPTH: only the top-ranked guides are screened (the "
|
| 83 |
+
"ones you would realistically order), not every candidate "
|
| 84 |
+
"in the table; the run tells you how many were checked.",
|
| 85 |
+
"citations": [
|
| 86 |
+
"Doench et al. 2016, Nat Biotechnol, Suppl. Table 19 (CFD)",
|
| 87 |
+
"Ensembl release 112 (GRCh38 / GRCm39 CDS); NCBI NC_000913.3 (E. coli K-12)",
|
| 88 |
+
],
|
| 89 |
+
},
|
| 90 |
"ko_score": {
|
| 91 |
"label": "KO score",
|
| 92 |
"what": "Probability the cut produces a true loss-of-function knockout.",
|
|
|
|
| 129 |
}
|
| 130 |
|
| 131 |
# Order the UI should present them in (matches the table's column order).
|
| 132 |
+
METHOD_ORDER: List[str] = ["composite", "on_target", "self_off", "genome_off",
|
| 133 |
+
"ko_score", "indels"]
|
| 134 |
|
| 135 |
|
| 136 |
def methods_payload() -> Dict[str, Any]:
|
|
|
|
| 140 |
"methods": {k: dict(METHODS[k]) for k in METHOD_ORDER},
|
| 141 |
"summary": (
|
| 142 |
"Every score here is computed from published, sequence-based "
|
| 143 |
+
"methods — no black box. Two scope limits matter most: for human "
|
| 144 |
+
"and mouse the genome off-target search covers coding sequence "
|
| 145 |
+
"only, and indel predictions are not tuned to your cell type."
|
| 146 |
),
|
| 147 |
}
|
|
@@ -2065,6 +2065,14 @@ def create_app() -> Flask:
|
|
| 2065 |
# requested.
|
| 2066 |
from dee.core import offtarget as _ot_status
|
| 2067 |
genome_index_status = _ot_status.index_status(target_organism)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2068 |
from dee.core import outcomes as _O
|
| 2069 |
guide_dicts = [
|
| 2070 |
{
|
|
@@ -2134,6 +2142,7 @@ def create_app() -> Flask:
|
|
| 2134 |
"base_editor": base_editor or (_be.DEFAULT_BASE_EDITOR if mode == "base_edit" else ""),
|
| 2135 |
"genome_organism": target_organism,
|
| 2136 |
"genome_index_status": genome_index_status,
|
|
|
|
| 2137 |
"calibrated": any("calibration" in g for g in guide_dicts),
|
| 2138 |
"guides": guide_dicts,
|
| 2139 |
})
|
|
|
|
| 2065 |
# requested.
|
| 2066 |
from dee.core import offtarget as _ot_status
|
| 2067 |
genome_index_status = _ot_status.index_status(target_organism)
|
| 2068 |
+
# Only the top-ranked guides get the (~3.5 s each) genome query, so
|
| 2069 |
+
# say exactly how many were screened rather than letting the UI imply
|
| 2070 |
+
# every guide was checked.
|
| 2071 |
+
from dee.core.crispr import GENOME_OFFTARGET_TOP_N as _GENOME_TOP_N
|
| 2072 |
+
genome_searched_top_n = (
|
| 2073 |
+
min(_GENOME_TOP_N, len(guides))
|
| 2074 |
+
if (genome_index_status == "ready" and enzyme == "cas9") else 0
|
| 2075 |
+
)
|
| 2076 |
from dee.core import outcomes as _O
|
| 2077 |
guide_dicts = [
|
| 2078 |
{
|
|
|
|
| 2142 |
"base_editor": base_editor or (_be.DEFAULT_BASE_EDITOR if mode == "base_edit" else ""),
|
| 2143 |
"genome_organism": target_organism,
|
| 2144 |
"genome_index_status": genome_index_status,
|
| 2145 |
+
"genome_searched_top_n": genome_searched_top_n,
|
| 2146 |
"calibrated": any("calibration" in g for g in guide_dicts),
|
| 2147 |
"guides": guide_dicts,
|
| 2148 |
})
|
|
@@ -5506,7 +5506,19 @@ if (_quitBtn) {
|
|
| 5506 |
if (g.flag_low_gc) watch.push('low GC (often less active)');
|
| 5507 |
if (g.flag_polyT) watch.push('a TTTT run (U6 may terminate early)');
|
| 5508 |
if (watch.length) p.push('Watch-outs: ' + watch.join(', ') + '.');
|
| 5509 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5510 |
// Phase 3 (M5): structure-view button when the cut maps to a residue
|
| 5511 |
// AND a human/mouse gene symbol is set (needed to resolve UniProt).
|
| 5512 |
// When the context is missing, show a hint telling the user exactly
|
|
|
|
| 5506 |
if (g.flag_low_gc) watch.push('low GC (often less active)');
|
| 5507 |
if (g.flag_polyT) watch.push('a TTTT run (U6 may terminate early)');
|
| 5508 |
if (watch.length) p.push('Watch-outs: ' + watch.join(', ') + '.');
|
| 5509 |
+
// Off-target guidance, matched to what the engine ACTUALLY did for this
|
| 5510 |
+
// guide. Previously this always told the user to go run CRISPOR — even
|
| 5511 |
+
// when a real genome search had just run here, which is what made the
|
| 5512 |
+
// tool feel like a stop on the way to somewhere else.
|
| 5513 |
+
if (!g.genome_organism) {
|
| 5514 |
+
p.push('<em>No genome off-target search was run — pick an organism above to screen your top guides against the genome.</em>');
|
| 5515 |
+
} else if (g.genome_organism === 'ecoli') {
|
| 5516 |
+
p.push('<em>Screened against the complete E. coli K-12 genome.</em>');
|
| 5517 |
+
} else {
|
| 5518 |
+
p.push('<em>Screened against ' + escapeHtml(g.genome_organism) +
|
| 5519 |
+
' coding sequence. Intronic and intergenic off-targets are outside this index — ' +
|
| 5520 |
+
'use a whole-genome tool if your application needs them.</em>');
|
| 5521 |
+
}
|
| 5522 |
// Phase 3 (M5): structure-view button when the cut maps to a residue
|
| 5523 |
// AND a human/mouse gene symbol is set (needed to resolve UniProt).
|
| 5524 |
// When the context is missing, show a hint telling the user exactly
|
|
@@ -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=20260718-
|
| 116 |
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg?v=2" />
|
| 117 |
<link rel="apple-touch-icon" href="/static/favicon.svg?v=2" />
|
| 118 |
<!-- Mol* (PDBe) 3-D viewer is ~4.9 MB. We do NOT eager-load it on every
|
|
@@ -960,10 +960,12 @@
|
|
| 960 |
</p>
|
| 961 |
<p class="hero-meta crispr-disclaimer">
|
| 962 |
On-target scoring is heuristic (Doench-style sequence
|
| 963 |
-
features). <strong>Off-target
|
| 964 |
-
|
| 965 |
-
|
| 966 |
-
|
|
|
|
|
|
|
| 967 |
</p>
|
| 968 |
</section>
|
| 969 |
|
|
@@ -1090,8 +1092,8 @@
|
|
| 1090 |
<select id="crisprOrganism" class="crispr-context-input">
|
| 1091 |
<option value="">None — skip genome search</option>
|
| 1092 |
<option value="ecoli">E. coli K-12 MG1655 · whole genome</option>
|
| 1093 |
-
<option value="human">Homo sapiens (GRCh38) · coding regions ·
|
| 1094 |
-
<option value="mouse">Mus musculus (GRCm39) · coding regions ·
|
| 1095 |
</select>
|
| 1096 |
</div>
|
| 1097 |
<div class="crispr-context-field">
|
|
@@ -1833,7 +1835,7 @@
|
|
| 1833 |
<li><strong>Knockout.</strong> Predicted indel spectrum, frameshift %, out-of-frame dominance, and a loss-of-function likelihood.</li>
|
| 1834 |
<li><strong>Cloning oligos.</strong> Ready-to-order sense/antisense pairs for the standard vectors (BbsI/BsmBI; Cas12a geometry), copied straight to a vendor order.</li>
|
| 1835 |
</ul>
|
| 1836 |
-
<p class="docs-callout">Off-target
|
| 1837 |
</section>
|
| 1838 |
|
| 1839 |
<section>
|
|
@@ -2304,7 +2306,7 @@
|
|
| 2304 |
<!-- Cloning reference data must load before app.js so the Designer
|
| 2305 |
can read VECTORS / ENZYMES / CLONING_METHODS / TAGS / LINKERS. -->
|
| 2306 |
<script src="/static/cloning_db.js?v=20260530-ui-polish" defer></script>
|
| 2307 |
-
<script src="/static/app.js?v=20260718-
|
| 2308 |
<!-- Dwell-time heartbeat. Loads after auth.js so its /api/ping calls go
|
| 2309 |
through the JWT-attaching fetch wrapper (signed-in attribution). -->
|
| 2310 |
<script src="/static/telemetry.js?v=20260622-analytics" defer></script>
|
|
|
|
| 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=20260718-offtarget" />
|
| 116 |
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg?v=2" />
|
| 117 |
<link rel="apple-touch-icon" href="/static/favicon.svg?v=2" />
|
| 118 |
<!-- Mol* (PDBe) 3-D viewer is ~4.9 MB. We do NOT eager-load it on every
|
|
|
|
| 960 |
</p>
|
| 961 |
<p class="hero-meta crispr-disclaimer">
|
| 962 |
On-target scoring is heuristic (Doench-style sequence
|
| 963 |
+
features). <strong>Off-target is screened here</strong> —
|
| 964 |
+
choose an organism and your top guides are searched
|
| 965 |
+
against the genome and CFD-scored. Coverage is the
|
| 966 |
+
complete genome for E. coli, and coding sequence
|
| 967 |
+
for human and mouse; intronic and intergenic sites
|
| 968 |
+
fall outside that index.
|
| 969 |
</p>
|
| 970 |
</section>
|
| 971 |
|
|
|
|
| 1092 |
<select id="crisprOrganism" class="crispr-context-input">
|
| 1093 |
<option value="">None — skip genome search</option>
|
| 1094 |
<option value="ecoli">E. coli K-12 MG1655 · whole genome</option>
|
| 1095 |
+
<option value="human">Homo sapiens (GRCh38) · coding regions · index builds once, ~3 min</option>
|
| 1096 |
+
<option value="mouse">Mus musculus (GRCm39) · coding regions · index builds once, ~3 min</option>
|
| 1097 |
</select>
|
| 1098 |
</div>
|
| 1099 |
<div class="crispr-context-field">
|
|
|
|
| 1835 |
<li><strong>Knockout.</strong> Predicted indel spectrum, frameshift %, out-of-frame dominance, and a loss-of-function likelihood.</li>
|
| 1836 |
<li><strong>Cloning oligos.</strong> Ready-to-order sense/antisense pairs for the standard vectors (BbsI/BsmBI; Cas12a geometry), copied straight to a vendor order.</li>
|
| 1837 |
</ul>
|
| 1838 |
+
<p class="docs-callout">Off-target is screened against the genome when you choose an organism: your top-ranked guides are searched with a seed index and CFD-scored. Coverage is the <strong>complete genome</strong> for E. coli and <strong>coding sequence</strong> for human (GRCh38) and mouse (GRCm39) — intronic and intergenic off-targets sit outside that index, so a whole-genome tool is still the right call if your application depends on them. Leave the organism unset and only the sequence you pasted is checked.</p>
|
| 1839 |
</section>
|
| 1840 |
|
| 1841 |
<section>
|
|
|
|
| 2306 |
<!-- Cloning reference data must load before app.js so the Designer
|
| 2307 |
can read VECTORS / ENZYMES / CLONING_METHODS / TAGS / LINKERS. -->
|
| 2308 |
<script src="/static/cloning_db.js?v=20260530-ui-polish" defer></script>
|
| 2309 |
+
<script src="/static/app.js?v=20260718-offtarget" defer></script>
|
| 2310 |
<!-- Dwell-time heartbeat. Loads after auth.js so its /api/ping calls go
|
| 2311 |
through the JWT-attaching fetch wrapper (signed-in attribution). -->
|
| 2312 |
<script src="/static/telemetry.js?v=20260622-analytics" defer></script>
|
|
@@ -40,9 +40,12 @@ def test_on_target_does_not_claim_to_be_rule_set_2():
|
|
| 40 |
assert "0.55" in m["limits"] # the honest correlation range
|
| 41 |
|
| 42 |
|
| 43 |
-
def
|
|
|
|
|
|
|
|
|
|
| 44 |
m = cm.METHODS["self_off"]
|
| 45 |
-
assert "
|
| 46 |
|
| 47 |
|
| 48 |
def test_indels_declare_heuristic_and_cell_type_agnostic():
|
|
@@ -64,3 +67,32 @@ def test_methods_route_is_public_and_shaped(client):
|
|
| 64 |
assert body["order"] == cm.METHOD_ORDER
|
| 65 |
assert set(body["methods"]) == set(cm.METHOD_ORDER)
|
| 66 |
assert "off-target" in body["summary"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
assert "0.55" in m["limits"] # the honest correlation range
|
| 41 |
|
| 42 |
|
| 43 |
+
def test_self_off_declares_its_input_only_scope():
|
| 44 |
+
# Self-off is deliberately input-only; the genome-wide answer lives in the
|
| 45 |
+
# separate "Genome off" column, so this must say so rather than imply the
|
| 46 |
+
# tool has no off-target capability at all.
|
| 47 |
m = cm.METHODS["self_off"]
|
| 48 |
+
assert "only the sequence you pasted" in m["limits"]
|
| 49 |
|
| 50 |
|
| 51 |
def test_indels_declare_heuristic_and_cell_type_agnostic():
|
|
|
|
| 67 |
assert body["order"] == cm.METHOD_ORDER
|
| 68 |
assert set(body["methods"]) == set(cm.METHOD_ORDER)
|
| 69 |
assert "off-target" in body["summary"]
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
# --------------------------------------------------------------------------- #
|
| 73 |
+
# Genome off-target: the column that decides whether "consolidation" is real
|
| 74 |
+
# --------------------------------------------------------------------------- #
|
| 75 |
+
def test_genome_off_is_documented_with_both_scope_limits():
|
| 76 |
+
m = cm.METHODS["genome_off"]
|
| 77 |
+
# Coverage limit: human/mouse are CDS-only, not whole genome.
|
| 78 |
+
assert "CODING SEQUENCE ONLY" in m["limits"]
|
| 79 |
+
assert "intergenic" in m["limits"]
|
| 80 |
+
# Depth limit: only top-ranked guides are screened.
|
| 81 |
+
assert "top-ranked" in m["limits"]
|
| 82 |
+
# Privacy: the guide never leaves the engine.
|
| 83 |
+
assert "never leaves" in m["basis"]
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def test_self_off_points_at_genome_search_not_an_external_tool():
|
| 87 |
+
# The old copy sent users to CRISPOR from here; it should now point at
|
| 88 |
+
# the engine's own genome column instead.
|
| 89 |
+
m = cm.METHODS["self_off"]
|
| 90 |
+
assert "Genome off" in m["limits"]
|
| 91 |
+
assert "CRISPOR" not in m["limits"]
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def test_genome_offtarget_top_n_is_bounded():
|
| 95 |
+
# The whole reason the feature was unshippable: a ~3.5s query per guide
|
| 96 |
+
# across all 50 candidates. Keep the screened set small and explicit.
|
| 97 |
+
from dee.core.crispr import GENOME_OFFTARGET_TOP_N
|
| 98 |
+
assert 1 <= GENOME_OFFTARGET_TOP_N <= 15
|