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