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
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
# workflows/map/starsolo.smk -- HAND-WRITTEN, VERSIONED, CI-TESTED. NEVER machine-generated.
|
|
2
|
+
#
|
|
3
|
+
# STARsolo mapping for barcoded single-cell RNA-seq (10x 3' v2/v3, SPLiT-seq, ...). The composer
|
|
4
|
+
# emits `config.yaml` + `units.tsv` and selects this module by id `map/starsolo`; it NEVER writes
|
|
5
|
+
# rule source. Every chemistry-defining knob arrives via `config["solo"]` (rendered from the KB's
|
|
6
|
+
# backend.params and asserted by compose's params gate); the read->role placement arrives via
|
|
7
|
+
# `config["read_files_in"]`, cDNA FIRST.
|
|
8
|
+
#
|
|
9
|
+
# The genome index resolves at RUN TIME from a `liulab-genome` assembly id — no genome path
|
|
10
|
+
# is ever baked into a config or a manifest, and we do not reimplement liulab-genome's job here.
|
|
11
|
+
|
|
12
|
+
import csv
|
|
13
|
+
|
|
14
|
+
# seqforge's own helpers, imported rather than restated. `h5ad_suffixes` decides both what the
|
|
15
|
+
# packaging rule DECLARES below and what `seqforge io h5ad` WRITES, so the two cannot drift; a rule
|
|
16
|
+
# that declared its outputs separately from the code producing them would be two sources of truth for
|
|
17
|
+
# one fact, which is the bug this repo keeps finding. The import is the same assumption
|
|
18
|
+
# `rule genome_index` already makes of `genome`: the env running snakemake is the env that has them.
|
|
19
|
+
from seqforge.workflows.h5ad import (
|
|
20
|
+
STAR_BAM,
|
|
21
|
+
STAR_LOG_FILES,
|
|
22
|
+
h5ad_suffixes,
|
|
23
|
+
solo_filtered_files,
|
|
24
|
+
solo_raw_files,
|
|
25
|
+
solo_stats_files,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _load_units(path):
|
|
30
|
+
with open(path, newline="") as fh:
|
|
31
|
+
return list(csv.DictReader(fh, delimiter="\t"))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
UNITS = _load_units(config["units_tsv"])
|
|
35
|
+
SAMPLES = sorted({u["sample_id"] for u in UNITS})
|
|
36
|
+
OUTDIR = config["outdir"]
|
|
37
|
+
ASSEMBLY = config["genome"]["assembly"]
|
|
38
|
+
SOLO = config["solo"]
|
|
39
|
+
# STAR takes --soloFeatures as N space-separated values and writes one Solo.out/<Feature>/ per value.
|
|
40
|
+
FEATURES = SOLO["soloFeatures"].split()
|
|
41
|
+
PRIMARY = config["primary_feature"]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def fastqs(sample, role):
|
|
45
|
+
# Ordered by the units.tsv `run` column so a pooled sample's mates pair correctly: STAR reads
|
|
46
|
+
# --readFilesIn mate-by-mate and desyncs (FATAL: "quality string length is not equal to sequence
|
|
47
|
+
# length") if cDNA run K is joined with barcode run J. `run` is seqforge's own run grouping, so
|
|
48
|
+
# run N of one mate lines up with run N of the other -- no filename parsing here.
|
|
49
|
+
us = [u for u in UNITS if u["sample_id"] == sample and u["read_id"] == role]
|
|
50
|
+
return [u["path"] for u in sorted(us, key=lambda u: (u["run"], u["path"]))]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def readfilesin(sample, *roles):
|
|
54
|
+
"""Render STAR ``--readFilesIn`` for one sample: each role (a mate) is its FASTQs **comma-joined**,
|
|
55
|
+
and the mates are space-separated -- ``cdna1,cdna2 barcode1,barcode2``.
|
|
56
|
+
|
|
57
|
+
A sample pooled across N sequencing runs passes every run's file for a mate as one comma-list, in
|
|
58
|
+
matching run order for every mate (``fastqs`` preserves units.tsv order, which lists a sample's
|
|
59
|
+
runs in one order). This is STAR's own multi-file syntax; joining with spaces instead -- the old
|
|
60
|
+
bug -- makes STAR read the extra files as extra mates and crash. A single-run sample renders one
|
|
61
|
+
file per mate, exactly as before, so this generalises to any run count with no special case."""
|
|
62
|
+
return " ".join(",".join(fastqs(sample, role)) for role in roles)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def whitelists():
|
|
66
|
+
"""One path for 10x; three for a split-pool chemistry. The config value is the argv rendering."""
|
|
67
|
+
return SOLO["soloCBwhitelist"].split()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def cb_umi_geometry():
|
|
71
|
+
"""Where the CB and UMI live -- and STARsolo spells this two different ways.
|
|
72
|
+
|
|
73
|
+
A simple chemistry (10x) has one contiguous barcode, so a start/length pair locates it. A
|
|
74
|
+
combinatorial one (SPLiT-seq, BD Rhapsody) has barcodes scattered between linkers, so each needs a
|
|
75
|
+
position quadruple and no start/length exists to give. This is not a preference: passing
|
|
76
|
+
--soloCBstart to CB_UMI_Complex is an error, and the keys are absent from the config precisely
|
|
77
|
+
because the chemistry has no such value. Compose emits whichever set the soloType implies (the
|
|
78
|
+
params gate proves the block is exactly what its owners declared), so the branch here reads what is
|
|
79
|
+
there.
|
|
80
|
+
|
|
81
|
+
The Complex branch also pins --soloCBmatchWLtype: STAR's global default is ``1MM_multi``, which it
|
|
82
|
+
REJECTS for CB_UMI_Complex ("does not work with --soloType CB_UMI_Complex"; allowed: Exact / 1MM).
|
|
83
|
+
So a Complex chemistry that named no match type would FATAL at STAR on the default alone -- a run
|
|
84
|
+
that dies on the node, not a thin matrix. ``1MM`` is the tolerant valid mode (one mismatch per
|
|
85
|
+
barcode block), the closest Complex-legal analogue of the Simple default. Confirmed against real BD
|
|
86
|
+
Rhapsody Enhanced reads (#43): the endorsed recipe on STAR #1607 intends this, and its "1MM multi"
|
|
87
|
+
is 1MM for a complex barcode.
|
|
88
|
+
"""
|
|
89
|
+
if SOLO["soloType"] == "CB_UMI_Complex":
|
|
90
|
+
return (
|
|
91
|
+
f"--soloCBmatchWLtype 1MM "
|
|
92
|
+
f"--soloCBposition {SOLO['soloCBposition']} "
|
|
93
|
+
f"--soloUMIposition {SOLO['soloUMIposition']}"
|
|
94
|
+
)
|
|
95
|
+
return (
|
|
96
|
+
f"--soloCBstart {SOLO['soloCBstart']} --soloCBlen {SOLO['soloCBlen']} "
|
|
97
|
+
f"--soloUMIstart {SOLO['soloUMIstart']} --soloUMIlen {SOLO['soloUMIlen']}"
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def barcode_read_length():
|
|
102
|
+
"""--soloBarcodeReadLength, and ONLY when the chemistry declares it.
|
|
103
|
+
|
|
104
|
+
STARsolo's default (1) FATALs unless the barcode read is exactly CB+UMI long. 10x v2/v3/v3.1 R1 is
|
|
105
|
+
routinely sequenced longer than the 26/28 nt the barcode occupies (a 150 nt R1 is common), so their
|
|
106
|
+
specs set `soloBarcodeReadLength: 0` to disable that check and read CB/UMI from the fixed offsets.
|
|
107
|
+
A chemistry that does not set the key (SPLiT-seq, ...) keeps STAR's default, so the flag is emitted
|
|
108
|
+
iff it is present -- the same "render whatever the chemistry put in the block" contract as the
|
|
109
|
+
geometry above.
|
|
110
|
+
|
|
111
|
+
`SOLO.get(...)`, deliberately NOT `SOLO["..."]`: a subscript would make `keys_read_by` (see
|
|
112
|
+
`workflows/__init__.py`) mark `solo.soloBarcodeReadLength` a REQUIRED config key, and the composer
|
|
113
|
+
would then be obliged to emit it for every starsolo chemistry -- including SPLiT-seq, whose params
|
|
114
|
+
gate forbids emitting a key it does not own. `.get` is the honest "optional read" the scanner
|
|
115
|
+
correctly leaves out of `required_config`.
|
|
116
|
+
"""
|
|
117
|
+
value = SOLO.get("soloBarcodeReadLength")
|
|
118
|
+
return f"--soloBarcodeReadLength {value}" if value is not None else ""
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def adapter_sequence():
|
|
122
|
+
"""--soloAdapterSequence, and ONLY when the chemistry declares it (an ANCHORED bead).
|
|
123
|
+
|
|
124
|
+
BD Rhapsody Enhanced prepends a variable 0-3 bp diversity insert to the barcode read, so the CB/UMI
|
|
125
|
+
offsets float. STARsolo absorbs the stagger by anchoring to this adapter (`NNN...GTGANNN...GACA`):
|
|
126
|
+
it finds the adapter in each read and reads the barcodes at the anchor-2/anchor-3 positions
|
|
127
|
+
`cb_umi_geometry()` emits. Derived from the linker elements at compose time (compose/params.py) and
|
|
128
|
+
present in `config["solo"]` only for such a chemistry -- `.get`, so a fixed-offset chemistry (10x,
|
|
129
|
+
the original BD bead) neither declares it nor has the scanner mark it a required key.
|
|
130
|
+
"""
|
|
131
|
+
value = SOLO.get("soloAdapterSequence")
|
|
132
|
+
return f"--soloAdapterSequence {value}" if value is not None else ""
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# Every raw matrix/axis file this run's --soloFeatures must produce, per sample -- declared
|
|
136
|
+
# file-by-file, and that is the point. `starsolo_count` used to declare
|
|
137
|
+
# `directory(f"{OUTDIR}/{{sample}}/Solo.out")`, under which STAR writing three of five features and
|
|
138
|
+
# exiting 0 was indistinguishable from success: the directory exists, snakemake is satisfied, and the
|
|
139
|
+
# missing counts surface later as an h5ad nobody can explain. A named output cannot be missing.
|
|
140
|
+
# The `{{{{sample}}}}` is snakemake's usual escape -- expand() fills `f` and leaves `sample` a wildcard.
|
|
141
|
+
SOLO_MATRICES = expand(f"{OUTDIR}/{{{{sample}}}}/Solo.out/{{f}}", f=solo_raw_files(FEATURES))
|
|
142
|
+
|
|
143
|
+
# The rest of what STAR writes, declared so the finalize rules can consume it and `temp()` can then
|
|
144
|
+
# delete it -- automatic, DAG-ordered cleanup, never a manual `rm`. Same file-by-file discipline as
|
|
145
|
+
# SOLO_MATRICES: a declared output STAR did not write fails the rule loudly. The stats + logs + the
|
|
146
|
+
# filtered/ tree feed `qc_bundle`; the BAM feeds `solo_to_cram`.
|
|
147
|
+
SOLO_STATS = expand(f"{OUTDIR}/{{{{sample}}}}/Solo.out/{{f}}", f=solo_stats_files(FEATURES))
|
|
148
|
+
SOLO_FILTERED = expand(f"{OUTDIR}/{{{{sample}}}}/Solo.out/{{f}}", f=solo_filtered_files(FEATURES))
|
|
149
|
+
STAR_LOGS = expand(f"{OUTDIR}/{{{{sample}}}}/{{f}}", f=list(STAR_LOG_FILES))
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
rule all:
|
|
153
|
+
input:
|
|
154
|
+
expand(
|
|
155
|
+
f"{OUTDIR}/{{sample}}/{{sample}}{{suffix}}",
|
|
156
|
+
sample=SAMPLES,
|
|
157
|
+
suffix=h5ad_suffixes(FEATURES),
|
|
158
|
+
),
|
|
159
|
+
# The retained finalize deliverables: a compact CRAM of the alignment and one gzipped-JSON
|
|
160
|
+
# stats bundle per sample. The raw matrices, filtered tree, stats, logs, and BAM they are
|
|
161
|
+
# built from are all `temp()` and gone by the time these land.
|
|
162
|
+
expand(f"{OUTDIR}/{{sample}}/{{sample}}.cram", sample=SAMPLES),
|
|
163
|
+
expand(f"{OUTDIR}/{{sample}}/{{sample}}.qc.json.gz", sample=SAMPLES),
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
rule onlist:
|
|
167
|
+
"""Materialize one barcode whitelist, for STAR to read once and snakemake to then delete.
|
|
168
|
+
|
|
169
|
+
`temp()` is the entire point. 10x's v3 whitelist is 6 794 880 barcodes = 111 MB of text, and
|
|
170
|
+
`compose` used to write it into the run directory at compile time -- so one dataset compiled
|
|
171
|
+
three ways cost a third of a gigabyte of identical bytes, sitting there forever, for a file STAR
|
|
172
|
+
opens once. Now it is built on demand and deleted when the last job that needs it is done.
|
|
173
|
+
|
|
174
|
+
It was also `temp()`-able in name only before this rule existed: the whitelist was bound to
|
|
175
|
+
`starsolo_count.input` with NO producing rule, and snakemake cannot delete what it did not make.
|
|
176
|
+
An input with no rule is a file snakemake merely requires to already be there.
|
|
177
|
+
|
|
178
|
+
No `container:` directive, deliberately. This runs `seqforge`, which is not an aligner -- the
|
|
179
|
+
ambient environment is the one that just ran `seqforge compose`, so it is by construction the one
|
|
180
|
+
that has it. Naming `align-rna` here would put our own tool inside STAR's image.
|
|
181
|
+
"""
|
|
182
|
+
output:
|
|
183
|
+
temp("onlists/{name}.txt"),
|
|
184
|
+
localrule: True
|
|
185
|
+
shell:
|
|
186
|
+
"seqforge io onlist write {wildcards.name} --out {output}"
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
rule genome_index:
|
|
190
|
+
"""Resolve the STAR index via liulab-genome at run time (never a path in the manifest).
|
|
191
|
+
|
|
192
|
+
This rule only **looks up** the index; it never builds one. `get_star_index` returns the genomeDir
|
|
193
|
+
liulab-genome already built for this assembly + annotation, and **raises if none exists** -- the
|
|
194
|
+
index is liulab-genome's artifact, built ahead of the run by its own machinery, in its own
|
|
195
|
+
environment. A machine with no prebuilt index fails loudly here ("build it first"), which is the
|
|
196
|
+
failure mode we want: the pipeline consumes the index, it does not decide when or how it is built.
|
|
197
|
+
|
|
198
|
+
Because nothing is invoked here -- no STAR, no `genomeGenerate` -- this rule needs no tool on PATH
|
|
199
|
+
and no `container:`. (A `container:` would be moot anyway: snakemake wraps a container around a
|
|
200
|
+
`shell:` command in `shell.py`, but a `run:` block executes Python in the snakemake process and
|
|
201
|
+
never passes through that wrap; snakemake's own linter excludes `is_run` rules from "missing
|
|
202
|
+
software definition".) The container on the alignment rule pins the aligner that does the work.
|
|
203
|
+
"""
|
|
204
|
+
output:
|
|
205
|
+
directory(f"{OUTDIR}/index/{ASSEMBLY}"),
|
|
206
|
+
params:
|
|
207
|
+
assembly=ASSEMBLY,
|
|
208
|
+
annotation=config["genome"]["annotation"],
|
|
209
|
+
run:
|
|
210
|
+
from pathlib import Path
|
|
211
|
+
|
|
212
|
+
from genome import Genome
|
|
213
|
+
|
|
214
|
+
index = Genome(params.assembly).get_star_index(gtf=params.annotation)
|
|
215
|
+
out = Path(output[0])
|
|
216
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
217
|
+
out.symlink_to(index)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
rule starsolo_count:
|
|
221
|
+
"""Map one sample's cDNA + barcode reads to a per-cell count matrix."""
|
|
222
|
+
input:
|
|
223
|
+
cdna=lambda wc: fastqs(wc.sample, config["read_files_in"]["cdna"]),
|
|
224
|
+
barcode=lambda wc: fastqs(wc.sample, config["read_files_in"]["barcode"]),
|
|
225
|
+
index=rules.genome_index.output,
|
|
226
|
+
whitelist=whitelists(),
|
|
227
|
+
output:
|
|
228
|
+
# `temp()` on everything: the raw matrices are consumed by `solo_to_h5ad`, the stats +
|
|
229
|
+
# filtered tree + logs by `qc_bundle`, and the BAM by `solo_to_cram`. Snakemake deletes each
|
|
230
|
+
# group once its one consumer finishes -- so nothing here survives that is not a `rule all`
|
|
231
|
+
# target. The files stay declared (not just `rm`'d) so a missing one is still a rule failure.
|
|
232
|
+
matrices=temp(SOLO_MATRICES),
|
|
233
|
+
stats=temp(SOLO_STATS),
|
|
234
|
+
filtered=temp(SOLO_FILTERED),
|
|
235
|
+
logs=temp(STAR_LOGS),
|
|
236
|
+
bam=temp(f"{OUTDIR}/{{sample}}/{STAR_BAM}"),
|
|
237
|
+
# The pinned aligner: liulab-runtime's `align-rna`, resolved by compose to a ghcr tag or to a
|
|
238
|
+
# prebuilt .sif on this machine. Naming it here is CONSUMING liulab-runtime's artifact, not
|
|
239
|
+
# defining an environment -- no conda YAML, no Dockerfile, no STAR in any dependency table.
|
|
240
|
+
#
|
|
241
|
+
# Honoured only when the run passes `--software-deployment-method apptainer` (measured: without
|
|
242
|
+
# it, snakemake plans the same jobs and never mentions the image). That is snakemake's contract
|
|
243
|
+
# and it is the user's call -- they submit, we do not.
|
|
244
|
+
container: config["container"]
|
|
245
|
+
threads: config["threads"]
|
|
246
|
+
params:
|
|
247
|
+
solo=SOLO,
|
|
248
|
+
geometry=cb_umi_geometry(),
|
|
249
|
+
barcode_read_length=barcode_read_length(),
|
|
250
|
+
adapter=adapter_sequence(),
|
|
251
|
+
prefix=lambda wc: f"{OUTDIR}/{wc.sample}/",
|
|
252
|
+
# cDNA mate first, then barcode mate (order asserted by the params gate); each mate is its
|
|
253
|
+
# runs comma-joined, so a sample pooled across runs maps in one STAR pass. See readfilesin().
|
|
254
|
+
reads=lambda wc: readfilesin(
|
|
255
|
+
wc.sample, config["read_files_in"]["cdna"], config["read_files_in"]["barcode"]
|
|
256
|
+
),
|
|
257
|
+
shell:
|
|
258
|
+
# --readFilesIn takes the cDNA read FIRST, then the barcode read (asserted by the params gate).
|
|
259
|
+
# {params.barcode_read_length} is `--soloBarcodeReadLength 0` for 10x (over-length R1) and empty
|
|
260
|
+
# for a chemistry that does not declare it -- an empty token is a valid line continuation.
|
|
261
|
+
r"""
|
|
262
|
+
STAR --runMode alignReads --genomeDir {input.index} --runThreadN {threads} \
|
|
263
|
+
--readFilesIn {params.reads} --readFilesCommand zcat \
|
|
264
|
+
--soloType {params.solo[soloType]} \
|
|
265
|
+
{params.geometry} \
|
|
266
|
+
{params.adapter} \
|
|
267
|
+
{params.barcode_read_length} \
|
|
268
|
+
--soloCBwhitelist {input.whitelist} \
|
|
269
|
+
--soloStrand {params.solo[soloStrand]} \
|
|
270
|
+
--soloFeatures {params.solo[soloFeatures]} \
|
|
271
|
+
--outFileNamePrefix {params.prefix} \
|
|
272
|
+
--outSAMtype BAM Unsorted
|
|
273
|
+
"""
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
rule solo_to_h5ad:
|
|
277
|
+
"""Package Solo.out's raw matrices as .h5ad -- THE deliverable of this pipeline.
|
|
278
|
+
|
|
279
|
+
A `shell:` calling a seqforge verb, not a `run:` block, and that is deliberate: `snakemake -n -p`
|
|
280
|
+
renders every shell block while planning and cannot see inside a `run:` block, so this way
|
|
281
|
+
compose's wiring gate covers the packaging step too. It is also the CLI-is-the-API line.
|
|
282
|
+
|
|
283
|
+
No `container:`. Writing an .h5ad is seqforge's own output-format job, not an aligner's; `anndata`
|
|
284
|
+
is a plain dependency of this package. Only `starsolo_count` needs liulab-runtime.
|
|
285
|
+
"""
|
|
286
|
+
input:
|
|
287
|
+
matrices=rules.starsolo_count.output.matrices,
|
|
288
|
+
output:
|
|
289
|
+
expand(f"{OUTDIR}/{{{{sample}}}}/{{{{sample}}}}{{suffix}}", suffix=h5ad_suffixes(FEATURES)),
|
|
290
|
+
params:
|
|
291
|
+
solo=lambda wc: f"{OUTDIR}/{wc.sample}/Solo.out",
|
|
292
|
+
prefix=lambda wc: f"{OUTDIR}/{wc.sample}/{wc.sample}",
|
|
293
|
+
features=" ".join(FEATURES),
|
|
294
|
+
primary=PRIMARY,
|
|
295
|
+
shell:
|
|
296
|
+
r"""
|
|
297
|
+
seqforge io h5ad --solo-dir {params.solo} --features "{params.features}" \
|
|
298
|
+
--primary {params.primary} --out-prefix {params.prefix}
|
|
299
|
+
"""
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
rule solo_to_cram:
|
|
303
|
+
"""Convert STAR's Aligned.out.bam to a coordinate-sorted CRAM, then let `temp()` drop the BAM.
|
|
304
|
+
|
|
305
|
+
A sibling of `solo_to_h5ad`: both consume `starsolo_count` and nothing else, so snakemake runs
|
|
306
|
+
them in parallel. The reference is resolved at run time from the assembly id via liulab-genome
|
|
307
|
+
(never a baked path); no `embed_ref`, so the CRAM carries the reference MD5 in its header and the
|
|
308
|
+
assembly id is recorded in the QC bundle.
|
|
309
|
+
|
|
310
|
+
`container:`, unlike `solo_to_h5ad`. This rule shells out to **samtools**, a runtime binary -- so,
|
|
311
|
+
exactly like `starsolo_count`'s STAR, the tool must come from the pinned `align-rna` image and not
|
|
312
|
+
from "whatever the submitting shell happened to have". `align-rna` carries samtools (its base
|
|
313
|
+
layer), seqforge and liulab-genome (its `lab` feature), so `seqforge io cram` runs fully inside it.
|
|
314
|
+
The h5ad/onlist/bundle steps stay container-less because they invoke no external binary; this one
|
|
315
|
+
does, which is the whole distinction.
|
|
316
|
+
"""
|
|
317
|
+
input:
|
|
318
|
+
bam=rules.starsolo_count.output.bam,
|
|
319
|
+
output:
|
|
320
|
+
cram=f"{OUTDIR}/{{sample}}/{{sample}}.cram",
|
|
321
|
+
crai=f"{OUTDIR}/{{sample}}/{{sample}}.cram.crai",
|
|
322
|
+
container: config["container"]
|
|
323
|
+
threads: config["threads"]
|
|
324
|
+
# Declared so the scheduler gates on it AND so the sort gets a real `-m` budget instead of
|
|
325
|
+
# samtools' single-thread default -- more cores and more memory both make the sort finish sooner.
|
|
326
|
+
resources:
|
|
327
|
+
mem_mb=config["mem_mb"],
|
|
328
|
+
params:
|
|
329
|
+
assembly=ASSEMBLY,
|
|
330
|
+
shell:
|
|
331
|
+
r"""
|
|
332
|
+
seqforge io cram --bam {input.bam} --assembly {params.assembly} \
|
|
333
|
+
--out {output.cram} --threads {threads} --sort-mem-mb {resources.mem_mb}
|
|
334
|
+
"""
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
rule qc_bundle:
|
|
338
|
+
"""Bundle STAR's stats + run logs into one gzipped JSON, then let `temp()` drop the originals.
|
|
339
|
+
|
|
340
|
+
Consumes the per-feature stats, the filtered/ tree (only its barcodes.tsv is read -- kept as
|
|
341
|
+
provenance of STAR's default cell call -- but listing the whole tree here is what triggers its
|
|
342
|
+
deletion), and the top-level logs. A `shell:` verb, not a `run:`, so compose's wiring gate sees it.
|
|
343
|
+
"""
|
|
344
|
+
input:
|
|
345
|
+
stats=rules.starsolo_count.output.stats,
|
|
346
|
+
filtered=rules.starsolo_count.output.filtered,
|
|
347
|
+
logs=rules.starsolo_count.output.logs,
|
|
348
|
+
output:
|
|
349
|
+
f"{OUTDIR}/{{sample}}/{{sample}}.qc.json.gz",
|
|
350
|
+
params:
|
|
351
|
+
solo=lambda wc: f"{OUTDIR}/{wc.sample}/Solo.out",
|
|
352
|
+
run_dir=lambda wc: f"{OUTDIR}/{wc.sample}",
|
|
353
|
+
features=" ".join(FEATURES),
|
|
354
|
+
assembly=ASSEMBLY,
|
|
355
|
+
shell:
|
|
356
|
+
r"""
|
|
357
|
+
seqforge io qc-bundle --solo-dir {params.solo} --run-dir {params.run_dir} \
|
|
358
|
+
--features "{params.features}" --sample {wildcards.sample} \
|
|
359
|
+
--assembly {params.assembly} --out {output}
|
|
360
|
+
"""
|
seqforge/workflows/qc.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Bundle STARsolo's scattered stats + run logs into one gzipped JSON per sample.
|
|
2
|
+
|
|
3
|
+
This is a finalize step of ``map/starsolo``: once ``<sample>.h5ad`` captures the counts, STAR's small
|
|
4
|
+
per-feature stat files, its knee-plot vectors, its run logs, and its splice-junction table are all
|
|
5
|
+
that is worth keeping — and they are worth keeping, for a future experiment-level QC pass. STAR
|
|
6
|
+
scatters them across ``Solo.out/<Feature>/`` and the sample directory as a dozen little text files;
|
|
7
|
+
this collapses them into **one** self-describing ``<sample>.qc.json.gz`` and lets the rule that calls
|
|
8
|
+
it ``temp()``-delete the originals.
|
|
9
|
+
|
|
10
|
+
JSON (gzipped), not pickle, on purpose: a QC corpus that outlives any one code version must not be
|
|
11
|
+
readable only by the exact class that wrote it. Text is portable, diffable, and language-agnostic;
|
|
12
|
+
gzip absorbs the one bulky field (``UMIperCellSorted``, one integer per barcode).
|
|
13
|
+
|
|
14
|
+
Like ``h5ad.py`` this shells out through a ``seqforge io`` verb rather than a Snakemake ``run:`` block
|
|
15
|
+
so ``snakemake -n -p`` (compose's wiring gate) can see the command.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import gzip
|
|
21
|
+
import json
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
from ..models.processing import SoloFeature
|
|
25
|
+
from .h5ad import _gene_axis, _stackable
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class QcError(RuntimeError):
|
|
29
|
+
"""A file STAR was supposed to write is missing or unreadable, so the bundle cannot be built."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _read(path: Path) -> str:
|
|
33
|
+
if not path.exists():
|
|
34
|
+
raise QcError(f"{path} is missing; the STAR run that should have written it did not")
|
|
35
|
+
return path.read_text()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _coerce(value: str) -> object:
|
|
39
|
+
"""A stat value as the narrowest type it cleanly is: ``int``, then ``float``, else the raw string.
|
|
40
|
+
|
|
41
|
+
STAR mixes integers (read counts), floats (rates), and strings (``95.5%``, timestamps) freely in
|
|
42
|
+
these files. Coercing the clean cases keeps the JSON queryable; leaving the rest as strings keeps
|
|
43
|
+
it lossless — no value is reshaped into a number it is not.
|
|
44
|
+
"""
|
|
45
|
+
try:
|
|
46
|
+
return int(value)
|
|
47
|
+
except ValueError:
|
|
48
|
+
pass
|
|
49
|
+
try:
|
|
50
|
+
return float(value)
|
|
51
|
+
except ValueError:
|
|
52
|
+
return value
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _parse_kv(text: str, sep: str) -> dict[str, object]:
|
|
56
|
+
"""``key<sep>value`` lines -> dict. Blank lines skipped; a line without ``sep`` is dropped."""
|
|
57
|
+
out: dict[str, object] = {}
|
|
58
|
+
for line in text.splitlines():
|
|
59
|
+
if not line.strip() or sep not in line:
|
|
60
|
+
continue
|
|
61
|
+
key, value = line.split(sep, 1)
|
|
62
|
+
out[key.strip()] = _coerce(value.strip())
|
|
63
|
+
return out
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _parse_whitespace_kv(text: str) -> dict[str, object]:
|
|
67
|
+
"""``name value`` (STAR's ``.stats`` files) -> dict, first token key, remainder value."""
|
|
68
|
+
out: dict[str, object] = {}
|
|
69
|
+
for line in text.splitlines():
|
|
70
|
+
parts = line.split()
|
|
71
|
+
if len(parts) < 2:
|
|
72
|
+
continue
|
|
73
|
+
out[parts[0]] = _coerce(parts[1]) if len(parts) == 2 else " ".join(parts[1:])
|
|
74
|
+
return out
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _parse_log_final(text: str) -> dict[str, object]:
|
|
78
|
+
"""``Log.final.out``: `` Label |\\tvalue`` lines, with section headers (no ``|``) skipped."""
|
|
79
|
+
return _parse_kv(text, "|")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _parse_umi_per_cell(text: str) -> list[int]:
|
|
83
|
+
"""``UMIperCellSorted.txt``: one integer per line, already descending. The knee-plot data."""
|
|
84
|
+
return [int(line) for line in text.splitlines() if line.strip()]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _parse_sj(text: str) -> list[list[str]]:
|
|
88
|
+
"""``SJ.out.tab``: tab-separated collapsed splice junctions, one list of columns per row."""
|
|
89
|
+
return [line.split("\t") for line in text.splitlines() if line.strip()]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _read_lines(path: Path) -> list[str]:
|
|
93
|
+
return [line for line in _read(path).splitlines() if line.strip()]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def build_qc_bundle(
|
|
97
|
+
solo_dir: Path,
|
|
98
|
+
run_dir: Path,
|
|
99
|
+
features: list[SoloFeature],
|
|
100
|
+
*,
|
|
101
|
+
sample: str,
|
|
102
|
+
assembly: str | None,
|
|
103
|
+
) -> dict[str, object]:
|
|
104
|
+
"""Every STAR stat/log for one sample, as one JSON-serialisable dict.
|
|
105
|
+
|
|
106
|
+
``solo_dir`` is the sample's ``Solo.out``; ``run_dir`` is the sample directory holding the
|
|
107
|
+
top-level logs. ``assembly`` is recorded for CRAM-reference provenance (the ``<sample>.cram`` in
|
|
108
|
+
the same directory pins the exact reference bytes by MD5, and this names which assembly that is).
|
|
109
|
+
"""
|
|
110
|
+
bundle: dict[str, object] = {
|
|
111
|
+
"sample": sample,
|
|
112
|
+
"assembly": assembly,
|
|
113
|
+
"soloFeatures": list(features),
|
|
114
|
+
"barcodes_stats": _parse_whitespace_kv(_read(solo_dir / "Barcodes.stats")),
|
|
115
|
+
"summary": {
|
|
116
|
+
feat: _parse_kv(_read(solo_dir / feat / "Summary.csv"), ",") for feat in features
|
|
117
|
+
},
|
|
118
|
+
"features_stats": {
|
|
119
|
+
feat: _parse_whitespace_kv(_read(solo_dir / feat / "Features.stats"))
|
|
120
|
+
for feat in features
|
|
121
|
+
},
|
|
122
|
+
"umi_per_cell": {
|
|
123
|
+
feat: _parse_umi_per_cell(_read(solo_dir / feat / "UMIperCellSorted.txt"))
|
|
124
|
+
for feat in _stackable(features)
|
|
125
|
+
},
|
|
126
|
+
# What STAR's default cell filter called -- kept because we drop the filtered matrix (the
|
|
127
|
+
# h5ad is built from raw/), and this tiny list is the only surviving record of that call.
|
|
128
|
+
"default_filtered_barcodes": {
|
|
129
|
+
feat: _read_lines(solo_dir / feat / "filtered" / "barcodes.tsv")
|
|
130
|
+
for feat in _gene_axis(features)
|
|
131
|
+
},
|
|
132
|
+
"log_final": _parse_log_final(_read(run_dir / "Log.final.out")),
|
|
133
|
+
"log_out": _read(run_dir / "Log.out"),
|
|
134
|
+
"log_progress": _read(run_dir / "Log.progress.out"),
|
|
135
|
+
"splice_junctions": _parse_sj(_read(run_dir / "SJ.out.tab")),
|
|
136
|
+
}
|
|
137
|
+
return bundle
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def write_qc_bundle(
|
|
141
|
+
solo_dir: Path,
|
|
142
|
+
run_dir: Path,
|
|
143
|
+
features: list[SoloFeature],
|
|
144
|
+
out: Path,
|
|
145
|
+
*,
|
|
146
|
+
sample: str,
|
|
147
|
+
assembly: str | None = None,
|
|
148
|
+
) -> Path:
|
|
149
|
+
"""Build the bundle and write it as gzipped JSON to ``out``. Returns ``out``."""
|
|
150
|
+
bundle = build_qc_bundle(solo_dir, run_dir, features, sample=sample, assembly=assembly)
|
|
151
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
152
|
+
with gzip.open(out, "wt", encoding="utf-8") as fh:
|
|
153
|
+
json.dump(bundle, fh)
|
|
154
|
+
return out
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
__all__ = ["QcError", "build_qc_bundle", "write_qc_bundle"]
|
seqforge/workspace.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Where seqforge keeps its state, and the one place that name is spelled.
|
|
2
|
+
|
|
3
|
+
``seqforge/``, not ``.seqforge/``. The leading dot said "this is plumbing, look away", and that was
|
|
4
|
+
exactly backwards: this directory holds the manifest, the resolve verdicts, the rendered documents a
|
|
5
|
+
citation greps into, and the compiled Snakefile the user submits. It is not cache — it is the
|
|
6
|
+
**output**, and "disk is state, context is cache" is a statement about which of the two matters.
|
|
7
|
+
A user who does not know it exists cannot read their own manifest, and hiding the artifacts of a
|
|
8
|
+
compiler whose whole product is artifacts is a strange thing to have done.
|
|
9
|
+
|
|
10
|
+
One constant, because the alternative is what this repo keeps finding: the literal was written out in
|
|
11
|
+
five modules, and five copies of a string is five chances for one of them to be stale.
|
|
12
|
+
|
|
13
|
+
.. warning::
|
|
14
|
+
|
|
15
|
+
A ``.gitignore`` entry for this must be **anchored** (``/seqforge/``). An unanchored ``seqforge/``
|
|
16
|
+
matches any directory of that name at any depth, which in this repo means ``src/seqforge/`` — git
|
|
17
|
+
would ignore our own source tree, and it would do it silently.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import re
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
#: The directory seqforge writes under a workspace. Visible on purpose; see the module docstring.
|
|
26
|
+
STATE_DIRNAME = "seqforge"
|
|
27
|
+
|
|
28
|
+
#: How much of a content hash to keep in a name a human reads. Twelve hex characters is 48 bits; at
|
|
29
|
+
#: the scale these address — the documents of one dataset, the recipes for one dataset — a collision
|
|
30
|
+
#: is not a thing that happens, and the other 52 characters are what made the directories unreadable.
|
|
31
|
+
SHORT_HASH = 12
|
|
32
|
+
|
|
33
|
+
#: The dot-prefixed name this replaced. Kept so :func:`legacy_state_dir` can find an old workspace
|
|
34
|
+
#: and say so, rather than silently starting a second one beside it.
|
|
35
|
+
LEGACY_STATE_DIRNAME = ".seqforge"
|
|
36
|
+
|
|
37
|
+
#: The four named subtrees, spelled once. `seqforge/` had grown into a flat pile — the manifest sat
|
|
38
|
+
#: beside the LLM cost ledger, the resolve cache, and the rendered documents, and nothing on disk
|
|
39
|
+
#: said which of those a reader was meant to open. So the top level now carries only the artifacts a
|
|
40
|
+
#: human reaches for (the manifest, the project views, `pipeline/`), and everything else sorts into
|
|
41
|
+
#: one of these:
|
|
42
|
+
#: records/ what the archive DECLARED, and the documents rendered from it (records/documents/)
|
|
43
|
+
#: logs/ run/debug output: the LLM cost ledger, the harvested assertions, stage summaries
|
|
44
|
+
#: cache/ content-addressed, resumable, safe to delete: observations, candidates, taxonomy
|
|
45
|
+
RECORDS_DIRNAME = "records"
|
|
46
|
+
DOCUMENTS_DIRNAME = "documents" #: under records/ — the bytes a span citation greps into
|
|
47
|
+
LOGS_DIRNAME = "logs"
|
|
48
|
+
CACHE_DIRNAME = "cache"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def state_dir(workspace: str | Path = ".", *parts: str) -> Path:
|
|
52
|
+
"""``<workspace>/seqforge/<parts...>``. Does not create anything — callers that write, mkdir."""
|
|
53
|
+
return Path(workspace).joinpath(STATE_DIRNAME, *parts)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def records_dir(workspace: str | Path = ".") -> Path:
|
|
57
|
+
"""``seqforge/records/`` — the archive's own declarations, one JSON per accession."""
|
|
58
|
+
return state_dir(workspace, RECORDS_DIRNAME)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def documents_dir(workspace: str | Path = ".") -> Path:
|
|
62
|
+
"""``seqforge/records/documents/`` — canonical text a span citation greps into.
|
|
63
|
+
|
|
64
|
+
Under ``records/`` on purpose: a record-derived document's bytes exist nowhere else because we
|
|
65
|
+
made them, so they belong with the records they were rendered from, not loose at the top level.
|
|
66
|
+
"""
|
|
67
|
+
return state_dir(workspace, RECORDS_DIRNAME, DOCUMENTS_DIRNAME)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def logs_dir(workspace: str | Path = ".") -> Path:
|
|
71
|
+
"""``seqforge/logs/`` — run/debug output (LLM cost ledger, assertions, stage summaries).
|
|
72
|
+
|
|
73
|
+
Nothing here is the deliverable; it is what a reader consults when a run surprises them.
|
|
74
|
+
"""
|
|
75
|
+
return state_dir(workspace, LOGS_DIRNAME)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def cache_dir(workspace: str | Path = ".") -> Path:
|
|
79
|
+
"""``seqforge/cache/`` — content-addressed, resumable, safe to delete and rebuild."""
|
|
80
|
+
return state_dir(workspace, CACHE_DIRNAME)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def readable(name: str, digest: str) -> str:
|
|
84
|
+
"""``("default", "a3f8...")`` -> ``default-a3f8c19d2b04``. A name a human can find, plus identity.
|
|
85
|
+
|
|
86
|
+
Both halves earn their place. The hash is the identity — two recipes over one dataset are two
|
|
87
|
+
runs, and a name alone cannot keep them apart. But a directory of bare 64-hex names is a
|
|
88
|
+
directory you cannot navigate, and that is what `pipeline/` and `normalized/` were: the pilot's
|
|
89
|
+
workspace had six documents and one pipeline in it, and nothing on disk said which was which.
|
|
90
|
+
|
|
91
|
+
No model is involved and none is needed. The recipe already has a name and the document already
|
|
92
|
+
has a filename; we simply stopped throwing them away.
|
|
93
|
+
"""
|
|
94
|
+
kept = "".join(c if (c.isalnum() or c in "-_.") else "-" for c in name).strip("-.")
|
|
95
|
+
stem = re.sub(r"-{2,}", "-", kept)[:60] or "run"
|
|
96
|
+
return f"{stem}-{digest[:SHORT_HASH]}"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def legacy_state_dir(workspace: str | Path = ".") -> Path | None:
|
|
100
|
+
"""An old ``.seqforge/`` in this workspace, if one is there.
|
|
101
|
+
|
|
102
|
+
Returned rather than migrated. Moving a user's artifacts without being asked is not this program's
|
|
103
|
+
business, and a rename that half-succeeds on a killed run would be worse than the two directories.
|
|
104
|
+
The CLI mentions it once and gets on with its life.
|
|
105
|
+
"""
|
|
106
|
+
old = Path(workspace) / LEGACY_STATE_DIRNAME
|
|
107
|
+
return old if old.is_dir() else None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
__all__ = [
|
|
111
|
+
"STATE_DIRNAME",
|
|
112
|
+
"LEGACY_STATE_DIRNAME",
|
|
113
|
+
"SHORT_HASH",
|
|
114
|
+
"RECORDS_DIRNAME",
|
|
115
|
+
"DOCUMENTS_DIRNAME",
|
|
116
|
+
"LOGS_DIRNAME",
|
|
117
|
+
"CACHE_DIRNAME",
|
|
118
|
+
"state_dir",
|
|
119
|
+
"records_dir",
|
|
120
|
+
"documents_dir",
|
|
121
|
+
"logs_dir",
|
|
122
|
+
"cache_dir",
|
|
123
|
+
"readable",
|
|
124
|
+
"legacy_state_dir",
|
|
125
|
+
]
|