Tengo Gzirishvili commited on
Commit
35fa528
·
1 Parent(s): 6a3f2e0

Genome off-target: four complete genomes, and an honest answer on mammals

Browse files

Adds S. cerevisiae, C. elegans and D. melanogaster alongside E. coli as
COMPLETE-genome off-target targets — for these an off-target anywhere
(intron, intergenic, regulatory) is found, not just coding sequence.
Verified end to end: yeast builds in 18.5s -> 944,171 sites across 17
chromosomes (16 + mito), 0.25s queries, and a positive control pulling a
real protospacer out of the index finds itself at I:79 with CFD 1.000.

Also fixes a latent bug that would have silently disabled every new
organism: server.py validated target_organism against a hardcoded
("", "ecoli", "human", "mouse") tuple in TWO places, so anything else fell
back to "no genome search". Now validated against offtarget.GENOME_SOURCES,
so registering a genome is enough to enable it end to end.

Whole-genome human/mouse is NOT added, and the methods text now says why
rather than leaving it looking like an oversight. Measured in clean
processes: the seed index costs ~90-260 bytes per site depending how the
baseline is counted; a whole human genome carries ~390M PAM sites, i.e.
tens of GB against a 16 GB container, with a build measured at ~1s per Mb
(hours). Getting there needs a different substrate (native aligner + a
multi-GB prebuilt index, or offloading the search) — a cost decision, not
a tuning one.

Guards the memory this opens up: _KMER_CACHE had no eviction, and six
organisms resident at once would OOM the Space alongside ESM-2. Added an
LRU cap (TURINGDNA_MAX_GENOME_INDEXES, default 2).

481 tests green.

dee/core/crispr_methods.py CHANGED
@@ -74,14 +74,19 @@ METHODS: Dict[str, Dict[str, Any]] = {
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)",
 
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, S. cerevisiae, "
78
+ "C. elegans and D. melanogaster are indexed over the "
79
+ "COMPLETE genome, so an off-target anywhere is found. Human "
80
+ "(GRCh38) and mouse (GRCm39) are indexed over CODING "
81
+ "SEQUENCE ONLY off-targets in introns and intergenic DNA "
82
+ "are not seen there, and a dedicated whole-genome tool is "
83
+ "still the right call for that work. The reason is memory, "
84
+ "not preference: the seed index costs on the order of "
85
+ "100 bytes per site (measured), and a whole human genome "
86
+ "carries roughly 390 million PAM sites — tens of gigabytes, "
87
+ "well beyond the container. (2) DEPTH: only the top-ranked guides are "
88
+ "screened (the ones you would realistically order), not "
89
+ "every candidate; the run tells you how many were checked.",
90
  "citations": [
91
  "Doench et al. 2016, Nat Biotechnol, Suppl. Table 19 (CFD)",
92
  "Ensembl release 112 (GRCh38 / GRCm39 CDS); NCBI NC_000913.3 (E. coli K-12)",
dee/core/offtarget.py CHANGED
@@ -104,10 +104,70 @@ GENOME_SOURCES: Dict[str, Dict[str, object]] = {
104
  "scope": "exome (CDS only)",
105
  "ready": True,
106
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
107
  }
108
 
109
  _GENOME_CACHE_DIR = os.environ.get("TURINGDNA_GENOME_CACHE", "/tmp/turingdna_genomes")
110
  _KMER_CACHE: Dict[str, "KmerIndex"] = {}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
111
  _BUILD_LOCK = threading.Lock()
112
  # Per-organism build state — distinct from _BUILD_LOCK because we want
113
  # concurrent requests for DIFFERENT organisms to proceed in parallel,
@@ -231,6 +291,7 @@ def find_genomic_offtargets(
231
  # building, refresh in 2 min" banner via the API status field.
232
  kick_off_build(organism)
233
  return []
 
234
 
235
  guide_seed = guide_spacer[-_SEED_LEN:]
236
  # Search the perfect-seed bucket plus all 1-mismatch seed buckets.
@@ -296,7 +357,9 @@ def _get_kmer_index(organism: str) -> Optional[KmerIndex]:
296
  "Built kmer index for %s: %d sites across %d chroms in %.1f s",
297
  organism, index.n_sites, index.n_chroms, elapsed,
298
  )
 
299
  _KMER_CACHE[organism] = index
 
300
  return index
301
  except Exception as exc: # noqa: BLE001
302
  logger.exception("Failed to build kmer index for %s: %s", organism, exc)
 
104
  "scope": "exome (CDS only)",
105
  "ready": True,
106
  },
