Spaces:
Running
Genome off-target: four complete genomes, and an honest answer on mammals
Browse filesAdds 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 +13 -8
- dee/core/offtarget.py +63 -0
- dee/server.py +12 -2
- dee/static/index.html +12 -5
- tests/test_crispr_methods.py +33 -0
|
@@ -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
|
| 78 |
-
"
|
| 79 |
-
"
|
| 80 |
-
"
|
| 81 |
-
"
|
| 82 |
-
"
|
| 83 |
-
"
|
| 84 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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)",
|
|
@@ -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)
|
|
@@ -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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 = ""
|
|
@@ -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=
|
| 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 |
-
<
|
| 1095 |
-
|
| 1096 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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=
|
| 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>
|
|
@@ -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)
|