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/e2e.py
ADDED
|
@@ -0,0 +1,1926 @@
|
|
|
1
|
+
"""``kb e2e`` — the one real end-to-end run, asserted against injected ground truth (design §4.1.3).
|
|
2
|
+
|
|
3
|
+
This is the only gate that can catch the failures that **do not error**: an inverted ``--soloStrand``,
|
|
4
|
+
a wrong ``--soloUMIlen``, a mangled whitelist. STARsolo exits 0 and emits a matrix that merely looks
|
|
5
|
+
like a thin dataset — a dry run and a linter see nothing. So we run the real toolchain on data small
|
|
6
|
+
enough to be free (sacCer3, 12 Mb) and assert **the count matrix equals the ground truth we injected**.
|
|
7
|
+
|
|
8
|
+
It drives the WHOLE compiler, not just the aligner:
|
|
9
|
+
|
|
10
|
+
simulate reads from sacCer3 transcripts (+ injected barcodes/UMIs)
|
|
11
|
+
-> probe -> resolve (must independently decide 10x 3' v3 from the bytes)
|
|
12
|
+
-> manifest fill/validate
|
|
13
|
+
-> compose (emits the STARsolo params from the KB)
|
|
14
|
+
-> STARsolo (run with THOSE composed params)
|
|
15
|
+
-> assert counts == injected truth
|
|
16
|
+
|
|
17
|
+
The strand check is the point, so it is proven both ways: the composed (Forward) params must recover
|
|
18
|
+
the truth, and the same reads under an inverted `--soloStrand Reverse` must collapse — otherwise the
|
|
19
|
+
test could not have caught an inversion in the first place.
|
|
20
|
+
|
|
21
|
+
Requires a toolchain seqforge does not own (STAR + a built genome index), so it is skip-gated
|
|
22
|
+
everywhere else and runs on a Linux compute node.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import gzip
|
|
28
|
+
import hashlib
|
|
29
|
+
import json
|
|
30
|
+
import multiprocessing
|
|
31
|
+
import os
|
|
32
|
+
import random
|
|
33
|
+
import re
|
|
34
|
+
import resource
|
|
35
|
+
import subprocess
|
|
36
|
+
import sys
|
|
37
|
+
import time
|
|
38
|
+
from collections import defaultdict
|
|
39
|
+
from collections.abc import Sequence
|
|
40
|
+
from concurrent.futures import ProcessPoolExecutor
|
|
41
|
+
from dataclasses import dataclass, field
|
|
42
|
+
from itertools import product as _product
|
|
43
|
+
from pathlib import Path
|
|
44
|
+
|
|
45
|
+
import yaml
|
|
46
|
+
|
|
47
|
+
from . import __version__
|
|
48
|
+
from .compose import compose
|
|
49
|
+
from .compose import plan as compose_plan
|
|
50
|
+
from .io import OnlistRegistry
|
|
51
|
+
from .kb import load_spec
|
|
52
|
+
from .kb.generate import write_fastq_gz as _write_fastq_gz
|
|
53
|
+
from .manifest import (
|
|
54
|
+
ExperimentInputs,
|
|
55
|
+
ProcessingInputs,
|
|
56
|
+
fill_manifest,
|
|
57
|
+
fill_processing,
|
|
58
|
+
validate_manifest,
|
|
59
|
+
)
|
|
60
|
+
from .models.dataset import DatasetManifest, SampleGroup
|
|
61
|
+
from .models.evidenced import EvidencedTaxid
|
|
62
|
+
from .models.processing import ProcessingManifest
|
|
63
|
+
from .probe import probe_file
|
|
64
|
+
from .resolve import resolve_dataset
|
|
65
|
+
|
|
66
|
+
_GENE_ID = re.compile(r'gene_id "([^"]+)"')
|
|
67
|
+
#: Ensembl and WormBase spell it ``gene_biotype``; GENCODE spells it ``gene_type``. Match both.
|
|
68
|
+
#:
|
|
69
|
+
#: This was ``gene_biotype`` alone until hg38 arrived, and every assembly the gates had used until
|
|
70
|
+
#: then (sacCer3/ensembl_R64-1-1, ce11/WS298) is Ensembl-flavoured — so the pattern was never wrong
|
|
71
|
+
#: in a run anyone made. On a GENCODE GTF it matches nothing, and because the filter below reads
|
|
72
|
+
#: ``if biotype and ...``, matching nothing meant *filtering* nothing: it failed OPEN, silently
|
|
73
|
+
#: admitting every lncRNA and pseudogene to a fixture whose docstring promises protein-coding genes.
|
|
74
|
+
#: :func:`_parse_exons` now refuses a GTF it cannot filter rather than quietly widening.
|
|
75
|
+
_BIOTYPE = re.compile(r'gene_(?:bio)?type "([^"]+)"')
|
|
76
|
+
_COMPLEMENT = str.maketrans("ACGTN", "TGCAN")
|
|
77
|
+
|
|
78
|
+
#: The pilot's e2e chemistry: 16 bp CB + 12 bp UMI on R1, cDNA on R2, soloStrand Forward.
|
|
79
|
+
E2E_TECH = "10x-3p-gex-v3"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class E2EUnavailable(RuntimeError):
|
|
83
|
+
"""The e2e toolchain (STAR + a genome index) is not present on this machine."""
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass(frozen=True)
|
|
87
|
+
class E2EAssets:
|
|
88
|
+
"""Everything the run needs from outside seqforge (liulab-genome + liulab-runtime own these)."""
|
|
89
|
+
|
|
90
|
+
fasta: Path
|
|
91
|
+
gtf: Path
|
|
92
|
+
star_index: Path
|
|
93
|
+
star_bin: str
|
|
94
|
+
assembly: str = "sacCer3"
|
|
95
|
+
annotation: str = "ensembl_R64-1-1"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass
|
|
99
|
+
class Simulation:
|
|
100
|
+
"""Simulated reads plus the ground truth they were generated from."""
|
|
101
|
+
|
|
102
|
+
cdna: list[str] = field(default_factory=list)
|
|
103
|
+
barcode: list[str] = field(default_factory=list)
|
|
104
|
+
#: (cell_barcode, gene_id) -> injected UMI count (UMIs are unique, so this is the read count)
|
|
105
|
+
truth: dict[tuple[str, str], int] = field(default_factory=dict)
|
|
106
|
+
whitelist: list[str] = field(default_factory=list)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _revcomp(seq: str) -> str:
|
|
110
|
+
return seq.translate(_COMPLEMENT)[::-1]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def read_fasta(path: Path) -> dict[str, str]:
|
|
114
|
+
"""Load a small genome into memory (sacCer3 is 12 Mb — this is the whole point of using it)."""
|
|
115
|
+
chroms: dict[str, str] = {}
|
|
116
|
+
name = None
|
|
117
|
+
chunks: list[str] = []
|
|
118
|
+
with open(path) as fh:
|
|
119
|
+
for line in fh:
|
|
120
|
+
if line.startswith(">"):
|
|
121
|
+
if name is not None:
|
|
122
|
+
chroms[name] = "".join(chunks)
|
|
123
|
+
name = line[1:].split()[0]
|
|
124
|
+
chunks = []
|
|
125
|
+
else:
|
|
126
|
+
chunks.append(line.strip())
|
|
127
|
+
if name is not None:
|
|
128
|
+
chroms[name] = "".join(chunks)
|
|
129
|
+
return chroms
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _parse_exons(
|
|
133
|
+
gtf: Path, *, biotype: str = "protein_coding"
|
|
134
|
+
) -> dict[str, list[tuple[str, int, int, str]]]:
|
|
135
|
+
"""Collect ``gene_id -> [(chrom, start, end, strand)]`` for exons of the wanted biotype.
|
|
136
|
+
|
|
137
|
+
**This refuses rather than widens.** A GTF whose exon lines carry no recognized biotype attribute
|
|
138
|
+
at all cannot be filtered, and the honest options are to error or to silently keep everything.
|
|
139
|
+
Keeping everything is what the Ensembl-only pattern used to do on a GENCODE GTF, and it is the
|
|
140
|
+
worse failure precisely because it does not look like one: the fixture still builds, the gate
|
|
141
|
+
still passes, and the gene universe is quietly the wrong one. So we raise.
|
|
142
|
+
"""
|
|
143
|
+
exons: dict[str, list[tuple[str, int, int, str]]] = defaultdict(list)
|
|
144
|
+
n_exon_lines = 0
|
|
145
|
+
n_typed = 0
|
|
146
|
+
with open(gtf) as fh:
|
|
147
|
+
for line in fh:
|
|
148
|
+
if line.startswith("#"):
|
|
149
|
+
continue
|
|
150
|
+
f = line.rstrip("\n").split("\t")
|
|
151
|
+
if len(f) < 9 or f[2] != "exon":
|
|
152
|
+
continue
|
|
153
|
+
n_exon_lines += 1
|
|
154
|
+
found = _BIOTYPE.search(f[8])
|
|
155
|
+
if found is not None:
|
|
156
|
+
n_typed += 1
|
|
157
|
+
if found.group(1) != biotype:
|
|
158
|
+
continue
|
|
159
|
+
gid = _GENE_ID.search(f[8])
|
|
160
|
+
if not gid:
|
|
161
|
+
continue
|
|
162
|
+
exons[gid.group(1)].append((f[0], int(f[3]), int(f[4]), f[6]))
|
|
163
|
+
if n_exon_lines and not n_typed:
|
|
164
|
+
raise E2EUnavailable(
|
|
165
|
+
f"{gtf} has {n_exon_lines} exon lines and not one carries `gene_biotype` or `gene_type`, "
|
|
166
|
+
f"so the {biotype!r} filter would pass every biotype through and the fixture would be "
|
|
167
|
+
"built from an annotation we cannot filter. Refusing instead of silently widening."
|
|
168
|
+
)
|
|
169
|
+
return exons
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def load_genes(
|
|
173
|
+
fasta: Path, gtf: Path, *, min_len: int = 600, max_genes: int = 120, seed: int = 0
|
|
174
|
+
) -> list[tuple[str, str]]:
|
|
175
|
+
"""Build spliced, sense-strand mRNA sequences per gene from the GTF's exons.
|
|
176
|
+
|
|
177
|
+
Returns ``(gene_id, mrna)`` for protein-coding genes long enough to sample from. The sequence is
|
|
178
|
+
the **mRNA sense strand** (minus-strand genes are reverse-complemented), which is what a 3' kit's
|
|
179
|
+
cDNA read carries — that identity is exactly what ``soloStrand Forward`` asserts.
|
|
180
|
+
"""
|
|
181
|
+
chroms = read_fasta(fasta)
|
|
182
|
+
exons = _parse_exons(gtf)
|
|
183
|
+
|
|
184
|
+
genes: list[tuple[str, str]] = []
|
|
185
|
+
for gene_id, parts in exons.items():
|
|
186
|
+
chrom, strand = parts[0][0], parts[0][3]
|
|
187
|
+
if chrom not in chroms:
|
|
188
|
+
continue
|
|
189
|
+
# merge exon spans (a gene's transcripts may overlap); splice in genomic order
|
|
190
|
+
spans = sorted({(s, e) for _c, s, e, _st in parts})
|
|
191
|
+
merged: list[tuple[int, int]] = []
|
|
192
|
+
for s, e in spans:
|
|
193
|
+
if merged and s <= merged[-1][1] + 1:
|
|
194
|
+
merged[-1] = (merged[-1][0], max(merged[-1][1], e))
|
|
195
|
+
else:
|
|
196
|
+
merged.append((s, e))
|
|
197
|
+
# .upper() BEFORE _revcomp, not after: _COMPLEMENT maps ACGTN only, so a soft-masked base
|
|
198
|
+
# would be reversed but never complemented, and a trailing .upper() would then launder the
|
|
199
|
+
# wrong base into plausible sequence. Dormant on every assembly this lab currently ships
|
|
200
|
+
# (all are unmasked), which is exactly why it needs to be structural rather than lucky.
|
|
201
|
+
seq = "".join(
|
|
202
|
+
chroms[chrom][s - 1 : e] for s, e in merged
|
|
203
|
+
).upper() # GTF is 1-based inclusive
|
|
204
|
+
if strand == "-":
|
|
205
|
+
seq = _revcomp(seq)
|
|
206
|
+
if len(seq) >= min_len and "N" not in seq:
|
|
207
|
+
genes.append((gene_id, seq))
|
|
208
|
+
|
|
209
|
+
genes.sort() # deterministic before sampling
|
|
210
|
+
rng = random.Random(seed)
|
|
211
|
+
rng.shuffle(genes)
|
|
212
|
+
return genes[:max_genes]
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
@dataclass(frozen=True)
|
|
216
|
+
class GeneModel:
|
|
217
|
+
"""A gene with BOTH what a cell sees and what a nucleus sees.
|
|
218
|
+
|
|
219
|
+
``mrna`` is the spliced, sense-strand transcript — a whole-cell library's cDNA. ``introns`` are
|
|
220
|
+
sense-strand intron sequences: a nucleus is full of unspliced pre-mRNA, so a nuclear library reads
|
|
221
|
+
these too. That difference is the entire reason ``--soloFeatures GeneFull`` exists, and the reason
|
|
222
|
+
yeast cannot test it.
|
|
223
|
+
"""
|
|
224
|
+
|
|
225
|
+
gene_id: str
|
|
226
|
+
mrna: str
|
|
227
|
+
introns: tuple[str, ...]
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _merge(spans: list[tuple[int, int]]) -> list[tuple[int, int]]:
|
|
231
|
+
out: list[tuple[int, int]] = []
|
|
232
|
+
for s, e in sorted(spans):
|
|
233
|
+
if out and s <= out[-1][1] + 1:
|
|
234
|
+
out[-1] = (out[-1][0], max(out[-1][1], e))
|
|
235
|
+
else:
|
|
236
|
+
out.append((s, e))
|
|
237
|
+
return out
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _overlaps(spans: list[tuple[int, int]], s: int, e: int) -> bool:
|
|
241
|
+
"""Does [s,e] touch any merged, sorted interval? Bisect on end coords."""
|
|
242
|
+
import bisect
|
|
243
|
+
|
|
244
|
+
i = bisect.bisect_left([sp[1] for sp in spans], s)
|
|
245
|
+
return i < len(spans) and spans[i][0] <= e
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def load_gene_models(
|
|
249
|
+
fasta: Path,
|
|
250
|
+
gtf: Path,
|
|
251
|
+
*,
|
|
252
|
+
min_len: int = 600,
|
|
253
|
+
min_intron: int = 300,
|
|
254
|
+
max_genes: int = 120,
|
|
255
|
+
seed: int = 0,
|
|
256
|
+
) -> list[GeneModel]:
|
|
257
|
+
"""Build spliced mRNA **and clean intron sequences** per gene (the intron-rich / GeneFull fixture).
|
|
258
|
+
|
|
259
|
+
"Clean" is doing real work here. A read is only unambiguously intronic if its intron overlaps no
|
|
260
|
+
exon *anywhere* in the annotation and no *other* gene's span — otherwise STARsolo may legitimately
|
|
261
|
+
assign it elsewhere or call it ambiguous, and the injected ground truth would be a fiction. So an
|
|
262
|
+
intron qualifies only when it is long enough to contain a whole read, hits no exon genome-wide,
|
|
263
|
+
and lies inside exactly one gene. Being strict here is what lets the assertion be exact rather
|
|
264
|
+
than approximate — the same discipline as the unique-UMI trick in :func:`simulate`.
|
|
265
|
+
"""
|
|
266
|
+
chroms = read_fasta(fasta)
|
|
267
|
+
exons = _parse_exons(gtf)
|
|
268
|
+
|
|
269
|
+
# every exon, genome-wide (any biotype filter already applied) — the ambiguity mask
|
|
270
|
+
all_exons: dict[str, list[tuple[int, int]]] = defaultdict(list)
|
|
271
|
+
gene_spans: dict[str, list[tuple[int, int]]] = defaultdict(list)
|
|
272
|
+
for parts in exons.values():
|
|
273
|
+
chrom = parts[0][0]
|
|
274
|
+
all_exons[chrom].extend((s, e) for _c, s, e, _st in parts)
|
|
275
|
+
gene_spans[chrom].append(
|
|
276
|
+
(min(s for _c, s, _e, _st in parts), max(e for _c, _s, e, _st in parts))
|
|
277
|
+
)
|
|
278
|
+
exon_mask = {c: _merge(v) for c, v in all_exons.items()}
|
|
279
|
+
span_list = {c: sorted(v) for c, v in gene_spans.items()}
|
|
280
|
+
|
|
281
|
+
models: list[GeneModel] = []
|
|
282
|
+
for gene_id, parts in sorted(exons.items()):
|
|
283
|
+
chrom, strand = parts[0][0], parts[0][3]
|
|
284
|
+
if chrom not in chroms:
|
|
285
|
+
continue
|
|
286
|
+
merged = _merge([(s, e) for _c, s, e, _st in parts])
|
|
287
|
+
# .upper() BEFORE _revcomp (see load_genes): complementing a lowercase base is a no-op, so
|
|
288
|
+
# revcomp-then-upper silently emits reversed-but-uncomplemented sequence.
|
|
289
|
+
mrna = "".join(
|
|
290
|
+
chroms[chrom][s - 1 : e] for s, e in merged
|
|
291
|
+
).upper() # GTF is 1-based inclusive
|
|
292
|
+
if strand == "-":
|
|
293
|
+
mrna = _revcomp(mrna)
|
|
294
|
+
if len(mrna) < min_len or "N" in mrna:
|
|
295
|
+
continue
|
|
296
|
+
|
|
297
|
+
introns: list[str] = []
|
|
298
|
+
for (_s1, e1), (s2, _e2) in zip(merged, merged[1:], strict=False):
|
|
299
|
+
istart, iend = e1 + 1, s2 - 1
|
|
300
|
+
if iend - istart + 1 < min_intron:
|
|
301
|
+
continue
|
|
302
|
+
if _overlaps(exon_mask[chrom], istart, iend):
|
|
303
|
+
continue # some other transcript exonifies this gap
|
|
304
|
+
covering = [g for g in span_list[chrom] if g[0] <= iend and g[1] >= istart]
|
|
305
|
+
if len(covering) != 1:
|
|
306
|
+
continue # another gene overlaps -> a read here is not unambiguously ours
|
|
307
|
+
seq = chroms[chrom][istart - 1 : iend].upper()
|
|
308
|
+
if "N" in seq:
|
|
309
|
+
continue
|
|
310
|
+
introns.append(_revcomp(seq) if strand == "-" else seq)
|
|
311
|
+
if introns:
|
|
312
|
+
models.append(GeneModel(gene_id=gene_id, mrna=mrna, introns=tuple(introns)))
|
|
313
|
+
|
|
314
|
+
rng = random.Random(seed)
|
|
315
|
+
rng.shuffle(models)
|
|
316
|
+
return models[:max_genes]
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def simulate(
|
|
320
|
+
genes: list[tuple[str, str]],
|
|
321
|
+
*,
|
|
322
|
+
n_cells: int = 8,
|
|
323
|
+
reads_per_cell: int = 250,
|
|
324
|
+
read_len: int = 90,
|
|
325
|
+
cb_len: int = 16,
|
|
326
|
+
umi_len: int = 12,
|
|
327
|
+
seed: int = 0,
|
|
328
|
+
) -> Simulation:
|
|
329
|
+
"""Emit 10x-3'-v3-shaped reads from real transcripts, recording exactly what we injected.
|
|
330
|
+
|
|
331
|
+
R2 = a ``read_len`` cDNA fragment taken **sense to the mRNA** (3'-biased, as a 3' kit is).
|
|
332
|
+
R1 = ``CB + UMI``. Every UMI is unique, so the injected UMI count per (cell, gene) is simply the
|
|
333
|
+
number of reads — which is what STARsolo's Gene matrix must reproduce.
|
|
334
|
+
"""
|
|
335
|
+
rng = random.Random(seed)
|
|
336
|
+
bases = "ACGT"
|
|
337
|
+
whitelist = sorted(
|
|
338
|
+
{"".join(rng.choice(bases) for _ in range(cb_len)) for _ in range(n_cells * 4)}
|
|
339
|
+
)
|
|
340
|
+
cells = whitelist[:n_cells]
|
|
341
|
+
|
|
342
|
+
sim = Simulation(whitelist=whitelist)
|
|
343
|
+
truth: dict[tuple[str, str], int] = defaultdict(int)
|
|
344
|
+
seen_umis: set[str] = set()
|
|
345
|
+
for cell in cells:
|
|
346
|
+
for _ in range(reads_per_cell):
|
|
347
|
+
gene_id, mrna = genes[rng.randrange(len(genes))]
|
|
348
|
+
# 3' bias: a 3' kit samples near the polyA end, i.e. the mRNA's tail
|
|
349
|
+
tail = min(len(mrna), 500)
|
|
350
|
+
lo = len(mrna) - tail
|
|
351
|
+
start = rng.randrange(lo, len(mrna) - read_len + 1)
|
|
352
|
+
sim.cdna.append(mrna[start : start + read_len])
|
|
353
|
+
|
|
354
|
+
while True: # unique UMIs => injected count == read count, so the assert is exact
|
|
355
|
+
umi = "".join(rng.choice(bases) for _ in range(umi_len))
|
|
356
|
+
if umi not in seen_umis:
|
|
357
|
+
seen_umis.add(umi)
|
|
358
|
+
break
|
|
359
|
+
sim.barcode.append(cell + umi)
|
|
360
|
+
truth[(cell, gene_id)] += 1
|
|
361
|
+
sim.truth = dict(truth)
|
|
362
|
+
return sim
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
@dataclass
|
|
366
|
+
class NucleiSimulation:
|
|
367
|
+
"""A single-NUCLEUS library: exonic + intronic reads, with the two truths kept apart.
|
|
368
|
+
|
|
369
|
+
Keeping them apart is the whole design. ``Gene`` must recover ``truth_exonic`` and count NONE of
|
|
370
|
+
the intronic reads; ``GeneFull`` must recover their sum. One matrix cannot satisfy both, so the
|
|
371
|
+
pair pins the semantic difference that yeast (nearly intron-free) can never exercise.
|
|
372
|
+
"""
|
|
373
|
+
|
|
374
|
+
cdna: list[str] = field(default_factory=list)
|
|
375
|
+
barcode: list[str] = field(default_factory=list)
|
|
376
|
+
truth_exonic: dict[tuple[str, str], int] = field(default_factory=dict)
|
|
377
|
+
truth_intronic: dict[tuple[str, str], int] = field(default_factory=dict)
|
|
378
|
+
whitelist: list[str] = field(default_factory=list)
|
|
379
|
+
|
|
380
|
+
@property
|
|
381
|
+
def truth_full(self) -> dict[tuple[str, str], int]:
|
|
382
|
+
"""What GeneFull must see: pre-mRNA = exons + introns."""
|
|
383
|
+
out = dict(self.truth_exonic)
|
|
384
|
+
for k, v in self.truth_intronic.items():
|
|
385
|
+
out[k] = out.get(k, 0) + v
|
|
386
|
+
return out
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def simulate_nuclei(
|
|
390
|
+
models: list[GeneModel],
|
|
391
|
+
*,
|
|
392
|
+
n_cells: int = 8,
|
|
393
|
+
reads_per_cell: int = 250,
|
|
394
|
+
intron_frac: float = 0.4,
|
|
395
|
+
read_len: int = 90,
|
|
396
|
+
cb_len: int = 16,
|
|
397
|
+
umi_len: int = 12,
|
|
398
|
+
seed: int = 0,
|
|
399
|
+
) -> NucleiSimulation:
|
|
400
|
+
"""Emit 10x-3'-v3-shaped reads from **pre-mRNA**: a fraction land in introns, as in real nuclei.
|
|
401
|
+
|
|
402
|
+
Same geometry and unique-UMI discipline as :func:`simulate`, so the injected count per (cell, gene)
|
|
403
|
+
is exactly the read count. Intronic reads are taken whole from a clean intron, sense to the gene,
|
|
404
|
+
so ``soloStrand Forward`` treats them exactly like exonic ones — the ONLY thing under test here is
|
|
405
|
+
exon-vs-full counting, and nothing else is allowed to vary.
|
|
406
|
+
"""
|
|
407
|
+
rng = random.Random(seed)
|
|
408
|
+
bases = "ACGT"
|
|
409
|
+
whitelist = sorted(
|
|
410
|
+
{"".join(rng.choice(bases) for _ in range(cb_len)) for _ in range(n_cells * 4)}
|
|
411
|
+
)
|
|
412
|
+
cells = whitelist[:n_cells]
|
|
413
|
+
usable = [m for m in models if any(len(i) >= read_len for i in m.introns)]
|
|
414
|
+
if not usable:
|
|
415
|
+
raise E2EUnavailable("no gene has an intron long enough to hold a read")
|
|
416
|
+
|
|
417
|
+
sim = NucleiSimulation(whitelist=whitelist)
|
|
418
|
+
exonic: dict[tuple[str, str], int] = defaultdict(int)
|
|
419
|
+
intronic: dict[tuple[str, str], int] = defaultdict(int)
|
|
420
|
+
seen_umis: set[str] = set()
|
|
421
|
+
for cell in cells:
|
|
422
|
+
for _ in range(reads_per_cell):
|
|
423
|
+
model = usable[rng.randrange(len(usable))]
|
|
424
|
+
if rng.random() < intron_frac:
|
|
425
|
+
pool = [i for i in model.introns if len(i) >= read_len]
|
|
426
|
+
intron = pool[rng.randrange(len(pool))]
|
|
427
|
+
start = rng.randrange(0, len(intron) - read_len + 1)
|
|
428
|
+
sim.cdna.append(intron[start : start + read_len])
|
|
429
|
+
intronic[(cell, model.gene_id)] += 1
|
|
430
|
+
else:
|
|
431
|
+
mrna = model.mrna
|
|
432
|
+
tail = min(len(mrna), 500) # 3' bias, as in simulate()
|
|
433
|
+
start = rng.randrange(len(mrna) - tail, len(mrna) - read_len + 1)
|
|
434
|
+
sim.cdna.append(mrna[start : start + read_len])
|
|
435
|
+
exonic[(cell, model.gene_id)] += 1
|
|
436
|
+
|
|
437
|
+
while True: # unique UMIs => injected count == read count, so the assert is exact
|
|
438
|
+
umi = "".join(rng.choice(bases) for _ in range(umi_len))
|
|
439
|
+
if umi not in seen_umis:
|
|
440
|
+
seen_umis.add(umi)
|
|
441
|
+
break
|
|
442
|
+
sim.barcode.append(cell + umi)
|
|
443
|
+
sim.truth_exonic = dict(exonic)
|
|
444
|
+
sim.truth_intronic = dict(intronic)
|
|
445
|
+
return sim
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def write_fastq_gz(path: Path, seqs: list[str], prefix: str) -> None:
|
|
449
|
+
"""Reproducible fastq.gz. Delegates to the KB's single writer (mtime-pinned; see its docstring)."""
|
|
450
|
+
_write_fastq_gz(path, seqs, prefix=prefix)
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def parse_h5ad(path: Path, layer: str | None = None) -> dict[tuple[str, str], int]:
|
|
454
|
+
"""Read **the deliverable** into ``(barcode, gene) -> count``. ``layer=None`` reads ``X``.
|
|
455
|
+
|
|
456
|
+
This reads the ``.h5ad`` the composed pipeline produced, not the ``Solo.out`` it produced it
|
|
457
|
+
from, and that is the same argument that moved these gates onto the Snakefile in the first place:
|
|
458
|
+
a gate must assert on the artifact a user actually receives. It replaced ``parse_solo_matrix``,
|
|
459
|
+
which read STAR's Matrix Market files directly — everything downstream of those files (the
|
|
460
|
+
transpose to cells x genes, which feature became ``X``, which layer got which name) was then
|
|
461
|
+
outside the only test that checks a number against ground truth.
|
|
462
|
+
|
|
463
|
+
So the assertion now covers the whole chain. A transposed matrix stops being a thing the unit
|
|
464
|
+
tests alone believe in, and becomes something ``kb e2e`` would fail on real reads.
|
|
465
|
+
"""
|
|
466
|
+
import anndata as ad
|
|
467
|
+
|
|
468
|
+
adata = ad.read_h5ad(path)
|
|
469
|
+
mat = (adata.X if layer is None else adata.layers[layer]).tocoo()
|
|
470
|
+
obs = list(adata.obs_names)
|
|
471
|
+
var = list(adata.var_names)
|
|
472
|
+
return {
|
|
473
|
+
(obs[int(i)], var[int(j)]): int(v)
|
|
474
|
+
for i, j, v in zip(mat.row, mat.col, mat.data, strict=True)
|
|
475
|
+
if v
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def _fq_arg(fq: Path | Sequence[Path]) -> str:
|
|
480
|
+
"""One path, or STAR's comma-separated list form for a sharded mate.
|
|
481
|
+
|
|
482
|
+
Order matters and is the caller's responsibility: STAR pairs the Nth cDNA shard with the Nth
|
|
483
|
+
barcode shard, so the two lists must be built in lockstep. They are — the sharder appends to both
|
|
484
|
+
in the same loop.
|
|
485
|
+
"""
|
|
486
|
+
if isinstance(fq, Path):
|
|
487
|
+
return str(fq)
|
|
488
|
+
if not fq:
|
|
489
|
+
raise E2EUnavailable("no FASTQ shards were produced")
|
|
490
|
+
return ",".join(str(p) for p in fq)
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def run_starsolo(
|
|
494
|
+
assets: E2EAssets,
|
|
495
|
+
*,
|
|
496
|
+
cdna_fq: Path | Sequence[Path],
|
|
497
|
+
barcode_fq: Path | Sequence[Path],
|
|
498
|
+
whitelist: Path,
|
|
499
|
+
solo: dict[str, object],
|
|
500
|
+
outdir: Path,
|
|
501
|
+
threads: int = 8,
|
|
502
|
+
cost: dict[str, object] | None = None,
|
|
503
|
+
timeout: int = 1800,
|
|
504
|
+
out_sam_type: tuple[str, ...] = ("None",),
|
|
505
|
+
extra_args: tuple[str, ...] = (),
|
|
506
|
+
) -> Path:
|
|
507
|
+
"""Invoke STAR **directly**, with the composed params. The MEMORY INSTRUMENT — not a gate.
|
|
508
|
+
|
|
509
|
+
Only `kb e2e-cost` uses this now, and the distinction is the whole point:
|
|
510
|
+
|
|
511
|
+
- a **gate** asks "is the artifact we ship correct?", so it must run the artifact we ship. Both
|
|
512
|
+
correctness arms do that via `run_composed`, which runs the emitted Snakefile.
|
|
513
|
+
- an **instrument** asks "how much memory does STAR need at depth N?", so it must reap STAR
|
|
514
|
+
itself. Under snakemake, `wait4` reaps snakemake and `ru_maxrss` folds in descendants, which
|
|
515
|
+
turns an exact number into an approximate one — and this file already carries the scar of a
|
|
516
|
+
memory reading that was silently a `max()` over several children.
|
|
517
|
+
|
|
518
|
+
This function used to back the gates too, and its docstring said running STAR with the composed
|
|
519
|
+
params "is what makes the gate test the compiler". That was true of the *params* and false of
|
|
520
|
+
everything else: it meant STARsolo's command line was rendered twice, by hand, in two places that
|
|
521
|
+
could not see each other — here, and in `starsolo.smk`, which nothing had ever executed. They had
|
|
522
|
+
already drifted: the argv below hardcodes `--soloCBstart/CBlen/UMIstart/UMIlen`, so it cannot run
|
|
523
|
+
a `CB_UMI_Complex` chemistry at all, while the module branches on `soloType` to handle one.
|
|
524
|
+
|
|
525
|
+
Keep that in mind before adding a flag here: this is a measuring device pointed at STAR, and it is
|
|
526
|
+
NOT evidence about the pipeline. If you want a claim about what users run, put it in `run_composed`.
|
|
527
|
+
|
|
528
|
+
``cost``, if given, is populated with this STAR run's wall-clock and peak RSS.
|
|
529
|
+
"""
|
|
530
|
+
outdir.mkdir(parents=True, exist_ok=True)
|
|
531
|
+
cmd = [
|
|
532
|
+
assets.star_bin,
|
|
533
|
+
"--runMode",
|
|
534
|
+
"alignReads",
|
|
535
|
+
"--genomeDir",
|
|
536
|
+
str(assets.star_index),
|
|
537
|
+
"--runThreadN",
|
|
538
|
+
str(threads),
|
|
539
|
+
# --readFilesIn takes the cDNA read FIRST, then the barcode read. Each mate may be a
|
|
540
|
+
# comma-separated LIST, which is what lets sharded generation skip a merge step entirely.
|
|
541
|
+
"--readFilesIn",
|
|
542
|
+
_fq_arg(cdna_fq),
|
|
543
|
+
_fq_arg(barcode_fq),
|
|
544
|
+
"--readFilesCommand",
|
|
545
|
+
"zcat",
|
|
546
|
+
"--soloType",
|
|
547
|
+
str(solo["soloType"]),
|
|
548
|
+
"--soloCBstart",
|
|
549
|
+
str(solo["soloCBstart"]),
|
|
550
|
+
"--soloCBlen",
|
|
551
|
+
str(solo["soloCBlen"]),
|
|
552
|
+
"--soloUMIstart",
|
|
553
|
+
str(solo["soloUMIstart"]),
|
|
554
|
+
"--soloUMIlen",
|
|
555
|
+
str(solo["soloUMIlen"]),
|
|
556
|
+
"--soloCBwhitelist",
|
|
557
|
+
str(whitelist),
|
|
558
|
+
"--soloStrand",
|
|
559
|
+
str(solo["soloStrand"]),
|
|
560
|
+
# --soloFeatures takes N space-separated values; STAR writes one Solo.out/<feature>/ per value.
|
|
561
|
+
*("--soloFeatures", *_feature_list(solo["soloFeatures"])),
|
|
562
|
+
"--outFileNamePrefix",
|
|
563
|
+
f"{outdir}/",
|
|
564
|
+
# `None` by default: the gates want a count matrix, not alignments, and writing a BAM they
|
|
565
|
+
# never read would be pure cost. But the SHIPPED module runs `BAM Unsorted`, so a cost arm
|
|
566
|
+
# that only ever prices `None` prices a command nobody runs -- which is why this is a
|
|
567
|
+
# parameter rather than a constant, and why `kb e2e-cost --out-sam-type` exists to measure
|
|
568
|
+
# the gap instead of estimating it in a docstring.
|
|
569
|
+
"--outSAMtype",
|
|
570
|
+
*out_sam_type,
|
|
571
|
+
*extra_args,
|
|
572
|
+
]
|
|
573
|
+
code, elapsed, maxrss_kib, err_tail = _run_measured(cmd, outdir=outdir, timeout=timeout)
|
|
574
|
+
if code != 0:
|
|
575
|
+
raise E2EUnavailable(f"STAR failed ({code}): {err_tail}")
|
|
576
|
+
if cost is not None:
|
|
577
|
+
# Linux reports ru_maxrss in KiB (macOS in bytes) — arc is Linux, and a cross-platform unit
|
|
578
|
+
# guess here would be a fabricated number, so record the raw value and its unit.
|
|
579
|
+
cost["star_wall_s"] = round(elapsed, 2)
|
|
580
|
+
cost["star_peak_rss_gb"] = round(maxrss_kib / 1024 / 1024, 3)
|
|
581
|
+
cost["star_peak_rss_kib"] = maxrss_kib
|
|
582
|
+
# This measuring process's own peak, recorded BESIDE the reading rather than trusted to be
|
|
583
|
+
# small. `wait4`'s rusage put a silent floor under every child at exactly this number (see
|
|
584
|
+
# `_run_measured`), and the reason that bug never corrupted a published figure is that STAR
|
|
585
|
+
# weighs ~34 GB and this process weighs ~1 -- which is an argument, not a measurement, until
|
|
586
|
+
# the two numbers sit in the same JSON. Now they do: any future reading within an order of
|
|
587
|
+
# magnitude of `harness_peak_rss_kib` deserves suspicion.
|
|
588
|
+
cost["harness_peak_rss_kib"] = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
|
|
589
|
+
cost["soloFeatures"] = _feature_list(solo["soloFeatures"])
|
|
590
|
+
return outdir / "Solo.out" / _feature_list(solo["soloFeatures"])[0] / "raw"
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
#: How often to sample a running child's ``VmHWM``. Small because it is a ~1 KB read of a virtual
|
|
594
|
+
#: file, and the thing being sampled (STAR's peak) is sustained for minutes; the cost of 20 reads a
|
|
595
|
+
#: second across a 30-minute run is nothing next to missing the peak.
|
|
596
|
+
_POLL_S = 0.05
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def _vm_hwm_kib(pid: int) -> int | None:
|
|
600
|
+
"""A running process's own peak RSS in KiB, or ``None`` if it cannot be read.
|
|
601
|
+
|
|
602
|
+
``None`` covers both "no ``/proc``" (macOS) and "already a zombie" (no ``mm`` to report), which
|
|
603
|
+
the caller treats the same way: keep whatever peak it already sampled.
|
|
604
|
+
"""
|
|
605
|
+
try:
|
|
606
|
+
for line in Path(f"/proc/{pid}/status").read_text().splitlines():
|
|
607
|
+
if line.startswith("VmHWM:"):
|
|
608
|
+
return int(line.split()[1])
|
|
609
|
+
except (OSError, ValueError, IndexError):
|
|
610
|
+
return None
|
|
611
|
+
return None
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def _run_measured(
|
|
615
|
+
cmd: list[str],
|
|
616
|
+
*,
|
|
617
|
+
outdir: Path,
|
|
618
|
+
timeout: int,
|
|
619
|
+
env: dict[str, str] | None = None,
|
|
620
|
+
log_prefix: str = "star",
|
|
621
|
+
) -> tuple[int, float, int, str]:
|
|
622
|
+
"""Run ``cmd`` to completion; return ``(exit_code, wall_s, peak_rss_kib, stderr_tail)``.
|
|
623
|
+
|
|
624
|
+
**The peak is the child's own ``VmHWM``, polled from ``/proc``, and NOT ``wait4``'s rusage.**
|
|
625
|
+
Two bugs deep, and each was found only after the previous fix:
|
|
626
|
+
|
|
627
|
+
1. It was ``resource.getrusage(RUSAGE_CHILDREN).ru_maxrss`` — a high-water mark over *every*
|
|
628
|
+
child the process has ever reaped, so a second STAR run inherited the first one's peak and
|
|
629
|
+
could never report a smaller number. Invisible while `kb e2e-introns` ran STAR once; fatal to
|
|
630
|
+
a sweep, whose every point after the first would be silently ``max()``-ed with its
|
|
631
|
+
predecessors — an increasing curve indistinguishable from the growth we are measuring.
|
|
632
|
+
2. ``os.wait4`` fixed that and was **still wrong on Linux**, which is where every real
|
|
633
|
+
measurement runs. Measured 2026-07-15: with an 879 MB parent, a child allocating 1 MB reports
|
|
634
|
+
``879260 KiB`` — the parent's RSS *to the byte* — and so does a child allocating 400 MB. On
|
|
635
|
+
Linux a spawned child's address space begins as a copy of the parent's, ``ru_maxrss`` is a
|
|
636
|
+
high-water mark, and ``exec`` never lowers it. So ``wait4`` reports
|
|
637
|
+
``max(parent_rss_at_fork, child_peak)``: a **floor**, silently, at whatever the caller happens
|
|
638
|
+
to weigh.
|
|
639
|
+
|
|
640
|
+
macOS does not do this — CPython spawns via ``posix_spawn`` there, and the same 1 MB child
|
|
641
|
+
reports ~10 MB beside a 903 MB parent. That is exactly why this survived: the guard test passed
|
|
642
|
+
locally for a reason that had nothing to do with the code being right, and only went red on arc,
|
|
643
|
+
under a full suite fat enough to cross the floor.
|
|
644
|
+
|
|
645
|
+
``VmHWM`` is the kernel's per-process high-water mark and ``execve`` resets it with the rest of
|
|
646
|
+
the ``mm``, so it is the post-exec peak of *this* program and nothing else. Polling can in
|
|
647
|
+
principle miss a spike shorter than :data:`_POLL_S`; for the thing this measures — STAR holding a
|
|
648
|
+
30 GB index for minutes — it cannot. Where there is no ``/proc`` we fall back to ``wait4``, which
|
|
649
|
+
is right on macOS and is not where any published number comes from.
|
|
650
|
+
|
|
651
|
+
Output goes to files rather than pipes on purpose: ``Popen.communicate`` reaps the child itself,
|
|
652
|
+
which would leave ``wait4`` nothing to collect rusage from.
|
|
653
|
+
"""
|
|
654
|
+
outdir.mkdir(parents=True, exist_ok=True)
|
|
655
|
+
err_log = outdir / f"{log_prefix}.stderr.log"
|
|
656
|
+
started = time.monotonic()
|
|
657
|
+
peak_kib = 0
|
|
658
|
+
with open(outdir / f"{log_prefix}.stdout.log", "w") as out_fh, open(err_log, "w") as err_fh:
|
|
659
|
+
proc = subprocess.Popen(cmd, stdout=out_fh, stderr=err_fh, env=env)
|
|
660
|
+
deadline = started + timeout
|
|
661
|
+
while True:
|
|
662
|
+
pid, status, usage = os.wait4(proc.pid, os.WNOHANG)
|
|
663
|
+
# Read before acting on `pid`: once reaped the child is a zombie with no `mm`, so this is
|
|
664
|
+
# the last chance at its high-water mark.
|
|
665
|
+
peak_kib = max(peak_kib, _vm_hwm_kib(proc.pid) or 0)
|
|
666
|
+
if pid != 0:
|
|
667
|
+
break
|
|
668
|
+
if time.monotonic() > deadline:
|
|
669
|
+
proc.kill()
|
|
670
|
+
os.wait4(proc.pid, 0)
|
|
671
|
+
proc.returncode = -9
|
|
672
|
+
raise E2EUnavailable(f"{log_prefix} exceeded its {timeout}s budget")
|
|
673
|
+
time.sleep(_POLL_S)
|
|
674
|
+
# Tell Popen the child is already reaped, so its own wait() does not race for an ECHILD.
|
|
675
|
+
proc.returncode = os.waitstatus_to_exitcode(status)
|
|
676
|
+
elapsed = time.monotonic() - started
|
|
677
|
+
# 0 means we never got a reading — no /proc (macOS), or a child too short-lived to poll. Falling
|
|
678
|
+
# back is right on macOS and merely imprecise elsewhere; silently reporting 0 GB would not be.
|
|
679
|
+
return proc.returncode, elapsed, peak_kib or usage.ru_maxrss, err_log.read_text()[-2000:]
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
def run_composed(
|
|
683
|
+
assets: E2EAssets,
|
|
684
|
+
*,
|
|
685
|
+
manifest: DatasetManifest,
|
|
686
|
+
processing: ProcessingManifest,
|
|
687
|
+
registry: OnlistRegistry,
|
|
688
|
+
workspace: Path,
|
|
689
|
+
fastq_dir: Path,
|
|
690
|
+
threads: int = 8,
|
|
691
|
+
timeout: int = 1800,
|
|
692
|
+
config_patch: dict[str, dict[str, str]] | None = None,
|
|
693
|
+
) -> tuple[Path, Path]:
|
|
694
|
+
"""Compose the pipeline and **run the Snakefile it emitted**. Returns `(h5ad, star_prefix)`.
|
|
695
|
+
|
|
696
|
+
**This is what makes `kb e2e` a test of the compiler's output rather than of its params dict.**
|
|
697
|
+
|
|
698
|
+
It used to call `run_starsolo`, which took the composed `solo` block and hand-assembled its own
|
|
699
|
+
STAR argv. That tested the params — its docstring said so, and it was true — and it never touched
|
|
700
|
+
`starsolo.smk`. So the command line was rendered twice, by hand, in two places that could not see
|
|
701
|
+
each other: once in the module we ship to users, once in the test we trust. Nothing ever ran the
|
|
702
|
+
first one. The two had already drifted — the test's copy hardcodes `--soloCBstart/CBlen/UMIstart/
|
|
703
|
+
UMIlen` and so cannot run a `CB_UMI_Complex` chemistry at all, while the module branches on
|
|
704
|
+
`soloType` to handle exactly that.
|
|
705
|
+
|
|
706
|
+
Now there is one rendering, in the module, and the ground-truth assertion — *the matrix equals the
|
|
707
|
+
barcodes and UMIs we injected* — covers the artifact a user actually submits.
|
|
708
|
+
|
|
709
|
+
Two consequences worth naming rather than discovering:
|
|
710
|
+
|
|
711
|
+
- **`--outSAMtype BAM Unsorted`** is what the module hardcodes, so that is what runs here; the old
|
|
712
|
+
path passed `None` to save time. Slower and bigger, and correct: a gate that runs a command
|
|
713
|
+
nobody ships is measuring the wrong thing. On a 12 Mb yeast fixture the difference is noise.
|
|
714
|
+
- **The `genome_index` rule now executes**, resolving the index through liulab-genome exactly as a
|
|
715
|
+
real run does (`get_star_index`, a pure lookup), instead of the test handing STAR a path it
|
|
716
|
+
found itself. seqforge never builds the index, so it must be prebuilt for this assembly +
|
|
717
|
+
annotation before `kb e2e` runs; the lookup then finds it and this costs nothing while covering
|
|
718
|
+
a rule that had no coverage at all.
|
|
719
|
+
|
|
720
|
+
No peak-RSS is reported from this path, deliberately. `wait4` would be reaping *snakemake*, and its
|
|
721
|
+
`ru_maxrss` folds in descendants it has waited for — so the number would be "STAR's peak, probably,
|
|
722
|
+
unless snakemake's was higher". `kb e2e-cost` keeps a direct STAR invocation precisely because a
|
|
723
|
+
memory instrument may not be approximate; see `run_starsolo`.
|
|
724
|
+
"""
|
|
725
|
+
result = compose(
|
|
726
|
+
manifest,
|
|
727
|
+
processing,
|
|
728
|
+
registry=registry,
|
|
729
|
+
workspace=workspace,
|
|
730
|
+
fastq_dir=fastq_dir,
|
|
731
|
+
# We are about to really run it. A dry run first would re-derive, at some cost, a subset of
|
|
732
|
+
# what the real run is about to prove.
|
|
733
|
+
run_wiring_gate=False,
|
|
734
|
+
)
|
|
735
|
+
rundir = (workspace / result.snakefile_path).parent
|
|
736
|
+
if config_patch:
|
|
737
|
+
config = yaml.safe_load((rundir / "config.yaml").read_text())
|
|
738
|
+
for block, entries in config_patch.items():
|
|
739
|
+
config[block].update(entries)
|
|
740
|
+
(rundir / "config.yaml").write_text(yaml.safe_dump(config, sort_keys=True))
|
|
741
|
+
|
|
742
|
+
# The module invokes a bare `STAR`, as it must: a module may not carry a machine's path.
|
|
743
|
+
# `assets.star_bin` is this host's answer, so put its directory on PATH for the child rather than
|
|
744
|
+
# rewriting the module's shell block.
|
|
745
|
+
env = dict(os.environ)
|
|
746
|
+
env["PATH"] = os.pathsep.join(
|
|
747
|
+
[str(Path(assets.star_bin).resolve().parent), env.get("PATH", "")]
|
|
748
|
+
)
|
|
749
|
+
code, _elapsed, _rss, err_tail = _run_measured(
|
|
750
|
+
[
|
|
751
|
+
"snakemake",
|
|
752
|
+
"-s",
|
|
753
|
+
str(rundir / "Snakefile"),
|
|
754
|
+
"-d",
|
|
755
|
+
str(rundir),
|
|
756
|
+
"--cores",
|
|
757
|
+
str(threads),
|
|
758
|
+
"-p",
|
|
759
|
+
"--nolock",
|
|
760
|
+
],
|
|
761
|
+
outdir=rundir,
|
|
762
|
+
timeout=timeout,
|
|
763
|
+
env=env,
|
|
764
|
+
log_prefix="snakemake",
|
|
765
|
+
)
|
|
766
|
+
if code != 0:
|
|
767
|
+
raise E2EUnavailable(f"the composed pipeline failed ({code}): {err_tail}")
|
|
768
|
+
|
|
769
|
+
config = yaml.safe_load((rundir / "config.yaml").read_text())
|
|
770
|
+
sample = str(config["samples"][0])
|
|
771
|
+
star_prefix = rundir / str(config["outdir"]) / sample
|
|
772
|
+
# The h5ad, not the Solo.out that fed it: this is the file the pipeline exists to make, so it is
|
|
773
|
+
# the file the ground-truth assertion has to open. `star_prefix` still comes back for `Log.final.out`
|
|
774
|
+
# — STAR's own mapping stats are the reconciliation term, not a count we are testing.
|
|
775
|
+
return star_prefix / f"{sample}.h5ad", star_prefix
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
def _feature_list(value: object) -> list[str]:
|
|
779
|
+
"""KB params carry soloFeatures as a list or a string; STAR's CLI wants separate argv items."""
|
|
780
|
+
if isinstance(value, list | tuple):
|
|
781
|
+
return [str(v) for v in value]
|
|
782
|
+
return str(value).split()
|
|
783
|
+
|
|
784
|
+
|
|
785
|
+
def run_e2e(
|
|
786
|
+
assets: E2EAssets,
|
|
787
|
+
*,
|
|
788
|
+
workdir: Path,
|
|
789
|
+
n_cells: int = 8,
|
|
790
|
+
reads_per_cell: int = 250,
|
|
791
|
+
threads: int = 8,
|
|
792
|
+
seed: int = 0,
|
|
793
|
+
check_strand_inversion: bool = True,
|
|
794
|
+
min_recovery: float = 0.95,
|
|
795
|
+
max_unexplained_loss: float = 0.02,
|
|
796
|
+
) -> dict[str, object]:
|
|
797
|
+
"""Drive simulate -> resolve -> fill -> compose -> STARsolo and assert against ground truth."""
|
|
798
|
+
workdir.mkdir(parents=True, exist_ok=True)
|
|
799
|
+
genes = load_genes(assets.fasta, assets.gtf, seed=seed)
|
|
800
|
+
if not genes:
|
|
801
|
+
raise E2EUnavailable("no protein-coding genes could be built from the GTF/fasta")
|
|
802
|
+
sim = simulate(genes, n_cells=n_cells, reads_per_cell=reads_per_cell, seed=seed)
|
|
803
|
+
|
|
804
|
+
cdna_fq = workdir / "sim_R2.fastq.gz"
|
|
805
|
+
bc_fq = workdir / "sim_R1.fastq.gz"
|
|
806
|
+
write_fastq_gz(cdna_fq, sim.cdna, "SIM")
|
|
807
|
+
write_fastq_gz(bc_fq, sim.barcode, "SIM")
|
|
808
|
+
|
|
809
|
+
# --- the compiler must identify the chemistry from the BYTES, with no metadata hint ---
|
|
810
|
+
spec = load_spec(E2E_TECH)
|
|
811
|
+
registry = OnlistRegistry(offline=True)
|
|
812
|
+
registry.register_synthetic(spec.onlists["cb_whitelist"].registry, sim.whitelist)
|
|
813
|
+
resolved = resolve_dataset(
|
|
814
|
+
[bc_fq, cdna_fq], registry=registry, workspace=workdir, use_cache=False
|
|
815
|
+
)
|
|
816
|
+
decided = resolved.result.candidates[0].technology if resolved.result.candidates else None
|
|
817
|
+
if decided != E2E_TECH:
|
|
818
|
+
return {"passed": False, "stage": "resolve", "decided": decided, "expected": E2E_TECH}
|
|
819
|
+
|
|
820
|
+
observations = [probe_file(p) for p in (bc_fq, cdna_fq)]
|
|
821
|
+
manifest = fill_manifest(
|
|
822
|
+
result=resolved.result,
|
|
823
|
+
spec=spec,
|
|
824
|
+
observations=observations,
|
|
825
|
+
registry=registry,
|
|
826
|
+
experiment=ExperimentInputs(
|
|
827
|
+
organism=EvidencedTaxid(value=559292, basis="user_confirmed", rung=0),
|
|
828
|
+
samples=[SampleGroup(sample_id="s1", file_uris=[p.name for p in (bc_fq, cdna_fq)])],
|
|
829
|
+
),
|
|
830
|
+
seqforge_version=__version__,
|
|
831
|
+
)
|
|
832
|
+
report = validate_manifest(manifest)
|
|
833
|
+
if not report.ok:
|
|
834
|
+
return {"passed": False, "stage": "validate", "blockers": [b.code for b in report.blockers]}
|
|
835
|
+
|
|
836
|
+
# --- compose emits the params; the aligner runs with exactly those ---
|
|
837
|
+
processing, _ = fill_processing(
|
|
838
|
+
spec=spec,
|
|
839
|
+
dataset=manifest,
|
|
840
|
+
processing=ProcessingInputs(assembly=assets.assembly, annotation_name=assets.annotation),
|
|
841
|
+
seqforge_version=__version__,
|
|
842
|
+
)
|
|
843
|
+
composed = compose_plan(manifest, processing, registry=registry)
|
|
844
|
+
solo = dict(composed.config["solo"]) # type: ignore[arg-type]
|
|
845
|
+
|
|
846
|
+
# Run the Snakefile COMPOSE EMITTED — not a second, hand-written STAR command line that merely
|
|
847
|
+
# uses the same params. See `run_composed`.
|
|
848
|
+
h5ad, star_prefix = run_composed(
|
|
849
|
+
assets,
|
|
850
|
+
manifest=manifest,
|
|
851
|
+
processing=processing,
|
|
852
|
+
registry=registry,
|
|
853
|
+
workspace=workdir / "forward",
|
|
854
|
+
fastq_dir=workdir,
|
|
855
|
+
threads=threads,
|
|
856
|
+
)
|
|
857
|
+
observed = parse_h5ad(h5ad)
|
|
858
|
+
verdict = _compare(sim.truth, observed)
|
|
859
|
+
stats = star_stats(star_prefix)
|
|
860
|
+
|
|
861
|
+
# What the gate actually asserts, and why each clause earns its place:
|
|
862
|
+
# - no spurious pair : a read must never be counted for a gene it did not come from.
|
|
863
|
+
# - no inflated count : we must never invent UMIs (a dedup/geometry bug looks like this).
|
|
864
|
+
# - recovery >= floor : every unambiguously-mappable read landed in the RIGHT (cell, gene).
|
|
865
|
+
# The residual is STAR's multimapper loss, measured below, not slack.
|
|
866
|
+
# - strand sensitive : proven separately, in run_e2e's inversion re-run.
|
|
867
|
+
unique_frac = (
|
|
868
|
+
stats.get("uniquely_mapped", 0.0) / stats["input_reads"]
|
|
869
|
+
if stats.get("input_reads")
|
|
870
|
+
else 0.0
|
|
871
|
+
)
|
|
872
|
+
recovery = float(verdict["recovery_rate"]) # type: ignore[arg-type]
|
|
873
|
+
# `unexplained_loss` is the clause that actually indicts US: of the reads STAR placed uniquely,
|
|
874
|
+
# how many failed to land in the right (cell, gene)? STAR's multimapper loss is subtracted out,
|
|
875
|
+
# so what remains is the compiler's own error. It must be ~0.
|
|
876
|
+
unexplained = max(0.0, unique_frac - recovery)
|
|
877
|
+
counts_ok = (
|
|
878
|
+
verdict["n_spurious_pairs"] == 0
|
|
879
|
+
and verdict["n_inflated_pairs"] == 0
|
|
880
|
+
and recovery >= min_recovery
|
|
881
|
+
and unexplained <= max_unexplained_loss
|
|
882
|
+
)
|
|
883
|
+
result: dict[str, object] = {
|
|
884
|
+
"passed": bool(counts_ok),
|
|
885
|
+
"stage": "counts",
|
|
886
|
+
"decided": decided,
|
|
887
|
+
"solo_params": solo,
|
|
888
|
+
"n_cells": n_cells,
|
|
889
|
+
"n_genes_injected": len({g for _c, g in sim.truth}),
|
|
890
|
+
"star": stats,
|
|
891
|
+
"star_unique_fraction": round(unique_frac, 4),
|
|
892
|
+
# the honest reconciliation: what we lost should be ~what STAR could not place uniquely
|
|
893
|
+
"unexplained_loss": round(unexplained, 4),
|
|
894
|
+
"min_recovery": min_recovery,
|
|
895
|
+
"max_unexplained_loss": max_unexplained_loss,
|
|
896
|
+
**verdict,
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
# --- prove the gate can actually SEE a strand inversion (else it proves nothing) ---
|
|
900
|
+
if check_strand_inversion:
|
|
901
|
+
# Patch the EMITTED config rather than compose a second one: the point is that this exact
|
|
902
|
+
# pipeline, with one flag flipped, must lose the signal. A separate workspace because a run
|
|
903
|
+
# directory is keyed by run_id, and the two runs share every hash that feeds it.
|
|
904
|
+
inv_h5ad, _ = run_composed(
|
|
905
|
+
assets,
|
|
906
|
+
manifest=manifest,
|
|
907
|
+
processing=processing,
|
|
908
|
+
registry=registry,
|
|
909
|
+
workspace=workdir / "reverse",
|
|
910
|
+
fastq_dir=workdir,
|
|
911
|
+
threads=threads,
|
|
912
|
+
config_patch={"solo": {"soloStrand": "Reverse"}},
|
|
913
|
+
)
|
|
914
|
+
inv_counts = parse_h5ad(inv_h5ad)
|
|
915
|
+
inv_total = sum(inv_counts.values())
|
|
916
|
+
total = sum(sim.truth.values())
|
|
917
|
+
result["inverted_strand_total"] = inv_total
|
|
918
|
+
result["injected_total"] = total
|
|
919
|
+
# an inverted strand must destroy the signal; if it did not, the gate is blind
|
|
920
|
+
result["strand_sensitive"] = inv_total < 0.2 * total
|
|
921
|
+
result["passed"] = bool(result["passed"] and result["strand_sensitive"])
|
|
922
|
+
|
|
923
|
+
return result
|
|
924
|
+
|
|
925
|
+
|
|
926
|
+
def run_intron_e2e(
|
|
927
|
+
assets: E2EAssets,
|
|
928
|
+
*,
|
|
929
|
+
workdir: Path,
|
|
930
|
+
n_cells: int = 8,
|
|
931
|
+
reads_per_cell: int = 250,
|
|
932
|
+
intron_frac: float = 0.4,
|
|
933
|
+
threads: int = 8,
|
|
934
|
+
seed: int = 0,
|
|
935
|
+
min_recovery: float = 0.90,
|
|
936
|
+
features: tuple[str, ...] | None = None,
|
|
937
|
+
) -> dict[str, object]:
|
|
938
|
+
"""The intron-rich / **GeneFull** gate (design §4.1 coverage caveat; needs ce11, not sacCer3).
|
|
939
|
+
|
|
940
|
+
Yeast is nearly intron-free, so ``Gene`` and ``GeneFull`` are indistinguishable on it and the
|
|
941
|
+
existing e2e certifies neither. This one injects a known number of **intronic** reads — what a
|
|
942
|
+
single-NUCLEUS library actually contains — and asserts the two counting rules disagree in exactly
|
|
943
|
+
the way they must:
|
|
944
|
+
|
|
945
|
+
- ``Gene`` recovers the exonic truth and counts **none** of the intronic reads.
|
|
946
|
+
- ``GeneFull`` recovers exonic + intronic.
|
|
947
|
+
|
|
948
|
+
Both matrices come from **one** STARsolo run (``--soloFeatures Gene GeneFull``), so the alignment
|
|
949
|
+
is bit-identical between them and the only thing that can differ is the counting rule. If they
|
|
950
|
+
ever agreed on this data, the fixture would be broken, not passing — hence ``genefull_exceeds_gene``.
|
|
951
|
+
|
|
952
|
+
It also produces the number that matters for the design: ``gene_signal_lost`` is the fraction of a
|
|
953
|
+
nuclear library that ``--soloFeatures Gene`` silently throws away. STARsolo exits 0 either way and
|
|
954
|
+
the matrix merely looks like a thin dataset — the same failure shape as a strand inversion, and
|
|
955
|
+
exactly the class §4.1 exists to catch.
|
|
956
|
+
|
|
957
|
+
This gate runs on the **compiler's own params**: no override. It used to force
|
|
958
|
+
``soloFeatures = [Gene, GeneFull]`` past a compiler that would have emitted ``Gene``, and its
|
|
959
|
+
docstring had to admit the fixture "does NOT prove the compiler would choose GeneFull, because
|
|
960
|
+
today it cannot". It can now, so ``gene_signal_lost`` stops measuring our own bug and
|
|
961
|
+
starts measuring a **counterfactual**: what Gene-only would have cost, on a run where we did not
|
|
962
|
+
do it.
|
|
963
|
+
|
|
964
|
+
``features`` overrides the compiler's default, and exists for exactly one job: the **cost arm**
|
|
965
|
+
of the all-5-vs-pair measurement. It cannot be used to make the gate pass — the assertion below
|
|
966
|
+
demands ``{Gene, GeneFull} ⊆ composed``, so any override that would hide the intron defect fails
|
|
967
|
+
the gate instead of quietly narrowing it. Leave it ``None`` and the compiler decides, which is
|
|
968
|
+
what the gate is *for*.
|
|
969
|
+
"""
|
|
970
|
+
workdir.mkdir(parents=True, exist_ok=True)
|
|
971
|
+
models = load_gene_models(assets.fasta, assets.gtf, seed=seed)
|
|
972
|
+
if not models:
|
|
973
|
+
raise E2EUnavailable(
|
|
974
|
+
f"no gene in {assets.assembly} has a clean intron long enough to hold a read "
|
|
975
|
+
"(is this an intron-poor assembly? the fixture needs ce11, not sacCer3)"
|
|
976
|
+
)
|
|
977
|
+
sim = simulate_nuclei(
|
|
978
|
+
models,
|
|
979
|
+
n_cells=n_cells,
|
|
980
|
+
reads_per_cell=reads_per_cell,
|
|
981
|
+
intron_frac=intron_frac,
|
|
982
|
+
seed=seed,
|
|
983
|
+
)
|
|
984
|
+
|
|
985
|
+
cdna_fq = workdir / "sim_R2.fastq.gz"
|
|
986
|
+
bc_fq = workdir / "sim_R1.fastq.gz"
|
|
987
|
+
write_fastq_gz(cdna_fq, sim.cdna, "SIM")
|
|
988
|
+
write_fastq_gz(bc_fq, sim.barcode, "SIM")
|
|
989
|
+
|
|
990
|
+
spec = load_spec(E2E_TECH)
|
|
991
|
+
registry = OnlistRegistry(offline=True)
|
|
992
|
+
registry.register_synthetic("3M-february-2018", sim.whitelist)
|
|
993
|
+
|
|
994
|
+
# drive the real compiler for the params, exactly as run_e2e does
|
|
995
|
+
resolved = resolve_dataset(
|
|
996
|
+
[bc_fq, cdna_fq], registry=registry, workspace=workdir, use_cache=False
|
|
997
|
+
)
|
|
998
|
+
decided = resolved.result.candidates[0].technology if resolved.result.candidates else None
|
|
999
|
+
if decided != E2E_TECH:
|
|
1000
|
+
return {"passed": False, "stage": "resolve", "decided": decided, "expected": E2E_TECH}
|
|
1001
|
+
|
|
1002
|
+
observations = [probe_file(p) for p in (bc_fq, cdna_fq)]
|
|
1003
|
+
manifest = fill_manifest(
|
|
1004
|
+
result=resolved.result,
|
|
1005
|
+
spec=spec,
|
|
1006
|
+
observations=observations,
|
|
1007
|
+
registry=registry,
|
|
1008
|
+
experiment=ExperimentInputs(
|
|
1009
|
+
organism=EvidencedTaxid(value=6239, basis="user_confirmed", rung=0), # C. elegans
|
|
1010
|
+
samples=[SampleGroup(sample_id="s1", file_uris=[p.name for p in (bc_fq, cdna_fq)])],
|
|
1011
|
+
),
|
|
1012
|
+
seqforge_version=__version__,
|
|
1013
|
+
)
|
|
1014
|
+
report = validate_manifest(manifest)
|
|
1015
|
+
if not report.ok:
|
|
1016
|
+
return {"passed": False, "stage": "validate", "blockers": [b.code for b in report.blockers]}
|
|
1017
|
+
|
|
1018
|
+
processing, _ = fill_processing(
|
|
1019
|
+
spec=spec,
|
|
1020
|
+
dataset=manifest,
|
|
1021
|
+
processing=ProcessingInputs(
|
|
1022
|
+
assembly=assets.assembly,
|
|
1023
|
+
annotation_name=assets.annotation,
|
|
1024
|
+
features=features,
|
|
1025
|
+
),
|
|
1026
|
+
seqforge_version=__version__,
|
|
1027
|
+
)
|
|
1028
|
+
composed = compose_plan(manifest, processing, registry=registry)
|
|
1029
|
+
solo = dict(composed.config["solo"]) # type: ignore[arg-type]
|
|
1030
|
+
composed_features = _feature_list(solo["soloFeatures"])
|
|
1031
|
+
# No override. The compiler's own params run, and both Gene and GeneFull are among them because
|
|
1032
|
+
# the default counts everything. If that ever regresses, this gate cannot even read its own
|
|
1033
|
+
# matrices — which is the point of asserting it here rather than trusting the default.
|
|
1034
|
+
if not {"Gene", "GeneFull"} <= set(composed_features):
|
|
1035
|
+
return {
|
|
1036
|
+
"passed": False,
|
|
1037
|
+
"stage": "compose",
|
|
1038
|
+
"reason": "the compiler no longer emits both Gene and GeneFull",
|
|
1039
|
+
"composed_soloFeatures": composed_features,
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
h5ad, star_prefix = run_composed(
|
|
1043
|
+
assets,
|
|
1044
|
+
manifest=manifest,
|
|
1045
|
+
processing=processing,
|
|
1046
|
+
registry=registry,
|
|
1047
|
+
workspace=workdir / "composed",
|
|
1048
|
+
fastq_dir=workdir,
|
|
1049
|
+
threads=threads,
|
|
1050
|
+
)
|
|
1051
|
+
|
|
1052
|
+
# Both counts out of ONE file, which is the point of the packaging step: `Gene` is the primary
|
|
1053
|
+
# feature so it is `X`, and `GeneFull` is a layer beside it. Reading them from the h5ad rather
|
|
1054
|
+
# than from two Solo.out directories means this arm now also proves the two matrices landed on
|
|
1055
|
+
# the SAME cells and the SAME genes — a misaligned layer would show up here as counts on the
|
|
1056
|
+
# wrong gene, which is exactly what the exon-vs-full comparison is measuring.
|
|
1057
|
+
gene = parse_h5ad(h5ad)
|
|
1058
|
+
full = parse_h5ad(h5ad, layer="GeneFull")
|
|
1059
|
+
v_gene = _compare(sim.truth_exonic, gene)
|
|
1060
|
+
v_full = _compare(sim.truth_full, full)
|
|
1061
|
+
|
|
1062
|
+
n_exonic = sum(sim.truth_exonic.values())
|
|
1063
|
+
n_intronic = sum(sim.truth_intronic.values())
|
|
1064
|
+
total = n_exonic + n_intronic
|
|
1065
|
+
gene_total, full_total = sum(gene.values()), sum(full.values())
|
|
1066
|
+
|
|
1067
|
+
# Gene must not count intronic reads: its total may not meaningfully exceed the exonic truth.
|
|
1068
|
+
# (<=1.02x rather than <= exactly: STAR can place a rare read ambiguously either way.)
|
|
1069
|
+
gene_excludes_introns = gene_total <= n_exonic * 1.02
|
|
1070
|
+
genefull_exceeds_gene = full_total > gene_total
|
|
1071
|
+
recovery_gene = float(v_gene["recovery_rate"]) # type: ignore[arg-type]
|
|
1072
|
+
recovery_full = float(v_full["recovery_rate"]) # type: ignore[arg-type]
|
|
1073
|
+
|
|
1074
|
+
passed = (
|
|
1075
|
+
v_gene["n_spurious_pairs"] == 0
|
|
1076
|
+
and v_full["n_spurious_pairs"] == 0
|
|
1077
|
+
and v_full["n_inflated_pairs"] == 0
|
|
1078
|
+
and gene_excludes_introns
|
|
1079
|
+
and genefull_exceeds_gene
|
|
1080
|
+
and recovery_gene >= min_recovery
|
|
1081
|
+
and recovery_full >= min_recovery
|
|
1082
|
+
)
|
|
1083
|
+
return {
|
|
1084
|
+
"passed": bool(passed),
|
|
1085
|
+
"stage": "counts",
|
|
1086
|
+
"assembly": assets.assembly,
|
|
1087
|
+
"decided": decided,
|
|
1088
|
+
"n_gene_models": len(models),
|
|
1089
|
+
"n_cells": n_cells,
|
|
1090
|
+
"injected_exonic": n_exonic,
|
|
1091
|
+
"injected_intronic": n_intronic,
|
|
1092
|
+
"gene_total": gene_total,
|
|
1093
|
+
"genefull_total": full_total,
|
|
1094
|
+
"recovery_gene_vs_exonic": round(recovery_gene, 4),
|
|
1095
|
+
"recovery_genefull_vs_full": round(recovery_full, 4),
|
|
1096
|
+
"gene_excludes_introns": gene_excludes_introns,
|
|
1097
|
+
"genefull_exceeds_gene": genefull_exceeds_gene,
|
|
1098
|
+
# THE HEADLINE, and it is now a COUNTERFACTUAL: what --soloFeatures Gene alone WOULD have
|
|
1099
|
+
# discarded from this nuclear library, measured on a run that did not discard it.
|
|
1100
|
+
"gene_signal_lost": round(1 - (gene_total / total), 4) if total else 0.0,
|
|
1101
|
+
# what the real compiler emitted — no override. This is the assertion.
|
|
1102
|
+
"composed_soloFeatures": composed_features,
|
|
1103
|
+
"primary_feature": composed.config.get("primary_feature"),
|
|
1104
|
+
# No `cost` key any more, and its absence is the honest answer rather than a regression.
|
|
1105
|
+
# This arm now runs the composed Snakefile, so the process it could time is *snakemake*, and
|
|
1106
|
+
# `wait4`'s `ru_maxrss` folds in descendants — the number would be "STAR's peak, probably".
|
|
1107
|
+
# A memory instrument may not be approximate. `kb e2e-cost` invokes STAR directly and is the
|
|
1108
|
+
# instrument; design.md §4.1 already says so ("no longer `kb e2e-introns --quantify`, which
|
|
1109
|
+
# is a correctness gate that happens to time itself"). This is that sentence becoming true.
|
|
1110
|
+
"star": star_stats(star_prefix),
|
|
1111
|
+
"gene_verdict": v_gene,
|
|
1112
|
+
"genefull_verdict": v_full,
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
|
|
1116
|
+
def run_cost_sweep(
|
|
1117
|
+
assets: E2EAssets,
|
|
1118
|
+
*,
|
|
1119
|
+
workdir: Path,
|
|
1120
|
+
whitelist: Path,
|
|
1121
|
+
sweep: tuple[int, ...] = (2_000_000, 8_000_000, 32_000_000),
|
|
1122
|
+
n_cells: int = 5_000,
|
|
1123
|
+
intron_frac: float = 0.4,
|
|
1124
|
+
read_len: int = 90,
|
|
1125
|
+
max_genes: int = 2_000,
|
|
1126
|
+
threads: int = 16,
|
|
1127
|
+
gen_jobs: int = 16,
|
|
1128
|
+
seed: int = 0,
|
|
1129
|
+
features: tuple[str, ...] | None = None,
|
|
1130
|
+
out_sam_type: tuple[str, ...] = ("None",),
|
|
1131
|
+
timeout: int = 24 * 3600,
|
|
1132
|
+
keep_reads: bool = False,
|
|
1133
|
+
) -> dict[str, object]:
|
|
1134
|
+
"""Price STARsolo's peak RSS against read depth, on the compiler's own params.
|
|
1135
|
+
|
|
1136
|
+
**Why a sweep and not one big run.** A single number would be dominated by the thing that does
|
|
1137
|
+
not vary: the hg38 index is ~30 GB resident before a single read is parsed, and on ce11 a 500x
|
|
1138
|
+
increase in reads moved peak RSS by 5 MB — the measurement was almost entirely index. What
|
|
1139
|
+
transfers to a corpus of 10^4 datasets of *different depths* is the line, not a point: an
|
|
1140
|
+
intercept you pay per job and a slope you pay per read. So we fit both, and report the fit's
|
|
1141
|
+
residual so a reader can see whether the linear model deserved to be believed.
|
|
1142
|
+
|
|
1143
|
+
The extrapolation is the deliverable and also the weakest link, which is why the result carries
|
|
1144
|
+
``extrapolation_factor`` explicitly: fitting to 32 M reads and quoting 500 M is a 16x reach, and
|
|
1145
|
+
a reader who does not know that cannot judge the number.
|
|
1146
|
+
"""
|
|
1147
|
+
workdir.mkdir(parents=True, exist_ok=True)
|
|
1148
|
+
log: list[str] = []
|
|
1149
|
+
|
|
1150
|
+
def note(msg: str) -> None:
|
|
1151
|
+
log.append(msg)
|
|
1152
|
+
_progress(msg)
|
|
1153
|
+
|
|
1154
|
+
star_v = _star_version(assets.star_bin)
|
|
1155
|
+
note(f"loading whitelist {whitelist}")
|
|
1156
|
+
barcodes = read_whitelist(whitelist)
|
|
1157
|
+
if len(barcodes) < n_cells:
|
|
1158
|
+
raise E2EUnavailable(f"whitelist has {len(barcodes)} barcodes; need >= {n_cells}")
|
|
1159
|
+
rng = random.Random(seed)
|
|
1160
|
+
cbs = rng.sample(barcodes, n_cells)
|
|
1161
|
+
note(f"whitelist: {len(barcodes)} barcodes; sampled {n_cells} as cells")
|
|
1162
|
+
|
|
1163
|
+
note(f"building gene models from {assets.gtf}")
|
|
1164
|
+
models = load_cost_models(assets.fasta, assets.gtf, max_genes=max_genes, seed=seed)
|
|
1165
|
+
if not models:
|
|
1166
|
+
raise E2EUnavailable(f"no usable gene models from {assets.gtf}")
|
|
1167
|
+
note(f"gene models: {len(models)}")
|
|
1168
|
+
|
|
1169
|
+
# The params are the compiler's, derived ONCE from a small fixture: they do not depend on read
|
|
1170
|
+
# depth, and re-deriving them per point would only add ways for the arms to differ. Driving the
|
|
1171
|
+
# real compiler (rather than hand-writing a STAR command line) is what makes this a measurement
|
|
1172
|
+
# of *our pipeline* instead of a measurement of STARsolo in the abstract.
|
|
1173
|
+
note("deriving params: resolve -> fill -> compose on a small fixture")
|
|
1174
|
+
solo, wl_path, decided = _compose_cost_params(
|
|
1175
|
+
assets,
|
|
1176
|
+
workdir=workdir,
|
|
1177
|
+
models=models,
|
|
1178
|
+
cbs=cbs,
|
|
1179
|
+
barcodes=barcodes,
|
|
1180
|
+
features=features,
|
|
1181
|
+
intron_frac=intron_frac,
|
|
1182
|
+
read_len=read_len,
|
|
1183
|
+
seed=seed,
|
|
1184
|
+
)
|
|
1185
|
+
feature_list = _feature_list(solo["soloFeatures"])
|
|
1186
|
+
note(f"compiler decided {decided!r}; soloFeatures = {feature_list}")
|
|
1187
|
+
|
|
1188
|
+
# Resume (disk is state, context is cache). A preemptible partition can requeue this job at
|
|
1189
|
+
# any moment, and a requeue that redid five hours of STAR would make the cheap partition the
|
|
1190
|
+
# expensive one. A point already measured at this depth, under these same features, is a fact —
|
|
1191
|
+
# so it is reloaded rather than recomputed. The features check is what makes that safe: the same
|
|
1192
|
+
# depth measured under a different --quantify is a different measurement wearing the same tag.
|
|
1193
|
+
partial_path = workdir / "cost_sweep.partial.json"
|
|
1194
|
+
fingerprint = _cost_fingerprint(
|
|
1195
|
+
feature_list=feature_list,
|
|
1196
|
+
assembly=assets.assembly,
|
|
1197
|
+
annotation=assets.annotation,
|
|
1198
|
+
n_cells=n_cells,
|
|
1199
|
+
intron_frac=intron_frac,
|
|
1200
|
+
read_len=read_len,
|
|
1201
|
+
max_genes=max_genes,
|
|
1202
|
+
threads=threads,
|
|
1203
|
+
seed=seed,
|
|
1204
|
+
star_version=star_v,
|
|
1205
|
+
whitelist_entries=len(barcodes),
|
|
1206
|
+
out_sam_type=out_sam_type,
|
|
1207
|
+
)
|
|
1208
|
+
done = _load_resumable_points(partial_path, fingerprint)
|
|
1209
|
+
if done:
|
|
1210
|
+
note(f"resuming ({fingerprint}): depths {sorted(done)} already measured")
|
|
1211
|
+
|
|
1212
|
+
points: list[dict[str, object]] = []
|
|
1213
|
+
for n_reads in sweep:
|
|
1214
|
+
tag = f"{n_reads // 1_000_000}M"
|
|
1215
|
+
if n_reads in done:
|
|
1216
|
+
points.append(done[n_reads])
|
|
1217
|
+
note(f"{tag}: already measured ({done[n_reads].get('star_peak_rss_gb')} GB) — skipping")
|
|
1218
|
+
continue
|
|
1219
|
+
note(f"--- point {tag}: generating reads on {gen_jobs} cores ---")
|
|
1220
|
+
t0 = time.monotonic()
|
|
1221
|
+
cdna_fq, bc_fq, gen = write_cost_fastqs_sharded(
|
|
1222
|
+
models,
|
|
1223
|
+
n_reads=n_reads,
|
|
1224
|
+
cbs=cbs,
|
|
1225
|
+
out_dir=workdir,
|
|
1226
|
+
tag=tag,
|
|
1227
|
+
n_workers=gen_jobs,
|
|
1228
|
+
intron_frac=intron_frac,
|
|
1229
|
+
read_len=read_len,
|
|
1230
|
+
seed=seed,
|
|
1231
|
+
)
|
|
1232
|
+
note(
|
|
1233
|
+
f"{tag}: generated {n_reads} reads in {gen['n_shards']} shards, {time.monotonic() - t0:.0f}s"
|
|
1234
|
+
)
|
|
1235
|
+
|
|
1236
|
+
outdir = workdir / f"star_{tag}"
|
|
1237
|
+
cost: dict[str, object] = {}
|
|
1238
|
+
note(f"{tag}: running STAR ({threads} threads)")
|
|
1239
|
+
# The sweep ascends, and a point can fail for the very reason we are measuring: the biggest
|
|
1240
|
+
# depth is the one that can exhaust the cgroup. Losing it must not lose the points below it,
|
|
1241
|
+
# which already determine the slope — so a failure is recorded and the sweep moves on.
|
|
1242
|
+
try:
|
|
1243
|
+
run_starsolo(
|
|
1244
|
+
assets,
|
|
1245
|
+
cdna_fq=cdna_fq,
|
|
1246
|
+
barcode_fq=bc_fq,
|
|
1247
|
+
whitelist=wl_path,
|
|
1248
|
+
solo=solo,
|
|
1249
|
+
outdir=outdir,
|
|
1250
|
+
threads=threads,
|
|
1251
|
+
cost=cost,
|
|
1252
|
+
timeout=timeout,
|
|
1253
|
+
out_sam_type=out_sam_type,
|
|
1254
|
+
)
|
|
1255
|
+
except E2EUnavailable as exc:
|
|
1256
|
+
note(f"{tag}: FAILED — {exc}")
|
|
1257
|
+
points.append({"n_reads": n_reads, **gen, "failed": True, "error": str(exc)})
|
|
1258
|
+
else:
|
|
1259
|
+
points.append({"n_reads": n_reads, **gen, **cost, "star": star_stats(outdir)})
|
|
1260
|
+
note(f"{tag}: peak RSS {cost.get('star_peak_rss_gb')} GB in {cost.get('star_wall_s')}s")
|
|
1261
|
+
if not keep_reads:
|
|
1262
|
+
for p in (*cdna_fq, *bc_fq):
|
|
1263
|
+
p.unlink(missing_ok=True)
|
|
1264
|
+
note(f"{tag}: reads deleted (--keep-reads to retain)")
|
|
1265
|
+
# Disk is state: a hard kill (Slurm OOM, wall-clock) must not cost us the points we
|
|
1266
|
+
# already paid for, so every point is durable the moment it exists rather than at the end.
|
|
1267
|
+
_atomic_write_json(
|
|
1268
|
+
partial_path,
|
|
1269
|
+
{"fingerprint": fingerprint, "soloFeatures": feature_list, "points": points},
|
|
1270
|
+
)
|
|
1271
|
+
|
|
1272
|
+
measured = [p for p in points if not p.get("failed")]
|
|
1273
|
+
fit = _fit_line([(int(p["n_reads"]), float(p["star_peak_rss_gb"])) for p in measured]) # type: ignore[arg-type]
|
|
1274
|
+
return {
|
|
1275
|
+
"assembly": assets.assembly,
|
|
1276
|
+
"annotation": assets.annotation,
|
|
1277
|
+
"star_version": star_v,
|
|
1278
|
+
"fingerprint": fingerprint,
|
|
1279
|
+
"decided_technology": decided,
|
|
1280
|
+
"soloFeatures": feature_list,
|
|
1281
|
+
"n_cells": n_cells,
|
|
1282
|
+
"n_gene_models": len(models),
|
|
1283
|
+
"whitelist_entries": len(barcodes),
|
|
1284
|
+
"intron_frac": intron_frac,
|
|
1285
|
+
"read_len": read_len,
|
|
1286
|
+
# Both are recorded because both change the number: STAR's per-thread buffers are real, so a
|
|
1287
|
+
# peak measured at 16 threads is a peak AT 16 THREADS and says nothing on its own about 48.
|
|
1288
|
+
"threads": threads,
|
|
1289
|
+
"gen_jobs": gen_jobs,
|
|
1290
|
+
"out_sam_type": list(out_sam_type),
|
|
1291
|
+
"points": points,
|
|
1292
|
+
"fit": fit,
|
|
1293
|
+
"log": log,
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
|
|
1297
|
+
def _compose_cost_params(
|
|
1298
|
+
assets: E2EAssets,
|
|
1299
|
+
*,
|
|
1300
|
+
workdir: Path,
|
|
1301
|
+
models: list[GeneModel],
|
|
1302
|
+
cbs: list[str],
|
|
1303
|
+
barcodes: list[str],
|
|
1304
|
+
features: tuple[str, ...] | None,
|
|
1305
|
+
intron_frac: float,
|
|
1306
|
+
read_len: int,
|
|
1307
|
+
seed: int,
|
|
1308
|
+
n_probe_reads: int = 200_000,
|
|
1309
|
+
) -> tuple[dict[str, object], Path, str | None]:
|
|
1310
|
+
"""Drive resolve -> fill -> compose on a small fixture and return the params STAR should run.
|
|
1311
|
+
|
|
1312
|
+
The fixture is small because the probe is budgeted and none of resolve/fill/compose cares
|
|
1313
|
+
how deep the library is — only the aligner does. Deriving the params from data rather than typing
|
|
1314
|
+
them here is what keeps this honest: if the compiler stopped recognizing 10x v3, or stopped
|
|
1315
|
+
emitting Velocyto, this reports that instead of quietly measuring a command line we wrote by hand.
|
|
1316
|
+
|
|
1317
|
+
The technology is **reported, not asserted**. v3 and v3.1 are byte-identical and declared
|
|
1318
|
+
processing-equivalent, so the resolver is free to pick either; demanding one would be asserting
|
|
1319
|
+
something the KB explicitly says is not decidable, and it makes no difference to memory.
|
|
1320
|
+
"""
|
|
1321
|
+
probe_cdna = workdir / "params_probe_R2.fastq.gz"
|
|
1322
|
+
probe_bc = workdir / "params_probe_R1.fastq.gz"
|
|
1323
|
+
write_cost_fastqs(
|
|
1324
|
+
models,
|
|
1325
|
+
n_reads=n_probe_reads,
|
|
1326
|
+
cbs=cbs,
|
|
1327
|
+
cdna_path=probe_cdna,
|
|
1328
|
+
bc_path=probe_bc,
|
|
1329
|
+
intron_frac=intron_frac,
|
|
1330
|
+
read_len=read_len,
|
|
1331
|
+
seed=seed,
|
|
1332
|
+
)
|
|
1333
|
+
spec = load_spec(E2E_TECH)
|
|
1334
|
+
registry = OnlistRegistry(offline=True)
|
|
1335
|
+
registry.register_synthetic("3M-february-2018", barcodes)
|
|
1336
|
+
resolved = resolve_dataset(
|
|
1337
|
+
[probe_bc, probe_cdna], registry=registry, workspace=workdir, use_cache=False
|
|
1338
|
+
)
|
|
1339
|
+
if not resolved.result.candidates:
|
|
1340
|
+
raise E2EUnavailable("the resolver found no candidate technology for the cost fixture")
|
|
1341
|
+
decided = resolved.result.candidates[0].technology
|
|
1342
|
+
observations = [probe_file(p) for p in (probe_bc, probe_cdna)]
|
|
1343
|
+
manifest = fill_manifest(
|
|
1344
|
+
result=resolved.result,
|
|
1345
|
+
spec=spec,
|
|
1346
|
+
observations=observations,
|
|
1347
|
+
registry=registry,
|
|
1348
|
+
experiment=ExperimentInputs(
|
|
1349
|
+
organism=EvidencedTaxid(value=9606, basis="user_confirmed", rung=0), # H. sapiens
|
|
1350
|
+
samples=[
|
|
1351
|
+
SampleGroup(sample_id="cost", file_uris=[p.name for p in (probe_bc, probe_cdna)])
|
|
1352
|
+
],
|
|
1353
|
+
),
|
|
1354
|
+
seqforge_version=__version__,
|
|
1355
|
+
)
|
|
1356
|
+
report = validate_manifest(manifest)
|
|
1357
|
+
if not report.ok:
|
|
1358
|
+
raise E2EUnavailable(
|
|
1359
|
+
f"cost fixture manifest is invalid: {[b.code for b in report.blockers]}"
|
|
1360
|
+
)
|
|
1361
|
+
processing, _ = fill_processing(
|
|
1362
|
+
spec=spec,
|
|
1363
|
+
dataset=manifest,
|
|
1364
|
+
processing=ProcessingInputs(
|
|
1365
|
+
assembly=assets.assembly, annotation_name=assets.annotation, features=features
|
|
1366
|
+
),
|
|
1367
|
+
seqforge_version=__version__,
|
|
1368
|
+
)
|
|
1369
|
+
composed = compose_plan(manifest, processing, registry=registry)
|
|
1370
|
+
solo = dict(composed.config["solo"]) # type: ignore[arg-type]
|
|
1371
|
+
|
|
1372
|
+
# run_starsolo takes the whitelist as a path of its own (the composed value is a run-dir-relative
|
|
1373
|
+
# name), exactly as the gates do it.
|
|
1374
|
+
wl_path = workdir / "whitelist.txt"
|
|
1375
|
+
lines = next(iter(composed.onlist_files.values()), None)
|
|
1376
|
+
if lines is None:
|
|
1377
|
+
raise E2EUnavailable("compose materialized no onlist, so --soloCBwhitelist has no file")
|
|
1378
|
+
wl_path.write_text("\n".join(lines) + "\n")
|
|
1379
|
+
for p in (probe_cdna, probe_bc):
|
|
1380
|
+
p.unlink(missing_ok=True)
|
|
1381
|
+
return solo, wl_path, decided
|
|
1382
|
+
|
|
1383
|
+
|
|
1384
|
+
def _load_resumable_points(partial_path: Path, fingerprint: str) -> dict[int, dict[str, object]]:
|
|
1385
|
+
"""Reload depths already measured under ``fingerprint``; ``{}`` if nothing is safe to reuse.
|
|
1386
|
+
|
|
1387
|
+
**The fingerprint must cover every input that moves the number, not just the interesting one.**
|
|
1388
|
+
This checked only ``soloFeatures`` at first, on the reasoning that a Gene-only number spliced into
|
|
1389
|
+
an all-five curve would corrupt the slope. True — and so would a number measured at different
|
|
1390
|
+
threads (per-thread buffers are in the peak), different cells, a different assembly, or a
|
|
1391
|
+
different read length. An audit caught it, and the sting was in the part I had not considered: the
|
|
1392
|
+
partial file is **never deleted**, so a *completed* sweep leaves the resume armed. It needs no
|
|
1393
|
+
preemption at all — a second invocation in the same workdir with ``--threads 48`` would silently
|
|
1394
|
+
reuse the 16-thread points and report the blend as one line.
|
|
1395
|
+
|
|
1396
|
+
That is precisely the failure the guard was written to prevent, reintroduced by the guard being
|
|
1397
|
+
narrower than the thing it guards. Anything unreadable is treated as absent: a cache is never
|
|
1398
|
+
worth a wrong answer.
|
|
1399
|
+
"""
|
|
1400
|
+
if not partial_path.is_file():
|
|
1401
|
+
return {}
|
|
1402
|
+
try:
|
|
1403
|
+
prior = json.loads(partial_path.read_text())
|
|
1404
|
+
if prior.get("fingerprint") != fingerprint:
|
|
1405
|
+
return {}
|
|
1406
|
+
return {int(p["n_reads"]): p for p in prior.get("points", []) if not p.get("failed")}
|
|
1407
|
+
except (json.JSONDecodeError, KeyError, TypeError, ValueError):
|
|
1408
|
+
return {}
|
|
1409
|
+
|
|
1410
|
+
|
|
1411
|
+
def _cost_fingerprint(
|
|
1412
|
+
*,
|
|
1413
|
+
feature_list: list[str],
|
|
1414
|
+
assembly: str,
|
|
1415
|
+
annotation: str,
|
|
1416
|
+
n_cells: int,
|
|
1417
|
+
intron_frac: float,
|
|
1418
|
+
read_len: int,
|
|
1419
|
+
max_genes: int,
|
|
1420
|
+
threads: int,
|
|
1421
|
+
seed: int,
|
|
1422
|
+
star_version: str,
|
|
1423
|
+
whitelist_entries: int,
|
|
1424
|
+
out_sam_type: tuple[str, ...],
|
|
1425
|
+
) -> str:
|
|
1426
|
+
"""Everything a measured peak depends on, hashed — the key a resumed point must match.
|
|
1427
|
+
|
|
1428
|
+
Derived from the arguments rather than listed by hand somewhere else, because a hand-kept list of
|
|
1429
|
+
what matters is the exact shape of defect this repo keeps finding. If a new knob starts moving the
|
|
1430
|
+
number, it belongs in this signature and the type checker will say so at every call site.
|
|
1431
|
+
|
|
1432
|
+
``gen_jobs`` is deliberately absent: it changes how fast the reads are written and nothing about
|
|
1433
|
+
what they are (shard seeds derive from ``seed``), so it cannot move a peak. ``sweep`` is absent
|
|
1434
|
+
too — depths are the x-axis, not a property of the curve, which is the whole reason a resumed run
|
|
1435
|
+
may keep points from a shorter sweep.
|
|
1436
|
+
"""
|
|
1437
|
+
payload = json.dumps(
|
|
1438
|
+
{
|
|
1439
|
+
"soloFeatures": feature_list,
|
|
1440
|
+
"assembly": assembly,
|
|
1441
|
+
"annotation": annotation,
|
|
1442
|
+
"n_cells": n_cells,
|
|
1443
|
+
"intron_frac": intron_frac,
|
|
1444
|
+
"read_len": read_len,
|
|
1445
|
+
"max_genes": max_genes,
|
|
1446
|
+
"threads": threads,
|
|
1447
|
+
"seed": seed,
|
|
1448
|
+
"star_version": star_version,
|
|
1449
|
+
"whitelist_entries": whitelist_entries,
|
|
1450
|
+
"out_sam_type": list(out_sam_type),
|
|
1451
|
+
},
|
|
1452
|
+
sort_keys=True,
|
|
1453
|
+
)
|
|
1454
|
+
return hashlib.sha256(payload.encode()).hexdigest()[:16]
|
|
1455
|
+
|
|
1456
|
+
|
|
1457
|
+
def _fit_line(points: list[tuple[int, float]]) -> dict[str, object]:
|
|
1458
|
+
"""Least-squares ``rss_gb = intercept + slope * reads``; the slope is the whole point.
|
|
1459
|
+
|
|
1460
|
+
Reports ``max_residual_gb`` so the linear model is falsifiable by its own output rather than
|
|
1461
|
+
assumed: if the points do not sit on a line, extrapolating from them is not defensible and the
|
|
1462
|
+
residual is what says so.
|
|
1463
|
+
|
|
1464
|
+
**Two points cannot do that, so two points are not a fit.** A line through two points passes
|
|
1465
|
+
through them exactly: the residual is identically 0.0 whatever the truth is, so the one number
|
|
1466
|
+
that exists to falsify linearity goes vacuous precisely on the run carrying the least evidence —
|
|
1467
|
+
and reads as the *strongest* possible evidence while doing it. That is not hypothetical. The sweep
|
|
1468
|
+
is deliberately resilient (a point that exhausts the cgroup is recorded and skipped), and the
|
|
1469
|
+
default sweep is three points, so a single lost point lands here at n=2. Worse, if the MIDDLE
|
|
1470
|
+
point is the one lost, every other field is unchanged — same max_measured_reads, same
|
|
1471
|
+
extrapolation_factor — and only the slope silently absorbs the noise that should have surfaced as
|
|
1472
|
+
residual. The run_cost_sweep comment that the remaining "points below already determine the slope"
|
|
1473
|
+
is the fallacy in one line: two points always determine *a* slope, never that it is the right one.
|
|
1474
|
+
|
|
1475
|
+
So ``ok`` requires 3, and ``n_points``/``degrees_of_freedom`` ship with every verdict — a fit with
|
|
1476
|
+
nothing left over may not advertise a perfect one.
|
|
1477
|
+
"""
|
|
1478
|
+
n = len(points)
|
|
1479
|
+
if n < 3:
|
|
1480
|
+
return {
|
|
1481
|
+
"ok": False,
|
|
1482
|
+
"n_points": n,
|
|
1483
|
+
"degrees_of_freedom": max(0, n - 2),
|
|
1484
|
+
"reason": (
|
|
1485
|
+
"need >= 3 points: a line through 2 points fits them exactly, so max_residual_gb "
|
|
1486
|
+
"would be 0.0 by construction and could not falsify anything"
|
|
1487
|
+
),
|
|
1488
|
+
}
|
|
1489
|
+
mean_x = sum(x for x, _ in points) / n
|
|
1490
|
+
mean_y = sum(y for _, y in points) / n
|
|
1491
|
+
denom = sum((x - mean_x) ** 2 for x, _ in points)
|
|
1492
|
+
if denom == 0:
|
|
1493
|
+
return {"ok": False, "n_points": n, "reason": "all points at the same read depth"}
|
|
1494
|
+
slope = sum((x - mean_x) * (y - mean_y) for x, y in points) / denom
|
|
1495
|
+
intercept = mean_y - slope * mean_x
|
|
1496
|
+
residuals = [abs(y - (intercept + slope * x)) for x, y in points]
|
|
1497
|
+
max_reads = max(x for x, _ in points)
|
|
1498
|
+
return {
|
|
1499
|
+
"ok": True,
|
|
1500
|
+
# Shipped with every verdict so max_residual_gb can never be read without knowing how much
|
|
1501
|
+
# slack it actually had. A residual of 0.0 means "linear" at 5 points and means nothing at 2;
|
|
1502
|
+
# the number alone cannot tell you which, so the number never travels alone.
|
|
1503
|
+
"n_points": n,
|
|
1504
|
+
"degrees_of_freedom": n - 2,
|
|
1505
|
+
"intercept_gb": round(intercept, 3),
|
|
1506
|
+
"bytes_per_read": round(slope * 1024**3, 1),
|
|
1507
|
+
"gb_per_100m_reads": round(slope * 100e6, 3),
|
|
1508
|
+
"max_residual_gb": round(max(residuals), 3),
|
|
1509
|
+
"max_measured_reads": max_reads,
|
|
1510
|
+
"projected": {
|
|
1511
|
+
f"{d // 1_000_000}M_reads": {
|
|
1512
|
+
"peak_rss_gb": round(intercept + slope * d, 1),
|
|
1513
|
+
"extrapolation_factor": round(d / max_reads, 1),
|
|
1514
|
+
}
|
|
1515
|
+
for d in (100_000_000, 250_000_000, 500_000_000, 1_000_000_000)
|
|
1516
|
+
},
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
|
|
1520
|
+
def _star_version(star_bin: str) -> str:
|
|
1521
|
+
try:
|
|
1522
|
+
return subprocess.run(
|
|
1523
|
+
[star_bin, "--version"], capture_output=True, text=True, timeout=30
|
|
1524
|
+
).stdout.strip()
|
|
1525
|
+
except Exception: # pragma: no cover - a version string is never worth failing a run for
|
|
1526
|
+
return "unknown"
|
|
1527
|
+
|
|
1528
|
+
|
|
1529
|
+
def load_cost_models(
|
|
1530
|
+
fasta: Path,
|
|
1531
|
+
gtf: Path,
|
|
1532
|
+
*,
|
|
1533
|
+
min_len: int = 600,
|
|
1534
|
+
min_intron: int = 300,
|
|
1535
|
+
max_genes: int = 2000,
|
|
1536
|
+
seed: int = 0,
|
|
1537
|
+
) -> list[GeneModel]:
|
|
1538
|
+
"""Gene models for the COST probe: mRNA + introns, with none of the ambiguity screening.
|
|
1539
|
+
|
|
1540
|
+
:func:`load_gene_models` spends nearly all of its time proving each intron is unambiguously one
|
|
1541
|
+
gene's, because the intron *gate* asserts an exact count and a read STAR could legitimately place
|
|
1542
|
+
elsewhere would make the injected truth a fiction. The cost probe asserts nothing about counts —
|
|
1543
|
+
it measures bytes — so that screening buys it nothing and costs a genome-wide overlap scan whose
|
|
1544
|
+
inner loop rebuilds an O(n) list per candidate (fine on ce11, not on hg38).
|
|
1545
|
+
|
|
1546
|
+
What the cost probe does need is reads landing in **both** exons and introns, so that all five
|
|
1547
|
+
counting rules — Velocyto above all, which is the one being priced — have real work to do.
|
|
1548
|
+
"""
|
|
1549
|
+
chroms = read_fasta(fasta)
|
|
1550
|
+
exons = _parse_exons(gtf)
|
|
1551
|
+
models: list[GeneModel] = []
|
|
1552
|
+
for gene_id, parts in sorted(exons.items()):
|
|
1553
|
+
chrom, strand = parts[0][0], parts[0][3]
|
|
1554
|
+
if chrom not in chroms:
|
|
1555
|
+
continue
|
|
1556
|
+
merged = _merge([(s, e) for _c, s, e, _st in parts])
|
|
1557
|
+
# .upper() BEFORE _revcomp (see load_genes): complementing a lowercase base is a no-op, so
|
|
1558
|
+
# revcomp-then-upper silently emits reversed-but-uncomplemented sequence.
|
|
1559
|
+
mrna = "".join(
|
|
1560
|
+
chroms[chrom][s - 1 : e] for s, e in merged
|
|
1561
|
+
).upper() # GTF is 1-based inclusive
|
|
1562
|
+
if strand == "-":
|
|
1563
|
+
mrna = _revcomp(mrna)
|
|
1564
|
+
if len(mrna) < min_len or "N" in mrna:
|
|
1565
|
+
continue
|
|
1566
|
+
introns: list[str] = []
|
|
1567
|
+
for (_s1, e1), (s2, _e2) in zip(merged, merged[1:], strict=False):
|
|
1568
|
+
istart, iend = e1 + 1, s2 - 1
|
|
1569
|
+
if iend - istart + 1 < min_intron:
|
|
1570
|
+
continue
|
|
1571
|
+
seq = chroms[chrom][istart - 1 : iend].upper()
|
|
1572
|
+
if "N" in seq:
|
|
1573
|
+
continue
|
|
1574
|
+
introns.append(_revcomp(seq) if strand == "-" else seq)
|
|
1575
|
+
if introns:
|
|
1576
|
+
models.append(GeneModel(gene_id=gene_id, mrna=mrna, introns=tuple(introns)))
|
|
1577
|
+
rng = random.Random(seed)
|
|
1578
|
+
rng.shuffle(models)
|
|
1579
|
+
return models[:max_genes]
|
|
1580
|
+
|
|
1581
|
+
|
|
1582
|
+
#: All 4096 six-mers. A 12 bp UMI is two of them, so ``T[getrandbits(12)] + T[getrandbits(12)]``
|
|
1583
|
+
#: draws uniformly from the whole 4^12 space with two list lookups instead of twelve rng.choice
|
|
1584
|
+
#: calls. At 10^8 reads that difference is the run.
|
|
1585
|
+
_SIXMERS: list[str] = ["".join(p) for p in _product("ACGT", repeat=6)]
|
|
1586
|
+
|
|
1587
|
+
|
|
1588
|
+
def write_cost_fastqs(
|
|
1589
|
+
models: list[GeneModel],
|
|
1590
|
+
*,
|
|
1591
|
+
n_reads: int,
|
|
1592
|
+
cbs: list[str],
|
|
1593
|
+
cdna_path: Path,
|
|
1594
|
+
bc_path: Path,
|
|
1595
|
+
intron_frac: float = 0.4,
|
|
1596
|
+
read_len: int = 90,
|
|
1597
|
+
seed: int = 0,
|
|
1598
|
+
chunk: int = 200_000,
|
|
1599
|
+
) -> dict[str, int]:
|
|
1600
|
+
"""Emit ``n_reads`` 10x-3'-v3-shaped reads from pre-mRNA. No ground truth — this prices, not proves.
|
|
1601
|
+
|
|
1602
|
+
Two deliberate departures from :func:`simulate_nuclei`, both of which exist *because* it is a
|
|
1603
|
+
cost arm and not a gate:
|
|
1604
|
+
|
|
1605
|
+
**UMIs are drawn at random, not forced globally unique.** The gate's uniqueness trick makes the
|
|
1606
|
+
injected count exactly the read count, and it is also a hard ceiling: a 12 bp UMI has 4^12 =
|
|
1607
|
+
16 777 216 values, so a run that demands a distinct one per read cannot exceed ~16.8 M reads and
|
|
1608
|
+
the rejection sampling degrades long before that. A cost probe must reach corpus scale, and it
|
|
1609
|
+
asserts no counts, so the constraint is pure cost. Drawing at random instead means near-zero
|
|
1610
|
+
duplication, i.e. **more distinct UMIs than real data of the same depth** — which makes every
|
|
1611
|
+
number here an over-estimate of the solo structures, and an over-estimate is the right side to
|
|
1612
|
+
err on when the output is a memory request.
|
|
1613
|
+
|
|
1614
|
+
**Barcodes come from the real whitelist**, so STARsolo does the CB matching it would really do.
|
|
1615
|
+
"""
|
|
1616
|
+
exon_src: list[tuple[str, int, int]] = [] # (seq, lo, hi) — 3'-biased window, as a 3' kit is
|
|
1617
|
+
intron_src: list[tuple[str, int]] = [] # (seq, hi)
|
|
1618
|
+
for model in models:
|
|
1619
|
+
mrna = model.mrna
|
|
1620
|
+
if len(mrna) >= read_len:
|
|
1621
|
+
tail = min(len(mrna), 500)
|
|
1622
|
+
exon_src.append((mrna, len(mrna) - tail, len(mrna) - read_len))
|
|
1623
|
+
for intron in model.introns:
|
|
1624
|
+
if len(intron) >= read_len:
|
|
1625
|
+
intron_src.append((intron, len(intron) - read_len))
|
|
1626
|
+
if not exon_src or not intron_src:
|
|
1627
|
+
raise E2EUnavailable(
|
|
1628
|
+
"the cost fixture needs both exonic and intronic source sequence; got "
|
|
1629
|
+
f"{len(exon_src)} exon and {len(intron_src)} intron sources"
|
|
1630
|
+
)
|
|
1631
|
+
|
|
1632
|
+
rng = random.Random(seed)
|
|
1633
|
+
randrange, getrandbits, random_f = rng.randrange, rng.getrandbits, rng.random
|
|
1634
|
+
sixmers, n_cb = _SIXMERS, len(cbs)
|
|
1635
|
+
n_ex, n_in = len(exon_src), len(intron_src)
|
|
1636
|
+
cdna_qual, bc_qual = "I" * read_len, "I" * (len(cbs[0]) + 12)
|
|
1637
|
+
n_intronic = 0
|
|
1638
|
+
|
|
1639
|
+
with (
|
|
1640
|
+
gzip.open(cdna_path, "wt", compresslevel=1) as cdna_fh,
|
|
1641
|
+
gzip.open(bc_path, "wt", compresslevel=1) as bc_fh,
|
|
1642
|
+
):
|
|
1643
|
+
written = 0
|
|
1644
|
+
while written < n_reads:
|
|
1645
|
+
n = min(chunk, n_reads - written)
|
|
1646
|
+
cdna_buf: list[str] = []
|
|
1647
|
+
bc_buf: list[str] = []
|
|
1648
|
+
for i in range(written, written + n):
|
|
1649
|
+
if random_f() < intron_frac:
|
|
1650
|
+
seq, hi = intron_src[randrange(n_in)]
|
|
1651
|
+
start = randrange(0, hi + 1)
|
|
1652
|
+
n_intronic += 1
|
|
1653
|
+
else:
|
|
1654
|
+
seq, lo, hi = exon_src[randrange(n_ex)]
|
|
1655
|
+
start = randrange(lo, hi + 1)
|
|
1656
|
+
cdna_buf.append(f"@SIM{i}\n{seq[start : start + read_len]}\n+\n{cdna_qual}\n")
|
|
1657
|
+
umi = sixmers[getrandbits(12)] + sixmers[getrandbits(12)]
|
|
1658
|
+
bc_buf.append(f"@SIM{i}\n{cbs[randrange(n_cb)]}{umi}\n+\n{bc_qual}\n")
|
|
1659
|
+
cdna_fh.write("".join(cdna_buf))
|
|
1660
|
+
bc_fh.write("".join(bc_buf))
|
|
1661
|
+
written += n
|
|
1662
|
+
return {"n_reads": n_reads, "n_intronic": n_intronic, "n_exonic": n_reads - n_intronic}
|
|
1663
|
+
|
|
1664
|
+
|
|
1665
|
+
#: Set in each worker by :func:`_init_gen_worker`. The models + barcode pool are read-only and big
|
|
1666
|
+
#: (2000 gene models carry their mRNA and intron sequence), so they are handed over ONCE per worker
|
|
1667
|
+
#: rather than per shard — under fork they cost nothing at all, being inherited copy-on-write.
|
|
1668
|
+
_GEN_STATE: dict[str, object] = {}
|
|
1669
|
+
|
|
1670
|
+
|
|
1671
|
+
def _init_gen_worker(models: list[GeneModel], cbs: list[str]) -> None:
|
|
1672
|
+
_GEN_STATE["models"] = models
|
|
1673
|
+
_GEN_STATE["cbs"] = cbs
|
|
1674
|
+
|
|
1675
|
+
|
|
1676
|
+
def _gen_shard(
|
|
1677
|
+
args: tuple[int, int, Path, Path, float, int, int],
|
|
1678
|
+
) -> dict[str, int]:
|
|
1679
|
+
idx, n_reads, cdna_path, bc_path, intron_frac, read_len, seed = args
|
|
1680
|
+
return write_cost_fastqs(
|
|
1681
|
+
_GEN_STATE["models"], # type: ignore[arg-type]
|
|
1682
|
+
n_reads=n_reads,
|
|
1683
|
+
cbs=_GEN_STATE["cbs"], # type: ignore[arg-type]
|
|
1684
|
+
cdna_path=cdna_path,
|
|
1685
|
+
bc_path=bc_path,
|
|
1686
|
+
intron_frac=intron_frac,
|
|
1687
|
+
read_len=read_len,
|
|
1688
|
+
# Each shard MUST draw a different stream, or N workers would emit N identical copies of the
|
|
1689
|
+
# same reads — which would still be n_reads records, still align, and still produce a
|
|
1690
|
+
# plausible number. Deterministic in the caller's seed, distinct per shard.
|
|
1691
|
+
seed=seed * 10_007 + idx,
|
|
1692
|
+
)
|
|
1693
|
+
|
|
1694
|
+
|
|
1695
|
+
def write_cost_fastqs_sharded(
|
|
1696
|
+
models: list[GeneModel],
|
|
1697
|
+
*,
|
|
1698
|
+
n_reads: int,
|
|
1699
|
+
cbs: list[str],
|
|
1700
|
+
out_dir: Path,
|
|
1701
|
+
tag: str,
|
|
1702
|
+
n_workers: int,
|
|
1703
|
+
intron_frac: float = 0.4,
|
|
1704
|
+
read_len: int = 90,
|
|
1705
|
+
seed: int = 0,
|
|
1706
|
+
) -> tuple[list[Path], list[Path], dict[str, int]]:
|
|
1707
|
+
"""Generate reads across ``n_workers`` processes, as shards STAR reads natively.
|
|
1708
|
+
|
|
1709
|
+
Generation was ~40 % of this arm's wall-clock on one core while 47 sat idle, which is the kind of
|
|
1710
|
+
waste that hides inside a job that "only" takes an hour. It parallelises perfectly — every read is
|
|
1711
|
+
independent — and the shards need no merge step, because ``--readFilesIn`` takes a comma-separated
|
|
1712
|
+
list per mate. So N cores buy an N-fold speedup and cost one extra comma.
|
|
1713
|
+
|
|
1714
|
+
The per-shard seed derivation is the part that matters: shards drawing the same stream would
|
|
1715
|
+
duplicate reads rather than add them, and the run would still align, still count, and still report
|
|
1716
|
+
a peak — just of the wrong library.
|
|
1717
|
+
"""
|
|
1718
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
1719
|
+
n_workers = max(1, min(n_workers, n_reads))
|
|
1720
|
+
base, rem = divmod(n_reads, n_workers)
|
|
1721
|
+
sizes = [base + (1 if i < rem else 0) for i in range(n_workers)]
|
|
1722
|
+
|
|
1723
|
+
jobs: list[tuple[int, int, Path, Path, float, int, int]] = []
|
|
1724
|
+
cdna_paths: list[Path] = []
|
|
1725
|
+
bc_paths: list[Path] = []
|
|
1726
|
+
for i, size in enumerate(sizes):
|
|
1727
|
+
cdna_p = out_dir / f"cost_{tag}_s{i:02d}_R2.fastq.gz"
|
|
1728
|
+
bc_p = out_dir / f"cost_{tag}_s{i:02d}_R1.fastq.gz"
|
|
1729
|
+
cdna_paths.append(cdna_p)
|
|
1730
|
+
bc_paths.append(bc_p)
|
|
1731
|
+
jobs.append((i, size, cdna_p, bc_p, intron_frac, read_len, seed))
|
|
1732
|
+
|
|
1733
|
+
if n_workers == 1:
|
|
1734
|
+
results = [_gen_shard_inline(models, cbs, jobs[0])]
|
|
1735
|
+
else:
|
|
1736
|
+
# fork where available: the gene models are inherited copy-on-write instead of pickled to
|
|
1737
|
+
# every worker (they are tens of MB of sequence). spawn still works, just pays the transfer.
|
|
1738
|
+
ctx = multiprocessing.get_context(
|
|
1739
|
+
"fork" if "fork" in multiprocessing.get_all_start_methods() else "spawn"
|
|
1740
|
+
)
|
|
1741
|
+
with ProcessPoolExecutor(
|
|
1742
|
+
max_workers=n_workers,
|
|
1743
|
+
mp_context=ctx,
|
|
1744
|
+
initializer=_init_gen_worker,
|
|
1745
|
+
initargs=(models, cbs),
|
|
1746
|
+
) as pool:
|
|
1747
|
+
results = list(pool.map(_gen_shard, jobs))
|
|
1748
|
+
|
|
1749
|
+
merged = {
|
|
1750
|
+
"n_reads": sum(r["n_reads"] for r in results),
|
|
1751
|
+
"n_intronic": sum(r["n_intronic"] for r in results),
|
|
1752
|
+
"n_exonic": sum(r["n_exonic"] for r in results),
|
|
1753
|
+
"n_shards": len(results),
|
|
1754
|
+
}
|
|
1755
|
+
if merged["n_reads"] != n_reads: # pragma: no cover - arithmetic guard on the split
|
|
1756
|
+
raise E2EUnavailable(f"sharding lost reads: asked {n_reads}, wrote {merged['n_reads']}")
|
|
1757
|
+
return cdna_paths, bc_paths, merged
|
|
1758
|
+
|
|
1759
|
+
|
|
1760
|
+
def _gen_shard_inline(
|
|
1761
|
+
models: list[GeneModel], cbs: list[str], job: tuple[int, int, Path, Path, float, int, int]
|
|
1762
|
+
) -> dict[str, int]:
|
|
1763
|
+
_init_gen_worker(models, cbs)
|
|
1764
|
+
return _gen_shard(job)
|
|
1765
|
+
|
|
1766
|
+
|
|
1767
|
+
def read_whitelist(path: Path) -> list[str]:
|
|
1768
|
+
"""Load a 10x whitelist (plain or gzipped) as barcode strings."""
|
|
1769
|
+
opener = gzip.open if path.suffix == ".gz" else open
|
|
1770
|
+
with opener(path, "rt") as fh: # type: ignore[operator]
|
|
1771
|
+
return [ln.strip() for ln in fh if ln.strip()]
|
|
1772
|
+
|
|
1773
|
+
|
|
1774
|
+
def star_stats(outdir: Path) -> dict[str, float]:
|
|
1775
|
+
"""Parse STAR's Log.final.out so the recovery shortfall is ACCOUNTED FOR, not hand-waved.
|
|
1776
|
+
|
|
1777
|
+
A read STAR maps to multiple loci is correctly dropped by STARsolo — paralog and subtelomeric
|
|
1778
|
+
repeat families (e.g. the Y' / YRF1 genes) genuinely cannot be assigned to one gene. That loss is
|
|
1779
|
+
STAR's ambiguity, not a compiler bug, so the gate must measure it rather than tolerate it blindly.
|
|
1780
|
+
"""
|
|
1781
|
+
log = outdir / "Log.final.out"
|
|
1782
|
+
if not log.is_file():
|
|
1783
|
+
return {}
|
|
1784
|
+
wanted = {
|
|
1785
|
+
"Number of input reads": "input_reads",
|
|
1786
|
+
"Uniquely mapped reads number": "uniquely_mapped",
|
|
1787
|
+
"Number of reads mapped to multiple loci": "multi_loci",
|
|
1788
|
+
"Number of reads mapped to too many loci": "too_many_loci",
|
|
1789
|
+
}
|
|
1790
|
+
stats: dict[str, float] = {}
|
|
1791
|
+
for line in log.read_text().splitlines():
|
|
1792
|
+
if "|" not in line:
|
|
1793
|
+
continue
|
|
1794
|
+
label, _, value = line.partition("|")
|
|
1795
|
+
key = wanted.get(label.strip())
|
|
1796
|
+
if key:
|
|
1797
|
+
try:
|
|
1798
|
+
stats[key] = float(value.strip())
|
|
1799
|
+
except ValueError:
|
|
1800
|
+
pass
|
|
1801
|
+
return stats
|
|
1802
|
+
|
|
1803
|
+
|
|
1804
|
+
def _compare(
|
|
1805
|
+
truth: dict[tuple[str, str], int], observed: dict[tuple[str, str], int]
|
|
1806
|
+
) -> dict[str, object]:
|
|
1807
|
+
"""Exact-match accounting, plus the diagnostics that make a failure debuggable."""
|
|
1808
|
+
injected_total = sum(truth.values())
|
|
1809
|
+
observed_total = sum(observed.values())
|
|
1810
|
+
spurious = {k: v for k, v in observed.items() if k not in truth}
|
|
1811
|
+
mismatched = {
|
|
1812
|
+
k: (truth[k], observed.get(k, 0)) for k in truth if observed.get(k, 0) != truth[k]
|
|
1813
|
+
}
|
|
1814
|
+
# inflation is categorically worse than loss: a count we did NOT inject is a fabricated
|
|
1815
|
+
# observation, whereas a missing count is (usually) STAR dropping an ambiguous read.
|
|
1816
|
+
inflated = {k: (truth[k], observed[k]) for k in truth if observed.get(k, 0) > truth[k]}
|
|
1817
|
+
recovered = sum(min(v, observed.get(k, 0)) for k, v in truth.items())
|
|
1818
|
+
return {
|
|
1819
|
+
"exact": not spurious and not mismatched,
|
|
1820
|
+
"n_inflated_pairs": len(inflated),
|
|
1821
|
+
"injected_total": injected_total,
|
|
1822
|
+
"observed_total": observed_total,
|
|
1823
|
+
"recovered_total": recovered,
|
|
1824
|
+
"recovery_rate": round(recovered / injected_total, 4) if injected_total else 0.0,
|
|
1825
|
+
"n_spurious_pairs": len(spurious),
|
|
1826
|
+
"n_mismatched_pairs": len(mismatched),
|
|
1827
|
+
# JSON-safe: a (cell, gene) tuple key cannot cross the wire (same rule as the M[role][file]
|
|
1828
|
+
# evidence matrix — no un-serializable value may reach a --json boundary).
|
|
1829
|
+
"example_mismatches": [
|
|
1830
|
+
{"cell": cb, "gene": gene, "injected": inj, "observed": obs}
|
|
1831
|
+
for (cb, gene), (inj, obs) in list(mismatched.items())[:5]
|
|
1832
|
+
],
|
|
1833
|
+
"example_spurious": [
|
|
1834
|
+
{"cell": cb, "gene": gene, "observed": n}
|
|
1835
|
+
for (cb, gene), n in list(spurious.items())[:5]
|
|
1836
|
+
],
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1839
|
+
|
|
1840
|
+
def discover_assets(
|
|
1841
|
+
*,
|
|
1842
|
+
assembly: str = "sacCer3",
|
|
1843
|
+
annotation: str = "ensembl_R64-1-1",
|
|
1844
|
+
fasta: Path | None = None,
|
|
1845
|
+
gtf: Path | None = None,
|
|
1846
|
+
star_index: Path | None = None,
|
|
1847
|
+
star_bin: str | None = None,
|
|
1848
|
+
) -> E2EAssets:
|
|
1849
|
+
"""Resolve the run's assets, preferring liulab-genome (we consume it, never reimplement it).
|
|
1850
|
+
|
|
1851
|
+
seqforge **never builds** a STAR index — not here, not in the `genome_index` rule. Building is
|
|
1852
|
+
liulab-genome's concern, done ahead of any run by its own machinery; seqforge only *resolves* the
|
|
1853
|
+
prebuilt artifact through `get_star_index`, which raises if none exists. So `kb e2e` requires the
|
|
1854
|
+
index for `assembly` + `annotation` to be built beforehand; if it is not, the lookup fails loudly
|
|
1855
|
+
("build it first") exactly as the composed pipeline would on a real run.
|
|
1856
|
+
|
|
1857
|
+
(Historical note: this arm once called `get_star_index` when liulab-genome had no such method,
|
|
1858
|
+
and the `AttributeError` waited in a cluster-only lazy import until it was run on 2026-07-15.
|
|
1859
|
+
liulab-genome added `get_star_index` as a resolve-only lookup on 2026-07-17, and seqforge dropped
|
|
1860
|
+
`build_star_index` entirely on the same day; the guard
|
|
1861
|
+
`test_seqforge_only_calls_liulab_genome_methods_that_exist` checks every call against the real
|
|
1862
|
+
package at import time, in every environment, rather than on a cluster nobody visits.)
|
|
1863
|
+
"""
|
|
1864
|
+
import shutil
|
|
1865
|
+
|
|
1866
|
+
# Resolve STAR FIRST and put it on PATH, because the alignment rules (`starsolo_count` /
|
|
1867
|
+
# `star_count`, and the direct `run_starsolo` instrument) invoke a bare `STAR`. We never build an
|
|
1868
|
+
# index, so STAR is not needed for that — but it IS needed to map. `--star` used to be read *after*
|
|
1869
|
+
# the Genome block, so telling seqforge exactly where STAR lived did not help the run that needed it.
|
|
1870
|
+
resolved_star = star_bin or shutil.which("STAR")
|
|
1871
|
+
if not resolved_star:
|
|
1872
|
+
raise E2EUnavailable(
|
|
1873
|
+
"STAR is not on PATH; pass --star (e.g. liulab-runtime's align-rna env)"
|
|
1874
|
+
)
|
|
1875
|
+
star_dir = str(Path(resolved_star).resolve().parent)
|
|
1876
|
+
if star_dir not in os.environ.get("PATH", "").split(os.pathsep):
|
|
1877
|
+
os.environ["PATH"] = os.pathsep.join([star_dir, os.environ.get("PATH", "")])
|
|
1878
|
+
|
|
1879
|
+
if fasta is None or gtf is None or star_index is None:
|
|
1880
|
+
try:
|
|
1881
|
+
from genome import Genome
|
|
1882
|
+
except ImportError as exc: # pragma: no cover - depends on the host
|
|
1883
|
+
raise E2EUnavailable(
|
|
1884
|
+
"liulab-genome is not importable and --fasta/--gtf/--star-index were not given"
|
|
1885
|
+
) from exc
|
|
1886
|
+
g = Genome(assembly)
|
|
1887
|
+
fasta = fasta or Path(str(g.fasta_path))
|
|
1888
|
+
gtf = gtf or Path(str(g.default_gtf_path))
|
|
1889
|
+
star_index = star_index or Path(str(g.get_star_index(gtf=annotation)))
|
|
1890
|
+
return E2EAssets(
|
|
1891
|
+
fasta=Path(fasta),
|
|
1892
|
+
gtf=Path(gtf),
|
|
1893
|
+
star_index=Path(star_index),
|
|
1894
|
+
star_bin=resolved_star,
|
|
1895
|
+
assembly=assembly,
|
|
1896
|
+
annotation=annotation,
|
|
1897
|
+
)
|
|
1898
|
+
|
|
1899
|
+
|
|
1900
|
+
def _atomic_write_json(path: Path, payload: dict[str, object]) -> None:
|
|
1901
|
+
"""Write-then-rename, so a kill mid-write cannot leave truncated state behind.
|
|
1902
|
+
|
|
1903
|
+
``rename`` is atomic within a filesystem, so a reader sees the old file or the new one and never
|
|
1904
|
+
half of either. The sweep's whole reason for writing each point immediately is to survive a
|
|
1905
|
+
preemption; a non-atomic write would make the preemption itself the thing that destroys the state
|
|
1906
|
+
it was saving. ``resolve/cache.py`` already does it this way — this is the repo's existing idiom,
|
|
1907
|
+
which it should have used from the start.
|
|
1908
|
+
"""
|
|
1909
|
+
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
1910
|
+
tmp.write_text(json.dumps(payload, indent=2, default=str))
|
|
1911
|
+
tmp.replace(path)
|
|
1912
|
+
|
|
1913
|
+
|
|
1914
|
+
def _progress(msg: str) -> None:
|
|
1915
|
+
"""Emit a progress line to **stderr**, because stdout belongs to the JSON.
|
|
1916
|
+
|
|
1917
|
+
`kb e2e-cost` runs for tens of minutes, so it has to say what it is doing — and the obvious
|
|
1918
|
+
`print()` put that narration on stdout, straight through the middle of the result. The CLI
|
|
1919
|
+
emits JSON on stdout; a consumer doing `seqforge kb e2e-cost ... | jq` got a parse error, and a
|
|
1920
|
+
consumer redirecting to a file got a file that is not JSON. Caught on the first real array run:
|
|
1921
|
+
`cost-hg38-2681399.json` is unparseable for exactly this reason, and only the separately-written
|
|
1922
|
+
`cost_sweep.partial.json` (disk is state) made its three measured points recoverable.
|
|
1923
|
+
|
|
1924
|
+
Progress is not a result. It goes to stderr, where a human reads it and a pipe ignores it.
|
|
1925
|
+
"""
|
|
1926
|
+
print(f"[cost] {msg}", file=sys.stderr, flush=True)
|