Tengo Gzirishvili commited on
Commit
6a3f2e0
·
1 Parent(s): 9fa9716

Genome off-target: make the existing engine actually shippable, and stop

Browse files

telling 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 CHANGED
@@ -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); subsequent calls hit the in-memory cache.
1118
- # Errors degrade silently — guides still rank, off-target fields
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
- for g in guides:
 
 
 
 
 
 
 
 
 
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
  )
dee/core/crispr_methods.py CHANGED
@@ -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 WARNING: this searches only the sequence you pasted. "
60
- "It is NOT a whole-genome off-target scan, so a guide that is "
61
- "unique in your input can still cut elsewhere in the genome. "
62
- "Run top guides through a genome-wide tool before ordering.",
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", "ko_score", "indels"]
 
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: off-target "
118
- "checking covers only the sequence you submit, and indel "
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
  }
dee/server.py CHANGED
@@ -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
  })
dee/static/app.js CHANGED
@@ -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
- p.push('<em>Run your top guides through CRISPOR for whole-genome off-target before ordering.</em>');
 
 
 
 
 
 
 
 
 
 
 
 
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
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=20260718-methods" />
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 risk is not assessed</strong> &mdash;
964
- for therapeutic or in-vivo applications, run your top
965
- guides through <a href="http://crispor.tefor.net/" target="_blank" rel="noopener">CRISPOR</a>
966
- before ordering.
 
 
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 · first run takes ~1 min</option>
1094
- <option value="mouse">Mus musculus (GRCm39) · coding regions · first run takes ~1 min</option>
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 search covers only the sequence you provide it is <strong>not</strong> a whole-genome scan. Run your top guides through a genome-wide tool (e.g. CRISPOR) before ordering.</p>
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-methods" defer></script>
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> &mdash;
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.&nbsp;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.&nbsp;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>
tests/test_crispr_methods.py CHANGED
@@ -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 test_self_off_declares_it_is_not_genome_wide():
 
 
 
44
  m = cm.METHODS["self_off"]
45
- assert "NOT a whole-genome" in m["limits"]
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