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
@@ -0,0 +1,515 @@
1
+ """``compose`` — (dataset, processing) -> ``Snakefile`` + ``config.yaml`` + ``units.tsv``.
2
+
3
+ **Emit data, never code.** The composer selects a hand-written, versioned module and emits its
4
+ *configuration*; it never writes rule source. It is a pure function of **both** its inputs plus two
5
+ versioned ones recorded in provenance (the KB and the workflow modules) — it needs no FASTQ on disk,
6
+ local or remote. Two inputs, still no I/O: a processing manifest is data, not a side channel.
7
+
8
+ Purity across *both* is what makes "same dataset + different processing = different pipeline, same
9
+ dataset hash" a fact rather than a hope. Output is keyed by ``run_id = H(dataset ⊕ processing ⊕ kb ⊕
10
+ workflow)``, so composing one dataset two ways yields two directories instead of the second silently
11
+ overwriting the first — which is what a fixed ``.seqforge/pipeline/`` path used to do, in exactly the
12
+ case the split exists to enable.
13
+
14
+ The machine-independent/machine-specific boundary lives here: the **manifest** carries URIs and a
15
+ ``liulab-genome`` assembly id; the **config** it compiles to is the resolved, machine-specific
16
+ instantiation, so paths appear here and only here.
17
+
18
+ The gate has three parts (design §4.1) — this module always runs the deterministic **params** gate;
19
+ ``wiring`` (``snakemake -n -p``) now really runs, because `snakemake-minimal` is a declared
20
+ dependency; ``e2e`` (the real count-matrix run) is reported as ``skip``, never silently as ``pass``.
21
+
22
+ **The Snakefile is the deliverable, and it is emitted here, unconditionally.** It used to be written
23
+ inside ``wiring_gate`` — so the product of the compiler was a side effect of a validation step, and
24
+ that step returned early whenever ``snakemake`` was absent from PATH, which it always was. `compose`
25
+ exited 0 and emitted nothing a user could run. A gate may read the deliverable; it may not be the
26
+ reason the deliverable exists.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import shutil
32
+ from dataclasses import dataclass
33
+ from pathlib import Path
34
+
35
+ import yaml
36
+
37
+ from ..io import DEFAULT_REGISTRY, OnlistNotAvailable, OnlistRegistry
38
+ from ..kb import load_spec
39
+ from ..kb.schema import Spec
40
+ from ..manifest.hash import run_id
41
+ from ..models.dataset import INDEX_ROLE, DatasetManifest
42
+ from ..models.processing import (
43
+ DatasetPin,
44
+ ProcessingManifest,
45
+ Quantification,
46
+ RuntimeEnv,
47
+ SoloQuant,
48
+ )
49
+ from ..models.resolve import ComposeResult, ModuleSelection
50
+ from ..resolve.group import run_key
51
+ from ..workflows import WorkflowModule, container_uri, get_module
52
+ from ..workspace import readable, state_dir
53
+ from .params import (
54
+ derived_params,
55
+ find_read_with_role,
56
+ param_block_key,
57
+ params_gate,
58
+ processing_params,
59
+ render_param,
60
+ )
61
+
62
+
63
+ class ComposeError(RuntimeError):
64
+ """The manifest cannot be compiled (unknown chemistry/module, or an unresolvable whitelist)."""
65
+
66
+
67
+ #: Names of the artifacts a run directory carries, beside the wrapper that reads them.
68
+ _CONFIG_NAME = "config.yaml"
69
+ _UNITS_TSV_NAME = "units.tsv"
70
+ _SNAKEFILE_NAME = "Snakefile"
71
+
72
+ #: The run wrapper: **the deliverable**. A user submits this file.
73
+ #:
74
+ #: It is data, not code: it names a hand-written module and points it at a config. There is no
75
+ #: rule body, no shell block, and no logic here — `test_the_generated_wrapper_contains_no_rule_source`
76
+ #: holds the line.
77
+ #:
78
+ #: **It uses `module` / `use rule *`, not `include:`, and that is load-bearing rather than stylistic.**
79
+ #: Snakemake's default target is the first rule defined in the *main* Snakefile, and an `include:`d
80
+ #: rule is not one — so the old `configfile:` + `include:` wrapper parsed clean, listed all three
81
+ #: rules, and then did **nothing at all** when run: "Nothing to be done", exit 0. Measured 2026-07-15;
82
+ #: the same module content inlined builds 3 jobs, and via `include:` builds 0. `use rule * from m as *`
83
+ #: re-declares the rules in this workflow, which makes `all` a real default target and `snakemake` with
84
+ #: no arguments do the right thing. This is also what design.md §4.1 specified all along.
85
+ _WRAPPER = """# GENERATED by `seqforge compose` -- this file is DATA: it names a hand-written module
86
+ # and points it at a config. Rule source is never generated; the rules live in the module below,
87
+ # `{snakefile}`, COPIED into this directory beside this Snakefile at the exact version this run is
88
+ # pinned to. The copy is deliberate: a run directory carries its own recipe, so it stays readable and
89
+ # reproducible even after it is moved off the machine that composed it -- no path back into an
90
+ # installed package. (Running it still needs `seqforge` and `genome` importable; the copy makes the
91
+ # pipeline legible, not standalone.)
92
+ #
93
+ # Run it: snakemake --profile <your-cluster-profile> --software-deployment-method apptainer
94
+ #
95
+ # The default target is `all`, which produces one <sample>.h5ad per sample in units.tsv (plus a
96
+ # <sample>.velocyto.h5ad when Velocyto is counted).
97
+ #
98
+ # `--software-deployment-method apptainer` is what makes the aligner rule run inside the
99
+ # liulab-runtime image named in config.yaml. WITHOUT IT SNAKEMAKE IGNORES THE `container:` DIRECTIVE
100
+ # ENTIRELY and STAR comes from your PATH -- the run still works, and is no longer pinned. Never
101
+ # needed for the packaging step, which runs in whatever environment has seqforge.
102
+ configfile: "config.yaml"
103
+
104
+
105
+ module {alias}:
106
+ snakefile:
107
+ "{snakefile}"
108
+ config:
109
+ config
110
+
111
+
112
+ use rule * from {alias} as *
113
+ """
114
+
115
+
116
+ def _module_alias(name: str) -> str:
117
+ """`map/starsolo` -> `starsolo`: a Snakemake module needs a Python-identifier-shaped name."""
118
+ return name.rsplit("/", 1)[-1].replace("-", "_")
119
+
120
+
121
+ def render_wrapper(module_name: str, snakefile: str) -> str:
122
+ """The run wrapper's text, for the module `module_name` at the LOCAL name `snakefile`.
123
+
124
+ ``snakefile`` is a bare filename (``starsolo.smk``), not a package path: the composer copies the
125
+ module into the run directory and the wrapper imports its neighbour, so the `snakefile:` line
126
+ resolves relative to this Snakefile. Snakemake resolves a module's snakefile against the including
127
+ file's directory, which is exactly where the copy lands.
128
+ """
129
+ return _WRAPPER.format(alias=_module_alias(module_name), snakefile=snakefile)
130
+
131
+
132
+ @dataclass(frozen=True)
133
+ class ComposePlan:
134
+ """The composed artifacts, before they are written (compose is a pure function of the manifest)."""
135
+
136
+ config: dict[str, object]
137
+ units: list[dict[str, str]]
138
+ module: ModuleSelection
139
+ spec: Spec
140
+ #: relative path -> the registry name `rule onlist` will build it from. NOT the barcodes:
141
+ #: see `_resolve_token` and workflows/map/starsolo.smk for why compose does not write 111 MB.
142
+ onlist_files: dict[str, list[str]]
143
+
144
+
145
+ def plan(
146
+ manifest: DatasetManifest,
147
+ processing: ProcessingManifest,
148
+ *,
149
+ registry: OnlistRegistry | None = None,
150
+ outdir: str = "results",
151
+ fastq_dir: str | Path | None = None,
152
+ sif_dir: str | Path | None = None,
153
+ ) -> ComposePlan:
154
+ """Build the config + units + module selection for one (dataset, processing) pair.
155
+
156
+ Both inputs are positional and required. Composing one dataset two ways is literally two calls
157
+ with one argument changed, producing two ``run_id``s over one unchanged ``dataset_hash``.
158
+
159
+ ``threads`` is deliberately NOT an argument: it lives in ``processing.resources.threads``. Having
160
+ it in both places was the same two-sources-of-truth disease this whole change is curing.
161
+ ``outdir`` and ``fastq_dir`` stay arguments, because they are paths — machine facts, which a
162
+ machine-independent manifest forbids carrying. ``threads`` is intent; a path is an invocation.
163
+
164
+ ``fastq_dir`` is where this machine keeps the bytes. A manifest URI is a *relative* name, so
165
+ without it ``units.tsv`` carries bare basenames and the pipeline resolves them against the run
166
+ directory — where the FASTQs are not. That is not a stricter kind of purity, it is a pipeline that
167
+ cannot find its input. Given a directory, ``units.tsv`` carries resolved absolute paths: the
168
+ manifest stays machine-independent and the *config* becomes the machine-specific instantiation,
169
+ which is the boundary this module's docstring has always drawn. Still no I/O — the path is joined,
170
+ never read, so ``plan`` stays pure and needs no FASTQ on disk.
171
+ """
172
+ registry = registry if registry is not None else DEFAULT_REGISTRY
173
+ _check_pin(manifest, processing)
174
+ chemistry = manifest.library.chemistry.value
175
+ if not chemistry:
176
+ raise ComposeError("manifest.library.chemistry is empty; nothing to compile")
177
+ # A processing-equivalent class emits identical params by construction (§12), so the first
178
+ # member compiles for all of them — that is exactly what makes the ambiguity benign.
179
+ spec = load_spec(chemistry[0])
180
+ try:
181
+ module = get_module(spec.require_backend().module)
182
+ except KeyError as exc:
183
+ raise ComposeError(str(exc)) from exc
184
+
185
+ onlist_files: dict[str, list[str]] = {}
186
+ intent = processing.processing
187
+ _check_env(intent.environment.value, module)
188
+ # Two owners, one block. The KB says how to PARSE; the processing manifest says what to
189
+ # COUNT. params_gate proves the key sets stay disjoint and that both halves arrive verbatim.
190
+ params = _resolve_params(manifest, spec, registry, onlist_files)
191
+ # Derived before processing, both after the KB: three owners, one key each, and `param_owners`
192
+ # is the definition all three agree on. A complex chemistry locates its barcodes by quadruple,
193
+ # computed from the element model rather than hand-entered (see `derived_params`).
194
+ params.update(derived_params(spec))
195
+ params.update(
196
+ {k: render_param(v) for k, v in processing_params(intent.quantification.value).items()}
197
+ )
198
+
199
+ config: dict[str, object] = {
200
+ "chemistry": list(chemistry),
201
+ "genome": {
202
+ "assembly": intent.genome.value.assembly,
203
+ "annotation": intent.genome.value.annotation_name,
204
+ },
205
+ # The env name RESOLVED to an image this machine can run. `config["env"]` used to sit here:
206
+ # emitted, and read by no rule — the env was recorded and then ignored, so a pipeline ran
207
+ # against whatever STAR happened to be on PATH. One key, one owner, and now something reads
208
+ # it. The manifest still carries the env *name*; this is its machine-specific rendering,
209
+ # exactly like a fastq path.
210
+ "container": container_uri(intent.environment.value, sif_dir),
211
+ "threads": intent.resources.threads,
212
+ # The advisory memory hint, in MB, so the finalize CRAM sort can be given a real `-m` budget
213
+ # rather than single-threading samtools' default. An intent knob (like threads), never a
214
+ # machine fact — the scheduler honours it when the user submits.
215
+ "mem_mb": intent.resources.mem_gb * 1024,
216
+ "outdir": outdir,
217
+ "read_files_in": _read_files_in(manifest, module),
218
+ "samples": [s.sample_id for s in manifest.experiment.samples],
219
+ # Emitted by the composer like every other key. It used to be injected by the run wrapper at
220
+ # parse time, and `required_config` documented that arrangement — but the wrapper was written
221
+ # inside a gate that could not run, so the injector named by the contract did not exist and
222
+ # every compiled pipeline was one `KeyError` from the start. One owner, no special case.
223
+ "units_tsv": _UNITS_TSV_NAME,
224
+ }
225
+ config[param_block_key(spec)] = params
226
+ primary = _primary_feature(intent.quantification.value)
227
+ if primary is not None:
228
+ # Top level, NOT inside config["solo"] — that block must stay "every key is a STAR CLI flag"
229
+ # for the gate's coverage check, and STARsolo has no --primaryFeature. Order in the manifest
230
+ # is a seqforge-side annotation with no aligner-side referent (STARsolo writes one
231
+ # Solo.out/<Feature>/ per value and does not care about order), so it gets projected out to
232
+ # an explicit value rather than leaving a positional convention load-bearing for every
233
+ # downstream reader.
234
+ config["primary_feature"] = primary
235
+
236
+ return ComposePlan(
237
+ config=config,
238
+ units=_units(manifest, fastq_dir),
239
+ module=ModuleSelection(name=module.name, version=module.version, env=module.env),
240
+ spec=spec,
241
+ onlist_files=onlist_files,
242
+ )
243
+
244
+
245
+ def _primary_feature(quant: Quantification) -> str | None:
246
+ """Which ``Solo.out/<Feature>/`` is THE matrix for this run. ``None`` for bulk (no such split)."""
247
+ return quant.features[0] if isinstance(quant, SoloQuant) else None
248
+
249
+
250
+ def _check_env(env: RuntimeEnv, module: WorkflowModule) -> None:
251
+ """The recipe's runtime env must be the one the module needs. Refuse; never quietly correct.
252
+
253
+ The env is read off the processing manifest, not off the module, because that is the artifact
254
+ that *records the decision* — and `ProcessingSection` forbids unknown keys precisely so that a
255
+ user's stated intent is never silently dropped. But a module knows what software it needs, and
256
+ ``map/starsolo`` in an env with no STAR is not a preference, it is a run that dies on a node.
257
+
258
+ So both are read and they must agree. Until `container:` landed, `processing.environment` was
259
+ emitted into the config and read by nothing, while policy hardcoded ``"align-rna"`` beside a
260
+ module that also declared it — two owners who could not disagree only because nobody asked
261
+ either. Policy now asks the module, which makes this check redundant *for the default path* and
262
+ load-bearing for the one where a user hand-writes a processing.yaml.
263
+ """
264
+ if env != module.env:
265
+ raise ComposeError(
266
+ f"processing.environment is {env!r} but module {module.name!r} needs {module.env!r}. "
267
+ f"The env is what supplies the aligner, so this run would start in a container without "
268
+ f"one. Drop the field to take the module's own env, or pick a module that wants {env!r}."
269
+ )
270
+
271
+
272
+ def _check_pin(manifest: DatasetManifest, processing: ProcessingManifest) -> None:
273
+ """A BOUND processing manifest must name this dataset. Never auto-repin."""
274
+ pin = processing.dataset
275
+ if pin is None:
276
+ return # a template: portable by design — that is what drives a corpus
277
+ if pin.dataset_hash != manifest.provenance.dataset_hash:
278
+ raise ComposeError(
279
+ f"processing manifest {processing.processing_id!r} is pinned to dataset "
280
+ f"{pin.dataset_hash[:12]}… but this dataset is "
281
+ f"{manifest.provenance.dataset_hash[:12]}…. Re-run `seqforge processing new` against "
282
+ f"this dataset, or drop the pin to make it a template."
283
+ )
284
+
285
+
286
+ def compose(
287
+ manifest: DatasetManifest,
288
+ processing: ProcessingManifest,
289
+ *,
290
+ registry: OnlistRegistry | None = None,
291
+ workspace: str | Path = ".",
292
+ outdir: str = "results",
293
+ fastq_dir: str | Path | None = None,
294
+ sif_dir: str | Path | None = None,
295
+ run_wiring_gate: bool = True,
296
+ subdir: str | None = None,
297
+ ) -> ComposeResult:
298
+ """Compile a (dataset, processing) pair into a runnable configuration + run the compose gates.
299
+
300
+ ``subdir`` roots the output one level deeper — ``seqforge/<subdir>/pipeline/…`` instead of
301
+ ``seqforge/pipeline/…`` — so a heterogeneous project can compile one assay per subdirectory. It
302
+ defaults to ``None`` (the flat, single-assay layout), which is byte-identical to before.
303
+ """
304
+ p = plan(
305
+ manifest, processing, registry=registry, outdir=outdir, fastq_dir=fastq_dir, sif_dir=sif_dir
306
+ )
307
+
308
+ # Keyed by the RUN, not by the workspace. A fixed `.seqforge/pipeline/` path meant recipe B
309
+ # silently overwrote recipe A's config, units, and materialized onlists — and "one dataset, many
310
+ # recipes" is precisely the case this whole change exists to enable.
311
+ rid = run_id(
312
+ dataset_hash=manifest.provenance.dataset_hash,
313
+ processing_hash=processing.provenance.processing_hash,
314
+ kb_version=manifest.provenance.kb_version,
315
+ workflow_version=processing.provenance.workflow_version,
316
+ )
317
+ # `pipeline/default-a3f8c19d2b04/`, not `pipeline/a3f8c19d…696/`. The run_id stays the identity
318
+ # -- it is what keeps two recipes over one dataset apart -- but the recipe already has a name and
319
+ # printing it costs nothing. The pilot's workspace was a directory of 64-hex names in which
320
+ # nothing said which pipeline was which.
321
+ root_parts = (subdir,) if subdir else ()
322
+ pipeline_dir = state_dir(
323
+ workspace, *root_parts, "pipeline", readable(processing.processing_id, rid)
324
+ )
325
+ pipeline_dir.mkdir(parents=True, exist_ok=True)
326
+ config_path = pipeline_dir / _CONFIG_NAME
327
+ units_path = pipeline_dir / _UNITS_TSV_NAME
328
+
329
+ # The whitelists are NOT written here. `rule onlist` builds each one when a job needs it and
330
+ # `temp()` deletes it after — see workflows/map/starsolo.smk. Compose used to expand 6 794 880
331
+ # packed barcodes into 111 MB of text per run directory, at compile time, for a file STAR opens
332
+ # once; compiling one dataset three ways cost a third of a gigabyte of identical bytes that
333
+ # nothing ever cleaned up. What compose owes the pipeline is the whitelist's NAME, which is a
334
+ # decision, and `_resolve_token` still verifies the registry can produce it before emitting one.
335
+ config_path.write_text(yaml.safe_dump(p.config, sort_keys=True))
336
+ units_path.write_text(_units_tsv(p.units))
337
+ # The Snakefile is THE DELIVERABLE, so the composer writes it — always, unconditionally, like the
338
+ # config beside it. It used to be written inside `wiring_gate`, which returned early when
339
+ # `snakemake` was absent from PATH, and `snakemake` was in no dependency table: the product was a
340
+ # side effect of a validation step that could not fire, so `compose` emitted no Snakefile at all.
341
+ # A gate may now read this file; it may not be the reason it exists.
342
+ # Copy the hand-written module in beside the wrapper, and point the wrapper at the local copy.
343
+ # This is still "emit data, never code": the module is checked-in source COPIED verbatim, not
344
+ # generated -- `test_shipped_modules_are_hand_written_not_generated` guards the source, and the
345
+ # wrapper itself still carries no rule body. What the copy buys is a run directory that reads and
346
+ # reproduces on its own, instead of a `snakefile:` line pointing at an absolute path inside
347
+ # whatever seqforge checkout happened to compose it.
348
+ module = get_module(p.module.name)
349
+ local_module_name = module.snakefile.name
350
+ shutil.copy2(module.snakefile, pipeline_dir / local_module_name)
351
+ snakefile_path = pipeline_dir / _SNAKEFILE_NAME
352
+ snakefile_path.write_text(render_wrapper(module.name, local_module_name))
353
+ # The resolved, dataset-BOUND processing manifest that produced this config, beside it. Disk is
354
+ # state, not input: the default path takes no --processing and must still
355
+ # leave behind exactly what decided the run.
356
+ bound = (
357
+ processing
358
+ if processing.dataset is not None
359
+ else processing.model_copy(
360
+ update={
361
+ "dataset": DatasetPin(
362
+ dataset_hash=manifest.provenance.dataset_hash,
363
+ accessions=list(manifest.experiment.accessions.value),
364
+ )
365
+ }
366
+ )
367
+ )
368
+ (pipeline_dir / "processing.lock.yaml").write_text(
369
+ yaml.safe_dump(bound.model_dump(mode="json"), sort_keys=True)
370
+ )
371
+
372
+ from .gates import e2e_gate, wiring_gate
373
+
374
+ status, problems = params_gate(manifest, processing, p.spec, p.config)
375
+ gate: dict[str, str] = {
376
+ "params": status,
377
+ "wiring": wiring_gate(pipeline_dir, p) if run_wiring_gate else "skip",
378
+ "e2e": e2e_gate(),
379
+ }
380
+
381
+ preview: dict[str, object] = dict(p.config)
382
+ # a failing gate must say WHY; the verdict alone is not actionable
383
+ preview["params_problems"] = problems
384
+
385
+ return ComposeResult(
386
+ modules=[p.module],
387
+ snakefile_path=str(snakefile_path.relative_to(Path(workspace))),
388
+ config_path=str(config_path.relative_to(Path(workspace))),
389
+ units_path=str(units_path.relative_to(Path(workspace))),
390
+ gate=gate, # type: ignore[arg-type]
391
+ params_preview=preview,
392
+ )
393
+
394
+
395
+ def _resolve_params(
396
+ manifest: DatasetManifest,
397
+ spec: Spec,
398
+ registry: OnlistRegistry,
399
+ onlist_files: dict[str, list[str]],
400
+ ) -> dict[str, object]:
401
+ """Render the KB backend params for a CLI, resolving every ``{onlist:alias}`` to a real path."""
402
+ out: dict[str, object] = {}
403
+ for key, value in spec.require_backend().params.items():
404
+ if isinstance(value, list):
405
+ rendered = [_resolve_token(v, spec, registry, onlist_files) for v in value]
406
+ out[key] = " ".join(str(r) for r in rendered)
407
+ else:
408
+ out[key] = render_param(_resolve_token(value, spec, registry, onlist_files))
409
+ return out
410
+
411
+
412
+ def _resolve_token(
413
+ value: object, spec: Spec, registry: OnlistRegistry, onlist_files: dict[str, list[str]]
414
+ ) -> object:
415
+ if not (isinstance(value, str) and value.startswith("{onlist:") and value.endswith("}")):
416
+ return value
417
+ alias = value[len("{onlist:") : -1]
418
+ ref = spec.onlists.get(alias)
419
+ if ref is None:
420
+ raise ComposeError(f"backend references undeclared onlist alias {alias!r}")
421
+ name = ref.registry
422
+ rel = f"onlists/{name}.txt"
423
+ try:
424
+ registry.packed(name)
425
+ except OnlistNotAvailable as exc:
426
+ raise ComposeError(
427
+ f"onlist {name!r} is not materialized, so --soloCBwhitelist cannot be emitted: {exc}. "
428
+ "Register it (URL + sha256) or run with a registry that can fetch it."
429
+ ) from exc
430
+ # Verified, not materialized. `registry.packed(name)` above is what proves the whitelist EXISTS
431
+ # and is the declared barcode set -- refusing here, at compile time, is the rung-3 promise
432
+ # `compose` makes. What it must not do is write the 111 MB: that is `rule onlist`'s job, and
433
+ # `temp()`'s. The name is recorded so the gate and the tests can still see which list was chosen.
434
+ onlist_files[rel] = [name]
435
+ return rel
436
+
437
+
438
+ def _read_files_in(manifest: DatasetManifest, module: WorkflowModule) -> dict[str, str]:
439
+ """Map the manifest's reads to the keys the module hands its aligner.
440
+
441
+ Dispatch is on the module's declared ``read_layout_kind``, **not** on its name. This was
442
+ `if spec.backend.module == "map/starsolo": ... else: <bulk>`, which is a bug rather than an
443
+ abstraction gap: it makes "not starsolo" mean "bulk paired-end", so any third module would have
444
+ silently emitted `mate1`/`mate2` for a chemistry that has neither and produced a wrong command
445
+ line with no error anywhere.
446
+ """
447
+ if module.read_layout_kind == "barcoded":
448
+ cdna = find_read_with_role(manifest, "cDNA") or find_read_with_role(manifest, "gDNA")
449
+ barcode = find_read_with_role(manifest, "CB")
450
+ if cdna is None or barcode is None:
451
+ raise ComposeError("a barcoded chemistry needs both a cDNA read and a CB-bearing read")
452
+ return {"cdna": cdna.read_id, "barcode": barcode.read_id}
453
+ reads = manifest.library.read_layout.reads
454
+ if len(reads) < 2:
455
+ raise ComposeError(f"bulk paired-end needs 2 reads, found {len(reads)}")
456
+ return {"mate1": reads[0].read_id, "mate2": reads[1].read_id}
457
+
458
+
459
+ def _resolve_uri(uri: str, fastq_dir: str | Path | None) -> str:
460
+ """A manifest URI -> the path this machine will actually open.
461
+
462
+ A non-file scheme (``s3://``, ``https://``, a bare accession) is passed through untouched: it is
463
+ not ours to resolve, and Snakemake storage plugins or a future `fetch` module own that. Only a
464
+ relative local name gets joined to ``fastq_dir``.
465
+ """
466
+ if "://" in uri or fastq_dir is None:
467
+ return uri
468
+ return str((Path(fastq_dir) / uri).resolve())
469
+
470
+
471
+ def _units(manifest: DatasetManifest, fastq_dir: str | Path | None = None) -> list[dict[str, str]]:
472
+ """One row per (sample, run, read role, file). Falls back to a single implicit sample.
473
+
474
+ ``run`` is which sequencing run a file came from, from the SAME `resolve.group.run_key` that
475
+ grouped the dataset into runs during resolution. Recording it here is what lets the mapping module
476
+ pair a pooled sample's mates by run: STAR reads `--readFilesIn` mate-by-mate and desyncs if cDNA of
477
+ run K is joined with barcodes of run J, and `sample.file_uris` gives no order guarantee. Deriving
478
+ the run once, in the one function that owns the notion, keeps the workflow from re-parsing names.
479
+ """
480
+ by_uri = {f.uri: f for f in manifest.library.files}
481
+ rows: list[dict[str, str]] = []
482
+ samples = manifest.experiment.samples
483
+ if not samples:
484
+ for f in manifest.library.files:
485
+ if f.read_id is not None and f.read_id != INDEX_ROLE:
486
+ rows.append(
487
+ {
488
+ "sample_id": "sample1",
489
+ "run": run_key(f.uri),
490
+ "read_id": f.read_id,
491
+ "path": _resolve_uri(f.uri, fastq_dir),
492
+ }
493
+ )
494
+ return rows
495
+ for sample in samples:
496
+ for uri in sample.file_uris:
497
+ item = by_uri.get(uri)
498
+ if item is None or item.read_id is None or item.read_id == INDEX_ROLE:
499
+ continue # unassigned files, and set-aside technical index reads, never become units
500
+ rows.append(
501
+ {
502
+ "sample_id": sample.sample_id,
503
+ "run": run_key(item.uri),
504
+ "read_id": item.read_id,
505
+ "path": _resolve_uri(item.uri, fastq_dir),
506
+ }
507
+ )
508
+ return rows
509
+
510
+
511
+ def _units_tsv(rows: list[dict[str, str]]) -> str:
512
+ header = ["sample_id", "run", "read_id", "path"]
513
+ lines = ["\t".join(header)]
514
+ lines += ["\t".join(r[h] for h in header) for r in rows]
515
+ return "\n".join(lines) + "\n"
@@ -0,0 +1,113 @@
1
+ """The compose gate's other two parts: **wiring** and **e2e** (design §4.1, parts 1 and 3).
2
+
3
+ Both depend on a toolchain seqforge does not own, so both report **``skip``** — never ``pass`` — when
4
+ that toolchain is absent. A gate that silently reports ``pass`` because it did not run is worse than
5
+ no gate at all: green CI would then be mistaken for coverage.
6
+
7
+ - **wiring** (`snakemake -n -p`): needs the `snakemake` binary, which is now a declared dependency
8
+ (`[tool.pixi.feature.wf]`), so this gate actually runs. It works in a **throwaway copy** of the run
9
+ directory and never writes into the run directory itself — see the incident note on `_replica`.
10
+ - **e2e** (the real count-matrix run): needs STAR + liulab-genome + network. It is a Linux/cluster
11
+ operation, deliberately NOT run inside `compose`; `seqforge kb e2e` owns it.
12
+
13
+ **Why `-p`, and why no `--lint`.** Both were measured on 2026-07-15 rather than reasoned about:
14
+
15
+ - `-p` forces Snakemake to *format* every `shell:` block while planning. Without it a dry run never
16
+ renders the command, so a `KeyError` on a missing param — `starsolo.smk` dereferencing
17
+ `soloCBstart` for a `CB_UMI_Complex` chemistry that has no such key — plans clean and dies on a
18
+ compute node. `-p` is the difference between this gate catching that and rubber-stamping it.
19
+ - `--lint` was in this gate and is now gone. It fails on *every* rule we ship, for a missing `log:`
20
+ directive and "mixed rules and functions in same snakefile" — style opinions, not wiring facts. A
21
+ gate that is red for a correct config teaches people to ignore it, and then it guards nothing.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import shutil
27
+ import subprocess
28
+ import tempfile
29
+ from pathlib import Path
30
+ from typing import TYPE_CHECKING
31
+
32
+ if TYPE_CHECKING: # pragma: no cover
33
+ from .core import ComposePlan
34
+
35
+
36
+ def have(binary: str) -> bool:
37
+ return shutil.which(binary) is not None
38
+
39
+
40
+ def _replica(pipeline_dir: Path, plan: ComposePlan) -> Path:
41
+ """Copy the compiled artifacts to a scratch dir and stand in zero-byte FASTQs **there**.
42
+
43
+ This gate needs its inputs to exist — `snakemake -n` raises `MissingInputException` otherwise —
44
+ and `compose` is a pure function that runs with no FASTQ on disk. So the dry run is given
45
+ zero-byte stand-ins: it validates *wiring*, not data.
46
+
47
+ **The stand-ins go in a throwaway copy, and that is the whole point of this function.** They used
48
+ to be touched straight into the run directory, at `pipeline_dir / row["path"]`, and never removed.
49
+ That was invisible only because `snakemake` was in no dependency table, so this gate never ran. The
50
+ moment it did, the run directory would contain zero-byte files named exactly like the FASTQs, STAR
51
+ would read them, and the pipeline would emit an empty matrix and **exit 0** — a silent, plausible,
52
+ wrong answer, which is the one failure class this project exists to prevent.
53
+
54
+ An **absolute** unit path is skipped rather than stood in: it names a real FASTQ the caller pointed
55
+ us at with `--fastq-dir`, and `scratch / "/abs/path"` is `/abs/path`, so touching it would create a
56
+ zero-byte file *at the real location*. If such a file is genuinely missing the dry run fails, and
57
+ failing is right — the caller said it was there.
58
+ """
59
+ scratch = Path(tempfile.mkdtemp(prefix="seqforge-wiring-"))
60
+ for item in pipeline_dir.iterdir():
61
+ if item.name == ".snakemake":
62
+ continue
63
+ dst = scratch / item.name
64
+ if item.is_dir():
65
+ shutil.copytree(item, dst)
66
+ else:
67
+ shutil.copy2(item, dst)
68
+ for row in plan.units:
69
+ path = Path(row["path"])
70
+ if path.is_absolute():
71
+ continue
72
+ target = scratch / path
73
+ target.parent.mkdir(parents=True, exist_ok=True)
74
+ target.touch(exist_ok=True)
75
+ return scratch
76
+
77
+
78
+ def wiring_gate(pipeline_dir: Path, plan: ComposePlan) -> str:
79
+ """`snakemake -n -p` over a throwaway replica. ``skip`` only if snakemake is absent."""
80
+ if not have("snakemake"):
81
+ return "skip"
82
+ scratch = _replica(pipeline_dir, plan)
83
+ try:
84
+ proc = subprocess.run(
85
+ ["snakemake", "-d", str(scratch), "-s", str(scratch / "Snakefile"), "-n", "-p"],
86
+ capture_output=True,
87
+ text=True,
88
+ timeout=300,
89
+ )
90
+ if proc.returncode != 0:
91
+ return "fail"
92
+ # A dry run that plans NOTHING exits 0 and says "Nothing to be done", which is exactly what a
93
+ # workflow with no reachable target does — and for most of this repo's life that is what the
94
+ # generated wrapper produced, because an `include:`d `rule all` is not a default target. A
95
+ # gate cannot tell "correct" from "planned nothing" by exit code, so it must look.
96
+ if "Nothing to be done" in (proc.stdout or ""):
97
+ return "fail"
98
+ return "pass"
99
+ finally:
100
+ shutil.rmtree(scratch, ignore_errors=True)
101
+
102
+
103
+ def e2e_gate() -> str:
104
+ """The real count-matrix run is owned by ``seqforge kb e2e`` — never implicitly run by compose."""
105
+ if not (have("STAR") and _have_genome()):
106
+ return "skip"
107
+ return "skip" # available but deliberately not run here; invoke `seqforge kb e2e`
108
+
109
+
110
+ def _have_genome() -> bool:
111
+ import importlib.util
112
+
113
+ return importlib.util.find_spec("genome") is not None