107
+ # ─── Full-genome model organisms ────────────────────────────────
108
+ # These are small enough that the COMPLETE genome fits the in-memory
109
+ # seed index, so there's no coding-only caveat: an off-target
110
+ # anywhere — intron, intergenic, regulatory — is found. Build cost
111
+ # scales with genome size at roughly 1 s per Mb (measured
112
+ # 2026-07-19), which is what bounds this list; see the module note
113
+ # on why mammalian whole genomes can't join it.
114
+ "yeast": {
115
+ "name": "Saccharomyces cerevisiae (R64-1-1)",
116
+ "accession": "R64-1-1",
117
+ "url": "https://ftp.ensembl.org/pub/release-112/fasta/saccharomyces_cerevisiae/"
118
+ "dna/Saccharomyces_cerevisiae.R64-1-1.dna.toplevel.fa.gz",
119
+ "is_gzip": True,
120
+ "size_mb": 3,
121
+ "scope": "full genome",
122
+ "ready": True,
123
+ },
124
+ "worm": {
125
+ "name": "Caenorhabditis elegans (WBcel235)",
126
+ "accession": "WBcel235",
127
+ "url": "https://ftp.ensembl.org/pub/release-112/fasta/caenorhabditis_elegans/"
128
+ "dna/Caenorhabditis_elegans.WBcel235.dna.toplevel.fa.gz",
129
+ "is_gzip": True,
130
+ "size_mb": 30,
131
+ "scope": "full genome",
132
+ "ready": True,
133
+ },
134
+ "fly": {
135
+ "name": "Drosophila melanogaster (BDGP6.46)",
136
+ "accession": "BDGP6.46",
137
+ "url": "https://ftp.ensembl.org/pub/release-112/fasta/drosophila_melanogaster/"
138
+ "dna/Drosophila_melanogaster.BDGP6.46.dna.toplevel.fa.gz",
139
+ "is_gzip": True,
140
+ "size_mb": 43,
141
+ "scope": "full genome",
142
+ "ready": True,
143
+ },
144
  }
145
 
146
  _GENOME_CACHE_DIR = os.environ.get("TURINGDNA_GENOME_CACHE", "/tmp/turingdna_genomes")
147
  _KMER_CACHE: Dict[str, "KmerIndex"] = {}
148
+ # Least-recently-used timestamps, so the cache can shed a big index instead
149
+ # of OOM-ing the container.
150
+ _LAST_USED: Dict[str, float] = {}
151
+ # Indexes cost ~152 bytes per site (measured 2026-07-19). A mammalian CDS
152
+ # index is ~3.5 GB and a fly genome ~2.7 GB, so holding every organism at
153
+ # once would exceed the Space's RAM alongside the ESM-2 model. Keep only the
154
+ # few most recently used; a re-build is slow but survivable, an OOM is not.
155
+ _MAX_CACHED_INDEXES = int(os.environ.get("TURINGDNA_MAX_GENOME_INDEXES", "2"))
156
+
157
+
158
+ def _evict_if_needed(incoming: str) -> None:
159
+ """Drop least-recently-used indexes so at most _MAX_CACHED_INDEXES - 1
160
+ remain before `incoming` is inserted. Caller holds _BUILD_LOCK."""
161
+ try:
162
+ while len(_KMER_CACHE) >= max(1, _MAX_CACHED_INDEXES):
163
+ victim = min(_KMER_CACHE, key=lambda k: _LAST_USED.get(k, 0.0))
164
+ if victim == incoming:
165
+ break
166
+ _KMER_CACHE.pop(victim, None)
167
+ _LAST_USED.pop(victim, None)
168
+ logger.info("Evicted genome index %s to stay within memory budget", victim)
169
+ except Exception: # noqa: BLE001 — eviction must never break a query
170
+ logger.exception("genome index eviction failed")
171
  _BUILD_LOCK = threading.Lock()
