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,306 @@
|
|
|
1
|
+
"""Signature-test evaluators — the CLOSED set that mirrors ``kb.schema`` exactly (§3.1).
|
|
2
|
+
|
|
3
|
+
``evaluate(test, read, wp, spec, registry)`` returns an :class:`Evaluation` carrying both a gate
|
|
4
|
+
``outcome`` (``PASS`` / ``FAIL`` / ``ABSTAIN``) and a supports ``score`` in ``[0, 1]``. The caller
|
|
5
|
+
uses ``outcome`` for ``requires`` / ``excludes`` gates and ``score`` for ``supports`` weighting.
|
|
6
|
+
|
|
7
|
+
Two invariants hold regardless of where a test is placed:
|
|
8
|
+
- **``ABSTAIN`` never gates.** "The probe cannot see this signal" is not "the signal is absent" — an
|
|
9
|
+
abstaining requires/excludes test is a pass-through, not a rejection (an SRA-normalized
|
|
10
|
+
``header_index`` must not reject every SRA dataset).
|
|
11
|
+
- **``distinct_ratio`` never gates.** It is depth-dependent: its gate outcome is forced to ``ABSTAIN``
|
|
12
|
+
so a misplaced ``requires`` cannot use it; its supports ``score`` remains meaningful.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from enum import StrEnum
|
|
19
|
+
|
|
20
|
+
from ..io import OnlistNotAvailable, OnlistRegistry
|
|
21
|
+
from ..kb.schema import (
|
|
22
|
+
BaseComposition,
|
|
23
|
+
DistinctRatio,
|
|
24
|
+
Element,
|
|
25
|
+
HasSegment,
|
|
26
|
+
HeaderIndex,
|
|
27
|
+
MotifPresent,
|
|
28
|
+
OnlistHitRate,
|
|
29
|
+
Read,
|
|
30
|
+
SegmentLength,
|
|
31
|
+
Spec,
|
|
32
|
+
)
|
|
33
|
+
from ..models.observation import CycleComposition
|
|
34
|
+
from .window import WindowProbe
|
|
35
|
+
|
|
36
|
+
_CONSTANT_PURITY = 0.9 # mean max-base fraction over a window to call it "constant sequence"
|
|
37
|
+
_RANDOM_MAXFRAC = 0.55 # mean max-base fraction below this is "near-uniform random"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Outcome(StrEnum):
|
|
41
|
+
PASS = "pass"
|
|
42
|
+
FAIL = "fail"
|
|
43
|
+
ABSTAIN = "abstain"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class Evaluation:
|
|
48
|
+
"""A test's gate ``outcome`` and its supports ``score`` in ``[0, 1]``, with a short reason."""
|
|
49
|
+
|
|
50
|
+
outcome: Outcome
|
|
51
|
+
score: float
|
|
52
|
+
detail: str = ""
|
|
53
|
+
#: True iff a real (materialized) onlist was consulted — lifts the deciding rung to 3.
|
|
54
|
+
used_onlist: bool = False
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _clip(x: float) -> float:
|
|
58
|
+
return max(0.0, min(1.0, x))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _window_for(test: object, read: Read) -> tuple[int, int | None]:
|
|
62
|
+
"""Resolve a test's FIXED target window from ``element`` name XOR explicit ``(start, end)``.
|
|
63
|
+
|
|
64
|
+
Only valid for fixed-offset elements. A **floating** element has no constant window — its per-read
|
|
65
|
+
frame is resolved by :meth:`WindowProbe.anchored_windows`, and the callers below route to it via
|
|
66
|
+
:func:`_anchored_element` before ever reaching here.
|
|
67
|
+
"""
|
|
68
|
+
element = getattr(test, "element", None)
|
|
69
|
+
if element is not None:
|
|
70
|
+
for el in read.elements:
|
|
71
|
+
if el.name == element:
|
|
72
|
+
return (el.start or 0), el.end
|
|
73
|
+
return 0, None
|
|
74
|
+
start = getattr(test, "start", None)
|
|
75
|
+
end = getattr(test, "end", None)
|
|
76
|
+
return (start or 0), end
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _anchored_element(test: object, read: Read) -> Element | None:
|
|
80
|
+
"""The floating element a test targets by name, or ``None`` if it targets a fixed one / coordinates.
|
|
81
|
+
|
|
82
|
+
This is where ``el.anchor`` — dropped by every consuming layer before #43 — is finally read on the
|
|
83
|
+
scoring path: an ``onlist_hit_rate`` / ``distinct_ratio`` addressed to an anchored element must be
|
|
84
|
+
answered over the per-read frame, not a constant column.
|
|
85
|
+
"""
|
|
86
|
+
name = getattr(test, "element", None)
|
|
87
|
+
if name is None:
|
|
88
|
+
return None
|
|
89
|
+
for el in read.elements:
|
|
90
|
+
if el.name == name and el.anchor is not None:
|
|
91
|
+
return el
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _mean_max_fraction(wp: WindowProbe, start: int, end: int | None) -> float | None:
|
|
96
|
+
comps = wp.composition_window(start, end)
|
|
97
|
+
if not comps:
|
|
98
|
+
return None
|
|
99
|
+
total = 0.0
|
|
100
|
+
for c in comps:
|
|
101
|
+
total += max(c.a, c.c, c.g, c.t)
|
|
102
|
+
return total / len(comps)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _base_fraction(c: CycleComposition, base: str) -> float:
|
|
106
|
+
return {"A": c.a, "C": c.c, "G": c.g, "T": c.t, "N": c.n}[base]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _mean_base_fraction(wp: WindowProbe, start: int, end: int | None, base: str) -> float | None:
|
|
110
|
+
comps = wp.composition_window(start, end)
|
|
111
|
+
if not comps:
|
|
112
|
+
return None
|
|
113
|
+
return sum(_base_fraction(c, base) for c in comps) / len(comps)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def evaluate(
|
|
117
|
+
test: object, read: Read, wp: WindowProbe, spec: Spec, registry: OnlistRegistry
|
|
118
|
+
) -> Evaluation:
|
|
119
|
+
"""Evaluate one signature test against a file's :class:`WindowProbe`."""
|
|
120
|
+
if isinstance(test, SegmentLength):
|
|
121
|
+
return _eval_segment_length(test, wp)
|
|
122
|
+
if isinstance(test, HasSegment):
|
|
123
|
+
return _eval_has_segment(test, read, wp)
|
|
124
|
+
if isinstance(test, DistinctRatio):
|
|
125
|
+
return _eval_distinct_ratio(test, read, wp)
|
|
126
|
+
if isinstance(test, OnlistHitRate):
|
|
127
|
+
return _eval_onlist(test, read, wp, spec, registry)
|
|
128
|
+
if isinstance(test, MotifPresent):
|
|
129
|
+
return _eval_motif(test, wp)
|
|
130
|
+
if isinstance(test, BaseComposition):
|
|
131
|
+
return _eval_base_composition(test, read, wp)
|
|
132
|
+
if isinstance(test, HeaderIndex):
|
|
133
|
+
return _eval_header_index(test, wp)
|
|
134
|
+
# read_count is a dataset-level global, handled by the assignment feasibility check.
|
|
135
|
+
return Evaluation(Outcome.ABSTAIN, 0.0, "not a per-cell test")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _eval_segment_length(test: SegmentLength, wp: WindowProbe) -> Evaluation:
|
|
139
|
+
mode = wp.mode_length
|
|
140
|
+
if mode == 0:
|
|
141
|
+
return Evaluation(Outcome.FAIL, 0.0, "no reads")
|
|
142
|
+
if test.over_length_min is not None and mode >= test.over_length_min:
|
|
143
|
+
# An over-sequenced / insert-bearing barcode read: CB+UMI live at the declared offsets and the
|
|
144
|
+
# trailing bases are junk STARsolo ignores. Canonical exactness is preserved because
|
|
145
|
+
# over_length_min sits strictly above `length` (a 28 bp read is never "over-length").
|
|
146
|
+
return Evaluation(
|
|
147
|
+
Outcome.PASS, 1.0, f"mode={mode} >= over_length_min={test.over_length_min}"
|
|
148
|
+
)
|
|
149
|
+
diff = abs(mode - test.length)
|
|
150
|
+
outcome = Outcome.PASS if diff <= test.tolerance else Outcome.FAIL
|
|
151
|
+
score = _clip(1.0 - diff / max(1.0, test.length * 0.1))
|
|
152
|
+
return Evaluation(outcome, score, f"mode={mode} vs {test.length}±{test.tolerance}")
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _eval_has_segment(test: HasSegment, read: Read, wp: WindowProbe) -> Evaluation:
|
|
156
|
+
start, end = _window_for(test, read)
|
|
157
|
+
if test.kind in ("constant", "random"):
|
|
158
|
+
mmf = _mean_max_fraction(wp, start, end)
|
|
159
|
+
if mmf is None:
|
|
160
|
+
return Evaluation(Outcome.ABSTAIN, 0.0, "window unreadable")
|
|
161
|
+
if test.kind == "constant":
|
|
162
|
+
outcome = Outcome.PASS if mmf >= _CONSTANT_PURITY else Outcome.FAIL
|
|
163
|
+
return Evaluation(outcome, _clip((mmf - 0.5) / 0.4), f"mean_maxfrac={mmf:.2f}")
|
|
164
|
+
outcome = Outcome.PASS if mmf <= _RANDOM_MAXFRAC else Outcome.FAIL
|
|
165
|
+
return Evaluation(outcome, _clip((_RANDOM_MAXFRAC - mmf) / 0.3), f"mean_maxfrac={mmf:.2f}")
|
|
166
|
+
base = "T" if test.kind == "polyT" else "A"
|
|
167
|
+
frac = _mean_base_fraction(wp, start, end, base)
|
|
168
|
+
if frac is None:
|
|
169
|
+
return Evaluation(Outcome.ABSTAIN, 0.0, "window unreadable")
|
|
170
|
+
outcome = Outcome.PASS if frac >= 0.8 else Outcome.FAIL
|
|
171
|
+
return Evaluation(outcome, _clip(frac), f"{base}-fraction={frac:.2f}")
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _eval_distinct_ratio(test: DistinctRatio, read: Read, wp: WindowProbe) -> Evaluation:
|
|
175
|
+
"""SUPPORTS-only: the gate outcome is forced to ABSTAIN so it can never gate (depth-dependent)."""
|
|
176
|
+
anchored = _anchored_element(test, read)
|
|
177
|
+
if anchored is not None:
|
|
178
|
+
ratio = wp.anchored_distinct_ratio(read, anchored.name)
|
|
179
|
+
detail = "anchored "
|
|
180
|
+
else:
|
|
181
|
+
start, end = _window_for(test, read)
|
|
182
|
+
if end is None:
|
|
183
|
+
return Evaluation(Outcome.ABSTAIN, 0.0, "open-ended window")
|
|
184
|
+
ratio = wp.distinct_ratio(start, end)
|
|
185
|
+
detail = ""
|
|
186
|
+
if ratio is None:
|
|
187
|
+
return Evaluation(Outcome.ABSTAIN, 0.0, "window unreadable")
|
|
188
|
+
score = _clip(1.0 - ratio) if test.expect == "low" else _clip(ratio)
|
|
189
|
+
return Evaluation(
|
|
190
|
+
Outcome.ABSTAIN, score, f"{detail}distinct_ratio={ratio:.3f} expect={test.expect}"
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _eval_onlist(
|
|
195
|
+
test: OnlistHitRate, read: Read, wp: WindowProbe, spec: Spec, registry: OnlistRegistry
|
|
196
|
+
) -> Evaluation:
|
|
197
|
+
ref = spec.onlists.get(test.onlist)
|
|
198
|
+
if ref is None:
|
|
199
|
+
return Evaluation(Outcome.ABSTAIN, 0.0, f"unknown onlist alias {test.onlist!r}")
|
|
200
|
+
if not registry.has(ref.registry):
|
|
201
|
+
return Evaluation(Outcome.ABSTAIN, 0.0, f"onlist {ref.registry!r} not registered")
|
|
202
|
+
try:
|
|
203
|
+
packed = registry.packed(ref.registry)
|
|
204
|
+
except OnlistNotAvailable:
|
|
205
|
+
return Evaluation(Outcome.ABSTAIN, 0.0, f"onlist {ref.registry!r} not materialized")
|
|
206
|
+
anchored = _anchored_element(test, read)
|
|
207
|
+
if anchored is not None:
|
|
208
|
+
hit = wp.anchored_onlist_hit(read, anchored.name, packed, orientation=test.orientation)
|
|
209
|
+
else:
|
|
210
|
+
start, _ = _window_for(test, read)
|
|
211
|
+
hit = wp.onlist_hit(start, packed, orientation=test.orientation)
|
|
212
|
+
outcome = Outcome.PASS if hit.hit_rate >= test.min else Outcome.FAIL
|
|
213
|
+
detail = f"hit={hit.hit_rate:.2f} min={test.min} {hit.orientation}@Δ{hit.offset} floor={hit.floor:.1e}"
|
|
214
|
+
return Evaluation(outcome, hit.score(test.min), detail, used_onlist=True)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
#: The over-length admission (scoring._over_length_admitted_by_onlist) asks a NARROWER question than
|
|
218
|
+
#: the onlist support gate. The support ``min`` (e.g. v2's 0.6) means "does this read carry confident,
|
|
219
|
+
#: 1MM-correctable barcodes?" — the bar STARsolo's own CB correction is measured against. Admission
|
|
220
|
+
#: asks only "is this over-sequenced dead-zone read barcode-bearing rather than cDNA?" A cDNA (or any
|
|
221
|
+
#: non-barcode) read hits a barcode whitelist at its CHANCE FLOOR — ``n_entries / 4**width`` ≈ 1e-4 to
|
|
222
|
+
#: ~2e-3 for the 10x whitelists — with negligible variance over a 200k-read sample, so a rate a few
|
|
223
|
+
#: hundredfold above the floor is decisive. Seqforge matches barcodes EXACTLY (no 1MM correction), so a
|
|
224
|
+
#: real over-sequenced barcode read with ordinary sequencing error sits well under 0.6 yet vastly above
|
|
225
|
+
#: the floor (GSE126954's SRX5411291 — the perfect-whitelist fixtures hit ~1.0 and never exposed this).
|
|
226
|
+
#: The floor-anchored bar admits it without ever admitting a same-length cDNA read.
|
|
227
|
+
_OVERLENGTH_ADMISSION_MIN = 0.05 # absolute floor: a meaningful barcode signal, not chance
|
|
228
|
+
_OVERLENGTH_ADMISSION_FLOOR_MULT = 50.0 # and always well clear of THIS whitelist's chance floor
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def onlist_admits_over_length(
|
|
232
|
+
test: OnlistHitRate, read: Read, wp: WindowProbe, spec: Spec, registry: OnlistRegistry
|
|
233
|
+
) -> bool:
|
|
234
|
+
"""True iff an onlist test's window hits the whitelist far enough above chance to call the read
|
|
235
|
+
barcode-bearing (not cDNA) — the floor-anchored admission bar, NOT the support ``min``.
|
|
236
|
+
|
|
237
|
+
Mirrors :func:`_eval_onlist`'s window/whitelist resolution exactly, but decides on a lower,
|
|
238
|
+
floor-derived threshold. Any unresolved / unmaterialized onlist yields ``False`` (no admission
|
|
239
|
+
without a whitelist to check against), matching the gate's ABSTAIN.
|
|
240
|
+
"""
|
|
241
|
+
ref = spec.onlists.get(test.onlist)
|
|
242
|
+
if ref is None or not registry.has(ref.registry):
|
|
243
|
+
return False
|
|
244
|
+
try:
|
|
245
|
+
packed = registry.packed(ref.registry)
|
|
246
|
+
except OnlistNotAvailable:
|
|
247
|
+
return False
|
|
248
|
+
anchored = _anchored_element(test, read)
|
|
249
|
+
if anchored is not None:
|
|
250
|
+
hit = wp.anchored_onlist_hit(read, anchored.name, packed, orientation=test.orientation)
|
|
251
|
+
else:
|
|
252
|
+
start, _ = _window_for(test, read)
|
|
253
|
+
hit = wp.onlist_hit(start, packed, orientation=test.orientation)
|
|
254
|
+
bar = max(_OVERLENGTH_ADMISSION_MIN, hit.floor * _OVERLENGTH_ADMISSION_FLOOR_MULT)
|
|
255
|
+
return hit.hit_rate >= bar
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _eval_motif(test: MotifPresent, wp: WindowProbe) -> Evaluation:
|
|
259
|
+
rate = wp.motif_rate(
|
|
260
|
+
test.motif,
|
|
261
|
+
where=test.where,
|
|
262
|
+
search_start=test.search_start,
|
|
263
|
+
search_end=test.search_end,
|
|
264
|
+
max_mismatch=test.max_mismatch,
|
|
265
|
+
)
|
|
266
|
+
if rate is None:
|
|
267
|
+
return Evaluation(Outcome.ABSTAIN, 0.0, "no reads long enough")
|
|
268
|
+
outcome = Outcome.PASS if rate >= test.min_rate else Outcome.FAIL
|
|
269
|
+
return Evaluation(outcome, _clip(rate / max(test.min_rate, 1e-9)), f"motif_rate={rate:.2f}")
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _eval_base_composition(test: BaseComposition, read: Read, wp: WindowProbe) -> Evaluation:
|
|
273
|
+
start, end = _window_for(test, read)
|
|
274
|
+
frac = _mean_base_fraction(wp, start, end, test.base)
|
|
275
|
+
if frac is None:
|
|
276
|
+
return Evaluation(Outcome.ABSTAIN, 0.0, "window unreadable")
|
|
277
|
+
outcome = Outcome.PASS if frac >= test.min_fraction else Outcome.FAIL
|
|
278
|
+
return Evaluation(
|
|
279
|
+
outcome, _clip(frac / max(test.min_fraction, 1e-9)), f"{test.base}={frac:.2f}"
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _eval_header_index(test: HeaderIndex, wp: WindowProbe) -> Evaluation:
|
|
284
|
+
grammar = wp.observation.read_name
|
|
285
|
+
if grammar.sra_normalized:
|
|
286
|
+
return Evaluation(Outcome.ABSTAIN, 0.0, "SRA-normalized header (index stripped)")
|
|
287
|
+
has_index = grammar.index is not None
|
|
288
|
+
outcome = Outcome.PASS if has_index == test.present else Outcome.FAIL
|
|
289
|
+
return Evaluation(outcome, 1.0 if has_index == test.present else 0.0, f"has_index={has_index}")
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def read_length_compatible(read: Read, wp: WindowProbe) -> Outcome:
|
|
293
|
+
"""Implicit per-role gate from the Read's declared ``min_len`` / ``max_len`` (a real requires).
|
|
294
|
+
|
|
295
|
+
A file can fill a role only if its mode length is compatible with the read's declared geometry:
|
|
296
|
+
a fixed read demands an exact mode; a variable read demands ``mode >= min_len`` (and ``<= max_len``
|
|
297
|
+
when declared). An empty file fails (it cannot fill any role).
|
|
298
|
+
"""
|
|
299
|
+
mode = wp.mode_length
|
|
300
|
+
if mode == 0:
|
|
301
|
+
return Outcome.FAIL
|
|
302
|
+
if read.min_len is not None and mode < read.min_len:
|
|
303
|
+
return Outcome.FAIL
|
|
304
|
+
if read.max_len is not None and mode > read.max_len:
|
|
305
|
+
return Outcome.FAIL
|
|
306
|
+
return Outcome.PASS
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Byte-derived geometry feasibility — the winner-invariance foundation for descent scoring.
|
|
2
|
+
|
|
3
|
+
``length_feasible(spec, wps)`` asks the necessary question the full scorer asks first: can this spec's
|
|
4
|
+
reads be assigned, one-to-one, to length-compatible files? It reuses the scorer's own
|
|
5
|
+
``read_length_compatible`` gate (:mod:`.evaluators`) and its bipartite matcher (:mod:`.assign`), so a
|
|
6
|
+
spec it rejects is one ``build_tech_evaluation`` would also reject — ``_score_cell`` forbids a cell
|
|
7
|
+
*unconditionally* on ``read_length_compatible == FAIL`` before any onlist logic runs (scoring.py). That
|
|
8
|
+
makes it a **proven necessary condition** for a valid score: narrowing the candidate set to
|
|
9
|
+
length-feasible specs can never drop a spec that full scoring would have made a winner. Descent scoring
|
|
10
|
+
and the confusability CI guard both stand on this.
|
|
11
|
+
|
|
12
|
+
``geometry_fingerprint`` is a coarser, dataset-independent key for grouping and diagnostics only;
|
|
13
|
+
``length_feasible`` is the correctness predicate.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
|
|
20
|
+
from ..kb.schema import SegmentLength, Spec
|
|
21
|
+
from .assign import best_assignment
|
|
22
|
+
from .evaluators import Outcome, read_length_compatible
|
|
23
|
+
from .window import WindowProbe
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def length_feasible(spec: Spec, wps: list[WindowProbe]) -> bool:
|
|
27
|
+
"""True iff every read can be matched one-to-one to a length-compatible file.
|
|
28
|
+
|
|
29
|
+
Uses only ``read_length_compatible`` — the scorer's first, unconditional gate — so a ``False`` here
|
|
30
|
+
provably implies ``build_tech_evaluation(spec, wps, ...).valid is False``. The converse does not
|
|
31
|
+
hold (a length-feasible spec may still be forbidden by a finer requires/excludes test); that is
|
|
32
|
+
correct for a necessary condition and simply means the spec is scored and rejected normally.
|
|
33
|
+
"""
|
|
34
|
+
n_roles = len(spec.reads)
|
|
35
|
+
n_files = len(wps)
|
|
36
|
+
if n_files < n_roles:
|
|
37
|
+
return False
|
|
38
|
+
forbidden = [
|
|
39
|
+
[read_length_compatible(read, wp) == Outcome.FAIL for wp in wps] for read in spec.reads
|
|
40
|
+
]
|
|
41
|
+
score = [[0.0] * n_files for _ in range(n_roles)]
|
|
42
|
+
prior = [[0.0] * n_files for _ in range(n_roles)]
|
|
43
|
+
return best_assignment(n_roles, n_files, score, forbidden, prior).valid
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def geometry_could_accept(spec: Spec, probes: list[object]) -> bool:
|
|
47
|
+
"""Pairwise necessary condition for ``accepts_at_rungs_0_2(spec, probes)`` (confuse.py).
|
|
48
|
+
|
|
49
|
+
Validity requires length-feasibility, so this is a sound skip for the confusability CI guard: a
|
|
50
|
+
``False`` here means the pair cannot be confusable at rungs 0-2, and the full scorer need not run.
|
|
51
|
+
Mirrors ``accepts_at_rungs_0_2``'s ``WindowProbe`` filtering so it is a drop-in guard.
|
|
52
|
+
"""
|
|
53
|
+
wps = [p for p in probes if isinstance(p, WindowProbe)]
|
|
54
|
+
return length_feasible(spec, wps)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def geometry_fingerprint(spec: Spec) -> str:
|
|
58
|
+
"""A deterministic, dataset-independent key grouping specs by read geometry (diagnostics only).
|
|
59
|
+
|
|
60
|
+
File order is irrelevant (reads are sorted by a canonical descriptor), so two specs that differ
|
|
61
|
+
only in which mate is R1 vs R2 collide. Not a correctness predicate — see :func:`length_feasible`.
|
|
62
|
+
"""
|
|
63
|
+
seg_by_read: dict[str, SegmentLength] = {
|
|
64
|
+
t.read: t for t in spec.signature.requires if isinstance(t, SegmentLength)
|
|
65
|
+
}
|
|
66
|
+
reads_desc: list[dict[str, object]] = []
|
|
67
|
+
for read in spec.reads:
|
|
68
|
+
el_types = {el.type for el in read.elements}
|
|
69
|
+
if el_types & {"cdna", "gdna"}:
|
|
70
|
+
kind = f"bio:{read.strand}"
|
|
71
|
+
elif "barcode" in el_types:
|
|
72
|
+
kind = "barcode"
|
|
73
|
+
else:
|
|
74
|
+
kind = "other"
|
|
75
|
+
seg = seg_by_read.get(read.id)
|
|
76
|
+
reads_desc.append(
|
|
77
|
+
{
|
|
78
|
+
"kind": kind,
|
|
79
|
+
"min_len": read.min_len,
|
|
80
|
+
"max_len": read.max_len,
|
|
81
|
+
"seg_length": seg.length if seg else None,
|
|
82
|
+
"over_length_min": seg.over_length_min if seg else None,
|
|
83
|
+
"open_ended": any(
|
|
84
|
+
el.type in ("cdna", "gdna") and el.end is None for el in read.elements
|
|
85
|
+
),
|
|
86
|
+
}
|
|
87
|
+
)
|
|
88
|
+
reads_desc.sort(key=lambda d: json.dumps(d, sort_keys=True))
|
|
89
|
+
return json.dumps({"n_reads": len(spec.reads), "reads": reads_desc}, sort_keys=True)
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Split a pile of FASTQ files into the **runs** they came from, by filename.
|
|
2
|
+
|
|
3
|
+
**Filenames lie about roles. They do not lie about identity — and the difference is the whole design
|
|
4
|
+
of this module.**
|
|
5
|
+
|
|
6
|
+
`fasterq-dump`'s `_1` / `_2` suffixes say nothing about which read is the barcode and which is the
|
|
7
|
+
cDNA: they are an artifact of dump order, and inferring roles from them is exactly the guess this
|
|
8
|
+
project exists to refuse. Roles are decided by bytes, in `resolve`, and nothing here touches them.
|
|
9
|
+
|
|
10
|
+
But `SRR28716558` is not an interpretation. It is an accession the archive assigned, printed on the
|
|
11
|
+
file by the tool that wrote it. Grouping by it is a rung-1 signal used for the one thing rung 1 is
|
|
12
|
+
allowed to do: a weak, checkable prior about which files belong together. So filenames *group*; bytes
|
|
13
|
+
*assign*. If the grouping is wrong the chemistry check downstream disagrees loudly, because two runs
|
|
14
|
+
of the same library resolve to the same chemistry and a mis-grouped pair does not.
|
|
15
|
+
|
|
16
|
+
**Why this exists.** `resolve_dataset` scores one set of files as ONE library, which is correct and
|
|
17
|
+
always was. The bug was that nobody split first: hand it a 6-run dataset's 12 files and it does a
|
|
18
|
+
single global role assignment, picks the best (R1, R2) pair out of all 12, and leaves the other **ten**
|
|
19
|
+
with no role at all. `_units` then skips them, `validate` passes clean, and you get a content-addressed
|
|
20
|
+
manifest that has quietly dropped 5/6 of the data. Exit 0. That is the failure class §5 of the brief
|
|
21
|
+
exists to prevent, and the pilot dataset is exactly 6 runs.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import re
|
|
27
|
+
from collections.abc import Sequence
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
#: A mate/read token at the END of a stem: `_1`, `_2`, `_R1`, `.R2`, and Illumina's `_R1_001`.
|
|
31
|
+
#:
|
|
32
|
+
#: `[1-4]` rather than `[12]` on purpose: `fasterq-dump --include-technical` emits `_1.._4` for a
|
|
33
|
+
#: 10x run (I1/I2/R1/R2), and a `_3` that failed to match here would become its own bogus "run"
|
|
34
|
+
#: instead of joining its siblings. Extra files inside a group are fine — a leftover is what
|
|
35
|
+
#: `resolve`'s assignment penalty is for; a leftover in a group of its own is not.
|
|
36
|
+
_MATE = re.compile(r"^(?P<stem>.+?)[._](?:R|read[-_]?)?(?P<mate>[1-4])(?:[._]\d{3})?$", re.I)
|
|
37
|
+
|
|
38
|
+
#: Extensions to strip before looking for a mate token. Longest first — `.fastq.gz` before `.gz`.
|
|
39
|
+
_EXTS = (".fastq.gz", ".fq.gz", ".fastq.bz2", ".fastq.xz", ".fastq", ".fq", ".gz")
|
|
40
|
+
|
|
41
|
+
#: A LEADING SRA/ENA/DDBJ run accession the dump tool printed on the file. Unlike the mate token, an
|
|
42
|
+
#: accession is a real identity the archive assigned (see the module docstring), so it OUTRANKS the
|
|
43
|
+
#: mate heuristic below. It has to: an original-format download can carry the submitter's own lane
|
|
44
|
+
#: naming *after* the accession — `SRR36109512_11314-RM-1_S1_L005_R1_001` — so the two mate files
|
|
45
|
+
#: differ only in `_R1_`/`_R2_` buried mid-name, where the end-anchored mate strip cannot see it. The
|
|
46
|
+
#: strip then keys each file to the whole `..._S1_L005` stem, every file becomes its own singleton
|
|
47
|
+
#: "run", and the record join (`records.py`, `by_accession.get(run_key(...))`) misses every file
|
|
48
|
+
#: (#6, GSE310667). Keying on the accession rejoins the mates and lands the join.
|
|
49
|
+
_SRA_RUN = re.compile(r"^([SED]RR\d+)(?=[._])")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _strip_ext(name: str) -> str:
|
|
53
|
+
lowered = name.lower()
|
|
54
|
+
for ext in _EXTS:
|
|
55
|
+
if lowered.endswith(ext):
|
|
56
|
+
return name[: -len(ext)]
|
|
57
|
+
return name
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def run_key(path: str | Path) -> str:
|
|
61
|
+
"""The run a file belongs to, derived from its name. Never a claim about the file's ROLE.
|
|
62
|
+
|
|
63
|
+
`SRR28716558_1.fastq.gz` -> `SRR28716558`; `SRR36109512_11314-RM-1_S1_L005_R1_001.fastq.gz` ->
|
|
64
|
+
`SRR36109512` (a leading accession wins over any submitter suffix); `x_S1_L001_R1_001.fastq.gz` ->
|
|
65
|
+
`x_S1_L001`; a name with no accession and no mate token is its own run, which is the right answer
|
|
66
|
+
for a single-end library.
|
|
67
|
+
"""
|
|
68
|
+
stem = _strip_ext(Path(path).name)
|
|
69
|
+
sra = _SRA_RUN.match(stem)
|
|
70
|
+
if sra is not None:
|
|
71
|
+
return sra.group(1)
|
|
72
|
+
match = _MATE.match(stem)
|
|
73
|
+
return match.group("stem") if match else stem
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def group_runs(paths: Sequence[str | Path]) -> dict[str, list[Path]]:
|
|
77
|
+
"""Group `paths` into runs, preserving input order within each run and sorting the runs by key.
|
|
78
|
+
|
|
79
|
+
Every input path lands in exactly one group; nothing is dropped, deduplicated, or reordered across
|
|
80
|
+
groups. That is worth stating because the bug this module fixes was files silently disappearing.
|
|
81
|
+
"""
|
|
82
|
+
groups: dict[str, list[Path]] = {}
|
|
83
|
+
for path in paths:
|
|
84
|
+
groups.setdefault(run_key(path), []).append(Path(path))
|
|
85
|
+
return {key: groups[key] for key in sorted(groups)}
|