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,646 @@
1
+ """``resolve score`` orchestration: probe -> per-tech evidence -> escalation -> ResolveResult.
2
+
3
+ Deterministic and LLM-free. The optional ``hypothesis`` (a span-verified metadata assertion) is a
4
+ control-flow input only — it selects/orders and can break a genuinely-non-decisive tie, but never
5
+ enters the evidence matrix. Every stage is content-addressed under ``.seqforge/``: the per-file
6
+ Observation and the dataset ResolveResult are cached, so a killed run resumes.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from collections.abc import Iterable, Sequence
13
+ from dataclasses import dataclass, field
14
+ from pathlib import Path
15
+
16
+ from ..io import DEFAULT_REGISTRY, OnlistNotAvailable, OnlistRegistry
17
+ from ..kb import KB_VERSION, load_all_specs
18
+ from ..kb.schema import Spec
19
+ from ..models.blocker import Blocker, BlockerCode, BlockerSubject
20
+ from ..models.dataset import INDEX_ROLE
21
+ from ..models.observation import Observation
22
+ from ..models.resolve import Candidate, ResolveResult
23
+ from ..probe import DEFAULT_MAX_BYTES, DEFAULT_MAX_READS, PROBE_VERSION, probe_sample
24
+ from . import RESOLVE_VERSION
25
+ from .cache import Cache, dataset_id, resume_key
26
+ from .escalate import escalate
27
+ from .geometry import length_feasible
28
+ from .scoring import TechEvaluation, build_tech_evaluation
29
+ from .window import WindowProbe
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class Hypothesis:
34
+ """A span-verified metadata assertion entering ``score`` as a selector / tie-break (§3.4)."""
35
+
36
+ value: str
37
+ id: str = "hypothesis"
38
+ confidence: float = 0.8
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class ResolveOutput:
43
+ """The engine's return: the wire :class:`ResolveResult`, the evidence matrices, and the probes.
44
+
45
+ ``observations`` is carried so a downstream ``manifest fill`` can assemble the file inventory
46
+ without re-probing the bytes (the sample is already within the budget; paying for it twice is
47
+ the bug this avoids).
48
+ """
49
+
50
+ result: ResolveResult
51
+ matrices: dict[str, dict[str, dict[str, dict[str, object]]]]
52
+ observations: list[Observation] = field(default_factory=list)
53
+
54
+ def exit_code(self) -> int:
55
+ return exit_code_for(self.result)
56
+
57
+
58
+ def exit_code_for(result: ResolveResult) -> int:
59
+ """Uniform exit contract: 3 BLOCKED (>=1 Blocker), 4 NEEDS_HUMAN (open Conflict/question), else 0."""
60
+ if result.blockers:
61
+ return 3
62
+ if result.questions or any(c.status == "open" for c in result.conflicts):
63
+ return 4
64
+ return 0
65
+
66
+
67
+ def _probe_paths(
68
+ paths: Sequence[str | Path], *, max_reads: int, max_bytes: int, cpus: int
69
+ ) -> dict[str, tuple[Observation, list[str]]]:
70
+ """Probe every file, across up to ``cpus`` processes, keyed by ``str(path)``.
71
+
72
+ Each FASTQ is an independent, CPU-bound pure-Python fingerprint whose hot loop holds the GIL, so
73
+ files parallelize across PROCESSES — threads would just serialize. The result is byte-identical to
74
+ a sequential probe: ``probe_sample`` is deterministic over a head-bounded sample, order does not
75
+ matter (the map is keyed by path and the manifest is assembled by content hash), and **core count
76
+ is folded into no hash** — cores are not a budget any more than wall-clock is. One shared pool
77
+ for the whole dataset is why a 12-file / 6-run study saturates the cores at once, rather than two
78
+ files at a time inside each run.
79
+ """
80
+ keyed = list(dict.fromkeys(str(p) for p in paths)) # de-dup, order-preserving
81
+ if cpus <= 1 or len(keyed) <= 1:
82
+ return {p: probe_sample(p, max_reads=max_reads, max_bytes=max_bytes) for p in keyed}
83
+
84
+ import multiprocessing as mp
85
+ from concurrent.futures import ProcessPoolExecutor
86
+
87
+ # Use a `fork` context where the OS has one (every POSIX box we run on). The probe stage is
88
+ # single-threaded, so fork is safe here, and it sidesteps `spawn`'s footgun of re-importing the
89
+ # caller's `__main__` — which is what makes a `--cpus 4` run explode under pytest or a bare script.
90
+ ctx = mp.get_context("fork") if "fork" in mp.get_all_start_methods() else None
91
+
92
+ out: dict[str, tuple[Observation, list[str]]] = {}
93
+ with ProcessPoolExecutor(max_workers=min(cpus, len(keyed)), mp_context=ctx) as pool:
94
+ futures = {
95
+ pool.submit(probe_sample, p, max_reads=max_reads, max_bytes=max_bytes): p for p in keyed
96
+ }
97
+ for fut in futures:
98
+ out[futures[fut]] = fut.result()
99
+ return out
100
+
101
+
102
+ def _score_pool(
103
+ pool: list[Spec], wps: list[WindowProbe], registry: OnlistRegistry, score_threads: int
104
+ ) -> list[TechEvaluation]:
105
+ """Score every candidate spec — optionally across a thread pool sharing the read-only registry.
106
+
107
+ The dominant cost is the onlist scan (``np.searchsorted`` over the packed whitelist), which
108
+ releases the GIL, so threads parallelize it while sharing the one ~27 MB array with zero copies.
109
+ The registry's lazy per-name materialization is NOT thread-safe, so every available onlist is
110
+ pre-warmed single-threaded first; afterwards ``packed()`` is a read-only dict lookup (an
111
+ unavailable onlist stays uncached and simply ABSTAINs, exactly as the serial path does).
112
+ ``ThreadPoolExecutor.map`` preserves order, so the result is byte-identical to the serial list
113
+ whatever the thread count — core count folds into no decision.
114
+ """
115
+ if score_threads <= 1 or len(pool) <= 1:
116
+ return [build_tech_evaluation(spec, wps, registry) for spec in pool]
117
+ for spec in pool:
118
+ for ref in spec.onlists.values():
119
+ if registry.has(ref.registry):
120
+ try:
121
+ registry.packed(ref.registry)
122
+ except OnlistNotAvailable:
123
+ pass # scoring ABSTAINs on this onlist; nothing cached, so no thread races on it
124
+ from concurrent.futures import ThreadPoolExecutor
125
+
126
+ with ThreadPoolExecutor(max_workers=min(score_threads, len(pool))) as ex:
127
+ return list(ex.map(lambda spec: build_tech_evaluation(spec, wps, registry), pool))
128
+
129
+
130
+ def resolve_dataset(
131
+ # Sequence, not list: the engine only iterates. `list` is invariant, so a caller holding a
132
+ # perfectly good list[Path] could not pass it without a copy — an API defect, not a caller bug.
133
+ paths: Sequence[str | Path],
134
+ *,
135
+ registry: OnlistRegistry | None = None,
136
+ specs: dict[str, Spec] | None = None,
137
+ hypothesis: Hypothesis | None = None,
138
+ workspace: str | Path = ".",
139
+ max_reads: int = DEFAULT_MAX_READS,
140
+ max_bytes: int = DEFAULT_MAX_BYTES,
141
+ use_cache: bool = True,
142
+ cpus: int = 1,
143
+ score_threads: int = 1,
144
+ _probed: dict[str, tuple[Observation, list[str]]] | None = None,
145
+ ) -> ResolveOutput:
146
+ """Score a dataset's FASTQ files against the KB and return the ranked, escalated verdict.
147
+
148
+ ``cpus`` bounds a per-file probe pool; ``_probed`` lets a caller (``resolve_runs``) hand in a probe
149
+ map it already computed across the whole dataset, so the files are not probed twice.
150
+ ``score_threads`` bounds a per-spec scoring thread pool (the onlist scan releases the GIL): a
151
+ standalone call reuses ``cpus`` for it, while ``resolve_runs`` hands an explicit value to
152
+ coordinate with its per-run fork and stay within the core budget.
153
+ """
154
+ registry = registry if registry is not None else DEFAULT_REGISTRY
155
+ kb_specs = specs if specs is not None else load_all_specs()
156
+ cache = Cache(workspace)
157
+
158
+ probed = (
159
+ _probed
160
+ if _probed is not None
161
+ else _probe_paths(paths, max_reads=max_reads, max_bytes=max_bytes, cpus=cpus)
162
+ )
163
+ observations: list[Observation] = []
164
+ wps: list[WindowProbe] = []
165
+ for path in paths:
166
+ obs, seqs = probed[str(path)]
167
+ if use_cache:
168
+ cache.write_observation(obs)
169
+ observations.append(obs)
170
+ wps.append(WindowProbe(observation=obs, seqs=seqs))
171
+
172
+ ds_id = dataset_id(
173
+ [o.file.sha256 for o in observations], KB_VERSION, PROBE_VERSION, RESOLVE_VERSION
174
+ )
175
+
176
+ # Descent narrows the scored pool WITHOUT changing the winner: (1) an ABSTRACT family node
177
+ # classifies but has no runnable backend, so it is never a candidate and is excluded; (2)
178
+ # `length_feasible` is the scorer's own length gate, so any spec it drops would have scored
179
+ # `forbidden` anyway (a proven necessary condition). The trailing `or runnable` is the mandatory
180
+ # fallback — narrowing may never leave the pool empty. `escalate` still receives the FULL `kb_specs`
181
+ # so id/confusable lookups resolve for unscored nodes.
182
+ runnable = [spec for spec in kb_specs.values() if spec.backend is not None]
183
+ pool = [spec for spec in runnable if length_feasible(spec, wps)] or runnable
184
+ # A standalone call runs probe then score sequentially, so the per-spec pool may reuse the full
185
+ # `cpus` budget; resolve_runs hands `_probed` + an explicit `score_threads` to stay bounded.
186
+ if _probed is None:
187
+ score_threads = max(score_threads, cpus)
188
+ evaluations = _score_pool(pool, wps, registry, score_threads)
189
+ hv = hypothesis.value if hypothesis else None
190
+ hid = hypothesis.id if hypothesis else None
191
+ hconf = hypothesis.confidence if hypothesis else 0.0
192
+ esc = escalate(evaluations, observations, kb_specs, hv, hid, hconf)
193
+
194
+ result = ResolveResult(
195
+ dataset_id=ds_id,
196
+ kb_version=KB_VERSION,
197
+ rung_reached=esc.rung_reached,
198
+ candidates=esc.candidates,
199
+ conflicts=esc.conflicts,
200
+ questions=esc.questions,
201
+ blockers=esc.blockers,
202
+ )
203
+ if use_cache:
204
+ cache.write_resolve(ds_id, result)
205
+
206
+ matrices = {e.tech: e.matrix_json() for e in evaluations}
207
+ return ResolveOutput(result=result, matrices=matrices, observations=observations)
208
+
209
+
210
+ #: A read at or below this many bases is a technical sample index (10x I1/I2 are 8-10 bp), well under
211
+ #: any CB+UMI read (>= 26 bp). The gate is a SAFETY, not decoration: a longer leftover — a stray
212
+ #: cDNA-length file — stays unassigned so ``validate`` still blocks it loudly.
213
+ INDEX_MAX_LEN = 20
214
+
215
+ #: The read designation a demultiplexed FASTQ carries — the mate the sequencer assigned it. Illumina/
216
+ #: bcl2fastq writes it as an ``R1``/``R2``/``I1``/``I2`` token between separators, before the trailing
217
+ #: ``_001`` set number (``..._S1_L001_R1_001.fastq.gz`` -> ``R1``). This is the identity a surplus lane
218
+ #: or flowcell file shares with its role representative: unlike a de-laned basename it carries NO
219
+ #: flowcell id, so it fuses the reads of one accession across every flowcell it was sequenced on — the
220
+ #: flowcell id legitimately differs between them (GSE208154), which a lane-token strip could not bridge.
221
+ _ILLUMINA_DESIGNATION = re.compile(r"[._]([RI][1-4])(?:[._]\d{3})?$", re.IGNORECASE)
222
+ #: fasterq-dump's numeric mate suffix (``SRR..._1`` / ``_2`` / ``_3``) — the SRA equivalent of the
223
+ #: Illumina token, mirroring ``group.py``'s ``_MATE`` shape. Tried only when no Illumina token is found.
224
+ _NUMERIC_DESIGNATION = re.compile(r"[._](?:read[-_]?)?([1-4])(?:[._]\d{3})?$", re.IGNORECASE)
225
+ #: A surplus lane/flowcell file must also match its role representative's read length (a sanity guard
226
+ #: beside the designation). Small on purpose: 10x roles sit far apart (index <= 20, barcode ~26-28,
227
+ #: cDNA >= 50), so the tolerance admits a lane's minor length jitter without ever bridging two roles.
228
+ _LANE_LEN_TOL = 3
229
+
230
+ #: Extensions stripped before reading the trailing designation token — longest first.
231
+ _FASTQ_EXTS = (".fastq.gz", ".fq.gz", ".fastq.bz2", ".fastq.xz", ".fastq", ".fq", ".gz")
232
+
233
+
234
+ def _read_designation(basename: str) -> str | None:
235
+ """The mate/read designation a filename declares — ``R1``/``R2``/``I1`` (Illumina) or ``1``/``2``/
236
+ ``3`` (fasterq-dump), or ``None`` when it declares none.
237
+
238
+ This — not a de-laned basename — is what a surplus lane or flowcell file shares with its role
239
+ representative. It carries no flowcell id, so it groups the reads of one accession sequenced across
240
+ several flowcells (GSE208154), which stripping the ``_L\\d{3}`` lane token alone could not: the
241
+ flowcell id differs between them, so their de-laned names differed and the surplus stayed unassigned.
242
+ """
243
+ name = basename
244
+ lowered = name.lower()
245
+ for ext in _FASTQ_EXTS:
246
+ if lowered.endswith(ext):
247
+ name = name[: -len(ext)]
248
+ break
249
+ illumina = _ILLUMINA_DESIGNATION.search(name)
250
+ if illumina is not None:
251
+ return illumina.group(1).upper()
252
+ numeric = _NUMERIC_DESIGNATION.search(name)
253
+ if numeric is not None:
254
+ return numeric.group(1)
255
+ return None
256
+
257
+
258
+ def index_tagged_roles(winner: Candidate, observations: Iterable[Observation]) -> dict[str, str]:
259
+ """Invert a winner's role assignment to ``sha -> role``, absorbing surplus lane/flowcell files.
260
+
261
+ The base map is ``assignment`` (role -> sha) inverted. Then, **only for a run the bytes actually
262
+ decided** (a ``scored`` winner), each unassigned leftover is placed:
263
+
264
+ - read length index-sized (<= :data:`INDEX_MAX_LEN`) -> :data:`~seqforge.models.dataset.INDEX_ROLE`,
265
+ a 10x sample-index file STARsolo never consumes, set aside rather than left to block;
266
+ - otherwise, if it carries the same **read designation** (R1/R2/…) as an assigned role's
267
+ representative and matches its read length -> that role. An accession sequenced across 8 lanes of
268
+ 2 flowcells groups into one run holding 16 R1 + 16 R2 + 16 I1, but the injective assignment fills
269
+ each role with ONE file, leaving the rest surplus. Every lane/flowcell of one read shares its
270
+ designation — the flowcell id, which a de-laned name still carries, legitimately differs across the
271
+ flowcells one accession spans — so a surplus file rejoins its role by designation + length.
272
+ ``units.tsv`` then emits every lane and STARsolo comma-joins them (``--readFilesIn R2a,R2b ...``).
273
+
274
+ Keying on the designation, not length alone, is deliberate: a stray leftover whose designation
275
+ matches no role's representative (a dropped/mis-uploaded read, or an undesignated file) stays
276
+ unassigned, so ``validate`` still blocks it loudly; and the ``len(matches) == 1`` gate refuses an
277
+ ambiguous file that could fit two roles. A ``forbidden`` winner decided nothing, so its leftovers are
278
+ not reinterpreted. A clean single-lane run has no leftovers and is byte-identical to before.
279
+ """
280
+ roles = {sha: role for role, sha in winner.role_assignment.assignment.items()}
281
+ if winner.score.status == "scored":
282
+ by_sha = {o.file.sha256: o for o in observations}
283
+ rep = {
284
+ role: (by_sha[sha].read_length.mode, _read_designation(by_sha[sha].file.basename))
285
+ for role, sha in winner.role_assignment.assignment.items()
286
+ if sha in by_sha
287
+ }
288
+ for sha in winner.role_assignment.unassigned:
289
+ obs = by_sha.get(sha)
290
+ if obs is None:
291
+ continue
292
+ mode = obs.read_length.mode
293
+ if mode <= INDEX_MAX_LEN:
294
+ roles[sha] = INDEX_ROLE
295
+ continue
296
+ designation = _read_designation(obs.file.basename)
297
+ if designation is None:
298
+ continue
299
+ matches = [
300
+ role
301
+ for role, (rmode, rdesig) in rep.items()
302
+ if rdesig == designation and abs(rmode - mode) <= _LANE_LEN_TOL
303
+ ]
304
+ if len(matches) == 1:
305
+ roles[sha] = matches[0]
306
+ return roles
307
+
308
+
309
+ @dataclass(frozen=True)
310
+ class RunResolution:
311
+ """One run: the files that came from it, and what the bytes said they are."""
312
+
313
+ run_id: str
314
+ paths: list[Path]
315
+ output: ResolveOutput
316
+
317
+ @property
318
+ def winner(self) -> str | None:
319
+ cands = self.output.result.candidates
320
+ return cands[0].technology if cands else None
321
+
322
+
323
+ def role_of_sha_for(runs: Iterable[RunResolution]) -> dict[str, str]:
324
+ """Merged file-sha -> role across ``runs`` (all of a dataset, or just one assay's slice).
325
+
326
+ A `RoleAssignment` maps role -> ONE sha, because it describes one library's reads. Six runs of one
327
+ library have six R1s, so the dataset-level fact is the inverse map, and it only exists once each
328
+ run has been assigned on its own bytes. A run's short leftovers (10x I1/I2 index files) are tagged
329
+ ``index`` — set aside, not dropped — gated on read length per run.
330
+ """
331
+ merged: dict[str, str] = {}
332
+ for run in runs:
333
+ for cand in run.output.result.candidates[:1]:
334
+ merged.update(index_tagged_roles(cand, run.output.observations))
335
+ return merged
336
+
337
+
338
+ @dataclass(frozen=True)
339
+ class MultiRunOutput:
340
+ """Every run in a dataset, resolved independently, plus the cross-run agreement check."""
341
+
342
+ runs: list[RunResolution]
343
+ blockers: list[Blocker] = field(default_factory=list)
344
+
345
+ @property
346
+ def observations(self) -> list[Observation]:
347
+ return [o for r in self.runs for o in r.output.observations]
348
+
349
+ def role_of_sha(self) -> dict[str, str]:
350
+ """The dataset-wide file-sha -> role map. The manifest's inventory is built from this."""
351
+ return role_of_sha_for(self.runs)
352
+
353
+ def by_chemistry(self) -> dict[str, list[RunResolution]]:
354
+ """Partition the runs by the chemistry each resolved to — one group per **assay**.
355
+
356
+ A large project (study) naturally contains several assays: groups of samples that share one
357
+ processing recipe (chemistry). Runs whose bytes decided nothing (``winner is None``) are
358
+ omitted — they carry their own blocker and cannot name an assay. Keyed order is sorted so the
359
+ partition is deterministic.
360
+ """
361
+ groups: dict[str, list[RunResolution]] = {}
362
+ for run in self.runs:
363
+ if run.winner is not None:
364
+ groups.setdefault(run.winner, []).append(run)
365
+ return {tech: groups[tech] for tech in sorted(groups)}
366
+
367
+ def chemistry_of_sha(self) -> dict[str, str]:
368
+ """file-sha -> the chemistry its run resolved to. The join for the per-sample agreement check."""
369
+ out: dict[str, str] = {}
370
+ for run in self.runs:
371
+ if run.winner is None:
372
+ continue
373
+ for obs in run.output.observations:
374
+ out[obs.file.sha256] = run.winner
375
+ return out
376
+
377
+ def sample_disagreements(self, sample_shas: dict[str, list[str]]) -> list[Blocker]:
378
+ """A sample whose files span more than one chemistry blocks — that IS a mis-grouping.
379
+
380
+ Runs of ONE sample resolve to one chemistry, always. Runs of *different* samples may resolve
381
+ to different chemistries — that is a legal partition into assays (:meth:`by_chemistry`), not a
382
+ disagreement. So the invariant is per-sample, checked against the sample->files map the
383
+ metadata resolver builds; the byte resolver alone cannot see it (filenames group into runs,
384
+ records join runs into samples).
385
+ """
386
+ chem_of = self.chemistry_of_sha()
387
+ blockers: list[Blocker] = []
388
+ for sample_id, shas in sorted(sample_shas.items()):
389
+ techs = sorted({chem_of[s] for s in shas if s in chem_of})
390
+ if len(techs) > 1:
391
+ blockers.append(
392
+ Blocker(
393
+ id=f"blk-sample-chemistry-{sample_id}",
394
+ code=BlockerCode.UNRESOLVED_CONFLICT,
395
+ message=(
396
+ f"sample {sample_id!r} has files resolving to more than one chemistry "
397
+ f"({', '.join(techs)}). Runs of one sample are one library and must "
398
+ f"resolve to one chemistry, so either these files are not all this "
399
+ f"sample's or they were grouped into runs incorrectly."
400
+ ),
401
+ remedy=(
402
+ "Check the file->sample join (the archive records, or the filenames) and "
403
+ "the run grouping. Different chemistries across DIFFERENT samples are a "
404
+ "legal multi-assay project; within one sample they are not."
405
+ ),
406
+ subject=BlockerSubject(kind="dataset", ref=sample_id),
407
+ evidence=sorted(shas),
408
+ )
409
+ )
410
+ return blockers
411
+
412
+ def exit_code(self) -> int:
413
+ if self.blockers:
414
+ return 3
415
+ return max((r.output.exit_code() for r in self.runs), default=0)
416
+
417
+
418
+ def _resolve_one_run(
419
+ item: tuple[str, list[Path]],
420
+ *,
421
+ registry: OnlistRegistry,
422
+ specs: dict[str, Spec],
423
+ hypothesis: Hypothesis | None,
424
+ workspace: str | Path,
425
+ max_reads: int,
426
+ max_bytes: int,
427
+ use_cache: bool,
428
+ score_threads: int,
429
+ probed: dict[str, tuple[Observation, list[str]]],
430
+ ) -> RunResolution:
431
+ """Resolve ONE run's files on their own bytes, reusing the dataset-wide probe map."""
432
+ run_id, run_paths = item
433
+ output = resolve_dataset(
434
+ run_paths,
435
+ registry=registry,
436
+ specs=specs,
437
+ hypothesis=hypothesis,
438
+ workspace=workspace,
439
+ max_reads=max_reads,
440
+ max_bytes=max_bytes,
441
+ use_cache=use_cache,
442
+ score_threads=score_threads,
443
+ _probed=probed,
444
+ )
445
+ return RunResolution(run_id=run_id, paths=list(run_paths), output=output)
446
+
447
+
448
+ #: Context a forked scoring worker inherits from its parent (see ``resolve_runs``). Set in the parent
449
+ #: right before the fork pool; carried by fork inheritance, never pickled, so the warm registry is not
450
+ #: rebuilt per worker (and its pages are shared copy-on-write where CPython's refcounting allows).
451
+ _RUN_CTX: dict[str, object] = {}
452
+
453
+
454
+ def _resolve_run_shared(item: tuple[str, Sequence[str | Path]]) -> RunResolution:
455
+ """Fork worker: resolve one run from the parent's COW-inherited ``_RUN_CTX`` (a warm registry whose
456
+ packed whitelist is shared read-only). Only the run's own paths cross the process boundary."""
457
+ return _resolve_one_run(item, **_RUN_CTX) # type: ignore[arg-type]
458
+
459
+
460
+ def _resume_payload(runs: list[RunResolution]) -> dict[str, object]:
461
+ """The stat-keyed resume pointer: per run, its id, its dataset_id, and its file content-keys."""
462
+ return {
463
+ "runs": [
464
+ {
465
+ "run_id": r.run_id,
466
+ "dataset_id": r.output.result.dataset_id,
467
+ "file_keys": [o.file.sha256 for o in r.output.observations],
468
+ }
469
+ for r in runs
470
+ ]
471
+ }
472
+
473
+
474
+ def _try_resume_runs(
475
+ grouped: dict[str, list[Path]], paths: Sequence[str | Path], cache: Cache
476
+ ) -> MultiRunOutput | None:
477
+ """Rebuild a :class:`MultiRunOutput` entirely from cache — reading ZERO FASTQ bytes — or ``None``.
478
+
479
+ The stat key (:func:`resume_key`) says the input files are byte-for-byte the last run's; the run
480
+ grouping is recomputed deterministically from filenames; each run's ``ResolveResult`` and its
481
+ files' ``Observation``s are then loaded from the content-addressed cache. Any missing/stale piece
482
+ aborts the resume (return ``None``) and the caller probes + scores afresh. ``matrices`` is scoring
483
+ debug output the ``run``/``fill`` path does not consume, so it is left empty on resume.
484
+ """
485
+ rk = resume_key(paths, KB_VERSION, PROBE_VERSION, RESOLVE_VERSION)
486
+ if rk is None:
487
+ return None
488
+ payload = cache.read_resume(rk)
489
+ if payload is None:
490
+ return None
491
+ runs_meta = payload.get("runs")
492
+ if not isinstance(runs_meta, list) or len(runs_meta) != len(grouped):
493
+ return None
494
+ runs: list[RunResolution] = []
495
+ for meta in runs_meta:
496
+ if not isinstance(meta, dict):
497
+ return None
498
+ run_id = meta.get("run_id")
499
+ ds = meta.get("dataset_id")
500
+ file_keys = meta.get("file_keys")
501
+ if (
502
+ not isinstance(run_id, str)
503
+ or not isinstance(ds, str)
504
+ or not isinstance(file_keys, list)
505
+ ):
506
+ return None
507
+ run_paths = grouped.get(run_id)
508
+ if run_paths is None or len(file_keys) != len(run_paths):
509
+ return None
510
+ obs_list = [cache.read_observation(k) if isinstance(k, str) else None for k in file_keys]
511
+ if any(o is None for o in obs_list):
512
+ return None
513
+ result = cache.read_resolve(ds)
514
+ if result is None:
515
+ return None
516
+ # Restore each file's own path: the observation cache is keyed by content-address, which
517
+ # same-named identical-content files in different runs can share, so a resumed run must take
518
+ # `local_uri` from its input path — not the (possibly another file's) cached value — to stay
519
+ # byte-identical to a fresh run.
520
+ observations = [
521
+ o.model_copy(update={"file": o.file.model_copy(update={"local_uri": str(path)})})
522
+ for path, o in zip(run_paths, obs_list, strict=True)
523
+ if o is not None
524
+ ]
525
+ output = ResolveOutput(result=result, matrices={}, observations=observations)
526
+ runs.append(RunResolution(run_id=run_id, paths=list(run_paths), output=output))
527
+ if {r.run_id for r in runs} != set(grouped):
528
+ return None # duplicate/mismatched run ids -> the cached shape is stale
529
+ order = list(grouped)
530
+ runs.sort(key=lambda r: order.index(r.run_id))
531
+ return MultiRunOutput(runs=runs)
532
+
533
+
534
+ def resolve_runs(
535
+ paths: Sequence[str | Path],
536
+ *,
537
+ registry: OnlistRegistry | None = None,
538
+ specs: dict[str, Spec] | None = None,
539
+ hypothesis: Hypothesis | None = None,
540
+ workspace: str | Path = ".",
541
+ max_reads: int = DEFAULT_MAX_READS,
542
+ max_bytes: int = DEFAULT_MAX_BYTES,
543
+ use_cache: bool = True,
544
+ cpus: int = 1,
545
+ ) -> MultiRunOutput:
546
+ """Group `paths` into runs and resolve **each run on its own bytes**.
547
+
548
+ This is the multi-run entry point, and it exists because `resolve_dataset` answers "what is this
549
+ ONE library?" — correctly, and always did. Handing it a 6-run dataset's 12 files was the bug: one
550
+ global assignment picks a single (R1, R2) pair out of twelve and leaves ten files with no role,
551
+ which `_units` skips and `validate` blesses. Five sixths of the data, gone, exit 0.
552
+
553
+ Nothing here re-decides roles or reads a byte differently. It splits the input by filename (a
554
+ rung-1 prior about *identity*, never about role — see `group.py`) and resolves each group.
555
+
556
+ **Runs may resolve to different chemistries, and that is a partition, not an error.** A large
557
+ project contains several assays; :meth:`MultiRunOutput.by_chemistry` groups the runs into them.
558
+ The safety the old dataset-wide "all runs must agree" block provided is now per-SAMPLE
559
+ (:meth:`MultiRunOutput.sample_disagreements`): runs of ONE sample must resolve to one chemistry,
560
+ but that check needs the sample->files map only the metadata resolver builds, so it is applied by
561
+ the caller (never a majority vote — a sample split across chemistries blocks loudly).
562
+ """
563
+ from .group import group_runs
564
+
565
+ grouped = group_runs(paths)
566
+ cache = Cache(workspace)
567
+ # Disk is state: if the input files are byte-for-byte the last run's, rebuild the whole answer from
568
+ # the content-addressed cache and read ZERO FASTQ bytes (the "resumable" the design promises).
569
+ if use_cache:
570
+ resumed = _try_resume_runs(grouped, paths, cache)
571
+ if resumed is not None:
572
+ return resumed
573
+ # Probe every file of every run ONCE, in one pool across the whole dataset (12 files, not 2 a
574
+ # run), then hand each run its slice. Probing per-run would cap parallelism at a run's file count.
575
+ probed = _probe_paths(
576
+ [p for run_paths in grouped.values() for p in run_paths],
577
+ max_reads=max_reads,
578
+ max_bytes=max_bytes,
579
+ cpus=cpus,
580
+ )
581
+ registry = registry if registry is not None else DEFAULT_REGISTRY
582
+ kb_specs = specs if specs is not None else load_all_specs()
583
+ run_items = list(grouped.items())
584
+
585
+ import multiprocessing as mp
586
+
587
+ # Parallelism has two axes sharing ONE core budget: per-run forking (one worker per run) and
588
+ # per-spec scoring threads inside each run. Fork is required for per-run, not just preferred: the
589
+ # worker reads the warm registry from `_RUN_CTX`, which a forked child inherits copy-on-write but a
590
+ # `spawn` child would re-import empty. Split the budget so the two axes never oversubscribe: when W
591
+ # runs fork concurrently each gets cpus//W scoring threads; a serial or single run gets them all.
592
+ parallel_runs = cpus > 1 and len(run_items) > 1 and "fork" in mp.get_all_start_methods()
593
+ if parallel_runs:
594
+ n_run_workers = min(cpus, len(run_items) - 1) # first run warms in-process; the rest fork
595
+ score_threads = max(1, cpus // n_run_workers)
596
+ else:
597
+ score_threads = max(1, cpus)
598
+ common: dict[str, object] = dict(
599
+ registry=registry,
600
+ specs=kb_specs,
601
+ hypothesis=hypothesis,
602
+ workspace=workspace,
603
+ max_reads=max_reads,
604
+ max_bytes=max_bytes,
605
+ use_cache=use_cache,
606
+ score_threads=score_threads,
607
+ probed=probed,
608
+ )
609
+
610
+ if not parallel_runs:
611
+ runs = [_resolve_one_run(it, **common) for it in run_items] # type: ignore[arg-type]
612
+ else:
613
+ # Warm the shared registry in-process on the FIRST run (this parses the onlists once), then
614
+ # fork workers for the rest: each inherits the warm registry, so the packed whitelist (millions
615
+ # of barcodes) is not re-parsed per worker, and its pages are shared copy-on-write where
616
+ # refcounting allows. Peak memory stays bounded by `--cpus`. Scoring is deterministic per run,
617
+ # so the parallel result is identical to the serial one; runs are reassembled in the order
618
+ # `group_runs` yields (sorted by run key) — the same order the serial path uses. Core count
619
+ # folds into no hash — parallelism is not a budget (see `_probe_paths`).
620
+ from concurrent.futures import ProcessPoolExecutor
621
+
622
+ first = _resolve_one_run(run_items[0], **common) # type: ignore[arg-type]
623
+ rest_items = run_items[1:]
624
+ results: dict[int, RunResolution] = {}
625
+ # Publish the warm context for the fork workers to inherit, and clear it in `finally` so the
626
+ # parent never pins the warm registry (millions of barcodes) past the pool — even on a raise.
627
+ _RUN_CTX.update(common)
628
+ try:
629
+ with ProcessPoolExecutor(
630
+ max_workers=min(cpus, len(rest_items)), mp_context=mp.get_context("fork")
631
+ ) as pool:
632
+ futures = {
633
+ pool.submit(_resolve_run_shared, it): i for i, it in enumerate(rest_items)
634
+ }
635
+ for fut in futures:
636
+ results[futures[fut]] = fut.result()
637
+ finally:
638
+ _RUN_CTX.clear()
639
+ runs = [first, *(results[i] for i in range(len(rest_items)))]
640
+
641
+ # Record the stat-keyed resume pointer so an unchanged re-run skips probe+score entirely.
642
+ if use_cache:
643
+ rk = resume_key(paths, KB_VERSION, PROBE_VERSION, RESOLVE_VERSION)
644
+ if rk is not None:
645
+ cache.write_resume(rk, _resume_payload(runs))
646
+ return MultiRunOutput(runs=runs)