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,447 @@
|
|
|
1
|
+
"""The **params gate** — the semantic assertions a dry-run cannot make (design §4.1, part 2).
|
|
2
|
+
|
|
3
|
+
``snakemake -n`` proves the *wiring*; it cannot see that ``--soloUMIlen`` is 10 when the reads carry a
|
|
4
|
+
12 bp UMI, or that ``--readFilesIn`` puts the barcode read where the cDNA read belongs. Those are the
|
|
5
|
+
bugs a config compiler actually produces, and they fail **silently**: STARsolo exits 0 and emits a
|
|
6
|
+
matrix that merely looks like a thin dataset. So they get deterministic assertions of their own, run
|
|
7
|
+
on every compose, with no data and no aligner.
|
|
8
|
+
|
|
9
|
+
Every emitted aligner param has exactly **one owner**, and this gate is where that stops being
|
|
10
|
+
a convention:
|
|
11
|
+
|
|
12
|
+
- the **KB** owns how to PARSE reads — soloType, CB/UMI offsets, whitelist, strand. Byte-decided.
|
|
13
|
+
- the **processing manifest** owns what to COUNT — soloFeatures, quantMode. Instructable.
|
|
14
|
+
|
|
15
|
+
Four checks:
|
|
16
|
+
|
|
17
|
+
1. **Disjointness** — the two owners' key sets never intersect. This is what makes "a user instruction
|
|
18
|
+
contradicts the observed bytes" *inexpressible* rather than merely deprioritized.
|
|
19
|
+
2. **Coverage / no orphan** — the emitted key set is EXACTLY the union of the two. Disjointness alone
|
|
20
|
+
is the decorative-``quantification`` bug in reverse: it proves the two sources cannot disagree, not
|
|
21
|
+
that either key actually *arrives*. Requiring the exact union means every emitted key is
|
|
22
|
+
attributable to one owner and every declared key is emitted — so a key that MOVES between owners is
|
|
23
|
+
caught by whichever side forgot it. Before this, the gate iterated the KB alone, and a key moved out
|
|
24
|
+
of the KB silently stopped being gated at all.
|
|
25
|
+
3. **Faithfulness, per key, per owner** — KB keys verbatim from the spec; processing keys verbatim from
|
|
26
|
+
the rendered manifest value. This is what stops ``processing.quantification`` being decorative:
|
|
27
|
+
policy used to write it to the manifest and compose ignored it, reading the KB instead — two sources
|
|
28
|
+
of truth for one decision, unable to disagree only because one was never consulted.
|
|
29
|
+
4. **Cross-derivation** — the KB's declared offsets/lengths agree with the *observed* read layout
|
|
30
|
+
(catches a KB whose params contradict the bytes: ``soloCBlen 16`` over a 12 bp CB).
|
|
31
|
+
|
|
32
|
+
Strand correctness itself is NOT decidable here — only the `kb e2e` count-matrix run can catch an
|
|
33
|
+
inverted ``--soloStrand``. This gate asserts the value survives compose intact; the e2e asserts it is
|
|
34
|
+
*right*.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
from collections.abc import Callable, Mapping
|
|
40
|
+
from typing import Literal
|
|
41
|
+
|
|
42
|
+
from ..kb.schema import KB_PARSE_KEYS, Element, Read, Spec
|
|
43
|
+
from ..models.dataset import DatasetManifest, ReadDef, ReadElement
|
|
44
|
+
from ..models.processing import ProcessingManifest, Quantification, SoloQuant
|
|
45
|
+
from ..workflows import get_module
|
|
46
|
+
|
|
47
|
+
GateStatus = Literal["pass", "fail"]
|
|
48
|
+
ParamOwner = Literal["kb", "processing", "derived"]
|
|
49
|
+
|
|
50
|
+
RECIPE_PARAM_KEYS: frozenset[str] = frozenset({"soloFeatures", "quantMode"})
|
|
51
|
+
"""Every backend param sourced from the processing manifest. Each says what to **COUNT**."""
|
|
52
|
+
|
|
53
|
+
DERIVED_PARAM_KEYS: frozenset[str] = frozenset(
|
|
54
|
+
{"soloCBposition", "soloUMIposition", "soloAdapterSequence"}
|
|
55
|
+
)
|
|
56
|
+
"""Params computed from the element model rather than declared by anyone.
|
|
57
|
+
|
|
58
|
+
Still parse keys — byte-decided, never instructable — but the bytes already answered them in
|
|
59
|
+
the spec's element coordinates, so a KB that *also* declared the quadruple would carry the same fact
|
|
60
|
+
twice and let the two drift. A third owner, because "one fact, one owner" is the whole point of
|
|
61
|
+
:func:`param_owners`; folding these into ``kb`` would make the gate certify a value the KB never
|
|
62
|
+
stated.
|
|
63
|
+
|
|
64
|
+
``soloAdapterSequence`` joined this set for BD Rhapsody Enhanced (#43): an anchored chemistry's
|
|
65
|
+
diversity insert is absorbed by STARsolo's adapter anchor, and the adapter (``NNN…GTGANNN…GACA``) is
|
|
66
|
+
just the barcode widths and linker literals read off the elements — one more fact the coordinates
|
|
67
|
+
already state. It was in ``KB_PARSE_KEYS`` (declarable) but nothing emitted it; now it is derived, and
|
|
68
|
+
the ``soloCBposition``/``soloUMIposition`` quadruples become adapter-anchored (anchor 2/3) rather than
|
|
69
|
+
read-start-anchored (anchor 0) for such a chemistry.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def derived_params(spec: Spec) -> dict[str, str]:
|
|
74
|
+
"""Locate a ``CB_UMI_Complex`` chemistry's barcodes/UMI from its elements, as STAR wants them.
|
|
75
|
+
|
|
76
|
+
STARsolo's complex chemistries take position quadruples
|
|
77
|
+
(``startAnchor_startPos_endAnchor_endPos``; positions 0-based INCLUSIVE) rather than the
|
|
78
|
+
start/length pair a simple chemistry uses. The splitseq spec says outright why this is computed
|
|
79
|
+
and not written down: *"never hand-enter a position quadruple from memory — generate it from the
|
|
80
|
+
element model"*. A published quadruple is also chemistry-specific in a way that invites exactly
|
|
81
|
+
that error — v1's Round1 sits at 86-93 and Parse/v2's at 78-85, so a remembered value is a coin
|
|
82
|
+
flip between two real chemistries.
|
|
83
|
+
|
|
84
|
+
Two geometries, one function. A **fixed-offset** chemistry anchors every element to the read start
|
|
85
|
+
(anchor 0): ``0_<start>_0_<end>``. An **anchored** chemistry (BD Rhapsody Enhanced's floating
|
|
86
|
+
diversity insert) cannot — no offset is constant — so it anchors to the ``GTGA…GACA`` adapter
|
|
87
|
+
instead (anchor 2 = adapter start, anchor 3 = adapter end), and also derives the
|
|
88
|
+
``soloAdapterSequence`` STARsolo locates that adapter by. Both are read off the same element model;
|
|
89
|
+
which one applies is decided by whether the barcode read carries an ``anchor``.
|
|
90
|
+
|
|
91
|
+
Order is load-bearing: STARsolo pairs the Nth ``soloCBwhitelist`` with the Nth
|
|
92
|
+
``soloCBposition``, so the quadruples are emitted in the whitelist's declared order, never the
|
|
93
|
+
elements' positional order.
|
|
94
|
+
"""
|
|
95
|
+
backend = spec.require_backend()
|
|
96
|
+
if backend.params.get("soloType") != "CB_UMI_Complex":
|
|
97
|
+
return {}
|
|
98
|
+
|
|
99
|
+
by_onlist: dict[str, Element] = {}
|
|
100
|
+
umi: Element | None = None
|
|
101
|
+
bc_read = None
|
|
102
|
+
for read in spec.reads:
|
|
103
|
+
for el in read.elements:
|
|
104
|
+
if el.type == "barcode" and el.onlist:
|
|
105
|
+
by_onlist[el.onlist] = el
|
|
106
|
+
bc_read = read
|
|
107
|
+
elif el.type == "umi":
|
|
108
|
+
umi = el
|
|
109
|
+
|
|
110
|
+
aliases = _whitelist_aliases(backend.params.get("soloCBwhitelist"))
|
|
111
|
+
out: dict[str, str] = {}
|
|
112
|
+
|
|
113
|
+
anchored = bc_read is not None and any(el.anchor is not None for el in bc_read.elements)
|
|
114
|
+
if anchored:
|
|
115
|
+
frame = _adapter_frame(bc_read) # type: ignore[arg-type]
|
|
116
|
+
if frame is None:
|
|
117
|
+
return {} # no linker anchor to hang the adapter on — nothing safe to derive
|
|
118
|
+
adapter_seq, quad = frame
|
|
119
|
+
out["soloAdapterSequence"] = adapter_seq
|
|
120
|
+
else:
|
|
121
|
+
quad = _quadruple
|
|
122
|
+
|
|
123
|
+
positions = [q for a in aliases if (q := quad(by_onlist.get(a))) is not None]
|
|
124
|
+
if positions:
|
|
125
|
+
out["soloCBposition"] = " ".join(positions)
|
|
126
|
+
umi_pos = quad(umi)
|
|
127
|
+
if umi_pos is not None:
|
|
128
|
+
out["soloUMIposition"] = umi_pos
|
|
129
|
+
return out
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _whitelist_aliases(whitelist: object) -> list[str]:
|
|
133
|
+
"""The ``{onlist:alias}`` tokens of ``soloCBwhitelist``, in declared (CB-position) order."""
|
|
134
|
+
values = whitelist if isinstance(whitelist, list) else [whitelist]
|
|
135
|
+
return [
|
|
136
|
+
v[len("{onlist:") : -1] for v in values if isinstance(v, str) and v.startswith("{onlist:")
|
|
137
|
+
]
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _quadruple(el: Element | None) -> str | None:
|
|
141
|
+
"""One FIXED-offset element -> ``0_<start>_0_<end>``: anchored at the read start, ends inclusive.
|
|
142
|
+
|
|
143
|
+
The element model is half-open ``[start, end)`` (Python's convention); STAR's quadruple is
|
|
144
|
+
closed. That off-by-one is the whole reason this is a function with a name.
|
|
145
|
+
|
|
146
|
+
``None`` when the element is absent or open-ended: a quadruple needs both coordinates, and an
|
|
147
|
+
element without them (cDNA runs to the end of the read, OR an anchored element that floats) has no
|
|
148
|
+
fixed position to state. Returning ``None`` keeps the key out of the config entirely rather than
|
|
149
|
+
emitting ``0_0_0_-1``, which STAR would accept as a real and wrong instruction.
|
|
150
|
+
"""
|
|
151
|
+
if el is None or el.start is None or el.end is None:
|
|
152
|
+
return None
|
|
153
|
+
return f"0_{el.start}_0_{el.end - 1}"
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _nominal_width(el: Element) -> int | None:
|
|
157
|
+
"""An element's constant width (diversity insert at its MINIMUM), or ``None`` if open-ended.
|
|
158
|
+
|
|
159
|
+
The adapter-anchored quadruples are invariant to the diversity insert's per-read length (every
|
|
160
|
+
element shifts together), so they are computed in NOMINAL coordinates — the layout with the insert
|
|
161
|
+
at its minimum. That is what makes ``2_0_2_8`` a single derivable fact rather than a per-read one.
|
|
162
|
+
"""
|
|
163
|
+
if el.start is not None and el.end is not None:
|
|
164
|
+
return el.end - el.start
|
|
165
|
+
if el.sequence is not None:
|
|
166
|
+
return len(el.sequence)
|
|
167
|
+
if el.min_len is not None:
|
|
168
|
+
return el.min_len
|
|
169
|
+
return None
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _adapter_frame(read: Read) -> tuple[str, Callable[[Element | None], str | None]] | None:
|
|
173
|
+
"""Build STARsolo's adapter sequence + an adapter-anchored quadruple maker for a floating chemistry.
|
|
174
|
+
|
|
175
|
+
The adapter spans from the first barcode/UMI/linker element through the LAST linker (BD Enhanced:
|
|
176
|
+
``CLS1 GTGA CLS2 GACA``), rendered as ``N``×width for a barcode/UMI and the literal for a linker ->
|
|
177
|
+
``NNNNNNNNNGTGANNNNNNNNNGACA``. STARsolo finds that in each read, absorbing the leading diversity
|
|
178
|
+
insert. Elements up to the last linker are then anchored to the adapter START (anchor 2); elements
|
|
179
|
+
after it (CLS3, UMI) to the adapter END (anchor 3, where position 0 is the adapter's last base).
|
|
180
|
+
``None`` when there is no linker to anchor on. All coordinates are NOMINAL (:func:`_nominal_width`).
|
|
181
|
+
"""
|
|
182
|
+
order = list(read.elements)
|
|
183
|
+
nominal: dict[str, tuple[int, int]] = {}
|
|
184
|
+
pos = 0
|
|
185
|
+
for el in order:
|
|
186
|
+
w = _nominal_width(el)
|
|
187
|
+
if w is None:
|
|
188
|
+
return None # an open-ended element in the barcode read: not an adapter chemistry
|
|
189
|
+
nominal[el.name] = (pos, pos + w)
|
|
190
|
+
pos += w
|
|
191
|
+
|
|
192
|
+
linker_idxs = [
|
|
193
|
+
i for i, el in enumerate(order) if el.type in ("linker", "fixed") and el.sequence
|
|
194
|
+
]
|
|
195
|
+
adapter_idxs = [
|
|
196
|
+
i for i, el in enumerate(order) if el.type in ("barcode", "umi", "linker", "fixed")
|
|
197
|
+
]
|
|
198
|
+
if not linker_idxs or not adapter_idxs:
|
|
199
|
+
return None
|
|
200
|
+
start_idx, last_linker_idx = adapter_idxs[0], linker_idxs[-1]
|
|
201
|
+
|
|
202
|
+
adapter_seq = "".join(
|
|
203
|
+
el.sequence
|
|
204
|
+
if (el.type in ("linker", "fixed") and el.sequence)
|
|
205
|
+
else "N" * (nominal[el.name][1] - nominal[el.name][0])
|
|
206
|
+
for el in order[start_idx : last_linker_idx + 1]
|
|
207
|
+
)
|
|
208
|
+
adapter_start = nominal[order[start_idx].name][0]
|
|
209
|
+
adapter_end = nominal[order[last_linker_idx].name][1] # one-past the adapter's last base
|
|
210
|
+
|
|
211
|
+
def quad(el: Element | None) -> str | None:
|
|
212
|
+
if el is None or el.name not in nominal:
|
|
213
|
+
return None
|
|
214
|
+
s, e = nominal[el.name]
|
|
215
|
+
width = e - s
|
|
216
|
+
if width <= 0:
|
|
217
|
+
return None
|
|
218
|
+
if s >= adapter_end: # after the adapter -> anchor 3 (position 0 == adapter's last base)
|
|
219
|
+
rel = s - (adapter_end - 1)
|
|
220
|
+
return f"3_{rel}_3_{rel + width - 1}"
|
|
221
|
+
rel = s - adapter_start # within the adapter -> anchor 2 (relative to adapter start)
|
|
222
|
+
return f"2_{rel}_2_{rel + width - 1}"
|
|
223
|
+
|
|
224
|
+
return adapter_seq, quad
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def processing_params(quant: Quantification) -> dict[str, object]:
|
|
228
|
+
"""Render a counting decision into the aligner params it stands for.
|
|
229
|
+
|
|
230
|
+
Module-scoped by construction: ``soloFeatures`` is meaningless to plain STAR and ``quantMode`` is
|
|
231
|
+
meaningless to STARsolo, so the discriminated union is what keeps a processing manifest from being
|
|
232
|
+
a type error the moment it meets the other module.
|
|
233
|
+
"""
|
|
234
|
+
if isinstance(quant, SoloQuant):
|
|
235
|
+
# space-joined, exactly as the KB's list rendering did — STAR takes repeated argv values
|
|
236
|
+
return {"soloFeatures": " ".join(quant.features)}
|
|
237
|
+
return {"quantMode": quant.mode}
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def param_owners(spec: Spec, processing: ProcessingManifest) -> dict[str, ParamOwner]:
|
|
241
|
+
"""Every emittable aligner param key -> the artifact entitled to set it.
|
|
242
|
+
|
|
243
|
+
The parse/count line as a **computed fact**, directly unit-testable, rather than a comment nobody
|
|
244
|
+
re-reads. A key with two owners, or with none, is a bug this function surfaces and the gate fails
|
|
245
|
+
on.
|
|
246
|
+
"""
|
|
247
|
+
owners: dict[str, ParamOwner] = dict.fromkeys(spec.require_backend().params, "kb")
|
|
248
|
+
for key in derived_params(spec):
|
|
249
|
+
owners[key] = "derived"
|
|
250
|
+
for key in processing_params(processing.processing.quantification.value):
|
|
251
|
+
owners[key] = "processing"
|
|
252
|
+
return owners
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def param_block_key(spec: Spec) -> str:
|
|
256
|
+
"""Which config block carries this spec's aligner params: ``solo`` xor ``bulk``.
|
|
257
|
+
|
|
258
|
+
Keyed by the MODULE, which is the only thing that decides it. The gate used to instead take
|
|
259
|
+
"whichever of the two happens to be a dict", so a bulk config carrying a stray ``solo`` block was
|
|
260
|
+
reported as *"config drops KB param 'quantMode'"* — a real failure diagnosed as an unrelated one,
|
|
261
|
+
which is worse than no gate: it sends you to the wrong file. One definition, consulted by both the
|
|
262
|
+
composer that writes the block and the gate that checks it.
|
|
263
|
+
|
|
264
|
+
And the module reads it off its own source. This function used to be
|
|
265
|
+
``"solo" if spec.backend.module == "map/starsolo" else "bulk"`` — the last string compare against
|
|
266
|
+
a module name in the tree, and the same shape as the `_read_files_in` bug that preceded it: every
|
|
267
|
+
module that is not starsolo silently means bulk. See :attr:`WorkflowModule.param_block`.
|
|
268
|
+
"""
|
|
269
|
+
return str(get_module(spec.require_backend().module).param_block)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def render_param(value: object) -> str:
|
|
273
|
+
"""Render a KB backend param the way a CLI takes it (a list becomes space-separated)."""
|
|
274
|
+
if isinstance(value, list):
|
|
275
|
+
return " ".join(str(v) for v in value)
|
|
276
|
+
return str(value)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _as_int(value: object) -> int | None:
|
|
280
|
+
"""KB params arrive as int or str depending on the YAML; compare them numerically."""
|
|
281
|
+
try:
|
|
282
|
+
return int(str(value))
|
|
283
|
+
except (TypeError, ValueError):
|
|
284
|
+
return None
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _element(read: ReadDef, role: str) -> ReadElement | None:
|
|
288
|
+
for el in read.elements:
|
|
289
|
+
if el.role == role:
|
|
290
|
+
return el
|
|
291
|
+
return None
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def find_read_with_role(manifest: DatasetManifest, role: str) -> ReadDef | None:
|
|
295
|
+
"""The layout read carrying an element of ``role`` (e.g. the cDNA read, the CB-bearing read)."""
|
|
296
|
+
for read in manifest.library.read_layout.reads:
|
|
297
|
+
if any(el.role == role for el in read.elements):
|
|
298
|
+
return read
|
|
299
|
+
return None
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def params_gate(
|
|
303
|
+
manifest: DatasetManifest,
|
|
304
|
+
processing: ProcessingManifest,
|
|
305
|
+
spec: Spec,
|
|
306
|
+
config: dict[str, object],
|
|
307
|
+
) -> tuple[GateStatus, list[str]]:
|
|
308
|
+
"""Assert every emitted param is owned, arrives verbatim, and agrees with the observed layout."""
|
|
309
|
+
problems: list[str] = []
|
|
310
|
+
backend = spec.require_backend()
|
|
311
|
+
params = backend.params
|
|
312
|
+
from_processing = processing_params(processing.processing.quantification.value)
|
|
313
|
+
from_derived = derived_params(spec)
|
|
314
|
+
|
|
315
|
+
# ---- 1. disjointness: one key, one owner ----
|
|
316
|
+
both = sorted(set(params) & RECIPE_PARAM_KEYS)
|
|
317
|
+
if both:
|
|
318
|
+
problems.append(
|
|
319
|
+
f"KB declares count key(s) {both}, which the processing manifest owns: backend.params "
|
|
320
|
+
f"says how to PARSE reads, not what to COUNT"
|
|
321
|
+
)
|
|
322
|
+
stray = sorted(set(params) - KB_PARSE_KEYS)
|
|
323
|
+
if stray:
|
|
324
|
+
problems.append(f"KB declares non-parse key(s) {stray}")
|
|
325
|
+
redeclared = sorted(set(params) & DERIVED_PARAM_KEYS)
|
|
326
|
+
if redeclared:
|
|
327
|
+
problems.append(
|
|
328
|
+
f"KB declares derived key(s) {redeclared}: these are computed from the element "
|
|
329
|
+
f"coordinates, which already state them. Declaring them here is the same fact twice, "
|
|
330
|
+
f"and the two copies can drift"
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
block = param_block_key(spec)
|
|
334
|
+
found = config.get(block)
|
|
335
|
+
if not isinstance(found, dict):
|
|
336
|
+
# ONE root cause, not N derivative ones. Enumerating every key as "dropped" on top of this
|
|
337
|
+
# buries the actual fault under a list that points at the KB, which is the one file that is
|
|
338
|
+
# fine. A gate is read by someone who does not yet know what is wrong.
|
|
339
|
+
problems.append(f"config has no {block!r} param block (module is {backend.module!r})")
|
|
340
|
+
else:
|
|
341
|
+
emitted: dict[str, object] = found
|
|
342
|
+
# ---- 2. coverage: the emitted key set is EXACTLY the union of the three owners ----
|
|
343
|
+
expected_keys = set(params) | set(from_processing) | set(from_derived)
|
|
344
|
+
orphans = sorted(set(emitted) - expected_keys)
|
|
345
|
+
if orphans:
|
|
346
|
+
problems.append(f"config emits param(s) {orphans} that no owner declares")
|
|
347
|
+
missing = sorted(expected_keys - set(emitted))
|
|
348
|
+
if missing:
|
|
349
|
+
problems.append(f"config drops declared param(s) {missing}")
|
|
350
|
+
|
|
351
|
+
# ---- 3. faithfulness, per key, per owner ----
|
|
352
|
+
for key, expected in params.items():
|
|
353
|
+
if key == "soloCBwhitelist":
|
|
354
|
+
continue # an {onlist:...} token is resolved to a path; checked separately below
|
|
355
|
+
want = render_param(expected)
|
|
356
|
+
got = emitted.get(key)
|
|
357
|
+
if got is not None and str(got) != want:
|
|
358
|
+
problems.append(f"config {key}={got!r} does not match KB {key}={want!r}")
|
|
359
|
+
for key, expected_p in from_processing.items():
|
|
360
|
+
want = render_param(expected_p)
|
|
361
|
+
got = emitted.get(key)
|
|
362
|
+
if got is not None and str(got) != want:
|
|
363
|
+
problems.append(
|
|
364
|
+
f"config {key}={got!r} does not match the processing manifest's "
|
|
365
|
+
f"{key}={want!r} — quantification must not be decorative"
|
|
366
|
+
)
|
|
367
|
+
for key, expected_d in from_derived.items():
|
|
368
|
+
got = emitted.get(key)
|
|
369
|
+
if got is not None and str(got) != expected_d:
|
|
370
|
+
problems.append(
|
|
371
|
+
f"config {key}={got!r} does not match {key}={expected_d!r} derived from the "
|
|
372
|
+
f"element coordinates — the spec's elements are the only source for this"
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
# ---- 4. cross-derivation: KB offsets/lengths must agree with the OBSERVED read layout ----
|
|
376
|
+
if params.get("soloType") == "CB_UMI_Simple":
|
|
377
|
+
bc_read = find_read_with_role(manifest, "CB")
|
|
378
|
+
if bc_read is None:
|
|
379
|
+
problems.append("layout has no CB-bearing read, but soloType is CB_UMI_Simple")
|
|
380
|
+
else:
|
|
381
|
+
problems += _check_simple_geometry(bc_read, params)
|
|
382
|
+
|
|
383
|
+
# ---- 5. readFilesIn order: the cDNA read must precede the barcode read ----
|
|
384
|
+
problems += _check_read_files_in(manifest, config, params)
|
|
385
|
+
|
|
386
|
+
return ("fail" if problems else "pass"), problems
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _check_simple_geometry(bc_read: ReadDef, params: Mapping[str, object]) -> list[str]:
|
|
390
|
+
problems: list[str] = []
|
|
391
|
+
cb = _element(bc_read, "CB")
|
|
392
|
+
umi = _element(bc_read, "UMI")
|
|
393
|
+
# lengths: the KB's declared width must equal the width actually present in the reads
|
|
394
|
+
lengths = [
|
|
395
|
+
("soloCBlen", cb.length if cb else None, "CB length"),
|
|
396
|
+
("soloUMIlen", umi.length if umi else None, "UMI length"),
|
|
397
|
+
]
|
|
398
|
+
for key, observed, label in lengths:
|
|
399
|
+
want = _as_int(params.get(key))
|
|
400
|
+
if want is not None and observed is not None and want != observed:
|
|
401
|
+
problems.append(f"KB {key}={want} contradicts the observed {label} of {observed} bp")
|
|
402
|
+
# starts: STARsolo offsets are 1-based; the element model is 0-based half-open.
|
|
403
|
+
starts = [
|
|
404
|
+
("soloCBstart", cb.start if cb else None, "CB"),
|
|
405
|
+
("soloUMIstart", umi.start if umi else None, "UMI"),
|
|
406
|
+
]
|
|
407
|
+
for key, start0, label in starts:
|
|
408
|
+
want = _as_int(params.get(key))
|
|
409
|
+
if want is not None and start0 is not None and want != start0 + 1:
|
|
410
|
+
problems.append(
|
|
411
|
+
f"KB {key}={want} (1-based) contradicts the observed {label} start "
|
|
412
|
+
f"{start0} (0-based) -> expected {start0 + 1}"
|
|
413
|
+
)
|
|
414
|
+
return problems
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def _check_read_files_in(
|
|
418
|
+
manifest: DatasetManifest, config: Mapping[str, object], params: Mapping[str, object]
|
|
419
|
+
) -> list[str]:
|
|
420
|
+
problems: list[str] = []
|
|
421
|
+
rfi = config.get("read_files_in")
|
|
422
|
+
if not isinstance(rfi, dict):
|
|
423
|
+
return ["config has no read_files_in mapping"]
|
|
424
|
+
cdna_read = find_read_with_role(manifest, "cDNA") or find_read_with_role(manifest, "gDNA")
|
|
425
|
+
if params.get("soloType") in ("CB_UMI_Simple", "CB_UMI_Complex"):
|
|
426
|
+
bc_read = find_read_with_role(manifest, "CB")
|
|
427
|
+
if cdna_read is None or bc_read is None:
|
|
428
|
+
problems.append("a barcoded chemistry needs both a cDNA read and a CB-bearing read")
|
|
429
|
+
return problems
|
|
430
|
+
if rfi.get("cdna") != cdna_read.read_id:
|
|
431
|
+
problems.append(
|
|
432
|
+
f"read_files_in.cdna={rfi.get('cdna')!r} is not the cDNA read {cdna_read.read_id!r}"
|
|
433
|
+
)
|
|
434
|
+
if rfi.get("barcode") != bc_read.read_id:
|
|
435
|
+
problems.append(
|
|
436
|
+
f"read_files_in.barcode={rfi.get('barcode')!r} is not the CB read {bc_read.read_id!r}"
|
|
437
|
+
)
|
|
438
|
+
if rfi.get("cdna") == rfi.get("barcode"):
|
|
439
|
+
problems.append("read_files_in maps the cDNA and barcode roles to the same read")
|
|
440
|
+
else: # bulk: two biological mates, no barcode role
|
|
441
|
+
mates = [rfi.get("mate1"), rfi.get("mate2")]
|
|
442
|
+
roles = [r.read_id for r in manifest.library.read_layout.reads]
|
|
443
|
+
if any(m not in roles for m in mates):
|
|
444
|
+
problems.append(f"read_files_in mates {mates} are not layout reads {roles}")
|
|
445
|
+
if mates[0] == mates[1]:
|
|
446
|
+
problems.append("read_files_in maps both mates to the same read")
|
|
447
|
+
return problems
|