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,531 @@
|
|
|
1
|
+
"""``manifest fill`` — assemble the two-section :class:`DatasetManifest` from a resolve Decision.
|
|
2
|
+
|
|
3
|
+
Each section keeps its own authority (design §1.6):
|
|
4
|
+
|
|
5
|
+
- ``library`` = **evidence**. Chemistry, read layout, and the file->role assignment all come from
|
|
6
|
+
the winning candidate, so every field is ``basis="observed"`` with the file shas as evidence.
|
|
7
|
+
- ``experiment``= **metadata/humans**. Organism and accessions cannot be read off bytes, so they
|
|
8
|
+
arrive as inputs (normally span-verified Assertions from ``harvest``) and are ``basis="asserted"``.
|
|
9
|
+
|
|
10
|
+
**There is no third section, and `fill` takes no genome.** Intent lives in a separate
|
|
11
|
+
:class:`~seqforge.models.processing.ProcessingManifest`, built by :func:`fill_processing`. That is
|
|
12
|
+
also why ``--assembly``/``--annotation`` left this verb: choosing a reference is not something you
|
|
13
|
+
learn by probing bytes, and it never belonged on the verb that probes them.
|
|
14
|
+
|
|
15
|
+
The manifest is machine-independent: a file's ``uri`` is its path **relative to the dataset's
|
|
16
|
+
own root**, never the absolute local path the probe read (which stays in
|
|
17
|
+
``Observation.file.local_uri``, an internal-only field). Relative, not *flat* — see
|
|
18
|
+
:func:`dataset_uris` for the two things a bare basename broke on the first real dataset.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import os
|
|
24
|
+
import re
|
|
25
|
+
from collections.abc import Sequence
|
|
26
|
+
from dataclasses import dataclass, field
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Literal
|
|
29
|
+
|
|
30
|
+
from ..io import OnlistRegistry
|
|
31
|
+
from ..kb import KB_VERSION
|
|
32
|
+
from ..kb.schema import Element, Spec
|
|
33
|
+
from ..models.blocker import ValidationWarning
|
|
34
|
+
from ..models.dataset import (
|
|
35
|
+
AssayLabel,
|
|
36
|
+
DatasetManifest,
|
|
37
|
+
DatasetProvenance,
|
|
38
|
+
ExperimentSection,
|
|
39
|
+
FileInventoryItem,
|
|
40
|
+
LibrarySection,
|
|
41
|
+
Onlist,
|
|
42
|
+
ReadAnchor,
|
|
43
|
+
ReadDef,
|
|
44
|
+
ReadElement,
|
|
45
|
+
ReadLayout,
|
|
46
|
+
SampleGroup,
|
|
47
|
+
Study,
|
|
48
|
+
)
|
|
49
|
+
from ..models.evidenced import (
|
|
50
|
+
EvidencedAccessionList,
|
|
51
|
+
EvidencedChemistrySet,
|
|
52
|
+
EvidencedTaxid,
|
|
53
|
+
)
|
|
54
|
+
from ..models.observation import Observation
|
|
55
|
+
from ..models.processing import (
|
|
56
|
+
DatasetPin,
|
|
57
|
+
ProcessingManifest,
|
|
58
|
+
ProcessingProvenance,
|
|
59
|
+
RuntimeEnv,
|
|
60
|
+
SoloFeature,
|
|
61
|
+
)
|
|
62
|
+
from ..models.resolve import Candidate, MetadataResolution, ResolveResult
|
|
63
|
+
from ..workflows import WORKFLOW_VERSION
|
|
64
|
+
from .hash import dataset_content_hash, processing_content_hash
|
|
65
|
+
from .instruct import Instruction
|
|
66
|
+
from .policy import ProcessingOverrides, resolve_processing
|
|
67
|
+
|
|
68
|
+
_SHA256 = re.compile(r"^[0-9a-f]{64}$")
|
|
69
|
+
|
|
70
|
+
#: KB element type -> the manifest's interpretive read-element role.
|
|
71
|
+
_ROLE_FOR_TYPE: dict[str, str] = {
|
|
72
|
+
"barcode": "CB",
|
|
73
|
+
"umi": "UMI",
|
|
74
|
+
"cdna": "cDNA",
|
|
75
|
+
"gdna": "gDNA",
|
|
76
|
+
"linker": "linker",
|
|
77
|
+
"fixed": "linker",
|
|
78
|
+
"poly_t": "polyT",
|
|
79
|
+
"poly_a": "polyA",
|
|
80
|
+
"index": "index",
|
|
81
|
+
"diversity": "linker", # a variable 5' insert; no barcode/UMI value, so it rides as a spacer role
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
_MODALITY: dict[str, str] = {"rna": "rna", "atac": "atac", "multi": "rna"}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class FillError(RuntimeError):
|
|
88
|
+
"""The resolve result cannot be assembled into a manifest (no clean Decision)."""
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass(frozen=True)
|
|
92
|
+
class ExperimentInputs:
|
|
93
|
+
"""Biological truth that bytes cannot supply.
|
|
94
|
+
|
|
95
|
+
Normally built by :func:`experiment_from_metadata` out of a
|
|
96
|
+
:class:`~seqforge.models.resolve.MetadataResolution`, so every value here traces to a record or a
|
|
97
|
+
span-verified assertion. The docstring used to say "normally span-verified Assertions from
|
|
98
|
+
harvest" while the only caller passed a CLI flag and an empty sample list — an aspiration written
|
|
99
|
+
in the present tense, which is how a comment becomes a lie.
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
organism: EvidencedTaxid
|
|
103
|
+
accessions: list[str] = field(default_factory=list)
|
|
104
|
+
samples: list[SampleGroup] = field(default_factory=list)
|
|
105
|
+
study: Study | None = None
|
|
106
|
+
#: Confidence for ``accessions`` only: a list of accessions a human typed or a record declared.
|
|
107
|
+
accession_confidence: float | None = None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def experiment_from_metadata(
|
|
111
|
+
resolution: MetadataResolution,
|
|
112
|
+
observations: list[Observation],
|
|
113
|
+
*,
|
|
114
|
+
organism_taxid: int | None = None,
|
|
115
|
+
uris: dict[str, str] | None = None,
|
|
116
|
+
) -> ExperimentInputs:
|
|
117
|
+
"""A :class:`MetadataResolution` -> the manifest's experiment inputs. The only conversion.
|
|
118
|
+
|
|
119
|
+
The resolver speaks in file **hashes**, because that is what a sample is carried by and it has no
|
|
120
|
+
business knowing what a URI is; the manifest speaks in URIs, because a manifest is
|
|
121
|
+
machine-independent. :func:`dataset_uris` owns that translation and always has — this is its
|
|
122
|
+
second caller, and the first one that was written knowing it existed. (The first time
|
|
123
|
+
``SampleGroup.file_uris`` was built beside it out of basenames, ``manifest fill`` refused its own
|
|
124
|
+
manifest with six referential-integrity Blockers.)
|
|
125
|
+
|
|
126
|
+
``organism_taxid`` overrides the record — a flag beats a database, which is the same precedence
|
|
127
|
+
the processing manifest uses and for the same reason: a human typing a taxid is asserting it now,
|
|
128
|
+
about this data, having looked.
|
|
129
|
+
"""
|
|
130
|
+
# ``uris`` is optional and, when given, dataset-wide: a multi-assay fill computes ONE map over
|
|
131
|
+
# every assay's files (the dataset root) and threads it in, so a sample in a deeper per-assay
|
|
132
|
+
# subdir still gets a URI relative to the same root ``--fastq-dir`` will use. Omitted (the
|
|
133
|
+
# single-assay / single-run callers), it degenerates to the local set, byte-identical to before.
|
|
134
|
+
if uris is None:
|
|
135
|
+
uris = dataset_uris(observations)
|
|
136
|
+
samples = [
|
|
137
|
+
SampleGroup(
|
|
138
|
+
sample_id=s.sample_id,
|
|
139
|
+
accession=s.accession,
|
|
140
|
+
attributes=dict(s.attributes),
|
|
141
|
+
file_uris=[uris[sha] for sha in s.file_shas if sha in uris],
|
|
142
|
+
)
|
|
143
|
+
for s in resolution.samples
|
|
144
|
+
]
|
|
145
|
+
if organism_taxid is not None:
|
|
146
|
+
organism = EvidencedTaxid(value=organism_taxid, basis="user_confirmed", rung=0)
|
|
147
|
+
elif resolution.organism is not None:
|
|
148
|
+
organism = resolution.organism
|
|
149
|
+
else:
|
|
150
|
+
raise FillError(
|
|
151
|
+
"no organism: the archive record does not declare one and none was given. Pass "
|
|
152
|
+
"`--organism <taxid>`. There is no default, and there must not be — a wrong taxid aligns "
|
|
153
|
+
"cleanly against the wrong genome, exits 0, and nothing downstream ever asks again."
|
|
154
|
+
)
|
|
155
|
+
accessions = sorted({s.accession for s in resolution.samples if s.accession})
|
|
156
|
+
if resolution.project is not None and resolution.project.accession:
|
|
157
|
+
accessions.append(resolution.project.accession)
|
|
158
|
+
study = Study(**resolution.project.model_dump()) if resolution.project is not None else None
|
|
159
|
+
return ExperimentInputs(
|
|
160
|
+
organism=organism,
|
|
161
|
+
accessions=sorted(set(accessions)),
|
|
162
|
+
samples=samples,
|
|
163
|
+
study=study,
|
|
164
|
+
accession_confidence=None, # transcribed from the record; no judgement was made
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@dataclass(frozen=True)
|
|
169
|
+
class ProcessingInputs:
|
|
170
|
+
"""CLI-typed processing choices — the top of the precedence ladder.
|
|
171
|
+
|
|
172
|
+
Reference selection is a liulab-genome assembly id + a REGISTERED GTF name, never a path.
|
|
173
|
+
This dataclass predates the split and was already the processing manifest in miniature — badly
|
|
174
|
+
named and half-built, sitting beside `fill` instead of owning the artifact it describes.
|
|
175
|
+
"""
|
|
176
|
+
|
|
177
|
+
assembly: str | None = None
|
|
178
|
+
annotation_name: str | None = None
|
|
179
|
+
features: tuple[SoloFeature, ...] | None = None # --quantify: EXACT replacement
|
|
180
|
+
threads: int | None = None
|
|
181
|
+
environment: RuntimeEnv | None = None
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def fill_manifest(
|
|
185
|
+
*,
|
|
186
|
+
result: ResolveResult,
|
|
187
|
+
spec: Spec,
|
|
188
|
+
observations: list[Observation],
|
|
189
|
+
registry: OnlistRegistry,
|
|
190
|
+
experiment: ExperimentInputs,
|
|
191
|
+
seqforge_version: str,
|
|
192
|
+
role_of_sha: dict[str, str] | None = None,
|
|
193
|
+
specs: dict[str, Spec] | None = None,
|
|
194
|
+
uris: dict[str, str] | None = None,
|
|
195
|
+
) -> DatasetManifest:
|
|
196
|
+
"""Assemble a :class:`DatasetManifest` from a clean resolve Decision + metadata inputs.
|
|
197
|
+
|
|
198
|
+
Bytes and metadata only. Takes no ``processing`` argument, by construction: a dataset does not
|
|
199
|
+
know how it will be processed, because it will be processed many ways.
|
|
200
|
+
|
|
201
|
+
``role_of_sha`` carries the **dataset-level** file->role map, which a single `ResolveResult`
|
|
202
|
+
cannot express: its `RoleAssignment` maps role -> one sha, because it describes one library's
|
|
203
|
+
reads, and a six-run dataset has six R1s. `resolve_runs` resolves each run on its own bytes and
|
|
204
|
+
merges the inverse map; pass it here. Omitted, the winner's own assignment is used — correct for
|
|
205
|
+
a genuinely single-run dataset, and the reason this parameter is optional rather than required.
|
|
206
|
+
"""
|
|
207
|
+
if result.blockers:
|
|
208
|
+
raise FillError(f"cannot fill a manifest over {len(result.blockers)} unresolved Blocker(s)")
|
|
209
|
+
if not result.candidates:
|
|
210
|
+
raise FillError("resolve produced no candidates")
|
|
211
|
+
winner = result.candidates[0]
|
|
212
|
+
if winner.score.status != "scored":
|
|
213
|
+
raise FillError(f"winning candidate {winner.technology!r} is forbidden, not a Decision")
|
|
214
|
+
if winner.technology != spec.identity.id:
|
|
215
|
+
raise FillError(
|
|
216
|
+
f"spec {spec.identity.id!r} does not match the winning candidate {winner.technology!r}"
|
|
217
|
+
)
|
|
218
|
+
if not spec.identity.assay_ontology:
|
|
219
|
+
raise FillError(
|
|
220
|
+
f"{spec.identity.id!r} has no assay_ontology CURIE — controlled vocabulary is required"
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
obs_by_sha = {o.file.sha256: o for o in observations}
|
|
224
|
+
# ONE decision, so ONE confidence and ONE rung. `chemistry` carries them; everything else in
|
|
225
|
+
# `library` is a consequence of that decision and carries no envelope of its own. See
|
|
226
|
+
# `LibrarySection` for why four copies of one number was never four truths.
|
|
227
|
+
confidence = min(1.0, max(0.0, winner.score.value if winner.score.value is not None else 0.5))
|
|
228
|
+
rung = winner.rung_resolved.get("chemistry", 2)
|
|
229
|
+
chemistry = sorted({winner.technology, *winner.equivalence_members})
|
|
230
|
+
|
|
231
|
+
library = LibrarySection(
|
|
232
|
+
chemistry=EvidencedChemistrySet(
|
|
233
|
+
# the §12 equivalence class: benign twins are recorded together, machine-visibly
|
|
234
|
+
value=chemistry,
|
|
235
|
+
basis="observed",
|
|
236
|
+
evidence=sorted(obs_by_sha),
|
|
237
|
+
confidence=confidence,
|
|
238
|
+
rung=rung,
|
|
239
|
+
),
|
|
240
|
+
assay=_assay_labels(chemistry, specs),
|
|
241
|
+
read_layout=_build_read_layout(spec, winner, obs_by_sha),
|
|
242
|
+
onlists=_build_onlists(spec, registry),
|
|
243
|
+
files=_build_files(winner, observations, role_of_sha, uris=uris),
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
experiment_section = ExperimentSection(
|
|
247
|
+
organism=experiment.organism,
|
|
248
|
+
accessions=EvidencedAccessionList(
|
|
249
|
+
value=list(experiment.accessions),
|
|
250
|
+
basis="asserted",
|
|
251
|
+
confidence=experiment.accession_confidence,
|
|
252
|
+
rung=0,
|
|
253
|
+
),
|
|
254
|
+
samples=list(experiment.samples),
|
|
255
|
+
study=experiment.study,
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
draft = DatasetManifest(
|
|
259
|
+
library=library,
|
|
260
|
+
experiment=experiment_section,
|
|
261
|
+
provenance=DatasetProvenance(
|
|
262
|
+
dataset_hash="",
|
|
263
|
+
kb_version=KB_VERSION,
|
|
264
|
+
seqforge_version=seqforge_version,
|
|
265
|
+
),
|
|
266
|
+
)
|
|
267
|
+
# the hash covers only the two truth sections, so filling it in cannot perturb it
|
|
268
|
+
return draft.model_copy(
|
|
269
|
+
update={
|
|
270
|
+
"provenance": DatasetProvenance(
|
|
271
|
+
dataset_hash=dataset_content_hash(draft),
|
|
272
|
+
kb_version=KB_VERSION,
|
|
273
|
+
seqforge_version=seqforge_version,
|
|
274
|
+
)
|
|
275
|
+
}
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def fill_processing(
|
|
280
|
+
*,
|
|
281
|
+
spec: Spec,
|
|
282
|
+
dataset: DatasetManifest,
|
|
283
|
+
processing: ProcessingInputs,
|
|
284
|
+
instructions: Sequence[Instruction] = (),
|
|
285
|
+
prep_type: str | None = None,
|
|
286
|
+
processing_id: str = "default",
|
|
287
|
+
pin: bool = True,
|
|
288
|
+
seqforge_version: str,
|
|
289
|
+
) -> tuple[ProcessingManifest, list[ValidationWarning]]:
|
|
290
|
+
"""Build one :class:`ProcessingManifest` for a dataset: policy -> instructions -> flags.
|
|
291
|
+
|
|
292
|
+
``pin=True`` binds it to this dataset's hash, so ``compose`` refuses any other. ``pin=False``
|
|
293
|
+
leaves it a **template**: portable across datasets, which is what lets a single file drive a whole
|
|
294
|
+
corpus. Both forms are legitimate and they are for different jobs — you publish a bound one and
|
|
295
|
+
you reprocess with a template.
|
|
296
|
+
|
|
297
|
+
Precedence itself lives in :func:`~seqforge.manifest.policy.resolve_processing`, and only there.
|
|
298
|
+
"""
|
|
299
|
+
section, warnings = resolve_processing(
|
|
300
|
+
spec=spec,
|
|
301
|
+
dataset=dataset,
|
|
302
|
+
instructions=instructions,
|
|
303
|
+
prep_type=prep_type,
|
|
304
|
+
overrides=ProcessingOverrides(
|
|
305
|
+
assembly=processing.assembly,
|
|
306
|
+
annotation_name=processing.annotation_name,
|
|
307
|
+
features=processing.features,
|
|
308
|
+
threads=processing.threads,
|
|
309
|
+
environment=processing.environment,
|
|
310
|
+
),
|
|
311
|
+
)
|
|
312
|
+
draft = ProcessingManifest(
|
|
313
|
+
processing_id=processing_id,
|
|
314
|
+
dataset=(
|
|
315
|
+
DatasetPin(
|
|
316
|
+
dataset_hash=dataset.provenance.dataset_hash,
|
|
317
|
+
accessions=list(dataset.experiment.accessions.value),
|
|
318
|
+
)
|
|
319
|
+
if pin
|
|
320
|
+
else None
|
|
321
|
+
),
|
|
322
|
+
processing=section,
|
|
323
|
+
provenance=ProcessingProvenance(
|
|
324
|
+
processing_hash="",
|
|
325
|
+
workflow_version=WORKFLOW_VERSION,
|
|
326
|
+
seqforge_version=seqforge_version,
|
|
327
|
+
),
|
|
328
|
+
)
|
|
329
|
+
return (
|
|
330
|
+
draft.model_copy(
|
|
331
|
+
update={
|
|
332
|
+
"provenance": ProcessingProvenance(
|
|
333
|
+
processing_hash=processing_content_hash(draft),
|
|
334
|
+
workflow_version=WORKFLOW_VERSION,
|
|
335
|
+
seqforge_version=seqforge_version,
|
|
336
|
+
)
|
|
337
|
+
}
|
|
338
|
+
),
|
|
339
|
+
warnings,
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _assay_labels(chemistry: list[str], specs: dict[str, Spec] | None) -> list[AssayLabel]:
|
|
344
|
+
"""The chemistry set, spelled in EFO. One label per member — including the §12 twin.
|
|
345
|
+
|
|
346
|
+
This is where the pilot's ``assay: EFO:0009922`` beside ``chemistry: [v3, v3.1]`` came from: the
|
|
347
|
+
assay field held one CURIE and the chemistry field held two ids, so v3.1's own term
|
|
348
|
+
(``EFO:0022980``) was silently dropped and the two fields read as if they disagreed. They never
|
|
349
|
+
did. They are the same answer, and now they are the same shape.
|
|
350
|
+
|
|
351
|
+
A member whose spec declares no CURIE, or whose CURIE has no shipped EFO label, is **skipped
|
|
352
|
+
rather than guessed at** — ``kb lint`` refuses both, so reaching that branch means a spec got in
|
|
353
|
+
without linting and a blank name would hide it.
|
|
354
|
+
"""
|
|
355
|
+
from ..io.efo import has_term, term
|
|
356
|
+
from ..kb import load_all_specs
|
|
357
|
+
|
|
358
|
+
all_specs = specs if specs is not None else load_all_specs()
|
|
359
|
+
out: list[AssayLabel] = []
|
|
360
|
+
for chem in chemistry:
|
|
361
|
+
s = all_specs.get(chem)
|
|
362
|
+
if s is None or not s.identity.assay_ontology:
|
|
363
|
+
continue
|
|
364
|
+
curie = s.identity.assay_ontology[0]
|
|
365
|
+
if not has_term(curie):
|
|
366
|
+
continue
|
|
367
|
+
out.append(AssayLabel(chemistry=chem, curie=curie, name=term(curie).name))
|
|
368
|
+
return out
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _build_read_layout(
|
|
372
|
+
spec: Spec, winner: Candidate, obs_by_sha: dict[str, Observation]
|
|
373
|
+
) -> ReadLayout:
|
|
374
|
+
"""Declared element structure (KB) x observed read geometry (the assigned file's bytes)."""
|
|
375
|
+
reads: list[ReadDef] = []
|
|
376
|
+
for read in spec.reads:
|
|
377
|
+
sha = winner.role_assignment.assignment.get(read.id)
|
|
378
|
+
if sha is None or sha not in obs_by_sha:
|
|
379
|
+
raise FillError(f"role {read.id!r} has no assigned file in the winning candidate")
|
|
380
|
+
profile = obs_by_sha[sha].read_length
|
|
381
|
+
reads.append(
|
|
382
|
+
ReadDef(
|
|
383
|
+
read_id=read.id,
|
|
384
|
+
strand=read.strand,
|
|
385
|
+
min_len=profile.min_len, # observed, not merely declared
|
|
386
|
+
max_len=profile.max_len,
|
|
387
|
+
elements=[_read_element(el, spec) for el in read.elements],
|
|
388
|
+
)
|
|
389
|
+
)
|
|
390
|
+
modality = _MODALITY.get(spec.identity.modality, "rna")
|
|
391
|
+
return ReadLayout(modality=modality, reads=reads) # type: ignore[arg-type]
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _read_element(el: Element, spec: Spec) -> ReadElement:
|
|
395
|
+
length = el.end - el.start if (el.start is not None and el.end is not None) else None
|
|
396
|
+
onlist_ref = spec.onlists[el.onlist].registry if el.onlist else None
|
|
397
|
+
# Carry a floating element's anchor into the IR verbatim (ReadAnchor mirrors kb.schema.Anchor
|
|
398
|
+
# field-for-field), so the manifest records that the element STAGGERS rather than pinning it to a
|
|
399
|
+
# constant start it does not have. Dropped here previously — an anchored spec would fill a manifest
|
|
400
|
+
# that silently looked fixed-offset.
|
|
401
|
+
anchor = (
|
|
402
|
+
ReadAnchor(
|
|
403
|
+
relative_to=el.anchor.relative_to,
|
|
404
|
+
ref_element=el.anchor.ref_element,
|
|
405
|
+
ref_side=el.anchor.ref_side,
|
|
406
|
+
offset=el.anchor.offset,
|
|
407
|
+
motif=el.anchor.motif,
|
|
408
|
+
max_mismatch=el.anchor.max_mismatch,
|
|
409
|
+
)
|
|
410
|
+
if el.anchor is not None
|
|
411
|
+
else None
|
|
412
|
+
)
|
|
413
|
+
return ReadElement(
|
|
414
|
+
role=_ROLE_FOR_TYPE.get(el.type, "linker"), # type: ignore[arg-type]
|
|
415
|
+
region_type=el.seqspec_region_type,
|
|
416
|
+
start=el.start,
|
|
417
|
+
length=length,
|
|
418
|
+
min_len=el.min_len,
|
|
419
|
+
max_len=el.max_len,
|
|
420
|
+
sequence=el.sequence,
|
|
421
|
+
onlist_ref=onlist_ref,
|
|
422
|
+
anchor=anchor,
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _build_onlists(spec: Spec, registry: OnlistRegistry) -> list[Onlist]:
|
|
427
|
+
"""Registry-backed whitelist entries for the onlists this chemistry's ELEMENTS actually use.
|
|
428
|
+
|
|
429
|
+
An onlist referenced only by an ``excludes`` anti-gate is a detection probe, not part of the
|
|
430
|
+
library, and is not recorded. A registry entry without a real URI + sha256 (a declared-but-not-
|
|
431
|
+
materialized real list) is skipped — ``validate`` surfaces that as a warning, not a silent pass.
|
|
432
|
+
"""
|
|
433
|
+
used = {el.onlist for read in spec.reads for el in read.elements if el.onlist}
|
|
434
|
+
out: list[Onlist] = []
|
|
435
|
+
for alias in sorted(used):
|
|
436
|
+
name = spec.onlists[alias].registry
|
|
437
|
+
if not registry.has(name):
|
|
438
|
+
continue
|
|
439
|
+
entry = registry.get(name)
|
|
440
|
+
if not entry.uri or not _SHA256.match(entry.sha256):
|
|
441
|
+
continue # declared but not materialized (e.g. a license-restricted real list)
|
|
442
|
+
hint: Literal["forward", "reverse_complement"] | None
|
|
443
|
+
hint = {"forward": "forward", "revcomp": "reverse_complement"}.get(entry.orientation) # type: ignore[assignment]
|
|
444
|
+
out.append(
|
|
445
|
+
Onlist(
|
|
446
|
+
name=name,
|
|
447
|
+
uri=entry.uri,
|
|
448
|
+
sha256=entry.sha256,
|
|
449
|
+
length=entry.width,
|
|
450
|
+
orientation_hint=hint,
|
|
451
|
+
n_entries=entry.n_entries,
|
|
452
|
+
)
|
|
453
|
+
)
|
|
454
|
+
return out
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def dataset_uris(observations: list[Observation]) -> dict[str, str]:
|
|
458
|
+
"""sha256 -> the file's URI: its path **relative to the dataset's own root**.
|
|
459
|
+
|
|
460
|
+
**Public, and that is the point.** The URI form has exactly one owner, because the moment it had
|
|
461
|
+
two they disagreed: this function got it right and `cli.py` built `SampleGroup.file_uris` out of
|
|
462
|
+
basenames beside it, so `manifest fill` refused its own manifest with six referential-integrity
|
|
463
|
+
Blockers ("sample 'SRR28716553' references 'SRR28716553_1.fastq.gz', which is not in the library
|
|
464
|
+
file inventory"). The validator did its job; the duplication was the bug. One function, every
|
|
465
|
+
caller.
|
|
466
|
+
|
|
467
|
+
Not the basename, which is what this was. Two things broke on the first real dataset — 6 runs
|
|
468
|
+
that ``fasterq-dump`` had written one directory per accession
|
|
469
|
+
(``SRX24283130/SRR28716558_1.fastq.gz``):
|
|
470
|
+
|
|
471
|
+
1. ``compose --fastq-dir <root>`` joins the URI to the root, so bare basenames resolved to
|
|
472
|
+
``<root>/SRR28716558_1.fastq.gz`` — a path that does not exist, in a `units.tsv` that looks
|
|
473
|
+
perfectly reasonable.
|
|
474
|
+
2. Worse and silent: a basename is **not unique**. Two runs each carrying ``reads_1.fastq.gz`` in
|
|
475
|
+
their own directory collapse to one URI, and `_units` looks files up *by URI* — so one run's
|
|
476
|
+
reads would quietly become the other's. Nothing would have said so.
|
|
477
|
+
|
|
478
|
+
A path relative to the common root keeps every URI distinct and machine-independent, which is all
|
|
479
|
+
machine-independence ever asked for: it forbids an *absolute* path, not structure. A flat
|
|
480
|
+
directory degenerates to the basenames this always produced. Files with no shared root fall back
|
|
481
|
+
to basenames — there is no relative name that spans two filesystems, and inventing one would be
|
|
482
|
+
worse than the fallback.
|
|
483
|
+
"""
|
|
484
|
+
locals_ = {o.file.sha256: o.file.local_uri for o in observations if o.file.local_uri}
|
|
485
|
+
if len(locals_) == len(observations) > 0:
|
|
486
|
+
paths = [Path(p) for p in locals_.values()]
|
|
487
|
+
try:
|
|
488
|
+
root = Path(os.path.commonpath([str(p.parent.resolve()) for p in paths]))
|
|
489
|
+
except ValueError: # different drives / no common root
|
|
490
|
+
root = None
|
|
491
|
+
if root is not None:
|
|
492
|
+
return {sha: str(Path(p).resolve().relative_to(root)) for sha, p in locals_.items()}
|
|
493
|
+
return {o.file.sha256: o.file.basename for o in observations}
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def _build_files(
|
|
497
|
+
winner: Candidate,
|
|
498
|
+
observations: list[Observation],
|
|
499
|
+
role_of_sha: dict[str, str] | None = None,
|
|
500
|
+
uris: dict[str, str] | None = None,
|
|
501
|
+
) -> list[FileInventoryItem]:
|
|
502
|
+
"""File identity is raw observed truth; the role is the other half of the chemistry decision.
|
|
503
|
+
|
|
504
|
+
No confidence per file: the assignment and the chemistry came out of one joint optimization, and
|
|
505
|
+
``library.chemistry`` carries its score. Twelve files each restating it is one number thirteen
|
|
506
|
+
times, which is exactly what the pilot's manifest looked like.
|
|
507
|
+
"""
|
|
508
|
+
# A dataset-wide ``uris`` (multi-assay fill) overrides the local computation so every assay's
|
|
509
|
+
# files are anchored on the same root; omitted, we compute it here as before.
|
|
510
|
+
if uris is None:
|
|
511
|
+
uris = dataset_uris(observations)
|
|
512
|
+
if role_of_sha is None:
|
|
513
|
+
# Single-run fill: build the map here, tagging index-sized leftovers the same way the
|
|
514
|
+
# dataset-level path does, so a stray 10x I1/I2 is set aside rather than left to block.
|
|
515
|
+
from ..resolve.engine import index_tagged_roles
|
|
516
|
+
|
|
517
|
+
role_of_sha = index_tagged_roles(winner, observations)
|
|
518
|
+
return [
|
|
519
|
+
FileInventoryItem(
|
|
520
|
+
# relative to the dataset root; never Observation.file.local_uri, which is absolute
|
|
521
|
+
uri=uris[obs.file.sha256],
|
|
522
|
+
basename=obs.file.basename,
|
|
523
|
+
sha256=obs.file.sha256,
|
|
524
|
+
size_bytes=obs.file.size_bytes,
|
|
525
|
+
read_id=role_of_sha.get(obs.file.sha256),
|
|
526
|
+
)
|
|
527
|
+
# Sorted by content hash so `library.files` — and the dataset content hash computed over it —
|
|
528
|
+
# is byte-identical however the observations were ordered (a forked probe pool need not return
|
|
529
|
+
# them in submission order). GSE208154 hashed differently at --cpus 1 vs 4 before this.
|
|
530
|
+
for obs in sorted(observations, key=lambda o: o.file.sha256)
|
|
531
|
+
]
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Content-addressed identity for the two artifacts and for the run that pairs them.
|
|
2
|
+
|
|
3
|
+
Three hashes, because there are three things worth identifying and they have different lifetimes:
|
|
4
|
+
|
|
5
|
+
- :func:`dataset_content_hash` — over ``library`` + ``experiment``. **Invariant under any processing
|
|
6
|
+
change**, which is the entire point of the split: re-running a dataset with a different aligner must
|
|
7
|
+
not perturb what the dataset *is*.
|
|
8
|
+
- :func:`processing_content_hash` — over the intent + its dataset pin.
|
|
9
|
+
- :func:`run_id` — ``H(dataset ⊕ processing ⊕ kb ⊕ workflow)``. The pairing is recorded **here**, at
|
|
10
|
+
compile time, and never inside either input. That is what lets one processing manifest stay a
|
|
11
|
+
portable template across 10^4 datasets while each pairing still gets a distinct identity.
|
|
12
|
+
|
|
13
|
+
Neither content hash covers its own ``provenance``, which carries it.
|
|
14
|
+
|
|
15
|
+
**Why this shape, and not the old one.** ``provenance_id(manifest_hash, kb, workflow)`` folded intent
|
|
16
|
+
into the manifest hash, so it could not express "one dataset, N processing manifests" — the two runs
|
|
17
|
+
collided on a single id, and the composer's fixed output path meant the second silently overwrote the
|
|
18
|
+
first. The collision case was exactly the use case the split exists for.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import hashlib
|
|
24
|
+
import json
|
|
25
|
+
|
|
26
|
+
from ..models.dataset import DatasetManifest
|
|
27
|
+
from ..models.processing import ProcessingManifest
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _canonical(payload: dict[str, object]) -> str:
|
|
31
|
+
return hashlib.sha256(
|
|
32
|
+
json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()
|
|
33
|
+
).hexdigest()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def dataset_content_hash(manifest: DatasetManifest) -> str:
|
|
37
|
+
"""Deterministic sha256 over the dataset's two truth sections (canonical JSON).
|
|
38
|
+
|
|
39
|
+
Note what is NOT folded in: ``PROBE_VERSION``. This hashes serialized *values*, so a probe change
|
|
40
|
+
that alters an observed value changes the hash (and a processing manifest pinned to the old one
|
|
41
|
+
correctly refuses); a probe refactor that changes nothing observable leaves it identical (and the
|
|
42
|
+
pin still resolves). Stamping the probe version in here would invalidate every pin on a no-op
|
|
43
|
+
refactor — the version belongs in the ``.seqforge/`` cache key, where it already is.
|
|
44
|
+
"""
|
|
45
|
+
return _canonical(
|
|
46
|
+
{
|
|
47
|
+
"library": manifest.library.model_dump(mode="json"),
|
|
48
|
+
"experiment": manifest.experiment.model_dump(mode="json"),
|
|
49
|
+
}
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def processing_content_hash(processing: ProcessingManifest) -> str:
|
|
54
|
+
"""Deterministic sha256 over the processing intent + its dataset pin (canonical JSON)."""
|
|
55
|
+
return _canonical(
|
|
56
|
+
{
|
|
57
|
+
"processing_id": processing.processing_id,
|
|
58
|
+
"dataset": processing.dataset.model_dump(mode="json") if processing.dataset else None,
|
|
59
|
+
"processing": processing.processing.model_dump(mode="json"),
|
|
60
|
+
}
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def run_id(
|
|
65
|
+
*, dataset_hash: str, processing_hash: str, kb_version: str, workflow_version: str
|
|
66
|
+
) -> str:
|
|
67
|
+
"""``H(dataset ⊕ processing ⊕ kb ⊕ workflow)`` — one run's content-addressed identity.
|
|
68
|
+
|
|
69
|
+
This is where the split pays: one dataset x N processing manifests = N distinct run ids over ONE
|
|
70
|
+
stable dataset hash. Keying the pipeline output directory by this is what stops the second
|
|
71
|
+
compose of a dataset from overwriting the first.
|
|
72
|
+
"""
|
|
73
|
+
key = f"{dataset_hash}|proc={processing_hash}|kb={kb_version}|wf={workflow_version}"
|
|
74
|
+
return hashlib.sha256(key.encode()).hexdigest()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
__all__ = ["dataset_content_hash", "processing_content_hash", "run_id"]
|