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