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.
Files changed (124) hide show
  1. seqforge/__init__.py +16 -0
  2. seqforge/cli/__init__.py +38 -0
  3. seqforge/cli/__main__.py +8 -0
  4. seqforge/cli/_common.py +105 -0
  5. seqforge/cli/compose.py +119 -0
  6. seqforge/cli/eval.py +103 -0
  7. seqforge/cli/harvest.py +417 -0
  8. seqforge/cli/hook.py +247 -0
  9. seqforge/cli/io.py +502 -0
  10. seqforge/cli/kb.py +348 -0
  11. seqforge/cli/manifest.py +536 -0
  12. seqforge/cli/probe.py +43 -0
  13. seqforge/cli/processing.py +192 -0
  14. seqforge/cli/project.py +52 -0
  15. seqforge/cli/resolve.py +55 -0
  16. seqforge/cli/root.py +66 -0
  17. seqforge/cli/run.py +463 -0
  18. seqforge/cli/schema.py +41 -0
  19. seqforge/compose/__init__.py +28 -0
  20. seqforge/compose/core.py +515 -0
  21. seqforge/compose/gates.py +113 -0
  22. seqforge/compose/params.py +447 -0
  23. seqforge/e2e.py +1926 -0
  24. seqforge/evals/__init__.py +78 -0
  25. seqforge/evals/case.py +382 -0
  26. seqforge/evals/grade.py +300 -0
  27. seqforge/evals/run.py +420 -0
  28. seqforge/harvest/__init__.py +121 -0
  29. seqforge/harvest/extract.py +319 -0
  30. seqforge/harvest/fields.py +212 -0
  31. seqforge/harvest/normalize.py +537 -0
  32. seqforge/harvest/prep.py +41 -0
  33. seqforge/harvest/providers.py +321 -0
  34. seqforge/harvest/verify.py +251 -0
  35. seqforge/hooks/__init__.py +33 -0
  36. seqforge/hooks/guards.py +214 -0
  37. seqforge/io/__init__.py +61 -0
  38. seqforge/io/archive.py +450 -0
  39. seqforge/io/attributes.py +190 -0
  40. seqforge/io/biosample/attributes.json +6341 -0
  41. seqforge/io/efo/labels.json +55 -0
  42. seqforge/io/efo.py +138 -0
  43. seqforge/io/onlist.py +661 -0
  44. seqforge/io/onlists/3M-february-2018.codes.gz +0 -0
  45. seqforge/io/onlists/737K-arc-v1.codes.gz +0 -0
  46. seqforge/io/onlists/737K-august-2016.codes.gz +0 -0
  47. seqforge/io/onlists/bd-rhapsody-cls1-384.codes.gz +0 -0
  48. seqforge/io/onlists/bd-rhapsody-cls1.codes.gz +0 -0
  49. seqforge/io/onlists/bd-rhapsody-cls2-384.codes.gz +0 -0
  50. seqforge/io/onlists/bd-rhapsody-cls2.codes.gz +0 -0
  51. seqforge/io/onlists/bd-rhapsody-cls3-384.codes.gz +0 -0
  52. seqforge/io/onlists/bd-rhapsody-cls3.codes.gz +0 -0
  53. seqforge/io/onlists/index.json +74 -0
  54. seqforge/io/remote.py +659 -0
  55. seqforge/io/taxonomy.py +194 -0
  56. seqforge/kb/__init__.py +62 -0
  57. seqforge/kb/anchor.py +169 -0
  58. seqforge/kb/generate.py +147 -0
  59. seqforge/kb/loader.py +152 -0
  60. seqforge/kb/roundtrip.py +112 -0
  61. seqforge/kb/schema.py +422 -0
  62. seqforge/kb/specs/10x-3p-gex/spec.yaml +62 -0
  63. seqforge/kb/specs/10x-3p-gex-v2/README.md +41 -0
  64. seqforge/kb/specs/10x-3p-gex-v2/spec.yaml +83 -0
  65. seqforge/kb/specs/10x-3p-gex-v3/README.md +56 -0
  66. seqforge/kb/specs/10x-3p-gex-v3/spec.yaml +118 -0
  67. seqforge/kb/specs/10x-3p-gex-v3.1/README.md +56 -0
  68. seqforge/kb/specs/10x-3p-gex-v3.1/spec.yaml +124 -0
  69. seqforge/kb/specs/bd-rhapsody-wta/README.md +103 -0
  70. seqforge/kb/specs/bd-rhapsody-wta/spec.yaml +130 -0
  71. seqforge/kb/specs/bd-rhapsody-wta-enhanced/spec.yaml +99 -0
  72. seqforge/kb/specs/bd-rhapsody-wta-enhanced-v1/spec.yaml +93 -0
  73. seqforge/kb/specs/bd-rhapsody-wta-enhanced-v2/spec.yaml +81 -0
  74. seqforge/kb/specs/bulk-rnaseq-pe/README.md +35 -0
  75. seqforge/kb/specs/bulk-rnaseq-pe/spec.yaml +97 -0
  76. seqforge/kb/specs/splitseq/README.md +51 -0
  77. seqforge/kb/specs/splitseq/spec.yaml +157 -0
  78. seqforge/manifest/__init__.py +61 -0
  79. seqforge/manifest/fill.py +531 -0
  80. seqforge/manifest/hash.py +77 -0
  81. seqforge/manifest/instruct.py +114 -0
  82. seqforge/manifest/policy.py +409 -0
  83. seqforge/manifest/validate.py +274 -0
  84. seqforge/models/__init__.py +268 -0
  85. seqforge/models/assertion.py +68 -0
  86. seqforge/models/base.py +100 -0
  87. seqforge/models/blocker.py +71 -0
  88. seqforge/models/conflict.py +47 -0
  89. seqforge/models/dataset.py +320 -0
  90. seqforge/models/evidenced.py +54 -0
  91. seqforge/models/observation.py +157 -0
  92. seqforge/models/processing.py +231 -0
  93. seqforge/models/records.py +145 -0
  94. seqforge/models/resolve.py +216 -0
  95. seqforge/probe/__init__.py +46 -0
  96. seqforge/probe/core.py +232 -0
  97. seqforge/probe/signals.py +250 -0
  98. seqforge/probe/streaming.py +118 -0
  99. seqforge/project.py +177 -0
  100. seqforge/py.typed +0 -0
  101. seqforge/resolve/__init__.py +98 -0
  102. seqforge/resolve/assign.py +204 -0
  103. seqforge/resolve/cache.py +119 -0
  104. seqforge/resolve/confuse.py +215 -0
  105. seqforge/resolve/engine.py +646 -0
  106. seqforge/resolve/escalate.py +668 -0
  107. seqforge/resolve/evaluators.py +306 -0
  108. seqforge/resolve/geometry.py +89 -0
  109. seqforge/resolve/group.py +85 -0
  110. seqforge/resolve/records.py +550 -0
  111. seqforge/resolve/scoring.py +373 -0
  112. seqforge/resolve/window.py +206 -0
  113. seqforge/workflows/__init__.py +234 -0
  114. seqforge/workflows/cram.py +117 -0
  115. seqforge/workflows/h5ad.py +368 -0
  116. seqforge/workflows/map/star.smk +101 -0
  117. seqforge/workflows/map/starsolo.smk +360 -0
  118. seqforge/workflows/qc.py +157 -0
  119. seqforge/workspace.py +125 -0
  120. seqforge-2026.7.1.dist-info/METADATA +125 -0
  121. seqforge-2026.7.1.dist-info/RECORD +124 -0
  122. seqforge-2026.7.1.dist-info/WHEEL +4 -0
  123. seqforge-2026.7.1.dist-info/entry_points.txt +2 -0
  124. 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)