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,216 @@
|
|
|
1
|
+
"""Score / compile output models — the wire formats between ``score`` and ``compile``.
|
|
2
|
+
|
|
3
|
+
Every stage output is a first-class model so ``schema export`` references only types that exist and
|
|
4
|
+
every stdout object round-trips through JSON Schema. ``TechScore`` is JSON-safe: no ``+/-inf`` ever
|
|
5
|
+
crosses the JSON boundary.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Literal
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel, Field
|
|
13
|
+
|
|
14
|
+
from .base import Accession, Basis, ChemistryId, Rung, Sha256, Uri
|
|
15
|
+
from .blocker import Blocker, ValidationWarning
|
|
16
|
+
from .conflict import Conflict, ConflictPosition, Decidable
|
|
17
|
+
from .evidenced import EvidencedStr, EvidencedTaxid
|
|
18
|
+
from .processing import RuntimeEnv
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class TechScore(BaseModel):
|
|
22
|
+
"""JSON-safe technology score. ``forbidden`` == a requires/excludes gate failed."""
|
|
23
|
+
|
|
24
|
+
technology: ChemistryId
|
|
25
|
+
status: Literal["forbidden", "scored"]
|
|
26
|
+
value: float | None = None
|
|
27
|
+
reason: str | None = None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class RoleAssignment(BaseModel):
|
|
31
|
+
"""The bipartite files->roles solution. ``assignment`` maps role_id -> file sha256."""
|
|
32
|
+
|
|
33
|
+
assignment: dict[str, str]
|
|
34
|
+
unassigned: list[str] = Field(default_factory=list)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Candidate(BaseModel):
|
|
38
|
+
"""One ranked technology candidate with its role assignment and per-field deciding rungs."""
|
|
39
|
+
|
|
40
|
+
technology: ChemistryId
|
|
41
|
+
score: TechScore
|
|
42
|
+
role_assignment: RoleAssignment
|
|
43
|
+
rung_resolved: dict[str, int]
|
|
44
|
+
equivalence_members: list[ChemistryId] = Field(default_factory=list)
|
|
45
|
+
evidence: list[str] = Field(default_factory=list)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class Question(BaseModel):
|
|
49
|
+
"""A human-facing question. The code decides the option set; a human/agent picks among it."""
|
|
50
|
+
|
|
51
|
+
id: str
|
|
52
|
+
field: str
|
|
53
|
+
prompt: str
|
|
54
|
+
options: list[str]
|
|
55
|
+
decidable_by: list[Decidable]
|
|
56
|
+
rung: Rung
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class Decision(BaseModel):
|
|
60
|
+
"""A persisted answer to an already-posed question (agents propose, code decides)."""
|
|
61
|
+
|
|
62
|
+
question_id: str
|
|
63
|
+
chosen: str
|
|
64
|
+
basis: Basis
|
|
65
|
+
actor: Literal["user", "agent", "code"]
|
|
66
|
+
evidence: list[str] = Field(default_factory=list)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class ResolveResult(BaseModel):
|
|
70
|
+
"""The output of ``resolve score``: ranked candidates, surfaced conflicts, and open questions."""
|
|
71
|
+
|
|
72
|
+
dataset_id: str
|
|
73
|
+
kb_version: str
|
|
74
|
+
rung_reached: Rung
|
|
75
|
+
candidates: list[Candidate]
|
|
76
|
+
conflicts: list[Conflict]
|
|
77
|
+
questions: list[Question]
|
|
78
|
+
blockers: list[Blocker] = Field(default_factory=list)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class ResolvedSample(BaseModel):
|
|
82
|
+
"""One biological sample, the files that carry it, and what we know about it.
|
|
83
|
+
|
|
84
|
+
``sample_id`` always exists and is always code's: it is the archive's sample accession when a
|
|
85
|
+
record was joined, and the run grouping otherwise. There is no path on which a language model
|
|
86
|
+
names a sample — that is the whole reason a per-record document works.
|
|
87
|
+
|
|
88
|
+
``attributes`` is keyed by an NCBI harmonized attribute name (``strain``, ``tissue``,
|
|
89
|
+
``dev_stage``). Open-keyed rather than a fixed set of typed fields, because the key space is
|
|
90
|
+
NCBI's 960 and mirroring 960 names into pydantic fields is the hand-maintained contract this repo
|
|
91
|
+
keeps getting bitten by. Enforcement lives in the validator, against the shipped vocabulary.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
sample_id: str
|
|
95
|
+
accession: Accession | None = None
|
|
96
|
+
attributes: dict[str, EvidencedStr] = Field(default_factory=dict)
|
|
97
|
+
#: The files carrying this sample, by content hash. ``fill`` turns these into manifest URIs; the
|
|
98
|
+
#: resolver does not know what a URI is and should not.
|
|
99
|
+
file_shas: list[Sha256] = Field(default_factory=list)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class ProjectFacts(BaseModel):
|
|
103
|
+
"""The study, as the archive declares it. Structured facts only (design decision, 2026-07-16).
|
|
104
|
+
|
|
105
|
+
Not ``Evidenced``: none of this is an interpretation. The record says the title is X and we copied
|
|
106
|
+
X, exactly as we copy a file's ``sha256`` — a basis and a confidence would be theatre. The study
|
|
107
|
+
*abstract* is deliberately absent: it is prose, it belongs in a document a quote can grep back
|
|
108
|
+
into, and pasting it into a content-addressed manifest would make the dataset's identity depend on
|
|
109
|
+
a paragraph of English.
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
accession: Accession | None = None
|
|
113
|
+
title: str | None = None
|
|
114
|
+
center: str | None = None
|
|
115
|
+
data_type: str | None = None
|
|
116
|
+
released: str | None = None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class MetadataResolution(BaseModel):
|
|
120
|
+
"""The output of the metadata resolver — the sibling of ``resolve score``, over records not bytes.
|
|
121
|
+
|
|
122
|
+
Same discipline and the same shape of answer, because it has the same ways of being wrong: it
|
|
123
|
+
emits evidenced values, and can refuse with a ``Blocker`` (a record whose runs do not match the
|
|
124
|
+
files on disk). A sample-attribute *disagreement* is different from a refusal: the resolver decides
|
|
125
|
+
it — a stronger authority wins, equal authorities leave the field null — so it is a non-blocking
|
|
126
|
+
``warning``, not something that stops the dataset compiling. A dataset with no archive record and
|
|
127
|
+
no prose resolves to samples-with-no-facts, which is a real answer and the honest one — most
|
|
128
|
+
sequencing data has never had an accession.
|
|
129
|
+
"""
|
|
130
|
+
|
|
131
|
+
samples: list[ResolvedSample] = Field(default_factory=list)
|
|
132
|
+
project: ProjectFacts | None = None
|
|
133
|
+
#: The organism, when a record or a document declares it. ``None`` means nobody said, and the
|
|
134
|
+
#: caller must supply it — never a default, because a wrong taxid aligns cleanly against the wrong
|
|
135
|
+
#: genome and nothing downstream ever asks again.
|
|
136
|
+
organism: EvidencedTaxid | None = None
|
|
137
|
+
#: Non-blocking notes on sample attributes the resolver decided under disagreement (precedence, or
|
|
138
|
+
#: null when it could not pick). Surfaced so the resolution is never silent; they never block.
|
|
139
|
+
warnings: list[ValidationWarning] = Field(default_factory=list)
|
|
140
|
+
blockers: list[Blocker] = Field(default_factory=list)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class ArbitrationRequest(BaseModel):
|
|
144
|
+
"""LLM job (b) INPUT schema (opt-in ``resolve adjudicate``)."""
|
|
145
|
+
|
|
146
|
+
conflict_id: str
|
|
147
|
+
positions: list[ConflictPosition]
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class ArbitrationResponse(BaseModel):
|
|
151
|
+
"""LLM job (b) OUTPUT schema — references a position by index, re-derives no values."""
|
|
152
|
+
|
|
153
|
+
conflict_id: str
|
|
154
|
+
chosen_index: int
|
|
155
|
+
rationale: str
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class ValidationReport(BaseModel):
|
|
159
|
+
"""The output of ``manifest validate``."""
|
|
160
|
+
|
|
161
|
+
ok: bool
|
|
162
|
+
blockers: list[Blocker]
|
|
163
|
+
conflicts: list[Conflict]
|
|
164
|
+
warnings: list[ValidationWarning] = Field(default_factory=list)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class ModuleSelection(BaseModel):
|
|
168
|
+
"""One selected, versioned workflow module and the runtime env it runs in."""
|
|
169
|
+
|
|
170
|
+
name: str
|
|
171
|
+
version: str
|
|
172
|
+
env: RuntimeEnv
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class ComposeResult(BaseModel):
|
|
176
|
+
"""The output of ``compose``: selected modules, emitted config paths, and the gate verdicts.
|
|
177
|
+
|
|
178
|
+
``gate`` maps a gate name (``params`` / ``wiring`` / ``e2e``) to its verdict. ``skip`` is
|
|
179
|
+
first-class and distinct from ``pass``: the wiring and e2e gates depend on a toolchain seqforge
|
|
180
|
+
does not own (snakemake; STAR + liulab-genome + network), and a gate that reports ``pass``
|
|
181
|
+
because it never ran would let green CI be mistaken for coverage.
|
|
182
|
+
"""
|
|
183
|
+
|
|
184
|
+
modules: list[ModuleSelection]
|
|
185
|
+
#: The run wrapper — **the thing a user submits**, and the reason `compose` exists. It is named
|
|
186
|
+
#: here beside the config rather than left to be discovered on disk: it was previously written as
|
|
187
|
+
#: a side effect of a gate that could not run, so `compose` reported success while emitting no
|
|
188
|
+
#: runnable artifact at all. A deliverable absent from the result object is a deliverable nobody
|
|
189
|
+
#: notices is missing.
|
|
190
|
+
snakefile_path: Uri
|
|
191
|
+
config_path: Uri
|
|
192
|
+
units_path: Uri
|
|
193
|
+
gate: dict[str, Literal["pass", "fail", "skip"]]
|
|
194
|
+
params_preview: dict[str, object]
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
class RunResult(BaseModel):
|
|
198
|
+
"""The output of the headless ``run`` entrypoint."""
|
|
199
|
+
|
|
200
|
+
dataset_id: str
|
|
201
|
+
stages: dict[str, str]
|
|
202
|
+
exit: int
|
|
203
|
+
artifacts: dict[str, Uri]
|
|
204
|
+
provenance_id: str
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
class EvalReport(BaseModel):
|
|
208
|
+
"""The output of ``eval run``: the metrics tracked on every prompt/KB/resolve change."""
|
|
209
|
+
|
|
210
|
+
n_cases: int
|
|
211
|
+
field_accuracy: float
|
|
212
|
+
false_accept_rate: float
|
|
213
|
+
false_refuse_rate: float
|
|
214
|
+
questions_asked: dict[str, float]
|
|
215
|
+
cost: dict[str, float]
|
|
216
|
+
per_case: list[dict[str, object]]
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""``probe`` — deterministic, bounded FASTQ fingerprinting (no LLM, no network).
|
|
2
|
+
|
|
3
|
+
Turns bytes into an :class:`~seqforge.models.Observation` from a bounded, head-limited decompressed
|
|
4
|
+
stream. Every touch is bounded by a read budget (``--max-reads``, default 200 000) and a byte cap
|
|
5
|
+
(``--max-bytes``, default 256 MB decompressed); wall-clock is never a budget, and a code path that
|
|
6
|
+
*can* stream a whole multi-GB FASTQ is a bug.
|
|
7
|
+
|
|
8
|
+
Tier A (this module) computes structural signals with no KB: per-cycle base composition, segmentation
|
|
9
|
+
(constant/random/homopolymer), read-length profile, distinct-value ratios, read-name grammar,
|
|
10
|
+
N-rate, quality encoding, gzip integrity, and an extrapolated read-count estimate. It assigns **no
|
|
11
|
+
roles** — that interpretation belongs to ``resolve``.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
#: CalVer YYYY.M.PATCH, bumped whenever probe output semantics change; folded into the Observation
|
|
17
|
+
#: cache key. Component/tool-stamp versions use CalVer just like the package version.
|
|
18
|
+
#: 2026.7.1 — content-address from a bounded local key (head + size + gzip ISIZE), not a whole-file
|
|
19
|
+
#: sha256 (issue #37); the file identity string changes, so cached observations recompute once.
|
|
20
|
+
PROBE_VERSION = "2026.7.1"
|
|
21
|
+
|
|
22
|
+
#: Default bounded-read budget. Overridable per call by the CLI.
|
|
23
|
+
DEFAULT_MAX_READS = 200_000
|
|
24
|
+
|
|
25
|
+
#: Default decompressed-byte cap. Whichever budget trips first stops the stream.
|
|
26
|
+
DEFAULT_MAX_BYTES = 256 * 1024 * 1024
|
|
27
|
+
|
|
28
|
+
# Imported last: core depends on the budget constants above (keeps the package import acyclic).
|
|
29
|
+
from .core import ( # noqa: E402
|
|
30
|
+
build_observation,
|
|
31
|
+
content_key_from_md5,
|
|
32
|
+
probe_file,
|
|
33
|
+
probe_sample,
|
|
34
|
+
remote_content_key,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
__all__ = [
|
|
38
|
+
"PROBE_VERSION",
|
|
39
|
+
"DEFAULT_MAX_READS",
|
|
40
|
+
"DEFAULT_MAX_BYTES",
|
|
41
|
+
"build_observation",
|
|
42
|
+
"content_key_from_md5",
|
|
43
|
+
"remote_content_key",
|
|
44
|
+
"probe_file",
|
|
45
|
+
"probe_sample",
|
|
46
|
+
]
|
seqforge/probe/core.py
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""``probe_file`` — orchestrate bounded streaming + Tier A signals into an :class:`Observation`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import re
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Literal
|
|
9
|
+
|
|
10
|
+
from ..models.observation import (
|
|
11
|
+
FileIdentity,
|
|
12
|
+
GzipIntegrity,
|
|
13
|
+
Observation,
|
|
14
|
+
ProbeProvenance,
|
|
15
|
+
)
|
|
16
|
+
from . import DEFAULT_MAX_BYTES, DEFAULT_MAX_READS, PROBE_VERSION
|
|
17
|
+
from . import signals as sig
|
|
18
|
+
from .streaming import StreamSample, sample_fastq_gz
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _content_key(basename: str, size_bytes: int, isize: int | None, seqs: list[str]) -> str:
|
|
22
|
+
"""Content-address a FASTQ from bounded, already-sampled data — never a whole-file read.
|
|
23
|
+
|
|
24
|
+
A file's identity here is a *name*: stable for the same file, distinct across files. It combines
|
|
25
|
+
the basename, the compressed size, the gzip ISIZE trailer (uncompressed size mod 2^32), and a hash
|
|
26
|
+
of the bounded head sample — all in hand after :func:`~seqforge.probe.streaming.sample_fastq_gz`,
|
|
27
|
+
so no extra bytes are read. The basename is part of the identity because a dataset's files are
|
|
28
|
+
distinguished by name (``_1``/``_2``, lane, flowcell): two files with identical reads but different
|
|
29
|
+
names are different files, and downstream maps (``dataset_uris``, role assignment) require one
|
|
30
|
+
sha per file. The whole-file sha256 this replaces captured the name incidentally (the gzip filename
|
|
31
|
+
header) and forced the entire file to be read — which was never the point (issue #37). At
|
|
32
|
+
10^4-dataset scale the durable identity is the provider md5, injected via
|
|
33
|
+
``probe_sample(..., sha256=...)``.
|
|
34
|
+
"""
|
|
35
|
+
h = hashlib.sha256()
|
|
36
|
+
h.update(
|
|
37
|
+
f"seqforge-content-key\x00{basename}\x00{size_bytes}\x00{isize}\x00{len(seqs)}\n".encode()
|
|
38
|
+
)
|
|
39
|
+
for s in seqs:
|
|
40
|
+
h.update(s.encode("ascii", "replace"))
|
|
41
|
+
h.update(b"\n")
|
|
42
|
+
return h.hexdigest()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def content_key_from_md5(md5: str) -> str:
|
|
46
|
+
"""Derive the 64-hex content-address of a file whose PROVIDER md5 is known (issue #39).
|
|
47
|
+
|
|
48
|
+
ENA/SRA publish a per-file md5 over the *hosted* bytes. It is 32 hex, but a
|
|
49
|
+
:class:`~seqforge.models.observation.FileIdentity` ``sha256`` is a 64-hex content-address — a
|
|
50
|
+
*name*, not a recomputed file hash (see :func:`_content_key`). This maps the provider md5 into that
|
|
51
|
+
space injectively: identical md5 -> identical address, so two hosted files with the same md5 dedup
|
|
52
|
+
correctly, and **no byte of the file is read**. Unlike the local key it carries no basename — for
|
|
53
|
+
hosted bytes an identical md5 legitimately means identical content. This is the durable, machine-
|
|
54
|
+
independent identity a remote probe (``io.remote.probe_remote``) stamps via ``sha256=``.
|
|
55
|
+
"""
|
|
56
|
+
m = md5.strip().lower()
|
|
57
|
+
if not re.fullmatch(r"[0-9a-f]{32}", m):
|
|
58
|
+
raise ValueError(f"not a 32-hex md5: {md5!r}")
|
|
59
|
+
return hashlib.sha256(f"seqforge-provider-md5\x00{m}".encode()).hexdigest()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def remote_content_key(basename: str, size_bytes: int, seqs: list[str]) -> str:
|
|
63
|
+
"""A bounded content key for a REMOTE file with no provider md5 — the local key minus the ISIZE.
|
|
64
|
+
|
|
65
|
+
The gzip ISIZE trailer is unreachable without the file's tail, so a remote fingerprint passes
|
|
66
|
+
``isize=None``; otherwise this is exactly :func:`_content_key` (basename + size + head sample).
|
|
67
|
+
Prefer :func:`content_key_from_md5` whenever the provider md5 is known — that is the durable
|
|
68
|
+
identity that matches the hosted bytes.
|
|
69
|
+
"""
|
|
70
|
+
return _content_key(basename, size_bytes, None, seqs)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _params_hash(max_reads: int, max_bytes: int) -> str:
|
|
74
|
+
return hashlib.sha256(f"{max_reads}:{max_bytes}".encode()).hexdigest()[:16]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _gzip_isize(path: Path) -> int | None:
|
|
78
|
+
"""The gzip ISIZE trailer: uncompressed size mod 2^32 (O(1); unreliable for >4GB / multi-member)."""
|
|
79
|
+
try:
|
|
80
|
+
with open(path, "rb") as fh:
|
|
81
|
+
fh.seek(-4, 2)
|
|
82
|
+
return int.from_bytes(fh.read(4), "little")
|
|
83
|
+
except OSError:
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _estimate_reads(
|
|
88
|
+
file_size: int,
|
|
89
|
+
n_reads: int,
|
|
90
|
+
decompressed_bytes: int,
|
|
91
|
+
compressed_bytes: int,
|
|
92
|
+
budget_exhausted: bool,
|
|
93
|
+
isize: int | None,
|
|
94
|
+
) -> tuple[int, Literal["isize", "compressed_ratio"]]:
|
|
95
|
+
"""Extrapolate total reads without reading the whole file.
|
|
96
|
+
|
|
97
|
+
Prefer the gzip ISIZE trailer (uncompressed size / average record size); fall back to the
|
|
98
|
+
compressed-size ratio. If the whole (small) file was read, the sampled count is exact. ``isize``
|
|
99
|
+
is read once by the caller (an O(1) seek) and shared with the content key.
|
|
100
|
+
"""
|
|
101
|
+
if n_reads == 0:
|
|
102
|
+
return 0, "compressed_ratio"
|
|
103
|
+
if not budget_exhausted:
|
|
104
|
+
return n_reads, "isize" # read to EOF: the count is exact
|
|
105
|
+
avg_record = decompressed_bytes / n_reads
|
|
106
|
+
if isize is not None and avg_record > 0 and isize > decompressed_bytes:
|
|
107
|
+
return int(isize / avg_record), "isize"
|
|
108
|
+
if compressed_bytes > 0:
|
|
109
|
+
return int(file_size * n_reads / compressed_bytes), "compressed_ratio"
|
|
110
|
+
return n_reads, "compressed_ratio"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def build_observation(
|
|
114
|
+
sample: StreamSample,
|
|
115
|
+
*,
|
|
116
|
+
size_bytes: int,
|
|
117
|
+
sha256: str,
|
|
118
|
+
basename: str,
|
|
119
|
+
local_uri: str | None,
|
|
120
|
+
isize: int | None,
|
|
121
|
+
max_reads: int = DEFAULT_MAX_READS,
|
|
122
|
+
max_bytes: int = DEFAULT_MAX_BYTES,
|
|
123
|
+
) -> tuple[Observation, list[str]]:
|
|
124
|
+
"""Assemble the Tier-A :class:`Observation` from an already-sampled ``StreamSample``.
|
|
125
|
+
|
|
126
|
+
The pure, source-agnostic core of a probe: it runs the signal pipeline over ``sample.seqs`` and
|
|
127
|
+
stamps identity/provenance, reading no bytes itself. Both a local probe (:func:`probe_sample`,
|
|
128
|
+
``sample`` from a file) and a remote probe (``io.remote.probe_remote``, ``sample`` from a bounded
|
|
129
|
+
range-read prefix) call it, so a URL resolves to a library exactly as a local file does. ``sha256``
|
|
130
|
+
is the fully-formed 64-hex content-address the caller chose (a provider md5 via
|
|
131
|
+
:func:`content_key_from_md5`, or a bounded local/remote key via :func:`_content_key`); ``isize`` is
|
|
132
|
+
the gzip ISIZE trailer when reachable (local) and ``None`` when it is not (a remote prefix has no
|
|
133
|
+
tail), which simply falls the read estimate back to the compressed-size ratio.
|
|
134
|
+
"""
|
|
135
|
+
comps = sig.per_cycle_composition(sample.seqs)
|
|
136
|
+
segments = sig.segment(comps)
|
|
137
|
+
read_length = sig.read_length_profile(sample.seqs)
|
|
138
|
+
windows = sig.distinct_ratios(sample.seqs, segments)
|
|
139
|
+
read_name = sig.parse_read_name(sample.first_name)
|
|
140
|
+
quality = sig.quality_encoding(sample.qual_min_ord, sample.qual_max_ord)
|
|
141
|
+
nrate = sig.n_rate(sample.seqs)
|
|
142
|
+
|
|
143
|
+
estimated_total, est_method = _estimate_reads(
|
|
144
|
+
size_bytes,
|
|
145
|
+
sample.n_reads,
|
|
146
|
+
sample.decompressed_bytes,
|
|
147
|
+
sample.compressed_bytes,
|
|
148
|
+
sample.budget_exhausted,
|
|
149
|
+
isize,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
observation = Observation(
|
|
153
|
+
file=FileIdentity(
|
|
154
|
+
sha256=sha256,
|
|
155
|
+
size_bytes=size_bytes,
|
|
156
|
+
basename=basename,
|
|
157
|
+
local_uri=local_uri,
|
|
158
|
+
),
|
|
159
|
+
probe=ProbeProvenance(
|
|
160
|
+
n_reads_sampled=sample.n_reads,
|
|
161
|
+
bytes_read=sample.decompressed_bytes,
|
|
162
|
+
compressed_bytes_read=sample.compressed_bytes,
|
|
163
|
+
tool_version=PROBE_VERSION,
|
|
164
|
+
params_hash=_params_hash(max_reads, max_bytes),
|
|
165
|
+
),
|
|
166
|
+
per_cycle_composition=comps,
|
|
167
|
+
segments=segments,
|
|
168
|
+
read_length=read_length,
|
|
169
|
+
distinct_value_windows=windows,
|
|
170
|
+
read_name=read_name,
|
|
171
|
+
quality_encoding=quality,
|
|
172
|
+
n_rate=nrate,
|
|
173
|
+
estimated_total_reads=estimated_total,
|
|
174
|
+
est_method=est_method,
|
|
175
|
+
gzip=GzipIntegrity(ok=sample.ok, truncated=sample.truncated),
|
|
176
|
+
)
|
|
177
|
+
return observation, sample.seqs
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def probe_sample(
|
|
181
|
+
path: str | Path,
|
|
182
|
+
*,
|
|
183
|
+
max_reads: int = DEFAULT_MAX_READS,
|
|
184
|
+
max_bytes: int = DEFAULT_MAX_BYTES,
|
|
185
|
+
sha256: str | None = None,
|
|
186
|
+
) -> tuple[Observation, list[str]]:
|
|
187
|
+
"""Fingerprint one LOCAL FASTQ gzip and ALSO return its bounded sampled sequences.
|
|
188
|
+
|
|
189
|
+
:class:`Observation` is structural + role-free and cached to disk; the raw sampled ``seqs`` are
|
|
190
|
+
the same bounded, in-memory sample used to build it. ``resolve`` needs those seqs to answer
|
|
191
|
+
role-conditioned distinct-ratio / onlist-hit-rate over arbitrary windows (a ``WindowProbe``),
|
|
192
|
+
which the structural Observation deliberately does not carry. The sample stays within the
|
|
193
|
+
budget — this returns it, it does not re-read the file.
|
|
194
|
+
"""
|
|
195
|
+
p = Path(path)
|
|
196
|
+
sample = sample_fastq_gz(p, max_reads=max_reads, max_bytes=max_bytes)
|
|
197
|
+
file_size = p.stat().st_size
|
|
198
|
+
isize = _gzip_isize(p)
|
|
199
|
+
return build_observation(
|
|
200
|
+
sample,
|
|
201
|
+
size_bytes=file_size,
|
|
202
|
+
sha256=sha256 or _content_key(p.name, file_size, isize, sample.seqs),
|
|
203
|
+
basename=p.name,
|
|
204
|
+
local_uri=str(p),
|
|
205
|
+
isize=isize,
|
|
206
|
+
max_reads=max_reads,
|
|
207
|
+
max_bytes=max_bytes,
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def probe_file(
|
|
212
|
+
path: str | Path,
|
|
213
|
+
*,
|
|
214
|
+
max_reads: int = DEFAULT_MAX_READS,
|
|
215
|
+
max_bytes: int = DEFAULT_MAX_BYTES,
|
|
216
|
+
sha256: str | None = None,
|
|
217
|
+
) -> Observation:
|
|
218
|
+
"""Fingerprint one FASTQ gzip into a role-free :class:`Observation` under a bounded budget.
|
|
219
|
+
|
|
220
|
+
Parameters
|
|
221
|
+
----------
|
|
222
|
+
path
|
|
223
|
+
Local path to a gzip-compressed FASTQ.
|
|
224
|
+
max_reads, max_bytes
|
|
225
|
+
The read budget and decompressed-byte cap.
|
|
226
|
+
sha256
|
|
227
|
+
Precomputed content identity (e.g. a provider md5); if omitted, a bounded local content key is
|
|
228
|
+
derived from the head sample + size + gzip ISIZE (see :func:`_content_key`) — never a
|
|
229
|
+
whole-file read.
|
|
230
|
+
"""
|
|
231
|
+
observation, _seqs = probe_sample(path, max_reads=max_reads, max_bytes=max_bytes, sha256=sha256)
|
|
232
|
+
return observation
|