172
  # Per-organism build state — distinct from _BUILD_LOCK because we want
173
  # concurrent requests for DIFFERENT organisms to proceed in parallel,
 
291
  # building, refresh in 2 min" banner via the API status field.
292
  kick_off_build(organism)
293
  return []
294
+ _LAST_USED[organism] = time.time() # keep the in-use index off the evict list
295
 
296
  guide_seed = guide_spacer[-_SEED_LEN:]
297
  # Search the perfect-seed bucket plus all 1-mismatch seed buckets.
 
357
  "Built kmer index for %s: %d sites across %d chroms in %.1f s",
358
  organism, index.n_sites, index.n_chroms, elapsed,
359
  )
360
+ _evict_if_needed(organism)
361
  _KMER_CACHE[organism] = index
362
+ _LAST_USED[organism] = time.time()
363
  return index
364
  except Exception as exc: # noqa: BLE001
365
  logger.exception("Failed to build kmer index for %s: %s", organism, exc)
dee/server.py CHANGED
@@ -2016,7 +2016,12 @@ def create_app() -> Flask:
2016
  target_organism = str(body.get("target_organism", "")).lower().strip()
2017
  gene_symbol = str(body.get("gene_symbol", "")).strip()
2018
  # Light input validation — anything else gets ignored.
2019
- if target_organism not in ("", "ecoli", "human", "mouse"):
 
 
 
 
 
2020
  target_organism = ""
2021
  if len(gene_symbol) > 32 or not re.match(r"^[A-Za-z0-9._-]*$", gene_symbol):
2022
  gene_symbol = ""
@@ -3158,7 +3163,12 @@ def create_app() -> Flask:
3158
  # downloaded XLSX matches what the user saw in the table.
3159
  target_organism = str(body.get("target_organism", "")).lower().strip()
3160
  gene_symbol = str(body.get("gene_symbol", "")).strip()
3161
- if target_organism not in ("", "ecoli", "human", "mouse"):
 
 
 
 
 
3162
  target_organism = ""
3163
  if len(gene_symbol) > 32 or not re.match(r"^[A-Za-z0-9._-]*$", gene_symbol):
3164
  gene_symbol = ""
 
2016
  target_organism = str(body.get("target_organism", "")).lower().strip()
2017
  gene_symbol = str(body.get("gene_symbol", "")).strip()
2018
  # Light input validation — anything else gets ignored.
2019
+ # Validate against the actual genome registry rather than a hardcoded
2020
+ # list, so adding an organism to offtarget.GENOME_SOURCES enables it
2021
+ # end to end. The old literal tuple silently rejected any new organism
2022
+ # and fell back to "no genome search".
2023
+ from dee.core.offtarget import GENOME_SOURCES as _GENOMES
2024
+ if target_organism and target_organism not in _GENOMES:
2025
  target_organism = ""
2026
  if len(gene_symbol) > 32 or not re.match(r"^[A-Za-z0-9._-]*$", gene_symbol):
2027
  gene_symbol = ""
 
3163
  # downloaded XLSX matches what the user saw in the table.
3164
  target_organism = str(body.get("target_organism", "")).lower().strip()
3165
  gene_symbol = str(body.get("gene_symbol", "")).strip()
3166
+ # Validate against the actual genome registry rather than a hardcoded
3167
+ # list, so adding an organism to offtarget.GENOME_SOURCES enables it
3168
+ # end to end. The old literal tuple silently rejected any new organism
3169
+ # and fell back to "no genome search".
3170
+ from dee.core.offtarget import GENOME_SOURCES as _GENOMES
3171
+ if target_organism and target_organism not in _GENOMES:
3172
  target_organism = ""
