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