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,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