seqforge 2026.7.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- seqforge/__init__.py +16 -0
- seqforge/cli/__init__.py +38 -0
- seqforge/cli/__main__.py +8 -0
- seqforge/cli/_common.py +105 -0
- seqforge/cli/compose.py +119 -0
- seqforge/cli/eval.py +103 -0
- seqforge/cli/harvest.py +417 -0
- seqforge/cli/hook.py +247 -0
- seqforge/cli/io.py +502 -0
- seqforge/cli/kb.py +348 -0
- seqforge/cli/manifest.py +536 -0
- seqforge/cli/probe.py +43 -0
- seqforge/cli/processing.py +192 -0
- seqforge/cli/project.py +52 -0
- seqforge/cli/resolve.py +55 -0
- seqforge/cli/root.py +66 -0
- seqforge/cli/run.py +463 -0
- seqforge/cli/schema.py +41 -0
- seqforge/compose/__init__.py +28 -0
- seqforge/compose/core.py +515 -0
- seqforge/compose/gates.py +113 -0
- seqforge/compose/params.py +447 -0
- seqforge/e2e.py +1926 -0
- seqforge/evals/__init__.py +78 -0
- seqforge/evals/case.py +382 -0
- seqforge/evals/grade.py +300 -0
- seqforge/evals/run.py +420 -0
- seqforge/harvest/__init__.py +121 -0
- seqforge/harvest/extract.py +319 -0
- seqforge/harvest/fields.py +212 -0
- seqforge/harvest/normalize.py +537 -0
- seqforge/harvest/prep.py +41 -0
- seqforge/harvest/providers.py +321 -0
- seqforge/harvest/verify.py +251 -0
- seqforge/hooks/__init__.py +33 -0
- seqforge/hooks/guards.py +214 -0
- seqforge/io/__init__.py +61 -0
- seqforge/io/archive.py +450 -0
- seqforge/io/attributes.py +190 -0
- seqforge/io/biosample/attributes.json +6341 -0
- seqforge/io/efo/labels.json +55 -0
- seqforge/io/efo.py +138 -0
- seqforge/io/onlist.py +661 -0
- seqforge/io/onlists/3M-february-2018.codes.gz +0 -0
- seqforge/io/onlists/737K-arc-v1.codes.gz +0 -0
- seqforge/io/onlists/737K-august-2016.codes.gz +0 -0
- seqforge/io/onlists/bd-rhapsody-cls1-384.codes.gz +0 -0
- seqforge/io/onlists/bd-rhapsody-cls1.codes.gz +0 -0
- seqforge/io/onlists/bd-rhapsody-cls2-384.codes.gz +0 -0
- seqforge/io/onlists/bd-rhapsody-cls2.codes.gz +0 -0
- seqforge/io/onlists/bd-rhapsody-cls3-384.codes.gz +0 -0
- seqforge/io/onlists/bd-rhapsody-cls3.codes.gz +0 -0
- seqforge/io/onlists/index.json +74 -0
- seqforge/io/remote.py +659 -0
- seqforge/io/taxonomy.py +194 -0
- seqforge/kb/__init__.py +62 -0
- seqforge/kb/anchor.py +169 -0
- seqforge/kb/generate.py +147 -0
- seqforge/kb/loader.py +152 -0
- seqforge/kb/roundtrip.py +112 -0
- seqforge/kb/schema.py +422 -0
- seqforge/kb/specs/10x-3p-gex/spec.yaml +62 -0
- seqforge/kb/specs/10x-3p-gex-v2/README.md +41 -0
- seqforge/kb/specs/10x-3p-gex-v2/spec.yaml +83 -0
- seqforge/kb/specs/10x-3p-gex-v3/README.md +56 -0
- seqforge/kb/specs/10x-3p-gex-v3/spec.yaml +118 -0
- seqforge/kb/specs/10x-3p-gex-v3.1/README.md +56 -0
- seqforge/kb/specs/10x-3p-gex-v3.1/spec.yaml +124 -0
- seqforge/kb/specs/bd-rhapsody-wta/README.md +103 -0
- seqforge/kb/specs/bd-rhapsody-wta/spec.yaml +130 -0
- seqforge/kb/specs/bd-rhapsody-wta-enhanced/spec.yaml +99 -0
- seqforge/kb/specs/bd-rhapsody-wta-enhanced-v1/spec.yaml +93 -0
- seqforge/kb/specs/bd-rhapsody-wta-enhanced-v2/spec.yaml +81 -0
- seqforge/kb/specs/bulk-rnaseq-pe/README.md +35 -0
- seqforge/kb/specs/bulk-rnaseq-pe/spec.yaml +97 -0
- seqforge/kb/specs/splitseq/README.md +51 -0
- seqforge/kb/specs/splitseq/spec.yaml +157 -0
- seqforge/manifest/__init__.py +61 -0
- seqforge/manifest/fill.py +531 -0
- seqforge/manifest/hash.py +77 -0
- seqforge/manifest/instruct.py +114 -0
- seqforge/manifest/policy.py +409 -0
- seqforge/manifest/validate.py +274 -0
- seqforge/models/__init__.py +268 -0
- seqforge/models/assertion.py +68 -0
- seqforge/models/base.py +100 -0
- seqforge/models/blocker.py +71 -0
- seqforge/models/conflict.py +47 -0
- seqforge/models/dataset.py +320 -0
- seqforge/models/evidenced.py +54 -0
- seqforge/models/observation.py +157 -0
- seqforge/models/processing.py +231 -0
- seqforge/models/records.py +145 -0
- seqforge/models/resolve.py +216 -0
- seqforge/probe/__init__.py +46 -0
- seqforge/probe/core.py +232 -0
- seqforge/probe/signals.py +250 -0
- seqforge/probe/streaming.py +118 -0
- seqforge/project.py +177 -0
- seqforge/py.typed +0 -0
- seqforge/resolve/__init__.py +98 -0
- seqforge/resolve/assign.py +204 -0
- seqforge/resolve/cache.py +119 -0
- seqforge/resolve/confuse.py +215 -0
- seqforge/resolve/engine.py +646 -0
- seqforge/resolve/escalate.py +668 -0
- seqforge/resolve/evaluators.py +306 -0
- seqforge/resolve/geometry.py +89 -0
- seqforge/resolve/group.py +85 -0
- seqforge/resolve/records.py +550 -0
- seqforge/resolve/scoring.py +373 -0
- seqforge/resolve/window.py +206 -0
- seqforge/workflows/__init__.py +234 -0
- seqforge/workflows/cram.py +117 -0
- seqforge/workflows/h5ad.py +368 -0
- seqforge/workflows/map/star.smk +101 -0
- seqforge/workflows/map/starsolo.smk +360 -0
- seqforge/workflows/qc.py +157 -0
- seqforge/workspace.py +125 -0
- seqforge-2026.7.1.dist-info/METADATA +125 -0
- seqforge-2026.7.1.dist-info/RECORD +124 -0
- seqforge-2026.7.1.dist-info/WHEEL +4 -0
- seqforge-2026.7.1.dist-info/entry_points.txt +2 -0
- seqforge-2026.7.1.dist-info/licenses/LICENSE +21 -0
seqforge/cli/kb.py
ADDED
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
"""`seqforge kb` -- the executable, self-testing knowledge base (list/show/lint/roundtrip/e2e)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from pydantic import ValidationError
|
|
10
|
+
|
|
11
|
+
from ..kb import list_spec_ids, load_spec, run_roundtrip
|
|
12
|
+
from ._common import _parse_quantify
|
|
13
|
+
from .root import kb_app
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@kb_app.command("list")
|
|
17
|
+
def kb_list() -> None:
|
|
18
|
+
"""List every technology in the knowledge base."""
|
|
19
|
+
for tech_id in list_spec_ids():
|
|
20
|
+
typer.echo(tech_id)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@kb_app.command("show")
|
|
24
|
+
def kb_show(tech: str = typer.Argument(..., help="Technology id, e.g. 10x-3p-gex-v3.")) -> None:
|
|
25
|
+
"""Dump one technology's validated spec as JSON."""
|
|
26
|
+
try:
|
|
27
|
+
spec = load_spec(tech)
|
|
28
|
+
except FileNotFoundError as exc:
|
|
29
|
+
typer.echo(f"unknown technology {tech!r}", err=True)
|
|
30
|
+
raise typer.Exit(2) from exc
|
|
31
|
+
typer.echo(json.dumps(spec.model_dump(mode="json"), indent=2))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@kb_app.command("lint")
|
|
35
|
+
def kb_lint() -> None:
|
|
36
|
+
"""Validate every shipped spec.yaml against the schema. Exit 3 if any is invalid."""
|
|
37
|
+
results = []
|
|
38
|
+
ok = True
|
|
39
|
+
for tech_id in list_spec_ids():
|
|
40
|
+
try:
|
|
41
|
+
load_spec(tech_id)
|
|
42
|
+
results.append({"tech": tech_id, "ok": True})
|
|
43
|
+
except (ValidationError, ValueError) as exc:
|
|
44
|
+
ok = False
|
|
45
|
+
results.append({"tech": tech_id, "ok": False, "error": str(exc)})
|
|
46
|
+
typer.echo(json.dumps({"ok": ok, "specs": results}, indent=2))
|
|
47
|
+
if not ok:
|
|
48
|
+
raise typer.Exit(3)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@kb_app.command("roundtrip")
|
|
52
|
+
def kb_roundtrip(
|
|
53
|
+
tech: str = typer.Argument(..., help="Technology id to round-trip."),
|
|
54
|
+
seed: int = typer.Option(0, help="RNG seed for the synthetic generator."),
|
|
55
|
+
) -> None:
|
|
56
|
+
"""Self-test: spec -> synth FASTQ -> probe -> recover; assert recovered == declared. Exit 3 on fail."""
|
|
57
|
+
try:
|
|
58
|
+
result = run_roundtrip(tech, seed=seed)
|
|
59
|
+
except FileNotFoundError as exc:
|
|
60
|
+
typer.echo(f"unknown technology {tech!r}", err=True)
|
|
61
|
+
raise typer.Exit(2) from exc
|
|
62
|
+
typer.echo(json.dumps(result, indent=2))
|
|
63
|
+
if not result["passed"]:
|
|
64
|
+
raise typer.Exit(3)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@kb_app.command("e2e")
|
|
68
|
+
def kb_e2e(
|
|
69
|
+
workdir: Path = typer.Option(..., "--workdir", help="Scratch dir for reads + STAR output."),
|
|
70
|
+
assembly: str = typer.Option("sacCer3", help="liulab-genome assembly id."),
|
|
71
|
+
annotation: str = typer.Option("ensembl_R64-1-1", help="Registered GTF name."),
|
|
72
|
+
fasta: Path | None = typer.Option(
|
|
73
|
+
None, help="Override: genome FASTA (else via liulab-genome)."
|
|
74
|
+
),
|
|
75
|
+
gtf: Path | None = typer.Option(None, help="Override: GTF (else via liulab-genome)."),
|
|
76
|
+
star_index: Path | None = typer.Option(
|
|
77
|
+
None, "--star-index", help="Override: prebuilt STAR index."
|
|
78
|
+
),
|
|
79
|
+
star: str | None = typer.Option(
|
|
80
|
+
None, "--star", help="STAR binary (e.g. liulab-runtime align-rna)."
|
|
81
|
+
),
|
|
82
|
+
n_cells: int = typer.Option(8, help="Simulated cells."),
|
|
83
|
+
reads_per_cell: int = typer.Option(250, help="Simulated reads per cell."),
|
|
84
|
+
threads: int = typer.Option(8, help="STAR threads."),
|
|
85
|
+
seed: int = typer.Option(0, help="Simulation seed."),
|
|
86
|
+
) -> None:
|
|
87
|
+
"""The real count-matrix run: simulate -> resolve -> compose -> STARsolo -> assert vs ground truth.
|
|
88
|
+
|
|
89
|
+
Exit 3 if the recovered matrix does not equal what was injected (or if a strand inversion would
|
|
90
|
+
go undetected); exit 1 if the toolchain (STAR / a genome index) is unavailable.
|
|
91
|
+
"""
|
|
92
|
+
from ..e2e import E2EUnavailable, discover_assets, run_e2e
|
|
93
|
+
|
|
94
|
+
try:
|
|
95
|
+
assets = discover_assets(
|
|
96
|
+
assembly=assembly,
|
|
97
|
+
annotation=annotation,
|
|
98
|
+
fasta=fasta,
|
|
99
|
+
gtf=gtf,
|
|
100
|
+
star_index=star_index,
|
|
101
|
+
star_bin=star,
|
|
102
|
+
)
|
|
103
|
+
result = run_e2e(
|
|
104
|
+
assets,
|
|
105
|
+
workdir=workdir,
|
|
106
|
+
n_cells=n_cells,
|
|
107
|
+
reads_per_cell=reads_per_cell,
|
|
108
|
+
threads=threads,
|
|
109
|
+
seed=seed,
|
|
110
|
+
)
|
|
111
|
+
except E2EUnavailable as exc:
|
|
112
|
+
typer.echo(json.dumps({"skipped": True, "reason": str(exc)}, indent=2), err=True)
|
|
113
|
+
raise typer.Exit(1) from exc
|
|
114
|
+
typer.echo(json.dumps(result, indent=2, default=str))
|
|
115
|
+
if not result.get("passed"):
|
|
116
|
+
raise typer.Exit(3)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@kb_app.command("e2e-cost")
|
|
120
|
+
def kb_e2e_cost(
|
|
121
|
+
workdir: Path = typer.Option(..., "--workdir", help="Scratch dir for reads + STAR output."),
|
|
122
|
+
whitelist: Path = typer.Option(
|
|
123
|
+
..., "--whitelist", help="Real 10x whitelist (.txt or .txt.gz)."
|
|
124
|
+
),
|
|
125
|
+
assembly: str = typer.Option("hg38", help="Assembly to price against."),
|
|
126
|
+
annotation: str = typer.Option("gencode_v50", help="Registered GTF name."),
|
|
127
|
+
sweep: str = typer.Option(
|
|
128
|
+
"2000000,8000000,32000000", help="Comma-separated read depths to measure."
|
|
129
|
+
),
|
|
130
|
+
n_cells: int = typer.Option(5000, help="Simulated cells (barcodes drawn from the whitelist)."),
|
|
131
|
+
intron_frac: float = typer.Option(0.4, help="Fraction of reads from introns (pre-mRNA)."),
|
|
132
|
+
max_genes: int = typer.Option(2000, help="Gene models to sample reads from."),
|
|
133
|
+
fasta: Path | None = typer.Option(None, help="Override: genome FASTA."),
|
|
134
|
+
gtf: Path | None = typer.Option(None, help="Override: GTF."),
|
|
135
|
+
star_index: Path | None = typer.Option(
|
|
136
|
+
None, "--star-index", help="Override: prebuilt STAR index."
|
|
137
|
+
),
|
|
138
|
+
star: str | None = typer.Option(None, "--star", help="STAR binary (liulab-runtime align-rna)."),
|
|
139
|
+
threads: int = typer.Option(
|
|
140
|
+
16, help="STAR threads. Peak RSS depends on this — it is recorded."
|
|
141
|
+
),
|
|
142
|
+
gen_jobs: int = typer.Option(
|
|
143
|
+
16, "--gen-jobs", help="Processes generating reads (it was 40% of wall-clock on one core)."
|
|
144
|
+
),
|
|
145
|
+
seed: int = typer.Option(0, help="Simulation seed."),
|
|
146
|
+
quantify: str | None = typer.Option(
|
|
147
|
+
None, "--quantify", help="Override soloFeatures. Omit to price the compiler's own default."
|
|
148
|
+
),
|
|
149
|
+
out_sam_type: str = typer.Option(
|
|
150
|
+
"None",
|
|
151
|
+
"--out-sam-type",
|
|
152
|
+
help="STAR --outSAMtype. The shipped module runs 'BAM Unsorted' — pass it to price the gap.",
|
|
153
|
+
),
|
|
154
|
+
keep_reads: bool = typer.Option(
|
|
155
|
+
False, "--keep-reads", help="Do not delete FASTQs after a run."
|
|
156
|
+
),
|
|
157
|
+
) -> None:
|
|
158
|
+
"""Measure STARsolo's peak RSS against read depth — what a counting rule costs, not whether it is right.
|
|
159
|
+
|
|
160
|
+
This is the PRICE arm, not a gate: it asserts nothing about counts and injects no ground truth.
|
|
161
|
+
A single measurement would be almost entirely genome index (~30 GB on hg38, paid before a read is
|
|
162
|
+
parsed), so this fits a line across several depths and reports the intercept you pay per job, the
|
|
163
|
+
slope you pay per read, and the residual that says whether the line deserved to be believed.
|
|
164
|
+
|
|
165
|
+
Prints JSON; exit 1 if the toolchain is unavailable. Needs STAR, an index, and real time.
|
|
166
|
+
"""
|
|
167
|
+
from ..e2e import E2EUnavailable, discover_assets, run_cost_sweep
|
|
168
|
+
|
|
169
|
+
try:
|
|
170
|
+
depths = tuple(int(float(s)) for s in sweep.split(",") if s.strip())
|
|
171
|
+
except ValueError as exc:
|
|
172
|
+
typer.echo(f"--sweep must be comma-separated read counts, got {sweep!r}", err=True)
|
|
173
|
+
raise typer.Exit(2) from exc
|
|
174
|
+
if not depths:
|
|
175
|
+
typer.echo("--sweep is empty", err=True)
|
|
176
|
+
raise typer.Exit(2)
|
|
177
|
+
|
|
178
|
+
try:
|
|
179
|
+
assets = discover_assets(
|
|
180
|
+
assembly=assembly,
|
|
181
|
+
annotation=annotation,
|
|
182
|
+
fasta=fasta,
|
|
183
|
+
gtf=gtf,
|
|
184
|
+
star_index=star_index,
|
|
185
|
+
star_bin=star,
|
|
186
|
+
)
|
|
187
|
+
result = run_cost_sweep(
|
|
188
|
+
assets,
|
|
189
|
+
workdir=workdir,
|
|
190
|
+
whitelist=whitelist,
|
|
191
|
+
sweep=depths,
|
|
192
|
+
n_cells=n_cells,
|
|
193
|
+
intron_frac=intron_frac,
|
|
194
|
+
max_genes=max_genes,
|
|
195
|
+
threads=threads,
|
|
196
|
+
gen_jobs=gen_jobs,
|
|
197
|
+
seed=seed,
|
|
198
|
+
features=_parse_quantify(quantify),
|
|
199
|
+
out_sam_type=tuple(out_sam_type.split()),
|
|
200
|
+
keep_reads=keep_reads,
|
|
201
|
+
)
|
|
202
|
+
except E2EUnavailable as exc:
|
|
203
|
+
typer.echo(json.dumps({"skipped": True, "reason": str(exc)}, indent=2), err=True)
|
|
204
|
+
raise typer.Exit(1) from exc
|
|
205
|
+
typer.echo(json.dumps(result, indent=2, default=str))
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
@kb_app.command("e2e-fit")
|
|
209
|
+
def kb_e2e_fit(
|
|
210
|
+
results: list[Path] = typer.Argument(..., help="cost JSONs to merge (one per array task)."),
|
|
211
|
+
) -> None:
|
|
212
|
+
"""Fit one line across cost runs measured separately — the collector for a job-array sweep.
|
|
213
|
+
|
|
214
|
+
The depths are independent, so a sweep parallelises across array tasks and each task emits its
|
|
215
|
+
own JSON. This merges them and fits the same line ``run_cost_sweep`` fits in-process, so an array
|
|
216
|
+
and a single sequential job produce the same answer in the same shape.
|
|
217
|
+
|
|
218
|
+
It refuses to merge runs whose soloFeatures, assembly, or thread count differ: the peak depends on
|
|
219
|
+
all three, so splicing them into one line would silently fit a curve through incomparable points —
|
|
220
|
+
the exact failure the per-shard seed and the resume guard exist to prevent elsewhere.
|
|
221
|
+
"""
|
|
222
|
+
from ..e2e import _fit_line
|
|
223
|
+
|
|
224
|
+
runs = []
|
|
225
|
+
for path in results:
|
|
226
|
+
try:
|
|
227
|
+
runs.append((path, json.loads(path.read_text())))
|
|
228
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
229
|
+
typer.echo(f"cannot read {path}: {exc}", err=True)
|
|
230
|
+
raise typer.Exit(1) from exc
|
|
231
|
+
if not runs:
|
|
232
|
+
typer.echo("no results given", err=True)
|
|
233
|
+
raise typer.Exit(2)
|
|
234
|
+
|
|
235
|
+
def key(r: dict[str, object]) -> tuple[object, ...]:
|
|
236
|
+
# soloFeatures arrives from JSON as a list, which is unhashable — tuple it before it meets a set.
|
|
237
|
+
features = r.get("soloFeatures")
|
|
238
|
+
return (
|
|
239
|
+
tuple(features) if isinstance(features, list) else features,
|
|
240
|
+
r.get("assembly"),
|
|
241
|
+
r.get("threads"),
|
|
242
|
+
r.get("n_cells"),
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
keys = {key(r) for _p, r in runs}
|
|
246
|
+
if len(keys) != 1:
|
|
247
|
+
typer.echo(
|
|
248
|
+
json.dumps(
|
|
249
|
+
{
|
|
250
|
+
"error": "refusing to fit incomparable runs",
|
|
251
|
+
"detail": "soloFeatures/assembly/threads/n_cells must match across every result",
|
|
252
|
+
"distinct": [list(k) for k in keys],
|
|
253
|
+
},
|
|
254
|
+
indent=2,
|
|
255
|
+
default=str,
|
|
256
|
+
),
|
|
257
|
+
err=True,
|
|
258
|
+
)
|
|
259
|
+
raise typer.Exit(3)
|
|
260
|
+
|
|
261
|
+
points: list[dict[str, object]] = []
|
|
262
|
+
for _p, r in runs:
|
|
263
|
+
points.extend(p for p in r.get("points", []) if not p.get("failed"))
|
|
264
|
+
points.sort(key=lambda p: int(p["n_reads"]))
|
|
265
|
+
if len({int(p["n_reads"]) for p in points}) != len(points):
|
|
266
|
+
typer.echo("duplicate read depths across results; refusing to fit", err=True)
|
|
267
|
+
raise typer.Exit(3)
|
|
268
|
+
|
|
269
|
+
head = runs[0][1]
|
|
270
|
+
typer.echo(
|
|
271
|
+
json.dumps(
|
|
272
|
+
{
|
|
273
|
+
"assembly": head.get("assembly"),
|
|
274
|
+
"annotation": head.get("annotation"),
|
|
275
|
+
"soloFeatures": head.get("soloFeatures"),
|
|
276
|
+
"threads": head.get("threads"),
|
|
277
|
+
"n_cells": head.get("n_cells"),
|
|
278
|
+
"n_runs_merged": len(runs),
|
|
279
|
+
"points": points,
|
|
280
|
+
"fit": _fit_line(
|
|
281
|
+
[(int(p["n_reads"]), float(p["star_peak_rss_gb"])) for p in points]
|
|
282
|
+
),
|
|
283
|
+
},
|
|
284
|
+
indent=2,
|
|
285
|
+
default=str,
|
|
286
|
+
)
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
@kb_app.command("e2e-introns")
|
|
291
|
+
def kb_e2e_introns(
|
|
292
|
+
workdir: Path = typer.Option(..., "--workdir", help="Scratch dir for reads + STAR output."),
|
|
293
|
+
assembly: str = typer.Option("ce11", help="Must be intron-rich; sacCer3 cannot test this."),
|
|
294
|
+
annotation: str = typer.Option("WS298", help="Registered GTF name."),
|
|
295
|
+
fasta: Path | None = typer.Option(None, help="Override: genome FASTA."),
|
|
296
|
+
gtf: Path | None = typer.Option(None, help="Override: GTF."),
|
|
297
|
+
star_index: Path | None = typer.Option(
|
|
298
|
+
None, "--star-index", help="Override: prebuilt STAR index."
|
|
299
|
+
),
|
|
300
|
+
star: str | None = typer.Option(None, "--star", help="STAR binary (liulab-runtime align-rna)."),
|
|
301
|
+
n_cells: int = typer.Option(8, help="Simulated cells."),
|
|
302
|
+
reads_per_cell: int = typer.Option(250, help="Simulated reads per cell."),
|
|
303
|
+
intron_frac: float = typer.Option(0.4, help="Fraction of reads drawn from introns (pre-mRNA)."),
|
|
304
|
+
threads: int = typer.Option(8, help="STAR threads."),
|
|
305
|
+
seed: int = typer.Option(0, help="Simulation seed."),
|
|
306
|
+
quantify: str | None = typer.Option(
|
|
307
|
+
None,
|
|
308
|
+
"--quantify",
|
|
309
|
+
help="Override soloFeatures (comma-separated) — the cost-measurement arm. "
|
|
310
|
+
"Omit to run the compiler's own default, which is what the gate is for.",
|
|
311
|
+
),
|
|
312
|
+
) -> None:
|
|
313
|
+
"""The intron-rich / GeneFull gate: inject intronic reads, assert Gene and GeneFull disagree right.
|
|
314
|
+
|
|
315
|
+
Yeast is nearly intron-free, so the sacCer3 e2e certifies neither counting rule. This injects a
|
|
316
|
+
known number of intronic reads (what a single-NUCLEUS library actually contains) and asserts Gene
|
|
317
|
+
recovers only the exonic truth while GeneFull recovers exon+intron — both from ONE STARsolo run,
|
|
318
|
+
so the alignment is identical and only the counting rule differs. Reports `gene_signal_lost`: what
|
|
319
|
+
`--soloFeatures Gene` silently discards from a nuclear library. Exit 3 on failure, 1 if the
|
|
320
|
+
toolchain is unavailable.
|
|
321
|
+
"""
|
|
322
|
+
from ..e2e import E2EUnavailable, discover_assets, run_intron_e2e
|
|
323
|
+
|
|
324
|
+
try:
|
|
325
|
+
assets = discover_assets(
|
|
326
|
+
assembly=assembly,
|
|
327
|
+
annotation=annotation,
|
|
328
|
+
fasta=fasta,
|
|
329
|
+
gtf=gtf,
|
|
330
|
+
star_index=star_index,
|
|
331
|
+
star_bin=star,
|
|
332
|
+
)
|
|
333
|
+
result = run_intron_e2e(
|
|
334
|
+
assets,
|
|
335
|
+
workdir=workdir,
|
|
336
|
+
n_cells=n_cells,
|
|
337
|
+
reads_per_cell=reads_per_cell,
|
|
338
|
+
intron_frac=intron_frac,
|
|
339
|
+
threads=threads,
|
|
340
|
+
seed=seed,
|
|
341
|
+
features=_parse_quantify(quantify),
|
|
342
|
+
)
|
|
343
|
+
except E2EUnavailable as exc:
|
|
344
|
+
typer.echo(json.dumps({"skipped": True, "reason": str(exc)}, indent=2), err=True)
|
|
345
|
+
raise typer.Exit(1) from exc
|
|
346
|
+
typer.echo(json.dumps(result, indent=2, default=str))
|
|
347
|
+
if not result.get("passed"):
|
|
348
|
+
raise typer.Exit(3)
|