3173
  if len(gene_symbol) > 32 or not re.match(r"^[A-Za-z0-9._-]*$", gene_symbol):
3174
  gene_symbol = ""
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-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
@@ -1091,9 +1091,16 @@
1091
  </label>
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">
@@ -2306,7 +2313,7 @@
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>
 
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=20260719-organisms" />
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
 
1091
  </label>
1092
  <select id="crisprOrganism" class="crispr-context-input">
1093
  <option value="">None — skip genome search</option>
1094
+ <optgroup label="Complete genome finds off-targets anywhere">
1095
+ <option value="ecoli">E. coli K-12 MG1655 · builds in seconds</option>
1096
+ <option value="yeast">S. cerevisiae (R64-1-1) · builds in ~30 s</option>
1097
+ <option value="worm">C. elegans (WBcel235) · builds once, ~3 min</option>
1098
+ <option value="fly">D. melanogaster (BDGP6.46) · builds once, ~5 min</option>
1099
+ </optgroup>
1100
+ <optgroup label="Coding regions only — introns/intergenic not covered">
1101
+ <option value="human">Homo sapiens (GRCh38) · builds once, ~3 min</option>
1102
+ <option value="mouse">Mus musculus (GRCm39) · builds once, ~2 min</option>
1103
+ </optgroup>
1104
  </select>
1105
  </div>
1106
  <div class="crispr-context-field">
 
2313
  <!-- Cloning reference data must load before app.js so the Designer
2314
  can read VECTORS / ENZYMES / CLONING_METHODS / TAGS / LINKERS. -->
2315
  <script src="/static/cloning_db.js?v=20260530-ui-polish" defer></script>
2316
+ <script src="/static/app.js?v=20260719-organisms" defer></script>
2317
  <!-- Dwell-time heartbeat. Loads after auth.js so its /api/ping calls go
2318
  through the JWT-attaching fetch wrapper (signed-in attribution). -->
2319
  <script src="/static/telemetry.js?v=20260622-analytics" defer></script>
tests/test_crispr_methods.py CHANGED
@@ -96,3 +96,36 @@ def test_genome_offtarget_top_n_is_bounded():
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
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
99
+
100
+
101
+ # --------------------------------------------------------------------------- #
102
+ # Genome registry: which organisms, and at what coverage
103
+ # --------------------------------------------------------------------------- #
104
+ def test_full_genome_organisms_are_actually_full_genome():
105
+ from dee.core.offtarget import GENOME_SOURCES, is_organism_ready
106
+ full = {k for k, v in GENOME_SOURCES.items() if v["scope"] == "full genome"}
107
+ # Small enough to index completely — an off-target ANYWHERE is found.
108
+ assert {"ecoli", "yeast", "worm", "fly"} <= full
109
+ for o in full:
110
+ assert is_organism_ready(o)
111
+ assert GENOME_SOURCES[o]["url"].startswith("https://")
112
+
113
+
114
+ def test_mammals_are_declared_cds_only_not_silently_partial():
115
+ from dee.core.offtarget import GENOME_SOURCES
116
+ for o in ("human", "mouse"):
117
+ assert "CDS" in GENOME_SOURCES[o]["scope"]
118
+ # And the user-facing methods must say so, with the reason.
119
+ limits = cm.METHODS["genome_off"]["limits"]
120
+ assert "CODING SEQUENCE ONLY" in limits
121
+ # The reason must be stated (memory), without false precision — the
122
+ # per-site cost was measured at roughly 90-260 B depending on how the
123
+ # baseline is counted, so we claim an order of magnitude, not a figure.
124
+ assert "390 million" in limits and "gigabytes" in limits
125
+
126
+
127
+ def test_index_cache_is_bounded():
128
+ # Six organisms x multi-GB indexes would OOM the Space; the cache must evict.
129
+ from dee.core import offtarget as ot
130
+ assert ot._MAX_CACHED_INDEXES >= 1
131
+ assert callable(ot._evict_if_needed)