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/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)