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