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,157 @@
|
|
|
1
|
+
"""``Observation`` — deterministic, LLM-free, network-free probe output for ONE file.
|
|
2
|
+
|
|
3
|
+
Cached by file sha256. Reports structural signals ONLY; it MUST NOT assign roles — mapping
|
|
4
|
+
``constant -> linker/TSO``, ``random -> CB|UMI|cDNA``, ``homopolymer-T -> polyT`` is the resolver's
|
|
5
|
+
job, scored and second-guessable.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Annotated, Literal
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
13
|
+
|
|
14
|
+
from .base import Confidence, LocalPath, Sha256
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class CycleComposition(BaseModel):
|
|
18
|
+
"""Base fractions at one 0-based cycle; ``a + c + g + t + n ~= 1.0``."""
|
|
19
|
+
|
|
20
|
+
cycle: int = Field(ge=0)
|
|
21
|
+
a: float
|
|
22
|
+
c: float
|
|
23
|
+
g: float
|
|
24
|
+
t: float
|
|
25
|
+
n: float
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ConstantSegment(BaseModel):
|
|
29
|
+
"""A cycle span where one base dominates (>~90%): a linker/adapter/TSO candidate.
|
|
30
|
+
|
|
31
|
+
Structural only — the role is NOT assigned here.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
kind: Literal["constant"] = "constant"
|
|
35
|
+
start: int = Field(ge=0)
|
|
36
|
+
end: int = Field(ge=0)
|
|
37
|
+
consensus: str
|
|
38
|
+
purity: Confidence
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class RandomSegment(BaseModel):
|
|
42
|
+
"""A near-uniform ACGT span: a CB/UMI/cDNA candidate (role NOT assigned)."""
|
|
43
|
+
|
|
44
|
+
kind: Literal["random"] = "random"
|
|
45
|
+
start: int = Field(ge=0)
|
|
46
|
+
end: int = Field(ge=0)
|
|
47
|
+
mean_entropy_bits: float
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class HomopolymerSegment(BaseModel):
|
|
51
|
+
"""A run of one base (polyT capture / polyA tail): structural only."""
|
|
52
|
+
|
|
53
|
+
kind: Literal["homopolymer"] = "homopolymer"
|
|
54
|
+
base: Literal["A", "C", "G", "T"]
|
|
55
|
+
start: int = Field(ge=0)
|
|
56
|
+
end: int = Field(ge=0)
|
|
57
|
+
mean_run: float
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
Segment = Annotated[
|
|
61
|
+
ConstantSegment | RandomSegment | HomopolymerSegment,
|
|
62
|
+
Field(discriminator="kind"),
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class FileIdentity(BaseModel):
|
|
67
|
+
"""Content identity of one FASTQ. Observation is internal, so a LOCAL path is allowed here only.
|
|
68
|
+
|
|
69
|
+
``sha256`` is a **content-address**, not a whole-file hash. Locally it is a bounded key over the
|
|
70
|
+
basename + head sample + size + gzip ISIZE (``probe.core._content_key``, issue #37). For hosted
|
|
71
|
+
bytes it is derived from the provider md5 (``probe.core.content_key_from_md5``, issue #39) — a
|
|
72
|
+
64-hex name that is a pure function of the md5, so two hosted files with the same md5 dedup. Either
|
|
73
|
+
way fingerprinting never reads a whole FASTQ; a remote probe range-reads only a bounded head.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
sha256: Sha256
|
|
77
|
+
size_bytes: int = Field(gt=0)
|
|
78
|
+
basename: str
|
|
79
|
+
local_uri: LocalPath | None = None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class ProbeProvenance(BaseModel):
|
|
83
|
+
"""What the bounded probe did under its read/byte budget.
|
|
84
|
+
|
|
85
|
+
``bytes_read`` is decompressed; ``compressed_bytes_read`` drives ``estimated_total_reads``
|
|
86
|
+
(avoids the compression-ratio undercount).
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
n_reads_sampled: int = Field(ge=0)
|
|
90
|
+
bytes_read: int = Field(ge=0)
|
|
91
|
+
compressed_bytes_read: int = Field(ge=0)
|
|
92
|
+
tool_version: str
|
|
93
|
+
params_hash: str
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class ReadLengthProfile(BaseModel):
|
|
97
|
+
"""Read-length summary. ``n_distinct > 1`` on a fixed-geometry read -> PRETRIMMED_VARIABLE_LENGTH."""
|
|
98
|
+
|
|
99
|
+
mode: int = Field(ge=0)
|
|
100
|
+
n_distinct: int = Field(ge=1)
|
|
101
|
+
min_len: int = Field(ge=0)
|
|
102
|
+
max_len: int = Field(ge=0)
|
|
103
|
+
percentiles: dict[str, int] | None = None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class WindowDistinctRatio(BaseModel):
|
|
107
|
+
"""``distinct/total`` over a candidate cycle window.
|
|
108
|
+
|
|
109
|
+
DEPTH-DEPENDENT: a supports signal only, never a gate. Normalize with ``4^len`` and sampled-N
|
|
110
|
+
before interpreting (see the scorer).
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
start: int = Field(ge=0)
|
|
114
|
+
end: int = Field(ge=0)
|
|
115
|
+
distinct_ratio: Confidence
|
|
116
|
+
n_sampled: int = Field(ge=1)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class ReadNameGrammar(BaseModel):
|
|
120
|
+
"""Parsed Illumina header; all optional. ``sra_normalized`` flags an ``@SRR....N`` rewrite."""
|
|
121
|
+
|
|
122
|
+
parsed: bool = False
|
|
123
|
+
instrument: str | None = None
|
|
124
|
+
run: str | None = None
|
|
125
|
+
flowcell: str | None = None
|
|
126
|
+
lane: int | None = None
|
|
127
|
+
tile: int | None = None
|
|
128
|
+
index: str | None = None
|
|
129
|
+
sra_normalized: bool = False
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class GzipIntegrity(BaseModel):
|
|
133
|
+
"""Gzip stream integrity. ``truncated`` -> TRUNCATED_GZIP Blocker downstream."""
|
|
134
|
+
|
|
135
|
+
ok: bool
|
|
136
|
+
truncated: bool
|
|
137
|
+
bgzf: bool | None = None
|
|
138
|
+
member_count: int | None = None
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class Observation(BaseModel):
|
|
142
|
+
"""Structural, role-free probe output for one file, cached by ``file.sha256``."""
|
|
143
|
+
|
|
144
|
+
model_config = ConfigDict(frozen=True)
|
|
145
|
+
|
|
146
|
+
file: FileIdentity
|
|
147
|
+
probe: ProbeProvenance
|
|
148
|
+
per_cycle_composition: list[CycleComposition]
|
|
149
|
+
segments: list[Segment]
|
|
150
|
+
read_length: ReadLengthProfile
|
|
151
|
+
distinct_value_windows: list[WindowDistinctRatio]
|
|
152
|
+
read_name: ReadNameGrammar
|
|
153
|
+
quality_encoding: Literal["phred33", "phred64", "unknown"]
|
|
154
|
+
n_rate: Confidence
|
|
155
|
+
estimated_total_reads: int = Field(ge=0)
|
|
156
|
+
est_method: Literal["isize", "compressed_ratio"]
|
|
157
|
+
gzip: GzipIntegrity
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
""":class:`ProcessingManifest` — one way to process a dataset. Many per dataset.
|
|
2
|
+
|
|
3
|
+
A finished assay is immutable; what you *do* with it is a choice, and there are several defensible
|
|
4
|
+
ones. So the manifest is two artifacts with two lifetimes:
|
|
5
|
+
|
|
6
|
+
- :class:`~seqforge.models.dataset.DatasetManifest` — the **IR**. What the data *is*. One per dataset.
|
|
7
|
+
- :class:`ProcessingManifest` — the **flags**. What to *do* with it. Many per dataset.
|
|
8
|
+
|
|
9
|
+
That is the compiler metaphor doing work rather than decorating it. Same IR + different flags =
|
|
10
|
+
different binaries; same dataset + different processing manifests = different pipelines, with the
|
|
11
|
+
dataset hash unchanged. ``-O2`` does not get to edit the IR, and neither does anything in this file.
|
|
12
|
+
|
|
13
|
+
**This section is intent, not truth, and it has no authority.** Every field is still ``Evidenced``,
|
|
14
|
+
but for a different reason than in ``dataset``: there, ``basis`` records HOW WE KNOW; here it records
|
|
15
|
+
WHO DECIDED. A corpus row reading "GeneFull because the user's instruction file said so" is
|
|
16
|
+
categorically different from "GeneFull because policy defaults to all five", and that difference has
|
|
17
|
+
to survive into the training corpus. ``user_confirmed`` — which has sat in the ``Basis`` literal since
|
|
18
|
+
the beginning without a single writer — is the basis this section exists to carry.
|
|
19
|
+
|
|
20
|
+
This module imports nothing from ``dataset``, and must not (see that module's header).
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from typing import Annotated, Literal
|
|
26
|
+
|
|
27
|
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
28
|
+
|
|
29
|
+
from .base import Accession, Evidenced, NcbiTaxid, Sha256
|
|
30
|
+
from .evidenced import EvidencedBool, EvidencedStr
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class GenomeRef(BaseModel):
|
|
34
|
+
"""liulab-genome selection: UCSC assembly id + a REGISTERED GTF name. Never a path."""
|
|
35
|
+
|
|
36
|
+
assembly: str
|
|
37
|
+
annotation_name: str
|
|
38
|
+
ncbi_taxid: NcbiTaxid | None = None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class EvidencedGenome(Evidenced[GenomeRef]):
|
|
42
|
+
"""An ``Evidenced`` genome reference."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
RuntimeEnv = Literal["align-rna", "align-dna", "ml", "ml-gpu"]
|
|
46
|
+
"""A literal liulab-runtime environment name — the env name IS the identifier (no profile layer)."""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class EvidencedRuntimeEnv(Evidenced[RuntimeEnv]):
|
|
50
|
+
"""An ``Evidenced`` liulab-runtime environment name."""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
SoloFeature = Literal[
|
|
54
|
+
"Gene",
|
|
55
|
+
"SJ",
|
|
56
|
+
"GeneFull",
|
|
57
|
+
"GeneFull_ExonOverIntron",
|
|
58
|
+
"GeneFull_Ex50pAS",
|
|
59
|
+
"Velocyto",
|
|
60
|
+
]
|
|
61
|
+
"""STARsolo's complete ``--soloFeatures`` vocabulary.
|
|
62
|
+
|
|
63
|
+
Closed on purpose, and it is the closure that does the safety work: ``verify.entails`` is **vacuous
|
|
64
|
+
when value ⊆ quote**, so span-verification only bites for a controlled vocabulary. Because this one is closed,
|
|
65
|
+
"aligned in GeneFull mode" entails ``GeneFull`` and "count introns too" does not — and the second
|
|
66
|
+
rejection is the *right* answer, not a gap to paper over with aliases.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class SoloQuant(BaseModel):
|
|
71
|
+
"""STARsolo counting. An ORDERED list; element 0 is the primary matrix.
|
|
72
|
+
|
|
73
|
+
Order is a seqforge-side annotation with no aligner-side referent: STARsolo writes one
|
|
74
|
+
``Solo.out/<Feature>/`` per value and does not care about order, so ``[Gene, GeneFull]`` and
|
|
75
|
+
``[GeneFull, Gene]`` produce byte-identical output. What the order buys is a deterministic answer
|
|
76
|
+
to "which matrix is THE matrix" without a second field — and because it has no effect on the
|
|
77
|
+
aligner, ``compose`` projects it out to an explicit ``primary_feature`` key rather than leaving a
|
|
78
|
+
positional convention load-bearing for every downstream reader.
|
|
79
|
+
|
|
80
|
+
A known cells-vs-nuclei prep fact may only REORDER this list, never shorten it. Compute is spent
|
|
81
|
+
once, count matrices are small, and dropping a feature is the only irreversible act available.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
kind: Literal["solo"] = "solo"
|
|
85
|
+
features: list[SoloFeature] = Field(min_length=1)
|
|
86
|
+
|
|
87
|
+
@model_validator(mode="after")
|
|
88
|
+
def _starsolo_rules(self) -> SoloQuant:
|
|
89
|
+
if len(set(self.features)) != len(self.features):
|
|
90
|
+
raise ValueError(f"duplicate soloFeatures: {self.features}")
|
|
91
|
+
# STARsolo's docs: "Velocyto quantification requires Gene features". A real aligner
|
|
92
|
+
# constraint, so it is a validator and not a comment — STAR would error out, but only AFTER
|
|
93
|
+
# the download and the alignment we were amortizing. Refuse first, with a remedy. This
|
|
94
|
+
# is also the clearest proof that a closed vocabulary is not by itself armor: no enum can
|
|
95
|
+
# express "this member requires that one".
|
|
96
|
+
if "Velocyto" in self.features and "Gene" not in self.features:
|
|
97
|
+
raise ValueError(
|
|
98
|
+
"STARsolo requires Gene features for Velocyto quantification; add 'Gene' to features"
|
|
99
|
+
)
|
|
100
|
+
return self
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class BulkQuant(BaseModel):
|
|
104
|
+
"""Plain-STAR counting.
|
|
105
|
+
|
|
106
|
+
There is no strandedness knob here and there never needs to be: ``--quantMode GeneCounts``
|
|
107
|
+
already emits all three strand columns in ``ReadsPerGene.out.tab``. ``bulk-rnaseq-pe``'s spec
|
|
108
|
+
long carried a comment promising that "library strandedness is resolved at compose from
|
|
109
|
+
processing policy", and no code ever implemented it — because there was never a decision to make.
|
|
110
|
+
Same law as ``soloFeatures``: when the aligner computes every alternative in one pass and the
|
|
111
|
+
outputs are small, compute them all and let the consumer choose.
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
kind: Literal["bulk"] = "bulk"
|
|
115
|
+
mode: Literal["GeneCounts", "TranscriptomeSAM", "None"] = "GeneCounts"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
Quantification = Annotated[SoloQuant | BulkQuant, Field(discriminator="kind")]
|
|
119
|
+
"""What to COUNT, discriminated by aligner family (the house style: cf. ``Segment``, ``Test``)."""
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class EvidencedQuantification(Evidenced[Quantification]):
|
|
123
|
+
"""An ``Evidenced`` counting decision — the field that used to be decorative.
|
|
124
|
+
|
|
125
|
+
Policy set it to the string ``"gene"``, wrote it to the manifest, and ``compose`` then ignored it
|
|
126
|
+
and read ``soloFeatures`` from the KB instead: two sources of truth for one decision, unable to
|
|
127
|
+
disagree only because one was never consulted.
|
|
128
|
+
"""
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class ResourceHints(BaseModel):
|
|
132
|
+
"""Advisory resource requests for the workflow scheduler."""
|
|
133
|
+
|
|
134
|
+
threads: int = Field(ge=1, default=8)
|
|
135
|
+
mem_gb: int = Field(ge=1, default=32)
|
|
136
|
+
disk_gb: int | None = None
|
|
137
|
+
gpus: int = Field(ge=0, default=0)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class ProcessingSection(BaseModel):
|
|
141
|
+
"""INTENT — what we choose to do to a finished assay. Not a truth; no authority.
|
|
142
|
+
|
|
143
|
+
``basis`` here records WHO DECIDED, on this ladder (highest first):
|
|
144
|
+
|
|
145
|
+
================== ================= ==========================
|
|
146
|
+
source basis evidence
|
|
147
|
+
================== ================= ==========================
|
|
148
|
+
a CLI flag ``user_confirmed`` ``["cli:--quantify"]``
|
|
149
|
+
an instruction doc ``user_confirmed`` ``["assert-..."]``
|
|
150
|
+
reference prose ``asserted`` ``["assert-..."]``
|
|
151
|
+
a policy default ``inferred`` ``["policy:<rule>"]``
|
|
152
|
+
================== ================= ==========================
|
|
153
|
+
|
|
154
|
+
The first two share a basis and differ only in precedence — both are the user talking to seqforge,
|
|
155
|
+
one just talks later — so the *channel* lives in ``evidence``. That is what design §1.0's open
|
|
156
|
+
note already prescribed, and it is why no ``policy_default`` basis is needed: once a section can
|
|
157
|
+
carry a **varying** basis, ``inferred`` plus an evidence ref naming the rule is distinguishable by
|
|
158
|
+
inspection.
|
|
159
|
+
"""
|
|
160
|
+
|
|
161
|
+
# `extra="forbid"` is that enforcement at the model, not just at the gate. The instructable
|
|
162
|
+
# surface is *enumerated*; an unknown key must be a validation error, never a silent drop. It was
|
|
163
|
+
# a silent drop until 2026-07-15 — `ProcessingSection(soloStrand="Reverse")` constructed happily
|
|
164
|
+
# and discarded the field — which is pydantic's default, and the wrong default here: this is the
|
|
165
|
+
# artifact a user hands us, so an unrecognised key is either a typo or an attempt to reach a
|
|
166
|
+
# parse decision, and both deserve to fail loudly rather than be dropped on the floor.
|
|
167
|
+
model_config = ConfigDict(extra="forbid")
|
|
168
|
+
|
|
169
|
+
genome: EvidencedGenome
|
|
170
|
+
aligner: EvidencedStr
|
|
171
|
+
quantification: EvidencedQuantification
|
|
172
|
+
variant_calling: EvidencedBool
|
|
173
|
+
environment: EvidencedRuntimeEnv
|
|
174
|
+
resources: ResourceHints = Field(default_factory=ResourceHints)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class DatasetPin(BaseModel):
|
|
178
|
+
"""Which dataset a processing manifest is bound to."""
|
|
179
|
+
|
|
180
|
+
dataset_hash: Sha256
|
|
181
|
+
accessions: list[Accession] = Field(default_factory=list) # human-readable, advisory only
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
class ProcessingProvenance(BaseModel):
|
|
185
|
+
"""Binds a processing manifest to the module source that will execute it."""
|
|
186
|
+
|
|
187
|
+
processing_hash: str
|
|
188
|
+
workflow_version: str
|
|
189
|
+
seqforge_version: str
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
class ProcessingManifest(BaseModel):
|
|
193
|
+
"""One way to process a dataset. Many per dataset — that plurality IS the design.
|
|
194
|
+
|
|
195
|
+
``dataset is None`` => a **template**: it applies to any dataset, which is what lets one file drive
|
|
196
|
+
10^4 of them (this is scRecounter's uniform reprocessing, and it is the half of the design that a
|
|
197
|
+
mandatory pin would destroy — you would have 10^4 near-identical files, none of which anyone
|
|
198
|
+
reads, and the file would stop carrying signal).
|
|
199
|
+
|
|
200
|
+
``dataset is not None`` => **bound**: ``compose`` refuses any dataset whose hash differs, with a
|
|
201
|
+
``Blocker`` (exit 3), and never auto-repins.
|
|
202
|
+
|
|
203
|
+
``compose`` always writes the bound form it actually used next to the config it produced, so the
|
|
204
|
+
default path leaves recoverable state on disk without demanding an input file. Disk is
|
|
205
|
+
*state*, not *input*.
|
|
206
|
+
"""
|
|
207
|
+
|
|
208
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
209
|
+
|
|
210
|
+
processing_id: str # a human slug, e.g. "default-2026.7" / "genefull-primary"
|
|
211
|
+
dataset: DatasetPin | None = None
|
|
212
|
+
processing: ProcessingSection
|
|
213
|
+
provenance: ProcessingProvenance
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
__all__ = [
|
|
217
|
+
"GenomeRef",
|
|
218
|
+
"EvidencedGenome",
|
|
219
|
+
"RuntimeEnv",
|
|
220
|
+
"EvidencedRuntimeEnv",
|
|
221
|
+
"SoloFeature",
|
|
222
|
+
"SoloQuant",
|
|
223
|
+
"BulkQuant",
|
|
224
|
+
"Quantification",
|
|
225
|
+
"EvidencedQuantification",
|
|
226
|
+
"ResourceHints",
|
|
227
|
+
"ProcessingSection",
|
|
228
|
+
"DatasetPin",
|
|
229
|
+
"ProcessingProvenance",
|
|
230
|
+
"ProcessingManifest",
|
|
231
|
+
]
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""``ArchiveRecord`` — what a public archive *declares* about a dataset, before anyone interprets it.
|
|
2
|
+
|
|
3
|
+
A record is not a truth and not a manifest. It is a transcript: this is what the submitter typed and
|
|
4
|
+
the archive stored, split into the two halves that need different machinery.
|
|
5
|
+
|
|
6
|
+
- ``attributes`` is the **structured** half (``strain = CQ758``). Code parses it. No model is
|
|
7
|
+
involved, and none is needed: it is already a key and a value.
|
|
8
|
+
- ``free_text`` is the **prose** half (``"Rep3 daf2 reads"``, a study abstract, a protocol
|
|
9
|
+
paragraph). Code cannot parse it; that is job (a), and it is what harvest is for.
|
|
10
|
+
|
|
11
|
+
**Every record is optional, and that is a requirement rather than an accident.** seqforge compiles
|
|
12
|
+
FASTQ that arrives with an accession, FASTQ that arrives with a README, and FASTQ that arrives with
|
|
13
|
+
nothing. There is no archive for a freshly-sequenced plate on a lab filesystem, so no code path may
|
|
14
|
+
assume one exists, and "no record" must produce a quieter manifest rather than a refusal. What a
|
|
15
|
+
record adds when it *is* there is per-sample subject identity — the thing a dataset-level document
|
|
16
|
+
can never supply.
|
|
17
|
+
|
|
18
|
+
**The hierarchy is the archive's, and the join is ours.** ``parent`` points one level up
|
|
19
|
+
(run -> experiment -> sample -> project) and is copied out of the record, never inferred: the archive
|
|
20
|
+
already knows which run came from which sample, and re-deriving that from filenames would be a guess
|
|
21
|
+
where a fact was available. What code still has to decide is the last hop — record run to *file on
|
|
22
|
+
disk* — because the archive does not know what you downloaded or what you named it.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from typing import Literal
|
|
28
|
+
|
|
29
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
30
|
+
|
|
31
|
+
#: The four levels of the archive's own hierarchy. ``project`` is the study; ``sample`` is the
|
|
32
|
+
#: biological material; ``experiment`` is the library prep; ``run`` is one sequencing run == the
|
|
33
|
+
#: files. Other archives use other words for the same four things.
|
|
34
|
+
RecordLevel = Literal["project", "sample", "experiment", "run"]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class FreeText(BaseModel):
|
|
38
|
+
"""One piece of prose from a record, and what the archive called it.
|
|
39
|
+
|
|
40
|
+
``label`` is the archive's own field name (``sample_alias``, ``design_description``), kept so a
|
|
41
|
+
quote can be traced to the field it came out of rather than to an anonymous blob of text.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
model_config = ConfigDict(frozen=True)
|
|
45
|
+
|
|
46
|
+
label: str
|
|
47
|
+
text: str
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class RecordAttribute(BaseModel):
|
|
51
|
+
"""One structured key/value a record declares.
|
|
52
|
+
|
|
53
|
+
``harmonized`` records whether ``name`` is one of NCBI's 960 curated attribute names or the
|
|
54
|
+
submitter's own invention. Both are kept: an unharmonized attribute is a real thing the submitter
|
|
55
|
+
said, and dropping it would lose information, while promoting it into the controlled key space
|
|
56
|
+
would be a guess. Only a harmonized attribute may become a manifest sample fact.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
model_config = ConfigDict(frozen=True)
|
|
60
|
+
|
|
61
|
+
name: str
|
|
62
|
+
value: str
|
|
63
|
+
harmonized: bool = False
|
|
64
|
+
#: The submitter's raw tag, when it differs from ``name``. Provenance for the harmonization.
|
|
65
|
+
raw_name: str | None = None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class ArchiveRecord(BaseModel):
|
|
69
|
+
"""One level of one archive record, as fetched. A transcript, not an interpretation."""
|
|
70
|
+
|
|
71
|
+
model_config = ConfigDict(frozen=True)
|
|
72
|
+
|
|
73
|
+
level: RecordLevel
|
|
74
|
+
#: The record's id in whatever namespace produced it. Not typed as ``Accession``: this is the
|
|
75
|
+
#: archive's word for the record, and seqforge is not only ever handed NCBI accessions.
|
|
76
|
+
accession: str
|
|
77
|
+
#: The record one level up, by its own id. Copied from the record; never inferred.
|
|
78
|
+
parent: str | None = None
|
|
79
|
+
attributes: list[RecordAttribute] = Field(default_factory=list)
|
|
80
|
+
free_text: list[FreeText] = Field(default_factory=list)
|
|
81
|
+
#: Filenames the archive says this record's data was submitted as. The only thing that can join a
|
|
82
|
+
#: record to a file whose name no longer contains the accession.
|
|
83
|
+
filenames: list[str] = Field(default_factory=list)
|
|
84
|
+
|
|
85
|
+
def attribute(self, name: str) -> str | None:
|
|
86
|
+
"""The value of a harmonized attribute, or ``None``. Never raises — absence is normal."""
|
|
87
|
+
for attr in self.attributes:
|
|
88
|
+
if attr.name == name and attr.harmonized:
|
|
89
|
+
return attr.value
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
def text(self, label: str) -> str | None:
|
|
93
|
+
for ft in self.free_text:
|
|
94
|
+
if ft.label == label:
|
|
95
|
+
return ft.text
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class ArchiveRecordSet(BaseModel):
|
|
100
|
+
"""Every record fetched for one query, across all four levels.
|
|
101
|
+
|
|
102
|
+
Content-addressed and cached under the workspace: a record is a fact about the archive at a
|
|
103
|
+
moment, so re-fetching it should be a choice rather than a side effect of re-running.
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
model_config = ConfigDict(frozen=True)
|
|
107
|
+
|
|
108
|
+
#: Which archive, and how. e.g. ``ncbi-sra+biosample``.
|
|
109
|
+
source: str
|
|
110
|
+
#: What was asked for. The accession a human typed.
|
|
111
|
+
query: str
|
|
112
|
+
records: list[ArchiveRecord] = Field(default_factory=list)
|
|
113
|
+
|
|
114
|
+
def at(self, level: RecordLevel) -> list[ArchiveRecord]:
|
|
115
|
+
return [r for r in self.records if r.level == level]
|
|
116
|
+
|
|
117
|
+
def by_accession(self, accession: str) -> ArchiveRecord | None:
|
|
118
|
+
for r in self.records:
|
|
119
|
+
if r.accession == accession:
|
|
120
|
+
return r
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
def ancestor(self, record: ArchiveRecord, level: RecordLevel) -> ArchiveRecord | None:
|
|
124
|
+
"""Walk ``parent`` up to ``level``. The join, and it is pure record-following.
|
|
125
|
+
|
|
126
|
+
Bounded by the number of records so a record set with a parent cycle (an archive bug, or a
|
|
127
|
+
hand-written one) terminates rather than hanging.
|
|
128
|
+
"""
|
|
129
|
+
seen: set[str] = set()
|
|
130
|
+
current: ArchiveRecord | None = record
|
|
131
|
+
while current is not None and current.accession not in seen:
|
|
132
|
+
if current.level == level:
|
|
133
|
+
return current
|
|
134
|
+
seen.add(current.accession)
|
|
135
|
+
current = self.by_accession(current.parent) if current.parent else None
|
|
136
|
+
return None
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
__all__ = [
|
|
140
|
+
"RecordLevel",
|
|
141
|
+
"FreeText",
|
|
142
|
+
"RecordAttribute",
|
|
143
|
+
"ArchiveRecord",
|
|
144
|
+
"ArchiveRecordSet",
|
|
145
|
+
]
|