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