seqforge 2026.7.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- seqforge/__init__.py +16 -0
- seqforge/cli/__init__.py +38 -0
- seqforge/cli/__main__.py +8 -0
- seqforge/cli/_common.py +105 -0
- seqforge/cli/compose.py +119 -0
- seqforge/cli/eval.py +103 -0
- seqforge/cli/harvest.py +417 -0
- seqforge/cli/hook.py +247 -0
- seqforge/cli/io.py +502 -0
- seqforge/cli/kb.py +348 -0
- seqforge/cli/manifest.py +536 -0
- seqforge/cli/probe.py +43 -0
- seqforge/cli/processing.py +192 -0
- seqforge/cli/project.py +52 -0
- seqforge/cli/resolve.py +55 -0
- seqforge/cli/root.py +66 -0
- seqforge/cli/run.py +463 -0
- seqforge/cli/schema.py +41 -0
- seqforge/compose/__init__.py +28 -0
- seqforge/compose/core.py +515 -0
- seqforge/compose/gates.py +113 -0
- seqforge/compose/params.py +447 -0
- seqforge/e2e.py +1926 -0
- seqforge/evals/__init__.py +78 -0
- seqforge/evals/case.py +382 -0
- seqforge/evals/grade.py +300 -0
- seqforge/evals/run.py +420 -0
- seqforge/harvest/__init__.py +121 -0
- seqforge/harvest/extract.py +319 -0
- seqforge/harvest/fields.py +212 -0
- seqforge/harvest/normalize.py +537 -0
- seqforge/harvest/prep.py +41 -0
- seqforge/harvest/providers.py +321 -0
- seqforge/harvest/verify.py +251 -0
- seqforge/hooks/__init__.py +33 -0
- seqforge/hooks/guards.py +214 -0
- seqforge/io/__init__.py +61 -0
- seqforge/io/archive.py +450 -0
- seqforge/io/attributes.py +190 -0
- seqforge/io/biosample/attributes.json +6341 -0
- seqforge/io/efo/labels.json +55 -0
- seqforge/io/efo.py +138 -0
- seqforge/io/onlist.py +661 -0
- seqforge/io/onlists/3M-february-2018.codes.gz +0 -0
- seqforge/io/onlists/737K-arc-v1.codes.gz +0 -0
- seqforge/io/onlists/737K-august-2016.codes.gz +0 -0
- seqforge/io/onlists/bd-rhapsody-cls1-384.codes.gz +0 -0
- seqforge/io/onlists/bd-rhapsody-cls1.codes.gz +0 -0
- seqforge/io/onlists/bd-rhapsody-cls2-384.codes.gz +0 -0
- seqforge/io/onlists/bd-rhapsody-cls2.codes.gz +0 -0
- seqforge/io/onlists/bd-rhapsody-cls3-384.codes.gz +0 -0
- seqforge/io/onlists/bd-rhapsody-cls3.codes.gz +0 -0
- seqforge/io/onlists/index.json +74 -0
- seqforge/io/remote.py +659 -0
- seqforge/io/taxonomy.py +194 -0
- seqforge/kb/__init__.py +62 -0
- seqforge/kb/anchor.py +169 -0
- seqforge/kb/generate.py +147 -0
- seqforge/kb/loader.py +152 -0
- seqforge/kb/roundtrip.py +112 -0
- seqforge/kb/schema.py +422 -0
- seqforge/kb/specs/10x-3p-gex/spec.yaml +62 -0
- seqforge/kb/specs/10x-3p-gex-v2/README.md +41 -0
- seqforge/kb/specs/10x-3p-gex-v2/spec.yaml +83 -0
- seqforge/kb/specs/10x-3p-gex-v3/README.md +56 -0
- seqforge/kb/specs/10x-3p-gex-v3/spec.yaml +118 -0
- seqforge/kb/specs/10x-3p-gex-v3.1/README.md +56 -0
- seqforge/kb/specs/10x-3p-gex-v3.1/spec.yaml +124 -0
- seqforge/kb/specs/bd-rhapsody-wta/README.md +103 -0
- seqforge/kb/specs/bd-rhapsody-wta/spec.yaml +130 -0
- seqforge/kb/specs/bd-rhapsody-wta-enhanced/spec.yaml +99 -0
- seqforge/kb/specs/bd-rhapsody-wta-enhanced-v1/spec.yaml +93 -0
- seqforge/kb/specs/bd-rhapsody-wta-enhanced-v2/spec.yaml +81 -0
- seqforge/kb/specs/bulk-rnaseq-pe/README.md +35 -0
- seqforge/kb/specs/bulk-rnaseq-pe/spec.yaml +97 -0
- seqforge/kb/specs/splitseq/README.md +51 -0
- seqforge/kb/specs/splitseq/spec.yaml +157 -0
- seqforge/manifest/__init__.py +61 -0
- seqforge/manifest/fill.py +531 -0
- seqforge/manifest/hash.py +77 -0
- seqforge/manifest/instruct.py +114 -0
- seqforge/manifest/policy.py +409 -0
- seqforge/manifest/validate.py +274 -0
- seqforge/models/__init__.py +268 -0
- seqforge/models/assertion.py +68 -0
- seqforge/models/base.py +100 -0
- seqforge/models/blocker.py +71 -0
- seqforge/models/conflict.py +47 -0
- seqforge/models/dataset.py +320 -0
- seqforge/models/evidenced.py +54 -0
- seqforge/models/observation.py +157 -0
- seqforge/models/processing.py +231 -0
- seqforge/models/records.py +145 -0
- seqforge/models/resolve.py +216 -0
- seqforge/probe/__init__.py +46 -0
- seqforge/probe/core.py +232 -0
- seqforge/probe/signals.py +250 -0
- seqforge/probe/streaming.py +118 -0
- seqforge/project.py +177 -0
- seqforge/py.typed +0 -0
- seqforge/resolve/__init__.py +98 -0
- seqforge/resolve/assign.py +204 -0
- seqforge/resolve/cache.py +119 -0
- seqforge/resolve/confuse.py +215 -0
- seqforge/resolve/engine.py +646 -0
- seqforge/resolve/escalate.py +668 -0
- seqforge/resolve/evaluators.py +306 -0
- seqforge/resolve/geometry.py +89 -0
- seqforge/resolve/group.py +85 -0
- seqforge/resolve/records.py +550 -0
- seqforge/resolve/scoring.py +373 -0
- seqforge/resolve/window.py +206 -0
- seqforge/workflows/__init__.py +234 -0
- seqforge/workflows/cram.py +117 -0
- seqforge/workflows/h5ad.py +368 -0
- seqforge/workflows/map/star.smk +101 -0
- seqforge/workflows/map/starsolo.smk +360 -0
- seqforge/workflows/qc.py +157 -0
- seqforge/workspace.py +125 -0
- seqforge-2026.7.1.dist-info/METADATA +125 -0
- seqforge-2026.7.1.dist-info/RECORD +124 -0
- seqforge-2026.7.1.dist-info/WHEEL +4 -0
- seqforge-2026.7.1.dist-info/entry_points.txt +2 -0
- seqforge-2026.7.1.dist-info/licenses/LICENSE +21 -0
seqforge/io/taxonomy.py
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""Organism name -> NCBI taxid, with a round-trip verifier. **No model is involved, deliberately.**
|
|
2
|
+
|
|
3
|
+
This is the one gap in "the organism must come from the paper" that survived every audit, and the
|
|
4
|
+
shape of it is worth stating because it looks like a job for a language model and is not:
|
|
5
|
+
|
|
6
|
+
harvest extracts experiment.organism = "Caenorhabditis elegans" (a str, with a verified span)
|
|
7
|
+
fill_manifest wants experiment.organism_taxid = 6239 (an int)
|
|
8
|
+
and nothing joined them, so the taxid had to be retyped by hand.
|
|
9
|
+
|
|
10
|
+
The model already does its job here — it *finds* the organism in the prose, span-verified. What was
|
|
11
|
+
missing is a converter, and a converter is a lookup table. A model asked for the taxid directly would
|
|
12
|
+
be asked to *recall* one, and a model confusing 6239 (C. elegans) with 6238 (C. briggsae) produces a
|
|
13
|
+
number that is well-formed, plausible, and wrong in a way nothing downstream can see: a worm dataset
|
|
14
|
+
aligned to a different worm maps at a rate that looks merely mediocre. That is the failure class this
|
|
15
|
+
project exists to prevent, so: **the LLM finds, code resolves.**
|
|
16
|
+
|
|
17
|
+
**The verifier is a round trip, and it is what makes this safe.** Resolve the name to a taxid, then
|
|
18
|
+
fetch that taxid back and confirm it answers to the name we started from (scientific name, synonym, or
|
|
19
|
+
common name). A wrong lookup does not survive it. Note this would make even a *model-proposed* taxid
|
|
20
|
+
safe — the criterion is "cheaply verifiable", not "hard to hardcode" — but a table is simpler and free,
|
|
21
|
+
so there is no reason to reach for one.
|
|
22
|
+
|
|
23
|
+
Two things this module refuses to do:
|
|
24
|
+
|
|
25
|
+
- **Guess a rank.** `Saccharomyces cerevisiae` resolves to 4932 (the species). The sacCer3 fixtures use
|
|
26
|
+
559292 (strain S288C). Both are correct answers to different questions, and NCBI's search returns the
|
|
27
|
+
species — so when a caller wants the strain, they say so. Silently promoting or demoting a rank is
|
|
28
|
+
the kind of helpfulness that ends up in a corpus.
|
|
29
|
+
- **Reach the network without saying so.** The seed table below covers what we ship fixtures for. A
|
|
30
|
+
name outside it needs one E-utilities call, and offline that is a refusal with an actionable remedy,
|
|
31
|
+
never a guess.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import json
|
|
37
|
+
import os
|
|
38
|
+
import re
|
|
39
|
+
import time
|
|
40
|
+
import urllib.error
|
|
41
|
+
import urllib.parse
|
|
42
|
+
import urllib.request
|
|
43
|
+
from dataclasses import dataclass
|
|
44
|
+
from pathlib import Path
|
|
45
|
+
|
|
46
|
+
from ..workspace import cache_dir
|
|
47
|
+
from .remote import _MAX_RETRIES, _RETRY_STATUS, retry_delay
|
|
48
|
+
|
|
49
|
+
_EUTILS = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils"
|
|
50
|
+
|
|
51
|
+
#: Names we resolve without a network call. **Every entry was verified against NCBI E-utilities on
|
|
52
|
+
#: 2026-07-15 by the round trip this module implements** — none is remembered.
|
|
53
|
+
#:
|
|
54
|
+
#: This is a cache, not a curation: it exists so the common path is offline and instant, not to decide
|
|
55
|
+
#: which organisms are worth supporting (that would be a policy hiding in a dict). Anything else does
|
|
56
|
+
#: one lookup. Keys are lowercased.
|
|
57
|
+
_SEED: dict[str, int] = {
|
|
58
|
+
"caenorhabditis elegans": 6239,
|
|
59
|
+
"c. elegans": 6239,
|
|
60
|
+
"homo sapiens": 9606,
|
|
61
|
+
"human": 9606,
|
|
62
|
+
"mus musculus": 10090,
|
|
63
|
+
"mouse": 10090,
|
|
64
|
+
"saccharomyces cerevisiae": 4932,
|
|
65
|
+
"saccharomyces cerevisiae s288c": 559292,
|
|
66
|
+
"danio rerio": 7955,
|
|
67
|
+
"drosophila melanogaster": 7227,
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class TaxonomyUnavailable(RuntimeError):
|
|
72
|
+
"""A name could not be resolved (unknown, offline, or the round trip failed)."""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass(frozen=True)
|
|
76
|
+
class Taxon:
|
|
77
|
+
"""A resolved organism: the taxid, plus what NCBI says it is."""
|
|
78
|
+
|
|
79
|
+
taxid: int
|
|
80
|
+
scientific_name: str
|
|
81
|
+
rank: str = ""
|
|
82
|
+
names: tuple[str, ...] = ()
|
|
83
|
+
|
|
84
|
+
def answers_to(self, name: str) -> bool:
|
|
85
|
+
"""Does this taxon go by ``name``? The round-trip check's actual question."""
|
|
86
|
+
wanted = _normalize(name)
|
|
87
|
+
return wanted in {_normalize(n) for n in (self.scientific_name, *self.names)}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _normalize(name: str) -> str:
|
|
91
|
+
"""Casefold and collapse whitespace. Nothing cleverer: this is a key, not a parser."""
|
|
92
|
+
return re.sub(r"\s+", " ", name).strip().lower()
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _with_api_key(url: str) -> str:
|
|
96
|
+
"""Append ``api_key`` to a eutils URL when the environment sets ``NCBI_API_KEY`` (lifts the keyless
|
|
97
|
+
3 req/sec cap to 10). Only eutils URLs — the key is NCBI's and belongs on no other host (#9)."""
|
|
98
|
+
key = os.environ.get("NCBI_API_KEY")
|
|
99
|
+
if not key or not url.startswith(_EUTILS):
|
|
100
|
+
return url
|
|
101
|
+
sep = "&" if "?" in url else "?"
|
|
102
|
+
return f"{url}{sep}api_key={urllib.parse.quote(key)}"
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _get(url: str, *, timeout: float) -> str:
|
|
106
|
+
# Mirror `remote._get`: a transient status (429 rate-limit, a 5xx blip) backs off and retries
|
|
107
|
+
# rather than aborting the lookup. taxonomy uses urllib, so a non-2xx arrives as an HTTPError.
|
|
108
|
+
full = _with_api_key(url)
|
|
109
|
+
attempt = 0
|
|
110
|
+
while True: # exits only by return (2xx) or raise (terminal status / exhausted budget)
|
|
111
|
+
try:
|
|
112
|
+
with urllib.request.urlopen(full, timeout=timeout) as fh: # noqa: S310 - pinned NCBI host
|
|
113
|
+
return str(fh.read().decode())
|
|
114
|
+
except urllib.error.HTTPError as exc:
|
|
115
|
+
if exc.code in _RETRY_STATUS and attempt < _MAX_RETRIES:
|
|
116
|
+
time.sleep(retry_delay(exc.headers.get("Retry-After"), attempt))
|
|
117
|
+
attempt += 1
|
|
118
|
+
continue
|
|
119
|
+
raise
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def fetch_taxon(taxid: int, *, timeout: float = 20.0) -> Taxon:
|
|
123
|
+
"""What NCBI says taxid ``taxid`` is. The second half of the round trip."""
|
|
124
|
+
xml = _get(f"{_EUTILS}/efetch.fcgi?db=taxonomy&id={taxid}&retmode=xml", timeout=timeout)
|
|
125
|
+
sci = re.search(r"<ScientificName>(.*?)</ScientificName>", xml)
|
|
126
|
+
if not sci:
|
|
127
|
+
raise TaxonomyUnavailable(f"NCBI returned no scientific name for taxid {taxid}")
|
|
128
|
+
rank = re.search(r"<Rank>(.*?)</Rank>", xml)
|
|
129
|
+
names = tuple(
|
|
130
|
+
re.findall(r"<Synonym>(.*?)</Synonym>", xml)
|
|
131
|
+
+ re.findall(r"<GenbankCommonName>(.*?)</GenbankCommonName>", xml)
|
|
132
|
+
+ re.findall(r"<CommonName>(.*?)</CommonName>", xml)
|
|
133
|
+
)
|
|
134
|
+
return Taxon(
|
|
135
|
+
taxid=int(taxid),
|
|
136
|
+
scientific_name=sci.group(1),
|
|
137
|
+
rank=rank.group(1) if rank else "",
|
|
138
|
+
names=names,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def resolve(name: str, *, offline: bool = False, timeout: float = 20.0, verify: bool = True) -> int:
|
|
143
|
+
"""``"Caenorhabditis elegans"`` -> ``6239``. Raises rather than guessing.
|
|
144
|
+
|
|
145
|
+
``verify`` runs the round trip: the taxid is fetched back and must answer to ``name``. It is on by
|
|
146
|
+
default and should stay on — a lookup that returns the wrong taxid is otherwise undetectable, and
|
|
147
|
+
the whole reason this is code instead of a model is that we can check it.
|
|
148
|
+
"""
|
|
149
|
+
key = _normalize(name)
|
|
150
|
+
if key in _SEED:
|
|
151
|
+
return _SEED[key]
|
|
152
|
+
if offline:
|
|
153
|
+
raise TaxonomyUnavailable(
|
|
154
|
+
f"cannot resolve organism {name!r} offline. Pass the NCBI taxid directly "
|
|
155
|
+
f"(`--organism <taxid>`), or run with network access."
|
|
156
|
+
)
|
|
157
|
+
term = urllib.parse.quote(name)
|
|
158
|
+
try:
|
|
159
|
+
payload = json.loads(
|
|
160
|
+
_get(f"{_EUTILS}/esearch.fcgi?db=taxonomy&term={term}&retmode=json", timeout=timeout)
|
|
161
|
+
)
|
|
162
|
+
ids = payload["esearchresult"]["idlist"]
|
|
163
|
+
except Exception as exc: # network, JSON, or shape - all mean "we do not know"
|
|
164
|
+
raise TaxonomyUnavailable(f"NCBI taxonomy lookup for {name!r} failed: {exc}") from exc
|
|
165
|
+
if not ids:
|
|
166
|
+
raise TaxonomyUnavailable(
|
|
167
|
+
f"NCBI taxonomy has no match for organism {name!r}. Check the spelling, or pass the "
|
|
168
|
+
f"taxid directly with `--organism <taxid>`."
|
|
169
|
+
)
|
|
170
|
+
if len(ids) > 1:
|
|
171
|
+
raise TaxonomyUnavailable(
|
|
172
|
+
f"organism {name!r} is ambiguous: NCBI returns taxids {ids}. Pass the one you mean with "
|
|
173
|
+
f"`--organism <taxid>` — picking for you is exactly the guess this refuses to make."
|
|
174
|
+
)
|
|
175
|
+
taxid = int(ids[0])
|
|
176
|
+
if verify:
|
|
177
|
+
taxon = fetch_taxon(taxid, timeout=timeout)
|
|
178
|
+
if not taxon.answers_to(name):
|
|
179
|
+
raise TaxonomyUnavailable(
|
|
180
|
+
f"round trip failed: {name!r} resolved to taxid {taxid}, but NCBI says that is "
|
|
181
|
+
f"{taxon.scientific_name!r} (rank {taxon.rank}). Refusing — a wrong organism aligns "
|
|
182
|
+
f"at a rate that merely looks mediocre, and nothing downstream would object."
|
|
183
|
+
)
|
|
184
|
+
return taxid
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def seed_names() -> dict[str, int]:
|
|
188
|
+
"""The offline table, for tests and `io taxon list`."""
|
|
189
|
+
return dict(_SEED)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
#: Where a resolved lookup is cached under a workspace, so a second run is offline and instant.
|
|
193
|
+
def cache_path(workspace: str | Path) -> Path:
|
|
194
|
+
return cache_dir(workspace) / "taxonomy.json"
|
seqforge/kb/__init__.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""``kb`` — the executable, self-testing knowledge base.
|
|
2
|
+
|
|
3
|
+
One directory per technology under ``specs/`` (``spec.yaml`` + ``README.md``). Each spec validates
|
|
4
|
+
against :class:`~seqforge.kb.schema.Spec`, generates its own synthetic fixtures, and round-trips
|
|
5
|
+
through the probe. ``KB_VERSION`` (CalVer) is folded into dataset-level cache keys.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from .generate import build_pools, generate_reads
|
|
11
|
+
from .loader import (
|
|
12
|
+
KbTree,
|
|
13
|
+
build_tree,
|
|
14
|
+
list_spec_ids,
|
|
15
|
+
load_all_specs,
|
|
16
|
+
load_spec,
|
|
17
|
+
load_tree,
|
|
18
|
+
runnable_spec_ids,
|
|
19
|
+
)
|
|
20
|
+
from .roundtrip import run_roundtrip
|
|
21
|
+
from .schema import Spec
|
|
22
|
+
|
|
23
|
+
#: CalVer YYYY.M.PATCH; bump when spec semantics change. Folded into dataset candidate cache keys.
|
|
24
|
+
#: 2026.7.3 — 10x 3' v2/v3/v3.1 accept an OVER-LENGTH barcode read (R1 max_len null +
|
|
25
|
+
#: segment_length over_length_min 100) and add soloBarcodeReadLength:0; v2<->v3 and v2<->v3.1 declared
|
|
26
|
+
#: processing_divergent (whitelist-decided) for the over-length case length can no longer separate.
|
|
27
|
+
#: (over_length_min is 100, not 40, so a 60-94 bp cDNA/split-pool read is not mistaken for an
|
|
28
|
+
#: over-sequenced barcode read -> the rung-0-2 separability guard stays green without over-declaring.)
|
|
29
|
+
#: 2026.7.2 — bulk-rnaseq-pe <-> splitseq declared processing_divergent, distinguishable_by onlist.
|
|
30
|
+
#: Found by the new rung-0-2 separability guard on its first run: the generic paired-end fallback
|
|
31
|
+
#: accepts SPLiT-seq's cdna+bc pair on geometry alone and had declared nothing.
|
|
32
|
+
#: 2026.7.1 — the parse/count line: soloFeatures / quantMode / outSAMtype left backend.params,
|
|
33
|
+
#: which now declares ONLY byte-decided parse keys. Also adds the 10x-3p-gex-v3.1 benign twin.
|
|
34
|
+
#: 2026.7.4 — added the bd-rhapsody-wta spec (BD Rhapsody WTA, original fixed-offset cell-label bead:
|
|
35
|
+
#: CB_UMI_Complex, three 97 x 9 bp CLS whitelists SHIPPED, two fixed linkers). bulk-rnaseq-pe <->
|
|
36
|
+
#: bd-rhapsody-wta declared processing_divergent, distinguishable_by onlist (same rung-0-2 collision
|
|
37
|
+
#: SPLiT-seq has with the generic paired-end fallback).
|
|
38
|
+
#: 2026.7.5 — the KB became a TREE: added the abstract family node 10x-3p-gex (node_kind: family, no
|
|
39
|
+
#: backend, children_decided_by: [onlist]) that recognizes v2/v3/v3.1 and rejects bulk; the three 10x
|
|
40
|
+
#: leaves gained parent: 10x-3p-gex and dropped their divergent-sibling confusable_with cliques (the
|
|
41
|
+
#: shared parent now implies them); v3<->v3.1 processing_equivalent edges kept. Descent narrows to a
|
|
42
|
+
#: length-feasible pool before scoring, so adding the Nth 10x chemistry is one parent link, not a clique.
|
|
43
|
+
#: 2026.7.6 — first ANCHORED chemistry: the bd-rhapsody-wta-enhanced family + leaves -96 / -v2 for the
|
|
44
|
+
#: BD Rhapsody Enhanced bead (variable 0-3 bp diversity insert). New `diversity` element type + per-
|
|
45
|
+
#: element anchors; the family recognizes the GTGA/GACA frame by motif; leaves split on onlist (97 vs
|
|
46
|
+
#: 384 CLS pools, disjoint). Ships bd-rhapsody-cls{1,2,3}-384; -96 reuses the original bead's lists.
|
|
47
|
+
KB_VERSION = "2026.7.6"
|
|
48
|
+
|
|
49
|
+
__all__ = [
|
|
50
|
+
"KB_VERSION",
|
|
51
|
+
"Spec",
|
|
52
|
+
"KbTree",
|
|
53
|
+
"load_spec",
|
|
54
|
+
"load_all_specs",
|
|
55
|
+
"load_tree",
|
|
56
|
+
"build_tree",
|
|
57
|
+
"list_spec_ids",
|
|
58
|
+
"runnable_spec_ids",
|
|
59
|
+
"generate_reads",
|
|
60
|
+
"build_pools",
|
|
61
|
+
"run_roundtrip",
|
|
62
|
+
]
|
seqforge/kb/anchor.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Per-read window resolution for **anchored / variable-position** elements (design §2.1, §9/§10).
|
|
2
|
+
|
|
3
|
+
Most chemistries put every element at a fixed ``[start, end)`` and the scorer slices a constant column.
|
|
4
|
+
A few do not: BD Rhapsody's *Enhanced* bead prepends a **variable 0-3 bp diversity insert** to Read 1,
|
|
5
|
+
so every cell-label block downstream **staggers** by a per-read amount and no single column holds a
|
|
6
|
+
barcode. STARsolo handles this with an *adapter anchor* (find the ``GTGA…GACA`` linker frame, read the
|
|
7
|
+
barcodes relative to it); this module is seqforge's byte-side twin of that idea.
|
|
8
|
+
|
|
9
|
+
``resolve_windows(seq, read)`` locates each element's ``[start, end)`` **in one read** by
|
|
10
|
+
**phase detection**: the only unknowns are the lengths of the variable-length elements (the insert),
|
|
11
|
+
so enumerate their candidate lengths, walk the element chain for each, and keep the phase whose
|
|
12
|
+
``linker``/``fixed`` elements Hamming-match their declared ``sequence`` (substitutions only, exactly
|
|
13
|
+
the error model rhapsodist uses across its 0-3 bp offset scan). A read whose frame cannot be found
|
|
14
|
+
(a cDNA read, a garbage read) returns ``None`` and simply does not contribute — never a wrong slice.
|
|
15
|
+
|
|
16
|
+
This lives in ``kb`` (not ``resolve`` or ``probe``) on purpose: it is pure layout logic over a
|
|
17
|
+
``kb.schema.Read`` and a string, and both ``kb.roundtrip`` (which cannot import ``resolve`` — that is a
|
|
18
|
+
cycle) and ``resolve.window`` need it.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from itertools import product
|
|
24
|
+
|
|
25
|
+
from .schema import Element, Read
|
|
26
|
+
|
|
27
|
+
#: Substitutions tolerated when Hamming-matching a linker whose element declares no ``anchor``
|
|
28
|
+
#: ``max_mismatch``. One per short linker absorbs ordinary sequencing error without letting a wrong
|
|
29
|
+
#: frame match (two independent linkers must BOTH match at the right spacing to lock a phase).
|
|
30
|
+
_DEFAULT_LINKER_MAX_MISMATCH = 1
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def has_anchored_elements(read: Read) -> bool:
|
|
34
|
+
"""True iff any element on this read floats (declares an ``anchor``) — the resolver's trigger."""
|
|
35
|
+
return any(el.anchor is not None for el in read.elements)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _fixed_width(el: Element) -> int | None:
|
|
39
|
+
"""The element's width when it is known up front; ``None`` for a variable-length / open element."""
|
|
40
|
+
if el.start is not None and el.end is not None:
|
|
41
|
+
return el.end - el.start
|
|
42
|
+
if el.sequence is not None:
|
|
43
|
+
return len(el.sequence)
|
|
44
|
+
if el.min_len is not None and el.min_len == el.max_len:
|
|
45
|
+
return el.min_len
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _variable_elements(read: Read) -> list[Element]:
|
|
50
|
+
"""Elements whose length is an unknown to solve — a declared ``[min_len, max_len]`` with min<max.
|
|
51
|
+
|
|
52
|
+
These are the phase's degrees of freedom (BD Enhanced has exactly one: the 0-3 bp insert). An
|
|
53
|
+
open-ended cDNA/gDNA tail is NOT one of these — it has no upstream effect on the barcode frame, so
|
|
54
|
+
it is resolved last, running to the end of the read.
|
|
55
|
+
"""
|
|
56
|
+
out: list[Element] = []
|
|
57
|
+
for el in read.elements:
|
|
58
|
+
if el.type in ("cdna", "gdna") and el.end is None:
|
|
59
|
+
continue
|
|
60
|
+
if el.min_len is not None and el.max_len is not None and el.min_len < el.max_len:
|
|
61
|
+
out.append(el)
|
|
62
|
+
return out
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _candidate_assignments(read: Read, *, cap: int = 256) -> list[dict[str, int]]:
|
|
66
|
+
"""Every combination of lengths for the variable elements, as ``{element_name: length}``.
|
|
67
|
+
|
|
68
|
+
Capped so a pathological spec cannot make resolution exponential; BD Enhanced yields 4 candidates
|
|
69
|
+
(insert length 0-3). Returns ``[{}]`` (the single empty assignment) when nothing is variable, so a
|
|
70
|
+
purely motif-anchored fixed-width chemistry still resolves.
|
|
71
|
+
"""
|
|
72
|
+
variable = _variable_elements(read)
|
|
73
|
+
if not variable:
|
|
74
|
+
return [{}]
|
|
75
|
+
ranges = [range(el.min_len or 0, (el.max_len or 0) + 1) for el in variable]
|
|
76
|
+
names = [el.name for el in variable]
|
|
77
|
+
combos = list(product(*ranges))
|
|
78
|
+
if len(combos) > cap:
|
|
79
|
+
combos = combos[:cap]
|
|
80
|
+
return [dict(zip(names, lengths, strict=True)) for lengths in combos]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _anchor_start(el: Element, windows: dict[str, tuple[int, int]], prev_end: int) -> int:
|
|
84
|
+
"""Where this element begins, from its ``anchor`` (falling back to the previous element's end).
|
|
85
|
+
|
|
86
|
+
The chain BD Enhanced declares is sequential — each element anchored to the previous element's
|
|
87
|
+
``end`` with ``offset 0`` — so resolving in element order means every ``ref_element`` is already
|
|
88
|
+
placed, and this reduces to a cumulative walk. Honouring the anchor rather than assuming adjacency
|
|
89
|
+
keeps a non-zero ``offset`` or a ``read_start``-relative element correct too.
|
|
90
|
+
"""
|
|
91
|
+
a = el.anchor
|
|
92
|
+
if a is None:
|
|
93
|
+
return el.start if el.start is not None else prev_end
|
|
94
|
+
if a.relative_to == "element" and a.ref_element and a.ref_element in windows:
|
|
95
|
+
ref = windows[a.ref_element]
|
|
96
|
+
base = ref[0] if a.ref_side == "start" else ref[1]
|
|
97
|
+
return base + a.offset
|
|
98
|
+
if a.relative_to == "read_start":
|
|
99
|
+
return a.offset
|
|
100
|
+
# read_end, or an unresolved ref: fall back to the running position (the chain never needs this).
|
|
101
|
+
return prev_end + a.offset
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _resolve_for_assignment(
|
|
105
|
+
seq: str, read: Read, assignment: dict[str, int]
|
|
106
|
+
) -> tuple[dict[str, tuple[int, int]], int, bool] | None:
|
|
107
|
+
"""Place every element for one candidate length assignment.
|
|
108
|
+
|
|
109
|
+
Returns ``(windows, linker_mismatches, all_linkers_matched)`` or ``None`` if the layout does not
|
|
110
|
+
fit the read (a window runs off the 3' end). ``linker_mismatches`` is the total Hamming distance
|
|
111
|
+
across all ``linker``/``fixed`` elements — the smaller, the better the frame fits.
|
|
112
|
+
"""
|
|
113
|
+
windows: dict[str, tuple[int, int]] = {}
|
|
114
|
+
pos = 0
|
|
115
|
+
mismatches = 0
|
|
116
|
+
all_matched = True
|
|
117
|
+
n = len(seq)
|
|
118
|
+
elements = read.elements
|
|
119
|
+
for i, el in enumerate(elements):
|
|
120
|
+
start = _anchor_start(el, windows, pos)
|
|
121
|
+
width = assignment.get(el.name)
|
|
122
|
+
if width is None:
|
|
123
|
+
width = _fixed_width(el)
|
|
124
|
+
if width is None:
|
|
125
|
+
# open-ended element (cDNA tail): runs to the end of the read; must be terminal.
|
|
126
|
+
if i != len(elements) - 1:
|
|
127
|
+
return None
|
|
128
|
+
end = n
|
|
129
|
+
else:
|
|
130
|
+
end = start + width
|
|
131
|
+
if start < 0 or end > n:
|
|
132
|
+
return None # this phase pushes an element off the read
|
|
133
|
+
windows[el.name] = (start, end)
|
|
134
|
+
if el.sequence is not None: # a linker/fixed element anchors the frame
|
|
135
|
+
tol = el.anchor.max_mismatch if el.anchor is not None else _DEFAULT_LINKER_MAX_MISMATCH
|
|
136
|
+
d = _hamming(seq[start:end], el.sequence)
|
|
137
|
+
mismatches += d
|
|
138
|
+
if d > tol:
|
|
139
|
+
all_matched = False
|
|
140
|
+
pos = end
|
|
141
|
+
return windows, mismatches, all_matched
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def resolve_windows(seq: str, read: Read) -> dict[str, tuple[int, int]] | None:
|
|
145
|
+
"""Locate each element's ``[start, end)`` in ``seq`` by phase detection; ``None`` if not found.
|
|
146
|
+
|
|
147
|
+
Picks the candidate phase whose linker/fixed elements all Hamming-match their declared sequence
|
|
148
|
+
(ties broken by fewest total mismatches). Requires at least one linker to anchor on — with none,
|
|
149
|
+
the frame is unobservable and the read is left unresolved rather than sliced arbitrarily.
|
|
150
|
+
"""
|
|
151
|
+
if not any(el.sequence is not None for el in read.elements):
|
|
152
|
+
return None
|
|
153
|
+
best: tuple[int, dict[str, tuple[int, int]]] | None = None
|
|
154
|
+
for assignment in _candidate_assignments(read):
|
|
155
|
+
resolved = _resolve_for_assignment(seq, read, assignment)
|
|
156
|
+
if resolved is None:
|
|
157
|
+
continue
|
|
158
|
+
windows, mismatches, all_matched = resolved
|
|
159
|
+
if not all_matched:
|
|
160
|
+
continue
|
|
161
|
+
if best is None or mismatches < best[0]:
|
|
162
|
+
best = (mismatches, windows)
|
|
163
|
+
return best[1] if best is not None else None
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _hamming(a: str, b: str) -> int:
|
|
167
|
+
"""Substitution distance over the compared length; length mismatch counts as all-different tail."""
|
|
168
|
+
d = sum(1 for x, y in zip(a, b, strict=False) if x != y) # short-zip: tail counted below
|
|
169
|
+
return d + abs(len(a) - len(b))
|
seqforge/kb/generate.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Synthetic FASTQ generation derived PURELY from ``spec.reads`` (round-trip).
|
|
2
|
+
|
|
3
|
+
The generator never reads ``signature`` or ``backend`` — that is what makes the round-trip
|
|
4
|
+
(``spec -> synth -> probe -> recover; assert recovered == declared``) a real test rather than a
|
|
5
|
+
tautology. Barcodes with an onlist are drawn from a fixed synthetic cell pool reused across reads so
|
|
6
|
+
the recurrence signal (low distinct-ratio) is realistic; UMIs are fresh-random (high distinct-ratio).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import gzip
|
|
12
|
+
import random
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from .schema import Element, Spec
|
|
16
|
+
|
|
17
|
+
_BASES = "ACGT"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def write_fastq_gz(path: Path, seqs: list[str], *, prefix: str = "SIM") -> None:
|
|
21
|
+
"""Write a REPRODUCIBLE ``.fastq.gz``: same reads in, same bytes out, hence the same sha256.
|
|
22
|
+
|
|
23
|
+
``gzip.open(path, "wt")`` stamps the current mtime into the gzip header (and embeds the source
|
|
24
|
+
filename), so regenerating identical reads a second later produces different bytes. Everything
|
|
25
|
+
downstream is content-addressed by file sha256, so a wall-clock-dependent header silently
|
|
26
|
+
changes the dataset id — two runs over the same synthetic input never share a cache entry, and
|
|
27
|
+
"deterministic in (spec, seed)" quietly stops being true at the byte level where it is claimed.
|
|
28
|
+
``mtime=0`` + ``filename=""`` make the output a pure function of the reads.
|
|
29
|
+
|
|
30
|
+
One writer, because this was duplicated at three sites and each carried the same latent bug.
|
|
31
|
+
"""
|
|
32
|
+
payload = "".join(
|
|
33
|
+
f"@{prefix}:{i}\n{s}\n+\n{'I' * len(s)}\n" for i, s in enumerate(seqs)
|
|
34
|
+
).encode()
|
|
35
|
+
with open(path, "wb") as raw, gzip.GzipFile(filename="", mode="wb", fileobj=raw, mtime=0) as gz:
|
|
36
|
+
gz.write(payload)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _rand(rng: random.Random, n: int) -> str:
|
|
40
|
+
return "".join(rng.choice(_BASES) for _ in range(n))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _fixed_length(el: Element) -> int | None:
|
|
44
|
+
"""The element's constant width (pool-barcode width), or ``None`` if it has none.
|
|
45
|
+
|
|
46
|
+
A variable-length element (``min_len < max_len``) has no single width — this returns ``min_len`` as
|
|
47
|
+
the pool width for a barcode that happens to be variable, but the per-read *generated* length comes
|
|
48
|
+
from :func:`_draw_length`, which is what actually creates the diversity-insert stagger.
|
|
49
|
+
"""
|
|
50
|
+
if el.start is not None and el.end is not None:
|
|
51
|
+
return el.end - el.start
|
|
52
|
+
if el.min_len is not None:
|
|
53
|
+
return el.min_len
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _draw_length(el: Element, rng: random.Random) -> int | None:
|
|
58
|
+
"""The length to GENERATE for one read: fixed when the element is fixed, drawn when it floats.
|
|
59
|
+
|
|
60
|
+
A ``[min_len, max_len]`` with ``min < max`` (BD Rhapsody Enhanced's 0-3 bp diversity insert) draws
|
|
61
|
+
a per-read length so downstream elements shift — the stagger the anchored resolver must recover.
|
|
62
|
+
Collapsing it to a constant (the pre-#43 behaviour) made the round-trip verify nothing about
|
|
63
|
+
anchoring: every synthetic read had the same offsets a fixed-window slice would have found anyway.
|
|
64
|
+
"""
|
|
65
|
+
if el.start is not None and el.end is not None:
|
|
66
|
+
return el.end - el.start
|
|
67
|
+
if el.min_len is not None and el.max_len is not None and el.min_len != el.max_len:
|
|
68
|
+
return rng.randint(el.min_len, el.max_len)
|
|
69
|
+
if el.min_len is not None:
|
|
70
|
+
return el.min_len
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def build_pools(spec: Spec, *, seed: int = 0, pool_size: int = 64) -> dict[str, list[str]]:
|
|
75
|
+
"""Build the synthetic barcode pool backing each onlist *alias*, keyed by ``Element.onlist``.
|
|
76
|
+
|
|
77
|
+
Deterministic in ``(seed, pool_size)`` and independent of read generation, so a test can
|
|
78
|
+
reconstruct the exact whitelist the reads will be drawn from (to register it as a synthetic
|
|
79
|
+
onlist for the resolver). ``pool_size`` distinct barcodes drive the low-distinct-ratio recurrence
|
|
80
|
+
signal; keep it well below the read count.
|
|
81
|
+
"""
|
|
82
|
+
rng = random.Random(seed)
|
|
83
|
+
pools: dict[str, list[str]] = {}
|
|
84
|
+
for read in spec.reads:
|
|
85
|
+
for el in read.elements:
|
|
86
|
+
if el.onlist and el.onlist not in pools:
|
|
87
|
+
length = _fixed_length(el) or 16
|
|
88
|
+
pools[el.onlist] = [_rand(rng, length) for _ in range(pool_size)]
|
|
89
|
+
return pools
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def generate_reads(
|
|
93
|
+
spec: Spec,
|
|
94
|
+
*,
|
|
95
|
+
n: int = 2000,
|
|
96
|
+
seed: int = 0,
|
|
97
|
+
pool_size: int = 64,
|
|
98
|
+
cdna_min: int = 60,
|
|
99
|
+
cdna_max: int = 91,
|
|
100
|
+
pools: dict[str, list[str]] | None = None,
|
|
101
|
+
) -> dict[str, list[str]]:
|
|
102
|
+
"""Generate ``n`` synthetic reads per declared read, keyed by ``Read.id``.
|
|
103
|
+
|
|
104
|
+
``pool_size`` sets how many distinct barcodes back each onlist (drives the recurrence signal);
|
|
105
|
+
keep it well below ``n`` so the cell-barcode distinct-ratio lands in-band. Pass ``pools`` (from
|
|
106
|
+
:func:`build_pools` with the same ``seed``/``pool_size``) to reuse a known whitelist.
|
|
107
|
+
"""
|
|
108
|
+
if pools is None:
|
|
109
|
+
pools = build_pools(spec, seed=seed, pool_size=pool_size)
|
|
110
|
+
rng = random.Random(seed + 1) # a stream distinct from pool construction, still deterministic
|
|
111
|
+
result: dict[str, list[str]] = {}
|
|
112
|
+
for read in spec.reads:
|
|
113
|
+
seqs: list[str] = []
|
|
114
|
+
for _ in range(n):
|
|
115
|
+
seqs.append(
|
|
116
|
+
"".join(_gen_element(el, rng, pools, cdna_min, cdna_max) for el in read.elements)
|
|
117
|
+
)
|
|
118
|
+
result[read.id] = seqs
|
|
119
|
+
return result
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _gen_element(
|
|
123
|
+
el: Element,
|
|
124
|
+
rng: random.Random,
|
|
125
|
+
pools: dict[str, list[str]],
|
|
126
|
+
cdna_min: int,
|
|
127
|
+
cdna_max: int,
|
|
128
|
+
) -> str:
|
|
129
|
+
if el.type in ("linker", "fixed"):
|
|
130
|
+
return el.sequence or ""
|
|
131
|
+
if el.type == "diversity":
|
|
132
|
+
# a variable 5' insert of random bases -> the per-read stagger; length drawn in [min,max]
|
|
133
|
+
return _rand(rng, _draw_length(el, rng) or 0)
|
|
134
|
+
length = _draw_length(el, rng)
|
|
135
|
+
if el.type == "barcode":
|
|
136
|
+
return rng.choice(pools[el.onlist]) if el.onlist else _rand(rng, length or 8)
|
|
137
|
+
if el.type == "umi":
|
|
138
|
+
return _rand(rng, length or 10)
|
|
139
|
+
if el.type in ("cdna", "gdna"):
|
|
140
|
+
return _rand(rng, length or rng.randint(cdna_min, cdna_max))
|
|
141
|
+
if el.type == "poly_t":
|
|
142
|
+
return "T" * (length or 10)
|
|
143
|
+
if el.type == "poly_a":
|
|
144
|
+
return "A" * (length or 10)
|
|
145
|
+
if el.type == "index":
|
|
146
|
+
return _rand(rng, length or 8)
|
|
147
|
+
return _rand(rng, length or 1)
|